diff --git a/backend/internal/service/openai_codex_account_identity.go b/backend/internal/service/openai_codex_account_identity.go index d0e336ec6c..a7ce166165 100644 --- a/backend/internal/service/openai_codex_account_identity.go +++ b/backend/internal/service/openai_codex_account_identity.go @@ -154,7 +154,7 @@ func applyCodexAccountIdentityEmbeddedMetadata(values map[string]any, account *A if !applyCodexAccountIdentityFields(metadata, account, apiKeyID) { return false } - rebuilt, err := json.Marshal(metadata) + rebuilt, err := marshalCodexTurnMetadata(metadata) if err != nil { return false } @@ -267,7 +267,7 @@ func applyCodexAccountIdentityHeaders(headers http.Header, account *Account, api if raw := strings.TrimSpace(headers.Get(openAIWSTurnMetadataHeader)); raw != "" { metadata := map[string]any{} if err := json.Unmarshal([]byte(raw), &metadata); err == nil && metadata != nil && applyCodexAccountIdentityFields(metadata, account, apiKeyID) { - if rebuilt, err := json.Marshal(metadata); err == nil { + if rebuilt, err := marshalCodexTurnMetadata(metadata); err == nil { headers.Set(openAIWSTurnMetadataHeader, string(rebuilt)) } } diff --git a/backend/internal/service/openai_codex_fingerprint.go b/backend/internal/service/openai_codex_fingerprint.go index 1e19c07bda..8058c0c488 100644 --- a/backend/internal/service/openai_codex_fingerprint.go +++ b/backend/internal/service/openai_codex_fingerprint.go @@ -402,7 +402,7 @@ func rewriteCodexTurnMetadataFields(h http.Header, fields map[string]any) { for k, v := range fields { metadata[k] = v } - rebuilt, err := json.Marshal(metadata) + rebuilt, err := marshalCodexTurnMetadata(metadata) if err != nil { return } @@ -595,7 +595,7 @@ func rewriteClientMetadataEmbeddedTurnMetadata(clientMetadata map[string]any, fi for k, v := range fields { metadata[k] = v } - if rebuilt, err := json.Marshal(metadata); err == nil { + if rebuilt, err := marshalCodexTurnMetadata(metadata); err == nil { clientMetadata["x-codex-turn-metadata"] = string(rebuilt) } } diff --git a/backend/internal/service/openai_codex_turn_metadata.go b/backend/internal/service/openai_codex_turn_metadata.go new file mode 100644 index 0000000000..1654a26ca1 --- /dev/null +++ b/backend/internal/service/openai_codex_turn_metadata.go @@ -0,0 +1,39 @@ +package service + +import ( + "encoding/json" + "unicode/utf16" +) + +// Turn metadata is also used as an HTTP header, including when carried in WS JSON. +func marshalCodexTurnMetadata(metadata map[string]any) ([]byte, error) { + raw, err := json.Marshal(metadata) + if err != nil { + return nil, err + } + for i, b := range raw { + if b < 0x7f { + continue + } + out := make([]byte, 0, len(raw)+16) + out = append(out, raw[:i]...) + appendEscape := func(r rune) { + const hex = "0123456789abcdef" + out = append(out, '\\', 'u', hex[r>>12&15], hex[r>>8&15], hex[r>>4&15], hex[r&15]) + } + for _, r := range string(raw[i:]) { + switch { + case r < 0x7f: + out = append(out, byte(r)) + case r <= 0xffff: + appendEscape(r) + default: + high, low := utf16.EncodeRune(r) + appendEscape(high) + appendEscape(low) + } + } + return out, nil + } + return raw, nil +} diff --git a/backend/internal/service/openai_codex_turn_metadata_test.go b/backend/internal/service/openai_codex_turn_metadata_test.go new file mode 100644 index 0000000000..a3a5b4773b --- /dev/null +++ b/backend/internal/service/openai_codex_turn_metadata_test.go @@ -0,0 +1,88 @@ +package service + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestCodexTurnMetadataRewritePreservesHeaderSafeJSON(t *testing.T) { + account := newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: "session"}) + account.Credentials = map[string]any{"chatgpt_account_id": "test-account"} + ids := resolveCodexFingerprintIDsFromRequest(account, http.Header{}) + require.NotNil(t, ids) + + rewriters := map[string]func(*testing.T, string) string{ + "account_header": func(t *testing.T, raw string) string { + h := make(http.Header) + h.Set(openAIWSTurnMetadataHeader, raw) + applyCodexAccountIdentityHeaders(h, account, 77) + return h.Get(openAIWSTurnMetadataHeader) + }, + "account_body_map": func(t *testing.T, raw string) string { + cm := map[string]any{openAIWSTurnMetadataHeader: raw} + require.True(t, applyCodexAccountIdentityClientMetadataMap(map[string]any{"client_metadata": cm}, account, 77)) + value, ok := cm[openAIWSTurnMetadataHeader].(string) + require.True(t, ok) + return value + }, + "account_body_raw": func(t *testing.T, raw string) string { + body, err := json.Marshal(map[string]any{"client_metadata": map[string]any{openAIWSTurnMetadataHeader: raw}}) + require.NoError(t, err) + updated, changed, err := applyCodexAccountIdentityClientMetadataRaw(body, account, 77) + require.NoError(t, err) + require.True(t, changed) + return gjson.GetBytes(updated, "client_metadata."+openAIWSTurnMetadataHeader).String() + }, + "fingerprint_header": func(t *testing.T, raw string) string { + h := make(http.Header) + h.Set(openAIWSTurnMetadataHeader, raw) + applyCodexFingerprintHeaders(h, ids) + return h.Get(openAIWSTurnMetadataHeader) + }, + "fingerprint_body_map": func(t *testing.T, raw string) string { + cm := map[string]any{openAIWSTurnMetadataHeader: raw} + require.True(t, applyCodexFingerprintClientMetadata(map[string]any{"client_metadata": cm}, ids)) + value, ok := cm[openAIWSTurnMetadataHeader].(string) + require.True(t, ok) + return value + }, + "fingerprint_body_raw": func(t *testing.T, raw string) string { + body, err := json.Marshal(map[string]any{"client_metadata": map[string]any{openAIWSTurnMetadataHeader: raw}}) + require.NoError(t, err) + updated, changed, err := applyCodexFingerprintClientMetadataRaw(body, ids) + require.NoError(t, err) + require.True(t, changed) + return gjson.GetBytes(updated, "client_metadata."+openAIWSTurnMetadataHeader).String() + }, + } + + for _, raw := range []string{ + `{"installation_id":"client-install","workspaces":{"C:\\work\\中文🚀":{}}}`, + `{"installation_id":"client-install","workspaces":{"C:\\work\\中文🚀":{"label":"café"}},"literal":"\\u4e2d","quote":"\"\\","controls":"\b\f\n\r\t\u0000\u007f","timestamp":1789302780858,"enabled":true}`, + `{"installation_id":"client-install","workspaces":{"C:\\work\\\u4e2d\u6587\ud83d\ude80":{"label":"caf\u00e9"}},"literal":"\\u4e2d","quote":"\"\\","controls":"\b\f\n\r\t\u0000\u007f","timestamp":1789302780858,"enabled":true}`, + `{"installation_id":"client-install","workspaces":{"C:\\work\\ascii":{}},"literal":"\\u4e2d"}`, + } { + var original map[string]any + require.NoError(t, json.Unmarshal([]byte(raw), &original)) + for name, rewrite := range rewriters { + t.Run(name, func(t *testing.T) { + updated := rewrite(t, raw) + var decoded map[string]any + require.NoError(t, json.Unmarshal([]byte(updated), &decoded)) + for key, value := range original { + if key != "installation_id" { + require.Equal(t, value, decoded[key], key) + } + } + require.NotEqual(t, original["installation_id"], decoded["installation_id"]) + for _, b := range []byte(updated) { + require.True(t, b >= 0x20 && b < 0x7f, "non-printable ASCII header byte: 0x%02x", b) + } + }) + } + } +}