Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .downstream/native-domains.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 21 additions & 1 deletion backend/internal/handler/admin/setting_codex_runtime.go
Original file line number Diff line number Diff line change
@@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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")
})
}
}
34 changes: 27 additions & 7 deletions backend/internal/service/codex_native_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package service

import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"

Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading