From 4f527e68b3b39fd237fafb377e5fa803ef9c04a7 Mon Sep 17 00:00:00 2001 From: jun Date: Thu, 27 Aug 2026 23:46:49 +0900 Subject: [PATCH 01/12] =?UTF-8?q?docs(devlog):=20remote=20hub=20mode=20?= =?UTF-8?q?=E2=80=94=20research,=20design=20draft,=20phased=20roadmap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_plan/260827_remote_hub/000_research.md | 95 ++++++++ devlog/_plan/260827_remote_hub/010_design.md | 216 ++++++++++++++++++ devlog/_plan/260827_remote_hub/020_roadmap.md | 44 ++++ 3 files changed, 355 insertions(+) create mode 100644 devlog/_plan/260827_remote_hub/000_research.md create mode 100644 devlog/_plan/260827_remote_hub/010_design.md create mode 100644 devlog/_plan/260827_remote_hub/020_roadmap.md diff --git a/devlog/_plan/260827_remote_hub/000_research.md b/devlog/_plan/260827_remote_hub/000_research.md new file mode 100644 index 0000000000..18d5404bf6 --- /dev/null +++ b/devlog/_plan/260827_remote_hub/000_research.md @@ -0,0 +1,95 @@ +# 000 — Research: remote hub mode (evidence base) + +Unit: 260827_remote_hub · Branch: codex/remote-hub-design · Status: research + +## Motivation (user request, 2026-08-27) + +Run one ocx as a central HUB (Oracle VM / Mac mini / Docker — any machine), keep every +provider key, OAuth credential, and shared config there, and let other machines connect +with only a pointer + token ("ocx connect "). The dashboard on a client +machine must still work at localhost:10100 with a two-plane split: shared pages operate +the hub, machine pages operate local file integration. Explicit constraint from the user: +today a Tailscale-bound GUI is unusable for some operations even WITH the admin token — +the design must fix remote GUI operability without collapsing the consent boundary. + +## In-repo evidence (verified 2026-08-27 on dev @ 8b1b65b8d) + +- Non-loopback bind forces the data token: `isApiAuthRequired` returns true whenever the + bind hostname is not loopback (src/server/auth-cors.ts:260-262), and startup refuses a + public bind without a configured data credential. +- The reported remote-GUI defect is real and structural: `issueGuiSession` returns null + when `isApiAuthRequired(config)` is true AND additionally requires a loopback Host + (src/server/management-auth.ts, issueGuiSession). So on a remote bind the principal + `gui-session` is unobtainable; consent-bearing routes that require + `ctx.principal === "gui-session"` (src/server/management/sidebar-routes.ts:42, + src/server/management/codex-prompt-routes.ts:298) answer 403 even to the admin token. + That 403 is BY DESIGN for the admin token (AGENTS.md user-consent boundary) — the defect + is only that a browser can never mint a session remotely. +- `managementRequestOrigin` returns null for a non-loopback Host when apiAuth is NOT + required (src/server/auth-cors.ts:118-129); when apiAuth IS required it derives the + origin from the request, which a TLS terminator breaks (http observed vs https public). +- The GUI attaches credentials only same-origin: `needsApiAuth` refuses absolute + cross-origin URLs (gui/src/api.ts:53-60). A two-plane GUI therefore needs an explicit + multi-target API layer, not a base-URL swap. +- The GUI needs a secure context in places: `crypto.subtle.digest` at + gui/src/log-conversation-id.ts:26, `navigator.clipboard` at + gui/src/oauth-health-display.ts:133 (with execCommand fallback). +- Injector already supports non-loopback targets: dedicated provider block with + `env_key = "OPENCODEX_API_AUTH_TOKEN"` and `model_catalog_json` requiring a LOCAL + absolute path (src/codex/inject.ts:186-247, 622+). +- `GET /api/catalog` and `GET /api/client-config` already exist behind management auth + (src/server/management/model-routes.ts:334-420). +- Headless OAuth exists: `oauthOpenBrowser: false` (src/oauth/open-browser-choice.ts) and + `POST /api/oauth/login/code` (src/server/management/oauth-account-routes.ts:208). +- Allowlist-listener precedent: the unauthenticated loopback listener enumerates exactly + the routes it serves (src/server/index.ts, loopbackRouteAllowed) — the machine-plane + listener should copy this failure mode (default-404). +- Token-file delivery precedent: `OCX_API_TOKEN_FILE` (src/lib/service-secrets.ts, + src/service.ts:1571+). +- CLI already talks to the management API over HTTP with injectable baseUrl + (src/cli/runtime-api.ts, RuntimeApiDeps.baseUrl) — client-mode remote management + commands are a URL + credential change, not a new client. + +## External evidence (Luna swarm, 3 lanes, sources opened 2026-08-27) + +Peer proxies separate UI sessions from master keys: +- LiteLLM: LITELLM_MASTER_KEY for API/admin, separate UI login minting expiring + virtual keys; per-user/per-device virtual keys with budgets, central key custody. + https://docs.litellm.com.cn/docs/proxy/ui , virtual_keys.md / access_control.md in + BerriAI/litellm-docs (opened 2026-08-27). +- sub2api: admin web UI uses JWT session; automation uses a separate global Admin API + Key (x-api-key). https://github.com/Wei-Shaw/sub2api (opened 2026-08-27). +- One API broken-access-control reports (#2410, #2423) show central key custody makes + route-level authz the main defense. + +Tailscale transport facts (official docs, verified dates in page footers): +- `tailscale serve` = tailnet-only reverse proxy to a localhost backend; injects + Tailscale-User-* identity headers; backend must bind loopback or headers are + spoofable. https://tailscale.com/docs/features/tailscale-serve +- `tailscale cert` issues public CA certs only for the ts.net FQDN (not bare MagicDNS + short names); names land in Certificate Transparency logs. + https://tailscale.com/docs/how-to/set-up-https-certificates +- Funnel is public-internet exposure (ports 443/8443/10000) — out of scope here. + +Browser platform facts (MDN/WHATWG/IETF, opened 2026-08-27): +- Plain-HTTP non-localhost origins are NOT secure contexts: no crypto.subtle, no + async clipboard, Secure cookies unavailable. http://localhost IS potentially + trustworthy. https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Secure_Contexts +- Header-token SPAs avoid ambient-cookie CSRF but still need exact-origin allowlists + and Origin checks on mutations (WHATWG Fetch; RFC 9700 OAuth BCP). +- RFC 8628 device flow is the reference pattern for headless-hub OAuth; ocx's + oauthOpenBrowser:false + /api/oauth/login/code is already equivalent in shape. + +## Design consequences (carried into 010) + +1. Two credential worlds stay separate: data-plane admission (client machines) vs + management (admin token / gui-session). Peers (LiteLLM, sub2api) validate this split. +2. Remote GUI needs a NEW session-issuance path, not a weakening of requireManagementAuth: + the loopback-only refusal in issueGuiSession is the single gate to generalize. +3. HTTPS via tailscale serve against a loopback-only management ingress is the + recommended browser path; plain-HTTP tailnet operation must exist as a documented + opt-in because usability on a private tailnet was the user's explicit complaint. +4. localhost:10100 client GUI + direct-to-hub shared plane is cross-origin; the hub + needs management CORS for an allowlisted client origin, or the client listener + relays. Both appear in 010 with the relay constrained to a fixed target. + diff --git a/devlog/_plan/260827_remote_hub/010_design.md b/devlog/_plan/260827_remote_hub/010_design.md new file mode 100644 index 0000000000..c295007e9d --- /dev/null +++ b/devlog/_plan/260827_remote_hub/010_design.md @@ -0,0 +1,216 @@ +# 010 — Design: remote hub mode (hub / client / two-plane GUI) + +Unit: 260827_remote_hub · Status: design draft (pre-audit) +Drafted by a sol-high subagent against dev @ 8b1b65b8d; every file:line claim +below re-verified by the main session on 2026-08-27. + +## 0. Runtime roles + +```text +standalone (default) today's behavior, untouched — no role configured, nothing changes +hub full server; provider keys/OAuth/routing/usage/logs live here +client no /v1 data plane, no provider adapters; thin loopback GUI + + machine integration plane; inference goes DIRECTLY to the hub +``` + +Client traffic never routes through the local listener — Codex/Claude talk straight to +`hub:10100/v1`. The client process is a remote control + file installer, idle otherwise. + +## 1. Goals / non-goals + +Goals: any machine as hub (Linux/systemd, macOS/launchd, Docker); clients keyless +(admission token only, never provider/admin credentials); single source of truth on the +hub; `ocx connect/disconnect/status`; dashboard always at localhost:10100 on clients; +remote GUI fully operable over Tailscale WITHOUT weakening the consent boundary; +injector/journal/restore reuse; in-repo (no separate repository). + +Non-goals: multi-hub replication/failover; provider execution on connected clients; +public-internet exposure preset (Funnel out of scope); generic reverse proxy in the +client listener; cryptographic human-click proof (AGENTS.md already concedes a local +process can drive a browser — the enforceable contract is that admin-token alone is +never promoted to gui-session). + +## 2. Architecture + +```text + HUB (any machine) + ┌───────────────────────────────────────┐ +Codex/Claude ──▶│ /v1/* (data token, x-opencodex-api-key)│ + │ providers · OAuth · routing · catalog │ + │ /api/* (shared management plane) │ + │ optional loopback mgmt ingress :10101 │◀─ tailscale serve (HTTPS) + └──────────────────┬────────────────────┘ + │ tailnet +┌──────────────────────────────────┴────────────────────────────┐ +│ CLIENT │ +│ browser → http://localhost:10100 │ +│ ├─ shared pages ──────────▶ hub /api/* (direct HTTPS │ +│ │ or fixed-target local relay) │ +│ └─ machine pages ──────────▶ localhost /api/machine/* │ +│ thin listener: GUI assets, machine API, relay; NO /v1 │ +│ derived files: config.toml · opencodex-catalog.json · journal │ +└───────────────────────────────────────────────────────────────┘ +``` + +Placement: new leaf `src/client/` (connection state, catalog fetch, machine listener) +plus a narrow protocol module. Core-path rule respected: router/lifecycle/responses-core +import nothing new; hub-side activation composes in `src/server/index.ts` and must not +add an await inside the guarded synchronous window (tests/core-lab-boundary.test.ts). + +## 3. Security model + +### Credential classes (unchanged classes, new scoping) + +| Credential | Lives | Grants | Consent authority | +|---|---|---|---| +| Provider keys / OAuth | hub only | upstream calls | — | +| Data admission token (env OPENCODEX_API_AUTH_TOKEN; per-client via config.apiKeys) | hub + that client | /v1/* only | none | +| Admin token | hub only | /api/*管理 | NEVER consent routes | +| gui-session | hub memory + browser | /api/* incl. consent routes | yes (origin+CSRF bound) | + +Per-client keys ride the existing `config.apiKeys` mechanism, still exported to Codex +as `OPENCODEX_API_AUTH_TOKEN` (env_key contract unchanged) → independent rotation and +per-machine attribution. This is the LiteLLM virtual-key / sub2api admin-key split, +which the research doc grounds. + +### The remote-GUI fix (the load-bearing change) + +Defect: `issueGuiSession` refuses when `isApiAuthRequired(config)` and demands a +loopback Host, so a remote bind can never mint the `gui-session` principal; consent +routes 403 even with the admin token. That refusal was correct when "remote" implied +"unprotected"; hub mode makes remote-with-credentials a first-class state. + +Change shape — generalize the session record, not the auth gate: + +```ts +interface GuiSessionRecord { + serverOrigin: string; // canonical hub management origin + browserOrigin: string; // page that owns the session (may be http://localhost:10100) + csrfToken: string; + expiresAt: number; + issuance: "loopback" | "tailscale-identity" | "pairing" | "trusted-tailnet"; +} +``` + +Validation keeps every current predicate (destination = serverOrigin, claimed GUI +origin = browserOrigin, mutations need browser Origin + per-session CSRF), just split +across two origins instead of assuming they are equal. `requireManagementAuth` and +`managementPrincipal` keep sharing one predicate. Admin token is NEVER an exchange +credential for a session — entering it still unlocks ordinary management, and consent +routes stay 403 until a real session exists. Boundary preserved. + +Issuance ladder (config-selected, strictest first): +1. loopback — today's path, unchanged. +2. tailscale-identity (recommended) — a loopback-only management ingress (:10101, + GUI + /api only, allowlist style like loopbackRouteAllowed) fronted by + `tailscale serve`; trust Tailscale-User-* headers ONLY on that ingress (Tailscale + strips inbound spoofs and requires a loopback backend — official docs). Browser gets + real HTTPS (ts.net cert), so secure-context features work. +3. pairing — `ocx gui pair` on the hub prints a single-use, short-TTL, origin-bound + grant that can only mint a session. For generic HTTPS terminators. +4. trusted-tailnet (documented opt-in: remoteGui.allowInsecureHttp + trustTailnet) — + exact configured origins on a plain-HTTP tailnet may bootstrap sessions. This is the + "don't over-harden" valve the user asked for: private tailnet, sole operator → + usable GUI with one config line, warned visibly, never default. + +Supporting changes: operator-configured `hub.managementPublicOrigin` (never derive the +public origin from forwarding headers — fixes today's TLS-terminator mismatch); +management CORS must allow x-opencodex-api-key / x-opencodex-gui-origin / +x-opencodex-csrf-token for allowlisted origins with exact-origin ACAO (currently +managementCorsHeaders never widens the header list, so a cross-origin GUI cannot even +preflight — verified src/server/auth-cors.ts:199-205). + +Secure-context reality (research doc): plain-HTTP remote origins lose crypto.subtle +(used in gui/src/log-conversation-id.ts:26) and async clipboard. Two-plane helps here: +the PAGE stays on http://localhost:10100 (a secure context), so local-plane features +keep working even when the hub side is plain HTTP via the relay. + +### Threat summary + +Compromised client → its own admission token + local files; NOT provider keys, admin +token, or other clients' keys. Compromised hub → everything (accepted: that's what +"hub" means; same posture as LiteLLM/sub2api). Tailnet membership ≠ admin identity: +data plane still needs the token, management still needs admin-token/session. + +## 4. ocx connect (client mode) + +```text +ocx connect [--management-url ] [--token-env NAME | --token-stdin] + [--clients codex,claude] [--management-transport direct|relay] [--no-sync] +ocx disconnect [--keep-catalog] ocx connect status [--json] +``` + +No `--token ` flag (argv/history leak). Local state = dumb pointer: +`{ serverUrl, managementUrl?, tokenEnv, selectedClients, managementTransport, +connectedAt, protocolVersion }`. Existing local provider config stays dormant → +disconnect is fully reversible offline (restore from injector journal, no hub needed). + +Connect is a transaction: validate URL → GET /readyz (version + protocol + advertised +managementUrl) → validate data credential → download catalog → injector preflight → +atomic catalog write → inject → persist state. Any failure before the end leaves the +machine untouched. + +Catalog: add data-authenticated `GET /v1/catalog` (same serializer as /api/catalog, +ETag/If-None-Match, bounded body) — a client must not hold the management token just to +sync models. Injector: generalize input to +`{ baseUrl, requiresAdmissionToken, tokenEnv }` — the loopback/non-loopback split in +inject.ts already carries 90% of this. `ocx sync` becomes mode-aware and NEVER falls +back to local provider discovery in client mode. Management CLI (`ocx models` 등) rides +RuntimeApiDeps.baseUrl toward the hub. Claude: launcher-scoped ANTHROPIC_BASE_URL + +ANTHROPIC_AUTH_TOKEN first; persistent settings.json mutation stays a machine-plane +opt-in with ownership records. + +## 5. Machine-plane listener (client, loopback-only) + +Explicit allowlist, default-404 (same failure mode as loopbackRouteAllowed): +/healthz · /readyz · GET /api/machine/status · GET /api/machine/clients · +POST /api/machine/sync · PUT /api/machine/clients/:id · POST /api/machine/shim/* · +POST /api/machine/disconnect · POST /api/machine/hub-relay/* (opt-in only). +Mutations need local gui-session + CSRF (auto-minted on loopback, today's flow). + +Relay constraints (it is NOT a proxy): fixed destination = client.managementUrl; +path allowlist (/api/* + session bootstrap); no caller-supplied host/scheme; redirects +rejected; hop-by-hop headers stripped; management size caps; nothing logged. + +GUI: replace the single same-origin `apiBase` assumption (gui/src/api.ts needsApiAuth +refuses cross-origin credentials today) with explicit shared/machine targets; pages map +to planes; hub-down leaves the shell + machine pages alive with one stable offline state. + +## 6. Deployment recipes + +- Oracle/systemd & Mac/launchd: existing `ocx service install` path; hostname = + tailscale IP; data token via env or OCX_API_TOKEN_FILE (existing mechanism, + src/lib/service-secrets.ts); management ingress loopback + tailscale serve; never + open :10100 on the cloud firewall. +- Docker: non-root; persistent ~/.opencodex volume; token as runtime secret via + OCX_API_TOKEN_FILE; tailscale sidecar or host TLS; /healthz + /readyz probes. +- Headless OAuth: oauthOpenBrowser:false → dashboard shows the auth URL → user finishes + in any browser → POST /api/oauth/login/code (both halves already exist; RFC 8628-shaped). + +## 7. Failure modes (contract) + +Hub down → clear CLI errors, machine GUI alive, NO local-provider fallback · catalog +refresh failure → keep last-known-good + stale age · token rotation → 401 with named +cause, token never printed · protocol major mismatch → refuse before any local write · +disconnect-while-hub-down → journal-based offline restore · plain-HTTP → relay + banner. + +## 8. Roadmap → 020_roadmap.md (6 dependency-ordered phases, one PABCD cycle each) + +## 9. Open questions for the maintainer + +1. First release: require tailscale-identity/pairing for remote sessions, trustTailnet + as advanced opt-in — or ship trustTailnet as the blessed tailnet default? +2. Per-client config.apiKeys mandatory at connect, or recommended-only? +3. One public URL for /v1+/api, or separate managementUrl acceptable? +4. /v1/catalog as the data-authenticated contract vs a scoped /api/catalog exception? +5. Hub mode: disable local Codex/Claude integration by default ("hub is also a client" + as explicit switch)? +6. Session TTL: keep 5-minute GUI sessions or add renewable browser grants for remote? +7. Plain-HTTP relay in the first stack, or hardening phase after HTTPS-direct is proven? + +## Riskiest three decisions + +Remote session issuance without weakening the consent principal; browser/server origin +split across direct+relay transports; injector generalization without regressing +journal/restore ownership. + diff --git a/devlog/_plan/260827_remote_hub/020_roadmap.md b/devlog/_plan/260827_remote_hub/020_roadmap.md new file mode 100644 index 0000000000..0b8da0d081 --- /dev/null +++ b/devlog/_plan/260827_remote_hub/020_roadmap.md @@ -0,0 +1,44 @@ +# 020 — Roadmap: remote hub phases (dependency-ordered, PHASE-SPLIT-01) + +Each phase = one PABCD cycle = one reviewable PR (stack against dev; children retarget +after parents land). Decade docs 030+ get diff-level detail when their cycle's P begins +(the P re-verifies against the then-current tree before executing). + +## Phase 1 — Foundations: protocol + catalog read path (doc 030) +Runtime role types (standalone/hub/client) in config; /readyz protocol metadata +{protocol, minimumClientProtocol, managementUrl}; data-authenticated GET /v1/catalog +sharing the /api/catalog serializer, ETag, bounded body. No GUI, no local writes. +Prove: /readyz secret-free; /v1/catalog auth matrix; byte-identical serialization vs +/api/catalog; core-lab-boundary green. + +## Phase 2 — Core security: remote gui-session + management CORS (doc 040) +serverOrigin/browserOrigin session records; hub.managementPublicOrigin; issuance modes +(loopback / tailscale-identity / pairing / trusted-tailnet); cross-origin bootstrap; +management preflight header allowlist; shared validation predicate; NO admin→session +exchange. Prove: remote HTTPS page mints session; consent routes 403 to admin-token but +200 to remote session; wrong origin/CSRF/expired/replay rejected; plain HTTP refused +unless opted in. Security-review-required phase (auth surface). + +## Phase 3 — Client core: connect/disconnect/sync + injector target (doc 050) +Connect transaction; client state; catalog download/atomic placement; CodexRoutingTarget +generalization; mode-aware sync (no silent fallback); Claude launcher target; offline +journal restore. Prove: no local write before checks pass; injected config byte-shape; +disconnect restores pre-connect state hub-down; standalone output byte-compatible. + +## Phase 4 — Integration: machine listener + two-plane GUI (doc 060) +Loopback allowlist listener; /api/machine/*; shared/machine API targets in GUI; +fixed-target relay; plane-aware offline/permission states. Prove: no /v1 on the +listener; mutations need session+CSRF; hub credentials only reach the hub origin; +hub-down UI renders; GUI build/lint/i18n + browser smoke on both transports. + +## Phase 5 — Deployment integration (doc 070) +Loopback management ingress on the hub; systemd/launchd via existing service installer; +Docker recipe (volume + OCX_API_TOKEN_FILE secret); tailscale serve docs; headless OAuth +walkthrough. Prove: all three targets pass health/ready/auth'd catalog/routed response/ +remote session smoke; identity headers unspoofable past the loopback backend. + +## Phase 6 — Hardening + release gate (doc 080) +Rotation UX; skew matrix; multi-client attribution; session invalidation/rate limits; +catalog adversarial tests; relay SSRF negatives; docs-site sync (5 locales); full +typecheck/test/privacy:scan/build:gui/lint:gui; MAINTAINERS security review. + From 83dd53c00881ce8e3af883c1e9ed5331919e0f67 Mon Sep 17 00:00:00 2001 From: jun Date: Thu, 27 Aug 2026 23:55:04 +0900 Subject: [PATCH 02/12] =?UTF-8?q?docs(devlog):=20fold=205=20audit=20blocke?= =?UTF-8?q?rs=20=E2=80=94=20drop=20header-only=20trusted-tailnet,=20add=20?= =?UTF-8?q?identity=20allowlist,=20name=20session=20consumers=20and=20/v1/?= =?UTF-8?q?catalog=20admission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devlog/_plan/260827_remote_hub/010_design.md | 50 ++++++++++++++++---- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/devlog/_plan/260827_remote_hub/010_design.md b/devlog/_plan/260827_remote_hub/010_design.md index c295007e9d..85639ec0b2 100644 --- a/devlog/_plan/260827_remote_hub/010_design.md +++ b/devlog/_plan/260827_remote_hub/010_design.md @@ -35,7 +35,8 @@ never promoted to gui-session). ```text HUB (any machine) ┌───────────────────────────────────────┐ -Codex/Claude ──▶│ /v1/* (data token, x-opencodex-api-key)│ +Codex/Claude ──▶│ /v1/* (data token: Bearer via env_key, │ + │ or x-opencodex-api-key — #1686) │ │ providers · OAuth · routing · catalog │ │ /api/* (shared management plane) │ │ optional loopback mgmt ingress :10101 │◀─ tailscale serve (HTTPS) @@ -105,20 +106,30 @@ Issuance ladder (config-selected, strictest first): GUI + /api only, allowlist style like loopbackRouteAllowed) fronted by `tailscale serve`; trust Tailscale-User-* headers ONLY on that ingress (Tailscale strips inbound spoofs and requires a loopback backend — official docs). Browser gets - real HTTPS (ts.net cert), so secure-context features work. + real HTTPS (ts.net cert), so secure-context features work. Identity is necessary + but NOT sufficient: the header proves who, an operator-configured + `remoteGui.allowedTailscaleUsers` allowlist decides whether that who may mint a + session. On a shared tailnet, an empty allowlist means nobody mints remotely. 3. pairing — `ocx gui pair` on the hub prints a single-use, short-TTL, origin-bound grant that can only mint a session. For generic HTTPS terminators. -4. trusted-tailnet (documented opt-in: remoteGui.allowInsecureHttp + trustTailnet) — - exact configured origins on a plain-HTTP tailnet may bootstrap sessions. This is the - "don't over-harden" valve the user asked for: private tailnet, sole operator → - usable GUI with one config line, warned visibly, never default. +4. insecure-http pairing (documented opt-in: `remoteGui.allowInsecureHttp`) — the SAME + single-use pairing grant as rung 3, allowed to travel over a plain-HTTP tailnet + origin. This is the "don't over-harden" valve the user asked for: private tailnet, + sole operator → run `ocx gui pair` once on the hub, paste the code, GUI works. + Audit note (blocker 1, folded): the earlier "trusted-tailnet" variant that minted + sessions from Host/Origin alone is DROPPED — headers are forgeable by anything with + TCP reach, so it would have granted consent routes with zero credential, strictly + weaker than the admin token. Issuance always consumes a real credential; only the + transport hardening is relaxable, and the relaxation is loudly warned. Supporting changes: operator-configured `hub.managementPublicOrigin` (never derive the public origin from forwarding headers — fixes today's TLS-terminator mismatch); management CORS must allow x-opencodex-api-key / x-opencodex-gui-origin / x-opencodex-csrf-token for allowlisted origins with exact-origin ACAO (currently -managementCorsHeaders never widens the header list, so a cross-origin GUI cannot even -preflight — verified src/server/auth-cors.ts:199-205). +managementCorsHeaders calls corsHeaders() without the request, so the echo path never +engages — verified src/server/auth-cors.ts:199-206. x-opencodex-api-key is already in +STATIC_ALLOWED_REQUEST_HEADERS; the two headers genuinely missing from preflight are +x-opencodex-gui-origin and x-opencodex-csrf-token, read at management-auth.ts:469/475). Secure-context reality (research doc): plain-HTTP remote origins lose crypto.subtle (used in gui/src/log-conversation-id.ts:26) and async clipboard. Two-plane helps here: @@ -196,6 +207,28 @@ disconnect-while-hub-down → journal-based offline restore · plain-HTTP → re ## 8. Roadmap → 020_roadmap.md (6 dependency-ordered phases, one PABCD cycle each) +### Phase-2 consumer chain (audit blocker 3, folded) + +GuiSessionRecord.origin is not private state. The serverOrigin/browserOrigin split must +enumerate and update, in doc 040 before Phase 2's P: +- src/server/index.ts:1609-1614 serveSessionBootstrap + the opencodex-session-origin + meta-tag contract in gui-static serving; +- gui/src/api.ts:94-96 and 154-156 (memorySessionOrigin validation, + SESSION_REBOOTSTRAP_PATH reader); +- tests/native-profile-route-security.test.ts:136; +- tests/server-management-auth.test.ts:897 ("non-loopback binding never issues a GUI + session from a forged loopback Host") must stay green: every new issuance mode is + strictly config-opt-in, defaults byte-identical to today. + +### /v1/catalog admission contract (audit blocker 4, folded) + +/v1/catalog uses the data-plane admission matrix as-is: x-opencodex-api-key OR a +Bearer that is one of our admission secrets (AUTH_MATRIX, auth-cors.ts:397-406 — the +#1686 substitution rule; the injector's env_key emits Bearer, inject.ts:231-237). +No Direct-passthrough route exists on this path, so no reservation conflict; the only +integration concern is route ordering ahead of the unknown-/v1 JSON-404 guard +(index.ts:1604). + ## 9. Open questions for the maintainer 1. First release: require tailscale-identity/pairing for remote sessions, trustTailnet @@ -213,4 +246,3 @@ disconnect-while-hub-down → journal-based offline restore · plain-HTTP → re Remote session issuance without weakening the consent principal; browser/server origin split across direct+relay transports; injector generalization without regressing journal/restore ownership. - From 66dcb298c663f93dd652c92c9eed2e92067fc661 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 00:11:23 +0900 Subject: [PATCH 03/12] =?UTF-8?q?docs(devlog):=20interview=20record=20?= =?UTF-8?q?=E2=80=94=20full-scope=20stacked=20delivery,=20dogfood=20compat?= =?UTF-8?q?,=20per-machine=20usage=20requirement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_plan/260827_remote_hub/001_interview.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 devlog/_plan/260827_remote_hub/001_interview.md diff --git a/devlog/_plan/260827_remote_hub/001_interview.md b/devlog/_plan/260827_remote_hub/001_interview.md new file mode 100644 index 0000000000..cdb0c1e156 --- /dev/null +++ b/devlog/_plan/260827_remote_hub/001_interview.md @@ -0,0 +1,33 @@ +# 001 — Interview record (2026-08-28) + +Answers captured from the maintainer (session 01a0439a, I-phase round 2): + +- **Scope: ALL 6 phases, full implementation including hardening (P6).** Delivery as a + stacked PR chain grown from this branch (codex/remote-hub-design is the stack base; + each phase PR targets the previous head; retarget to dev as parents land — + DEV-STACK / enforce-target child rules). +- Q2 (plain-HTTP pairing): accepted — rung 4 ships in Phase 2 with rung 3. +- Q3 (per-client keys): recommendation accepted BUT see new usage requirement below, + which pulls toward auto-issuing per-client keys at connect. +- Q4 (URL split): accepted — separate managementUrl allowed, /readyz advertises it. +- Q5 (remote session TTL): accepted — renewable long-lived remote sessions. +- Q6 (hub local integration): accepted — hub does not inject locally by default. +- Q7 (Claude): launcher-scope first confirmed; maintainer notes it is machine-local + anyway — clean separation is the requirement, not persistent integration. +- Q8 (deployment): **dogfood on clisu-oracle as part of this work**, AND the protocol + must tolerate release-build peers: a released client against a dev-build hub (and + the reverse) must interoperate "어느정도" — i.e. protocol-version negotiation in + /readyz is a hard requirement, not polish (Phase 1 scope). +- **NEW requirement (usage attribution):** the client GUI usage page should reflect + "my machine's usage" while connected, and after `ocx disconnect` the GUI (back in + standalone mode) shows the local proxy's own usage again. Feasibility confirmed in + code: usage attempts already persist `apiKeyId` for configured-key admissions + (src/server/management/api-key-usage.ts:78-89, admissionFields in + src/server/auth-cors.ts:369-375), so a per-client filtered usage view is a query + over existing data — it requires the machine to authenticate with its OWN key, + which is why connect should default to per-client key issuance. + +Open contradiction (to resolve this round): shared-token-allowed (Q3 answer) vs +per-machine usage view (new requirement) — attribution is keyed on apiKeyId, so a +shared token collapses all machines into one bucket. + From a7d49f3d8b534d05c4c784b1791a72896412ae3e Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 00:14:03 +0900 Subject: [PATCH 04/12] =?UTF-8?q?docs(devlog):=20interview=20round=203=20?= =?UTF-8?q?=E2=80=94=20per-client=20key=20auto-issue=20via=20token=20file,?= =?UTF-8?q?=20protocol=20compat=20floor,=20contradictions=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_plan/260827_remote_hub/001_interview.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/devlog/_plan/260827_remote_hub/001_interview.md b/devlog/_plan/260827_remote_hub/001_interview.md index cdb0c1e156..2024eae666 100644 --- a/devlog/_plan/260827_remote_hub/001_interview.md +++ b/devlog/_plan/260827_remote_hub/001_interview.md @@ -31,3 +31,34 @@ Open contradiction (to resolve this round): shared-token-allowed (Q3 answer) vs per-machine usage view (new requirement) — attribution is keyed on apiKeyId, so a shared token collapses all machines into one bucket. +## Round 3 answers (2026-08-28) + +- **Q-A = a (auto-issue per-client key at connect).** Storage question resolved in + code: the key is NEVER written to config.toml (env_key contract); it lands in the + existing owner-only token file (serviceApiTokenFilePath, src/lib/service-secrets.ts:5, + 0600 + ACL hardening) which the shim already reads into OPENCODEX_API_AUTH_TOKEN when + the env is empty (src/codex/shim.ts:699-701 unix, :1000-1001 batch, :1043 ps). + disconnect deletes the file. The shared-token-vs-attribution contradiction is CLOSED: + per-client keys are the connect default, so per-machine usage attribution works. +- **Q-C = a.** Protocol v1 negotiated via /readyz; same-major interop with + feature-detection; guaranteed pair = dev hub ↔ latest release client; older peers get + an explicit "hub protocol too new/old, upgrade ocx" error. Phase 1 hard requirement. +- **Q-B: OPEN ASSUMPTION (low)** — usage page default while connected = "this machine" + slice with a toggle to hub-wide; not answered explicitly, adopting the recommended + default; reversible in Phase 4 GUI work. + +## Final contradiction rescan (round 3) + +- Shared-token vs attribution: RESOLVED (per-client default; shared token remains a + degraded documented mode where usage collapses into one bucket). +- Pairing-grant issuance vs POST /api/keys authority: connect needs admin-class + authority ONCE — satisfied by pairing code (rung 3/4) or admin token; neither is + persisted on the client. No contradiction. +- Dogfood release-compat vs stacked delivery: protocol version lives in Phase 1 (stack + base), so every later phase rides it. No ordering conflict. +- Remaining OPEN ASSUMPTIONS: Q-B default; session TTL exact value (12h sliding, + tunable); relay streaming backpressure deferred to Phase 6. + +Interview readiness: Goal/Constraint/Success/Ontology all covered by asked-and-answered +rounds 1-3. Ready for I -> P. + From 3920954f0862805b1f1d9fbd7fe56d5f235c642c Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 00:16:01 +0900 Subject: [PATCH 05/12] =?UTF-8?q?docs(devlog):=20interview=20round=204=20?= =?UTF-8?q?=E2=80=94=20usage=20renders=20from=20whichever=20store=20served?= =?UTF-8?q?=20the=20traffic;=20no=20mirroring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devlog/_plan/260827_remote_hub/001_interview.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/devlog/_plan/260827_remote_hub/001_interview.md b/devlog/_plan/260827_remote_hub/001_interview.md index 2024eae666..50fec74e3e 100644 --- a/devlog/_plan/260827_remote_hub/001_interview.md +++ b/devlog/_plan/260827_remote_hub/001_interview.md @@ -62,3 +62,14 @@ shared token collapses all machines into one bucket. Interview readiness: Goal/Constraint/Success/Ontology all covered by asked-and-answered rounds 1-3. Ready for I -> P. +## Round 4 answer (2026-08-28) — usage rendering settled + +Maintainer's rule, adopted verbatim as the design: **connected → render the hub's +usage (my apiKeyId slice); not connected → render the local usage.jsonl.** No local +mirroring of the connect-period usage (option b rejected as unnecessary complexity); +the connect-period history lives on the hub and is visible there. Grounding: +usage persists where the serving proxy runs (appendUsageEntry → +~/.opencodex/usage.jsonl, src/usage/log.ts:166-167, 521-523), so this rule is just +"render the store that actually recorded the traffic" — zero data duplication, +no schema change. Q-B default (this-machine slice with hub-wide toggle) stands as +the connected view's default. From d225b2d24d5c22e4f4cd2968ec88c302144eb05d Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 00:53:32 +0900 Subject: [PATCH 06/12] =?UTF-8?q?docs(devlog):=20remote=20hub=20decade=20d?= =?UTF-8?q?ocs=20030-080=20=E2=80=94=20diff-level=20roadmap=20for=20all=20?= =?UTF-8?q?six=20phases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../030_phase1_protocol_catalog.md | 261 ++++++++ .../040_phase2_remote_session.md | 476 +++++++++++++ .../260827_remote_hub/050_phase3_connect.md | 530 +++++++++++++++ .../260827_remote_hub/060_phase4_two_plane.md | 499 ++++++++++++++ .../260827_remote_hub/070_phase5_deploy.md | 483 +++++++++++++ .../260827_remote_hub/080_phase6_hardening.md | 633 ++++++++++++++++++ 6 files changed, 2882 insertions(+) create mode 100644 devlog/_plan/260827_remote_hub/030_phase1_protocol_catalog.md create mode 100644 devlog/_plan/260827_remote_hub/040_phase2_remote_session.md create mode 100644 devlog/_plan/260827_remote_hub/050_phase3_connect.md create mode 100644 devlog/_plan/260827_remote_hub/060_phase4_two_plane.md create mode 100644 devlog/_plan/260827_remote_hub/070_phase5_deploy.md create mode 100644 devlog/_plan/260827_remote_hub/080_phase6_hardening.md diff --git a/devlog/_plan/260827_remote_hub/030_phase1_protocol_catalog.md b/devlog/_plan/260827_remote_hub/030_phase1_protocol_catalog.md new file mode 100644 index 0000000000..1765944173 --- /dev/null +++ b/devlog/_plan/260827_remote_hub/030_phase1_protocol_catalog.md @@ -0,0 +1,261 @@ +# 030 — Phase 1: protocol negotiation and data-plane catalog + +Unit: `260827_remote_hub` · Branch: `codex/remote-hub-design` · Phase: 1 · Status: diff-level plan · Work class: C4 + +This phase establishes the smallest release-compatible wire contract needed before any +client writes local files. It adds no connect command, no remote GUI, and no client-mode +runtime behavior. All code paths remain standalone-compatible when `runtimeRole` is absent. + +## 1. Outcome and fixed contract + +- Persisted runtime role key: `runtimeRole?: "standalone" | "hub" | "client"`. + Absence resolves to `"standalone"`; `getDefaultConfig()` does not start writing the key + into existing files. +- Protocol constants: `REMOTE_HUB_PROTOCOL = 1` and + `MINIMUM_REMOTE_CLIENT_PROTOCOL = 1`. +- Exact unauthenticated `GET /readyz` keeps its current status/identity fields and adds: + + ```json + { + "protocol": 1, + "minimumClientProtocol": 1, + "managementUrl": "https://hub.example.ts.net" + } + ``` + + `managementUrl` is the canonical origin observed by this request in Phase 1. Phase 2 + changes only its source for hub deployments by preferring + `hub.managementPublicOrigin`; the field and parser do not change. +- Data-authenticated exact `GET /v1/catalog` returns the same serialized catalog bytes as + `GET /api/catalog`, with a strong ETag calculated from those bytes and conditional + `If-None-Match` support. +- `/v1/catalog` admits only the two forms used by the Codex injector contract: + `x-opencodex-api-key: ` or `Authorization: Bearer `. + `x-api-key`, a foreign bearer, the admin token, and no credential are rejected on a + non-loopback bind. The row is added to `AUTH_MATRIX` with `xApiKey: "rejected"`. +- A serialized catalog larger than `MAX_REMOTE_CATALOG_BYTES = 32 * 1024 * 1024` is not + returned over `/v1/catalog`; it fails with HTTP 503 and the stable code + `catalog_too_large`. The management route continues to expose the same serialized bytes + for local diagnosis, so the bound does not hide the operator's recovery surface. + +## 2. IN / OUT + +### IN + +- Runtime-role type, read validation, write validation, and explicit default resolver. +- Protocol-v1 metadata in every ready/pending/failed `/readyz` body. +- A parser/compatibility predicate for future `ocx connect`, including additive-field + tolerance for a dev hub paired with the latest released client. +- Shared catalog serialization, ETag, `If-None-Match`, size cap, and data-plane admission. +- Route placement before the unknown-`/v1/*` JSON-404 guard. +- Focused and full remote-only verification commands. + +### OUT + +- `ocx connect`, client state, per-client key issuance to the owner-only + `serviceApiTokenFilePath` file, catalog installation, inject/restore, usage filtering, + and any machine listener (Phases 3–4). No client admission key is written to config. +- `hub.managementPublicOrigin`, remote GUI sessions, pairing, Tailscale identity, and + management CORS (Phase 2). +- Provider discovery or catalog regeneration. This endpoint serves the current persisted + Codex catalog only. +- Protocol v2 design, multi-hub negotiation, server-side downgrade, and silent fallback to + local providers. +- Any import from the new remote modules into `src/router.ts`, + `src/server/lifecycle.ts`, or `src/server/responses/core.ts`. + +## 3. Wire and compatibility contract + +### 3.1 Readiness shape + +`/readyz` remains exact `GET`, unauthenticated, and 200 only for `status: "ready"`; pending, +failed, and draining remain 503 with `Retry-After: 1`. The new fields are public protocol +metadata only—no path, warning, provider, account, key id, or config payload is exposed. + +The current latest-release readiness parser is already additive-field tolerant +(`validateReadyzBody` reads named fields rather than rejecting unknown keys in +`src/server/proxy-liveness.ts:275-297`). Therefore a protocol-v1 dev hub remains a valid +readiness target for the latest released client. New clients parse the three protocol +fields separately before any connect-side mutation. + +`managementUrl` rules in this phase: + +- It is an HTTP(S) origin only: no path other than `/`, no query, fragment, or userinfo. +- It is derived from the request URL/Host using the same canonical-origin rules as the + current management surface. +- It is present for standalone, hub, and client roles, because old/new process discovery + must not branch on shape. Phase 3 decides whether a client role may serve `/readyz`. +- It never trusts `Forwarded` or `X-Forwarded-*`. Phase 2's configured public origin is the + only TLS-terminator override. + +### 3.2 Version parser and exact mismatch strings + +The parser accepts additional unknown keys but validates these required values as positive +safe integers and an HTTP(S) origin. Compatibility is an interval intersection: + +```text +hub.protocol >= client.minimumHubProtocol +client.protocol >= hub.minimumClientProtocol +``` + +The v1 client constants are both `1`. Fail before catalog fetch and before any local write. +Exact user-visible strings: + +- Hub requires a newer client: + `OpenCodex hub requires remote protocol {hubMinimum}; this client supports protocol {clientProtocol}. Upgrade ocx on this client.` +- Hub is too old for the client: + `OpenCodex hub provides remote protocol {hubProtocol}; this client requires at least {clientMinimum}. Upgrade ocx on the hub.` +- Missing/malformed metadata: + `OpenCodex hub returned invalid remote protocol metadata; upgrade or repair ocx on the hub.` + +There is no optimistic assumption that a missing field means v1. Old hubs are explicit +incompatibility for `ocx connect`, while their ordinary standalone readiness remains usable. + +### 3.3 Catalog bytes, cache, and admission + +One function reads the current catalog, serializes it exactly once with +`JSON.stringify(catalog)`, and returns the resulting UTF-8 bytes. Both routes consume that +result. The test oracle compares the two route bodies byte-for-byte; it does not derive an +expected body by calling the serializer twice. + +The ETag is `"sha256-"` over the exact UTF-8 response bytes. A matching +strong tag, weak spelling of that same tag, a comma-list containing it, or `*` returns 304 +with ETag and no body. A stale/malformed `If-None-Match` returns 200. ETag is computed only +after the 32 MiB bound passes. + +`/v1/catalog` uses `resolveResponsesApiAuth(req, policy)`, not `resolveApiAuth`, because the +former is the existing dedicated-header/our-secret-Bearer matrix and rejects `x-api-key` +(`src/server/auth-cors.ts:465-478`). It performs the existing data-plane origin check after +admission. No Direct passthrough exists on this read-only route and no credential is +forwarded. + +## 4. Diff-level file-change map + +All paths below exist in the current tree except the two files marked **NEW**. + +| Action | Exact path | Diff-level change | +|---|---|---| +| MODIFY | `src/types/config.ts` | Export `OcxRuntimeRole`; add optional `runtimeRole` to `OcxConfig` beside bind/runtime settings. | +| MODIFY | `src/config.ts` | Add role schema and `runtimeRole` field validation; export `runtimeRole(config)`; reject invalid live candidates while preserving absence as standalone. Add degraded persisted-value diagnostics without deleting providers or `apiKeys`. | +| NEW | `src/remote/protocol.ts` | Own protocol constants, readiness metadata type/parser, management-origin validation, compatibility result, and exact mismatch strings. This is a passive leaf and imports no router, lifecycle, Responses, provider, or Lab code. | +| NEW | `src/server/catalog-download.ts` | Own `MAX_REMOTE_CATALOG_BYTES`, one persisted-catalog serialization result, byte-derived ETag matching, `/api/catalog` response construction, and bounded `/v1/catalog` response construction. | +| MODIFY | `src/server/management/model-routes.ts` | Replace the inline `/api/catalog` read/`JSON.stringify` block at lines 334–345 with the shared response builder; preserve 404 and `x-opencodex-codex-version`. | +| MODIFY | `src/server/auth-cors.ts` | Add the `/v1/catalog` `AUTH_MATRIX` row with bearer/dedicated accepted and `xApiKey` rejected. Do not change any existing row or credential precedence. | +| MODIFY | `src/server/index.ts` | Add protocol metadata to the current `/readyz` body at lines 991–998. Mount exact `GET /v1/catalog` after readiness/management handling and before the unknown-`/v1/*` guard at line 1604; use `resolveResponsesApiAuth` plus the existing origin policy. Keep `startServer` synchronous and add no `await` between `Bun.serve` and `labActivationRequired`. | +| MODIFY | `src/server/proxy-liveness.ts` | Keep existing identity/status parsing additive; extend the internal body type/comments so protocol fields are recognized but do not make ordinary `ocx ready` reject a v0/legacy standalone server. Remote compatibility remains in `src/remote/protocol.ts`. | +| MODIFY | `tests/config.test.ts` | Extend the existing config-default/validation sibling tests with absent/default, three valid roles, malformed live candidate, and malformed persisted role preservation cases. | +| MODIFY | `tests/server-live.test.ts` | Extend the existing `GET /readyz` suite (lines 1220+) for exact protocol values on ready/pending/failed/draining, sanitized keys, management origin, method/path negatives, and no-auth behavior. | +| MODIFY | `tests/proxy-liveness.test.ts` | Extend the current strict readiness parser/probe tests to prove additive protocol fields neither invalidate readiness nor bypass identity/status checks. | +| MODIFY | `tests/api-catalog-route.test.ts` | Extend the existing `/api/catalog` sibling suite with fixed fixture bytes and version-header preservation after serializer extraction. | +| MODIFY | `tests/server-auth.test.ts` | Extend live-server auth/order coverage with `/v1/catalog` header matrix, exact-path/method negatives, foreign/admin bearer rejection, bound overflow, ETag/304, and unknown-`/v1` guard preservation. | +| MODIFY | `tests/api-key-attribution.test.ts` | Extend the existing live `AUTH_MATRIX` loop so `/v1/catalog` is a GET route and each cell reaches the real handler rather than the generic 404. | + +No other production or test file is in scope. If implementation proves another path is +required, stop the phase and amend this document before editing it. + +## 5. New and changed signatures + +```ts +// src/types/config.ts +export type OcxRuntimeRole = "standalone" | "hub" | "client"; + +export interface OcxConfig { + runtimeRole?: OcxRuntimeRole; +} + +// src/config.ts +export function runtimeRole(config: Pick): OcxRuntimeRole; + +// src/remote/protocol.ts +export const REMOTE_HUB_PROTOCOL = 1; +export const MINIMUM_REMOTE_CLIENT_PROTOCOL = 1; + +export interface RemoteReadyMetadata { + protocol: number; + minimumClientProtocol: number; + managementUrl: string; +} + +export type RemoteProtocolCompatibility = + | { ok: true; metadata: RemoteReadyMetadata } + | { ok: false; reason: "invalid" | "hub-too-new" | "hub-too-old"; message: string }; + +export function readyProtocolMetadata(req: Request): RemoteReadyMetadata; +export function parseRemoteReadyMetadata(value: unknown): RemoteReadyMetadata | null; +export function checkRemoteProtocolCompatibility( + value: unknown, + client?: { protocol: number; minimumHubProtocol: number }, +): RemoteProtocolCompatibility; + +// src/server/catalog-download.ts +export const MAX_REMOTE_CATALOG_BYTES = 32 * 1024 * 1024; + +export interface SerializedCatalog { + bytes: Uint8Array; + codexVersion?: string; +} + +export async function serializePersistedCatalog(): Promise; +export function catalogEtag(bytes: Uint8Array): string; +export function catalogManagementResponse( + catalog: SerializedCatalog | null, + req: Request, + config: OcxConfig, +): Response; +export function catalogDataPlaneResponse( + catalog: SerializedCatalog | null, + req: Request, + policy: RequestPolicyView, +): Response; +``` + +The shared serializer returns `null` only for the current “catalog not found” state. Read, +parse, or serialization errors remain bounded server failures; they are not converted into +an empty catalog. No function accepts a caller-provided catalog path. + +## 6. Acceptance criteria with activation grounding + +| ID | Constructible activation scenario | Required result / oracle | +|---|---|---| +| P1-A01 | Load config with no `runtimeRole`. | `runtimeRole(config) === "standalone"`; saved bytes are not rewritten merely by reading. | +| P1-A02 | Validate each explicit role through `validateConfigCandidate`. | `standalone`, `hub`, and `client` are accepted and preserved exactly. | +| P1-A03 | Validate/write an unknown role, then separately load a hand-edited unknown role fixture containing provider and API-key sentinels. | Live write is rejected with a path-specific error; persisted recovery preserves unrelated provider/key state and emits a non-secret diagnostic. | +| P1-A04 | Start a server with a pending gate and request exact unauthenticated `GET /readyz`; repeat after ready, failed, and drain activation. | Existing HTTP/status/Retry-After contract holds and all three protocol fields remain identical across states. | +| P1-A05 | Send POST, OPTIONS, `/readyz/`, and encoded `/readyz%2F`. | Existing deterministic JSON 404 path remains; no protocol document leaks through the GUI fallback. | +| P1-A06 | Feed a v1 document plus unknown future fields to the new parser and to `validateReadyzBody`. | Both accept the document; readiness identity remains strict and remote parser preserves only validated protocol fields. | +| P1-A07 | Feed `{protocol: 1, minimumClientProtocol: 2}` to a v1 client. | `hub-too-new` and the exact “Upgrade ocx on this client” string are returned before catalog access. | +| P1-A08 | Feed `{protocol: 0, minimumClientProtocol: 0}` to a client requiring hub protocol 1. | `hub-too-old` and the exact “Upgrade ocx on the hub” string are returned. | +| P1-A09 | Omit, mistype, overflow, or give a path-bearing `managementUrl`. | `invalid` and the exact malformed-metadata string are returned; no fallback to protocol 1. | +| P1-A10 | Persist a fixed catalog fixture; call authorized `/api/catalog` and authorized `/v1/catalog`. | Status 200 and response bytes are byte-identical; the ETag independently hashes those bytes. | +| P1-A11 | Repeat `/v1/catalog` on non-loopback with dedicated header, our-secret Bearer, `x-api-key`, foreign Bearer, admin token, and no token. | First two reach 200; all remaining cases are 401. No case reaches the generic unknown-route 404. | +| P1-A12 | Call `/v1/catalog` with matching tag, weak matching tag, tag list, `*`, stale tag, and malformed tag. | Matches return 304/no body/same ETag; stale or malformed values return 200/full bytes. | +| P1-A13 | Serialize exactly the cap and cap+1 fixtures through an injected serialization seam. | Exact cap returns 200; cap+1 returns 503 `catalog_too_large`, never a partial body. | +| P1-A14 | Call POST `/v1/catalog`, GET `/v1/catalog/`, and an unrelated `/v1/does-not-exist`. | Every request returns the existing JSON 404 envelope; route ordering does not widen path/method matching. | +| P1-A15 | Run the import-graph and synchronous-window guard after the diff. | No new subsystem is reachable from the three protected core files; `startServer` remains non-async and its guarded window contains no top-level `await`. | + +## 7. Verification — remote only on `lidge-ai` + +Do not run Bun tests, typecheck, or privacy/full-suite gates on the local Mac. The remote +checkout must contain the phase branch and run as the ordinary `lidgeai` user, not root. + +Focused implementation gate: + +```bash +ssh lidge-ai 'cd ~/Developer/opencodex && bun run typecheck && bun test tests/config.test.ts tests/server-live.test.ts tests/proxy-liveness.test.ts tests/api-catalog-route.test.ts tests/server-auth.test.ts tests/api-key-attribution.test.ts tests/core-lab-boundary.test.ts' +``` + +Review-ready shared-server gate: + +```bash +ssh lidge-ai 'cd ~/Developer/opencodex && bun run test && bun run privacy:scan' +``` + +Record the remote commit, Bun version, command, exit code, and pass/fail counts in the phase +evidence ledger. Do not repeat a passing command unless code covered by it changes. + +## 8. Completion boundary + +Phase 1 is complete only when every acceptance row has remote evidence and the route is a +real authenticated catalog response, not merely a health response. Do not begin client-side +writes in this phase. Any protocol-field rename after Phase 1 is a compatibility change and +requires an explicit protocol-version decision. diff --git a/devlog/_plan/260827_remote_hub/040_phase2_remote_session.md b/devlog/_plan/260827_remote_hub/040_phase2_remote_session.md new file mode 100644 index 0000000000..9b0534e1b5 --- /dev/null +++ b/devlog/_plan/260827_remote_hub/040_phase2_remote_session.md @@ -0,0 +1,476 @@ +# 040 — Phase 2: remote GUI session issuance and management CORS + +Unit: `260827_remote_hub` · Branch: `codex/remote-hub-design` · Phase: 2 · Status: diff-level plan · Work class: C4 + +> **SECURITY REVIEW REQUIRED.** This phase changes authentication, session issuance, +> origin binding, CSRF enforcement, CORS, and a consent-bearing principal. It must receive +> the explicit security review required by `AGENTS.md` and `MAINTAINERS.md` before merge. + +## 1. Outcome and non-negotiable boundary + +Remote hub dashboards can obtain an origin-bound `gui-session` through one of four +evidence paths, ordered from strongest automatic path to explicit opt-in: + +1. `loopback` — current behavior, unchanged and fixed at five minutes. +2. `tailscale-identity` — trusted Tailscale Serve ingress plus exact + `remoteGui.allowedTailscaleUsers` membership. +3. `pairing` — a short-lived, origin-bound, single-use grant printed by `ocx gui pair`. +4. `insecure-http-pairing` — the same grant over non-loopback HTTP only when + `remoteGui.allowInsecureHttp === true`. + +The admin token remains an ordinary management principal. It cannot create a pairing grant, +is never accepted by the session bootstrap/exchange endpoint, is never re-labeled as +`gui-session`, and consent routes continue to reject it. `ocx gui pair` uses an attested, +process-bound, operation-only capability to create a separate one-time credential; +consumption of that credential is the only pairing exchange. + +## 2. Threat model and must-pass controls + +### Assets + +- Provider/OAuth credentials and hub-wide config. +- Admin token, GUI session token, CSRF token, and pairing grant. +- Consent-bearing actions guarded by `principal === "gui-session"`. +- Tailscale identity headers and the configured public management origin. + +### Entrypoints and attackers + +- Browser navigation/fetch to `/opencodex-session`. +- Management preflight and `/api/*` requests. +- Local `ocx gui pair` attestation and operation-capability request. +- Anonymous tailnet peer, allowlisted tailnet peer, process holding only the data key, + process holding only the admin token, compromised browser origin, replay attacker, and a + direct caller spoofing `Tailscale-User-*` against the public listener. + +### Trust boundaries and controls + +- Browser origin and server destination are separate facts; neither is inferred from the + other. +- Tailscale headers are trusted only when the listener supplies an unforgeable + `trustedTailscaleIngress: true` context. Direct/public-listener headers are ignored. +- An empty/missing `allowedTailscaleUsers` list authorizes nobody remotely. +- Pairing grants are stored only as SHA-256 digests, capped, expire after five minutes, + are deleted before session minting, and are never logged or returned again. +- Pairing-grant creation accepts only a short-lived capability bound to the exact runtime + PID, port, method, path, nonce, expiry, and canonical browser origin. The reusable admin + token and every other management principal are rejected on that route. +- Remote sessions use a separate 12-hour sliding TTL and renew only after the complete + destination + browser-origin + CSRF predicate succeeds. Failed requests never renew. +- Plain non-loopback HTTP is denied for all automatic issuance and for pairing unless the + explicit opt-in is true. The opt-in does not relax origin, grant, CSRF, or replay checks. +- `requireManagementAuth` and `managementPrincipal` consume one shared admission result; + there is no second “token exists in map” predicate that can disagree with authorization. + +## 3. IN / OUT + +### IN + +- `GuiSessionRecord.serverOrigin` / `browserOrigin` split and full server/GUI consumer chain. +- Config validation for `hub.managementPublicOrigin`, + `remoteGui.allowedTailscaleUsers`, and `remoteGui.allowInsecureHttp`. +- Automatic loopback and trusted-Tailscale issuance; pairing grant creation and exchange. +- Separate loopback/remote TTLs and sliding renewal for remote sessions. +- Exact management CORS header widening for GUI-origin and CSRF headers. +- CLI `ocx gui pair` grant creation through the existing runtime-attestation pattern; no + grant or reusable admin credential in argv, config, disk, logs, or shell history. +- Backend and GUI regressions for every positive and negative issuance path. + +### OUT + +- The production loopback-only Tailscale Serve management listener and deployment recipe + (Phase 5). Phase 2 implements and tests the trusted-ingress policy through an explicit + request context; the public listener always passes `false` until Phase 5 supplies the + dedicated listener. +- Client machine listener, shared/machine API target routing, fixed-target relay, pairing + form/banner, and hub-down UI (Phase 4). Phase 2 establishes the session wire contract the + Phase 4 UI consumes. +- Per-client data-key issuance, `serviceApiTokenFilePath` writes/deletes, connect state, + catalog installation, and usage filtering (Phase 3/4). +- Any usage mirroring. Phase 4 reads the hub's `apiKeyId` slice while connected and the + local `usage.jsonl` while standalone; traffic is rendered from the store that served it. +- Cookies, JWTs, persisted refresh tokens, trusted-Host-only issuance, Funnel/public + internet exposure, or a generic reverse proxy. +- Rate-limit policy beyond bounded grant/session maps; Phase 6 adds operational rate limits. +- Any import into `src/router.ts`, `src/server/lifecycle.ts`, or + `src/server/responses/core.ts` from the new GUI-session module. + +## 4. Config contract + +```ts +// src/types/config.ts +export interface OcxHubConfig { + managementPublicOrigin?: string; +} + +export interface OcxRemoteGuiConfig { + allowedTailscaleUsers?: string[]; + allowInsecureHttp?: boolean; +} + +export interface OcxConfig { + hub?: OcxHubConfig; + remoteGui?: OcxRemoteGuiConfig; +} +``` + +Validation rules: + +- Remote issuance requires `runtimeRole === "hub"`; config keys may round-trip before the + role is activated, but they grant nothing in standalone/client roles. +- `hub.managementPublicOrigin` is a canonical `http:` or `https:` origin with no userinfo, + non-root path, query, or fragment. Persist the normalized `URL.origin` spelling. +- `remoteGui.allowedTailscaleUsers` contains at most 64 unique, trimmed, non-empty strings, + each at most 320 UTF-8 bytes and containing no ASCII control character. Matching is exact + after trim; no substring/domain matching. +- `remoteGui.allowInsecureHttp` is optional and defaults false. It affects pairing only; + tailscale-identity issuance still requires HTTPS. +- A malformed live candidate is rejected with its full config path. A malformed persisted + optional block degrades to remote issuance disabled while preserving providers, accounts, + and API keys, and emits a diagnostic that never repeats the malformed value. +- Browser origins eligible for a remote session must equal + `hub.managementPublicOrigin` or an exact canonical entry already present in + `corsAllowOrigins`. Pairing cannot create an origin allowlist bypass. + +## 5. Session and issuance contract + +### 5.1 Records and TTLs + +```ts +export type GuiSessionIssuance = + | "loopback" + | "tailscale-identity" + | "pairing" + | "insecure-http-pairing"; + +export interface GuiSessionRecord { + serverOrigin: string; + browserOrigin: string; + csrfToken: string; + expiresAt: number; + issuance: GuiSessionIssuance; +} + +export interface GuiSessionBootstrap extends GuiSessionRecord { + token: string; +} + +export const LOOPBACK_GUI_SESSION_TTL_MS = 5 * 60_000; +export const REMOTE_GUI_SESSION_TTL_MS = 12 * 60 * 60_000; +export const GUI_PAIRING_GRANT_TTL_MS = 5 * 60_000; +``` + +`loopback` sessions retain the current fixed five-minute expiry and silent rebootstrap. +The three remote issuance values receive `now + REMOTE_GUI_SESSION_TTL_MS`; each fully +authorized management request moves expiry to `now + REMOTE_GUI_SESSION_TTL_MS`. The +session limit remains 128. Renewal does not change the token or CSRF token. + +### 5.2 Origin predicate + +For a session-bearing management request: + +```text +destination origin from the actual request == session.serverOrigin +X-OpenCodex-GUI-Origin == session.browserOrigin +Origin absent only for safe same-browser reads; when present it == session.browserOrigin +mutation Origin == session.browserOrigin +mutation X-OpenCodex-CSRF-Token == session.csrfToken +``` + +For cross-origin remote reads the browser sends `Origin`, and it must match. The legacy +Origin-absent allowance remains only for safe `GET`/`HEAD` requests carrying a session token +and claimed GUI origin; it never authorizes a mutation. + +`managementRequestOrigin` uses the observed loopback origin for loopback Host values. For a +non-loopback hub request it prefers configured `hub.managementPublicOrigin`; otherwise it +keeps today's observed-origin behavior. It never reads forwarding headers. + +### 5.3 Bootstrap meta consumer chain + +The compatibility meta name `opencodex-session-origin` remains and now explicitly means +`browserOrigin`. Add `opencodex-session-server-origin` for the destination binding: + +```html + + + + +``` + +Full consumer chain required in this phase: + +- `src/server/index.ts:1608-1614`: GET/POST bootstrap routing and session candidate. +- `src/server/gui-static.ts:68-74,102-105`: escaped meta serialization. +- `gui/src/api.ts:93-110`: initial injected-session read and validation. +- `gui/src/api.ts:143-160`: `SESSION_REBOOTSTRAP_PATH` response parsing. +- `gui/src/api.ts:188-199`: attach a session only when request destination equals + `memorySessionServerOrigin`; send `memorySessionBrowserOrigin` in the GUI header. +- `tests/native-profile-route-security.test.ts:136-160`: native mutation remains session + + browser-origin + CSRF gated. +- `tests/server-management-auth.test.ts:790-898`: bootstrap/meta behavior and the exact + non-loopback forged-Host regression at line 897. + +The GUI accepts a bootstrap only when `browserOrigin === window.location.origin` and +`serverOrigin === new URL(bootstrapResponse.url).origin` (or the same-origin document +origin during initial injection). Failure clears all in-memory session fields. Tokens remain +memory-only and are never written to web storage. + +### 5.4 Issuance routes + +- `GET /opencodex-session` + - loopback request: current auto-issuance. + - trusted Tailscale ingress: read `Tailscale-User-Login`; require HTTPS public origin, + exact allowlist membership, and an allowed browser origin; issue + `tailscale-identity`. + - public listener with spoofed Tailscale headers: no session. +- `POST /api/gui/pairing-grants` + - exact operation-capability endpoint used by local `ocx gui pair`; it does not accept + admin-token, gui-session, local-read, provider-reload, restart, or data-key authority. + - bodyless. The canonical browser origin is carried in a dedicated header and is included + in the HMAC capability payload, so a body/header substitution cannot retarget the grant. + - returns `{grant, browserOrigin, serverOrigin, expiresAt}` once; response has + `Cache-Control: no-store` and no grant digest. +- `POST /opencodex-session` + - strict body `{ "grant": "…" }`, 4 KiB maximum, unknown fields rejected. + - requires an `Origin` matching the grant's `browserOrigin`; the grant is the only + credential accepted. Admin/data/session credentials in headers do not substitute. + - HTTPS issues `pairing`; non-loopback HTTP issues `insecure-http-pairing` only when the + config opt-in is true. The grant is consumed before minting; all replays fail. + +`ocx gui pair [--origin ] [--json]` defaults `--origin` to +`hub.managementPublicOrigin`; it fails if neither exists. It resolves the identity-checked +runtime, verifies the `/healthz` challenge proof, rechecks PID/port, derives the one-operation +capability from the protected runtime attestation secret, and POSTs once. It prints the grant +exactly once to stdout and never accepts a grant/token argument. JSON output is intended for +an immediately consuming operator tool and carries the same no-persistence warning. CLI +error paths redact response bodies containing a grant. + +## 6. Management CORS contract + +`managementCorsHeaders` currently calls `corsHeaders()` without the request +(`src/server/auth-cors.ts:199-206`), so it can echo an allowed origin but cannot include the +two GUI session headers in preflight. Keep the existing management header set and append +exactly: + +```text +X-OpenCodex-GUI-Origin, X-OpenCodex-CSRF-Token +``` + +Do not route management preflight through data-plane dynamic vendor-header echo. An allowed +origin receives exact-origin ACAO and the fixed header set; a rejected origin remains 403. +No `Access-Control-Allow-Credentials` is added because authentication is an explicit header, +not a cookie. + +## 7. Diff-level file-change map + +All paths below exist in the current tree except files marked **NEW**. + +| Action | Exact path | Diff-level change | +|---|---|---| +| MODIFY | `src/types/config.ts` | Add `OcxHubConfig`, `OcxRemoteGuiConfig`, and optional `hub`/`remoteGui` fields. Extend the Phase-1 role type only by reference, not by new values. | +| MODIFY | `src/config.ts` | Add strict nested schemas, canonical-origin/user-list validation, cross-field diagnostics, and persisted malformed-block degradation that preserves unrelated config. | +| NEW | `src/lib/gui-pair-capability.ts` | Own v1 method/path/header constants and HMAC create/verify functions bound to nonce, expiry, canonical browser origin, PID, and port. It accepts only the existing local-attestation secret shape. | +| NEW | `src/server/gui-session.ts` | Own session/grant records, constants, bounded maps, digest-only grant storage, issuance policy, grant consumption, shared request admission predicate, and sliding renewal. No provider/router/Lab imports. | +| MODIFY | `src/server/management-auth.ts` | Replace private `origin` records and duplicate authorization/principal checks with the shared GUI-session module. Preserve exported `issueGuiSession` as the loopback-compatible facade. Add pairing-grant state and exact `gui-pair-capability` principal/replay handling without changing admin-token initialization. | +| MODIFY | `src/server/auth-cors.ts` | Prefer configured hub public origin only for non-loopback management requests; add exact fixed management preflight headers and exact-origin ACAO. Do not change data-plane CORS or credential admission. | +| MODIFY | `src/server/index.ts` | Advertise GUI-pair capability v1 in `/healthz`; mount exact pairing-grant creation after capability admission, mount GET/POST bootstrap before GUI fallback, pass `trustedTailscaleIngress: false` on the public/ordinary loopback listeners, and preserve the line-1604 unknown-`/v1` guard. Do not make `startServer` async or add an await in its synchronous activation window. | +| MODIFY | `src/server/proxy-liveness.ts` | Add optional `guiPairCapability` to the existing health identity projection so the local client can fail closed against an old/foreign listener without changing required liveness identity fields. | +| MODIFY | `src/server/gui-static.ts` | Serialize escaped browser/server origin meta tags; keep `opencodex-session-origin` as browser-origin compatibility metadata. | +| NEW | `src/cli/gui.ts` | Own `runGuiCommand(args, deps)`: existing no-subcommand open behavior plus `pair`; strict `--origin`/`--json` parsing and one-time grant output. | +| NEW | `src/cli/gui-pair-client.ts` | Mirror the existing bound restart/provider-reload client pattern: read runtime identity, challenge `/healthz`, verify proof/capability version, recheck the target, derive the browser-origin-bound capability, POST once, and return a redacted typed result. | +| MODIFY | `src/cli/dispatch.ts` | Delegate the current inline `gui` runner to `runGuiCommand`, passing existing open/start dependencies; do not duplicate live-proxy discovery. | +| MODIFY | `src/cli/registry.ts` | Change usage to `ocx gui [pair [--origin ] [--json]]` and document that pairing output is secret and single-use. | +| MODIFY | `src/cli/help.ts` | Update the curated GUI command line so registry/help parity remains green. | +| MODIFY | `gui/src/api.ts` | Split memory browser/server origins, validate both meta sources, scope token attachment to the server origin, keep browser origin in the GUI header, and clear all four session values atomically. No web-storage persistence. | +| MODIFY | `tests/config.test.ts` | Extend sibling config tests for valid HTTPS, explicit HTTP opt-in, invalid origin components, duplicate/empty/oversize Tailscale users, malformed persisted block preservation, and non-hub inertness. | +| MODIFY | `tests/server-management-auth.test.ts` | Extend the primary auth suite for every issuance/expiry/replay/origin/CSRF/admin negative; preserve the line-897 forged-Host test unchanged in meaning. | +| MODIFY | `tests/native-profile-route-security.test.ts` | Update session fixture fields and prove native consent mutations still reject admin, wrong browser origin, wrong server destination, absent CSRF, and accept only the full remote-session predicate. | +| MODIFY | `tests/server-auth.test.ts` | Extend management preflight tests for exactly the two added headers, allowed/rejected origins, and no data-plane header-policy drift. | +| MODIFY | `tests/server-live.test.ts` | Extend the existing `/healthz` capability metadata coverage for GUI-pair v1 while keeping readiness/session secrets absent. | +| MODIFY | `tests/proxy-liveness.test.ts` | Extend health identity fixtures for optional GUI-pair capability detection and prove a foreign/malformed body cannot become an attested target. | +| NEW | `tests/gui-pair-capability.test.ts` | Characterize payload binding, wrong method/path/origin/PID/port, malformed nonce/expiry, constant-time mismatch, and expiration for the operation capability, following `tests/local-management-capability.test.ts` and `tests/system-restart-contract-security.test.ts`. | +| NEW | `tests/gui-pair-client.test.ts` | Characterize attestation, PID/port recheck, capability-version refusal, bodyless POST headers, one-attempt behavior, and redacted transport failures, following `tests/system-restart-client.test.ts` and `tests/local-provider-reload-client.test.ts`. | +| MODIFY | `tests/cli-dispatch.test.ts` | Extend the existing GUI runner coverage for default open vs `pair`, remote API failure, and exit codes. | +| MODIFY | `tests/cli-registry.test.ts` | Keep registry/dispatch/help parity and assert the GUI usage shape from registry values. | +| MODIFY | `tests/cli-help.test.ts` | Extend real CLI help coverage for `ocx gui pair`; do not spawn a live pairing request. | +| MODIFY | `gui/tests/api-auth-memory.test.ts` | Extend in-memory auth sibling tests for two-origin meta validation, destination-scoped attachment, remote CSRF headers, rejection/clear, and silent renewal. | +| MODIFY | `gui/tests/api-auth-deadline.test.ts` | Update bootstrap fixtures to both origins and prove timeout/watchdog behavior still settles without credential prompts or stale-session reuse. | + +No pairing form/component, locale file, docs-site page, or generated GUI output is touched in +this phase. If usable pairing requires visible UI before Phase 4, that is a scope expansion +and must be approved/amended before adding component or i18n paths. + +## 8. New and changed signatures + +```ts +// src/lib/gui-pair-capability.ts +export const GUI_PAIR_METHOD = "POST"; +export const GUI_PAIR_PATH = "/api/gui/pairing-grants"; +export const GUI_PAIR_CAPABILITY_VERSION = "v1"; + +export function createGuiPairCapability( + secret: string, + nonce: string, + method: string, + path: string, + browserOrigin: string, + pid: number, + port: number, + expiresAt: number, +): string | null; + +export function verifyGuiPairCapability( + secret: string, + nonce: string | null, + method: string, + path: string, + browserOrigin: string | null, + pid: number, + port: number, + expiresAt: number, + capability: string | null, + now?: number, +): boolean; + +// src/server/gui-session.ts +export interface GuiSessionState { + sessions: Map; + pairingGrants: Map; // key is SHA-256 digest +} + +export interface GuiSessionRequestContext { + trustedTailscaleIngress: boolean; + now?: number; +} + +export type GuiSessionAdmission = + | { ok: true; principal: "gui-session"; session: GuiSessionRecord } + | { ok: false; reason: "missing" | "expired" | "server-origin" | "browser-origin" | "csrf" }; + +export function issueGuiSession( + req: Request, + config: OcxConfig, + state: GuiSessionState, + context?: GuiSessionRequestContext, +): GuiSessionBootstrap | null; + +export function createGuiPairingGrant( + browserOrigin: string, + config: OcxConfig, + state: GuiSessionState, + now?: number, +): { grant: string; browserOrigin: string; serverOrigin: string; expiresAt: number }; + +export function consumeGuiPairingGrant( + req: Request, + body: unknown, + config: OcxConfig, + state: GuiSessionState, + now?: number, +): GuiSessionBootstrap | null; + +export function authorizeGuiSessionRequest( + req: Request, + config: OcxConfig, + state: GuiSessionState, + now?: number, +): GuiSessionAdmission; + +// src/server/management-auth.ts — public facade stays source-compatible +export type ManagementPrincipal = + | "admin-token" + | "gui-session" + | "gui-pair-capability" + | "local-read-capability" + | "local-provider-reload-capability" + | "system-restart-capability"; + +export function issueGuiSession( + req: Request, + config: OcxConfig, + state: ManagementAuthState, + context?: GuiSessionRequestContext, +): GuiSessionBootstrap | null; + +// src/cli/gui.ts +export interface GuiCommandDeps extends RuntimeApiDeps { + openDefaultGui: () => Promise; + loadConfig: () => OcxConfig; +} +export function runGuiCommand(args: string[], deps: GuiCommandDeps): Promise; + +// src/cli/gui-pair-client.ts +export type GuiPairRequestResult = + | { kind: "created"; grant: string; browserOrigin: string; serverOrigin: string; expiresAt: number } + | { kind: "unavailable"; reason: "unattested-target" | "runtime-mismatch" | "attestation" | "capability" | "transport" | "rejected" }; + +export function requestBoundGuiPairingGrant( + target: LiveProxy, + browserOrigin: string, + deps?: GuiPairClientDeps, +): Promise; +``` + +`requireManagementAuth` and `managementPrincipal` keep their public signatures. Internally +they call one `resolveManagementAdmission(req, ...)` result; a WeakMap keyed by the exact +`Request` may carry that result from the gate to principal projection so successful remote +sessions renew at most once per request. + +## 9. Acceptance criteria with activation grounding + +| ID | Constructible activation scenario | Required result / oracle | +|---|---|---| +| P2-A01 | Existing loopback config, GET page/bootstrap with loopback Host. | Session issues with equal server/browser origins, `issuance: loopback`, and exactly five-minute fixed expiry; current silent rebootstrap stays green. | +| P2-A02 | Current `remoteConfig()` and forged loopback Host on the public non-loopback bind (`tests/server-management-auth.test.ts:891-898`). | `issueGuiSession(...) === null`; this row remains green without adding config or trusted context. | +| P2-A03 | Hub config + allowed Tailscale login + HTTPS public origin, but direct/public listener context and spoofed `Tailscale-User-Login`. | No session. Header presence alone never activates identity issuance. | +| P2-A04 | Same request through `trustedTailscaleIngress: true`, exact allowlisted login, and allowed browser origin. | `tailscale-identity` session with separate origins where applicable and 12-hour expiry. | +| P2-A05 | Trusted ingress with empty list, nonmember, whitespace variant, HTTP public origin, standalone role, or client role. | No remote session for every branch; loopback behavior remains independent. | +| P2-A06 | Local CLI resolves the live runtime, verifies its challenge proof/capability version, rechecks PID/port, and POSTs a valid origin-bound capability. | One grant returned with 5-minute expiry/no-store; state stores only its digest; no session exists yet. | +| P2-A07 | Call grant creation with admin token, GUI session, data key, wrong/replayed/expired capability, changed PID/port/origin, or an origin outside public origin/`corsAllowOrigins`. | 403/401 as appropriate; no grant/session state change. Admin authority cannot reach grant creation. | +| P2-A08 | HTTPS POST bootstrap with fresh grant and exact `Origin`. | Grant is deleted and one `pairing` session is returned with both origin meta values. | +| P2-A09 | Replay consumed grant; use expired grant, wrong Origin, wrong server destination, data key, admin token, or session token in place of grant. | No session for every case; replay and alternate credentials cannot enter the exchange branch. | +| P2-A10 | Non-loopback HTTP pairing with opt-in absent/false, then true. | False path refuses without consuming into a session; true path consumes once and issues `insecure-http-pairing`. Automatic Tailscale issuance remains refused on HTTP in both cases. | +| P2-A11 | Authorized remote safe read immediately before expiry. | Full origin predicate passes and expiry slides to `now + 12h`; token/CSRF unchanged. | +| P2-A12 | Wrong destination, wrong claimed browser origin, wrong browser `Origin`, absent/wrong CSRF mutation, and an expired session. | 401 and expiry remains unchanged/deleted as applicable; principal is never projected as `gui-session`. | +| P2-A13 | Admin token calls ordinary management, pairing-grant creation, bootstrap exchange, and then a consent route; valid remote GUI session calls ordinary/consent routes with correct CSRF. | Admin remains ordinary management-capable but the other three are refused; remote session reaches consent route. No admin-to-grant or admin-to-session exchange exists. | +| P2-A14 | Serve initial GUI HTML and dedicated bootstrap for same-origin and two-origin fixtures. | Escaped meta contains compatibility browser origin plus new server origin; no raw attribute injection. | +| P2-A15 | GUI loads valid two-origin meta, then calls the bound server and an evil third origin. | Session headers attach only to bound server; evil origin receives no token/CSRF and triggers no admin prompt. | +| P2-A16 | GUI receives mismatched browser origin, mismatched response/server origin, missing meta, or a failed renewal. | All in-memory session fields clear atomically; no web-storage write and no stale header reuse. | +| P2-A17 | Allowed management OPTIONS requests GUI-origin + CSRF headers; repeat from rejected origin and request an unrelated custom header. | Allowed response lists the two exact additions and exact ACAO; rejected origin is 403; unrelated header is not dynamically echoed by management CORS. | +| P2-A18 | Run `ocx gui pair` with configured public origin, explicit allowed origin, missing origin, `--json`, extra args, stale target, failed attestation, and capability/API failure. | Valid cases create one grant and print once; invalid cases fail without falling back to admin auth or echoing secrets; no grant appears in argv/config/disk/log fixtures. | +| P2-A19 | Run native-profile mutation suite with admin, malformed remote session, and valid remote session. | Existing consent boundary remains: only the valid session + correct origin + CSRF dispatches the mutation. | +| P2-A20 | Run import-graph and synchronous-window guard. | No protected core import reaches GUI-session code; `startServer` remains synchronous and activation ordering is unchanged. | + +## 10. Verification — remote only on `lidge-ai` + +Do not run Bun tests, GUI tests, typecheck, full suite, or privacy scan on the local Mac. +Run as the ordinary `lidgeai` user in the remote checkout. + +Focused backend/CLI gate: + +```bash +ssh lidge-ai 'cd ~/Developer/opencodex && bun run typecheck && bun test tests/config.test.ts tests/server-management-auth.test.ts tests/native-profile-route-security.test.ts tests/server-auth.test.ts tests/server-live.test.ts tests/proxy-liveness.test.ts tests/gui-pair-capability.test.ts tests/gui-pair-client.test.ts tests/cli-dispatch.test.ts tests/cli-registry.test.ts tests/cli-help.test.ts tests/core-lab-boundary.test.ts' +``` + +Focused GUI auth gate: + +```bash +ssh lidge-ai 'cd ~/Developer/opencodex/gui && bun test tests/api-auth-memory.test.ts tests/api-auth-deadline.test.ts' +``` + +Review-ready security/shared-server gate: + +```bash +ssh lidge-ai 'cd ~/Developer/opencodex && bun run test && bun run privacy:scan' +``` + +Record remote commit, Bun version, command, exit code, pass/fail counts, and the security +review decision. Do not mark the phase review-ready from focused tests alone. + +## 11. Completion boundary + +Phase 2 is complete only when all four issuance values have a reachable positive or explicit +refusal scenario, every failure path leaves consent authority closed, and the existing +line-897 forged-Host regression remains green. A healthy endpoint alone is insufficient: +evidence must show an ordinary management request and one consent-bearing request with the +correct principal distinction. Production Tailscale listener wiring and visible pairing UX +remain later-phase work and must not be implied complete here. diff --git a/devlog/_plan/260827_remote_hub/050_phase3_connect.md b/devlog/_plan/260827_remote_hub/050_phase3_connect.md new file mode 100644 index 0000000000..fbd1c20dec --- /dev/null +++ b/devlog/_plan/260827_remote_hub/050_phase3_connect.md @@ -0,0 +1,530 @@ +# 050 — Phase 3: connect/disconnect/sync and client routing targets + +Unit: `260827_remote_hub` · Phase: 3 · Status: diff-level implementation plan + +Depends on Phase 1's `src/remote/protocol.ts`, protocol-v1 `/readyz`, and +data-authenticated `/v1/catalog` contracts and Phase 2's one-time pairing exchange at +`POST /opencodex-session`. Pairing never authenticates `/api/keys` directly: connect +first consumes it into an origin-bound GUI session, then uses that session once for the +existing key POST. This phase does not weaken either contract. + +## 0. Structural decision + +### Context + +Today `src/codex/inject.ts` derives one local target from `hostname + port`, +`src/codex/sync.ts` always gathers the local provider catalog, and +`src/cli/claude.ts` always ensures and targets a local proxy. A connected machine +instead needs one immutable remote target and one admission secret, while standalone +output must remain byte-for-byte unchanged. + +### Chosen move + +- Add a leaf `src/client/` subsystem that owns persisted connection-state parsing, + hub HTTP calls, and the connect transaction. +- Generalize Codex generation around a `CodexRoutingTarget`, but retain the current + numeric overloads as compatibility wrappers. The wrappers construct the same + standalone target and therefore emit identical bytes. +- Extend the injection journal with an optional durable client owner. A successful + connect outlives the short-lived connect CLI PID; startup preserves the journal only + while validated `runtimeRole === "client"` and `config.client.apiKeyId` match that + owner. Missing/mismatched state restores exactly as today's dead-PID recovery does. +- Make CLI dispatch choose standalone sync or connected sync before entering + `src/codex/sync.ts`. Connected sync never calls local provider discovery. +- Reuse the existing `POST /api/keys` owner in + `src/server/management/oauth-account-routes.ts:596`; do not create a second key + store or key-generation route. Admin authenticates that POST directly; pairing can + reach it only through Phase 2's session exchange and full origin/CSRF predicate. +- Store the issued secret only at `serviceApiTokenFilePath()` + (`src/lib/service-secrets.ts:5`). Persist only its key id and SHA-256 ownership + fingerprint under `config.json.client`. + +### Rejected alternatives + +- Persisting the data key in `config.json` or `$CODEX_HOME/config.toml`: both widen + secret exposure and violate the existing `env_key`/shim contract. +- Pointing connected sync at `refreshCodexModelCatalog()`: a hub outage would then + silently repopulate the catalog from local providers and route traffic to the wrong + authority. +- Adding a second CLI switch in `src/cli/index.ts`: command registration is now + registry-driven (`src/cli/registry.ts`, `src/cli/dispatch.ts`); bypassing it would + drift help, aliases, and dispatch parity. + +### Dependency direction and blast radius + +`src/cli/* -> src/client/* -> config/codex/service-secret leaves`. No new client +module is imported by `src/router.ts`, `src/server/lifecycle.ts`, or +`src/server/responses/core.ts`. `src/server/index.ts` is not changed in this phase, so +its synchronous `Bun.serve` activation window is untouched. Blast radius: CLI, +Codex/Claude machine integration, persisted config schema, and the existing API-key +management endpoint tests; no provider request-path change. + +## 1. IN / OUT + +### IN + +- `ocx connect `, `ocx disconnect`, `ocx connect status [--json]`, and connected + fields in existing `ocx status [--json]`. +- Protocol-v1 readiness negotiation through Phase 1's parser/predicate, with the + guaranteed compatibility floor: + current dev hub ↔ latest released client, with same-major feature detection. +- Per-client key auto-issuance through exact `POST /api/keys`, using an admin token + directly once or a Phase-2 pairing grant indirectly through one transient GUI session; + none of those management credentials is persisted. +- Owner-only service token file, bounded/atomic catalog placement, injector preflight, + rollback, and final atomic `runtimeRole + config.json.client` commit. +- Codex target generalization, connected `ocx sync` with no local fallback, Claude + launcher targeting, and offline journal-backed disconnect. + +### OUT + +- Client-mode HTTP listener, `/api/machine/*`, hub relay, and GUI two-plane wiring + (Phase 4 / doc 060). +- Deployment recipes and Tailscale Serve setup (Phase 5). +- Rotation UI, orphan-key reconciliation while the hub is unreachable, multi-hub, + catalog adversarial hardening beyond the Phase-1 contract, and release docs + (Phase 6). +- Provider execution on the client, local usage mirroring, or any write to `src/lab/`. + +## 2. File-change map + +Every existing path below was verified in the current tree. For NEW client paths, +`src/` exists and Phase 3 creates the approved `src/client/` feature leaf from +`010_design.md`; the other NEW parents already exist. + +| Action | Exact path | Diff-level change | +|---|---|---| +| NEW | `src/client/state.ts` | Parse/validate `runtimeRole + config.json.client`, expose fail-closed connected/absent/invalid/mismatched states, and atomically commit/clear both keys through config mutation. | +| NEW | `src/client/hub-client.ts` | Validate/normalize URLs; bounded `GET /readyz`, `POST /api/keys`, `GET /v1/catalog`; protocol/capability checks; redact all credential-bearing errors. | +| NEW | `src/client/connect.ts` | Transaction coordinator, connected sync, rollback, and offline disconnect. No argv or presentation logic. | +| NEW | `src/cli/connect.ts` | Parse connect/disconnect/status arguments, read the one-time credential from stdin or a named env var, call the coordinator, and render redacted human/JSON output. | +| MODIFY | `src/types/config.ts` | Add `OcxClientConnectionConfig` and top-level `OcxConfig.client?`; the secret itself is not a field. | +| MODIFY | `src/config.ts` | Add schema and field-scoped persistence behavior for `client`; malformed-present client state must be diagnosable and must never degrade into standalone routing. | +| MODIFY | `src/lib/service-secrets.ts` | Add atomic owner-only write and fingerprint-checked removal beside existing path/read helpers. | +| MODIFY | `src/codex/inject.ts` | Add `CodexRoutingTarget`; thread it through provider table, `env_key`, root URL, profile, preflight, journal witness, and inject while retaining byte-compatible standalone overloads. | +| MODIFY | `src/codex/journal.ts` | Add backward-compatible durable client ownership; reconcile a dead process journal only when no matching committed client state exists. | +| MODIFY | `src/cli/index.ts` | Pass fail-closed client ownership into pre-start journal reconciliation; command registration itself remains registry/dispatch-owned. | +| MODIFY | `src/cli/dispatch.ts` | Register lazy connect/disconnect runners; branch `sync` on client state before `syncModelsToCodex`; invalid or connected-but-unusable state fails without local discovery. | +| MODIFY | `src/cli/registry.ts` | Add canonical command metadata and usage for `connect` and `disconnect`. | +| MODIFY | `src/cli/help.ts` | Add both commands to the compact top-level usage list; detailed help remains registry-derived. | +| MODIFY | `src/cli/status.ts` | Add a redacted connection block: state, URLs, protocol, key id, selected clients, catalog age, and token-file ownership state; never token bytes/fingerprint. | +| MODIFY | `src/cli/claude.ts` | Resolve standalone vs connected launcher target; connected mode skips local proxy startup and injects hub `ANTHROPIC_BASE_URL` + client token only for that exact target. | +| MODIFY | `src/claude/gateway-cache.ts` | Generalize cache refresh from numeric local port to explicit base URL + admission token while retaining the numeric wrapper. | +| MODIFY | `tests/config.test.ts` | Extend config round-trip/degradation coverage for valid, absent, unknown-field, and malformed-present `client`. | +| MODIFY | `tests/cli-registry.test.ts` | Assert registry/help ownership for connect/disconnect. | +| MODIFY | `tests/cli-dispatch.test.ts` | Assert lazy dispatch and standalone/connected sync selection. | +| MODIFY | `tests/cli-help.test.ts` | Assert compact and subcommand help without credential-bearing argv forms. | +| MODIFY | `tests/cli-status-json.test.ts` | Assert redacted connected/invalid/disconnected status JSON. | +| MODIFY | `tests/api-keys-routes.test.ts` | Extend exact `POST /api/keys` authority matrix for admin and a fully authorized Phase-2 GUI session; a raw pairing grant is rejected and response secret remains one-time. | +| MODIFY | `tests/codex-inject.test.ts` | Add explicit-target generation plus standalone golden-byte parity. | +| MODIFY | `tests/codex-inject-integration.test.ts` | Add preflight/commit/restore tests for a remote target and absolute catalog path. | +| MODIFY | `tests/codex-catalog-restore.test.ts` | Add version-1 journal compatibility and durable-client-owner restore/preserve cases. | +| MODIFY | `tests/cli-start-journal-order.test.ts` | Prove matching committed client ownership preserves the connect journal after the connect PID exits; absent/mismatched state still restores. | +| MODIFY | `tests/claude-cli.test.ts` | Add connected target, user-override, token non-forwarding, and no-local-start cases. | +| MODIFY | `tests/claude-gateway-cache.test.ts` | Add remote model URL/token and local-wrapper parity. | +| NEW | `tests/client-connect.test.ts` | Transaction, protocol, credential, catalog, rollback, connected sync, and offline disconnect matrix. | +| NEW | `tests/service-secrets.test.ts` | 0600/ACL-aware write, fingerprint, symlink/refusal, changed-file removal refusal, and redacted failures. | + +Verified dependencies, not Phase-3 edits: `src/server/management/oauth-account-routes.ts` +owns `/api/keys`; `src/server/management/api-access.ts` only builds displayed data-plane +endpoints; `src/codex/paths.ts:29` owns +`$CODEX_HOME/opencodex-catalog.json`; Phase 1's `src/remote/protocol.ts` owns the +readiness parser/compatibility strings and `src/server/catalog-download.ts` owns +`MAX_REMOTE_CATALOG_BYTES` plus the `/v1/catalog` wire bytes. + +## 3. Persisted config and public signatures + +### `src/types/config.ts` + +```ts +export type OcxConnectedClientId = "codex" | "claude"; + +export interface OcxClientConnectionConfig { + serverUrl: string; // canonical origin; no path/query/hash/userinfo + managementUrl: string; // canonical origin; may differ from serverUrl + managementTransport: "direct" | "relay"; + selectedClients: OcxConnectedClientId[]; + tokenEnv: "OPENCODEX_API_AUTH_TOKEN"; + apiKeyId: string; // attribution id; not secret + tokenFingerprint: string; // lowercase SHA-256; ownership check only + protocolVersion: 1; + connectedAt: string; // ISO-8601 + catalogEtag?: string; + catalogSyncedAt?: string; +} + +export interface OcxConfig { + // existing fields unchanged + runtimeRole?: "standalone" | "hub" | "client"; // Phase 1 owner + client?: OcxClientConnectionConfig; +} +``` + +The parser rejects unknown selected-client ids, non-origin URLs, protocol values other +than 1, duplicate client ids, malformed timestamps, and non-64-hex fingerprints. +Forward-compatible unknown object keys are preserved on unrelated config writes. A raw +`client` key that is present but invalid is `kind: "invalid"`, not `absent`; start, +sync, Claude launch, and status must refuse local-provider fallback in that state. +`runtimeRole === "client"` requires a valid `client` object and a present client object +requires that role. `hub` plus `client`, or one half missing, is a mismatch and fails +closed. + +### `src/client/state.ts` + +```ts +export type ClientConnectionState = + | { kind: "disconnected" } + | { kind: "connected"; value: OcxClientConnectionConfig } + | { kind: "invalid"; reason: string } + | { kind: "mismatched"; reason: string }; + +export function readClientConnectionState(): ClientConnectionState; +export function commitClientConnection( + state: OcxClientConnectionConfig, +): "committed" | "unchanged"; +export function clearClientConnection( + expectedApiKeyId: string, +): "committed" | "absent" | "conflict"; +``` + +`commitClientConnection()` writes `runtimeRole: "client"` and `client` in one config +mutation. `clearClientConnection()` removes `client` and removes the role only when it +is still `client`, in one mutation. `readClientConnectionState()` inspects the raw +top-level keys before relying on a repaired/fallback config DTO. This is the guard that +makes malformed-present or half-present state fail closed instead of appearing +disconnected. + +### `src/lib/service-secrets.ts` + +```ts +export interface PersistedServiceApiToken { + path: string; + fingerprint: string; +} + +export function writeServiceApiTokenFile(token: string): PersistedServiceApiToken; +export function removeServiceApiTokenFileIfOwned( + expectedFingerprint: string, +): "removed" | "absent" | "changed"; +``` + +Write is temp → `0600`/Windows ACL harden → rename at the exact +`serviceApiTokenFilePath()`. It refuses symlink targets and a non-client pre-existing +secret. Removal rereads and hashes the bounded regular file; a changed file is never +deleted. Neither function returns or logs the token after the write. + +### `src/codex/inject.ts` + +```ts +export interface CodexRoutingTarget { + baseUrl: string; // canonical absolute .../v1 + requiresAdmissionToken: boolean; + tokenEnv: "OPENCODEX_API_AUTH_TOKEN"; +} + +export function standaloneCodexRoutingTarget( + port: number, + config?: Pick, +): CodexRoutingTarget; + +export interface InjectCodexOptions { + // existing fields unchanged + routingTarget?: CodexRoutingTarget; + journalOwner?: { kind: "process" } | { kind: "client"; apiKeyId: string }; +} +``` + +Existing `injectCodexConfig(port, config, options)`, `buildProviderTableBlock(...)`, +`buildOpenaiBaseUrlLine(...)`, and `buildProfileFile(...)` exports retain their current +call forms as overloads. Their implementations normalize through one target-aware +builder. With no `routingTarget`, the bytes are exactly current output, including EOL, +comments, provider names, `env_key = "OPENCODEX_API_AUTH_TOKEN"`, profile wording, +and loopback root-override behavior. With a connected target, +`requiresAdmissionToken: true` selects the provider-table form independent of whether +the URL hostname itself looks loopback. + +### `src/codex/journal.ts` + +```ts +export type JournalOwner = + | { kind: "process"; pid: number } + | { kind: "client"; apiKeyId: string }; + +export interface ReconcileJournalOptions { + activeClientApiKeyId?: string; +} + +export function reconcileJournal(options?: ReconcileJournalOptions): boolean; +``` + +Existing version-1 `{ pid }` journals parse as process-owned. A new journal records the +owner without removing the existing hashes/preimages. `reconcileJournal()` preserves a +client-owned journal only when a separately validated `runtimeRole === "client"` and +`config.client.apiKeyId` match; invalid, absent, or different state restores it. This +avoids both failure modes: a +successful connect is not undone merely because its CLI PID exited, while a crash before +the final client-state commit cannot leave durable remote routing behind. + +### `src/client/hub-client.ts` + +```ts +export type OneTimeConnectCredential = + | { kind: "admin"; value: string } + | { kind: "pairing-grant"; value: string }; + +export interface ConnectGuiSession { + token: string; + csrfToken: string; + browserOrigin: string; + serverOrigin: string; +} + +export interface IssuedClientKey { + id: string; + key: string; + createdAt: string; + name: string; +} + +export function normalizeHubOrigin(input: string): string; +export function fetchHubReady( + serverUrl: string, + options?: { timeoutMs?: number; fetchImpl?: typeof fetch }, +): Promise<{ status: "ready" | "pending" | "failed"; metadata: RemoteReadyMetadata }>; +export function exchangeConnectPairingGrant( + managementUrl: string, + browserOrigin: string, + grant: string, + options?: { allowInsecureHttp?: boolean; timeoutMs?: number; fetchImpl?: typeof fetch }, +): Promise; +export function issueClientKey( + managementUrl: string, + credential: + | { kind: "admin"; value: string } + | { kind: "gui-session"; value: ConnectGuiSession }, + name: string, + options?: { timeoutMs?: number; fetchImpl?: typeof fetch }, +): Promise; +export function downloadClientCatalog( + serverUrl: string, + admissionToken: string, + options?: { etag?: string; timeoutMs?: number; maxBytes?: number; fetchImpl?: typeof fetch }, +): Promise<{ kind: "fresh"; body: string; etag?: string } | { kind: "not-modified" }>; +``` + +`fetchHubReady()` parses through Phase 1's `parseRemoteReadyMetadata()` and evaluates +through `checkRemoteProtocolCompatibility()`; it does not define a second protocol +shape, constants, or mismatch strings. Both URLs accept only `http:`/`https:`, reject +credentials/query/hash and non-root paths +(a terminal `/v1` input normalizes to the server origin). Admin credentials may be sent +only over HTTPS. A pairing grant may use HTTP only when the caller explicitly supplied +`--allow-insecure-http`; the Phase-2 hub independently requires +`remoteGui.allowInsecureHttp === true`, so both sides must opt in. Redirects are +rejected. Bodies and timeouts are bounded. Errors carry status and safe code, never +response/header secrets. + +`POST /api/keys` remains the exact key authority. The request body is only a validated, +bounded `name`; admin uses the ordinary management header. Pairing uses strict +`POST /opencodex-session` with the future machine-GUI browser origin, then the returned +session token + `X-OpenCodex-GUI-Origin` + CSRF authorize the key POST. A raw pairing +grant cannot access any `/api/*` route. An admin token is never submitted to the session +exchange and therefore never mints or becomes `gui-session`. + +### `src/client/connect.ts` + +```ts +export interface ConnectOptions { + serverUrl: string; + managementUrl?: string; + credential: OneTimeConnectCredential; + selectedClients: OcxConnectedClientId[]; + managementTransport: "direct" | "relay"; + noSync?: boolean; + allowInsecureHttp?: boolean; +} + +export interface ClientConnectDeps { + fetchImpl?: typeof fetch; + now?: () => Date; +} + +export function connectClient( + options: ConnectOptions, + deps?: ClientConnectDeps, +): Promise; +export function syncConnectedClient( + options?: { restartCodex?: boolean }, + deps?: ClientConnectDeps, +): Promise<{ catalogWritten: boolean; cacheSynced: boolean; injected: boolean; stale: boolean }>; +export function disconnectClient( + options?: { keepCatalog?: boolean }, +): Promise<{ restored: boolean; tokenRemoved: boolean; catalogRemoved: boolean }>; +``` + +### CLI contract + +```text +ocx connect [--management-url ] + [--credential-stdin | --credential-env ] + [--clients codex,claude] + [--management-transport direct|relay] + [--allow-insecure-http] [--no-sync] +ocx connect status [--json] +ocx disconnect [--keep-catalog] [--json] +``` + +There is deliberately no `--token `, `--admin-token `, or pairing-code +positional form. `--credential-env` stores only the variable name in argv; the value is +read once and cleared from the coordinator's local reference after key issuance. +`--credential-stdin` uses the bounded stdin helper. Parse errors redact unknown bare +values and all credential-shaped option values. + +## 4. Connect transaction and rollback + +The observable order is fixed: + +1. Normalize `serverUrl`/optional `managementUrl`; reject an already connected, + role/client-mismatched, or malformed-present state. Preflight the token target and + refuse a foreign pre-existing service token before network or file writes. +2. `GET /readyz`; require `status=ready`, protocol-v1 compatibility, and + advertise/derive the management origin. +3. Validate transport/credential combination. Admin: POST + `/api/keys` directly. Pairing: consume the grant once at + `/opencodex-session` using the future localhost machine-GUI Origin, + then use the returned session + CSRF once at `/api/keys`. Hold `{id,key}` only in + memory. +4. Snapshot pre-existing owned client artifacts; atomically write the issued key only + to `serviceApiTokenFilePath()` and retain its fingerprint. +5. `GET /v1/catalog` with the issued data key, validate bounded JSON, then + atomically replace `$CODEX_HOME/opencodex-catalog.json`. +6. Run `injectCodexConfig(..., { validateOnly: true, routingTarget, catalogPath })`. +7. Unless `--no-sync`, inject selected Codex state under the existing journal/write-lock + transaction with `{ journalOwner: { kind: "client", apiKeyId } }`. Prepare Claude + launcher state only; no persistent Claude settings write. +8. Commit `runtimeRole: "client"` + `config.json.client` together and last. That state + commit makes the connection visible to future commands. + +Failure at steps 4–8 removes the newly written token, restores prior owned catalog +bytes, calls journal restore for any committed Codex injection, and leaves both client +config fields absent. The still-in-memory admin credential or exchanged GUI session +attempts exact `DELETE /api/keys` for the just-created id. If hub cleanup is unreachable, +the failure reports only the safe key id and exact revoke action; it never prints the +key. Machine-local rollback success is mandatory and remote cleanup inability is +explicit, never hidden as full rollback. + +`--no-sync` still performs readiness, key issuance, token placement, catalog download, +and final state commit, but does not mutate Codex/Claude client files. The next +connected `ocx sync` is the sole apply path. + +## 5. Mode-aware sync and launch behavior + +### `ocx sync` + +- `client.kind === disconnected`: run today's `syncModelsToCodex(...)` path unchanged. +- `client.kind === invalid`: exit non-zero before proxy discovery, provider discovery, + catalog write, or injection. +- `client.kind === connected`: read and fingerprint-check the service token, request + `/v1/catalog` with `If-None-Match`, and inject the saved `CodexRoutingTarget`. +- Connected 304 reuses the existing catalog only if it is a bounded regular file and + the configured catalog path is the expected absolute path. +- Connected timeout/5xx keeps last-known-good catalog and reports stale age; it does + not gather local providers. Missing/changed token file and 401 are hard failures and + do not inject or fall back. + +### Claude launcher + +Connected `ocx claude` does not call `ensureProxyForClaude()` and does not target the +Phase-4 machine listener. It derives: + +```ts +interface ClaudeRoutingTarget { + baseUrl: string; // client.serverUrl, no /v1 suffix + admissionToken: string; // token file, memory only +} +``` + +`buildClaudeEnv` retains its numeric standalone overload and adds an explicit-target +overload. Default connected launch sets `ANTHROPIC_BASE_URL=` and +`ANTHROPIC_AUTH_TOKEN=`, plus the existing discovery/model variables. +An explicit user `ANTHROPIC_BASE_URL` still wins; if it differs from the connected hub, +the hub admission token is removed before spawn so it cannot follow the user override. +Gateway cache refresh uses `/v1/models?limit=1000&ids=cli`; context-window +metadata comes from the downloaded catalog, not a management-token `/api/*` request. + +### Disconnect + +Disconnect is local-authoritative and works with the hub offline: + +1. Read valid connected state and verify `apiKeyId`/token fingerprint ownership. +2. Call existing journal-backed native restore (`restoreNativeCodexAsync` / + `restoreJournalState`); preserve user-edited foreign fields exactly as today. +3. Remove the token only when its fingerprint still matches. +4. Remove only the OpenCodex-owned catalog unless `--keep-catalog`. +5. Clear `config.json.client` + the `client` runtime role together and last (absence + resolves to standalone). + +If restore is partial or the token changed, state is not cleared and the command names +the conflicting artifact. This avoids claiming disconnected while Codex still points at +the hub or deleting a replacement secret. Remote key revocation is not required for +offline completion; Phase 6 owns stale-key/rotation UX. + +## 6. Test plan + +Tests use temp `OPENCODEX_HOME`/`CODEX_HOME`, injected fetch, and synthetic credentials. +No test sends live hub traffic or reads the developer's homes. + +| Test file | Required cases | +|---|---| +| `tests/client-connect.test.ts` (NEW) | URL canonicalization; Phase-1 ready parser/mismatch strings; ready/pending/failed; same-major v1 acceptance; management URL advertisement; admin HTTPS direct key POST; pairing HTTPS session exchange then key POST; dual-opt-in pairing HTTP; admin HTTP refusal; raw grant rejection at `/api/keys`; bounded catalog; atomic role+state commit; each rollback point; no-sync; connected 200/304/401/timeout sync; no local discovery fake called; offline disconnect and partial restore. | +| `tests/service-secrets.test.ts` (NEW) | Exact path, 0600, Windows ACL seam, atomic replacement, symlink refusal, fingerprint, changed-file non-removal, no token in errors. | +| `tests/codex-inject.test.ts` | Current standalone goldens byte-equal; explicit HTTPS target emits exact `base_url`, provider table, `env_key`; loopback-looking connected URL still requires admission; malformed target refused before journal. | +| `tests/codex-inject-integration.test.ts` | Validate-only has zero writes; target commit records journal ownership; offline restore returns exact preimage; partial write rollback. | +| `tests/codex-catalog-restore.test.ts`, `tests/cli-start-journal-order.test.ts` | Version-1 process journals retain current behavior; client journal survives only a matching final state; absent/invalid/mismatched state restores after dead connect PID. | +| `tests/config.test.ts` | Valid client round-trip; atomic role+client pair; unknown keys preserved; absent remains standalone; half-present/malformed remains fail-closed; no secret field accepted/emitted. | +| `tests/api-keys-routes.test.ts` | Admin and full GUI-session predicates create once; raw pairing grant and incomplete origin/CSRF reject; list/patch never echo secret. Phase-2 session tests remain the admin-never-mints-session oracle. | +| `tests/cli-registry.test.ts`, `tests/cli-dispatch.test.ts`, `tests/cli-help.test.ts` | Registry/dispatch/help parity; no credential argv form; connected sync calls only remote coordinator; invalid client refuses. | +| `tests/cli-status-json.test.ts` | Stable redacted status in disconnected/connected/invalid/token-changed/catalog-stale states. | +| `tests/claude-cli.test.ts`, `tests/claude-gateway-cache.test.ts` | Standalone parity; connected direct target; no local ensure; service token precedence; user destination strips hub token; remote model cache URL/token; no management credential dependency. | +| `tests/core-lab-boundary.test.ts` | Existing three protected import roots and synchronous `startServer` checks remain green. | + +## 7. Acceptance criteria with activation grounding + +| ID | Constructible activation scenario | Expected result | +|---|---|---| +| P3-A1 | Disconnected temp home; HTTPS hub ready on protocol 1; admin token arrives through stdin; `/api/keys` and `/v1/catalog` succeed. | One key is issued, token exists only in owner-only service file, catalog and injection commit, and `runtimeRole=client` + `config.client` are written together and last with key id/fingerprint only. | +| P3-A2 | Same as A1, but a Phase-2 pairing grant bound to the future localhost GUI origin is supplied. | Grant is consumed once at `/opencodex-session`; returned GUI session + CSRF performs exact key POST; raw grant on `/api/keys` and replay fail; no transient credential persists. | +| P3-A3 | HTTP management URL with pairing grant, client `--allow-insecure-http`, and hub `remoteGui.allowInsecureHttp=true`. | Exchange/key issuance succeeds with explicit warning. Missing either opt-in refuses; admin credential over HTTP refuses before credential transmission. | +| P3-A4 | `/readyz` returns protocol major 2, minimum client above 1, or status pending/failed. | Clear upgrade/not-ready error; zero key POSTs and zero local writes. Latest-release client ↔ dev hub protocol-1 fixture remains accepted. | +| P3-A5 | Key POST returns 401/403/409 or malformed/oversized JSON. | No token/catalog/journal/config writes and no secret in diagnostics. | +| P3-A6 | Token, catalog, injector preflight, inject commit, or final role+state commit is fault-injected in turn. | Prior machine bytes are restored at every point; neither `runtimeRole=client` nor visible `client` remains; remote orphan cleanup status is explicit by safe key id only. | +| P3-A7 | Existing standalone config runs every current injector golden. | Output bytes are identical; no connected-only env/header/config key appears. | +| P3-A8 | Connected state plus valid token; hub catalog returns 200 then 304. | First sync atomically updates/injects; second uses last-known-good and ETag; local provider gather fake is never called. | +| P3-A9 | Connected state with hub down, 401, missing token, changed token, or malformed-present client config. | No local-provider fallback and no new local catalog; timeout keeps LKG as stale, credential/state errors fail hard. | +| P3-A10 | Connected Claude launch with no user Anthropic overrides. | Child receives hub base and client token; no local proxy is started; gateway cache uses hub `/v1/models`. | +| P3-A11 | Connected Claude launch with user-owned different `ANTHROPIC_BASE_URL`. | User destination wins and the hub admission token is absent from child env. | +| P3-A12 | Hub unreachable during disconnect with intact journal/token. | Native Codex bytes restore offline, owned token/catalog are removed per flags, and `runtimeRole + client` clear together and last. | +| P3-A13 | Disconnect sees changed token or a journal ownership conflict. | Conflicting artifact is preserved, command fails, and connected state remains so status is honest. | +| P3-A14 | Connect injection committed, connect process exited, and matching `runtimeRole=client + config.client.apiKeyId` was committed last; then `ocx start` runs. | Pre-start reconciliation preserves the client journal/routing. If final state is absent, invalid, mismatched, or names another key id, the same journal restores before startup. | + +## 8. Verification — remote only on `lidge-ai` + +No Bun test, typecheck, build, or privacy suite runs on the local Mac. Create the +phase checkout at `/home/lidgeai/codex-runs/260827-remote-hub-phase3`, owned by the +unprivileged `lidgeai` user, install dependencies there, and run: + +```bash +ssh lidge-ai 'sudo -iu lidgeai bash -lc '\''cd /home/lidgeai/codex-runs/260827-remote-hub-phase3 && ./node_modules/.bin/bun run typecheck'\''' +ssh lidge-ai 'sudo -iu lidgeai bash -lc '\''cd /home/lidgeai/codex-runs/260827-remote-hub-phase3 && ./node_modules/.bin/bun test tests/client-connect.test.ts tests/service-secrets.test.ts tests/config.test.ts tests/cli-registry.test.ts tests/cli-dispatch.test.ts tests/cli-help.test.ts tests/cli-status-json.test.ts tests/api-keys-routes.test.ts tests/codex-inject.test.ts tests/codex-inject-integration.test.ts tests/codex-catalog-restore.test.ts tests/cli-start-journal-order.test.ts tests/claude-cli.test.ts tests/claude-gateway-cache.test.ts tests/core-lab-boundary.test.ts'\''' +ssh lidge-ai 'sudo -iu lidgeai bash -lc '\''cd /home/lidgeai/codex-runs/260827-remote-hub-phase3 && ./node_modules/.bin/bun run privacy:scan'\''' +``` + +Before marking the non-trivial PR review-ready, repository policy additionally requires +the full suite on the same remote checkout (never local): + +```bash +ssh lidge-ai 'sudo -iu lidgeai bash -lc '\''cd /home/lidgeai/codex-runs/260827-remote-hub-phase3 && ./node_modules/.bin/bun run test'\''' +``` + +Record remote user, absolute path, HEAD, command exit codes, pass/fail counts, and the +focused/full suite tails in this unit's C-phase evidence. Do not rerun an unchanged +passing command. diff --git a/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md b/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md new file mode 100644 index 0000000000..659e24e756 --- /dev/null +++ b/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md @@ -0,0 +1,499 @@ +# 060 — Phase 4: client machine listener and two-plane GUI + +Unit: `260827_remote_hub` · Phase: 4 · Status: diff-level implementation plan + +Depends on Phase 3's matching `runtimeRole: "client" + config.json.client`, token-file +ownership, connected sync, and offline disconnect, plus Phase 2's +`src/server/gui-session.ts` `serverOrigin`/`browserOrigin` contract. This phase adds no +provider execution to the client. + +## 0. Structural decision + +### Context + +The current dashboard assumes one same-origin `apiBase`. `gui/src/api.ts:52-60` +explicitly refuses auth on cross-origin URLs, while a connected machine needs shared +pages to call the hub and machine pages to call localhost. The current full server also +cannot be reused as a client listener: it mounts `/v1/*`, provider adapters, and shared +management routes that client mode must not expose. + +### Chosen move + +- Add an independent, loopback-only Bun listener under `src/client/`. Its route + allowlist copies the default-404 shape of `loopbackRouteAllowed` + (`src/server/index.ts:665-680`) but contains only GUI/static, health/readiness, + `/api/machine/*`, and an opt-in fixed-target relay. +- Branch in `src/cli/index.ts` before dynamically importing the full server. Connected + mode starts only the machine runtime; disconnected mode follows today's full-server + path. +- Add one GUI `ApiTargets` owner. Page components continue to receive an `apiBase`, but + App selects shared vs machine explicitly and the fetch auth layer keeps independent + in-memory session/CSRF state per logical target. +- Extend the existing `/api/usage` projection with exact `apiKeyId`. Connected Usage + defaults to that key id and can explicitly toggle hub-wide; disconnected Usage calls + the local server unchanged. No usage row is copied between stores. + +### Rejected alternatives + +- A generic localhost reverse proxy: caller-controlled destination/path creates an SSRF + and credential-forwarding surface. The relay destination is fixed by validated client + state and redirects are rejected. +- Serving `/v1/*` on the machine listener: Codex/Claude must dial the hub directly, and + a local data plane would make fallback/provider execution possible. +- One token slot keyed only by browser origin: direct hub and localhost share the same + browser origin claim but have different server origins and credentials; one slot can + send a hub session to a machine endpoint or vice versa. +- Mirroring hub usage into local `usage.jsonl`: it creates two authorities and was + explicitly rejected in `001_interview.md`. + +### Dependency direction and invariants + +`src/cli/index.ts -> src/client/runtime.ts -> machine-listener/machine-api/hub-relay`. +The client leaf may reuse `src/server/gui-static.ts` and `src/server/management-auth.ts`; +the full server never imports the client listener. No new subsystem import enters +`src/router.ts`, `src/server/lifecycle.ts`, or `src/server/responses/core.ts`. +`src/server/index.ts` is unchanged, so the synchronous window from `Bun.serve` to Lab +activation remains unchanged and contains no new `await`. + +## 1. IN / OUT + +### IN + +- Loopback-only client listener, explicit default-404 route allowlist, GUI assets, and + local machine-session/CSRF enforcement. +- `GET /api/machine/status`, `GET /api/machine/clients`, + `POST /api/machine/sync`, `GET|POST /api/machine/shim`, and + `POST /api/machine/disconnect`. +- Opt-in fixed-target `/api/machine/hub-relay/*` selected only by + `client.managementTransport === "relay"`. +- GUI machine/shared target discovery, independent auth state, page-plane mapping, + stable hub-offline states, and mode-aware stop/restart actions. +- Connected usage = hub store filtered to this machine's `apiKeyId` by default, with an + explicit hub-wide toggle; disconnected usage = local `usage.jsonl` unchanged. + +### OUT + +- `/v1/*`, providers, OAuth storage, routing, Lab, shared config mutation, or local + usage persistence on the machine listener. +- Caller-selected relay hosts/schemes, redirects, WebSocket tunneling, arbitrary files, + cookies, or generic forward-proxy behavior. +- Usage replication, merge, import, backfill, or schema migration. +- Tailscale service installation/deployment docs (Phase 5) and relay rate/backpressure + hardening beyond fixed bounds (Phase 6). + +## 2. File-change map + +All existing paths were verified in the current tree. For NEW client paths, `src/` +exists and this phase extends the Phase-3-created `src/client/` leaf; every other NEW +parent exists. No generated `gui/dist` file is edited. + +| Action | Exact path | Diff-level change | +|---|---|---| +| NEW | `src/client/machine-auth.ts` | Define machine-session header contract for requests that also carry a hub credential; adapt to existing management-auth validation and strip local headers before relay. | +| NEW | `src/client/machine-api.ts` | Exact `/api/machine/*` route dispatcher and non-secret DTOs; all mutation orchestration stays here. | +| NEW | `src/client/hub-relay.ts` | Fixed destination/path allowlist, header/body bounds, redirect rejection, response filtering, and no-log relay. | +| NEW | `src/client/machine-listener.ts` | Loopback Bun listener, route allowlist/default-404, GUI/session bootstrap, health/readiness, and dispatch to machine API/relay. | +| NEW | `src/client/runtime.ts` | Client-process PID/runtime state, signal/drain handling, start/recycle, and transition to standalone after disconnect. | +| MODIFY | `src/cli/index.ts` | Read client state before full-server import; dynamically start client runtime when connected; retain current standalone branch byte-for-byte. | +| MODIFY | `src/server/management/logs-usage-routes.ts` | Read optional `apiKeyId`, include it in the projection-only filter, keep filtered responses out of the summary cache. | +| MODIFY | `src/usage/summary.ts` | Extend `UsageFilterEcho` and `projectUsageSummary` to filter exact entry `apiKeyId` before model/provider attribution projection. | +| MODIFY | `tests/api-usage.test.ts` | Add exact key slice, no-match, cache-poisoning, and combined surface/provider/model/key filter cases. | +| MODIFY | `tests/usage-summary.test.ts` | Add pure key projection, old-row exclusion, combo behavior, and exact-case id tests. | +| MODIFY | `tests/cli-start-journal-order.test.ts` | Prove connected start skips stale-process journal restore only for a matching durable client owner and starts no full data plane. | +| NEW | `tests/client-machine-listener.test.ts` | Listener bind/allowlist/auth/API/startup/offline matrix. | +| NEW | `tests/client-hub-relay.test.ts` | Fixed target, header separation, body caps, redirects, errors, and SSRF negatives. | +| NEW | `gui/src/api-targets.ts` | Canonical `ApiTargets`, machine-status discovery, page-plane map, relay URL construction, and disconnected fallback. | +| MODIFY | `gui/src/api.ts` | Replace `needsApiAuth`'s one same-origin slot with exact target classification and per-target in-memory session/CSRF state; attach both auth domains only on relay. | +| MODIFY | `gui/src/App.tsx` | Discover targets before page fetches; map pages/actions to planes; machine health remains live when hub is down; connected stop becomes disconnect/recycle. | +| MODIFY | `gui/src/stop-proxy.ts` | Add mode-aware machine disconnect request while preserving existing standalone `/api/stop` behavior. | +| MODIFY | `gui/src/pages/Usage.tsx` | Add this-machine/hub-wide scope control, key-id query/cache key, source label, and hub-offline behavior without local fallback. | +| MODIFY | `gui/src/pages/Storage.tsx` | Pass its selected shared `apiBase` through to `StorageWorkspace`. | +| MODIFY | `gui/src/components/storage-workspace/StorageWorkspace.tsx` | Remove module-global `VITE_API_BASE`; use the supplied shared-plane base for Codex-log storage calls. | +| MODIFY | `gui/src/styles-usage-workspace.css` | Style compact usage-source/scope controls and connected/offline qualification without changing layout direction. | +| MODIFY | `gui/src/i18n/en.ts` | Source-of-truth copy keys for connected source, this machine, hub-wide, hub offline, disconnect, and relay warnings. | +| MODIFY | `gui/src/i18n/de.ts` | Add the same keys. | +| MODIFY | `gui/src/i18n/fr.ts` | Add the same keys. | +| MODIFY | `gui/src/i18n/ja.ts` | Add the same keys. | +| MODIFY | `gui/src/i18n/ko.ts` | Add the same keys. | +| MODIFY | `gui/src/i18n/ru.ts` | Add the same keys. | +| MODIFY | `gui/src/i18n/tr.ts` | Add the same keys. | +| MODIFY | `gui/src/i18n/zh.ts` | Add the same keys. | +| MODIFY | `gui/src/i18n/zh-TW.ts` | Add the same keys. | +| NEW | `gui/tests/api-targets.test.ts` | Target discovery, page mapping, relay construction, and hub-down fallback. | +| MODIFY | `gui/tests/api-auth-memory.test.ts` | Independent machine/shared sessions; direct/relay header matrix; no cross-target leakage; bootstrap validation. | +| MODIFY | `gui/tests/api-auth-deadline.test.ts` | Per-target shared resolution/watchdog behavior. | +| MODIFY | `gui/tests/usage-layout.test.ts` | Connected own-key default, hub-wide toggle, disconnected local source, cache partition, and offline rendering. | +| MODIFY | `gui/tests/app-stop.test.ts` | Standalone stop vs connected disconnect/recycle. | +| MODIFY | `gui/tests/integrations-routing.test.ts` | Integrations remains machine-plane while shared pages use hub. | +| MODIFY | `tests/core-lab-boundary.test.ts` | Existing protected-root and synchronous-start checks remain green; no rule weakening. | + +Verified reuse without edits: `src/server/gui-static.ts` serves assets/bootstrap; +`src/server/management-auth.ts` owns session/CSRF validation; +`src/usage/log.ts:80` already persists `apiKeyId`; `src/server/management/api-key-usage.ts:78-89` +already proves exact per-key aggregation; `gui/src/pages/Startup.tsx` and +`gui/src/pages/Integrations.tsx` already accept an `apiBase` prop. + +## 3. Machine listener and API contracts + +### `src/client/machine-auth.ts` + +Relay requests carry two principals and therefore cannot overload one header: + +```ts +export const MACHINE_SESSION_HEADER = "x-opencodex-machine-session"; +export const MACHINE_GUI_ORIGIN_HEADER = "x-opencodex-machine-gui-origin"; +export const MACHINE_CSRF_HEADER = "x-opencodex-machine-csrf-token"; + +export function requireMachineAuth( + req: Request, + state: ManagementAuthState, + config: OcxConfig, +): Response | null; +export function stripMachineAuthHeaders(headers: Headers): Headers; +``` + +Ordinary `/api/machine/*` requests may use the existing standard session headers. +Relay requests put the hub principal in standard `x-opencodex-*` headers and the local +machine principal in the three headers above. `requireMachineAuth` maps only the local +triple into a synthetic request for the existing `requireManagementAuth` predicate, +then the relay strips that triple. An admin token still cannot mint or substitute for +a GUI session; the machine principal is issued by loopback page bootstrap and mutation +CSRF checks remain mandatory. + +### `src/client/machine-listener.ts` + +```ts +export interface MachineListenerDeps { + state?: OcxClientConnectionConfig; + managementAuthState?: ManagementAuthState; + fetchImpl?: typeof fetch; +} + +export function machineRouteAllowed(url: URL, req: Request, relayEnabled: boolean): boolean; +export function startMachineListener( + port?: number, + deps?: MachineListenerDeps, +): Server; +``` + +The bind hostname is hard-coded `127.0.0.1`; `config.hostname`, wildcard values, and +request headers cannot alter it. The allowlist is evaluated before auth or handlers: + +| Method/path | Purpose | +|---|---| +| `GET /healthz` | Process liveness and PID/port identity only. | +| `GET /readyz` | Local machine-plane readiness and role; no hub/provider/account data. | +| `GET /`, `GET /opencodex-session`, static GUI assets, SPA extensionless GET | Existing GUI serving/session bootstrap. | +| `GET /api/machine/status` | Redacted connection and target state. | +| `GET /api/machine/clients` | Selected client/journal/shim status, no secret paths outside approved DTOs. | +| `POST /api/machine/sync` | Connected sync. | +| `GET /api/machine/shim` | Current Codex shim status. | +| `POST /api/machine/shim` | `{ action: "install" | "repair" | "uninstall" }`. | +| `POST /api/machine/disconnect` | Offline-capable restore and scheduled standalone recycle. | +| `/api/machine/hub-relay/*` | Only when relay is explicitly selected; methods/path further constrained by relay. | + +Everything else, including every `/v1/*`, `/api/config`, `/api/usage`, provider route, +unknown machine route, wrong method, and WebSocket upgrade returns JSON 404. A future +route is unreachable until added to this function. + +### `src/client/machine-api.ts` + +```ts +export interface MachineStatusV1 { + mode: "client"; + connected: true; + machineBase: string; + sharedBase: string; + sharedServerOrigin: string; + managementTransport: "direct" | "relay"; + apiKeyId: string; + protocolVersion: 1; + connectedAt: string; + catalogSyncedAt?: string; + hubReachability: "unknown" | "online" | "offline" | "unauthorized"; +} + +export interface MachineApiDeps { + sync: typeof syncConnectedClient; + disconnect: typeof disconnectClient; + scheduleStandaloneRecycle: () => void; +} + +export function handleMachineApi( + req: Request, + url: URL, + state: OcxClientConnectionConfig, + deps: MachineApiDeps, +): Promise; +``` + +Status and clients are safe GETs but still require the loopback GUI session, matching +the dashboard management model. Sync, shim mutation, and disconnect require browser +Origin + matching machine CSRF. Bodies are strict, unknown fields rejected, and the +existing bounded management-body limit is reused. Status reports the key id and token +ownership state only; never the token, fingerprint, admin credential, pairing grant, or +raw filesystem contents. + +Disconnect calls Phase 3 restore even when the hub is down, returns 202 only after local +state commits, then recycles the process on the same loopback port. The replacement sees +no `config.client` and enters today's standalone full-server path; a browser reload then +reads local `/api/usage`. If restore conflicts, no recycle is scheduled and the client +state remains visible. + +### `src/client/runtime.ts` and `src/cli/index.ts` + +```ts +export function startClientRuntime( + options?: { port?: number; block?: boolean }, +): Promise; +export function scheduleStandaloneRecycle(): void; +``` + +`handleStart` reads `ClientConnectionState` before full-server import: + +```text +invalid/mismatched role+client -> fail before listener/import/provider timer +runtimeRole=client + connected -> dynamic import src/client/runtime.ts; start machine listener +standalone/absent + disconnected -> dynamic import ../server; run current startServer path +``` + +The client runtime writes the existing PID/runtime records, installs crash/signal +handlers, drains only its listener, and never starts token/history/provider/catalog +timers. Its runtime record names the actual loopback host/port so existing process +ownership checks remain valid. Client stop preserves connection intent unless the user +requested disconnect; disconnect performs restore and recycle explicitly. + +## 4. Fixed-target hub relay + +### `src/client/hub-relay.ts` + +```ts +export interface HubRelayTarget { + managementUrl: string; + browserOrigin: string; +} + +export function relayHubManagementRequest( + req: Request, + suffix: string, + target: HubRelayTarget, + deps?: { fetchImpl?: typeof fetch; timeoutMs?: number }, +): Promise; +``` + +Activation requires all of: + +1. valid connected state; +2. `managementTransport === "relay"`; +3. exact `/api/machine/hub-relay/` prefix; +4. valid local machine session (custom headers for relay); +5. suffix exactly `/opencodex-session` GET or inside `/api/` with an allowed HTTP + method. + +The destination is `new URL(suffix, state.managementUrl)` after rejecting encoded +slashes/backslashes, authority syntax, userinfo, query-host tricks, and path traversal. +The caller supplies no host/scheme/port. `redirect: "manual"`; every 3xx is an error. +Strip `Host`, connection/hop-by-hop headers, machine auth headers, proxy credentials, +cookies, and forwarding headers. Forward only the bounded management header allowlist, +including hub session, GUI-origin, CSRF, content type, and conditional cache headers. +Response headers are similarly allowlisted; `Set-Cookie` and hop-by-hop headers are +never returned. Request and response bodies have named constants and abort on overflow. +No URL query, auth header, body, or response body is logged. + +The hub sees its fixed canonical server origin and the browser's localhost origin from +Phase 2's split-origin session. Relay mode does not mint a new authority and cannot turn +the local machine session or admin token into a hub `gui-session`. + +## 5. GUI two-plane contract + +### `gui/src/api-targets.ts` + +```ts +export type ApiPlane = "machine" | "shared"; +export type SharedTransport = "same-origin" | "direct" | "relay"; + +export interface ApiTarget { + id: ApiPlane; + baseUrl: string; + serverOrigin: string; + bootstrapPath: string; + transport: SharedTransport; +} + +export interface ApiTargets { + connected: boolean; + machine: ApiTarget; + shared: ApiTarget; + apiKeyId?: string; +} + +export function standaloneApiTargets(initialBase: string): ApiTargets; +export function targetsFromMachineStatus(initialBase: string, status: MachineStatusV1): ApiTargets; +export function apiPlaneForPage(page: Page): ApiPlane; +export function apiBaseForPage(page: Page, targets: ApiTargets): string; +export async function discoverApiTargets(initialBase: string, signal?: AbortSignal): Promise; +``` + +Page mapping is explicit: + +| Plane | Pages/actions | +|---|---| +| Shared | Dashboard, Providers, Models, Subagents, Logs, Usage, Storage, Codex Set. | +| Machine | Startup, Integrations, health/version, Codex app-server restart, shim actions, disconnect. | + +In a standalone full server, `/api/machine/status` returns 404 and discovery returns one +same-origin target, preserving existing behavior. A connected machine status response +constructs either an exact cross-origin hub base or the local relay prefix. A network +failure to machine status is not interpreted as standalone; App renders a local-plane +startup error so it cannot accidentally send shared requests to an unknown local server. + +### `gui/src/api.ts` + +Replace global `memoryToken/memoryCsrfToken/memorySessionOrigin` with: + +```ts +interface ApiSessionState { + token: string | null; + csrfToken: string | null; + browserOrigin: string | null; + serverOrigin: string | null; +} + +export function configureApiTargets(targets: ApiTargets): void; +``` + +Target classification uses exact configured base URL/prefix, not arbitrary cross-origin +matching. Each target has an independent 401 resolution gate, prompt-cancel state, +watchdog, session state, and bootstrap URL. A bootstrap is stored only when +`browserOrigin === window.location.origin` and `serverOrigin === target.serverOrigin`. + +Header behavior is exact: + +| Request | Headers attached | +|---|---| +| Machine endpoint | Machine session in standard GUI headers only. | +| Shared direct | Hub session/admin header + hub GUI-origin/CSRF only; no machine header. | +| Shared relay | Hub session in standard headers plus machine session in custom machine headers; relay strips custom headers before hub. | +| Unknown target/cross-origin URL | No OpenCodex credential and no auth prompt. | + +Tokens remain memory-only. Legacy sessionStorage cleanup remains. A 401 on one target +clears/prompts only that target and cannot wipe the other target's newer session. + +### `gui/src/App.tsx` + +App blocks page resource mounting until target discovery settles, then passes the mapped +base. Health/version polls machine. Connected hub failure leaves shell, navigation, +Startup, Integrations, disconnect, and local status usable; shared pages render one +stable hub-offline state and never substitute machine data. In connected mode the power +action uses `POST /api/machine/disconnect`; in standalone it remains `POST /api/stop`. + +`StorageWorkspace` must receive the shared base from `Storage.tsx`; its current +module-global `VITE_API_BASE` at `gui/src/components/storage-workspace/StorageWorkspace.tsx:20` +would otherwise bypass plane selection on Codex-log actions. + +## 6. Usage source and filtering + +### Server projection + +`projectUsageSummary` changes to: + +```ts +export interface UsageFilterEcho { + provider: string | null; + model: string | null; + apiKeyId: string | null; + matched: boolean; + comboOverlap: boolean; +} + +export function projectUsageSummary( + summary: T, + filter: { provider?: string | null; model?: string | null; apiKeyId?: string | null }, + entries?: PersistedUsageEntry[], +): T & { filter?: UsageFilterEcho }; +``` + +`apiKeyId` is trimmed and compared exactly, not lowercased. It filters entries before +attempt/model attribution. Old rows, environment-token rows, and loopback rows have no +matching id and are excluded. Provider/model filtering then applies to the retained +entries as today. Any requested filter bypasses the unfiltered summary cache and never +warms a filtered value under `range:surface`. + +### GUI rule + +`Usage` receives `{ apiBase, connected, apiKeyId }`: + +- connected initial scope = `machine`; request includes + `apiKeyId=` and reads the hub's `usage.jsonl`; +- connected explicit toggle = `hub`; omit `apiKeyId` and read the whole hub store; +- disconnected = no scope toggle/query; read the same local `/api/usage` as today; +- hub down = error/stale held hub payload for that exact source key, never local data; +- disconnect/reload = standalone target/cache key, so the local store appears; +- no endpoint writes or mirrors usage rows. + +The cache key adds server origin + transport + scope + apiKeyId, preventing a prior +hub-wide payload from appearing as this-machine or a prior connected payload from +appearing after disconnect. + +## 7. Test plan + +| Test file | Required cases | +|---|---| +| `tests/client-machine-listener.test.ts` (NEW) | IPv4 loopback bind; GUI/bootstrap; exact allowlist; every `/v1/*` 404; unknown/wrong-method 404; safe GET auth; mutation Origin/CSRF; status redaction; sync success/failure; shim actions; disconnect offline; recycle to standalone; invalid state refuses startup; no provider/timer fake invoked. | +| `tests/client-hub-relay.test.ts` (NEW) | Relay disabled 404; direct mode 404; fixed host/path; direct/encoded traversal and authority injection rejected; redirects rejected; request/response caps; hop-by-hop/cookie/forwarded/machine headers stripped; hub auth retained; timeout/abort; no body/header log. | +| `tests/cli-start-journal-order.test.ts` | Matching durable client journal survives start; missing/mismatched client owner restores; connected branch never starts full server; disconnected branch remains current. | +| `tests/api-usage.test.ts` | Exact `apiKeyId` response/echo; old and other-key rows excluded; no match; combined filters; filtered request cannot poison cache; unfiltered next request remains whole hub. | +| `tests/usage-summary.test.ts` | Pure projection totals/days/models/providers/accounts consistency; exact-case id; combo attempts; absent id; provider/model/key cross-product. | +| `tests/core-lab-boundary.test.ts` | Protected roots import no client subsystem; `startServer` remains non-async and no new top-level-window await. | +| `gui/tests/api-targets.test.ts` (NEW) | Standalone 404 fallback; valid direct/relay status; page map; exact bases; machine-status network failure not standalone; encoded relay paths. | +| `gui/tests/api-auth-memory.test.ts` | Two simultaneous sessions; direct headers; relay dual headers; machine custom stripping contract; cross-target 401 races; server/browser-origin mismatch; unknown target receives nothing; no web storage. | +| `gui/tests/api-auth-deadline.test.ts` | One target watchdog does not block/clear the other; direct and relay bootstrap timeout states. | +| `gui/tests/usage-layout.test.ts` | Connected default key query; hub-wide omission; disconnected local query; source-qualified cache keys; hub-down no local fetch; scope labels/a11y. | +| `gui/tests/app-stop.test.ts` | Standalone `/api/stop`; connected `/api/machine/disconnect`; refusal re-enables action; accepted recycle tolerates connection drop. | +| `gui/tests/integrations-routing.test.ts` | Startup/Integrations machine base; Providers/Usage/Storage shared base under direct and relay. | + +## 8. Acceptance criteria with activation grounding + +| ID | Constructible activation scenario | Expected result | +|---|---|---| +| P4-A1 | Valid connected state, then `ocx start`. | Only a 127.0.0.1 machine listener starts; no full server/provider/timer starts; PID/runtime records name it. | +| P4-A2 | Request every known data/shared route on the machine listener. | Every `/v1/*`, `/api/config`, `/api/usage`, OAuth/provider/Lab path is JSON 404; only explicit machine routes/assets answer. | +| P4-A3 | GET status/clients with valid loopback GUI session, then without it. | Valid request returns redacted DTO; missing/admin-only/expired/wrong-origin session is rejected and no secret/fingerprint leaks. | +| P4-A4 | POST sync/shim/disconnect with valid session but missing/wrong CSRF or browser Origin. | Mutation is rejected before work; exact session+Origin+CSRF reaches the handler. Admin token never becomes GUI session. | +| P4-A5 | Connected direct transport with Phase-2 session issued by an exact `remoteGui.allowedTailscaleUsers` match or a consumed pairing grant; hub CORS includes the localhost browser origin. | Shared requests go directly to exact hub origin with hub headers only; machine pages remain localhost; CORS/session validation succeeds. A non-allowlisted Tailscale identity mints no session and gets no local fallback. | +| P4-A6 | Connected relay transport and valid machine + hub sessions. | Browser sends dual auth domains; relay validates machine session, strips custom headers, forwards hub session only to fixed hub target, and rejects redirect/SSRF variants. | +| P4-A7 | Relay path requested while transport is direct/disabled or caller supplies host/scheme/traversal. | Default 404/refusal before outbound fetch; no credential or body is logged. | +| P4-A8 | Hub becomes unreachable after target discovery. | Shell, machine pages, status, and disconnect remain usable; shared pages show stable hub-offline state and never fetch local substitutes. | +| P4-A9 | Connected Usage opens with key A while hub log contains A, B, environment, loopback, and old rows. | Default totals contain only A rows and echo A; hub-wide toggle contains all hub rows; no local file is read or written. | +| P4-A10 | User disconnects while hub is unreachable; recycle succeeds. | Journal/token/catalog/client state restore locally, replacement starts standalone on same port, reload shows local usage store. | +| P4-A11 | Standalone full server opens the same GUI. | `/api/machine/status` 404 selects same-origin targets; all current pages, auth, stop, usage, and injector output remain unchanged. | +| P4-A12 | Shared direct session 401s while machine session is renewed (and inverse). | Each target resolves/clears only its own in-memory state; no token crosses target and no prompt fan-out occurs. | +| P4-A13 | GUI PR is prepared for review. | PR description uses the repository template and includes screenshots of connected this-machine Usage plus hub-offline machine shell (or a maintainer-approved `gui-screenshot-waived` exception). | + +## 9. Verification — remote only on `lidge-ai` + +No Bun test, typecheck, GUI lint/build, or browser suite runs on the local Mac. Create +the phase checkout at `/home/lidgeai/codex-runs/260827-remote-hub-phase4`, owned by +unprivileged `lidgeai`, install root and `gui/` dependencies there, and run: + +```bash +ssh lidge-ai 'sudo -iu lidgeai bash -lc '\''cd /home/lidgeai/codex-runs/260827-remote-hub-phase4 && ./node_modules/.bin/bun run typecheck'\''' +ssh lidge-ai 'sudo -iu lidgeai bash -lc '\''cd /home/lidgeai/codex-runs/260827-remote-hub-phase4 && ./node_modules/.bin/bun test tests/client-machine-listener.test.ts tests/client-hub-relay.test.ts tests/cli-start-journal-order.test.ts tests/api-usage.test.ts tests/usage-summary.test.ts tests/core-lab-boundary.test.ts'\''' +ssh lidge-ai 'sudo -iu lidgeai bash -lc '\''cd /home/lidgeai/codex-runs/260827-remote-hub-phase4/gui && ../node_modules/.bin/bun test tests/api-targets.test.ts tests/api-auth-memory.test.ts tests/api-auth-deadline.test.ts tests/usage-layout.test.ts tests/app-stop.test.ts tests/integrations-routing.test.ts && ../node_modules/.bin/bun run lint:i18n && ../node_modules/.bin/bun run lint && ../node_modules/.bin/bun run build'\''' +ssh lidge-ai 'sudo -iu lidgeai bash -lc '\''cd /home/lidgeai/codex-runs/260827-remote-hub-phase4 && ./node_modules/.bin/bun run privacy:scan'\''' +``` + +Before the non-trivial GUI/security PR is marked review-ready, run the full repository +and GUI suites on that same remote checkout, never locally: + +```bash +ssh lidge-ai 'sudo -iu lidgeai bash -lc '\''cd /home/lidgeai/codex-runs/260827-remote-hub-phase4 && ./node_modules/.bin/bun run test && cd gui && ../node_modules/.bin/bun test tests'\''' +``` + +Browser smoke also runs against the remote checkout through an SSH tunnel. Capture and +inspect screenshots for direct connected Usage (this-machine selected), relay connected +Usage, hub-offline machine pages, and post-disconnect standalone Usage. Put the required +GUI screenshots in the PR description; do not commit credentials, session meta, or +screenshots containing tokens. Record remote user/path/HEAD, commands, exit codes, +counts, and screenshot artifact names in C-phase evidence. Do not rerun unchanged green +checks. diff --git a/devlog/_plan/260827_remote_hub/070_phase5_deploy.md b/devlog/_plan/260827_remote_hub/070_phase5_deploy.md new file mode 100644 index 0000000000..112ffac2bb --- /dev/null +++ b/devlog/_plan/260827_remote_hub/070_phase5_deploy.md @@ -0,0 +1,483 @@ +# 070 — Phase 5: deployment integration and remote-hub dogfood + +Unit: `260827_remote_hub` · Phase: 5/6 · Work class: C4 (auth + deployment) · Status: implementation-ready + +Dependencies: Phases 1–4 are complete. In particular, this phase assumes the Phase-1 +`/readyz` protocol contract and `/v1/catalog`, the Phase-2 remote-session issuance +contract, the Phase-3 `ocx connect` transaction and per-client token file, and the +Phase-4 machine listener/two-plane GUI exist at the paths named by their phase docs. + +This document is the diff-level implementation contract. Every command that executes +TypeScript or tests runs on `ssh lidge-ai`, never on the workstation. The live deployment +smoke is the separately scoped `ssh clisu-oracle` dogfood described in §8. + +## 0. Locked outcome and boundaries + +Phase 5 makes a hub operable on a headless Linux host, macOS launchd host, or Docker +container without widening the data or consent planes. + +### IN + +- An opt-in second hub listener bound exactly to `127.0.0.1`, serving only packaged GUI + routes, SPA routes, `/opencodex-session`, and `/api/*`. +- Tailscale Serve as the recommended HTTPS frontend for that listener, with + `remoteGui.allowedTailscaleUsers` still deciding who may mint a session. +- Existing `ocx service install` for launchd/systemd. The data token is persisted only + through the existing owner-only `service-api-token` path and is never rendered into a + plist or unit. +- A Docker recipe that runs non-root, persists `~/.opencodex`, reads a mounted secret via + `OCX_API_TOKEN_FILE`, and probes both `/healthz` and `/readyz`. +- Headless OAuth using `oauthOpenBrowser:false` and the existing manual-code endpoint. +- A real `clisu-oracle` hub + MacBook client dogfood, including remote session issuance, + per-machine usage attribution, and protocol compatibility evidence. +- English deployment documentation in the new remote-hub guide. Locale and reference-page + synchronization is Phase 6 (§080), after the security contract is final. + +### OUT + +- No public Funnel preset, public-internet ingress, cloud firewall automation, generic + reverse proxy, Kubernetes, registry image, image publish workflow, or hosted control plane. +- No root `Dockerfile` or `.dockerignore` in this phase. The repository currently has + neither. Shipping one would create a maintained image/release surface requiring pinned + base digests, scanning, SBOM, signing, and rollback policy. The guide instead includes a + copyable multi-stage Dockerfile recipe and makes the operator own the resulting image. +- No service-manager rewrite. Windows remains supported by the existing service path but is + not a Phase-5 deployment target; the requested targets are systemd and launchd. +- No key-rotation UX, pairing throttles, skew fuzzing, catalog adversarial matrix, or relay + hardening; those are Phase 6. +- No traffic mirroring or usage-log mirroring. Connected clients render their own + `apiKeyId` slice from the hub store; disconnected clients render the local store. +- No import, direct or transitive, from a new subsystem into `src/router.ts`, + `src/server/lifecycle.ts`, or `src/server/responses/core.ts`. + +## 1. Deployment trust boundaries + +| Asset / boundary | Required control | +| --- | --- | +| Provider/OAuth credentials on hub | Never copied to a client, container layer, unit, plist, docs output, or dogfood artifact. | +| Data admission token | Delivered by `serviceApiTokenFilePath()` or `OCX_API_TOKEN_FILE`; never an argv value and never logged. | +| Management admin token | Remains hub-only. It may perform ordinary `/api/*` administration but must never mint or exchange into `gui-session`. | +| Tailscale identity headers | Trusted only when the request arrived on the new loopback management listener. Identical headers on the public listener are ignored. | +| Browser consent | Only the Phase-2 `gui-session` predicate authorizes consent routes. `allowedTailscaleUsers` is an issuance allowlist, not a new principal. | +| Docker volume | Holds provider credentials, OAuth state, usage, config, and service secrets; owner-writable only and never baked into an image. | +| Dogfood evidence | Records versions, protocol values, key ids/prefixes, counts, and HTTP status only; no tokens, emails, request bodies, account ids, or raw usage rows. | + +Rollback is configuration-first: disable the management ingress or Tailscale Serve without +changing the main data listener; stop the branch service and repair the prior release against +the same `OPENCODEX_HOME`; remove a container while retaining its named volume. + +## 2. Diff-level file-change map + +All existing paths below were verified against the 2026-08-28 tree. `NEW` paths have an +existing parent and are introduced deliberately. + +| Path | Change | Exact responsibility | +| --- | --- | --- | +| `src/types/config.ts` | MODIFY | Extend Phase-2 `OcxHubConfig` with the disabled/enabled `hub.managementIngress` union and document loopback-only semantics. Do not duplicate Phase-1 `runtimeRole` or Phase-2 `managementPublicOrigin` / `remoteGui` types. | +| `src/config.ts` | MODIFY | Parse the ingress opt-in, degrade malformed hand edits to disabled on load, and reject invalid live writes and port collisions. | +| `src/server/index.ts` | MODIFY | Compose the management listener using the existing optional-listener transaction, route allowlist, per-listener policy, rollback, and shutdown list. No body-level `await` may be added between the main `Bun.serve` and synchronous Lab activation. | +| `tests/loopback-listener-admission.test.ts` | MODIFY | Extend the existing optional-listener config/policy sibling tests for management-ingress defaults, role gate, and collisions. | +| `tests/loopback-listener-integration.test.ts` | MODIFY | Extend the existing real-socket sibling tests for bind address, GUI+/API allowlist, rollback, and all-listener shutdown. | +| `tests/server-management-auth.test.ts` | MODIFY | Prove ingress-scoped Tailscale identity, allowlist outcomes, pairing fallback, and the admin-token consent refusal. | +| `tests/service.test.ts` | MODIFY | Add only characterization needed by the documented hub install: systemd/launchd still read the protected token path and never embed the token. Do not change service generation. | +| `tests/oauth-manual-code.test.ts` | MODIFY | Exercise the existing manual-code route through the new management ingress; retain malformed/oversized negatives. | +| `tests/core-lab-boundary.test.ts` | VERIFY ONLY | Existing import-graph and synchronous-window guard must remain green; do not weaken it. | +| `docs-site/src/content/docs/guides/remote-hub.md` | NEW | Canonical English hub/client deployment guide: service, Tailscale, Docker, OAuth, health/readiness, rollback, and consent warning. | +| `docs-site/astro.config.mjs` | MODIFY | Add `guides/remote-hub` to Guides navigation. Phase 6 fills all configured locale labels/pages. | +| `structure/01_runtime.md` | MODIFY | Record the third listener as an opt-in composition-root concern and the service reuse decision. | +| `structure/05_gui-and-management-api.md` | MODIFY | Replace the loopback-only remote-GUI description with the final ingress-scoped issuance contract; preserve the admin-token boundary. | +| `structure/06_docs-and-release.md` | MODIFY | Record that Phase 5 ships a docs recipe, not an official Docker image/release channel. | + +Explicitly unchanged: `src/service.ts`, `src/lib/service-secrets.ts`, +`src/server/management/oauth-account-routes.ts`, `src/router.ts`, +`src/server/lifecycle.ts`, and `src/server/responses/core.ts`. Their current behavior is +reused and verified, not copied. + +## 3. Config and function contract + +### 3.1 Config keys + +Phase 1 owns `runtimeRole`; Phase 2 owns `hub.managementPublicOrigin`, +`remoteGui.allowedTailscaleUsers`, and `remoteGui.allowInsecureHttp`. Phase 5 adds only: + +```ts +export interface OcxHubConfig { // existing Phase-2 interface, shown extended + // Phase 2 field, shown for nesting only. + managementPublicOrigin?: string; + managementIngress?: + | { enabled: false } + | { enabled: true; port: number }; +} +``` + +Contract: + +- Missing and `{enabled:false}` are identical: no socket, no header trust, no new route. +- `{enabled:true}` is valid only when `runtimeRole === "hub"` and `port` is an integer in + `1..65535` distinct from `config.port` and from an enabled + `unauthenticatedLoopbackListener.port`. +- The hostname is not configurable. The socket always binds `127.0.0.1`; accepting a + caller-provided hostname would destroy the Tailscale-header trust argument. +- A malformed hand edit disables only this optional listener on read. `ocx config set` / + management writes fail with a concrete `schema_invalid: hub.managementIngress...` error. +- `managementPublicOrigin` is still the canonical browser-facing origin. Forwarded headers + never synthesize it. + +### 3.2 Listener integration signatures + +Keep helpers private to `startServer` unless a direct unit seam is already established by the +Phase-2 implementation: + +```ts +type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management"; + +function managementIngressRouteAllowed(url: URL, req: Request): boolean; +function ingressForServer(server: Server): ServerIngress; +``` + +Use the exact Phase-2 context and facade; do not create a second session API: + +```ts +export function issueGuiSession( + req: Request, + config: OcxConfig, + state: ManagementAuthState, + context?: GuiSessionRequestContext, // { trustedTailscaleIngress: boolean; now?: number } +): GuiSessionBootstrap | null; +``` + +Pass `{trustedTailscaleIngress:true}` only when `requestServer === managementIngressServer`. +Every public/ordinary-loopback call passes false. The load-bearing fact is that the trusted +context is selected by a separately bound loopback socket; never infer it from Host, Origin, +`Forwarded`, `X-Forwarded-*`, or `Tailscale-User-*`. + +### 3.3 Management listener route allowlist + +The listener is GUI + management API only: + +- `GET`/`HEAD` packaged GUI assets and `/`. +- `GET` extensionless SPA routes that the existing GUI fallback serves. +- `GET /opencodex-session`. +- `/api/*`, with existing management authentication, Origin, session, CSRF, body-size, and + route authorization intact. +- Everything else is deterministic JSON 404 before a handler runs, including all `/v1/*`, + `/healthz`, `/readyz`, WebSocket upgrades, and unknown static paths. + +The public listener remains the health/readiness/data endpoint. This prevents Tailscale Serve +from becoming an accidental unmetered data-plane proxy. + +### 3.4 Startup and shutdown transaction + +Reuse the shape at `src/server/index.ts` around the existing public + unauthenticated-loopback +bind: + +1. Bind the public listener. +2. Bind the existing unauthenticated loopback listener when enabled. +3. Bind the hub management listener when enabled. +4. If either optional bind fails, synchronously initiate stop on every listener already bound, + preserve the original bind error, and throw. Do not add `await` to `startServer`. +5. Add every successfully bound optional server to the existing `server.stop` closure so the + shutdown promise joins all stops before background lifecycle release. +6. Log only bind address/port and mode. Never log identity headers, tokens, pairing codes, or + public-origin query strings. + +## 4. Existing service installer: Linux and macOS + +No `src/service.ts` implementation change is warranted. Verified owners: + +- `buildPlist(proxyEnv?)` in `src/service.ts` builds launchd and calls the common + `buildServiceShellCommand`. +- `buildUnit(proxyEnv?)` builds the systemd user unit and calls the same command. +- `buildServiceShellCommand` reads `serviceApiTokenFilePath()` into + `OPENCODEX_API_AUTH_TOKEN` at process start. +- `assertServiceAuthEnvironment()` refuses a non-loopback install without a token. +- `writeServiceApiTokenFile()` writes the token owner-only; unit/plist tests already assert + that the literal secret is absent. +- Windows additionally carries `OCX_API_TOKEN_FILE` in the generated wrapper at the current + `src/service.ts:1571+` path, but Windows deployment is not exercised here. + +Canonical hub setup shown in the guide (values are examples, not defaults): + +```bash +ocx config set runtimeRole hub +ocx config set hostname 100.64.0.10 +ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' +ocx config set hub.managementIngress '{"enabled":true,"port":10101}' +ocx config set remoteGui.allowedTailscaleUsers '["operator@example.com"]' + +# Read from a protected shell/secret manager; never put the token on argv. +export OPENCODEX_API_AUTH_TOKEN="$(openssl rand -hex 32)" +ocx service install +ocx service status +curl --fail --silent http://100.64.0.10:10100/healthz +curl --fail --silent http://100.64.0.10:10100/readyz +``` + +The guide must say that the `openssl` command is an operator-side example, not a source of +provider credentials, and that `service install` copies the value into the existing protected +token file. `ocx config show`, unit/plist output, screenshots, and support bundles must never +contain it. + +## 5. Tailscale Serve and ts.net certificate walkthrough + +### Recommended: Tailscale Serve + +```bash +tailscale serve --bg --https=443 http://127.0.0.1:10101 +tailscale serve status +``` + +Expected public browser origin is the exact HTTPS `https://..ts.net` +configured in `hub.managementPublicOrigin`. The guide must require: + +- `hub.managementIngress.enabled=true` and loopback bind proof before Serve is enabled. +- The user's exact Tailscale login in `remoteGui.allowedTailscaleUsers`; an empty list means + no remote identity can mint a session. +- No cloud-firewall opening for port 10101. It is loopback-only. +- `tailscale serve`, not Funnel. Funnel is public internet and remains out of scope. +- A negative check that direct tailnet access to `:10101` fails and a positive check that the + HTTPS page loads through Serve. + +### Manual ts.net certificate path + +For an operator-owned TLS proxy rather than Serve: + +```bash +tailscale cert hub-name.tailnet-name.ts.net +``` + +The certificate names only the full ts.net FQDN. The guide must tell the operator to protect +the private key, renew it through Tailscale's supported mechanism, and proxy only to +`127.0.0.1:10101`. A generic TLS proxy does not supply trustworthy Tailscale identity headers, +so it uses the Phase-2 single-use pairing rung; it must not fabricate `Tailscale-User-*`. + +Rollback: + +```bash +tailscale serve reset +ocx config set hub.managementIngress '{"enabled":false}' +ocx service repair +``` + +The reset command removes all Serve mappings on that node, so the guide must instruct the +operator to inspect `tailscale serve status` first and use a narrower supported removal command +when unrelated mappings exist. + +## 6. Docker recipe decision and contract + +The new guide contains a full example Dockerfile but the repository does not ship or publish +one in Phase 5. The example is multi-stage, pins the Bun version to the repository's +`package.json` dependency (`1.4.0` at planning time), requires the operator to resolve and pin +the base image digest, builds `gui/dist`, copies only package/runtime files plus installed +dependencies, and ends as the image's non-root `bun` user. + +Runtime contract: + +```text +working directory /home/bun/app +OPENCODEX_HOME /home/bun/.opencodex +persistent volume /home/bun/.opencodex +secret mount /run/secrets/ocx_api_token (0400/0440) +OCX_API_TOKEN_FILE /run/secrets/ocx_api_token +published data port 10100 only +management ingress 127.0.0.1:10101 inside the container; expose only through an + explicitly co-located tailnet/TLS topology +process bun run src/cli/index.ts start --port 10100 +``` + +The example must include: + +- `USER bun` (or an explicit numeric non-root uid/gid) in the final stage. +- No token in `ARG`, `ENV`, `COPY`, image history, Compose YAML, or command line. +- A named volume for `/home/bun/.opencodex`; deleting/replacing the container retains state. +- A liveness probe to `/healthz` and a separate readiness promotion check to `/readyz`. +- A data-authenticated `GET /v1/catalog` probe after ready, then one real routed response. +- `--read-only` where feasible, with writable volume and tmpfs exceptions. +- No Docker socket, host home, Codex home, SSH agent, or provider-key bind mount. + +If the secret is absent/unreadable, a non-loopback hub must fail before being accepted as +ready. A 200 `/healthz` alone is never deployment proof. + +## 7. Headless OAuth walkthrough + +The server behavior is reused from `src/oauth/open-browser-choice.ts` and +`src/server/management/oauth-account-routes.ts:208`; no new OAuth route is added. + +```bash +ocx config set oauthOpenBrowser false +``` + +Flow: + +1. From the authenticated remote GUI or management client, call `POST /api/oauth/login` + with the provider. The hub returns the authorization URL/instructions and does not invoke + a browser on the hub. +2. Open the URL on the operator's machine and complete authorization. +3. When the loopback callback cannot reach the hub, paste the final redirect URL or code into + the GUI/CLI, which sends `POST /api/oauth/login/code` with + `{provider,input}`. +4. Poll the existing status endpoint until complete. Never paste the code into shell argv, + logs, issue text, screenshots, or dogfood evidence. +5. Verify a routed request, not merely the OAuth status. + +The route keeps its existing 409 for no active flow/invalid code, 400 for unknown provider, +and 4096-character cap. Tailscale session issuance changes neither provider allowlisting nor +OAuth credential persistence. + +## 8. `clisu-oracle` dogfood runbook + +### 8.1 Safety and isolated homes + +- Use a dedicated branch worktree and dedicated `OPENCODEX_HOME` on `clisu-oracle`. +- Inventory existing listeners/services before selecting ports. Do not stop an unrelated + production proxy. +- Keep the main hub port on the Tailscale address and the management ingress on + `127.0.0.1`; do not open a cloud firewall rule. +- Record the exact git SHA, `ocx --version`, `/readyz` protocol fields, and client package + version before traffic. + +Branch deployment shape: + +```bash +ssh clisu-oracle +git -C ~/Developer/opencodex fetch origin codex/remote-hub-design +git -C ~/Developer/opencodex worktree add ~/ocx-dogfood/remote-hub FETCH_HEAD +cd ~/ocx-dogfood/remote-hub +bun install --frozen-lockfile +bun run build:gui +export OPENCODEX_HOME="$HOME/.opencodex-remote-hub-dogfood" +# Apply the §4 config with clisu-oracle's Tailscale IP/FQDN and protected token. +bun run src/cli/index.ts service install +``` + +The implementation turn must replace `FETCH_HEAD` with the recorded exact SHA before declaring +evidence; the sketch above is setup, not exact-head proof. + +### 8.2 MacBook connect and remote session + +1. On the hub, run `ocx gui pair` and copy the single-use, short-TTL code through the + interactive channel. Do not record it. +2. On the MacBook, run the Phase-3 connect command with the pairing code on stdin. The exact + Phase-3 signature must support transient `--pairing-code-stdin` (or its already-approved + equivalent) and must not accept a literal secret flag. +3. Assert `ocx connect status --json` reports protocol v1, hub URL, management URL, + management transport, and the non-secret client key id. +4. Assert `serviceApiTokenFilePath()` exists owner-only and contains the auto-issued per-client + data key; `config.toml` contains only the env-key reference. +5. Open `http://localhost:10100`, mint the remote session through HTTPS or the fixed relay, + and prove an ordinary management route works. +6. Prove a consent route is 403 with the admin token and succeeds only with the remote + `gui-session` + matching browser origin + CSRF. + +### 8.3 Per-machine usage slice + +1. Create traffic from the MacBook client key and from a second distinct client key. +2. Capture the MacBook's non-secret `apiKeyId` from connect status. +3. In connected mode, assert the Usage page reads the hub store and defaults to only that id; + the hub-wide toggle must show both clients. +4. Disconnect while the hub is reachable, then make one local standalone request. +5. Assert the disconnected Usage page reads local `usage.jsonl`, contains only local traffic, + and does not contain mirrored connect-period rows. +6. Reconnect and assert the earlier MacBook slice still exists on the hub. + +Counts, key ids, and timestamps may be recorded. Raw usage rows and all credentials may not. + +### 8.4 Release ↔ dev protocol smoke + +Two directions are mandatory once the latest published release contains protocol v1 and the +remote client commands: + +| Hub | Client | Expected | +| --- | --- | --- | +| Branch/dev build on `clisu-oracle` | `@bitkyc08/opencodex@latest` on MacBook | Same-major connect, catalog sync, one routed request, remote session. | +| `@bitkyc08/opencodex@latest` in a second isolated home/port | Branch/dev client on MacBook | Same-major connect with feature detection; unsupported optional features stay disabled. | + +Activation grounding: the 2026-08-28 tree has no released `connect` command. Therefore a current +pre-v1 `@latest` cannot construct either row and must not be reported as a pass. Before the first +v1 release, use a release-shaped `npm pack` candidate only as preflight evidence and label it +`candidate`, not `latest-release`. Phase 5 reaches terminal acceptance only after either (a) a +published protocol-v1 release makes both rows constructible or (b) the maintainer explicitly moves +the live release-pair gate to the post-release Phase-6 outcome while retaining the skew contract +tests. No silent substitution is allowed. + +## 9. Test plan and activation matrix + +Existing sibling files to extend are named in §2. Do not create a broad generic +`remote-hub.test.ts` that duplicates their established real-socket/auth/service harnesses. + +| Conditional path | Constructible activation | Required observation / owner test | +| --- | --- | --- | +| ingress missing/disabled | Hub config omits it or sets false | Exactly one fewer `Bun.serve`; public behavior byte-compatible. `loopback-listener-admission`. | +| ingress on non-hub | `runtimeRole=standalone|client`, enabled true | Write-time schema rejection before bind. `loopback-listener-admission`. | +| valid ingress | Hub + unique port | Socket binds only `127.0.0.1`; GUI, SPA, bootstrap, and authenticated `/api` work. `loopback-listener-integration`. | +| disallowed route | Request `/v1/catalog`, `/readyz`, WS upgrade, or unknown path on ingress | JSON 404 before route handling; no provider call. `loopback-listener-integration`. | +| port collision | Match public or unauthenticated-loopback port | Config rejection before startup. `loopback-listener-admission`. | +| optional bind failure | Occupy ingress port before `startServer` | Startup throws original error and every earlier listener becomes rebindable. `loopback-listener-integration`. | +| normal shutdown | Enable all three listeners, then `server.stop(true)` | All three ports become rebindable; lifecycle release happens once. `loopback-listener-integration`. | +| spoofed Tailscale header on public listener | Send allowlisted identity header to main bind | No remote session. `server-management-auth`. | +| Tailscale allowlist match on ingress | Hub ingress + HTTPS public origin + allowed identity | Session minted with server/browser origins and ingress issuance. `server-management-auth`. | +| empty/wrong allowlist | Ingress request with absent or nonmatching identity | No session; admin token still cannot exchange. `server-management-auth`. | +| pairing via generic TLS proxy | Valid one-use origin-bound grant, no Tailscale identity | Session minted once; replay fails. `server-management-auth`. | +| service token present | Non-loopback hub + env token + install builder | Protected token path referenced; literal absent from unit/plist. `service.test`. | +| service token absent | Non-loopback hub, no env/file token | Install refuses before registration. `service.test`. | +| headless OAuth | `oauthOpenBrowser=false`, active provider flow | URL returned, no server-side open, manual code accepted. `oauth-manual-code`. | +| bad manual code | Unknown provider, no active flow, or >4096 input | Existing 400/409 response; no credential mutation. `oauth-manual-code`. | +| Docker secret missing | Non-loopback container without mounted token | Not ready / startup refusal; never accept health alone. Deployment smoke. | +| connected usage | Two client ids create hub traffic | This-machine slice and hub-wide toggle differ; hub store only. Dogfood. | +| disconnected usage | Disconnect then local standalone traffic | Local store only; no mirrored hub rows. Dogfood. | +| protocol same-major | Constructible v1 release/dev peers | Both directions connect with feature detection. Dogfood + Phase-6 skew tests. | + +## 10. Acceptance criteria + +- [ ] Default standalone and hub-with-ingress-disabled startup remain byte-compatible at the + public listener. +- [ ] Management ingress is kernel-bound to `127.0.0.1`, default-deny, and serves no data, + health, readiness, or WebSocket route. +- [ ] A failed optional bind rolls back every prior bind; normal stop joins every listener. +- [ ] `src/server/index.ts` remains synchronous through the guarded startup window and no new + subsystem enters the three core import graphs. +- [ ] Tailscale identity is accepted only on management ingress and only for an exact configured + user; admin-token-only consent remains 403. +- [ ] launchd/systemd installs use the existing secret-file flow and prove serving, readiness, + authenticated catalog, and a real routed response. +- [ ] Docker recipe is non-root, volume-backed, secret-file-based, and checks liveness + + readiness + authenticated functionality. +- [ ] Headless OAuth completes without opening a hub browser and produces a usable provider + route. +- [ ] `clisu-oracle` dogfood proves MacBook connect, remote session, machine usage slice, + disconnect/local-store behavior, and rollback. +- [ ] Release/dev compatibility is either genuinely run with a protocol-v1 published peer or + explicitly remains a named, non-waived gate per §8.4. +- [ ] No token, pairing grant, OAuth code, email, account id, request body, or raw usage row is + present in git diff or evidence. + +## 11. Verification — remote only + +Do not run any command below locally. Use an isolated checkout on `lidge-ai` at the exact SHA. + +```bash +VERIFY_SHA="$(git rev-parse HEAD)" +ssh lidge-ai "set -eu + export PATH=\$HOME/.bun/bin:\$PATH + repo=\$HOME/ocx-verify/remote-hub-p5 + git -C \$repo fetch origin + git -C \$repo checkout --detach $VERIFY_SHA + test \"\$(git -C \$repo rev-parse HEAD)\" = \"$VERIFY_SHA\" + cd \$repo + bun install --frozen-lockfile + bun run typecheck + bun test tests/loopback-listener-admission.test.ts \ + tests/loopback-listener-integration.test.ts \ + tests/server-management-auth.test.ts \ + tests/service.test.ts \ + tests/oauth-manual-code.test.ts \ + tests/core-lab-boundary.test.ts + cd docs-site + bun install --frozen-lockfile + bun run build +" +``` + +Then execute §8 on `clisu-oracle`; record exact SHA/version, sanitized protocol fields, HTTP +statuses, key ids/counts, and rollback result. A green `lidge-ai` suite does not replace the +deployment smoke, and a green `/healthz` does not replace ready/catalog/routed/session proof. diff --git a/devlog/_plan/260827_remote_hub/080_phase6_hardening.md b/devlog/_plan/260827_remote_hub/080_phase6_hardening.md new file mode 100644 index 0000000000..6e24eaa8b9 --- /dev/null +++ b/devlog/_plan/260827_remote_hub/080_phase6_hardening.md @@ -0,0 +1,633 @@ +# 080 — Phase 6: hardening, documentation sync, and release gate + +Unit: `260827_remote_hub` · Phase: 6/6 · Work class: C4 (auth, secrets, relay, release) · Status: implementation-ready + +Dependencies: Phases 1–5 are behaviorally complete, including a `clisu-oracle` dogfood +record. This phase hardens the contracts; it does not redesign hub/client roles or introduce +another transport. + +Every executable verification command in this document runs on `ssh lidge-ai`, never on +the workstation. Full-suite execution is serialized with other `lidge-ai` suite owners. + +## 0. Locked outcome and boundaries + +### IN + +- Recoverable per-client data-key rotation with a one-time secret response, bounded overlap, + client-side atomic token-file replacement, explicit commit/abort, and stable `apiKeyId` usage + attribution. +- Remote-session self-logout, automatic session invalidation after key commit/delete, and + disconnect-time best-effort revocation without making hub availability a prerequisite for + offline local restore. +- Pairing issuance/redemption limits, one-use semantics, bounded active state, and safe 429s. +- Protocol negotiation matrix tests covering the v1 compatibility floor and feature detection. +- Adversarial `/v1/catalog` consumer tests for decompressed size, malformed JSON, invalid schema, + row limits, stale ETags, and no-write failure behavior. +- Fixed-target relay negatives for SSRF, redirect escape, authority confusion, hop-by-hop header + injection, CL/TE ambiguity, response header stripping, and bounded streaming. +- Public documentation synchronized across every locale currently configured by Starlight. +- Full lidge gate and explicit MAINTAINERS security-review evidence for every auth-surface PR. + +### OUT + +- No multi-hub replication, failover, public Funnel, generic reverse proxy, VPN replacement, + identity provider, organization/tenant RBAC, key escrow, usage mirroring, or automatic release. +- No provider-key/OAuth rotation. This phase rotates only per-client data admission keys. +- No data key gains general `/api/*` authority. Rotation uses a transient pairing/admin authority + and the existing management gate; a data key cannot mint a GUI session or rotate itself. +- No admin-token-to-`gui-session` exchange, including in tests, migration, compatibility, or + emergency fallback paths. +- No edits to or new imports from remote subsystems into `src/router.ts`, + `src/server/lifecycle.ts`, or `src/server/responses/core.ts`. +- No body-level `await` in the guarded `src/server/index.ts` startup window. + +## 1. Threat model and must-pass controls + +| Attacker / failure | Asset at risk | Required control | +| --- | --- | --- | +| Holder of one client data key | Other clients, management, provider keys | Data-only scope; rotation needs transient management authority; same key id never reveals another key. | +| Holder of hub admin token | Browser-consent routes | May rotate/revoke ordinary data credentials, but can never mint or exchange into `gui-session`. | +| Pairing-code guesser/replayer | Remote GUI consent session | High-entropy one-use grant, short TTL, origin binding, per-grant and aggregate attempt caps, immediate consumption. | +| Malicious/compromised hub response | Client filesystem/memory | Decompressed byte cap, schema/row validation, atomic write after validation, LKG retained, no local fallback. | +| Browser controlling relay path/headers | Hub network and credentials | Destination fixed by connection state; route allowlist; redirects blocked; authority and hop-by-hop headers rebuilt. | +| Header-smuggling attempt | Hub request parser/proxy chain | Reject transfer-encoding, conflicting content-length, connection-nominated headers, CR/LF values, and upgrade paths. | +| Protocol-skewed peer | Local client files / silent misroute | Negotiate before any local write; reject incompatible floors with explicit upgrade error; unknown features stay off. | +| Rotation crash between hub and client | Client availability | Old and pending keys overlap for a bounded window; commit only after new-key probe; abort/expiry preserves old key. | +| Session surviving credential change | Revoked client access | Key commit/delete invalidates sessions and pairing grants bound to that `apiKeyId`; current in-flight data turn may finish, next admission fails. | +| Logs/evidence | Tokens, codes, identities | Record ids/prefixes/counts/status only; privacy scan; no raw secret, email, Origin query, request body, or account id. | + +Security level: ASVS L2 for the remote management/session surface. Applicable architecture, +session, access-control, validation, secret-rotation, CORS, error, and API checks must be attached +to the security review; a generic checklist tick with no test/evidence link is insufficient. + +## 2. Diff-level file-change map + +All existing paths were verified against the 2026-08-28 tree. Paths under `src/client/` and the +remote-session/pairing owners are Phase-2–4 dependencies; those directories are absent on the +planning base and must exist before Phase 6 begins. If an earlier phase deliberately chose a +different exact owner path, amend this file mechanically before implementation rather than adding +a second owner. + +### 2.1 Key rotation and session invalidation + +| Path | Change | Exact responsibility | +| --- | --- | --- | +| `src/types/config.ts` | MODIFY | Extend `OcxApiKeyEntry` with an optional, secret-bearing pending-rotation record; keep stable id/name/createdAt. | +| `src/config.ts` | MODIFY | Validate/degrade pending rotation independently so one malformed pending record cannot reset providers or revoke the current key. | +| `src/server/auth-cors.ts` | MODIFY | Admit an unexpired pending key under the same configured `apiKeyId`; never return or serialize its secret. | +| `src/server/management/api-key-rotation.ts` | NEW | Single owner for start/commit/abort/expiry cleanup and constant-time rotation-id comparison. | +| `src/server/management/oauth-account-routes.ts` | MODIFY | Add the three rotation operations next to existing `/api/keys` CRUD and call session invalidation after commit/delete. Existing GET continues to mask all secrets. | +| `src/server/management/session-routes.ts` | NEW | `POST /api/session/logout` self-revocation route; requires the current `gui-session` and CSRF. Admin token receives 403, not a promoted session. | +| `src/server/management-api.ts` | MODIFY | Wire `handleSessionRoutes` and the narrow session-control dependency into `ManagementContext`. | +| `src/server/management/context.ts` | MODIFY | Carry only the revocation interface, never the raw admin token or session map. | +| `src/server/management-auth.ts` | MODIFY | Associate remote sessions with optional `apiKeyId`; export narrow current/by-key invalidation helpers; preserve one shared auth predicate. | +| `src/client/connect.ts` | MODIFY | Implement `ocx connect rotate`: transient authority, start rotation, atomically replace token file, validate new key, commit, and restore+abort on failure. | +| `src/client/state.ts` | MODIFY | Persist non-secret key id and pending operation metadata only; never persist admin/pairing authority or old/new secret. | +| `src/cli/access.ts` | MODIFY | Add management-side `ocx access key rotate ` start/commit/abort UX with one-time secret warning; no literal secret flags. | +| `src/cli/registry.ts` | MODIFY | Document rotation command shapes and transient-authority requirement. | +| `gui/src/pages/ApiKeys.tsx` | MODIFY | Load pending status, start/commit/abort rotation, and render the new secret exactly once. | +| `gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx` | MODIFY | Accessible rotation confirmation/status/error UI; distinguish pending, committed, expired, and aborted outcomes. | +| `gui/src/i18n/en.ts` | MODIFY | Canonical rotation/session strings and `TKey`. | +| `gui/src/i18n/de.ts` | MODIFY | Locale parity. | +| `gui/src/i18n/fr.ts` | MODIFY | Locale parity. | +| `gui/src/i18n/ja.ts` | MODIFY | Locale parity. | +| `gui/src/i18n/ko.ts` | MODIFY | Locale parity. | +| `gui/src/i18n/ru.ts` | MODIFY | Locale parity. | +| `gui/src/i18n/tr.ts` | MODIFY | Locale parity. | +| `gui/src/i18n/zh-TW.ts` | MODIFY | Locale parity. | +| `gui/src/i18n/zh.ts` | MODIFY | Locale parity. | +| `tests/api-keys-routes.test.ts` | MODIFY | Rotation route contract, masking, pending overlap, commit, abort, expiry, malformed inputs, and delete invalidation. | +| `tests/data-plane-admission-identity.test.ts` | MODIFY | Current and pending secret map to one id; expired/committed/aborted secrets do not admit. | +| `tests/api-key-attribution.test.ts` | MODIFY | Traffic before/during/after rotation remains one `apiKeyId` bucket. | +| `tests/server-management-auth.test.ts` | MODIFY | Session self-logout/by-key invalidation and admin-token refusal. | +| `gui/tests/apikeys-actions.test.tsx` | MODIFY | Start/commit/abort wire actions and one-time secret handling. | +| `gui/tests/apikeys-mutation-timeout.test.tsx` | MODIFY | Rotation controls recover after bounded network failure. | +| `gui/tests/apikeys-workspace.test.tsx` | MODIFY | Accessible rendered states and confirmations. | +| `gui/tests/locale-parity.test.ts` | VERIFY/MODIFY | All new visible strings exist in every GUI locale. | + +### 2.2 Pairing, protocol, catalog, and relay hardening + +| Path | Change | Exact responsibility | +| --- | --- | --- | +| `src/server/gui-session.ts` | MODIFY (Phase-2 owner) | Extend the existing digest-only pairing/session owner with bounded attempt stores, source-key derivation, 429 result, expiry, and by-key revocation. | +| `src/remote/protocol.ts` | MODIFY (Phase-1 owner) | Extend the existing pure parser/interval-compatibility owner with additive feature intersection; no I/O or local writes. | +| `src/client/catalog.ts` | MODIFY | Bounded decompressed read, strict remote catalog validation, ETag/LKG handling, and atomic-write precondition. | +| `src/client/relay.ts` | MODIFY | Fixed-target URL construction, request/response header rebuilding, redirect refusal, body/stream caps, and redacted errors. | +| `tests/server-management-auth.test.ts` | MODIFY (Phase-2 owner) | Deterministic pairing attempt/TTL/capacity/replay/race matrix in the existing primary session suite. | +| `tests/proxy-liveness.test.ts` | MODIFY (Phase-1 owner) | Protocol metadata parsing remains additive while ordinary readiness identity remains strict. | +| `tests/cli-ready-subprocess.test.ts` | MODIFY | Full released-process skew matrix and no-write mismatch outcomes. | +| `tests/remote-catalog.test.ts` | MODIFY (Phase-1/3 owner) | Oversized/malformed/schema/ETag/LKG/no-write adversarial matrix. | +| `tests/client-hub-relay.test.ts` | MODIFY (Phase-4 owner) | SSRF, redirect, authority, smuggling, header stripping, and bounded streaming negatives. | +| `tests/bounded-body.test.ts` | MODIFY only if shared helper changes | Reuse exact-cap/one-byte-over/trickle semantics; do not duplicate the helper contract in client tests. | +| `tests/credential-redirect-guard.test.ts` | EXTEND/REUSE | Existing sibling evidence for credential-bearing redirect refusal. | +| `tests/provider-outbound-private-network.test.ts` | EXTEND/REUSE | Existing sibling vocabulary for destination classification; relay remains fixed-target rather than a provider fetch. | +| `tests/cli-ready.test.ts` | EXTEND/REUSE | Existing readiness identity/shape harness for protocol fields. | +| `tests/cli-ready-subprocess.test.ts` | EXTEND/REUSE | Released CLI subprocess compatibility fixtures and no-write rejection. | +| `tests/core-lab-boundary.test.ts` | VERIFY ONLY | Core import graph and synchronous startup window remain green. | + +### 2.3 Source-of-truth and public docs + +| Path | Change | Exact responsibility | +| --- | --- | --- | +| `structure/01_runtime.md` | MODIFY | Final hub/client protocol, listener, catalog, and relay ownership map. | +| `structure/02_config-and-codex-home.md` | MODIFY | Client token-file ownership, rotation overlap, disconnect deletion, and no usage mirroring. | +| `structure/05_gui-and-management-api.md` | MODIFY | Final credential classes, issuance ladder, revocation, rate limits, origin/CSRF, and admin consent refusal. | +| `structure/06_docs-and-release.md` | MODIFY | Correct the locale inventory and record the remote-hub release gate. | +| `structure/09_client-integrations.md` | MODIFY | Remote connection journal/restore, direct data path, fixed relay, and launcher-scoped Claude behavior. | +| `docs-site/astro.config.mjs` | MODIFY | Final Remote Hub sidebar label/translations for every configured locale. | + +The roadmap's “5 locales” count is stale. `docs-site/astro.config.mjs` currently declares eight +site locales: root English, `fr`, `ko`, `zh-cn`, `zh-tw`, `ru`, `ja`, and `tr`. Phase 6 must not +drop the later Russian, Japanese, or Turkish trees merely to satisfy the older count. + +New translated guide files (English was created in Phase 5): + +- `docs-site/src/content/docs/fr/guides/remote-hub.md` +- `docs-site/src/content/docs/ko/guides/remote-hub.md` +- `docs-site/src/content/docs/zh-cn/guides/remote-hub.md` +- `docs-site/src/content/docs/zh-tw/guides/remote-hub.md` +- `docs-site/src/content/docs/ru/guides/remote-hub.md` +- `docs-site/src/content/docs/ja/guides/remote-hub.md` +- `docs-site/src/content/docs/tr/guides/remote-hub.md` + +Existing pages to synchronize in all eight trees: + +- CLI lifecycle/connect/service: + `docs-site/src/content/docs/reference/cli/lifecycle.md`, + `docs-site/src/content/docs/fr/reference/cli/lifecycle.md`, + `docs-site/src/content/docs/ko/reference/cli/lifecycle.md`, + `docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md`, + `docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md`, + `docs-site/src/content/docs/ru/reference/cli/lifecycle.md`, + `docs-site/src/content/docs/ja/reference/cli/lifecycle.md`, + `docs-site/src/content/docs/tr/reference/cli/lifecycle.md`. +- Server/runtime config: + `docs-site/src/content/docs/reference/configuration/server.md`, + `docs-site/src/content/docs/fr/reference/configuration/server.md`, + `docs-site/src/content/docs/ko/reference/configuration/server.md`, + `docs-site/src/content/docs/zh-cn/reference/configuration/server.md`, + `docs-site/src/content/docs/zh-tw/reference/configuration/server.md`, + `docs-site/src/content/docs/ru/reference/configuration/server.md`, + `docs-site/src/content/docs/ja/reference/configuration/server.md`, + `docs-site/src/content/docs/tr/reference/configuration/server.md`. +- Management/data contracts: + `docs-site/src/content/docs/reference/management-api.md`, + `docs-site/src/content/docs/fr/reference/management-api.md`, + `docs-site/src/content/docs/ko/reference/management-api.md`, + `docs-site/src/content/docs/zh-cn/reference/management-api.md`, + `docs-site/src/content/docs/zh-tw/reference/management-api.md`, + `docs-site/src/content/docs/ru/reference/management-api.md`, + `docs-site/src/content/docs/ja/reference/management-api.md`, + `docs-site/src/content/docs/tr/reference/management-api.md`. +- Dashboard two-plane/session/usage behavior: + `docs-site/src/content/docs/guides/web-dashboard.md`, + `docs-site/src/content/docs/fr/guides/web-dashboard.md`, + `docs-site/src/content/docs/ko/guides/web-dashboard.md`, + `docs-site/src/content/docs/zh-cn/guides/web-dashboard.md`, + `docs-site/src/content/docs/zh-tw/guides/web-dashboard.md`, + `docs-site/src/content/docs/ru/guides/web-dashboard.md`, + `docs-site/src/content/docs/ja/guides/web-dashboard.md`, + `docs-site/src/content/docs/tr/guides/web-dashboard.md`. + +English is canonical. Translations may be concise, but must preserve warnings, config keys, +defaults, command flags, endpoint names, and the “admin token never grants consent” statement. + +## 3. Per-client key rotation contract + +### 3.1 Persisted shape + +```ts +export interface OcxPendingApiKeyRotation { + id: string; // random opaque rotation id, compared constant-time + key: string; // pending data secret; never serialized by GET/list/status + createdAt: string; + expiresAt: string; +} + +export interface OcxApiKeyEntry { + id: string; + name: string; + key: string; + createdAt: string; + pendingRotation?: OcxPendingApiKeyRotation; +} +``` + +One configured id owns at most one pending rotation. The overlap TTL is 10 minutes. The old +and pending keys both admit data during that window and both attribute to the same id. Expiry +removes only the pending key; the old key remains authoritative. A process restart reloads the +durable pending state and applies the same expiry rule. + +### 3.2 Pure owner signatures + +```ts +export type ApiKeyRotationStart = { + id: string; + name: string; + key: string; // returned once by start only + rotationId: string; + expiresAt: string; +}; + +export function startApiKeyRotation( + config: OcxConfig, + keyId: string, + now?: number, +): ApiKeyRotationStart | { error: "not-found" | "already-pending" }; + +export function commitApiKeyRotation( + config: OcxConfig, + keyId: string, + rotationId: string, + now?: number, +): { ok: true } | { error: "not-found" | "expired" | "mismatch" }; + +export function abortApiKeyRotation( + config: OcxConfig, + keyId: string, + rotationId: string, +): boolean; +``` + +Routes stay under the existing management auth: + +```text +POST /api/keys/rotate {id} -> 201 + one-time key +POST /api/keys/rotate/commit {id,rotationId} -> 200 +DELETE /api/keys/rotate {id,rotationId} -> 200 +``` + +Unknown fields are rejected. Error envelopes distinguish not found (404), conflict/already +pending or mismatched/expired (409), invalid body (400), and busy persistence (existing 503). +No response except successful start contains the pending secret. + +### 3.3 Client transaction + +`ocx connect rotate` requires one transient `--pairing-code-stdin` or +`--admin-token-stdin`; neither is persisted. It performs: + +1. Read current key id and current token into memory; create no output containing either secret. +2. Start rotation; receive pending secret once. +3. Write pending secret to a same-directory owner-only temp, harden it with the same + `serviceApiTokenFilePath()` rules, fsync, and atomically replace the token file. +4. Probe authenticated `/v1/catalog` with the new key and verify the expected client key id via + the safe response/diagnostic contract. +5. Commit rotation. Commit invalidates old-key admission, sessions, and pairing grants bound to + that key id. An already-admitted in-flight turn may complete; the next old-key request is 401. +6. If steps 3–5 fail before a confirmed commit, restore the old token atomically and abort the + pending rotation. If commit outcome is uncertain, probe with both keys: exactly one accepted + result determines the local file; never replay commit blindly. + +The GUI exposes the same lifecycle for an operator updating a client manually, with explicit +copy-once and commit-after-client-probe wording. Closing the modal does not imply commit; pending +state remains visible and abortable until expiry. + +## 4. Session invalidation contract + +Phase-2 `GuiSessionRecord` gains optional `apiKeyId` for sessions created from a client-bound +pairing grant. Loopback and Tailscale sessions without a client association may omit it. + +```ts +export interface ManagementSessionControl { + revokeCurrent(req: Request): boolean; + revokeForApiKeyId(apiKeyId: string): number; +} + +export function createManagementSessionControl( + state: ManagementAuthState, +): ManagementSessionControl; +``` + +`handleManagementAPI` receives this narrow control (directly or through `ManagementContext`), +not the session map and never the admin token. `POST /api/session/logout` requires +`principal === "gui-session"`, same browser Origin, and CSRF. Admin-token calls return 403. + +Rotation commit and key deletion call `revokeForApiKeyId` only after config persistence commits. +A persistence failure leaves key and sessions unchanged. `ocx disconnect` calls self-logout +best-effort before local restore; hub-down still restores from the local journal and reports that +remote session expiry/revocation could not be confirmed. + +## 5. Pairing rate limits + +The Phase-2 `src/server/gui-session.ts` owner remains the only grant store. Add no generic middleware and no timer on +the standalone/core request path. + +```ts +export interface PairingAttemptContext { + ingress: "public" | "hub-management"; + peerAddress: string | null; + tailscaleUser: string | null; // populated only by trusted management ingress + browserOrigin: string; +} + +export type PairingAttemptResult = + | { allowed: true } + | { allowed: false; retryAfterSeconds: number; reason: "grant" | "source" | "capacity" }; +``` + +Fixed starting limits (configurable downward only is unnecessary in v1): + +- Grant TTL: Phase-2 short TTL, capped at 10 minutes. +- One successful redemption consumes immediately before session return. +- Five failed redemption attempts burn that grant. +- Ten failed attempts per source key in 10 minutes produce 429; source key is allowlisted + Tailscale identity on trusted ingress, otherwise immediate peer address, otherwise the global + anonymous bucket. +- At most 128 live grants and 1,024 source buckets. Capacity refusal is 429 and creates no grant. +- Expired grant/source entries are pruned synchronously on pairing operations; no core timer. +- `Retry-After` is integer seconds, bounded by the remaining window, and contains no identity. +- Constant-time code comparison; generic invalid/expired/consumed response; no existence oracle. +- Rotation commit/key delete revoke unconsumed grants associated with that client key id. + +Rate-limit logs contain only reason, ingress class, and aggregate count. No code, raw IP, +Tailscale user/email, Origin, token, or account id. + +## 6. Protocol skew matrix + +Phase 1's wire fields remain `protocol`, `minimumClientProtocol`, and `managementUrl`; Phase 6 +adds optional additive `features: string[]`. Protocol v1 is the compatibility floor. Negotiation is pure and +must run before catalog download, token-file writes, injector preflight, journal writes, or state +persistence. + +```ts +export interface RemoteReadyMetadata { + protocol: number; + minimumClientProtocol: number; + managementUrl: string; + features?: string[]; +} + +export type RemoteProtocolCompatibility = + | { ok: true; metadata: RemoteReadyMetadata; features: Set } + | { ok: false; reason: "invalid" | "hub-too-new" | "hub-too-old"; message: string }; + +export function checkRemoteProtocolCompatibility( + value: unknown, + client?: { protocol: number; minimumHubProtocol: number; features?: readonly string[] }, +): RemoteProtocolCompatibility; +``` + +Required matrix: + +| Hub descriptor | Client | Activation | Expected | +| --- | --- | --- | --- | +| p1/min1/baseline | p1/min1 | first v1 pair | Accept baseline. | +| p2/min1/A+B | p1/min1/A | newer dev hub, latest v1 client | Accept p1 behavior; feature intersection = A. | +| p2/min2 | p1/min1 | hub dropped v1 floor | Reject `hub-too-new` before write. | +| p1/min1 | p2/min2 | dev client requires newer hub | Reject hub-too-old before write. | +| p1/min1/unknown-X | p1/min1 | additive unknown feature | Accept; unknown feature remains disabled. | +| missing/NaN/fraction/negative/min>protocol or invalid `managementUrl` | p1 | malformed or legacy non-v1 hub | Exact Phase-1 `invalid` message; zero local writes. | +| valid descriptor, `/readyz` pending/failed | any | startup not ready | Do not negotiate or write; preserve existing readiness behavior. | + +The guaranteed live pair is dev hub ↔ latest published protocol-v1 client and the reverse. +Until a release with `connect` exists, fixture tests and release-shaped package candidates are +preflight only; they do not satisfy the live published-pair gate recorded in 070 §8.4. + +## 7. Catalog adversarial contract + +The remote consumer owns the Phase-1 `MAX_REMOTE_CATALOG_BYTES` 32 MiB **decompressed** body cap +and a 2,000 model-row cap. Use the existing bounded-response helper when +its API can express this without importing `src/server/responses/core.ts`; otherwise add a client +leaf that depends only on `src/lib/bounded-body.ts`. + +Validation order: + +1. Status/redirect: only 200 or valid 304; redirect is refused, not followed. +2. Content type is JSON-compatible; content length above cap rejects early, but streamed bytes are + still counted because length may be absent or false. +3. Read at most cap+1 decompressed bytes; exactly cap is allowed, one byte over cancels/discards. +4. Parse JSON once. Top level must be a plain object with `models` array. +5. `models.length <= 2000`; every row is a plain object with a non-empty printable `slug` string; + reject NUL/control characters and duplicate slugs. Preserve additive unknown fields after the + required shape passes. +6. Serialize/write only after complete validation. Failed refresh retains the exact LKG bytes and + stale age; no local provider fallback and no partial file. +7. A 304 without an existing validated LKG triggers one unconditional refetch; a second 304 is a + protocol error, not an empty catalog. + +Adversarial tests include: forged small Content-Length with oversized chunks, gzip/decompressed +oversize fixture, exact-cap and cap+1, fragmented trickle, malformed/truncated/UTF-8 JSON, null, +array top level, missing/non-array models, 2,001 rows, non-object row, empty/control/duplicate slug, +unexpected future fields, stale/mismatched ETag, and filesystem write failure after validation. +Every rejection asserts token/catalog/state/journal bytes are unchanged. + +## 8. Relay SSRF and header-smuggling negatives + +`src/client/relay.ts` is not a general proxy. Its destination is the validated +`connectionState.managementUrl` captured when the listener starts. A request cannot supply or +override scheme, host, port, userinfo, fragment, DNS result, or redirect target. + +### 8.1 URL/path rules + +- Accept only relative paths in the Phase-4 allowlist: session bootstrap and the explicitly + supported `/api/*` management namespace. +- Reject absolute-form URLs, scheme-relative `//host`, backslashes, userinfo, fragments, + percent-decoded authority/path confusion, encoded slash/backslash traversal, and any path that + normalizes outside the allowlist. +- Resolve against the fixed management origin, then assert protocol/hostname/port equal the fixed + origin before fetch. +- `redirect:"manual"`/`"error"`; every 3xx is an error and Location is never followed or returned + with credentials. +- Private/tailnet destinations are allowed because the operator selected the hub; SSRF prevention + is fixed authority, not a blanket public-IP rule. + +### 8.2 Request headers and body + +Build a fresh allowlist. Preserve only required content negotiation plus Phase-2 session/origin/CSRF +headers. Never forward caller `Host`, `Forwarded`, `X-Forwarded-*`, `Tailscale-User-*`, cookies, +proxy auth, upgrade, or data-plane authorization. The relay's management session credential is +attached by the trusted client owner, not copied from arbitrary browser input. + +Strip the standard hop-by-hop set and every header named by `Connection`: `connection`, +`keep-alive`, `proxy-authenticate`, `proxy-authorization`, `te`, `trailer`, +`transfer-encoding`, and `upgrade`. Reject any request carrying Transfer-Encoding, multiple or +invalid Content-Length, CL/TE together, CR/LF in a header value, unsupported method, or body above +the management cap. Do not rely on Fetch normalization as the only smuggling defense; tests call +the pure validator with raw tuples for otherwise-unconstructible header shapes. + +### 8.3 Response rules and streaming + +- Rebuild response headers and strip hop-by-hop headers, `Set-Cookie`, proxy auth, server identity + headers, Tailscale identity, and connection-nominated headers. +- Preserve safe content type, cache control, ETag, retry-after, and approved CORS/session bootstrap + metadata only. +- Enforce Phase-4 management body caps. Phase-6 streaming uses backpressure and abort propagation; + it must not buffer an unbounded response or continue after browser disconnect. +- Errors name only status/category and fixed hub label. No destination URL query, session token, + admin token, response body, or identity header reaches logs. + +Negative test servers bind loopback only. No test reaches cloud metadata, public internet, LAN, or +the user's configured real hub. + +## 9. Test plan and activation matrix + +Existing siblings to extend are listed in §2. Tests created by earlier phases remain their owners; +Phase 6 extends them rather than creating parallel “hardening2” files. + +| Conditional path | Constructible activation | Required observation | +| --- | --- | --- | +| rotation start | Existing key, no pending rotation, admin/session authority | New key returned once; old+pending both admit under same id; list masks both. | +| second start | Existing unexpired pending rotation | 409; no third secret/state change. | +| client commit | New key written + authenticated catalog succeeds | Pending promoted atomically; old next request 401; id/usage bucket stable; sessions/grants invalidated. | +| client write/probe fail | Fail temp write, hardening, rename, or new-key probe | Old file restored/unchanged; pending aborted or expires; old key remains valid. | +| uncertain commit | Drop commit response after server may commit | Probe old+new; choose sole accepted key; no blind replay. | +| pending expiry | Fake clock past 10 minutes | Pending rejected/removed; old accepted. | +| delete key | Delete configured key with bound sessions/grants | Admission, sessions, and grants revoked after persistence only. | +| self logout | GUI session + Origin + CSRF | Current session removed; replay 401. Admin-token call 403. | +| pairing bad guesses | Same grant/source repeated with fake clock | Fifth grant failure burns; source threshold yields 429 + bounded Retry-After; no identity leak. | +| pairing replay/race | Two concurrent valid redemptions | Exactly one session; other generic failure. | +| pairing capacity | Fill 128 grants / 1,024 buckets | Refusal/prune behavior bounded; no eviction of a newer live grant to admit attacker input. | +| newer compatible hub | p2/min1 + p1 client | Feature intersection only; no unsupported path. | +| incompatible floor | client Date: Fri, 28 Aug 2026 01:15:43 +0900 Subject: [PATCH 07/12] =?UTF-8?q?docs(devlog):=20fold=20roadmap=20audit=20?= =?UTF-8?q?r1=20=E2=80=94=2010=20blockers=20closed=20across=20010-080=20(s?= =?UTF-8?q?ynthesis=20in=20002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../002_audit_r1_synthesis.md | 16 +++ devlog/_plan/260827_remote_hub/010_design.md | 6 +- .../030_phase1_protocol_catalog.md | 28 +++-- .../040_phase2_remote_session.md | 14 ++- .../260827_remote_hub/050_phase3_connect.md | 22 ++-- .../260827_remote_hub/060_phase4_two_plane.md | 50 +++++---- .../260827_remote_hub/070_phase5_deploy.md | 8 +- .../260827_remote_hub/080_phase6_hardening.md | 106 ++++++++++-------- 8 files changed, 154 insertions(+), 96 deletions(-) create mode 100644 devlog/_plan/260827_remote_hub/002_audit_r1_synthesis.md diff --git a/devlog/_plan/260827_remote_hub/002_audit_r1_synthesis.md b/devlog/_plan/260827_remote_hub/002_audit_r1_synthesis.md new file mode 100644 index 0000000000..ca2dcd3c0c --- /dev/null +++ b/devlog/_plan/260827_remote_hub/002_audit_r1_synthesis.md @@ -0,0 +1,16 @@ +# 002 — Audit synthesis, roadmap round 1 (FAIL, 10 blockers) — canonical decisions + +Reviewer: Volta (same reviewer retained for re-audit). Per-blocker disposition: + +1 [fold] Fixture repair: hub-too-new = hub{p:2,min:2} vs client p1; hub-too-old = client p2 requiring min2 vs hub{p:1,min:1}. Zero/malformed rows move to the malformed-input test class (400), not the mismatch class. +2 [fold] Chain completion: 030's readyz metadata builder signature becomes build(config, req) from Phase 1; Phase 2's file map adds src/remote/protocol.ts + the /readyz handler as consumers of hub.managementPublicOrigin (config wins over observed origin when set). +3 [fold] Pairing end-to-end: the relay (060) and the mgmt ingress (070) BOTH allow POST /opencodex-session (exchange) in addition to GET bootstrap; ocx gui pair prints a code bound to a caller-supplied browser origin (default http://localhost:10100); dogfood config (070) adds corsAllowOrigins:["http://localhost:10100"]. +4 [fold] Plane mapping is per-CALL, not per-page: Startup/Integrations keep their existing /api/* calls on the shared plane; only new machine sections call /api/machine/*. 060 file map adds gui/src/pages/Startup.tsx, Integrations.tsx, ApiKeys.tsx, Grok.tsx (call-site routing), and drops the page-level table. +5 [fold] Canonical names, propagated everywhere: routes = exactly 060's /api/machine/{status,clients,sync,shim,disconnect,hub-relay} with GET/POST /api/machine/shim (no PUT clients/:id — 010 updated); connect flags = --pairing-code-stdin | --admin-token-stdin (050 drops --credential-*; 010 drops --token-env/--token-stdin). +6 [fold] Phase 6 owners renamed to the real creators: src/client/hub-client.ts, src/client/hub-relay.ts, tests/client-connect.test.ts; tests/remote-catalog.test.ts either created BY Phase 6 (listed as Add) or folded into client-connect tests — 080 names it as Add. +7 [fold] /v1/catalog gains authenticated-only response header x-opencodex-key-id echoing the admitted key's id (030 IN-scope; never on unauthenticated paths); 080's rotation probe consumes it. +8 [fold] Remove impossible self-invalidation: pairing grants are NOT key-bound; disconnect revokes nothing on the hub by itself — key deletion is an operator action (hub GUI / ocx connect revoke WITH admin credential). 080 reworded; 040 grant contract loses boundKeyId. +9 [fold] 050: transient admin credential is retained in memory until the connect transaction commits or rolls back, then zeroized. +10 [fold] 080 rotation names src/cli/connect.ts (parser) + tests; src/client/state.ts pendingOperation {kind:"rotate", newKeyIssuedAt, oldKeyBackupPath} full chain; rotation writes old key to .prev (0600) until verified commit, then deletes — crash recovery documented. + +No rebuttals; all 10 folded. diff --git a/devlog/_plan/260827_remote_hub/010_design.md b/devlog/_plan/260827_remote_hub/010_design.md index 85639ec0b2..33332c77fd 100644 --- a/devlog/_plan/260827_remote_hub/010_design.md +++ b/devlog/_plan/260827_remote_hub/010_design.md @@ -146,7 +146,7 @@ data plane still needs the token, management still needs admin-token/session. ## 4. ocx connect (client mode) ```text -ocx connect [--management-url ] [--token-env NAME | --token-stdin] +ocx connect [--management-url ] [--pairing-code-stdin | --admin-token-stdin] [--clients codex,claude] [--management-transport direct|relay] [--no-sync] ocx disconnect [--keep-catalog] ocx connect status [--json] ``` @@ -175,8 +175,8 @@ opt-in with ownership records. Explicit allowlist, default-404 (same failure mode as loopbackRouteAllowed): /healthz · /readyz · GET /api/machine/status · GET /api/machine/clients · -POST /api/machine/sync · PUT /api/machine/clients/:id · POST /api/machine/shim/* · -POST /api/machine/disconnect · POST /api/machine/hub-relay/* (opt-in only). +POST /api/machine/sync · GET/POST /api/machine/shim · POST /api/machine/disconnect · +POST /api/machine/hub-relay (opt-in only). Mutations need local gui-session + CSRF (auto-minted on loopback, today's flow). Relay constraints (it is NOT a proxy): fixed destination = client.managementUrl; diff --git a/devlog/_plan/260827_remote_hub/030_phase1_protocol_catalog.md b/devlog/_plan/260827_remote_hub/030_phase1_protocol_catalog.md index 1765944173..90e8ed526d 100644 --- a/devlog/_plan/260827_remote_hub/030_phase1_protocol_catalog.md +++ b/devlog/_plan/260827_remote_hub/030_phase1_protocol_catalog.md @@ -33,6 +33,8 @@ runtime behavior. All code paths remain standalone-compatible when `runtimeRole` `x-opencodex-api-key: ` or `Authorization: Bearer `. `x-api-key`, a foreign bearer, the admin token, and no credential are rejected on a non-loopback bind. The row is added to `AUTH_MATRIX` with `xApiKey: "rejected"`. +- An admitted `/v1/catalog` response includes `x-opencodex-key-id` with the admitted + key's id. Rejected and unauthenticated responses never include this header. - A serialized catalog larger than `MAX_REMOTE_CATALOG_BYTES = 32 * 1024 * 1024` is not returned over `/v1/catalog`; it fails with HTTP 503 and the stable code `catalog_too_large`. The management route continues to expose the same serialized bytes @@ -46,7 +48,8 @@ runtime behavior. All code paths remain standalone-compatible when `runtimeRole` - Protocol-v1 metadata in every ready/pending/failed `/readyz` body. - A parser/compatibility predicate for future `ocx connect`, including additive-field tolerance for a dev hub paired with the latest released client. -- Shared catalog serialization, ETag, `If-None-Match`, size cap, and data-plane admission. +- Shared catalog serialization, ETag, `If-None-Match`, size cap, data-plane admission, and + authenticated `x-opencodex-key-id` attribution. - Route placement before the unknown-`/v1/*` JSON-404 guard. - Focused and full remote-only verification commands. @@ -126,8 +129,9 @@ after the 32 MiB bound passes. `/v1/catalog` uses `resolveResponsesApiAuth(req, policy)`, not `resolveApiAuth`, because the former is the existing dedicated-header/our-secret-Bearer matrix and rejects `x-api-key` (`src/server/auth-cors.ts:465-478`). It performs the existing data-plane origin check after -admission. No Direct passthrough exists on this read-only route and no credential is -forwarded. +admission, then sets `x-opencodex-key-id` from that admitted key identity. Rejected and +unauthenticated paths never emit the header. No Direct passthrough exists on this read-only +route and no credential is forwarded. ## 4. Diff-level file-change map @@ -137,18 +141,18 @@ All paths below exist in the current tree except the two files marked **NEW**. |---|---|---| | MODIFY | `src/types/config.ts` | Export `OcxRuntimeRole`; add optional `runtimeRole` to `OcxConfig` beside bind/runtime settings. | | MODIFY | `src/config.ts` | Add role schema and `runtimeRole` field validation; export `runtimeRole(config)`; reject invalid live candidates while preserving absence as standalone. Add degraded persisted-value diagnostics without deleting providers or `apiKeys`. | -| NEW | `src/remote/protocol.ts` | Own protocol constants, readiness metadata type/parser, management-origin validation, compatibility result, and exact mismatch strings. This is a passive leaf and imports no router, lifecycle, Responses, provider, or Lab code. | +| NEW | `src/remote/protocol.ts` | Own protocol constants, readiness metadata type/parser, `readyProtocolMetadata(config, req)`, management-origin validation, compatibility result, and exact mismatch strings. Phase 1 observes the request origin; accepting config from the start lets Phase 2 prefer `hub.managementPublicOrigin` without changing the consumer signature. This is a passive leaf and imports no router, lifecycle, Responses, provider, or Lab code. | | NEW | `src/server/catalog-download.ts` | Own `MAX_REMOTE_CATALOG_BYTES`, one persisted-catalog serialization result, byte-derived ETag matching, `/api/catalog` response construction, and bounded `/v1/catalog` response construction. | | MODIFY | `src/server/management/model-routes.ts` | Replace the inline `/api/catalog` read/`JSON.stringify` block at lines 334–345 with the shared response builder; preserve 404 and `x-opencodex-codex-version`. | | MODIFY | `src/server/auth-cors.ts` | Add the `/v1/catalog` `AUTH_MATRIX` row with bearer/dedicated accepted and `xApiKey` rejected. Do not change any existing row or credential precedence. | -| MODIFY | `src/server/index.ts` | Add protocol metadata to the current `/readyz` body at lines 991–998. Mount exact `GET /v1/catalog` after readiness/management handling and before the unknown-`/v1/*` guard at line 1604; use `resolveResponsesApiAuth` plus the existing origin policy. Keep `startServer` synchronous and add no `await` between `Bun.serve` and `labActivationRequired`. | +| MODIFY | `src/server/index.ts` | Add protocol metadata to the current `/readyz` body at lines 991–998 by passing `(config, req)` to the builder. Mount exact `GET /v1/catalog` after readiness/management handling and before the unknown-`/v1/*` guard at line 1604; use `resolveResponsesApiAuth` plus the existing origin policy and emit `x-opencodex-key-id` only from the admitted key identity. Keep `startServer` synchronous and add no `await` between `Bun.serve` and `labActivationRequired`. | | MODIFY | `src/server/proxy-liveness.ts` | Keep existing identity/status parsing additive; extend the internal body type/comments so protocol fields are recognized but do not make ordinary `ocx ready` reject a v0/legacy standalone server. Remote compatibility remains in `src/remote/protocol.ts`. | | MODIFY | `tests/config.test.ts` | Extend the existing config-default/validation sibling tests with absent/default, three valid roles, malformed live candidate, and malformed persisted role preservation cases. | | MODIFY | `tests/server-live.test.ts` | Extend the existing `GET /readyz` suite (lines 1220+) for exact protocol values on ready/pending/failed/draining, sanitized keys, management origin, method/path negatives, and no-auth behavior. | | MODIFY | `tests/proxy-liveness.test.ts` | Extend the current strict readiness parser/probe tests to prove additive protocol fields neither invalidate readiness nor bypass identity/status checks. | | MODIFY | `tests/api-catalog-route.test.ts` | Extend the existing `/api/catalog` sibling suite with fixed fixture bytes and version-header preservation after serializer extraction. | -| MODIFY | `tests/server-auth.test.ts` | Extend live-server auth/order coverage with `/v1/catalog` header matrix, exact-path/method negatives, foreign/admin bearer rejection, bound overflow, ETag/304, and unknown-`/v1` guard preservation. | -| MODIFY | `tests/api-key-attribution.test.ts` | Extend the existing live `AUTH_MATRIX` loop so `/v1/catalog` is a GET route and each cell reaches the real handler rather than the generic 404. | +| MODIFY | `tests/server-auth.test.ts` | Extend live-server auth/order coverage with `/v1/catalog` admission matrix, exact admitted `x-opencodex-key-id`, header absence on every rejected/unauthenticated path, exact-path/method negatives, foreign/admin bearer rejection, bound overflow, ETag/304, and unknown-`/v1` guard preservation. | +| MODIFY | `tests/api-key-attribution.test.ts` | Extend the existing live `AUTH_MATRIX` loop so `/v1/catalog` is a GET route, each cell reaches the real handler rather than the generic 404, and successful dedicated/Bearer admission echoes that key's id. | No other production or test file is in scope. If implementation proves another path is required, stop the phase and amend this document before editing it. @@ -180,7 +184,7 @@ export type RemoteProtocolCompatibility = | { ok: true; metadata: RemoteReadyMetadata } | { ok: false; reason: "invalid" | "hub-too-new" | "hub-too-old"; message: string }; -export function readyProtocolMetadata(req: Request): RemoteReadyMetadata; +export function readyProtocolMetadata(config: OcxConfig, req: Request): RemoteReadyMetadata; export function parseRemoteReadyMetadata(value: unknown): RemoteReadyMetadata | null; export function checkRemoteProtocolCompatibility( value: unknown, @@ -223,11 +227,11 @@ an empty catalog. No function accepts a caller-provided catalog path. | P1-A04 | Start a server with a pending gate and request exact unauthenticated `GET /readyz`; repeat after ready, failed, and drain activation. | Existing HTTP/status/Retry-After contract holds and all three protocol fields remain identical across states. | | P1-A05 | Send POST, OPTIONS, `/readyz/`, and encoded `/readyz%2F`. | Existing deterministic JSON 404 path remains; no protocol document leaks through the GUI fallback. | | P1-A06 | Feed a v1 document plus unknown future fields to the new parser and to `validateReadyzBody`. | Both accept the document; readiness identity remains strict and remote parser preserves only validated protocol fields. | -| P1-A07 | Feed `{protocol: 1, minimumClientProtocol: 2}` to a v1 client. | `hub-too-new` and the exact “Upgrade ocx on this client” string are returned before catalog access. | -| P1-A08 | Feed `{protocol: 0, minimumClientProtocol: 0}` to a client requiring hub protocol 1. | `hub-too-old` and the exact “Upgrade ocx on the hub” string are returned. | -| P1-A09 | Omit, mistype, overflow, or give a path-bearing `managementUrl`. | `invalid` and the exact malformed-metadata string are returned; no fallback to protocol 1. | +| P1-A07 | Feed `{protocol: 2, minimumClientProtocol: 2}` to a protocol-v1 client. | `hub-too-new` and the exact “Upgrade ocx on this client” string are returned before catalog access. | +| P1-A08 | Feed `{protocol: 1, minimumClientProtocol: 1}` to a protocol-v2 client requiring minimum hub protocol 2. | `hub-too-old` and the exact “Upgrade ocx on the hub” string are returned. | +| P1-A09 | Supply zero, omit, mistype, overflow, set minimum above protocol, or give a path-bearing `managementUrl`. | The malformed-input class (`400`) returns `invalid` and the exact malformed-metadata string; it is never classified as a version mismatch and never falls back to protocol 1. | | P1-A10 | Persist a fixed catalog fixture; call authorized `/api/catalog` and authorized `/v1/catalog`. | Status 200 and response bytes are byte-identical; the ETag independently hashes those bytes. | -| P1-A11 | Repeat `/v1/catalog` on non-loopback with dedicated header, our-secret Bearer, `x-api-key`, foreign Bearer, admin token, and no token. | First two reach 200; all remaining cases are 401. No case reaches the generic unknown-route 404. | +| P1-A11 | Repeat `/v1/catalog` on non-loopback with dedicated header, our-secret Bearer, `x-api-key`, foreign Bearer, admin token, and no token. | First two reach 200 with `x-opencodex-key-id` equal to the admitted key id; all remaining cases are 401 without that header. No case reaches the generic unknown-route 404. | | P1-A12 | Call `/v1/catalog` with matching tag, weak matching tag, tag list, `*`, stale tag, and malformed tag. | Matches return 304/no body/same ETag; stale or malformed values return 200/full bytes. | | P1-A13 | Serialize exactly the cap and cap+1 fixtures through an injected serialization seam. | Exact cap returns 200; cap+1 returns 503 `catalog_too_large`, never a partial body. | | P1-A14 | Call POST `/v1/catalog`, GET `/v1/catalog/`, and an unrelated `/v1/does-not-exist`. | Every request returns the existing JSON 404 envelope; route ordering does not widen path/method matching. | diff --git a/devlog/_plan/260827_remote_hub/040_phase2_remote_session.md b/devlog/_plan/260827_remote_hub/040_phase2_remote_session.md index 9b0534e1b5..8d7408fa97 100644 --- a/devlog/_plan/260827_remote_hub/040_phase2_remote_session.md +++ b/devlog/_plan/260827_remote_hub/040_phase2_remote_session.md @@ -50,7 +50,8 @@ consumption of that credential is the only pairing exchange. `trustedTailscaleIngress: true` context. Direct/public-listener headers are ignored. - An empty/missing `allowedTailscaleUsers` list authorizes nobody remotely. - Pairing grants are stored only as SHA-256 digests, capped, expire after five minutes, - are deleted before session minting, and are never logged or returned again. + are deleted before session minting, and are never logged or returned again. They are not + bound to or invalidated with any data key. - Pairing-grant creation accepts only a short-lived capability bound to the exact runtime PID, port, method, path, nonce, expiry, and canonical browser origin. The reusable admin token and every other management principal are rejected on that route. @@ -184,6 +185,10 @@ and claimed GUI origin; it never authorizes a mutation. non-loopback hub request it prefers configured `hub.managementPublicOrigin`; otherwise it keeps today's observed-origin behavior. It never reads forwarding headers. +The Phase-1 `readyProtocolMetadata(config, req)` consumer follows the same rule: configured +`hub.managementPublicOrigin` wins for hub readiness metadata, with observed request origin +used only when the setting is absent. + ### 5.3 Bootstrap meta consumer chain The compatibility meta name `opencodex-session-origin` remains and now explicitly means @@ -235,6 +240,8 @@ memory-only and are never written to web storage. credential accepted. Admin/data/session credentials in headers do not substitute. - HTTPS issues `pairing`; non-loopback HTTP issues `insecure-http-pairing` only when the config opt-in is true. The grant is consumed before minting; all replays fail. + - Phase 4's fixed-target relay path allowlist admits this exact POST exchange in addition + to GET bootstrap; no other non-`/api/*` method/path is widened. `ocx gui pair [--origin ] [--json]` defaults `--origin` to `hub.managementPublicOrigin`; it fails if neither exists. It resolves the identity-checked @@ -268,11 +275,12 @@ All paths below exist in the current tree except files marked **NEW**. |---|---|---| | MODIFY | `src/types/config.ts` | Add `OcxHubConfig`, `OcxRemoteGuiConfig`, and optional `hub`/`remoteGui` fields. Extend the Phase-1 role type only by reference, not by new values. | | MODIFY | `src/config.ts` | Add strict nested schemas, canonical-origin/user-list validation, cross-field diagnostics, and persisted malformed-block degradation that preserves unrelated config. | +| MODIFY | `src/remote/protocol.ts` | Consume `hub.managementPublicOrigin` in `readyProtocolMetadata(config, req)` so configured origin wins and observed request origin is the fallback. Preserve the Phase-1 wire shape and parser. | | NEW | `src/lib/gui-pair-capability.ts` | Own v1 method/path/header constants and HMAC create/verify functions bound to nonce, expiry, canonical browser origin, PID, and port. It accepts only the existing local-attestation secret shape. | | NEW | `src/server/gui-session.ts` | Own session/grant records, constants, bounded maps, digest-only grant storage, issuance policy, grant consumption, shared request admission predicate, and sliding renewal. No provider/router/Lab imports. | | MODIFY | `src/server/management-auth.ts` | Replace private `origin` records and duplicate authorization/principal checks with the shared GUI-session module. Preserve exported `issueGuiSession` as the loopback-compatible facade. Add pairing-grant state and exact `gui-pair-capability` principal/replay handling without changing admin-token initialization. | | MODIFY | `src/server/auth-cors.ts` | Prefer configured hub public origin only for non-loopback management requests; add exact fixed management preflight headers and exact-origin ACAO. Do not change data-plane CORS or credential admission. | -| MODIFY | `src/server/index.ts` | Advertise GUI-pair capability v1 in `/healthz`; mount exact pairing-grant creation after capability admission, mount GET/POST bootstrap before GUI fallback, pass `trustedTailscaleIngress: false` on the public/ordinary loopback listeners, and preserve the line-1604 unknown-`/v1` guard. Do not make `startServer` async or add an await in its synchronous activation window. | +| MODIFY | `src/server/index.ts` | Pass `(config, req)` to the `/readyz` protocol metadata builder so `hub.managementPublicOrigin` reaches the response; advertise GUI-pair capability v1 in `/healthz`; mount exact pairing-grant creation after capability admission, mount GET/POST bootstrap before GUI fallback, pass `trustedTailscaleIngress: false` on the public/ordinary loopback listeners, and preserve the line-1604 unknown-`/v1` guard. Do not make `startServer` async or add an await in its synchronous activation window. | | MODIFY | `src/server/proxy-liveness.ts` | Add optional `guiPairCapability` to the existing health identity projection so the local client can fail closed against an old/foreign listener without changing required liveness identity fields. | | MODIFY | `src/server/gui-static.ts` | Serialize escaped browser/server origin meta tags; keep `opencodex-session-origin` as browser-origin compatibility metadata. | | NEW | `src/cli/gui.ts` | Own `runGuiCommand(args, deps)`: existing no-subcommand open behavior plus `pair`; strict `--origin`/`--json` parsing and one-time grant output. | @@ -285,7 +293,7 @@ All paths below exist in the current tree except files marked **NEW**. | MODIFY | `tests/server-management-auth.test.ts` | Extend the primary auth suite for every issuance/expiry/replay/origin/CSRF/admin negative; preserve the line-897 forged-Host test unchanged in meaning. | | MODIFY | `tests/native-profile-route-security.test.ts` | Update session fixture fields and prove native consent mutations still reject admin, wrong browser origin, wrong server destination, absent CSRF, and accept only the full remote-session predicate. | | MODIFY | `tests/server-auth.test.ts` | Extend management preflight tests for exactly the two added headers, allowed/rejected origins, and no data-plane header-policy drift. | -| MODIFY | `tests/server-live.test.ts` | Extend the existing `/healthz` capability metadata coverage for GUI-pair v1 while keeping readiness/session secrets absent. | +| MODIFY | `tests/server-live.test.ts` | Extend `/readyz` coverage so configured `hub.managementPublicOrigin` wins over the observed origin and absence falls back to the observed origin; extend `/healthz` capability metadata coverage for GUI-pair v1 while keeping readiness/session secrets absent. | | MODIFY | `tests/proxy-liveness.test.ts` | Extend health identity fixtures for optional GUI-pair capability detection and prove a foreign/malformed body cannot become an attested target. | | NEW | `tests/gui-pair-capability.test.ts` | Characterize payload binding, wrong method/path/origin/PID/port, malformed nonce/expiry, constant-time mismatch, and expiration for the operation capability, following `tests/local-management-capability.test.ts` and `tests/system-restart-contract-security.test.ts`. | | NEW | `tests/gui-pair-client.test.ts` | Characterize attestation, PID/port recheck, capability-version refusal, bodyless POST headers, one-attempt behavior, and redacted transport failures, following `tests/system-restart-client.test.ts` and `tests/local-provider-reload-client.test.ts`. | diff --git a/devlog/_plan/260827_remote_hub/050_phase3_connect.md b/devlog/_plan/260827_remote_hub/050_phase3_connect.md index fbd1c20dec..51c7a5d31c 100644 --- a/devlog/_plan/260827_remote_hub/050_phase3_connect.md +++ b/devlog/_plan/260827_remote_hub/050_phase3_connect.md @@ -97,7 +97,7 @@ Every existing path below was verified in the current tree. For NEW client paths | NEW | `src/client/state.ts` | Parse/validate `runtimeRole + config.json.client`, expose fail-closed connected/absent/invalid/mismatched states, and atomically commit/clear both keys through config mutation. | | NEW | `src/client/hub-client.ts` | Validate/normalize URLs; bounded `GET /readyz`, `POST /api/keys`, `GET /v1/catalog`; protocol/capability checks; redact all credential-bearing errors. | | NEW | `src/client/connect.ts` | Transaction coordinator, connected sync, rollback, and offline disconnect. No argv or presentation logic. | -| NEW | `src/cli/connect.ts` | Parse connect/disconnect/status arguments, read the one-time credential from stdin or a named env var, call the coordinator, and render redacted human/JSON output. | +| NEW | `src/cli/connect.ts` | Parse connect/disconnect/status arguments, read exactly one `--pairing-code-stdin` or `--admin-token-stdin` credential, call the coordinator, and render redacted human/JSON output. | | MODIFY | `src/types/config.ts` | Add `OcxClientConnectionConfig` and top-level `OcxConfig.client?`; the secret itself is not a field. | | MODIFY | `src/config.ts` | Add schema and field-scoped persistence behavior for `client`; malformed-present client state must be diagnosable and must never degrade into standalone routing. | | MODIFY | `src/lib/service-secrets.ts` | Add atomic owner-only write and fingerprint-checked removal beside existing path/read helpers. | @@ -365,7 +365,7 @@ export function disconnectClient( ```text ocx connect [--management-url ] - [--credential-stdin | --credential-env ] + [--pairing-code-stdin | --admin-token-stdin] [--clients codex,claude] [--management-transport direct|relay] [--allow-insecure-http] [--no-sync] @@ -373,11 +373,13 @@ ocx connect status [--json] ocx disconnect [--keep-catalog] [--json] ``` -There is deliberately no `--token `, `--admin-token `, or pairing-code -positional form. `--credential-env` stores only the variable name in argv; the value is -read once and cleared from the coordinator's local reference after key issuance. -`--credential-stdin` uses the bounded stdin helper. Parse errors redact unknown bare -values and all credential-shaped option values. +There is deliberately no `--token `, `--admin-token `, pairing-code +positional form, or credential environment-variable form. Exactly one of +`--pairing-code-stdin` and `--admin-token-stdin` uses the bounded stdin helper. Parse errors +redact unknown bare values and all credential-shaped option values. The transient admin +credential remains in memory until the connect transaction commits or rollback finishes, +then its buffer and coordinator reference are zeroized; successful key issuance alone is +not a terminal outcome. ## 4. Connect transaction and rollback @@ -410,7 +412,9 @@ config fields absent. The still-in-memory admin credential or exchanged GUI sess attempts exact `DELETE /api/keys` for the just-created id. If hub cleanup is unreachable, the failure reports only the safe key id and exact revoke action; it never prints the key. Machine-local rollback success is mandatory and remote cleanup inability is -explicit, never hidden as full rollback. +explicit, never hidden as full rollback. Only after that cleanup attempt completes does +the coordinator zeroize the transient admin credential; the success path zeroizes it +immediately after the final state commit. `--no-sync` still performs readiness, key issuance, token placement, catalog download, and final state commit, but does not mutate Codex/Claude client files. The next @@ -475,7 +479,7 @@ No test sends live hub traffic or reads the developer's homes. | Test file | Required cases | |---|---| -| `tests/client-connect.test.ts` (NEW) | URL canonicalization; Phase-1 ready parser/mismatch strings; ready/pending/failed; same-major v1 acceptance; management URL advertisement; admin HTTPS direct key POST; pairing HTTPS session exchange then key POST; dual-opt-in pairing HTTP; admin HTTP refusal; raw grant rejection at `/api/keys`; bounded catalog; atomic role+state commit; each rollback point; no-sync; connected 200/304/401/timeout sync; no local discovery fake called; offline disconnect and partial restore. | +| `tests/client-connect.test.ts` (NEW) | URL canonicalization; exact stdin-flag exclusivity and literal/env credential rejection; Phase-1 ready parser/mismatch strings; ready/pending/failed; same-major v1 acceptance; management URL advertisement; admin HTTPS direct key POST; pairing HTTPS session exchange then key POST; dual-opt-in pairing HTTP; admin HTTP refusal; raw grant rejection at `/api/keys`; admin credential retained through commit/rollback then zeroized; bounded catalog; atomic role+state commit; each rollback point; no-sync; connected 200/304/401/timeout sync; no local discovery fake called; offline disconnect and partial restore. | | `tests/service-secrets.test.ts` (NEW) | Exact path, 0600, Windows ACL seam, atomic replacement, symlink refusal, fingerprint, changed-file non-removal, no token in errors. | | `tests/codex-inject.test.ts` | Current standalone goldens byte-equal; explicit HTTPS target emits exact `base_url`, provider table, `env_key`; loopback-looking connected URL still requires admission; malformed target refused before journal. | | `tests/codex-inject-integration.test.ts` | Validate-only has zero writes; target commit records journal ownership; offline restore returns exact preimage; partial write rollback. | diff --git a/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md b/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md index 659e24e756..62f667bbab 100644 --- a/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md +++ b/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md @@ -66,7 +66,7 @@ activation remains unchanged and contains no new `await`. `POST /api/machine/disconnect`. - Opt-in fixed-target `/api/machine/hub-relay/*` selected only by `client.managementTransport === "relay"`. -- GUI machine/shared target discovery, independent auth state, page-plane mapping, +- GUI machine/shared target discovery, independent auth state, per-call plane routing, stable hub-offline states, and mode-aware stop/restart actions. - Connected usage = hub store filtered to this machine's `apiKeyId` by default, with an explicit hub-wide toggle; disconnected usage = local `usage.jsonl` unchanged. @@ -102,10 +102,14 @@ parent exists. No generated `gui/dist` file is edited. | MODIFY | `tests/cli-start-journal-order.test.ts` | Prove connected start skips stale-process journal restore only for a matching durable client owner and starts no full data plane. | | NEW | `tests/client-machine-listener.test.ts` | Listener bind/allowlist/auth/API/startup/offline matrix. | | NEW | `tests/client-hub-relay.test.ts` | Fixed target, header separation, body caps, redirects, errors, and SSRF negatives. | -| NEW | `gui/src/api-targets.ts` | Canonical `ApiTargets`, machine-status discovery, page-plane map, relay URL construction, and disconnected fallback. | +| NEW | `gui/src/api-targets.ts` | Canonical `ApiTargets`, machine-status discovery, per-plane call-base selection, relay URL construction, and disconnected fallback. | | MODIFY | `gui/src/api.ts` | Replace `needsApiAuth`'s one same-origin slot with exact target classification and per-target in-memory session/CSRF state; attach both auth domains only on relay. | -| MODIFY | `gui/src/App.tsx` | Discover targets before page fetches; map pages/actions to planes; machine health remains live when hub is down; connected stop becomes disconnect/recycle. | +| MODIFY | `gui/src/App.tsx` | Discover targets before page fetches; supply both call bases instead of one page base; machine health remains live when hub is down; connected stop becomes disconnect/recycle. | | MODIFY | `gui/src/stop-proxy.ts` | Add mode-aware machine disconnect request while preserving existing standalone `/api/stop` behavior. | +| MODIFY | `gui/src/pages/Startup.tsx` | Keep existing settings/startup-health/windows-tray/startup-action calls on the shared base; use the machine base only for new `/api/machine/*` status/shim sections. | +| MODIFY | `gui/src/pages/Integrations.tsx` | Pass the shared base to all existing integration descendants, including ApiKeys and Grok; pass the machine base only to new local-client controls. | +| MODIFY | `gui/src/pages/ApiKeys.tsx` | Keep existing `/api/keys`, `/v1/models`, and model-test calls on the shared base while mounted under Integrations. | +| MODIFY | `gui/src/pages/Grok.tsx` | Keep existing `/api/grok*` calls on the shared base while mounted under Integrations. | | MODIFY | `gui/src/pages/Usage.tsx` | Add this-machine/hub-wide scope control, key-id query/cache key, source label, and hub-offline behavior without local fallback. | | MODIFY | `gui/src/pages/Storage.tsx` | Pass its selected shared `apiBase` through to `StorageWorkspace`. | | MODIFY | `gui/src/components/storage-workspace/StorageWorkspace.tsx` | Remove module-global `VITE_API_BASE`; use the supplied shared-plane base for Codex-log storage calls. | @@ -119,12 +123,12 @@ parent exists. No generated `gui/dist` file is edited. | MODIFY | `gui/src/i18n/tr.ts` | Add the same keys. | | MODIFY | `gui/src/i18n/zh.ts` | Add the same keys. | | MODIFY | `gui/src/i18n/zh-TW.ts` | Add the same keys. | -| NEW | `gui/tests/api-targets.test.ts` | Target discovery, page mapping, relay construction, and hub-down fallback. | +| NEW | `gui/tests/api-targets.test.ts` | Target discovery, per-plane call-base selection, relay construction, and hub-down fallback. | | MODIFY | `gui/tests/api-auth-memory.test.ts` | Independent machine/shared sessions; direct/relay header matrix; no cross-target leakage; bootstrap validation. | | MODIFY | `gui/tests/api-auth-deadline.test.ts` | Per-target shared resolution/watchdog behavior. | | MODIFY | `gui/tests/usage-layout.test.ts` | Connected own-key default, hub-wide toggle, disconnected local source, cache partition, and offline rendering. | | MODIFY | `gui/tests/app-stop.test.ts` | Standalone stop vs connected disconnect/recycle. | -| MODIFY | `gui/tests/integrations-routing.test.ts` | Integrations remains machine-plane while shared pages use hub. | +| MODIFY | `gui/tests/integrations-routing.test.ts` | Existing Startup/Integrations/ApiKeys/Grok calls stay shared; only new local-client `/api/machine/*` calls use the machine base. | | MODIFY | `tests/core-lab-boundary.test.ts` | Existing protected-root and synchronous-start checks remain green; no rule weakening. | Verified reuse without edits: `src/server/gui-static.ts` serves assets/bootstrap; @@ -287,8 +291,8 @@ Activation requires all of: 2. `managementTransport === "relay"`; 3. exact `/api/machine/hub-relay/` prefix; 4. valid local machine session (custom headers for relay); -5. suffix exactly `/opencodex-session` GET or inside `/api/` with an allowed HTTP - method. +5. suffix exactly `/opencodex-session` with GET bootstrap or POST pairing exchange, or + inside `/api/` with an allowed HTTP method. The destination is `new URL(suffix, state.managementUrl)` after rejecting encoded slashes/backslashes, authority syntax, userinfo, query-host tricks, and path traversal. @@ -329,17 +333,19 @@ export interface ApiTargets { export function standaloneApiTargets(initialBase: string): ApiTargets; export function targetsFromMachineStatus(initialBase: string, status: MachineStatusV1): ApiTargets; -export function apiPlaneForPage(page: Page): ApiPlane; -export function apiBaseForPage(page: Page, targets: ApiTargets): string; +export function apiBaseForPlane(plane: ApiPlane, targets: ApiTargets): string; export async function discoverApiTargets(initialBase: string, signal?: AbortSignal): Promise; ``` -Page mapping is explicit: +Routing is selected at each call site, not once for a page: -| Plane | Pages/actions | -|---|---| -| Shared | Dashboard, Providers, Models, Subagents, Logs, Usage, Storage, Codex Set. | -| Machine | Startup, Integrations, health/version, Codex app-server restart, shim actions, disconnect. | +| Call sites | Plane | Rule | +|---|---|---| +| Existing Startup calls (`/api/settings`, `/api/startup-health`, `/api/windows-tray`, `/api/startup-action`) | Shared | Preserve hub-backed behavior. | +| Existing Integrations descendants, including ApiKeys (`/api/keys`, `/v1/models`, model tests) and Grok (`/api/grok*`) | Shared | Preserve provider/config/catalog ownership on the hub. | +| Dashboard, Providers, Models, Subagents, Logs, Usage, Storage, Codex Set existing calls | Shared | Continue to use the hub target. | +| New local status/client/sync/shim/disconnect calls | Machine | Only explicit `/api/machine/*` routes use the machine target. | +| Shell health/version and connected disconnect/recycle | Machine | Remain available independently of hub reachability. | In a standalone full server, `/api/machine/status` returns 404 and discovery returns one same-origin target, preserving existing behavior. A connected machine status response @@ -381,11 +387,13 @@ clears/prompts only that target and cannot wipe the other target's newer session ### `gui/src/App.tsx` -App blocks page resource mounting until target discovery settles, then passes the mapped -base. Health/version polls machine. Connected hub failure leaves shell, navigation, -Startup, Integrations, disconnect, and local status usable; shared pages render one -stable hub-offline state and never substitute machine data. In connected mode the power -action uses `POST /api/machine/disconnect`; in standalone it remains `POST /api/stop`. +App blocks page resource mounting until target discovery settles, then passes both bases +to mixed pages rather than assigning one plane to the whole page. Health/version polls +machine. Connected hub failure leaves shell, navigation, disconnect, and new local-machine +sections usable; existing shared sections inside Startup and Integrations render the same +stable hub-offline state as other shared calls and never substitute machine data. In +connected mode the power action uses `POST /api/machine/disconnect`; in standalone it +remains `POST /api/stop`. `StorageWorkspace` must receive the shared base from `Storage.tsx`; its current module-global `VITE_API_BASE` at `gui/src/components/storage-workspace/StorageWorkspace.tsx:20` @@ -445,12 +453,12 @@ appearing after disconnect. | `tests/api-usage.test.ts` | Exact `apiKeyId` response/echo; old and other-key rows excluded; no match; combined filters; filtered request cannot poison cache; unfiltered next request remains whole hub. | | `tests/usage-summary.test.ts` | Pure projection totals/days/models/providers/accounts consistency; exact-case id; combo attempts; absent id; provider/model/key cross-product. | | `tests/core-lab-boundary.test.ts` | Protected roots import no client subsystem; `startServer` remains non-async and no new top-level-window await. | -| `gui/tests/api-targets.test.ts` (NEW) | Standalone 404 fallback; valid direct/relay status; page map; exact bases; machine-status network failure not standalone; encoded relay paths. | +| `gui/tests/api-targets.test.ts` (NEW) | Standalone 404 fallback; valid direct/relay status; per-plane call bases; machine-status network failure not standalone; encoded relay paths. | | `gui/tests/api-auth-memory.test.ts` | Two simultaneous sessions; direct headers; relay dual headers; machine custom stripping contract; cross-target 401 races; server/browser-origin mismatch; unknown target receives nothing; no web storage. | | `gui/tests/api-auth-deadline.test.ts` | One target watchdog does not block/clear the other; direct and relay bootstrap timeout states. | | `gui/tests/usage-layout.test.ts` | Connected default key query; hub-wide omission; disconnected local query; source-qualified cache keys; hub-down no local fetch; scope labels/a11y. | | `gui/tests/app-stop.test.ts` | Standalone `/api/stop`; connected `/api/machine/disconnect`; refusal re-enables action; accepted recycle tolerates connection drop. | -| `gui/tests/integrations-routing.test.ts` | Startup/Integrations machine base; Providers/Usage/Storage shared base under direct and relay. | +| `gui/tests/integrations-routing.test.ts` | Existing Startup/Integrations/ApiKeys/Grok calls use the shared base; only new `/api/machine/*` local controls use the machine base under direct and relay. | ## 8. Acceptance criteria with activation grounding diff --git a/devlog/_plan/260827_remote_hub/070_phase5_deploy.md b/devlog/_plan/260827_remote_hub/070_phase5_deploy.md index 112ffac2bb..78f64c1059 100644 --- a/devlog/_plan/260827_remote_hub/070_phase5_deploy.md +++ b/devlog/_plan/260827_remote_hub/070_phase5_deploy.md @@ -157,7 +157,7 @@ The listener is GUI + management API only: - `GET`/`HEAD` packaged GUI assets and `/`. - `GET` extensionless SPA routes that the existing GUI fallback serves. -- `GET /opencodex-session`. +- `GET /opencodex-session` bootstrap and `POST /opencodex-session` pairing exchange. - `/api/*`, with existing management authentication, Origin, session, CSRF, body-size, and route authorization intact. - Everything else is deterministic JSON 404 before a handler runs, including all `/v1/*`, @@ -202,6 +202,7 @@ Canonical hub setup shown in the guide (values are examples, not defaults): ocx config set runtimeRole hub ocx config set hostname 100.64.0.10 ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' +ocx config set corsAllowOrigins '["http://localhost:10100"]' ocx config set hub.managementIngress '{"enabled":true,"port":10101}' ocx config set remoteGui.allowedTailscaleUsers '["operator@example.com"]' @@ -357,9 +358,8 @@ evidence; the sketch above is setup, not exact-head proof. 1. On the hub, run `ocx gui pair` and copy the single-use, short-TTL code through the interactive channel. Do not record it. -2. On the MacBook, run the Phase-3 connect command with the pairing code on stdin. The exact - Phase-3 signature must support transient `--pairing-code-stdin` (or its already-approved - equivalent) and must not accept a literal secret flag. +2. On the MacBook, run the Phase-3 connect command with exactly one transient + `--pairing-code-stdin` or `--admin-token-stdin`; it must not accept a literal secret flag. 3. Assert `ocx connect status --json` reports protocol v1, hub URL, management URL, management transport, and the non-secret client key id. 4. Assert `serviceApiTokenFilePath()` exists owner-only and contains the auto-issued per-client diff --git a/devlog/_plan/260827_remote_hub/080_phase6_hardening.md b/devlog/_plan/260827_remote_hub/080_phase6_hardening.md index 6e24eaa8b9..f0d112cfbf 100644 --- a/devlog/_plan/260827_remote_hub/080_phase6_hardening.md +++ b/devlog/_plan/260827_remote_hub/080_phase6_hardening.md @@ -16,9 +16,9 @@ the workstation. Full-suite execution is serialized with other `lidge-ai` suite - Recoverable per-client data-key rotation with a one-time secret response, bounded overlap, client-side atomic token-file replacement, explicit commit/abort, and stable `apiKeyId` usage attribution. -- Remote-session self-logout, automatic session invalidation after key commit/delete, and - disconnect-time best-effort revocation without making hub availability a prerequisite for - offline local restore. +- Remote-session self-logout plus explicit operator data-key revocation from the hub GUI or + `ocx connect revoke` with an admin credential. Disconnect performs no hub-side revocation and + remains available while the hub is offline. - Pairing issuance/redemption limits, one-use semantics, bounded active state, and safe 429s. - Protocol negotiation matrix tests covering the v1 compatibility floor and feature detection. - Adversarial `/v1/catalog` consumer tests for decompressed size, malformed JSON, invalid schema, @@ -53,7 +53,7 @@ the workstation. Full-suite execution is serialized with other `lidge-ai` suite | Header-smuggling attempt | Hub request parser/proxy chain | Reject transfer-encoding, conflicting content-length, connection-nominated headers, CR/LF values, and upgrade paths. | | Protocol-skewed peer | Local client files / silent misroute | Negotiate before any local write; reject incompatible floors with explicit upgrade error; unknown features stay off. | | Rotation crash between hub and client | Client availability | Old and pending keys overlap for a bounded window; commit only after new-key probe; abort/expiry preserves old key. | -| Session surviving credential change | Revoked client access | Key commit/delete invalidates sessions and pairing grants bound to that `apiKeyId`; current in-flight data turn may finish, next admission fails. | +| Disconnected client whose data key still exists | Hub data admission | Disconnect changes local state only; an operator explicitly deletes the key in the hub GUI or runs `ocx connect revoke --admin-token-stdin`. | | Logs/evidence | Tokens, codes, identities | Record ids/prefixes/counts/status only; privacy scan; no raw secret, email, Origin query, request body, or account id. | Security level: ASVS L2 for the remote management/session surface. Applicable architecture, @@ -68,7 +68,7 @@ planning base and must exist before Phase 6 begins. If an earlier phase delibera different exact owner path, amend this file mechanically before implementation rather than adding a second owner. -### 2.1 Key rotation and session invalidation +### 2.1 Key rotation, self-logout, and operator revocation | Path | Change | Exact responsibility | | --- | --- | --- | @@ -76,13 +76,15 @@ a second owner. | `src/config.ts` | MODIFY | Validate/degrade pending rotation independently so one malformed pending record cannot reset providers or revoke the current key. | | `src/server/auth-cors.ts` | MODIFY | Admit an unexpired pending key under the same configured `apiKeyId`; never return or serialize its secret. | | `src/server/management/api-key-rotation.ts` | NEW | Single owner for start/commit/abort/expiry cleanup and constant-time rotation-id comparison. | -| `src/server/management/oauth-account-routes.ts` | MODIFY | Add the three rotation operations next to existing `/api/keys` CRUD and call session invalidation after commit/delete. Existing GET continues to mask all secrets. | +| `src/server/management/oauth-account-routes.ts` | MODIFY | Add the three rotation operations next to existing `/api/keys` CRUD. Existing GET continues to mask all secrets; key deletion remains an explicit operator action. | | `src/server/management/session-routes.ts` | NEW | `POST /api/session/logout` self-revocation route; requires the current `gui-session` and CSRF. Admin token receives 403, not a promoted session. | | `src/server/management-api.ts` | MODIFY | Wire `handleSessionRoutes` and the narrow session-control dependency into `ManagementContext`. | -| `src/server/management/context.ts` | MODIFY | Carry only the revocation interface, never the raw admin token or session map. | -| `src/server/management-auth.ts` | MODIFY | Associate remote sessions with optional `apiKeyId`; export narrow current/by-key invalidation helpers; preserve one shared auth predicate. | +| `src/server/management/context.ts` | MODIFY | Carry only the current-session logout interface, never the raw admin token or session map. | +| `src/server/management-auth.ts` | MODIFY | Export a narrow current-session invalidation helper for explicit self-logout; preserve one shared auth predicate and add no key binding to pairing grants or sessions. | | `src/client/connect.ts` | MODIFY | Implement `ocx connect rotate`: transient authority, start rotation, atomically replace token file, validate new key, commit, and restore+abort on failure. | -| `src/client/state.ts` | MODIFY | Persist non-secret key id and pending operation metadata only; never persist admin/pairing authority or old/new secret. | +| `src/client/hub-client.ts` | MODIFY (Phase-3 owner) | Add bounded rotation/revoke management calls, authenticated catalog key-id probe, and redacted errors beside the existing ready/key/catalog calls. | +| `src/client/state.ts` | MODIFY | Persist `pendingOperation: {kind:"rotate",newKeyIssuedAt,oldKeyBackupPath}` through the full backup/replace/probe/commit or recovery chain; never persist admin/pairing authority or old/new secret. | +| `src/cli/connect.ts` | MODIFY (Phase-3 owner) | Parse `rotate` and operator-only `revoke`, enforce exact stdin flags, reject literal/env secret forms, and render redacted recovery status. | | `src/cli/access.ts` | MODIFY | Add management-side `ocx access key rotate ` start/commit/abort UX with one-time secret warning; no literal secret flags. | | `src/cli/registry.ts` | MODIFY | Document rotation command shapes and transient-authority requirement. | | `gui/src/pages/ApiKeys.tsx` | MODIFY | Load pending status, start/commit/abort rotation, and render the new secret exactly once. | @@ -99,7 +101,8 @@ a second owner. | `tests/api-keys-routes.test.ts` | MODIFY | Rotation route contract, masking, pending overlap, commit, abort, expiry, malformed inputs, and delete invalidation. | | `tests/data-plane-admission-identity.test.ts` | MODIFY | Current and pending secret map to one id; expired/committed/aborted secrets do not admit. | | `tests/api-key-attribution.test.ts` | MODIFY | Traffic before/during/after rotation remains one `apiKeyId` bucket. | -| `tests/server-management-auth.test.ts` | MODIFY | Session self-logout/by-key invalidation and admin-token refusal. | +| `tests/server-management-auth.test.ts` | MODIFY | Explicit session self-logout, absence of key-bound grant/session invalidation, and admin-token consent refusal. | +| `tests/client-connect.test.ts` | MODIFY (Phase-3 owner) | Rotation/revoke parser flags, `.prev` crash recovery, pending-operation lifecycle, uncertain commit, and operator-only key deletion. | | `gui/tests/apikeys-actions.test.tsx` | MODIFY | Start/commit/abort wire actions and one-time secret handling. | | `gui/tests/apikeys-mutation-timeout.test.tsx` | MODIFY | Rotation controls recover after bounded network failure. | | `gui/tests/apikeys-workspace.test.tsx` | MODIFY | Accessible rendered states and confirmations. | @@ -109,14 +112,14 @@ a second owner. | Path | Change | Exact responsibility | | --- | --- | --- | -| `src/server/gui-session.ts` | MODIFY (Phase-2 owner) | Extend the existing digest-only pairing/session owner with bounded attempt stores, source-key derivation, 429 result, expiry, and by-key revocation. | +| `src/server/gui-session.ts` | MODIFY (Phase-2 owner) | Extend the existing digest-only pairing/session owner with bounded attempt stores, source-key derivation, 429 result, and expiry; grants remain independent of data keys. | | `src/remote/protocol.ts` | MODIFY (Phase-1 owner) | Extend the existing pure parser/interval-compatibility owner with additive feature intersection; no I/O or local writes. | -| `src/client/catalog.ts` | MODIFY | Bounded decompressed read, strict remote catalog validation, ETag/LKG handling, and atomic-write precondition. | -| `src/client/relay.ts` | MODIFY | Fixed-target URL construction, request/response header rebuilding, redirect refusal, body/stream caps, and redacted errors. | +| `src/client/hub-client.ts` | MODIFY (Phase-3 owner) | Bounded decompressed read, strict remote catalog validation, ETag/LKG handling, and atomic-write precondition. | +| `src/client/hub-relay.ts` | MODIFY (Phase-4 owner) | Fixed-target URL construction, request/response header rebuilding, redirect refusal, body/stream caps, and redacted errors. | | `tests/server-management-auth.test.ts` | MODIFY (Phase-2 owner) | Deterministic pairing attempt/TTL/capacity/replay/race matrix in the existing primary session suite. | | `tests/proxy-liveness.test.ts` | MODIFY (Phase-1 owner) | Protocol metadata parsing remains additive while ordinary readiness identity remains strict. | | `tests/cli-ready-subprocess.test.ts` | MODIFY | Full released-process skew matrix and no-write mismatch outcomes. | -| `tests/remote-catalog.test.ts` | MODIFY (Phase-1/3 owner) | Oversized/malformed/schema/ETag/LKG/no-write adversarial matrix. | +| `tests/remote-catalog.test.ts` | ADD BY PHASE 6 | Oversized/malformed/schema/ETag/LKG/no-write adversarial matrix for the Phase-3 `hub-client` owner. | | `tests/client-hub-relay.test.ts` | MODIFY (Phase-4 owner) | SSRF, redirect, authority, smuggling, header stripping, and bounded streaming negatives. | | `tests/bounded-body.test.ts` | MODIFY only if shared helper changes | Reuse exact-cap/one-byte-over/trickle semantics; do not duplicate the helper contract in client tests. | | `tests/credential-redirect-guard.test.ts` | EXTEND/REUSE | Existing sibling evidence for credential-bearing redirect refusal. | @@ -266,31 +269,40 @@ No response except successful start contains the pending secret. `ocx connect rotate` requires one transient `--pairing-code-stdin` or `--admin-token-stdin`; neither is persisted. It performs: -1. Read current key id and current token into memory; create no output containing either secret. -2. Start rotation; receive pending secret once. +1. Read current key id and current token into memory; write the old token to + `.prev` with the same owner-only 0600/ACL rules and fsync it. +2. Start rotation; receive pending secret once, then persist + `pendingOperation: {kind:"rotate",newKeyIssuedAt,oldKeyBackupPath}` before replacement. 3. Write pending secret to a same-directory owner-only temp, harden it with the same `serviceApiTokenFilePath()` rules, fsync, and atomically replace the token file. 4. Probe authenticated `/v1/catalog` with the new key and verify the expected client key id via the safe response/diagnostic contract. -5. Commit rotation. Commit invalidates old-key admission, sessions, and pairing grants bound to - that key id. An already-admitted in-flight turn may complete; the next old-key request is 401. -6. If steps 3–5 fail before a confirmed commit, restore the old token atomically and abort the - pending rotation. If commit outcome is uncertain, probe with both keys: exactly one accepted - result determines the local file; never replay commit blindly. +5. Commit rotation. Commit invalidates old-key admission only; pairing grants and GUI sessions + are not key-bound. An already-admitted in-flight turn may complete; the next old-key request + is 401. After verified commit, delete `.prev` and clear `pendingOperation`. +6. If steps 2–5 fail before a confirmed commit, restore the old token atomically from + `.prev`, abort the pending rotation, then delete the backup and clear the operation. + If commit outcome is uncertain, probe with both keys: exactly one accepted result determines + the local file; never replay commit blindly. + +On startup/status, `src/client/state.ts` treats a rotate `pendingOperation` as a recovery gate: +verify that `oldKeyBackupPath` is exactly `.prev`, require an owner-only regular file, +probe current and backup keys using the authenticated catalog key-id echo, then complete the same +commit-or-restore chain. Missing, unsafe, or doubly accepted/rejected evidence stops with an exact +recovery instruction and never deletes either candidate blindly. The GUI exposes the same lifecycle for an operator updating a client manually, with explicit copy-once and commit-after-client-probe wording. Closing the modal does not imply commit; pending state remains visible and abortable until expiry. -## 4. Session invalidation contract +## 4. Session self-logout contract -Phase-2 `GuiSessionRecord` gains optional `apiKeyId` for sessions created from a client-bound -pairing grant. Loopback and Tailscale sessions without a client association may omit it. +Phase-2 pairing grants and `GuiSessionRecord` remain independent of data-key ids. Rotation, +disconnect, and key deletion therefore do not implicitly log out a browser session. ```ts export interface ManagementSessionControl { revokeCurrent(req: Request): boolean; - revokeForApiKeyId(apiKeyId: string): number; } export function createManagementSessionControl( @@ -298,14 +310,17 @@ export function createManagementSessionControl( ): ManagementSessionControl; ``` -`handleManagementAPI` receives this narrow control (directly or through `ManagementContext`), -not the session map and never the admin token. `POST /api/session/logout` requires +`handleManagementAPI` receives this narrow current-session control (directly or through +`ManagementContext`), not the session map and never the admin token. +`POST /api/session/logout` requires `principal === "gui-session"`, same browser Origin, and CSRF. Admin-token calls return 403. -Rotation commit and key deletion call `revokeForApiKeyId` only after config persistence commits. -A persistence failure leaves key and sessions unchanged. `ocx disconnect` calls self-logout -best-effort before local restore; hub-down still restores from the local journal and reports that -remote session expiry/revocation could not be confirmed. +`ocx disconnect` performs local restore only and sends no hub revocation request. To revoke the +still-valid data key, an operator uses the hub GUI's existing key deletion or +`ocx connect revoke --admin-token-stdin`; the CLI requires the transient admin credential, +deletes the exact configured key id, and clears no local state unless deletion is confirmed. +Explicit GUI self-logout remains available independently. A hub-down disconnect succeeds locally +and reports that operator revocation remains outstanding. ## 5. Pairing rate limits @@ -337,7 +352,8 @@ Fixed starting limits (configurable downward only is unnecessary in v1): - Expired grant/source entries are pruned synchronously on pairing operations; no core timer. - `Retry-After` is integer seconds, bounded by the remaining window, and contains no identity. - Constant-time code comparison; generic invalid/expired/consumed response; no existence oracle. -- Rotation commit/key delete revoke unconsumed grants associated with that client key id. +- Pairing grants have no client-key association; rotation, key deletion, and disconnect do not + scan or revoke the grant store. Rate-limit logs contain only reason, ingress class, and aggregate count. No code, raw IP, Tailscale user/email, Origin, token, or account id. @@ -376,7 +392,7 @@ Required matrix: | p2/min2 | p1/min1 | hub dropped v1 floor | Reject `hub-too-new` before write. | | p1/min1 | p2/min2 | dev client requires newer hub | Reject hub-too-old before write. | | p1/min1/unknown-X | p1/min1 | additive unknown feature | Accept; unknown feature remains disabled. | -| missing/NaN/fraction/negative/min>protocol or invalid `managementUrl` | p1 | malformed or legacy non-v1 hub | Exact Phase-1 `invalid` message; zero local writes. | +| missing/zero/NaN/fraction/negative/min>protocol or invalid `managementUrl` | p1 | malformed-input class (`400`), never a version mismatch | Exact Phase-1 `invalid` message; zero local writes. | | valid descriptor, `/readyz` pending/failed | any | startup not ready | Do not negotiate or write; preserve existing readiness behavior. | The guaranteed live pair is dev hub ↔ latest published protocol-v1 client and the reverse. @@ -413,7 +429,7 @@ Every rejection asserts token/catalog/state/journal bytes are unchanged. ## 8. Relay SSRF and header-smuggling negatives -`src/client/relay.ts` is not a general proxy. Its destination is the validated +`src/client/hub-relay.ts` is not a general proxy. Its destination is the validated `connectionState.managementUrl` captured when the listener starts. A request cannot supply or override scheme, host, port, userinfo, fragment, DNS result, or redirect target. @@ -468,11 +484,11 @@ Phase 6 extends them rather than creating parallel “hardening2” files. | --- | --- | --- | | rotation start | Existing key, no pending rotation, admin/session authority | New key returned once; old+pending both admit under same id; list masks both. | | second start | Existing unexpired pending rotation | 409; no third secret/state change. | -| client commit | New key written + authenticated catalog succeeds | Pending promoted atomically; old next request 401; id/usage bucket stable; sessions/grants invalidated. | -| client write/probe fail | Fail temp write, hardening, rename, or new-key probe | Old file restored/unchanged; pending aborted or expires; old key remains valid. | -| uncertain commit | Drop commit response after server may commit | Probe old+new; choose sole accepted key; no blind replay. | +| client commit | New key written + authenticated catalog succeeds | Pending promoted atomically; old next request 401; id/usage bucket stable; `.prev` deleted and pending operation cleared; sessions/grants unchanged. | +| client write/probe fail | Fail backup/temp write, hardening, rename, or new-key probe | Old file restored/unchanged from `.prev`; pending aborted or expires; old key remains valid. | +| uncertain commit/crash | Drop commit response or restart with pending operation | Probe current+`.prev`; choose sole accepted key; finish commit-or-restore chain; no blind replay. | | pending expiry | Fake clock past 10 minutes | Pending rejected/removed; old accepted. | -| delete key | Delete configured key with bound sessions/grants | Admission, sessions, and grants revoked after persistence only. | +| operator revoke | Hub GUI delete or `ocx connect revoke --admin-token-stdin` for configured id | Data-key admission revoked after persistence; sessions/grants unchanged; disconnect alone made no hub request. | | self logout | GUI session + Origin + CSRF | Current session removed; replay 401. Admin-token call 403. | | pairing bad guesses | Same grant/source repeated with fake clock | Fifth grant failure burns; source threshold yields 429 + bounded Retry-After; no identity leak. | | pairing replay/race | Two concurrent valid redemptions | Exactly one session; other generic failure. | @@ -503,8 +519,8 @@ The Remote Hub guide in all eight locales must cover: - admin token ordinary-management scope and permanent inability to mint consent sessions; - systemd/launchd, Docker volume/secret/probes, headless OAuth, rotation, rollback, and protocol upgrade errors; -- troubleshooting for hub down, stale catalog, rotated token, protocol mismatch, lost pairing, - plain HTTP, and remote-session expiry/invalidation. +- troubleshooting for hub down, stale catalog, rotated token/`.prev` recovery, protocol mismatch, + lost pairing, plain HTTP, remote-session logout/expiry, and outstanding operator revocation. Reference pages list exact config keys/defaults and endpoint auth. No page may call `/healthz` readiness, claim usage mirroring, suggest putting a token on argv, suggest `0.0.0.0:10101`, trust @@ -527,7 +543,8 @@ Docker secret guidance, or dependency installation is security-review-required u migration, error, or recovery branch. - [ ] Data keys authorize only the data matrix and `/v1/catalog`; rotation endpoints remain management-authenticated and transient authority is never persisted. -- [ ] Session Origin/serverOrigin/browserOrigin, CSRF, TTL, renewal, logout, key invalidation, +- [ ] Session Origin/serverOrigin/browserOrigin, CSRF, TTL, renewal, explicit logout, absence of + key-bound invalidation, replay, and wrong-ingress tests are linked. - [ ] Pairing entropy, one-use, TTL, attempt/capacity limits, race behavior, 429, and redacted logging tests are linked. @@ -548,9 +565,10 @@ the same readiness report. ## 12. Acceptance criteria -- [ ] Per-client rotation is recoverable, bounded, stable-id-attributed, secret-safe, and invalidates - the old key/session/grants only after commit. -- [ ] Session self-logout and disconnect behavior are explicit; admin-token consent remains 403. +- [ ] Per-client rotation is recoverable through `pendingOperation` + `.prev`, bounded, + stable-id-attributed, secret-safe, and invalidates only old-key admission after commit. +- [ ] Session self-logout, local-only disconnect, and explicit operator revoke behavior are + distinct; admin-token consent remains 403. - [ ] Pairing attempt and capacity state are bounded, deterministic under fake time, one-use under races, and privacy-safe. - [ ] Every protocol matrix row is reachable and proves no-write behavior before incompatibility. From ca1114fcb07563d59ade56bee8d248b9127beeec Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 01:32:59 +0900 Subject: [PATCH 08/12] =?UTF-8?q?docs(devlog):=20fold=20roadmap=20audit=20?= =?UTF-8?q?r2=20=E2=80=94=207=20blockers=20closed=20(synthesis=20in=20003)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../003_audit_r2_synthesis.md | 34 ++++++++ devlog/_plan/260827_remote_hub/010_design.md | 2 +- .../030_phase1_protocol_catalog.md | 37 +++++---- .../040_phase2_remote_session.md | 16 ++-- .../260827_remote_hub/050_phase3_connect.md | 68 +++++++++++----- .../260827_remote_hub/060_phase4_two_plane.md | 28 ++++--- .../260827_remote_hub/070_phase5_deploy.md | 4 +- .../260827_remote_hub/080_phase6_hardening.md | 80 ++++++++++++------- 8 files changed, 186 insertions(+), 83 deletions(-) create mode 100644 devlog/_plan/260827_remote_hub/003_audit_r2_synthesis.md diff --git a/devlog/_plan/260827_remote_hub/003_audit_r2_synthesis.md b/devlog/_plan/260827_remote_hub/003_audit_r2_synthesis.md new file mode 100644 index 0000000000..d8f6899453 --- /dev/null +++ b/devlog/_plan/260827_remote_hub/003_audit_r2_synthesis.md @@ -0,0 +1,34 @@ +# 003 — Audit synthesis, roadmap round 2 (FAIL, 7 blockers) — canonical decisions + +Closed in r2: old-2 (managementPublicOrigin chain), old-4 (per-call planes), old-6 (phase-6 owners). +Decisions for the 7 remaining (all fold, no rebuttals): + +1 P3-A4 fixture: rejection row uses hub {protocol:2, minimumClientProtocol:2}; a p2/min1 hub + is COMPATIBLE and gets its own acceptance row. "protocol major 2" wording deleted. +2 Pairing e2e, single truth: ocx gui pair --origin REQUIRED argument, no + default (040+070 updated; dogfood runbook passes --origin http://localhost:10100). + 060 gains a pairing UI owner row: gui/src/connect-pairing.ts + i18n keys + activation + scenario (paste code → POST exchange via relay → session stored). Relay contract states + it forwards the browser Origin header verbatim on POST /opencodex-session and 060 test + plan adds the exact-POST-route case. +3 Canonical relay spelling everywhere: POST /api/machine/hub-relay/* (prefix + suffix); + 010:179 updated to the wildcard form. +4 x-opencodex-key-id: configured-key admission ONLY (environment/loopback/none → header + absent); value re-validated header-safe as ^[A-Za-z0-9._-]{1,64}$ at emission (mismatch → + omit header, log once); emitted on 200 AND 304; response gains Cache-Control: private, + no-cache; tests cover absence for environment/loopback and no key-id in logs. privacy:scan + claim removed — runtime-header privacy is proven by the log-absence test instead. +5 Post-disconnect revoke: hub GUI is the SOLE post-disconnect revocation path. ocx connect + revoke exists only while connected (state carries apiKeyId from issuance response — 050 + state gains apiKeyId field, full chain issuance→state→revoke→display); disconnect prompts + a reminder naming the hub GUI page. No tombstones. +6 Zeroization wording: "release references and overwrite the coordinator's Uint8Array copy; + the immutable argv/stdin string copies are best-effort GC" — OneTimeConnectCredential.value + becomes Uint8Array (decoded once at read), display never renders it. +7 Rotation chain completed: OcxClientConnectionConfig gains pendingOperation?: { kind: + "rotate"; rotationId: string; newKeyIssuedAt: string; oldKeyBackupPath: string } with + validation in the client-config reader; recovery on doubly-accepted = COMMIT the new key + (delete .prev + clear pendingOperation) because new-key acceptance proves issuance + completed; .prev writer assigned to src/lib/service-secrets.ts (existing owner) as + writeTokenBackup/restoreTokenBackup; 080 focused commands add tests/client-connect.test.ts + and tests/service-secrets.test.ts. diff --git a/devlog/_plan/260827_remote_hub/010_design.md b/devlog/_plan/260827_remote_hub/010_design.md index 33332c77fd..76099d4e30 100644 --- a/devlog/_plan/260827_remote_hub/010_design.md +++ b/devlog/_plan/260827_remote_hub/010_design.md @@ -176,7 +176,7 @@ opt-in with ownership records. Explicit allowlist, default-404 (same failure mode as loopbackRouteAllowed): /healthz · /readyz · GET /api/machine/status · GET /api/machine/clients · POST /api/machine/sync · GET/POST /api/machine/shim · POST /api/machine/disconnect · -POST /api/machine/hub-relay (opt-in only). +POST /api/machine/hub-relay/* (opt-in only). Mutations need local gui-session + CSRF (auto-minted on loopback, today's flow). Relay constraints (it is NOT a proxy): fixed destination = client.managementUrl; diff --git a/devlog/_plan/260827_remote_hub/030_phase1_protocol_catalog.md b/devlog/_plan/260827_remote_hub/030_phase1_protocol_catalog.md index 90e8ed526d..171b99a841 100644 --- a/devlog/_plan/260827_remote_hub/030_phase1_protocol_catalog.md +++ b/devlog/_plan/260827_remote_hub/030_phase1_protocol_catalog.md @@ -33,8 +33,11 @@ runtime behavior. All code paths remain standalone-compatible when `runtimeRole` `x-opencodex-api-key: ` or `Authorization: Bearer `. `x-api-key`, a foreign bearer, the admin token, and no credential are rejected on a non-loopback bind. The row is added to `AUTH_MATRIX` with `xApiKey: "rejected"`. -- An admitted `/v1/catalog` response includes `x-opencodex-key-id` with the admitted - key's id. Rejected and unauthenticated responses never include this header. +- A `/v1/catalog` response admitted by a configured key includes `x-opencodex-key-id` with + that key's id on both 200 and 304. Environment-token and loopback paths, plus rejected + and unauthenticated responses, never include this header. Revalidate the id at emission as + `^[A-Za-z0-9._-]{1,64}$`; on mismatch omit the header and log one non-secret warning. + Every successful catalog response includes `Cache-Control: private, no-cache`. - A serialized catalog larger than `MAX_REMOTE_CATALOG_BYTES = 32 * 1024 * 1024` is not returned over `/v1/catalog`; it fails with HTTP 503 and the stable code `catalog_too_large`. The management route continues to expose the same serialized bytes @@ -49,7 +52,7 @@ runtime behavior. All code paths remain standalone-compatible when `runtimeRole` - A parser/compatibility predicate for future `ocx connect`, including additive-field tolerance for a dev hub paired with the latest released client. - Shared catalog serialization, ETag, `If-None-Match`, size cap, data-plane admission, and - authenticated `x-opencodex-key-id` attribution. + configured-key-only `x-opencodex-key-id` attribution. - Route placement before the unknown-`/v1/*` JSON-404 guard. - Focused and full remote-only verification commands. @@ -123,15 +126,17 @@ expected body by calling the serializer twice. The ETag is `"sha256-"` over the exact UTF-8 response bytes. A matching strong tag, weak spelling of that same tag, a comma-list containing it, or `*` returns 304 -with ETag and no body. A stale/malformed `If-None-Match` returns 200. ETag is computed only -after the 32 MiB bound passes. +with ETag and no body. A stale/malformed `If-None-Match` returns 200. Both 200 and 304 carry +`Cache-Control: private, no-cache`. ETag is computed only after the 32 MiB bound passes. `/v1/catalog` uses `resolveResponsesApiAuth(req, policy)`, not `resolveApiAuth`, because the former is the existing dedicated-header/our-secret-Bearer matrix and rejects `x-api-key` (`src/server/auth-cors.ts:465-478`). It performs the existing data-plane origin check after -admission, then sets `x-opencodex-key-id` from that admitted key identity. Rejected and -unauthenticated paths never emit the header. No Direct passthrough exists on this read-only -route and no credential is forwarded. +admission, then sets `x-opencodex-key-id` on 200 and 304 only when the admitted identity is +a configured API key and its id passes `^[A-Za-z0-9._-]{1,64}$` again at emission. A +mismatch omits the header and emits one non-secret warning without the id. Environment-token, +loopback, rejected, and unauthenticated paths never emit the header. No Direct passthrough +exists on this read-only route and no credential is forwarded. ## 4. Diff-level file-change map @@ -145,14 +150,14 @@ All paths below exist in the current tree except the two files marked **NEW**. | NEW | `src/server/catalog-download.ts` | Own `MAX_REMOTE_CATALOG_BYTES`, one persisted-catalog serialization result, byte-derived ETag matching, `/api/catalog` response construction, and bounded `/v1/catalog` response construction. | | MODIFY | `src/server/management/model-routes.ts` | Replace the inline `/api/catalog` read/`JSON.stringify` block at lines 334–345 with the shared response builder; preserve 404 and `x-opencodex-codex-version`. | | MODIFY | `src/server/auth-cors.ts` | Add the `/v1/catalog` `AUTH_MATRIX` row with bearer/dedicated accepted and `xApiKey` rejected. Do not change any existing row or credential precedence. | -| MODIFY | `src/server/index.ts` | Add protocol metadata to the current `/readyz` body at lines 991–998 by passing `(config, req)` to the builder. Mount exact `GET /v1/catalog` after readiness/management handling and before the unknown-`/v1/*` guard at line 1604; use `resolveResponsesApiAuth` plus the existing origin policy and emit `x-opencodex-key-id` only from the admitted key identity. Keep `startServer` synchronous and add no `await` between `Bun.serve` and `labActivationRequired`. | +| MODIFY | `src/server/index.ts` | Add protocol metadata to the current `/readyz` body at lines 991–998 by passing `(config, req)` to the builder. Mount exact `GET /v1/catalog` after readiness/management handling and before the unknown-`/v1/*` guard at line 1604; use `resolveResponsesApiAuth` plus the existing origin policy, add `Cache-Control: private, no-cache`, and emit `x-opencodex-key-id` on 200/304 only for a configured-key identity whose id passes the emission-time header-safe guard. Omit and log once without the id on mismatch. Keep `startServer` synchronous and add no `await` between `Bun.serve` and `labActivationRequired`. | | MODIFY | `src/server/proxy-liveness.ts` | Keep existing identity/status parsing additive; extend the internal body type/comments so protocol fields are recognized but do not make ordinary `ocx ready` reject a v0/legacy standalone server. Remote compatibility remains in `src/remote/protocol.ts`. | | MODIFY | `tests/config.test.ts` | Extend the existing config-default/validation sibling tests with absent/default, three valid roles, malformed live candidate, and malformed persisted role preservation cases. | | MODIFY | `tests/server-live.test.ts` | Extend the existing `GET /readyz` suite (lines 1220+) for exact protocol values on ready/pending/failed/draining, sanitized keys, management origin, method/path negatives, and no-auth behavior. | | MODIFY | `tests/proxy-liveness.test.ts` | Extend the current strict readiness parser/probe tests to prove additive protocol fields neither invalidate readiness nor bypass identity/status checks. | | MODIFY | `tests/api-catalog-route.test.ts` | Extend the existing `/api/catalog` sibling suite with fixed fixture bytes and version-header preservation after serializer extraction. | -| MODIFY | `tests/server-auth.test.ts` | Extend live-server auth/order coverage with `/v1/catalog` admission matrix, exact admitted `x-opencodex-key-id`, header absence on every rejected/unauthenticated path, exact-path/method negatives, foreign/admin bearer rejection, bound overflow, ETag/304, and unknown-`/v1` guard preservation. | -| MODIFY | `tests/api-key-attribution.test.ts` | Extend the existing live `AUTH_MATRIX` loop so `/v1/catalog` is a GET route, each cell reaches the real handler rather than the generic 404, and successful dedicated/Bearer admission echoes that key's id. | +| MODIFY | `tests/server-auth.test.ts` | Extend live-server auth/order coverage with `/v1/catalog` admission matrix, exact configured-key `x-opencodex-key-id` on 200 and 304, `Cache-Control: private, no-cache`, header absence for environment-token/loopback/rejected/unauthenticated paths, invalid-id omission with one id-free log, exact-path/method negatives, foreign/admin bearer rejection, bound overflow, ETag/304, and unknown-`/v1` guard preservation. | +| MODIFY | `tests/api-key-attribution.test.ts` | Extend the existing live `AUTH_MATRIX` loop so `/v1/catalog` is a GET route, each cell reaches the real handler rather than the generic 404, configured-key dedicated/Bearer admission echoes that key's id, and environment/loopback admission does not. | No other production or test file is in scope. If implementation proves another path is required, stop the phase and amend this document before editing it. @@ -230,12 +235,13 @@ an empty catalog. No function accepts a caller-provided catalog path. | P1-A07 | Feed `{protocol: 2, minimumClientProtocol: 2}` to a protocol-v1 client. | `hub-too-new` and the exact “Upgrade ocx on this client” string are returned before catalog access. | | P1-A08 | Feed `{protocol: 1, minimumClientProtocol: 1}` to a protocol-v2 client requiring minimum hub protocol 2. | `hub-too-old` and the exact “Upgrade ocx on the hub” string are returned. | | P1-A09 | Supply zero, omit, mistype, overflow, set minimum above protocol, or give a path-bearing `managementUrl`. | The malformed-input class (`400`) returns `invalid` and the exact malformed-metadata string; it is never classified as a version mismatch and never falls back to protocol 1. | -| P1-A10 | Persist a fixed catalog fixture; call authorized `/api/catalog` and authorized `/v1/catalog`. | Status 200 and response bytes are byte-identical; the ETag independently hashes those bytes. | -| P1-A11 | Repeat `/v1/catalog` on non-loopback with dedicated header, our-secret Bearer, `x-api-key`, foreign Bearer, admin token, and no token. | First two reach 200 with `x-opencodex-key-id` equal to the admitted key id; all remaining cases are 401 without that header. No case reaches the generic unknown-route 404. | -| P1-A12 | Call `/v1/catalog` with matching tag, weak matching tag, tag list, `*`, stale tag, and malformed tag. | Matches return 304/no body/same ETag; stale or malformed values return 200/full bytes. | +| P1-A10 | Persist a fixed catalog fixture; call authorized `/api/catalog` and authorized `/v1/catalog`. | Status 200 and response bytes are byte-identical; the ETag independently hashes those bytes and `/v1/catalog` carries `Cache-Control: private, no-cache`. | +| P1-A11 | Repeat `/v1/catalog` with configured-key dedicated/Bearer admission, environment-token admission, loopback admission, `x-api-key`, foreign Bearer, admin token, and no token; inject an invalid configured key id, repeat the request, and capture logs. | Configured-key 200 and matching 304 carry the exact safe key id. Environment/loopback/rejected/unauthenticated responses omit it; invalid id is omitted with one non-secret warning and no key id appears in logs. No case reaches the generic unknown-route 404. | +| P1-A12 | Call `/v1/catalog` with matching tag, weak matching tag, tag list, `*`, stale tag, and malformed tag. | Matches return 304/no body/same ETag; stale or malformed values return 200/full bytes; every successful response carries `Cache-Control: private, no-cache`. | | P1-A13 | Serialize exactly the cap and cap+1 fixtures through an injected serialization seam. | Exact cap returns 200; cap+1 returns 503 `catalog_too_large`, never a partial body. | | P1-A14 | Call POST `/v1/catalog`, GET `/v1/catalog/`, and an unrelated `/v1/does-not-exist`. | Every request returns the existing JSON 404 envelope; route ordering does not widen path/method matching. | | P1-A15 | Run the import-graph and synchronous-window guard after the diff. | No new subsystem is reachable from the three protected core files; `startServer` remains non-async and its guarded window contains no top-level `await`. | +| P1-A16 | Feed `{protocol: 2, minimumClientProtocol: 1}` to a protocol-v1 client. | Compatibility succeeds; only protocol-v1 behavior is enabled. | ## 7. Verification — remote only on `lidge-ai` @@ -254,6 +260,9 @@ Review-ready shared-server gate: ssh lidge-ai 'cd ~/Developer/opencodex && bun run test && bun run privacy:scan' ``` +`privacy:scan` remains a repository gate, not the runtime-header privacy oracle; P1-A11's +captured-log absence assertion proves that key ids do not reach logs. + Record the remote commit, Bun version, command, exit code, and pass/fail counts in the phase evidence ledger. Do not repeat a passing command unless code covered by it changes. diff --git a/devlog/_plan/260827_remote_hub/040_phase2_remote_session.md b/devlog/_plan/260827_remote_hub/040_phase2_remote_session.md index 8d7408fa97..7e93db79cb 100644 --- a/devlog/_plan/260827_remote_hub/040_phase2_remote_session.md +++ b/devlog/_plan/260827_remote_hub/040_phase2_remote_session.md @@ -72,7 +72,8 @@ consumption of that credential is the only pairing exchange. - Automatic loopback and trusted-Tailscale issuance; pairing grant creation and exchange. - Separate loopback/remote TTLs and sliding renewal for remote sessions. - Exact management CORS header widening for GUI-origin and CSRF headers. -- CLI `ocx gui pair` grant creation through the existing runtime-attestation pattern; no +- CLI `ocx gui pair --origin ` grant creation through the existing + runtime-attestation pattern; no grant or reusable admin credential in argv, config, disk, logs, or shell history. - Backend and GUI regressions for every positive and negative issuance path. @@ -241,10 +242,11 @@ memory-only and are never written to web storage. - HTTPS issues `pairing`; non-loopback HTTP issues `insecure-http-pairing` only when the config opt-in is true. The grant is consumed before minting; all replays fail. - Phase 4's fixed-target relay path allowlist admits this exact POST exchange in addition - to GET bootstrap; no other non-`/api/*` method/path is widened. + to GET bootstrap and forwards the browser's `Origin` header verbatim; no other + non-`/api/*` method/path is widened. -`ocx gui pair [--origin ] [--json]` defaults `--origin` to -`hub.managementPublicOrigin`; it fails if neither exists. It resolves the identity-checked +`ocx gui pair --origin [--json]` requires an explicit `--origin`; there is +no config-derived or localhost default. It resolves the identity-checked runtime, verifies the `/healthz` challenge proof, rechecks PID/port, derives the one-operation capability from the protected runtime attestation secret, and POSTs once. It prints the grant exactly once to stdout and never accepts a grant/token argument. JSON output is intended for @@ -283,10 +285,10 @@ All paths below exist in the current tree except files marked **NEW**. | MODIFY | `src/server/index.ts` | Pass `(config, req)` to the `/readyz` protocol metadata builder so `hub.managementPublicOrigin` reaches the response; advertise GUI-pair capability v1 in `/healthz`; mount exact pairing-grant creation after capability admission, mount GET/POST bootstrap before GUI fallback, pass `trustedTailscaleIngress: false` on the public/ordinary loopback listeners, and preserve the line-1604 unknown-`/v1` guard. Do not make `startServer` async or add an await in its synchronous activation window. | | MODIFY | `src/server/proxy-liveness.ts` | Add optional `guiPairCapability` to the existing health identity projection so the local client can fail closed against an old/foreign listener without changing required liveness identity fields. | | MODIFY | `src/server/gui-static.ts` | Serialize escaped browser/server origin meta tags; keep `opencodex-session-origin` as browser-origin compatibility metadata. | -| NEW | `src/cli/gui.ts` | Own `runGuiCommand(args, deps)`: existing no-subcommand open behavior plus `pair`; strict `--origin`/`--json` parsing and one-time grant output. | +| NEW | `src/cli/gui.ts` | Own `runGuiCommand(args, deps)`: existing no-subcommand open behavior plus `pair`; require exactly one explicit `--origin`, parse `--json` strictly, and emit the one-time grant. | | NEW | `src/cli/gui-pair-client.ts` | Mirror the existing bound restart/provider-reload client pattern: read runtime identity, challenge `/healthz`, verify proof/capability version, recheck the target, derive the browser-origin-bound capability, POST once, and return a redacted typed result. | | MODIFY | `src/cli/dispatch.ts` | Delegate the current inline `gui` runner to `runGuiCommand`, passing existing open/start dependencies; do not duplicate live-proxy discovery. | -| MODIFY | `src/cli/registry.ts` | Change usage to `ocx gui [pair [--origin ] [--json]]` and document that pairing output is secret and single-use. | +| MODIFY | `src/cli/registry.ts` | Change usage to `ocx gui [pair --origin [--json]]` and document that pairing output is secret and single-use. | | MODIFY | `src/cli/help.ts` | Update the curated GUI command line so registry/help parity remains green. | | MODIFY | `gui/src/api.ts` | Split memory browser/server origins, validate both meta sources, scope token attachment to the server origin, keep browser origin in the GUI header, and clear all four session values atomically. No web-storage persistence. | | MODIFY | `tests/config.test.ts` | Extend sibling config tests for valid HTTPS, explicit HTTP opt-in, invalid origin components, duplicate/empty/oversize Tailscale users, malformed persisted block preservation, and non-hub inertness. | @@ -444,7 +446,7 @@ sessions renew at most once per request. | P2-A15 | GUI loads valid two-origin meta, then calls the bound server and an evil third origin. | Session headers attach only to bound server; evil origin receives no token/CSRF and triggers no admin prompt. | | P2-A16 | GUI receives mismatched browser origin, mismatched response/server origin, missing meta, or a failed renewal. | All in-memory session fields clear atomically; no web-storage write and no stale header reuse. | | P2-A17 | Allowed management OPTIONS requests GUI-origin + CSRF headers; repeat from rejected origin and request an unrelated custom header. | Allowed response lists the two exact additions and exact ACAO; rejected origin is 403; unrelated header is not dynamically echoed by management CORS. | -| P2-A18 | Run `ocx gui pair` with configured public origin, explicit allowed origin, missing origin, `--json`, extra args, stale target, failed attestation, and capability/API failure. | Valid cases create one grant and print once; invalid cases fail without falling back to admin auth or echoing secrets; no grant appears in argv/config/disk/log fixtures. | +| P2-A18 | Run `ocx gui pair --origin ` with an explicit allowed origin, then missing `--origin`, malformed/disallowed origin, `--json`, extra args, stale target, failed attestation, and capability/API failure. | Valid cases create one grant and print once; every absent/invalid origin fails with no default and without falling back to admin auth or echoing secrets; no grant appears in argv/config/disk/log fixtures. | | P2-A19 | Run native-profile mutation suite with admin, malformed remote session, and valid remote session. | Existing consent boundary remains: only the valid session + correct origin + CSRF dispatches the mutation. | | P2-A20 | Run import-graph and synchronous-window guard. | No protected core import reaches GUI-session code; `startServer` remains synchronous and activation ordering is unchanged. | diff --git a/devlog/_plan/260827_remote_hub/050_phase3_connect.md b/devlog/_plan/260827_remote_hub/050_phase3_connect.md index 51c7a5d31c..97628e74c7 100644 --- a/devlog/_plan/260827_remote_hub/050_phase3_connect.md +++ b/devlog/_plan/260827_remote_hub/050_phase3_connect.md @@ -94,12 +94,12 @@ Every existing path below was verified in the current tree. For NEW client paths | Action | Exact path | Diff-level change | |---|---|---| -| NEW | `src/client/state.ts` | Parse/validate `runtimeRole + config.json.client`, expose fail-closed connected/absent/invalid/mismatched states, and atomically commit/clear both keys through config mutation. | +| NEW | `src/client/state.ts` | Parse/validate `runtimeRole + config.json.client`, including the optional rotation `pendingOperation`; expose fail-closed connected/absent/invalid/mismatched states, and atomically commit/clear both keys through config mutation. | | NEW | `src/client/hub-client.ts` | Validate/normalize URLs; bounded `GET /readyz`, `POST /api/keys`, `GET /v1/catalog`; protocol/capability checks; redact all credential-bearing errors. | | NEW | `src/client/connect.ts` | Transaction coordinator, connected sync, rollback, and offline disconnect. No argv or presentation logic. | | NEW | `src/cli/connect.ts` | Parse connect/disconnect/status arguments, read exactly one `--pairing-code-stdin` or `--admin-token-stdin` credential, call the coordinator, and render redacted human/JSON output. | -| MODIFY | `src/types/config.ts` | Add `OcxClientConnectionConfig` and top-level `OcxConfig.client?`; the secret itself is not a field. | -| MODIFY | `src/config.ts` | Add schema and field-scoped persistence behavior for `client`; malformed-present client state must be diagnosable and must never degrade into standalone routing. | +| MODIFY | `src/types/config.ts` | Add `OcxClientConnectionConfig`, its optional non-secret rotation `pendingOperation`, and top-level `OcxConfig.client?`; the secret itself is not a field. | +| MODIFY | `src/config.ts` | Add schema and field-scoped persistence behavior for `client`, including `pendingOperation`; malformed-present client state must be diagnosable and must never degrade into standalone routing. | | MODIFY | `src/lib/service-secrets.ts` | Add atomic owner-only write and fingerprint-checked removal beside existing path/read helpers. | | MODIFY | `src/codex/inject.ts` | Add `CodexRoutingTarget`; thread it through provider table, `env_key`, root URL, profile, preflight, journal witness, and inject while retaining byte-compatible standalone overloads. | | MODIFY | `src/codex/journal.ts` | Add backward-compatible durable client ownership; reconcile a dead process journal only when no matching committed client state exists. | @@ -145,12 +145,18 @@ export interface OcxClientConnectionConfig { managementTransport: "direct" | "relay"; selectedClients: OcxConnectedClientId[]; tokenEnv: "OPENCODEX_API_AUTH_TOKEN"; - apiKeyId: string; // attribution id; not secret + apiKeyId: string; // exact IssuedClientKey.id; attribution/revoke id, not secret tokenFingerprint: string; // lowercase SHA-256; ownership check only protocolVersion: 1; connectedAt: string; // ISO-8601 catalogEtag?: string; catalogSyncedAt?: string; + pendingOperation?: { + kind: "rotate"; + rotationId: string; + newKeyIssuedAt: string; + oldKeyBackupPath: string; + }; } export interface OcxConfig { @@ -161,7 +167,10 @@ export interface OcxConfig { ``` The parser rejects unknown selected-client ids, non-origin URLs, protocol values other -than 1, duplicate client ids, malformed timestamps, and non-64-hex fingerprints. +than 1, duplicate client ids, malformed timestamps, and non-64-hex fingerprints. A present +`pendingOperation` must have exactly `kind: "rotate"`, a non-empty `rotationId`, a valid +`newKeyIssuedAt`, and the exact owner-approved `.prev` backup path; malformed or +partial pending state makes the client state invalid rather than dropping the recovery gate. Forward-compatible unknown object keys are preserved on unrelated config writes. A raw `client` key that is present but invalid is `kind: "invalid"`, not `absent`; start, sync, Claude launch, and status must refuse local-provider fallback in that state. @@ -269,8 +278,8 @@ the final client-state commit cannot leave durable remote routing behind. ```ts export type OneTimeConnectCredential = - | { kind: "admin"; value: string } - | { kind: "pairing-grant"; value: string }; + | { kind: "admin"; value: Uint8Array } + | { kind: "pairing-grant"; value: Uint8Array }; export interface ConnectGuiSession { token: string; @@ -294,13 +303,13 @@ export function fetchHubReady( export function exchangeConnectPairingGrant( managementUrl: string, browserOrigin: string, - grant: string, + grant: Uint8Array, options?: { allowInsecureHttp?: boolean; timeoutMs?: number; fetchImpl?: typeof fetch }, ): Promise; export function issueClientKey( managementUrl: string, credential: - | { kind: "admin"; value: string } + | { kind: "admin"; value: Uint8Array } | { kind: "gui-session"; value: ConnectGuiSession }, name: string, options?: { timeoutMs?: number; fetchImpl?: typeof fetch }, @@ -330,6 +339,11 @@ session token + `X-OpenCodex-GUI-Origin` + CSRF authorize the key POST. A raw pa grant cannot access any `/api/*` route. An admin token is never submitted to the session exchange and therefore never mints or becomes `gui-session`. +The successful issuance response's `IssuedClientKey.id` is copied unchanged into +`OcxClientConnectionConfig.apiKeyId` at the final state commit. That stored field is the +single id consumed by journal ownership, connected status/display, Usage attribution, and +Phase 6's connected-only `ocx connect revoke`; no revoke key id is accepted from argv. + ### `src/client/connect.ts` ```ts @@ -373,13 +387,20 @@ ocx connect status [--json] ocx disconnect [--keep-catalog] [--json] ``` +Phase 6 may extend this command family with `ocx connect revoke --admin-token-stdin`, but +that command is valid only while `readClientConnectionState()` is connected. It resolves +the exact key solely from `config.client.apiKeyId` and rejects disconnected, invalid, or +mismatched state before any hub request. + There is deliberately no `--token `, `--admin-token `, pairing-code positional form, or credential environment-variable form. Exactly one of -`--pairing-code-stdin` and `--admin-token-stdin` uses the bounded stdin helper. Parse errors -redact unknown bare values and all credential-shaped option values. The transient admin -credential remains in memory until the connect transaction commits or rollback finishes, -then its buffer and coordinator reference are zeroized; successful key issuance alone is -not a terminal outcome. +`--pairing-code-stdin` and `--admin-token-stdin` uses the bounded stdin helper. It decodes +the credential once at read into the coordinator-owned `Uint8Array`; no display path renders +that value. Parse errors redact unknown bare values and all credential-shaped option values. +The transient credential remains in memory until the connect transaction commits or rollback +finishes; successful key issuance alone is not a terminal outcome. At that terminal boundary, +release references and overwrite the coordinator's `Uint8Array` copy; immutable argv/stdin +string copies are best-effort GC. ## 4. Connect transaction and rollback @@ -413,8 +434,9 @@ attempts exact `DELETE /api/keys` for the just-created id. If hub cleanup is unr the failure reports only the safe key id and exact revoke action; it never prints the key. Machine-local rollback success is mandatory and remote cleanup inability is explicit, never hidden as full rollback. Only after that cleanup attempt completes does -the coordinator zeroize the transient admin credential; the success path zeroizes it -immediately after the final state commit. +the coordinator release references and overwrite its transient credential `Uint8Array`; +the success path does so immediately after the final state commit. Immutable string copies +remain best-effort GC rather than a zeroization guarantee. `--no-sync` still performs readiness, key issuance, token placement, catalog download, and final state commit, but does not mutate Codex/Claude client files. The next @@ -467,6 +489,11 @@ Disconnect is local-authoritative and works with the hub offline: 5. Clear `config.json.client` + the `client` runtime role together and last (absence resolves to standalone). +After successful local disconnect, human and JSON output retain the safe prior `apiKeyId` +only long enough to remind the operator: revoke the still-valid key from the hub GUI's +**Integrations → API Keys** page. Once state is cleared, CLI revoke is unavailable; the hub +GUI is the sole post-disconnect revocation path. + If restore is partial or the token changed, state is not cleared and the command names the conflicting artifact. This avoids claiming disconnected while Codex still points at the hub or deleting a replacement secret. Remote key revocation is not required for @@ -479,12 +506,12 @@ No test sends live hub traffic or reads the developer's homes. | Test file | Required cases | |---|---| -| `tests/client-connect.test.ts` (NEW) | URL canonicalization; exact stdin-flag exclusivity and literal/env credential rejection; Phase-1 ready parser/mismatch strings; ready/pending/failed; same-major v1 acceptance; management URL advertisement; admin HTTPS direct key POST; pairing HTTPS session exchange then key POST; dual-opt-in pairing HTTP; admin HTTP refusal; raw grant rejection at `/api/keys`; admin credential retained through commit/rollback then zeroized; bounded catalog; atomic role+state commit; each rollback point; no-sync; connected 200/304/401/timeout sync; no local discovery fake called; offline disconnect and partial restore. | +| `tests/client-connect.test.ts` (NEW) | URL canonicalization; exact stdin-flag exclusivity and literal/env credential rejection; Phase-1 ready parser/mismatch strings; ready/pending/failed; p2/min1 acceptance and p2/min2 rejection; management URL advertisement; admin HTTPS direct key POST; pairing HTTPS session exchange then key POST; issued id copied unchanged through state/status/revoke ownership; dual-opt-in pairing HTTP; admin HTTP refusal; raw grant rejection at `/api/keys`; credential retained through commit/rollback, never rendered, then coordinator `Uint8Array` overwritten and references released; bounded catalog; atomic role+state commit; each rollback point; no-sync; connected 200/304/401/timeout sync; no local discovery fake called; offline disconnect, post-disconnect hub-GUI reminder, and partial restore. | | `tests/service-secrets.test.ts` (NEW) | Exact path, 0600, Windows ACL seam, atomic replacement, symlink refusal, fingerprint, changed-file non-removal, no token in errors. | | `tests/codex-inject.test.ts` | Current standalone goldens byte-equal; explicit HTTPS target emits exact `base_url`, provider table, `env_key`; loopback-looking connected URL still requires admission; malformed target refused before journal. | | `tests/codex-inject-integration.test.ts` | Validate-only has zero writes; target commit records journal ownership; offline restore returns exact preimage; partial write rollback. | | `tests/codex-catalog-restore.test.ts`, `tests/cli-start-journal-order.test.ts` | Version-1 process journals retain current behavior; client journal survives only a matching final state; absent/invalid/mismatched state restores after dead connect PID. | -| `tests/config.test.ts` | Valid client round-trip; atomic role+client pair; unknown keys preserved; absent remains standalone; half-present/malformed remains fail-closed; no secret field accepted/emitted. | +| `tests/config.test.ts` | Valid client round-trip including a complete rotation `pendingOperation`; malformed/missing `rotationId`, timestamp, or backup path fails closed; atomic role+client pair; unknown keys preserved; absent remains standalone; half-present/malformed remains fail-closed; no secret field accepted/emitted. | | `tests/api-keys-routes.test.ts` | Admin and full GUI-session predicates create once; raw pairing grant and incomplete origin/CSRF reject; list/patch never echo secret. Phase-2 session tests remain the admin-never-mints-session oracle. | | `tests/cli-registry.test.ts`, `tests/cli-dispatch.test.ts`, `tests/cli-help.test.ts` | Registry/dispatch/help parity; no credential argv form; connected sync calls only remote coordinator; invalid client refuses. | | `tests/cli-status-json.test.ts` | Stable redacted status in disconnected/connected/invalid/token-changed/catalog-stale states. | @@ -498,7 +525,7 @@ No test sends live hub traffic or reads the developer's homes. | P3-A1 | Disconnected temp home; HTTPS hub ready on protocol 1; admin token arrives through stdin; `/api/keys` and `/v1/catalog` succeed. | One key is issued, token exists only in owner-only service file, catalog and injection commit, and `runtimeRole=client` + `config.client` are written together and last with key id/fingerprint only. | | P3-A2 | Same as A1, but a Phase-2 pairing grant bound to the future localhost GUI origin is supplied. | Grant is consumed once at `/opencodex-session`; returned GUI session + CSRF performs exact key POST; raw grant on `/api/keys` and replay fail; no transient credential persists. | | P3-A3 | HTTP management URL with pairing grant, client `--allow-insecure-http`, and hub `remoteGui.allowInsecureHttp=true`. | Exchange/key issuance succeeds with explicit warning. Missing either opt-in refuses; admin credential over HTTP refuses before credential transmission. | -| P3-A4 | `/readyz` returns protocol major 2, minimum client above 1, or status pending/failed. | Clear upgrade/not-ready error; zero key POSTs and zero local writes. Latest-release client ↔ dev hub protocol-1 fixture remains accepted. | +| P3-A4 | `/readyz` returns `{protocol: 2, minimumClientProtocol: 2}` or status pending/failed. | Clear upgrade/not-ready error; zero key POSTs and zero local writes. | | P3-A5 | Key POST returns 401/403/409 or malformed/oversized JSON. | No token/catalog/journal/config writes and no secret in diagnostics. | | P3-A6 | Token, catalog, injector preflight, inject commit, or final role+state commit is fault-injected in turn. | Prior machine bytes are restored at every point; neither `runtimeRole=client` nor visible `client` remains; remote orphan cleanup status is explicit by safe key id only. | | P3-A7 | Existing standalone config runs every current injector golden. | Output bytes are identical; no connected-only env/header/config key appears. | @@ -506,9 +533,10 @@ No test sends live hub traffic or reads the developer's homes. | P3-A9 | Connected state with hub down, 401, missing token, changed token, or malformed-present client config. | No local-provider fallback and no new local catalog; timeout keeps LKG as stale, credential/state errors fail hard. | | P3-A10 | Connected Claude launch with no user Anthropic overrides. | Child receives hub base and client token; no local proxy is started; gateway cache uses hub `/v1/models`. | | P3-A11 | Connected Claude launch with user-owned different `ANTHROPIC_BASE_URL`. | User destination wins and the hub admission token is absent from child env. | -| P3-A12 | Hub unreachable during disconnect with intact journal/token. | Native Codex bytes restore offline, owned token/catalog are removed per flags, and `runtimeRole + client` clear together and last. | +| P3-A12 | Hub unreachable during disconnect with intact journal/token. | Native Codex bytes restore offline, owned token/catalog are removed per flags, and `runtimeRole + client` clear together and last; output names the hub GUI **Integrations → API Keys** page as the sole post-disconnect revoke path. | | P3-A13 | Disconnect sees changed token or a journal ownership conflict. | Conflicting artifact is preserved, command fails, and connected state remains so status is honest. | | P3-A14 | Connect injection committed, connect process exited, and matching `runtimeRole=client + config.client.apiKeyId` was committed last; then `ocx start` runs. | Pre-start reconciliation preserves the client journal/routing. If final state is absent, invalid, mismatched, or names another key id, the same journal restores before startup. | +| P3-A15 | `/readyz` returns `{protocol: 2, minimumClientProtocol: 1}` to the protocol-v1 client. | Compatibility succeeds using protocol-v1 behavior; key issuance and connect continue normally. | ## 8. Verification — remote only on `lidge-ai` diff --git a/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md b/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md index 62f667bbab..b456ed32d9 100644 --- a/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md +++ b/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md @@ -103,6 +103,7 @@ parent exists. No generated `gui/dist` file is edited. | NEW | `tests/client-machine-listener.test.ts` | Listener bind/allowlist/auth/API/startup/offline matrix. | | NEW | `tests/client-hub-relay.test.ts` | Fixed target, header separation, body caps, redirects, errors, and SSRF negatives. | | NEW | `gui/src/api-targets.ts` | Canonical `ApiTargets`, machine-status discovery, per-plane call-base selection, relay URL construction, and disconnected fallback. | +| NEW | `gui/src/connect-pairing.ts` | Own the visible pairing-code form and activation flow: paste a one-time code, POST the exact `/opencodex-session` exchange through the selected direct/relay shared target, and install the returned session only in the shared target's in-memory auth slot. | | MODIFY | `gui/src/api.ts` | Replace `needsApiAuth`'s one same-origin slot with exact target classification and per-target in-memory session/CSRF state; attach both auth domains only on relay. | | MODIFY | `gui/src/App.tsx` | Discover targets before page fetches; supply both call bases instead of one page base; machine health remains live when hub is down; connected stop becomes disconnect/recycle. | | MODIFY | `gui/src/stop-proxy.ts` | Add mode-aware machine disconnect request while preserving existing standalone `/api/stop` behavior. | @@ -114,15 +115,15 @@ parent exists. No generated `gui/dist` file is edited. | MODIFY | `gui/src/pages/Storage.tsx` | Pass its selected shared `apiBase` through to `StorageWorkspace`. | | MODIFY | `gui/src/components/storage-workspace/StorageWorkspace.tsx` | Remove module-global `VITE_API_BASE`; use the supplied shared-plane base for Codex-log storage calls. | | MODIFY | `gui/src/styles-usage-workspace.css` | Style compact usage-source/scope controls and connected/offline qualification without changing layout direction. | -| MODIFY | `gui/src/i18n/en.ts` | Source-of-truth copy keys for connected source, this machine, hub-wide, hub offline, disconnect, and relay warnings. | -| MODIFY | `gui/src/i18n/de.ts` | Add the same keys. | -| MODIFY | `gui/src/i18n/fr.ts` | Add the same keys. | -| MODIFY | `gui/src/i18n/ja.ts` | Add the same keys. | -| MODIFY | `gui/src/i18n/ko.ts` | Add the same keys. | -| MODIFY | `gui/src/i18n/ru.ts` | Add the same keys. | -| MODIFY | `gui/src/i18n/tr.ts` | Add the same keys. | -| MODIFY | `gui/src/i18n/zh.ts` | Add the same keys. | -| MODIFY | `gui/src/i18n/zh-TW.ts` | Add the same keys. | +| MODIFY | `gui/src/i18n/en.ts` | Source-of-truth copy keys for pairing code/submit/error, connected source, this machine, hub-wide, hub offline, disconnect, and relay warnings. | +| MODIFY | `gui/src/i18n/de.ts` | Add the same pairing and connection keys. | +| MODIFY | `gui/src/i18n/fr.ts` | Add the same pairing and connection keys. | +| MODIFY | `gui/src/i18n/ja.ts` | Add the same pairing and connection keys. | +| MODIFY | `gui/src/i18n/ko.ts` | Add the same pairing and connection keys. | +| MODIFY | `gui/src/i18n/ru.ts` | Add the same pairing and connection keys. | +| MODIFY | `gui/src/i18n/tr.ts` | Add the same pairing and connection keys. | +| MODIFY | `gui/src/i18n/zh.ts` | Add the same pairing and connection keys. | +| MODIFY | `gui/src/i18n/zh-TW.ts` | Add the same pairing and connection keys. | | NEW | `gui/tests/api-targets.test.ts` | Target discovery, per-plane call-base selection, relay construction, and hub-down fallback. | | MODIFY | `gui/tests/api-auth-memory.test.ts` | Independent machine/shared sessions; direct/relay header matrix; no cross-target leakage; bootstrap validation. | | MODIFY | `gui/tests/api-auth-deadline.test.ts` | Per-target shared resolution/watchdog behavior. | @@ -299,7 +300,9 @@ slashes/backslashes, authority syntax, userinfo, query-host tricks, and path tra The caller supplies no host/scheme/port. `redirect: "manual"`; every 3xx is an error. Strip `Host`, connection/hop-by-hop headers, machine auth headers, proxy credentials, cookies, and forwarding headers. Forward only the bounded management header allowlist, -including hub session, GUI-origin, CSRF, content type, and conditional cache headers. +including hub session, GUI-origin, CSRF, content type, and conditional cache headers. For +the exact `POST /opencodex-session` exchange, forward the browser's `Origin` value verbatim; +do not synthesize it from the hub URL, localhost bind, or GUI-origin header. Response headers are similarly allowlisted; `Set-Cookie` and hop-by-hop headers are never returned. Request and response bodies have named constants and abort on overflow. No URL query, auth header, body, or response body is logged. @@ -448,13 +451,13 @@ appearing after disconnect. | Test file | Required cases | |---|---| | `tests/client-machine-listener.test.ts` (NEW) | IPv4 loopback bind; GUI/bootstrap; exact allowlist; every `/v1/*` 404; unknown/wrong-method 404; safe GET auth; mutation Origin/CSRF; status redaction; sync success/failure; shim actions; disconnect offline; recycle to standalone; invalid state refuses startup; no provider/timer fake invoked. | -| `tests/client-hub-relay.test.ts` (NEW) | Relay disabled 404; direct mode 404; fixed host/path; direct/encoded traversal and authority injection rejected; redirects rejected; request/response caps; hop-by-hop/cookie/forwarded/machine headers stripped; hub auth retained; timeout/abort; no body/header log. | +| `tests/client-hub-relay.test.ts` (NEW) | Relay disabled 404; direct mode 404; fixed host/path; exact `POST /api/machine/hub-relay/opencodex-session` reaches only hub `POST /opencodex-session` and forwards browser `Origin` verbatim; direct/encoded traversal and authority injection rejected; redirects rejected; request/response caps; hop-by-hop/cookie/forwarded/machine headers stripped; hub auth retained; timeout/abort; no body/header log. | | `tests/cli-start-journal-order.test.ts` | Matching durable client journal survives start; missing/mismatched client owner restores; connected branch never starts full server; disconnected branch remains current. | | `tests/api-usage.test.ts` | Exact `apiKeyId` response/echo; old and other-key rows excluded; no match; combined filters; filtered request cannot poison cache; unfiltered next request remains whole hub. | | `tests/usage-summary.test.ts` | Pure projection totals/days/models/providers/accounts consistency; exact-case id; combo attempts; absent id; provider/model/key cross-product. | | `tests/core-lab-boundary.test.ts` | Protected roots import no client subsystem; `startServer` remains non-async and no new top-level-window await. | | `gui/tests/api-targets.test.ts` (NEW) | Standalone 404 fallback; valid direct/relay status; per-plane call bases; machine-status network failure not standalone; encoded relay paths. | -| `gui/tests/api-auth-memory.test.ts` | Two simultaneous sessions; direct headers; relay dual headers; machine custom stripping contract; cross-target 401 races; server/browser-origin mismatch; unknown target receives nothing; no web storage. | +| `gui/tests/api-auth-memory.test.ts` | Two simultaneous sessions; direct headers; relay dual headers; pasted pairing code exchanges through the selected shared target and stores only the returned shared session in memory; machine custom stripping contract; cross-target 401 races; server/browser-origin mismatch; unknown target receives nothing; no web storage. | | `gui/tests/api-auth-deadline.test.ts` | One target watchdog does not block/clear the other; direct and relay bootstrap timeout states. | | `gui/tests/usage-layout.test.ts` | Connected default key query; hub-wide omission; disconnected local query; source-qualified cache keys; hub-down no local fetch; scope labels/a11y. | | `gui/tests/app-stop.test.ts` | Standalone `/api/stop`; connected `/api/machine/disconnect`; refusal re-enables action; accepted recycle tolerates connection drop. | @@ -477,6 +480,7 @@ appearing after disconnect. | P4-A11 | Standalone full server opens the same GUI. | `/api/machine/status` 404 selects same-origin targets; all current pages, auth, stop, usage, and injector output remain unchanged. | | P4-A12 | Shared direct session 401s while machine session is renewed (and inverse). | Each target resolves/clears only its own in-memory state; no token crosses target and no prompt fan-out occurs. | | P4-A13 | GUI PR is prepared for review. | PR description uses the repository template and includes screenshots of connected this-machine Usage plus hub-offline machine shell (or a maintainer-approved `gui-screenshot-waived` exception). | +| P4-A14 | Relay-connected GUI has no hub session; user pastes a fresh Phase-2 pairing code and submits the pairing form. | `gui/src/connect-pairing.ts` sends the exact POST exchange through `/api/machine/hub-relay/opencodex-session`, the relay forwards browser `Origin` verbatim, and the returned session/CSRF/origins are stored only in the shared target's in-memory slot. | ## 9. Verification — remote only on `lidge-ai` diff --git a/devlog/_plan/260827_remote_hub/070_phase5_deploy.md b/devlog/_plan/260827_remote_hub/070_phase5_deploy.md index 78f64c1059..8b84ddf0fe 100644 --- a/devlog/_plan/260827_remote_hub/070_phase5_deploy.md +++ b/devlog/_plan/260827_remote_hub/070_phase5_deploy.md @@ -356,8 +356,8 @@ evidence; the sketch above is setup, not exact-head proof. ### 8.2 MacBook connect and remote session -1. On the hub, run `ocx gui pair` and copy the single-use, short-TTL code through the - interactive channel. Do not record it. +1. On the hub, run `ocx gui pair --origin http://localhost:10100` and copy the single-use, + short-TTL code through the interactive channel. Do not record it. 2. On the MacBook, run the Phase-3 connect command with exactly one transient `--pairing-code-stdin` or `--admin-token-stdin`; it must not accept a literal secret flag. 3. Assert `ocx connect status --json` reports protocol v1, hub URL, management URL, diff --git a/devlog/_plan/260827_remote_hub/080_phase6_hardening.md b/devlog/_plan/260827_remote_hub/080_phase6_hardening.md index f0d112cfbf..1ba9102bcf 100644 --- a/devlog/_plan/260827_remote_hub/080_phase6_hardening.md +++ b/devlog/_plan/260827_remote_hub/080_phase6_hardening.md @@ -16,9 +16,10 @@ the workstation. Full-suite execution is serialized with other `lidge-ai` suite - Recoverable per-client data-key rotation with a one-time secret response, bounded overlap, client-side atomic token-file replacement, explicit commit/abort, and stable `apiKeyId` usage attribution. -- Remote-session self-logout plus explicit operator data-key revocation from the hub GUI or - `ocx connect revoke` with an admin credential. Disconnect performs no hub-side revocation and - remains available while the hub is offline. +- Remote-session self-logout plus explicit operator data-key revocation from the hub GUI or, + only while connected, `ocx connect revoke` with an admin credential. Disconnect performs no + hub-side revocation, remains available while the hub is offline, and leaves the hub GUI as the + sole post-disconnect revocation path. - Pairing issuance/redemption limits, one-use semantics, bounded active state, and safe 429s. - Protocol negotiation matrix tests covering the v1 compatibility floor and feature detection. - Adversarial `/v1/catalog` consumer tests for decompressed size, malformed JSON, invalid schema, @@ -53,7 +54,7 @@ the workstation. Full-suite execution is serialized with other `lidge-ai` suite | Header-smuggling attempt | Hub request parser/proxy chain | Reject transfer-encoding, conflicting content-length, connection-nominated headers, CR/LF values, and upgrade paths. | | Protocol-skewed peer | Local client files / silent misroute | Negotiate before any local write; reject incompatible floors with explicit upgrade error; unknown features stay off. | | Rotation crash between hub and client | Client availability | Old and pending keys overlap for a bounded window; commit only after new-key probe; abort/expiry preserves old key. | -| Disconnected client whose data key still exists | Hub data admission | Disconnect changes local state only; an operator explicitly deletes the key in the hub GUI or runs `ocx connect revoke --admin-token-stdin`. | +| Disconnected client whose data key still exists | Hub data admission | Disconnect changes local state only and reminds the operator to delete the key from the hub GUI's **Integrations → API Keys** page; that GUI is the sole post-disconnect revocation path. | | Logs/evidence | Tokens, codes, identities | Record ids/prefixes/counts/status only; privacy scan; no raw secret, email, Origin query, request body, or account id. | Security level: ASVS L2 for the remote management/session surface. Applicable architecture, @@ -81,10 +82,11 @@ a second owner. | `src/server/management-api.ts` | MODIFY | Wire `handleSessionRoutes` and the narrow session-control dependency into `ManagementContext`. | | `src/server/management/context.ts` | MODIFY | Carry only the current-session logout interface, never the raw admin token or session map. | | `src/server/management-auth.ts` | MODIFY | Export a narrow current-session invalidation helper for explicit self-logout; preserve one shared auth predicate and add no key binding to pairing grants or sessions. | -| `src/client/connect.ts` | MODIFY | Implement `ocx connect rotate`: transient authority, start rotation, atomically replace token file, validate new key, commit, and restore+abort on failure. | +| `src/client/connect.ts` | MODIFY | Implement `ocx connect rotate`: transient authority, start rotation, atomically replace token file, validate new key, commit, and restore+abort on failure; allow `ocx connect revoke` only while connected and source its id solely from persisted `apiKeyId`. | | `src/client/hub-client.ts` | MODIFY (Phase-3 owner) | Add bounded rotation/revoke management calls, authenticated catalog key-id probe, and redacted errors beside the existing ready/key/catalog calls. | -| `src/client/state.ts` | MODIFY | Persist `pendingOperation: {kind:"rotate",newKeyIssuedAt,oldKeyBackupPath}` through the full backup/replace/probe/commit or recovery chain; never persist admin/pairing authority or old/new secret. | -| `src/cli/connect.ts` | MODIFY (Phase-3 owner) | Parse `rotate` and operator-only `revoke`, enforce exact stdin flags, reject literal/env secret forms, and render redacted recovery status. | +| `src/client/state.ts` | MODIFY | Validate and persist `pendingOperation: {kind:"rotate",rotationId,newKeyIssuedAt,oldKeyBackupPath}` through the full backup/replace/probe/commit or recovery chain; never persist admin/pairing authority or old/new secret. | +| `src/cli/connect.ts` | MODIFY (Phase-3 owner) | Parse `rotate` and connected-only operator `revoke`, enforce exact stdin flags, reject literal/env secret/id forms, and render redacted recovery status. | +| `src/lib/service-secrets.ts` | MODIFY (Phase-3 owner) | Own `.prev` creation and restoration as `writeTokenBackup` / `restoreTokenBackup`, reusing the token file's owner-only, regular-file, fsync, and atomic-replace rules. | | `src/cli/access.ts` | MODIFY | Add management-side `ocx access key rotate ` start/commit/abort UX with one-time secret warning; no literal secret flags. | | `src/cli/registry.ts` | MODIFY | Document rotation command shapes and transient-authority requirement. | | `gui/src/pages/ApiKeys.tsx` | MODIFY | Load pending status, start/commit/abort rotation, and render the new secret exactly once. | @@ -102,7 +104,8 @@ a second owner. | `tests/data-plane-admission-identity.test.ts` | MODIFY | Current and pending secret map to one id; expired/committed/aborted secrets do not admit. | | `tests/api-key-attribution.test.ts` | MODIFY | Traffic before/during/after rotation remains one `apiKeyId` bucket. | | `tests/server-management-auth.test.ts` | MODIFY | Explicit session self-logout, absence of key-bound grant/session invalidation, and admin-token consent refusal. | -| `tests/client-connect.test.ts` | MODIFY (Phase-3 owner) | Rotation/revoke parser flags, `.prev` crash recovery, pending-operation lifecycle, uncertain commit, and operator-only key deletion. | +| `tests/client-connect.test.ts` | MODIFY (Phase-3 owner) | Rotation/revoke parser flags, issued `apiKeyId` state chain, connected-only revoke/disconnected refusal, `.prev` crash recovery, pending-operation lifecycle, doubly-accepted commit, uncertain commit, and operator-only key deletion. | +| `tests/service-secrets.test.ts` | MODIFY (Phase-3 owner) | `writeTokenBackup` / `restoreTokenBackup` exact-path, owner-only mode/ACL, symlink/refusal, fsync/atomic replacement, and redacted failure cases. | | `gui/tests/apikeys-actions.test.tsx` | MODIFY | Start/commit/abort wire actions and one-time secret handling. | | `gui/tests/apikeys-mutation-timeout.test.tsx` | MODIFY | Rotation controls recover after bounded network failure. | | `gui/tests/apikeys-workspace.test.tsx` | MODIFY | Accessible rendered states and confirmations. | @@ -133,7 +136,7 @@ a second owner. | Path | Change | Exact responsibility | | --- | --- | --- | | `structure/01_runtime.md` | MODIFY | Final hub/client protocol, listener, catalog, and relay ownership map. | -| `structure/02_config-and-codex-home.md` | MODIFY | Client token-file ownership, rotation overlap, disconnect deletion, and no usage mirroring. | +| `structure/02_config-and-codex-home.md` | MODIFY | Client token-file ownership, rotation overlap, disconnect reminder plus hub-GUI-only post-disconnect revocation, and no usage mirroring. | | `structure/05_gui-and-management-api.md` | MODIFY | Final credential classes, issuance ladder, revocation, rate limits, origin/CSRF, and admin consent refusal. | | `structure/06_docs-and-release.md` | MODIFY | Correct the locale inventory and record the remote-hub release gate. | | `structure/09_client-integrations.md` | MODIFY | Remote connection journal/restore, direct data path, fixed relay, and launcher-scoped Claude behavior. | @@ -269,10 +272,26 @@ No response except successful start contains the pending secret. `ocx connect rotate` requires one transient `--pairing-code-stdin` or `--admin-token-stdin`; neither is persisted. It performs: +```ts +pendingOperation?: { + kind: "rotate"; + rotationId: string; + newKeyIssuedAt: string; + oldKeyBackupPath: string; +}; +``` + +The Phase-3 client-config reader validates all four fields before recovery can run. +`src/lib/service-secrets.ts` is the sole `.prev` I/O owner through +`writeTokenBackup` and `restoreTokenBackup`; the coordinator does not open, chmod, copy, +or replace the backup directly. + 1. Read current key id and current token into memory; write the old token to - `.prev` with the same owner-only 0600/ACL rules and fsync it. + `.prev` through `writeTokenBackup`, with the same owner-only 0600/ACL rules + and fsync it. 2. Start rotation; receive pending secret once, then persist - `pendingOperation: {kind:"rotate",newKeyIssuedAt,oldKeyBackupPath}` before replacement. + `pendingOperation: {kind:"rotate",rotationId,newKeyIssuedAt,oldKeyBackupPath}` before + replacement. 3. Write pending secret to a same-directory owner-only temp, harden it with the same `serviceApiTokenFilePath()` rules, fsync, and atomically replace the token file. 4. Probe authenticated `/v1/catalog` with the new key and verify the expected client key id via @@ -280,16 +299,19 @@ No response except successful start contains the pending secret. 5. Commit rotation. Commit invalidates old-key admission only; pairing grants and GUI sessions are not key-bound. An already-admitted in-flight turn may complete; the next old-key request is 401. After verified commit, delete `.prev` and clear `pendingOperation`. -6. If steps 2–5 fail before a confirmed commit, restore the old token atomically from - `.prev`, abort the pending rotation, then delete the backup and clear the operation. - If commit outcome is uncertain, probe with both keys: exactly one accepted result determines - the local file; never replay commit blindly. +6. If steps 2–5 fail before a confirmed commit, restore the old token atomically through + `restoreTokenBackup`, abort the pending rotation, then delete the backup and clear the + operation. If commit outcome is uncertain, probe with both keys. New+old both accepted means + issuance completed and overlap is still pending, so commit the new key with the stored + `rotationId`; new-only accepted means commit already took effect; old-only accepted restores + and aborts. Never replay commit without this evidence. On startup/status, `src/client/state.ts` treats a rotate `pendingOperation` as a recovery gate: verify that `oldKeyBackupPath` is exactly `.prev`, require an owner-only regular file, probe current and backup keys using the authenticated catalog key-id echo, then complete the same -commit-or-restore chain. Missing, unsafe, or doubly accepted/rejected evidence stops with an exact -recovery instruction and never deletes either candidate blindly. +commit-or-restore chain. Doubly accepted evidence commits the current new key with the persisted +`rotationId`, deletes `.prev`, and clears `pendingOperation`; doubly rejected, missing, or unsafe +evidence stops with an exact recovery instruction and never deletes either candidate blindly. The GUI exposes the same lifecycle for an operator updating a client manually, with explicit copy-once and commit-after-client-probe wording. Closing the modal does not imply commit; pending @@ -315,12 +337,13 @@ export function createManagementSessionControl( `POST /api/session/logout` requires `principal === "gui-session"`, same browser Origin, and CSRF. Admin-token calls return 403. -`ocx disconnect` performs local restore only and sends no hub revocation request. To revoke the -still-valid data key, an operator uses the hub GUI's existing key deletion or -`ocx connect revoke --admin-token-stdin`; the CLI requires the transient admin credential, -deletes the exact configured key id, and clears no local state unless deletion is confirmed. -Explicit GUI self-logout remains available independently. A hub-down disconnect succeeds locally -and reports that operator revocation remains outstanding. +`ocx connect revoke --admin-token-stdin` exists only while connected: it requires valid connected +state, reads the exact `apiKeyId` copied from issuance into that state, accepts no id override, and +uses the transient admin credential to delete that key. Disconnected, invalid, or mismatched state +fails before a hub request. `ocx disconnect` performs local restore only and sends no hub revocation +request; its output names the hub GUI's **Integrations → API Keys** page and reports that revocation +remains outstanding. Once disconnect clears client state, that hub GUI page is the sole revocation +path. Explicit GUI self-logout remains available independently. ## 5. Pairing rate limits @@ -486,9 +509,10 @@ Phase 6 extends them rather than creating parallel “hardening2” files. | second start | Existing unexpired pending rotation | 409; no third secret/state change. | | client commit | New key written + authenticated catalog succeeds | Pending promoted atomically; old next request 401; id/usage bucket stable; `.prev` deleted and pending operation cleared; sessions/grants unchanged. | | client write/probe fail | Fail backup/temp write, hardening, rename, or new-key probe | Old file restored/unchanged from `.prev`; pending aborted or expires; old key remains valid. | -| uncertain commit/crash | Drop commit response or restart with pending operation | Probe current+`.prev`; choose sole accepted key; finish commit-or-restore chain; no blind replay. | +| uncertain commit/crash | Drop commit response or restart with pending operation | Probe current+`.prev`; both accepted commits the current new key with stored `rotationId`, new-only finalizes committed state, old-only restores+aborts, and both rejected stops without deletion. | | pending expiry | Fake clock past 10 minutes | Pending rejected/removed; old accepted. | -| operator revoke | Hub GUI delete or `ocx connect revoke --admin-token-stdin` for configured id | Data-key admission revoked after persistence; sessions/grants unchanged; disconnect alone made no hub request. | +| connected operator revoke | Valid connected state + `ocx connect revoke --admin-token-stdin` | CLI reads the issuance-derived `apiKeyId` from state, accepts no id argument, and revokes that key; sessions/grants remain unchanged. | +| post-disconnect revoke | Disconnect clears local client state while its hub key remains | CLI revoke refuses before any request; output points to hub GUI **Integrations → API Keys**, the sole post-disconnect revocation path. | | self logout | GUI session + Origin + CSRF | Current session removed; replay 401. Admin-token call 403. | | pairing bad guesses | Same grant/source repeated with fake clock | Fifth grant failure burns; source threshold yields 429 + bounded Retry-After; no identity leak. | | pairing replay/race | Two concurrent valid redemptions | Exactly one session; other generic failure. | @@ -567,8 +591,8 @@ the same readiness report. - [ ] Per-client rotation is recoverable through `pendingOperation` + `.prev`, bounded, stable-id-attributed, secret-safe, and invalidates only old-key admission after commit. -- [ ] Session self-logout, local-only disconnect, and explicit operator revoke behavior are - distinct; admin-token consent remains 403. +- [ ] Session self-logout, local-only disconnect, connected-only CLI revoke, and hub-GUI-only + post-disconnect revoke are distinct; admin-token consent remains 403. - [ ] Pairing attempt and capacity state are bounded, deterministic under fake time, one-use under races, and privacy-safe. - [ ] Every protocol matrix row is reachable and proves no-write behavior before incompatibility. @@ -605,6 +629,8 @@ ssh lidge-ai "set -eu tests/data-plane-admission-identity.test.ts \ tests/api-key-attribution.test.ts \ tests/server-management-auth.test.ts \ + tests/client-connect.test.ts \ + tests/service-secrets.test.ts \ tests/remote-catalog.test.ts \ tests/client-hub-relay.test.ts \ tests/bounded-body.test.ts \ From a938448d306b271ea8f7511cb6514a1a7ef131ec Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 01:36:13 +0900 Subject: [PATCH 09/12] =?UTF-8?q?docs(devlog):=20fold=20roadmap=20audit=20?= =?UTF-8?q?r3=20=E2=80=94=20pairing=20form=20mount=20chain=20+=20orphan=20?= =?UTF-8?q?.prev=20crash-window=20recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_plan/260827_remote_hub/060_phase4_two_plane.md | 5 +++-- .../_plan/260827_remote_hub/080_phase6_hardening.md | 13 +++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md b/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md index b456ed32d9..1297d19d9d 100644 --- a/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md +++ b/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md @@ -105,7 +105,7 @@ parent exists. No generated `gui/dist` file is edited. | NEW | `gui/src/api-targets.ts` | Canonical `ApiTargets`, machine-status discovery, per-plane call-base selection, relay URL construction, and disconnected fallback. | | NEW | `gui/src/connect-pairing.ts` | Own the visible pairing-code form and activation flow: paste a one-time code, POST the exact `/opencodex-session` exchange through the selected direct/relay shared target, and install the returned session only in the shared target's in-memory auth slot. | | MODIFY | `gui/src/api.ts` | Replace `needsApiAuth`'s one same-origin slot with exact target classification and per-target in-memory session/CSRF state; attach both auth domains only on relay. | -| MODIFY | `gui/src/App.tsx` | Discover targets before page fetches; supply both call bases instead of one page base; machine health remains live when hub is down; connected stop becomes disconnect/recycle. | +| MODIFY | `gui/src/App.tsx` | Discover targets before page fetches; supply both call bases instead of one page base; machine health remains live when hub is down; connected stop becomes disconnect/recycle; import and MOUNT the `connect-pairing` form in the connected-without-hub-session state (banner slot above page content) so the pairing UI is reachable, not just defined. | | MODIFY | `gui/src/stop-proxy.ts` | Add mode-aware machine disconnect request while preserving existing standalone `/api/stop` behavior. | | MODIFY | `gui/src/pages/Startup.tsx` | Keep existing settings/startup-health/windows-tray/startup-action calls on the shared base; use the machine base only for new `/api/machine/*` status/shim sections. | | MODIFY | `gui/src/pages/Integrations.tsx` | Pass the shared base to all existing integration descendants, including ApiKeys and Grok; pass the machine base only to new local-client controls. | @@ -458,6 +458,7 @@ appearing after disconnect. | `tests/core-lab-boundary.test.ts` | Protected roots import no client subsystem; `startServer` remains non-async and no new top-level-window await. | | `gui/tests/api-targets.test.ts` (NEW) | Standalone 404 fallback; valid direct/relay status; per-plane call bases; machine-status network failure not standalone; encoded relay paths. | | `gui/tests/api-auth-memory.test.ts` | Two simultaneous sessions; direct headers; relay dual headers; pasted pairing code exchanges through the selected shared target and stores only the returned shared session in memory; machine custom stripping contract; cross-target 401 races; server/browser-origin mismatch; unknown target receives nothing; no web storage. | +| `gui/tests/connect-pairing.test.ts` (NEW) | RENDERED form test: connected-without-hub-session state mounts the pairing form from App; submitting a pasted code fires the exact POST exchange; success hides the form and populates the shared auth slot; failure renders the error state without clearing the input. | | `gui/tests/api-auth-deadline.test.ts` | One target watchdog does not block/clear the other; direct and relay bootstrap timeout states. | | `gui/tests/usage-layout.test.ts` | Connected default key query; hub-wide omission; disconnected local query; source-qualified cache keys; hub-down no local fetch; scope labels/a11y. | | `gui/tests/app-stop.test.ts` | Standalone `/api/stop`; connected `/api/machine/disconnect`; refusal re-enables action; accepted recycle tolerates connection drop. | @@ -480,7 +481,7 @@ appearing after disconnect. | P4-A11 | Standalone full server opens the same GUI. | `/api/machine/status` 404 selects same-origin targets; all current pages, auth, stop, usage, and injector output remain unchanged. | | P4-A12 | Shared direct session 401s while machine session is renewed (and inverse). | Each target resolves/clears only its own in-memory state; no token crosses target and no prompt fan-out occurs. | | P4-A13 | GUI PR is prepared for review. | PR description uses the repository template and includes screenshots of connected this-machine Usage plus hub-offline machine shell (or a maintainer-approved `gui-screenshot-waived` exception). | -| P4-A14 | Relay-connected GUI has no hub session; user pastes a fresh Phase-2 pairing code and submits the pairing form. | `gui/src/connect-pairing.ts` sends the exact POST exchange through `/api/machine/hub-relay/opencodex-session`, the relay forwards browser `Origin` verbatim, and the returned session/CSRF/origins are stored only in the shared target's in-memory slot. | +| P4-A14 | Relay-connected GUI has no hub session; user pastes a fresh Phase-2 pairing code and submits the pairing form. | The form is MOUNTED from `gui/src/App.tsx` in that state (rendered test `gui/tests/connect-pairing.test.ts`), `gui/src/connect-pairing.ts` sends the exact POST exchange through `/api/machine/hub-relay/opencodex-session`, the relay forwards browser `Origin` verbatim, and the returned session/CSRF/origins are stored only in the shared target's in-memory slot. | ## 9. Verification — remote only on `lidge-ai` diff --git a/devlog/_plan/260827_remote_hub/080_phase6_hardening.md b/devlog/_plan/260827_remote_hub/080_phase6_hardening.md index 1ba9102bcf..98375890ef 100644 --- a/devlog/_plan/260827_remote_hub/080_phase6_hardening.md +++ b/devlog/_plan/260827_remote_hub/080_phase6_hardening.md @@ -86,7 +86,7 @@ a second owner. | `src/client/hub-client.ts` | MODIFY (Phase-3 owner) | Add bounded rotation/revoke management calls, authenticated catalog key-id probe, and redacted errors beside the existing ready/key/catalog calls. | | `src/client/state.ts` | MODIFY | Validate and persist `pendingOperation: {kind:"rotate",rotationId,newKeyIssuedAt,oldKeyBackupPath}` through the full backup/replace/probe/commit or recovery chain; never persist admin/pairing authority or old/new secret. | | `src/cli/connect.ts` | MODIFY (Phase-3 owner) | Parse `rotate` and connected-only operator `revoke`, enforce exact stdin flags, reject literal/env secret/id forms, and render redacted recovery status. | -| `src/lib/service-secrets.ts` | MODIFY (Phase-3 owner) | Own `.prev` creation and restoration as `writeTokenBackup` / `restoreTokenBackup`, reusing the token file's owner-only, regular-file, fsync, and atomic-replace rules. | +| `src/lib/service-secrets.ts` | MODIFY (Phase-3 owner) | Own `.prev` creation, restoration, and orphan handling as `writeTokenBackup` / `restoreTokenBackup` / `removeOrphanTokenBackup`, reusing the token file's owner-only, regular-file, fsync, and atomic-replace rules. | | `src/cli/access.ts` | MODIFY | Add management-side `ocx access key rotate ` start/commit/abort UX with one-time secret warning; no literal secret flags. | | `src/cli/registry.ts` | MODIFY | Document rotation command shapes and transient-authority requirement. | | `gui/src/pages/ApiKeys.tsx` | MODIFY | Load pending status, start/commit/abort rotation, and render the new secret exactly once. | @@ -105,7 +105,7 @@ a second owner. | `tests/api-key-attribution.test.ts` | MODIFY | Traffic before/during/after rotation remains one `apiKeyId` bucket. | | `tests/server-management-auth.test.ts` | MODIFY | Explicit session self-logout, absence of key-bound grant/session invalidation, and admin-token consent refusal. | | `tests/client-connect.test.ts` | MODIFY (Phase-3 owner) | Rotation/revoke parser flags, issued `apiKeyId` state chain, connected-only revoke/disconnected refusal, `.prev` crash recovery, pending-operation lifecycle, doubly-accepted commit, uncertain commit, and operator-only key deletion. | -| `tests/service-secrets.test.ts` | MODIFY (Phase-3 owner) | `writeTokenBackup` / `restoreTokenBackup` exact-path, owner-only mode/ACL, symlink/refusal, fsync/atomic replacement, and redacted failure cases. | +| `tests/service-secrets.test.ts` | MODIFY (Phase-3 owner) | `writeTokenBackup` / `restoreTokenBackup` / `removeOrphanTokenBackup` exact-path, owner-only mode/ACL, symlink/refusal, fsync/atomic replacement, orphan crash-window cases (crash BEFORE marker persistence → orphan removed; crash AFTER → recovery gate runs), and redacted failure cases. | | `gui/tests/apikeys-actions.test.tsx` | MODIFY | Start/commit/abort wire actions and one-time secret handling. | | `gui/tests/apikeys-mutation-timeout.test.tsx` | MODIFY | Rotation controls recover after bounded network failure. | | `gui/tests/apikeys-workspace.test.tsx` | MODIFY | Accessible rendered states and confirmations. | @@ -292,6 +292,15 @@ or replace the backup directly. 2. Start rotation; receive pending secret once, then persist `pendingOperation: {kind:"rotate",rotationId,newKeyIssuedAt,oldKeyBackupPath}` before replacement. + + Crash window between steps 1 and 2: a `.prev` file with NO persisted `pendingOperation` + is an orphan duplicate of the still-active secret. Startup/status therefore checks the + inverse gate too: `.prev` present + no rotate `pendingOperation` → the rotation never + started on the hub, the live token file is authoritative — call + `removeOrphanTokenBackup` (owner-only unlink with the same symlink/regular-file + refusals) and log one redacted line. Activation scenario: kill the CLI between backup + write and marker persistence; next `ocx connect status` removes the orphan and reports + clean state (covered in tests/service-secrets.test.ts and tests/client-connect.test.ts). 3. Write pending secret to a same-directory owner-only temp, harden it with the same `serviceApiTokenFilePath()` rules, fsync, and atomically replace the token file. 4. Probe authenticated `/v1/catalog` with the new key and verify the expected client key id via From 5eb58b7e2cf72e207f310d4241ea702fd330b55e Mon Sep 17 00:00:00 2001 From: jun Date: Tue, 1 Sep 2026 16:41:41 +0900 Subject: [PATCH 10/12] fix(design): close five trust-boundary defects in the remote hub contract Rebased onto current dev and repaired the contract defects the review raised on the previous head. D1 - remove insecure-http-pairing. A reusable pairing grant crossing non-loopback plaintext HTTP is captured verbatim by a passive observer, and the config opt-in gating it could not bound the risk it recorded. The "bootstrap over HTTP then upgrade" variant is rejected too: the plaintext hop has no trust anchor, so an on-path attacker substitutes its own valid HTTPS origin and the upgrade authenticates the attacker. remoteGui.allowInsecureHttp is deleted and a persisted true is dropped with a warning. D2 - /v1/catalog emits no ETag and never answers 304. The response varies by key type and key id, so a shared strong validator lets a store revalidate one identity's representation for another; private, no-cache does not prevent storage, and the revalidation is what crosses identities. /api/catalog keeps its validator because it is loopback-scoped and identity-invariant. D3 - forward the browser Origin verbatim on every allowed session-authenticated request, not only POST /opencodex-session. The session is origin-bound and mutations enforce Origin/CSRF, so relayed writes were losing the evidence the hub requires. Synthesizing an Origin is refused: the relay would attest to something it never observed and the hub would validate the relay against itself. D4 - compare candidate identities before probing during rotation recovery. A crash after pendingOperation is persisted but before the token is replaced leaves both files holding the old key, where both probe successfully; the old "both accepted implies commit" rule read that as a completed rotation and lost the new key permanently. Identical candidates now mean pre-replacement: never commit, resume instead. Unconfirmed abort or restore retains evidence rather than installing a guessed generation. D5 - relayed session, bootstrap, and management responses are rewritten to no-store with ETag and Last-Modified stripped. Preserving an upstream validator reintroduced D2 one layer up, at exactly the position where an intermediary cache is most likely to sit. Also corrects the 000_research framing of the remote gui-session limitation. It is a deliberate fail-closed restriction already visible in shipped code and documented publicly, not an unreported weakness, so calling it a defect invited the wrong reading of what belongs in a public devlog. --- .../_plan/260827_remote_hub/000_research.md | 24 ++++++--- .../030_phase1_protocol_catalog.md | 45 +++++++++++------ .../040_phase2_remote_session.md | 48 +++++++++++++----- .../260827_remote_hub/060_phase4_two_plane.md | 23 +++++++-- .../260827_remote_hub/080_phase6_hardening.md | 50 ++++++++++++++++--- 5 files changed, 147 insertions(+), 43 deletions(-) diff --git a/devlog/_plan/260827_remote_hub/000_research.md b/devlog/_plan/260827_remote_hub/000_research.md index 18d5404bf6..6bbd687de3 100644 --- a/devlog/_plan/260827_remote_hub/000_research.md +++ b/devlog/_plan/260827_remote_hub/000_research.md @@ -17,14 +17,25 @@ the design must fix remote GUI operability without collapsing the consent bounda - Non-loopback bind forces the data token: `isApiAuthRequired` returns true whenever the bind hostname is not loopback (src/server/auth-cors.ts:260-262), and startup refuses a public bind without a configured data credential. -- The reported remote-GUI defect is real and structural: `issueGuiSession` returns null - when `isApiAuthRequired(config)` is true AND additionally requires a loopback Host - (src/server/management-auth.ts, issueGuiSession). So on a remote bind the principal - `gui-session` is unobtainable; consent-bearing routes that require +- The remote-GUI limitation is a deliberate restriction, not an unreported weakness, and + it is already visible in shipped public code: `issueGuiSession` returns null when + `isApiAuthRequired(config)` is true and additionally requires a loopback Host + (src/server/management-auth.ts, `issueGuiSession`). The published dashboard guide + states the same boundary in user terms. + + The consequence is a capability gap rather than an exposure: on a remote bind the + principal `gui-session` is unobtainable, so consent-bearing routes requiring `ctx.principal === "gui-session"` (src/server/management/sidebar-routes.ts:42, src/server/management/codex-prompt-routes.ts:298) answer 403 even to the admin token. - That 403 is BY DESIGN for the admin token (AGENTS.md user-consent boundary) — the defect - is only that a browser can never mint a session remotely. + That 403 is correct and stays correct — the admin token must never be able to spend the + user's consent (AGENTS.md user-consent boundary). What is missing is any path for a + *browser* to mint a session remotely, which is what this unit designs. + + Stated precisely: the current behavior fails closed. Nothing here describes a way to + obtain authority one should not have, so this note is a design rationale rather than + pre-disclosure material, and `AGENTS.md`'s scratch-space rule for unfixed defects does + not apply to it. Anything in this unit that WOULD describe an unfixed exploitable + weakness belongs in scratch space, not in `devlog/`. - `managementRequestOrigin` returns null for a non-loopback Host when apiAuth is NOT required (src/server/auth-cors.ts:118-129); when apiAuth IS required it derives the origin from the request, which a TLS terminator breaks (http observed vs https public). @@ -92,4 +103,3 @@ Browser platform facts (MDN/WHATWG/IETF, opened 2026-08-27): 4. localhost:10100 client GUI + direct-to-hub shared plane is cross-origin; the hub needs management CORS for an allowlisted client origin, or the client listener relays. Both appear in 010 with the relay constrained to a fixed target. - diff --git a/devlog/_plan/260827_remote_hub/030_phase1_protocol_catalog.md b/devlog/_plan/260827_remote_hub/030_phase1_protocol_catalog.md index 171b99a841..5ea1d99d5a 100644 --- a/devlog/_plan/260827_remote_hub/030_phase1_protocol_catalog.md +++ b/devlog/_plan/260827_remote_hub/030_phase1_protocol_catalog.md @@ -27,17 +27,31 @@ runtime behavior. All code paths remain standalone-compatible when `runtimeRole` changes only its source for hub deployments by preferring `hub.managementPublicOrigin`; the field and parser do not change. - Data-authenticated exact `GET /v1/catalog` returns the same serialized catalog bytes as - `GET /api/catalog`, with a strong ETag calculated from those bytes and conditional - `If-None-Match` support. + `GET /api/catalog`. It carries **no ETag and no conditional `If-None-Match` support**, + and never answers 304. + + A previous revision gave this response a strong ETag derived from the bytes plus + `Cache-Control: private, no-cache`, while also varying the body-adjacent + `x-opencodex-key-id` by identity. That pairing is unsafe: a strong validator asserts + that one entity-tag names one representation, but the representation here varies by + key type and key id. Any store that keys on URL plus validator — a shared intermediary, + a client cache reused across key rotation, a future hub relay — can serve or revalidate + one identity's representation to another. `no-cache` does not prevent storage; it only + forces revalidation, and the revalidation itself is what crosses identities. + + Making the validator safe would require an identity-partitioned cache key and validator + proven across every store in the path, including ones we do not control. That proof is + more expensive than the bandwidth a 304 saves on a catalog this size, so the design + does not attempt it. - `/v1/catalog` admits only the two forms used by the Codex injector contract: `x-opencodex-api-key: ` or `Authorization: Bearer `. `x-api-key`, a foreign bearer, the admin token, and no credential are rejected on a non-loopback bind. The row is added to `AUTH_MATRIX` with `xApiKey: "rejected"`. - A `/v1/catalog` response admitted by a configured key includes `x-opencodex-key-id` with - that key's id on both 200 and 304. Environment-token and loopback paths, plus rejected + that key's id on the 200. Environment-token and loopback paths, plus rejected and unauthenticated responses, never include this header. Revalidate the id at emission as `^[A-Za-z0-9._-]{1,64}$`; on mismatch omit the header and log one non-secret warning. - Every successful catalog response includes `Cache-Control: private, no-cache`. + Every successful catalog response includes `Cache-Control: no-store` and no validator. - A serialized catalog larger than `MAX_REMOTE_CATALOG_BYTES = 32 * 1024 * 1024` is not returned over `/v1/catalog`; it fails with HTTP 503 and the stable code `catalog_too_large`. The management route continues to expose the same serialized bytes @@ -124,15 +138,18 @@ One function reads the current catalog, serializes it exactly once with result. The test oracle compares the two route bodies byte-for-byte; it does not derive an expected body by calling the serializer twice. -The ETag is `"sha256-"` over the exact UTF-8 response bytes. A matching -strong tag, weak spelling of that same tag, a comma-list containing it, or `*` returns 304 -with ETag and no body. A stale/malformed `If-None-Match` returns 200. Both 200 and 304 carry -`Cache-Control: private, no-cache`. ETag is computed only after the 32 MiB bound passes. +`/api/catalog` keeps its byte-derived ETag and `If-None-Match` handling: that route is +management-authenticated, loopback-scoped, and its representation does not vary by data +key identity. + +`/v1/catalog` does not participate. It emits no ETag, ignores `If-None-Match`, never +returns 304, and carries `Cache-Control: no-store`. The two routes therefore share +serialization and the size bound, but not the validator. `/v1/catalog` uses `resolveResponsesApiAuth(req, policy)`, not `resolveApiAuth`, because the former is the existing dedicated-header/our-secret-Bearer matrix and rejects `x-api-key` (`src/server/auth-cors.ts:465-478`). It performs the existing data-plane origin check after -admission, then sets `x-opencodex-key-id` on 200 and 304 only when the admitted identity is +admission, then sets `x-opencodex-key-id` on the 200 only when the admitted identity is a configured API key and its id passes `^[A-Za-z0-9._-]{1,64}$` again at emission. A mismatch omits the header and emits one non-secret warning without the id. Environment-token, loopback, rejected, and unauthenticated paths never emit the header. No Direct passthrough @@ -150,13 +167,13 @@ All paths below exist in the current tree except the two files marked **NEW**. | NEW | `src/server/catalog-download.ts` | Own `MAX_REMOTE_CATALOG_BYTES`, one persisted-catalog serialization result, byte-derived ETag matching, `/api/catalog` response construction, and bounded `/v1/catalog` response construction. | | MODIFY | `src/server/management/model-routes.ts` | Replace the inline `/api/catalog` read/`JSON.stringify` block at lines 334–345 with the shared response builder; preserve 404 and `x-opencodex-codex-version`. | | MODIFY | `src/server/auth-cors.ts` | Add the `/v1/catalog` `AUTH_MATRIX` row with bearer/dedicated accepted and `xApiKey` rejected. Do not change any existing row or credential precedence. | -| MODIFY | `src/server/index.ts` | Add protocol metadata to the current `/readyz` body at lines 991–998 by passing `(config, req)` to the builder. Mount exact `GET /v1/catalog` after readiness/management handling and before the unknown-`/v1/*` guard at line 1604; use `resolveResponsesApiAuth` plus the existing origin policy, add `Cache-Control: private, no-cache`, and emit `x-opencodex-key-id` on 200/304 only for a configured-key identity whose id passes the emission-time header-safe guard. Omit and log once without the id on mismatch. Keep `startServer` synchronous and add no `await` between `Bun.serve` and `labActivationRequired`. | +| MODIFY | `src/server/index.ts` | Add protocol metadata to the current `/readyz` body at lines 991–998 by passing `(config, req)` to the builder. Mount exact `GET /v1/catalog` after readiness/management handling and before the unknown-`/v1/*` guard at line 1604; use `resolveResponsesApiAuth` plus the existing origin policy, add `Cache-Control: no-store` with no validator, and emit `x-opencodex-key-id` on the 200 only for a configured-key identity whose id passes the emission-time header-safe guard. Omit and log once without the id on mismatch. Keep `startServer` synchronous and add no `await` between `Bun.serve` and `labActivationRequired`. | | MODIFY | `src/server/proxy-liveness.ts` | Keep existing identity/status parsing additive; extend the internal body type/comments so protocol fields are recognized but do not make ordinary `ocx ready` reject a v0/legacy standalone server. Remote compatibility remains in `src/remote/protocol.ts`. | | MODIFY | `tests/config.test.ts` | Extend the existing config-default/validation sibling tests with absent/default, three valid roles, malformed live candidate, and malformed persisted role preservation cases. | | MODIFY | `tests/server-live.test.ts` | Extend the existing `GET /readyz` suite (lines 1220+) for exact protocol values on ready/pending/failed/draining, sanitized keys, management origin, method/path negatives, and no-auth behavior. | | MODIFY | `tests/proxy-liveness.test.ts` | Extend the current strict readiness parser/probe tests to prove additive protocol fields neither invalidate readiness nor bypass identity/status checks. | | MODIFY | `tests/api-catalog-route.test.ts` | Extend the existing `/api/catalog` sibling suite with fixed fixture bytes and version-header preservation after serializer extraction. | -| MODIFY | `tests/server-auth.test.ts` | Extend live-server auth/order coverage with `/v1/catalog` admission matrix, exact configured-key `x-opencodex-key-id` on 200 and 304, `Cache-Control: private, no-cache`, header absence for environment-token/loopback/rejected/unauthenticated paths, invalid-id omission with one id-free log, exact-path/method negatives, foreign/admin bearer rejection, bound overflow, ETag/304, and unknown-`/v1` guard preservation. | +| MODIFY | `tests/server-auth.test.ts` | Extend live-server auth/order coverage with `/v1/catalog` admission matrix, exact configured-key `x-opencodex-key-id` on the 200, `Cache-Control: no-store`, absence of `ETag`, a request carrying `If-None-Match` still receiving 200 with full bytes, header absence for environment-token/loopback/rejected/unauthenticated paths, invalid-id omission with one id-free log, exact-path/method negatives, foreign/admin bearer rejection, bound overflow, and unknown-`/v1` guard preservation. | | MODIFY | `tests/api-key-attribution.test.ts` | Extend the existing live `AUTH_MATRIX` loop so `/v1/catalog` is a GET route, each cell reaches the real handler rather than the generic 404, configured-key dedicated/Bearer admission echoes that key's id, and environment/loopback admission does not. | No other production or test file is in scope. If implementation proves another path is @@ -235,9 +252,9 @@ an empty catalog. No function accepts a caller-provided catalog path. | P1-A07 | Feed `{protocol: 2, minimumClientProtocol: 2}` to a protocol-v1 client. | `hub-too-new` and the exact “Upgrade ocx on this client” string are returned before catalog access. | | P1-A08 | Feed `{protocol: 1, minimumClientProtocol: 1}` to a protocol-v2 client requiring minimum hub protocol 2. | `hub-too-old` and the exact “Upgrade ocx on the hub” string are returned. | | P1-A09 | Supply zero, omit, mistype, overflow, set minimum above protocol, or give a path-bearing `managementUrl`. | The malformed-input class (`400`) returns `invalid` and the exact malformed-metadata string; it is never classified as a version mismatch and never falls back to protocol 1. | -| P1-A10 | Persist a fixed catalog fixture; call authorized `/api/catalog` and authorized `/v1/catalog`. | Status 200 and response bytes are byte-identical; the ETag independently hashes those bytes and `/v1/catalog` carries `Cache-Control: private, no-cache`. | -| P1-A11 | Repeat `/v1/catalog` with configured-key dedicated/Bearer admission, environment-token admission, loopback admission, `x-api-key`, foreign Bearer, admin token, and no token; inject an invalid configured key id, repeat the request, and capture logs. | Configured-key 200 and matching 304 carry the exact safe key id. Environment/loopback/rejected/unauthenticated responses omit it; invalid id is omitted with one non-secret warning and no key id appears in logs. No case reaches the generic unknown-route 404. | -| P1-A12 | Call `/v1/catalog` with matching tag, weak matching tag, tag list, `*`, stale tag, and malformed tag. | Matches return 304/no body/same ETag; stale or malformed values return 200/full bytes; every successful response carries `Cache-Control: private, no-cache`. | +| P1-A10 | Persist a fixed catalog fixture; call authorized `/api/catalog` and authorized `/v1/catalog`. | Status 200 and response bytes are byte-identical. `/api/catalog` carries its byte-derived ETag; `/v1/catalog` carries `Cache-Control: no-store` and no `ETag`. | +| P1-A11 | Repeat `/v1/catalog` with configured-key dedicated/Bearer admission, environment-token admission, loopback admission, `x-api-key`, foreign Bearer, admin token, and no token; inject an invalid configured key id, repeat the request, and capture logs. | The configured-key 200 carries the exact safe key id. Environment/loopback/rejected/unauthenticated responses omit it; invalid id is omitted with one non-secret warning and no key id appears in logs. No case reaches the generic unknown-route 404. | +| P1-A12 | Call `/v1/catalog` with a matching tag, weak matching tag, tag list, `*`, stale tag, and malformed tag in `If-None-Match`. | Every case returns 200 with the full bytes, no `ETag`, and `Cache-Control: no-store`: the route has no validator to match against, so no request can elicit a 304. The same tags against `/api/catalog` still return 304, proving the removal is scoped to the identity-varying route. | | P1-A13 | Serialize exactly the cap and cap+1 fixtures through an injected serialization seam. | Exact cap returns 200; cap+1 returns 503 `catalog_too_large`, never a partial body. | | P1-A14 | Call POST `/v1/catalog`, GET `/v1/catalog/`, and an unrelated `/v1/does-not-exist`. | Every request returns the existing JSON 404 envelope; route ordering does not widen path/method matching. | | P1-A15 | Run the import-graph and synchronous-window guard after the diff. | No new subsystem is reachable from the three protected core files; `startServer` remains non-async and its guarded window contains no top-level `await`. | diff --git a/devlog/_plan/260827_remote_hub/040_phase2_remote_session.md b/devlog/_plan/260827_remote_hub/040_phase2_remote_session.md index 7e93db79cb..0324291d6e 100644 --- a/devlog/_plan/260827_remote_hub/040_phase2_remote_session.md +++ b/devlog/_plan/260827_remote_hub/040_phase2_remote_session.md @@ -8,15 +8,35 @@ Unit: `260827_remote_hub` · Branch: `codex/remote-hub-design` · Phase: 2 · St ## 1. Outcome and non-negotiable boundary -Remote hub dashboards can obtain an origin-bound `gui-session` through one of four +Remote hub dashboards can obtain an origin-bound `gui-session` through one of three evidence paths, ordered from strongest automatic path to explicit opt-in: 1. `loopback` — current behavior, unchanged and fixed at five minutes. 2. `tailscale-identity` — trusted Tailscale Serve ingress plus exact `remoteGui.allowedTailscaleUsers` membership. -3. `pairing` — a short-lived, origin-bound, single-use grant printed by `ocx gui pair`. -4. `insecure-http-pairing` — the same grant over non-loopback HTTP only when - `remoteGui.allowInsecureHttp === true`. +3. `pairing` — a short-lived, origin-bound, single-use grant printed by `ocx gui pair`, + transmitted only over loopback or authenticated HTTPS. + +**There is no fourth path.** A previous revision of this document defined +`insecure-http-pairing`: the same reusable grant over non-loopback plaintext HTTP, +gated behind `remoteGui.allowInsecureHttp === true`. That path is removed, not +merely discouraged. + +Operator opt-in does not defeat a passive network observer or an on-path attacker. +A grant crossing plaintext HTTP is captured verbatim, and the session it mints is +reusable. An opt-in flag records that the operator accepted a risk they cannot +actually bound, so the flag was doing no security work. + +A "bootstrap over HTTP, then upgrade to HTTPS" variant was considered and rejected: +the plaintext hop has no trust anchor, so an on-path attacker substitutes its own +valid HTTPS origin and the upgrade authenticates the attacker. An upgrade is only +admissible when the HTTPS origin is already known to the client out of band, the +scheme upgrade stays on the same host, certificate validation is ordinary, and no +authority is derived from a redirect. + +Non-loopback plaintext HTTP therefore carries no grant, no session, no admin token, +and no client key. What it may carry is an unauthenticated error naming the required +scheme. Nothing else. The admin token remains an ordinary management principal. It cannot create a pairing grant, is never accepted by the session bootstrap/exchange endpoint, is never re-labeled as @@ -68,7 +88,7 @@ consumption of that credential is the only pairing exchange. - `GuiSessionRecord.serverOrigin` / `browserOrigin` split and full server/GUI consumer chain. - Config validation for `hub.managementPublicOrigin`, - `remoteGui.allowedTailscaleUsers`, and `remoteGui.allowInsecureHttp`. + and `remoteGui.allowedTailscaleUsers`. - Automatic loopback and trusted-Tailscale issuance; pairing grant creation and exchange. - Separate loopback/remote TTLs and sliding renewal for remote sessions. - Exact management CORS header widening for GUI-origin and CSRF headers. @@ -106,7 +126,6 @@ export interface OcxHubConfig { export interface OcxRemoteGuiConfig { allowedTailscaleUsers?: string[]; - allowInsecureHttp?: boolean; } export interface OcxConfig { @@ -124,8 +143,10 @@ Validation rules: - `remoteGui.allowedTailscaleUsers` contains at most 64 unique, trimmed, non-empty strings, each at most 320 UTF-8 bytes and containing no ASCII control character. Matching is exact after trim; no substring/domain matching. -- `remoteGui.allowInsecureHttp` is optional and defaults false. It affects pairing only; - tailscale-identity issuance still requires HTTPS. +- `remoteGui.allowInsecureHttp` no longer exists. A persisted `true` from a + pre-release tree is not honored: it is dropped with a warning naming the key, and + remote issuance continues under the loopback/HTTPS-only rule. A config key cannot + re-enable a transmission path this design removed. - A malformed live candidate is rejected with its full config path. A malformed persisted optional block degrades to remote issuance disabled while preserving providers, accounts, and API keys, and emits a diagnostic that never repeats the malformed value. @@ -142,7 +163,7 @@ export type GuiSessionIssuance = | "loopback" | "tailscale-identity" | "pairing" - | "insecure-http-pairing"; + ; export interface GuiSessionRecord { serverOrigin: string; @@ -239,8 +260,10 @@ memory-only and are never written to web storage. - strict body `{ "grant": "…" }`, 4 KiB maximum, unknown fields rejected. - requires an `Origin` matching the grant's `browserOrigin`; the grant is the only credential accepted. Admin/data/session credentials in headers do not substitute. - - HTTPS issues `pairing`; non-loopback HTTP issues `insecure-http-pairing` only when the - config opt-in is true. The grant is consumed before minting; all replays fail. + - Loopback and authenticated HTTPS issue `pairing`. A non-loopback plaintext HTTP + request is refused **before** the grant is read, so a captured request cannot even + consume the grant as a denial-of-service. The grant is consumed before minting; + all replays fail. - Phase 4's fixed-target relay path allowlist admits this exact POST exchange in addition to GET bootstrap and forwards the browser's `Origin` header verbatim; no other non-`/api/*` method/path is widened. @@ -438,7 +461,8 @@ sessions renew at most once per request. | P2-A07 | Call grant creation with admin token, GUI session, data key, wrong/replayed/expired capability, changed PID/port/origin, or an origin outside public origin/`corsAllowOrigins`. | 403/401 as appropriate; no grant/session state change. Admin authority cannot reach grant creation. | | P2-A08 | HTTPS POST bootstrap with fresh grant and exact `Origin`. | Grant is deleted and one `pairing` session is returned with both origin meta values. | | P2-A09 | Replay consumed grant; use expired grant, wrong Origin, wrong server destination, data key, admin token, or session token in place of grant. | No session for every case; replay and alternate credentials cannot enter the exchange branch. | -| P2-A10 | Non-loopback HTTP pairing with opt-in absent/false, then true. | False path refuses without consuming into a session; true path consumes once and issues `insecure-http-pairing`. Automatic Tailscale issuance remains refused on HTTP in both cases. | +| P2-A10 | Non-loopback HTTP pairing exchange, with and without a legacy persisted `remoteGui.allowInsecureHttp: true`. | Refused in both cases, before the grant is read, so the grant survives for a later HTTPS exchange. The legacy key is dropped with a warning and grants nothing. Automatic Tailscale issuance remains refused on HTTP. | +| P2-A11 | Plaintext HTTP request for the session bootstrap on a non-loopback bind. | Response carries no grant, session, admin token, or client key — only an unauthenticated error naming the required scheme. | | P2-A11 | Authorized remote safe read immediately before expiry. | Full origin predicate passes and expiry slides to `now + 12h`; token/CSRF unchanged. | | P2-A12 | Wrong destination, wrong claimed browser origin, wrong browser `Origin`, absent/wrong CSRF mutation, and an expired session. | 401 and expiry remains unchanged/deleted as applicable; principal is never projected as `gui-session`. | | P2-A13 | Admin token calls ordinary management, pairing-grant creation, bootstrap exchange, and then a consent route; valid remote GUI session calls ordinary/consent routes with correct CSRF. | Admin remains ordinary management-capable but the other three are refused; remote session reaches consent route. No admin-to-grant or admin-to-session exchange exists. | diff --git a/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md b/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md index 1297d19d9d..83d0006b27 100644 --- a/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md +++ b/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md @@ -300,9 +300,24 @@ slashes/backslashes, authority syntax, userinfo, query-host tricks, and path tra The caller supplies no host/scheme/port. `redirect: "manual"`; every 3xx is an error. Strip `Host`, connection/hop-by-hop headers, machine auth headers, proxy credentials, cookies, and forwarding headers. Forward only the bounded management header allowlist, -including hub session, GUI-origin, CSRF, content type, and conditional cache headers. For -the exact `POST /opencodex-session` exchange, forward the browser's `Origin` value verbatim; -do not synthesize it from the hub URL, localhost bind, or GUI-origin header. +including hub session, GUI-origin, CSRF, and content type. + +Forward the browser's `Origin` value **verbatim on every allowed session-authenticated +request**: the `POST /opencodex-session` exchange, the `GET` bootstrap, and every allowed +`/api/` method including `POST`, `PUT`, `PATCH`, and `DELETE`. Never synthesize it from +the hub URL, the localhost bind, or the GUI-origin header, and never omit it. + +A previous revision forwarded `Origin` only for the exact `POST /opencodex-session` +exchange. That is both a functional and a security defect. The minted GUI session is +origin-bound and management mutations enforce Origin/CSRF, so a relayed mutation arriving +without `Origin` loses the evidence the hub requires and is refused — the relay silently +breaks every write path it is supposed to carry. Repairing that by synthesizing an +`Origin` would be worse: the relay would be attesting to a fact it did not observe, and +the hub's CSRF check would be validating the relay against itself. The browser value is +the only admissible source, so it is forwarded unchanged or the request does not go. + +When the browser sends no `Origin` on a request that requires it, the relay refuses +rather than inventing one. Response headers are similarly allowlisted; `Set-Cookie` and hop-by-hop headers are never returned. Request and response bodies have named constants and abort on overflow. No URL query, auth header, body, or response body is logged. @@ -472,6 +487,8 @@ appearing after disconnect. | P4-A2 | Request every known data/shared route on the machine listener. | Every `/v1/*`, `/api/config`, `/api/usage`, OAuth/provider/Lab path is JSON 404; only explicit machine routes/assets answer. | | P4-A3 | GET status/clients with valid loopback GUI session, then without it. | Valid request returns redacted DTO; missing/admin-only/expired/wrong-origin session is rejected and no secret/fingerprint leaks. | | P4-A4 | POST sync/shim/disconnect with valid session but missing/wrong CSRF or browser Origin. | Mutation is rejected before work; exact session+Origin+CSRF reaches the handler. Admin token never becomes GUI session. | +| P4-A4b | Relay every allowed session-authenticated method — GET bootstrap, POST `/opencodex-session`, and `/api/` POST, PUT, PATCH, DELETE — from a browser origin the hub allows. | Each request arrives at the hub carrying the browser's `Origin` byte-for-byte. No case is missing `Origin`, and no case carries a value the browser did not send. | +| P4-A4c | Relay an allowed mutation whose browser request has no `Origin`. | The relay refuses without contacting the hub, and does not synthesize an `Origin` from the hub URL, the localhost bind, or the GUI-origin header. | | P4-A5 | Connected direct transport with Phase-2 session issued by an exact `remoteGui.allowedTailscaleUsers` match or a consumed pairing grant; hub CORS includes the localhost browser origin. | Shared requests go directly to exact hub origin with hub headers only; machine pages remain localhost; CORS/session validation succeeds. A non-allowlisted Tailscale identity mints no session and gets no local fallback. | | P4-A6 | Connected relay transport and valid machine + hub sessions. | Browser sends dual auth domains; relay validates machine session, strips custom headers, forwards hub session only to fixed hub target, and rejects redirect/SSRF variants. | | P4-A7 | Relay path requested while transport is direct/disabled or caller supplies host/scheme/traversal. | Default 404/refusal before outbound fetch; no credential or body is logged. | diff --git a/devlog/_plan/260827_remote_hub/080_phase6_hardening.md b/devlog/_plan/260827_remote_hub/080_phase6_hardening.md index 98375890ef..69daa58b47 100644 --- a/devlog/_plan/260827_remote_hub/080_phase6_hardening.md +++ b/devlog/_plan/260827_remote_hub/080_phase6_hardening.md @@ -316,11 +316,34 @@ or replace the backup directly. and aborts. Never replay commit without this evidence. On startup/status, `src/client/state.ts` treats a rotate `pendingOperation` as a recovery gate: -verify that `oldKeyBackupPath` is exactly `.prev`, require an owner-only regular file, -probe current and backup keys using the authenticated catalog key-id echo, then complete the same -commit-or-restore chain. Doubly accepted evidence commits the current new key with the persisted -`rotationId`, deletes `.prev`, and clears `pendingOperation`; doubly rejected, missing, or unsafe -evidence stops with an exact recovery instruction and never deletes either candidate blindly. +verify that `oldKeyBackupPath` is exactly `.prev` and require an owner-only regular +file. + +**Compare the two candidates' identities before probing anything.** If the live token and +`.prev` carry the same identity, the process stopped after `pendingOperation` was persisted +but before the token was replaced. Both candidates are the old key, so both probe +successfully — and a "both accepted" rule would read that as a completed issuance and commit +a rotation that never happened, permanently losing the new key. Identical candidates +therefore mean pre-replacement: never commit, restore nothing, and resume the rotation from +the beginning. + +Only when the candidates differ does probing decide anything, and only a confirmed authority +may act: + +- New and old both accepted: issuance completed, overlap still pending. Commit the new key + with the stored `rotationId`. +- New only: commit already took effect. Clear the operation. +- Old only: issuance did not take effect. Restore and abort. +- Neither accepted, a probe that fails for a reason other than rejection, or any state the + above does not name: stop with an exact recovery instruction. Delete nothing. + +If an abort or restore itself fails, the uncertainty is retained rather than papered over: +keep both candidates and the `pendingOperation` record, and report which step could not be +confirmed. A restore performed without confirmed authority can install the wrong generation +and is worse than stopping. + +A concurrent `ocx connect status` never deletes a backup belonging to an in-flight rotation. +Recovery only acts on a `pendingOperation` it can prove is abandoned. The GUI exposes the same lifecycle for an operator updating a client manually, with explicit copy-once and commit-after-client-probe wording. Closing the modal does not imply commit; pending @@ -497,8 +520,18 @@ the pure validator with raw tuples for otherwise-unconstructible header shapes. - Rebuild response headers and strip hop-by-hop headers, `Set-Cookie`, proxy auth, server identity headers, Tailscale identity, and connection-nominated headers. -- Preserve safe content type, cache control, ETag, retry-after, and approved CORS/session bootstrap - metadata only. +- Preserve safe content type, retry-after, and approved CORS/session bootstrap metadata only. +- Relayed session, bootstrap, and management responses are rewritten to + `Cache-Control: no-store` and have `ETag` and `Last-Modified` removed. The relay does + not pass an upstream validator through, and does not honor a conditional request against + one. + + A previous revision preserved upstream `cache control` and `ETag` on relayed responses. + That reintroduces the Phase-1 defect one layer up: relayed responses vary by hub session + and client identity, so a preserved strong validator lets a store revalidate one + identity's representation for another — and the relay sits in exactly the position where + an intermediary cache is most likely to exist. The relay is not the right place to prove + an identity-partitioned cache key, so it does not carry a validator at all. - Enforce Phase-4 management body caps. Phase-6 streaming uses backpressure and abort propagation; it must not buffer an unbounded response or continue after browser disconnect. - Errors name only status/category and fixed hub label. No destination URL query, session token, @@ -684,3 +717,6 @@ the matching CI partition before classifying it; never call a red result environ Final live evidence runs on `clisu-oracle`/MacBook per 070 §8 after the remote gates. It proves health, readiness, authenticated catalog, one routed response, remote session, consent refusal, rotation, usage slice, disconnect/local store, rollback, and both constructible protocol directions. +| P6-A20 | Persist `pendingOperation`, then stop before the token is replaced so the live token and `.prev` hold the same key. Restart. | Recovery detects identical candidates before probing, refuses to commit, leaves both files intact, and resumes the rotation. The pre-fix "both probes accepted implies commit" rule is what this row exists to keep dead. | +| P6-A21 | Rotation reaches installed-new-token state, then the abort request fails transiently. | Neither candidate is deleted and `pendingOperation` survives with the unconfirmed step named. No generation is restored on unconfirmed authority. | +| P6-A22 | Run `ocx connect status` while `rotateConnectedClientKey` is awaiting `/api/keys/rotate`. | The in-flight `.prev` backup is not deleted and the rotation completes normally. | From 2da2d734d7b9c5327e658e8d7321005d3ec9aed6 Mon Sep 17 00:00:00 2001 From: jun Date: Tue, 1 Sep 2026 16:48:08 +0900 Subject: [PATCH 11/12] fix(design): finish removing insecure-http pairing across the unit The previous commit closed D1 where the review pointed (040) but left the enabling contract alive in three other documents: 010 still listed insecure-http pairing as evidence rung 4, 050 still defined the client --allow-insecure-http option plus an allowInsecureHttp field on the exchange call, and 070 still named remoteGui.allowInsecureHttp as a Phase-2 key. A contract that removes a path in one document and specifies it in three others is not a fix; an implementer reading 050 would have built the option. 050's "both sides must opt in" rationale is also removed rather than reworded. Requiring two opt-ins makes the choice deliberate, but deliberateness is not the control that matters here: the grant is still readable by anything on the path and the session it mints is still reusable. The client now refuses before transmission instead of warning after it. 010 additionally records why the "don't over-harden" valve does not need this path: tailscale serve terminates HTTPS for exactly that deployment, so rung 3 already covers the private-tailnet sole-operator case the valve was for. P3-A3 is rewritten from "succeeds with explicit warning" to refusal in every combination, including a tree still carrying the legacy CLI argument or a persisted config key. --- devlog/_plan/260827_remote_hub/010_design.md | 16 +++++++++--- .../260827_remote_hub/050_phase3_connect.md | 26 ++++++++++++------- .../260827_remote_hub/070_phase5_deploy.md | 5 ++-- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/devlog/_plan/260827_remote_hub/010_design.md b/devlog/_plan/260827_remote_hub/010_design.md index 76099d4e30..42365a96b7 100644 --- a/devlog/_plan/260827_remote_hub/010_design.md +++ b/devlog/_plan/260827_remote_hub/010_design.md @@ -112,10 +112,18 @@ Issuance ladder (config-selected, strictest first): session. On a shared tailnet, an empty allowlist means nobody mints remotely. 3. pairing — `ocx gui pair` on the hub prints a single-use, short-TTL, origin-bound grant that can only mint a session. For generic HTTPS terminators. -4. insecure-http pairing (documented opt-in: `remoteGui.allowInsecureHttp`) — the SAME - single-use pairing grant as rung 3, allowed to travel over a plain-HTTP tailnet - origin. This is the "don't over-harden" valve the user asked for: private tailnet, - sole operator → run `ocx gui pair` once on the hub, paste the code, GUI works. +4. ~~insecure-http pairing~~ — REMOVED. An earlier revision let the rung-3 grant travel + over a plain-HTTP tailnet origin behind `remoteGui.allowInsecureHttp`, as the + "don't over-harden" valve for a private tailnet with a sole operator. A reusable + grant on plaintext HTTP is captured verbatim by anything with tailnet reach, and an + opt-in flag records a risk the operator cannot actually bound, so the flag was doing + no security work. A private tailnet is not a private wire. + + The valve the user asked for is served by rung 3 over `tailscale serve`, which + terminates HTTPS for exactly this deployment and needs no plaintext hop. Non-loopback + plaintext HTTP now carries no grant, session, admin token, or client key — only an + unauthenticated error naming the required scheme. + Audit note (blocker 1, folded): the earlier "trusted-tailnet" variant that minted sessions from Host/Origin alone is DROPPED — headers are forgeable by anything with TCP reach, so it would have granted consent routes with zero credential, strictly diff --git a/devlog/_plan/260827_remote_hub/050_phase3_connect.md b/devlog/_plan/260827_remote_hub/050_phase3_connect.md index 97628e74c7..adfa817e56 100644 --- a/devlog/_plan/260827_remote_hub/050_phase3_connect.md +++ b/devlog/_plan/260827_remote_hub/050_phase3_connect.md @@ -304,7 +304,7 @@ export function exchangeConnectPairingGrant( managementUrl: string, browserOrigin: string, grant: Uint8Array, - options?: { allowInsecureHttp?: boolean; timeoutMs?: number; fetchImpl?: typeof fetch }, + options?: { timeoutMs?: number; fetchImpl?: typeof fetch }, ): Promise; export function issueClientKey( managementUrl: string, @@ -325,12 +325,19 @@ export function downloadClientCatalog( through `checkRemoteProtocolCompatibility()`; it does not define a second protocol shape, constants, or mismatch strings. Both URLs accept only `http:`/`https:`, reject credentials/query/hash and non-root paths -(a terminal `/v1` input normalizes to the server origin). Admin credentials may be sent -only over HTTPS. A pairing grant may use HTTP only when the caller explicitly supplied -`--allow-insecure-http`; the Phase-2 hub independently requires -`remoteGui.allowInsecureHttp === true`, so both sides must opt in. Redirects are -rejected. Bodies and timeouts are bounded. Errors carry status and safe code, never -response/header secrets. +(a terminal `/v1` input normalizes to the server origin). + +No credential travels over non-loopback plaintext HTTP. That covers the admin +credential, the pairing grant, the resulting session, and the issued client key alike. +An earlier revision let a grant use HTTP when the caller passed +`--allow-insecure-http` and the hub set `remoteGui.allowInsecureHttp === true`, on the +theory that requiring both sides to opt in made it deliberate. Deliberateness is not the +control that matters: the grant is still readable by anything on the path, and the +session it mints is reusable. Both the flag and the CLI option are removed, and the +client refuses before transmission rather than warning after it. + +Redirects are rejected. Bodies and timeouts are bounded. Errors carry status and safe +code, never response/header secrets. `POST /api/keys` remains the exact key authority. The request body is only a validated, bounded `name`; admin uses the ordinary management header. Pairing uses strict @@ -354,7 +361,6 @@ export interface ConnectOptions { selectedClients: OcxConnectedClientId[]; managementTransport: "direct" | "relay"; noSync?: boolean; - allowInsecureHttp?: boolean; } export interface ClientConnectDeps { @@ -382,7 +388,7 @@ ocx connect [--management-url ] [--pairing-code-stdin | --admin-token-stdin] [--clients codex,claude] [--management-transport direct|relay] - [--allow-insecure-http] [--no-sync] + [--no-sync] ocx connect status [--json] ocx disconnect [--keep-catalog] [--json] ``` @@ -524,7 +530,7 @@ No test sends live hub traffic or reads the developer's homes. |---|---|---| | P3-A1 | Disconnected temp home; HTTPS hub ready on protocol 1; admin token arrives through stdin; `/api/keys` and `/v1/catalog` succeed. | One key is issued, token exists only in owner-only service file, catalog and injection commit, and `runtimeRole=client` + `config.client` are written together and last with key id/fingerprint only. | | P3-A2 | Same as A1, but a Phase-2 pairing grant bound to the future localhost GUI origin is supplied. | Grant is consumed once at `/opencodex-session`; returned GUI session + CSRF performs exact key POST; raw grant on `/api/keys` and replay fail; no transient credential persists. | -| P3-A3 | HTTP management URL with pairing grant, client `--allow-insecure-http`, and hub `remoteGui.allowInsecureHttp=true`. | Exchange/key issuance succeeds with explicit warning. Missing either opt-in refuses; admin credential over HTTP refuses before credential transmission. | +| P3-A3 | Non-loopback HTTP management URL with a pairing grant, including a tree that still carries a legacy `--allow-insecure-http` argument or a persisted `remoteGui.allowInsecureHttp: true`. | Refused before any credential is transmitted, in every combination. The removed CLI option is rejected as unknown rather than silently accepted, and the legacy config key grants nothing. The admin credential over HTTP is likewise refused before transmission. | | P3-A4 | `/readyz` returns `{protocol: 2, minimumClientProtocol: 2}` or status pending/failed. | Clear upgrade/not-ready error; zero key POSTs and zero local writes. | | P3-A5 | Key POST returns 401/403/409 or malformed/oversized JSON. | No token/catalog/journal/config writes and no secret in diagnostics. | | P3-A6 | Token, catalog, injector preflight, inject commit, or final role+state commit is fault-injected in turn. | Prior machine bytes are restored at every point; neither `runtimeRole=client` nor visible `client` remains; remote orphan cleanup status is explicit by safe key id only. | diff --git a/devlog/_plan/260827_remote_hub/070_phase5_deploy.md b/devlog/_plan/260827_remote_hub/070_phase5_deploy.md index 8b84ddf0fe..789850d9b2 100644 --- a/devlog/_plan/260827_remote_hub/070_phase5_deploy.md +++ b/devlog/_plan/260827_remote_hub/070_phase5_deploy.md @@ -97,8 +97,9 @@ reused and verified, not copied. ### 3.1 Config keys -Phase 1 owns `runtimeRole`; Phase 2 owns `hub.managementPublicOrigin`, -`remoteGui.allowedTailscaleUsers`, and `remoteGui.allowInsecureHttp`. Phase 5 adds only: +Phase 1 owns `runtimeRole`; Phase 2 owns `hub.managementPublicOrigin` and +`remoteGui.allowedTailscaleUsers`. (`remoteGui.allowInsecureHttp` was removed from the +Phase-2 contract; a persisted `true` grants nothing.) Phase 5 adds only: ```ts export interface OcxHubConfig { // existing Phase-2 interface, shown extended From 4080bdb3402b7ba12e8c337c3768d888224c98fa Mon Sep 17 00:00:00 2001 From: jun Date: Tue, 1 Sep 2026 16:55:23 +0900 Subject: [PATCH 12/12] fix(design): close the review findings the first D-fix pass left open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial review of dfae1da61 found four contract defects still live and one bookkeeping error. All are repaired here. D2 was fixed in Phase 1 but not in the phases that consume it. 030 still described ETag/If-None-Match as "shared" between the two catalog routes, and 050/080 still specified a client that persists an ETag, sends If-None-Match, and handles 304 — against a route that no longer emits a validator. An implementer following Phase 3 would have rebuilt exactly what Phase 1 deleted. The client now fetches unconditionally, and an unsolicited 304 is a protocol error rather than a cache hit, since the client never issued a conditional request. D3 overcorrected. Saying Origin must never be omitted contradicted the Phase-2 predicate, which deliberately allows Origin-absent safe GET/HEAD reads. The two rules are now separated: forwarding is verbatim whenever the browser sends a value, while requiring Origin stays the hub predicate's decision. The relay refuses only where it would otherwise have to invent a value. The owner test row now names each mutation method plus both Origin-absent branches, instead of testing only the pairing exchange. D4 stated the new identity-comparison rule while leaving the old "both accepted implies commit" rule intact three paragraphs above and in the activation matrix, so the document contradicted itself on the exact point the review raised. The obsolete text is replaced rather than supplemented. The recovery outcome is also made executable: the new secret is returned once and startup/status holds no management authority, so recovery cannot "resume" anything — it stops with evidence intact, and the next rotate, which does carry transient authority, confirms the stranded rotationId's abort before starting over. Bookkeeping: the earlier pass introduced a duplicate P2-A11 and appended three P6 rows after the verification section instead of into the activation matrix. The plaintext-bootstrap row is renumbered P2-A21 and the rotation rows are folded into the matrix, replacing the stale uncertain-commit row. --- .../030_phase1_protocol_catalog.md | 5 +- .../040_phase2_remote_session.md | 2 +- .../260827_remote_hub/050_phase3_connect.md | 13 ++--- .../260827_remote_hub/060_phase4_two_plane.md | 27 +++++++--- .../260827_remote_hub/080_phase6_hardening.md | 52 +++++++++++++------ 5 files changed, 66 insertions(+), 33 deletions(-) diff --git a/devlog/_plan/260827_remote_hub/030_phase1_protocol_catalog.md b/devlog/_plan/260827_remote_hub/030_phase1_protocol_catalog.md index 5ea1d99d5a..25ea959abc 100644 --- a/devlog/_plan/260827_remote_hub/030_phase1_protocol_catalog.md +++ b/devlog/_plan/260827_remote_hub/030_phase1_protocol_catalog.md @@ -65,8 +65,9 @@ runtime behavior. All code paths remain standalone-compatible when `runtimeRole` - Protocol-v1 metadata in every ready/pending/failed `/readyz` body. - A parser/compatibility predicate for future `ocx connect`, including additive-field tolerance for a dev hub paired with the latest released client. -- Shared catalog serialization, ETag, `If-None-Match`, size cap, data-plane admission, and - configured-key-only `x-opencodex-key-id` attribution. +- Shared catalog serialization, size cap, data-plane admission, and configured-key-only + `x-opencodex-key-id` attribution. The byte-derived ETag and `If-None-Match` handling + belong to `/api/catalog` alone; `/v1/catalog` has no validator (§ above). - Route placement before the unknown-`/v1/*` JSON-404 guard. - Focused and full remote-only verification commands. diff --git a/devlog/_plan/260827_remote_hub/040_phase2_remote_session.md b/devlog/_plan/260827_remote_hub/040_phase2_remote_session.md index 0324291d6e..14d23c0842 100644 --- a/devlog/_plan/260827_remote_hub/040_phase2_remote_session.md +++ b/devlog/_plan/260827_remote_hub/040_phase2_remote_session.md @@ -462,7 +462,6 @@ sessions renew at most once per request. | P2-A08 | HTTPS POST bootstrap with fresh grant and exact `Origin`. | Grant is deleted and one `pairing` session is returned with both origin meta values. | | P2-A09 | Replay consumed grant; use expired grant, wrong Origin, wrong server destination, data key, admin token, or session token in place of grant. | No session for every case; replay and alternate credentials cannot enter the exchange branch. | | P2-A10 | Non-loopback HTTP pairing exchange, with and without a legacy persisted `remoteGui.allowInsecureHttp: true`. | Refused in both cases, before the grant is read, so the grant survives for a later HTTPS exchange. The legacy key is dropped with a warning and grants nothing. Automatic Tailscale issuance remains refused on HTTP. | -| P2-A11 | Plaintext HTTP request for the session bootstrap on a non-loopback bind. | Response carries no grant, session, admin token, or client key — only an unauthenticated error naming the required scheme. | | P2-A11 | Authorized remote safe read immediately before expiry. | Full origin predicate passes and expiry slides to `now + 12h`; token/CSRF unchanged. | | P2-A12 | Wrong destination, wrong claimed browser origin, wrong browser `Origin`, absent/wrong CSRF mutation, and an expired session. | 401 and expiry remains unchanged/deleted as applicable; principal is never projected as `gui-session`. | | P2-A13 | Admin token calls ordinary management, pairing-grant creation, bootstrap exchange, and then a consent route; valid remote GUI session calls ordinary/consent routes with correct CSRF. | Admin remains ordinary management-capable but the other three are refused; remote session reaches consent route. No admin-to-grant or admin-to-session exchange exists. | @@ -473,6 +472,7 @@ sessions renew at most once per request. | P2-A18 | Run `ocx gui pair --origin ` with an explicit allowed origin, then missing `--origin`, malformed/disallowed origin, `--json`, extra args, stale target, failed attestation, and capability/API failure. | Valid cases create one grant and print once; every absent/invalid origin fails with no default and without falling back to admin auth or echoing secrets; no grant appears in argv/config/disk/log fixtures. | | P2-A19 | Run native-profile mutation suite with admin, malformed remote session, and valid remote session. | Existing consent boundary remains: only the valid session + correct origin + CSRF dispatches the mutation. | | P2-A20 | Run import-graph and synchronous-window guard. | No protected core import reaches GUI-session code; `startServer` remains synchronous and activation ordering is unchanged. | +| P2-A21 | Plaintext HTTP request for the session bootstrap on a non-loopback bind. | Response carries no grant, session, admin token, or client key — only an unauthenticated error naming the required scheme. | ## 10. Verification — remote only on `lidge-ai` diff --git a/devlog/_plan/260827_remote_hub/050_phase3_connect.md b/devlog/_plan/260827_remote_hub/050_phase3_connect.md index adfa817e56..6eaec757f8 100644 --- a/devlog/_plan/260827_remote_hub/050_phase3_connect.md +++ b/devlog/_plan/260827_remote_hub/050_phase3_connect.md @@ -317,8 +317,8 @@ export function issueClientKey( export function downloadClientCatalog( serverUrl: string, admissionToken: string, - options?: { etag?: string; timeoutMs?: number; maxBytes?: number; fetchImpl?: typeof fetch }, -): Promise<{ kind: "fresh"; body: string; etag?: string } | { kind: "not-modified" }>; + options?: { timeoutMs?: number; maxBytes?: number; fetchImpl?: typeof fetch }, +): Promise<{ kind: "fresh"; body: string }>; ``` `fetchHubReady()` parses through Phase 1's `parseRemoteReadyMetadata()` and evaluates @@ -456,9 +456,10 @@ connected `ocx sync` is the sole apply path. - `client.kind === invalid`: exit non-zero before proxy discovery, provider discovery, catalog write, or injection. - `client.kind === connected`: read and fingerprint-check the service token, request - `/v1/catalog` with `If-None-Match`, and inject the saved `CodexRoutingTarget`. -- Connected 304 reuses the existing catalog only if it is a bounded regular file and - the configured catalog path is the expected absolute path. + `/v1/catalog` unconditionally, and inject the saved `CodexRoutingTarget`. + `/v1/catalog` carries no validator (Phase 1, D2), so the client sends no + `If-None-Match` and never receives a 304. There is no conditional-fetch state to keep + correct, and no way for a cached representation to cross client keys. - Connected timeout/5xx keeps last-known-good catalog and reports stale age; it does not gather local providers. Missing/changed token file and 401 are hard failures and do not inject or fall back. @@ -535,7 +536,7 @@ No test sends live hub traffic or reads the developer's homes. | P3-A5 | Key POST returns 401/403/409 or malformed/oversized JSON. | No token/catalog/journal/config writes and no secret in diagnostics. | | P3-A6 | Token, catalog, injector preflight, inject commit, or final role+state commit is fault-injected in turn. | Prior machine bytes are restored at every point; neither `runtimeRole=client` nor visible `client` remains; remote orphan cleanup status is explicit by safe key id only. | | P3-A7 | Existing standalone config runs every current injector golden. | Output bytes are identical; no connected-only env/header/config key appears. | -| P3-A8 | Connected state plus valid token; hub catalog returns 200 then 304. | First sync atomically updates/injects; second uses last-known-good and ETag; local provider gather fake is never called. | +| P3-A8 | Connected state plus valid token; two consecutive syncs. | Each sync fetches unconditionally and atomically updates/injects; no request carries `If-None-Match`; a hub that answered 304 anyway is treated as a protocol error rather than as an empty catalog. The local provider gather fake is never called. | | P3-A9 | Connected state with hub down, 401, missing token, changed token, or malformed-present client config. | No local-provider fallback and no new local catalog; timeout keeps LKG as stale, credential/state errors fail hard. | | P3-A10 | Connected Claude launch with no user Anthropic overrides. | Child receives hub base and client token; no local proxy is started; gateway cache uses hub `/v1/models`. | | P3-A11 | Connected Claude launch with user-owned different `ANTHROPIC_BASE_URL`. | User destination wins and the hub admission token is absent from child env. | diff --git a/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md b/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md index 83d0006b27..d82d1c7727 100644 --- a/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md +++ b/devlog/_plan/260827_remote_hub/060_phase4_two_plane.md @@ -302,10 +302,21 @@ Strip `Host`, connection/hop-by-hop headers, machine auth headers, proxy credent cookies, and forwarding headers. Forward only the bounded management header allowlist, including hub session, GUI-origin, CSRF, and content type. -Forward the browser's `Origin` value **verbatim on every allowed session-authenticated -request**: the `POST /opencodex-session` exchange, the `GET` bootstrap, and every allowed -`/api/` method including `POST`, `PUT`, `PATCH`, and `DELETE`. Never synthesize it from -the hub URL, the localhost bind, or the GUI-origin header, and never omit it. +Two separate rules govern `Origin`, and conflating them is what produced the earlier defect. + +**Forwarding.** Whenever the browser sends `Origin`, forward that value verbatim, on every +allowed session-authenticated request: the `POST /opencodex-session` exchange, the `GET` +bootstrap, and every allowed `/api/` method including `POST`, `PUT`, `PATCH`, and +`DELETE`. Never synthesize it from the hub URL, the localhost bind, or the GUI-origin +header, and never drop a value the browser did send. + +**Requiring.** The hub's own predicate (Phase 2 §5.2) decides when `Origin` must be +present: mandatory for the pairing exchange and for every mutation, optional for safe +same-browser `GET`/`HEAD` reads. The relay does not tighten or loosen that predicate; a +safe read whose browser sent no `Origin` still relays and still succeeds. + +So the relay refuses only when it would otherwise have to invent a value: a mutation +arriving without `Origin` is rejected rather than given a synthesized one. A previous revision forwarded `Origin` only for the exact `POST /opencodex-session` exchange. That is both a functional and a security defect. The minted GUI session is @@ -316,8 +327,8 @@ breaks every write path it is supposed to carry. Repairing that by synthesizing the hub's CSRF check would be validating the relay against itself. The browser value is the only admissible source, so it is forwarded unchanged or the request does not go. -When the browser sends no `Origin` on a request that requires it, the relay refuses -rather than inventing one. +When the browser sends no `Origin` on a request the Phase-2 predicate requires it for, +the relay refuses rather than inventing one. Response headers are similarly allowlisted; `Set-Cookie` and hop-by-hop headers are never returned. Request and response bodies have named constants and abort on overflow. No URL query, auth header, body, or response body is logged. @@ -466,7 +477,7 @@ appearing after disconnect. | Test file | Required cases | |---|---| | `tests/client-machine-listener.test.ts` (NEW) | IPv4 loopback bind; GUI/bootstrap; exact allowlist; every `/v1/*` 404; unknown/wrong-method 404; safe GET auth; mutation Origin/CSRF; status redaction; sync success/failure; shim actions; disconnect offline; recycle to standalone; invalid state refuses startup; no provider/timer fake invoked. | -| `tests/client-hub-relay.test.ts` (NEW) | Relay disabled 404; direct mode 404; fixed host/path; exact `POST /api/machine/hub-relay/opencodex-session` reaches only hub `POST /opencodex-session` and forwards browser `Origin` verbatim; direct/encoded traversal and authority injection rejected; redirects rejected; request/response caps; hop-by-hop/cookie/forwarded/machine headers stripped; hub auth retained; timeout/abort; no body/header log. | +| `tests/client-hub-relay.test.ts` (NEW) | Relay disabled 404; direct mode 404; fixed host/path; exact `POST /api/machine/hub-relay/opencodex-session` reaches only hub `POST /opencodex-session`; browser `Origin` forwarded byte-for-byte on the pairing exchange, the GET bootstrap, and each allowed `/api/` POST, PUT, PATCH, and DELETE; a mutation whose browser sent no `Origin` is refused without contacting the hub and without a synthesized value; a safe GET/HEAD whose browser sent no `Origin` still relays and succeeds; direct/encoded traversal and authority injection rejected; redirects rejected; request/response caps; hop-by-hop/cookie/forwarded/machine headers stripped; hub auth retained; timeout/abort; no body/header log. | | `tests/cli-start-journal-order.test.ts` | Matching durable client journal survives start; missing/mismatched client owner restores; connected branch never starts full server; disconnected branch remains current. | | `tests/api-usage.test.ts` | Exact `apiKeyId` response/echo; old and other-key rows excluded; no match; combined filters; filtered request cannot poison cache; unfiltered next request remains whole hub. | | `tests/usage-summary.test.ts` | Pure projection totals/days/models/providers/accounts consistency; exact-case id; combo attempts; absent id; provider/model/key cross-product. | @@ -488,7 +499,7 @@ appearing after disconnect. | P4-A3 | GET status/clients with valid loopback GUI session, then without it. | Valid request returns redacted DTO; missing/admin-only/expired/wrong-origin session is rejected and no secret/fingerprint leaks. | | P4-A4 | POST sync/shim/disconnect with valid session but missing/wrong CSRF or browser Origin. | Mutation is rejected before work; exact session+Origin+CSRF reaches the handler. Admin token never becomes GUI session. | | P4-A4b | Relay every allowed session-authenticated method — GET bootstrap, POST `/opencodex-session`, and `/api/` POST, PUT, PATCH, DELETE — from a browser origin the hub allows. | Each request arrives at the hub carrying the browser's `Origin` byte-for-byte. No case is missing `Origin`, and no case carries a value the browser did not send. | -| P4-A4c | Relay an allowed mutation whose browser request has no `Origin`. | The relay refuses without contacting the hub, and does not synthesize an `Origin` from the hub URL, the localhost bind, or the GUI-origin header. | +| P4-A4c | Relay an allowed mutation whose browser request has no `Origin`, then a safe `GET` whose browser request has no `Origin`. | The mutation is refused without contacting the hub and without a synthesized `Origin`. The safe read is relayed unchanged and succeeds, preserving the Phase-2 §5.2 allowance. | | P4-A5 | Connected direct transport with Phase-2 session issued by an exact `remoteGui.allowedTailscaleUsers` match or a consumed pairing grant; hub CORS includes the localhost browser origin. | Shared requests go directly to exact hub origin with hub headers only; machine pages remain localhost; CORS/session validation succeeds. A non-allowlisted Tailscale identity mints no session and gets no local fallback. | | P4-A6 | Connected relay transport and valid machine + hub sessions. | Browser sends dual auth domains; relay validates machine session, strips custom headers, forwards hub session only to fixed hub target, and rejects redirect/SSRF variants. | | P4-A7 | Relay path requested while transport is direct/disabled or caller supplies host/scheme/traversal. | Default 404/refusal before outbound fetch; no credential or body is logged. | diff --git a/devlog/_plan/260827_remote_hub/080_phase6_hardening.md b/devlog/_plan/260827_remote_hub/080_phase6_hardening.md index 69daa58b47..84d4dd2565 100644 --- a/devlog/_plan/260827_remote_hub/080_phase6_hardening.md +++ b/devlog/_plan/260827_remote_hub/080_phase6_hardening.md @@ -310,10 +310,11 @@ or replace the backup directly. is 401. After verified commit, delete `.prev` and clear `pendingOperation`. 6. If steps 2–5 fail before a confirmed commit, restore the old token atomically through `restoreTokenBackup`, abort the pending rotation, then delete the backup and clear the - operation. If commit outcome is uncertain, probe with both keys. New+old both accepted means - issuance completed and overlap is still pending, so commit the new key with the stored - `rotationId`; new-only accepted means commit already took effect; old-only accepted restores - and aborts. Never replay commit without this evidence. + operation — but only when the restore and abort are both confirmed. If the commit outcome + is uncertain, follow the recovery gate below rather than probing directly: the identity + comparison comes first, because two candidates holding the same key both probe + successfully and would otherwise be read as a completed issuance. Never replay commit, + and never delete a candidate, without evidence that survives that comparison. On startup/status, `src/client/state.ts` treats a rotate `pendingOperation` as a recovery gate: verify that `oldKeyBackupPath` is exactly `.prev` and require an owner-only regular @@ -324,8 +325,21 @@ file. but before the token was replaced. Both candidates are the old key, so both probe successfully — and a "both accepted" rule would read that as a completed issuance and commit a rotation that never happened, permanently losing the new key. Identical candidates -therefore mean pre-replacement: never commit, restore nothing, and resume the rotation from -the beginning. +therefore mean pre-replacement: never commit and restore nothing. + +What happens next is constrained by two facts. The new secret is returned exactly once, so +if it was issued it is already unrecoverable from disk; and startup/status holds no +management authority, so it cannot ask the hub anything. Recovery at startup/status +therefore **stops** — it does not "resume," because nothing at that point is able to. +It reports the exact state, leaves both candidates and `pendingOperation` intact, and +names the command the operator runs next. + +Resumption belongs to the next `ocx connect rotate`, which carries fresh transient +authority. That command sees the stored `rotationId`, confirms its abort with the hub, +and only then starts a new rotation. It is not blocked by the `already-pending` rule +(§ rotate contract), because confirming and clearing the stranded operation is precisely +what it is doing. If the abort cannot be confirmed, it stops with the evidence preserved +rather than starting a second rotation on top of an unresolved one. Only when the candidates differ does probing decide anything, and only a confirmed authority may act: @@ -463,7 +477,9 @@ leaf that depends only on `src/lib/bounded-body.ts`. Validation order: -1. Status/redirect: only 200 or valid 304; redirect is refused, not followed. +1. Status/redirect: only 200; redirect is refused, not followed. `/v1/catalog` carries no + validator (Phase 1, D2), so the client sends no conditional request and a 304 is a + protocol error rather than a cache hit. 2. Content type is JSON-compatible; content length above cap rejects early, but streamed bytes are still counted because length may be absent or false. 3. Read at most cap+1 decompressed bytes; exactly cap is allowed, one byte over cancels/discards. @@ -473,13 +489,15 @@ Validation order: required shape passes. 6. Serialize/write only after complete validation. Failed refresh retains the exact LKG bytes and stale age; no local provider fallback and no partial file. -7. A 304 without an existing validated LKG triggers one unconditional refetch; a second 304 is a - protocol error, not an empty catalog. +7. A 304 is a protocol error in every case. The client never issued a conditional request, + so a hub answering 304 is either misconfigured or being impersonated; treat it as a + failed refresh that retains the exact LKG bytes, never as an empty catalog. Adversarial tests include: forged small Content-Length with oversized chunks, gzip/decompressed oversize fixture, exact-cap and cap+1, fragmented trickle, malformed/truncated/UTF-8 JSON, null, array top level, missing/non-array models, 2,001 rows, non-object row, empty/control/duplicate slug, -unexpected future fields, stale/mismatched ETag, and filesystem write failure after validation. +unexpected future fields, an unsolicited 304, an unsolicited `ETag` on the 200, and +filesystem write failure after validation. Every rejection asserts token/catalog/state/journal bytes are unchanged. ## 8. Relay SSRF and header-smuggling negatives @@ -551,7 +569,11 @@ Phase 6 extends them rather than creating parallel “hardening2” files. | second start | Existing unexpired pending rotation | 409; no third secret/state change. | | client commit | New key written + authenticated catalog succeeds | Pending promoted atomically; old next request 401; id/usage bucket stable; `.prev` deleted and pending operation cleared; sessions/grants unchanged. | | client write/probe fail | Fail backup/temp write, hardening, rename, or new-key probe | Old file restored/unchanged from `.prev`; pending aborted or expires; old key remains valid. | -| uncertain commit/crash | Drop commit response or restart with pending operation | Probe current+`.prev`; both accepted commits the current new key with stored `rotationId`, new-only finalizes committed state, old-only restores+aborts, and both rejected stops without deletion. | +| uncertain commit/crash | Drop commit response or restart with pending operation | Compare candidate identities FIRST. Differing candidates: both accepted commits the current new key with stored `rotationId`, new-only finalizes committed state, old-only restores+aborts, both rejected stops without deletion. | +| pre-replacement crash | Persist `pendingOperation`, then stop before the token is replaced, so the live token and `.prev` hold the same key | Identical candidates are detected before any probe. No commit, no restore, no deletion; both files and `pendingOperation` survive and recovery stops with the operator instruction. The old "both probes accepted implies commit" reading is what this row keeps dead — it would commit a rotation that never happened and lose the new key permanently. | +| unconfirmed abort | Rotation reaches installed-new-token state, then the abort request fails transiently | Neither candidate is deleted and `pendingOperation` survives naming the unconfirmed step. No generation is restored on unconfirmed authority. | +| status during rotation | Run `ocx connect status` while `rotateConnectedClientKey` awaits `/api/keys/rotate` | The in-flight `.prev` backup is not deleted and the rotation completes normally. | +| stranded-operation resume | After a pre-replacement crash, run `ocx connect rotate` | The stored `rotationId` is confirmed aborted with the hub before a new rotation starts; `already-pending` does not block this path. An unconfirmable abort stops with evidence preserved rather than starting a second rotation. | | pending expiry | Fake clock past 10 minutes | Pending rejected/removed; old accepted. | | connected operator revoke | Valid connected state + `ocx connect revoke --admin-token-stdin` | CLI reads the issuance-derived `apiKeyId` from state, accepts no id argument, and revokes that key; sessions/grants remain unchanged. | | post-disconnect revoke | Disconnect clears local client state while its hub key remains | CLI revoke refuses before any request; output points to hub GUI **Integrations → API Keys**, the sole post-disconnect revocation path. | @@ -564,7 +586,7 @@ Phase 6 extends them rather than creating parallel “hardening2” files. | malformed protocol | Invalid `/readyz` fields | Malformed error; no catalog/token/inject/state write. | | oversized catalog | Content-Length lie or chunked cap+1 | Cancel/discard, LKG unchanged, no fallback. | | malformed/schema catalog | Each §7 shape | Precise safe error class, LKG unchanged. | -| 304 no LKG | Empty cache + 304 | One unconditional retry; second 304 errors. | +| unsolicited 304 | Hub answers 304 to an unconditional request, with and without an existing LKG | Treated as a protocol error in both cases; LKG unchanged where present, no empty catalog written, no local provider fallback. | | relay URL override | Absolute/scheme-relative/encoded authority path | Reject before fetch; fixed hub sees zero requests. | | relay redirect | Fixed hub returns 3xx to attacker | No follow, no credential at target. | | request smuggling | Raw CL/TE, duplicate CL, Connection-nominated secret header | Reject/strip before fetch. | @@ -581,7 +603,8 @@ The Remote Hub guide in all eight locales must cover: - connected usage = hub store filtered to this `apiKeyId`; disconnected usage = local store; no mirroring; - loopback management ingress, Tailscale Serve, exact `allowedTailscaleUsers`, pairing, and the - explicit insecure-HTTP warning; + requirement that pairing runs over loopback or HTTPS only — plaintext HTTP carries no + credential and there is no opt-in that changes this; - admin token ordinary-management scope and permanent inability to mint consent sessions; - systemd/launchd, Docker volume/secret/probes, headless OAuth, rotation, rollback, and protocol upgrade errors; @@ -717,6 +740,3 @@ the matching CI partition before classifying it; never call a red result environ Final live evidence runs on `clisu-oracle`/MacBook per 070 §8 after the remote gates. It proves health, readiness, authenticated catalog, one routed response, remote session, consent refusal, rotation, usage slice, disconnect/local store, rollback, and both constructible protocol directions. -| P6-A20 | Persist `pendingOperation`, then stop before the token is replaced so the live token and `.prev` hold the same key. Restart. | Recovery detects identical candidates before probing, refuses to commit, leaves both files intact, and resumes the rotation. The pre-fix "both probes accepted implies commit" rule is what this row exists to keep dead. | -| P6-A21 | Rotation reaches installed-new-token state, then the abort request fails transiently. | Neither candidate is deleted and `pendingOperation` survives with the unconfirmed step named. No generation is restored on unconfirmed authority. | -| P6-A22 | Run `ocx connect status` while `rotateConnectedClientKey` is awaiting `/api/keys/rotate`. | The in-flight `.prev` backup is not deleted and the rotation completes normally. |