diff --git a/src/cli/status.ts b/src/cli/status.ts index cc1f4506bf..0743c0cb3b 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -22,6 +22,7 @@ import type { HubStateOAuthEntry, HubStateProvider } from "../remote/hub-state"; import type { HubStateSource } from "../client/hub-state"; import { readServiceApiTokenState, serviceApiTokenFilePath } from "../lib/service-secrets"; import { tokenCollidesWithAdmin } from "../lib/admin-secrets"; +import { readClientConnectionState } from "../client/state"; export { proxyHealthFailureReason, isConnectionRefused, isUncleanExitEvidence, probeUncleanExitState } from "./status-probes"; export type { ListenTarget } from "./status-probes"; import { checkProxyHealth, probeUncleanExitState, type ListenTarget } from "./status-probes"; @@ -340,14 +341,25 @@ export async function collectRemoteHubStatus( return disconnectedRemoteHubStatus(); } const { resolveHubState } = await import("../client/hub-state"); + // Re-read the connection and token as one ownership observation. The connection passed above + // is an earlier status snapshot; combining its URL with an independently read token can send a + // newly rotated or reconnected credential to the old hub. A transition between these two reads + // is also safe: the new token's fingerprint cannot match the old connection generation. + const currentConnection = readClientConnectionState(); const token = readServiceApiTokenState(); + const tokenBelongsToSnapshot = currentConnection.kind === "connected" + && currentConnection.value.serverUrl === connection.serverUrl + && currentConnection.value.apiKeyId === connection.apiKeyId + && currentConnection.value.connectedAt === connection.connectedAt + && token.kind === "present" + && token.fingerprint === currentConnection.value.tokenFingerprint; const resolved = await resolveHubState({ owner: { serverUrl: connection.serverUrl, apiKeyId: connection.apiKeyId, connectedAt: connection.connectedAt, }, - token: token.kind === "present" ? token.token : null, + token: tokenBelongsToSnapshot ? token.token : null, ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), ...(options.now === undefined ? {} : { now: options.now }), diff --git a/tests/cli/cli-status-hub-state.test.ts b/tests/cli/cli-status-hub-state.test.ts index e396f023e1..843297c3bd 100644 --- a/tests/cli/cli-status-hub-state.test.ts +++ b/tests/cli/cli-status-hub-state.test.ts @@ -88,6 +88,13 @@ function jsonFetch(body: unknown): typeof fetch { })) as unknown as typeof fetch; } +function rejectingFetch(onCall: () => void): typeof fetch { + return (async () => { + onCall(); + throw new Error("fetch must not be called"); + }) as unknown as typeof fetch; +} + beforeEach(() => { testHome = mkdtempSync(join(tmpdir(), "ocx-status-hub-")); process.env.OPENCODEX_HOME = testHome; @@ -126,6 +133,30 @@ describe("collectRemoteHubStatus", () => { expect(remoteHub.reason).toContain("data-plane token"); }); + test("a mismatched token is never sent to the snapshotted hub", async () => { + writeConnectedHome(testHome, "https://hub.example.test:8443"); + writeFileSync(join(testHome, "service-api-token"), "token-from-another-connection", { mode: 0o600 }); + let fetchCalls = 0; + const remoteHub = await collectRemoteHubStatus( + { state: "connected", serverUrl: "https://hub.example.test:8443", apiKeyId: "status-hub-state", connectedAt: "2026-09-06T00:00:00.000Z" }, + { fetchImpl: rejectingFetch(() => fetchCalls++) }, + ); + expect(fetchCalls).toBe(0); + expect(remoteHub.stateSource).toBe("unavailable"); + expect(remoteHub.reason).toContain("data-plane token"); + }); + + test("a new connection's token is never sent to an earlier connection snapshot", async () => { + const snapshot = { state: "connected" as const, serverUrl: "https://hub-a.example.test", apiKeyId: "status-hub-state", connectedAt: "2026-09-06T00:00:00.000Z" }; + writeConnectedHome(testHome, "https://hub-b.example.test"); + let fetchCalls = 0; + const remoteHub = await collectRemoteHubStatus(snapshot, { + fetchImpl: rejectingFetch(() => fetchCalls++), + }); + expect(fetchCalls).toBe(0); + expect(remoteHub.stateSource).toBe("unavailable"); + }); + test("a disconnected machine asks the hub nothing", async () => { const remoteHub = await collectRemoteHubStatus({ state: "disconnected" }); expect(remoteHub).toEqual(disconnectedRemoteHubStatus());