diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 682c2d8fe58..97a7c674b70 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -1398,7 +1398,7 @@ export function createServeOptions(ctx: ServeOptionsContext) { }; return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => { const response = await handleContextHistory(req, config, logCtx, contextEndpoint(url.pathname)!, - turnAdmissionLease, admission, () => resolveApiAuth(req, policy)); + turnAdmissionLease, admission, () => resolveApiAuth(req, ingress === "hub-link" ? linkPolicy() : policy)); addFinalRequestLog(requestId, start, logCtx, response.status, response.status === 499 ? { closeReason: "client_cancel" } : undefined); return withCors(response, req, policy); diff --git a/structure/runtime.md b/structure/runtime.md index fd99beb93a0..3e4e455314e 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -232,7 +232,7 @@ GUI, session bootstrap/exchange, and `/api/*`. The `hub-link` socket is HTTP-only and default-denies all but the fixed data routes, catalog, hub-state, usage, and `GET /readyz`; every `Upgrade` header, management, GUI, session, health, and unknown `/v1/*` route is rejected before dispatch. Its `opencodex-link.invalid` policy admits only configured -key ids recorded by `links.json`, never the environment token. `ensureStarted()` is single-flight, +key ids recorded by `links.json`, never the environment token. Context relays rebuild that policy for their post-body admission check, so key revocation stops an in-flight request before dispatch. `ensureStarted()` is single-flight, final deletion closes the listener, and `src/server/index/optional-listeners.ts` runs supervisor teardown before closing this listener and the Claude intercept pair. ### Claude intercept pair diff --git a/tests/server/context-history-ownership.test.ts b/tests/server/context-history-ownership.test.ts index 04c10475847..66109db3e85 100644 --- a/tests/server/context-history-ownership.test.ts +++ b/tests/server/context-history-ownership.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { handleResponses } from "../../src/server/responses"; import { handleContextHistory } from "../../src/server/context-history"; import { tryAdmitTurn } from "../../src/server/lifecycle"; -import type { DataPlaneAdmission } from "../../src/server/auth-cors"; +import { requestPolicyView, resolveApiAuth, type DataPlaneAdmission } from "../../src/server/auth-cors"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; import { clearAccountNeedsReauth } from "../../src/codex/account-runtime-state"; import { clearAccountQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; @@ -176,3 +176,56 @@ test("Direct proxy-bearer model and notes use stored main without leaking the pr expect(sent.map(row => row.headers.get("authorization"))).toEqual([`Bearer ${token}`, `Bearer ${token}`]); expect(sent.every(row => row.headers.get("chatgpt-account-id") === "physical-main")).toBe(true); }); + +test("post-body admission revalidation consults the live link policy, not the request-entry snapshot", async () => { + // The hub-link listener resolves its policy at request entry and again inside the context + // relay's post-body revalidation. This drives that gate with the same requestPolicyView/ + // resolveApiAuth pair the listener uses, so a key revoked mid-request must stop dispatch. + const LINK_KEY = "link-linked-revoke"; + const LINK_ID = "linked-key"; + const cfg = config(); + cfg.apiKeys = [{ id: LINK_ID, name: LINK_ID, key: LINK_KEY, createdAt: "2026-09-26T00:00:00.000Z" }]; + const linkIngress = { allowedKeyIds: new Set([LINK_ID]) }; + const linkPolicy = () => requestPolicyView(cfg, "opencodex-link.invalid", linkIngress); + + const linkRequest = (session: string) => new Request("http://opencodex-link.invalid/v1/alpha/notes/v2/read_file", { + method: "POST", + headers: { + "content-type": "application/json", + "x-opencodex-api-key": LINK_KEY, + authorization: "Bearer caller-native-token", + "chatgpt-account-id": "caller-account", + }, + body: JSON.stringify({ context: { session_id: session } }), + }); + const contextNotes = async (req: Request, admission: DataPlaneAdmission, revalidate: () => DataPlaneAdmission | null) => { + const lease = tryAdmitTurn(); expect(lease).not.toBeNull(); + try { + return await handleContextHistory(req, cfg, { model: "context_history", provider: "" }, + "alpha/notes/v2/read_file", lease!, admission, revalidate); + } finally { lease?.release(); } + }; + + const entryPolicy = linkPolicy(); + const entryAdmission = resolveApiAuth(linkRequest("root-link"), entryPolicy); + expect(entryAdmission?.contextPrincipalId).toBeDefined(); + // Record this principal's session owner the same way the ownership tests do: one model turn. + expect((await model(cfg, "root-link", "side/gpt-5.5", requestHeaders("root-link"), entryAdmission!)).status).toBe(200); + + // Revoke the key mid-request: the next policy rebuild no longer resolves this credential. + cfg.apiKeys = cfg.apiKeys?.filter(k => k.id !== LINK_ID); + + // Fixed wiring: the closure consults the live policy and the revoked key cannot dispatch. + { + const req = linkRequest("root-link"); + const denied = await contextNotes(req, entryAdmission!, () => resolveApiAuth(req, linkPolicy())); + expect(denied.status).toBe(401); + } + // Pre-fix wiring kept the request-entry snapshot and still dispatched upstream. + { + const req = linkRequest("root-link"); + const admitted = await contextNotes(req, entryAdmission!, () => resolveApiAuth(req, entryPolicy)); + expect(admitted.status).toBe(200); + } + expect(sent.filter(row => row.url.includes("/alpha/"))).toHaveLength(1); +}); diff --git a/tests/server/link-listener-admission.test.ts b/tests/server/link-listener-admission.test.ts index 004cee3f24a..0bda9ca8991 100644 --- a/tests/server/link-listener-admission.test.ts +++ b/tests/server/link-listener-admission.test.ts @@ -1,12 +1,14 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { OcxConfig } from "../../src/types"; import { linkStorePath } from "../../src/link/paths"; import { emptyLinkStore, writeLinkStore } from "../../src/link/store"; +import { requestPolicyView, resolveApiAuth } from "../../src/server/auth-cors"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath } from "../helpers/repo-root"; const ENV_KEY = "link-env-admission"; const OTHER_KEY = "link-other-admission"; @@ -152,6 +154,19 @@ afterEach(async () => { }); describe("hub-link admission", () => { + test("context revalidation refreshes the link policy after asynchronous request work", () => { + const source = readFileSync(repoPath("src/server/index/serve-options.ts"), "utf8"); + expect(source).toContain('() => resolveApiAuth(req, ingress === "hub-link" ? linkPolicy() : policy)'); + + const liveConfig = config(); + const req = new Request("http://opencodex-link.invalid/v1/alpha/notes/v2/read_file", { headers: headers(LINKED_KEY) }); + const initialPolicy = requestPolicyView(liveConfig, "opencodex-link.invalid", { allowedKeyIds: new Set([LINKED_ID]) }); + expect(resolveApiAuth(req, initialPolicy)?.kind).toBe("configured"); + liveConfig.apiKeys = liveConfig.apiKeys?.filter(key => key.id !== LINKED_ID); + const refreshedPolicy = requestPolicyView(liveConfig, "opencodex-link.invalid", { allowedKeyIds: new Set([LINKED_ID]) }); + expect(resolveApiAuth(req, refreshedPolicy)).toBeNull(); + }); + test("applies the four-credential matrix on every allowlisted route", async () => { const linkPort = JSON.parse(await Bun.file(linkStorePath()).text()).listenerPort as number; const base = `http://127.0.0.1:${linkPort}`;