From 094f12713199a66efa33418399eda2f04feda0a0 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:18:16 +0900 Subject: [PATCH] fix(cli): bind hub status credentials to connection snapshots --- .../src/content/docs/guides/remote-hub.md | 4 + .../src/content/docs/ko/guides/remote-hub.md | 7 ++ src/cli/status.ts | 23 +++-- structure/clients/claude-desktop.md | 3 + structure/config.md | 3 + structure/ops/docs-and-release.md | 4 + structure/runtime.md | 8 ++ tests/cli/cli-status-hub-state.test.ts | 96 ++++++++++++++++++- 8 files changed, 141 insertions(+), 7 deletions(-) diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 1f2db4486c..dce98d2a86 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -92,6 +92,10 @@ local login state, and `ocx config show` on a client prints a `_remoteHub` note credentials and model availability live on the hub. The hub read uses the per-client data key only; no admin token and no provider secret ever reaches a client. +`ocx status` makes a live hub-state request only when the saved connection still matches the +status snapshot and the data-token file matches that connection. If either check fails, it skips +the request and shows matching cached hub state, or `unavailable` if no matching cache exists. + ## Linux systemd or macOS launchd Bind the data listener to the hub's Tailscale address, enable the loopback companion so the hub's diff --git a/docs-site/src/content/docs/ko/guides/remote-hub.md b/docs-site/src/content/docs/ko/guides/remote-hub.md index 72e96ae263..f47ae658fe 100644 --- a/docs-site/src/content/docs/ko/guides/remote-hub.md +++ b/docs-site/src/content/docs/ko/guides/remote-hub.md @@ -36,6 +36,13 @@ ocx sync 허브가 발급한 클라이언트별 키는 권한이 제한된 `service-api-token` 파일에 저장됩니다. `config.json`에는 저장되지 않습니다. 연결 중 사용량은 허브 기록에서 해당 `apiKeyId`만 조회하고, 연결을 끊은 뒤에는 로컬 기록을 봅니다. 두 기록은 서로 복제되지 않습니다. +### 연결된 클라이언트의 상태 표시 + +연결된 클라이언트의 `ocx status`는 허브 상태를 표시합니다. 상태 수집 중 연결 정보가 +바뀌거나 데이터 토큰 파일이 현재 연결과 일치하지 않으면 실시간 허브 조회를 건너뜁니다. +이때 상태 조회 대상 연결의 캐시를 표시하고, 일치하는 캐시가 없으면 `unavailable`로 보고합니다. +`ocx status --json`의 `remoteHub.stateSource`는 `hub`, `cache`, `unavailable` 중 하나입니다. + ## systemd 또는 launchd 데이터 리스너는 허브의 Tailscale 주소에 바인드하고, 허브 자신의 프로세스가 같은 포트를 자격 증명 없이 쓸 수 있도록 루프백 companion을 켜고, 관리 평면은 따로 공개합니다. 아래 값은 예시입니다. diff --git a/src/cli/status.ts b/src/cli/status.ts index cc1f4506bf..45c9ac8c1a 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -18,6 +18,7 @@ import { effectiveLoopbackListenerPort } from "../codex/loopback-target"; import { claudeDesktopIntegrationEnabled } from "../codex/desired-state"; import { claudeDesktopPolicyHealth, probeClaudeDesktopPolicy, type ClaudeDesktopPolicyHealth } from "../claude/desktop-policy"; import { collectClientConnectionStatus, type ClientConnectionStatus } from "./connect"; +import { readClientConnectionState, sameClientConnectionOwner } from "../client/state"; import type { HubStateOAuthEntry, HubStateProvider } from "../remote/hub-state"; import type { HubStateSource } from "../client/hub-state"; import { readServiceApiTokenState, serviceApiTokenFilePath } from "../lib/service-secrets"; @@ -340,14 +341,24 @@ export async function collectRemoteHubStatus( return disconnectedRemoteHubStatus(); } const { resolveHubState } = await import("../client/hub-state"); + const current = readClientConnectionState(); const token = readServiceApiTokenState(); + const owner = { + serverUrl: connection.serverUrl, + apiKeyId: connection.apiKeyId, + connectedAt: connection.connectedAt, + }; + // A reconnect or rotation may have replaced the files since the status snapshot. + // A matching cache owner alone does not authorize sending the current token. + const boundToken = current.kind === "connected" + && sameClientConnectionOwner(current.value, owner) + && token.kind === "present" + && token.fingerprint === current.value.tokenFingerprint + ? token.token + : null; const resolved = await resolveHubState({ - owner: { - serverUrl: connection.serverUrl, - apiKeyId: connection.apiKeyId, - connectedAt: connection.connectedAt, - }, - token: token.kind === "present" ? token.token : null, + owner, + token: boundToken, ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), ...(options.now === undefined ? {} : { now: options.now }), diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 795aa19045..4a2b6b4833 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -66,6 +66,9 @@ or profile-upload API. Thinking replay and prompt caching remain separate in #37 The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. +Connected `ocx status` diagnostics follow the shared +[status credential binding](../runtime.md#remote-hub-status-credential-binding). + ## Claude Desktop config-library resolution The Desktop profile writer and the management status probe share diff --git a/structure/config.md b/structure/config.md index a54368b163..706c70febb 100644 --- a/structure/config.md +++ b/structure/config.md @@ -203,6 +203,9 @@ the residual directory for manual review; there is no recursive-delete fallback. ## Remote client key files +The connection's `tokenFingerprint` participates in +[`ocx status` credential binding](runtime.md#remote-hub-status-credential-binding). + Client connection metadata stores a stable `apiKeyId` and a non-secret rotation `pendingOperation`. The current data secret remains only in `service-api-token`; a bounded rotation temporarily keeps the old secret in owner-only `service-api-token.prev`. Commit or recovery clears the marker before orphan cleanup. `ocx disconnect` is local-only and leaves remote revocation to the hub's **Integrations → API Keys** page. Hub and local usage stores are not mirrored. Codex display-cache expiry, retained main-policy evidence, and reset history follow the diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 74b20aff6e..bf880bfb9d 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -304,6 +304,10 @@ The shared Responses path follows the [bounded multipart recovery contract](../s ## Remote Hub locale and release gate +The Remote Hub guide describes the +[status credential binding](../runtime.md#remote-hub-status-credential-binding) +and its matching-cache or `unavailable` result. + The Remote Hub guide and affected CLI, server-config, management-API, and dashboard references have eight sources: root English plus `fr`, `ko`, `zh-cn`, `zh-tw`, `ru`, `ja`, and `tr`. English is canonical; commands, defaults, endpoint auth, and warnings remain exact in translations. A release requires the remote-only focused/full gates, privacy scan, GUI/docs builds, protocol compatibility receipts, and the MAINTAINERS security review for the exact head. Codex display-cache expiry, retained main-policy evidence, and reset history follow the diff --git a/structure/runtime.md b/structure/runtime.md index fe99ed8b64..557474a786 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -210,6 +210,14 @@ The shared Responses path follows the [bounded multipart recovery contract](suba `src/remote/protocol.ts` owns pure interval/feature negotiation. `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. +### Remote Hub status credential binding + +`src/cli/status.ts` rereads the persisted client connection and `service-api-token` state before +requesting hub state. It passes a usable token only when the current connection matches the +status snapshot's `serverUrl`, `apiKeyId`, and `connectedAt`, and the token fingerprint matches +the current connection's `tokenFingerprint`. Otherwise it skips the live request and uses the +snapshot owner's matching cached hub state, or reports `unavailable`. + Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). diff --git a/tests/cli/cli-status-hub-state.test.ts b/tests/cli/cli-status-hub-state.test.ts index e396f023e1..4224adb093 100644 --- a/tests/cli/cli-status-hub-state.test.ts +++ b/tests/cli/cli-status-hub-state.test.ts @@ -27,6 +27,7 @@ import { type CliRemoteHubStatus, } from "../../src/cli/status"; import type { HubStateDTO } from "../../src/remote/hub-state"; +import { writeCachedHubState } from "../../src/client/hub-state"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; @@ -88,6 +89,27 @@ function jsonFetch(body: unknown): typeof fetch { })) as unknown as typeof fetch; } +function observedHubFetch() { + const requests: { url: string; token: string | null }[] = []; + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ + url: String(input), + token: new Headers(init?.headers).get("x-opencodex-api-key"), + }); + return Response.json(hubState()); + }) as typeof fetch; + return { requests, fetchImpl }; +} + +function connectionSnapshot() { + return { + state: "connected" as const, + serverUrl: "https://hub.example.test:8443", + apiKeyId: "status-hub-state", + connectedAt: "2026-09-06T00:00:00.000Z", + }; +} + beforeEach(() => { testHome = mkdtempSync(join(tmpdir(), "ocx-status-hub-")); process.env.OPENCODEX_HOME = testHome; @@ -103,10 +125,15 @@ afterEach(() => { describe("collectRemoteHubStatus", () => { test("a connected client with a live hub reports the hub's facts", async () => { writeConnectedHome(testHome, "https://hub.example.test:8443"); + const probe = observedHubFetch(); const remoteHub = await collectRemoteHubStatus( { state: "connected", serverUrl: "https://hub.example.test:8443", apiKeyId: "status-hub-state", connectedAt: "2026-09-06T00:00:00.000Z" }, - { fetchImpl: jsonFetch(hubState()) }, + { fetchImpl: probe.fetchImpl }, ); + expect(probe.requests).toEqual([{ + url: "https://hub.example.test:8443/v1/hub-state", + token: FIXTURE_TOKEN, + }]); expect(remoteHub.connected).toBe(true); expect(remoteHub.stateSource).toBe("hub"); expect(remoteHub.hubVersion).toBe("2.51.0"); @@ -115,6 +142,73 @@ describe("collectRemoteHubStatus", () => { expect(remoteHub.claudeCodeEnabled).toBe(true); }); + test.each([ + ["origin", { serverUrl: "https://other-hub.example.test" }], + ["key", { apiKeyId: "another-client-key" }], + ["enrollment", { connectedAt: "2026-09-07T00:00:00.000Z" }], + ])("a changed %s never sends a credential for the old snapshot", async (_label, changed) => { + const snapshot = connectionSnapshot(); + const config = connectedConfig(snapshot.serverUrl); + Object.assign(config.client, changed); + writeFileSync(join(testHome, "config.json"), JSON.stringify(config)); + writeFileSync(join(testHome, "service-api-token"), FIXTURE_TOKEN, { mode: 0o600 }); + const probe = observedHubFetch(); + + const remoteHub = await collectRemoteHubStatus(snapshot, { fetchImpl: probe.fetchImpl }); + + expect(probe.requests).toEqual([]); + expect(remoteHub.stateSource).toBe("unavailable"); + expect(remoteHub.providers).toEqual([]); + }); + + test("a persistent token fingerprint mismatch never sends the changed token", async () => { + const snapshot = connectionSnapshot(); + writeConnectedHome(testHome, snapshot.serverUrl); + writeFileSync(join(testHome, "service-api-token"), "unowned-status-token", { mode: 0o600 }); + const probe = observedHubFetch(); + + const remoteHub = await collectRemoteHubStatus(snapshot, { fetchImpl: probe.fetchImpl }); + + expect(probe.requests).toEqual([]); + expect(remoteHub.stateSource).toBe("unavailable"); + expect(remoteHub.reason).toContain("data-plane token"); + }); + + test("a token rotation during the asynchronous boundary cannot reach the old hub", async () => { + const snapshot = connectionSnapshot(); + writeConnectedHome(testHome, snapshot.serverUrl); + const probe = observedHubFetch(); + const pending = collectRemoteHubStatus(snapshot, { fetchImpl: probe.fetchImpl }); + // The collector has yielded before reading credentials. Rotate the actual files before + // its continuation runs, including a fingerprint that legitimately owns the NEW token. + const config = connectedConfig("https://other-hub.example.test"); + const rotatedToken = "rotated-status-token"; + config.client.apiKeyId = "rotated-client-key"; + config.client.tokenFingerprint = createHash("sha256").update(rotatedToken).digest("hex"); + writeFileSync(join(testHome, "config.json"), JSON.stringify(config)); + writeFileSync(join(testHome, "service-api-token"), rotatedToken, { mode: 0o600 }); + + const remoteHub = await pending; + + expect(probe.requests).toEqual([]); + expect(remoteHub.stateSource).toBe("unavailable"); + }); + + test.each([true, false])("a mismatched connection uses only the snapshot's cache (matching=%s)", async matching => { + const snapshot = connectionSnapshot(); + const current = connectedConfig("https://other-hub.example.test"); + writeFileSync(join(testHome, "config.json"), JSON.stringify(current)); + writeFileSync(join(testHome, "service-api-token"), FIXTURE_TOKEN, { mode: 0o600 }); + expect(writeCachedHubState(matching ? snapshot : current.client, hubState(), "2026-09-06T00:00:00.000Z")).toBe(true); + const probe = observedHubFetch(); + + const remoteHub = await collectRemoteHubStatus(snapshot, { fetchImpl: probe.fetchImpl }); + + expect(probe.requests).toEqual([]); + expect(remoteHub.stateSource).toBe(matching ? "cache" : "unavailable"); + expect(remoteHub.providers).toEqual(matching ? hubState().providers : []); + }); + test("a client whose token file is missing is unavailable, not locally sourced", async () => { writeFileSync(join(testHome, "config.json"), JSON.stringify(connectedConfig("https://hub.example.test:8443"))); const remoteHub = await collectRemoteHubStatus(