From 863a88ea3cea37501e7ea001b2bf7c200c5c1109 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 02:38:44 +0900 Subject: [PATCH 01/14] 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 7cabaab64a..0b89e3580c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -911,6 +911,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 @@ -920,6 +959,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() @@ -1788,6 +1830,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); @@ -2104,6 +2155,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}`); } @@ -2219,6 +2272,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 @@ -2338,6 +2414,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 f682abb777..2ff1a2a0bc 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 c4b0b6ed8a..e28d438444 100644 --- a/src/types.ts +++ b/src/types.ts @@ -65,6 +65,8 @@ export type { OcxConfigRebaseProvenance, OcxHubConfig, OcxRemoteGuiConfig, + OcxConnectedClientId, + OcxClientConnectionConfig, OcxConfig, OcxAccountPoolRotationStrategy, OcxAccountPoolQuotaWindow, diff --git a/src/types/config.ts b/src/types/config.ts index e75f3e438b..48da5e7a60 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -268,6 +268,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. */ @@ -276,6 +298,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 aba5aaf9c7..46417e52d4 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -272,6 +272,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 6d9aed20fd2f7cf84d7233a9ab3c21ec225a80b5 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 02:41:19 +0900 Subject: [PATCH 02/14] 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 ca1a3a0fa9..37eb47b714 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -9,6 +9,7 @@ import { runCodexHistoryJob, } from "../codex/history-job"; import { reconcileJournal } from "../codex/journal"; +import { readClientConnectionState } from "../client/state"; import { codexAutoStartEnabled, getConfigDir, @@ -224,7 +225,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 91a4f6c409fe14a6a410e6bd1632974fc32d6788 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 02:51:01 +0900 Subject: [PATCH 03/14] 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 0b1d74cea7..fa84f1813f 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); @@ -154,16 +182,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 @@ -199,7 +231,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에서 인증 모드를 직접 지정하면 이 판단을 덮어쓸 수 있습니다."); @@ -282,6 +314,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; @@ -340,21 +404,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."); } @@ -363,14 +451,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 8e09cc29e8..bcec92e814 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -59,6 +59,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); }, @@ -245,6 +255,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); }, @@ -317,6 +335,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, @@ -372,6 +413,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 95a0a8ebd1..e03cdd903e 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -38,6 +38,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 37eb47b714..74e58af77f 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1300,6 +1300,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 d607c0b588..f09356d4e3 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 0ec60fcb07..15c5b037d6 100644 --- a/src/cli/runtime-api.ts +++ b/src/cli/runtime-api.ts @@ -182,7 +182,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 8c435d0582..e3120b074d 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -18,6 +18,7 @@ import { collectOrcaCodexHomeDiagnostic, type OrcaCodexHomeDiagnostic } from ".. import { grokFenceEndpointDrift, readGrokStatus } from "../grok/status"; import { claudeDesktopIntegrationEnabled } from "../codex/desired-state"; import { claudeDesktopPolicyHealth, probeClaudeDesktopPolicy, type ClaudeDesktopPolicyHealth } from "../claude/desktop-policy"; +import { collectClientConnectionStatus } from "./connect"; type HealthCheck = { ok: boolean; @@ -63,6 +64,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; @@ -309,6 +322,7 @@ export async function collectStatus(): Promise { desiredEnabled: claudeDesktopIntegrationEnabled(config), policy: claudeDesktopPolicyHealth(probeClaudeDesktopPolicy()), }; + 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). @@ -491,6 +505,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 db95f8f22bb5d3b05a4133b25d4e7f0bda4c020c Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 02:51:08 +0900 Subject: [PATCH 04/14] 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, 401 insertions(+), 1 deletion(-) 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 f98b6ff80c..b38309ae44 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 be2425bebd..069f63004a 100644 --- a/tests/cli-dispatch.test.ts +++ b/tests/cli-dispatch.test.ts @@ -3,7 +3,8 @@ 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 { existsSync, readFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { getConfigDir } from "../src/config"; import { getAccountSet, removeCredential, saveCredential } from "../src/oauth/store"; @@ -63,6 +64,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 7bbb1d5101..ccfdf87404 100644 --- a/tests/cli-registry.test.ts +++ b/tests/cli-registry.test.ts @@ -111,6 +111,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 d8a2ea445f..bbccac72cb 100644 --- a/tests/cli-status-json.test.ts +++ b/tests/cli-status-json.test.ts @@ -69,6 +69,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?: { @@ -131,6 +138,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 9d46ca30c3eadd013d482477057d0df51831fdd9 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 02:52:28 +0900 Subject: [PATCH 05/14] 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 fa84f1813f..fe29889756 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -333,10 +333,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 6fdaaf810c64fea6d2c29701dfa5a7f009b4f63d Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 02:57:43 +0900 Subject: [PATCH 06/14] 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 46417e52d4..56f325236e 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -126,13 +126,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", () => { @@ -260,7 +264,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 3d9184d137f18dfa88f79b67238af541150311d9 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 03:02:25 +0900 Subject: [PATCH 07/14] 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 bcec92e814..964b45661e 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -348,10 +348,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)}`); @@ -903,3 +900,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 753f097d225d73933d617efdf168a29d838a9755 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 03:07:56 +0900 Subject: [PATCH 08/14] 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 0b89e3580c..f2411bc217 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2768,6 +2768,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. @@ -2895,7 +2898,7 @@ export function mutatePersistedConfig( const projected = projectCustomModelCatalogMigration( commitBase.diagnostics.config, - confirmedConfig, + projectConfigRebaseProvenance(confirmedConfig), ); if (persistConfigUnlocked(projected)) bumpGenerationForCooperatingConfigWrite(); return { status: "committed", value: confirmed.value }; @@ -2904,6 +2907,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 56f325236e..5cb35391be 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -357,6 +357,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 fd27c1e04725450b86b2ab6f98898ef07aec5770 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 03:13:39 +0900 Subject: [PATCH 09/14] 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 dcd05d295b..f16742c3ea 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> = []; @@ -516,6 +518,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 e2fe633932..f325dbd7e4 100644 --- a/tests/cli-start-journal-order.test.ts +++ b/tests/cli-start-journal-order.test.ts @@ -152,6 +152,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 4ae8fa49ce194b2bf50bd98fe5c868bc12a3dcb7 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 03:15:05 +0900 Subject: [PATCH 10/14] 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 964b45661e..b792d560cd 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -23,6 +23,7 @@ import { stripGrokConfig } from "../grok/inject"; import { afterCatalogWriteHandleAppServers } from "../codex/app-server-processes"; import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job"; import { isJsonOption, takeFlag } from "./runtime-api"; +import type { ClientConnectionState } from "../client/state"; export interface CliDispatchDeps { args: string[]; @@ -61,6 +62,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; @@ -257,6 +259,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'." @@ -910,3 +913,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 f325dbd7e4..4ca5a077c1 100644 --- a/tests/cli-start-journal-order.test.ts +++ b/tests/cli-start-journal-order.test.ts @@ -184,8 +184,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 886c26e9ad3c9981a3f3038dc9fca311b2a5876d Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 03:19:56 +0900 Subject: [PATCH 11/14] 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(); } + }); +}); From acdac3fa16850d160f7048a8c8f1e93ddca6fbfe Mon Sep 17 00:00:00 2001 From: jun Date: Tue, 1 Sep 2026 17:26:30 +0900 Subject: [PATCH 12/14] fix(connect): a process-owned journal is ours to unwind, not a conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connecting after `ocx start` is the ordinary path: routing is already injected and the Codex journal is owned by the proxy process. Ownership never transfers during connect, because writeJournal() refuses to overwrite a journal whose config is already injected — so the process owner survives into the connected state. disconnectClient() read any non-matching owner as a conflict and refused. That stranded the connection: the operator could not disconnect, and no action available to them would make the check pass. The artifacts were preserved, so nothing was lost, but the connected state had no exit. A process-owned journal records the pre-injection baseline this same tool wrote, so restoring it is exactly the right unwind. The genuine conflict is a journal owned by a DIFFERENT client key, where restoring would tear down another key's routing; that case still refuses, and its existing test still passes. Injected routing with no journal at all now gets its own message. Previously it fell into the ownership error, which named the wrong cause: there is no recorded baseline to restore, so unwinding would be guessing at the original config rather than reading it. The regression test drives the real shape — injected config plus a process-owned journal — and fails against the previous refusal. --- src/client/connect.ts | 24 +++++++++++++++++++--- tests/client-connect.test.ts | 40 ++++++++++++++++++++++++++++++++---- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/client/connect.ts b/src/client/connect.ts index 3c954e5146..3d0acf4fc6 100644 --- a/src/client/connect.ts +++ b/src/client/connect.ts @@ -365,9 +365,27 @@ export async function disconnectClient( 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()) { + // A journal owned by this client key is ours, obviously. A journal owned by a PROCESS is + // also ours to unwind: it is what `ocx start` leaves behind, and connecting on top of it + // never transfers ownership — writeJournal() declines to overwrite a journal whose + // config is already injected, so the process owner survives into the connected state. + // + // Treating that as a conflict stranded the normal "start, then connect" path: disconnect + // refused, and nothing the operator could do would satisfy the check. The genuine + // conflict is a journal owned by a DIFFERENT client key, which is the one case where + // restoring would unwind somebody else's routing. + if ( + owner === null + || owner.kind === "process" + || owner.apiKeyId === state.value.apiKeyId + ) { + if (owner !== null) restored = restoreJournalState().complete; + else if (isCodexRoutingInjected()) { + // Injected routing with no journal at all: there is no recorded baseline to restore, + // so unwinding would be a guess about what the config looked like before. + throw new Error("disconnect refused: Codex routing is injected but no journal records the original state"); + } + } else { 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"); diff --git a/tests/client-connect.test.ts b/tests/client-connect.test.ts index 9a589e0cfe..6a172a1bd8 100644 --- a/tests/client-connect.test.ts +++ b/tests/client-connect.test.ts @@ -263,14 +263,15 @@ describe("connect transaction and offline disconnect", () => { } }); -function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-conflict") { +function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-conflict" | "disconnect-process-journal") { 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"]; + const isDisconnect = mode === "disconnect-conflict" || mode === "disconnect-process-journal"; + const selectedClients = isDisconnect ? ["codex"] : ["claude"]; writeFileSync(join(opencodexHome, "config.json"), JSON.stringify({ port: 10100, providers: {}, @@ -292,7 +293,7 @@ function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-c }), "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" + writeFileSync(join(codexHome, "config.toml"), isDisconnect ? 'model_provider = "opencodex"\n' : 'model_provider = "openai"\n', "utf8"); if (mode === "disconnect-conflict") { @@ -305,6 +306,20 @@ function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-c timestamp: "2026-08-28T00:00:00.000Z", })); } + if (mode === "disconnect-process-journal") { + // The state `ocx start` leaves behind: routing is injected and the journal is owned by + // the proxy PROCESS, not by any client key. Connecting on top of this does not take + // ownership — writeJournal() refuses to overwrite a journal whose config is already + // injected — so the process owner survives into the connected state. + writeFileSync(join(codexHome, "opencodex-journal.json"), JSON.stringify({ + version: 1, + originalConfig: Buffer.from('model_provider = "openai"\n').toString("base64"), + originalProfile: null, + owner: { kind: "process", pid: 999_999 }, + pid: 999_999, + timestamp: "2026-08-28T00:00:00.000Z", + })); + } const script = ` const fs = require("node:fs"); const path = require("node:path"); @@ -315,7 +330,7 @@ function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-c let result = null; let error = null; try { - if (mode === "disconnect-conflict") result = await disconnectClient(); + if (mode === "disconnect-conflict" || mode === "disconnect-process-journal") result = await disconnectClient(); else result = await syncConnectedClient({}, { fetchImpl: async () => Response.json({ error: "fixture" }, { status: mode === "sync-401" ? 401 : 503 }), }); @@ -378,4 +393,21 @@ describe("connected sync and disconnect conflicts", () => { expect(run.parsed.journalExists).toBe(true); } finally { run.cleanup(); } }); + + test("a journal left owned by the proxy process does not strand the connection", () => { + // Connecting after `ocx start` is the normal path, not an edge case: routing is already + // injected and the journal is owned by the proxy process. Ownership never transfers, + // because writeJournal() will not overwrite a journal whose config is already injected. + // + // Disconnect then read that surviving process owner as a conflict and refused, so the + // operator could neither disconnect nor make the check pass — the connection was stuck. + // A process-owned journal is ours to re-own on connect, so disconnect must complete. + const run = runConnectedStateScenario("disconnect-process-journal"); + try { + expect(run.status).toBe(0); + expect(run.parsed.error).toBeNull(); + expect(run.parsed.state.kind).toBe("disconnected"); + expect(run.parsed.journalExists).toBe(false); + } finally { run.cleanup(); } + }); }); From af95d597524265215cfd19e1ad5488a1a30ca3cc Mon Sep 17 00:00:00 2001 From: jun Date: Tue, 1 Sep 2026 18:07:43 +0900 Subject: [PATCH 13/14] fix(connect): preserve the sync handler exit code on the connected branch tests/cli-transport-honesty.test.ts flags any runner that awaits a handler and then returns a literal 0, because that erases a failure the handler recorded in process.exitCode. The exemption list requires a verified reason rather than a name, and the connected sync branch has none: handleConnectedSyncCatalogWrite drives app-server restarts, so a failure there must survive. Returns process.exitCode like every other runner. Node types it as number | string; only a numeric code is meaningful to the dispatcher. --- src/cli/dispatch.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index b792d560cd..b3ecc87daa 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -352,7 +352,12 @@ const commandRunners: Record = { ? "Hub unavailable; retained and applied the last-known-good remote catalog (stale)." : "Remote hub catalog synchronized."); await handleConnectedSyncCatalogWrite(result, restartCodex, restartDesktopApp); - return 0; + // `process.exitCode` rather than a literal 0, for the same reason every other + // runner does it (tests/cli-transport-honesty.test.ts): the catalog-write helper + // drives app-server restarts, and one of those recording a failure must not be + // erased by the value this runner returns. It reads 0 on the ordinary path. Node + // types it as `number | string`; only a numeric code means anything here. + return typeof process.exitCode === "number" ? process.exitCode : 0; } catch (error) { console.error(`Connected sync failed without local fallback: ${error instanceof Error ? error.message : String(error)}`); return 1; From 20f3c11f819ff29e6b625aeecf754a66706f9945 Mon Sep 17 00:00:00 2001 From: jun Date: Tue, 1 Sep 2026 19:31:24 +0900 Subject: [PATCH 14/14] fix(connect): restore the catalog the user had, and stop calling a stuck profile a clean restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rollback defects. Both let disconnect report that native Codex state was restored while leaving the user worse off than before they connected. Connect overwrites whatever catalog is already at DEFAULT_CATALOG_PATH. The pre-connect bytes were snapshotted only into an in-memory `priorCatalog`, which covers a connect that fails and rolls back in the same run — not a disconnect, which is a different process on a different day. Durable state recorded only the remote catalog's fingerprint, so disconnect deleted the remote catalog and left the user with none. That is the one artifact a rollback cannot reconstruct from anywhere else: the token can be reissued and the config is journaled, but a catalog the user brought with them is simply gone. The snapshot is now persisted on the connection as `priorCatalog` (base64, or "" for "there genuinely was none") and disconnect writes it back. An older connection with the field absent keeps the previous removal behavior, since nothing recorded what to restore. Ownership is still checked first — a catalog edited since connect belongs to the user and `changed` refuses rather than overwriting it. The result gains `catalogRestored` so the two outcomes are distinguishable instead of both reading as `catalogRemoved`. restoreJournalState set profileRestored = true after a swallowed unlink. When the original profile was absent, "delete the one we generated" failing meant the function still reported complete, which deletes the journal — the only record that the leftover profile is ours. The user is told native state was restored while our profile stays on disk with nothing left pointing at it. Now only a verified removal counts, with ENOENT treated as success because the file being already gone is the outcome the removal wanted. The catalog fix carries a runtime regression driven red against the previous behavior. The profile fix is asserted source-level, and the test says why: making unlink fail requires denying writes on the Codex home, which denies the atomic config write earlier in the same function, so the branch is unreachable from a test process. Asserting a fabricated runtime failure would prove less than asserting the shape. --- src/client/connect.ts | 40 +++++++++++++++++++++++++++++++----- src/codex/journal.ts | 16 +++++++++++++-- src/config.ts | 3 +++ src/types/config.ts | 10 +++++++++ tests/client-connect.test.ts | 39 ++++++++++++++++++++++++++++++++--- tests/codex-journal.test.ts | 24 ++++++++++++++++++++++ 6 files changed, 122 insertions(+), 10 deletions(-) diff --git a/src/client/connect.ts b/src/client/connect.ts index 3d0acf4fc6..2bdeea9558 100644 --- a/src/client/connect.ts +++ b/src/client/connect.ts @@ -245,6 +245,10 @@ export async function connectClient( protocolVersion: 1, connectedAt: now, catalogEtag: catalog.etag, + // Durable so disconnect — a different process — can put back whatever was here + // before. `priorCatalog` above is only reachable by a connect that fails and rolls + // back in the same run. + priorCatalog: priorCatalog.kind === "file" ? Buffer.from(priorCatalog.body, "utf8").toString("base64") : "", catalogSyncedAt: now, }; commitClientConnection(connection); @@ -340,11 +344,28 @@ export async function syncConnectedClient( return { catalogWritten, cacheSynced, injected, stale }; } -function removeOwnedCatalog(connection: OcxClientConnectionConfig): "removed" | "absent" | "changed" { +/** + * Put the catalog back the way connect found it. + * + * Not a delete. Connect overwrites whatever catalog was already there, so removing the + * remote one leaves the user with nothing — and disconnect still reports that native Codex + * state was restored. If the connection recorded a prior catalog, it is rewritten; + * `priorCatalog: ""` means there genuinely was none and removal is the restoration. + * + * Still ownership-checked first: a catalog the user edited or replaced since connect is + * theirs, and `changed` refuses rather than overwriting it. + */ +function restorePriorCatalog(connection: OcxClientConnectionConfig): "removed" | "restored" | "absent" | "changed" { if (!existsSync(DEFAULT_CATALOG_PATH)) return "absent"; try { const body = validLocalCatalog(); if (!catalogMatchesEtag(body, connection.catalogEtag)) return "changed"; + if (connection.priorCatalog) { + atomicWriteFile(DEFAULT_CATALOG_PATH, Buffer.from(connection.priorCatalog, "base64").toString("utf8")); + return "restored"; + } + // Undefined means the connection predates this field: the pre-connect catalog was + // never recorded, so removal is the only honest option and matches the old behavior. unlinkSync(DEFAULT_CATALOG_PATH); return "removed"; } catch { @@ -354,7 +375,15 @@ function removeOwnedCatalog(connection: OcxClientConnectionConfig): "removed" | export async function disconnectClient( options: { keepCatalog?: boolean } = {}, -): Promise<{ restored: boolean; tokenRemoved: boolean; catalogRemoved: boolean; apiKeyId: string }> { +): Promise<{ + restored: boolean; + tokenRemoved: boolean; + /** True when the catalog no longer holds remote bytes: removed outright or overwritten. */ + catalogRemoved: boolean; + /** True only when a recorded pre-connect catalog was written back. */ + catalogRestored: boolean; + apiKeyId: string; +}> { const state = readClientConnectionState(); if (state.kind !== "connected") throw new Error(`disconnect refused: client state is ${state.kind}`); const token = readServiceApiTokenState(); @@ -393,9 +422,9 @@ export async function disconnectClient( 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"; + let catalogRemoval: "removed" | "restored" | "absent" | "changed" = "absent"; if (!options.keepCatalog) { - catalogRemoval = removeOwnedCatalog(state.value); + catalogRemoval = restorePriorCatalog(state.value); if (catalogRemoval === "changed") throw new Error("disconnect refused: catalog ownership changed"); } if (clearClientConnection(state.value.apiKeyId) !== "committed") { @@ -404,7 +433,8 @@ export async function disconnectClient( return { restored, tokenRemoved: tokenRemoval === "removed", - catalogRemoved: catalogRemoval === "removed", + catalogRemoved: catalogRemoval === "removed" || catalogRemoval === "restored", + catalogRestored: catalogRemoval === "restored", apiKeyId: state.value.apiKeyId, }; } diff --git a/src/codex/journal.ts b/src/codex/journal.ts index 68523fe685..f515f3aac3 100644 --- a/src/codex/journal.ts +++ b/src/codex/journal.ts @@ -210,10 +210,22 @@ export function restoreJournalState(): RestoreJournalResult { if (profileUnchanged) { if (journal.originalProfile !== null) { atomicWriteFile(CODEX_PROFILE_PATH, Buffer.from(journal.originalProfile, "base64").toString("utf-8")); + profileRestored = true; } else if (existsSync(CODEX_PROFILE_PATH)) { - try { unlinkSync(CODEX_PROFILE_PATH); } catch { /* ignore */ } + // "There was no profile before, so remove the one we generated." Claiming success + // without checking is how a caller ends up deleting the journal, reporting a clean + // restore, and leaving our profile on disk with nothing left that records it should + // not be there. ENOENT is the one benign outcome: the file is already gone, which is + // the state we wanted. + try { + unlinkSync(CODEX_PROFILE_PATH); + profileRestored = true; + } catch (error) { + profileRestored = (error as NodeJS.ErrnoException).code === "ENOENT"; + } + } else { + profileRestored = true; } - profileRestored = true; } const complete = configRestored && profileRestored; if (complete) removeJournal(); diff --git a/src/config.ts b/src/config.ts index f2411bc217..0755124640 100644 --- a/src/config.ts +++ b/src/config.ts @@ -936,6 +936,9 @@ const clientConnectionSchema = z.object({ protocolVersion: z.literal(1), connectedAt: clientTimestampSchema, catalogEtag: z.string().min(1).max(512).optional(), + // base64 of the pre-connect catalog, or "" for "there was none". Bounded above the + // catalog size cap so a legitimate snapshot round-trips. + priorCatalog: z.string().max(64 * 1024 * 1024).optional(), catalogSyncedAt: clientTimestampSchema.optional(), pendingOperation: z.object({ kind: z.literal("rotate"), diff --git a/src/types/config.ts b/src/types/config.ts index 48da5e7a60..0c867abfa8 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -281,6 +281,16 @@ export interface OcxClientConnectionConfig { protocolVersion: 1; connectedAt: string; catalogEtag?: string; + /** + * The catalog that was on disk before connect overwrote it, base64-encoded, or the + * empty string when there was none. + * + * Durable because disconnect runs in a different process than connect: an in-memory + * snapshot only covers a connect that fails and rolls back on the spot. Without this, + * disconnect deletes the remote catalog and reports a restored native state while the + * user's own catalog is simply gone. + */ + priorCatalog?: string; catalogSyncedAt?: string; pendingOperation?: { kind: "rotate"; diff --git a/tests/client-connect.test.ts b/tests/client-connect.test.ts index 6a172a1bd8..5d7edbb0f1 100644 --- a/tests/client-connect.test.ts +++ b/tests/client-connect.test.ts @@ -144,7 +144,10 @@ describe("remote hub client boundary", () => { }); }); -function runTransactionScenario(stage: "success" | "catalog" | "preflight" | "commit") { +/** A catalog the user already had before ever connecting. */ +const PRIOR_CATALOG_BYTES = '{"models":[{"slug":"local/only-model"}]}'; + +function runTransactionScenario(stage: "success" | "catalog" | "preflight" | "commit" | "prior-catalog") { const opencodexHome = mkdtempSync(join(tmpdir(), "ocx-client-connect-home-")); const codexHome = mkdtempSync(join(tmpdir(), "ocx-client-connect-codex-")); const configPath = join(opencodexHome, "config.json"); @@ -155,6 +158,10 @@ function runTransactionScenario(stage: "success" | "catalog" | "preflight" | "co }; writeFileSync(configPath, `${JSON.stringify(originalConfig, null, 2)}\n`, "utf8"); if (stage !== "preflight") writeFileSync(join(codexHome, "config.toml"), 'model_provider = "openai"\n', "utf8"); + // A catalog the user already had. Connect overwrites it; disconnect has to put it back. + if (stage === "prior-catalog") { + writeFileSync(join(codexHome, "opencodex-catalog.json"), PRIOR_CATALOG_BYTES, "utf8"); + } if (stage === "commit") { const { mkdirSync } = require("node:fs") as typeof import("node:fs"); mkdirSync(join(opencodexHome, "config-mutation.sqlite")); @@ -207,8 +214,9 @@ function runTransactionScenario(stage: "success" | "catalog" | "preflight" | "co 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 })); + if ((stage === "success" || stage === "prior-catalog") && connected) disconnected = await disconnectClient(); + const catalogAfter = existsSync(DEFAULT_CATALOG_PATH) ? readFileSync(DEFAULT_CATALOG_PATH, "utf8") : null; + console.log(JSON.stringify({ connected, error, beforeDisconnect, artifacts, disconnected, catalogAfter, after: readClientConnectionState(), calls })); })(); `; const result = spawnSync(process.execPath, ["--eval", script], { @@ -245,6 +253,31 @@ describe("connect transaction and offline disconnect", () => { } finally { run.cleanup(); } }); + test("disconnect puts back the catalog the user had before connecting", () => { + // Connect overwrites whatever catalog is already on disk. Disconnect used to delete the + // remote one and report that native Codex state was restored, which left a user who had + // their own catalog with no catalog at all — the one artifact a rollback cannot + // reconstruct from anywhere else. + const run = runTransactionScenario("prior-catalog"); + try { + expect(run.status).toBe(0); + expect(run.parsed.error).toBeNull(); + expect(run.parsed.disconnected).toMatchObject({ catalogRestored: true, catalogRemoved: true }); + expect(run.parsed.catalogAfter).toBe(PRIOR_CATALOG_BYTES); + expect(run.parsed.after).toEqual({ kind: "disconnected" }); + } finally { run.cleanup(); } + }); + + test("disconnect removes the catalog when the user had none", () => { + // The other half of the same contract: `priorCatalog: ""` records "there genuinely was + // none", so removal IS the restoration and must not be mistaken for a lost file. + const run = runTransactionScenario("success"); + try { + expect(run.parsed.disconnected).toMatchObject({ catalogRemoved: true, catalogRestored: false }); + expect(run.parsed.catalogAfter).toBeNull(); + } 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); diff --git a/tests/codex-journal.test.ts b/tests/codex-journal.test.ts index cdb59121ed..5eb2d3152d 100644 --- a/tests/codex-journal.test.ts +++ b/tests/codex-journal.test.ts @@ -652,4 +652,28 @@ describe("codex-journal", () => { runScript(testDir, `require("./src/codex/journal").writeJournal(); console.log("done");`); expect(existsSync(join(testDir, "opencodex-journal.json"))).toBe(false); }); + + test("a restore that leaves the profile behind never reports complete (source-level)", () => { + // "There was no profile before, so delete the one we generated." When that unlink + // fails, reporting success also deletes the journal — the only record that the leftover + // profile is ours — and disconnect then tells the user native state was restored. + // + // Source-level because the failure is not reachable from a test process: making unlink + // fail requires denying writes on the Codex home, and that denies the atomic config + // write earlier in the same function, so the call throws before the branch runs. + // Asserting the shape is honest about what is being checked; asserting a fabricated + // runtime failure would not be. + const source = readFileSync(join(repoRoot, "src/codex/journal.ts"), "utf8"); + const restore = source.slice(source.indexOf("export function restoreJournalState")); + const body = restore.slice(0, restore.indexOf("\nexport ")); + + // The unlink result must decide profileRestored. The pre-fix shape set it + // unconditionally after a swallowed try/catch. + expect(body).not.toMatch(/catch \{ \/\* ignore \*\/ \}\s*\n\s*\}\s*\n\s*profileRestored = true;/); + // ENOENT is the one benign unlink failure: the file is already gone, which is the + // outcome the removal wanted. + expect(body).toContain('=== "ENOENT"'); + // And completeness still gates journal deletion. + expect(body).toContain("if (complete) removeJournal();"); + }); });