diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 81d8950e5fc..d58e28c3552 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -301,6 +301,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/scripts/test-layout/layout-responses.json b/scripts/test-layout/layout-responses.json index 5267d3b4a49..60f1e7075ea 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/skills/ocx/references/01_surface_observe-system.md b/skills/ocx/references/01_surface_observe-system.md index a2e086b04e7..363e5d2b026 100644 --- a/skills/ocx/references/01_surface_observe-system.md +++ b/skills/ocx/references/01_surface_observe-system.md @@ -437,7 +437,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 5ad0304e337..1a3f2196007 100644 --- a/src/cli/capabilities-base.ts +++ b/src/cli/capabilities-base.ts @@ -636,7 +636,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/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/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 0c48f6e5973..56b3331dea2 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/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/cli-management.md b/structure/cli-management.md index f291df75e71..03469aac082 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -187,7 +187,7 @@ 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. ## Keyless CC-Switch provider import diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index b11e93bb80c..ed9dfeff068 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -499,7 +499,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 192569907e9..6cd30a5bc4e 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/structure/transports/inventory.md b/structure/transports/inventory.md index bd662b60a22..67235469b37 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/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; diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c2f191980c6..bcf9a0bcd61 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -8,6 +8,7 @@ "codex-installation-turn-limit.test.ts": "responses", "claude-installation-helper.test.ts": "claude-integration", "codex-role-configured-credentials.test.ts": "server", + "responses-native-output-identity.test.ts": "responses", "provider-anthropic-usage-windows.test.ts": "providers", "anthropic-active-limit-routing.test.ts": "adapters/anthropic", "advisor-caller-policy.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..5978668337e --- /dev/null +++ b/tests/responses/responses-native-output-identity.test.ts @@ -0,0 +1,54 @@ +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 })); +}); + +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); +}); diff --git a/tests/server/management-route-registry.test.ts b/tests/server/management-route-registry.test.ts index 742a4d21f44..1fdf813cb9e 100644 --- a/tests/server/management-route-registry.test.ts +++ b/tests/server/management-route-registry.test.ts @@ -216,6 +216,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); @@ -237,6 +238,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); @@ -262,6 +264,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..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"; @@ -143,15 +144,61 @@ 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((await dispatch(sessionRequest(device, "/api/codex/cli-update/apply", "POST", APPLY))).status).toBe(403); + 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 () => {