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 docs-site/src/content/docs/guides/remote-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ When a step fails, the dashboard shows the reason and, when SSH reported one, th

## Security

The Child uses the Home computer's providers and provider credentials through the link. The Home creates a separate link key for each Child; removing the link revokes that key. On the Child, the key stays inside OpenCodex: credentials that Codex or Claude Code send there are not forwarded to the Home, and any program on the Child that reaches `127.0.0.1:<port>` uses the Home without a key, the same local trust a standalone install gives. Web pages from other sites are refused. Compare the host fingerprint before confirmation so a wrong machine or changed host key is not accepted by mistake. Dashboard sessions issued from a Tailscale identity cannot manage machine links.
The Child uses the Home computer's providers and provider credentials through the link. The Home creates a separate link key for each Child; removing the link revokes that key. On the Child, the key stays inside OpenCodex: credentials that Codex or Claude Code send there are not forwarded to the Home, including Bearer, Azure `api-key`, Anthropic-compatible `x-api-key`, and Google `x-goog-api-key` forms. Any program on the Child that reaches `127.0.0.1:<port>` uses the Home without a key, the same local trust a standalone install gives. Web pages from other sites are refused. Compare the host fingerprint before confirmation so a wrong machine or changed host key is not accepted by mistake. Dashboard sessions issued from a Tailscale identity cannot manage machine links.

## CLI reference

Expand Down
5 changes: 4 additions & 1 deletion src/client/link-relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,10 @@ const RESPONSE_OMITTED_HEADERS = new Set(["content-encoding", "content-length"])
* Caller credentials never cross the tunnel. The Child's own ChatGPT or Anthropic credential
* stays on the Child, and the Home sees exactly one admission: the link key.
*/
const CALLER_CREDENTIAL_HEADERS = ["authorization", "x-api-key", "x-opencodex-api-key", "chatgpt-account-id", "cookie"] as const;
const CALLER_CREDENTIAL_HEADERS = [
"authorization", "api-key", "x-api-key", "x-goog-api-key", "x-opencodex-api-key",
"chatgpt-account-id", "cookie",
] as const;

