From 617fb49b4d47acfe7987c57d7ae95a21ff744e32 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 01:59:43 +0000 Subject: [PATCH 1/5] fix(security): require paired session for CLI updates --- src/server/management/codex-cli-update-routes.ts | 3 ++- tests/server/management-route-registry.test.ts | 3 +++ tests/server/persistent-dashboard-consent.test.ts | 8 ++++---- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/server/management/codex-cli-update-routes.ts b/src/server/management/codex-cli-update-routes.ts index 3f93937e3b1..5aec2714346 100644 --- a/src/server/management/codex-cli-update-routes.ts +++ b/src/server/management/codex-cli-update-routes.ts @@ -25,7 +25,8 @@ export async function handleCodexCliUpdateRoutes(ctx: ManagementContext): Promis } if (ctx.url.pathname !== "/api/codex/cli-update/apply") return null; if (ctx.req.method !== "POST") return jsonResponse({ error: "method_not_allowed" }, 405, ctx.req, ctx.config); - if (!hasHumanDashboardSession(ctx) || !ctx.trustedLoopbackIngress) { + if (!hasHumanDashboardSession(ctx) || !ctx.trustedLoopbackIngress + || ctx.sessionControl?.isPaired(ctx.req, ctx.config) !== true) { return jsonResponse({ error: "local_dashboard_confirmation_required" }, 403, ctx.req, ctx.config); } let body: unknown; diff --git a/tests/server/management-route-registry.test.ts b/tests/server/management-route-registry.test.ts index 518d8aee239..b1e844a07f1 100644 --- a/tests/server/management-route-registry.test.ts +++ b/tests/server/management-route-registry.test.ts @@ -201,6 +201,7 @@ describe("management route registry reconciliation", () => { expect(untrustedIngress?.status).toBe(403); expect(await untrustedIngress?.json()).toEqual({ error: "local_dashboard_confirmation_required" }); ctx.trustedLoopbackIngress = true; + ctx.sessionControl = { isPaired: () => true, isCurrent: () => true, revokeCurrent: () => true }; ctx.req = new Request(request.url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ requestId: "retry-key-00001", planId: "a".repeat(32), confirmed: false }) }); const unconfirmed = await handleCodexCliUpdateRoutes(ctx); @@ -222,6 +223,7 @@ describe("management route registry reconciliation", () => { const ctx: ManagementContext = { req, url: new URL(req.url), config: { port: 10100, providers: {} } as import("../../src/types").OcxConfig, deps: {}, version: "test", principal: "gui-session", trustedLoopbackIngress: true, guiSessionIssuance: "loopback", + sessionControl: { isPaired: () => true, isCurrent: () => true, revokeCurrent: () => true }, convergeCodexCatalog: async () => ({ status: "skipped", reason: "not-requested", retryable: false }), syncClaudeAgentDefsBestEffort: async () => {} }; const response = await handleCodexCliUpdateRoutes(ctx); @@ -247,6 +249,7 @@ describe("management route registry reconciliation", () => { const ctx: ManagementContext = { req: makeRequest(), url: new URL("http://localhost/api/codex/cli-update/apply"), config: { port: 10100, providers: {} } as import("../../src/types").OcxConfig, deps: {}, version: "test", principal: "gui-session", trustedLoopbackIngress: true, guiSessionIssuance: "loopback", + sessionControl: { isPaired: () => true, isCurrent: () => true, revokeCurrent: () => true }, convergeCodexCatalog: async () => ({ status: "skipped", reason: "not-requested", retryable: false }), syncClaudeAgentDefsBestEffort: async () => {} }; const storeDir = join(configDir, "codex-cli-update-jobs"), heldLock = join(storeDir, "admission.lock"); diff --git a/tests/server/persistent-dashboard-consent.test.ts b/tests/server/persistent-dashboard-consent.test.ts index 8843e8cb0b9..fd50607db64 100644 --- a/tests/server/persistent-dashboard-consent.test.ts +++ b/tests/server/persistent-dashboard-consent.test.ts @@ -143,15 +143,15 @@ for (const resumed of [false, true]) { }); } -test("ordinary loopback GUI apply retains confirmation and ingress gates, then dispatches once", async () => { +test("ordinary loopback GUI apply cannot self-authorize an update", async () => { const session = issueGuiSession(request("/", "GET", ""), config, state)!; expect(session.issuance).toBe("loopback"); const device = { session, cookie: "" }; - expect((await dispatch(sessionRequest(device, "/api/codex/cli-update/apply", "POST", { ...APPLY, confirmed: false }))).status).toBe(400); + expect((await dispatch(sessionRequest(device, "/api/codex/cli-update/apply", "POST", { ...APPLY, confirmed: false }))).status).toBe(403); expect((await dispatch(sessionRequest(device, "/api/codex/cli-update/apply", "POST", APPLY), false)).status).toBe(403); expect(effects).toEqual([]); - expect((await dispatch(sessionRequest(device, "/api/codex/cli-update/apply", "POST", APPLY))).status).toBe(202); - expect(effects).toEqual(["admit", "run", "worker"]); + expect((await dispatch(sessionRequest(device, "/api/codex/cli-update/apply", "POST", APPLY))).status).toBe(403); + expect(effects).toEqual([]); }); test("remembered CLI plan and job reads retain ordinary management admission", async () => { From 6b62695d309984375c15e3b734052361bf45f0f5 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 18:25:55 +0900 Subject: [PATCH 2/5] test(security): exercise paired CLI update admission Redeem a real local pairing grant before exercising update admission and retain confirmation, ingress, origin, CSRF, expiry, and current-session guards. Document supported pairing origins and the independent local CLI path across the public guide, generated capabilities, and owning structure references. --- .../src/content/docs/guides/web-dashboard.md | 8 ++++ .../references/01_surface_observe-system.md | 3 +- src/cli/capabilities-base.ts | 2 +- src/server/management/route-registry.ts | 2 +- structure/cli-management.md | 2 +- structure/codex-cli-update.md | 4 +- structure/gui-and-management-api.md | 2 +- .../persistent-dashboard-consent.test.ts | 47 +++++++++++++++++++ 8 files changed, 64 insertions(+), 6 deletions(-) diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 6344b33d8a6..4901d8bb4e7 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -299,6 +299,14 @@ they have been synchronized. See In **Connect → API Keys**, every row of the key table has its own delete button, which asks for confirmation in place. Clicking a key can show its full value with a **Copy** button only from an independently authorized session. +### Applying a Codex CLI update over HTTP + +The CLI-update HTTP API has no dashboard job UI. Its apply route requires a current explicitly paired browser session on trusted loopback ingress, matching Origin and CSRF headers, and `confirmed: true`. Automatic local sign-in, a remembered admin session, an admin token or Tailscale sign-in alone cannot authorize this action. + +For standalone local pairing, open the dashboard at its configured literal `http://127.0.0.1:` or `http://[::1]:` origin. Use the existing local pairing form in **Connect → API Keys** or Remote Link setup: run the displayed `ocx gui pair --origin ""` command locally, then redeem its one-time code in that form. Pairing does not apply an update; a subsequent confirmed API request is still required. A server configured with hostname `localhost`, a wildcard bind, or a connected-client role cannot issue this local pairing authority. Remote pairing does not remove the apply route's trusted-loopback requirement. + +Without an eligible HTTP pairing path, use `ocx system codex-cli-update plan` followed by `ocx system codex-cli-update apply --plan-id <32-hex> --yes` locally. This uses the same durable job owner without a GUI session. Pairing proves authority to write the protected configuration home, not human identity or protection from unrestricted same-user processes. + ### Reading an existing API key Showing a stored key's full value requires a dashboard session established by explicit pairing or diff --git a/skills/ocx/references/01_surface_observe-system.md b/skills/ocx/references/01_surface_observe-system.md index d3cea04a9b8..db34089e654 100644 --- a/skills/ocx/references/01_surface_observe-system.md +++ b/skills/ocx/references/01_surface_observe-system.md @@ -417,7 +417,8 @@ Drives no management route. | `--request-id` | string | Optional stable retry key. | | `--json` | boolean | Emit the public job status. | -- Local counterpart of POST /api/codex/cli-update/apply: invokes the same durable job owner with --yes confirmation; HTTP admission separately requires a trusted loopback GUI session. +- Local counterpart of POST /api/codex/cli-update/apply: invokes the same durable job owner with --yes confirmation; HTTP admission separately requires a current paired dashboard session on trusted loopback ingress, matching origin, CSRF and confirmed: true. +- Standalone HTTP pairing requires the configured literal 127.0.0.1 or ::1 origin; localhost aliases, wildcard binds and connected-client roles use the local CLI --yes path instead. - Admission is stored under the protected current-user config root before the installer can run. - Exit 0 requires an applied job; refused, not_applied, ambiguous and pending duplicate jobs exit 1, including with --json. - Only POSIX npm-global managed installs are currently supported; Windows, shims and other managers remain refused. diff --git a/src/cli/capabilities-base.ts b/src/cli/capabilities-base.ts index da19fc790e2..045c5d56e65 100644 --- a/src/cli/capabilities-base.ts +++ b/src/cli/capabilities-base.ts @@ -618,7 +618,7 @@ export const CAPABILITIES: readonly Capability[] = [ routes: [], flags: [{ name: "--plan-id", value: "string", summary: "Required 32-hex id from the current plan." }, { name: "--yes", value: "boolean", summary: "Required confirmation." }, { name: "--request-id", value: "string", summary: "Optional stable retry key." }, { name: "--json", value: "boolean", summary: "Emit the public job status." }], mutates: true, json: "envelope", - details: ["Local counterpart of POST /api/codex/cli-update/apply: invokes the same durable job owner with --yes confirmation; HTTP admission separately requires a trusted loopback GUI session.", "Admission is stored under the protected current-user config root before the installer can run.", "Exit 0 requires an applied job; refused, not_applied, ambiguous and pending duplicate jobs exit 1, including with --json.", "Only POSIX npm-global managed installs are currently supported; Windows, shims and other managers remain refused."], + details: ["Local counterpart of POST /api/codex/cli-update/apply: invokes the same durable job owner with --yes confirmation; HTTP admission separately requires a current paired dashboard session on trusted loopback ingress, matching origin, CSRF and confirmed: true.", "Standalone HTTP pairing requires the configured literal 127.0.0.1 or ::1 origin; localhost aliases, wildcard binds and connected-client roles use the local CLI --yes path instead.", "Admission is stored under the protected current-user config root before the installer can run.", "Exit 0 requires an applied job; refused, not_applied, ambiguous and pending duplicate jobs exit 1, including with --json.", "Only POSIX npm-global managed installs are currently supported; Windows, shims and other managers remain refused."], usage: "ocx system codex-cli-update apply --plan-id <32-hex> --yes [--request-id ] [--json]", }, { diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 4b10087659d..0afc5e154b3 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -180,7 +180,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "POST", path: "/api/codex/desktop-compatibility/settings", module: "server/management/desktop-compatibility-settings-routes", mutates: true, mechanism: "path-constant", exempt: { reason: "session-only", why: "Saved native observation intent requires an operator-paired local GUI action and fresh field revision." } }, // server/management/codex-cli-update-routes { method: "GET", path: "/api/codex/cli-update/plan", module: "server/management/codex-cli-update-routes", mutates: false, exempt: { reason: "local-transport", why: "The CLI runs the same proof-bound plan builder locally; the authenticated route also supplies the dashboard plan." } }, - { method: "POST", path: "/api/codex/cli-update/apply", module: "server/management/codex-cli-update-routes", mutates: true, exempt: { reason: "session-only", why: "HTTP apply requires a confirmed trusted loopback GUI session; the CLI invokes the same durable job owner locally." } }, + { method: "POST", path: "/api/codex/cli-update/apply", module: "server/management/codex-cli-update-routes", mutates: true, exempt: { reason: "session-only", why: "HTTP apply requires a current paired dashboard session on trusted loopback ingress, matching origin, CSRF and confirmed: true; the local CLI uses --yes independently." } }, { method: "GET", path: "/api/codex/cli-update/jobs/{jobId}", module: "server/management/codex-cli-update-routes", mutates: false, mechanism: "regex", exempt: { reason: "local-transport", why: "The CLI status command reads the same protected job record directly and does not use job IDs as authorization." } }, { method: "PUT", path: "/api/codex-auth/features/default-mode-request-user-input", module: "server/management/agent-settings-routes", mutates: true }, { method: "PUT", path: "/api/effort-caps", module: "server/management/agent-settings-routes", mutates: true }, diff --git a/structure/cli-management.md b/structure/cli-management.md index c3aab111345..5a0957d0b51 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -179,4 +179,4 @@ eligibility, drift refusal and recovery. No endpoint or credential is printed by `ocx system codex-cli-update apply` durably admits and records the apply result under the protected config root; `--request-id` provides a retry key, and reusing it with a different plan conflicts. `ocx system codex-cli-update status ` reads the same path-free terminal DTO without a GUI token. A record left queued/running after owner restart is reported ambiguous with unknown owner state. Apply remains limited to the supported POSIX npm-global installation. On Windows the same durable record and status read are served by the handle-bound job-store backend described in `codex-cli-update.md`. -The capability declarations document `ocx system codex-cli-update plan`, `apply` and `status` as the local counterparts of `GET /api/codex/cli-update/plan`, `POST /api/codex/cli-update/apply` and `GET /api/codex/cli-update/jobs/{jobId}`. They share the plan builder and durable job owner with the dashboard rather than sending HTTP management requests, so their driven-route lists remain empty. CLI apply requires `--yes`; HTTP apply separately requires explicit confirmation from a trusted loopback GUI session. The GUI parity test resolves these registered capabilities, checks their mutation classifications and documented resource counterparts, and includes all three resources in endpoint coverage. +The capability declarations document `ocx system codex-cli-update plan`, `apply` and `status` as the local counterparts of `GET /api/codex/cli-update/plan`, `POST /api/codex/cli-update/apply` and `GET /api/codex/cli-update/jobs/{jobId}`. They share the plan builder and durable job owner with the dashboard rather than sending HTTP management requests, so their driven-route lists remain empty. CLI apply requires `--yes`; HTTP apply separately requires explicit confirmation from a current paired dashboard session on trusted loopback ingress, with matching origin and CSRF. The GUI parity test resolves these registered capabilities, checks their mutation classifications and documented resource counterparts, and includes all three resources in endpoint coverage. diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index f84c105d140..f3f33bfded8 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -469,7 +469,9 @@ documented residual it always was. `bin/ocx.mjs` includes the proof-bound Codex candidate snapshot for the published `start` command as well as the updater commands; this makes the production management plan work without accepting request-supplied candidate evidence. `src/codex/cli-update-inspection-context.ts` is the shared bridge from that Node snapshot to the installer inspector. Direct Bun/source launches have no trusted candidate and fail closed; neither path reconstructs write targets from display data. `src/codex/cli-update-job.ts` owns the bounded durable admission ledger shared by CLI and management. Request IDs are scoped to the protected config root and payload digest; exact retries return the same opaque job, conflicts refuse, and a full ledger refuses new admission. Every store operation requires a canonical same-owner config parent with safe write permissions and canonical ancestry to filesystem root; each ancestor must be owned by root or the current user, and shared-writable ancestors are refused unless root-owned and sticky. Only confirmed apply admission may create one absent config-root level, which is revalidated as owner-private. Plan and status reads never create it, and an existing root must pass the same canonical/no-link checks. This path-based ancestry check prevents untrusted other-user writable ancestors from redirecting store operations; it does not defend against concurrent tampering by the same user or root. Atomic updates use a private same-owner POSIX directory, no-follow reads and a short exclusive lock. Unresolved records are not expired. An in-process generation and revision own completion writes; after restart a queued/running record projects as `ambiguous` with `owner_state_unknown`, without claiming the old owner stopped. A `retained` lease state requires an execution reason that proves acquisition and intentional preservation; synthetic job errors, unverified release and release exceptions project as `unknown`. The path-free DTO separates owner state, installer child state, lease retention and overall lifetime uncertainty with fixed outcome/reason/cleanup codes. -`src/cli/codex-cli-update.ts` records confirmed CLI apply in that ledger and exposes `status ` from the same protected record, without requiring a GUI token. `src/server/management/codex-cli-update-routes.ts` exposes a proof-bound read-only plan, confirmed human dashboard session (`hasHumanDashboardSession`) plus trusted-loopback apply admission, and authenticated status. Plan and job reads use ordinary management admission. HTTP apply returns after durable admission; disconnect does not cancel the worker. Dashboard display data is never accepted back as a candidate, prefix, executable or managed-selection attestation. There is no dashboard job UI in this contract. `tests/server/persistent-dashboard-consent.test.ts` covers management admission and the apply service boundary. +`src/cli/codex-cli-update.ts` records confirmed CLI apply in that ledger and exposes `status ` from the same protected record, without requiring a GUI token. `src/server/management/codex-cli-update-routes.ts` exposes a proof-bound read-only plan, confirmed apply from a current paired dashboard session on trusted-loopback ingress, and authenticated status. Apply requires both `hasHumanDashboardSession` and `sessionControl.isPaired`; an automatically issued loopback session, remembered-admin session, raw admin token or Tailscale-identity session does not suffice. Origin, CSRF and `confirmed: true` checks remain required. Plan and job reads use ordinary management admission. HTTP apply returns after durable admission; disconnect does not cancel the worker. Dashboard display data is never accepted back as a candidate, prefix, executable or managed-selection attestation. There is no dashboard job UI in this contract. `tests/server/persistent-dashboard-consent.test.ts` covers refusal and a genuinely issued-and-redeemed local pairing session reaching one apply admission without running an installer. + +Standalone pairing requires the configured literal `127.0.0.1` or `::1` loopback origin, as described in [Standalone pairing delivery](remote-link.md#standalone-pairing-delivery). A `localhost` alias, wildcard bind or connected-client role cannot obtain that local pairing authority; remote pairing does not bypass trusted-loopback ingress. Operators without an eligible HTTP pairing path use `ocx system codex-cli-update plan` and `ocx system codex-cli-update apply --plan-id <32-hex> --yes` locally. Pairing establishes protected-home write authority, not human identity or a boundary against unrestricted same-user or elevated processes; the local CLI remains independently available to that owner. On Windows, `src/codex/cli-update-job-store-windows.ts` maps the same ledger contract onto NTFS handle semantics and the public admit/read/finish API in `src/codex/cli-update-job.ts` dispatches to it asynchronously. Each ancestor from the volume root is opened relative to its already-held parent — never a re-parsed path — and refused on any reparse attribute, then evaluated under the "trusted-ancestry" ACL policy (owner is the serving token or SYSTEM/Administrators/TrustedInstaller; untrusted grantees may hold read and, on admin-owned ancestors only, broad create-only rights). The config-root parent and a pre-existing config root are verified under the "write-private" policy and are never rehardened; only a directory the call itself creates is icacls-hardened and held to the strict "private" rule. File identity is the volume serial plus 128-bit file ID with single link and no pending delete, the dev:ino analog. Ledger writes go through a private hardened temp that is byte/ACL/identity-verified, renamed while its handle is held, re-read through the published name, and flushed at the directory. The admission mutex is a mkdir lock plus an owner record released only while both held pins still show this call identity and content, so a leftover lock blocks later admission exactly like POSIX. ACL snapshots are taken per phase through the shared bounded PowerShell read in `src/lib/windows-owner-acl.ts` (async variant), not per path. Pins bind each object to its verified state for the operation and the private DACL confines writers, but this is not a proof against concurrent mutation by the serving account or an elevated process, which sit inside the trust boundary as on POSIX. The backend is x64-only; cross-account (other-SID) and ARM64 behavior are unverified. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 898df8af8d1..d6d9a93de48 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -597,4 +597,4 @@ keep their existing no-catalog-refresh behavior. The regression suite is Automatic account exhaustion and recovery use the [spendable Codex credit evidence contract](providers/openai-tiers.md#spendable-codex-credits), including independent freshness, upstream refusal, and reset-ticket separation. The PINNED badge confirms before sending a null selection, which clears the pin and returns routing to automatic while bound threads keep their captured account. The dashboard also sends `expectedPinnedAccountId`; the persisted mutation rejects a changed pin with `account_pin_changed` before writing. Refused or uncertain releases reload `/active` and close the old confirmation without claiming that the prior selection survived. -`src/server/management/subagent-model-routes.ts` owns roster/picker and optional Claude force updates. The route registry declares its GET/PUT ownership; `agent-settings-routes.ts` only lazy-loads and delegates the matching path. GET includes `force`, exposed-only `forceAvailable`, and bounded `forceStatus`; PUT accepts force alone, null clears, and omission preserves it. Force-only updates do not regenerate the roster or converge Codex catalogs. The Subagents page keeps save failures visible and disables overlapping force writes. `src/server/management/codex-cli-update-routes.ts` serves the same proof-bound Codex CLI plan to the dashboard, admits apply only from a confirmed human dashboard session (`hasHumanDashboardSession`) on trusted loopback ingress, and exposes authenticated job status. The job ID is a lookup key, never authorization; CLI status reads the protected common job record directly. The route registry declares the three paths and the CLI/local-transport parity; `tests/server/persistent-dashboard-consent.test.ts` exercises its mutating session-only routes through management admission, including current-session logout. +`src/server/management/subagent-model-routes.ts` owns roster/picker and optional Claude force updates. The route registry declares its GET/PUT ownership; `agent-settings-routes.ts` only lazy-loads and delegates the matching path. GET includes `force`, exposed-only `forceAvailable`, and bounded `forceStatus`; PUT accepts force alone, null clears, and omission preserves it. Force-only updates do not regenerate the roster or converge Codex catalogs. The Subagents page keeps save failures visible and disables overlapping force writes. `src/server/management/codex-cli-update-routes.ts` serves the same proof-bound Codex CLI plan to the dashboard, admits apply only from a confirmed, currently paired dashboard session (`hasHumanDashboardSession` and `sessionControl.isPaired`) on trusted loopback ingress, and exposes authenticated job status. The job ID is a lookup key, never authorization; CLI status reads the protected common job record directly. The route registry declares the three paths and the CLI/local-transport parity; `tests/server/persistent-dashboard-consent.test.ts` exercises its mutating session-only routes through management admission, including current-session logout. diff --git a/tests/server/persistent-dashboard-consent.test.ts b/tests/server/persistent-dashboard-consent.test.ts index fd50607db64..53c054abfe2 100644 --- a/tests/server/persistent-dashboard-consent.test.ts +++ b/tests/server/persistent-dashboard-consent.test.ts @@ -13,6 +13,7 @@ import { MANAGEMENT_ROUTES } from "../../src/server/management/route-registry"; import { trustedLoopbackForIngress } from "../../src/server/index/serve-options"; import * as jobs from "../../src/codex/cli-update-job"; import * as workers from "../../src/codex/cli-update-job-worker"; +import { consumeGuiPairingGrant, createGuiPairingGrant } from "../../src/server/gui-session"; const ORIGIN = "http://127.0.0.1:10100"; const ADMIN = "ocx_admin_consent_fixture"; @@ -154,6 +155,52 @@ test("ordinary loopback GUI apply cannot self-authorize an update", async () => expect(effects).toEqual([]); }); +function pairLocalDashboard() { + config.port = Number(new URL(ORIGIN).port); + if (!state.available) throw new Error("fixture management authority unavailable"); + const issued = createGuiPairingGrant(ORIGIN, config, state); + const exchange = request("/opencodex-session", "POST", "", { grant: issued.grant }); + exchange.headers.delete("X-OpenCodex-API-Key"); + const session = consumeGuiPairingGrant(exchange, + { grant: issued.grant }, config, state, Date.now(), { + ingress: "public", peerAddress: "127.0.0.1", tailscaleUser: null, browserOrigin: ORIGIN, + }); + if (!session || "allowed" in session) throw new Error("fixture pairing redemption refused"); + expect(session.issuance).toBe("pairing"); + expect(state.pairingGrants.size).toBe(0); + return { session, cookie: "" }; +} + +test("a redeemed local pairing session admits one confirmed CLI update", async () => { + const device = pairLocalDashboard(); + const response = await dispatch(sessionRequest(device, "/api/codex/cli-update/apply", "POST", APPLY)); + expect(response.status).toBe(202); + expect(await response.json()).toEqual({ job: JOB }); + expect(effects).toEqual(["admit", "run", "worker"]); + expect(jobs.admitCodexCliUpdateJob).toHaveBeenCalledTimes(1); + expect(workers.runCodexCliUpdateApplyInWorker).toHaveBeenCalledTimes(1); +}); + +test("paired CLI apply retains confirmation, ingress, origin, CSRF and current-session gates", async () => { + const device = pairLocalDashboard(); + const makeRequest = () => sessionRequest(device, "/api/codex/cli-update/apply", "POST", APPLY); + expect((await dispatch(sessionRequest(device, "/api/codex/cli-update/apply", "POST", { ...APPLY, confirmed: false }))).status).toBe(400); + expect((await dispatch(makeRequest(), false)).status).toBe(403); + const wrongOrigin = makeRequest(); + wrongOrigin.headers.set("Origin", "http://localhost:10100"); + expect((await dispatch(wrongOrigin)).status).toBe(401); + const wrongCsrf = makeRequest(); + wrongCsrf.headers.set("X-OpenCodex-CSRF-Token", "wrong"); + expect((await dispatch(wrongCsrf)).status).toBe(401); + const invalidSession = makeRequest(); + invalidSession.headers.set("X-OpenCodex-API-Key", "unknown-session"); + expect((await dispatch(invalidSession)).status).toBe(401); + if (!state.available) throw new Error("fixture management authority unavailable"); + state.sessions.get(device.session.token)!.expiresAt = Date.now() - 1; + expect((await dispatch(makeRequest())).status).toBe(401); + expect(effects).toEqual([]); +}); + test("remembered CLI plan and job reads retain ordinary management admission", async () => { const device = await remember(true); const plan = spyOn(workers, "runCodexCliUpdatePlanInWorker").mockRejectedValue(new Error("Fixture plan unavailable")); From 7d24cf3f8413a251006abbfef664ca2301266fd3 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 19:21:11 +0900 Subject: [PATCH 3/5] test(security): pair published management apply fixture Obtain and redeem a real CLI pairing grant before exercising lost-response update admission. Assert that ordinary sessions are refused without creating the durable ledger, while retaining all existing retry and status checks. --- .../codex-cli-update-apply.test.ts | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/tests/codex-integration/codex-cli-update-apply.test.ts b/tests/codex-integration/codex-cli-update-apply.test.ts index 1e01a75c14a..dea2f9b1fb9 100644 --- a/tests/codex-integration/codex-cli-update-apply.test.ts +++ b/tests/codex-integration/codex-cli-update-apply.test.ts @@ -88,7 +88,14 @@ async function reserveLoopbackPort(): Promise { } function startFixtureManagementServer(f: ReturnType, port: number, gate: string) { - const { node, env } = managementTestEnv(f); + const { node, configDir, env } = managementTestEnv(f); + // The operator pairing CLI must observe the same configured literal origin + // as the fixture listener; a start-only --port override is not authority. + mkdirSync(configDir, { recursive: true, mode: 0o700 }); + const configPath = join(configDir, "config.json"); + if (!existsSync(configPath)) { + writeFileSync(configPath, JSON.stringify({ port, hostname: "127.0.0.1" }), { mode: 0o600 }); + } env.OCX_FIXTURE_NPM_GATE = gate; const child = spawn(node, [repoPath("bin", "ocx.mjs"), "start", "--port", String(port)], { cwd: f.root, env, detached: true, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], @@ -147,6 +154,28 @@ async function stopFixtureManagementServer(server: ReturnType, base: string) { + const origin = new URL(base).origin; + const { node, env } = managementTestEnv(f); + const result = spawnSync(node, [repoPath("bin", "ocx.mjs"), "gui", "pair", "--origin", origin, "--json"], { + cwd: f.root, encoding: "utf8", env, timeout: 60_000, + }); + if (result.error || result.status !== 0) throw new Error("fixture operator pairing CLI failed"); + const issued = JSON.parse(result.stdout) as { kind?: string; grant?: string; browserOrigin?: string; serverOrigin?: string }; + if (issued.kind !== "created" || typeof issued.grant !== "string" + || !/^ocx_pair_[A-Za-z0-9_-]{43}$/.test(issued.grant) + || issued.browserOrigin !== origin || issued.serverOrigin !== origin) { + throw new Error("fixture pairing grant invalid"); + } + const response = await fetch(`${base}/opencodex-session`, { method: "POST", + headers: { origin, "content-type": "application/json" }, body: JSON.stringify({ grant: issued.grant }) }); + return readFixtureGuiSession(response, origin); +} + +async function readFixtureGuiSession(response: Response, origin: string) { if (!response.ok) throw new Error(`fixture GUI session refused (${response.status})`); const html = await response.text(); const read = (name: string) => new RegExp(` { const requestId = "lost-response-management-e2e-01"; const applyBody = { requestId, planId: plan.planId, confirmed: true }; + expect((await postFixtureApply(base, session, applyBody)).status).toBe(403); + expect(existsSync(join(f.home, "ocx", "codex-cli-update-jobs", "jobs.json"))).toBe(false); + session = await pairFixtureGuiSession(f, base); lostSocket = await sendLostApplyResponse(base, session, applyBody); jobId = await waitForDurableAdmission(join(f.home, "ocx"), requestId); lostSocket.destroy(); lostSocket = undefined; From 046af1a9338b440c7803de945d21c734cca9070e Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 31 Oct 2026 01:53:57 +0900 Subject: [PATCH 4/5] perf(responses): avoid hashing bounded native output identities --- scripts/test-layout/layout-responses.json | 1 + src/server/responses/native-response-json.ts | 17 +++++++-- .../responses/native-response-output.ts | 3 +- structure/transports/inventory.md | 10 +++++ structure/transports/streaming-health.md | 6 +-- tests/fixtures/test-layout-expected.json | 1 + .../responses-native-output-identity.test.ts | 38 +++++++++++++++++++ 7 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 tests/responses/responses-native-output-identity.test.ts diff --git a/scripts/test-layout/layout-responses.json b/scripts/test-layout/layout-responses.json index 306719dbad2..0966bc5bc37 100644 --- a/scripts/test-layout/layout-responses.json +++ b/scripts/test-layout/layout-responses.json @@ -1,4 +1,5 @@ { + "responses-native-output-identity.test.ts": "responses", "graceful-max-output-tokens.test.ts": "responses", "graceful-max-output-tokens-exclusions.test.ts": "responses", "advisor-web-search.test.ts": "advisor", diff --git a/src/server/responses/native-response-json.ts b/src/server/responses/native-response-json.ts index 1692c6b25d1..b94de15a1c5 100644 --- a/src/server/responses/native-response-json.ts +++ b/src/server/responses/native-response-json.ts @@ -5,10 +5,19 @@ export function nativeResponseRecord(value: unknown): value is Record Array.isArray(item) ? `[${item.map(canonical).join(",")}]` + : nativeResponseRecord(item) ? `{${Object.keys(item).sort().map(key => `${JSON.stringify(key)}:${canonical(item[key])}`).join(",")}}` + : JSON.stringify(item) ?? "null"; +const fingerprint = (json: string): string => createHash("sha256").update(json).digest("hex"); + /** Compare JSON content by value: object-key order is irrelevant, array order is not. */ export function nativeResponseFingerprint(value: unknown): string { - const canonical = (item: unknown): string => Array.isArray(item) ? `[${item.map(canonical).join(",")}]` - : nativeResponseRecord(item) ? `{${Object.keys(item).sort().map(key => `${JSON.stringify(key)}:${canonical(item[key])}`).join(",")}}` - : JSON.stringify(item) ?? "null"; - return createHash("sha256").update(canonical(value)).digest("hex"); + return fingerprint(canonical(value)); +} + +/** Keep transient body keys within the existing 64-byte digest payload bound. */ +export function nativeResponseOutputIdentity(item: Record): string { + if (typeof item.id === "string") return `id:${item.id}`; + const json = canonical(item); + return Buffer.byteLength(json, "utf8") <= 64 ? `json:${json}` : `body:${fingerprint(json)}`; } diff --git a/src/server/responses/native-response-output.ts b/src/server/responses/native-response-output.ts index 04cce41e54e..29ef53a50fe 100644 --- a/src/server/responses/native-response-output.ts +++ b/src/server/responses/native-response-output.ts @@ -1,4 +1,4 @@ -import { nativeResponseFingerprint as injectionFingerprint, nativeResponseRecord as record } from "./native-response-json"; +import { nativeResponseFingerprint as injectionFingerprint, nativeResponseOutputIdentity as identity, nativeResponseRecord as record } from "./native-response-json"; type Frame = Record; /** @@ -10,7 +10,6 @@ export function nativeResponseOutput(done: ReadonlyMap, terminal: const observed = [...done.entries()].sort((a, b) => a[0] - b[0]).map(([, item]) => item); if (terminal == null || (Array.isArray(terminal) && !terminal.length)) return observed; if (!Array.isArray(terminal) || terminal.some(item => !record(item))) throw new Error("Invalid native response output."); - const identity = (item: Frame) => typeof item.id === "string" ? `id:${item.id}` : `body:${injectionFingerprint(item)}`; const positions = new Map(); observed.forEach((item, index) => { const key = identity(item); diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index d0ea8de7b71..22fb88cb6aa 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -32,6 +32,16 @@ Anthropic managed sends recheck [family admission](../providers/anthropic-accoun ## Transport inventory +### Native output identity + +`src/server/responses/native-response-json.ts` compares ID-less output records by canonical JSON. +Transient keys use `json:` plus that JSON when it fits 64 UTF-8 bytes; larger records retain +`body:` plus SHA-256. String IDs retain the distinct `id:` namespace. Body keys remain at most +69 bytes, without skipping duplicate, relative-order or same-ID content checks in +`native-response-output.ts`. Exported fingerprints retain their SHA-256 format. The unchanged +750,000-item replay cases and `tests/responses/responses-native-output-identity.test.ts` cover +key-order equivalence, both key families, sparse output, contradictions and byte bounds. + The sections above cover the transports with load-bearing invariants. The rest of the transport surface is listed here so a maintainer can find the owner without grepping: diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index b9ec2c7a0b8..419316447b4 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -550,9 +550,9 @@ charged against the unchanged 32 MiB serialized history budget. The existing body-persistence eligibility and accepted-only steering commit rules still apply. `tests/responses/ws-steering-stability.test.ts` binds these deadline and replay contracts to deterministic clocks and a synthetic real-handler continuation test. -`src/server/responses/native-response-json.ts` owns content comparison without -importing either control owner, keeping the replay dependency graph acyclic. -Injection retains its existing helper export names and comparison semantics. +`src/server/responses/native-response-json.ts` owns content comparison and +[bounded output identity](inventory.md#native-output-identity) without importing either control owner. +Injection retains its existing helper export names and SHA-256 comparison semantics. ## Steering settings, public API and diagnostic probe diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 7939d5f9243..bcd5f0cc695 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,6 @@ { "lmstudio-system-policy.test.ts": "config", "openai-chat-single-system.test.ts": "adapters/openai", "update-reclaim-authorizer.test.ts": "update", "update-capability-policy.test.ts": "update", "update-service-home.test.ts": "update", "update-service-home-policy.test.ts": "update", "update-home-recovery.test.ts": "update", "update-recovery-authority.test.ts": "update", "update-recovery-boundaries.test.ts": "update", "update-home-launcher.test.ts": "update", "update-home-worker.test.ts": "update", "update-mutation-boundaries.test.ts": "update", "update-mutation-guard.test.ts": "update", "update-guarded-ops.test.ts": "update", "update-effect-authority.test.ts": "update", "update-windows-wrapper-authority.test.ts": "update", "update-mutation-scanner.test.ts": "update", "refreshed-upstream-deadlines.test.ts": "responses", "local-upstream-deadlines.test.ts": "responses", "service-desktop-recovery.test.ts": "service", "provider-input-modalities.test.ts": "providers", "turn-membership.test.ts": "responses", "turn-membership-ingress.test.ts": "responses", "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "codex-role-configured-credentials.test.ts": "server", + "responses-native-output-identity.test.ts": "responses", "advisor-caller-policy.test.ts": "advisor", "advisor-caller-policy-sidecar-authority.test.ts": "advisor", "advisor-caller-policy-vision.test.ts": "advisor", diff --git a/tests/responses/responses-native-output-identity.test.ts b/tests/responses/responses-native-output-identity.test.ts new file mode 100644 index 00000000000..fbe1deb6232 --- /dev/null +++ b/tests/responses/responses-native-output-identity.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test"; +import { nativeResponseOutput } from "../../src/server/responses/native-response-output"; +import { nativeResponseFingerprint, nativeResponseOutputIdentity } from "../../src/server/responses/native-response-json"; + +test("native output identity preserves key-order equivalence and duplicate refusal", () => { + for (const text of ["short", "한".repeat(40), "x".repeat(1024)]) { + const a = { role: "assistant", content: text }; + const b = { content: text, role: "assistant" }; + expect(nativeResponseOutputIdentity(a)).toBe(nativeResponseOutputIdentity(b)); + expect(nativeResponseOutput(new Map([[0, a]]), [b])).toEqual([b]); + expect(() => nativeResponseOutput(new Map([[0, a], [1, b]]), [a])).toThrow("Duplicate native completed output identity"); + expect(() => nativeResponseOutput(new Map(), [a, b])).toThrow("Duplicate native terminal output identity"); + } +}); + +test("native output identity keeps sparse mixed items ordered and rejects contradictions", () => { + const id = { id: "json:{}", role: "assistant", content: "first" }; + const small = { role: "assistant", content: "second" }; + const large = { role: "assistant", content: "third".repeat(100) }; + const done = new Map([[0, id], [2, small], [9, large]]); + expect(nativeResponseOutput(done, [id, large])).toEqual([id, small, large]); + expect(() => nativeResponseOutput(done, [large, id])).toThrow("contradicts"); + expect(() => nativeResponseOutput(done, [{ ...id, content: "changed" }])).toThrow("contradicts"); + expect(nativeResponseOutput(new Map(), [{ id: "json:{}" }, {}])).toEqual([{ id: "json:{}" }, {}]); +}); + +test("native body identity stays within the former digest key byte bound", () => { + for (const content of ["", "x".repeat(55), "x".repeat(56), "한".repeat(20), "한".repeat(1000)]) { + expect(Buffer.byteLength(nativeResponseOutputIdentity({ content }), "utf8")).toBeLessThanOrEqual(69); + } + expect(nativeResponseOutputIdentity({ content: [1, 2] })).not.toBe(nativeResponseOutputIdentity({ content: [2, 1] })); +}); + +test("the exported native fingerprint retains its SHA-256 contract", () => { + expect(nativeResponseFingerprint({ b: 2, a: 1 })).toBe("43258cff783fe7036d8a43033f830adfc60ec037382473548ac742b888292777"); + expect(nativeResponseFingerprint({ a: [1, 2] })).not.toBe(nativeResponseFingerprint({ a: [2, 1] })); + expect(nativeResponseFingerprint({ b: null, a: { y: "한", x: true } })).toBe(nativeResponseFingerprint({ a: { x: true, y: "한" }, b: null })); +}); From 1a73d02478f5c81342b1903d2aef4df02f1795c7 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 31 Oct 2026 12:50:26 +0900 Subject: [PATCH 5/5] test(responses): pin native identity UTF-8 byte boundary (cherry picked from commit 03c107599d7f276a2b48f590d3cf98e4e178dcfb) --- .../responses-native-output-identity.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/responses/responses-native-output-identity.test.ts b/tests/responses/responses-native-output-identity.test.ts index fbe1deb6232..5978668337e 100644 --- a/tests/responses/responses-native-output-identity.test.ts +++ b/tests/responses/responses-native-output-identity.test.ts @@ -36,3 +36,19 @@ test("the exported native fingerprint retains its SHA-256 contract", () => { expect(nativeResponseFingerprint({ a: [1, 2] })).not.toBe(nativeResponseFingerprint({ a: [2, 1] })); expect(nativeResponseFingerprint({ b: null, a: { y: "한", x: true } })).toBe(nativeResponseFingerprint({ a: { x: true, y: "한" }, b: null })); }); + +test.each([ + { label: "ASCII 64", content: "x".repeat(50), bytes: 64, prefix: "json:" }, + { label: "ASCII 65", content: "x".repeat(51), bytes: 65, prefix: "body:" }, + { label: "UTF-8 64", content: "é".repeat(25), bytes: 64, prefix: "json:" }, + { label: "UTF-8 65", content: "é".repeat(25) + "x", bytes: 65, prefix: "body:" }, +])("native identity uses the canonical byte boundary: $label", ({ content, bytes, prefix }) => { + const item = { content }; + const json = JSON.stringify(item); + expect(Buffer.byteLength(json, "utf8")).toBe(bytes); + const identity = nativeResponseOutputIdentity(item); + expect(identity.startsWith(prefix)).toBe(true); + if (prefix === "json:") expect(identity).toBe(`json:${json}`); + else expect(identity).toMatch(/^body:[a-f0-9]{64}$/); + expect(Buffer.byteLength(identity, "utf8")).toBeLessThanOrEqual(69); +});