-
Notifications
You must be signed in to change notification settings - Fork 1.1k
client: report local Codex readiness instead of bare connected state #4246
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
635a3d1
efefde4
87f5b52
6b04f13
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,13 @@ | ||
| import { existsSync, lstatSync } from "node:fs"; | ||
| import { existsSync, lstatSync, readFileSync } from "node:fs"; | ||
| import { DEFAULT_CATALOG_PATH } from "../codex/paths"; | ||
| import { codexSupportedReasoningEfforts } from "../codex/catalog/effort"; | ||
| import { resolveCodexRuntime } from "../codex/runtime"; | ||
| import { | ||
| inspectClientCatalogReadiness, | ||
| type CatalogCompatibilityDeps, | ||
| type ClientCatalogFileState, | ||
| type ClientCatalogReadiness, | ||
| } from "../client/catalog-compatibility"; | ||
| import { | ||
| disconnectClient, | ||
| revokeConnectedClientKey, | ||
|
|
@@ -26,6 +34,12 @@ import { | |
|
|
||
| export interface ClientCommandDeps extends RuntimeApiDeps { | ||
| lifecycleLockDeps?: ClientLifecycleLockDeps; | ||
| catalogProbeDeps?: ClientCatalogProbeDeps; | ||
| } | ||
|
|
||
| export interface ClientCatalogProbeDeps extends CatalogCompatibilityDeps { | ||
| /** Injected in tests; defaults to reading the materialized client catalog off disk. */ | ||
| readCatalogBody?: () => string | null; | ||
| } | ||
|
|
||
| export const CONNECT_USAGE = `Usage: | ||
|
|
@@ -56,21 +70,96 @@ export type ClientConnectionStatus = { | |
| catalog: "present" | "missing" | "unsafe"; | ||
| token: "owned" | "missing" | "changed" | "unsafe"; | ||
| rotation: "clean" | "orphan-cleaned" | "recovery-required" | "unsafe"; | ||
| /** | ||
| * Whether the selected local Codex CLI can actually launch against this connection (#4207). | ||
| * | ||
| * `state: "connected"` proves the hub answered and the credential works. It never proved the | ||
| * local runtime could consume what was downloaded, which is how a connection kept reporting | ||
| * itself healthy while `codex exec` exited on `unknown variant` before its first request. | ||
| * | ||
| * Reported only while connected, and reported as its own field rather than as a fourth | ||
| * `catalog` value: the status JSON is documented additive-only, so widening an existing | ||
| * field's value domain would change what `catalog: "present"` means for every consumer that | ||
| * already reads it. | ||
| */ | ||
| readiness?: ClientCatalogReadiness["kind"]; | ||
| /** Present whenever readiness is not `ready`; names the fault and the way out. */ | ||
| readinessReason?: string; | ||
| }; | ||
|
|
||
| export function collectClientConnectionStatus(now = Date.now(), lifecycleLockDeps?: ClientLifecycleLockDeps): ClientConnectionStatus { | ||
| function readInstalledCatalogBody(): string | null { | ||
| try { | ||
| return readFileSync(DEFAULT_CATALOG_PATH, "utf8"); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * The ladder the selected Codex CLI accepts, observed without persisting anything. | ||
| * | ||
| * `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. | ||
| */ | ||
| function observeLocalCodexEffortLadder(): ReadonlySet<string> | null { | ||
| const command = resolveCodexRuntime().runtime.command; | ||
| return codexSupportedReasoningEfforts({ commandCandidates: () => [command] }); | ||
| } | ||
|
|
||
| /** | ||
| * One observer per command, for the write-time gate and the readiness check alike. Built even | ||
| * when nothing was injected, so production does not silently fall back to the default inside | ||
| * {@link assertClientCatalogCompatible} — that default persists runtime selection state and | ||
| * would run its own probe, which is how the two checks could disagree about the ladder within | ||
| * a single `ocx connect`. | ||
| */ | ||
| function catalogObserver(deps: ClientCatalogProbeDeps | undefined): CatalogCompatibilityDeps { | ||
| return { supportedEfforts: deps?.supportedEfforts ?? observeLocalCodexEffortLadder }; | ||
| } | ||
|
|
||
| /** The stat half of the catalog verdict, shared by the status collector and `ocx connect`. */ | ||
| function installedCatalogFileState(): ClientCatalogFileState { | ||
| if (!existsSync(DEFAULT_CATALOG_PATH)) return "missing"; | ||
| try { | ||
| const stat = lstatSync(DEFAULT_CATALOG_PATH); | ||
| return !stat.isSymbolicLink() && stat.isFile() ? "present" : "unsafe"; | ||
| } catch { | ||
| return "unsafe"; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Observing the runtime spawns `codex debug models`, so this runs only for a connected client — | ||
| * the one configuration that installs hub bytes the local clamp never touched. A standalone or | ||
| * hub install pays nothing for it. | ||
| * | ||
| * Never throws. A status command that dies because a Codex probe failed would replace one wrong | ||
| * answer with a worse one. | ||
| */ | ||
| function inspectInstalledCatalogReadiness( | ||
| file: ClientCatalogFileState, | ||
| deps: ClientCatalogProbeDeps, | ||
| ): ClientCatalogReadiness { | ||
| try { | ||
| const read = deps.readCatalogBody ?? readInstalledCatalogBody; | ||
| return inspectClientCatalogReadiness(file, file === "present" ? read() : null, catalogObserver(deps)); | ||
| } catch { | ||
| return { kind: "unverified", reason: "the selected local Codex runtime could not be inspected" }; | ||
| } | ||
| } | ||
|
|
||
| export function collectClientConnectionStatus( | ||
| now = Date.now(), | ||
| lifecycleLockDeps?: ClientLifecycleLockDeps, | ||
| catalogProbeDeps: ClientCatalogProbeDeps = {}, | ||
| ): ClientConnectionStatus { | ||
| const state = readClientConnectionState(); | ||
| const tokenState = readServiceApiTokenState(); | ||
| const rotation = inspectClientRotationRecoveryGate(state, lifecycleLockDeps).kind; | ||
| 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"; | ||
| } | ||
| } | ||
| const catalog = installedCatalogFileState(); | ||
| if (state.kind !== "connected") { | ||
| return { | ||
| state: state.kind, | ||
|
|
@@ -88,6 +177,7 @@ export function collectClientConnectionStatus(now = Date.now(), lifecycleLockDep | |
| : tokenState.kind === "unsafe" | ||
| ? "unsafe" | ||
| : tokenState.fingerprint === state.value.tokenFingerprint ? "owned" : "changed"; | ||
| const readiness = inspectInstalledCatalogReadiness(catalog, catalogProbeDeps); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Gate Codex readiness reporting on When 🤖 Prompt for AI Agents |
||
| return { | ||
| state: "connected", | ||
| serverUrl: state.value.serverUrl, | ||
|
|
@@ -102,6 +192,8 @@ export function collectClientConnectionStatus(now = Date.now(), lifecycleLockDep | |
| catalog, | ||
| token, | ||
| rotation, | ||
| readiness: readiness.kind, | ||
| ...(readiness.kind === "ready" ? {} : { readinessReason: readiness.reason }), | ||
| }; | ||
| } | ||
|
|
||
|
|
@@ -113,12 +205,74 @@ function parseClients(raw: string | undefined): OcxConnectedClientId[] { | |
| return values as OcxConnectedClientId[]; | ||
| } | ||
|
|
||
| /** Reads as a verdict, not a field dump: "ready" is the only word that means the client works. */ | ||
| function readinessLine(status: ClientConnectionStatus): string { | ||
| const label = status.readiness === "ready" | ||
| ? "ready" | ||
| : status.readiness === "incompatible" | ||
| ? "not ready" | ||
| : "unverified"; | ||
| return `Local Codex CLI: ${label}${status.readinessReason ? ` (${status.readinessReason})` : ""}`; | ||
| } | ||
|
|
||
| export type ConnectCompletionReport = { | ||
| readonly lines: readonly string[]; | ||
| /** Non-null when `ocx connect` must exit non-zero rather than report success. */ | ||
| readonly failure: string | null; | ||
| }; | ||
|
|
||
| /** | ||
| * What `ocx connect` says once the hub and the credential are settled, and whether the command | ||
| * still fails (#4207). | ||
| * | ||
| * Pure so the fail-closed decision can be exercised without a hub. Two rules it encodes: | ||
| * | ||
| * On a proven incompatibility the verdict is printed FIRST and the `Connected to …` line is | ||
| * withheld, because a caller grepping for that phrase would otherwise read a catalog the local | ||
| * CLI cannot parse as a success. The connection really was saved, so the replacement line says | ||
| * where to see it. | ||
| * | ||
| * And that failure applies only when this connection selected Codex. A Claude-only connection | ||
| * never launches the Codex CLI, so an old binary somewhere on PATH is not a reason to fail the | ||
| * operator's Claude Desktop setup — it is still worth saying, which is why the line survives | ||
| * without the exit code. | ||
| */ | ||
| export function connectCompletionReport( | ||
| connection: { serverUrl: string; apiKeyId: string }, | ||
| selectedClients: readonly OcxConnectedClientId[], | ||
| readiness: ClientCatalogReadiness, | ||
| ): ConnectCompletionReport { | ||
| const connected = `Connected to ${connection.serverUrl} as key ${connection.apiKeyId}.`; | ||
| if (readiness.kind === "ready") { | ||
| return { lines: [connected, "Local Codex CLI: ready (it accepts every reasoning level in the installed catalog)."], failure: null }; | ||
| } | ||
| if (readiness.kind === "unverified") { | ||
| // Not a failure. A client with no observable Codex CLI is a working configuration, and the | ||
| // write-time gate deliberately lets it through; saying so is the honest middle report. | ||
| return { lines: [connected, `Local Codex CLI: unverified (${readiness.reason}).`], failure: null }; | ||
| } | ||
| const verdict = `Local Codex CLI: not ready (${readiness.reason})`; | ||
| if (!selectedClients.includes("codex")) { | ||
| return { | ||
| lines: [connected, `${verdict} This connection selected ${selectedClients.join(", ")}, so nothing here launches Codex.`], | ||
| failure: null, | ||
| }; | ||
| } | ||
| return { | ||
| lines: [verdict, `The connection to ${connection.serverUrl} as key ${connection.apiKeyId} was saved; run 'ocx connect status' to see it.`], | ||
| failure: `client_not_ready: ${readiness.reason}`, | ||
| }; | ||
| } | ||
|
|
||
| function statusLines(status: ClientConnectionStatus): string[] { | ||
| if (status.state !== "connected") { | ||
| return [`Connection: ${status.state}${status.reason ? ` (${status.reason})` : ""}`]; | ||
| } | ||
| return [ | ||
| "Connection: connected", | ||
| // Second line on purpose. The whole of #4207 is that a reader stopped at "connected" and | ||
| // believed the client was usable, so the local verdict has to arrive before the hub detail. | ||
| readinessLine(status), | ||
| `Hub: ${status.serverUrl}`, | ||
| `Management: ${status.managementUrl} (${status.managementTransport})`, | ||
| `Protocol: ${status.protocolVersion}`, | ||
|
|
@@ -183,8 +337,21 @@ async function runConnect(argv: string[], deps: ClientCommandDeps): Promise<void | |
| managementTransport, | ||
| noSync, | ||
| ...(catalogTimeoutSeconds === undefined ? {} : { catalogTimeoutMs: catalogTimeoutSeconds * 1_000 }), | ||
| }, { fetchImpl: deps.fetchImpl, lifecycleLockDeps: deps.lifecycleLockDeps }); | ||
| console.log(`Connected to ${connection.serverUrl} as key ${connection.apiKeyId}.`); | ||
| }, { | ||
| fetchImpl: deps.fetchImpl, | ||
| lifecycleLockDeps: deps.lifecycleLockDeps, | ||
| // Same observer the readiness check below uses. Passed unconditionally: leaving it out in | ||
| // production would let the gate fall back to its own probing, persisting default, so one | ||
| // command could run two probes and act on two different ladders. | ||
| catalogCompatibility: catalogObserver(deps.catalogProbeDeps), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Reuse one memoized catalog observation for both checks.
🤖 Prompt for AI Agents🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Skip the catalog compatibility assertion for Claude-only connections
🤖 Prompt for AI Agents |
||
| }); | ||
| // The hub and the credential are proven at this point; the local runtime is not. Reporting | ||
| // only the first half is what #4207 was filed for, so the catalog now on disk is checked | ||
| // against the Codex CLI that will read it. | ||
| const readiness = inspectInstalledCatalogReadiness(installedCatalogFileState(), deps.catalogProbeDeps ?? {}); | ||
| const report = connectCompletionReport(connection, clients, readiness); | ||
| for (const line of report.lines) console.log(line); | ||
| if (report.failure) throw new Error(report.failure); | ||
| } | ||
|
|
||
| async function runRevoke(argv: string[], deps: ClientCommandDeps): Promise<void> { | ||
|
|
@@ -204,7 +371,7 @@ export async function handleConnectCommand(argv: string[], deps: ClientCommandDe | |
| const args = argv.slice(1); | ||
| const wantsJson = takeFlag(args, "--json"); | ||
| rejectArgs(args, CONNECT_USAGE, { redactValues: true }); | ||
| const status = collectClientConnectionStatus(Date.now(), deps.lifecycleLockDeps); | ||
| const status = collectClientConnectionStatus(Date.now(), deps.lifecycleLockDeps, deps.catalogProbeDeps ?? {}); | ||
| printData(status, wantsJson, statusLines(status)); | ||
| return; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Bound the installed catalog read.
installedCatalogFileState()accepts any regularDEFAULT_CATALOG_PATH, andreadInstalledCatalogBody()then loads it withreadFileSyncforocx connect status. This path bypasses the existingMAX_REMOTE_CATALOG_BYTESchecks used during catalog download and connection setup. An oversized local catalog can therefore consume unbounded memory and block the CLI. Reject oversized files before inspection, and use a bounded read that remains safe if the file changes between the state check and read. Add a focused oversized-catalog test.🤖 Prompt for AI Agents