diff --git a/devlog/_plan/260912_cache_lane/040_hermes.md b/devlog/_plan/260912_cache_lane/040_hermes.md index cc75d79180..de79dc1d7e 100644 --- a/devlog/_plan/260912_cache_lane/040_hermes.md +++ b/devlog/_plan/260912_cache_lane/040_hermes.md @@ -7,3 +7,5 @@ MODIFY tests/responses/chat-completions-endpoint.test.ts or a registered adjacen MODIFY canonical inbound contract docs to distinguish stable client conversation identity, request-scoped lane and prompt prefix. Durable scratch evidence names public comment URLs, actual test command coverage and limitations. Real Hermes same-conversation/fresh-session identifier and outbound capture from its running client are unavailable unless provided by existing public evidence; synthetic regression proves transport contract only. Do not claim actual client identity was observed, cache hits improved or #3433 solved. C hosted final tip executes the contract; local suite NOT RUN. D records exactly what is proven and remaining controlled live-client comparison. + +Execution refinement: NEW tests/responses/chat-conversation-affinity.test.ts and register it in both layout maps. Invoke actual Chat handler with synthetic caller JWT against canonical ChatGPT Responses config in isolated homes. Mock only outbound fetch, record Headers/body. Two header shapes (underscore session_id, hyphen session-id/thread-id), key present/absent, A/A/B growing messages; explicit request-id differs per turn. Absent identity controls prove shared key never becomes a session. This fixture establishes OCX preservation, not actual Hermes emission. No runtime patch unless evidence finds loss. diff --git a/devlog/_plan/260912_cache_lane/041_hermes_ci_refresh.md b/devlog/_plan/260912_cache_lane/041_hermes_ci_refresh.md new file mode 100644 index 0000000000..5c7f1cb3d9 --- /dev/null +++ b/devlog/_plan/260912_cache_lane/041_hermes_ci_refresh.md @@ -0,0 +1,11 @@ +# Hermes hosted-CI refresh after shared fixture repair + +Resume preserves the same worktree and session. The host goal is blocked and persisted phase remains C; no goal/FSM reset, reactivation or completion is claimed. This file records the authorized remaining work, not a new completed cycle. + +The original Hermes head `b254efc8385ce2a9dc34b9a5ac7d2a449605d75d` failed gates on the Combo active-reactivation fixture, while four Linux and two macOS product shards succeeded. Shared repair #4390 is now integrated as `20861aebf56c6f8ec2b0d8d04d1d0b54441650bb` and its exact hosted CI `34688482827` succeeds. Affinity/prefix old runs failed restore/Cline fixtures subsequently repaired by that same PR. Old failed runs remain failed. + +Rebase the one owned Hermes test-only commit from `e4ee8c54` onto current `origin/dev` at `392e182a00` (record full SHA in handoff). Read-only merge-tree reports no conflict. Preserve the 99-line runtime-boundary test and both mappings; no Combo, Cline, restore or provider source edits. Compare old/new authored source deltas and obtain an inherited-model independent source audit. Append terminal evidence to `060_handoff.md`, preserving real Hermes acceptance as open. Push only existing Hermes branch with `--no-verify` and exact old-head lease; no new task/worktree or recreation of merged evidence PR #4377. Bind new final hosted CI to new Hermes SHA. Parent owns integration and chooses any source collision slot. + +Local suites/focused/GUI/build/typecheck/install remain NOT RUN. The successful shared integration CI proves its own cumulative source tree, not the old failed PR heads. A new Hermes tip must be independently verified remotely before a passing delivery claim. + +Parent integration-slot update: final pinned base is `c311f9bf7f5003af29fa8e7ebc2f2b5db20267f6`, including the subsequently integrated pnpm and Devin fixture corrections. Rebase the two owned commits from `392e182a` without runtime changes. Original 99-line test and mappings must remain byte-identical; source review is renewed for final head. Prior run `34693156321` at `524afd8d80` is superseded evidence only, never final-tip proof. Parent is holding the Hermes slot. Persisted phase C and blocked host goal stay unchanged. diff --git a/devlog/_plan/260912_cache_lane/060_handoff.md b/devlog/_plan/260912_cache_lane/060_handoff.md index 551d1b62ac..d6522bec9f 100644 --- a/devlog/_plan/260912_cache_lane/060_handoff.md +++ b/devlog/_plan/260912_cache_lane/060_handoff.md @@ -28,3 +28,13 @@ Local tests of every size, build, typecheck and install were **NOT RUN** by expl ## Final evidence-cycle record The final evidence B phase produces this tracked update as its documentation artifact. An earlier C transition was rejected by SOURCE-DELTA-01 because only scratch metadata and the PR body had changed; that rejected transition did not advance the FSM. The evidence branch now records the actual delivered source heads, parent integrations and unproven closure attribution. It changes no runtime code. Hosted terminal results must still be read before this cycle closes; an evidence document is not a product-test pass. + +## Resumed terminal-CI reconciliation + +The original final-tip results are now terminal: affinity run `34674962749` FAILED (native restore/injection fixtures); prefix run `34675829597` FAILED (the same restore family plus Cline registry/CLI/localization/icon/test-layout expectations); Hermes run `34674763850` FAILED (Combo active-reactivation GUI fixture). Their passing cache assertions do not make those runs green. Claim run `34673563105` remains SUCCESS and was not rerun. + +Shared fixture repair #4390 is merged at `20861aebf56c6f8ec2b0d8d04d1d0b54441650bb`, containing the delivered affinity and repaired prefix heads by verified Git ancestry. Its [hosted CI 34688482827](https://github.com/lidge-jun/opencodex/actions/runs/34688482827) succeeded: 19 jobs successful, 2 skipped. This is new cumulative integration evidence, not a relabeling of the old failed results. Cache runtime sources were not rewritten to fix another lane's failure. + +The remaining open Hermes PR #4365 is refreshed onto `392e182a004d61b38c7cf652642e63b9a11d9a65` without conflicts, preserving its test-only delta. Its new final head and hosted outcome are exported to that PR description and the scratch handoff after source audit and publication. Merged evidence PR #4377 is left intact. The host goal is blocked; persisted phase C is preserved and neither is claimed completed. Local product suites/build/typecheck/install remain NOT RUN. + +Final Hermes slot: the coordinator pinned `c311f9bf7f5003af29fa8e7ebc2f2b5db20267f6` after the pnpm/Devin fixture corrections. The test-only PR is rebased onto that fixed base without conflicts or runtime edits. Its exact final-head source review and hosted run replace the earlier `524afd8d80` candidate evidence in the PR description. The previous failed runs remain historical failures, and actual Hermes client-field acceptance remains open. diff --git a/devlog/_plan/260912_combo_carry/040_pnpm_ci_repair.md b/devlog/_plan/260912_combo_carry/040_pnpm_ci_repair.md new file mode 100644 index 0000000000..2c5fec8f33 --- /dev/null +++ b/devlog/_plan/260912_combo_carry/040_pnpm_ci_repair.md @@ -0,0 +1,27 @@ +# Native-platform pnpm shim regression fixtures + +The final all-lane run found two pnpm shim tests failing on Windows. Both request Linux shim +semantics against Windows filesystem metadata. The tested source and fixture blobs are identical +at the Combo base, failing tip and current dev; this is source evidence of pre-existing code, not +an executed baseline reproduction. Hosted run34674363301 job103503916566 contains the failure. + +Scope: MODIFY tests/update/update-pnpm.test.ts only, plus this record. Production resolver, +POSIX executable-bit guard, declaration file and update/job.ts remain unchanged. Local suites, +build/typecheck/install remain NOT RUN; hosted CI observes the repair. No workflow changes. + +NEW local fixture helper emits actual-host launchers: POSIX ocx/opencodex scripts with executable +permissions; Windows cmd and PowerShell files for both commands. MODIFY the active-target and +alias cases to use the helper and verifier's real host default. The stale-target case replaces +both forms of only opencodex on Windows, preserving rejection coverage for both command names. +The two previously failing cases continue to run on every platform. + +NEW separate POSIX permission case: native valid target passes, removing one shim's execute bits +rejects that command, restoring permissions passes. This new filesystem-specific case runs on +POSIX only; NTFS cannot provide the claimed mode-bit contract. This is not a skip of either +failing test and does not weaken the production permission predicate. + +Preserve the existing explicit Windows cmd/PowerShell case. Reject the earlier proposed stat +injection: host-native fixtures preserve coverage without changing production APIs. Use a new +owned dev repair PR, independent read-only review, --no-verify push and repaired cumulative tip +hosted CI. Original Combo all-lane FAIL remains recorded. Windows shard3's separate devin CLI +discovery failure is handed to the parent for its owner; this pnpm change does not claim to fix it. diff --git a/devlog/_plan/260912_history_containment/051_resume_status.md b/devlog/_plan/260912_history_containment/051_resume_status.md new file mode 100644 index 0000000000..98b7c5d92c --- /dev/null +++ b/devlog/_plan/260912_history_containment/051_resume_status.md @@ -0,0 +1,25 @@ +# Resumed history verification + +Continuation carry #4350 was merged by the coordinator; its final-head hosted run 34673958547 +completed successfully. Containment carry #4342 was also merged, but run 34674692660 failed and +has not been represented as passing. Relay #4360 remains draft; latest inspected carry92592066 +includes the independently supplied activation-fixture update. Run34682796830 failed. + +This repair addresses hosted history-owned failures: canonical temporary-home identities on macOS, +explicit failed config artifacts, complete preimage preservation after failed cleanup, admission +principal result fields and relay error/credential validation ordering. Default-off dispatch is +revalidated after asynchronous waits. Listener cancellation coverage observes actual active-turn +accounting; a manually released fake lease is not release evidence. + +Local product suites, builds, typecheck and installation: NOT RUN by explicit user direction. +Regression source and independent source review are not a runtime pass. Final repaired-head hosted +CI remains required. Unrelated GUI/client/translation failures are recorded for their owners and +are not changed in this lane. No new worktree, task, account setting or service mutation occurred. + +The host goal is blocked and the persisted workflow remains A. Its state was not reset or edited; +authorized repairs continue under the explicit resume instruction. This status does not claim a +completed verification cycle. + +The prior same-process Windows spill impossibility conclusion is withdrawn: the existing ACL +compliance check executes before the timeout-memo refusal. Actual long-lived process recovery, +native paginated-writer support and damaged-history recovery remain unverified and unresolved. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 333671101d..e56f2bd55b 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -123,6 +123,87 @@ The proxy listens on port `10100` by default and serves `POST /v1/responses`, `POST /v1/responses/compact`, `POST /v1/images/generations`, `POST /v1/images/edits`, `GET /v1/models`, `GET /healthz`, and the `/api/*` management surface. +### Experimental context management (Codex 0.153+) + +For an eligible ChatGPT account, enable the experimental feature in Codex's own +`config.toml` (merge this into an existing `[features]` table): + +```toml +[features] +context_management.experimental_mode = true +``` + +On the default built-in loopback integration, the next `ocx sync` or proxy start changes the +managed root `openai_base_url` to `http://127.0.0.1:10100/backend-api/codex`. Codex checks this +backend path before enabling its `new_context`, history, and notes tools. Start a new Codex +session after synchronization. The feature remains opt-in; user-owned base URLs and remote +custom-provider injection are not rewritten. No context-window or compaction-limit override +is needed or added. + +The backend prefix aliases the existing data-plane routes, including Responses WebSocket +upgrades. The original `/v1` routes and the realtime sideband override remain available. The +proxy also relays the ten native `alpha/history/v2/*` and `alpha/notes/v2/*` POST endpoints +through the built-in `openai` provider with the `openai-responses` adapter and canonical +ChatGPT forward destination. Direct uses the current caller/main login; Pool selects a Codex +account. `openai-apikey` uses its configured API key, and custom or noncanonical Responses +providers are not candidates for this context relay and receive no Codex-account credentials +from it. These private endpoints are not implemented by other model providers or the OpenAI +API-key route. + +Caller headers are restricted to the shared Codex forward allowlist: `authorization`, +`chatgpt-account-id`, and approved OpenAI beta, originator, session, and Codex protocol metadata. +The context relay additionally forwards `x-openai-encrypted-tool-arguments` and +`x-openai-tool-output-truncation-policy`; arbitrary caller headers such as cookies are not +forwarded. A proxy data-plane key presented as a bearer is replaced with the selected Codex +credential (the stored main login in Direct); missing credentials fail before forwarding. +Proxy admission credentials never go upstream. Encrypted arguments, response bodies, and +upstream error statuses are preserved. + +A successful ChatGPT model response records the root session's actual serving account in a +bounded, process-local ownership registry. History and notes use that recorded owner, including +an explicitly selected account, even when the current active account changes. Stored-account token +refresh may continue for the same physical account; a replaced account identity is rejected. +Direct caller-owned sessions keep the caller credential and cannot be taken over by a proxy bearer. +This does not migrate server-side history between accounts. + +Ownership is partitioned by the opencodex API key that admitted the request, so two keys never +reach each other's sessions even when both resolve to the same ChatGPT workspace. A workspace id +names an organization rather than a person, so the registry also binds the stable user carried by +the credential upstream accepted: an ordinary token refresh for that same user continues the +session, while a different user in the same workspace does not. When the accepted credential +proves no stable user, only that exact credential continues. That principal is the opencodex API key the request presents. A remote bind already requires one, +so ownership works there. On the default loopback bind opencodex admits requests without reading a +key, and the built-in loopback injection cannot carry the `x-opencodex-api-key` header, so Codex +presents no opencodex key and context history returns HTTP 403. **The relay is therefore available +on a remote bind with a configured key, or to a client that sends `x-opencodex-api-key` itself, and +not through the default built-in loopback integration.** Whether a loopback-bound proxy should be +able to name a caller at all is an open maintainer decision, so the current behaviour refuses +rather than guessing. + +Unknown, expired, evicted, conflicting, or restart-lost ownership returns HTTP 409 before account +selection or upstream I/O. The relay does not guess from the current active account. Existing +sessions should save a checkpoint or other durable summary before enabling the feature or resetting +context. Enabling it does not backfill earlier history or notes, and a new ownership observation +does not prove that older backend content exists. After a restart, establish ownership with a +successful model request before using context tools; start a new session when ownership conflicts. + +Existing model affinity, cooldown, and retry rules are unchanged. Context requests are not +automatically retried, including notes writes; ChatGPT forward requests do not use same-key 429 +replay. History traffic does not consume or settle a model quota-recovery probe. + +One 35-second deadline covers the whole relay operation, starting before the request body is read +and covering credential selection, so a stalled client cannot hold an admitted turn slot open. +A client disconnect returns 499 and an expired deadline returns 504; nothing is dispatched +upstream after either. + +To disable the feature, remove the experimental key (or set it to `false`), run `ocx sync`, +and start a new Codex session. The managed root base returns to `/v1`. + +While the key is absent or `false` the relay does not exist: the ten endpoints answer 404 for any +caller, including one that posts them directly, and a model turn records no history ownership. +opencodex decides this by reading Codex own config itself, so the switch does not depend on the +injected URL or on anything a client sends, and turning it off takes effect without a restart. + ### Built-in image generation (`image_gen`) Codex's built-in `image_gen` tool does not go through `/v1/responses` — the codex-rs extension diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 07433d11e5..f0c9d87848 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -46,6 +46,83 @@ loopback `openai_base_url` 형태에서만 쓰이고, 그 키와 함께 제거 프록시는 기본적으로 포트 `10100`에서 듣고 `POST /v1/responses`, `POST /v1/responses/compact`, `POST /v1/images/generations`, `POST /v1/images/edits`, `GET /v1/models`, `GET /healthz`, 그리고 `/api/*` 관리 표면을 제공합니다. +### 실험적 컨텍스트 관리 (Codex 0.153+) + +지원되는 ChatGPT 계정에서는 Codex의 `config.toml`에 다음 설정을 추가합니다. +기존 `[features]` 테이블이 있으면 그 안에 병합하세요. + +```toml +[features] +context_management.experimental_mode = true +``` + +기본 내장 loopback 통합에서는 다음 `ocx sync` 또는 프록시 시작 시 관리되는 루트 +`openai_base_url`이 `http://127.0.0.1:10100/backend-api/codex`로 바뀝니다. Codex는 이 경로를 +확인한 뒤 `new_context`, history, notes 도구를 활성화합니다. 동기화 후 새 Codex 세션을 +시작하세요. 이 기능은 명시적으로 켜야 하며, 사용자 소유 URL과 원격 사용자 지정 provider +주입은 변경하지 않습니다. 컨텍스트 창이나 압축 한도를 덮어쓰지 않습니다. + +이 backend 접두사는 Responses WebSocket 업그레이드를 포함한 기존 데이터 경로의 별칭입니다. +원래 `/v1` 경로와 realtime sideband 오버라이드도 유지됩니다. 열 개의 네이티브 +`alpha/history/v2/*`, `alpha/notes/v2/*` POST 엔드포인트는 `openai-responses` 어댑터와 정식 +ChatGPT forward 목적지를 사용하는 내장 `openai` provider로만 전달합니다. Direct는 현재 +호출자/메인 로그인을, Pool은 선택된 Codex 계정을 사용합니다. `openai-apikey`는 설정된 API +키를 사용하는 별도 경로이며 이 비공개 엔드포인트를 지원하지 않습니다. 사용자 지정 또는 +비정식 Responses provider는 이 컨텍스트 relay의 후보가 아니며 Codex 계정 자격 증명을 +전달받지 않습니다. + +호출자 헤더는 공통 Codex forward 허용 목록으로 제한됩니다. `authorization`, +`chatgpt-account-id`, 승인된 OpenAI beta, originator, session 및 Codex 프로토콜 메타데이터와 +추가 헤더 `x-openai-encrypted-tool-arguments`, `x-openai-tool-output-truncation-policy`만 +전달합니다. 쿠키 등 임의 헤더는 전달하지 않습니다. 프록시 데이터 키를 bearer로 사용하면 +선택된 Codex 자격 증명으로 교체하며, Direct에서는 저장된 메인 로그인을 사용합니다. +자격 증명이 없으면 전달 전에 실패합니다. 프록시 인증 자격 증명은 upstream으로 보내지 +않습니다. 암호화된 인수, 응답 본문, upstream 오류 상태는 보존합니다. + +소유권은 요청을 받아들인 opencodex API 키 단위로 분리됩니다. 두 키가 같은 ChatGPT 워크스페이스를 +가리키더라도 서로의 세션에 접근할 수 없습니다. 워크스페이스 ID는 사람이 아니라 조직을 가리키므로, +레지스트리는 업스트림이 실제로 수락한 자격 증명이 담고 있는 사용자 식별자까지 함께 묶습니다. 같은 +사용자의 토큰 갱신은 세션을 이어가지만, 같은 워크스페이스의 다른 사용자는 이어받지 못합니다. 수락된 +자격 증명이 사용자 식별자를 증명하지 못하면 정확히 그 자격 증명만 세션을 이어갈 수 있습니다. +여기서 principal은 요청이 제시한 opencodex API 키입니다. 원격 바인드는 이미 키를 요구하므로 +소유권이 정상 동작합니다. 기본 loopback 바인드는 키를 읽지 않고 요청을 받아들이고, 내장 loopback +주입은 `x-opencodex-api-key` 헤더를 실을 수 없습니다. 따라서 Codex가 opencodex 키를 보내지 못해 +context history는 HTTP 403을 반환합니다. **즉 이 relay는 키가 설정된 원격 바인드, 또는 +`x-opencodex-api-key`를 직접 보내는 클라이언트에서 사용할 수 있고 기본 내장 loopback 통합에서는 +사용할 수 없습니다.** loopback 바인드에서 호출자를 식별할 수 있게 할지는 메인테이너가 정할 문제이며, +지금은 추측하지 않고 거부합니다. + +relay 작업 전체는 35초 deadline 하나로 묶입니다. 본문을 읽기 전에 시작해서 자격 증명 선택까지 +포함하므로, 멈춘 클라이언트가 처리 슬롯을 붙잡고 있을 수 없습니다. 클라이언트가 끊으면 499, +deadline이 지나면 504를 반환하며 두 경우 모두 업스트림으로 아무것도 보내지 않습니다. + +성공한 ChatGPT 모델 응답은 루트 세션을 실제로 처리한 계정을 크기가 제한된 프로세스 로컬 +소유권 레지스트리에 기록합니다. History와 notes는 현재 활성 계정이 바뀌어도 명시적으로 +선택된 계정을 포함해 기록된 소유 계정을 사용합니다. 저장된 계정의 토큰은 같은 실제 계정에 +한해 갱신할 수 있으며, 다른 실제 계정으로 교체되면 거부합니다. Direct 호출자 소유 세션은 +호출자 자격 증명을 유지하며 프록시 bearer로 인계할 수 없습니다. 서버 측 history를 계정 간에 +이동하는 기능은 아닙니다. + +소유권이 없거나 만료, 제거, 충돌 또는 재시작으로 소실된 경우 계정 선택이나 upstream 요청 +전에 HTTP 409를 반환합니다. 현재 활성 계정으로 추측하지 않습니다. 기존 세션에서는 기능을 +켜거나 컨텍스트를 초기화하기 전에 체크포인트 또는 지속적으로 보관할 요약을 먼저 저장하세요. +기능을 켜도 이전 history나 notes를 소급해서 채우지 않으며, 소유권이 새로 확인되어도 이전 +backend 콘텐츠가 존재한다는 뜻은 아닙니다. 재시작 후에는 성공한 모델 요청으로 소유권을 +확립한 뒤 컨텍스트 도구를 사용하고, 소유권이 충돌하면 새 세션을 시작하세요. + +기존 모델 affinity, cooldown 및 재시도 규칙은 변경하지 않습니다. Notes 쓰기를 포함한 +컨텍스트 요청은 자동 재시도하지 않으며, ChatGPT forward 요청에 같은 키를 사용한 429 +재시도를 추가하지 않습니다. History 트래픽은 모델의 quota-recovery probe를 점유하거나 +완료 처리하지 않습니다. + +끄려면 실험 설정을 삭제하거나 `false`로 바꾼 뒤 `ocx sync`를 실행하고 새 세션을 시작하세요. +관리되는 루트 URL은 `/v1`로 돌아갑니다. + +키가 없거나 `false`인 동안에는 relay 자체가 존재하지 않습니다. 열 개 엔드포인트는 직접 POST하는 +호출자에게도 404를 반환하고, 모델 턴은 history 소유권을 기록하지 않습니다. opencodex가 Codex 설정을 +직접 읽어 판단하므로 주입된 URL이나 클라이언트가 보내는 값에 의존하지 않으며, 끄면 재시작 없이 +반영됩니다. + ### 내장 이미지 생성 (`image_gen`) Codex의 내장 `image_gen` 도구는 `/v1/responses`를 거치지 않습니다. codex-rs 확장은 채팅과 같은 ChatGPT bearer 인증을 사용해서 `{base_url}/images/generations`를 직접 POST하며, 참조 이미지가 붙어 있으면 `/images/edits`를 POST합니다. 주입된 `base_url`이 opencodex를 가리키므로, 프록시가 이 호출을 OpenAI upstream으로 전달합니다. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 4f35987b54..b2eabcbcc9 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -11,7 +11,7 @@ "domains": { "providers": { "match": [ - "^(?:aside(?!-profile)|auto|azure|baseten|chutes|cline(?!-(?:client|writer))|command|commandcode|context|cyber|deepinfra|deepseek|digitalocean|exa|featherless|forward|hyperbolic|kimi|meta|mimo|minimax|moonshot|muse|new|nous|novita|nscale|nvidia|opencode|openrouter|qwen38|sambanova|umans|vercel|zcode|zhipu)-" + "^(?:aside(?!-profile)|auto|azure|baseten|chutes|cline(?!-(?:client|writer))|command|commandcode|context(?!-compat|-history)|cyber|deepinfra|deepseek|digitalocean|exa|featherless|forward|hyperbolic|kimi|meta|mimo|minimax|moonshot|muse|new|nous|novita|nscale|nvidia|opencode|openrouter|qwen38|sambanova|umans|vercel|zcode|zhipu)-" ], "children": { "cursor": [ @@ -33,11 +33,13 @@ }, "codex-integration": { "match": [ + "^context-compat\\.test\\.ts$", "^(?:active|app|bearer|catalog|combos\\.test\\.ts|doctor\\.test\\.ts|effort|gather|history|injection|issue|multi|native|parallel|project|selected|slug|ultrafast|warmup\\.test\\.ts)-" ] }, "server": { "match": [ + "^context-history\\.test\\.ts$", "^aside-profiles-routes", "^(?:account|alias|bounded|cancel|config\\.test\\.ts|consume|data|debug|error|errors|fetch|health|input|loopback|management|memory|outbound|owned|passive|port|ports\\.test\\.ts|proxy|relay|response|retry|server|session|sidebar|stream|v2)-" ] @@ -279,6 +281,7 @@ "catalog-vision-sidecar-modalities.test.ts": "codex-integration", "catalog-zero-credit-picker.test.ts": "codex-integration", "chat-completions-endpoint.test.ts": "responses", + "chat-conversation-affinity.test.ts": "responses", "chat-json-sse-fallback.test.ts": "responses", "chat-refusal.test.ts": "responses", "chatgpt-device-auth.test.ts": "oauth", @@ -419,6 +422,7 @@ "codex-cli-update-zero-effect.test.ts": "codex-integration", "codex-composed-acceptance.test.ts": "codex-integration", "codex-config-generation.test.ts": "codex-integration", + "codex-context-owner.test.ts": "codex-integration", "codex-convergence-account-selectors.test.ts": "codex-integration", "codex-convergence-contract.test.ts": "codex-integration", "codex-cooldown-recovery.test.ts": "codex-integration", @@ -536,6 +540,9 @@ "consume-for-inspection-cancel.test.ts": "server", "container-bootstrap.test.ts": "service", "context-cap-unknown-window.test.ts": "providers", + "context-compat.test.ts": "codex-integration", + "context-history-ownership.test.ts": "server", + "context-history.test.ts": "server", "continuation-dedup.test.ts": "responses", "core-lab-boundary.test.ts": "lab", "cost-cap-unknown-evidence.test.ts": "usage", diff --git a/src/codex/account-store.ts b/src/codex/account-store.ts index 4e8d5c513e..285db1708c 100644 --- a/src/codex/account-store.ts +++ b/src/codex/account-store.ts @@ -751,8 +751,13 @@ export async function forceRefreshCodexPoolToken( }; } -export async function getValidCodexToken(id: string): Promise { - const result = await resolveCodexToken(id); +export async function getValidCodexToken( + id: string, + options: { signal?: AbortSignal } = {}, +): Promise { + // Cancellation ends THIS caller's wait. A shared refresh already in flight keeps running for + // whoever else awaits it, which is what `awaitOwnCancellation` inside the resolver preserves. + const result = await resolveCodexToken(id, undefined, options.signal); return { accessToken: result.accessToken, chatgptAccountId: result.chatgptAccountId, diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 2cb97df2e9..f362a2893a 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1023,7 +1023,7 @@ export async function resolveCodexAuthContext( } try { - const token = await getValidCodexToken(accountId); + const token = await getValidCodexToken(accountId, { signal: options.signal }); assertCodexAccountValidationReady(accountId); return { kind: "pool", diff --git a/src/codex/context-compat.ts b/src/codex/context-compat.ts new file mode 100644 index 0000000000..e78a456163 --- /dev/null +++ b/src/codex/context-compat.ts @@ -0,0 +1,97 @@ +/** Backend path and opt-in config compatibility for native Codex history/notes. */ +import { readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { getCodexHome } from "./paths"; + +export const CONTEXT_BACKEND_PREFIX = "/backend-api/codex"; + +/** The single definition of the opt-in, shared by injection and the runtime gate. */ +export function contextExperimentalEnabled(configContent: string): boolean { + let parsed: { features?: { context_management?: { experimental_mode?: boolean } } }; + try { + parsed = Bun.TOML.parse(configContent) as typeof parsed; + } catch { + // Injection tolerates incomplete user config; malformed TOML is not an opt-in. + return false; + } + return parsed.features?.context_management?.experimental_mode === true; +} + +let activation: { key: string; active: boolean } | undefined; + +/** + * Whether this proxy may serve the context relay at all, decided by opencodex reading Codex own + * config rather than by anything a caller sends. + * + * Rewriting the injected base URL is what makes the feature REACHABLE, and gating only that would + * leave the ownership registry and both endpoint prefixes live for anyone who can already reach + * the data plane. An opt-in that a direct POST walks around is not an opt-in, so the same + * predicate guards recording and dispatch. An absent, unreadable or malformed config is not an + * opt-in. The result is cached against the config identity and re-read when the file changes, so + * turning the feature off takes effect without a restart and steady-state traffic does not parse + * TOML per request. + */ +export function contextRelayActivated(configPath?: string): boolean { + let key: string; + let path: string; + try { + // Resolved here, not in a default parameter: those are evaluated before the body, so a + // CODEX_HOME that became unreadable while the proxy runs would throw past this try. This + // function is now on the model path, where that would abort a turn upstream already served. + path = configPath ?? join(getCodexHome(), "config.toml"); + const seen = statSync(path); + key = `${path}:${seen.mtimeMs}:${seen.size}:${String(seen.ino)}`; + } catch { + activation = undefined; + return false; + } + if (activation?.key === key) return activation.active; + let active = false; + try { + active = contextExperimentalEnabled(readFileSync(path, "utf8")); + } catch { + // A readable stat with an unreadable body caches false under that identity, so the feature + // stays off until the content itself changes. Fail-closed is the right direction here. + active = false; + } + activation = { key, active }; + return active; +} + +/** Test seam: the cache is keyed by config identity, which a temp home reuses across cases. */ +export function resetContextRelayActivationForTests(): void { + activation = undefined; +} + +const CONTEXT_ENDPOINTS = new Set([ + "alpha/history/v2/list_windows", "alpha/history/v2/list_items", + "alpha/history/v2/read_item", "alpha/history/v2/search_contents", + "alpha/notes/v2/thread_hint", "alpha/notes/v2/list_files_by_prefix", + "alpha/notes/v2/read_file", "alpha/notes/v2/search_contents", + "alpha/notes/v2/append_to_file", "alpha/notes/v2/write_file", +]); + +export function contextEndpoint(path: string): string | undefined { + const endpoint = path.startsWith("/v1/") ? path.slice(4) : ""; + return CONTEXT_ENDPOINTS.has(endpoint) ? endpoint : undefined; +} + +/** Alias only the data-plane prefix. Existing auth/origin and route gates still run. */ +export function codexCompatibleUrl(rawUrl: string): URL { + const url = new URL(rawUrl); + if (url.pathname === CONTEXT_BACKEND_PREFIX || url.pathname.startsWith(CONTEXT_BACKEND_PREFIX + "/")) { + url.pathname = "/v1" + url.pathname.slice(CONTEXT_BACKEND_PREFIX.length); + } + return url; +} + +/** Change only marker-managed built-in routing, and only with an explicit context opt-in. */ +export function contextCompatibleBaseLine(content: string, line: string): string { + if (!contextExperimentalEnabled(content)) return line; + const match = /^openai_base_url = "([^"]+)"$/.exec(line); + if (!match) return line; + const url = new URL(match[1]); + if (url.pathname !== "/v1" || !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)) return line; + url.pathname = CONTEXT_BACKEND_PREFIX; + return `openai_base_url = "${url.href}"`; +} diff --git a/src/codex/context-owner.ts b/src/codex/context-owner.ts new file mode 100644 index 0000000000..eb38620131 --- /dev/null +++ b/src/codex/context-owner.ts @@ -0,0 +1,201 @@ +import { createHmac, randomBytes } from "node:crypto"; +import type { CodexAuthContext } from "./auth-context"; +import { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; +import { nativeUserIdClaims } from "./reserve-availability"; + +export type ContextSessionOwner = Readonly< + | { kind: "stored"; accountId: string; physicalIdentity: string; userIdentity?: string; + callerCredentialIdentity: string; ambiguous: boolean } + | { kind: "caller"; physicalIdentity?: string; userIdentity?: string; + callerCredentialIdentity: string; ambiguous: boolean } +>; + +const TTL_MS = 24 * 60 * 60_000; +const MAX_ENTRIES = 2048; +const MAX_BYTES = 1024 * 1024; +const salt = randomBytes(32); +type Entry = { owner: ContextSessionOwner; destination: string; touchedAt: number; bytes: number }; +const owners = new Map(); +let totalBytes = 0; + +function digest(domain: string, value: string): string { + return createHmac("sha256", salt).update(domain).update("\0").update(value).digest("hex"); +} + +function validId(value: string | null | undefined): value is string { + return typeof value === "string" && /^[A-Za-z0-9._:-]{1,512}$/.test(value); +} + +function destinationIdentity(destination: string): string | undefined { + if (!destination || destination.length > 4096) return undefined; + try { + const url = new URL(destination); + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.hash) return undefined; + return digest("destination", url.href.replace(/\/+$/, "")); + } catch { return undefined; } +} + +function physicalIdentity(headers: Headers): string | undefined { + const account = headers.get("chatgpt-account-id"); + return validId(account) ? digest("physical-account", account) : undefined; +} + +/** + * The stable person behind an accepted credential, when the token says so. + * + * `chatgpt-account-id` names a WORKSPACE, and two people in one workspace send the same value. + * Ownership therefore also binds the user claim carried by the credential that upstream actually + * accepted. The claim is read without signature verification, which is why upstream acceptance + * remains the evidence and a conflicting pair of claims fails closed instead of picking one. + */ +function stableUserIdentity(headers: Headers): { identity?: string; conflict: boolean } { + const authorization = headers.get("authorization"); + if (!authorization || authorization.length > 32_768 || !/^Bearer [^\s]+$/i.test(authorization)) { + return { conflict: false }; + } + const claims = nativeUserIdClaims(authorization.slice(7)); + if (claims.conflict) return { conflict: true }; + return { ...(claims.userId ? { identity: digest("native-user", claims.userId) } : {}), conflict: false }; +} + +function callerCredentialIdentity(headers: Headers): string | undefined { + const authorization = headers.get("authorization"); + if (!authorization || authorization.length > 32_768 || !/^Bearer [^\s]+$/i.test(authorization)) return undefined; + const account = headers.get("chatgpt-account-id"); + if (account !== null && !validId(account)) return undefined; + return digest("caller-credential", JSON.stringify([authorization.slice(7), account])); +} + +/** + * Whether two accepted observations describe the same owner. + * + * A proven stable user may present a refreshed credential. Without that proof on BOTH sides the + * only continuity evidence left is the credential itself, because a workspace id is shared by + * everyone in the organization and would otherwise let one member rebind another session. + */ +function sameOwnerIdentity(prior: ContextSessionOwner, next: ContextSessionOwner): boolean { + if (prior.kind !== next.kind) return false; + if (prior.physicalIdentity !== next.physicalIdentity) return false; + return prior.userIdentity !== undefined && next.userIdentity !== undefined + ? prior.userIdentity === next.userIdentity + : prior.callerCredentialIdentity === next.callerCredentialIdentity; +} + +function remove(key: string): void { + const prior = owners.get(key); + if (!prior) return; + owners.delete(key); + totalBytes -= prior.bytes; +} + +function sweep(now: number): void { + for (const [key, entry] of owners) { + if (now < entry.touchedAt || now - entry.touchedAt >= TTL_MS) remove(key); + } +} + +/** Called only after a model attempt was accepted by its actual destination. */ +export function recordContextSessionOwner( + principalId: string | undefined, inboundHeaders: Headers, destination: string, auth: CodexAuthContext, + outboundHeaders: Headers, substituteMainCredential: boolean, now = Date.now(), +): void { + // Ownership is partitioned per admission principal. Without one there is no caller identity to + // own anything, so nothing is recorded rather than creating an entry any local process matches. + if (!principalId || !Number.isFinite(now)) return; + // A malformed explicit parent must not fall back to an unrelated local session. + const root = inboundHeaders.get("x-codex-parent-thread-id") ?? inboundHeaders.get("session-id"); + if (!validId(root)) return; + const destinationKey = destinationIdentity(destination); + const credential = callerCredentialIdentity(outboundHeaders); + if (!destinationKey || !credential) return; + const physical = physicalIdentity(outboundHeaders); + const user = stableUserIdentity(outboundHeaders); + if (user.conflict) { + // An accepted credential naming two different users is evidence of nobody. Whatever entry + // this session already had stops being trustworthy at that moment. + markContextSessionAmbiguous(principalId, root, now); + return; + } + let owner: ContextSessionOwner; + if (auth.kind !== "main" || substituteMainCredential) { + if (!physical) return; + if (auth.kind !== "main" && (!validId(auth.accountId) + || auth.chatgptAccountId !== outboundHeaders.get("chatgpt-account-id"))) return; + // Direct proxy-bearer substitution has no token snapshot in its `main` context; + // its accepted outbound identity is still evidence for the stored main slot. + owner = { kind: "stored", accountId: auth.kind === "main" ? MAIN_CODEX_ACCOUNT_ID : auth.accountId, + physicalIdentity: physical, ...(user.identity ? { userIdentity: user.identity } : {}), + callerCredentialIdentity: credential, ambiguous: false }; + } else { + owner = { kind: "caller", ...(physical ? { physicalIdentity: physical } : {}), + ...(user.identity ? { userIdentity: user.identity } : {}), + callerCredentialIdentity: credential, ambiguous: false }; + } + sweep(now); + const key = digest("root-session", `${principalId}\u0000${root}`); + const prior = owners.get(key); + if (prior) { + // Same workspace, different person is exactly the case a workspace id cannot see, and a + // credential proving nobody must not inherit an entry just by sharing that id. + if (prior.owner.ambiguous || prior.destination !== destinationKey + || !sameOwnerIdentity(prior.owner, owner)) { + owner = { ...prior.owner, ambiguous: true }; + } + } + const ownerDestination = prior?.destination ?? destinationKey; + const bytes = Buffer.byteLength(JSON.stringify([key, ownerDestination, owner]), "utf8"); + remove(key); + owners.set(key, { owner: Object.freeze(owner), destination: ownerDestination, touchedAt: now, bytes }); + totalBytes += bytes; + while (owners.size > MAX_ENTRIES || totalBytes > MAX_BYTES) { + const oldest = owners.keys().next().value; + if (oldest === undefined) break; + remove(oldest); + } +} + +/** Missing/expired/evicted ownership is unknown; never infer it from active routing. */ +export function getContextSessionOwner( + principalId: string | undefined, sessionId: string, destination: string, now = Date.now(), +): ContextSessionOwner | undefined { + if (!principalId || !validId(sessionId) || !Number.isFinite(now)) return undefined; + const destinationKey = destinationIdentity(destination); + if (!destinationKey) return undefined; + sweep(now); + const key = digest("root-session", `${principalId}\u0000${sessionId}`); + const entry = owners.get(key); + if (!entry || entry.destination !== destinationKey) return undefined; + owners.delete(key); + entry.touchedAt = now; + owners.set(key, entry); + return entry.owner; +} + +/** Compare only already-materialized headers; this function never reads credentials. */ +export function contextSessionOwnerMatches(owner: ContextSessionOwner, headers: Headers): boolean { + if (owner.ambiguous) return false; + const user = stableUserIdentity(headers); + if (user.conflict) return false; + if (owner.physicalIdentity !== physicalIdentity(headers)) return false; + if (owner.userIdentity !== user.identity) return false; + // A stored credential was minted by this proxy for the account it selected, so a proven user + // may present a refreshed token. A caller-supplied bearer is not ours: it becomes this + // session credential only when a model turn was accepted with it, so history has to present + // exactly that credential rather than any token carrying the same claims. + return owner.kind === "stored" && owner.userIdentity !== undefined + ? true + : owner.callerCredentialIdentity === callerCredentialIdentity(headers); +} + +/** Poison an entry whose accepted evidence stopped being coherent. */ +function markContextSessionAmbiguous(principalId: string, root: string | null, now: number): void { + if (!validId(root)) return; + const key = digest("root-session", principalId + "\u0000" + root); + const prior = owners.get(key); + if (!prior || prior.owner.ambiguous) return; + owners.set(key, { ...prior, owner: Object.freeze({ ...prior.owner, ambiguous: true }), touchedAt: now }); +} + +export function clearContextSessionOwnersForTests(): void { + owners.clear(); totalBytes = 0; +} diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 65661963fb..31893a3867 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -1,3 +1,4 @@ +import { contextCompatibleBaseLine } from "./context-compat"; import { existsSync, readFileSync, unlinkSync } from "node:fs"; import { atomicWriteFile, @@ -396,7 +397,7 @@ export function setRootOpenaiBaseUrl( const lines = content.split("\n"); const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); const rootEnd = firstTable === -1 ? lines.length : firstTable; - const key = buildOpenaiBaseUrlLine(portOrTarget, hostname); + const key = contextCompatibleBaseLine(content, buildOpenaiBaseUrlLine(portOrTarget, hostname)); for (let i = 0; i < rootEnd; i++) { if (!isRootOpenaiBaseUrlLine(lines[i])) continue; @@ -431,7 +432,7 @@ function setRootOpenaiBaseUrlForTarget( const lines = content.split("\n"); const firstTable = lines.findIndex((line) => /^\s*\[/.test(line)); const rootEnd = firstTable === -1 ? lines.length : firstTable; - const key = buildOpenaiBaseUrlLineForTarget(target); + const key = contextCompatibleBaseLine(content, buildOpenaiBaseUrlLineForTarget(target)); for (let index = 0; index < rootEnd; index += 1) { if (!isRootOpenaiBaseUrlLine(lines[index])) continue; const markerOwned = index > 0 && lines[index - 1].includes(OCX_SECTION_MARKER); @@ -901,7 +902,11 @@ export interface CodexInjectResult { } class CodexHistoryPreflightRefusal extends Error {} -class CodexRestoreRefusal extends Error {} +class CodexRestoreRefusal extends Error { + constructor(readonly config: CodexRestoreConfigResult) { + super(config.message); + } +} let historyArtifactStageForTests: ((stage: string) => void) | undefined; export function setHistoryArtifactStageForTests(hook: typeof historyArtifactStageForTests): void { historyArtifactStageForTests = hook; @@ -1911,6 +1916,13 @@ export function skippedRestoreEnvelope(success: boolean, message: string): Codex }; } +/** Config was attempted and failed; downstream artifacts were never attempted. */ +function failedConfigRestoreEnvelope(config: CodexRestoreConfigResult): CodexNativeRestoreResult { + const result = skippedRestoreEnvelope(false, config.message); + result.artifacts.config = config; + return result; +} + /** The config/profile half of a native restore, reported as one artifact. */ function restoreCodexConfigInline(kind = "sync"): CodexRestoreConfigResult { const preImages = captureCodexPreImages(); @@ -2004,7 +2016,7 @@ export async function restoreNativeCodexAsync( return await restoreNativeCodexAsyncImpl(options); } catch (error) { if (!(error instanceof CodexRestoreRefusal)) throw error; - return skippedRestoreEnvelope(false, error.message); + return failedConfigRestoreEnvelope(error.config); } } @@ -2084,7 +2096,7 @@ async function restoreNativeCodexAsyncImpl( try { restored = restoreCodexConfigInline(eligibility.kind); // Throw inside N so the published remove transition rolls back too. - if (restored.state === "failed") throw new CodexRestoreRefusal(restored.message); + if (restored.state === "failed") throw new CodexRestoreRefusal(restored); } catch (error) { const compensated = restoreCodexPreImages(preImages); if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored); @@ -2128,7 +2140,7 @@ async function restoreNativeCodexAsyncImpl( config = restoreCodexConfigInline(eligibility.kind); } - if (config.state === "failed") return skippedRestoreEnvelope(false, config.message); + if (config.state === "failed") return failedConfigRestoreEnvelope(config); const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath); const outcome = await runCodexHistoryJob({ ...resolveCodexHistoryJobTarget(), @@ -2185,7 +2197,7 @@ export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateD // catalog we actually wrote (#1798). const journaledCatalogPath = journaledInjectedCatalogPath(); const config = restoreCodexConfigInline(); - if (config.state === "failed") return skippedRestoreEnvelope(false, config.message); + if (config.state === "failed") return failedConfigRestoreEnvelope(config); const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath); // Design B (loopback) steady state: threads are already tagged openai, so prove the // no-op with a readonly probe instead of write-opening a DB the Codex app may hold diff --git a/src/oauth/devin-cli.ts b/src/oauth/devin-cli.ts index 27dd21060e..14a5466b53 100644 --- a/src/oauth/devin-cli.ts +++ b/src/oauth/devin-cli.ts @@ -18,7 +18,7 @@ */ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { posix, win32 } from "node:path"; import { DEVIN_CLI_INSTALL_HINT } from "../adapters/devin-cli/binary"; import { identityFromApiKey } from "./devin"; import { resolveDevinApiBaseUrl } from "./devin/api-base"; @@ -58,12 +58,13 @@ export function devinCliCredentialsPath( // Absolute only. A relative override would resolve against whatever directory // the proxy happens to be running in, which is not a location a user can mean. if (override && (override.startsWith("/") || /^[A-Za-z]:[\\/]/.test(override))) return override; + const paths = platform === "win32" ? win32 : posix; if (platform === "win32") { - const appData = env.APPDATA ?? join(homedir(), "AppData", "Roaming"); - return join(appData, "devin", "credentials.toml"); + const appData = env.APPDATA ?? paths.join(homedir(), "AppData", "Roaming"); + return paths.join(appData, "devin", "credentials.toml"); } - const dataHome = env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"); - return join(dataHome, "devin", "credentials.toml"); + const dataHome = env.XDG_DATA_HOME ?? paths.join(homedir(), ".local", "share"); + return paths.join(dataHome, "devin", "credentials.toml"); } export interface DevinCliCredentialFile { diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 0910698a0c..4bf99b4f0f 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1,4 +1,4 @@ -import { timingSafeEqual } from "node:crypto"; +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { initialModelSelection } from "../providers/initial-model-selection"; import { extractAccountId } from "../oauth/chatgpt"; import { formatErrorResponse } from "../bridge"; @@ -353,9 +353,29 @@ function secretEquals(actual: string, expected: string | undefined): boolean { */ export type DataPlaneAdmissionSource = "loopback" | "dedicated" | "bearer" | "x-api-key"; +/** + * Process-local, salted identity of the admission secret that was actually matched. + * + * Context-relay ownership is partitioned by this value, so two operators holding different + * keys cannot reach each other's sessions even when both resolve to the same upstream + * workspace. `source` is deliberately excluded: the same key arriving as a bearer or in the + * dedicated header is one principal. Rotating or replacing a secret mints a new principal and + * drops continuity, which is the safe direction — a reused key id or a replaced environment + * secret must not inherit the previous holder's sessions. Loopback admission carries no caller + * identity and mints nothing, so the relay refuses it rather than treating every local process + * as one user. + */ +const CONTEXT_PRINCIPAL_SALT = randomBytes(32); + +function mintContextPrincipal(kind: string, keyId: string, credential: string): string { + return createHmac("sha256", CONTEXT_PRINCIPAL_SALT) + .update(kind).update("\0").update(keyId).update("\0").update(credential) + .digest("hex"); +} + export type DataPlaneAdmission = - | { kind: "configured"; keyId: string; source: DataPlaneAdmissionSource } - | { kind: "environment"; source: DataPlaneAdmissionSource } + | { kind: "configured"; keyId: string; source: DataPlaneAdmissionSource; contextPrincipalId?: string } + | { kind: "environment"; source: DataPlaneAdmissionSource; contextPrincipalId?: string } | { kind: "loopback"; source: "loopback" }; /** @@ -374,17 +394,52 @@ export function resolveDataPlaneAdmissionSecret( ): DataPlaneAdmission | null { const actual = token.trim(); if (!actual) return null; - if (secretEquals(actual, configuredApiAuthToken(config))) return { kind: "environment", source }; + if (secretEquals(actual, configuredApiAuthToken(config))) { + return { kind: "environment", source, contextPrincipalId: mintContextPrincipal("environment", "", actual) }; + } for (const k of config.apiKeys ?? []) { - if (secretEquals(actual, k.key)) return { kind: "configured", keyId: k.id, source }; + if (secretEquals(actual, k.key)) { + return { kind: "configured", keyId: k.id, source, contextPrincipalId: mintContextPrincipal("configured", k.id, actual) }; + } const pending = k.pendingRotation; if (pending && Date.parse(pending.expiresAt) > Date.now() && secretEquals(actual, pending.key)) { - return { kind: "configured", keyId: k.id, source }; + return { kind: "configured", keyId: k.id, source, contextPrincipalId: mintContextPrincipal("configured", k.id, pending.key) }; } } return null; } +/** The principal an authenticated admission belongs to, or undefined for loopback. */ +export function contextPrincipalIdOf(admission: DataPlaneAdmission | undefined): string | undefined { + return admission && "contextPrincipalId" in admission ? admission.contextPrincipalId : undefined; +} + +/** + * The caller principal for a context-relay request, which is a stricter question than admission. + * + * The default bind is loopback, where admission deliberately never reads a token, so an admitted + * request carries no caller identity. History ownership needs one, so the relay asks separately: + * a caller that presents a real opencodex API key gets that key’s principal even on loopback, + * and a caller that presents none gets nothing and is refused. This adds identity where the caller + * volunteered it; it does not admit anyone who was not already admitted, and it does not change + * which credential goes upstream. + */ +export function resolveContextPrincipal(req: Request, config: OcxConfig, admission: DataPlaneAdmission | undefined): string | undefined { + const named = contextPrincipalIdOf(admission); + if (named) return named; + if (admission?.kind !== "loopback") return undefined; + const dedicated = req.headers.get("x-opencodex-api-key")?.trim(); + const bearer = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const apiKey = req.headers.get("x-api-key")?.trim(); + for (const [token, source] of [[dedicated, "dedicated"], [bearer, "bearer"], [apiKey, "x-api-key"]] as const) { + if (!token) continue; + const resolved = resolveDataPlaneAdmissionSecret(token, config, source); + const principal = contextPrincipalIdOf(resolved ?? undefined); + if (principal) return principal; + } + return undefined; +} + /** Whether `token` is a data-plane admission secret. */ export function isDataPlaneAdmissionSecret(token: string, config: OcxConfig): boolean { return resolveDataPlaneAdmissionSecret(token, config) !== null; diff --git a/src/server/context-history.ts b/src/server/context-history.ts new file mode 100644 index 0000000000..759157a406 --- /dev/null +++ b/src/server/context-history.ts @@ -0,0 +1,207 @@ +/** Native history/notes JSON relay. No interpretation of encrypted tool arguments or retries. */ +import { formatErrorResponse } from "../bridge"; +import { + CodexAccountCooldownError, CodexAuthContextError, CodexMainProfileDrainingError, CodexDirectAuthenticationError, + CodexPoolAuthenticationError, CodexThreadAffinityExpiredError, CodexMainSubstitutionUnavailableError, + codexMainProfileDrainingResponse, cooldownErrorResponse, + materializeCodexUpstreamAuth, isCodexAuthContextUsable, resolveCodexAuthContext, releaseCodexAuthContextProbeLease, +} from "../codex/auth-context"; +import { getContextSessionOwner, contextSessionOwnerMatches } from "../codex/context-owner"; +import { contextEndpoint, contextRelayActivated } from "../codex/context-compat"; +import { formatCodexProviderForLog } from "../codex/routing"; +import { listOpenAiForwardSidecarCandidates } from "../providers/openai-sidecar"; +import { clearableDeadline, type ClearableDeadline } from "../lib/abort"; +import { readBoundedResponseBytes } from "../lib/bounded-body"; +import type { AdmissionLease } from "../lib/admission"; +import type { OcxConfig } from "../types"; +import { resolveContextPrincipal, ForwardAdmissionCredentialError, validateForwardAdmissionCredential, type DataPlaneAdmission } from "./auth-cors"; +import { readBoundedJsonRequestBody } from "./request-decompress"; +import { codexLogAccountId, decodeRequestErrorResponse } from "./responses"; +import { codexAccountSelectionForTurn } from "./lifecycle"; +import type { RequestLogContext } from "./request-log"; + +const PROTOCOL_HEADERS = ["x-openai-encrypted-tool-arguments", "x-openai-tool-output-truncation-policy"]; +const RESPONSE_HEADERS = ["content-type", "retry-after", "x-request-id", "openai-processing-ms", ...PROTOCOL_HEADERS]; +const MAX_RESPONSE_BYTES = 16 * 1024 * 1024; +const MAX_REQUEST_BYTES = 16 * 1024 * 1024; +const RELAY_DEADLINE_MS = 35_000; + +export function contextSelectionHeaders(headers: Headers, sessionId: string): Headers { + const result = new Headers(headers); + // Codex history tools carry root session_id in JSON, unlike Responses' HTTP headers. + // Root model requests use (session-id=root, thread-id=root); don't fabricate a parent key. + if (!result.has("x-codex-parent-thread-id") && !result.has("session-id") && !result.has("thread-id")) { + result.set("session-id", sessionId); + result.set("thread-id", sessionId); + } + return result; +} + +/** + * One deadline covers the whole operation, starting before the body is read. + * + * Reading the body and selecting a credential are both waits a caller controls, and this route + * holds an admitted turn slot for their duration. A deadline that started at dispatch would let + * an unfinished body or a stalled refresh hold that slot with no bound at all. + */ +export async function handleContextHistory( + req: Request, config: OcxConfig, logCtx: RequestLogContext, + endpoint: string, turnAdmissionLease?: AdmissionLease, admission?: DataPlaneAdmission, + revalidateAdmission?: () => DataPlaneAdmission | null, +): Promise { + const deadline = clearableDeadline(RELAY_DEADLINE_MS, req.signal); + try { + return await relayContextHistory(req, config, logCtx, endpoint, deadline, turnAdmissionLease, + admission, revalidateAdmission); + } finally { + deadline.clear(); + } +} + +async function relayContextHistory( + req: Request, config: OcxConfig, logCtx: RequestLogContext, + endpoint: string, deadline: ClearableDeadline, + turnAdmissionLease?: AdmissionLease, admission?: DataPlaneAdmission, + revalidateAdmission?: () => DataPlaneAdmission | null, +): Promise { + // The feature is opt-in, and the opt-in has to hold here rather than only where the injected + // base URL is rewritten: a caller that can reach the data plane can POST these paths directly. + // While it is off the endpoints do not exist, which is also what a disabled route should look + // like from outside. + if (!contextEndpoint("/v1/" + endpoint) || req.method !== "POST" || !contextRelayActivated()) { + return formatErrorResponse(404, "not_found", "Unknown context endpoint"); + } + // Only a trusted listener admission authorizes replacing a proxy bearer with Codex auth. + const substituteMainCredential = admission?.source === "bearer"; + try { if (!substituteMainCredential) validateForwardAdmissionCredential(req.headers, config); } + catch (err) { + if (err instanceof ForwardAdmissionCredentialError) return formatErrorResponse(401, "authentication_error", err.message); + throw err; + } + // A workspace is not a person and a local socket is not a caller. Ownership is keyed by the + // admission secret that was actually matched, so an admission that carries no principal — + // loopback — cannot own or reach a session. + const principalId = resolveContextPrincipal(req, config, admission); + if (!principalId) { + return formatErrorResponse(403, "context_principal_required", + "Context history requires an opencodex API key on the request; admission alone carries no caller identity"); + } + let body: unknown; + try { body = await readBoundedJsonRequestBody(req, MAX_REQUEST_BYTES, undefined, { signal: deadline.signal }); } + catch (err) { + const cancelled = cancellationResponse(req, deadline); + if (cancelled) return cancelled; + return decodeRequestErrorResponse(err, "context_history"); + } + const sessionId = (body as {context?: {session_id?: unknown}} | null)?.context?.session_id; + if (typeof sessionId !== "string" || !/^[A-Za-z0-9._:-]{1,512}$/.test(sessionId)) { + return formatErrorResponse(400, "invalid_request_error", "context.session_id must be a bounded nonempty string"); + } + const candidate = listOpenAiForwardSidecarCandidates(config)[0]; + if (!candidate) return formatErrorResponse(400, "invalid_request_error", "History and notes require the native ChatGPT forward provider"); + const rootHeader = req.headers.get("x-codex-parent-thread-id")?.trim() || req.headers.get("session-id")?.trim(); + if (rootHeader && rootHeader !== sessionId) { + return formatErrorResponse(409, "context_account_unavailable", "Context root does not match this request"); + } + const owner = getContextSessionOwner(principalId, sessionId, candidate.provider.baseUrl); + if (!owner || owner.ambiguous || (owner.kind === "caller" && substituteMainCredential)) { + return formatErrorResponse(409, "context_account_unavailable", + "Context account ownership is unavailable; start a new session and preserve needed state before resetting context"); + } + let authContext: Awaited>; + const headers = new Headers(candidate.provider.headers); + try { + authContext = await resolveCodexAuthContext(contextSelectionHeaders(req.headers, sessionId), config, owner.kind === "stored" ? "pool" : "direct", { + // The same deadline bounds credential selection and refresh waits. + signal: deadline.signal, + // Resolve the proven physical owner as an explicit account. Context operations + // never create affinity, rotate on quota, or inspect file-main for a caller owner. + modelId: "context_history", + ...(owner.kind === "stored" ? { accountId: owner.accountId } : { requestScopedMainCredential: true }), + admission, + substituteMainCredentialForDirect: substituteMainCredential, + beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease), + }); + if (authContext.kind !== "main" && authContext.probeLeaseId) { + // History traffic must not occupy or settle the model's quota-recovery probe. + releaseCodexAuthContextProbeLease(authContext); + return formatErrorResponse(503, "upstream_error", "Model quota recovery is pending; retry context operation later"); + } + if (!isCodexAuthContextUsable(authContext, config)) throw new CodexPoolAuthenticationError("Selected Codex account is unavailable"); + logCtx.provider = formatCodexProviderForLog(candidate.providerName, codexLogAccountId(authContext), config); + // Materialization rechecks the current account policy after async selection. + // Synthetic lane IDs are local selection metadata, never upstream headers. + for (const [key, value] of materializeCodexUpstreamAuth(req.headers, authContext, { + config, modelId: "context_history", admission, substituteMainCredential, + })) { + headers.set(key, value); + } + // Check the assembled outbound headers, including configured provider headers. + validateForwardAdmissionCredential(headers, config); + // Recheck actual wire identity after async selection/materialization. A replaced + // account slot or login must not receive another physical account's history. + const currentOwner = getContextSessionOwner(principalId, sessionId, candidate.provider.baseUrl); + if (!currentOwner || currentOwner.kind !== owner.kind + || !contextSessionOwnerMatches(owner, headers) || !contextSessionOwnerMatches(currentOwner, headers)) { + return formatErrorResponse(409, "context_account_unavailable", "Context account identity changed; start a new session"); + } + } catch (err) { + const cancelled = cancellationResponse(req, deadline); + if (cancelled) return cancelled; + if (err instanceof CodexAccountCooldownError) return cooldownErrorResponse(err); + if (err instanceof CodexMainProfileDrainingError) return codexMainProfileDrainingResponse(); + if (err instanceof CodexThreadAffinityExpiredError) return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session"); + if (err instanceof CodexAuthContextError || err instanceof CodexPoolAuthenticationError || err instanceof CodexDirectAuthenticationError + || err instanceof CodexMainSubstitutionUnavailableError || err instanceof ForwardAdmissionCredentialError) { + return formatErrorResponse(401, "authentication_error", "Selected Codex account is unavailable or needs reauthentication"); + } + throw err; + } + headers.set("content-type", "application/json"); + for (const key of PROTOCOL_HEADERS) { + const value = req.headers.get(key); if (value !== null) headers.set(key, value); + } + // Nothing is dispatched after cancellation; a notes write is not replayable. + const cancelledBeforeDispatch = cancellationResponse(req, deadline); + if (cancelledBeforeDispatch) return cancelledBeforeDispatch; + // Body reading and credential selection are both waits, and a key can be revoked, rotated or + // replaced during them. Re-resolve admission against the receiving listener policy and require + // the same principal, so a withdrawn key cannot dispatch on a snapshot taken minutes earlier. + if (revalidateAdmission && resolveContextPrincipal(req, config, revalidateAdmission() ?? undefined) !== principalId) { + return formatErrorResponse(401, "authentication_error", + "opencodex API key changed during this request; retry with current credentials"); + } + // The operator may disable the feature while body or credential IO is pending. + if (!contextRelayActivated()) return formatErrorResponse(404, "not_found", "Unknown context endpoint"); + let response: Response | undefined; + try { + response = await fetch(`${candidate.provider.baseUrl}/${endpoint}`, { + method: "POST", headers, body: JSON.stringify(body), signal: deadline.signal, redirect: "manual", + }); + const result = await readBoundedResponseBytes(response, {maxBytes: MAX_RESPONSE_BYTES, signal: deadline.signal}); + if (result.oversized) return formatErrorResponse(502, "upstream_error", "Context response exceeded 16 MiB"); + const outputHeaders = new Headers(); + for (const key of RESPONSE_HEADERS) { + const value = response.headers.get(key); if (value !== null) outputHeaders.set(key, value); + } + // A context 403 is not evidence that the model credential is invalid. Don't mutate pool + // health/quota or retry writes; preserve the real upstream result for the caller. + return new Response([204,205,304].includes(response.status) ? null : result.bytes, {status:response.status, headers:outputHeaders}); + } catch { + return cancellationResponse(req, deadline) + ?? formatErrorResponse(502, "upstream_error", "Context upstream connection failed"); + } finally { + if (response?.body && !response.body.locked) void response.body.cancel().catch(() => undefined); + } +} + +/** Client cancellation and deadline expiry are different outcomes and must not share a status. */ +function cancellationResponse(req: Request, deadline: ClearableDeadline): Response | undefined { + if (req.signal.aborted) { + return formatErrorResponse(499, "client_closed_request", "Context request canceled by client"); + } + if (deadline.didExpire()) { + return formatErrorResponse(504, "upstream_error", "Context operation exceeded its deadline"); + } + return undefined; +} diff --git a/src/server/index.ts b/src/server/index.ts index 2cb11c1e9f..3e458a3fea 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -205,6 +205,8 @@ import { import { handleImages } from "./images"; import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live"; import { handleSearch } from "./search"; +import { handleContextHistory } from "./context-history"; +import { codexCompatibleUrl, contextEndpoint, contextRelayActivated } from "../codex/context-compat"; import { fetchAllModels, handleManagementAPI, VERSION, type ManagementApiDeps } from "./management-api"; import { createManagementSessionControl, @@ -845,6 +847,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { + const response = await handleContextHistory(req, config, logCtx, contextEndpoint(url.pathname)!, + turnAdmissionLease, admission, () => resolveApiAuth(req, policy)); + addFinalRequestLog(requestId, start, logCtx, response.status, + response.status === 499 ? { closeReason: "client_cancel" } : undefined); + return withCors(response, req, policy); + }); + } + if (url.pathname === "/v1/alpha/search" && req.method === "POST") { disableResponsesRequestTimeout(req, requestServer); if (isDraining()) { diff --git a/src/server/live.ts b/src/server/live.ts index 6d983ce0aa..eab37d6bda 100644 --- a/src/server/live.ts +++ b/src/server/live.ts @@ -1,3 +1,4 @@ +import { codexCompatibleUrl } from "../codex/context-compat"; /** * /v1/live and /v1/realtime/calls relay (issue #371). * @@ -635,7 +636,7 @@ export async function handleLive( // Frameless API-shape call-create posts to `{base}/live` without the AVAS // query (openai/codex RealtimeCallClient, realtime_call.rs); only the // realtime/calls inbound shape keeps the legacy keyed AVAS endpoint. - url = new URL(req.url).pathname === "/v1/live" + url = codexCompatibleUrl(req.url).pathname === "/v1/live" ? forwardLiveUrl(relay.providerBaseUrl, /* usesBackendShape */ false) : keyedLiveUrl(relay.providerBaseUrl); } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index e141b55ef0..8b499d5111 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1,4 +1,6 @@ import type { Server } from "bun"; +import { recordContextSessionOwner } from "../../codex/context-owner"; +import { contextRelayActivated } from "../../codex/context-compat"; import { randomUUID } from "node:crypto"; import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge"; import { formatPassthroughUpstreamError } from "./passthrough-error"; @@ -221,6 +223,7 @@ import { isProxyAdmissionSecret, validateForwardAdmissionCredential, } from "../auth-cors"; +import { resolveContextPrincipal } from "../auth-cors"; import type { DataPlaneAdmission } from "../auth-cors"; import { createTranslatorBudget, isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget"; import { captureExplicitOpenAiCallerAuth, listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ExplicitOpenAiCallerAuth, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; @@ -4771,8 +4774,16 @@ async function handleResponsesInner( // `compaction_trigger` item — only the canonical ChatGPT backend speaks that // contract. An API-key gateway would receive the trigger, answer with an ordinary // message, and leave Codex fataling on a missing compaction item (#422). - const commitReasoningReplayServingRoute = (): void => { + const commitReasoningReplayServingRoute = (outboundHeaders?: HeadersInit): void => { commitReasoningReplayServingIdentity(parsed._reasoningReplayScope); + // History has no model namespace. Record the account that actually accepted this + // final attempt, after refresh/failover, rather than guessing from mutable affinity. + // Recording is relay state. With the feature off there is no relay, so building an owner + // registry for it is out of scope for this request. + if (outboundHeaders && isCanonicalOpenAiForwardProvider(route.provider) && contextRelayActivated()) { + recordContextSessionOwner(resolveContextPrincipal(req, config, options.admission), req.headers, + route.provider.baseUrl, authCtx, new Headers(outboundHeaders), substituteMainCredential); + } }; if (routedCompaction) { delete parsed.context.tools; @@ -6011,7 +6022,7 @@ async function handleResponsesInner( // For streamed passthrough, a successful terminal response means non-error upstream status // before relay starts. Waiting for SSE completion would retain request state across the whole // stream; a later body failure does not undo that this destination accepted and served the turn. - commitReasoningReplayServingRoute(); + commitReasoningReplayServingRoute(request.headers); const terminalRepairPolicy = providerModelResponsesTerminalRepair( route.providerName, route.provider, @@ -6408,7 +6419,7 @@ async function handleResponsesInner( declaredBareWireToolNames, ); } - commitReasoningReplayServingRoute(); + commitReasoningReplayServingRoute(request.headers); try { rememberPassthroughResponseChecked( JSON.parse(text) as { id?: unknown; output?: unknown; status?: unknown; model?: unknown }, @@ -6490,7 +6501,7 @@ async function handleResponsesInner( } // An unclassified passthrough body is relayed directly and has no bounded completion observer; // use the same non-error-status success boundary as SSE instead of retaining per-stream state. - commitReasoningReplayServingRoute(); + commitReasoningReplayServingRoute(request.headers); const body = relayWithAbort(upstreamResponse.body, upstream); const turnAc = new AbortController(); const tracked = body ? trackStreamLifetime(body, turnAc, undefined, options.turnAdmissionLease) : null; diff --git a/structure/codex-home.md b/structure/codex-home.md index b11ddd3f1a..03eee35aa2 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -236,3 +236,8 @@ Injection preflights affected history using the normalized config candidate befo The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. Native restore preflight also checks manifest-owned targets whose rows already returned to `openai`, including interrupted restores. Preimage capture distinguishes absent files from unreadable artifacts and aborts before mutation when a complete snapshot cannot be read. + +A config restoration that was attempted and failed retains its failed artifact in the restore +result; unattempted catalog and history artifacts remain skipped. Successful preimage compensation +preserves config/profile/journal bytes without relabeling the failure as a skipped operation. +Incomplete compensation still raises the explicit partial-write error. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 2d17c11875..ee1e7b69c0 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -33,6 +33,15 @@ request-signal cancellation contracts as routed Responses transport. Because call before it binds the adapter; the pick remains inert unless a strategy is configured and the committed key is cooling. See [`responses.md`](../transports/responses.md). +## Chat conversation identity forwarding + +`src/server/chat-completions.ts` preserves caller `prompt_cache_key` on the Chat-to-Responses +bridge. Canonical ChatGPT Responses forwarding preserves `session_id`, `session-id`, `thread-id` +and per-request `x-client-request-id` under their original names. Missing conversation identity +stays missing; a shared prefix/cache key is not converted into a session. The direct-mode +outbound contract is covered by `tests/responses/chat-conversation-affinity.test.ts`. +This transport contract does not prove a client's emission, Pool selection stability or cache hits. + ## Chat streaming client with a JSON upstream result The translated inbound path in `src/server/chat-completions.ts` may receive a complete JSON diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 6fdc920b02..ff5f33d9a3 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -420,3 +420,36 @@ successful main usage refresh clears the runtime mark. ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +## Context relay ownership + +`src/codex/context-owner.ts` records which account actually served a root session, taken from the +final materialized outbound headers of an accepted model attempt, after refresh and failover. +Entries are bounded, process-local and expiring, and are keyed by the admission principal that +`src/server/auth-cors.ts` mints for the matched opencodex API key, plus the destination and the +root session. Two keys therefore cannot observe or overwrite each other's ownership even when both +resolve to one ChatGPT workspace, and rotating a key mints a new principal instead of inheriting +the previous holder's sessions. `resolveContextPrincipal` resolves that principal from the opencodex API key the request +presents, on both the recording and the relay path so the two agree. A remote bind supplies it +through admission. A loopback bind admits without reading a token, so the key is resolved from the +request only for a loopback admission; this adds identity where the caller volunteered it rather +than admitting anyone new, and changes neither admission nor which credential goes upstream. The +built-in loopback injection cannot carry that header, so the relay is unavailable through the +default Codex integration and refuses instead of inferring an owner. Making loopback callers +identifiable is an open maintainer decision, not a gap to be closed by relaxing the refusal. + +A workspace id identifies an organization, so an entry also binds the stable user claim carried by +the accepted credential. That claim is read without signature verification, which is why upstream +acceptance stays the evidence: a credential proving a different user does not continue the session, +conflicting claims are never recorded, and an entry with no proven user continues only for the +exact accepted credential. Conflicting observations stay ambiguous, and ambiguous, unknown, +expired, evicted or restart-lost ownership fails closed before account selection or upstream I/O. + +`src/server/context-history.ts` relays the native history and notes endpoints under one deadline +that starts on route entry, before the body is read and before credential selection, so an +unfinished body cannot hold an admitted turn. Client cancellation and deadline expiry are reported +separately, nothing is dispatched upstream after either, and notes writes are never retried. + +Context relay dispatch rechecks the native experimental opt-in after body and credential waits. +A disabled gate prevents upstream dispatch even when the request entered while enabled. Final +materialized headers pass the proxy-credential exclusion check before owner matching. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 5b149ac6a2..c6e7cd48f3 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -65,3 +65,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Devin CLI credential path composition in `src/oauth/devin-cli.ts` follows the selected platform: Windows uses Win32 APPDATA paths, other platforms use POSIX XDG-data paths. The explicit absolute override remains verbatim; credential parsing and login behavior are unchanged. diff --git a/structure/runtime.md b/structure/runtime.md index e21d9b5fb4..365fb04e07 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -233,3 +233,5 @@ Cline CLI joins the existing export/client integration registries. Explicit CLI Config JSON preserves the boolean; only literal true activates the role-changing transform. The lightweight top-level CLI help counts Cline CLI among the fifteen registered export clients; registry parity remains covered by the client help and integration tests. + +Devin CLI credential path composition in `src/oauth/devin-cli.ts` follows the selected platform: Windows uses Win32 APPDATA paths, other platforms use POSIX XDG-data paths. The explicit absolute override remains verbatim; credential parsing and login behavior are unchanged. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index b2fc3b3fae..88a26d3500 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -70,3 +70,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. + +Devin CLI credential path composition in `src/oauth/devin-cli.ts` follows the selected platform: Windows uses Win32 APPDATA paths, other platforms use POSIX XDG-data paths. The explicit absolute override remains verbatim; credential parsing and login behavior are unchanged. diff --git a/tests/cli/cli-status-json.test.ts b/tests/cli/cli-status-json.test.ts index aeef546421..1c613ed376 100644 --- a/tests/cli/cli-status-json.test.ts +++ b/tests/cli/cli-status-json.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, spyOn, test } from "bun:test"; import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs"; -import { createServer } from "node:net"; +import { createConnection, createServer } from "node:net"; import type { AddressInfo } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -879,6 +879,24 @@ describe("status reports stale process records end to end", () => { await new Promise(resolve => { probe.close(() => resolve()); }); return port; } + + /** + * Prove the endpoint refuses right now. + * + * `allocateFreePort` releases the port it reports, and on a sharded runner every other + * test binding an ephemeral port is a candidate to take it. A fixture that assumes a + * released port is still refusing asserts against whatever happened to bind it. + */ + async function refusesConnection(port: number): Promise { + return await new Promise(resolve => { + const socket = createConnection({ port, host: "127.0.0.1" }); + const settle = (refused: boolean): void => { socket.destroy(); resolve(refused); }; + socket.setTimeout(1_000); + socket.once("connect", () => settle(false)); + socket.once("timeout", () => settle(false)); + socket.once("error", () => settle(true)); + }); + } let freePort: number; beforeEach(async () => { freePort = await allocateFreePort(); }); @@ -950,17 +968,27 @@ describe("status reports stale process records end to end", () => { await new Promise(resolve => { occupied.listen(0, "127.0.0.1", () => resolve()); }); const occupiedPort = (occupied.address() as AddressInfo).port; try { - // Allocate after the listener is bound: it can reuse the port released by - // beforeEach, so that earlier number no longer proves a refused endpoint. - const recordedPort = await allocateFreePort(); - expect(recordedPort).not.toBe(occupiedPort); const pid = findDeadPid(); writeFileSync(join(home, "config.json"), JSON.stringify({ port: occupiedPort, codexAutoStart: false }), "utf8"); writeFileSync(join(home, "ocx.pid"), String(pid), "utf8"); - writeFileSync(join(home, "runtime-port.json"), JSON.stringify({ pid, port: recordedPort, hostname: "127.0.0.1" }), "utf8"); - const parsed = JSON.parse(runStatusJson(home).stdout) as { proxy?: { staleProcessState?: unknown } }; - expect(parsed.proxy?.staleProcessState).toBe(true); + // The recorded port has to refuse for this to discriminate, and `allocateFreePort` + // hands back a port it has already released. Confirm refusal immediately before and + // immediately after the probe, and re-allocate when something took it in between, so + // a stolen port retries instead of failing an assertion it never exercised. + let parsed: { proxy?: { staleProcessState?: unknown } } | undefined; + for (let attempt = 0; attempt < 5 && parsed === undefined; attempt++) { + const recordedPort = await allocateFreePort(); + if (recordedPort === occupiedPort) continue; + if (!await refusesConnection(recordedPort)) continue; + writeFileSync(join(home, "runtime-port.json"), JSON.stringify({ pid, port: recordedPort, hostname: "127.0.0.1" }), "utf8"); + const observed = JSON.parse(runStatusJson(home).stdout) as { proxy?: { staleProcessState?: unknown } }; + if (!await refusesConnection(recordedPort)) continue; + parsed = observed; + } + + expect(parsed, "no allocated port stayed refused across the status probe").toBeDefined(); + expect(parsed?.proxy?.staleProcessState).toBe(true); } finally { await new Promise(resolve => { occupied.close(() => resolve()); }); removeTreeWithRetry(home); diff --git a/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index 580f6d15bd..39e3c45329 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -73,6 +73,9 @@ import { tryAdmitTurn, } from "../../src/server/lifecycle"; import type { CodexModelEntitlementSnapshot } from "../../src/codex/model-entitlements"; +import { recordContextSessionOwner, clearContextSessionOwnersForTests } from "../../src/codex/context-owner"; +import { handleContextHistory } from "../../src/server/context-history"; +import { resetContextRelayActivationForTests } from "../../src/codex/context-compat"; import { hasForwardableCodexBearer } from "../../src/server/auth-cors"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -102,6 +105,7 @@ beforeEach(() => { }); afterEach(() => { + resetContextRelayActivationForTests(); setIcaclsRunnerForTests(null); removeTreeWithRetry(testDir); clearThreadAccountMap(); @@ -2387,3 +2391,52 @@ describe("native-main fence names its gate reason", () => { } }); }); + + +test("context Direct bearer admission uses real stored-main materialization and fails closed without it", async () => { + writeFileSync(join(testDir, "config.toml"), "[features]\ncontext_management.experimental_mode = true\n"); + resetContextRelayActivationForTests(); + const cfg = config(); + cfg.providers.openai = { + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", codexAccountMode: "direct", + }; + const token = liveJwt(); + const admission = { kind: "environment", source: "bearer", contextPrincipalId: "principal-a" } as const; + clearContextSessionOwnersForTests(); + recordContextSessionOwner("principal-a", new Headers({ "session-id": "synthetic-root" }), cfg.providers.openai.baseUrl, + { kind: "main", accountId: null }, new Headers({ authorization: `Bearer ${token}`, "chatgpt-account-id": "stored_main_acc" }), true); + const request = () => new Request("http://localhost/v1/alpha/notes/v2/read_file", { + method: "POST", headers: { authorization: "Bearer ocx_data_test_admission", "openai-beta": "responses=experimental", cookie: "synthetic=private" }, + body: JSON.stringify({ context: { session_id: "synthetic-root" } }), + }); + const originalFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = Object.assign(async (input: string | URL | Request, init?: RequestInit) => { + calls++; + expect(String(input)).toBe("https://chatgpt.com/backend-api/codex/alpha/notes/v2/read_file"); + const headers = new Headers(init?.headers); + expect(headers.get("authorization")).toBe(`Bearer ${token}`); + expect(headers.get("chatgpt-account-id")).toBe("stored_main_acc"); + expect(headers.get("openai-beta")).toBe("responses=experimental"); + expect(headers.has("cookie")).toBe(false); + return new Response("{}"); + }, { preconnect: originalFetch.preconnect }); + try { + for (const available of [true, false]) { + writeFileSync(join(testDir, "auth.json"), JSON.stringify({ tokens: available ? { access_token: token, account_id: "stored_main_acc" } : {} })); + const turn = tryAdmitTurn(); + expect(turn).not.toBeNull(); + try { + const response = await handleContextHistory(request(), cfg, { model: "context_history", provider: "" }, "alpha/notes/v2/read_file", turn!, admission); + expect(response.status).toBe(available ? 200 : 401); + } finally { + turn?.release(); + } + } + expect(calls).toBe(1); + } finally { + clearContextSessionOwnersForTests(); + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/codex-integration/codex-context-owner.test.ts b/tests/codex-integration/codex-context-owner.test.ts new file mode 100644 index 0000000000..6c60edbb33 --- /dev/null +++ b/tests/codex-integration/codex-context-owner.test.ts @@ -0,0 +1,238 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import type { CodexAuthContext } from "../../src/codex/auth-context"; +import { clearContextSessionOwnersForTests, contextSessionOwnerMatches, + getContextSessionOwner, recordContextSessionOwner } from "../../src/codex/context-owner"; + +const destination = "https://chatgpt.com/backend-api/codex"; +const principal = "principal-a"; +const start = 1_000_000; +const root = (id = "root") => new Headers({ "session-id": id, "thread-id": id }); +const outbound = (account: string | null = "physical-a", token = "accepted-a") => new Headers({ + authorization: `Bearer ${token}`, ...(account === null ? {} : { "chatgpt-account-id": account }), +}); +const stored = (id = "slot-a", account = "physical-a"): CodexAuthContext => ({ + kind: "pool", accountId: id, chatgptAccountId: account, + accessToken: "accepted-a", generation: 1, writerGeneration: 0, fixedAccount: true, +}); +const caller: CodexAuthContext = { kind: "main", accountId: null }; +beforeEach(clearContextSessionOwnersForTests); + +let issuedTokens = 0; +const userToken = (user: string | undefined, second?: string) => { + const auth: Record = {}; + if (user !== undefined) auth.chatgpt_user_id = user; + if (second !== undefined) auth.user_id = second; + // iat varies per call so two tokens for the same person are genuinely different credentials, + // which is what a refresh looks like and what the fingerprint rules are about. + const payload = Buffer.from(JSON.stringify({ + iat: ++issuedTokens, "https://api.openai.com/auth": auth, + }), "utf8").toString("base64url"); + return `header.${payload}.signature`; +}; + +describe("context session ownership", () => { + test("another admission principal can neither read nor poison the first principal's session", () => { + recordContextSessionOwner(principal, root(), destination, stored(), outbound(), false, start); + expect(getContextSessionOwner("principal-b", "root", destination, start)).toBeUndefined(); + recordContextSessionOwner("principal-b", root(), destination, stored("slot-b", "physical-b"), + outbound("physical-b", "accepted-b"), false, start); + const first = getContextSessionOwner(principal, "root", destination, start)!; + expect(first).toMatchObject({ accountId: "slot-a", ambiguous: false }); + expect(getContextSessionOwner("principal-b", "root", destination, start)) + .toMatchObject({ accountId: "slot-b", ambiguous: false }); + }); + + test("a second user inside the same workspace does not inherit the session", () => { + const first = outbound("physical-a", userToken("user-a")); + const second = outbound("physical-a", userToken("user-b")); + recordContextSessionOwner(principal, root(), destination, stored(), first, false, start); + const owner = getContextSessionOwner(principal, "root", destination, start)!; + expect(contextSessionOwnerMatches(owner, first)).toBe(true); + expect(contextSessionOwnerMatches(owner, second)).toBe(false); + recordContextSessionOwner(principal, root(), destination, stored(), second, false, start + 1); + expect(getContextSessionOwner(principal, "root", destination, start + 1)?.ambiguous).toBe(true); + }); + + test("a proven user survives a token refresh; without one only the exact credential continues", () => { + const issued = outbound("physical-a", userToken("user-a")); + recordContextSessionOwner(principal, root(), destination, stored(), issued, false, start); + const owner = getContextSessionOwner(principal, "root", destination, start)!; + expect(contextSessionOwnerMatches(owner, outbound("physical-a", userToken("user-a")))).toBe(true); + + recordContextSessionOwner(principal, root("bare"), destination, stored(), outbound(), false, start); + const bare = getContextSessionOwner(principal, "bare", destination, start)!; + expect(contextSessionOwnerMatches(bare, outbound())).toBe(true); + expect(contextSessionOwnerMatches(bare, outbound("physical-a", "rotated"))).toBe(false); + // A credential that only now starts proving a user is a different fact, not a continuation. + expect(contextSessionOwnerMatches(bare, outbound("physical-a", userToken("user-a")))).toBe(false); + }); + + test("conflicting user claims are never recorded and poison an existing entry", () => { + recordContextSessionOwner(principal, root(), destination, stored(), + outbound("physical-a", userToken("user-a", "user-b")), false, start); + expect(getContextSessionOwner(principal, "root", destination, start)).toBeUndefined(); + + recordContextSessionOwner(principal, root("seeded"), destination, stored(), + outbound("physical-a", userToken("user-a")), false, start); + recordContextSessionOwner(principal, root("seeded"), destination, stored(), + outbound("physical-a", userToken("user-a", "user-b")), false, start + 1); + expect(getContextSessionOwner(principal, "seeded", destination, start + 1)?.ambiguous).toBe(true); + }); + + test("an unauthenticated admission owns nothing", () => { + recordContextSessionOwner(undefined, root(), destination, stored(), outbound(), false, start); + expect(getContextSessionOwner(undefined, "root", destination, start)).toBeUndefined(); + expect(getContextSessionOwner(principal, "root", destination, start)).toBeUndefined(); + }); + + test("an explicit stored account owns the session independently of routing state", () => { + recordContextSessionOwner(principal, root(), destination, stored(), outbound(), false, start); + const owner = getContextSessionOwner(principal, "root", destination, start)!; + expect(owner).toMatchObject({ kind: "stored", accountId: "slot-a", ambiguous: false }); + expect(contextSessionOwnerMatches(owner, outbound())).toBe(true); + expect(contextSessionOwnerMatches(owner, outbound("physical-b"))).toBe(false); + expect(JSON.stringify(owner)).not.toContain("physical-a"); + expect(JSON.stringify(owner)).not.toContain("accepted-a"); + }); + + test("stored ownership survives token and generation refresh for the same proven user", () => { + const issued = outbound("physical-a", userToken("user-a")); + const renewed = outbound("physical-a", userToken("user-a")); + expect(renewed.get("authorization")).not.toBe(issued.get("authorization")); + recordContextSessionOwner(principal, root(), destination, stored(), issued, false, start); + const refreshed = { ...stored(), generation: 2, accessToken: "refreshed" } as CodexAuthContext; + recordContextSessionOwner(principal, root(), destination, refreshed, renewed, false, start + 1); + const owner = getContextSessionOwner(principal, "root", destination, start + 2)!; + expect(owner.ambiguous).toBe(false); + // A stored credential is minted by the proxy for the account it selected, so either token + // of the same proven person is accepted; a different person in that workspace is not. + expect(contextSessionOwnerMatches(owner, renewed)).toBe(true); + expect(contextSessionOwnerMatches(owner, issued)).toBe(true); + expect(contextSessionOwnerMatches(owner, outbound("physical-a", userToken("user-b")))).toBe(false); + }); + + test("a userless credential cannot rebind an entry by sharing its workspace", () => { + recordContextSessionOwner(principal, root(), destination, stored(), outbound(), false, start); + recordContextSessionOwner(principal, root(), destination, stored(), + outbound("physical-a", "another-userless-token"), false, start + 1); + expect(getContextSessionOwner(principal, "root", destination, start + 1)?.ambiguous).toBe(true); + }); + + test("stored identity must match the accepted outbound account", () => { + recordContextSessionOwner(principal, root(), destination, stored(), outbound("physical-b"), false, start); + expect(getContextSessionOwner(principal, "root", destination, start)).toBeUndefined(); + recordContextSessionOwner(principal, root(), destination, stored(), outbound(null), false, start); + expect(getContextSessionOwner(principal, "root", destination, start)).toBeUndefined(); + }); + + test("caller-owned credentials remain fenced from proxy or other bearer credentials", () => { + recordContextSessionOwner(principal, root(), destination, caller, outbound(), false, start); + const owner = getContextSessionOwner(principal, "root", destination, start)!; + expect(owner.kind).toBe("caller"); + expect(contextSessionOwnerMatches(owner, outbound())).toBe(true); + expect(contextSessionOwnerMatches(owner, outbound("physical-a", "proxy-secret"))).toBe(false); + expect(contextSessionOwnerMatches(owner, outbound("physical-b"))).toBe(false); + }); + + test("only an accepted same-account model turn authorizes a rotated caller token", () => { + const issued = outbound("physical-a", userToken("user-a")); + recordContextSessionOwner(principal, root(), destination, caller, issued, false, start); + const refreshed = outbound("physical-a", userToken("user-a")); + expect(refreshed.get("authorization")).not.toBe(issued.get("authorization")); + // Same person, but this bearer has never been accepted upstream for this session. + expect(contextSessionOwnerMatches(getContextSessionOwner(principal, "root", destination, start)!, refreshed)).toBe(false); + recordContextSessionOwner(principal, root(), destination, caller, refreshed, false, start + 1); + const owner = getContextSessionOwner(principal, "root", destination, start + 1)!; + expect(owner.ambiguous).toBe(false); + expect(contextSessionOwnerMatches(owner, refreshed)).toBe(true); + // A caller bearer is not ours to reissue, so the superseded one stops being the credential. + expect(contextSessionOwnerMatches(owner, issued)).toBe(false); + expect(contextSessionOwnerMatches(owner, outbound())).toBe(false); + }); + + test("caller without physical identity allows only the exact credential", () => { + recordContextSessionOwner(principal, root(), destination, caller, outbound(null), false, start); + let owner = getContextSessionOwner(principal, "root", destination, start)!; + expect(contextSessionOwnerMatches(owner, outbound(null))).toBe(true); + recordContextSessionOwner(principal, root(), destination, caller, outbound(null, "rotated"), false, start + 1); + owner = getContextSessionOwner(principal, "root", destination, start + 1)!; + expect(owner.ambiguous).toBe(true); + expect(contextSessionOwnerMatches(owner, outbound(null, "rotated"))).toBe(false); + }); + + test("substituted Direct main is stored ownership, not caller ownership", () => { + recordContextSessionOwner(principal, root(), destination, caller, outbound(), true, start); + expect(getContextSessionOwner(principal, "root", destination, start)).toMatchObject({ kind: "stored", accountId: "__main__" }); + }); + + test("root and child model turns share the root body session lookup", () => { + recordContextSessionOwner(principal, root(), destination, stored(), outbound(), false, start); + recordContextSessionOwner(principal, new Headers({ "x-codex-parent-thread-id": "root", "session-id": "child" }), + destination, stored(), outbound(), false, start + 1); + expect(getContextSessionOwner(principal, "root", destination, start + 1)?.ambiguous).toBe(false); + expect(getContextSessionOwner(principal, "child", destination, start + 1)).toBeUndefined(); + }); + + test.each(["physical", "kind", "destination"])("conflicting %s remains ambiguous after later writes", conflict => { + recordContextSessionOwner(principal, root(), destination, stored(), outbound(), false, start); + recordContextSessionOwner(principal, root(), conflict === "destination" ? "https://other.test/codex" : destination, + conflict === "kind" ? caller : stored("slot-b", conflict === "physical" ? "physical-b" : "physical-a"), + outbound(conflict === "physical" ? "physical-b" : "physical-a"), false, start + 1); + recordContextSessionOwner(principal, root(), destination, stored(), outbound(), false, start + 2); + const owner = getContextSessionOwner(principal, "root", destination, start + 2)!; + expect(owner.ambiguous).toBe(true); + expect(contextSessionOwnerMatches(owner, outbound())).toBe(false); + expect(getContextSessionOwner(principal, "root", "https://other.test/codex", start + 2)).toBeUndefined(); + }); + + test("destination mismatch cannot bootstrap a new owner", () => { + recordContextSessionOwner(principal, root(), destination, stored(), outbound(), false, start); + expect(getContextSessionOwner(principal, "root", "https://other.test/codex", start)).toBeUndefined(); + expect(getContextSessionOwner(principal, "root", destination + "/", start)).toBeDefined(); + }); + + test("a fresh lookup detects conflict after an earlier snapshot was read", () => { + recordContextSessionOwner(principal, root(), destination, stored(), outbound(), false, start); + const oldOwner = getContextSessionOwner(principal, "root", destination, start)!; + recordContextSessionOwner(principal, root(), destination, stored("slot-b", "physical-b"), outbound("physical-b"), false, start + 1); + const currentOwner = getContextSessionOwner(principal, "root", destination, start + 1)!; + expect(oldOwner.ambiguous).toBe(false); + expect(currentOwner.ambiguous).toBe(true); + expect(contextSessionOwnerMatches(currentOwner, outbound())).toBe(false); + }); + + test("expiry and restart lose ownership instead of guessing an active account", () => { + recordContextSessionOwner(principal, root(), destination, stored(), outbound(), false, start); + expect(getContextSessionOwner(principal, "root", destination, start + 24 * 60 * 60_000)).toBeUndefined(); + recordContextSessionOwner(principal, root(), destination, stored(), outbound(), false, start); + clearContextSessionOwnersForTests(); + expect(getContextSessionOwner(principal, "root", destination, start)).toBeUndefined(); + }); + + test("LRU capacity evicts the untouched entry, preserving a recently looked-up owner", () => { + for (let i = 0; i < 2048; i++) recordContextSessionOwner(principal, root(`root-${i}`), destination, stored(), outbound(), false, start); + expect(getContextSessionOwner(principal, "root-0", destination, start + 1)).toBeDefined(); + recordContextSessionOwner(principal, root("overflow"), destination, stored(), outbound(), false, start + 2); + expect(getContextSessionOwner(principal, "root-0", destination, start + 2)).toBeDefined(); + expect(getContextSessionOwner(principal, "root-1", destination, start + 2)).toBeUndefined(); + }); + + test("byte capacity also bounds long account slots", () => { + for (let i = 0; i < 1600; i++) recordContextSessionOwner(principal, root(`root-${i}`), destination, + stored("a".repeat(512)), outbound(), false, start); + expect(getContextSessionOwner(principal, "root-0", destination, start)).toBeUndefined(); + expect(getContextSessionOwner(principal, "root-1599", destination, start)).toBeDefined(); + }); + + test("invalid and oversized identifiers never create ownership", () => { + for (const id of ["", "bad root", "a".repeat(513)]) { + recordContextSessionOwner(principal, root(id), destination, stored(), outbound(), false, start); + expect(getContextSessionOwner(principal, id, destination, start)).toBeUndefined(); + } + recordContextSessionOwner(principal, new Headers({ "x-codex-parent-thread-id": "bad root", "session-id": "root" }), + destination, stored(), outbound(), false, start); + expect(getContextSessionOwner(principal, "root", destination, start)).toBeUndefined(); + recordContextSessionOwner(principal, root(), "https://x.test/" + "a".repeat(4096), stored(), outbound(), false, start); + expect(getContextSessionOwner(principal, "root", destination, start)).toBeUndefined(); + }); +}); diff --git a/tests/codex-integration/codex-inject-integration.test.ts b/tests/codex-integration/codex-inject-integration.test.ts index fbbc2af457..eb981fe214 100644 --- a/tests/codex-integration/codex-inject-integration.test.ts +++ b/tests/codex-integration/codex-inject-integration.test.ts @@ -233,7 +233,8 @@ describe("injectCodexConfig integration (Design B)", () => { expect(value.before.state).toMatchObject({nativeGeneration:0,currentTxId:null}); expect(value.after.state).toEqual(value.before.state); } - for (const artifact of Object.values(value.result.artifacts)) { + expect(value.result.artifacts.config).toMatchObject({state:"failed",changed:false}); + for (const artifact of [value.result.artifacts.catalog, value.result.artifacts.history]) { expect(artifact).toMatchObject({state:"skipped",changed:false}); } expect(readFileSync(join(codexHome,"config.toml"),"utf8")).toBe(original); diff --git a/tests/codex-integration/codex-inject.test.ts b/tests/codex-integration/codex-inject.test.ts index efdac8d15b..01518fa445 100644 --- a/tests/codex-integration/codex-inject.test.ts +++ b/tests/codex-integration/codex-inject.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test"; import { applyEol, buildOpenaiBaseUrlLine, + buildRealtimeWsBaseUrlLine, buildProfileFile, buildProviderTableBlock, chooseCatalogPathForInjection, @@ -14,7 +15,7 @@ import { stripRootContextWindowOverrides, standaloneCodexRoutingTarget, } from "../../src/codex/inject"; -import { stripJournaledOpenaiBaseUrl } from "../../src/codex/injected-marker"; +import { OCX_SECTION_MARKER, stripJournaledOpenaiBaseUrl } from "../../src/codex/injected-marker"; import { MANAGED_AGENTS_TABLE_MARKER, MANAGED_SUBAGENT_DEFAULT_MARKER, @@ -738,3 +739,33 @@ describe("EOL boundary helpers (Windows CRLF configs)", () => { expect(applyEol(crlf, "\r\n")).toBe(crlf); }); }); + +test('managed injection is idempotent and retains every unrelated value',()=>{ + const source=`model = "gpt-6-astra"\n${OCX_SECTION_MARKER}\nopenai_base_url = "http://127.0.0.1:10100/v1"\nservice_tier = "fast"\n[features]\ncontext_management.experimental_mode = true\n[features.multi_agent_v2]\nenabled = true\n`; + const target={baseUrl:'http://127.0.0.1:10100/v1',requiresAdmissionToken:false,tokenEnv:'OPENCODEX_API_AUTH_TOKEN' as const}; + const result=setRootOpenaiBaseUrl(source,target); + expect(result.keptUserBaseUrl).toBe(false); + expect(result.content).toBe(source.replace('10100/v1','10100/backend-api/codex')); + expect(setRootOpenaiBaseUrl(result.content,target).content).toBe(result.content); + expect(buildRealtimeWsBaseUrlLine(target)).toContain('10100/v1'); + expect(setRootOpenaiBaseUrl(source,10100).content).toBe(result.content); +}); +test('feature disabled and user-owned routing remain intact',()=>{ + const source='openai_base_url = "http://127.0.0.1:10100/v1"\n[features]\ncontext_management.experimental_mode = true\n'; + expect(setRootOpenaiBaseUrl(source,10100)).toEqual({content:source,keptUserBaseUrl:true}); + const managed=`${OCX_SECTION_MARKER}\nopenai_base_url = "http://127.0.0.1:10100/v1"\n[features]\ncontext_management.experimental_mode = false\n`; + expect(setRootOpenaiBaseUrl(managed,10100).content).toBe(managed); +}); + + +test("malformed TOML preserves user routing and does not enable context injection", () => { + const target = { baseUrl: "http://127.0.0.1:10100/v1", requiresAdmissionToken: false, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" as const }; + for (const malformed of ['model = "unterminated', '[features]\ncontext_management.experimental_mode = true\nbroken = [']) { + const userOwned = `openai_base_url = "https://example.invalid/v1"\n${malformed}\n`; + const managed = `${OCX_SECTION_MARKER}\nopenai_base_url = "http://127.0.0.1:10100/v1"\n${malformed}\n`; + for (const inject of [(source: string) => setRootOpenaiBaseUrl(source, 10100), (source: string) => setRootOpenaiBaseUrl(source, target)]) { + expect(inject(userOwned)).toEqual({ content: userOwned, keptUserBaseUrl: true }); + expect(inject(managed)).toEqual({ content: managed, keptUserBaseUrl: false }); + } + } +}); diff --git a/tests/codex-integration/codex-journal.test.ts b/tests/codex-integration/codex-journal.test.ts index 23989a2a5b..efb6faeb32 100644 --- a/tests/codex-integration/codex-journal.test.ts +++ b/tests/codex-integration/codex-journal.test.ts @@ -168,7 +168,8 @@ describe("codex-journal", () => { const out = JSON.parse(r.stdout); expect(out.result.success).toBe(false); expect(out.result.message).toContain("journal recovery was not verified"); - for (const artifact of Object.values(out.result.artifacts)) { + expect(out.result.artifacts.config).toMatchObject({ state: "failed", changed: false }); + for (const artifact of [out.result.artifacts.catalog, out.result.artifacts.history]) { expect(artifact).toMatchObject({ state: "skipped", changed: false }); } expect(out.config).toBe(edited); @@ -532,6 +533,7 @@ describe("codex-journal", () => { expect(out.result.message).toContain("could not be safely removed"); expect(out.result.message).toContain("orphaned managed subagent default marker"); expect(out.after).toEqual(out.before); + expect(out.result.artifacts.config.state).toBe("failed"); const after = readFileSync(join(testDir, "config.toml"), "utf8"); expect(after).toContain("openai_base_url"); expect(after).toContain("# Managed by opencodex: native subagent default"); diff --git a/tests/codex-integration/context-compat.test.ts b/tests/codex-integration/context-compat.test.ts new file mode 100644 index 0000000000..a0e19f4c60 --- /dev/null +++ b/tests/codex-integration/context-compat.test.ts @@ -0,0 +1,22 @@ +import { describe, test, expect } from "bun:test"; +import { codexCompatibleUrl, contextEndpoint, contextCompatibleBaseLine } from "../../src/codex/context-compat"; + +describe("route and config isolation", () => { + test("aliases data-plane routes and preserves query; never aliases management", () => { + for (const route of ["responses", "responses/compact", "models", "alpha/search", "live", "realtime/calls"]) { + expect(codexCompatibleUrl(`http://127.0.0.1:10100/backend-api/codex/${route}?a=1`).pathname).toBe(`/v1/${route}`); + } + expect(codexCompatibleUrl("http://127.0.0.1:10100/v1/responses?a=1").href).toBe("http://127.0.0.1:10100/v1/responses?a=1"); + expect(codexCompatibleUrl("http://127.0.0.1:10100/backend-api/codex/api/config").pathname).toBe("/v1/api/config"); + expect(contextEndpoint("/v1/alpha/notes/v2/write_file")).toBe("alpha/notes/v2/write_file"); + expect(contextEndpoint("/v1/alpha/notes/v2/delete_file")).toBeUndefined(); + expect(contextEndpoint("/v1/alpha/notes/v2/../write_file")).toBeUndefined(); + }); + test("opt-in changes only the built-in loopback base URL", () => { + const line='openai_base_url = "http://127.0.0.1:10100/v1"'; + expect(contextCompatibleBaseLine('[features]\ncontext_management.experimental_mode = true\n',line)).toBe('openai_base_url = "http://127.0.0.1:10100/backend-api/codex"'); + for(const content of ['','[features]\ncontext_management.experimental_mode = false\n']) expect(contextCompatibleBaseLine(content,line)).toBe(line); + const remote='openai_base_url = "https://example.com/v1"'; + expect(contextCompatibleBaseLine('[features.context_management]\nexperimental_mode=true\n',remote)).toBe(remote); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b758e9e9d8..b56ab03905 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -113,6 +113,7 @@ "catalog-vision-sidecar-modalities.test.ts": "codex-integration", "catalog-zero-credit-picker.test.ts": "codex-integration", "chat-completions-endpoint.test.ts": "responses", + "chat-conversation-affinity.test.ts": "responses", "chat-json-sse-fallback.test.ts": "responses", "chat-refusal.test.ts": "responses", "chatgpt-device-auth.test.ts": "oauth", @@ -253,6 +254,7 @@ "codex-cli-update-zero-effect.test.ts": "codex-integration", "codex-composed-acceptance.test.ts": "codex-integration", "codex-config-generation.test.ts": "codex-integration", + "codex-context-owner.test.ts": "codex-integration", "codex-convergence-account-selectors.test.ts": "codex-integration", "codex-convergence-contract.test.ts": "codex-integration", "codex-cooldown-recovery.test.ts": "codex-integration", @@ -370,6 +372,9 @@ "consume-for-inspection-cancel.test.ts": "server", "container-bootstrap.test.ts": "service", "context-cap-unknown-window.test.ts": "providers", + "context-compat.test.ts": "codex-integration", + "context-history-ownership.test.ts": "server", + "context-history.test.ts": "server", "continuation-dedup.test.ts": "responses", "core-lab-boundary.test.ts": "lab", "cost-cap-unknown-evidence.test.ts": "usage", diff --git a/tests/providers/devin-cli-adapter.test.ts b/tests/providers/devin-cli-adapter.test.ts index ea7953c52e..565cd08478 100644 --- a/tests/providers/devin-cli-adapter.test.ts +++ b/tests/providers/devin-cli-adapter.test.ts @@ -1,3 +1,4 @@ +import { join } from "node:path"; import { describe, expect, test } from "bun:test"; import { ACP_SESSION_NEW_ID, @@ -213,13 +214,20 @@ describe("devin cli discovery", () => { test("known install paths are preferred over a shadowed PATH entry, and absence is undefined", () => { const previous = process.env[DEVIN_CLI_BIN_ENV]; + const previousPath = process.env.PATH; delete process.env[DEVIN_CLI_BIN_ENV]; + process.env.PATH = join("/shadow", "bin"); try { - const only = (p: string) => p === "/home/u/.local/bin/devin"; - expect(resolveDevinCliBinary({ exists: only, home: "/home/u", useCache: false })).toBe("/home/u/.local/bin/devin"); + const expected = join("/home/u", ".local", "bin", "devin"); + const shadowed = join("/shadow", "bin", "devin"); + const exists = (p: string) => p === expected || p === shadowed; + expect(resolveDevinCliBinary({ exists, home: "/home/u", useCache: false })).toBe(expected); + expect(resolveDevinCliBinary({ exists: p => p === shadowed, home: "/home/u", useCache: false })).toBe(shadowed); expect(resolveDevinCliBinary({ exists: () => false, home: "/home/u", useCache: false })).toBeUndefined(); } finally { if (previous !== undefined) process.env[DEVIN_CLI_BIN_ENV] = previous; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; } }); }); diff --git a/tests/providers/devin-cli-login.test.ts b/tests/providers/devin-cli-login.test.ts index a3784e05d3..ba82c033f1 100644 --- a/tests/providers/devin-cli-login.test.ts +++ b/tests/providers/devin-cli-login.test.ts @@ -46,7 +46,7 @@ describe("devin-cli credentials path", () => { test("Windows uses APPDATA", () => { expect(devinCliCredentialsPath({ APPDATA: "C:\\Users\\u\\AppData\\Roaming" }, "win32")) - .toContain("devin"); + .toBe("C:\\Users\\u\\AppData\\Roaming\\devin\\credentials.toml"); }); test("the override must be absolute", () => { diff --git a/tests/responses/chat-conversation-affinity.test.ts b/tests/responses/chat-conversation-affinity.test.ts new file mode 100644 index 0000000000..97c599b308 --- /dev/null +++ b/tests/responses/chat-conversation-affinity.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleChatCompletions } from "../../src/server/chat-completions"; +import type { OcxConfig } from "../../src/types"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// #3433 transport contract only: these client-assigned fixture IDs are not a capture of Hermes. +const originalFetch = globalThis.fetch; +const identityHeaders = ["session_id", "session-id", "thread-id", "x-codex-parent-thread-id"]; +let isolated: IsolatedCodexHome; +let home: string; +let previousHome: string | undefined; + +beforeEach(() => { + isolated = installIsolatedCodexHome("ocx-chat-identity-"); + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-chat-identity-config-")); + process.env.OPENCODEX_HOME = home; +}); +afterEach(() => { + globalThis.fetch = originalFetch; + isolated.restore(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +interface Capture { headers: Headers; body: Record } +function mockNativeWire(): Capture[] { + const seen: Capture[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toBe("https://chatgpt.com/backend-api/codex/responses"); + seen.push({ headers: new Headers(init?.headers), body: JSON.parse(String(init?.body)) }); + return Response.json({ id: "resp_chat_identity", object: "response", status: "completed", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] }], + usage: { input_tokens: 10, output_tokens: 1, total_tokens: 11, input_tokens_details: { cached_tokens: 0 } } }); + }) as typeof fetch; + return seen; +} + +async function chat(headers: Record, cacheKey: string | undefined, continued: boolean) { + const token = fakeChatGptJwt({ chatgpt_account_id: "fixture-chat-caller", exp: Math.floor(Date.now() / 1000) + 86400 }); + const config = { defaultProvider: "openai", openaiProviderTierVersion: 2, providers: { + openai: { adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", + baseUrl: "https://chatgpt.com/backend-api/codex", models: ["gpt-5.6-luna"] }, + } } as OcxConfig; + const messages = [{ role: "system", content: "A shared prefix is not a conversation identity." }, + { role: "user", content: "first turn" }, + ...(continued ? [{ role: "assistant", content: "first answer" }, { role: "user", content: "next turn" }] : [])]; + const req = new Request("http://localhost/v1/chat/completions", { method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}`, + "chatgpt-account-id": "fixture-chat-caller", ...headers }, + body: JSON.stringify({ model: "openai/gpt-5.6-luna", messages, stream: false, reasoning_effort: "medium", + ...(cacheKey === undefined ? {} : { prompt_cache_key: cacheKey }) }), + }); + const response = await handleChatCompletions(req, config, { model: "", provider: "" }); + await response.text(); + expect(response.status).toBe(200); +} + +describe("Chat conversation identity at canonical Responses outbound boundary", () => { + for (const keyPresent of [false, true]) { + for (const shape of ["underscore", "hyphen-pair"] as const) { + test(`${shape}, key=${keyPresent}: A/A/B retains caller identity and independent request IDs`, async () => { + const seen = mockNativeWire(); + const key = keyPresent ? "shared-cache-cohort" : undefined; + for (const [index, conversation] of ["a", "a", "b"].entries()) { + const identity: Record = shape === "underscore" + ? { session_id: `conversation-${conversation}` } + : { "session-id": `session-${conversation}`, "thread-id": `thread-${conversation}` }; + await chat({ ...identity, "x-client-request-id": `request-${index}` }, key, index === 1); + expect(seen).toHaveLength(index + 1); + const wire = seen[index]!; + for (const name of identityHeaders) expect(wire.headers.get(name)).toBe(identity[name] ?? null); + expect(wire.headers.get("x-client-request-id")).toBe(`request-${index}`); + expect(wire.body.prompt_cache_key).toBe(key); + expect(Object.hasOwn(wire.body, "prompt_cache_key")).toBe(keyPresent); + expect(wire.body.reasoning).toMatchObject({ effort: "medium" }); + } + expect(JSON.stringify(seen[1]!.body.input).length).toBeGreaterThan(JSON.stringify(seen[0]!.body.input).length); + expect(seen[0]!.body.input).toEqual(seen[2]!.body.input); + }); + } + + test(`identity absent, key=${keyPresent}: no session is synthesized`, async () => { + const seen = mockNativeWire(); + for (const continued of [false, true]) await chat({}, keyPresent ? "shared-cache-cohort" : undefined, continued); + expect(seen).toHaveLength(2); + for (const wire of seen) { + for (const name of identityHeaders) expect(wire.headers.has(name)).toBe(false); + expect(wire.body.prompt_cache_key).toBe(keyPresent ? "shared-cache-cohort" : undefined); + } + }); + } +}); diff --git a/tests/server/context-history-ownership.test.ts b/tests/server/context-history-ownership.test.ts new file mode 100644 index 0000000000..3a1df4253f --- /dev/null +++ b/tests/server/context-history-ownership.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { handleResponses } from "../../src/server/responses"; +import { handleContextHistory } from "../../src/server/context-history"; +import { tryAdmitTurn } from "../../src/server/lifecycle"; +import type { DataPlaneAdmission } from "../../src/server/auth-cors"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { clearAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import { clearAccountQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; +import { clearCodexUpstreamHealth, clearThreadAccountMap, resetCodexRoutingForManualSelection } from "../../src/codex/routing"; +import { clearContextSessionOwnersForTests, getContextSessionOwner } from "../../src/codex/context-owner"; +import { resetContextRelayActivationForTests } from "../../src/codex/context-compat"; + +const principal = "principal-a"; +const keyAdmission: DataPlaneAdmission = { kind: "configured", keyId: "k1", source: "dedicated", contextPrincipalId: principal }; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const destination = "https://chatgpt.com/backend-api/codex"; +const originalFetch = globalThis.fetch; +let previousHome: string | undefined; +let previousCodexHome: string | undefined; +let home = ""; +let sent: Array<{ url: string; headers: Headers }> = []; +let failFirstAccount: string | undefined; + +function install(id: string, owner: string, token = `${id}-token`): void { + saveCodexAccountCredential(id, { accessToken: token, refreshToken: `${id}-refresh`, + chatgptAccountId: owner, expiresAt: Date.now() + 3600_000 }); + setAccountQuotaFromParsed(id, { weeklyPercent: 20 }); +} + +function config(): OcxConfig { + return { port: 0, defaultProvider: "openai", activeCodexAccountId: "pool-b", + autoSwitchThreshold: 95, accountPoolStrategy: "fill-first", emptyCompletionRetry: false, + codexAccountNamespaces: { side: "pool-a" }, + codexAccounts: [{ id: "pool-a", isMain: false }, { id: "pool-b", isMain: false }], + providers: { openai: { adapter: "openai-responses", baseUrl: destination, + authMode: "forward", codexAccountMode: "pool" } } }; +} + +function requestHeaders(session: string, bearer = "caller-native-token", account = "caller-account"): Headers { + return new Headers({ "content-type": "application/json", authorization: `Bearer ${bearer}`, + "chatgpt-account-id": account, "session-id": session, "thread-id": session }); +} + +async function model(cfg: OcxConfig, session: string, name = "side/gpt-5.5", headers = requestHeaders(session), admission: DataPlaneAdmission = keyAdmission): Promise { + const lease = tryAdmitTurn(); expect(lease).not.toBeNull(); + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { method: "POST", headers, + body: JSON.stringify({ model: name, input: "hello", stream: false }) }), cfg, + { model: "", provider: "" }, { turnAdmissionLease: lease!, admission }); + const text = await response.text(); + return new Response(text, { status: response.status, headers: response.headers }); + } finally { lease?.release(); } +} + +async function notes(cfg: OcxConfig, session: string, headers = requestHeaders(session), admission: DataPlaneAdmission = keyAdmission): Promise { + const lease = tryAdmitTurn(); expect(lease).not.toBeNull(); + try { + return await handleContextHistory(new Request("http://localhost/v1/alpha/notes/v2/read_file", { + method: "POST", headers, body: JSON.stringify({ context: { session_id: session } }), + }), cfg, { model: "context_history", provider: "" }, "alpha/notes/v2/read_file", lease!, admission); + } finally { lease?.release(); } +} + +function setContextFeature(enabled: boolean): void { + writeFileSync(join(home, "config.toml"), enabled ? "[features]\ncontext_management.experimental_mode = true\n" : "model = \"gpt-5.5\"\n"); + resetContextRelayActivationForTests(); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; previousCodexHome = process.env.CODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-context-owner-")); + process.env.OPENCODEX_HOME = home; process.env.CODEX_HOME = home; + setContextFeature(true); + clearContextSessionOwnersForTests(); clearAccountQuota(); clearThreadAccountMap(); clearCodexUpstreamHealth(); + for (const id of ["pool-a", "pool-b", "__main__"]) clearAccountNeedsReauth(id); + install("pool-a", "physical-a"); install("pool-b", "physical-b"); + sent = []; failFirstAccount = undefined; + globalThis.fetch = Object.assign(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); const headers = new Headers(init?.headers); sent.push({ url, headers }); + if (url === `${destination}/responses`) { + if (headers.get("chatgpt-account-id") === failFirstAccount) { + failFirstAccount = undefined; + return Response.json({ error: { message: "usage limit reached", type: "usage_limit_reached" } }, { status: 429 }); + } + return Response.json({ id: "response-owned", object: "response", status: "completed", + output: [{ id: "message-owned", type: "message", role: "assistant", status: "completed", + content: [{ type: "output_text", text: "ready", annotations: [] }] }], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } }); + } + if (url === `${destination}/alpha/notes/v2/read_file`) return Response.json({ value: "same account" }); + throw new Error(`Unexpected test request: ${url}`); + }, { preconnect: originalFetch.preconnect }); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearContextSessionOwnersForTests(); clearAccountQuota(); clearThreadAccountMap(); clearCodexUpstreamHealth(); + removeTreeWithRetry(home); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = previousCodexHome; +}); + +test("successful explicit account A owns context while active B remains selected", async () => { + const cfg = config(); resetCodexRoutingForManualSelection("pool-b"); + const response = await model(cfg, "root-explicit"); + expect(response.status).toBe(200); + expect(getContextSessionOwner(principal, "root-explicit", destination)).toMatchObject({ kind: "stored", accountId: "pool-a", ambiguous: false }); + expect((await notes(cfg, "root-explicit")).status).toBe(200); + expect(sent.map(row => row.headers.get("chatgpt-account-id"))).toEqual(["physical-a", "physical-a"]); + expect(cfg.activeCodexAccountId).toBe("pool-b"); + // These synthetic tokens carry no user claim, so a replaced credential is a different owner + // fingerprint: history waits for an accepted model turn instead of trusting the workspace id. + install("pool-a", "physical-a", "renewed-a-token"); + expect((await notes(cfg, "root-explicit")).status).toBe(409); + install("pool-a", "replacement-a"); + expect((await notes(cfg, "root-explicit")).status).toBe(409); + clearContextSessionOwnersForTests(); + expect((await notes(cfg, "root-explicit")).status).toBe(409); + expect(sent).toHaveLength(2); +}); + +test("with the experimental feature off no relay state is built and no endpoint answers", async () => { + const cfg = config(); resetCodexRoutingForManualSelection("pool-b"); + setContextFeature(false); + expect((await model(cfg, "root-disabled")).status).toBe(200); + // The model turn still serves normally; what it must not do is build ownership for a relay + // that is switched off, and the endpoints must not answer. + expect(getContextSessionOwner(principal, "root-disabled", destination)).toBeUndefined(); + expect((await notes(cfg, "root-disabled")).status).toBe(404); + expect(sent.filter(row => row.url.includes("/alpha/"))).toHaveLength(0); +}); + +test("failed account A then successful B records only the serving account", async () => { + const cfg = config(); cfg.activeCodexAccountId = "pool-a"; + resetCodexRoutingForManualSelection("pool-a"); failFirstAccount = "physical-a"; + const response = await model(cfg, "root-retry", "gpt-5.5"); + expect(response.status).toBe(200); + expect(sent.map(row => row.headers.get("chatgpt-account-id"))).toEqual(["physical-a", "physical-b"]); + expect(getContextSessionOwner(principal, "root-retry", destination)).toMatchObject({ kind: "stored", accountId: "pool-b", ambiguous: false }); + expect((await notes(cfg, "root-retry")).status).toBe(200); + expect(sent.at(-1)!.headers.get("chatgpt-account-id")).toBe("physical-b"); +}); + +test("Direct caller context never borrows stored login or proxy admission", async () => { + const cfg = config(); cfg.providers.openai.codexAccountMode = "direct"; + expect((await model(cfg, "root-caller", "gpt-5.5")).status).toBe(200); + expect(getContextSessionOwner(principal, "root-caller", destination)?.kind).toBe("caller"); + expect((await notes(cfg, "root-caller")).status).toBe(200); + expect(sent.map(row => row.headers.get("authorization"))).toEqual(["Bearer caller-native-token", "Bearer caller-native-token"]); + const admission = { kind: "environment", source: "bearer", contextPrincipalId: principal } as const; + expect((await notes(cfg, "root-caller", requestHeaders("root-caller", "ocx_data_test_key"), admission)).status).toBe(409); + expect(sent).toHaveLength(2); +}); + +test("Direct proxy-bearer model and notes use stored main without leaking the proxy secret", async () => { + const cfg = config(); cfg.providers.openai.codexAccountMode = "direct"; + const token = `header.${Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 })).toString("base64url")}.signature`; + writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { access_token: token, account_id: "physical-main" } })); + const admission = { kind: "environment", source: "bearer", contextPrincipalId: principal } as const; + const headers = requestHeaders("root-proxy", "ocx_data_test_key", "untrusted-account"); + expect((await model(cfg, "root-proxy", "gpt-5.5", headers, admission)).status).toBe(200); + expect(getContextSessionOwner(principal, "root-proxy", destination)).toMatchObject({ kind: "stored", accountId: "__main__" }); + expect((await notes(cfg, "root-proxy", headers, admission)).status).toBe(200); + expect(sent.map(row => row.headers.get("authorization"))).toEqual([`Bearer ${token}`, `Bearer ${token}`]); + expect(sent.every(row => row.headers.get("chatgpt-account-id") === "physical-main")).toBe(true); +}); diff --git a/tests/server/context-history.test.ts b/tests/server/context-history.test.ts new file mode 100644 index 0000000000..b44c144ab9 --- /dev/null +++ b/tests/server/context-history.test.ts @@ -0,0 +1,408 @@ +// mock.module replacements require file isolation (bun test --isolate). +import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resetContextRelayActivationForTests } from "../../src/codex/context-compat"; +import type { CodexAuthContext } from "../../src/codex/auth-context"; +import { recordContextSessionOwner, clearContextSessionOwnersForTests } from "../../src/codex/context-owner"; +import type { DataPlaneAdmission } from "../../src/server/auth-cors"; +import type { OcxConfig } from "../../src/types"; +import type { RequestLogContext } from "../../src/server/request-log"; + +type Selection = { headers: Headers; mode: string; options: { modelId: string; admission?: DataPlaneAdmission; substituteMainCredentialForDirect?: boolean; accountId?: string; requestScopedMainCredential?: boolean } }; +let selection: Selection | undefined; +const config: OcxConfig = { port: 0, defaultProvider: "openai", providers: {} }; +const logContext = (): RequestLogContext => ({ model: "context_history", provider: "" }); + +// The relay only exists while Codex own config opts in, so these cases need a home that does. +const codexHome = mkdtempSync(join(tmpdir(), "ocx-context-flag-")); +const codexConfigPath = join(codexHome, "config.toml"); +const previousCodexHome = process.env.CODEX_HOME; +process.env.CODEX_HOME = codexHome; +function setContextFeature(enabled: boolean): void { + writeFileSync(codexConfigPath, enabled ? "[features]\ncontext_management.experimental_mode = true\n" : "model = \"gpt-5.5\"\n"); + resetContextRelayActivationForTests(); +} +setContextFeature(true); +let materialized: { config: OcxConfig; modelId: string } | undefined; +let materializationError: Error | undefined; +let materializationOptions: { admission?: DataPlaneAdmission; substituteMainCredential?: boolean } | undefined; +let outgoingBearer = "test-only"; +let outgoingAccount = "test-only"; +let accountMode = "pool"; +let validated=0; +let probe=false;let released=0;let directError=false;let duringSelection:(()=>void)|undefined; +const errors = { + CodexAccountCooldownError: class extends Error {}, + CodexMainSubstitutionUnavailableError: class extends Error {}, + CodexDirectAuthenticationError: class extends Error {}, + CodexAuthContextError: class extends Error {}, + CodexMainProfileDrainingError: class extends Error {}, + CodexPoolAuthenticationError: class extends Error {}, + CodexThreadAffinityExpiredError: class extends Error {}, +}; +mock.module("../../src/codex/auth-context",()=>({ + ...errors, + resolveCodexAuthContext:async(headers: Headers, _config: OcxConfig, mode: string, options: Selection["options"])=>{selection={headers,mode,options};duringSelection?.();if(directError)throw new errors.CodexDirectAuthenticationError();return {kind:"pool",accountId:"test-account",...(probe?{probeLeaseId:"test-probe"}:{})};}, + isCodexAuthContextUsable:()=>true, + releaseCodexAuthContextProbeLease:()=>{released++;}, + materializeCodexUpstreamAuth: (_headers: Headers, _auth: unknown, options: { config: OcxConfig; modelId: string; admission?: DataPlaneAdmission; substituteMainCredential?: boolean }) => { + materializationOptions = options; + materialized = { config: options.config, modelId: options.modelId }; + if (materializationError) throw materializationError; + return new Headers({ authorization: `Bearer ${outgoingBearer}`, "chatgpt-account-id": outgoingAccount }); + }, + headersForCodexAuthContext:(_headers: Headers, _auth: unknown, selectedConfig: OcxConfig, modelId: string) => { + materialized = { config: selectedConfig, modelId }; + if (materializationError) throw materializationError; + return new Headers({ authorization: "Bearer test-only", "chatgpt-account-id": outgoingAccount }); + }, + cooldownErrorResponse:()=>new Response("cooldown",{status:429}), + codexMainProfileDrainingResponse:()=>new Response("draining",{status:503}), +})); +const realRouting = await import("../../src/codex/routing"); +mock.module("../../src/codex/routing",()=>({...realRouting, formatCodexProviderForLog:()=>"openai-test"})); +mock.module("../../src/providers/openai-sidecar",()=>({listOpenAiForwardSidecarCandidates:()=>[{providerName:"openai",provider:{baseUrl:"https://chatgpt.com/backend-api/codex"},accountMode}]})); +class ForwardAdmissionCredentialError extends Error {} +mock.module("../../src/server/auth-cors",()=>({ForwardAdmissionCredentialError,resolveContextPrincipal:(_r:Request,_c:OcxConfig,a?:{contextPrincipalId?:string})=>a?.contextPrincipalId,validateForwardAdmissionCredential:(h:Headers)=>{validated++;if(!h.has("authorization") || h.get("authorization") === "Bearer ocx_data_test_admission")throw new ForwardAdmissionCredentialError("test credential missing");}})); +mock.module("../../src/server/responses",()=>({codexLogAccountId:()=>"test",decodeRequestErrorResponse:()=>new Response("invalid json",{status:400})})); +mock.module("../../src/server/lifecycle",()=>({codexAccountSelectionForTurn:()=>()=>undefined})); +const { handleContextHistory, contextSelectionHeaders } = await import("../../src/server/context-history"); +const destination = "https://chatgpt.com/backend-api/codex"; +let issuedTokens = 0; +// A real ChatGPT credential carries a stable user claim; ownership continuity across a refresh +// depends on it, so a test that models a refresh has to issue distinct tokens for one user. +function userToken(user: string): string { + const payload = Buffer.from(JSON.stringify({ + iat: ++issuedTokens, "https://api.openai.com/auth": { chatgpt_user_id: user }, + }), "utf8").toString("base64url"); + return `header.${payload}.signature`; +} +function seedOwner(sessionId: string, kind: "stored" | "caller" = "stored", account = "test-only", + now?: number, token = "test-only") { + const auth = kind === "caller" ? { kind: "main", accountId: null } : { + kind: "pool", accountId: "test-account", chatgptAccountId: account, + accessToken: "test-only", generation: 1, writerGeneration: 0, + }; + recordContextSessionOwner("principal-a", new Headers({ "session-id": sessionId }), destination, + auth as CodexAuthContext, new Headers({ authorization: `Bearer ${token}`, "chatgpt-account-id": account }), false, now); +} +const originalFetch=globalThis.fetch; +function setFetch(handler: (input: string | URL | Request, init?: RequestInit) => Promise): void { + globalThis.fetch = Object.assign(handler, { preconnect: originalFetch.preconnect }); +} +afterAll(()=>{if(previousCodexHome===undefined)delete process.env.CODEX_HOME;else process.env.CODEX_HOME=previousCodexHome;resetContextRelayActivationForTests();clearContextSessionOwnersForTests();globalThis.fetch=originalFetch;mock.restore();}); +beforeEach(()=>{clearContextSessionOwnersForTests();for (const id of ["root", "root-test", "s"]) seedOwner(id);outgoingAccount="test-only";globalThis.fetch=originalFetch;materialized=undefined;materializationError=undefined;selection=undefined;validated=0;materializationOptions=undefined;outgoingBearer="test-only";accountMode="pool";probe=false;released=0;directError=false;duringSelection=undefined;setContextFeature(true);}); + +describe("context relay contract",()=>{ + test("selects root shared lane but sends original body and protocol headers",async()=>{ + const body={context:{session_id:"root-test",current_agent_name:"/root"},encrypted_arguments:"opaque+==",unknown:{future:true}}; + let sent: RequestInit & { url: string } = { url: "" }; + setFetch(async(url: string | URL | Request, opts?: RequestInit)=>{sent={url:String(url),...opts};return new Response('{"ok":true}',{headers:{"content-type":"application/json","x-request-id":"req-test"}});}); + const req=new Request("http://127.0.0.1/backend-api/codex/alpha/notes/v2/write_file",{method:"POST",headers:{authorization:"Bearer incoming","content-type":"application/json","x-openai-encrypted-tool-arguments":"true","x-openai-tool-output-truncation-policy":"{\"mode\":\"tokens\"}"},body:JSON.stringify(body)}); + const r=await handleContextHistory(req, config, logContext(), "alpha/notes/v2/write_file", undefined, keyAdmission); + expect(r.status).toBe(200);expect(r.headers.get("x-request-id")).toBe("req-test"); + expect(materialized).toEqual({ config, modelId: "context_history" }); + expect(validated).toBeGreaterThanOrEqual(1);expect(selection?.mode).toBe("pool");expect(selection?.options.modelId).toBe("context_history"); + expect(selection?.headers.get("session-id")).toBe("root-test");expect(selection?.headers.get("thread-id")).toBe("root-test");expect(selection?.headers.get("x-codex-parent-thread-id")).toBeNull(); + expect(JSON.parse(String(sent.body))).toEqual(body);expect(new Headers(sent.headers).has("session-id")).toBe(false); + expect(new Headers(sent.headers).get("x-openai-encrypted-tool-arguments")).toBe("true");expect(sent.redirect).toBe("manual"); + }); + test("propagates errors without retrying or hiding a write failure",async()=>{ + let calls=0; + setFetch(async()=>{calls++;return new Response('{"error":"feature denied"}',{status:403,headers:{"retry-after":"7"}});}); + const req=new Request("http://localhost/v1/alpha/notes/v2/write_file",{method:"POST",headers:{authorization:"Bearer test","content-type":"application/json"},body:JSON.stringify({context:{session_id:"s"}})}); + const r=await handleContextHistory(req, config, logContext(), "alpha/notes/v2/write_file", undefined, keyAdmission); + expect(r.status).toBe(403);expect(await r.text()).toBe('{"error":"feature denied"}');expect(r.headers.get("retry-after")).toBe("7");expect(calls).toBe(1); + }); + test("rejects malformed context before any account selection",async()=>{ + const req=new Request("http://localhost/v1/alpha/notes/v2/write_file",{method:"POST",headers:{authorization:"Bearer test","content-type":"application/json"},body:"{}"}); + expect((await handleContextHistory(req, config, logContext(), "alpha/notes/v2/write_file", undefined, keyAdmission)).status).toBe(400);expect(selection).toBeUndefined(); + expect(contextSelectionHeaders(new Headers({"x-codex-parent-thread-id":"parent"}),"s").get("session-id")).toBeNull(); + }); +}); + +test("context traffic releases quota probe and maps missing direct auth",async()=>{ + const request=()=>new Request("http://localhost/v1/alpha/notes/v2/read_file",{method:"POST",headers:{authorization:"Bearer test","content-type":"application/json"},body:JSON.stringify({context:{session_id:"root-test"}})}); + probe=true; + expect((await handleContextHistory(request(), config, logContext(), "alpha/notes/v2/read_file", undefined, keyAdmission)).status).toBe(503); + expect(released).toBe(1); + probe=false;directError=true; + expect((await handleContextHistory(request(), config, logContext(), "alpha/notes/v2/read_file", undefined, keyAdmission)).status).toBe(401); +}); +test("invalid header characters are rejected before selection",async()=>{ + const req=new Request("http://localhost/v1/alpha/notes/v2/read_file",{method:"POST",headers:{authorization:"Bearer test","content-type":"application/json"},body:JSON.stringify({context:{session_id:"bad\nvalue"}})}); + expect((await handleContextHistory(req, config, logContext(), "alpha/notes/v2/read_file", undefined, keyAdmission)).status).toBe(400); + expect(selection).toBeUndefined(); +}); + +function contextRequest(body: string, headers: HeadersInit = { authorization: "Bearer test" }, signal?: AbortSignal): Request { + return new Request("http://localhost/v1/alpha/notes/v2/read_file", { method: "POST", headers, body, signal }); +} + +test("missing admission credential fails before parsing or selecting an account", async () => { + const response = await handleContextHistory(contextRequest("not json", {}), config, logContext(), "alpha/notes/v2/read_file", undefined, keyAdmission); + expect(response.status).toBe(401); + expect(selection).toBeUndefined(); +}); + +test("malformed JSON and non-string or oversized session IDs fail before selection", async () => { + for (const body of ["not json", "null", JSON.stringify({ context: { session_id: 42 } }), JSON.stringify({ context: { session_id: "a".repeat(513) } })]) { + expect((await handleContextHistory(contextRequest(body), config, logContext(), "alpha/notes/v2/read_file", undefined, keyAdmission)).status).toBe(400); + expect(selection).toBeUndefined(); + } +}); + +test("existing session and child affinity headers are not overwritten", () => { + for (const entry of [{ "session-id": "existing" }, { "thread-id": "child" }, { "x-codex-parent-thread-id": "parent" }] as Record[]) { + const headers = new Headers(entry); + const result = contextSelectionHeaders(headers, "root"); + expect([...result]).toEqual([...headers]); + expect([...headers]).toEqual([...new Headers(entry)]); + } +}); + +test("network failures and client cancellation do not retry writes", async () => { + let calls = 0; + setFetch(async () => { calls++; throw new Error("test transport failure"); }); + const body = JSON.stringify({ context: { session_id: "root" } }); + expect((await handleContextHistory(contextRequest(body), config, logContext(), "alpha/notes/v2/write_file", undefined, keyAdmission)).status).toBe(502); + const controller = new AbortController(); + setFetch(async () => { calls++; controller.abort(); throw new Error("test canceled"); }); + expect((await handleContextHistory(contextRequest(body, { authorization: "Bearer test" }, controller.signal), config, logContext(), "alpha/notes/v2/write_file", undefined, keyAdmission)).status).toBe(499); + expect(calls).toBe(2); +}); + +test("a client that gives up before dispatch releases without reaching upstream", async () => { + let calls = 0; + setFetch(async () => { calls++; return Response.json({}); }); + const controller = new AbortController(); + controller.abort(); + const response = await handleContextHistory( + contextRequest(JSON.stringify({ context: { session_id: "root" } }), { authorization: "Bearer test" }, controller.signal), + config, logContext(), "alpha/notes/v2/write_file", undefined, keyAdmission); + // The deadline starts before the body is read, so cancellation there is reported as the client + // hanging up rather than as a parse failure, and nothing is written upstream. + expect(response.status).toBe(499); + expect(calls).toBe(0); +}); + +test("cancelling during credential selection dispatches nothing", async () => { + let calls = 0; + setFetch(async () => { calls++; return Response.json({ value: "ok" }); }); + const controller = new AbortController(); + duringSelection = () => controller.abort(); + const response = await handleContextHistory( + contextRequest(JSON.stringify({ context: { session_id: "root" } }), { authorization: "Bearer test" }, controller.signal), + config, logContext(), "alpha/notes/v2/write_file", undefined, keyAdmission); + // Selection is inside the deadline, so a client that leaves during it is reported as a hangup + // and nothing reaches upstream. Listener-owned lease release is covered separately. + expect(response.status).toBe(499); + expect(selection).toBeDefined(); + expect(calls).toBe(0); +}); + +test.each(["body", "selection"])("disabling the feature during %s prevents dispatch", async stage => { + let calls = 0; + setFetch(async () => { calls++; return Response.json({}); }); + const body = JSON.stringify({ context: { session_id: "root" } }); + if (stage === "selection") duringSelection = () => setContextFeature(false); + const req = stage === "body" + ? new Request("http://localhost/v1/alpha/notes/v2/write_file", { + method: "POST", headers: { authorization: "Bearer test" }, + body: new ReadableStream({ pull(controller) { + setContextFeature(false); + controller.enqueue(new TextEncoder().encode(body)); + controller.close(); + } }, { highWaterMark: 0 }), + }) + : contextRequest(body); + const response = await handleContextHistory(req, config, logContext(), + "alpha/notes/v2/write_file", undefined, keyAdmission); + expect(response.status).toBe(404); + expect(calls).toBe(0); +}); + +test("a key withdrawn during the request cannot dispatch on its earlier admission", async () => { + let calls = 0; + setFetch(async () => { calls++; return Response.json({ value: "ok" }); }); + seedOwner("root-revalidate"); + const body = JSON.stringify({ context: { session_id: "root-revalidate" } }); + const response = await handleContextHistory(contextRequest(body), config, logContext(), + "alpha/notes/v2/read_file", undefined, keyAdmission, () => null); + expect(response.status).toBe(401); + expect(calls).toBe(0); + + const rotated = { kind: "configured", keyId: "synthetic-key", source: "dedicated", contextPrincipalId: "principal-b" } as const; + expect((await handleContextHistory(contextRequest(body), config, logContext(), + "alpha/notes/v2/read_file", undefined, keyAdmission, () => rotated)).status).toBe(401); + expect(calls).toBe(0); +}); + +test("with the experimental feature off the endpoints do not exist", async () => { + let calls = 0; + setFetch(async () => { calls++; return Response.json({ value: "ok" }); }); + setContextFeature(false); + const body = JSON.stringify({ context: { session_id: "root" } }); + // Rewriting the injected base URL is what makes the feature reachable, but a caller that can + // already reach the data plane can POST these paths directly, so the opt-in has to hold here. + for (const endpoint of ["alpha/history/v2/list_items", "alpha/notes/v2/write_file"]) { + const response = await handleContextHistory(contextRequest(body), config, logContext(), endpoint, undefined, keyAdmission); + expect(response.status).toBe(404); + } + expect(selection).toBeUndefined(); + expect(calls).toBe(0); +}); + +test("an admission without a caller principal cannot reach context history", async () => { + const before = selection; + // Loopback admission authenticates the socket, not a person. Without a minted principal there + // is no owner to compare against, so the relay refuses instead of serving whoever asked. + for (const admission of [undefined, { kind: "loopback", source: "loopback" } as const]) { + const response = await handleContextHistory( + contextRequest(JSON.stringify({ context: { session_id: "root" } })), + config, logContext(), "alpha/notes/v2/read_file", undefined, admission); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: { + type: "permission_error", code: "permission_denied", + message: "Context history requires an opencodex API key on the request; admission alone carries no caller identity", + } }); + } + expect(selection).toBe(before); +}); + +test("unknown endpoints and methods are rejected before admission", async () => { + expect((await handleContextHistory(contextRequest("{}"), config, logContext(), "alpha/notes/v2/delete_file", undefined, keyAdmission)).status).toBe(404); + expect((await handleContextHistory(new Request("http://localhost/v1/alpha/notes/v2/read_file"), config, logContext(), "alpha/notes/v2/read_file", undefined, keyAdmission)).status).toBe(404); + expect(validated).toBe(0); + expect(selection).toBeUndefined(); +}); + +test("credential materialization rechecks hardlocks and maps auth errors", async () => { + const body = JSON.stringify({ context: { session_id: "root" } }); + let calls = 0; + setFetch(async () => { calls++; return new Response("unexpected"); }); + for (const [error, status] of [[new errors.CodexAccountCooldownError(), 429], [new errors.CodexAuthContextError(), 401]] as const) { + materializationError = error; + const response = await handleContextHistory(contextRequest(body), config, logContext(), "alpha/notes/v2/read_file", undefined, keyAdmission); + expect(response.status).toBe(status); + expect(materialized).toEqual({ config, modelId: "context_history" }); + } + expect(calls).toBe(0); +}); + + +const keyAdmission: DataPlaneAdmission = { kind: "configured", keyId: "synthetic-key", source: "dedicated", contextPrincipalId: "principal-a" }; +const bearerAdmission: DataPlaneAdmission = { kind: "configured", keyId: "synthetic-key", source: "bearer", contextPrincipalId: "principal-a" }; +const admissionHeaders = { authorization: "Bearer ocx_data_test_admission" }; + +test("bearer-admitted context validates the body before selecting credentials", async () => { + const response = await handleContextHistory(contextRequest("{}", admissionHeaders), config, logContext(), "alpha/notes/v2/read_file", undefined, bearerAdmission); + expect(response.status).toBe(400); + expect(await response.text()).toContain("context.session_id"); + expect(selection).toBeUndefined(); +}); + +test("stored ownership remains fixed across current Direct and Pool settings", async () => { + let calls = 0; + setFetch(async (_url, init) => { + calls++; + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer test-only"); + return new Response("{}"); + }); + for (const mode of ["direct", "pool"]) { + accountMode = mode; + const response = await handleContextHistory(contextRequest('{"context":{"session_id":"root"}}', admissionHeaders), config, logContext(), "alpha/notes/v2/read_file", undefined, bearerAdmission); + expect(response.status).toBe(200); + expect(selection?.mode).toBe("pool"); + expect(selection?.options.accountId).toBe("test-account"); + expect(selection?.options.admission).toEqual(bearerAdmission); + expect(selection?.options.substituteMainCredentialForDirect).toBe(true); + expect(materializationOptions?.admission).toEqual(bearerAdmission); + expect(materializationOptions?.substituteMainCredential).toBe(true); + } + expect(calls).toBe(2); +}); + +test("proxy credentials cannot escape materialization or bypass non-bearer rejection", async () => { + let calls = 0; + setFetch(async () => { calls++; return new Response("unexpected"); }); + outgoingBearer = "ocx_data_test_admission"; + const body = '{"context":{"session_id":"root"}}'; + const response = await handleContextHistory(contextRequest(body, admissionHeaders), config, logContext(), "alpha/notes/v2/read_file", undefined, bearerAdmission); + expect(response.status).toBe(401); + expect(materialized).toBeDefined(); + for (const admission of [undefined, { kind: "environment", source: "dedicated" } as const, { kind: "loopback", source: "loopback" } as const]) { + selection = undefined; + expect((await handleContextHistory(contextRequest(body, admissionHeaders), config, logContext(), "alpha/notes/v2/read_file", undefined, admission)).status).toBe(401); + expect(selection).toBeUndefined(); + } + expect(calls).toBe(0); +}); + + +test("missing usable stored credentials fail before upstream I/O in bearer mode", async () => { + let calls = 0; + setFetch(async () => { calls++; return new Response("unexpected"); }); + for (const mode of ["direct", "pool"]) { + accountMode = mode; + materializationError = mode === "direct" + ? new errors.CodexMainSubstitutionUnavailableError() + : new errors.CodexPoolAuthenticationError(); + const response = await handleContextHistory(contextRequest('{"context":{"session_id":"root"}}', admissionHeaders), config, logContext(), "alpha/notes/v2/read_file", undefined, bearerAdmission); + expect(response.status).toBe(401); + expect(materializationOptions?.substituteMainCredential).toBe(true); + } + expect(calls).toBe(0); +}); + + +test("unknown, expired and conflicting owners fail before selecting or sending", async () => { + let calls = 0; + setFetch(async () => { calls++; return new Response("unexpected"); }); + for (const state of ["unknown", "expired", "conflicting"]) { + clearContextSessionOwnersForTests(); + if (state === "expired") seedOwner("root", "stored", "test-only", Date.now() - 25 * 60 * 60_000); + if (state === "conflicting") { seedOwner("root"); seedOwner("root", "stored", "other-account"); } + expect((await handleContextHistory(contextRequest('{"context":{"session_id":"root"}}'), config, logContext(), "alpha/notes/v2/read_file", undefined, keyAdmission)).status).toBe(409); + expect(selection).toBeUndefined(); + } + expect(calls).toBe(0); +}); + +test("stored token refresh is accepted but physical account replacement is refused", async () => { + let calls = 0; + setFetch(async () => { calls++; return new Response("{}"); }); + // Same person, new token: that is what a refresh looks like, and it stays the same owner. + clearContextSessionOwnersForTests(); + seedOwner("root", "stored", "test-only", undefined, userToken("user-a")); + outgoingBearer = userToken("user-a"); + const body = '{"context":{"session_id":"root"}}'; + expect((await handleContextHistory(contextRequest(body), config, logContext(), "alpha/notes/v2/read_file", undefined, keyAdmission)).status).toBe(200); + outgoingAccount = "replacement-account"; + expect((await handleContextHistory(contextRequest(body), config, logContext(), "alpha/notes/v2/read_file", undefined, keyAdmission)).status).toBe(409); + expect(calls).toBe(1); +}); + +test("caller owner uses Direct request credentials and cannot authorize proxy-bearer substitution", async () => { + clearContextSessionOwnersForTests(); seedOwner("root", "caller"); + let calls = 0; + setFetch(async () => { calls++; return new Response("{}"); }); + const body = '{"context":{"session_id":"root"}}'; + expect((await handleContextHistory(contextRequest(body), config, logContext(), "alpha/notes/v2/read_file", undefined, keyAdmission)).status).toBe(200); + expect(selection?.mode).toBe("direct"); + expect(selection?.options.requestScopedMainCredential).toBe(true); + expect(selection?.options.accountId).toBeUndefined(); + expect(materializationOptions?.substituteMainCredential).toBe(false); + selection = undefined; + expect((await handleContextHistory(contextRequest(body, admissionHeaders), config, logContext(), "alpha/notes/v2/read_file", undefined, bearerAdmission)).status).toBe(409); + expect(selection).toBeUndefined(); expect(calls).toBe(1); +}); + +test("a context root conflicting with protocol headers is rejected without selection", async () => { + const response = await handleContextHistory(contextRequest('{"context":{"session_id":"root"}}', { + authorization: "Bearer test", "session-id": "another-root", + }), config, logContext(), "alpha/notes/v2/read_file", undefined, keyAdmission); + expect(response.status).toBe(409); expect(selection).toBeUndefined(); +}); diff --git a/tests/server/data-plane-admission-identity.test.ts b/tests/server/data-plane-admission-identity.test.ts index 474ed0658a..1e79431aa2 100644 --- a/tests/server/data-plane-admission-identity.test.ts +++ b/tests/server/data-plane-admission-identity.test.ts @@ -5,12 +5,14 @@ import { isProxyAdmissionSecret, requireResponsesApiAuth, resolveApiAuth, + resolveContextPrincipal, resolveDataPlaneAdmissionSecret, resolveResponsesApiAuth, } from "../../src/server/auth-cors"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { contextRelayActivated } from "../../src/codex/context-compat"; import { saveConfig } from "../../src/config"; import { startServer } from "../../src/server"; import { buildResponsesWsData } from "../../src/server/ws-bridge"; @@ -70,12 +72,35 @@ describe("resolveDataPlaneAdmissionSecret", () => { config.apiKeys![0]!.pendingRotation!.expiresAt = new Date(Date.now() - 1).toISOString(); expect(resolveDataPlaneAdmissionSecret("ocx_data_pendingsecret", config)).toBeNull(); }); + test("principal identity binds secret rotation while preserving promotion and transport identity", () => { + const config = remoteConfig(); + const key = config.apiKeys![0]!; + const principal = (token: string, source: "dedicated" | "bearer" = "dedicated") => { + const admission = resolveDataPlaneAdmissionSecret(token, config, source); + expect(admission).not.toBeNull(); + return admission && "contextPrincipalId" in admission ? admission.contextPrincipalId : undefined; + }; + const initial = principal(key.key); + expect(initial).toMatch(/^[a-f0-9]{64}$/); + expect(principal(key.key, "bearer")).toBe(initial); + expect(principal(config.apiKeys![1]!.key)).not.toBe(initial); + key.pendingRotation = { id: "rotation", key: "ocx_data_new_secret", + createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString() }; + const pending = principal(key.pendingRotation.key); + expect(pending).not.toBe(initial); + const old = key.key; + key.key = key.pendingRotation.key; + delete key.pendingRotation; + expect(principal(key.key)).toBe(pending); + expect(resolveDataPlaneAdmissionSecret(old, config)).toBeNull(); + }); test("names the configured key that actually matched", () => { const config = remoteConfig(); expect(resolveDataPlaneAdmissionSecret("ocx_data_firstsecret", config)).toEqual({ kind: "configured", keyId: "first-key", source: "dedicated", + contextPrincipalId: expect.stringMatching(/^[a-f0-9]{64}$/), }); }); @@ -87,12 +112,13 @@ describe("resolveDataPlaneAdmissionSecret", () => { kind: "configured", keyId: "second-key", source: "dedicated", + contextPrincipalId: expect.stringMatching(/^[a-f0-9]{64}$/), }); }); test("the environment token has no configured key to name", () => { process.env.OPENCODEX_API_AUTH_TOKEN = "env-secret"; - expect(resolveDataPlaneAdmissionSecret("env-secret", remoteConfig())).toEqual({ kind: "environment", source: "dedicated" }); + expect(resolveDataPlaneAdmissionSecret("env-secret", remoteConfig())).toEqual({ kind: "environment", source: "dedicated", contextPrincipalId: expect.stringMatching(/^[a-f0-9]{64}$/) }); }); test.each([ @@ -135,7 +161,7 @@ describe("the two wrappers still differ", () => { const bearer = request({ authorization: "Bearer ocx_data_firstsecret" }); // /v1/models and /v1/messages take bearer... - expect(resolveApiAuth(bearer, config)).toEqual({ kind: "configured", keyId: "first-key", source: "bearer" }); + expect(resolveApiAuth(bearer, config)).toEqual({ kind: "configured", keyId: "first-key", source: "bearer", contextPrincipalId: expect.stringMatching(/^[a-f0-9]{64}$/) }); expect(hasValidApiAuth(bearer, config)).toBe(true); // ...and Responses now does too. Rejecting it meant a Codex client configured with @@ -143,7 +169,7 @@ describe("the two wrappers still differ", () => { // credential is substituted rather than forwarded -- see materializeCodexUpstreamAuth. // The source is recorded so that substitution can be made conditional on it. expect(resolveResponsesApiAuth(request({ authorization: "Bearer ocx_data_firstsecret" }), config)) - .toEqual({ kind: "configured", keyId: "first-key", source: "bearer" }); + .toEqual({ kind: "configured", keyId: "first-key", source: "bearer", contextPrincipalId: expect.stringMatching(/^[a-f0-9]{64}$/) }); expect(requireResponsesApiAuth(request({ authorization: "Bearer ocx_data_firstsecret" }), config)).toBeNull(); }); @@ -163,7 +189,7 @@ describe("the two wrappers still differ", () => { authorization: "Bearer ocx_data_firstsecret", }); expect(resolveResponsesApiAuth(both, config)) - .toEqual({ kind: "configured", keyId: "second-key", source: "dedicated" }); + .toEqual({ kind: "configured", keyId: "second-key", source: "dedicated", contextPrincipalId: expect.stringMatching(/^[a-f0-9]{64}$/) }); }); test("x-api-key is accepted only by the broad path", () => { @@ -175,8 +201,8 @@ describe("the two wrappers still differ", () => { test("the dedicated header works on both", () => { const config = remoteConfig(); const dedicated = () => request({ "x-opencodex-api-key": "ocx_data_secondsecret" }); - expect(resolveApiAuth(dedicated(), config)).toEqual({ kind: "configured", keyId: "second-key", source: "dedicated" }); - expect(resolveResponsesApiAuth(dedicated(), config)).toEqual({ kind: "configured", keyId: "second-key", source: "dedicated" }); + expect(resolveApiAuth(dedicated(), config)).toEqual({ kind: "configured", keyId: "second-key", source: "dedicated", contextPrincipalId: expect.stringMatching(/^[a-f0-9]{64}$/) }); + expect(resolveResponsesApiAuth(dedicated(), config)).toEqual({ kind: "configured", keyId: "second-key", source: "dedicated", contextPrincipalId: expect.stringMatching(/^[a-f0-9]{64}$/) }); expect(requireResponsesApiAuth(dedicated(), config)).toBeNull(); }); }); @@ -189,6 +215,55 @@ describe("loopback binds", () => { expect(hasValidApiAuth(request(), config)).toBe(true); expect(requireResponsesApiAuth(request(), config)).toBeNull(); }); + + test("still let a context caller name itself with a real key", () => { + const config = loopbackConfig(); + const admission = resolveApiAuth(request(), config)!; + // Admission is unchanged: the relay asks the identity question separately, so a caller that + // volunteers a real key owns its sessions even here, and one that volunteers nothing does not. + expect(resolveContextPrincipal(request(), config, admission)).toBeUndefined(); + expect(resolveContextPrincipal(request({ "x-opencodex-api-key": "ocx_data_wrongsecret" }), config, admission)).toBeUndefined(); + + const first = resolveContextPrincipal(request({ "x-opencodex-api-key": "ocx_data_firstsecret" }), config, admission); + const bearer = resolveContextPrincipal(request({ authorization: "Bearer ocx_data_firstsecret" }), config, admission); + const second = resolveContextPrincipal(request({ "x-opencodex-api-key": "ocx_data_secondsecret" }), config, admission); + expect(first).toBeString(); + expect(bearer).toBe(first!); + expect(second).not.toBe(first!); + }); + + test("the activation gate re-reads a changed config and fails closed on a bad home", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-context-gate-")); + const configPath = join(home, "config.toml"); + writeFileSync(configPath, "[features]\ncontext_management.experimental_mode = true\n"); + expect(contextRelayActivated(configPath)).toBe(true); + // No reset seam here on purpose: turning the feature off has to take effect on its own, + // which is the whole point of keying the cache on the config identity. + writeFileSync(configPath, "[features]\ncontext_management.experimental_mode = false\n"); + expect(contextRelayActivated(configPath)).toBe(false); + writeFileSync(configPath, "model = \"gpt-5.5\"\n"); + expect(contextRelayActivated(configPath)).toBe(false); + expect(contextRelayActivated(join(home, "absent.toml"))).toBe(false); + + const previous = process.env.CODEX_HOME; + process.env.CODEX_HOME = join(home, "not-a-directory-here"); + try { + // An unreadable CODEX_HOME is a refusal, never an exception: this runs during model turns. + expect(contextRelayActivated()).toBe(false); + } finally { + if (previous === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previous; + } + removeTreeWithRetry(home); + }); + + test("a remote bind keeps naming the principal from its own admission", () => { + const config = remoteConfig(); + const admission = resolveApiAuth(request({ "x-opencodex-api-key": "ocx_data_firstsecret" }), config)!; + expect(resolveContextPrincipal(request(), config, admission)) + .toBe(resolveContextPrincipal(request({ "x-opencodex-api-key": "ocx_data_firstsecret" }), config, admission)); + expect(resolveContextPrincipal(request(), config, undefined)).toBeUndefined(); + }); }); describe("the Responses WebSocket handshake", () => { diff --git a/tests/server/loopback-listener-admission.test.ts b/tests/server/loopback-listener-admission.test.ts index e87cc5104c..140cebbbfb 100644 --- a/tests/server/loopback-listener-admission.test.ts +++ b/tests/server/loopback-listener-admission.test.ts @@ -57,7 +57,7 @@ describe("loopback listener policy view", () => { expect(resolveResponsesApiAuth( request("/v1/responses", { "x-opencodex-api-key": "ocx_data_realsecret" }), wildcardConfig, - )).toEqual({ kind: "configured", keyId: "k1", source: "dedicated" }); + )).toEqual({ kind: "configured", keyId: "k1", source: "dedicated", contextPrincipalId: expect.stringMatching(/^[a-f0-9]{64}$/) }); }); test("both Anthropic routes finish CORS with the listener-effective policy", () => { diff --git a/tests/server/server-live.test.ts b/tests/server/server-live.test.ts index f6d4da8916..f0fc6c94cd 100644 --- a/tests/server/server-live.test.ts +++ b/tests/server/server-live.test.ts @@ -198,7 +198,7 @@ test("POST /v1/live rewrites ChatGPT multipart into backend realtime/calls JSON" } }); -test("POST /v1/live relays to an OpenAI API-key provider at /v1/live without AVAS", async () => { +test.each(["/v1/live", "/backend-api/codex/live"])("POST %s relays to an OpenAI API-key provider at /v1/live without AVAS", async (path) => { const captured: CapturedRequest[] = []; const upstream = fakeLiveUpstream(captured, 201, "/v1/live/rtc_api"); saveConfig({ @@ -217,7 +217,7 @@ test("POST /v1/live relays to an OpenAI API-key provider at /v1/live without AVA const server = startServer(0); try { const { body, contentType } = multipartLiveBody(); - const response = await fetch(new URL("/v1/live", server.url), { + const response = await fetch(new URL(path, server.url), { method: "POST", headers: { "content-type": contentType }, body, diff --git a/tests/server/server-management-auth.test.ts b/tests/server/server-management-auth.test.ts index a0c977202d..2e5f3db061 100644 --- a/tests/server/server-management-auth.test.ts +++ b/tests/server/server-management-auth.test.ts @@ -1,3 +1,5 @@ +import { request as httpRequest } from "node:http"; +import { getActiveTurnCount } from "../../src/server/lifecycle"; // Holds INV-AUTH-01 from structure/overview.md; keep the id here if this file is split or renamed. import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { SERVER_BUDGET_MS } from "../helpers/test-budget"; @@ -5,6 +7,8 @@ import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getConfigPath, saveConfig } from "../../src/config"; +import { clearContextSessionOwnersForTests } from "../../src/codex/context-owner"; +import { resetContextRelayActivationForTests } from "../../src/codex/context-compat"; import { startServer } from "../../src/server"; import { readCodexAccountRecord, saveCodexAccountCredential } from "../../src/codex/account-store"; import type { OcxConfig } from "../../src/types"; @@ -87,10 +91,16 @@ import { setSystemRestartIoForTests } from "../../src/server/management/system-r import { removeTreeWithRetry } from "../helpers/remove-tree"; const previousHome = process.env.OPENCODEX_HOME; +const previousCodexHome = process.env.CODEX_HOME; const previousDataToken = process.env.OPENCODEX_API_AUTH_TOKEN; const previousAdminToken = process.env.OPENCODEX_ADMIN_AUTH_TOKEN; let testHome = ""; +function enableContextRelay(): void { + writeFileSync(join(testHome, "config.toml"), "[features]\ncontext_management.experimental_mode = true\n"); + resetContextRelayActivationForTests(); +} + function remoteConfig(): OcxConfig { return { port: 0, @@ -173,11 +183,16 @@ function websocketHandshakeOpens(url: URL, token: string): Promise { beforeEach(() => { testHome = mkdtempSync(join(tmpdir(), "ocx-management-auth-")); process.env.OPENCODEX_HOME = testHome; + process.env.CODEX_HOME = testHome; + resetContextRelayActivationForTests(); process.env.OPENCODEX_API_AUTH_TOKEN = "data-secret"; process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "admin-secret"; }); afterEach(() => { + resetContextRelayActivationForTests(); + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; setSystemRestartIoForTests(); setIcaclsRunnerForTests(null); setPlatformForTests(null); @@ -621,6 +636,114 @@ describe("management and data-plane credential separation", () => { } }); + test("Codex backend aliases retain data-plane authentication and cannot enter management", async () => { + enableContextRelay(); + saveConfig(remoteConfig()); + const server = startServer(0); + try { + for (const token of [undefined, "admin-secret", "data-secret"]) { + const headers: Record = token ? { "x-opencodex-api-key": token } : {}; + const models = await fetch(new URL("/backend-api/codex/models", server.url), { headers }); + expect(models.status).toBe(token === "data-secret" ? 200 : 401); + const context = await fetch(new URL("/backend-api/codex/alpha/notes/v2/read_file", server.url), { + method: "POST", headers, body: "{}", + }); + expect(context.status).toBe(token === "data-secret" ? 400 : 401); + const management = await fetch(new URL("/backend-api/codex/api/config", server.url), { headers }); + expect(management.status).toBe(404); + } + } finally { + await server.stop(true); + } + }); + + test("canceling an incomplete context body releases the actual listener turn", async () => { + enableContextRelay(); + saveConfig(remoteConfig()); + const server = startServer(0); + const baseline = getActiveTurnCount(); + const request = httpRequest(new URL("/v1/alpha/notes/v2/write_file", server.url), { + method: "POST", headers: { authorization: "Bearer data-secret", "content-length": "1000" }, + }); + request.on("error", () => {}); // Destroying this deliberately unfinished request resets the socket. + const waitForCount = async (expected: number) => { + const deadline = Date.now() + 5000; + while (getActiveTurnCount() !== expected && Date.now() < deadline) { + await new Promise(resolve => setImmediate(resolve)); + } + expect(getActiveTurnCount()).toBe(expected); + }; + try { + request.write("{"); // Never end the body: the server is waiting inside the bounded parser. + await waitForCount(baseline + 1); + request.destroy(); + await waitForCount(baseline); + } finally { + request.destroy(); + await server.stop(true); + } + }); + + test("context relay remains absent without the native experimental opt-in", async () => { + saveConfig(remoteConfig()); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/alpha/notes/v2/read_file", server.url), { + method: "POST", headers: { authorization: "Bearer data-secret" }, body: "{}", + }); + expect(response.status).toBe(404); + } finally { + await server.stop(true); + } + }); + + test("context bearer admission reaches body validation without accepting foreign credentials", async () => { + enableContextRelay(); + saveConfig(remoteConfig()); + const server = startServer(0); + try { + for (const prefix of ["/v1", "/backend-api/codex"]) { + for (const token of ["data-secret", "admin-secret", "foreign-secret"]) { + const response = await fetch(new URL(`${prefix}/alpha/notes/v2/read_file`, server.url), { + method: "POST", headers: { authorization: `Bearer ${token}` }, body: "{}", + }); + expect(response.status).toBe(token === "data-secret" ? 400 : 401); + if (token === "data-secret") expect(await response.text()).toContain("context.session_id"); + } + } + } finally { + await server.stop(true); + } + }); + + test("authenticated context without a successful model owner fails closed on both listener prefixes", async () => { + enableContextRelay(); + const cfg = remoteConfig(); + cfg.providers.openai = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool" }; + saveConfig(cfg); clearContextSessionOwnersForTests(); + const originalFetch = globalThis.fetch; + let upstreamCalls = 0; + globalThis.fetch = Object.assign(async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "0.0.0.0") return originalFetch(input, init); + upstreamCalls++; throw new Error("unknown context owner must not reach upstream"); + }, { preconnect: originalFetch.preconnect }); + const server = startServer(0); + try { + for (const prefix of ["/v1", "/backend-api/codex"]) { + const response = await fetch(new URL(`${prefix}/alpha/notes/v2/read_file`, server.url), { + method: "POST", headers: { authorization: "Bearer data-secret" }, + body: JSON.stringify({ context: { session_id: "unknown-root" } }), + }); + expect(response.status).toBe(409); + expect(await response.text()).toContain("context_account_unavailable"); + } + expect(upstreamCalls).toBe(0); + } finally { + await server.stop(true); globalThis.fetch = originalFetch; clearContextSessionOwnersForTests(); + } + }); + test("data and management environment tokens authorize only their own planes", async () => { saveConfig(remoteConfig()); const server = startServer(0); diff --git a/tests/update/update-pnpm.test.ts b/tests/update/update-pnpm.test.ts index aadf1ce960..f0918250b2 100644 --- a/tests/update/update-pnpm.test.ts +++ b/tests/update/update-pnpm.test.ts @@ -386,7 +386,22 @@ describe("pnpm package tree verification", () => { }); describe("pnpm generated shims", () => { - test("verifies POSIX shims point at the active package", () => { + function writeHostShims(globalBinDir: string, targetPackageDir: string, commands = ["ocx", "opencodex"]): void { + const target = relative(globalBinDir, join(targetPackageDir, "bin", "ocx.mjs")); + for (const command of commands) { + if (process.platform === "win32") { + const windowsTarget = target.replaceAll("/", "\\"); + writeFileSync(join(globalBinDir, `${command}.cmd`), `@echo off\r\nnode "%~dp0\\${windowsTarget}" %*\r\n`); + writeFileSync(join(globalBinDir, `${command}.ps1`), + `$basedir = Split-Path $MyInvocation.MyCommand.Definition -Parent\n& "$basedir\\${windowsTarget}" @args\n`); + } else { + writeFileSync(join(globalBinDir, command), `#!/bin/sh\nbasedir=$(dirname "$0")\nexec node "$basedir/${target}" "$@"\n`); + chmodSync(join(globalBinDir, command), 0o755); + } + } + } + + test("verifies host-native shims point at the active package", () => { const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-shims-")); try { const packageDir = join(root, "global", "v11", "node_modules", PKG); @@ -394,14 +409,10 @@ describe("pnpm generated shims", () => { mkdirSync(join(packageDir, "bin"), { recursive: true }); mkdirSync(globalBinDir, { recursive: true }); writeFileSync(join(packageDir, "bin", "ocx.mjs"), "#!/usr/bin/env node\n"); - const target = relative(globalBinDir, join(packageDir, "bin", "ocx.mjs")); - for (const name of ["ocx", "opencodex"]) { - writeFileSync(join(globalBinDir, name), `#!/bin/sh\nexec node ${target} "$@"\n`); - chmodSync(join(globalBinDir, name), 0o755); - } - expect(verifyPnpmGlobalShims(packageDir, globalBinDir, "linux")).toEqual({ ok: true }); - writeFileSync(join(globalBinDir, "opencodex"), "#!/bin/sh\nexec node ../global/v11/node_modules/old/bin/ocx.mjs\n"); - expect(verifyPnpmGlobalShims(packageDir, globalBinDir, "linux").ok).toBe(false); + writeHostShims(globalBinDir, packageDir); + expect(verifyPnpmGlobalShims(packageDir, globalBinDir)).toEqual({ ok: true }); + writeHostShims(globalBinDir, join(root, "global", "v11", "node_modules", "old"), ["opencodex"]); + expect(verifyPnpmGlobalShims(packageDir, globalBinDir).ok).toBe(false); } finally { rmSync(root, { recursive: true, force: true }); } @@ -418,12 +429,30 @@ describe("pnpm generated shims", () => { mkdirSync(globalBinDir, { recursive: true }); writeFileSync(join(packageDir, "bin", "ocx.mjs"), "#!/usr/bin/env node\n"); symlinkSync(activeGroup, aliasGroup, "dir"); - const target = relative(globalBinDir, join(aliasGroup, "node_modules", PKG, "bin", "ocx.mjs")); - for (const name of ["ocx", "opencodex"]) { - writeFileSync(join(globalBinDir, name), `#!/bin/sh\nexec node ${target} "$@"\n`); - chmodSync(join(globalBinDir, name), 0o755); - } - expect(verifyPnpmGlobalShims(packageDir, globalBinDir, "linux")).toEqual({ ok: true }); + writeHostShims(globalBinDir, join(aliasGroup, "node_modules", PKG)); + expect(verifyPnpmGlobalShims(packageDir, globalBinDir)).toEqual({ ok: true }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + // Windows does not expose POSIX execute bits; target/alias coverage above still runs there. + test.skipIf(process.platform === "win32")("requires executable permissions on POSIX shims", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-posix-mode-")); + try { + const packageDir = join(root, "node_modules", PKG); + const globalBinDir = join(root, "bin"); + mkdirSync(join(packageDir, "bin"), { recursive: true }); + mkdirSync(globalBinDir, { recursive: true }); + writeFileSync(join(packageDir, "bin", "ocx.mjs"), "#!/usr/bin/env node\n"); + writeHostShims(globalBinDir, packageDir); + expect(verifyPnpmGlobalShims(packageDir, globalBinDir)).toEqual({ ok: true }); + chmodSync(join(globalBinDir, "ocx"), 0o644); + expect(verifyPnpmGlobalShims(packageDir, globalBinDir)).toEqual({ + ok: false, reason: "pnpm generated shim verification failed (ocx)", + }); + chmodSync(join(globalBinDir, "ocx"), 0o755); + expect(verifyPnpmGlobalShims(packageDir, globalBinDir)).toEqual({ ok: true }); } finally { rmSync(root, { recursive: true, force: true }); }