diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index e3dbbf673e3..ba5781c78ec 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -65,6 +65,8 @@ A failed link does not silently switch to a local provider. The Child returns a retryable `503` without forwarding the link key or request when its tunnel is failed, stopped, or not supervised. It also keeps the key local when another process owns the tunnel port; free that port so the Child's SSH tunnel can bind it. +To confirm which Home is serving a Child, compare the `x-opencodex-served-by` header on the Child's `GET /v1/models` with the `instanceId` in the Home's `GET /healthz`. The Child passes the Home's header through unchanged, so the two match while the link works. The value is an opaque id, never a hostname; see [Home identity and the served-by header](/reference/management-api/#home-identity-and-the-served-by-header). + ## What a Child shows and manages A Child runs on the Home's providers, accounts and settings, so the Home is where you change them. diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 1ef9de2d0a5..45b7a709cf8 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -658,6 +658,23 @@ API page. The CLI drives these routes with `ocx api protocols`, `ocx api explain [API surfaces](/reference/configuration/server/#api-surfaces-apisurfaces) for how the Messages setting interacts with `claudeCode.enabled` across upgrades and downgrades. +### Home identity and the served-by header + +`GET /healthz` includes `instanceId`, this install's opaque Home id: `home_` followed by 32 hex +characters, generated at random once per install. It is the same value as `producer.homeId` in +`GET /api/quota-pool`, it stays the same across restarts, and it is never a hostname or derived from +one. Every response to `GET /v1/models` carries the same value in the `x-opencodex-served-by` +header, so a client can tell which Home answered without reading logs. A Remote Link Child relays the +Home's header unchanged, and a Hub-mode client's Codex reads the Hub's response directly, so on any +device the header names the Home that served the model list. `GET /readyz` lists +`served-by-header` in `features` when the build sends it. + +Right after a fresh install starts, before its identity file exists, or when that file is +unreadable, both the `instanceId` field and the header are left out; an empty value is never sent. +Deleting `quota-pool-identity.json` from the OpenCodex home rotates the id together with every +quota-pool account reference when the proxy next starts; a running proxy keeps reporting the id it +already read. + ### Providers | Method and path | Purpose | Notable errors | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 48c487f770f..98eeb485ba6 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1453,7 +1453,7 @@ "proxy-env-macos.test.ts": "server", "proxy-env.test.ts": "server", "proxy-liveness-package-tree-fence.test.ts": "server", - "proxy-liveness.test.ts": "server", "readyz-features.test.ts": "server", + "proxy-liveness.test.ts": "server", "readyz-features.test.ts": "server", "served-by-header.test.ts": "server", "qoder-adapter.test.ts": "providers", "qoder-live-models.test.ts": "providers", "qoder-mcp-server.test.ts": "providers", "qoder-scaffold-guard.test.ts": "providers", "quota-401-recovery-runtime.test.ts": "usage", diff --git a/src/remote/served-by.ts b/src/remote/served-by.ts index 5f30a3ae1b8..22b37dc396c 100644 --- a/src/remote/served-by.ts +++ b/src/remote/served-by.ts @@ -1,14 +1,57 @@ /** - * The served-by response header and whether this build sends it. + * The served-by response header, the Home instance id it carries, and whether this build sends it. * - * Relayed and Hub-served responses are meant to name the Home that served them in - * `x-opencodex-served-by`, so a peer can prove which Home answered without reading logs. This module - * is the emitter's descriptor: `SERVED_BY_HEADER_EMITTED` is true only in a build whose responses - * carry the header, and the change that starts sending it flips the flag in the same commit. - * `/readyz` advertises `served-by-header` from this flag (`src/remote/features.ts`), and - * `tests/server/readyz-features.test.ts` fails if the advertisement and live responses disagree. + * Every `GET /v1/models` response the server answers itself names the serving Home in + * `x-opencodex-served-by`, and `GET /healthz` publishes the same value as `instanceId`, so a peer + * (GOAT's parity check P4) can prove which Home answered without reading logs. A Remote Link Child + * never answers `/v1/models` itself: its relay copies the Home's response headers, so the value a + * Child returns is the Home's. A Hub-mode client's Codex calls the Hub's data listener directly, so + * the Hub stamps its own response. + * + * The instance id is the install's `homeId` from `src/quota-pool/identity.ts`: a random + * `home_<32 hex>` minted once per install, persisted owner-only, and stable across restarts. It is + * never a hostname and is not derived from one. Until the identity exists (it is created in the + * background after the listener starts) or when its file is unreadable, the header and the + * `/healthz` field are omitted; an empty or malformed value is never sent. + * + * `SERVED_BY_HEADER_EMITTED` tells `/readyz` to advertise `served-by-header` + * (`src/remote/features.ts`); `tests/server/readyz-features.test.ts` fails if the advertisement + * and live responses disagree. */ +import { readQuotaPoolIdentity } from "../quota-pool/identity"; + export const SERVED_BY_HEADER = "x-opencodex-served-by"; /** Whether responses from this build carry {@link SERVED_BY_HEADER}. */ -export const SERVED_BY_HEADER_EMITTED: boolean = false; +export const SERVED_BY_HEADER_EMITTED: boolean = true; + +const INSTANCE_ID = /^home_[0-9a-f]{32}$/; + +/** This Home's opaque instance id, or undefined while the install identity is unavailable. */ +export function homeInstanceId(): string | undefined { + let homeId: unknown; + try { homeId = readQuotaPoolIdentity()?.homeId; } catch { return undefined; } + return typeof homeId === "string" && INSTANCE_ID.test(homeId) ? homeId : undefined; +} + +/** Stamp {@link SERVED_BY_HEADER} with this Home's instance id; leaves the response alone without one. */ +export function withServedBy(response: Response, instanceId = homeInstanceId()): Response { + if (!instanceId) return response; + try { + response.headers.set(SERVED_BY_HEADER, instanceId); + return response; + } catch { + const headers = new Headers(response.headers); + headers.set(SERVED_BY_HEADER, instanceId); + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); + } +} + +/** + * The server's last step for every answered request: `GET /v1/models` names the serving Home, + * whichever branch produced the response. `pathname` is the dispatch path the router matched. + */ +export function stampServedBy(method: string, pathname: string, response: Response): Response { + if (!response || method !== "GET" || pathname !== "/v1/models") return response; + return withServedBy(response); +} diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 04a8bf34af6..584a2f7d50a 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -204,6 +204,7 @@ import type { ReadinessGate } from "../readiness"; import type { WorkflowRefusalLog } from "../workflow-refusal"; import { readyProtocolMetadata } from "../../remote/protocol"; +import { homeInstanceId, stampServedBy } from "../../remote/served-by"; import { modelCapabilityFields } from "../models-capabilities"; import { createWebsocketHandler } from "./websocket-handler"; import { reasoningArchiveErrorResponse } from "../responses/reasoning-archive-error"; @@ -413,11 +414,13 @@ export function createServeOptions(ctx: ServeOptionsContext) { const message = "OpenCodex package files changed while this proxy was running; it restarts on its own, or run 'ocx restart' ('ocx service restart' for a background service)."; const fencedPort = ctx.boundPort ?? requestServer.port ?? listenPort; const fencedHealth = url.pathname === "/healthz"; + const fencedInstanceId = fencedHealth ? homeInstanceId() : undefined; const response = fencedHealth || readyzPath !== undefined ? jsonResponse({ status: "restart_required", service: "opencodex", version: VERSION, + ...(fencedInstanceId ? { instanceId: fencedInstanceId } : {}), uptime: process.uptime(), pid: process.pid, port: fencedPort, @@ -628,10 +631,13 @@ export function createServeOptions(ctx: ServeOptionsContext) { if (url.pathname === "/healthz" && req.method === "GET") { // service/pid/port let CLI liveness reject foreign 200s and verify pid identity. const healthPort = ctx.server.port ?? listenPort; + // Opaque Home instance id (src/remote/served-by.ts); omitted until the identity exists. + const instanceId = homeInstanceId(); const response = jsonResponse({ status: "ok", service: "opencodex", version: VERSION, + ...(instanceId ? { instanceId } : {}), uptime: process.uptime(), pid: process.pid, port: healthPort, @@ -1982,5 +1988,8 @@ export function createServeOptions(ctx: ServeOptionsContext) { }, websocket: createWebsocketHandler(ctx, requestMetrics), } as const; - return serveOptions; + const routeFetch = serveOptions.fetch; + // x-opencodex-served-by on GET /v1/models, whichever branch answered (src/remote/served-by.ts). + return { ...serveOptions, fetch: async (req: Request, requestServer: Server): Promise => + stampServedBy(req.method, codexCompatibleUrl(req.url).pathname, await routeFetch(req, requestServer)) } as const; } diff --git a/structure/quota-pool.md b/structure/quota-pool.md index 3f39547e1f1..e3a534036b1 100644 --- a/structure/quota-pool.md +++ b/structure/quota-pool.md @@ -70,10 +70,16 @@ in-place exclusive create where hard links are unavailable), so two servers star home adopt the same identity, and a symlink or malformed file at the path is refused and left untouched rather than overwritten. +`homeId` is also the Home instance id other peers see: `GET /healthz` publishes it as `instanceId` +and `GET /v1/models` names it in `x-opencodex-served-by` (`src/remote/served-by.ts`, see +[Runtime](runtime.md)). It is random and carries nothing derivable from the machine; the secret is +never published. + `accountRef` is `acct_` + base64url of the first 128 bits of HMAC-SHA256(secret, `ocx.quota-pool/1` NUL provider NUL accountId). It is stable across restarts, distinct per provider, and never contains an email, a raw account id or a token; without the secret a recipient cannot confirm a guessed identity against it. Deleting the file rotates `homeId` and every reference -together. `label` is the account's existing log label (`main`, `p`, `o`), never an +together when the proxy next starts; a running proxy keeps the identity it already read (and keeps +publishing that `homeId` as its instance id). `label` is the account's existing log label (`main`, `p`, `o`), never an email or alias. `producer.role` is `runtimeRole` (`standalone` when unset) and `producer.version` is the package version. `driver` and `pooled.headroom` are not produced yet. diff --git a/structure/remote-link.md b/structure/remote-link.md index fe388d83b96..ea736823a51 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -81,6 +81,8 @@ Enrollment cancellation in `src/client/connect.ts` reaches actual network reques The production relay's complete-send boundary is `src/client/link-relay-transport.ts`. A dedicated HTTP agent opens exactly one TCP socket. Before any admission header or body is sent, a credential-free, five-second challenge proves the stored link/key identity using direction-separated HMACs from `src/link/relay-auth.ts`. The data request uses that same authenticated socket, with a one-use session nonce; a closed socket cannot be silently replaced. A peer lacking this protocol is refused with an upgrade/re-link error, never retried through an unauthenticated send. Home-initiated reverse tunnels use the same check. `LinkRelayDeps.fetchImpl` is a trusted complete-send test seam, not a user setting; production supplies no substitute. Bun's Node HTTP compatibility is confined to this leaf so streamed request caps, backpressure, credential replacement, decompression, response status, caller cancellation and SSE limits remain owned by the existing relay. A Home advertises `LINK_RELAY_AUTH_VERSION` as `relay-auth/`, and the management relay as `link-management-relay`, in its `/readyz` `features` ([Runtime](runtime.md), `src/remote/features.ts`), so a peer can detect a relay-auth mismatch without comparing release versions. +The relay copies the Home's response headers except `content-encoding`, `content-length` and hop-by-hop headers, so the Home's `x-opencodex-served-by` on `GET /v1/models` reaches the Child's caller unchanged and the Child never stamps its own; `tests/server/served-by-header.test.ts` relays through the Home's real link listener and through a fake Home naming another id ([Runtime](runtime.md)). + `src/server/index/link-relay-sessions.ts` owns a per-listener reservation book. A valid caller proof reserves its peer socket for five seconds (at most 256 outstanding reservations); the data request must consume that nonce on the same peer address/port while its link and credential remain valid. Current and unexpired pending-rotation fingerprints use the same expiry rule as ordinary API-key admission, and the server signs with the fingerprint that matched. The nonce is not an alternative data-plane credential: dispatch still applies its ordinary key and route policy. Removal, rotation expiry, abort and commit are observed anew at dispatch. Replays and a different socket are refused. Last-link `close()` refuses new challenges/work, retains both the bound port and hub-link ingress identity through pending reservations and active response bodies, then closes. An abandoned proof expires; EOF, error and cancellation release an active response. Process `stop()` remains forceful: it retires reservations and closes every socket, which the client cannot reconnect. The regression file `tests/clients/link-relay-bound-transport.test.ts` exercises the actual sockets, shutdown, same-connection proof, refusal to reconnect, bounded reservations, current/pending key lookup and response decoding. This is port-replacement protection, not a claim of encryption against an active transparent local intermediary. SSH continues to own remote-host authentication/encryption; enrollment and supervisor probes are separate boundaries. diff --git a/structure/runtime.md b/structure/runtime.md index 1824e83b4ff..290f43e3a25 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -347,7 +347,7 @@ config snapshot and the bounded service-token observation needed for its `_remot it does not import the connect command, inspect catalog readiness, acquire lifecycle locks, or run config/secret ACL hardening. -`src/remote/protocol.ts` owns pure interval/feature negotiation. `src/remote/features.ts` is the one registry of peer features `GET /readyz` lists in `features`, and each entry reads its owning source: `quota-pool/1` when `src/server/management/route-registry.ts` declares `GET /api/quota-pool`, `link-management-relay` when `src/link/management-routes.ts` has read paths, `served-by-header` when the emitter descriptor `src/remote/served-by.ts` says responses carry `x-opencodex-served-by`, and `relay-auth/` from `src/link/relay-auth.ts`. The ids are fixed strings with no account or configuration data; `tests/server/readyz-features.test.ts` checks presence and absence per source, the peer parser, and the served-by advertisement against live responses. The Child machine listener answers its own `/readyz` without `features`. `src/remote/hub-state.ts` owns the `GET|HEAD /v1/hub-state` contract, its caps, and the parser both sides share. `src/client/hub-client.ts` owns bounded, schema-validated remote catalog consumption, hub-state reads, and key-id probes; `src/client/hub-state.ts` owns the resolution and the owner-stamped 0600 cache, and a failed read reports "unavailable" rather than degrading to the client's own local provider and login state. `src/client/hub-relay.ts` is a fixed-authority management relay with URL, header, body, redirect, and stream bounds. The public data listener remains the direct client→hub path; the loopback management ingress never serves data-plane routes. A client with `transport: "link"` reaches its hub through an SSH tunnel instead; see [Remote Link](remote-link.md). A client with a link sidecar owns its SSH tunnel; see [Remote Link](remote-link.md). For Remote Link, `src/cli/status.ts` asks the identity-checked local Child for `/v1/hub-state` without any credential header; the Child's connection-bound relay owns Home authentication. Hub Homes retain their bounded parsed projection and owner-bound cache. `src/client/hub-client.ts` distinguishes a bounded nested `hub_state_not_a_hub` 404 from legacy route absence; `src/client/hub-state.ts` reports role absence without presenting obsolete cached Hub state. `src/client/link-status.ts` reads matching sidecar/marker evidence for initiation identity; ambiguous data does not claim a Home-owned tunnel. These contracts are covered in `tests/cli/cli-status-hub-state.test.ts` and `tests/clients/link-status-projection.test.ts`. +`src/remote/protocol.ts` owns pure interval/feature negotiation. `src/remote/features.ts` is the one registry of peer features `GET /readyz` lists in `features`, and each entry reads its owning source: `quota-pool/1` when `src/server/management/route-registry.ts` declares `GET /api/quota-pool`, `link-management-relay` when `src/link/management-routes.ts` has read paths, `served-by-header` when the emitter descriptor `src/remote/served-by.ts` says responses carry `x-opencodex-served-by`, and `relay-auth/` from `src/link/relay-auth.ts`. `src/remote/served-by.ts` also owns the value: the Home instance id is the install's `homeId` from `src/quota-pool/identity.ts` (random, persisted, never a hostname), published on `GET /healthz` (including its package-tree-fenced 503 body) as `instanceId` and stamped on every `GET /v1/models` response by the wrapper at the end of `createServeOptions` (`src/server/index/serve-options.ts`), whichever branch answered. While the identity is unavailable both are omitted rather than sent empty. A link Child's relay passes the Home's header through and a Hub-mode client's Codex reads the Hub data listener directly, so the header always names the serving Home; `tests/server/served-by-header.test.ts` covers standalone, Hub, link-relayed, restart-stable, opaque and unavailable-identity cases. The ids are fixed strings with no account or configuration data; `tests/server/readyz-features.test.ts` checks presence and absence per source, the peer parser, and the served-by advertisement against live responses. The Child machine listener answers its own `/readyz` without `features`. `src/remote/hub-state.ts` owns the `GET|HEAD /v1/hub-state` contract, its caps, and the parser both sides share. `src/client/hub-client.ts` owns bounded, schema-validated remote catalog consumption, hub-state reads, and key-id probes; `src/client/hub-state.ts` owns the resolution and the owner-stamped 0600 cache, and a failed read reports "unavailable" rather than degrading to the client's own local provider and login state. `src/client/hub-relay.ts` is a fixed-authority management relay with URL, header, body, redirect, and stream bounds. The public data listener remains the direct client→hub path; the loopback management ingress never serves data-plane routes. A client with `transport: "link"` reaches its hub through an SSH tunnel instead; see [Remote Link](remote-link.md). A client with a link sidecar owns its SSH tunnel; see [Remote Link](remote-link.md). For Remote Link, `src/cli/status.ts` asks the identity-checked local Child for `/v1/hub-state` without any credential header; the Child's connection-bound relay owns Home authentication. Hub Homes retain their bounded parsed projection and owner-bound cache. `src/client/hub-client.ts` distinguishes a bounded nested `hub_state_not_a_hub` 404 from legacy route absence; `src/client/hub-state.ts` reports role absence without presenting obsolete cached Hub state. `src/client/link-status.ts` reads matching sidecar/marker evidence for initiation identity; ambiguous data does not claim a Home-owned tunnel. These contracts are covered in `tests/cli/cli-status-hub-state.test.ts` and `tests/clients/link-status-projection.test.ts`. ### Remote Hub status credential binding diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 74344230178..b63fabdf1ba 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1022,7 +1022,7 @@ "provider-workspace-data.test.ts": "gui", "provider-workspace-rail.test.ts": "gui", "provider-workspace-state.test.ts": "gui", "proxy-env-macos.test.ts": "server", "proxy-env.test.ts": "server", "proxy-liveness-package-tree-fence.test.ts": "server", - "proxy-liveness.test.ts": "server", "readyz-features.test.ts": "server", "qoder-adapter.test.ts": "providers", + "proxy-liveness.test.ts": "server", "readyz-features.test.ts": "server", "served-by-header.test.ts": "server", "qoder-adapter.test.ts": "providers", "qoder-live-models.test.ts": "providers", "qoder-mcp-server.test.ts": "providers", "qoder-scaffold-guard.test.ts": "providers", "quota-401-recovery-runtime.test.ts": "usage", "quota-401-recovery.test.ts": "usage", "quota-bars-rows.test.ts": "gui", "quota-reset-account-key.test.ts": "usage", diff --git a/tests/server/readyz-features.test.ts b/tests/server/readyz-features.test.ts index 4c0cfa6b5d4..47b159567e0 100644 --- a/tests/server/readyz-features.test.ts +++ b/tests/server/readyz-features.test.ts @@ -11,6 +11,7 @@ import { join } from "node:path"; import { getDefaultConfig, saveConfig } from "../../src/config"; import { LINK_MANAGEMENT_READ_PATHS } from "../../src/link/management-routes"; import { LINK_RELAY_AUTH_VERSION } from "../../src/link/relay-auth"; +import { ensureQuotaPoolIdentity } from "../../src/quota-pool/identity"; import { buildPeerFeatures, currentPeerFeatureSources, @@ -107,16 +108,25 @@ describe("GET /readyz features on a live server", () => { }); test("served-by-header is advertised exactly when responses carry the header", async () => { + saveConfig({ + ...getDefaultConfig(), + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:9/v1", allowPrivateNetwork: true, liveModels: false, models: ["m"] } }, + }); + ensureQuotaPoolIdentity(); const server = startServer(0, { readinessGate: createReadinessGate() }); try { const readyz = await fetch(new URL("/readyz", server.url)); const advertised = ((await readyz.json()) as { features: string[] }).features.includes("served-by-header"); - const responses = [ - readyz, - await fetch(new URL("/healthz", server.url)), - await fetch(new URL("/v1/opencodex-readyz-features-probe", server.url)), - ]; - for (const response of responses) expect(response.headers.has(SERVED_BY_HEADER)).toBe(advertised); + expect(advertised).toBe(SERVED_BY_HEADER_EMITTED); + expect(advertised).toBe(true); + const models = await fetch(new URL("/v1/models", server.url)); + expect(models.status).toBe(200); + expect(models.headers.has(SERVED_BY_HEADER)).toBe(advertised); + // The header names the Home on model discovery only; other routes stay unstamped. + for (const response of [readyz, await fetch(new URL("/v1/opencodex-readyz-features-probe", server.url))]) { + expect(response.headers.has(SERVED_BY_HEADER)).toBe(false); + } } finally { await server.stop(true); } diff --git a/tests/server/served-by-header.test.ts b/tests/server/served-by-header.test.ts new file mode 100644 index 00000000000..fe57acc3a22 --- /dev/null +++ b/tests/server/served-by-header.test.ts @@ -0,0 +1,227 @@ +/** + * OCX-2 (goat-si/goat #684 P4, #696): the serving Home's opaque instance id on `GET /healthz` + * (`instanceId`) and on every `GET /v1/models` response (`x-opencodex-served-by`), whichever + * path the request took: the Home or Hub data listener (a Hub-mode client's Codex calls it directly), + * a Remote Link Child's relay to the Home's link listener, or a standalone proxy. The id is the + * install's random `homeId`, stable across restarts, never a hostname, and absent (never empty) + * while the identity is unavailable. + */ +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { hostname, tmpdir } from "node:os"; +import { join } from "node:path"; +import { getConfigDir, saveConfig } from "../../src/config"; +import { HOME_INITIATED_LINK_TUNNEL, relayLinkDataRequest } from "../../src/client/link-relay"; +import { linkStorePath } from "../../src/link/paths"; +import { writeLinkStore } from "../../src/link/store"; +import { + ensureQuotaPoolIdentity, quotaPoolIdentityPath, readQuotaPoolIdentity, resetQuotaPoolIdentityMemoForTests, +} from "../../src/quota-pool/identity"; +import { SERVED_BY_HEADER } from "../../src/remote/served-by"; +import { startServer } from "../../src/server"; +import type { OcxConfig } from "../../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeAfterBackgroundWork } from "../helpers/remove-tree"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; + +setDefaultTimeout(SERVER_BUDGET_MS); + +const API_KEY = "served-by-env-admission"; +const LINKED_KEY = "served-by-linked-admission"; +const INSTANCE_ID = /^home_[0-9a-f]{32}$/; +const previous = { home: process.env.OPENCODEX_HOME, apiToken: process.env.OPENCODEX_API_AUTH_TOKEN }; +let testHome = ""; +let codexHome: IsolatedCodexHome | null = null; +let server: ReturnType | null = null; + +function config(role: "hub" | "standalone"): OcxConfig { + return { + port: 0, + hostname: role === "hub" ? "0.0.0.0" : "127.0.0.1", + ...(role === "hub" ? { runtimeRole: "hub" } : {}), + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:9/v1", allowPrivateNetwork: true, liveModels: false, models: ["m"] } }, + apiKeys: [{ id: "linked-key", name: "linked-key", key: LINKED_KEY, createdAt: "2026-10-22T00:00:00.000Z" }], + clientIntegrations: { codex: false }, + webSearchSidecar: { enabled: false }, + } as OcxConfig; +} + +function start(role: "hub" | "standalone") { + saveConfig(config(role)); + server = startServer(0); + return server; +} + +const auth = { "x-opencodex-api-key": API_KEY }; + +async function health(): Promise> { + return await (await fetch(new URL("/healthz", server!.url))).json() as Record; +} + +/** The server mints the identity in the background after it listens; wait for /healthz to carry it. */ +async function instanceIdFromHealth(): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + const id = (await health()).instanceId; + if (id !== undefined) { + expect(id).toMatch(INSTANCE_ID); + return id as string; + } + await Bun.sleep(10); + } + throw new Error("instanceId never appeared on /healthz"); +} + +function expectOpaque(id: string): void { + expect(id).toMatch(INSTANCE_ID); + // Only the random body can carry anything, and a short or hex-only label can occur in random hex + // by chance; check labels long enough for a match to mean derivation. + const body = id.slice("home_".length); + const label = hostname().toLowerCase().split(".")[0] ?? ""; + if (label.length >= 8) { + expect(body).not.toContain(label); + expect(body).not.toContain(Buffer.from(label).toString("hex").slice(0, 16)); + } +} + +async function stopServer(): Promise { + if (server) await server.stop(true); + server = null; +} + +beforeEach(() => { + testHome = mkdtempSync(join(tmpdir(), "ocx-served-by-")); + process.env.OPENCODEX_HOME = testHome; + process.env.OPENCODEX_API_AUTH_TOKEN = API_KEY; + resetQuotaPoolIdentityMemoForTests(); + codexHome = installIsolatedCodexHome("ocx-served-by-codex-"); +}); + +afterEach(async () => { + await stopServer(); + codexHome?.restore(); + codexHome = null; + resetQuotaPoolIdentityMemoForTests(); + for (const [key, value] of [["OPENCODEX_HOME", previous.home], ["OPENCODEX_API_AUTH_TOKEN", previous.apiToken]] as const) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + if (testHome) await removeTreeAfterBackgroundWork(testHome); + testHome = ""; +}); + +describe("GET /healthz instanceId", () => { + test("is the install's opaque homeId, stable across restarts", async () => { + start("standalone"); + const first = await instanceIdFromHealth(); + expect(first).toBe(readQuotaPoolIdentity()!.homeId); + expectOpaque(first); + await stopServer(); + resetQuotaPoolIdentityMemoForTests(); + start("standalone"); + expect(await instanceIdFromHealth()).toBe(first); + const models = await fetch(new URL("/v1/models", server!.url), { headers: auth }); + expect(models.headers.get(SERVED_BY_HEADER)).toBe(first); + }); + + test("a fresh install gets a different id: nothing machine-derived", async () => { + start("standalone"); + const first = await instanceIdFromHealth(); + await stopServer(); + process.env.OPENCODEX_HOME = mkdtempSync(join(tmpdir(), "ocx-served-by-other-")); + const other = process.env.OPENCODEX_HOME; + try { + resetQuotaPoolIdentityMemoForTests(); + start("standalone"); + const second = await instanceIdFromHealth(); + expectOpaque(second); + expect(second).not.toBe(first); + } finally { + await stopServer(); + process.env.OPENCODEX_HOME = testHome; + await removeTreeAfterBackgroundWork(other); + } + }); + + test("an unavailable identity omits the field and the header instead of sending an empty value", async () => { + // A malformed file is refused and never overwritten, so the identity stays unavailable. + writeFileSync(quotaPoolIdentityPath(getConfigDir()), JSON.stringify({ version: 1, homeId: "", secret: "" }), { mode: 0o600 }); + start("standalone"); + await Bun.sleep(200); + const body = await health(); + expect(body.status).toBe("ok"); + expect("instanceId" in body).toBe(false); + const models = await fetch(new URL("/v1/models", server!.url), { headers: auth }); + expect(models.status).toBe(200); + expect(models.headers.has(SERVED_BY_HEADER)).toBe(false); + }); + + test("a package-tree fence keeps the id on /healthz and the header on /v1/models", async () => { + const homeId = ensureQuotaPoolIdentity().homeId; + saveConfig(config("standalone")); + server = startServer(0, { + packageTreeIntegrity: { status: () => ({ ok: false as const, reason: "package_tree_replaced" as const }), dispose: () => {} }, + }); + const health = await fetch(new URL("/healthz", server.url)); + expect(health.status).toBe(503); + expect(await health.json()).toMatchObject({ status: "restart_required", instanceId: homeId }); + const models = await fetch(new URL("/v1/models", server.url), { headers: auth }); + expect(models.status).toBe(503); + expect(models.headers.get(SERVED_BY_HEADER)).toBe(homeId); + }); +}); + +describe("x-opencodex-served-by on GET /v1/models", () => { + test("standalone: the proxy names itself", async () => { + start("standalone"); + const id = await instanceIdFromHealth(); + for (const query of ["", "?client_version=0.130.0"]) { + const response = await fetch(new URL(`/v1/models${query}`, server!.url), { headers: auth }); + expect(response.status).toBe(200); + expect(response.headers.get(SERVED_BY_HEADER)).toBe(id); + } + // Refusals from the same route name the same Home; other routes carry no header. + const refusal = await fetch(new URL("/v1/models", server!.url), { headers: { origin: "https://elsewhere.example" } }); + // Loopback admits the keyless call; the foreign Origin is what refuses it. + expect(refusal.status).toBe(403); + expect(await refusal.text()).toContain("origin_rejected"); + expect(refusal.headers.get(SERVED_BY_HEADER)).toBe(id); + expect((await fetch(new URL("/healthz", server!.url))).headers.has(SERVED_BY_HEADER)).toBe(false); + }); + + test("Hub data listener: the response a Hub-mode client's Codex reads names the Hub", async () => { + start("hub"); + const id = await instanceIdFromHealth(); + const response = await fetch(new URL("/v1/models", server!.url), { headers: { authorization: `Bearer ${LINKED_KEY}` } }); + expect(response.status).toBe(200); + expect(response.headers.get(SERVED_BY_HEADER)).toBe(id); + }); + + test("Remote Link Child relay: the Child returns the Home's id unchanged", async () => { + writeLinkStore(linkStorePath(), { + version: 1, + listenerPort: null, + links: [{ + id: "lnk_00000000000000ab", alias: "served-by-test", direction: "client-initiated", + hostKeyFingerprint: "SHA256:abcdefghijklmnop", tunnelPort: 2222, apiKeyId: "linked-key", createdAt: "2026-10-22T00:00:00.000Z", + }], + }); + start("hub"); + const homeId = await instanceIdFromHealth(); + const linkPort = JSON.parse(await Bun.file(linkStorePath()).text()).listenerPort as number; + const relay = (fetchImpl: typeof fetch) => relayLinkDataRequest( + new Request("http://127.0.0.1:10100/v1/models", { headers: { authorization: "Bearer child-local-caller" } }), + { tunnelPort: linkPort, admissionKey: LINKED_KEY }, + { fetchImpl, tunnel: HOME_INITIATED_LINK_TUNNEL }, + ); + const relayed = await relay(fetch); + expect(relayed.status).toBe(200); + expect(relayed.headers.get(SERVED_BY_HEADER)).toBe(homeId); + // A Child never substitutes its own id: whatever the Home names passes through as is. + const otherHome = `home_${"a".repeat(32)}`; + const passed = await relay((async () => Response.json({ data: [] }, { headers: { [SERVED_BY_HEADER]: otherHome } })) as typeof fetch); + expect(passed.headers.get(SERVED_BY_HEADER)).toBe(otherHome); + const unstamped = await relay((async () => Response.json({ data: [] })) as typeof fetch); + expect(unstamped.headers.has(SERVED_BY_HEADER)).toBe(false); + }); +}); diff --git a/tests/server/server-auth.test.ts b/tests/server/server-auth.test.ts index a14b4253332..f24cc0b0206 100644 --- a/tests/server/server-auth.test.ts +++ b/tests/server/server-auth.test.ts @@ -909,7 +909,10 @@ describe("server local API auth", () => { const health = await fetch(`http://127.0.0.1:${server.port}/healthz`); expect(health.status).toBe(200); const healthBody = await health.json() as Record; - expect(Object.keys(healthBody).sort()).toEqual([ + // OCX-2: the opaque Home instance id is the one identity field, present once the install + // identity exists and always in its fixed random shape. + if ("instanceId" in healthBody) expect(healthBody.instanceId).toMatch(/^home_[0-9a-f]{32}$/); + expect(Object.keys(healthBody).filter(key => key !== "instanceId").sort()).toEqual([ "asideSyncCapability", "guiPairCapability", "pid",