From 859bc17aae31771d02d3dc8eac7f0a35c53a372e Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 02:38:44 +0900 Subject: [PATCH 01/11] feat(connect): persist fail-closed client ownership --- src/client/state.ts | 91 +++++++++++++++++++++++++++++++++++ src/config.ts | 78 ++++++++++++++++++++++++++++++ src/lib/service-secrets.ts | 72 ++++++++++++++++++++++++++- src/types.ts | 2 + src/types/config.ts | 24 +++++++++ tests/config.test.ts | 81 +++++++++++++++++++++++++++++++ tests/service-secrets.test.ts | 86 +++++++++++++++++++++++++++++++++ 7 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 src/client/state.ts create mode 100644 tests/service-secrets.test.ts diff --git a/src/client/state.ts b/src/client/state.ts new file mode 100644 index 0000000000..97e41f39c6 --- /dev/null +++ b/src/client/state.ts @@ -0,0 +1,91 @@ +import { readFileSync } from "node:fs"; +import { + getConfigPath, + mutatePersistedConfig, + readConfigDiagnostics, +} from "../config"; +import type { OcxClientConnectionConfig } from "../types"; + +export type ClientConnectionState = + | { kind: "disconnected" } + | { kind: "connected"; value: OcxClientConnectionConfig } + | { kind: "invalid"; reason: string } + | { kind: "mismatched"; reason: string }; + +function rawTopLevelConfig(): Record | null { + try { + const parsed = JSON.parse(readFileSync(getConfigPath(), "utf8").replace(/^\uFEFF/, "")) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : null; + } catch { + return null; + } +} + +export function readClientConnectionState(): ClientConnectionState { + const raw = rawTopLevelConfig(); + const diagnostics = readConfigDiagnostics(); + if (!raw) { + return diagnostics.source === "default" + ? { kind: "disconnected" } + : { kind: "invalid", reason: "config.json is missing or unreadable" }; + } + const hasClient = Object.hasOwn(raw, "client") && raw.client !== undefined; + const role = raw.runtimeRole; + if (role !== undefined && role !== "standalone" && role !== "hub" && role !== "client") { + return { kind: "invalid", reason: "config.json.runtimeRole is invalid" }; + } + if (!hasClient && (role === undefined || role === "standalone")) return { kind: "disconnected" }; + if (!hasClient && role === "hub") { + return { kind: "mismatched", reason: "runtimeRole=hub cannot be used as a connected client" }; + } + if (!hasClient || role !== "client") { + return { + kind: "mismatched", + reason: hasClient + ? "config.json.client is present without runtimeRole=client" + : "runtimeRole=client is present without config.json.client", + }; + } + const client = diagnostics.config.client; + if (!client) { + const warning = diagnostics.warnings?.find(value => value.startsWith("client")); + return { kind: "invalid", reason: warning ?? "config.json.client is malformed" }; + } + return { kind: "connected", value: client }; +} + +export function commitClientConnection( + state: OcxClientConnectionConfig, +): "committed" | "unchanged" { + const outcome = mutatePersistedConfig(config => { + const unchanged = config.runtimeRole === "client" + && JSON.stringify(config.client) === JSON.stringify(state); + if (!unchanged) { + config.runtimeRole = "client"; + config.client = structuredClone(state); + } + return { changed: !unchanged, value: undefined }; + }); + if (outcome.status === "committed" || outcome.status === "unchanged") return outcome.status; + throw new Error(`client state commit unavailable: ${outcome.reason}`); +} + +export function clearClientConnection( + expectedApiKeyId: string, +): "committed" | "absent" | "conflict" { + const outcome = mutatePersistedConfig(config => { + if (!config.client && config.runtimeRole !== "client") { + return { changed: false, value: "absent" as const }; + } + if (!config.client || config.runtimeRole !== "client" || config.client.apiKeyId !== expectedApiKeyId) { + return { changed: false, value: "conflict" as const }; + } + delete config.client; + delete config.runtimeRole; + return { changed: true, value: "committed" as const }; + }); + if (outcome.status === "unavailable") return "conflict"; + return outcome.value; +} diff --git a/src/config.ts b/src/config.ts index 5729537f60..13b949f172 100644 --- a/src/config.ts +++ b/src/config.ts @@ -895,6 +895,45 @@ const remoteGuiConfigSchema = z.object({ allowInsecureHttp: z.boolean().optional(), }).strict(); +const connectedClientIdSchema = z.enum(["codex", "claude"]); +const clientTimestampSchema = z.string().datetime({ offset: true }); +const clientOriginSchema = z.string().transform((value, ctx) => { + const origin = canonicalHttpOrigin(value); + if (!origin) { + ctx.addIssue({ code: "custom", message: "must be a canonical http(s) origin without credentials, path, query, or fragment" }); + return z.NEVER; + } + return origin; +}); +const clientConnectionSchema = z.object({ + serverUrl: clientOriginSchema, + managementUrl: clientOriginSchema, + managementTransport: z.enum(["direct", "relay"]), + selectedClients: z.array(connectedClientIdSchema).min(1).max(2).superRefine((clients, ctx) => { + if (new Set(clients).size !== clients.length) { + ctx.addIssue({ code: "custom", message: "must contain unique client ids" }); + } + }), + tokenEnv: z.literal("OPENCODEX_API_AUTH_TOKEN"), + apiKeyId: z.string().trim().min(1).max(256), + tokenFingerprint: z.string().regex(/^[a-f0-9]{64}$/), + protocolVersion: z.literal(1), + connectedAt: clientTimestampSchema, + catalogEtag: z.string().min(1).max(512).optional(), + catalogSyncedAt: clientTimestampSchema.optional(), + pendingOperation: z.object({ + kind: z.literal("rotate"), + rotationId: z.string().trim().min(1).max(256), + newKeyIssuedAt: clientTimestampSchema, + oldKeyBackupPath: z.string().min(1), + }).strict().superRefine((operation, ctx) => { + const expected = join(getConfigDir(), "service-api-token.prev"); + if (operation.oldKeyBackupPath !== expected) { + ctx.addIssue({ code: "custom", path: ["oldKeyBackupPath"], message: `must equal ${expected}` }); + } + }).optional(), +}).strict(); + const configSchema = z.object({ port: z.number().int().min(0).max(65535).default(10100), // A malformed hand edit must disable only remote-role behavior, not discard @@ -904,6 +943,9 @@ const configSchema = z.object({ // candidates are rejected explicitly by remoteGuiConfigError below. hub: hubConfigSchema.optional().catch(undefined), remoteGui: remoteGuiConfigSchema.optional().catch(undefined), + // A malformed present client block must remain diagnosable from raw config and + // fail closed through src/client/state.ts; unrelated provider state still loads. + client: clientConnectionSchema.optional().catch(undefined), managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024), // Invalid hand edits disable only this opt-in circuit. Live writes remain strict. upstreamHostCircuitThreshold: z.number().int() @@ -1757,6 +1799,15 @@ function malformedOptionalRemoteBlockWarning( return `${key}${field ? `.${field}` : ""} ignored: invalid remote GUI configuration`; } +function malformedClientConnectionWarning(rawParsed: unknown): string | null { + const raw = rawConfigRecord(rawParsed); + if (!raw || !Object.hasOwn(raw, "client") || raw.client === undefined) return null; + const result = clientConnectionSchema.safeParse(raw.client); + if (result.success) return null; + const field = result.error.issues[0]?.path.join("."); + return `client${field ? `.${field}` : ""} invalid: remote client mode is disabled until config.json is repaired`; +} + function warnDegradedOptionalRemoteBlocks(rawParsed: unknown): void { for (const key of ["hub", "remoteGui"] as const) { const warning = malformedOptionalRemoteBlockWarning(rawParsed, key); @@ -2073,6 +2124,8 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf if (hubWarning) warnings.push(hubWarning); const remoteGuiWarning = malformedOptionalRemoteBlockWarning(rawParsed, "remoteGui"); if (remoteGuiWarning) warnings.push(remoteGuiWarning); + const clientWarning = malformedClientConnectionWarning(rawParsed); + if (clientWarning) warnings.push(clientWarning); if (syncDisabledReason) { warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`); } @@ -2188,6 +2241,29 @@ function remoteGuiConfigError(value: unknown): string | null { return null; } +function clientConnectionConfigError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "client") || raw.client === undefined) return null; + const result = clientConnectionSchema.safeParse(raw.client); + if (result.success) return null; + const issue = result.error.issues[0]; + const field = issue?.path.join("."); + return `schema_invalid: client${field ? `.${field}` : ""}: ${issue?.message ?? "invalid client connection"}`; +} + +function clientRolePairError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw) return null; + const hasClient = Object.hasOwn(raw, "client") && raw.client !== undefined; + if (raw.runtimeRole === "client" && !hasClient) { + return "schema_invalid: runtimeRole client requires a complete client connection"; + } + if (hasClient && raw.runtimeRole !== "client") { + return "schema_invalid: client connection requires runtimeRole client"; + } + return null; +} + /** * Same reasoning as {@link blankHostnameError}, and more urgent: the read path degrades a * malformed selection-order map to undefined, which on a write would drop every entry the @@ -2307,6 +2383,8 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? oauthOpenBrowserError(value) ?? runtimeRoleError(value) ?? remoteGuiConfigError(value) + ?? clientConnectionConfigError(value) + ?? clientRolePairError(value) ?? loopbackListenerPortError(value); if (boundaryError) return { ok: false, error: boundaryError }; const result = configSchema.safeParse(value); diff --git a/src/lib/service-secrets.ts b/src/lib/service-secrets.ts index 71edbd7335..f135cf2c92 100644 --- a/src/lib/service-secrets.ts +++ b/src/lib/service-secrets.ts @@ -1,11 +1,81 @@ +import { createHash } from "node:crypto"; +import { existsSync, lstatSync, readFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; -import { readFileSync } from "node:fs"; import { getConfigDir } from "../config"; +import { atomicWriteFile } from "../config/atomic-write"; + +const MAX_SERVICE_API_TOKEN_BYTES = 4096; + +export interface PersistedServiceApiToken { + path: string; + fingerprint: string; +} + +export type ServiceApiTokenState = + | { kind: "absent" } + | { kind: "present"; token: string; fingerprint: string } + | { kind: "unsafe"; reason: string }; export function serviceApiTokenFilePath(): string { return join(getConfigDir(), "service-api-token"); } +export function serviceApiTokenFingerprint(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +export function readServiceApiTokenState(): ServiceApiTokenState { + const path = serviceApiTokenFilePath(); + if (!existsSync(path)) return { kind: "absent" }; + let stat; + try { + stat = lstatSync(path); + } catch { + return { kind: "unsafe", reason: "service token path could not be inspected" }; + } + if (stat.isSymbolicLink() || !stat.isFile() || stat.size > MAX_SERVICE_API_TOKEN_BYTES) { + return { kind: "unsafe", reason: "service token path is not a bounded regular file" }; + } + try { + const token = readFileSync(path, "utf8").trim(); + if (!token) return { kind: "unsafe", reason: "service token file is empty" }; + return { kind: "present", token, fingerprint: serviceApiTokenFingerprint(token) }; + } catch { + return { kind: "unsafe", reason: "service token file could not be read" }; + } +} + +export function writeServiceApiTokenFile(token: string): PersistedServiceApiToken { + const value = token.trim(); + if (!value || /[\r\n\0]/.test(value) || Buffer.byteLength(value) > MAX_SERVICE_API_TOKEN_BYTES) { + throw new Error("refusing to persist an invalid service API token"); + } + const path = serviceApiTokenFilePath(); + const existing = readServiceApiTokenState(); + if (existing.kind !== "absent") { + throw new Error(existing.kind === "unsafe" + ? existing.reason + : "refusing to replace a pre-existing service API token"); + } + atomicWriteFile(path, `${value}\n`); + return { path, fingerprint: serviceApiTokenFingerprint(value) }; +} + +export function removeServiceApiTokenFileIfOwned( + expectedFingerprint: string, +): "removed" | "absent" | "changed" { + const state = readServiceApiTokenState(); + if (state.kind === "absent") return "absent"; + if (state.kind !== "present" || state.fingerprint !== expectedFingerprint) return "changed"; + try { + unlinkSync(serviceApiTokenFilePath()); + return "removed"; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return "absent"; + throw new Error("owned service API token could not be removed", { cause: error }); + } +} + /** * App-side service token loading (WinSW native mode has no batch wrapper to read the * token file into the environment). Pure: returns the token or null — the CALLER diff --git a/src/types.ts b/src/types.ts index 997a9e33bf..41b183ebf2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,6 +64,8 @@ export type { OcxConfigRebaseProvenance, OcxHubConfig, OcxRemoteGuiConfig, + OcxConnectedClientId, + OcxClientConnectionConfig, OcxConfig, OcxAccountPoolRotationStrategy, OcxComboStrategy, diff --git a/src/types/config.ts b/src/types/config.ts index 713594b46e..e7df9a0b0e 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -258,6 +258,28 @@ export interface OcxRemoteGuiConfig { allowInsecureHttp?: boolean; } +export type OcxConnectedClientId = "codex" | "claude"; + +export interface OcxClientConnectionConfig { + serverUrl: string; + managementUrl: string; + managementTransport: "direct" | "relay"; + selectedClients: OcxConnectedClientId[]; + tokenEnv: "OPENCODEX_API_AUTH_TOKEN"; + apiKeyId: string; + tokenFingerprint: string; + protocolVersion: 1; + connectedAt: string; + catalogEtag?: string; + catalogSyncedAt?: string; + pendingOperation?: { + kind: "rotate"; + rotationId: string; + newKeyIssuedAt: string; + oldKeyBackupPath: string; + }; +} + export interface OcxConfig { port: number; /** Runtime topology role. Absence preserves the historical standalone behavior. */ @@ -266,6 +288,8 @@ export interface OcxConfig { hub?: OcxHubConfig; /** Opt-in remote dashboard issuance policy. Presence is inert outside the hub role. */ remoteGui?: OcxRemoteGuiConfig; + /** Remote-hub client state. The admission secret is stored only in service-api-token. */ + client?: OcxClientConnectionConfig; /** Opt in to one identical-turn retry when a Responses completion has no text or tool call. */ emptyCompletionRetry?: boolean; /** diff --git a/tests/config.test.ts b/tests/config.test.ts index 17d8e6c529..cc0096d9c4 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -270,6 +270,87 @@ describe("opencodex config defaults", () => { } }); + test("remote client state round-trips without accepting a secret field", () => { + const client = { + serverUrl: "https://hub.example.test", + managementUrl: "https://manage.example.test:443", + managementTransport: "direct" as const, + selectedClients: ["codex", "claude"] as const, + tokenEnv: "OPENCODEX_API_AUTH_TOKEN" as const, + apiKeyId: "issued-key-id", + tokenFingerprint: "a".repeat(64), + protocolVersion: 1 as const, + connectedAt: "2026-08-28T00:00:00.000Z", + catalogEtag: '"sha256-example"', + catalogSyncedAt: "2026-08-28T00:01:00.000Z", + pendingOperation: { + kind: "rotate" as const, + rotationId: "rotation-1", + newKeyIssuedAt: "2026-08-28T00:02:00.000Z", + oldKeyBackupPath: join(testDir, "service-api-token.prev"), + }, + }; + const result = validateConfigCandidate({ + ...getDefaultConfig(), + runtimeRole: "client", + client, + }); + expect(result).toMatchObject({ + ok: true, + config: { + runtimeRole: "client", + client: { + serverUrl: "https://hub.example.test", + managementUrl: "https://manage.example.test", + apiKeyId: "issued-key-id", + }, + }, + }); + if (!result.ok) return; + saveConfig(result.config); + expect(loadConfig().client).toEqual(result.config.client); + expect(readFileSync(getConfigPath(), "utf8")).not.toContain("ocx_data_"); + + expect(validateConfigCandidate({ + ...getDefaultConfig(), + runtimeRole: "client", + client: { ...client, key: "ocx_data_forbidden" }, + })).toMatchObject({ ok: false, error: expect.stringContaining("client") }); + }); + + test("remote client state rejects half-present and malformed rotation recovery state", () => { + const validClient = { + serverUrl: "https://hub.example.test", + managementUrl: "https://hub.example.test", + managementTransport: "direct", + selectedClients: ["codex"], + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + apiKeyId: "issued-key-id", + tokenFingerprint: "b".repeat(64), + protocolVersion: 1, + connectedAt: "2026-08-28T00:00:00.000Z", + }; + expect(validateConfigCandidate({ ...getDefaultConfig(), runtimeRole: "client" })).toMatchObject({ + ok: false, + error: expect.stringContaining("requires a complete client connection"), + }); + expect(validateConfigCandidate({ ...getDefaultConfig(), client: validClient })).toMatchObject({ + ok: false, + error: expect.stringContaining("requires runtimeRole client"), + }); + for (const pendingOperation of [ + { kind: "rotate", newKeyIssuedAt: "2026-08-28T00:00:00.000Z", oldKeyBackupPath: join(testDir, "service-api-token.prev") }, + { kind: "rotate", rotationId: "r", newKeyIssuedAt: "not-a-time", oldKeyBackupPath: join(testDir, "service-api-token.prev") }, + { kind: "rotate", rotationId: "r", newKeyIssuedAt: "2026-08-28T00:00:00.000Z", oldKeyBackupPath: join(testDir, "foreign.prev") }, + ]) { + expect(validateConfigCandidate({ + ...getDefaultConfig(), + runtimeRole: "client", + client: { ...validClient, pendingOperation }, + })).toMatchObject({ ok: false, error: expect.stringContaining("client.pendingOperation") }); + } + }); + test("malformed classifier config is normalized at load, even with subagentEffort absent (#1697)", () => { // normalizePersistedClaudeCode used to be reached only through a subagentEffort short-circuit, // so a config whose ONLY defect was elsewhere in claudeCode was never normalized. These diff --git a/tests/service-secrets.test.ts b/tests/service-secrets.test.ts new file mode 100644 index 0000000000..7ee274ab64 --- /dev/null +++ b/tests/service-secrets.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + existsSync, + lstatSync, + mkdtempSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + readServiceApiTokenState, + removeServiceApiTokenFileIfOwned, + serviceApiTokenFilePath, + serviceApiTokenFingerprint, + writeServiceApiTokenFile, +} from "../src/lib/service-secrets"; + +let home = ""; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-service-secret-")); + process.env.OPENCODEX_HOME = home; +}); + +afterEach(() => { + delete process.env.OPENCODEX_HOME; + if (home) rmSync(home, { recursive: true, force: true }); +}); + +describe("service API token ownership", () => { + test("writes only the exact owner path through an atomic owner-only replacement", () => { + const token = "ocx_data_0123456789abcdef0123456789abcdef01234567"; + const persisted = writeServiceApiTokenFile(token); + + expect(persisted.path).toBe(join(home, "service-api-token")); + expect(persisted.path).toBe(serviceApiTokenFilePath()); + expect(persisted.fingerprint).toBe(serviceApiTokenFingerprint(token)); + expect(lstatSync(persisted.path).isFile()).toBe(true); + if (process.platform !== "win32") expect(lstatSync(persisted.path).mode & 0o777).toBe(0o600); + expect(readdirSync(home).filter(name => name.includes(".tmp"))).toEqual([]); + expect(readServiceApiTokenState()).toEqual({ + kind: "present", + token, + fingerprint: persisted.fingerprint, + }); + }); + + test("refuses symlink and pre-existing token targets without exposing token bytes", () => { + const path = serviceApiTokenFilePath(); + const target = join(home, "foreign-token"); + writeFileSync(target, "foreign-secret\n", { mode: 0o600 }); + let symlinkAvailable = true; + try { + symlinkSync(target, path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EPERM") symlinkAvailable = false; + else throw error; + } + if (symlinkAvailable) { + const secret = "ocx_data_should_never_appear_in_an_error"; + expect(() => writeServiceApiTokenFile(secret)).toThrow("bounded regular file"); + try { writeServiceApiTokenFile(secret); } catch (error) { + expect(String(error)).not.toContain(secret); + } + rmSync(path); + } + + writeFileSync(path, "foreign-secret\n", { mode: 0o600 }); + expect(() => writeServiceApiTokenFile("ocx_data_new_secret")).toThrow("pre-existing"); + }); + + test("removes only the fingerprint-owned unchanged token", () => { + const first = writeServiceApiTokenFile("ocx_data_first"); + writeFileSync(first.path, "ocx_data_replacement\n", { mode: 0o600 }); + expect(removeServiceApiTokenFileIfOwned(first.fingerprint)).toBe("changed"); + expect(existsSync(first.path)).toBe(true); + + const replacementFingerprint = serviceApiTokenFingerprint("ocx_data_replacement"); + expect(removeServiceApiTokenFileIfOwned(replacementFingerprint)).toBe("removed"); + expect(existsSync(first.path)).toBe(false); + expect(removeServiceApiTokenFileIfOwned(replacementFingerprint)).toBe("absent"); + }); +}); From 8b1b03d0e89b50b35f4179f7b623a39f7c798a8a Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 02:41:19 +0900 Subject: [PATCH 02/11] feat(connect): add durable remote routing target --- src/cli/index.ts | 8 +- src/codex/inject.ts | 211 +++++++++++++++++++++++++++++++----- src/codex/journal.ts | 44 +++++++- tests/codex-inject.test.ts | 27 +++++ tests/codex-journal.test.ts | 33 ++++++ 5 files changed, 292 insertions(+), 31 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 85d4b61a94..8e3f6b2d57 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -8,6 +8,7 @@ import { runCodexHistoryJob, } from "../codex/history-job"; import { reconcileJournal } from "../codex/journal"; +import { readClientConnectionState } from "../client/state"; import { codexAutoStartEnabled, getConfigDir, @@ -213,7 +214,12 @@ async function findProxyOwnerBeforeJournalRecovery( // The probe established that the snapshotted owner is stale. Compare before // deleting so a concurrent start that rewrote the PID file keeps its state. removePidIfValueIs(pidSnapshot); - if (!currentExternalCodexModelProvider()) reconcileJournal(); + if (!currentExternalCodexModelProvider()) { + const clientState = readClientConnectionState(); + reconcileJournal(clientState.kind === "connected" + ? { activeClientApiKeyId: clientState.value.apiKeyId } + : undefined); + } return { live: null, pidSnapshot }; } diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 72be578784..536b12a360 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -139,6 +139,54 @@ export interface InjectCodexOptions { * provider discovery so a deterministic config refusal cannot degrade an existing catalog. */ validateOnly?: boolean; + /** Explicit remote routing target. Absence preserves byte-compatible standalone output. */ + routingTarget?: CodexRoutingTarget; + journalOwner?: { kind: "process" } | { kind: "client"; apiKeyId: string }; +} + +export interface CodexRoutingTarget { + baseUrl: string; + requiresAdmissionToken: boolean; + tokenEnv: "OPENCODEX_API_AUTH_TOKEN"; +} + +function validateCodexRoutingTarget(target: CodexRoutingTarget): CodexRoutingTarget { + let parsed: URL; + try { + parsed = new URL(target.baseUrl); + } catch { + throw new TypeError("Codex routing target must be an absolute HTTP(S) /v1 URL"); + } + if ( + (parsed.protocol !== "http:" && parsed.protocol !== "https:") + || parsed.username + || parsed.password + || parsed.pathname !== "/v1" + || parsed.search + || parsed.hash + || target.tokenEnv !== "OPENCODEX_API_AUTH_TOKEN" + ) { + throw new TypeError("Codex routing target must be a canonical HTTP(S) /v1 URL without credentials, query, or fragment"); + } + return { ...target, baseUrl: `${parsed.origin}/v1` }; +} + +export function standaloneCodexRoutingTarget( + port: number, + config?: Pick, +): CodexRoutingTarget { + const loopback = config?.unauthenticatedLoopbackListener; + const effectivePort = loopback?.enabled ? loopback.port : port; + const hostname = loopback?.enabled ? undefined : config?.hostname; + return { + baseUrl: `http://${providerBaseHost(hostname)}:${effectivePort}/v1`, + requiresAdmissionToken: loopback?.enabled ? false : shouldInjectApiAuthHeader(config), + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + }; +} + +function routingTargetOrigin(target: CodexRoutingTarget): string { + return target.baseUrl.slice(0, -3); } function configuredManagedSubagentDefaults( @@ -213,28 +261,51 @@ export function shouldInjectApiAuthHeader( export function buildProviderTableBlock( port: number, + supportsWebsockets?: boolean, + includeApiAuthHeader?: boolean, + hostname?: string, +): string; +export function buildProviderTableBlock( + target: CodexRoutingTarget, + supportsWebsockets?: boolean, +): string; +export function buildProviderTableBlock( + portOrTarget: number | CodexRoutingTarget, supportsWebsockets = false, includeApiAuthHeader = false, hostname?: string, ): string { - const host = providerBaseHost(hostname); + const target = typeof portOrTarget === "number" + ? validateCodexRoutingTarget({ + baseUrl: `http://${providerBaseHost(hostname)}:${portOrTarget}/v1`, + requiresAdmissionToken: includeApiAuthHeader, + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + }) + : validateCodexRoutingTarget(portOrTarget); + return buildProviderTableBlockForTarget(target, supportsWebsockets); +} + +function buildProviderTableBlockForTarget( + target: CodexRoutingTarget, + supportsWebsockets = false, +): string { const lines = [ "", OCX_SECTION_MARKER, "[model_providers.opencodex]", 'name = "OpenCodex Proxy"', - `base_url = "http://${host}:${port}/v1"`, + `base_url = ${tomlString(target.baseUrl)}`, 'wire_api = "responses"', "requires_openai_auth = true", ]; - if (includeApiAuthHeader) { + if (target.requiresAdmissionToken) { // codex-cli 0.146+ contract (#2073): env_key sends Authorization: Bearer $VAR and // hard-errors on a missing/empty variable instead of silently omitting auth. It // coexists with requires_openai_auth (env_key wins wire auth; the flag keeps the // login/account UX), and the server substitutes stored main auth for our admission // bearer (#1686), so the modern form is strictly better than the legacy // env_http_headers table this line used to emit. - lines.push('env_key = "OPENCODEX_API_AUTH_TOKEN"'); + lines.push(`env_key = ${tomlString(target.tokenEnv)}`); } if (supportsWebsockets) lines.push("supports_websockets = true"); return lines.join("\n") + "\n"; @@ -243,8 +314,19 @@ export function buildProviderTableBlock( export function buildOpenaiBaseUrlLine( port: number, hostname?: string, +): string; +export function buildOpenaiBaseUrlLine(target: CodexRoutingTarget): string; +export function buildOpenaiBaseUrlLine( + portOrTarget: number | CodexRoutingTarget, + hostname?: string, ): string { - return `openai_base_url = "http://${providerBaseHost(hostname)}:${port}/v1"`; + return typeof portOrTarget === "number" + ? `openai_base_url = "http://${providerBaseHost(hostname)}:${portOrTarget}/v1"` + : buildOpenaiBaseUrlLineForTarget(validateCodexRoutingTarget(portOrTarget)); +} + +function buildOpenaiBaseUrlLineForTarget(target: CodexRoutingTarget): string { + return `openai_base_url = ${tomlString(target.baseUrl)}`; } /** @@ -257,11 +339,23 @@ export function setRootOpenaiBaseUrl( content: string, port: number, hostname?: string, +): { content: string; keptUserBaseUrl: boolean }; +export function setRootOpenaiBaseUrl( + content: string, + target: CodexRoutingTarget, +): { content: string; keptUserBaseUrl: boolean }; +export function setRootOpenaiBaseUrl( + content: string, + portOrTarget: number | CodexRoutingTarget, + hostname?: string, ): { content: string; keptUserBaseUrl: boolean } { + if (typeof portOrTarget !== "number") { + return setRootOpenaiBaseUrlForTarget(content, validateCodexRoutingTarget(portOrTarget)); + } const lines = content.split("\n"); const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); const rootEnd = firstTable === -1 ? lines.length : firstTable; - const key = buildOpenaiBaseUrlLine(port, hostname); + const key = buildOpenaiBaseUrlLine(portOrTarget, hostname); for (let i = 0; i < rootEnd; i++) { if (!isRootOpenaiBaseUrlLine(lines[i])) continue; @@ -289,6 +383,33 @@ export function setRootOpenaiBaseUrl( return { content: lines.join("\n"), keptUserBaseUrl: false }; } +function setRootOpenaiBaseUrlForTarget( + content: string, + target: CodexRoutingTarget, +): { content: string; keptUserBaseUrl: boolean } { + const lines = content.split("\n"); + const firstTable = lines.findIndex((line) => /^\s*\[/.test(line)); + const rootEnd = firstTable === -1 ? lines.length : firstTable; + const key = buildOpenaiBaseUrlLineForTarget(target); + for (let index = 0; index < rootEnd; index += 1) { + if (!isRootOpenaiBaseUrlLine(lines[index])) continue; + const markerOwned = index > 0 && lines[index - 1].includes(OCX_SECTION_MARKER); + if (!markerOwned) return { content, keptUserBaseUrl: true }; + lines[index] = key; + return { content: lines.join("\n"), keptUserBaseUrl: false }; + } + if (firstTable === -1) { + return { + content: `${content.replace(/\n+$/, "")}\n${OCX_SECTION_MARKER}\n${key}\n`, + keptUserBaseUrl: false, + }; + } + let insertAt = firstTable; + while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt -= 1; + lines.splice(insertAt, 0, OCX_SECTION_MARKER, key); + return { content: lines.join("\n"), keptUserBaseUrl: false }; +} + /** * Remove the marker-owned root `openai_base_url` (marker line + the key line right after it). * A user's own root override (no marker) survives; an orphaned marker with no key line after @@ -619,17 +740,48 @@ function stripOpencodexCatalogPath(content: string): string { .join("\n"); } -export function buildProfileFile(port: number, catalogPath?: string | null, supportsWebsockets = false, includeApiAuthHeader = false, hostname?: string, fastMode?: boolean): string { - const host = providerBaseHost(hostname); +export function buildProfileFile(port: number, catalogPath?: string | null, supportsWebsockets?: boolean, includeApiAuthHeader?: boolean, hostname?: string, fastMode?: boolean): string; +export function buildProfileFile(target: CodexRoutingTarget, catalogPath?: string | null, supportsWebsockets?: boolean, fastMode?: boolean): string; +export function buildProfileFile( + portOrTarget: number | CodexRoutingTarget, + catalogPath?: string | null, + supportsWebsockets = false, + includeApiAuthHeaderOrFastMode?: boolean, + hostname?: string, + fastMode?: boolean, +): string { + const target = typeof portOrTarget === "number" + ? validateCodexRoutingTarget({ + baseUrl: `http://${providerBaseHost(hostname)}:${portOrTarget}/v1`, + requiresAdmissionToken: includeApiAuthHeaderOrFastMode === true, + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + }) + : validateCodexRoutingTarget(portOrTarget); + return buildProfileFileForTarget( + target, + catalogPath, + supportsWebsockets, + typeof portOrTarget === "number" ? fastMode : includeApiAuthHeaderOrFastMode, + ); +} + +function buildProfileFileForTarget( + target: CodexRoutingTarget, + catalogPath?: string | null, + supportsWebsockets = false, + fastMode?: boolean, +): string { + const origin = routingTargetOrigin(target); + const host = new URL(origin).host; // Design B (loopback): the reference/fallback file documents the root override form. // Non-loopback keeps the legacy provider-table shape (built-in provider cannot carry // the x-opencodex-api-key env header). - if (!includeApiAuthHeader) { + if (!target.requiresAdmissionToken) { const lines = [ "# OpenCodex proxy fallback config (Design B)", - `# Root override that points Codex's built-in openai provider at the proxy on ${host}:${port}.`, + `# Root override that points Codex's built-in openai provider at the proxy on ${host}.`, "# Merge these root keys into ~/.codex/config.toml manually if auto-injection was removed.", - buildOpenaiBaseUrlLine(port, hostname), + buildOpenaiBaseUrlLineForTarget(target), ]; if (catalogPath) lines.push(`model_catalog_json = ${tomlString(catalogPath)}`); if (fastMode !== undefined) lines.push("", "[features]", `fast_mode = ${fastMode ? "true" : "false"}`, ""); @@ -637,12 +789,12 @@ export function buildProfileFile(port: number, catalogPath?: string | null, supp } const lines = [ "# OpenCodex proxy profile — use with: codex --profile opencodex", - `# Routes all model requests through the opencodex proxy at ${host}:${port}`, + `# Routes all model requests through the opencodex proxy at ${host}`, 'model_provider = "opencodex"', ]; if (catalogPath) lines.push(`model_catalog_json = ${tomlString(catalogPath)}`); if (fastMode !== undefined) lines.push("", "[features]", `fast_mode = ${fastMode ? "true" : "false"}`); - lines.push(buildProviderTableBlock(port, supportsWebsockets, includeApiAuthHeader, hostname).trimEnd(), ""); + lines.push(buildProviderTableBlockForTarget(target, supportsWebsockets).trimEnd(), ""); return lines.join("\n"); } @@ -684,8 +836,14 @@ export async function injectCodexConfig( // // The listener port is fixed in config, never OS-assigned, so this value survives restarts // and matches what an already-running app-server read at startup. - const loopback = config?.unauthenticatedLoopbackListener; - if (loopback?.enabled) port = loopback.port; + let routingTarget: CodexRoutingTarget; + try { + routingTarget = options.routingTarget + ? validateCodexRoutingTarget(options.routingTarget) + : standaloneCodexRoutingTarget(port, config); + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Invalid Codex routing target" }; + } if (!existsSync(CODEX_CONFIG_PATH)) { return { success: false, @@ -712,8 +870,8 @@ export async function injectCodexConfig( message: `⚠️ Codex routing NOT injected: config.toml selects the external model_provider ${tomlString(activeProvider)}.\n` + ` OpenCodex preserves external provider configuration so existing ${tomlString(activeProvider)} session history stays visible.\n` + - ` Configure that provider for Responses passthrough at http://${providerBaseHost(config?.hostname)}:${port}/v1` + - `${shouldInjectApiAuthHeader(config) ? ` with x-opencodex-api-key from OPENCODEX_API_AUTH_TOKEN` : ""}.\n` + + ` Configure that provider for Responses passthrough at ${routingTarget.baseUrl}` + + `${routingTarget.requiresAdmissionToken ? ` with x-opencodex-api-key from ${routingTarget.tokenEnv}` : ""}.\n` + ` For direct injection, switch to the built-in openai provider, remove any user-owned root openai_base_url, and rerun 'ocx start'.`, }; } @@ -781,7 +939,7 @@ export async function injectCodexConfig( ? setRootModelCatalogPath(content, catalogPath) : stripOpencodexCatalogPath(content); - const legacyMode = shouldInjectApiAuthHeader(config); + const legacyMode = routingTarget.requiresAdmissionToken; let keptUserBaseUrl = false; if (legacyMode) { // Legacy (non-loopback) injection: the built-in openai provider cannot carry the @@ -792,17 +950,12 @@ export async function injectCodexConfig( content = content.trimEnd() + "\n" + - buildProviderTableBlock( - port, - websocketsEnabled(config ?? {}), - true, - config?.hostname, - ); + buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {})); } else { // Design B (loopback): a single root override; codex keeps its native `openai` provider id // so thread history is never remapped. Any legacy form was already stripped above. content = stripInjectedOpenaiBaseUrl(content); // normalize before idempotent re-insert - const result = setRootOpenaiBaseUrl(content, port, config?.hostname); + const result = setRootOpenaiBaseUrlForTarget(content, routingTarget); content = result.content; keptUserBaseUrl = result.keptUserBaseUrl; } @@ -838,7 +991,12 @@ export async function injectCodexConfig( managedDefaultsMessage = ` ⚠️ ${nativeSubagentDefaultsWarning}\n`; } - const profileContent = buildProfileFile(port, catalogPath, websocketsEnabled(config ?? {}), legacyMode, config?.hostname, config?.fastMode); + const profileContent = buildProfileFileForTarget( + routingTarget, + catalogPath, + websocketsEnabled(config ?? {}), + config?.fastMode, + ); content = applyEol(content, eol); /* @@ -916,6 +1074,7 @@ export async function injectCodexConfig( writeJournal({ currentStateIsNative: !hasInjectedCodexRouting(rawContent), configContent: baselineContent, + owner: options.journalOwner, }); atomicWriteFile(CODEX_CONFIG_PATH, content); atomicWriteFile(CODEX_PROFILE_PATH, profileContent); diff --git a/src/codex/journal.ts b/src/codex/journal.ts index b8923a6daa..68523fe685 100644 --- a/src/codex/journal.ts +++ b/src/codex/journal.ts @@ -16,6 +16,10 @@ import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths"; */ export const JOURNAL_PATH = join(CODEX_HOME, "opencodex-journal.json"); +export type JournalOwner = + | { kind: "process"; pid: number } + | { kind: "client"; apiKeyId: string }; + interface Journal { version: 1; originalConfig: string; @@ -41,10 +45,11 @@ interface Journal { */ injectedCatalogPath?: string | null; pid: number; + owner?: JournalOwner; timestamp: string; } -interface RestoreJournalResult { +export interface RestoreJournalResult { configRestored: boolean; profileRestored: boolean; configChanged: boolean; @@ -71,6 +76,7 @@ export interface WriteJournalOptions { * another process rewrites config.toml mid-flight. */ configContent?: string; + owner?: { kind: "process" } | { kind: "client"; apiKeyId: string }; } /** @@ -103,6 +109,9 @@ export function writeJournal(options: WriteJournalOptions = {}): void { originalConfig: Buffer.from(config).toString("base64"), originalProfile: profile ? Buffer.from(profile).toString("base64") : null, pid: process.pid, + owner: options.owner?.kind === "client" + ? { kind: "client", apiKeyId: options.owner.apiKeyId } + : { kind: "process", pid: process.pid }, timestamp: new Date().toISOString(), }; atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal)); @@ -168,6 +177,20 @@ function readJournal(): Journal | null { } } +export function journalOwner(): JournalOwner | null { + const journal = readJournal(); + if (!journal) return null; + if (journal.owner?.kind === "client" && typeof journal.owner.apiKeyId === "string" && journal.owner.apiKeyId) { + return { kind: "client", apiKeyId: journal.owner.apiKeyId }; + } + if (journal.owner?.kind === "process" && Number.isSafeInteger(journal.owner.pid) && journal.owner.pid > 0) { + return { kind: "process", pid: journal.owner.pid }; + } + return Number.isSafeInteger(journal.pid) && journal.pid > 0 + ? { kind: "process", pid: journal.pid } + : null; +} + export function restoreJournalState(): RestoreJournalResult { const journal = readJournal(); if (!journal) { @@ -207,11 +230,24 @@ export function restoreJournal(): boolean { return restoreJournalState().complete; } -export function reconcileJournal(): boolean { +export interface ReconcileJournalOptions { + activeClientApiKeyId?: string; +} + +export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean { const journal = readJournal(); if (!journal) return false; + const owner = journalOwner(); + if (owner?.kind === "client") { + if (options.activeClientApiKeyId === owner.apiKeyId) return false; + const restored = restoreJournalState(); + if (!restored.configRestored && !restored.profileRestored) return false; + console.error(`⚠️ Uncommitted or mismatched client routing (${owner.apiKeyId}) was restored from the Codex journal.`); + return true; + } + const pid = owner?.kind === "process" ? owner.pid : journal.pid; try { - process.kill(journal.pid, 0); + process.kill(pid, 0); return false; } catch (e: unknown) { if ((e as NodeJS.ErrnoException).code === "EPERM") { @@ -220,6 +256,6 @@ export function reconcileJournal(): boolean { } const restored = restoreJournalState(); if (!restored.configRestored && !restored.profileRestored) return false; - console.error(`⚠️ Previous session (PID ${journal.pid}) did not shut down cleanly. Codex state restored from journal.`); + console.error(`⚠️ Previous session (PID ${pid}) did not shut down cleanly. Codex state restored from journal.`); return true; } diff --git a/tests/codex-inject.test.ts b/tests/codex-inject.test.ts index 1d9b86a00f..14197aa4ec 100644 --- a/tests/codex-inject.test.ts +++ b/tests/codex-inject.test.ts @@ -10,6 +10,7 @@ import { stripInjectedOpenaiBaseUrl, stripOpencodexConfig, stripRootContextWindowOverrides, + standaloneCodexRoutingTarget, } from "../src/codex/inject"; import { MANAGED_AGENTS_TABLE_MARKER, @@ -17,6 +18,32 @@ import { } from "../src/codex/subagent-defaults"; describe("Codex config injection", () => { + test("standalone routing-target wrappers remain byte-compatible", () => { + const target = standaloneCodexRoutingTarget(10100, { hostname: "192.168.1.20" }); + expect(buildProviderTableBlock(target, true)).toBe( + buildProviderTableBlock(10100, true, true, "192.168.1.20"), + ); + expect(buildProfileFile(target, "/tmp/opencodex-catalog.json", true)).toBe( + buildProfileFile(10100, "/tmp/opencodex-catalog.json", true, true, "192.168.1.20"), + ); + }); + + test("explicit HTTPS target emits exact provider destination and admission env", () => { + const target = { + baseUrl: "https://hub.example.test/v1", + requiresAdmissionToken: true, + tokenEnv: "OPENCODEX_API_AUTH_TOKEN" as const, + }; + const block = buildProviderTableBlock(target); + expect(block).toContain('base_url = "https://hub.example.test/v1"'); + expect(block).toContain('env_key = "OPENCODEX_API_AUTH_TOKEN"'); + const loopbackLooking = buildProviderTableBlock({ ...target, baseUrl: "https://127.0.0.1/v1" }); + expect(loopbackLooking).toContain('env_key = "OPENCODEX_API_AUTH_TOKEN"'); + expect(() => buildProviderTableBlock({ ...target, baseUrl: "https://hub.example.test/not-v1" })).toThrow( + "canonical HTTP(S) /v1 URL", + ); + }); + test("omits provider-level Responses WebSocket support by default", () => { const block = buildProviderTableBlock(10100); diff --git a/tests/codex-journal.test.ts b/tests/codex-journal.test.ts index b1e5e1fbce..cdb59121ed 100644 --- a/tests/codex-journal.test.ts +++ b/tests/codex-journal.test.ts @@ -126,6 +126,39 @@ describe("codex-journal", () => { expect(existsSync(journalPath)).toBe(true); }); + test("client-owned journal survives only the matching committed api key id", () => { + const journalPath = join(testDir, "opencodex-journal.json"); + const original = "# original client baseline\n"; + const injected = "# connected routing\n"; + writeFileSync(join(testDir, "config.toml"), injected, "utf8"); + writeFileSync(journalPath, JSON.stringify({ + version: 1, + originalConfig: Buffer.from(original).toString("base64"), + originalProfile: null, + owner: { kind: "client", apiKeyId: "client-key-1" }, + pid: 999999, + timestamp: new Date().toISOString(), + }), "utf8"); + + const preserved = runScript(testDir, ` + const { reconcileJournal } = require("./src/codex/journal"); + console.log(JSON.stringify({ restored: reconcileJournal({ activeClientApiKeyId: "client-key-1" }) })); + `); + expect(preserved.status).toBe(0); + expect(JSON.parse(preserved.stdout).restored).toBe(false); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(injected); + expect(existsSync(journalPath)).toBe(true); + + const restored = runScript(testDir, ` + const { reconcileJournal } = require("./src/codex/journal"); + console.log(JSON.stringify({ restored: reconcileJournal({ activeClientApiKeyId: "different-key" }) })); + `); + expect(restored.status).toBe(0); + expect(JSON.parse(restored.stdout).restored).toBe(true); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(original); + expect(existsSync(journalPath)).toBe(false); + }); + test("removeJournal cleans up", () => { const journalPath = join(testDir, "opencodex-journal.json"); writeFileSync(journalPath, "{}", "utf8"); From 6398f5cfd8eecca6761320d434b33d541f24ae20 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 02:51:01 +0900 Subject: [PATCH 03/11] feat(connect): add remote hub CLI and sync --- src/claude/gateway-cache.ts | 26 ++- src/cli/claude.ts | 132 ++++++++++-- src/cli/connect.ts | 198 ++++++++++++++++++ src/cli/dispatch.ts | 49 +++++ src/cli/help.ts | 2 + src/cli/index.ts | 4 + src/cli/registry.ts | 15 ++ src/cli/runtime-api.ts | 11 +- src/cli/status.ts | 26 +++ src/client/connect.ts | 406 ++++++++++++++++++++++++++++++++++++ src/client/hub-client.ts | 301 ++++++++++++++++++++++++++ 11 files changed, 1145 insertions(+), 25 deletions(-) create mode 100644 src/cli/connect.ts create mode 100644 src/client/connect.ts create mode 100644 src/client/hub-client.ts diff --git a/src/claude/gateway-cache.ts b/src/claude/gateway-cache.ts index aeedf3e652..33df1e8457 100644 --- a/src/claude/gateway-cache.ts +++ b/src/claude/gateway-cache.ts @@ -26,6 +26,12 @@ export interface GatewayModelCacheRefreshOptions { configDir?: string; admissionConfig?: Pick; env?: NodeJS.ProcessEnv; + fetchImpl?: typeof fetch; +} + +export interface GatewayModelTarget { + baseUrl: string; + admissionToken: string; } /** Claude Code config dir (CLAUDE_CONFIG_DIR override honored, like the CLI). */ @@ -70,6 +76,14 @@ function serviceFileToken(env: NodeJS.ProcessEnv): string | null { /** Fetch the anthropic-flavor /v1/models from the local proxy and write the cache. */ export async function refreshGatewayModelCacheFromProxy( port: number, + options?: GatewayModelCacheRefreshOptions, +): Promise; +export async function refreshGatewayModelCacheFromProxy( + target: GatewayModelTarget, + options?: GatewayModelCacheRefreshOptions, +): Promise; +export async function refreshGatewayModelCacheFromProxy( + portOrTarget: number | GatewayModelTarget, options: GatewayModelCacheRefreshOptions = {}, ): Promise { try { @@ -82,12 +96,18 @@ export async function refreshGatewayModelCacheFromProxy( const configuredToken = options.admissionConfig?.apiKeys ?.find(entry => entry.key.trim().length > 0) ?.key.trim(); - const admissionToken = envToken || serviceFileToken(options.env ?? process.env) || configuredToken; + const admissionToken = typeof portOrTarget === "number" + ? envToken || serviceFileToken(options.env ?? process.env) || configuredToken + : portOrTarget.admissionToken; if (admissionToken) headers.set("x-opencodex-api-key", admissionToken); + const baseUrl = typeof portOrTarget === "number" + ? `http://127.0.0.1:${portOrTarget}` + : new URL(portOrTarget.baseUrl).origin; + // ?ids=cli pins the readable claude-ocx id family deterministically (audit 051 // #5): the cache prewrite must not depend on UA sniffing. - const res = await fetch(`http://127.0.0.1:${port}/v1/models?limit=1000&ids=cli`, { + const res = await (options.fetchImpl ?? fetch)(`${baseUrl}/v1/models?limit=1000&ids=cli`, { headers, signal: AbortSignal.timeout(options.timeoutMs ?? 3_000), }); @@ -100,7 +120,7 @@ export async function refreshGatewayModelCacheFromProxy( id: m.id as string, display_name: typeof m.display_name === "string" ? m.display_name : undefined, })); - return writeGatewayModelCache(`http://127.0.0.1:${port}`, models, options.configDir); + return writeGatewayModelCache(baseUrl, models, options.configDir); } catch { return null; } diff --git a/src/cli/claude.ts b/src/cli/claude.ts index ebb77d3359..74c9aa2314 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -21,11 +21,22 @@ import { resolveClaudeAuthMode } from "../claude/auth-mode"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; import { selfLaunchArgv } from "../lib/self-launch-argv"; import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "./launcher-context"; +import { readClientConnectionState } from "../client/state"; +import { readServiceApiTokenState } from "../lib/service-secrets"; +import { DEFAULT_CATALOG_PATH } from "../codex/paths"; +import { readFileSync } from "node:fs"; +import { aliasForNative, aliasForRoute } from "../claude/alias"; +import { desktop3pAlias } from "../claude/desktop-3p"; export interface ClaudeLaunchEnv { [key: string]: string | undefined; } +export interface ClaudeRoutingTarget { + baseUrl: string; + admissionToken: string; +} + /** * Injectable IO for tests. `env` is deliberately NOT injectable: it is bound to the * launch base so detection and the spawned process can never disagree (audit R3-3). @@ -61,6 +72,20 @@ function targetsLocalClaudeProxy(value: string | undefined, port: number): boole } } +function targetsClaudeRoutingTarget(value: string | undefined, target: ClaudeRoutingTarget): boolean { + if (!value) return false; + try { + const actual = new URL(value); + const expected = new URL(target.baseUrl); + return actual.origin === expected.origin + && (actual.pathname === "/" || actual.pathname === "") + && !actual.username + && !actual.password; + } catch { + return false; + } +} + /** * Pure env assembly (unit-tested): never sets ANTHROPIC_API_KEY (setting both * token vars triggers Claude Code's auth-conflict warning, 003 E1), and never @@ -70,11 +95,14 @@ function targetsLocalClaudeProxy(value: string | undefined, port: number): boole */ export function buildClaudeEnv( config: OcxConfig, - port: number, + portOrTarget: number | ClaudeRoutingTarget, base: ClaudeLaunchEnv, contextWindows: Record = {}, deps: ClaudeEnvDeps = {}, ): ClaudeLaunchEnv { + const explicitTarget = typeof portOrTarget === "number" ? null : portOrTarget; + const port = typeof portOrTarget === "number" ? portOrTarget : null; + const managedBaseUrl = explicitTarget ? new URL(explicitTarget.baseUrl).origin : `http://127.0.0.1:${port}`; const env: ClaudeLaunchEnv = { ...base }; // Step 1 — strip OUR OWN dummy from the inherited environment before anything reads // or writes the token slot. setDefault below preserves any non-empty value, so a @@ -120,9 +148,9 @@ export function buildClaudeEnv( if (deps.allowRootSkipPermissions === true) { setDefault("IS_SANDBOX", "1"); } - setDefault("ANTHROPIC_BASE_URL", `http://127.0.0.1:${port}`); + setDefault("ANTHROPIC_BASE_URL", managedBaseUrl); const existingBaseUrl = env.ANTHROPIC_BASE_URL; - if (existingBaseUrl) { + if (existingBaseUrl && port !== null) { try { const parsed = new URL(existingBaseUrl); const effectivePort = parsed.port === "" ? 80 : Number(parsed.port); @@ -142,16 +170,20 @@ export function buildClaudeEnv( // the user's Claude login. Only inject a token when the proxy actually requires an // admission key; otherwise Claude Code keeps its own OAuth and sends it to us — // native claude models then pass through verbatim (see server/claude-messages.ts). - const ownTokens = ownAdmissionTokens(config); - const targetsLocalProxy = targetsLocalClaudeProxy(env.ANTHROPIC_BASE_URL, port); + const ownTokens = explicitTarget ? [explicitTarget.admissionToken] : ownAdmissionTokens(config); + const targetsLocalProxy = explicitTarget + ? targetsClaudeRoutingTarget(env.ANTHROPIC_BASE_URL, explicitTarget) + : targetsLocalClaudeProxy(env.ANTHROPIC_BASE_URL, port!); + const isOwnAdmissionToken = (value: string): boolean => + ownTokens.includes(value) || isProxyAdmissionSecret(value, config); const inheritedApiKey = env.ANTHROPIC_API_KEY; - if (typeof inheritedApiKey === "string" && isProxyAdmissionSecret(inheritedApiKey, config)) { + if (typeof inheritedApiKey === "string" && isOwnAdmissionToken(inheritedApiKey)) { delete env.ANTHROPIC_API_KEY; } const hasUserApiKey = Boolean(env.ANTHROPIC_API_KEY?.trim()); const inheritedAuthToken = env.ANTHROPIC_AUTH_TOKEN; const inheritedTokenIsOurs = typeof inheritedAuthToken === "string" - && isProxyAdmissionSecret(inheritedAuthToken, config); + && isOwnAdmissionToken(inheritedAuthToken); // system-env may have injected the proxy's admission key into the parent. A // proof-bound external BASE_URL is still user-owned, so never let our inherited // key follow it. A user API key also wins on a local launch; remove only the token @@ -187,7 +219,7 @@ export function buildClaudeEnv( && typeof finalAuthToken === "string" && ( finalAuthToken.trim() === PROXY_MARKER - || isProxyAdmissionSecret(finalAuthToken, config) + || isOwnAdmissionToken(finalAuthToken) ); if (resolved.origin === "auto-unknown") { console.error("⚠ Claude 인증을 확인하지 못했습니다 — 구독 방식으로 진행합니다. GUI에서 인증 모드를 직접 지정하면 이 판단을 덮어쓸 수 있습니다."); @@ -270,6 +302,38 @@ export async function fetchClaudeContextWindows(config: OcxConfig, port: number, } } +export function readConnectedClaudeContextWindows(path = DEFAULT_CATALOG_PATH): Record { + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as { models?: unknown }; + if (!Array.isArray(parsed.models)) return {}; + const out: Record = {}; + const put = (key: string, value: number) => { if (out[key] === undefined) out[key] = value; }; + for (const row of parsed.models) { + if (!row || typeof row !== "object" || Array.isArray(row)) continue; + const entry = row as Record; + const slug = typeof entry.slug === "string" ? entry.slug : ""; + const contextWindow = typeof entry.context_window === "number" && entry.context_window > 0 + ? entry.context_window + : undefined; + if (!slug || contextWindow === undefined) continue; + put(slug, contextWindow); + const slash = slug.indexOf("/"); + if (slash > 0 && slash < slug.length - 1) { + const provider = slug.slice(0, slash); + const id = slug.slice(slash + 1); + put(aliasForRoute(provider, id), contextWindow); + put(desktop3pAlias(provider, id), contextWindow); + } else { + put(aliasForNative(slug), contextWindow); + put(desktop3pAlias("native", slug), contextWindow); + } + } + return out; + } catch { + return {}; + } +} + async function ensureProxyForClaude(): Promise { const live = await findLiveProxy(); if (live) return live.port; @@ -328,21 +392,45 @@ export async function cmdClaude(args: string[]): Promise { console.error("Claude inbound is disabled (config.claudeCode.enabled=false — flip the Claude ON toggle in the GUI or edit config)."); return 1; } - const port = await ensureProxyForClaude(); - if (!port) { - console.error("❌ Proxy did not become healthy after starting."); + const clientState = readClientConnectionState(); + if (clientState.kind === "invalid" || clientState.kind === "mismatched") { + console.error(`Client state is ${clientState.kind}: ${clientState.reason}`); return 1; } - const contextWindows = await fetchClaudeContextWindows(config, port); + let route: number | ClaudeRoutingTarget; + let contextWindows: Record; + if (clientState.kind === "connected") { + if (!clientState.value.selectedClients.includes("claude")) { + console.error("Claude is not selected for this remote hub connection."); + return 1; + } + const token = readServiceApiTokenState(); + if (token.kind !== "present" || token.fingerprint !== clientState.value.tokenFingerprint) { + console.error(token.kind === "absent" ? "Connected service token is missing." : "Connected service token ownership changed."); + return 1; + } + route = { baseUrl: clientState.value.serverUrl, admissionToken: token.token }; + contextWindows = readConnectedClaudeContextWindows(); + } else { + const port = await ensureProxyForClaude(); + if (!port) { + console.error("❌ Proxy did not become healthy after starting."); + return 1; + } + route = port; + contextWindows = await fetchClaudeContextWindows(config, port); + } const allowRootSkipPermissions = shouldAllowRootSkipPermissions(args); - const env = buildClaudeEnv(config, port, process.env, contextWindows, { allowRootSkipPermissions }); + const env = buildClaudeEnv(config, route, process.env, contextWindows, { allowRootSkipPermissions }); if (allowRootSkipPermissions) { console.error(rootSkipPermissionsNotice(env)); } // Pre-write the CLI's gateway-model cache (devlog 030): without a token the CLI // never refreshes it, so the picker would keep showing yesterday's aliases. try { - const cachePath = await refreshGatewayModelCacheFromProxy(port, { admissionConfig: config }); + const cachePath = typeof route === "number" + ? await refreshGatewayModelCacheFromProxy(route, { admissionConfig: config }) + : await refreshGatewayModelCacheFromProxy(route, { admissionConfig: config }); if (cachePath === null) { console.error("⚠ Gateway model cache could not be refreshed; the model picker may be stale."); } @@ -351,14 +439,16 @@ export async function cmdClaude(args: string[]): Promise { console.error(`⚠ Gateway model cache could not be refreshed: ${message}`); } // Sync roster agents (devlog 070): subagentModels + self -> ~/.claude/agents/ocx-*.md. - try { - const written = injectClaudeAgentDefs(config, contextWindows); - if (written === null) { - console.error("⚠ Claude agent definitions could not be synced; check ~/.claude/agents permissions."); + if (typeof route === "number") { + try { + const written = injectClaudeAgentDefs(config, contextWindows); + if (written === null) { + console.error("⚠ Claude agent definitions could not be synced; check ~/.claude/agents permissions."); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`⚠ Claude agent definitions could not be synced: ${message}`); } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`⚠ Claude agent definitions could not be synced: ${message}`); } return await new Promise(resolve => { const inv = commandInvocation("claude", args); diff --git a/src/cli/connect.ts b/src/cli/connect.ts new file mode 100644 index 0000000000..1182a9b2fb --- /dev/null +++ b/src/cli/connect.ts @@ -0,0 +1,198 @@ +import { existsSync, lstatSync } from "node:fs"; +import { DEFAULT_CATALOG_PATH } from "../codex/paths"; +import { + disconnectClient, + revokeConnectedClientKey, + connectClient, +} from "../client/connect"; +import { readClientConnectionState } from "../client/state"; +import { readServiceApiTokenState } from "../lib/service-secrets"; +import type { OcxConnectedClientId } from "../types"; +import { + CliUsageError, + csv, + printData, + readSecretLine, + rejectArgs, + runCliAction, + takeFlag, + takeOption, + type RuntimeApiDeps, +} from "./runtime-api"; + +export const CONNECT_USAGE = `Usage: + ocx connect [--management-url ] + (--pairing-code-stdin | --admin-token-stdin) + [--clients codex,claude] [--management-transport direct|relay] + [--allow-insecure-http] [--no-sync] + ocx connect status [--json] + ocx connect revoke --admin-token-stdin [--json]`; + +export const DISCONNECT_USAGE = `Usage: + ocx disconnect [--keep-catalog] [--json]`; + +export type ClientConnectionStatus = { + state: "disconnected" | "connected" | "invalid" | "mismatched"; + reason?: string; + serverUrl?: string; + managementUrl?: string; + managementTransport?: "direct" | "relay"; + protocolVersion?: number; + apiKeyId?: string; + selectedClients?: OcxConnectedClientId[]; + connectedAt?: string; + catalogSyncedAt?: string; + catalogAgeSeconds?: number; + catalog: "present" | "missing" | "unsafe"; + token: "owned" | "missing" | "changed" | "unsafe"; +}; + +export function collectClientConnectionStatus(now = Date.now()): ClientConnectionStatus { + const state = readClientConnectionState(); + const tokenState = readServiceApiTokenState(); + let catalog: ClientConnectionStatus["catalog"] = "missing"; + if (existsSync(DEFAULT_CATALOG_PATH)) { + try { + const stat = lstatSync(DEFAULT_CATALOG_PATH); + catalog = !stat.isSymbolicLink() && stat.isFile() ? "present" : "unsafe"; + } catch { + catalog = "unsafe"; + } + } + if (state.kind !== "connected") { + return { + state: state.kind, + ...(state.kind === "invalid" || state.kind === "mismatched" ? { reason: state.reason } : {}), + catalog, + token: tokenState.kind === "absent" ? "missing" : tokenState.kind === "unsafe" ? "unsafe" : "changed", + }; + } + const catalogAgeSeconds = state.value.catalogSyncedAt + ? Math.max(0, Math.floor((now - Date.parse(state.value.catalogSyncedAt)) / 1000)) + : undefined; + const token = tokenState.kind === "absent" + ? "missing" + : tokenState.kind === "unsafe" + ? "unsafe" + : tokenState.fingerprint === state.value.tokenFingerprint ? "owned" : "changed"; + return { + state: "connected", + serverUrl: state.value.serverUrl, + managementUrl: state.value.managementUrl, + managementTransport: state.value.managementTransport, + protocolVersion: state.value.protocolVersion, + apiKeyId: state.value.apiKeyId, + selectedClients: [...state.value.selectedClients], + connectedAt: state.value.connectedAt, + ...(state.value.catalogSyncedAt ? { catalogSyncedAt: state.value.catalogSyncedAt } : {}), + ...(catalogAgeSeconds !== undefined ? { catalogAgeSeconds } : {}), + catalog, + token, + }; +} + +function parseClients(raw: string | undefined): OcxConnectedClientId[] { + const values = csv(raw) ?? ["codex", "claude"]; + if (values.length < 1 || values.some(value => value !== "codex" && value !== "claude")) { + throw new CliUsageError("--clients must contain codex and/or claude", CONNECT_USAGE); + } + return values as OcxConnectedClientId[]; +} + +function statusLines(status: ClientConnectionStatus): string[] { + if (status.state !== "connected") { + return [`Connection: ${status.state}${status.reason ? ` (${status.reason})` : ""}`]; + } + return [ + "Connection: connected", + `Hub: ${status.serverUrl}`, + `Management: ${status.managementUrl} (${status.managementTransport})`, + `Protocol: ${status.protocolVersion}`, + `API key id: ${status.apiKeyId}`, + `Clients: ${status.selectedClients?.join(", ")}`, + `Token file: ${status.token}`, + `Catalog: ${status.catalog}${status.catalogAgeSeconds !== undefined ? ` (${status.catalogAgeSeconds}s old)` : ""}`, + ]; +} + +async function runConnect(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const serverUrl = args.shift(); + if (!serverUrl || serverUrl.startsWith("--")) throw new CliUsageError("hub URL is required", CONNECT_USAGE); + const managementUrl = takeOption(args, "--management-url"); + const clients = parseClients(takeOption(args, "--clients")); + const managementTransport = takeOption(args, "--management-transport") ?? "direct"; + if (managementTransport !== "direct" && managementTransport !== "relay") { + throw new CliUsageError("--management-transport must be direct or relay", CONNECT_USAGE); + } + const pairing = takeFlag(args, "--pairing-code-stdin"); + const admin = takeFlag(args, "--admin-token-stdin"); + const allowInsecureHttp = takeFlag(args, "--allow-insecure-http"); + const noSync = takeFlag(args, "--no-sync"); + if (Number(pairing) + Number(admin) !== 1) { + throw new CliUsageError("choose exactly one of --pairing-code-stdin or --admin-token-stdin", CONNECT_USAGE); + } + rejectArgs(args, CONNECT_USAGE, { redactValues: true }); + const secret = await readSecretLine(deps, pairing ? "pairing code" : "admin token"); + const value = new TextEncoder().encode(secret); + const connection = await connectClient({ + serverUrl, + ...(managementUrl ? { managementUrl } : {}), + credential: { kind: pairing ? "pairing-grant" : "admin", value }, + selectedClients: clients, + managementTransport, + allowInsecureHttp, + noSync, + }, { fetchImpl: deps.fetchImpl }); + console.log(`Connected to ${connection.serverUrl} as key ${connection.apiKeyId}.`); +} + +async function runRevoke(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + const admin = takeFlag(args, "--admin-token-stdin"); + if (!admin) throw new CliUsageError("revoke requires --admin-token-stdin", CONNECT_USAGE); + rejectArgs(args, CONNECT_USAGE, { redactValues: true }); + const value = new TextEncoder().encode(await readSecretLine(deps, "admin token")); + const result = await revokeConnectedClientKey({ kind: "admin", value }, { fetchImpl: deps.fetchImpl }); + printData(result, wantsJson, [`Revoked connected API key ${result.apiKeyId}. Disconnect this client next.`]); +} + +export async function handleConnectCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { + return runCliAction(async () => { + if (argv[0] === "status") { + const args = argv.slice(1); + const wantsJson = takeFlag(args, "--json"); + rejectArgs(args, CONNECT_USAGE, { redactValues: true }); + const status = collectClientConnectionStatus(); + printData(status, wantsJson, statusLines(status)); + return; + } + if (argv[0] === "revoke") { + await runRevoke(argv.slice(1), deps); + return; + } + await runConnect(argv, deps); + }); +} + +export async function handleDisconnectCommand(argv: string[]): Promise { + return runCliAction(async () => { + const args = [...argv]; + const keepCatalog = takeFlag(args, "--keep-catalog"); + const wantsJson = takeFlag(args, "--json"); + rejectArgs(args, DISCONNECT_USAGE, { redactValues: true }); + const result = await disconnectClient({ keepCatalog }); + const payload = { + ...result, + revoke: { + apiKeyId: result.apiKeyId, + location: "Integrations → API Keys", + }, + }; + printData(payload, wantsJson, [ + "Disconnected locally; native Codex state was restored.", + `The hub key ${result.apiKeyId} is still valid. Revoke it from Integrations → API Keys.`, + ]); + }); +} diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 408f3703c1..9d0832f346 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -57,6 +57,16 @@ const commandRunners: Record = { return Number(process.exitCode ?? 0); }, start: async deps => { + const { readClientConnectionState } = await import("../client/state"); + const clientState = readClientConnectionState(); + if (clientState.kind === "connected") { + console.error("Client mode does not start a local provider proxy in Remote Hub Phase 3; use 'ocx sync'."); + return 1; + } + if (clientState.kind === "invalid" || clientState.kind === "mismatched") { + console.error(`Client state is ${clientState.kind}: ${clientState.reason}`); + return 1; + } await deps.handleStart(); return Number(process.exitCode ?? 0); }, @@ -187,6 +197,14 @@ const commandRunners: Record = { return 0; }, ensure: async deps => { + const { readClientConnectionState } = await import("../client/state"); + const clientState = readClientConnectionState(); + if (clientState.kind !== "disconnected") { + console.error(clientState.kind === "connected" + ? "Client mode does not start a local provider proxy; use 'ocx sync'." + : `Client state is ${clientState.kind}: ${clientState.reason}`); + return 1; + } await deps.handleEnsure(); return Number(process.exitCode ?? 0); }, @@ -208,6 +226,29 @@ const commandRunners: Record = { // Separate flag on purpose: --restart-codex promises app-server-only scope, // and quitting the desktop app ends live conversations. const restartDesktopApp = syncArgs.includes("--restart-desktop-app"); + const { readClientConnectionState } = await import("../client/state"); + const clientState = readClientConnectionState(); + if (clientState.kind === "invalid" || clientState.kind === "mismatched") { + console.error(`Client state is ${clientState.kind}: ${clientState.reason}`); + return 1; + } + if (clientState.kind === "connected") { + try { + const { syncConnectedClient } = await import("../client/connect"); + const result = await syncConnectedClient({ restartCodex }); + console.log(result.stale + ? "Hub unavailable; retained and applied the last-known-good remote catalog (stale)." + : "Remote hub catalog synchronized."); + if (result.catalogWritten || result.cacheSynced) { + afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console }); + if (restartDesktopApp) await handleDesktopAppRestart(console); + } + return 0; + } catch (error) { + console.error(`Connected sync failed without local fallback: ${error instanceof Error ? error.message : String(error)}`); + return 1; + } + } const live = await deps.findLiveProxy(); const synced = await syncModelsToCodex( live?.port, @@ -263,6 +304,14 @@ const commandRunners: Record = { const { cmdV2 } = await import("./v2"); return await cmdV2(deps.args.slice(1), {}, async () => (await deps.findLiveProxy())?.port); }, + connect: async deps => { + const { handleConnectCommand } = await import("./connect"); + return await handleConnectCommand(deps.args.slice(1)); + }, + disconnect: async deps => { + const { handleDisconnectCommand } = await import("./connect"); + return await handleDisconnectCommand(deps.args.slice(1)); + }, "sync-cache": async deps => { const cacheArgs = deps.args.slice(1); const restartCodex = cacheArgs.includes("--restart-codex"); diff --git a/src/cli/help.ts b/src/cli/help.ts index bcc5e0591d..239d4b131c 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -31,6 +31,8 @@ Usage: ocx codex-shim Auto-start proxy when \`codex\` launches (install|status|uninstall|remove) ocx tray Windows status tray (install|start|stop|status|uninstall) ocx ensure Ensure the proxy is running and Codex config/cache are current + ocx connect Connect this machine to a remote OpenCodex hub (credential via stdin) + ocx disconnect Restore local state and clear the hub connection ocx sync [--restart-codex] Fetch models from providers and inject into Codex config ocx sync-cache [--restart-codex] Refresh Codex's model cache from the active catalog diff --git a/src/cli/index.ts b/src/cli/index.ts index 8e3f6b2d57..c07e53d6db 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -877,6 +877,10 @@ async function handleStatus() { console.log(` Runtime: ${status.json.paths.runtime}`); console.log(` Runtime source: ${status.json.runtime.source}${status.json.runtime.overrideEnv ? ` (${status.json.runtime.overrideEnv})` : ""}`); console.log(` Default provider: ${status.json.defaultProvider}`); + console.log(` Remote hub: ${status.json.connection.state}${status.json.connection.serverUrl ? ` (${status.json.connection.serverUrl})` : ""}`); + if (status.json.connection.state === "invalid" || status.json.connection.state === "mismatched") { + console.log(` ⚠️ ${status.json.connection.reason}`); + } console.log(` Codex autostart: ${status.json.codexAutostart ? "enabled" : "disabled"}`); console.log(` Restart safety: ${startupHealthSummary(status.json.startup)}`); console.log(` ${formatStartupRoutingDetail(status.json.startup)}`); diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 0df4464922..e9cac8593e 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -86,6 +86,21 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ ], }, { name: "ensure", usage: "ocx ensure", summary: "Ensure the proxy is running and Codex config/cache are current." }, + { + name: "connect", + usage: "ocx connect [--management-url ] (--pairing-code-stdin | --admin-token-stdin) [--clients codex,claude] [--management-transport direct|relay] [--allow-insecure-http] [--no-sync]", + summary: "Connect this machine to a remote OpenCodex hub without persisting the one-time authority.", + details: [ + "Status: ocx connect status [--json]", + "Revoke while connected: ocx connect revoke --admin-token-stdin [--json]", + "Credentials are accepted only through stdin; argv and environment credential forms are not supported.", + ], + }, + { + name: "disconnect", + usage: "ocx disconnect [--keep-catalog] [--json]", + summary: "Restore local client state offline and clear the remote-hub connection.", + }, { name: "sync", usage: "ocx sync [--restart-codex] [--restart-desktop-app]", diff --git a/src/cli/runtime-api.ts b/src/cli/runtime-api.ts index 9c74fc7d1b..dc28bf053f 100644 --- a/src/cli/runtime-api.ts +++ b/src/cli/runtime-api.ts @@ -138,7 +138,16 @@ export function csv(value: string | undefined): string[] | undefined { * `--code=https://…?code=SECRET` that writes the authorization code to stderr, * which is the exact exposure the stdin path exists to avoid. */ -const SECRET_OPTIONS = ["--code", "--headers"]; +const SECRET_OPTIONS = [ + "--code", + "--headers", + "--token", + "--admin-token", + "--pairing-code", + "--credential-env", + "--admin-token-env", + "--pairing-code-env", +]; /** * Replace credential values before they are reported back. diff --git a/src/cli/status.ts b/src/cli/status.ts index 1ba11f96a3..a1156d3c5a 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -13,6 +13,7 @@ import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortCla import { redactSecretString, redactUserPath } from "../lib/redact"; import { collectOrcaCodexHomeDiagnostic, type OrcaCodexHomeDiagnostic } from "../codex/home"; import { grokFenceEndpointDrift, readGrokStatus } from "../grok/status"; +import { collectClientConnectionStatus } from "./connect"; type HealthCheck = { ok: boolean; @@ -54,6 +55,18 @@ export type CliStatusJson = { source: "default" | "file" | "fallback"; error: string | null; }; + connection: { + state: "disconnected" | "connected" | "invalid" | "mismatched"; + reason?: string; + serverUrl?: string; + managementUrl?: string; + protocolVersion?: number; + apiKeyId?: string; + selectedClients?: string[]; + catalog?: "present" | "missing" | "unsafe"; + catalogAgeSeconds?: number; + credentialFile: "owned" | "missing" | "changed" | "unsafe"; + }; service: { summary: string }; codexShim: { summary: string }; codexPlugins: CodexPluginsDiagnostic; @@ -171,6 +184,7 @@ async function checkProxyHealth(target: ListenTarget): Promise { export async function collectStatus(): Promise { const configDiagnostics = readConfigDiagnostics(); const config = configDiagnostics.config; + const clientConnection = collectClientConnectionStatus(); // Prefer identity-verified liveness (runtime-port + /healthz) over ocx.pid alone (#618). // Pass the already-resolved diagnostics config so findLiveProxy does not re-load and // warn on malformed config.json (status --json must stay stderr-clean). @@ -342,6 +356,18 @@ export async function collectStatus(): Promise { source: configDiagnostics.source, error: configDiagnostics.error, }, + connection: { + state: clientConnection.state, + ...(clientConnection.reason ? { reason: clientConnection.reason } : {}), + ...(clientConnection.serverUrl ? { serverUrl: clientConnection.serverUrl } : {}), + ...(clientConnection.managementUrl ? { managementUrl: clientConnection.managementUrl } : {}), + ...(clientConnection.protocolVersion ? { protocolVersion: clientConnection.protocolVersion } : {}), + ...(clientConnection.apiKeyId ? { apiKeyId: clientConnection.apiKeyId } : {}), + ...(clientConnection.selectedClients ? { selectedClients: [...clientConnection.selectedClients] } : {}), + catalog: clientConnection.catalog, + ...(clientConnection.catalogAgeSeconds !== undefined ? { catalogAgeSeconds: clientConnection.catalogAgeSeconds } : {}), + credentialFile: clientConnection.token, + }, service: { summary: serviceSummary }, codexShim: { summary: codexShimSummary }, codexPlugins, diff --git a/src/client/connect.ts b/src/client/connect.ts new file mode 100644 index 0000000000..3c954e5146 --- /dev/null +++ b/src/client/connect.ts @@ -0,0 +1,406 @@ +import { createHash } from "node:crypto"; +import { + existsSync, + lstatSync, + readFileSync, + unlinkSync, +} from "node:fs"; +import { hostname } from "node:os"; +import { atomicWriteFile, loadConfig } from "../config"; +import { invalidateCodexModelsCache } from "../codex/catalog/sync"; +import { + injectCodexConfig, + currentExternalCodexModelProvider, + isCodexRoutingInjected, + type CodexRoutingTarget, +} from "../codex/inject"; +import { + journalOwner, + restoreJournalState, +} from "../codex/journal"; +import { DEFAULT_CATALOG_PATH } from "../codex/paths"; +import { + readServiceApiTokenState, + removeServiceApiTokenFileIfOwned, + writeServiceApiTokenFile, +} from "../lib/service-secrets"; +import { MAX_REMOTE_CATALOG_BYTES } from "../server/catalog-download"; +import type { + OcxClientConnectionConfig, + OcxConnectedClientId, +} from "../types"; +import { + downloadClientCatalog, + exchangeConnectPairingGrant, + fetchHubReady, + HubClientError, + issueClientKey, + normalizeHubOrigin, + revokeClientKey, + type ConnectGuiSession, + type IssuedClientKey, + type OneTimeConnectCredential, +} from "./hub-client"; +import { + clearClientConnection, + commitClientConnection, + readClientConnectionState, +} from "./state"; + +export interface ConnectOptions { + serverUrl: string; + managementUrl?: string; + credential: OneTimeConnectCredential; + selectedClients: OcxConnectedClientId[]; + managementTransport: "direct" | "relay"; + noSync?: boolean; + allowInsecureHttp?: boolean; +} + +export interface ClientConnectDeps { + fetchImpl?: typeof fetch; + now?: () => Date; +} + +type CatalogSnapshot = + | { kind: "absent" } + | { kind: "file"; body: string; fingerprint: string }; + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function catalogSnapshot(): CatalogSnapshot { + if (!existsSync(DEFAULT_CATALOG_PATH)) return { kind: "absent" }; + const stat = lstatSync(DEFAULT_CATALOG_PATH); + if (stat.isSymbolicLink() || !stat.isFile() || stat.size > MAX_REMOTE_CATALOG_BYTES) { + throw new Error("existing OpenCodex catalog is not a bounded regular file"); + } + const body = readFileSync(DEFAULT_CATALOG_PATH, "utf8"); + return { kind: "file", body, fingerprint: sha256(body) }; +} + +function restoreCatalogSnapshot(snapshot: CatalogSnapshot, writtenFingerprint: string): boolean { + try { + if (!existsSync(DEFAULT_CATALOG_PATH)) return snapshot.kind === "absent"; + const stat = lstatSync(DEFAULT_CATALOG_PATH); + if (stat.isSymbolicLink() || !stat.isFile() || stat.size > MAX_REMOTE_CATALOG_BYTES) return false; + const current = readFileSync(DEFAULT_CATALOG_PATH, "utf8"); + if (sha256(current) !== writtenFingerprint) return false; + if (snapshot.kind === "absent") unlinkSync(DEFAULT_CATALOG_PATH); + else atomicWriteFile(DEFAULT_CATALOG_PATH, snapshot.body); + return true; + } catch { + return false; + } +} + +function validLocalCatalog(): string { + const snapshot = catalogSnapshot(); + if (snapshot.kind !== "file") throw new Error("connected catalog is missing"); + try { + const parsed = JSON.parse(snapshot.body) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("invalid"); + } catch { + throw new Error("connected catalog is malformed"); + } + return snapshot.body; +} + +function catalogMatchesEtag(body: string, etag: string | undefined): boolean { + if (!etag) return false; + const digest = createHash("sha256").update(body).digest("base64url"); + return etag === `"sha256-${digest}"` || etag === `W/"sha256-${digest}"`; +} + +function routingTarget(serverUrl: string): CodexRoutingTarget { + return { + baseUrl: `${serverUrl}/v1`, + requiresAdmissionToken: true, + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + }; +} + +function localGuiOrigin(): string { + const port = loadConfig().port; + return `http://localhost:${Number.isInteger(port) && port > 0 ? port : 10100}`; +} + +function clientKeyName(): string { + const raw = `ocx connect ${hostname() || "client"}`; + return raw.slice(0, 80); +} + +function releaseCredential(credential: OneTimeConnectCredential): void { + credential.value.fill(0); +} + +async function cleanupIssuedKey( + managementUrl: string, + credential: { kind: "admin"; value: Uint8Array } | { kind: "gui-session"; value: ConnectGuiSession }, + issuedId: string, + deps: ClientConnectDeps, +): Promise { + try { + await revokeClientKey(managementUrl, credential, issuedId, { fetchImpl: deps.fetchImpl }); + return null; + } catch { + return `Hub cleanup could not revoke client key ${issuedId}; revoke it from Integrations → API Keys.`; + } +} + +export async function connectClient( + options: ConnectOptions, + deps: ClientConnectDeps = {}, +): Promise { + let serverUrl = ""; + let managementUrl = ""; + let issued: IssuedClientKey | null = null; + let cleanupCredential: { kind: "admin"; value: Uint8Array } | { kind: "gui-session"; value: ConnectGuiSession } | null = null; + let tokenFingerprint: string | null = null; + let priorCatalog: CatalogSnapshot | null = null; + let writtenCatalogFingerprint: string | null = null; + let injectionCommitted = false; + let committed = false; + try { + serverUrl = normalizeHubOrigin(options.serverUrl); + if (options.managementUrl) managementUrl = normalizeHubOrigin(options.managementUrl); + if (options.selectedClients.length < 1 || new Set(options.selectedClients).size !== options.selectedClients.length) { + throw new Error("at least one unique connected client is required"); + } + const state = readClientConnectionState(); + if (state.kind !== "disconnected") { + const detail = state.kind === "connected" ? "already connected" : state.reason; + throw new Error(`connect refused: client state is ${state.kind} (${detail})`); + } + const externalProvider = currentExternalCodexModelProvider(); + if (externalProvider) throw new Error(`connect refused: external Codex provider ${externalProvider} owns config.toml`); + const tokenState = readServiceApiTokenState(); + if (tokenState.kind !== "absent") { + throw new Error(tokenState.kind === "unsafe" ? tokenState.reason : "connect refused: service token file already exists"); + } + + const ready = await fetchHubReady(serverUrl, { fetchImpl: deps.fetchImpl }); + if (ready.status !== "ready") throw new Error(`hub is not ready (${ready.status})`); + managementUrl = managementUrl || ready.metadata.managementUrl; + if (options.managementTransport === "relay") { + throw new Error("relay management transport is not available before Remote Hub Phase 4"); + } + + if (options.credential.kind === "pairing-grant") { + const session = await exchangeConnectPairingGrant( + managementUrl, + localGuiOrigin(), + options.credential.value, + { allowInsecureHttp: options.allowInsecureHttp, fetchImpl: deps.fetchImpl }, + ); + cleanupCredential = { kind: "gui-session", value: session }; + } else { + cleanupCredential = { kind: "admin", value: options.credential.value }; + } + issued = await issueClientKey(managementUrl, cleanupCredential, clientKeyName(), { fetchImpl: deps.fetchImpl }); + + priorCatalog = catalogSnapshot(); + const persisted = writeServiceApiTokenFile(issued.key); + tokenFingerprint = persisted.fingerprint; + + const catalog = await downloadClientCatalog(serverUrl, issued.key, { fetchImpl: deps.fetchImpl }); + if (catalog.kind !== "fresh" || !catalog.etag) { + throw new Error("initial hub catalog did not include a fresh ETag"); + } + atomicWriteFile(DEFAULT_CATALOG_PATH, catalog.body); + writtenCatalogFingerprint = sha256(catalog.body); + + const config = loadConfig(); + const target = routingTarget(serverUrl); + const injectConfig = { ...config, syncResumeHistory: false }; + const preflight = await injectCodexConfig(config.port, injectConfig, { + validateOnly: true, + routingTarget: target, + catalogPath: DEFAULT_CATALOG_PATH, + journalOwner: { kind: "client", apiKeyId: issued.id }, + }); + if (!preflight.success) throw new Error(preflight.message); + + if (!options.noSync && options.selectedClients.includes("codex")) { + const injected = await injectCodexConfig(config.port, injectConfig, { + routingTarget: target, + catalogPath: DEFAULT_CATALOG_PATH, + journalOwner: { kind: "client", apiKeyId: issued.id }, + }); + if (!injected.success || injected.status === "skipped") throw new Error(injected.message); + injectionCommitted = true; + if (!isCodexRoutingInjected()) throw new Error("Codex routing target was not committed"); + } + + const now = (deps.now ?? (() => new Date()))().toISOString(); + const connection: OcxClientConnectionConfig = { + serverUrl, + managementUrl, + managementTransport: options.managementTransport, + selectedClients: [...options.selectedClients], + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + apiKeyId: issued.id, + tokenFingerprint: persisted.fingerprint, + protocolVersion: 1, + connectedAt: now, + catalogEtag: catalog.etag, + catalogSyncedAt: now, + }; + commitClientConnection(connection); + committed = true; + return connection; + } catch (error) { + const rollbackFailures: string[] = []; + if (injectionCommitted) { + const restored = restoreJournalState(); + if (!restored.complete) rollbackFailures.push("Codex journal restore was partial"); + } + if (priorCatalog && writtenCatalogFingerprint && !restoreCatalogSnapshot(priorCatalog, writtenCatalogFingerprint)) { + rollbackFailures.push("catalog rollback did not match the written artifact"); + } + if (tokenFingerprint) { + const removed = removeServiceApiTokenFileIfOwned(tokenFingerprint); + if (removed === "changed") rollbackFailures.push("service token changed during rollback"); + } + let remoteCleanup: string | null = null; + if (issued && cleanupCredential && managementUrl) { + remoteCleanup = await cleanupIssuedKey(managementUrl, cleanupCredential, issued.id, deps); + } + const base = error instanceof Error ? error.message : String(error); + const details = [ + ...rollbackFailures, + ...(remoteCleanup ? [remoteCleanup] : []), + ]; + throw new Error(details.length > 0 ? `${base}. ${details.join(" ")}` : base, { cause: error }); + } finally { + releaseCredential(options.credential); + cleanupCredential = null; + issued = null; + if (!committed) { + tokenFingerprint = null; + priorCatalog = null; + writtenCatalogFingerprint = null; + } + } +} + +export async function syncConnectedClient( + _options: { restartCodex?: boolean } = {}, + deps: ClientConnectDeps = {}, +): Promise<{ catalogWritten: boolean; cacheSynced: boolean; injected: boolean; stale: boolean }> { + const state = readClientConnectionState(); + if (state.kind !== "connected") throw new Error(`connected sync refused: client state is ${state.kind}`); + const token = readServiceApiTokenState(); + if (token.kind !== "present" || token.fingerprint !== state.value.tokenFingerprint) { + throw new Error(token.kind === "absent" ? "connected service token is missing" : "connected service token ownership changed"); + } + + let catalogWritten = false; + let stale = false; + let next = state.value; + try { + const downloaded = await downloadClientCatalog(state.value.serverUrl, token.token, { + etag: state.value.catalogEtag, + fetchImpl: deps.fetchImpl, + }); + if (downloaded.kind === "fresh") { + atomicWriteFile(DEFAULT_CATALOG_PATH, downloaded.body); + catalogWritten = true; + const now = (deps.now ?? (() => new Date()))().toISOString(); + next = { + ...state.value, + ...(downloaded.etag ? { catalogEtag: downloaded.etag } : {}), + catalogSyncedAt: now, + }; + commitClientConnection(next); + } else { + validLocalCatalog(); + } + } catch (error) { + const transient = error instanceof HubClientError + && (error.code === "unreachable" || (error.status !== undefined && error.status >= 500)); + if (!transient) throw error; + validLocalCatalog(); + stale = true; + } + + let injected = false; + if (next.selectedClients.includes("codex")) { + const config = loadConfig(); + const result = await injectCodexConfig(config.port, { ...config, syncResumeHistory: false }, { + routingTarget: routingTarget(next.serverUrl), + catalogPath: DEFAULT_CATALOG_PATH, + journalOwner: { kind: "client", apiKeyId: next.apiKeyId }, + }); + if (!result.success || result.status === "skipped") throw new Error(result.message); + injected = true; + } + const cacheSynced = invalidateCodexModelsCache({ allowWhenDesiredDisabled: true }); + return { catalogWritten, cacheSynced, injected, stale }; +} + +function removeOwnedCatalog(connection: OcxClientConnectionConfig): "removed" | "absent" | "changed" { + if (!existsSync(DEFAULT_CATALOG_PATH)) return "absent"; + try { + const body = validLocalCatalog(); + if (!catalogMatchesEtag(body, connection.catalogEtag)) return "changed"; + unlinkSync(DEFAULT_CATALOG_PATH); + return "removed"; + } catch { + return "changed"; + } +} + +export async function disconnectClient( + options: { keepCatalog?: boolean } = {}, +): Promise<{ restored: boolean; tokenRemoved: boolean; catalogRemoved: boolean; apiKeyId: string }> { + const state = readClientConnectionState(); + if (state.kind !== "connected") throw new Error(`disconnect refused: client state is ${state.kind}`); + const token = readServiceApiTokenState(); + if (token.kind !== "present" || token.fingerprint !== state.value.tokenFingerprint) { + throw new Error(token.kind === "absent" ? "disconnect refused: service token is missing" : "disconnect refused: service token ownership changed"); + } + + let restored = true; + if (state.value.selectedClients.includes("codex")) { + const owner = journalOwner(); + if (owner?.kind === "client" && owner.apiKeyId === state.value.apiKeyId) { + restored = restoreJournalState().complete; + } else if (owner !== null || isCodexRoutingInjected()) { + throw new Error("disconnect refused: Codex journal ownership conflicts with the connected key"); + } + if (!restored) throw new Error("disconnect refused: Codex journal restore was partial"); + } + + const tokenRemoval = removeServiceApiTokenFileIfOwned(state.value.tokenFingerprint); + if (tokenRemoval === "changed") throw new Error("disconnect refused: service token changed before removal"); + let catalogRemoval: "removed" | "absent" | "changed" = "absent"; + if (!options.keepCatalog) { + catalogRemoval = removeOwnedCatalog(state.value); + if (catalogRemoval === "changed") throw new Error("disconnect refused: catalog ownership changed"); + } + if (clearClientConnection(state.value.apiKeyId) !== "committed") { + throw new Error("disconnect refused: client state changed before final commit"); + } + return { + restored, + tokenRemoved: tokenRemoval === "removed", + catalogRemoved: catalogRemoval === "removed", + apiKeyId: state.value.apiKeyId, + }; +} + +export async function revokeConnectedClientKey( + credential: { kind: "admin"; value: Uint8Array }, + deps: ClientConnectDeps = {}, +): Promise<{ apiKeyId: string }> { + try { + const state = readClientConnectionState(); + if (state.kind !== "connected") throw new Error("connect revoke is available only while connected"); + await revokeClientKey(state.value.managementUrl, credential, state.value.apiKeyId, { fetchImpl: deps.fetchImpl }); + return { apiKeyId: state.value.apiKeyId }; + } finally { + credential.value.fill(0); + } +} diff --git a/src/client/hub-client.ts b/src/client/hub-client.ts new file mode 100644 index 0000000000..af188280ff --- /dev/null +++ b/src/client/hub-client.ts @@ -0,0 +1,301 @@ +import { MAX_REMOTE_CATALOG_BYTES } from "../server/catalog-download"; +import { + checkRemoteProtocolCompatibility, + parseRemoteReadyMetadata, + type RemoteReadyMetadata, +} from "../remote/protocol"; + +const READY_BODY_LIMIT = 64 * 1024; +const MANAGEMENT_BODY_LIMIT = 128 * 1024; +const DEFAULT_TIMEOUT_MS = 5_000; + +export type OneTimeConnectCredential = + | { kind: "admin"; value: Uint8Array } + | { kind: "pairing-grant"; value: Uint8Array }; + +export interface ConnectGuiSession { + token: string; + csrfToken: string; + browserOrigin: string; + serverOrigin: string; +} + +export interface IssuedClientKey { + id: string; + key: string; + createdAt: string; + name: string; +} + +export class HubClientError extends Error { + constructor( + readonly code: string, + message: string, + readonly status?: number, + options?: ErrorOptions, + ) { + super(message, options); + this.name = "HubClientError"; + } +} + +function credentialString(value: Uint8Array): string { + const decoded = new TextDecoder("utf-8", { fatal: true }).decode(value).trim(); + if (!decoded || /[\r\n\0]/.test(decoded) || value.byteLength > 4096) { + throw new HubClientError("credential_invalid", "Connect credential is invalid"); + } + return decoded; +} + +function safeTimeout(timeoutMs: number | undefined): number { + return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 + ? Math.min(Math.floor(timeoutMs), 120_000) + : DEFAULT_TIMEOUT_MS; +} + +async function fetchBounded( + fetchImpl: typeof fetch, + url: string, + init: RequestInit, + timeoutMs: number | undefined, +): Promise { + try { + const response = await fetchImpl(url, { + ...init, + redirect: "manual", + signal: AbortSignal.timeout(safeTimeout(timeoutMs)), + }); + if (response.status >= 300 && response.status < 400) { + throw new HubClientError("redirect_refused", "Hub request redirect was refused", response.status); + } + return response; + } catch (error) { + if (error instanceof HubClientError) throw error; + throw new HubClientError("unreachable", "Hub request did not complete", undefined, { cause: error }); + } +} + +async function boundedText(response: Response, maxBytes: number): Promise { + const declared = Number(response.headers.get("content-length") ?? "0"); + if (Number.isFinite(declared) && declared > maxBytes) { + throw new HubClientError("body_too_large", "Hub response exceeded the allowed size", response.status); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > maxBytes) { + throw new HubClientError("body_too_large", "Hub response exceeded the allowed size", response.status); + } + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (error) { + throw new HubClientError("body_invalid", "Hub response was not valid UTF-8", response.status, { cause: error }); + } +} + +function parseJson(text: string, code: string): unknown { + try { + return JSON.parse(text) as unknown; + } catch (error) { + throw new HubClientError(code, "Hub returned malformed JSON", undefined, { cause: error }); + } +} + +export function normalizeHubOrigin(input: string): string { + let parsed: URL; + try { + parsed = new URL(input); + } catch { + throw new HubClientError("url_invalid", "Hub URL must be an absolute HTTP(S) URL"); + } + if ( + (parsed.protocol !== "http:" && parsed.protocol !== "https:") + || parsed.username + || parsed.password + || parsed.search + || parsed.hash + || (parsed.pathname !== "/" && parsed.pathname !== "/v1" && parsed.pathname !== "/v1/") + ) { + throw new HubClientError( + "url_invalid", + "Hub URL must be an HTTP(S) origin without credentials, query, fragment, or non-/v1 path", + ); + } + return parsed.origin; +} + +export async function fetchHubReady( + serverUrl: string, + options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {}, +): Promise<{ status: "ready" | "pending" | "failed"; metadata: RemoteReadyMetadata }> { + const origin = normalizeHubOrigin(serverUrl); + const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/readyz`, { + method: "GET", + headers: { Accept: "application/json" }, + }, options.timeoutMs); + const body = parseJson(await boundedText(response, READY_BODY_LIMIT), "ready_invalid"); + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw new HubClientError("ready_invalid", "Hub readiness response was invalid", response.status); + } + const raw = body as Record; + const status = raw.status; + if (status !== "ready" && status !== "pending" && status !== "failed") { + throw new HubClientError("ready_invalid", "Hub readiness status was invalid", response.status); + } + const metadata = parseRemoteReadyMetadata(raw); + const compatibility = checkRemoteProtocolCompatibility(raw); + if (!metadata || !compatibility.ok) { + throw new HubClientError( + compatibility.ok ? "ready_invalid" : compatibility.reason, + compatibility.ok ? "Hub readiness metadata was invalid" : compatibility.message, + response.status, + ); + } + if ((status === "ready" && response.status !== 200) || (status !== "ready" && response.status !== 503)) { + throw new HubClientError("ready_invalid", "Hub readiness HTTP status did not match its state", response.status); + } + return { status, metadata }; +} + +function htmlMeta(html: string, name: string): string | null { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = new RegExp(`") + .replaceAll("&", "&") ?? null; +} + +export async function exchangeConnectPairingGrant( + managementUrl: string, + browserOrigin: string, + grant: Uint8Array, + options: { allowInsecureHttp?: boolean; timeoutMs?: number; fetchImpl?: typeof fetch } = {}, +): Promise { + const origin = normalizeHubOrigin(managementUrl); + const browser = normalizeHubOrigin(browserOrigin); + if (new URL(origin).protocol !== "https:" && options.allowInsecureHttp !== true) { + throw new HubClientError("insecure_http_refused", "Pairing over HTTP requires --allow-insecure-http"); + } + const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/opencodex-session`, { + method: "POST", + headers: { "Content-Type": "application/json", Origin: browser, Accept: "text/html" }, + body: JSON.stringify({ grant: credentialString(grant) }), + }, options.timeoutMs); + if (!response.ok) throw new HubClientError("pairing_refused", "Hub pairing grant was refused", response.status); + const html = await boundedText(response, MANAGEMENT_BODY_LIMIT); + const session: ConnectGuiSession = { + token: htmlMeta(html, "opencodex-session-token") ?? "", + csrfToken: htmlMeta(html, "opencodex-session-csrf") ?? "", + browserOrigin: htmlMeta(html, "opencodex-session-origin") ?? "", + serverOrigin: htmlMeta(html, "opencodex-session-server-origin") ?? "", + }; + if (!session.token || !session.csrfToken || session.browserOrigin !== browser || session.serverOrigin !== origin) { + throw new HubClientError("pairing_invalid", "Hub pairing session response was invalid", response.status); + } + return session; +} + +function parseIssuedClientKey(value: unknown): IssuedClientKey | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const raw = value as Record; + if ( + typeof raw.id !== "string" || !raw.id || raw.id.length > 256 + || typeof raw.name !== "string" || !raw.name || raw.name.length > 80 + || typeof raw.key !== "string" || !/^ocx_data_[0-9a-f]{40}$/.test(raw.key) + || typeof raw.createdAt !== "string" || Number.isNaN(Date.parse(raw.createdAt)) + ) return null; + return { id: raw.id, name: raw.name, key: raw.key, createdAt: raw.createdAt }; +} + +export async function issueClientKey( + managementUrl: string, + credential: + | { kind: "admin"; value: Uint8Array } + | { kind: "gui-session"; value: ConnectGuiSession }, + name: string, + options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {}, +): Promise { + const origin = normalizeHubOrigin(managementUrl); + if (!name.trim() || name.length > 80 || /[\x00-\x1f\x7f]/.test(name)) { + throw new HubClientError("key_name_invalid", "Client key name is invalid"); + } + if (credential.kind === "admin" && new URL(origin).protocol !== "https:") { + throw new HubClientError("admin_http_refused", "Admin credentials may be sent only over HTTPS"); + } + const headers = new Headers({ "Content-Type": "application/json", Accept: "application/json" }); + if (credential.kind === "admin") { + headers.set("x-opencodex-api-key", credentialString(credential.value)); + } else { + headers.set("x-opencodex-api-key", credential.value.token); + headers.set("Origin", credential.value.browserOrigin); + headers.set("X-OpenCodex-GUI-Origin", credential.value.browserOrigin); + headers.set("X-OpenCodex-CSRF-Token", credential.value.csrfToken); + } + const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/api/keys`, { + method: "POST", + headers, + body: JSON.stringify({ name: name.trim() }), + }, options.timeoutMs); + if (!response.ok) { + throw new HubClientError(`key_issue_http_${response.status}`, `Hub refused client key issuance (${response.status})`, response.status); + } + const issued = parseIssuedClientKey(parseJson( + await boundedText(response, MANAGEMENT_BODY_LIMIT), + "key_issue_invalid", + )); + if (!issued) throw new HubClientError("key_issue_invalid", "Hub returned an invalid client key response", response.status); + return issued; +} + +export async function revokeClientKey( + managementUrl: string, + credential: { kind: "admin"; value: Uint8Array } | { kind: "gui-session"; value: ConnectGuiSession }, + id: string, + options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {}, +): Promise { + const origin = normalizeHubOrigin(managementUrl); + if (!id || id.length > 256) throw new HubClientError("key_id_invalid", "Client key id is invalid"); + if (credential.kind === "admin" && new URL(origin).protocol !== "https:") { + throw new HubClientError("admin_http_refused", "Admin credentials may be sent only over HTTPS"); + } + const headers = new Headers({ "Content-Type": "application/json", Accept: "application/json" }); + if (credential.kind === "admin") headers.set("x-opencodex-api-key", credentialString(credential.value)); + else { + headers.set("x-opencodex-api-key", credential.value.token); + headers.set("Origin", credential.value.browserOrigin); + headers.set("X-OpenCodex-GUI-Origin", credential.value.browserOrigin); + headers.set("X-OpenCodex-CSRF-Token", credential.value.csrfToken); + } + const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/api/keys`, { + method: "DELETE", + headers, + body: JSON.stringify({ id }), + }, options.timeoutMs); + if (!response.ok) throw new HubClientError("key_revoke_failed", `Hub refused key revocation (${response.status})`, response.status); +} + +export async function downloadClientCatalog( + serverUrl: string, + admissionToken: string, + options: { etag?: string; timeoutMs?: number; maxBytes?: number; fetchImpl?: typeof fetch } = {}, +): Promise<{ kind: "fresh"; body: string; etag?: string } | { kind: "not-modified" }> { + const origin = normalizeHubOrigin(serverUrl); + const headers = new Headers({ Accept: "application/json", "x-opencodex-api-key": admissionToken }); + if (options.etag) headers.set("If-None-Match", options.etag); + const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/v1/catalog`, { + method: "GET", + headers, + }, options.timeoutMs); + if (response.status === 304) return { kind: "not-modified" }; + if (!response.ok) { + const code = response.status === 401 ? "catalog_unauthorized" : `catalog_http_${response.status}`; + throw new HubClientError(code, `Hub catalog request failed (${response.status})`, response.status); + } + const body = await boundedText(response, options.maxBytes ?? MAX_REMOTE_CATALOG_BYTES); + const parsed = parseJson(body, "catalog_invalid"); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new HubClientError("catalog_invalid", "Hub catalog response was invalid", response.status); + } + const etag = response.headers.get("etag")?.trim() || undefined; + return { kind: "fresh", body, ...(etag ? { etag } : {}) }; +} From ab7373e916da82d0eb9199c591e42a311c5c19ce Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 02:51:08 +0900 Subject: [PATCH 04/11] test(connect): cover transaction and client routing --- tests/api-keys-routes.test.ts | 23 +++ tests/claude-cli.test.ts | 26 +++ tests/claude-gateway-cache.test.ts | 23 +++ tests/cli-dispatch.test.ts | 31 ++++ tests/cli-help.test.ts | 14 ++ tests/cli-registry.test.ts | 10 ++ tests/cli-status-json.test.ts | 11 ++ tests/client-connect.test.ts | 264 +++++++++++++++++++++++++++++ 8 files changed, 402 insertions(+) create mode 100644 tests/client-connect.test.ts diff --git a/tests/api-keys-routes.test.ts b/tests/api-keys-routes.test.ts index d628a4d430..267916701b 100644 --- a/tests/api-keys-routes.test.ts +++ b/tests/api-keys-routes.test.ts @@ -84,6 +84,29 @@ afterEach(() => { }); describe("POST /api/keys", () => { + test("a raw pairing grant cannot authorize the key route", async () => { + saveConfig({ + ...baseConfig(), + runtimeRole: "hub", + hub: { managementPublicOrigin: "https://hub.example.test" }, + }); + const server = startServer(0); + try { + const response = await fetch(new URL("/api/keys", server.url), { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-opencodex-api-key": `ocx_pair_${"a".repeat(43)}`, + }, + body: JSON.stringify({ name: "forbidden" }), + }); + expect(response.status).toBe(401); + expect(loadConfig().apiKeys ?? []).toHaveLength(0); + } finally { + await server.stop(true); + } + }); + test("persists a key and returns the full secret exactly once", async () => { saveConfig(baseConfig()); const server = startServer(0); diff --git a/tests/claude-cli.test.ts b/tests/claude-cli.test.ts index aa0950a9b8..18258944fe 100644 --- a/tests/claude-cli.test.ts +++ b/tests/claude-cli.test.ts @@ -26,6 +26,32 @@ const AUTH_PRESENT = { }; describe("ocx claude env assembly", () => { + test("connected target injects only the hub base and client admission token", () => { + const env = buildClaudeEnv(cfg(), { + baseUrl: "https://hub.example.test", + admissionToken: "ocx_data_connected", + }, {}, {}, AUTH_PRESENT); + expect(env.ANTHROPIC_BASE_URL).toBe("https://hub.example.test"); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe("ocx_data_connected"); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe("1"); + }); + + test("user-owned connected destination wins and cannot receive the hub token", () => { + const env = buildClaudeEnv(cfg(), { + baseUrl: "https://hub.example.test", + admissionToken: "ocx_data_connected", + }, { + ANTHROPIC_BASE_URL: "https://user-gateway.example.test", + ANTHROPIC_AUTH_TOKEN: "ocx_data_connected", + }, {}, { + ...AUTH_PRESENT, + preBunAnthropicSlots: ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"], + }); + expect(env.ANTHROPIC_BASE_URL).toBe("https://user-gateway.example.test"); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + }); + test("root skip-permissions bypass requires both the explicit flag and uid 0", () => { expect(shouldAllowRootSkipPermissions(["--dangerously-skip-permissions"], () => 0)).toBe(true); expect(shouldAllowRootSkipPermissions([], () => 0)).toBe(false); diff --git a/tests/claude-gateway-cache.test.ts b/tests/claude-gateway-cache.test.ts index 186d6dd550..481205cb14 100644 --- a/tests/claude-gateway-cache.test.ts +++ b/tests/claude-gateway-cache.test.ts @@ -87,6 +87,29 @@ describe("Claude Code gateway-model cache pre-write (devlog 260712 030)", () => } }); + test("connected refresh targets the hub models endpoint with only the client token", async () => { + const dir = tempDir(); + let requestedUrl = ""; + let admission = ""; + const path = await refreshGatewayModelCacheFromProxy({ + baseUrl: "https://hub.example.test", + admissionToken: "ocx_data_connected", + }, { + configDir: dir, + fetchImpl: async (input, init) => { + requestedUrl = String(input); + admission = new Headers(init?.headers).get("x-opencodex-api-key") ?? ""; + return new Response(JSON.stringify({ data: [{ id: "claude-ocx-hub-model" }] }), { + headers: { "content-type": "application/json" }, + }); + }, + }); + expect(requestedUrl).toBe("https://hub.example.test/v1/models?limit=1000&ids=cli"); + expect(admission).toBe("ocx_data_connected"); + const body = JSON.parse(readFileSync(path!, "utf8")); + expect(body.baseUrl).toBe("https://hub.example.test"); + }); + test("proxy refresh falls back to a configured admission key", async () => { const dir = tempDir(); const originalFetch = globalThis.fetch; diff --git a/tests/cli-dispatch.test.ts b/tests/cli-dispatch.test.ts index a82377bf93..60366f8948 100644 --- a/tests/cli-dispatch.test.ts +++ b/tests/cli-dispatch.test.ts @@ -3,6 +3,9 @@ import { CLI_COMMANDS } from "../src/cli/registry"; import { DISPATCH_ALIASES, DISPATCH_COMMANDS, dispatchCommand, resolveDispatchCommand } from "../src/cli/dispatch"; import type { CliDispatchDeps } from "../src/cli/dispatch"; import { runGuiCommand } from "../src/cli/gui"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; /** Minimal fake deps. dispatchCommand only touches deps for real command * runners, which these tests never invoke, so an empty object is enough. */ @@ -59,6 +62,34 @@ describe("CLI dispatch aliases", () => { }); describe("dispatchCommand exit codes", () => { + test("invalid client state refuses sync before local proxy discovery", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-dispatch-client-invalid-")); + const previous = process.env.OPENCODEX_HOME; + let discoveries = 0; + try { + process.env.OPENCODEX_HOME = home; + writeFileSync(join(home, "config.json"), JSON.stringify({ + port: 10100, + providers: {}, + defaultProvider: "openai", + runtimeRole: "client", + client: { apiKeyId: "half-present" }, + }), "utf8"); + const args = ["sync"]; + const deps = { + ...fakeDeps, + args, + findLiveProxy: async () => { discoveries += 1; return null; }, + }; + expect(await dispatchCommand({ kind: "command", command: "sync", args }, deps)).toBe(1); + expect(discoveries).toBe(0); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previous; + rmSync(home, { recursive: true, force: true }); + } + }); + test("returns 0 for help forms", async () => { expect(await dispatchCommand({ kind: "help", command: "help", args: ["help"] }, fakeDeps)).toBe(0); expect(await dispatchCommand({ kind: "help", command: "--help", args: ["--help"] }, fakeDeps)).toBe(0); diff --git a/tests/cli-help.test.ts b/tests/cli-help.test.ts index ef0a475116..4b13c3ec5d 100644 --- a/tests/cli-help.test.ts +++ b/tests/cli-help.test.ts @@ -138,6 +138,20 @@ describe("CLI subcommand help", () => { expect(result.stdout).toContain("must not be persisted"); }); + test("connect help exposes stdin-only credentials and offline disconnect", () => { + const connect = runCli(["help", "connect"]); + expectSpawnFinished(connect, "ocx help connect"); + expect(connect.status).toBe(0); + expect(connect.stdout).toContain("--pairing-code-stdin"); + expect(connect.stdout).toContain("--admin-token-stdin"); + expect(connect.stdout).not.toContain("--admin-token <"); + + const disconnect = runCli(["help", "disconnect"]); + expectSpawnFinished(disconnect, "ocx help disconnect"); + expect(disconnect.status).toBe(0); + expect(disconnect.stdout).toContain("--keep-catalog"); + }); + test("unknown command with help flag remains an error", () => { const result = runCli(["foobar", "--help"]); expectSpawnFinished(result, "ocx foobar --help"); diff --git a/tests/cli-registry.test.ts b/tests/cli-registry.test.ts index f0914269a2..75f953829f 100644 --- a/tests/cli-registry.test.ts +++ b/tests/cli-registry.test.ts @@ -105,6 +105,16 @@ describe("CLI command registry parity", () => { expect(gui?.details?.join(" ")).toContain("single-use"); expect(gui?.details?.join(" ")).toContain("no localhost or config-derived default"); }); + + test("connect and disconnect are registry-owned without credential argv forms", () => { + const connect = findCommand("connect"); + expect(connect?.usage).toContain("--pairing-code-stdin"); + expect(connect?.usage).toContain("--admin-token-stdin"); + expect(connect?.usage).not.toContain("--token <"); + expect(connect?.usage).not.toContain("--admin-token <"); + expect(connect?.details?.join(" ")).toContain("not supported"); + expect(findCommand("disconnect")?.usage).toBe("ocx disconnect [--keep-catalog] [--json]"); + }); }); describe("help banner command coverage", () => { diff --git a/tests/cli-status-json.test.ts b/tests/cli-status-json.test.ts index f959223342..1500bec635 100644 --- a/tests/cli-status-json.test.ts +++ b/tests/cli-status-json.test.ts @@ -66,6 +66,13 @@ describe("CLI status JSON", () => { }; defaultProvider?: unknown; config?: { source?: unknown; error?: unknown }; + connection?: { + state?: unknown; + serverUrl?: unknown; + apiKeyId?: unknown; + credentialFile?: unknown; + catalog?: unknown; + }; service?: { summary?: unknown }; codexShim?: { summary?: unknown }; codexRuntime?: { @@ -128,6 +135,10 @@ describe("CLI status JSON", () => { expect(typeof parsed.codexHome?.appCodexHome).toBe("string"); expect(typeof parsed.codexHome?.mismatch).toBe("boolean"); expect(parsed.codexHome?.warning === null || typeof parsed.codexHome?.warning === "string").toBe(true); + expect(parsed.connection).toMatchObject({ + state: "disconnected", + credentialFile: "missing", + }); const serialized = JSON.stringify(parsed).toLowerCase(); for (const forbidden of ["apikey", "sk-test-secret", "token", "refreshtoken", "authorization", "email"]) { diff --git a/tests/client-connect.test.ts b/tests/client-connect.test.ts new file mode 100644 index 0000000000..6fa83f37d3 --- /dev/null +++ b/tests/client-connect.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + downloadClientCatalog, + exchangeConnectPairingGrant, + fetchHubReady, + issueClientKey, + normalizeHubOrigin, +} from "../src/client/hub-client"; +import { handleConnectCommand } from "../src/cli/connect"; + +const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); + +function readyBody(protocol = 1, minimumClientProtocol = 1) { + return { + service: "opencodex", + version: "0.0.0", + uptime: 1, + pid: 1, + port: 443, + status: "ready", + protocol, + minimumClientProtocol, + managementUrl: "https://manage.example.test", + }; +} + +describe("remote hub client boundary", () => { + test("canonicalizes origin and terminal /v1 only", () => { + expect(normalizeHubOrigin("https://hub.example.test/v1")).toBe("https://hub.example.test"); + expect(normalizeHubOrigin("https://hub.example.test/v1/")).toBe("https://hub.example.test"); + for (const value of [ + "ftp://hub.example.test", + "https://user@hub.example.test", + "https://hub.example.test/private", + "https://hub.example.test/?secret=1", + "https://hub.example.test/#secret", + ]) expect(() => normalizeHubOrigin(value)).toThrow(); + }); + + test("uses Phase-1 readiness compatibility including p2/min1 and rejects p2/min2", async () => { + const accepted = await fetchHubReady("https://hub.example.test", { + fetchImpl: async () => Response.json(readyBody(2, 1)), + }); + expect(accepted.metadata.protocol).toBe(2); + + await expect(fetchHubReady("https://hub.example.test", { + fetchImpl: async () => Response.json(readyBody(2, 2)), + })).rejects.toThrow("requires remote protocol 2"); + for (const status of ["pending", "failed"] as const) { + const result = await fetchHubReady("https://hub.example.test", { + fetchImpl: async () => Response.json({ ...readyBody(), status }, { status: 503 }), + }); + expect(result.status).toBe(status); + } + }); + + test("admin key issuance is HTTPS-only and pairing exchanges into a full GUI session", async () => { + let calls = 0; + await expect(issueClientKey("http://hub.example.test", { + kind: "admin", + value: new TextEncoder().encode("ocx_admin_secret"), + }, "client", { + fetchImpl: async () => { calls += 1; return new Response(); }, + })).rejects.toThrow("only over HTTPS"); + expect(calls).toBe(0); + + const browserOrigin = "http://localhost:10100"; + const sessionHtml = [ + '', + '', + ``, + '', + ].join(""); + const seen: Array<{ url: string; headers: Headers; body: string }> = []; + const fetchImpl: typeof fetch = async (input, init) => { + seen.push({ url: String(input), headers: new Headers(init?.headers), body: String(init?.body ?? "") }); + if (String(input).endsWith("/opencodex-session")) return new Response(sessionHtml); + return Response.json({ + id: "issued-id", + name: "client", + key: `ocx_data_${"a".repeat(40)}`, + createdAt: "2026-08-28T00:00:00.000Z", + }, { status: 201 }); + }; + const grant = new TextEncoder().encode(`ocx_pair_${"b".repeat(43)}`); + const session = await exchangeConnectPairingGrant( + "https://hub.example.test", + browserOrigin, + grant, + { fetchImpl }, + ); + const issued = await issueClientKey("https://hub.example.test", { kind: "gui-session", value: session }, "client", { fetchImpl }); + expect(issued.id).toBe("issued-id"); + expect(seen[0]?.headers.get("origin")).toBe(browserOrigin); + expect(seen[1]?.headers.get("x-opencodex-gui-origin")).toBe(browserOrigin); + expect(seen[1]?.headers.get("x-opencodex-csrf-token")).toBe("csrf-test"); + expect(seen[1]?.body).toBe(JSON.stringify({ name: "client" })); + }); + + test("pairing HTTP requires explicit client opt-in and catalog is bounded/conditional", async () => { + let calls = 0; + await expect(exchangeConnectPairingGrant( + "http://hub.example.test", + "http://localhost:10100", + new TextEncoder().encode(`ocx_pair_${"c".repeat(43)}`), + { fetchImpl: async () => { calls += 1; return new Response(); } }, + )).rejects.toThrow("--allow-insecure-http"); + expect(calls).toBe(0); + + const notModified = await downloadClientCatalog("https://hub.example.test", "ocx_data_test", { + etag: '"etag"', + fetchImpl: async (_input, init) => { + expect(new Headers(init?.headers).get("if-none-match")).toBe('"etag"'); + return new Response(null, { status: 304 }); + }, + }); + expect(notModified).toEqual({ kind: "not-modified" }); + await expect(downloadClientCatalog("https://hub.example.test", "ocx_data_test", { + maxBytes: 4, + fetchImpl: async () => new Response('{"models":[]}'), + })).rejects.toThrow("allowed size"); + }); + + test("CLI rejects literal/env credential forms without rendering their values", async () => { + const errors: string[] = []; + const spy = spyOn(console, "error").mockImplementation(value => { errors.push(String(value)); }); + try { + expect(await handleConnectCommand([ + "https://hub.example.test", + "--admin-token-stdin", + "--admin-token=super-secret-value", + ])).toBe(2); + expect(errors.join(" ")).not.toContain("super-secret-value"); + expect(errors.join(" ")).toContain(""); + } finally { + spy.mockRestore(); + } + }); +}); + +function runTransactionScenario(stage: "success" | "catalog" | "preflight" | "commit") { + const opencodexHome = mkdtempSync(join(tmpdir(), "ocx-client-connect-home-")); + const codexHome = mkdtempSync(join(tmpdir(), "ocx-client-connect-codex-")); + const configPath = join(opencodexHome, "config.json"); + const originalConfig = { + port: 10100, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + defaultProvider: "openai", + }; + writeFileSync(configPath, `${JSON.stringify(originalConfig, null, 2)}\n`, "utf8"); + if (stage !== "preflight") writeFileSync(join(codexHome, "config.toml"), 'model_provider = "openai"\n', "utf8"); + if (stage === "commit") { + const { mkdirSync } = require("node:fs") as typeof import("node:fs"); + mkdirSync(join(opencodexHome, "config-mutation.sqlite")); + } + const script = ` + const { existsSync, readFileSync } = require("node:fs"); + const { createHash } = require("node:crypto"); + const { connectClient, disconnectClient } = require("./src/client/connect"); + const { readClientConnectionState } = require("./src/client/state"); + const { serviceApiTokenFilePath } = require("./src/lib/service-secrets"); + const { DEFAULT_CATALOG_PATH } = require("./src/codex/paths"); + const stage = ${JSON.stringify(stage)}; + const catalog = '{"models":[]}'; + const etag = '"sha256-' + createHash("sha256").update(catalog).digest("base64url") + '"'; + const calls = []; + const credential = new TextEncoder().encode("ocx_admin_test-authority"); + const fetchImpl = async (input, init = {}) => { + const url = String(input); + calls.push({ url, method: init.method || "GET" }); + if (url.endsWith("/readyz")) return Response.json(${JSON.stringify(readyBody())}); + if (url.endsWith("/api/keys") && init.method === "POST") return Response.json({ + id: "issued-id", + name: "client", + key: "ocx_data_${"d".repeat(40)}", + createdAt: "2026-08-28T00:00:00.000Z", + }, { status: 201 }); + if (url.endsWith("/api/keys") && init.method === "DELETE") return Response.json({ success: true }); + if (url.endsWith("/v1/catalog")) { + if (stage === "catalog") return Response.json({ error: "down" }, { status: 503 }); + return new Response(catalog, { headers: { ETag: etag, "Content-Type": "application/json" } }); + } + throw new Error("unexpected request " + url); + }; + (async () => { + let connected = null; + let error = null; + try { + connected = await connectClient({ + serverUrl: "https://hub.example.test", + credential: { kind: "admin", value: credential }, + selectedClients: ["claude"], + managementTransport: "direct", + noSync: true, + }, { fetchImpl, now: () => new Date("2026-08-28T00:00:00.000Z") }); + } catch (cause) { error = cause instanceof Error ? cause.message : String(cause); } + const beforeDisconnect = readClientConnectionState(); + const artifacts = { + token: existsSync(serviceApiTokenFilePath()), + catalog: existsSync(DEFAULT_CATALOG_PATH), + credentialZeroed: credential.every(value => value === 0), + }; + let disconnected = null; + if (stage === "success" && connected) disconnected = await disconnectClient(); + console.log(JSON.stringify({ connected, error, beforeDisconnect, artifacts, disconnected, after: readClientConnectionState(), calls })); + })(); + `; + const result = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { ...process.env, OPENCODEX_HOME: opencodexHome, CODEX_HOME: codexHome }, + encoding: "utf8", + }); + const output = result.stdout.trim().split("\n").at(-1) ?? "{}"; + const parsed = JSON.parse(output) as Record; + return { + status: result.status, + stderr: result.stderr, + parsed, + configBytes: readFileSync(configPath, "utf8"), + cleanup: () => { + rmSync(opencodexHome, { recursive: true, force: true }); + rmSync(codexHome, { recursive: true, force: true }); + }, + }; +} + +describe("connect transaction and offline disconnect", () => { + test("commits key id/state last, zeroes authority, and disconnects with the hub offline", () => { + const run = runTransactionScenario("success"); + try { + expect(run.status).toBe(0); + expect(run.parsed.error).toBeNull(); + expect(run.parsed.connected.apiKeyId).toBe("issued-id"); + expect(run.parsed.beforeDisconnect).toMatchObject({ kind: "connected", value: { apiKeyId: "issued-id" } }); + expect(run.parsed.artifacts).toEqual({ token: true, catalog: true, credentialZeroed: true }); + expect(run.parsed.disconnected).toMatchObject({ apiKeyId: "issued-id", tokenRemoved: true, catalogRemoved: true }); + expect(run.parsed.after).toEqual({ kind: "disconnected" }); + expect(run.parsed.calls.filter((call: any) => call.method === "DELETE")).toEqual([]); + } finally { run.cleanup(); } + }); + + for (const stage of ["catalog", "preflight", "commit"] as const) { + test(`rolls back local artifacts when ${stage} fails before final commit`, () => { + const run = runTransactionScenario(stage); + try { + expect(run.status).toBe(0); + expect(run.parsed.connected).toBeNull(); + expect(run.parsed.beforeDisconnect).toEqual({ kind: "disconnected" }); + expect(run.parsed.artifacts.token).toBe(false); + expect(run.parsed.artifacts.catalog).toBe(false); + expect(run.parsed.artifacts.credentialZeroed).toBe(true); + expect(run.parsed.calls.some((call: any) => call.method === "DELETE")).toBe(true); + expect(run.configBytes).not.toContain("issued-id"); + expect(`${run.parsed.error} ${run.stderr}`).not.toContain(`ocx_data_${"d".repeat(40)}`); + } finally { run.cleanup(); } + }); + } +}); From 97e3f3947f1b9c54ba541fe8095ac8bdcef0d0ed Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 02:52:28 +0900 Subject: [PATCH 05/11] fix(connect): narrow client type boundaries --- src/cli/claude.ts | 6 ++++-- src/client/state.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 74c9aa2314..db492e1e17 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -321,10 +321,12 @@ export function readConnectedClaudeContextWindows(path = DEFAULT_CATALOG_PATH): if (slash > 0 && slash < slug.length - 1) { const provider = slug.slice(0, slash); const id = slug.slice(slash + 1); - put(aliasForRoute(provider, id), contextWindow); + const routeAlias = aliasForRoute(provider, id); + if (routeAlias) put(routeAlias, contextWindow); put(desktop3pAlias(provider, id), contextWindow); } else { - put(aliasForNative(slug), contextWindow); + const nativeAlias = aliasForNative(slug); + if (nativeAlias) put(nativeAlias, contextWindow); put(desktop3pAlias("native", slug), contextWindow); } } diff --git a/src/client/state.ts b/src/client/state.ts index 97e41f39c6..8710729cb8 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -69,7 +69,7 @@ export function commitClientConnection( return { changed: !unchanged, value: undefined }; }); if (outcome.status === "committed" || outcome.status === "unchanged") return outcome.status; - throw new Error(`client state commit unavailable: ${outcome.reason}`); + throw new Error(`client state commit unavailable: ${"reason" in outcome ? outcome.reason : "unknown"}`); } export function clearClientConnection( From 04a26df582b37610b6254090eb8b27c440db5cec Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 02:57:43 +0900 Subject: [PATCH 06/11] fix(connect): preserve catalog conditional fetch --- src/client/hub-client.ts | 2 +- tests/config.test.ts | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/client/hub-client.ts b/src/client/hub-client.ts index af188280ff..b60126476f 100644 --- a/src/client/hub-client.ts +++ b/src/client/hub-client.ts @@ -65,7 +65,7 @@ async function fetchBounded( redirect: "manual", signal: AbortSignal.timeout(safeTimeout(timeoutMs)), }); - if (response.status >= 300 && response.status < 400) { + if (response.status >= 300 && response.status < 400 && response.status !== 304) { throw new HubClientError("redirect_refused", "Hub request redirect was refused", response.status); } return response; diff --git a/tests/config.test.ts b/tests/config.test.ts index cc0096d9c4..a4b12b22c2 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -124,13 +124,17 @@ describe("opencodex config defaults", () => { expect(readFileSync(getConfigPath(), "utf8")).toBe(before); }); - test("runtime role accepts the three explicit contract values", () => { - for (const role of ["standalone", "hub", "client"] as const) { + test("runtime role accepts standalone/hub alone while client requires atomic state", () => { + for (const role of ["standalone", "hub"] as const) { expect(validateConfigCandidate({ ...getDefaultConfig(), runtimeRole: role })).toMatchObject({ ok: true, config: { runtimeRole: role }, }); } + expect(validateConfigCandidate({ ...getDefaultConfig(), runtimeRole: "client" })).toMatchObject({ + ok: false, + error: expect.stringContaining("requires a complete client connection"), + }); }); test("runtime role rejects malformed live candidates", () => { @@ -258,7 +262,7 @@ describe("opencodex config defaults", () => { }); test("remote GUI config round-trips but remains inert outside the hub role", () => { - for (const runtimeRole of [undefined, "standalone", "client"] as const) { + for (const runtimeRole of [undefined, "standalone"] as const) { const result = validateConfigCandidate({ ...getDefaultConfig(), ...(runtimeRole ? { runtimeRole } : {}), From f384c23095ba12a3e957c0613cb2e7d86bc8687d Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 03:02:25 +0900 Subject: [PATCH 07/11] fix(connect): preserve sync wiring contract --- src/cli/dispatch.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 9d0832f346..5f96ad6b90 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -239,10 +239,7 @@ const commandRunners: Record = { console.log(result.stale ? "Hub unavailable; retained and applied the last-known-good remote catalog (stale)." : "Remote hub catalog synchronized."); - if (result.catalogWritten || result.cacheSynced) { - afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console }); - if (restartDesktopApp) await handleDesktopAppRestart(console); - } + await handleConnectedSyncCatalogWrite(result, restartCodex, restartDesktopApp); return 0; } catch (error) { console.error(`Connected sync failed without local fallback: ${error instanceof Error ? error.message : String(error)}`); @@ -699,3 +696,13 @@ async function handleDesktopAppRestart(log: Pick): Pro } } } + +async function handleConnectedSyncCatalogWrite( + result: { catalogWritten: boolean; cacheSynced: boolean }, + restartCodex: boolean, + restartDesktopApp: boolean, +): Promise { + if (!result.catalogWritten && !result.cacheSynced) return; + afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console }); + if (restartDesktopApp) await handleDesktopAppRestart(console); +} From 6e8694917fa6b98703dad3bc9d9cc6581452f3d0 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 03:07:56 +0900 Subject: [PATCH 08/11] fix(connect): preserve malformed client state fail-closed --- src/client/state.ts | 5 +++-- src/config.ts | 30 +++++++++++++++++++++++++++++- tests/config.test.ts | 14 ++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/client/state.ts b/src/client/state.ts index 8710729cb8..3947b383cd 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import { getConfigPath, + deleteConfigTopLevelKey, mutatePersistedConfig, readConfigDiagnostics, } from "../config"; @@ -82,8 +83,8 @@ export function clearClientConnection( if (!config.client || config.runtimeRole !== "client" || config.client.apiKeyId !== expectedApiKeyId) { return { changed: false, value: "conflict" as const }; } - delete config.client; - delete config.runtimeRole; + deleteConfigTopLevelKey(config, "client"); + deleteConfigTopLevelKey(config, "runtimeRole"); return { changed: true, value: "committed" as const }; }); if (outcome.status === "unavailable") return "conflict"; diff --git a/src/config.ts b/src/config.ts index 13b949f172..25d0714efa 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2714,6 +2714,9 @@ export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync */ function persistConfigUnlocked(config: OcxConfig): boolean { const configPath = getConfigPath(); + const rawBeforeWrite = readRawConfigJson(); + const clientPersistenceError = failClosedClientPersistenceError(rawBeforeWrite, config); + if (clientPersistenceError) throw new Error(clientPersistenceError); // External editors can add provider rows the live config deliberately does // not route with yet; merge them at the serialization boundary so an // unrelated in-process save cannot erase the provider or its overlay. @@ -2841,7 +2844,7 @@ export function mutatePersistedConfig( const projected = projectCustomModelCatalogMigration( commitBase.diagnostics.config, - confirmedConfig, + projectConfigRebaseProvenance(confirmedConfig), ); if (persistConfigUnlocked(projected)) bumpGenerationForCooperatingConfigWrite(); return { status: "committed", value: confirmed.value }; @@ -2850,6 +2853,31 @@ export function mutatePersistedConfig( }); } +function failClosedClientPersistenceError( + raw: Record | undefined, + candidate: OcxConfig, +): string | null { + if (!raw) return null; + const rawHasClient = Object.hasOwn(raw, "client") && raw.client !== undefined; + const rawRole = raw.runtimeRole; + const rawRoleValid = rawRole === undefined + || rawRole === "standalone" + || rawRole === "hub" + || rawRole === "client"; + const rawClientValid = !rawHasClient || clientConnectionSchema.safeParse(raw.client).success; + const rawPairValid = rawRoleValid + && ((rawRole === "client" && rawHasClient && rawClientValid) + || (rawRole !== "client" && !rawHasClient)); + if (rawPairValid) return null; + + const candidateValid = candidate.runtimeRole === "client" + && clientConnectionSchema.safeParse(candidate.client).success; + const deletions = configRebaseDeletionKeys(candidate); + const explicitClear = deletions.has("client") && deletions.has("runtimeRole"); + if (candidateValid || explicitClear) return null; + return "config write refused: malformed or mismatched remote client state must be repaired or explicitly cleared"; +} + export function websocketsEnabled(config: Pick): boolean { return config.websockets === true; } diff --git a/tests/config.test.ts b/tests/config.test.ts index a4b12b22c2..514df594cb 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -355,6 +355,20 @@ describe("opencodex config defaults", () => { } }); + test("an unrelated save cannot erase malformed-present client state", () => { + const raw = { + ...getDefaultConfig(), + runtimeRole: "client", + client: { apiKeyId: "half-present", key: "must-not-be-reemitted" }, + }; + writeConfig(raw); + const before = readFileSync(getConfigPath(), "utf8"); + const loaded = loadConfig(); + loaded.codexAutoStart = false; + expect(() => saveConfig(loaded)).toThrow("malformed or mismatched remote client state"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(before); + }); + test("malformed classifier config is normalized at load, even with subagentEffort absent (#1697)", () => { // normalizePersistedClaudeCode used to be reached only through a subagentEffort short-circuit, // so a config whose ONLY defect was elsewhere in claudeCode was never normalized. These From 914081cec6684ee463aa3d186a83ee7d8a2ea211 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 03:13:39 +0900 Subject: [PATCH 09/11] test(connect): align audited phase three matrix --- tests/cli-headless-parity.test.ts | 21 +++++++++++ tests/cli-start-journal-order.test.ts | 39 ++++++++++++++++++++ tests/codex-catalog-restore.test.ts | 40 +++++++++++++++++++++ tests/codex-inject-integration.test.ts | 49 ++++++++++++++++++++++++++ 4 files changed, 149 insertions(+) diff --git a/tests/cli-headless-parity.test.ts b/tests/cli-headless-parity.test.ts index b8faa77ea2..d4b50b2d7e 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { Readable } from "node:stream"; import { handleAccessCommand } from "../src/cli/access"; import { handleAgentCommand } from "../src/cli/agent"; import { handleComboCommand } from "../src/cli/combo"; @@ -11,6 +12,7 @@ import { handleModelsRuntimeCommand } from "../src/cli/models-runtime"; import { handleProviderRuntimeCommand } from "../src/cli/provider-runtime"; import { providerQuotaLine } from "../src/cli/account-extended"; import { formatAccountTable } from "../src/cli/account"; +import { handleConnectCommand } from "../src/cli/connect"; type Recorded = { path: string; method: string; body: unknown }; const servers: Array> = []; @@ -502,6 +504,25 @@ describe("headless GUI parity CLI", () => { expect(runtime.requests[0]).toEqual({ path: "/api/keys", method: "POST", body: { name: "deploy" } }); }); + test("remote connect status is headless and revoke refuses disconnected state before hub traffic", async () => { + let requests = 0; + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleConnectCommand(["status", "--json"], { + fetchImpl: async () => { requests += 1; return new Response(); }, + })).toBe(0); + expect(await handleConnectCommand(["revoke", "--admin-token-stdin", "--json"], { + stdinImpl: Readable.from(["ocx_admin_test\n"]), + fetchImpl: async () => { requests += 1; return new Response(); }, + })).toBe(1); + expect(requests).toBe(0); + } finally { + logSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); + test("Grok include edits the persisted exclusion set before apply", async () => { const runtime = fakeRuntime((req) => { const url = new URL(req.url); diff --git a/tests/cli-start-journal-order.test.ts b/tests/cli-start-journal-order.test.ts index 48f4ba95bc..efe027441f 100644 --- a/tests/cli-start-journal-order.test.ts +++ b/tests/cli-start-journal-order.test.ts @@ -137,6 +137,45 @@ afterEach(async () => { }); describe("start and ensure journal ownership (#1230)", () => { + test("startup preserves only a client journal matching the final committed api key id", async () => { + for (const matches of [true, false]) { + const fx = fixture(); + const original = '# original client baseline\nmodel_provider = "openai"\n'; + const injected = '# connected remote routing\nmodel_provider = "opencodex"\n'; + writeFileSync(fx.configPath, injected); + writeFileSync(join(fx.ocxHome, "config.json"), JSON.stringify({ + port: 0, + providers: {}, + defaultProvider: "openai", + runtimeRole: "client", + client: { + serverUrl: "https://hub.example.test", + managementUrl: "https://hub.example.test", + managementTransport: "direct", + selectedClients: ["codex"], + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + apiKeyId: matches ? "client-key-1" : "different-key", + tokenFingerprint: "a".repeat(64), + protocolVersion: 1, + connectedAt: "2026-08-28T00:00:00.000Z", + }, + })); + writeFileSync(fx.journalPath, JSON.stringify({ + version: 1, + originalConfig: Buffer.from(original).toString("base64"), + originalProfile: null, + owner: { kind: "client", apiKeyId: "client-key-1" }, + pid: 999_999, + timestamp: new Date().toISOString(), + })); + + const result = await runCli(fx, ["status", "--json"]); + expect(result.exitCode).toBe(0); + expect(readFileSync(fx.configPath, "utf8")).toBe(matches ? injected : original); + expect(existsSync(fx.journalPath)).toBe(matches); + } + }, 30_000); + test("a healthy proxy owner preserves the journal for both start and ensure", async () => { const fx = fixture(); const owner = await startOwner(fx); diff --git a/tests/codex-catalog-restore.test.ts b/tests/codex-catalog-restore.test.ts index 253c42f449..9dd89a478e 100644 --- a/tests/codex-catalog-restore.test.ts +++ b/tests/codex-catalog-restore.test.ts @@ -38,6 +38,46 @@ describe("Codex catalog restore", () => { if (existsSync(opencodexHome)) rmSync(opencodexHome, { recursive: true, force: true }); }); + test("version-1 process journals restore, while matching client ownership is durable", () => { + const configPath = join(codexHome, "config.toml"); + const journalPath = join(codexHome, "opencodex-journal.json"); + const original = '# original\nmodel_provider = "openai"\n'; + const injected = '# injected\nmodel_provider = "opencodex"\n'; + writeFileSync(configPath, injected); + writeFileSync(journalPath, JSON.stringify({ + version: 1, + originalConfig: Buffer.from(original).toString("base64"), + originalProfile: null, + pid: 999_999, + timestamp: new Date().toISOString(), + })); + const legacy = runScript(codexHome, opencodexHome, ` + const { reconcileJournal } = require("./src/codex/journal"); + console.log(JSON.stringify({ restored: reconcileJournal() })); + `); + expect(legacy.status).toBe(0); + expect(JSON.parse(legacy.stdout).restored).toBe(true); + expect(readFileSync(configPath, "utf8")).toBe(original); + + writeFileSync(configPath, injected); + writeFileSync(journalPath, JSON.stringify({ + version: 1, + originalConfig: Buffer.from(original).toString("base64"), + originalProfile: null, + owner: { kind: "client", apiKeyId: "client-key-1" }, + pid: 999_999, + timestamp: new Date().toISOString(), + })); + const client = runScript(codexHome, opencodexHome, ` + const { reconcileJournal } = require("./src/codex/journal"); + console.log(JSON.stringify({ restored: reconcileJournal({ activeClientApiKeyId: "client-key-1" }) })); + `); + expect(client.status).toBe(0); + expect(JSON.parse(client.stdout).restored).toBe(false); + expect(readFileSync(configPath, "utf8")).toBe(injected); + expect(existsSync(journalPath)).toBe(true); + }); + // spawnSync(bun --eval) under `bun test --isolate` on Windows can exceed the // default 5s case budget when the runner is under load (seen at ~5.4s on GHA). test("drops routed entries without overwriting user-added native entries", () => { diff --git a/tests/codex-inject-integration.test.ts b/tests/codex-inject-integration.test.ts index 9f7157eb5b..90c9ffbd5d 100644 --- a/tests/codex-inject-integration.test.ts +++ b/tests/codex-inject-integration.test.ts @@ -61,6 +61,55 @@ describe("injectCodexConfig integration (Design B)", () => { rmSync(ocxHome, { recursive: true, force: true }); }); + test("remote target validate-only writes nothing; commit journals client ownership and restores exact preimage", () => { + const original = '# remote baseline\nmodel_provider = "openai"\n'; + writeFileSync(join(codexHome, "config.toml"), original, "utf8"); + const script = ` + const fs = require("node:fs"); + const path = require("node:path"); + const { injectCodexConfig } = require("./src/codex/inject"); + const { journalOwner, restoreJournalState } = require("./src/codex/journal"); + const target = { baseUrl: "https://hub.example.test/v1", requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" }; + (async () => { + const configPath = path.join(process.env.CODEX_HOME, "config.toml"); + const journalPath = path.join(process.env.CODEX_HOME, "opencodex-journal.json"); + const before = fs.readFileSync(configPath, "utf8"); + const preflight = await injectCodexConfig(10100, { syncResumeHistory: false }, { + validateOnly: true, routingTarget: target, catalogPath: null, + journalOwner: { kind: "client", apiKeyId: "client-key-1" }, + }); + const afterPreflight = fs.readFileSync(configPath, "utf8"); + const journalAfterPreflight = fs.existsSync(journalPath); + const committed = await injectCodexConfig(10100, { syncResumeHistory: false }, { + routingTarget: target, catalogPath: null, + journalOwner: { kind: "client", apiKeyId: "client-key-1" }, + }); + const injected = fs.readFileSync(configPath, "utf8"); + const owner = journalOwner(); + const restored = restoreJournalState(); + console.log(JSON.stringify({ preflight, committed, before, afterPreflight, journalAfterPreflight, injected, owner, restored, final: fs.readFileSync(configPath, "utf8") })); + })(); + `; + const result = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome }, + encoding: "utf8", + timeout: SPAWN_BUDGET_MS - 5_000, + }); + expect(result.status).toBe(0); + const value = JSON.parse(result.stdout.trim()); + expect(value.preflight.success).toBe(true); + expect(value.before).toBe(original); + expect(value.afterPreflight).toBe(original); + expect(value.journalAfterPreflight).toBe(false); + expect(value.committed.success).toBe(true); + expect(value.injected).toContain('base_url = "https://hub.example.test/v1"'); + expect(value.injected).toContain('env_key = "OPENCODEX_API_AUTH_TOKEN"'); + expect(value.owner).toEqual({ kind: "client", apiKeyId: "client-key-1" }); + expect(value.restored.complete).toBe(true); + expect(value.final).toBe(original); + }); + test("upgrade path: a legacy-injected config converts to the Design B form in one inject", () => { writeFileSync(join(codexHome, "config.toml"), [ 'model_provider = "opencodex"', From 024c1ede376317124170f971448cb5fb91a6076e Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 03:15:05 +0900 Subject: [PATCH 10/11] fix(connect): reconcile client journal before lifecycle --- src/cli/dispatch.ts | 13 +++++++++++++ tests/cli-start-journal-order.test.ts | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 5f96ad6b90..9898701110 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -21,6 +21,7 @@ import { restoreNativeCodexAsync } from "../codex/inject"; import { stripGrokConfig } from "../grok/inject"; import { afterCatalogWriteHandleAppServers } from "../codex/app-server-processes"; import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job"; +import type { ClientConnectionState } from "../client/state"; export interface CliDispatchDeps { args: string[]; @@ -59,6 +60,7 @@ const commandRunners: Record = { start: async deps => { const { readClientConnectionState } = await import("../client/state"); const clientState = readClientConnectionState(); + await reconcileClientJournalBeforeLifecycle(clientState); if (clientState.kind === "connected") { console.error("Client mode does not start a local provider proxy in Remote Hub Phase 3; use 'ocx sync'."); return 1; @@ -199,6 +201,7 @@ const commandRunners: Record = { ensure: async deps => { const { readClientConnectionState } = await import("../client/state"); const clientState = readClientConnectionState(); + await reconcileClientJournalBeforeLifecycle(clientState); if (clientState.kind !== "disconnected") { console.error(clientState.kind === "connected" ? "Client mode does not start a local provider proxy; use 'ocx sync'." @@ -706,3 +709,13 @@ async function handleConnectedSyncCatalogWrite( afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console }); if (restartDesktopApp) await handleDesktopAppRestart(console); } + +async function reconcileClientJournalBeforeLifecycle( + state: ClientConnectionState, +): Promise { + if (state.kind === "disconnected") return; + const { reconcileJournal } = await import("../codex/journal"); + reconcileJournal(state.kind === "connected" + ? { activeClientApiKeyId: state.value.apiKeyId } + : undefined); +} diff --git a/tests/cli-start-journal-order.test.ts b/tests/cli-start-journal-order.test.ts index efe027441f..3680ed5b4d 100644 --- a/tests/cli-start-journal-order.test.ts +++ b/tests/cli-start-journal-order.test.ts @@ -169,8 +169,8 @@ describe("start and ensure journal ownership (#1230)", () => { timestamp: new Date().toISOString(), })); - const result = await runCli(fx, ["status", "--json"]); - expect(result.exitCode).toBe(0); + const result = await runCli(fx, ["start"]); + expect(result.exitCode).toBe(1); expect(readFileSync(fx.configPath, "utf8")).toBe(matches ? injected : original); expect(existsSync(fx.journalPath)).toBe(matches); } From aa26159532d4da2002f1d0aed897620788a1c6e0 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 03:19:56 +0900 Subject: [PATCH 11/11] test(connect): cover sync and disconnect conflicts --- tests/client-connect.test.ts | 117 +++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/tests/client-connect.test.ts b/tests/client-connect.test.ts index 6fa83f37d3..9a589e0cfe 100644 --- a/tests/client-connect.test.ts +++ b/tests/client-connect.test.ts @@ -262,3 +262,120 @@ describe("connect transaction and offline disconnect", () => { }); } }); + +function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-conflict") { + const opencodexHome = mkdtempSync(join(tmpdir(), "ocx-client-state-home-")); + const codexHome = mkdtempSync(join(tmpdir(), "ocx-client-state-codex-")); + const token = `ocx_data_${"e".repeat(40)}`; + const fingerprint = createHash("sha256").update(token).digest("hex"); + const catalog = '{"models":[]}'; + const etag = `"sha256-${createHash("sha256").update(catalog).digest("base64url")}"`; + const selectedClients = mode === "disconnect-conflict" ? ["codex"] : ["claude"]; + writeFileSync(join(opencodexHome, "config.json"), JSON.stringify({ + port: 10100, + providers: {}, + defaultProvider: "openai", + runtimeRole: "client", + client: { + serverUrl: "https://hub.example.test", + managementUrl: "https://hub.example.test", + managementTransport: "direct", + selectedClients, + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + apiKeyId: "client-key-1", + tokenFingerprint: fingerprint, + protocolVersion: 1, + connectedAt: "2026-08-28T00:00:00.000Z", + catalogEtag: etag, + catalogSyncedAt: "2026-08-28T00:00:00.000Z", + }, + }), "utf8"); + writeFileSync(join(opencodexHome, "service-api-token"), `${token}\n`, { mode: 0o600 }); + writeFileSync(join(codexHome, "opencodex-catalog.json"), catalog, "utf8"); + writeFileSync(join(codexHome, "config.toml"), mode === "disconnect-conflict" + ? 'model_provider = "opencodex"\n' + : 'model_provider = "openai"\n', "utf8"); + if (mode === "disconnect-conflict") { + writeFileSync(join(codexHome, "opencodex-journal.json"), JSON.stringify({ + version: 1, + originalConfig: Buffer.from('model_provider = "openai"\n').toString("base64"), + originalProfile: null, + owner: { kind: "client", apiKeyId: "different-key" }, + pid: 999_999, + timestamp: "2026-08-28T00:00:00.000Z", + })); + } + const script = ` + const fs = require("node:fs"); + const path = require("node:path"); + const { disconnectClient, syncConnectedClient } = require("./src/client/connect"); + const { readClientConnectionState } = require("./src/client/state"); + const mode = ${JSON.stringify(mode)}; + (async () => { + let result = null; + let error = null; + try { + if (mode === "disconnect-conflict") result = await disconnectClient(); + else result = await syncConnectedClient({}, { + fetchImpl: async () => Response.json({ error: "fixture" }, { status: mode === "sync-401" ? 401 : 503 }), + }); + } catch (cause) { error = cause instanceof Error ? cause.message : String(cause); } + console.log(JSON.stringify({ + result, + error, + state: readClientConnectionState(), + tokenExists: fs.existsSync(path.join(process.env.OPENCODEX_HOME, "service-api-token")), + journalExists: fs.existsSync(path.join(process.env.CODEX_HOME, "opencodex-journal.json")), + })); + })(); + `; + const child = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { ...process.env, OPENCODEX_HOME: opencodexHome, CODEX_HOME: codexHome }, + encoding: "utf8", + }); + const parsed = JSON.parse(child.stdout.trim().split("\n").at(-1) ?? "{}") as Record; + return { + status: child.status, + parsed, + cleanup: () => { + rmSync(opencodexHome, { recursive: true, force: true }); + rmSync(codexHome, { recursive: true, force: true }); + }, + }; +} + +describe("connected sync and disconnect conflicts", () => { + test("401 is a hard failure and never falls back to local providers", () => { + const run = runConnectedStateScenario("sync-401"); + try { + expect(run.status).toBe(0); + expect(run.parsed.result).toBeNull(); + expect(run.parsed.error).toContain("401"); + expect(run.parsed.state.kind).toBe("connected"); + expect(run.parsed.tokenExists).toBe(true); + } finally { run.cleanup(); } + }); + + test("hub 503 keeps and applies the last-known-good catalog as stale", () => { + const run = runConnectedStateScenario("sync-503"); + try { + expect(run.status).toBe(0); + expect(run.parsed.error).toBeNull(); + expect(run.parsed.result).toMatchObject({ stale: true, catalogWritten: false, injected: false }); + expect(run.parsed.state.kind).toBe("connected"); + } finally { run.cleanup(); } + }); + + test("journal ownership conflict preserves every artifact and connected state", () => { + const run = runConnectedStateScenario("disconnect-conflict"); + try { + expect(run.status).toBe(0); + expect(run.parsed.result).toBeNull(); + expect(run.parsed.error).toContain("journal ownership conflicts"); + expect(run.parsed.state.kind).toBe("connected"); + expect(run.parsed.tokenExists).toBe(true); + expect(run.parsed.journalExists).toBe(true); + } finally { run.cleanup(); } + }); +});