Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/server/index/serve-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
55 changes: 54 additions & 1 deletion tests/server/context-history-ownership.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
});
17 changes: 16 additions & 1 deletion tests/server/link-listener-admission.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
});
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

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}`;
Expand Down
Loading