function jsonError(status: number, error: string, retry = false): Response {
const headers = retry ? { "Retry-After": String(LINK_RELAY_RETRY_AFTER_SECONDS) } : undefined;
Expand Down
12 changes: 12 additions & 0 deletions structure/decisions/ADR-6032-link-relay-credential-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# ADR-6032 — Child link relay credential boundary

- Contract owner: [Remote Link](../remote-link.md)

## Decision record

- Purpose and intent: Ensure a Child sends only its link admission key across the machine tunnel, never a caller's provider credential.
- Existing implementation and constraints: The relay already removed Bearer, `x-api-key`, OpenCodex, account and cookie credentials before attaching the link key. Built-in Azure and Google adapters use the independent `api-key` and `x-goog-api-key` forms, which were not in that denylist and therefore survived ordinary end-to-end header forwarding.
- Alternatives considered: Strip every header containing `key` or `token`; reuse the broad log-redaction regex; extend the relay's explicit list with the provider credential forms it actually supports.
- Chosen approach: Add `api-key` and `x-goog-api-key` to the case-insensitive explicit relay denylist and exercise them through both header construction and the actual fetch boundary.
- Why this approach: A broad name heuristic could remove legitimate protocol headers such as `idempotency-key`. The explicit list closes the proven built-in adapter paths while preserving ordinary request metadata and the existing link-key wire contract.
- Benefits, costs and impact: Azure and Google caller keys remain on the Child, matching the existing Anthropic/OpenAI behavior. Custom credential header names still require deliberate review before they become supported provider authentication forms.
4 changes: 3 additions & 1 deletion structure/remote-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ Codex keeps the standalone loopback routing: `routingTarget` in `src/client/conn

`src/client/link-ingress.ts` is the link-mode data plane of the machine listener. A `/v1/responses` WebSocket upgrade answers `426 upgrade_required`, which codex-rs maps to its HTTP fallback, and no upgrade is ever relayed. Every relayed route first passes the standalone loopback Host and Origin gate (`isAllowedRequestOrigin` in `src/server/auth-cors.ts`), so a rebinding or cross-site page gets `403 origin_rejected` and nothing is fetched upstream. `/readyz` is answered locally. The link key is read from the service token file once, when the listener starts, and held in memory; the file must hold the key whose fingerprint the connection committed. While it does not, relayed routes answer `503 link_credential_unavailable` without an upstream fetch, and the file is read again at most once a second; a valid key is never re-read. The key is never logged or returned.

`src/client/link-relay.ts` forwards exactly the `linkRouteAllowed` routes from `src/link/routes.ts` through the tunnel. It drops the caller's `Authorization`, `x-api-key`, `x-opencodex-api-key`, `chatgpt-account-id` and `cookie` and sends the link key as `Authorization: Bearer`, the wire an `env_key` config sent; `GET /v1/usage` takes it as `x-opencodex-api-key`, the only header that route admits. The Home admits the key and serves the Child with its own accounts. The request body is streamed chunk by chunk with the caller's `Content-Length` and a byte-counting cap at the inbound limit (`resolveInboundBodyLimitBytes`, 256 MiB by default); a larger declared or streamed body answers 413. A lone `Transfer-Encoding: chunked` without `Content-Length` is admitted as a standalone admits it, because the listener has already de-chunked the body; any other Transfer-Encoding, or one next to a `Content-Length`, answers 400. The Home's response headers may take up to 300 seconds, and a caller abort ends the wait sooner. SSE passes through chunk by chunk with caller-abort propagation and a 300-second idle limit, other response bodies stream under the same byte cap, and the relay answers 503 with Retry-After while the tunnel is down. The client supervisor is the relay's tunnel gate (`LinkTunnelGate`): only while the tunnel is connecting or reconnecting (including the start of the client runtime) does a relayed request wait, for at most 15 seconds (`LINK_RELAY_HOLD_MS`) from its first wait and with at most 64 requests waiting, before it is forwarded once; a connected tunnel costs one `pending()` call per request, and a failed one answers 503 at once. A forward whose connection was refused sent nothing, so while the tunnel reconnects it may wait again and be sent again inside the same 15 seconds, provided the streamed body was never read or cancelled; any other failure (a reset, a timeout, a failure after the body started) is never replayed. Both the Child's machine listener and the Home's hub-link listener bind with `idleTimeout: 255`, the public listener's limit, so a held or slow turn is not cut by Bun's 10-second default. Like a standalone data route, a relayed request then lifts its own idle timer (`server.timeout(req, 0)` in `src/client/link-ingress.ts`), so a quiet stretch longer than 255 seconds inside a long generation is not cut either; the relay's header deadline, SSE idle limit and caller abort bound the wait instead. Hub transport keeps the 4 MiB management-relay listener bound and its default idle limit. Link mode waits for the configured port without signalling its holder, then binds there or fails; `src/client/runtime.ts` passes the cached link key, tunnel status and tunnel gate through `bindClientListener` to every bind attempt. Link mode turns the management relay off and refuses key rotation and revocation, which belong to the hub.
`src/client/link-relay.ts` forwards exactly the `linkRouteAllowed` routes from `src/link/routes.ts` through the tunnel. It drops the caller's `Authorization`, Azure `api-key`, Anthropic-compatible `x-api-key`, Google `x-goog-api-key`, `x-opencodex-api-key`, `chatgpt-account-id` and `cookie` and sends the link key as `Authorization: Bearer`, the wire an `env_key` config sent; `GET /v1/usage` takes it as `x-opencodex-api-key`, the only header that route admits. The explicit provider forms matter because the Child and Home are separate credential owners: caller provider credentials never cross the tunnel merely because they are not Bearer tokens. The Home admits the link key and serves the Child with its own accounts. The request body is streamed chunk by chunk with the caller's `Content-Length` and a byte-counting cap at the inbound limit (`resolveInboundBodyLimitBytes`, 256 MiB by default); a larger declared or streamed body answers 413. A lone `Transfer-Encoding: chunked` without `Content-Length` is admitted as a standalone admits it, because the listener has already de-chunked the body; any other Transfer-Encoding, or one next to a `Content-Length`, answers 400. The Home's response headers may take up to 300 seconds, and a caller abort ends the wait sooner. SSE passes through chunk by chunk with caller-abort propagation and a 300-second idle limit, other response bodies stream under the same byte cap, and the relay answers 503 with Retry-After while the tunnel is down. The client supervisor is the relay's tunnel gate (`LinkTunnelGate`): only while the tunnel is connecting or reconnecting (including the start of the client runtime) does a relayed request wait, for at most 15 seconds (`LINK_RELAY_HOLD_MS`) from its first wait and with at most 64 requests waiting, before it is forwarded once; a connected tunnel costs one `pending()` call per request, and a failed one answers 503 at once. A forward whose connection was refused sent nothing, so while the tunnel reconnects it may wait again and be sent again inside the same 15 seconds, provided the streamed body was never read or cancelled; any other failure (a reset, a timeout, a failure after the body started) is never replayed. Both the Child's machine listener and the Home's hub-link listener bind with `idleTimeout: 255`, the public listener's limit, so a held or slow turn is not cut by Bun's 10-second default. Like a standalone data route, a relayed request then lifts its own idle timer (`server.timeout(req, 0)` in `src/client/link-ingress.ts`), so a quiet stretch longer than 255 seconds inside a long generation is not cut either; the relay's header deadline, SSE idle limit and caller abort bound the wait instead. Hub transport keeps the 4 MiB management-relay listener bound and its default idle limit. Link mode waits for the configured port without signalling its holder, then binds there or fails; `src/client/runtime.ts` passes the cached link key, tunnel status and tunnel gate through `bindClientListener` to every bind attempt. Link mode turns the management relay off and refuses key rotation and revocation, which belong to the hub.

> Decision record: [ADR-6032](decisions/ADR-6032-link-relay-credential-boundary.md)

Regression coverage lives in `tests/clients/link-ssh-argv.test.ts`, `tests/clients/link-ssh-config.test.ts`, `tests/clients/link-tunnel-state.test.ts`, `tests/clients/link-store.test.ts`, `tests/clients/link-boundary.test.ts`, `tests/clients/link-routes.test.ts`, `tests/clients/client-link-connect.test.ts`, `tests/clients/client-link-relay.test.ts`, `tests/clients/client-machine-listener.test.ts`, `tests/clients/client-link-status.test.ts`, `tests/clients/client-link-runtime.test.ts`, `tests/codex-integration/injection-link-websocket.test.ts`, `tests/clients/link-supervisor.test.ts`, `tests/clients/link-status-projection.test.ts`, `tests/clients/link-admission-wait.test.ts`, `tests/clients/link-fingerprint.test.ts`, `tests/cli/cli-link.test.ts`, `tests/server/link-management-routes.test.ts`, `tests/server/link-join-route.test.ts`, `tests/server/link-listener-lifecycle.test.ts`, `tests/clients/client-link-teardown.test.ts` and `gui/tests/remote-link.test.tsx`.
38 changes: 33 additions & 5 deletions tests/clients/client-link-relay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,13 @@ describe("client link HTTP relay", () => {
test("replaces every caller credential with the link key and filters hop-by-hop headers", () => {
const caller = new Headers({
Authorization: "Bearer caller-chatgpt-oauth",
"Api-Key": "azure-caller",
"X-OpenCodex-API-Key": "ocx_data_caller",
"X-Api-Key": "sk-ant-caller",
"X-Goog-Api-Key": "google-caller",
"ChatGPT-Account-Id": "acct-caller",
Cookie: "session=caller",
"Idempotency-Key": "idem-1",
"X-Trace": "trace-1",
Connection: "keep-alive, X-Remove",
"X-Remove": "secret",
Expand All @@ -117,15 +120,18 @@ describe("client link HTTP relay", () => {
});
const forwarded = forwardLinkRequestHeaders(caller, LINK_KEY, "/v1/responses");
expect(forwarded.get("authorization")).toBe(`Bearer ${LINK_KEY}`);
expect(forwarded.get("idempotency-key")).toBe("idem-1");
expect(forwarded.get("x-trace")).toBe("trace-1");
for (const name of ["x-opencodex-api-key", "x-api-key", "chatgpt-account-id", "cookie", "connection", "keep-alive", "x-remove", "host", "content-length"]) {
for (const name of ["api-key", "x-opencodex-api-key", "x-api-key", "x-goog-api-key", "chatgpt-account-id", "cookie", "connection", "keep-alive", "x-remove", "host", "content-length"]) {
expect(forwarded.get(name)).toBeNull();
}
// /v1/usage admits only the dedicated header on the Home.
const usage = forwardLinkRequestHeaders(caller, LINK_KEY, "/v1/usage");
expect(usage.get("x-opencodex-api-key")).toBe(LINK_KEY);
expect(usage.get("authorization")).toBeNull();
expect(usage.get("api-key")).toBeNull();
expect(usage.get("x-api-key")).toBeNull();
expect(usage.get("x-goog-api-key")).toBeNull();

const response = sanitizeLinkResponseHeaders(new Headers({
Connection: "X-Response-Secret",
Expand All @@ -145,18 +151,32 @@ describe("client link HTTP relay", () => {
const fetchImpl = (async (_input, init) => { sent.push(new Headers(init?.headers)); return Response.json({ ok: true }); }) as typeof fetch;
const response = await relayLinkDataRequest(relayRequest({
method: "POST",
headers: { Authorization: "Bearer caller-chatgpt-oauth", "ChatGPT-Account-Id": "acct-caller", "Content-Type": "application/json" },
headers: {
Authorization: "Bearer caller-chatgpt-oauth",
"Api-Key": "azure-caller",
"X-Goog-Api-Key": "google-caller",
"ChatGPT-Account-Id": "acct-caller",
"Content-Type": "application/json",
},
body: "{}",
}), target, { fetchImpl });
expect(response.status).toBe(200);
expect(sent[0]?.get("authorization")).toBe(`Bearer ${LINK_KEY}`);
expect(sent[0]?.get("api-key")).toBeNull();
expect(sent[0]?.get("x-goog-api-key")).toBeNull();
expect(sent[0]?.get("chatgpt-account-id")).toBeNull();
const usage = await relayLinkDataRequest(new Request("http://127.0.0.1:10100/v1/usage", {
headers: { Authorization: "Bearer caller-chatgpt-oauth" },
headers: {
Authorization: "Bearer caller-chatgpt-oauth",
"Api-Key": "azure-caller",
"X-Goog-Api-Key": "google-caller",
},
}), target, { fetchImpl });
expect(usage.status).toBe(200);
expect(sent[1]?.get("x-opencodex-api-key")).toBe(LINK_KEY);
expect(sent[1]?.get("authorization")).toBeNull();
expect(sent[1]?.get("api-key")).toBeNull();
expect(sent[1]?.get("x-goog-api-key")).toBeNull();
});

test("rejects TE/CL ambiguity and oversized requests before outbound I/O", async () => {
Expand Down Expand Up @@ -386,6 +406,8 @@ describe("client link HTTP relay", () => {
path: new URL(req.url).pathname + new URL(req.url).search,
host: req.headers.get("host"),
authorization: req.headers.get("authorization"),
apiKey: req.headers.get("api-key"),
googleApiKey: req.headers.get("x-goog-api-key"),
dedicated: req.headers.get("x-opencodex-api-key"),
contentLength: req.headers.get("content-length"),
transferEncoding: req.headers.get("transfer-encoding"),
Expand All @@ -400,14 +422,20 @@ describe("client link HTTP relay", () => {
const body = JSON.stringify({ input: "hello" });
const response = await fetch(new URL("/v1/responses?trace=1", machine.url), {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer caller-chatgpt-oauth", "X-OpenCodex-API-Key": "ocx_data_caller" },
headers: {
"Content-Type": "application/json",
Authorization: "Bearer caller-chatgpt-oauth",
"Api-Key": "azure-caller",
"X-Goog-Api-Key": "google-caller",
"X-OpenCodex-API-Key": "ocx_data_caller",
},
body,
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ relayed: true });
expect(received).toEqual({
method: "POST", path: "/v1/responses?trace=1", host: `127.0.0.1:${hub.port}`,
authorization: `Bearer ${LINK_KEY}`, dedicated: null,
authorization: `Bearer ${LINK_KEY}`, apiKey: null, googleApiKey: null, dedicated: null,
contentLength: String(body.length), transferEncoding: null, body,
});
expect((await fetch(new URL("/v1/unknown", machine.url))).status).toBe(404);
Expand Down
Loading