From f7ebbe40184bc2cd637bb231553a8a69fdd5f1d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9A=B0=EC=B2=A0?= Date: Fri, 2 Oct 2026 17:13:19 +0900 Subject: [PATCH 1/2] fix(cli): return not-found for missing routing profiles --- .../content/docs/ko/reference/cli/agents.md | 4 +++ .../src/content/docs/reference/cli/agents.md | 4 +++ scripts/test-layout/layout.json | 1 + src/cli/route-policy.ts | 3 ++- structure/runtime.md | 2 +- tests/cli/cli-route-policy-not-found.test.ts | 25 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 7 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 tests/cli/cli-route-policy-not-found.test.ts diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index 1a26b811bf9..9cb532a9f8f 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -263,3 +263,7 @@ ocx system codex-cli-update attest --candidate --npm-prefix `는 프로필이 없으면 종료 코드 4를 반환합니다. 명령 인수가 빠졌거나 잘못되면 2를 반환하므로 스크립트에서 두 경우를 구별할 수 있습니다. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index a8eff6dde45..3670004adfd 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -485,3 +485,7 @@ backup can restore the configuration; store exported files as secrets. `ocx usage` reads the connected hub with this client's enrolled data key. Human output identifies the hub source and client-key scope; `--json` returns the same scoped data. Range, surface, provider/model filters and custom `--since`/`--until` bounds remain available. Account breakdowns and other clients' records are not shared. An old or unavailable hub produces an explicit error instead of substituting local usage; upgrade the hub if it does not support this read. The read-only data-plane endpoint is `GET /v1/usage`, using `x-opencodex-api-key` with a configured client key. Environment-wide and admin keys are refused. It accepts `range`, `surface`, `provider`, `model`, `since`, and `until`; unknown/repeated options and caller-selected key IDs are rejected. Oversized skipped rows retain the explicit incomplete-history warning. + +## Routing profile lookup status + +`ocx route policy show ` exits 4 when the profile does not exist. Missing or invalid command arguments exit 2. Scripts can distinguish a missing profile from incorrect usage. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 48a167a139a..32be11f4c95 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -422,6 +422,7 @@ "cli-resolve.test.ts": "cli", "cli-restart-health.test.ts": "cli", "cli-restore-back.test.ts": "cli", + "cli-route-policy-not-found.test.ts": "cli", "cli-start-auxiliary-bind.test.ts": "cli", "cli-start-journal-order.test.ts": "cli", "sibling-home-client-sync.test.ts": "cli", diff --git a/src/cli/route-policy.ts b/src/cli/route-policy.ts index 1d5e7809d75..8be8bee9b13 100644 --- a/src/cli/route-policy.ts +++ b/src/cli/route-policy.ts @@ -1,5 +1,6 @@ import { CliUsageError, + RuntimeApiError, printData, rejectArgs, runCliAction, @@ -46,7 +47,7 @@ async function show(argv: string[], deps: RuntimeApiDeps): Promise { rejectArgs(args, USAGE); const result = await runtimeRequest<{ profiles?: ProfileRow[] }>("/api/routing-profiles", {}, deps); const profile = (result.profiles ?? []).find(candidate => candidate.id === id); - if (!profile) throw new CliUsageError(`unknown routing profile: ${id}`, USAGE); + if (!profile) throw new RuntimeApiError(`unknown routing profile: ${id}`, 404, null); printData(profile, wantsJson); } diff --git a/structure/runtime.md b/structure/runtime.md index 714f0e144e5..163e23b75cd 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -53,7 +53,7 @@ this wire projection does not change the usage ledger. ## CLI readiness diagnostics -Catalog-derived reasoning-level diagnostics are escaped only at the human-output boundary, which `src/cli/runtime-api.ts` owns alongside the human/JSON print split. Every CLI path that prints a hub-supplied catalog value renders it there: the first-time refusal in `src/cli/connect.ts` and the connected `ocx sync` refusal in `src/cli/dispatch.ts`. C0/C1 controls, DEL, and Unicode line/paragraph separators print as visible hexadecimal escapes; structured status retains the exact reason, and a rendered failure keeps the domain error as its `cause`. The ready/unverified/incompatible classification and exit policy are unchanged. +Catalog-derived reasoning-level diagnostics are escaped only at the human-output boundary, which `src/cli/runtime-api.ts` owns alongside the human/JSON print split. Every CLI path that prints a hub-supplied catalog value renders it there: the first-time refusal in `src/cli/connect.ts` and the connected `ocx sync` refusal in `src/cli/dispatch.ts`. C0/C1 controls, DEL, and Unicode line/paragraph separators print as visible hexadecimal escapes; structured status retains the exact reason, and a rendered failure keeps the domain error as its `cause`. The ready/unverified/incompatible classification and exit policy are unchanged. `src/cli/route-policy.ts` distinguishes a missing profile (exit 4) from malformed `show` arguments (exit 2); an upstream failure propagates through the management error mapping. ## CLI resolve and stop contracts for embedding shells diff --git a/tests/cli/cli-route-policy-not-found.test.ts b/tests/cli/cli-route-policy-not-found.test.ts new file mode 100644 index 00000000000..0c815a011d3 --- /dev/null +++ b/tests/cli/cli-route-policy-not-found.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { handleRoutePolicyCommand } from "../../src/cli/route-policy"; + +describe("routing profile lookup exits", () => { + test.each([["missing", 4], ["present", 0]] as const)("returns resource status for %s", async (id, expected) => { + const out = spyOn(console, "log").mockImplementation(() => {}); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleRoutePolicyCommand(["show", id, "--json"], { baseUrl: "http://cli.test", fetchImpl: async () => Response.json({ profiles: [{ id: "present", model: "policy/present" }] }) })).toBe(expected); + if (expected === 4) expect(out).not.toHaveBeenCalled(); + } finally { out.mockRestore(); err.mockRestore(); } + }); + test("missing input is still a usage error without a request", async () => { + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleRoutePolicyCommand(["show", "--json"], { baseUrl: "http://cli.test", fetchImpl: async () => { throw new Error("unexpected request"); } })).toBe(2); + } finally { err.mockRestore(); } + }); + test("an upstream server failure does not become not-found", async () => { + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleRoutePolicyCommand(["show", "missing"], { baseUrl: "http://cli.test", fetchImpl: async () => Response.json({ error: "unavailable" }, { status: 503 }) })).toBe(1); + } finally { err.mockRestore(); } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 727a2556362..f02400f0096 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -430,6 +430,7 @@ "cli-resolve.test.ts": "cli", "cli-restart-health.test.ts": "cli", "cli-restore-back.test.ts": "cli", + "cli-route-policy-not-found.test.ts": "cli", "cli-start-auxiliary-bind.test.ts": "cli", "cli-start-journal-order.test.ts": "cli", "sibling-home-client-sync.test.ts": "cli", From f5a94dea180cfe4ffd8e566b2a4f1c81b7097f8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9A=B0=EC=B2=A0?= Date: Fri, 2 Oct 2026 18:39:10 +0900 Subject: [PATCH 2/2] fix: address review feedback for PR #6437 --- src/cli/route-policy.ts | 1 + structure/runtime.md | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cli/route-policy.ts b/src/cli/route-policy.ts index 8be8bee9b13..76afab9dbcc 100644 --- a/src/cli/route-policy.ts +++ b/src/cli/route-policy.ts @@ -39,6 +39,7 @@ async function list(argv: string[], deps: RuntimeApiDeps): Promise { ); } +/** Show an existing routing profile, distinguishing malformed usage from a missing record. */ async function show(argv: string[], deps: RuntimeApiDeps): Promise { const args = [...argv]; const id = args.shift(); diff --git a/structure/runtime.md b/structure/runtime.md index 163e23b75cd..5bcf7fee1d2 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -53,11 +53,11 @@ this wire projection does not change the usage ledger. ## CLI readiness diagnostics -Catalog-derived reasoning-level diagnostics are escaped only at the human-output boundary, which `src/cli/runtime-api.ts` owns alongside the human/JSON print split. Every CLI path that prints a hub-supplied catalog value renders it there: the first-time refusal in `src/cli/connect.ts` and the connected `ocx sync` refusal in `src/cli/dispatch.ts`. C0/C1 controls, DEL, and Unicode line/paragraph separators print as visible hexadecimal escapes; structured status retains the exact reason, and a rendered failure keeps the domain error as its `cause`. The ready/unverified/incompatible classification and exit policy are unchanged. `src/cli/route-policy.ts` distinguishes a missing profile (exit 4) from malformed `show` arguments (exit 2); an upstream failure propagates through the management error mapping. +Catalog-derived reasoning-level diagnostics are escaped only at the human-output boundary, which `src/cli/runtime-api.ts` owns alongside the human/JSON print split. Every CLI path that prints a hub-supplied catalog value renders it there: the first-time refusal in `src/cli/connect.ts` and the connected `ocx sync` refusal in `src/cli/dispatch.ts`. C0/C1 controls, DEL, and Unicode line/paragraph separators print as visible hexadecimal escapes; structured status retains the exact reason, and a rendered failure keeps the domain error as its `cause`. The ready/unverified/incompatible classification and exit policy are unchanged. ## CLI resolve and stop contracts for embedding shells -`ocx resolve` (`src/cli/resolve.ts`) is the machine surface a desktop shell asks instead of resolving the config home, the port, and liveness itself: the home comes from `src/config/paths.ts`, the effective port is the live listener's when the identity-checked `findLiveProxy` answers and the configured `config.port ?? 10100` otherwise, and the liveness verdict is that same module's output (pid, runtime-versus-config provenance, version, role). Config reads go through `readConfigDiagnostics`, not `loadConfig`: a missing file is defaults, but an invalid file exits 1 instead of being repaired to defaults, because a defaulted port is a guess the caller must refuse. Liveness is three-valued: when `findLiveProxy` returns null, resolve re-asks the endpoints with the updater's tri-state probe (`endpointsToProve` + `everyEndpointProvenDown` + `probeProxyLiveness`), and only a unanimous definitive "dead" becomes `absent-proven`; unknown exits 1 and never authorises a start. Discovery borrows `START_OWNERSHIP_LIVENESS`, the start path's ownership budget — the verdict feeds the shell's launch decision, so the cost of a false "nobody listening" is the duplicate proxy (#5004). The verb is in `skipsCodexShimAutoRestore`, so a read-only lookup never triggers a shim repair. Arguments are pre-parsed in `src/cli/root.ts` and exit 64 before any preflight side effect, the same ordering `ocx ready` obeys. `ocx-resolve/1` also reports `ownership` (`none`, `owned`, or `unknown`) and `takeover` (`supported` with protocol version, minimum CLI version and compatibility token, or `blocked` with a reason). A known liveness verdict still exits 0 when ownership is unknown, but takeover is blocked. An unreadable second service-state read blocks takeover instead of becoming an absent registration. `src/service/managing-cli.ts` observes the registered and selected PATH CLIs, including Windows PATHEXT order and file validation, before compatibility is offered. A `--version` probe is retried once on Windows — one transient failure must not read as an unobservable CLI — and a Windows `.CMD`/`.BAT` shim is invoked through a single verbatim-quoted `cmd /c` line, so a spaced executable path and spaced arguments both survive cmd's re-parse. +`ocx resolve` (`src/cli/resolve.ts`) is the machine surface a desktop shell asks instead of resolving the config home, the port, and liveness itself: the home comes from `src/config/paths.ts`, the effective port is the live listener's when the identity-checked `findLiveProxy` answers and the configured `config.port ?? 10100` otherwise, and the liveness verdict is that same module's output (pid, runtime-versus-config provenance, version, role). Config reads go through `readConfigDiagnostics`, not `loadConfig`: a missing file is defaults, but an invalid file exits 1 instead of being repaired to defaults, because a defaulted port is a guess the caller must refuse. Liveness is three-valued: when `findLiveProxy` returns null, resolve re-asks the endpoints with the updater's tri-state probe (`endpointsToProve` + `everyEndpointProvenDown` + `probeProxyLiveness`), and only a unanimous definitive "dead" becomes `absent-proven`; unknown exits 1 and never authorises a start. Discovery borrows `START_OWNERSHIP_LIVENESS`, the start path's ownership budget — the verdict feeds the shell's launch decision, so the cost of a false "nobody listening" is the duplicate proxy (#5004). The verb is in `skipsCodexShimAutoRestore`, so a read-only lookup never triggers a shim repair. Arguments are pre-parsed in `src/cli/root.ts` and exit 64 before any preflight side effect, the same ordering `ocx ready` obeys. `ocx-resolve/1` also reports `ownership` (`none`, `owned`, or `unknown`) and `takeover` (`supported` with protocol version, minimum CLI version and compatibility token, or `blocked` with a reason). A known liveness verdict still exits 0 when ownership is unknown, but takeover is blocked. An unreadable second service-state read blocks takeover instead of becoming an absent registration. `src/service/managing-cli.ts` observes the registered and selected PATH CLIs, including Windows PATHEXT order and file validation, before compatibility is offered. A `--version` probe is retried once on Windows — one transient failure must not read as an unobservable CLI — and a Windows `.CMD`/`.BAT` shim is invoked through a single verbatim-quoted `cmd /c` line, so a spaced executable path and spaced arguments both survive cmd's re-parse. `src/cli/route-policy.ts` distinguishes a missing profile (exit 4) from malformed `show` arguments (exit 2); an upstream failure propagates through the management error mapping. `ocx stop --json` is a reporting layer over the unchanged stop path. `src/cli/index.ts` threads a `StopRunRecord` through the existing receipt, drain, respawn-verification and restore flow, and `src/cli/stop-report.ts` maps the recorded facts plus the signals that already pick the exit code into one versioned document (`schema: "ocx-stop/1"`). With `--json` the human lines print on stderr and stdout carries only that document; exit codes 0/1/79/80 cross the process boundary unchanged. The desktop's opt-in guarded stop adds exact approved PID, endpoint, home, CLI version and compatibility-token expectations; plain `ocx stop` retains its ordinary behavior. Under the ownership mutation lease, `src/cli/stop-approval.ts` re-resolves them and `src/service/guarded-manager-target.ts` binds any installed manager's process tree to the approved PID before a stop action. A pre-action mismatch returns `approval-changed` without stopping, and the summary carries `detail` naming the single guard fact that failed — live PID/port/hostname/CLI drift, an unverifiable ownership claim, a blocked takeover, or the compatibility fingerprint — so a transient re-probe can be told from genuine drift in the caller's log. Immediately before any manager stop or direct PID signal, the CLI rechecks the same manager identity or proven absence; a change also returns `approval-changed`. A non-OpenCodex process replacing the OS job in the remaining instant before the manager command is a residual same-user risk outside the ownership lease. The guarded path stops the bound manager, or the approved PID directly when managers are proven absent, then uses a five-second shared deadline to verify PID exit, port availability and definitive endpoint absence. Only after settlement does a read-only manager probe require launchd `not-loaded` in both domains or systemd `inactive` with `MainPID=0`; the state is checked again before a success summary. Timeout, active or unreadable manager state returns terminal `manager-still-active`, even if the endpoint briefly refuses. Windows managers bind through `src/service/windows-guarded-manager.ts`: a Task Scheduler backend must prove a recognized OpenCodex registration against the bounded current-user SID/name lookup, a running task, and a wrapper ancestor carrying this home's canonical paths — presence and running state are read root-folder-scoped because every `/tn` operation resolves a bare name there and nowhere else, so a same-named task inside a task folder is a different registration neither the probe nor the stop can reach; the registered definition itself is read only on the running path, and a state or registration read that cannot answer re-probes presence so a task deleted between probes — which can respawn nothing — reads absent while a merely unreadable one stays unknown; an unreadable current identity leaves the manager unknown; a WinSW backend must prove this installation's binary and SCM service PID ancestry; either marks `childNeedsSeparateStop`, so the approved PID receives its own graceful signal only after a successful manager stop because `schtasks /end` does not reliably cascade to the wrapper's child (#764); a failed or unknown manager stop leaves the child untouched. Post-stop Windows inactivity means the task not running, no surviving wrapper process, and no started WinSW — never mere registration presence; a running-state read that cannot answer re-probes presence the same way, so a task deleted between probes reports inactive while a persisting one stays unknown. Every Windows manager command a verdict or stop depends on — schtasks, sc.exe, WinSW verbs — runs under a bounded `execFileSync` timeout so a wedged Task Scheduler or Service Control Manager cannot hang the guarded stop; a killed command classifies through the same fail-closed paths a nonzero exit would (the interactive `install /p` password prompt is the one deliberate exception). A `wscript.exe`, `cscript.exe` or `cmd.exe` whose command line CIM cannot read makes the state unprovable only when it is tied to the proxy: pre-stop, as an ancestor of the approved PID; post-stop, as the former bound manager process or a descendant of it. Unrelated unreadable shells, which a non-admin query returns for other users' and elevated processes, are not evidence either way. An unprovable Windows manager still blocks guarded takeover. The token checks snapshot consistency, not whether a person approved the desktop prompt. `src/cli/stop-restore.ts` owns the shared Codex/Grok restoration and its history-only, history-deferred, and other failure categories.