diff --git a/.downstream/native-domains.md b/.downstream/native-domains.md index 95bef62ee1b9..32f0e86dda32 100644 --- a/.downstream/native-domains.md +++ b/.downstream/native-domains.md @@ -22,6 +22,34 @@ The older synchronous ticket-stop endpoint remains compatible. Ordinary account bulk editing remains HTTP 202; this change does not replace background tasks with synchronous editing. Existing API-key reveal and account-field protections remain. +### Codex runtime receipt after v0.2.8-codexrip.5 + +The headers below extend the native API after `v0.2.8-codexrip.5`. +That published image remains immutable; these headers are not part of its contract. + +`GET /admin/settings/codex-runtime` retains the raw JSON body and +`Cache-Control: no-store`, and adds: + +| Response header | Value | +| --- | --- | +| `X-Sub2API-Codex-Hosting-Mode` | `native` | +| `X-Sub2API-Codex-Config-Version` | Active snapshot configuration version. | +| `X-Sub2API-Codex-Config-SHA256` | Lowercase SHA-256 of the exact snapshot body bytes. | +| `X-Sub2API-Codex-Runtime-Generation` | Active snapshot runtime generation. | + +Version and generation are positive signed 64-bit integers serialized as canonical +decimal strings without leading zeros. The hash covers the exact `snapshot.raw` +UTF-8 bytes after decoding any response `Content-Encoding`; do not reorder JSON, +pretty-print it, or append a newline. + +HTTP 200 requires the body and receipt to come from the same active snapshot, +whose runtime has started and is not cancelled, with matching persisted fence +metadata and configuration hash. Otherwise the endpoint returns HTTP 503 with +`{"code":503,"message":"Codex runtime is unavailable"}`, without receipt headers +or raw configuration. The receipt describes the epoch observed by that read; +later operations still use their existing execution fences. Retained +disabled installation rows stay unchanged; no generic metadata endpoint is added. + ## Startup and retained data Before loading native settings or starting either runtime, one transaction: diff --git a/backend/internal/handler/admin/setting_codex_runtime.go b/backend/internal/handler/admin/setting_codex_runtime.go index 56acb4189d71..15751b10ca08 100644 --- a/backend/internal/handler/admin/setting_codex_runtime.go +++ b/backend/internal/handler/admin/setting_codex_runtime.go @@ -1,26 +1,46 @@ package admin import ( + "context" "encoding/json" "errors" "net/http" + "strconv" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" ) +const ( + nativeCodexHostingModeHeader = "X-Sub2API-Codex-Hosting-Mode" + nativeCodexConfigVersionHeader = "X-Sub2API-Codex-Config-Version" + nativeCodexConfigSHA256Header = "X-Sub2API-Codex-Config-SHA256" + nativeCodexRuntimeGenerationHeader = "X-Sub2API-Codex-Runtime-Generation" +) + func (h *SettingHandler) SetNativeCodexConfigEncryptor(encryptor service.SecretEncryptor) { h.nativeCodexConfigEncryptor = encryptor } func (h *SettingHandler) GetNativeCodexConfiguration(c *gin.Context) { + writeNativeCodexConfiguration(c, h.codexTicketGateway.NativeCodexConfiguration) +} + +func writeNativeCodexConfiguration(c *gin.Context, read func(context.Context) (json.RawMessage, service.NativeCodexMetadata, error)) { c.Header("Cache-Control", "no-store") - raw, err := h.codexTicketGateway.NativeCodexConfiguration() + for _, name := range [...]string{nativeCodexHostingModeHeader, nativeCodexConfigVersionHeader, nativeCodexConfigSHA256Header, nativeCodexRuntimeGenerationHeader} { + c.Writer.Header().Del(name) + } + raw, metadata, err := read(c.Request.Context()) if err != nil { response.Error(c, 503, "Codex runtime is unavailable") return } + c.Header(nativeCodexHostingModeHeader, "native") + c.Header(nativeCodexConfigVersionHeader, strconv.FormatInt(metadata.ConfigVersion, 10)) + c.Header(nativeCodexConfigSHA256Header, metadata.ConfigSHA256) + c.Header(nativeCodexRuntimeGenerationHeader, strconv.FormatInt(metadata.RuntimeGeneration, 10)) c.Data(http.StatusOK, "application/json; charset=utf-8", raw) } diff --git a/backend/internal/handler/admin/setting_codex_runtime_receipt_test.go b/backend/internal/handler/admin/setting_codex_runtime_receipt_test.go new file mode 100644 index 000000000000..218836067a0a --- /dev/null +++ b/backend/internal/handler/admin/setting_codex_runtime_receipt_test.go @@ -0,0 +1,83 @@ +package admin + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "math" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestNativeCodexConfigurationHTTPReceiptPreservesExactBody(t *testing.T) { + gin.SetMode(gin.TestMode) + // Deliberate formatting makes re-marshalling or wrapping the snapshot visible. + raw := json.RawMessage("{\n \"enabled\": false, \"models\": [\"gpt-6-astra\"]\n}") + sum := sha256.Sum256(raw) + digest := hex.EncodeToString(sum[:]) + for _, version := range []int64{7, math.MaxInt64} { + t.Run(strconv.FormatInt(version, 10), func(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + requestContext, cancel := context.WithCancel(context.Background()) + defer cancel() + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings/codex-runtime", nil).WithContext(requestContext) + reads := 0 + writeNativeCodexConfiguration(c, func(ctx context.Context) (json.RawMessage, service.NativeCodexMetadata, error) { + require.Same(t, requestContext, ctx) + reads++ + return raw, service.NativeCodexMetadata{ + ID: 7, ConfigVersion: version, RuntimeGeneration: version, ConfigSHA256: digest, + }, nil + }) + require.Equal(t, 1, reads) + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, []byte(raw), recorder.Body.Bytes(), "success must stay raw JSON without an envelope or added newline") + require.Equal(t, "application/json; charset=utf-8", recorder.Header().Get("Content-Type")) + require.Equal(t, "no-store", recorder.Header().Get("Cache-Control")) + require.Equal(t, "native", recorder.Header().Get(nativeCodexHostingModeHeader)) + require.Equal(t, strconv.FormatInt(version, 10), recorder.Header().Get(nativeCodexConfigVersionHeader)) + require.Equal(t, strconv.FormatInt(version, 10), recorder.Header().Get(nativeCodexRuntimeGenerationHeader)) + bodyHash := sha256.Sum256(recorder.Body.Bytes()) + require.Equal(t, hex.EncodeToString(bodyHash[:]), recorder.Header().Get(nativeCodexConfigSHA256Header)) + }) + } +} + +func TestNativeCodexConfigurationHTTPReceiptUnavailableIsOpaque(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, name := range []string{"missing_runtime", "read_failure_with_partial_snapshot"} { + t.Run(name, func(t *testing.T) { + recorder := httptest.NewRecorder() + for _, header := range [...]string{nativeCodexHostingModeHeader, nativeCodexConfigVersionHeader, nativeCodexConfigSHA256Header, nativeCodexRuntimeGenerationHeader} { + recorder.Header().Set(header, "stale-receipt") + } + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings/codex-runtime", nil) + if name == "missing_runtime" { + (&SettingHandler{}).GetNativeCodexConfiguration(c) + } else { + writeNativeCodexConfiguration(c, func(context.Context) (json.RawMessage, service.NativeCodexMetadata, error) { + return json.RawMessage(`{"proxy_url":"synthetic-private-config"}`), + service.NativeCodexMetadata{ConfigVersion: 1, RuntimeGeneration: 2}, + errors.New("synthetic-private-database-detail") + }) + } + require.Equal(t, http.StatusServiceUnavailable, recorder.Code) + require.Equal(t, "no-store", recorder.Header().Get("Cache-Control")) + require.JSONEq(t, `{"code":503,"message":"Codex runtime is unavailable"}`, recorder.Body.String()) + for _, header := range [...]string{nativeCodexHostingModeHeader, nativeCodexConfigVersionHeader, nativeCodexConfigSHA256Header, nativeCodexRuntimeGenerationHeader} { + require.Empty(t, recorder.Header().Values(header), "unavailable reads must not expose a receipt") + } + require.NotContains(t, recorder.Body.String(), "synthetic-private") + }) + } +} diff --git a/backend/internal/service/codex_native_provider.go b/backend/internal/service/codex_native_provider.go index c34aa4e41c88..0bf3df635c6b 100644 --- a/backend/internal/service/codex_native_provider.go +++ b/backend/internal/service/codex_native_provider.go @@ -2,6 +2,8 @@ package service import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" @@ -60,15 +62,33 @@ func ProvideNativeCodexRuntime(gateway *OpenAIGatewayService, repo NativeCodexRe return runtime } -func (s *OpenAIGatewayService) NativeCodexConfiguration() (json.RawMessage, error) { - if s == nil || s.nativeCodexRuntime == nil { - return nil, ErrNativeCodexRuntimeUnavailable +func (s *OpenAIGatewayService) NativeCodexConfiguration(ctx context.Context) (json.RawMessage, NativeCodexMetadata, error) { + if s == nil || s.nativeCodexRuntime == nil || ctx == nil || ctx.Err() != nil { + return nil, NativeCodexMetadata{}, ErrNativeCodexRuntimeUnavailable + } + runtime := s.nativeCodexRuntime + // Keep the receipt in the same lifecycle epoch throughout the persistent read. + runtime.mu.Lock() + defer runtime.mu.Unlock() + snapshot := runtime.snapshot.Load() + if !runtime.started || runtime.repo == nil || snapshot == nil || snapshot.metadata == nil || len(snapshot.raw) == 0 || + snapshot.ctx == nil || snapshot.ctx.Err() != nil || ctx.Err() != nil { + return nil, NativeCodexMetadata{}, ErrNativeCodexRuntimeUnavailable + } + metadata := *snapshot.metadata + if metadata.ID <= 0 || metadata.ConfigVersion <= 0 || metadata.RuntimeGeneration <= 0 { + return nil, NativeCodexMetadata{}, ErrNativeCodexRuntimeUnavailable + } + raw := append(json.RawMessage(nil), snapshot.raw...) + sum := sha256.Sum256(raw) + if metadata.ConfigSHA256 != hex.EncodeToString(sum[:]) { + return nil, NativeCodexMetadata{}, ErrNativeCodexRuntimeUnavailable } - snapshot := s.nativeCodexRuntime.snapshot.Load() - if snapshot == nil { - return nil, ErrNativeCodexRuntimeUnavailable + current, err := runtime.repo.LoadNativeCodexMetadata(ctx) + if err != nil || current == nil || *current != metadata || ctx.Err() != nil || snapshot.ctx.Err() != nil { + return nil, NativeCodexMetadata{}, ErrNativeCodexRuntimeUnavailable } - return append(json.RawMessage(nil), snapshot.raw...), nil + return raw, metadata, nil } func (s *OpenAIGatewayService) UpdateNativeCodexConfiguration(ctx context.Context, raw json.RawMessage, encryptor SecretEncryptor) error { diff --git a/backend/internal/service/codex_native_provider_receipt_test.go b/backend/internal/service/codex_native_provider_receipt_test.go new file mode 100644 index 000000000000..30cba2ea6b48 --- /dev/null +++ b/backend/internal/service/codex_native_provider_receipt_test.go @@ -0,0 +1,225 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +type nativeCodexReceiptRepository struct { + // Any config write, stored-source read or business lease is unexpected. + NativeCodexRepository + metadata NativeCodexMetadata + loads int + load func(context.Context) (*NativeCodexMetadata, error) +} + +func (r *nativeCodexReceiptRepository) LoadNativeCodexMetadata(ctx context.Context) (*NativeCodexMetadata, error) { + r.loads++ + if r.load != nil { + return r.load(ctx) + } + metadata := r.metadata + return &metadata, nil +} + +type nativeCodexReceiptModule struct{ nativeCodexModule } + +func (nativeCodexReceiptModule) Stop(context.Context) error { return nil } + +func nativeCodexReceiptFixture(t *testing.T) (*OpenAIGatewayService, *nativeCodexSnapshot, *nativeCodexReceiptRepository) { + t.Helper() + // Preserve field order, whitespace and the trailing newline in the receipt. + raw := json.RawMessage("{\n \"models\": [\"gpt-6-astra\"],\n \"enabled\": false\n}\n") + sum := sha256.Sum256(raw) + metadata := NativeCodexMetadata{ + ID: 17, ConfigVersion: 9_007_199_254_740_993, RuntimeGeneration: 9_223_372_036_854_775_807, + ConfigSHA256: hex.EncodeToString(sum[:]), + } + repo := &nativeCodexReceiptRepository{metadata: metadata} + epoch, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + snapshot := &nativeCodexSnapshot{ + metadata: &metadata, raw: raw, ctx: epoch, cancel: cancel, + module: nativeCodexReceiptModule{}, host: &nativeCodexHost{}, + } + runtime := &NativeCodexRuntime{ + root: context.Background(), started: true, repo: repo, + factory: func(context.Context) (json.RawMessage, error) { + t.Fatal("a receipt read must not rebuild configuration") + return nil, ErrNativeCodexRuntimeUnavailable + }, + } + runtime.snapshot.Store(snapshot) + return &OpenAIGatewayService{nativeCodexRuntime: runtime}, snapshot, repo +} + +func requireNativeCodexReceiptUnavailable(t *testing.T, raw json.RawMessage, metadata NativeCodexMetadata, err error) { + t.Helper() + require.Equal(t, ErrNativeCodexRuntimeUnavailable, err) + require.Nil(t, raw) + require.Zero(t, metadata) +} + +func TestNativeCodexConfigurationReceiptCopiesExactSnapshot(t *testing.T) { + service, snapshot, repo := nativeCodexReceiptFixture(t) + wantRaw := append(json.RawMessage(nil), snapshot.raw...) + wantMetadata := *snapshot.metadata + raw, metadata, err := service.NativeCodexConfiguration(context.Background()) + require.NoError(t, err) + require.Equal(t, wantRaw, raw) + require.Equal(t, wantMetadata, metadata) + require.Equal(t, 1, repo.loads) + sum := sha256.Sum256(raw) + require.Equal(t, hex.EncodeToString(sum[:]), metadata.ConfigSHA256) + + raw[0] = '!' + metadata.ID = 99 + metadata.ConfigVersion = 1 + metadata.RuntimeGeneration = 1 + metadata.ConfigSHA256 = strings.Repeat("0", 64) + require.NotEqual(t, wantRaw, raw) + require.NotEqual(t, wantMetadata, metadata) + require.Equal(t, wantRaw, snapshot.raw) + require.Equal(t, wantMetadata, *snapshot.metadata) + require.Equal(t, wantMetadata, repo.metadata) + secondRaw, secondMetadata, err := service.NativeCodexConfiguration(context.Background()) + require.NoError(t, err) + require.Equal(t, wantRaw, secondRaw) + require.Equal(t, wantMetadata, secondMetadata) + require.Equal(t, 2, repo.loads) +} + +func TestNativeCodexConfigurationReceiptRejectsInactiveRuntime(t *testing.T) { + for _, name := range []string{ + "nil service", "missing runtime", "not started", "stopped", "missing repository", + "missing snapshot", "missing metadata", "missing epoch", "canceled epoch", "nil request context", "canceled request", + } { + t.Run(name, func(t *testing.T) { + service, snapshot, repo := nativeCodexReceiptFixture(t) + ctx := context.Background() + switch name { + case "nil service": + service = nil + case "missing runtime": + service.nativeCodexRuntime = nil + case "not started": + service.nativeCodexRuntime.started = false + case "stopped": + require.NoError(t, service.nativeCodexRuntime.Stop(ctx)) + require.Same(t, snapshot, service.nativeCodexRuntime.current()) + case "missing repository": + service.nativeCodexRuntime.repo = nil + case "missing snapshot": + service.nativeCodexRuntime.snapshot.Store(nil) + case "missing metadata": + snapshot.metadata = nil + case "missing epoch": + snapshot.ctx = nil + case "canceled epoch": + snapshot.cancel() + case "nil request context": + ctx = nil + case "canceled request": + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + cancel() + } + raw, metadata, err := service.NativeCodexConfiguration(ctx) + requireNativeCodexReceiptUnavailable(t, raw, metadata, err) + require.Zero(t, repo.loads) + }) + } +} + +func TestNativeCodexConfigurationReceiptRejectsInvalidSnapshot(t *testing.T) { + tests := []struct { + name string + change func(*nativeCodexSnapshot) + }{ + {"zero ID", func(s *nativeCodexSnapshot) { s.metadata.ID = 0 }}, + {"negative ID", func(s *nativeCodexSnapshot) { s.metadata.ID = -1 }}, + {"zero version", func(s *nativeCodexSnapshot) { s.metadata.ConfigVersion = 0 }}, + {"negative version", func(s *nativeCodexSnapshot) { s.metadata.ConfigVersion = -1 }}, + {"zero generation", func(s *nativeCodexSnapshot) { s.metadata.RuntimeGeneration = 0 }}, + {"negative generation", func(s *nativeCodexSnapshot) { s.metadata.RuntimeGeneration = -1 }}, + {"missing hash", func(s *nativeCodexSnapshot) { s.metadata.ConfigSHA256 = "" }}, + {"uppercase hash", func(s *nativeCodexSnapshot) { s.metadata.ConfigSHA256 = strings.ToUpper(s.metadata.ConfigSHA256) }}, + {"invalid hash", func(s *nativeCodexSnapshot) { s.metadata.ConfigSHA256 = strings.Repeat("x", 64) }}, + {"wrong hash", func(s *nativeCodexSnapshot) { s.metadata.ConfigSHA256 = strings.Repeat("0", 64) }}, + {"changed raw bytes", func(s *nativeCodexSnapshot) { s.raw = append(s.raw, '\n') }}, + {"empty raw", func(s *nativeCodexSnapshot) { + s.raw = nil + sum := sha256.Sum256(nil) + s.metadata.ConfigSHA256 = hex.EncodeToString(sum[:]) + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service, snapshot, repo := nativeCodexReceiptFixture(t) + tt.change(snapshot) + // Matching persistence must not make an invalid snapshot acceptable. + repo.metadata = *snapshot.metadata + raw, metadata, err := service.NativeCodexConfiguration(context.Background()) + requireNativeCodexReceiptUnavailable(t, raw, metadata, err) + require.Zero(t, repo.loads) + }) + } +} + +func TestNativeCodexConfigurationReceiptRejectsUnavailableFence(t *testing.T) { + for _, name := range []string{"read failure", "missing metadata", "ID changed", "version changed", "generation changed", "hash changed"} { + t.Run(name, func(t *testing.T) { + service, _, repo := nativeCodexReceiptFixture(t) + switch name { + case "read failure": + repo.load = func(context.Context) (*NativeCodexMetadata, error) { + return &repo.metadata, errors.New("private repository failure detail") + } + case "missing metadata": + repo.load = func(context.Context) (*NativeCodexMetadata, error) { return nil, nil } + case "ID changed": + repo.metadata.ID++ + case "version changed": + repo.metadata.ConfigVersion++ + case "generation changed": + repo.metadata.RuntimeGeneration-- + case "hash changed": + repo.metadata.ConfigSHA256 = strings.Repeat("0", 64) + } + raw, metadata, err := service.NativeCodexConfiguration(context.Background()) + requireNativeCodexReceiptUnavailable(t, raw, metadata, err) + require.Equal(t, 1, repo.loads) + }) + } +} + +func TestNativeCodexConfigurationReceiptRejectsCancellationDuringRead(t *testing.T) { + for _, name := range []string{"request", "epoch"} { + t.Run(name, func(t *testing.T) { + service, snapshot, repo := nativeCodexReceiptFixture(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + repo.load = func(loadCtx context.Context) (*NativeCodexMetadata, error) { + require.Same(t, ctx, loadCtx) + if name == "request" { + cancel() + } else { + snapshot.cancel() + } + // A reader may return a row despite cancellation; no receipt may escape. + metadata := repo.metadata + return &metadata, nil + } + raw, metadata, err := service.NativeCodexConfiguration(ctx) + requireNativeCodexReceiptUnavailable(t, raw, metadata, err) + require.Equal(t, 1, repo.loads) + }) + } +}