From 986b5e83cf0544497b6a2cadaac65611f207e47f 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 19:43:41 +0900 Subject: [PATCH 01/12] fix(cli): reject ignored capabilities arguments (carry #6429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries #6429 by @oocheol onto current dev as part of the oocheol CLI batch. Co-authored-by: 정우철 --- .../010_plan.md | 57 +++++++++++++++++++ .../src/content/docs/ko/reference/cli.md | 4 ++ docs-site/src/content/docs/reference/cli.md | 4 ++ scripts/test-layout/layout.json | 1 + src/cli/capabilities-command.ts | 5 +- structure/runtime.md | 2 +- tests/cli/cli-capabilities-arguments.test.ts | 25 ++++++++ tests/fixtures/test-layout-expected.json | 1 + 8 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 devlog/_plan/261002_oocheol_cli_batch_carry/010_plan.md create mode 100644 tests/cli/cli-capabilities-arguments.test.ts diff --git a/devlog/_plan/261002_oocheol_cli_batch_carry/010_plan.md b/devlog/_plan/261002_oocheol_cli_batch_carry/010_plan.md new file mode 100644 index 00000000000..9cfd259a7c9 --- /dev/null +++ b/devlog/_plan/261002_oocheol_cli_batch_carry/010_plan.md @@ -0,0 +1,57 @@ +# 261002 oocheol CLI batch carry — plan (wp1) + +Loop-spec: class C2 (eight small, independently reviewed CLI fixes plus one behavioral amendment). Mode HOTL lane, single work-phase wp1. +Tool/credential scope: local git, bun, gh (push this branch, open one PR; no merge, no comments on other PRs). +Write scope: src/cli/{capabilities-command,runtime-api,config-command,init,alias,observe,route-policy}.ts, src/lib/errors.ts, the eight new test files, docs-site reference/cli.md, reference/cli/agents.md, reference/cli/lifecycle.md (en, ko), structure/{runtime,config,dashboard-and-usage}.md and structure/transports/responses-wire-shapes.md (sentences the PRs touch), scripts/test-layout/layout.json, tests/fixtures/test-layout-expected.json. +Budget: one lane session; wall-clock bound ~3h including one hosted CI run. +Base: origin/dev a300b57c81. Branch: codex/carry-oocheol-cli-batch. + +## Architect consultation +Architect handle 01a0fc2f-188e-7632-8bd6-e5cee64ec6ab (gpt-6.1-sol, read-only). Decisions D1–D7 all ACCEPTED: +- D1 eight commits in order 6429, 6431, 6432, 6433, 6434, 6435, 6437, 6438; author 정우철 , Co-authored-by trailer. +- D2 port retry: keep parseInitPort; do/while around prompt.ask at src/cli/init.ts:197; stderr "Proxy port must be a whole decimal number from 1 to 65535. Please try again."; no exit code; no retry cap; EOF/SIGINT keep InitCancelledError (exit 1/130). +- D3 wizard test: invalid 10100oops then valid 10101, exit 0, config.json port 10101, two port prompts, consumed-output cursor; add invalid→EOF case (exit 1, no config). SIGINT case: invalid→SIGINT while retry prompt pending, exit 130, no config (skip Windows). +- D4 preserve exit contracts (64/4 capabilities; 2/4/1 shared); init port stays stricter than shared integer options. +- D5 docs: keep every appended section once in en+ko; reword #6433 docs/structure to "re-prompts". +- D6 no baseline caps on touched files; structure docs keep line counts (append in-line); run skill:surface:check without regeneration. +- D7 reject abort and retry caps. + +## File change map +| Commit | Files | Change | +|---|---|---| +| #6429 | capabilities-command.ts, tests/cli/cli-capabilities-arguments.test.ts, docs cli.md en/ko, structure/runtime.md, layout maps | as source | +| #6431 | runtime-api.ts, tests/cli/cli-integer-options.test.ts, docs cli.md en/ko, structure/config.md, layout maps | as source | +| #6432 | config-command.ts, tests/cli/cli-config-bom.test.ts, docs, structure/config.md, layout | as source | +| #6433 | init.ts, tests/cli/cli-init-port.test.ts, lifecycle.md en/ko, structure/runtime.md, layout | source + D2/D3 amendment, docs "re-prompts" | +| #6434 | alias.ts, tests/cli/cli-alias-json.test.ts, docs, structure/runtime.md, layout | as source | +| #6435 | observe.ts, tests/cli/cli-logs-request-id.test.ts, agents.md en/ko, structure/dashboard-and-usage.md, layout | as source | +| #6437 | route-policy.ts, tests/cli/cli-route-policy-not-found.test.ts, agents.md, structure/runtime.md, layout | as source | +| #6438 | src/lib/errors.ts, tests/lib/upstream-error-message-fallback.test.ts, agents.md, structure/transports/responses-wire-shapes.md, layout | as source | + +Conflict resolution (rehearsed with git apply --3way): docs cli.md (6431/6432/6434 after 6429), agents.md (6437/6438 after 6435), layout maps (6433). Resolve by keeping all sections in commit order and all layout entries. + +## Scope +IN: the eight behaviors, #6433 amendment, docs/structure wording. OUT: #6436, src/lib/sse-decoder.ts, merge, other PRs. + +## Acceptance (activation scenarios) +- c-1 each commit carries its regression test; activation: bun test of each new test file passes at final head. +- c-2 invalid port path: wizard subprocess answers 10100oops then 10101 → stderr contains "Please try again", exit 0, config.json.port 10101; invalid then EOF → exit 1, no config.json. +- c-3 rg counts each new heading exactly once in en and ko docs. +- c-4 verifiers (run at final head): bun run typecheck; bun test <8 new files> tests/service/init-eof.test.ts tests/test-layout*.test.ts; bun run test:changed; bun run structure:check; bun run skill:surface:check; bun run privacy:scan. +- c-5 push once; PR to dev with full template; one CI run green. + +## SoT sync +structure/*.md sentences listed above (owned docs for touched src areas); no new src area. + + +## Reflection (same architect, revision 2) +Verdict MISALIGNED with two gaps, both FOLDED: +1. D3 SIGINT: REQUIRED (not optional). tests/cli/cli-init-port.test.ts adds invalid→SIGINT while the retry prompt is pending (exit 130, no config), skipped on Windows like tests/service/init-eof.test.ts:225. +2. Ownership fan-out: C reviews every structure doc mapped to src/cli/ and src/lib/ (structure/INDEX.md:124,141) and updates only explanations the eight changes make stale (structure/AGENTS.md:49). structure:check is necessary but not sufficient. + + +## Audit round 1 (reviewer 01a0fc32-39d3-7aa0-82df-5c56d0ba5ae8, gpt-6.1-sol): GO-WITH-FIXES (blockers=2), both FOLDED +1. #6438 consumer effect (Medium): a blank primary error.message no longer hides response.error.message, so isEncryptedFunctionOutputRejection (src/server/responses/core-opaque-recovery.ts:109) and codexQuotaFailureMessage (src/server/responses/core-codex-account.ts:274) now see the fallback. This is the intended fix. FOLD: extend tests/lib/upstream-error-message-fallback.test.ts with consumer cases — blank primary + fallback rejection string → encrypted rejection true; blank primary + fallback quota text → quota message returned and classified as quota; blank primary + unrelated fallback → not quota; nonblank primary still wins over a conflicting fallback. relay.ts:266 code/type precedence uses its own candidate scan and is unchanged; one assertion that a blank primary message does not change the relay-selected code if cheaply reachable, else recorded as unaffected by inspection. +2. Docs validation (Medium): FOLD: C runs `cd docs-site && bun install --frozen-lockfile && bun run build` (docs-site/AGENTS.md:25-30); the PR also requires the exact-head "docs site build" CI job. +Main judgment: near-pass (both Medium blockers folded; no High/Critical). + diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index da0ed2b0248..498804fdc1a 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -40,3 +40,7 @@ Windows x64 설치 관측은 [`attest` 명령](/ko/reference/cli/agents/)을 참 `ocx --version`, `ocx -v`, `ocx version`은 스크립트가 읽기 좋은 한 줄짜리 버전을 출력하고 종료합니다. 일반 도움말에는 두 개의 디스패치 대상이 의도적으로 빠져 있습니다. `__refresh-version [preview]`는 분리된 프로세스에서 업데이트 알림 캐시를 새로 고치고, `__gui-update-worker [latest|preview] [restart]`는 대시보드 업데이트 작업을 실행합니다. 이들은 구현 세부 사항일 뿐이며 안정적인 사용자 명령이 아닙니다. 대시보드는 worker PID를 기록하고, worker가 죽은 활성 작업은 복구하며, PID가 없는 오래된 활성 기록은 10분 뒤 오래된 것으로 취급하고, 살아 있는 worker를 동시 업데이트로부터 보호합니다. + +## Capability argument validation + +`ocx capabilities`는 알 수 없는 인수, 반복된 플래그, 빈 `--route` 값을 종료 코드 64로 거부합니다. 유효한 경로에 등록된 기능이 없으면 종료 코드 4를 반환합니다. diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index dbfcd400066..bf8816194e1 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -156,3 +156,7 @@ refreshes the update-notification cache in a detached process, and implementation details, not stable user-facing commands. The dashboard records the worker PID, recovers an active job whose worker died, treats older PID-less active records as stale after ten minutes, and protects a live worker from concurrent updates. + +## Capability argument validation + +`ocx capabilities` rejects unknown arguments, repeated flags and blank `--route` values with exit 64. A valid route with no declared capability exits 4. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d7e11c09315..db408bfa300 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -391,6 +391,7 @@ "cli-account-pool-verbs.test.ts": "cli", "cli-account-threshold.test.ts": "cli", "cli-account.test.ts": "cli", + "cli-capabilities-arguments.test.ts": "cli", "cli-capabilities.test.ts": "cli", "cli-api-protocols.test.ts": "cli", "cli-catalog-prewarm.test.ts": "cli", diff --git a/src/cli/capabilities-command.ts b/src/cli/capabilities-command.ts index 59bee22d2f5..56f148357bd 100644 --- a/src/cli/capabilities-command.ts +++ b/src/cli/capabilities-command.ts @@ -45,14 +45,15 @@ function renderHuman(caps: readonly Capability[], includeHead: boolean): void { } } +/** Render declared capabilities; reject invalid arguments with 64 and report unmatched routes with 4. */ export async function runCapabilities(argv: string[]): Promise { const args = [...argv]; const json = takeFlag(args, "--json"); const mutatingOnly = takeFlag(args, "--mutating-only"); const route = takeValueFlag(args, "--route"); - if (route !== undefined && route.length === 0) { - console.error("Usage: ocx capabilities --route "); + if (args.length > 0 || (route !== undefined && route.trim().length === 0)) { + console.error("Usage: ocx capabilities [--json] [--mutating-only] [--route ]"); return 64; } diff --git a/structure/runtime.md b/structure/runtime.md index 6974c90120b..7479f9cfe1f 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/capabilities-command.ts` rejects leftover positional arguments, unknown or repeated flags, and blank route filters with exit 64 before emitting a capability index; a valid unmatched route remains exit 4. ## CLI resolve and stop contracts for embedding shells diff --git a/tests/cli/cli-capabilities-arguments.test.ts b/tests/cli/cli-capabilities-arguments.test.ts new file mode 100644 index 00000000000..ee6b4d9d1cd --- /dev/null +++ b/tests/cli/cli-capabilities-arguments.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { runCapabilities } from "../../src/cli/capabilities-command"; + +describe("capabilities argument validation", () => { + test.each([ + ["--mutating-onyl"], ["extra"], ["--json", "--json"], + ["--route", "/api/logs", "--route", "/api/usage"], + ["--route", "/not-a-route", "--typo"], ["--route", ""], + ].map(args => [args]))("rejects %j without a successful payload", async args => { + const out = spyOn(console, "log").mockImplementation(() => {}); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await runCapabilities(args)).toBe(64); + expect(out).not.toHaveBeenCalled(); + expect(err).toHaveBeenCalled(); + } finally { out.mockRestore(); err.mockRestore(); } + }); + test("keeps valid no-match distinct from invalid usage", async () => { + const out = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await runCapabilities(["--route", "/not-a-route", "--json"])).toBe(4); + expect(JSON.parse(String(out.mock.calls[0]![0])).capabilities).toEqual([]); + } finally { out.mockRestore(); } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e2b9a1af730..dce49f9fea7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -399,6 +399,7 @@ "cli-account-pool-verbs.test.ts": "cli", "cli-account-threshold.test.ts": "cli", "cli-account.test.ts": "cli", + "cli-capabilities-arguments.test.ts": "cli", "cli-capabilities.test.ts": "cli", "cli-api-protocols.test.ts": "cli", "cli-catalog-prewarm.test.ts": "cli", From a7bed3436ad762ca307156dfb6b32123fc075376 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 19:43:55 +0900 Subject: [PATCH 02/12] fix(cli): reject ambiguous and unsafe integer options (carry #6431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries #6431 by @oocheol onto current dev as part of the oocheol CLI batch. Co-authored-by: 정우철 --- docs-site/src/content/docs/ko/reference/cli.md | 3 +++ docs-site/src/content/docs/reference/cli.md | 3 +++ scripts/test-layout/layout.json | 1 + src/cli/runtime-api.ts | 7 +++++-- structure/config.md | 2 +- tests/cli/cli-integer-options.test.ts | 18 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 7 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 tests/cli/cli-integer-options.test.ts diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index 498804fdc1a..b94b1f8ec53 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -44,3 +44,6 @@ Windows x64 설치 관측은 [`attest` 명령](/ko/reference/cli/agents/)을 참 ## Capability argument validation `ocx capabilities`는 알 수 없는 인수, 반복된 플래그, 빈 `--route` 값을 종료 코드 64로 거부합니다. 유효한 경로에 등록된 기능이 없으면 종료 코드 4를 반환합니다. +## Integer option values + +`--limit` 같은 정수 옵션은 안전하게 표현 가능한 십진 정수를 받습니다. `1_000`, `1,000` 같은 숫자 구분자는 허용합니다. 빈 값, 16진수, 지수 표기 및 소수는 요청을 보내기 전에 거부합니다. diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index bf8816194e1..45cb8b15c54 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -160,3 +160,6 @@ minutes, and protects a live worker from concurrent updates. ## Capability argument validation `ocx capabilities` rejects unknown arguments, repeated flags and blank `--route` values with exit 64. A valid route with no declared capability exits 4. +## Integer option values + +Integer options such as `--limit` require decimal whole numbers within JavaScript safe-integer bounds. Digit separators such as `1_000` and `1,000` are accepted. Empty values, hexadecimal, exponent notation and fractions are rejected before a request is sent. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index db408bfa300..55376f653ab 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -412,6 +412,7 @@ "cli-head.test.ts": "cli", "cli-headless-parity.test.ts": "cli", "cli-help.test.ts": "cli", + "cli-integer-options.test.ts": "cli", "cli-json-contract.test.ts": "cli", "cli-management-auth.test.ts": "cli", "cli-models-free-only.test.ts": "cli", diff --git a/src/cli/runtime-api.ts b/src/cli/runtime-api.ts index fae907ee1b7..570e60680a1 100644 --- a/src/cli/runtime-api.ts +++ b/src/cli/runtime-api.ts @@ -200,6 +200,7 @@ export function takeOption(args: string[], flag: string): string | undefined { return value; } +/** Consume one boolean option using the supported on/off synonyms; reject other values. */ export function takeBooleanOption(args: string[], flag: string): boolean | undefined { const raw = takeOption(args, flag); if (raw === undefined) return undefined; @@ -208,11 +209,13 @@ export function takeBooleanOption(args: string[], flag: string): boolean | undef throw new CliUsageError(`${flag} must be on or off`); } +/** Parse a decimal safe integer, allowing separators only between digits. */ export function takeIntegerOption(args: string[], flag: string, options: { min?: number } = {}): number | undefined { const raw = takeOption(args, flag); if (raw === undefined) return undefined; - const value = Number(raw.replace(/[_,]/g, "")); - if (!Number.isInteger(value) || value < (options.min ?? Number.MIN_SAFE_INTEGER)) { + const decimal = raw.trim(); + const value = /^[+-]?\d+(?:[_,]\d+)*$/.test(decimal) ? Number(decimal.replace(/[_,]/g, "")) : NaN; + if (!Number.isSafeInteger(value) || value < (options.min ?? Number.MIN_SAFE_INTEGER)) { throw new CliUsageError(`${flag} must be an integer${options.min !== undefined ? ` >= ${options.min}` : ""}`); } return value; diff --git a/structure/config.md b/structure/config.md index 984432d6bb2..079cdc88d08 100644 --- a/structure/config.md +++ b/structure/config.md @@ -528,7 +528,7 @@ it owns the refusal as well as the request. `runtimeBaseUrl` resolves the live l states that the listener serves only the machine routes, points custom-model and other management edits at the hub the machine is connected to, and gives the on-machine alternative: edit `customModels` in `config.json`, then run `ocx sync`. The status is also the honest exit code, since -`runCliAction` maps 404 to exit 4 and would otherwise report a missing record. +`runCliAction` maps 404 to exit 4 and would otherwise report a missing record. Shared integer options in `src/cli/runtime-api.ts` accept decimal safe integers with optional comma/underscore separators between digits and apply each caller's minimum. Blank, hexadecimal, exponential, fractional and unsafe values fail before management requests. A 404 body carrying both `method` and `path` is rendered as the route that listener does not serve, so any not-served-here answer stays legible rather than printing a bare token. `ocx models edit` in diff --git a/tests/cli/cli-integer-options.test.ts b/tests/cli/cli-integer-options.test.ts new file mode 100644 index 00000000000..a735fe87312 --- /dev/null +++ b/tests/cli/cli-integer-options.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; +import { CliUsageError, takeIntegerOption } from "../../src/cli/runtime-api"; + +describe("CLI integer options", () => { + test.each(["", " ", ",", "_", "0x10", "1e3", "1.0", "1.5", "1__2", "1,,2", "_12", "12_", "9007199254740993", "-9007199254740993"])("rejects %j", raw => { + expect(() => takeIntegerOption(["--limit", raw], "--limit")).toThrow(CliUsageError); + }); + test.each([["0", 0], ["-12", -12], ["+12", 12], [" 42 ", 42], ["1_000", 1000], ["1,000", 1000], ["9007199254740991", Number.MAX_SAFE_INTEGER]] as const)("accepts %s", (raw, expected) => { + expect(takeIntegerOption(["--limit", raw], "--limit")).toBe(expected); + }); + test("keeps per-command minimum and missing-option semantics", () => { + expect(() => takeIntegerOption(["--limit", "0"], "--limit", { min: 1 })).toThrow(CliUsageError); + expect(takeIntegerOption([], "--limit")).toBeUndefined(); + const args = ["--limit", "20", "--json"]; + expect(takeIntegerOption(args, "--limit", { min: 1 })).toBe(20); + expect(args).toEqual(["--json"]); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index dce49f9fea7..17a315c72ab 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -420,6 +420,7 @@ "cli-head.test.ts": "cli", "cli-headless-parity.test.ts": "cli", "cli-help.test.ts": "cli", + "cli-integer-options.test.ts": "cli", "cli-json-contract.test.ts": "cli", "cli-management-auth.test.ts": "cli", "cli-models-free-only.test.ts": "cli", From 2c7930413c7a766179778c4bf19cda77b15c9315 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 19:43:56 +0900 Subject: [PATCH 03/12] fix(cli): accept UTF-8 BOM in imported config files (carry #6432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries #6432 by @oocheol onto current dev as part of the oocheol CLI batch. Co-authored-by: 정우철 --- .../src/content/docs/ko/reference/cli.md | 3 + docs-site/src/content/docs/reference/cli.md | 3 + scripts/test-layout/layout.json | 1 + src/cli/config-command.ts | 4 +- structure/config.md | 2 +- tests/cli/cli-config-bom.test.ts | 58 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 7 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 tests/cli/cli-config-bom.test.ts diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index b94b1f8ec53..20928ef2455 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -47,3 +47,6 @@ Windows x64 설치 관측은 [`attest` 명령](/ko/reference/cli/agents/)을 참 ## Integer option values `--limit` 같은 정수 옵션은 안전하게 표현 가능한 십진 정수를 받습니다. `1_000`, `1,000` 같은 숫자 구분자는 허용합니다. 빈 값, 16진수, 지수 표기 및 소수는 요청을 보내기 전에 거부합니다. +## Windows JSON configuration files + +`ocx config validate `과 `ocx config import --yes`는 선행 BOM이 있는 UTF-8 JSON도 읽습니다. 표준 입력(`-`)에도 적용되므로 Windows PowerShell이나 편집기의 UTF-8 내보내기를 사용할 수 있습니다. UTF-16은 지원하지 않습니다. diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 45cb8b15c54..1c41d7a74f2 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -163,3 +163,6 @@ minutes, and protects a live worker from concurrent updates. ## Integer option values Integer options such as `--limit` require decimal whole numbers within JavaScript safe-integer bounds. Digit separators such as `1_000` and `1,000` are accepted. Empty values, hexadecimal, exponent notation and fractions are rejected before a request is sent. +## Windows JSON configuration files + +`ocx config validate ` and `ocx config import --yes` accept UTF-8 JSON with or without a leading BOM, including stdin (`-`). This supports UTF-8 exports from Windows PowerShell and editors. UTF-16 input is not accepted. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 55376f653ab..ce95ecef51f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -400,6 +400,7 @@ "cli-codex-log-guard-protection.test.ts": "cli", "cli-codex-log-guard.test.ts": "cli", "cli-companion.test.ts": "cli", + "cli-config-bom.test.ts": "cli", "cli-config-command.test.ts": "cli", "cli-config-show-client.test.ts": "cli", "cli-connect-readiness.test.ts": "cli", diff --git a/src/cli/config-command.ts b/src/cli/config-command.ts index 0c7cda8c674..faf53dcd962 100644 --- a/src/cli/config-command.ts +++ b/src/cli/config-command.ts @@ -159,14 +159,16 @@ function setPath(root: Record, path: string, value: unknown, re else current[leaf] = value; } +/** Interpret config-set input as JSON when valid, otherwise preserve it as a string. */ function parseValue(raw: string): unknown { try { return JSON.parse(raw); } catch { return raw; } } +/** Accept one UTF-8 BOM from Windows JSON files or piped input. */ function loadInput(path: string): unknown { const raw = path === "-" ? readFileSync(0, "utf8") : readFileSync(path, "utf8"); - try { return JSON.parse(raw); } + try { return JSON.parse(raw.replace(/^\uFEFF/, "")); } catch { throw new CliUsageError(`invalid JSON in ${path}`); } } diff --git a/structure/config.md b/structure/config.md index 079cdc88d08..25839ef3390 100644 --- a/structure/config.md +++ b/structure/config.md @@ -28,7 +28,7 @@ the [source-owned credential contract](codex-home.md#orca-source-owned-account-i ## Config surface -`src/config/schema/compaction-recovery.ts` strictly validates opt-in `compactionRecovery`; invalid disk values disable it with a warning, while candidate writes reject them. `src/config/schema/blocked-model-redirects.ts` applies the same read-degrade/write-reject boundary to malformed `blockedModelRedirects` maps. The [failure-only contract](transports/responses-failover.md) leaves provider identity, accounts and client compaction unchanged. +`src/config/schema/compaction-recovery.ts` strictly validates opt-in `compactionRecovery`; invalid disk values disable it with a warning, while candidate writes reject them. `src/config/schema/blocked-model-redirects.ts` applies the same read-degrade/write-reject boundary to malformed `blockedModelRedirects` maps. The [failure-only contract](transports/responses-failover.md) leaves provider identity, accounts and client compaction unchanged. `src/cli/config-command.ts` accepts one leading UTF-8 BOM when parsing validate/import input from a file or stdin. JSON syntax and schema validation still run before persistence; BOM characters inside string values remain data. `skills.catalog_refresh` in the proxy JSON configuration accepts `per_session` (the runtime default when absent) or `per_turn`. The former retains received skills instructions for a conversation; the latter passes through the current catalog. This is separate from Codex's `skills.include_instructions` TOML switch and does not change the live dashboard probe. See the [Responses snapshot contract](transports/responses.md#responses-httpsse). diff --git a/tests/cli/cli-config-bom.test.ts b/tests/cli/cli-config-bom.test.ts new file mode 100644 index 00000000000..26ed5057247 --- /dev/null +++ b/tests/cli/cli-config-bom.test.ts @@ -0,0 +1,58 @@ +import { expect, spyOn, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleConfigCommand } from "../../src/cli/config-command"; +import { flushConfigDirHardeningAndReaps } from "../../src/config/paths"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath, repoRoot } from "../helpers/repo-root"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +test("validate and import accept a Windows UTF-8 BOM without rewriting string data", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-bom-")); + const previous = process.env.OPENCODEX_HOME; + const previousExit = process.exitCode; + process.env.OPENCODEX_HOME = root; + const out = spyOn(console, "log").mockImplementation(() => {}); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + const input = join(root, "input.json"); + const config = { port: 10100, providers: { local: { adapter: "openai-chat", baseUrl: "https://example.test/v1", note: "kept\uFEFFinside" } }, defaultProvider: "local" }; + writeFileSync(input, "\uFEFF" + JSON.stringify(config)); + expect(await handleConfigCommand(["validate", input, "--json"])).toBe(0); + expect(JSON.parse(String(out.mock.calls.at(-1)![0]))).toMatchObject({ ok: true }); + expect(await handleConfigCommand(["import", input, "--yes", "--json"])).toBe(0); + const persisted = readFileSync(join(root, "config.json"), "utf8"); + expect(JSON.parse(persisted).providers.local.note).toBe("kept\uFEFFinside"); + writeFileSync(input, "\uFEFF{ invalid"); + expect(await handleConfigCommand(["import", input, "--yes"])).not.toBe(0); + expect(readFileSync(join(root, "config.json"), "utf8")).toBe(persisted); + } finally { + out.mockRestore(); err.mockRestore(); process.exitCode = previousExit; + await flushConfigDirHardeningAndReaps(root); + if (previous === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previous; + removeTreeWithRetry(root); + } +}); + +test.each(["validate", "import"])("accepts a BOM on real config %s stdin", action => { + const root = mkdtempSync(join(tmpdir(), "ocx-bom-stdin-")); + const codexHome = join(root, "codex"); + mkdirSync(codexHome); + const config = { port: 10100, providers: { local: { adapter: "openai-chat", baseUrl: "https://example.test/v1", note: "kept\uFEFFinside" } }, defaultProvider: "local" }; + try { + const args = [repoPath("src", "cli", "index.ts"), "config", action, "-", "--json"]; + if (action === "import") args.push("--yes"); + const result = spawnSync(process.execPath, args, { + cwd: repoRoot(), env: { ...process.env, OPENCODEX_HOME: root, CODEX_HOME: codexHome }, + input: "\uFEFF" + JSON.stringify(config), encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).ok).toBe(true); + if (action === "import") { + expect(JSON.parse(readFileSync(join(root, "config.json"), "utf8")).providers.local.note).toBe("kept\uFEFFinside"); + } + } finally { removeTreeWithRetry(root); } +}, SPAWN_BUDGET_MS); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 17a315c72ab..b9a2870d962 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -408,6 +408,7 @@ "cli-codex-log-guard-protection.test.ts": "cli", "cli-codex-log-guard.test.ts": "cli", "cli-companion.test.ts": "cli", + "cli-config-bom.test.ts": "cli", "cli-config-command.test.ts": "cli", "cli-config-show-client.test.ts": "cli", "cli-connect-readiness.test.ts": "cli", From 7225e2ad0987e7e94014b51d6a48d93ae70c5c20 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 19:43:57 +0900 Subject: [PATCH 04/12] fix(cli): validate setup ports before saving config (carry #6433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries #6433 by @oocheol onto current dev as part of the oocheol CLI batch. Co-authored-by: 정우철 --- .../docs/ko/reference/cli/lifecycle.md | 4 ++ .../content/docs/reference/cli/lifecycle.md | 4 ++ scripts/test-layout/layout.json | 1 + src/cli/init.ts | 16 +++++- structure/runtime.md | 2 +- tests/cli/cli-init-port.test.ts | 55 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 7 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 tests/cli/cli-init-port.test.ts diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 0d3f98f1010..17c8b644eea 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -457,3 +457,7 @@ npm에 게시하면 사용할 수 있게 됩니다. ## Remote Hub 클라이언트 라이프사이클 `ocx connect --pairing-code-stdin`, `ocx connect status`, `ocx sync`, `ocx connect rotate --pairing-code-stdin`을 사용합니다. `ocx disconnect`는 오프라인에서도 로컬 상태를 복원하지만 허브 키는 폐기하지 않습니다. 연결 중에는 `ocx connect revoke --admin-token-stdin`으로 저장된 `apiKeyId`를 폐기할 수 있고, 연결을 끊은 뒤에는 허브의 **Integrations → API Keys**를 사용해야 합니다. 비밀값은 stdin으로만 전달하고 argv에 넣지 마세요. + +## Setup port validation + +`ocx init`의 포트는 1–65535 범위의 십진 정수입니다. Enter만 누르면 10100을 사용합니다. `0`, `10100oops`, `1.5` 같은 잘못된 값은 임의로 바꾸거나 자르지 않고 설정 저장 전에 중단합니다. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index af2f6b7d713..87e4c0888d5 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -854,3 +854,7 @@ publishes them to npm. ## Remote Hub client lifecycle Use `ocx connect --pairing-code-stdin`, `ocx connect status`, `ocx sync`, and `ocx connect rotate --pairing-code-stdin`. The initial catalog download fails after five seconds without incoming bytes, but active transfers may run longer; use `--catalog-timeout ` (1–120) to override that inactivity window. `ocx disconnect` restores local state offline and does not revoke the hub key. While connected only, `ocx connect revoke --admin-token-stdin` revokes the persisted `apiKeyId`; after disconnect use the hub's **Integrations → API Keys** page. Secrets are stdin-only and never belong in argv. + +## Setup port validation + +`ocx init` accepts TCP ports 1–65535 as decimal whole numbers. Press Enter to use 10100. Invalid input stops setup before writing config; values such as `0`, `10100oops` and `1.5` are not silently replaced or truncated. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ce95ecef51f..dea3103cb51 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -414,6 +414,7 @@ "cli-headless-parity.test.ts": "cli", "cli-help.test.ts": "cli", "cli-integer-options.test.ts": "cli", + "cli-init-port.test.ts": "cli", "cli-json-contract.test.ts": "cli", "cli-management-auth.test.ts": "cli", "cli-models-free-only.test.ts": "cli", diff --git a/src/cli/init.ts b/src/cli/init.ts index e3ae5155358..e1b0cd82024 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -11,6 +11,14 @@ import { deriveInitProviders } from "../providers/derive"; import { pinSponsorsWithinKind } from "../providers/sponsor-order"; import type { OcxConfig, OcxProviderConfig } from "../types"; +/** Parse a setup TCP port; only an empty answer selects the default. */ +export function parseInitPort(raw: string): number | null { + const text = raw.trim(); + if (!text) return 10100; + const value = /^\d+$/.test(text) ? Number(text) : NaN; + return Number.isInteger(value) && value >= 1 && value <= 65535 ? value : null; +} + class InitCancelledError extends Error { constructor(readonly exitCode: 1 | 130) { super(exitCode === 130 ? "Setup cancelled." : "stdin reached EOF while waiting for input. Re-run `ocx init` in an interactive terminal."); @@ -111,6 +119,7 @@ export function cleanupOpenAiTierBackupAfterInit(configPath = getConfigPath()): } catch { /* cleanup is best-effort; never block init on backup housekeeping */ } } +/** Create a first configuration interactively, preserving existing files and refusing invalid input. */ export async function runInit(): Promise { const initial = observeInitialConfigState(); if (initial === "exists") { @@ -195,7 +204,12 @@ export async function runInit(): Promise { } const portStr = await prompt.ask("\nProxy port [10100]: "); - const port = parseInt(portStr, 10) || 10100; + const port = parseInitPort(portStr); + if (port === null) { + console.error("Proxy port must be a whole decimal number from 1 to 65535. No config was saved."); + process.exitCode = 1; + return; + } initializeProviderModelSelection(providerName, providerConfig); const config: OcxConfig = { diff --git a/structure/runtime.md b/structure/runtime.md index 7479f9cfe1f..67075cf79fb 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -124,7 +124,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, lazy-runs `bun/install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. The exact `system codex-cli-update` inspection namespace skips both boot repair and lazy Bun installation; missing runtime support fails closed instead of mutating state. | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | | `src/lib/plain-data.ts` | Detached copies for a consumer that must not observe later edits. Descriptor-based reads, including array elements, so an accessor is refused rather than invoked; refuses cycles, functions, class instances and anything else JSON could not have produced, and returns a copy-or-refusal union rather than degrading silently. Symbol-keyed process bookkeeping is skipped. | -| `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version, because the replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full command surface is `src/cli/help.ts`; this table names the groups, not every verb. After help/version early exits, ordinary commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `system codex-cli-update` is the deliberate read-only exception and suppresses auto-restore for its whole namespace, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). | +| `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version, because the replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full command surface is `src/cli/help.ts`; this table names the groups, not every verb. After help/version early exits, ordinary commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `system codex-cli-update` is the deliberate read-only exception and suppresses auto-restore for its whole namespace, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input exits unsuccessfully without saving. | | `src/server/index.ts` | Bun server entrypoint: `startServer`, `/v1/responses` HTTP + WebSocket routing (compact handled before generic Responses), exact `POST /v1/images/generations` and `POST /v1/images/edits` routing, `/v1/models`, the Anthropic-shaped `/v1/messages` and OpenAI-shaped `/v1/chat/completions` compatibility surfaces, the Live/Realtime surface, the hosted-search relay, artifact serving, `/healthz`, the `/api/*` auth gate, the `/v1/*` JSON 404 guard, GUI fallback, the opt-in loopback-only hub-management listener, and facade re-exports for split server modules. The route table itself is built by `src/server/index/serve-options.ts`; this entry file owns the listener and the startup transaction. | | `src/server/images.ts` | Standalone Images data plane: default OpenAI or explicit custom-provider selection, Codex account affinity, bounded opaque request relay, single-attempt upstream fetch, pool health recording, and safe response/cancellation relay. | | `src/server/audio-transcriptions.ts` | Standalone multipart transcription; audio-specific key admission, bounded upload/response, stored OpenAI credential resolution and lease-bound cancellation. See [audio contracts](data-planes/inbound-compat.md#standalone-file-transcription). | diff --git a/tests/cli/cli-init-port.test.ts b/tests/cli/cli-init-port.test.ts new file mode 100644 index 00000000000..ca96bfeeb14 --- /dev/null +++ b/tests/cli/cli-init-port.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import * as init from "../../src/cli/init"; +import { existsSync, mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoPath, repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +describe("setup port validation", () => { + test.each([["", 10100], [" ", 10100], ["1", 1], ["65535", 65535], [" 10101 ", 10101]] as const)("accepts %j", (raw, expected) => { + expect(init.parseInitPort(raw)).toBe(expected); + }); + test.each(["0", "-1", "65536", "10100oops", "1.5", "1e3", "0x100", "oops", "9007199254740993"])("rejects %j before publication", raw => { + expect(init.parseInitPort(raw)).toBeNull(); + }); + test("the actual wizard refuses a truncated port before writing config", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-invalid-port-")); + mkdirSync(join(home, "native")); + const proc = Bun.spawn([process.execPath, "--eval", 'import {runInit} from ' + JSON.stringify(repoPath("src", "cli", "init.ts")) + '; await runInit();'], { + cwd: repoRoot(), env: { ...process.env, OPENCODEX_HOME: home, CODEX_HOME: join(home, "native"), HOME: home, USERPROFILE: home, + APPDATA: join(home, "appdata"), LOCALAPPDATA: join(home, "localappdata"), XDG_CONFIG_HOME: join(home, "xdg") }, + stdin: "pipe", stdout: "pipe", stderr: "pipe", + }); + const stderr = new Response(proc.stderr).text(); + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + const answers = [ + ["Select default provider (number):", "999"], ["Provider name:", "port-fixture"], + ["Base URL (e.g. http://localhost:11434/v1):", "https://example.test/v1"], + ["Adapter [openai-chat]:", ""], ["API key (optional):", ""], ["Default model:", "fixture"], + ["Proxy port [10100]:", "10100oops"], ["Inject into Codex config.toml? [Y/n]:", "n"], + ["Install Codex autostart shim? [Y/n]:", "n"], + ]; + let output = "", answered = 0; + const timer = setTimeout(() => proc.kill(), 20_000); + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + const next = answers[answered]; + if (next && output.includes(next[0]!)) { + proc.stdin.write(next[1] + "\n"); await proc.stdin.flush(); answered++; + } + } + expect({ answered, stderr: await stderr, output }).toMatchObject({ answered: 7 }); + expect(await proc.exited).toBe(1); + expect(await stderr).toContain("Proxy port must be"); + expect(existsSync(join(home, "config.json"))).toBe(false); + } finally { + clearTimeout(timer); if (proc.exitCode === null) proc.kill(); + await proc.exited; reader.releaseLock(); removeTreeWithRetry(home); + } + }, 30_000); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b9a2870d962..cbfabfca1f4 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -422,6 +422,7 @@ "cli-headless-parity.test.ts": "cli", "cli-help.test.ts": "cli", "cli-integer-options.test.ts": "cli", + "cli-init-port.test.ts": "cli", "cli-json-contract.test.ts": "cli", "cli-management-auth.test.ts": "cli", "cli-models-free-only.test.ts": "cli", From f926ec951ae564f2d3fe8e86c3f63ddd04948dc5 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 19:43:57 +0900 Subject: [PATCH 05/12] fix(cli): support JSON output on the default alias command (carry #6434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries #6434 by @oocheol onto current dev as part of the oocheol CLI batch. Co-authored-by: 정우철 --- .../src/content/docs/ko/reference/cli.md | 3 +++ docs-site/src/content/docs/reference/cli.md | 3 +++ scripts/test-layout/layout.json | 1 + src/cli/alias.ts | 3 ++- structure/runtime.md | 2 +- tests/cli/cli-alias-json.test.ts | 27 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 7 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 tests/cli/cli-alias-json.test.ts diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index 20928ef2455..a62cddb8891 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -50,3 +50,6 @@ Windows x64 설치 관측은 [`attest` 명령](/ko/reference/cli/agents/)을 참 ## Windows JSON configuration files `ocx config validate `과 `ocx config import --yes`는 선행 BOM이 있는 UTF-8 JSON도 읽습니다. 표준 입력(`-`)에도 적용되므로 Windows PowerShell이나 편집기의 UTF-8 내보내기를 사용할 수 있습니다. UTF-16은 지원하지 않습니다. +## Default alias listing + +`ocx alias --json`은 `ocx alias list --json`과 같습니다. `--json`은 명시한 alias 작업의 앞이나 뒤에 둘 수 있습니다. diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 1c41d7a74f2..1337d46451f 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -166,3 +166,6 @@ Integer options such as `--limit` require decimal whole numbers within JavaScrip ## Windows JSON configuration files `ocx config validate ` and `ocx config import --yes` accept UTF-8 JSON with or without a leading BOM, including stdin (`-`). This supports UTF-8 exports from Windows PowerShell and editors. UTF-16 input is not accepted. +## Default alias listing + +`ocx alias --json` is equivalent to `ocx alias list --json`. The output flag can precede or follow an explicit alias action. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index dea3103cb51..f00f36d42e5 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -129,6 +129,7 @@ "anthropic-account-pause.test.ts": "adapters/anthropic", "anthropic-account-threshold.test.ts": "adapters/anthropic", "anthropic-combo-account-cooldown.test.ts": "adapters/anthropic", + "cli-alias-json.test.ts": "cli", "cli-anthropic-account-threshold.test.ts": "cli", "anthropic-model-routes.test.ts": "adapters/anthropic", "anthropic-agentrouter-language-framing.test.ts": "adapters/anthropic", diff --git a/src/cli/alias.ts b/src/cli/alias.ts index 462d98e2299..4680f21689b 100644 --- a/src/cli/alias.ts +++ b/src/cli/alias.ts @@ -12,10 +12,11 @@ function selector(value: string): { provider: string; model?: string } { return slash < 0 ? { provider: value } : { provider: value.slice(0, slash), model: value.slice(slash + 1) }; } +/** Manage model aliases, consuming JSON output selection before the default list action. */ export async function handleAliasCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { const args = [...argv]; - const action = (args.shift() ?? "list").toLowerCase(); const wantsJson = takeFlag(args, "--json"); + const action = (args.shift() ?? "list").toLowerCase(); if (action === "list") { rejectArgs(args, USAGE); const result = await runtimeRequest>("/api/aliases", {}, deps); diff --git a/structure/runtime.md b/structure/runtime.md index 67075cf79fb..b53b36f920d 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -14,7 +14,7 @@ evidence remain late and cannot widen a captured static limit. Virtual models are the sole model-identity transition: the ordinary and compact paths preserve the selected public id in diagnostics, rewrite `route.modelId` to the upstream wire id, and atomically replace `route.staticPolicy` before adapter or capability decisions continue. Model aliases are -resolved before the route result is built, so their policy is already keyed by the native wire id. `src/router.ts` applies bare `blockedModelRedirects` keys after this resolution; ordinary targets substitute one upstream model id on the selected provider/account, including slash-valued targets, while only targets explicitly naming a different configured provider reroute. Qualified source keys take precedence only for those cross-provider targets. A cross-provider redirect sets `RouteResult.credentialDomainRewrite`, which Responses and Chat header scoping treat like combo/policy routes, so a caller credential for the source route is never forwarded. +resolved before the route result is built, so their policy is already keyed by the native wire id. `src/router.ts` applies bare `blockedModelRedirects` keys after this resolution; ordinary targets substitute one upstream model id on the selected provider/account, including slash-valued targets, while only targets explicitly naming a different configured provider reroute. Qualified source keys take precedence only for those cross-provider targets. A cross-provider redirect sets `RouteResult.credentialDomainRewrite`, which Responses and Chat header scoping treat like combo/policy routes, so a caller credential for the source route is never forwarded. `src/cli/alias.ts` consumes the JSON output flag before choosing its default list action. `ocx alias --json` and both flag positions around an explicit action preserve the selected read or mutation. Live selector hints obey the [credential-scoped cache contract](catalog.md): a selection change cannot reuse the previous credential's roster to choose an alias target. Passive OAuth observation neither refreshes credentials nor repairs their storage. Cross-provider redirects require an enabled destination with a resolved key, usable stored OAuth account, or local/keyless auth (never source-caller forward auth), and share one visited set and five-edge budget across alias, provider, combo and policy resolution; every policy redirect must end at an eligible declared candidate, including same-provider substitutions and candidate fallback retries, and pinned account selectors reject provider changes. Decision traces name the final provider/model while policy candidates retain selection evidence and combos retain their route kind and redirect reason. diff --git a/tests/cli/cli-alias-json.test.ts b/tests/cli/cli-alias-json.test.ts new file mode 100644 index 00000000000..e010b1e92aa --- /dev/null +++ b/tests/cli/cli-alias-json.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { handleAliasCommand } from "../../src/cli/alias"; + +describe("alias list JSON flags", () => { + test.each([["--json"], ["list", "--json"], ["--json", "list"]].map(args => [args]))("lists aliases for %j", async args => { + const out = spyOn(console, "log").mockImplementation(() => {}); + const paths: string[] = []; + const payload = { providers: { local: "Work" }, models: {} }; + try { + expect(await handleAliasCommand(args, { baseUrl: "http://cli.test", fetchImpl: async input => { + paths.push(String(input)); return Response.json(payload); + } })).toBe(0); + expect(paths).toEqual(["http://cli.test/api/aliases"]); + expect(JSON.parse(String(out.mock.calls[0]![0]))).toEqual(payload); + } finally { out.mockRestore(); } + }); + test("a leading JSON flag does not turn mutation into a list", async () => { + const out = spyOn(console, "log").mockImplementation(() => {}); + try { + let method: string | undefined; + expect(await handleAliasCommand(["--json", "set", "local", "Work"], { + baseUrl: "http://cli.test", fetchImpl: async (_input, init) => { method = init?.method; return Response.json({ ok: true }); }, + })).toBe(0); + expect(method).toBe("PUT"); + } finally { out.mockRestore(); } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index cbfabfca1f4..0a2511861c2 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -135,6 +135,7 @@ "anthropic-account-pause.test.ts": "adapters/anthropic", "anthropic-account-threshold.test.ts": "adapters/anthropic", "anthropic-combo-account-cooldown.test.ts": "adapters/anthropic", + "cli-alias-json.test.ts": "cli", "cli-anthropic-account-threshold.test.ts": "cli", "anthropic-model-routes.test.ts": "adapters/anthropic", "anthropic-agentrouter-language-framing.test.ts": "adapters/anthropic", From 15321b94c6c538384dfe4ea372ed3a81f762bbf6 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 19:43:58 +0900 Subject: [PATCH 06/12] feat(cli): show request IDs in human log output (carry #6435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries #6435 by @oocheol onto current dev as part of the oocheol CLI batch. Co-authored-by: 정우철 --- .../content/docs/ko/reference/cli/agents.md | 4 +++ .../src/content/docs/reference/cli/agents.md | 4 +++ scripts/test-layout/layout.json | 1 + src/cli/observe.ts | 7 ++++- structure/dashboard-and-usage.md | 2 +- tests/cli/cli-logs-request-id.test.ts | 31 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 7 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 tests/cli/cli-logs-request-id.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..7d92d347290 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 `가 표시됩니다. 이 값을 `ocx logs explain `에 넣어 라우팅 결정을 확인할 수 있습니다. ID가 없거나 ID에 제어 문자가 있으면 조회 키를 바꾸어 표시하지 않고 이 항목을 생략합니다. JSON과 JSONL은 원래 ID와 형식을 유지합니다. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index a8eff6dde45..9c6a676939c 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. + +## Explain a listed request + +Human `ocx logs` output includes `id=`. Pass that value to `ocx logs explain ` to inspect routing decisions. Rows without an ID or with control characters in their ID omit the field instead of displaying a different lookup key. JSON and JSONL output retain their existing schema. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f00f36d42e5..342dce5a75e 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -417,6 +417,7 @@ "cli-integer-options.test.ts": "cli", "cli-init-port.test.ts": "cli", "cli-json-contract.test.ts": "cli", + "cli-logs-request-id.test.ts": "cli", "cli-management-auth.test.ts": "cli", "cli-models-free-only.test.ts": "cli", "cli-models-price.test.ts": "cli", diff --git a/src/cli/observe.ts b/src/cli/observe.ts index dcbc5e9a594..79ef22ec4b2 100644 --- a/src/cli/observe.ts +++ b/src/cli/observe.ts @@ -52,6 +52,7 @@ function logRows(data: unknown): LogEntry[] { return []; } +/** Render one human log row with an exact, control-free request ID suitable for history lookup. */ function formatLog(row: LogEntry): string { const time = String(row.timestamp ?? row.createdAt ?? ""); const route = [row.provider, row.model].filter(Boolean).join("/"); @@ -69,9 +70,13 @@ function formatLog(row: LogEntry): string { const account = typeof row.accountLogLabel === "string" && row.accountLogLabel.length > 0 ? `acct=${row.accountLogLabel}` : ""; - return [time, String(status), route, duration, account, conversation].filter(Boolean).join(" "); + const requestId = typeof row.requestId === "string" && row.requestId.length > 0 + && !/[\x00-\x1f\x7f-\x9f\u2028\u2029]/.test(row.requestId) + ? `id=${row.requestId}` : ""; + return [time, String(status), route, duration, account, conversation, requestId].filter(Boolean).join(" "); } +/** Read or follow request logs while preserving raw JSON and JSONL output. */ async function logs(argv: string[], deps: RuntimeApiDeps): Promise { const args = [...argv]; const wantsJson = takeFlag(args, "--json"); diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index d046bc9d667..90536160170 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -166,7 +166,7 @@ keeps the saved state and renders fixed `ocx sync` guidance without server/accou `OcxUsage.providerCredits` preserves provider-reported credit spend in request and attempt rows through `src/usage/log.ts` normalization and ledger reloads. Missing readings stay absent, and zero is a measured value. Separate attempts add credits when usage is merged. The field is independent -of token estimation (`estimated` describes tokens) and is never treated as USD or token usage. +of token estimation (`estimated` describes tokens) and is never treated as USD or token usage. The human log projection in `src/cli/observe.ts` includes the persisted request ID as `id=...` for direct use with `ocx logs explain`. Rows without an ID or with control characters in their ID omit it rather than rewriting the lookup key; JSON and JSONL keep the API payload. ### Upstream key account attribution diff --git a/tests/cli/cli-logs-request-id.test.ts b/tests/cli/cli-logs-request-id.test.ts new file mode 100644 index 00000000000..a153765fecb --- /dev/null +++ b/tests/cli/cli-logs-request-id.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { handleObserveCommand } from "../../src/cli/observe"; + +describe("human logs request identity", () => { + test("prints persisted request IDs and omits absent IDs", async () => { + const rows = [{ requestId: "request-42", status: 200 }, { requestId: "request\n42", status: 500 }, { status: 201 }]; + const out = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await handleObserveCommand(["logs"], { baseUrl: "http://cli.test", fetchImpl: async () => Response.json(rows) })).toBe(0); + expect(String(out.mock.calls[0]![0])).toContain("id=request-42"); + expect(String(out.mock.calls[1]![0])).not.toContain("id="); + expect(String(out.mock.calls[2]![0])).not.toContain("id="); + } finally { out.mockRestore(); } + }); + test.each(["--json", "--jsonl"])("preserves %s data", async flag => { + const rows = [{ requestId: "request\n42", status: 200 }]; + const out = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await handleObserveCommand(["logs", flag], { baseUrl: "http://cli.test", fetchImpl: async () => Response.json(rows) })).toBe(0); + expect(JSON.parse(String(out.mock.calls[0]![0]))).toEqual(flag === "--json" ? rows : rows[0]); + } finally { out.mockRestore(); } + }); +}); + +test.each(["\u0000", "\u001b", "\u0085", "\u2028", "\u2029"])("omits an ID containing control %j", async control => { + const out = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await handleObserveCommand(["logs"], { baseUrl: "http://cli.test", fetchImpl: async () => Response.json([{ requestId: "request" + control + "42", status: 200 }]) })).toBe(0); + expect(String(out.mock.calls[0]![0])).not.toContain("id="); + } finally { out.mockRestore(); } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0a2511861c2..0c9bdc1b047 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -425,6 +425,7 @@ "cli-integer-options.test.ts": "cli", "cli-init-port.test.ts": "cli", "cli-json-contract.test.ts": "cli", + "cli-logs-request-id.test.ts": "cli", "cli-management-auth.test.ts": "cli", "cli-models-free-only.test.ts": "cli", "cli-models-price.test.ts": "cli", From abf22e230a1ffa49e6535adc7fa165833e125d86 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 19:43:58 +0900 Subject: [PATCH 07/12] fix(cli): return not-found for missing routing profiles (carry #6437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries #6437 by @oocheol onto current dev as part of the oocheol CLI batch. Co-authored-by: 정우철 --- .../content/docs/ko/reference/cli/agents.md | 3 +++ .../src/content/docs/reference/cli/agents.md | 3 +++ scripts/test-layout/layout.json | 1 + src/cli/route-policy.ts | 4 ++- structure/runtime.md | 2 +- tests/cli/cli-route-policy-not-found.test.ts | 25 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 7 files changed, 37 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 7d92d347290..432a83e522d 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -267,3 +267,6 @@ ocx system codex-cli-update attest --candidate --npm-prefix `가 표시됩니다. 이 값을 `ocx logs explain `에 넣어 라우팅 결정을 확인할 수 있습니다. ID가 없거나 ID에 제어 문자가 있으면 조회 키를 바꾸어 표시하지 않고 이 항목을 생략합니다. JSON과 JSONL은 원래 ID와 형식을 유지합니다. +## Routing profile lookup status + +`ocx route policy show `는 프로필이 없으면 종료 코드 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 9c6a676939c..a5b54318678 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -489,3 +489,6 @@ The read-only data-plane endpoint is `GET /v1/usage`, using `x-opencodex-api-key ## Explain a listed request Human `ocx logs` output includes `id=`. Pass that value to `ocx logs explain ` to inspect routing decisions. Rows without an ID or with control characters in their ID omit the field instead of displaying a different lookup key. JSON and JSONL output retain their existing schema. +## 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 342dce5a75e..2d2087f3a83 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -434,6 +434,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..76afab9dbcc 100644 --- a/src/cli/route-policy.ts +++ b/src/cli/route-policy.ts @@ -1,5 +1,6 @@ import { CliUsageError, + RuntimeApiError, printData, rejectArgs, runCliAction, @@ -38,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(); @@ -46,7 +48,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 b53b36f920d..7086e8b461a 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -57,7 +57,7 @@ Catalog-derived reasoning-level diagnostics are escaped only at the human-output ## 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. 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 0c9bdc1b047..1443eba5e37 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -442,6 +442,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 531e53677ae898f43f7c43b7f80fd531d44cfcca 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 19:43:59 +0900 Subject: [PATCH 08/12] fix(responses): keep usable fallback upstream error messages (carry #6438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries #6438 by @oocheol onto current dev as part of the oocheol CLI batch. Co-authored-by: 정우철 --- .../content/docs/ko/reference/cli/agents.md | 3 +++ .../src/content/docs/reference/cli/agents.md | 3 +++ scripts/test-layout/layout.json | 1 + src/lib/errors.ts | 14 ++++++------- structure/transports/responses-wire-shapes.md | 2 +- tests/fixtures/test-layout-expected.json | 1 + .../upstream-error-message-fallback.test.ts | 20 +++++++++++++++++++ 7 files changed, 36 insertions(+), 8 deletions(-) create mode 100644 tests/lib/upstream-error-message-fallback.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 432a83e522d..deb225e85b3 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -270,3 +270,6 @@ ocx system codex-cli-update attest --candidate --npm-prefix `는 프로필이 없으면 종료 코드 4를 반환합니다. 명령 인수가 빠졌거나 잘못되면 2를 반환하므로 스크립트에서 두 경우를 구별할 수 있습니다. +## Upstream error details + +업스트림 오류 응답에 여러 메시지 필드가 있으면 기존 우선순위에서 처음 발견한 비어 있지 않은 문자열을 사용합니다. 빈 값이나 잘못된 형식의 필드 때문에 유효한 후순위 진단이 사라지지 않습니다. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index a5b54318678..a2d748f205b 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -492,3 +492,6 @@ Human `ocx logs` output includes `id=`. Pass that value to `ocx logs ## 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. +## Upstream error details + +When an upstream error envelope contains several message fields, OpenCodex uses the first nonblank string in its established priority order. Empty or malformed fields no longer hide a valid fallback diagnostic. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2d2087f3a83..8e7a88aacf7 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1776,6 +1776,7 @@ "update-tray-handoff.test.ts": "update", "update-tree-ownership.test.ts": "update", "upstream-connect-error.test.ts": "server", + "upstream-error-message-fallback.test.ts": "lib", "upstream-http-error.test.ts": "adapters", "upstream-http-version.test.ts": "server", "upstream-reachability.test.ts": "codex-integration", diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 2e8616a8272..ab4d3c7cee0 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -18,7 +18,7 @@ export const ENCRYPTED_FUNCTION_OUTPUT_REJECTION = */ export const SEND_BUDGET_EXHAUSTED_CODE = "request_send_budget_exhausted"; -/** Canonical human-readable message paths used by Responses upstream failures. */ +/** First nonblank string across the canonical upstream error paths, in priority order. */ export function upstreamErrorMessageFromPayload(payload: unknown): string | undefined { if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined; const json = payload as { @@ -31,14 +31,14 @@ export function upstreamErrorMessageFromPayload(payload: unknown): string | unde incomplete_details?: { message?: unknown }; }; }; - const message = json.error?.message - ?? json.last_error?.message - ?? json.response?.error?.message - ?? json.response?.incomplete_details?.message + const messages = [json.error?.message, + json.last_error?.message, + json.response?.error?.message, + json.response?.incomplete_details?.message, // The Responses stream error event carries a flat message (type/code/message), // unlike the response.failed envelope the branches above already cover. - ?? (json.type === "error" ? json.message : undefined); - return typeof message === "string" ? message : undefined; + json.type === "error" ? json.message : undefined]; + return messages.find((message): message is string => typeof message === "string" && message.trim().length > 0); } /** OpenAI / Codex hard block for high-risk cybersecurity activity (HTTP 400 or mid-stream). */ diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index e4b11cc65ae..f2844b32528 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -376,7 +376,7 @@ destination receives unchanged whenever a caller sends one itself. ## Passthrough SSE stream shapes (#314) Native passthrough SSE has TWO shapes, selected per request in -`src/server/responses/core.ts`; both apply the client-only [hosted-image projection](../data-planes/images.md#hosted-responses-image-display) after continuation-cache observers: +`src/server/responses/core.ts`; both apply the client-only [hosted-image projection](../data-planes/images.md#hosted-responses-image-display) after continuation-cache observers: `src/lib/errors.ts` selects the first nonblank string in the canonical upstream message precedence (error, last_error, response error, incomplete details, then a flat error event). Malformed or blank fields cannot hide a later valid diagnostic; retained text is not rewritten. - **Default outside Windows: tee + background inspection.** `upstreamResponse.body.tee()` sends branch[0] through a terminal-aware client relay while branch[1] is diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 1443eba5e37..c210b306187 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1796,6 +1796,7 @@ "update-tray-handoff.test.ts": "update", "update-tree-ownership.test.ts": "update", "upstream-connect-error.test.ts": "server", + "upstream-error-message-fallback.test.ts": "lib", "upstream-http-error.test.ts": "adapters", "upstream-http-version.test.ts": "server", "upstream-reachability.test.ts": "codex-integration", diff --git a/tests/lib/upstream-error-message-fallback.test.ts b/tests/lib/upstream-error-message-fallback.test.ts new file mode 100644 index 00000000000..e945ce6c5de --- /dev/null +++ b/tests/lib/upstream-error-message-fallback.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; +import { upstreamErrorMessageFromPayload } from "../../src/lib/errors"; + +describe("upstream diagnostic message fallback", () => { + test.each([null, 123, false, {}, [], "", " \t\n"].map(message => [message]))("skips unusable message %j", message => { + expect(upstreamErrorMessageFromPayload({ error: { message }, response: { error: { message: "provider detail" } } })).toBe("provider detail"); + }); + test("retains established precedence and original text", () => { + expect(upstreamErrorMessageFromPayload({ error: { message: " first " }, last_error: { message: "second" }, response: { error: { message: "third" } } })).toBe(" first "); + expect(upstreamErrorMessageFromPayload({ error: { message: "" }, last_error: { message: "second" }, response: { error: { message: "third" } } })).toBe("second"); + }); + test("flat messages are admitted only for error events", () => { + expect(upstreamErrorMessageFromPayload({ type: "error", error: { message: false }, message: "flat detail" })).toBe("flat detail"); + expect(upstreamErrorMessageFromPayload({ type: "response.completed", message: "ordinary output" })).toBeUndefined(); + expect(upstreamErrorMessageFromPayload({ response: { incomplete_details: { message: "incomplete detail" } } })).toBe("incomplete detail"); + }); + test.each([null, [], "message", 10, { error: { message: " " } }].map(payload => [payload]))("has no message for %j", payload => { + expect(upstreamErrorMessageFromPayload(payload)).toBeUndefined(); + }); +}); From ab857d31289625c673b15b6b54582b1de974617e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 2 Oct 2026 19:45:19 +0900 Subject: [PATCH 09/12] fix(cli): ask for the setup port again instead of abandoning ocx init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amends the #6433 carry: an invalid interactive port now reports the error and re-asks, so a typo no longer discards every earlier answer. EOF and SIGINT at the repeated prompt still cancel without saving (exit 1 / 130); the wizard tests cover all three paths. Co-authored-by: 정우철 --- .../docs/ko/reference/cli/lifecycle.md | 2 +- .../content/docs/reference/cli/lifecycle.md | 2 +- src/cli/init.ts | 14 +-- structure/runtime.md | 2 +- tests/cli/cli-init-port.test.ts | 112 ++++++++++++------ 5 files changed, 83 insertions(+), 49 deletions(-) diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 17c8b644eea..dc6d38b8ee3 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -460,4 +460,4 @@ npm에 게시하면 사용할 수 있게 됩니다. ## Setup port validation -`ocx init`의 포트는 1–65535 범위의 십진 정수입니다. Enter만 누르면 10100을 사용합니다. `0`, `10100oops`, `1.5` 같은 잘못된 값은 임의로 바꾸거나 자르지 않고 설정 저장 전에 중단합니다. +`ocx init`의 포트는 1–65535 범위의 십진 정수입니다. Enter만 누르면 10100을 사용합니다. `0`, `10100oops`, `1.5` 같은 잘못된 값은 임의로 바꾸거나 자르지 않고, 오류를 알린 뒤 포트를 다시 묻습니다. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 87e4c0888d5..64d990df618 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -857,4 +857,4 @@ Use `ocx connect --pairing-code-stdin`, `ocx connect status`, `ocx sync`, ## Setup port validation -`ocx init` accepts TCP ports 1–65535 as decimal whole numbers. Press Enter to use 10100. Invalid input stops setup before writing config; values such as `0`, `10100oops` and `1.5` are not silently replaced or truncated. +`ocx init` accepts TCP ports 1–65535 as decimal whole numbers. Press Enter to use 10100. Invalid input such as `0`, `10100oops` or `1.5` is never silently replaced or truncated; setup reports it and asks for the port again. diff --git a/src/cli/init.ts b/src/cli/init.ts index e1b0cd82024..83a69cff06f 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -203,13 +203,13 @@ export async function runInit(): Promise { }; } - const portStr = await prompt.ask("\nProxy port [10100]: "); - const port = parseInitPort(portStr); - if (port === null) { - console.error("Proxy port must be a whole decimal number from 1 to 65535. No config was saved."); - process.exitCode = 1; - return; - } + // A mistyped port re-asks instead of discarding every earlier answer; EOF and SIGINT + // still reject the pending question with InitCancelledError. + let port: number | null; + do { + port = parseInitPort(await prompt.ask("\nProxy port [10100]: ")); + if (port === null) console.error("Proxy port must be a whole decimal number from 1 to 65535. Please try again."); + } while (port === null); initializeProviderModelSelection(providerName, providerConfig); const config: OcxConfig = { diff --git a/structure/runtime.md b/structure/runtime.md index 7086e8b461a..f82140ee9a1 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -124,7 +124,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, lazy-runs `bun/install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. The exact `system codex-cli-update` inspection namespace skips both boot repair and lazy Bun installation; missing runtime support fails closed instead of mutating state. | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | | `src/lib/plain-data.ts` | Detached copies for a consumer that must not observe later edits. Descriptor-based reads, including array elements, so an accessor is refused rather than invoked; refuses cycles, functions, class instances and anything else JSON could not have produced, and returns a copy-or-refusal union rather than degrading silently. Symbol-keyed process bookkeeping is skipped. | -| `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version, because the replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full command surface is `src/cli/help.ts`; this table names the groups, not every verb. After help/version early exits, ordinary commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `system codex-cli-update` is the deliberate read-only exception and suppresses auto-restore for its whole namespace, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input exits unsuccessfully without saving. | +| `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version, because the replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full command surface is `src/cli/help.ts`; this table names the groups, not every verb. After help/version early exits, ordinary commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `system codex-cli-update` is the deliberate read-only exception and suppresses auto-restore for its whole namespace, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input is reported and the port question is asked again, while EOF or SIGINT still cancels without saving. | | `src/server/index.ts` | Bun server entrypoint: `startServer`, `/v1/responses` HTTP + WebSocket routing (compact handled before generic Responses), exact `POST /v1/images/generations` and `POST /v1/images/edits` routing, `/v1/models`, the Anthropic-shaped `/v1/messages` and OpenAI-shaped `/v1/chat/completions` compatibility surfaces, the Live/Realtime surface, the hosted-search relay, artifact serving, `/healthz`, the `/api/*` auth gate, the `/v1/*` JSON 404 guard, GUI fallback, the opt-in loopback-only hub-management listener, and facade re-exports for split server modules. The route table itself is built by `src/server/index/serve-options.ts`; this entry file owns the listener and the startup transaction. | | `src/server/images.ts` | Standalone Images data plane: default OpenAI or explicit custom-provider selection, Codex account affinity, bounded opaque request relay, single-attempt upstream fetch, pool health recording, and safe response/cancellation relay. | | `src/server/audio-transcriptions.ts` | Standalone multipart transcription; audio-specific key admission, bounded upload/response, stored OpenAI credential resolution and lease-bound cancellation. See [audio contracts](data-planes/inbound-compat.md#standalone-file-transcription). | diff --git a/tests/cli/cli-init-port.test.ts b/tests/cli/cli-init-port.test.ts index ca96bfeeb14..10db4cd8938 100644 --- a/tests/cli/cli-init-port.test.ts +++ b/tests/cli/cli-init-port.test.ts @@ -1,11 +1,59 @@ import { describe, expect, test } from "bun:test"; import * as init from "../../src/cli/init"; -import { existsSync, mkdirSync, mkdtempSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { repoPath, repoRoot } from "../helpers/repo-root"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +const PORT_PROMPT = "Proxy port [10100]:"; +const BEFORE_PORT: Array<[string, string]> = [ + ["Select default provider (number):", "999"], ["Provider name:", "port-fixture"], + ["Base URL (e.g. http://localhost:11434/v1):", "https://example.test/v1"], + ["Adapter [openai-chat]:", ""], ["API key (optional):", ""], ["Default model:", "fixture"], +]; +type Step = [prompt: string, action: string | "" | ""]; + +/** Drives the real wizard; each prompt is matched only in output not yet consumed, so a repeated prompt needs a fresh occurrence. */ +async function runWizard(steps: Step[]) { + const home = mkdtempSync(join(tmpdir(), "ocx-init-port-")); + mkdirSync(join(home, "native")); + const proc = Bun.spawn([process.execPath, "--eval", "import {runInit} from " + JSON.stringify(repoPath("src", "cli", "init.ts")) + "; await runInit();"], { + cwd: repoRoot(), env: { ...process.env, OPENCODEX_HOME: home, CODEX_HOME: join(home, "native"), HOME: home, USERPROFILE: home, + APPDATA: join(home, "appdata"), LOCALAPPDATA: join(home, "localappdata"), XDG_CONFIG_HOME: join(home, "xdg") }, + stdin: "pipe", stdout: "pipe", stderr: "pipe", + }); + const stderr = new Response(proc.stderr).text(); + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + let output = "", cursor = 0, answered = 0; + const timer = setTimeout(() => proc.kill(), 20_000); + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + for (let next = steps[answered]; next; next = steps[answered]) { + const at = output.indexOf(next[0], cursor); + if (at < 0) break; + cursor = at + next[0].length; + answered++; + if (next[1] === "") proc.stdin.end(); + else if (next[1] === "") proc.kill("SIGINT"); + else { proc.stdin.write(next[1] + "\n"); await proc.stdin.flush(); } + } + } + const exitCode = await proc.exited; + const configPath = join(home, "config.json"); + return { answered, exitCode, output, stderr: await stderr, config: existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) as { port?: number } : undefined }; + } finally { + clearTimeout(timer); if (proc.exitCode === null) proc.kill(); + await proc.exited; reader.releaseLock(); removeTreeWithRetry(home); + } +} + +const portPrompts = (output: string) => output.split(PORT_PROMPT).length - 1; + describe("setup port validation", () => { test.each([["", 10100], [" ", 10100], ["1", 1], ["65535", 65535], [" 10101 ", 10101]] as const)("accepts %j", (raw, expected) => { expect(init.parseInitPort(raw)).toBe(expected); @@ -13,43 +61,29 @@ describe("setup port validation", () => { test.each(["0", "-1", "65536", "10100oops", "1.5", "1e3", "0x100", "oops", "9007199254740993"])("rejects %j before publication", raw => { expect(init.parseInitPort(raw)).toBeNull(); }); - test("the actual wizard refuses a truncated port before writing config", async () => { - const home = mkdtempSync(join(tmpdir(), "ocx-invalid-port-")); - mkdirSync(join(home, "native")); - const proc = Bun.spawn([process.execPath, "--eval", 'import {runInit} from ' + JSON.stringify(repoPath("src", "cli", "init.ts")) + '; await runInit();'], { - cwd: repoRoot(), env: { ...process.env, OPENCODEX_HOME: home, CODEX_HOME: join(home, "native"), HOME: home, USERPROFILE: home, - APPDATA: join(home, "appdata"), LOCALAPPDATA: join(home, "localappdata"), XDG_CONFIG_HOME: join(home, "xdg") }, - stdin: "pipe", stdout: "pipe", stderr: "pipe", - }); - const stderr = new Response(proc.stderr).text(); - const reader = proc.stdout.getReader(); - const decoder = new TextDecoder(); - const answers = [ - ["Select default provider (number):", "999"], ["Provider name:", "port-fixture"], - ["Base URL (e.g. http://localhost:11434/v1):", "https://example.test/v1"], - ["Adapter [openai-chat]:", ""], ["API key (optional):", ""], ["Default model:", "fixture"], - ["Proxy port [10100]:", "10100oops"], ["Inject into Codex config.toml? [Y/n]:", "n"], - ["Install Codex autostart shim? [Y/n]:", "n"], - ]; - let output = "", answered = 0; - const timer = setTimeout(() => proc.kill(), 20_000); - try { - for (;;) { - const { value, done } = await reader.read(); - if (done) break; - output += decoder.decode(value, { stream: true }); - const next = answers[answered]; - if (next && output.includes(next[0]!)) { - proc.stdin.write(next[1] + "\n"); await proc.stdin.flush(); answered++; - } - } - expect({ answered, stderr: await stderr, output }).toMatchObject({ answered: 7 }); - expect(await proc.exited).toBe(1); - expect(await stderr).toContain("Proxy port must be"); - expect(existsSync(join(home, "config.json"))).toBe(false); - } finally { - clearTimeout(timer); if (proc.exitCode === null) proc.kill(); - await proc.exited; reader.releaseLock(); removeTreeWithRetry(home); - } + + test("an invalid port is asked again and the valid retry is saved", async () => { + const run = await runWizard([...BEFORE_PORT, [PORT_PROMPT, "10100oops"], [PORT_PROMPT, "10101"], + ["Inject into Codex config.toml? [Y/n]:", "n"], ["Install Codex autostart shim? [Y/n]:", "n"]]); + expect(run).toMatchObject({ answered: 10, exitCode: 0 }); + expect(run.stderr).toContain("Proxy port must be a whole decimal number from 1 to 65535. Please try again."); + expect(portPrompts(run.output)).toBe(2); + expect(run.config?.port).toBe(10101); + }, 30_000); + + test("EOF at the repeated port prompt cancels without saving", async () => { + const run = await runWizard([...BEFORE_PORT, [PORT_PROMPT, "1.5"], [PORT_PROMPT, ""]]); + expect(run).toMatchObject({ answered: 8, exitCode: 1 }); + expect(run.stderr).toContain("Please try again"); + expect(run.stderr).toContain("stdin reached EOF"); + expect(run.config).toBeUndefined(); + }, 30_000); + + // Windows process.kill does not deliver a POSIX SIGINT to readline. + test.skipIf(process.platform === "win32")("SIGINT at the repeated port prompt cancels without saving", async () => { + const run = await runWizard([...BEFORE_PORT, [PORT_PROMPT, "0"], [PORT_PROMPT, ""]]); + expect(run).toMatchObject({ answered: 8, exitCode: 130 }); + expect(run.stderr).toContain("Setup cancelled"); + expect(run.config).toBeUndefined(); }, 30_000); }); From c51dc839c707cd048924d7a2ab8c8ea497b3ec3b Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 2 Oct 2026 19:45:53 +0900 Subject: [PATCH 10/12] test(responses): pin recovery consumers of the upstream message fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the #6438 carry: encrypted-output resend and quota alternate-account detection now see a fallback message hidden behind a blank primary one; a nonblank primary still wins. Co-authored-by: 정우철 --- .../upstream-error-message-fallback.test.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/tests/lib/upstream-error-message-fallback.test.ts b/tests/lib/upstream-error-message-fallback.test.ts index e945ce6c5de..9124c91a5ca 100644 --- a/tests/lib/upstream-error-message-fallback.test.ts +++ b/tests/lib/upstream-error-message-fallback.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { upstreamErrorMessageFromPayload } from "../../src/lib/errors"; +import { ENCRYPTED_FUNCTION_OUTPUT_REJECTION, isRateLimitOrQuotaFailureMessage, upstreamErrorMessageFromPayload } from "../../src/lib/errors"; +import { codexQuotaFailureMessage } from "../../src/server/responses/core-codex-account"; +import { isEncryptedFunctionOutputRejection } from "../../src/server/responses/core-opaque-recovery"; describe("upstream diagnostic message fallback", () => { test.each([null, 123, false, {}, [], "", " \t\n"].map(message => [message]))("skips unusable message %j", message => { @@ -18,3 +20,29 @@ describe("upstream diagnostic message fallback", () => { expect(upstreamErrorMessageFromPayload(payload)).toBeUndefined(); }); }); + +// Recovery consumers read the same helper, so a blank primary message must not hide the +// fallback that decides whether a resend or alternate account is warranted. +describe("recovery consumers see the fallback message", () => { + const blankPrimary = (fallback: string) => JSON.stringify({ error: { message: "" }, response: { error: { message: fallback } } }); + + test("encrypted function output rejection behind a blank primary message", () => { + expect(isEncryptedFunctionOutputRejection(blankPrimary(ENCRYPTED_FUNCTION_OUTPUT_REJECTION))).toBe(true); + expect(isEncryptedFunctionOutputRejection(blankPrimary("model overloaded"))).toBe(false); + }); + + test("quota failure behind a blank primary message", () => { + const quota = codexQuotaFailureMessage(blankPrimary("You exceeded your current quota")); + expect(quota).toBe("You exceeded your current quota"); + expect(isRateLimitOrQuotaFailureMessage(quota!)).toBe(true); + const unrelated = codexQuotaFailureMessage(blankPrimary("invalid tool schema")); + expect(unrelated).toBe("invalid tool schema"); + expect(isRateLimitOrQuotaFailureMessage(unrelated!)).toBe(false); + }); + + test("a nonblank primary message still wins over a conflicting fallback", () => { + const body = JSON.stringify({ error: { message: "invalid tool schema" }, response: { error: { message: "You exceeded your current quota" } } }); + expect(codexQuotaFailureMessage(body)).toBe("invalid tool schema"); + expect(isEncryptedFunctionOutputRejection(JSON.stringify({ error: { message: "model overloaded" }, response: { error: { message: ENCRYPTED_FUNCTION_OUTPUT_REJECTION } } }))).toBe(false); + }); +}); From 92db9debff500835a0711c6745d81dd7a25d838f Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 2 Oct 2026 19:45:53 +0900 Subject: [PATCH 11/12] docs(cli): separate the carried reference sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 정우철 --- docs-site/src/content/docs/ko/reference/cli.md | 3 +++ docs-site/src/content/docs/ko/reference/cli/agents.md | 2 ++ docs-site/src/content/docs/reference/cli.md | 3 +++ docs-site/src/content/docs/reference/cli/agents.md | 2 ++ 4 files changed, 10 insertions(+) diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index a62cddb8891..d3dbd395ad2 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -44,12 +44,15 @@ Windows x64 설치 관측은 [`attest` 명령](/ko/reference/cli/agents/)을 참 ## Capability argument validation `ocx capabilities`는 알 수 없는 인수, 반복된 플래그, 빈 `--route` 값을 종료 코드 64로 거부합니다. 유효한 경로에 등록된 기능이 없으면 종료 코드 4를 반환합니다. + ## Integer option values `--limit` 같은 정수 옵션은 안전하게 표현 가능한 십진 정수를 받습니다. `1_000`, `1,000` 같은 숫자 구분자는 허용합니다. 빈 값, 16진수, 지수 표기 및 소수는 요청을 보내기 전에 거부합니다. + ## Windows JSON configuration files `ocx config validate `과 `ocx config import --yes`는 선행 BOM이 있는 UTF-8 JSON도 읽습니다. 표준 입력(`-`)에도 적용되므로 Windows PowerShell이나 편집기의 UTF-8 내보내기를 사용할 수 있습니다. UTF-16은 지원하지 않습니다. + ## Default alias listing `ocx alias --json`은 `ocx alias list --json`과 같습니다. `--json`은 명시한 alias 작업의 앞이나 뒤에 둘 수 있습니다. 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 deb225e85b3..38ea7dc4cd8 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -267,9 +267,11 @@ ocx system codex-cli-update attest --candidate --npm-prefix `가 표시됩니다. 이 값을 `ocx logs explain `에 넣어 라우팅 결정을 확인할 수 있습니다. ID가 없거나 ID에 제어 문자가 있으면 조회 키를 바꾸어 표시하지 않고 이 항목을 생략합니다. JSON과 JSONL은 원래 ID와 형식을 유지합니다. + ## Routing profile lookup status `ocx route policy show `는 프로필이 없으면 종료 코드 4를 반환합니다. 명령 인수가 빠졌거나 잘못되면 2를 반환하므로 스크립트에서 두 경우를 구별할 수 있습니다. + ## Upstream error details 업스트림 오류 응답에 여러 메시지 필드가 있으면 기존 우선순위에서 처음 발견한 비어 있지 않은 문자열을 사용합니다. 빈 값이나 잘못된 형식의 필드 때문에 유효한 후순위 진단이 사라지지 않습니다. diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 1337d46451f..2cf78314711 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -160,12 +160,15 @@ minutes, and protects a live worker from concurrent updates. ## Capability argument validation `ocx capabilities` rejects unknown arguments, repeated flags and blank `--route` values with exit 64. A valid route with no declared capability exits 4. + ## Integer option values Integer options such as `--limit` require decimal whole numbers within JavaScript safe-integer bounds. Digit separators such as `1_000` and `1,000` are accepted. Empty values, hexadecimal, exponent notation and fractions are rejected before a request is sent. + ## Windows JSON configuration files `ocx config validate ` and `ocx config import --yes` accept UTF-8 JSON with or without a leading BOM, including stdin (`-`). This supports UTF-8 exports from Windows PowerShell and editors. UTF-16 input is not accepted. + ## Default alias listing `ocx alias --json` is equivalent to `ocx alias list --json`. The output flag can precede or follow an explicit alias action. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index a2d748f205b..727a5699fc8 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -489,9 +489,11 @@ The read-only data-plane endpoint is `GET /v1/usage`, using `x-opencodex-api-key ## Explain a listed request Human `ocx logs` output includes `id=`. Pass that value to `ocx logs explain ` to inspect routing decisions. Rows without an ID or with control characters in their ID omit the field instead of displaying a different lookup key. JSON and JSONL output retain their existing schema. + ## 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. + ## Upstream error details When an upstream error envelope contains several message fields, OpenCodex uses the first nonblank string in its established priority order. Empty or malformed fields no longer hide a valid fallback diagnostic. From 283bc4a2d5f35c3aab87dd5a0774a46482cb25ee Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 2 Oct 2026 19:51:26 +0900 Subject: [PATCH 12/12] chore: keep the lane plan record out of the carry --- .../010_plan.md | 57 ------------------- 1 file changed, 57 deletions(-) delete mode 100644 devlog/_plan/261002_oocheol_cli_batch_carry/010_plan.md diff --git a/devlog/_plan/261002_oocheol_cli_batch_carry/010_plan.md b/devlog/_plan/261002_oocheol_cli_batch_carry/010_plan.md deleted file mode 100644 index 9cfd259a7c9..00000000000 --- a/devlog/_plan/261002_oocheol_cli_batch_carry/010_plan.md +++ /dev/null @@ -1,57 +0,0 @@ -# 261002 oocheol CLI batch carry — plan (wp1) - -Loop-spec: class C2 (eight small, independently reviewed CLI fixes plus one behavioral amendment). Mode HOTL lane, single work-phase wp1. -Tool/credential scope: local git, bun, gh (push this branch, open one PR; no merge, no comments on other PRs). -Write scope: src/cli/{capabilities-command,runtime-api,config-command,init,alias,observe,route-policy}.ts, src/lib/errors.ts, the eight new test files, docs-site reference/cli.md, reference/cli/agents.md, reference/cli/lifecycle.md (en, ko), structure/{runtime,config,dashboard-and-usage}.md and structure/transports/responses-wire-shapes.md (sentences the PRs touch), scripts/test-layout/layout.json, tests/fixtures/test-layout-expected.json. -Budget: one lane session; wall-clock bound ~3h including one hosted CI run. -Base: origin/dev a300b57c81. Branch: codex/carry-oocheol-cli-batch. - -## Architect consultation -Architect handle 01a0fc2f-188e-7632-8bd6-e5cee64ec6ab (gpt-6.1-sol, read-only). Decisions D1–D7 all ACCEPTED: -- D1 eight commits in order 6429, 6431, 6432, 6433, 6434, 6435, 6437, 6438; author 정우철 , Co-authored-by trailer. -- D2 port retry: keep parseInitPort; do/while around prompt.ask at src/cli/init.ts:197; stderr "Proxy port must be a whole decimal number from 1 to 65535. Please try again."; no exit code; no retry cap; EOF/SIGINT keep InitCancelledError (exit 1/130). -- D3 wizard test: invalid 10100oops then valid 10101, exit 0, config.json port 10101, two port prompts, consumed-output cursor; add invalid→EOF case (exit 1, no config). SIGINT case: invalid→SIGINT while retry prompt pending, exit 130, no config (skip Windows). -- D4 preserve exit contracts (64/4 capabilities; 2/4/1 shared); init port stays stricter than shared integer options. -- D5 docs: keep every appended section once in en+ko; reword #6433 docs/structure to "re-prompts". -- D6 no baseline caps on touched files; structure docs keep line counts (append in-line); run skill:surface:check without regeneration. -- D7 reject abort and retry caps. - -## File change map -| Commit | Files | Change | -|---|---|---| -| #6429 | capabilities-command.ts, tests/cli/cli-capabilities-arguments.test.ts, docs cli.md en/ko, structure/runtime.md, layout maps | as source | -| #6431 | runtime-api.ts, tests/cli/cli-integer-options.test.ts, docs cli.md en/ko, structure/config.md, layout maps | as source | -| #6432 | config-command.ts, tests/cli/cli-config-bom.test.ts, docs, structure/config.md, layout | as source | -| #6433 | init.ts, tests/cli/cli-init-port.test.ts, lifecycle.md en/ko, structure/runtime.md, layout | source + D2/D3 amendment, docs "re-prompts" | -| #6434 | alias.ts, tests/cli/cli-alias-json.test.ts, docs, structure/runtime.md, layout | as source | -| #6435 | observe.ts, tests/cli/cli-logs-request-id.test.ts, agents.md en/ko, structure/dashboard-and-usage.md, layout | as source | -| #6437 | route-policy.ts, tests/cli/cli-route-policy-not-found.test.ts, agents.md, structure/runtime.md, layout | as source | -| #6438 | src/lib/errors.ts, tests/lib/upstream-error-message-fallback.test.ts, agents.md, structure/transports/responses-wire-shapes.md, layout | as source | - -Conflict resolution (rehearsed with git apply --3way): docs cli.md (6431/6432/6434 after 6429), agents.md (6437/6438 after 6435), layout maps (6433). Resolve by keeping all sections in commit order and all layout entries. - -## Scope -IN: the eight behaviors, #6433 amendment, docs/structure wording. OUT: #6436, src/lib/sse-decoder.ts, merge, other PRs. - -## Acceptance (activation scenarios) -- c-1 each commit carries its regression test; activation: bun test of each new test file passes at final head. -- c-2 invalid port path: wizard subprocess answers 10100oops then 10101 → stderr contains "Please try again", exit 0, config.json.port 10101; invalid then EOF → exit 1, no config.json. -- c-3 rg counts each new heading exactly once in en and ko docs. -- c-4 verifiers (run at final head): bun run typecheck; bun test <8 new files> tests/service/init-eof.test.ts tests/test-layout*.test.ts; bun run test:changed; bun run structure:check; bun run skill:surface:check; bun run privacy:scan. -- c-5 push once; PR to dev with full template; one CI run green. - -## SoT sync -structure/*.md sentences listed above (owned docs for touched src areas); no new src area. - - -## Reflection (same architect, revision 2) -Verdict MISALIGNED with two gaps, both FOLDED: -1. D3 SIGINT: REQUIRED (not optional). tests/cli/cli-init-port.test.ts adds invalid→SIGINT while the retry prompt is pending (exit 130, no config), skipped on Windows like tests/service/init-eof.test.ts:225. -2. Ownership fan-out: C reviews every structure doc mapped to src/cli/ and src/lib/ (structure/INDEX.md:124,141) and updates only explanations the eight changes make stale (structure/AGENTS.md:49). structure:check is necessary but not sufficient. - - -## Audit round 1 (reviewer 01a0fc32-39d3-7aa0-82df-5c56d0ba5ae8, gpt-6.1-sol): GO-WITH-FIXES (blockers=2), both FOLDED -1. #6438 consumer effect (Medium): a blank primary error.message no longer hides response.error.message, so isEncryptedFunctionOutputRejection (src/server/responses/core-opaque-recovery.ts:109) and codexQuotaFailureMessage (src/server/responses/core-codex-account.ts:274) now see the fallback. This is the intended fix. FOLD: extend tests/lib/upstream-error-message-fallback.test.ts with consumer cases — blank primary + fallback rejection string → encrypted rejection true; blank primary + fallback quota text → quota message returned and classified as quota; blank primary + unrelated fallback → not quota; nonblank primary still wins over a conflicting fallback. relay.ts:266 code/type precedence uses its own candidate scan and is unchanged; one assertion that a blank primary message does not change the relay-selected code if cheaply reachable, else recorded as unaffected by inspection. -2. Docs validation (Medium): FOLD: C runs `cd docs-site && bun install --frozen-lockfile && bun run build` (docs-site/AGENTS.md:25-30); the PR also requires the exact-head "docs site build" CI job. -Main judgment: near-pass (both Medium blockers folded; no High/Critical). -