From a1c2ea8f98e8efa9445f8ff306f4ca9e8b5ddce4 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:21:58 +0900 Subject: [PATCH 1/2] fix(cli): stop connect runtime discovery after a valid selection --- .../src/content/docs/guides/remote-hub.md | 5 + src/cli/connect.ts | 7 +- structure/clients/claude-desktop.md | 3 + structure/config.md | 3 + structure/ops/docs-and-release.md | 3 + structure/runtime.md | 5 + tests/cli/cli-connect-readiness.test.ts | 175 ++++++++++++++++-- 7 files changed, 186 insertions(+), 15 deletions(-) diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index e582b03428..6f672b8ed5 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -81,6 +81,11 @@ data-key rotation, revocation, and disconnect. ### What a connected client shows +`ocx connect` and `ocx connect status` check catalog readiness against the first valid local +Codex runtime in selection order. Failed preferred candidates can fall back, but lower-priority +alternatives are not probed after a valid runtime is selected. This check leaves the saved runtime +selection unchanged. General `ocx status` still discovers alternatives for runtime diagnostics. + A client stores no provider credentials and no catalog of its own, so its local config and credential store are empty by design — and reading them as the truth produces a confident, wrong answer about what the hub can serve. On a connected client `ocx status` therefore leads with diff --git a/src/cli/connect.ts b/src/cli/connect.ts index c656d5b2e6..2618305423 100644 --- a/src/cli/connect.ts +++ b/src/cli/connect.ts @@ -103,11 +103,12 @@ function readInstalledCatalogBody(): string | null { * `codexSupportedReasoningEfforts()` with no deps reaches `resolveAndPersistCodexRuntime`, which * writes codex-runtime.json. `ocx status` deliberately resolves without persisting, and a * read-only diagnostics command should not start writing runtime selection state because a - * readiness check was added to it. Handing the already-resolved command in as the only candidate - * skips that path and reuses the resolve cache `ocx status` has usually already filled. + * readiness check was added to it. Stop at the first valid runtime, then hand only that command + * to the catalog probe: readiness does not consume alternative-runtime diagnostics. The resolver + * keeps this priority-only cache separate from the full discovery used by `ocx status`. */ function observeLocalCodexEffortLadder(): ReadonlySet | null { - const command = resolveCodexRuntime().runtime.command; + const command = resolveCodexRuntime({ discoverAlternatives: false }).runtime.command; return codexSupportedReasoningEfforts({ commandCandidates: () => [command] }); } diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 00a0b78dbe..3c24465c0d 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -12,6 +12,9 @@ Claude-only connections keep their existing non-failing readiness policy; displa ## Connected Claude Desktop profiles +The connection's local Codex readiness check follows the [selected-runtime probe contract](../runtime.md#remote-hub-hardening-ownership). +It does not discover lower-priority alternatives after a valid selection or alter Desktop ownership. + Connected `ocx claude desktop apply` reads the hub's Desktop snapshot and writes the hub origin and exact hub-issued IDs to the local Desktop configuration. Static/hybrid embed the entries; discovery-only keeps discovery on the hub. The hub owns family assignments and defaults; local diff --git a/structure/config.md b/structure/config.md index ac643d3c7f..528e591cbf 100644 --- a/structure/config.md +++ b/structure/config.md @@ -249,6 +249,9 @@ the residual directory for manual review; there is no recursive-delete fallback. The connection's `tokenFingerprint` participates in [`ocx status` credential binding](runtime.md#remote-hub-status-credential-binding). +Client catalog readiness observes the selected Codex runtime without creating or rewriting +`codex-runtime.json`; its probe scope follows the [runtime contract](runtime.md#remote-hub-hardening-ownership). + Client connection metadata stores a stable `apiKeyId` and a non-secret rotation `pendingOperation`. The current data secret remains only in `service-api-token`; a bounded rotation temporarily keeps the old secret in owner-only `service-api-token.prev`. Commit or recovery clears the marker before orphan cleanup. `ocx disconnect` is local-only and leaves remote revocation to the hub's **Integrations → API Keys** page. Hub and local usage stores are not mirrored. Codex display-cache expiry, retained main-policy evidence, and reset history follow the diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 2789f3bd11..8c52f49075 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -19,6 +19,9 @@ Native retirement keeps active model/quota instructions aligned across locales w [catalog contract](../catalog.md#shared-catalog). Historical records and other providers sharing a model-name fragment remain distinct from current Codex-native support. +The Remote Hub guide distinguishes selected-runtime readiness from general runtime diagnostics; +`tests/cli/cli-connect-readiness.test.ts` exercises that boundary with isolated executable fixtures. + ## GitHub Pages `.github/workflows/deploy-docs.yml` publishes the docs to: diff --git a/structure/runtime.md b/structure/runtime.md index 3287baba40..e54bfb0f21 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -286,6 +286,11 @@ provider name for that assessment, so `planPassthroughWebSearchBridge` takes it ## Remote Hub hardening ownership +`src/cli/connect.ts` resolves only through the first valid local Codex runtime for catalog +readiness, then reads that runtime's effort ladder without persisting its selection. Rejected +preferred candidates still fall back in priority order. General `ocx status` retains full runtime +discovery; its resolver cache mode is distinct from this selected-runtime observation. + `src/remote/protocol.ts` owns pure interval/feature negotiation. `src/remote/hub-state.ts` owns the `GET|HEAD /v1/hub-state` contract, its caps, and the parser both sides share. `src/client/hub-client.ts` owns bounded, schema-validated remote catalog consumption, hub-state reads, and key-id probes; `src/client/hub-state.ts` owns the resolution and the owner-stamped 0600 cache, and a failed read reports "unavailable" rather than degrading to the client's own local provider and login state. `src/client/hub-relay.ts` is a fixed-authority management relay with URL, header, body, redirect, and stream bounds. The public data listener remains the direct client→hub path; the loopback management ingress never serves data-plane routes. ### Remote Hub status credential binding diff --git a/tests/cli/cli-connect-readiness.test.ts b/tests/cli/cli-connect-readiness.test.ts index bb739cdbf0..1e59e6e587 100644 --- a/tests/cli/cli-connect-readiness.test.ts +++ b/tests/cli/cli-connect-readiness.test.ts @@ -7,15 +7,15 @@ * The write-time gate added in the first round cannot close this. It runs once, on bytes about * to be written, so it says nothing about a catalog that predates it, one written while the * runtime ladder was unverified, or a runtime swapped after the write. These tests drive the - * status surface itself, in a real client home, with the ladder injected so no Codex process is - * spawned to observe it. + * status surface itself, in an isolated client home, with injected ladders or harmless fixture + * launchers in place of the operator's Codex runtime. */ import { describe, expect, spyOn, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoRoot } from "../helpers/repo-root"; import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; @@ -24,6 +24,7 @@ import { dispatchCommand } from "../../src/cli/dispatch"; import type { CliDispatchDeps } from "../../src/cli/dispatch"; import { ClientCatalogIncompatibleError } from "../../src/client/catalog-compatibility"; import type { ClientCatalogReadiness } from "../../src/client/catalog-compatibility"; +import type { RuntimeProbeFailure } from "../../src/codex/runtime"; /** Codex CLI 0.135.0's ladder, verbatim from the parse error in the issue. */ const OLD_CLI = ["none", "minimal", "low", "medium", "high", "xhigh"]; @@ -39,24 +40,69 @@ type ProbeResult = { exitCode?: number; errors: string[]; catalogUnchanged?: boolean; + commandCode?: number; status: { state: string; catalog: string; readiness?: string; readinessReason?: string; }; + runtime?: { + beforeDiagnostics: Record; + afterDiagnostics: Record; + diagnosticsCached: boolean; + newerVersion?: string; + selectionUnchanged: boolean; + failures: RuntimeProbeFailure[]; + }; }; +/** Harmless real launchers: the fixture PATH never includes the operator's Codex. */ +function writeRuntimeFixture(dir: string, version: string, valid = true): string { + mkdirSync(dir, { recursive: true }); + const command = join(dir, process.platform === "win32" ? "codex.cmd" : "codex"); + const catalog = JSON.stringify({ models: [{ + slug: "gpt-5.6-sol", + base_instructions: "fixture", + supported_reasoning_levels: NEW_CLI.map(effort => ({ effort })), + }] }); + writeFileSync(command, process.platform === "win32" + ? [ + "@echo off", + 'echo %~1 %~2 %~3>>"%~dp0calls.log"', + ...(valid ? [ + 'if "%~1"=="--version" (', + ` echo codex-cli ${version}`, + " exit /b 0", + ")", + `echo ${catalog}`, + "exit /b 0", + ] : ["exit /b 1"]), + ].join("\r\n") + : [ + "#!/bin/sh", + 'printf "%s\\n" "$*" >> "${0%/*}/calls.log"', + ...(valid ? [ + `if [ "$1" = "--version" ]; then printf '%s\\n' 'codex-cli ${version}'; exit 0; fi`, + `printf '%s\\n' '${catalog}'`, + ] : ["exit 1"]), + ].join("\n"), "utf8"); + if (process.platform !== "win32") chmodSync(command, 0o755); + return command; +} + /** * Runs the real "ocx connect status" surface against a throwaway client home. The ladder is - * injected rather than observed: a spawned "codex debug models" would make the assertion depend - * on whichever Codex CLI the test machine happens to have. + * injected by default; the observer cases use only the isolated fixture launchers below. */ function runStatusProbe(options: { connected: boolean; - ladder: string[] | null | "forbidden"; + ladder: string[] | null | "forbidden" | "observed"; catalog?: string; connectRejectCatalog?: string; + preferred?: "valid" | "failed" | "missing"; + persisted?: boolean; + fullDiagnostics?: boolean; }): ProbeResult { const opencodexHome = mkdtempSync(join(tmpdir(), "ocx-readiness-home-")); const codexHome = mkdtempSync(join(tmpdir(), "ocx-readiness-codex-")); @@ -89,20 +135,47 @@ function runStatusProbe(options: { writeFileSync(join(opencodexHome, "service-api-token"), `${token}\n`, { mode: 0o600 }); } writeFileSync(join(codexHome, "opencodex-catalog.json"), catalog, "utf8"); + const runtimeEnv: NodeJS.ProcessEnv = {}; + if (options.ladder === "observed") { + const selectedDir = join(opencodexHome, "selected"); + const lowerDir = join(opencodexHome, "lower"); + const rejectedDir = join(opencodexHome, "rejected"); + const selected = writeRuntimeFixture(selectedDir, "0.145.0"); + writeRuntimeFixture(lowerDir, "99.0.0"); + const preferred = options.preferred ?? "valid"; + runtimeEnv.CODEX_CLI_PATH = preferred === "valid" ? selected + : preferred === "failed" ? writeRuntimeFixture(rejectedDir, "", false) + : join(rejectedDir, process.platform === "win32" ? "codex.cmd" : "codex"); + runtimeEnv.PATH = [selectedDir, lowerDir].join(delimiter); + runtimeEnv.HOME = opencodexHome; + runtimeEnv.USERPROFILE = opencodexHome; + runtimeEnv.FIXTURE_RUNTIME_DIRS = JSON.stringify({ selected: selectedDir, lower: lowerDir, rejected: rejectedDir }); + runtimeEnv.FIXTURE_FULL_DIAGNOSTICS = options.fullDiagnostics ? "1" : "0"; + if (options.persisted) writeFileSync(join(opencodexHome, "codex-runtime.json"), JSON.stringify({ + version: 1, command: selected, source: "configured", selectedVersion: "0.145.0", + updatedAt: "2026-08-28T00:00:00.000Z", + })); + } const script = ` const { collectClientConnectionStatus, handleConnectCommand } = require("./src/cli/connect"); + const { readFileSync } = require("node:fs"); + const { join } = require("node:path"); const ladder = JSON.parse(process.env.FIXTURE_LADDER); const supportedEfforts = ladder === "forbidden" ? () => { throw new Error("the runtime was probed on a path that must not probe it"); } : ladder === null ? () => null : () => new Set(ladder); + const catalogProbeDeps = ladder === "observed" ? {} : { supportedEfforts }; + const readOptional = path => { try { return readFileSync(path, "utf8"); } catch { return null; } }; + const selectionPath = join(process.env.OPENCODEX_HOME, "codex-runtime.json"); + const selectionBefore = readOptional(selectionPath); const lifecycleLockDeps = { lockPath: process.env.OPENCODEX_HOME + "/lifecycle.sqlite" }; const captured = []; const errors = []; const real = console.log; const realError = console.error; (async () => { - let exitCode, catalogUnchanged; + let exitCode, catalogUnchanged, commandCode; console.log = (...parts) => captured.push(parts.join(" ")); console.error = (...parts) => errors.push(parts.join(" ")); try { @@ -134,7 +207,7 @@ function runStatusProbe(options: { }); catalogUnchanged = fs.readFileSync(catalogPath, "utf8") === before; } else { - await handleConnectCommand(["status"], { lifecycleLockDeps, catalogProbeDeps: { supportedEfforts } }); + commandCode = await handleConnectCommand(["status"], { lifecycleLockDeps, catalogProbeDeps }); } } finally { console.log = real; @@ -143,9 +216,30 @@ function runStatusProbe(options: { const status = collectClientConnectionStatus( Date.parse("2026-08-28T00:00:10.000Z"), lifecycleLockDeps, - { supportedEfforts }, + catalogProbeDeps, ); - console.log(JSON.stringify({ lines: captured, status, exitCode, errors, catalogUnchanged })); + let runtime; + if (ladder === "observed") { + const dirs = JSON.parse(process.env.FIXTURE_RUNTIME_DIRS); + const calls = () => Object.fromEntries(Object.entries(dirs).map(([key, dir]) => + [key, (readOptional(join(dir, "calls.log")) ?? "").split(/\\r?\\n/).map(line => line.trim()).filter(Boolean)])); + const beforeDiagnostics = calls(); + const { resolveCodexRuntime } = require("./src/codex/runtime"); + // Same priority-only scope the status path resolved with, so this reads the memo that + // path published instead of probing again, and reports the candidates it rejected. + const failures = resolveCodexRuntime({ discoverAlternatives: false }).failures; + let newerVersion; + let diagnosticsCached = true; + if (process.env.FIXTURE_FULL_DIAGNOSTICS === "1") { + newerVersion = resolveCodexRuntime().newerAvailable?.version; + const first = JSON.stringify(calls()); + resolveCodexRuntime(); + diagnosticsCached = first === JSON.stringify(calls()); + } + runtime = { beforeDiagnostics, afterDiagnostics: calls(), diagnosticsCached, newerVersion, + selectionUnchanged: selectionBefore === readOptional(selectionPath), failures }; + } + console.log(JSON.stringify({ lines: captured, commandCode, status, runtime, exitCode, errors, catalogUnchanged })); })(); `; @@ -166,10 +260,17 @@ function runStatusProbe(options: { // Desktop configuration, even transitively. OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop"), FIXTURE_LADDER: JSON.stringify(options.ladder), + ...runtimeEnv, }, }); expect(result.status).toBe(0); - return JSON.parse(result.stdout.trim().split("\n").at(-1)!) as ProbeResult; + const probe = JSON.parse(result.stdout.trim().split("\n").at(-1)!) as ProbeResult; + // A status command that exited nonzero printed no verdict worth asserting on, so every + // readiness expectation below would otherwise be checking a report that was never produced. + if (!options.connectRejectCatalog) { + expect(probe.commandCode).toBe(0); + } + return probe; } finally { removeTreeWithRetry(opencodexHome); removeTreeWithRetry(codexHome); @@ -277,6 +378,56 @@ describe("#4207 connected-client readiness", () => { }); }); +describe("connected-client runtime probe scope", () => { + test("observes only the selected runtime and leaves full diagnostics available", () => { + const probe = runStatusProbe({ connected: true, ladder: "observed", fullDiagnostics: true }); + + expect(probe.status.readiness).toBe("ready"); + expect(probe.runtime?.beforeDiagnostics.lower).toEqual([]); + expect(probe.runtime?.beforeDiagnostics.selected).toEqual([ + "--version", "debug models --bundled", "debug models --bundled", + ]); + // The preferred runtime answered, so the readiness scope rejected no candidate at all. + expect(probe.runtime?.failures).toEqual([]); + expect(probe.runtime?.newerVersion).toBe("99.0.0"); + expect(probe.runtime?.afterDiagnostics.lower).toEqual(["--version"]); + expect(probe.runtime?.diagnosticsCached).toBe(true); + expect(probe.runtime?.selectionUnchanged).toBe(true); + }, SPAWN_BUDGET_MS); + + test("a rejected preferred runtime falls back without rewriting the saved selection", () => { + const probe = runStatusProbe({ connected: true, ladder: "observed", preferred: "failed", persisted: true }); + + expect(probe.status.readiness).toBe("ready"); + expect(probe.runtime?.beforeDiagnostics.lower).toEqual([]); + expect(probe.runtime?.beforeDiagnostics.rejected).toEqual(["--version"]); + expect(probe.runtime?.beforeDiagnostics.selected).toEqual([ + "--version", "debug models --bundled", "debug models --bundled", + ]); + // The fallback is only meaningful if the preferred runtime was probed and refused, so the + // resolver has to say so rather than leave a silent selection. + const rejected = probe.runtime?.failures.filter(item => item.source === "environment") ?? []; + expect(rejected).toHaveLength(1); + expect(rejected[0]?.command).toContain("rejected"); + expect(rejected[0]?.reason).toContain("failed --version"); + expect(probe.runtime?.selectionUnchanged).toBe(true); + }, SPAWN_BUDGET_MS); + + test("a missing preferred runtime falls back to the first valid PATH candidate", () => { + const probe = runStatusProbe({ connected: true, ladder: "observed", preferred: "missing" }); + + expect(probe.status.readiness).toBe("ready"); + expect(probe.runtime?.beforeDiagnostics.lower).toEqual([]); + expect(probe.runtime?.beforeDiagnostics.selected).toEqual([ + "--version", "debug models --bundled", "debug models --bundled", + ]); + const missing = probe.runtime?.failures.filter(item => item.source === "environment") ?? []; + expect(missing).toHaveLength(1); + expect(missing[0]?.reason).toBe("path does not exist"); + expect(probe.runtime?.selectionUnchanged).toBe(true); + }, SPAWN_BUDGET_MS); +}); + describe("#4207 what ocx connect reports when the local CLI cannot use the catalog", () => { const incompatible: ClientCatalogReadiness = { kind: "incompatible", From 8bc6a211d3eca1f4ed2b4c423b03ace24c15c6c9 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:27:36 +0900 Subject: [PATCH 2/2] fix(cli): reuse status runtime selection for readiness --- src/cli/connect.ts | 19 +++++++++-- src/cli/status.ts | 40 ++++++++++++----------- structure/clients/claude-desktop.md | 2 +- structure/config.md | 2 +- structure/ops/docs-and-release.md | 2 +- structure/runtime.md | 2 +- tests/cli/cli-connect-readiness.test.ts | 42 +++++++++++++++++++++++-- 7 files changed, 80 insertions(+), 29 deletions(-) diff --git a/src/cli/connect.ts b/src/cli/connect.ts index 2618305423..dba3ad4aa6 100644 --- a/src/cli/connect.ts +++ b/src/cli/connect.ts @@ -42,6 +42,15 @@ export interface ClientCommandDeps extends RuntimeApiDeps { export interface ClientCatalogProbeDeps extends CatalogCompatibilityDeps { /** Injected in tests; defaults to reading the materialized client catalog off disk. */ readCatalogBody?: () => string | null; + /** + * A Codex command the caller already resolved, handed over so readiness skips resolving it + * again. General `ocx status` resolves the full runtime for its diagnostics block; the resolver + * memo is keyed by discovery scope and holds one entry, so a priority-only readiness resolve + * and the full one miss each other and re-probe the same command with `--version` — up to eight + * seconds apiece. Passing the command across adds no cache state, and it cannot disagree with + * what status prints because it is the selection status is printing. + */ + selectedCodexCommand?: string; } export const CONNECT_USAGE = `Usage: @@ -106,9 +115,12 @@ function readInstalledCatalogBody(): string | null { * readiness check was added to it. Stop at the first valid runtime, then hand only that command * to the catalog probe: readiness does not consume alternative-runtime diagnostics. The resolver * keeps this priority-only cache separate from the full discovery used by `ocx status`. + * + * A caller that has already resolved passes its selection in through `selectedCodexCommand` rather + * than paying for a second `--version` probe of the command it just resolved. */ -function observeLocalCodexEffortLadder(): ReadonlySet | null { - const command = resolveCodexRuntime({ discoverAlternatives: false }).runtime.command; +function observeLocalCodexEffortLadder(selected?: string): ReadonlySet | null { + const command = selected ?? resolveCodexRuntime({ discoverAlternatives: false }).runtime.command; return codexSupportedReasoningEfforts({ commandCandidates: () => [command] }); } @@ -120,7 +132,8 @@ function observeLocalCodexEffortLadder(): ReadonlySet | null { * a single `ocx connect`. */ function catalogObserver(deps: ClientCatalogProbeDeps | undefined): CatalogCompatibilityDeps { - return { supportedEfforts: deps?.supportedEfforts ?? observeLocalCodexEffortLadder }; + const selected = deps?.selectedCodexCommand; + return { supportedEfforts: deps?.supportedEfforts ?? (() => observeLocalCodexEffortLadder(selected)) }; } /** The stat half of the catalog verdict, shared by the status collector and `ocx connect`. */ diff --git a/src/cli/status.ts b/src/cli/status.ts index e120b5de70..b77f494082 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -509,7 +509,27 @@ export async function collectStatus(): Promise { desiredEnabled: claudeDesktopIntegrationEnabled(config), policy: claudeDesktopPolicyHealth(probeClaudeDesktopPolicy()), }; - const clientConnection = collectClientConnectionStatus(); + const resolvedRuntime = (() => { + try { + return resolveCodexRuntime(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const redacted = redactUserPath(redactSecretString(message)).slice(0, 160); + return { + runtime: { command: "codex", version: null, source: "fallback" as const }, + failures: [{ + command: "codex", + source: "fallback" as const, + reason: `resolve threw: ${redacted}`, + }], + replacedConfigured: undefined, + newerAvailable: undefined, + }; + } + })(); + const clientConnection = collectClientConnectionStatus(Date.now(), undefined, { + selectedCodexCommand: resolvedRuntime.runtime.command, + }); // Asked before the local probes below so a connected client's report is hub-sourced from its // first line. Bounded and failure-tolerant: an offline hub degrades the remoteHub block, it // does not fail `ocx status`. @@ -567,24 +587,6 @@ export async function collectStatus(): Promise { routingKind: getCodexRoutingKind(), }); const codexPlugins = diagnoseCodexBundledPlugins(); - const resolvedRuntime = (() => { - try { - return resolveCodexRuntime(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const redacted = redactUserPath(redactSecretString(message)).slice(0, 160); - return { - runtime: { command: "codex", version: null, source: "fallback" as const }, - failures: [{ - command: "codex", - source: "fallback" as const, - reason: `resolve threw: ${redacted}`, - }], - replacedConfigured: undefined, - newerAvailable: undefined, - }; - } - })(); const lastClamp = loadLastEffortClamp(); const clampActive = effortClampAppliesToRuntime(lastClamp, resolvedRuntime.runtime); const codexHome = collectOrcaCodexHomeDiagnostic(); diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 3c24465c0d..a8940054fc 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -12,7 +12,7 @@ Claude-only connections keep their existing non-failing readiness policy; displa ## Connected Claude Desktop profiles -The connection's local Codex readiness check follows the [selected-runtime probe contract](../runtime.md#remote-hub-hardening-ownership). +The connection's local Codex readiness check follows the [selected-runtime probe contract](../runtime.md#remote-hub-hardening-ownership); general status hands its resolved command to this check instead of probing the version twice. It does not discover lower-priority alternatives after a valid selection or alter Desktop ownership. Connected `ocx claude desktop apply` reads the hub's Desktop snapshot and writes the hub origin diff --git a/structure/config.md b/structure/config.md index 528e591cbf..d70ba43dcf 100644 --- a/structure/config.md +++ b/structure/config.md @@ -250,7 +250,7 @@ The connection's `tokenFingerprint` participates in [`ocx status` credential binding](runtime.md#remote-hub-status-credential-binding). Client catalog readiness observes the selected Codex runtime without creating or rewriting -`codex-runtime.json`; its probe scope follows the [runtime contract](runtime.md#remote-hub-hardening-ownership). +`codex-runtime.json`; general status reuses its already-resolved command under the [runtime contract](runtime.md#remote-hub-hardening-ownership). Client connection metadata stores a stable `apiKeyId` and a non-secret rotation `pendingOperation`. The current data secret remains only in `service-api-token`; a bounded rotation temporarily keeps the old secret in owner-only `service-api-token.prev`. Commit or recovery clears the marker before orphan cleanup. `ocx disconnect` is local-only and leaves remote revocation to the hub's **Integrations → API Keys** page. Hub and local usage stores are not mirrored. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 8c52f49075..631c012e06 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -20,7 +20,7 @@ Native retirement keeps active model/quota instructions aligned across locales w sharing a model-name fragment remain distinct from current Codex-native support. The Remote Hub guide distinguishes selected-runtime readiness from general runtime diagnostics; -`tests/cli/cli-connect-readiness.test.ts` exercises that boundary with isolated executable fixtures. +`tests/cli/cli-connect-readiness.test.ts` exercises that boundary and general status's single discovery pass with isolated executable fixtures. ## GitHub Pages diff --git a/structure/runtime.md b/structure/runtime.md index e54bfb0f21..98b795a188 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -289,7 +289,7 @@ provider name for that assessment, so `planPassthroughWebSearchBridge` takes it `src/cli/connect.ts` resolves only through the first valid local Codex runtime for catalog readiness, then reads that runtime's effort ladder without persisting its selection. Rejected preferred candidates still fall back in priority order. General `ocx status` retains full runtime -discovery; its resolver cache mode is distinct from this selected-runtime observation. +discovery and passes its resolved command into readiness, avoiding a second version probe without adding cache state. `src/remote/protocol.ts` owns pure interval/feature negotiation. `src/remote/hub-state.ts` owns the `GET|HEAD /v1/hub-state` contract, its caps, and the parser both sides share. `src/client/hub-client.ts` owns bounded, schema-validated remote catalog consumption, hub-state reads, and key-id probes; `src/client/hub-state.ts` owns the resolution and the owner-stamped 0600 cache, and a failed read reports "unavailable" rather than degrading to the client's own local provider and login state. `src/client/hub-relay.ts` is a fixed-authority management relay with URL, header, body, redirect, and stream bounds. The public data listener remains the direct client→hub path; the loopback management ingress never serves data-plane routes. diff --git a/tests/cli/cli-connect-readiness.test.ts b/tests/cli/cli-connect-readiness.test.ts index 1e59e6e587..11af5522e4 100644 --- a/tests/cli/cli-connect-readiness.test.ts +++ b/tests/cli/cli-connect-readiness.test.ts @@ -103,6 +103,8 @@ function runStatusProbe(options: { preferred?: "valid" | "failed" | "missing"; persisted?: boolean; fullDiagnostics?: boolean; + /** "connect" drives `ocx connect status`; "status" drives the general `ocx status` collector. */ + surface?: "connect" | "status"; }): ProbeResult { const opencodexHome = mkdtempSync(join(tmpdir(), "ocx-readiness-home-")); const codexHome = mkdtempSync(join(tmpdir(), "ocx-readiness-codex-")); @@ -167,6 +169,9 @@ function runStatusProbe(options: { : ladder === null ? () => null : () => new Set(ladder); const catalogProbeDeps = ladder === "observed" ? {} : { supportedEfforts }; const readOptional = path => { try { return readFileSync(path, "utf8"); } catch { return null; } }; + const dirs = process.env.FIXTURE_RUNTIME_DIRS ? JSON.parse(process.env.FIXTURE_RUNTIME_DIRS) : null; + const calls = () => Object.fromEntries(Object.entries(dirs ?? {}).map(([key, dir]) => + [key, (readOptional(join(dir, "calls.log")) ?? "").split(/\\r?\\n/).map(line => line.trim()).filter(Boolean)])); const selectionPath = join(process.env.OPENCODEX_HOME, "codex-runtime.json"); const selectionBefore = readOptional(selectionPath); const lifecycleLockDeps = { lockPath: process.env.OPENCODEX_HOME + "/lifecycle.sqlite" }; @@ -176,6 +181,18 @@ function runStatusProbe(options: { const realError = console.error; (async () => { let exitCode, catalogUnchanged, commandCode; + if (process.env.FIXTURE_SURFACE === "status") { + const { collectStatus } = require("./src/cli/status"); + const view = await collectStatus(); + const observed = calls(); + console.log(JSON.stringify({ + lines: [], commandCode: 0, status: view.json.connection, + runtime: { beforeDiagnostics: observed, afterDiagnostics: observed, diagnosticsCached: true, + selectionUnchanged: selectionBefore === readOptional(selectionPath), failures: [] }, + exitCode, errors, catalogUnchanged, + })); + return; + } console.log = (...parts) => captured.push(parts.join(" ")); console.error = (...parts) => errors.push(parts.join(" ")); try { @@ -220,9 +237,6 @@ function runStatusProbe(options: { ); let runtime; if (ladder === "observed") { - const dirs = JSON.parse(process.env.FIXTURE_RUNTIME_DIRS); - const calls = () => Object.fromEntries(Object.entries(dirs).map(([key, dir]) => - [key, (readOptional(join(dir, "calls.log")) ?? "").split(/\\r?\\n/).map(line => line.trim()).filter(Boolean)])); const beforeDiagnostics = calls(); const { resolveCodexRuntime } = require("./src/codex/runtime"); // Same priority-only scope the status path resolved with, so this reads the memo that @@ -260,6 +274,7 @@ function runStatusProbe(options: { // Desktop configuration, even transitively. OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop"), FIXTURE_LADDER: JSON.stringify(options.ladder), + FIXTURE_SURFACE: options.surface ?? "connect", ...runtimeEnv, }, }); @@ -426,6 +441,27 @@ describe("connected-client runtime probe scope", () => { expect(missing[0]?.reason).toBe("path does not exist"); expect(probe.runtime?.selectionUnchanged).toBe(true); }, SPAWN_BUDGET_MS); + + test("a general ocx status does not re-probe the runtime it already resolved", () => { + // General `ocx status` answers readiness and then reports full runtime diagnostics. Both + // land on the same selected command, and each `codex --version` probe is allowed up to + // eight seconds, so resolving it twice is latency the operator pays for nothing. The + // readiness scope caches under its own key, so before the fix the second resolution missed. + const probe = runStatusProbe({ connected: true, ladder: "observed", surface: "status" }); + + expect(probe.status.readiness).toBe("ready"); + // One full discovery pass, then the ladder. The pass probes the configured path and the + // bare `codex` fallback as separate candidates, which PATH resolves back to this fixture; + // what must not appear is a third `--version` after `debug models`, which is what the + // readiness scope added when it resolved the selection for itself. + expect(probe.runtime?.afterDiagnostics.selected).toEqual([ + "--version", "--version", "debug models --bundled", + ]); + // Full discovery still runs: the lower-priority candidate is still version-probed, so the + // saving comes from reusing the selection rather than from narrowing what status reports. + expect(probe.runtime?.afterDiagnostics.lower).toEqual(["--version"]); + expect(probe.runtime?.selectionUnchanged).toBe(true); + }, SPAWN_BUDGET_MS); }); describe("#4207 what ocx connect reports when the local CLI cannot use the catalog", () => {