From 96265174b4de2905534857e20c47261158e97451 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:50:00 +0900 Subject: [PATCH 1/2] fix(cli): escape catalog diagnostics before terminal output --- .../src/content/docs/fr/guides/remote-hub.md | 2 + .../src/content/docs/guides/remote-hub.md | 2 + .../src/content/docs/ja/guides/remote-hub.md | 2 + .../src/content/docs/ko/guides/remote-hub.md | 2 + .../src/content/docs/ru/guides/remote-hub.md | 2 + .../src/content/docs/tr/guides/remote-hub.md | 2 + .../content/docs/zh-cn/guides/remote-hub.md | 2 + .../content/docs/zh-tw/guides/remote-hub.md | 2 + src/cli/connect.ts | 15 +- src/cli/dispatch.ts | 8 +- src/cli/runtime-api.ts | 25 +++ structure/clients/claude-desktop.md | 2 + structure/config.md | 2 + structure/ops/docs-and-release.md | 2 + structure/runtime.md | 4 + tests/cli/cli-connect-readiness.test.ts | 175 +++++++++++++++++- 16 files changed, 238 insertions(+), 11 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/remote-hub.md b/docs-site/src/content/docs/fr/guides/remote-hub.md index 7c24706562..6b11aaccb7 100644 --- a/docs-site/src/content/docs/fr/guides/remote-hub.md +++ b/docs-site/src/content/docs/fr/guides/remote-hub.md @@ -15,6 +15,8 @@ ocx connect status ocx sync ``` +Les diagnostics de disponibilité lisibles par un humain affichent les caractères de contrôle des valeurs du catalogue sous forme d’échappements hexadécimaux visibles, aussi bien à la première connexion que lorsque `ocx sync` refuse un catalogue de hub actualisé. Le statut JSON conserve la valeur de diagnostic d’origine. + La clé client est écrite dans le fichier privé `service-api-token`, jamais dans `config.json`. En mode connecté, l’usage provient du hub et est filtré par `apiKeyId`; après déconnexion, il provient du stockage local. Il n’existe aucune réplication entre les deux. Le jeton admin permet la gestion ordinaire mais ne peut jamais créer une session de consentement. Les actions de consentement exigent une `gui-session`, une Origin correspondante et un jeton CSRF. `Tailscale-User-Login` n’est fiable que sur l’entrée de gestion dédiée; renseignez les identités exactes dans `remoteGui.allowedTailscaleUsers`. diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index dce98d2a86..e582b03428 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -48,6 +48,8 @@ ocx connect status ocx sync ``` +Human-readable readiness diagnostics show control characters in catalog values as visible hexadecimal escapes, both when you first connect and when `ocx sync` refuses a refreshed hub catalog. Structured JSON status retains the original diagnostic value. + You do not have to assemble that line by hand. `ocx hub invite`, run on the hub, mints the code and prints the exact command — including both origins — for the machine that is joining. See [Inviting another machine](#inviting-another-machine). diff --git a/docs-site/src/content/docs/ja/guides/remote-hub.md b/docs-site/src/content/docs/ja/guides/remote-hub.md index daa836ebec..1c14c39e70 100644 --- a/docs-site/src/content/docs/ja/guides/remote-hub.md +++ b/docs-site/src/content/docs/ja/guides/remote-hub.md @@ -15,6 +15,8 @@ ocx connect status ocx sync ``` +準備状況を人が読む出力では、カタログ値に含まれる制御文字を目に見える 16 進エスケープとして表示します。初回の接続だけでなく、`ocx sync` が取得し直したハブのカタログを拒否したときも同じです。JSON 形式の状態には元の診断値をそのまま残します。 + 発行されたキーは所有者だけが読める `service-api-token` に保存され、`config.json` には入りません。接続中の使用量は hub 側で同じ `apiKeyId` に絞り込まれ、切断後はローカル保存分を表示します。両者はミラーリングされません。 管理トークンは通常の管理だけに使え、同意セッションを作ることは永久にできません。同意操作にはサーバー発行の `gui-session`、一致する Origin、CSRF が必要です。`Tailscale-User-Login` は専用管理リスナーでのみ信頼し、許可する ID を `remoteGui.allowedTailscaleUsers` に正確に設定します。 diff --git a/docs-site/src/content/docs/ko/guides/remote-hub.md b/docs-site/src/content/docs/ko/guides/remote-hub.md index f47ae658fe..18fcb31a14 100644 --- a/docs-site/src/content/docs/ko/guides/remote-hub.md +++ b/docs-site/src/content/docs/ko/guides/remote-hub.md @@ -32,6 +32,8 @@ ocx connect status ocx sync ``` +준비 상태를 사람이 읽는 출력에서는 카탈로그 값의 제어문자를 눈에 보이는 16진수 이스케이프로 표시합니다. 처음 연결할 때뿐 아니라 `ocx sync`가 새로 받은 허브 카탈로그를 거부할 때도 같습니다. JSON 상태에는 원래 진단값을 그대로 유지합니다. + 이 줄을 직접 만들 필요는 없습니다. 허브에서 `ocx hub invite`를 실행하면 코드를 발급하고, 두 Origin이 모두 채워진 명령을 그대로 출력합니다. [다른 컴퓨터 초대하기](#다른-컴퓨터-초대하기)를 보세요. 허브가 발급한 클라이언트별 키는 권한이 제한된 `service-api-token` 파일에 저장됩니다. `config.json`에는 저장되지 않습니다. 연결 중 사용량은 허브 기록에서 해당 `apiKeyId`만 조회하고, 연결을 끊은 뒤에는 로컬 기록을 봅니다. 두 기록은 서로 복제되지 않습니다. diff --git a/docs-site/src/content/docs/ru/guides/remote-hub.md b/docs-site/src/content/docs/ru/guides/remote-hub.md index 964fe4e423..42e753adeb 100644 --- a/docs-site/src/content/docs/ru/guides/remote-hub.md +++ b/docs-site/src/content/docs/ru/guides/remote-hub.md @@ -15,6 +15,8 @@ ocx connect status ocx sync ``` +В читаемой человеком диагностике готовности управляющие символы из значений каталога показываются как видимые шестнадцатеричные escape-последовательности — и при первом подключении, и когда `ocx sync` отклоняет обновлённый каталог hub. В JSON-статусе исходное значение диагностики сохраняется без изменений. + Ключ клиента записывается в защищённый `service-api-token`, а не в `config.json`. При подключении статистика читается с hub и фильтруется по `apiKeyId`; после отключения используется локальное хранилище. Зеркалирования нет. Admin token разрешает обычное управление, но никогда не создаёт consent session. Для действий с согласием нужны `gui-session`, совпадающий Origin и CSRF. Заголовок `Tailscale-User-Login` доверен только отдельному management ingress; точные логины задаются в `remoteGui.allowedTailscaleUsers`. diff --git a/docs-site/src/content/docs/tr/guides/remote-hub.md b/docs-site/src/content/docs/tr/guides/remote-hub.md index ae89276984..e15655a93f 100644 --- a/docs-site/src/content/docs/tr/guides/remote-hub.md +++ b/docs-site/src/content/docs/tr/guides/remote-hub.md @@ -15,6 +15,8 @@ ocx connect status ocx sync ``` +İnsanın okuyacağı hazırlık tanılarında katalog değerlerindeki denetim karakterleri görünür onaltılık kaçış dizileri olarak yazılır; bu hem ilk bağlanışta hem de `ocx sync` yenilenen hub kataloğunu reddettiğinde geçerlidir. JSON durumu özgün tanı değerini olduğu gibi korur. + İstemci anahtarı yalnızca sahibinin okuyabildiği `service-api-token` dosyasına yazılır, `config.json` içine yazılmaz. Bağlı kullanım hub deposundan aynı `apiKeyId` ile filtrelenir; bağlantı kesilince yerel depo kullanılır. İki depo birbirini yansıtmaz. Admin token sıradan yönetim yapabilir ancak hiçbir zaman onay oturumu oluşturamaz. Onay işlemleri sunucu tarafından verilen `gui-session`, eşleşen Origin ve CSRF ister. `Tailscale-User-Login` yalnızca ayrı yönetim girişinde güvenilirdir; tam kimlikleri `remoteGui.allowedTailscaleUsers` içinde belirtin. diff --git a/docs-site/src/content/docs/zh-cn/guides/remote-hub.md b/docs-site/src/content/docs/zh-cn/guides/remote-hub.md index 47516f1dbd..5e4bb66e6a 100644 --- a/docs-site/src/content/docs/zh-cn/guides/remote-hub.md +++ b/docs-site/src/content/docs/zh-cn/guides/remote-hub.md @@ -15,6 +15,8 @@ ocx connect status ocx sync ``` +面向人阅读的就绪诊断会把目录值中的控制字符显示为可见的十六进制转义,首次连接时如此,`ocx sync` 拒绝重新获取的 hub 目录时也一样。JSON 状态仍保留原始的诊断值。 + 客户端密钥写入仅所有者可读的 `service-api-token`,绝不会写入 `config.json`。连接期间,使用记录来自 hub 并按稳定的 `apiKeyId` 过滤;断开后显示本地记录。两者不会镜像。 Admin token 只能执行普通管理,永远不能创建用户同意会话。用户同意操作必须使用服务器签发的 `gui-session`、匹配的 Origin 和 CSRF。`Tailscale-User-Login` 只在独立管理入口可信;请在 `remoteGui.allowedTailscaleUsers` 中填写准确登录名。 diff --git a/docs-site/src/content/docs/zh-tw/guides/remote-hub.md b/docs-site/src/content/docs/zh-tw/guides/remote-hub.md index cade45c84c..cc736202fa 100644 --- a/docs-site/src/content/docs/zh-tw/guides/remote-hub.md +++ b/docs-site/src/content/docs/zh-tw/guides/remote-hub.md @@ -15,6 +15,8 @@ ocx connect status ocx sync ``` +供人閱讀的就緒診斷會把目錄值中的控制字元顯示為可見的十六進位逸出序列,首次連線時如此,`ocx sync` 拒絕重新取得的 hub 目錄時也一樣。JSON 狀態仍保留原始的診斷值。 + 用戶端金鑰會寫入只有擁有者可讀的 `service-api-token`,絕不寫入 `config.json`。連線期間,用量來自 hub 並依穩定的 `apiKeyId` 篩選;中斷後則顯示本機記錄。兩者不會互相鏡像。 Admin token 只能執行一般管理,永遠不能建立使用者同意工作階段。同意操作必須使用伺服器簽發的 `gui-session`、相符的 Origin 與 CSRF。`Tailscale-User-Login` 只在獨立管理入口可信;請在 `remoteGui.allowedTailscaleUsers` 填入完整且正確的登入名稱。 diff --git a/src/cli/connect.ts b/src/cli/connect.ts index 8059cf1d74..c656d5b2e6 100644 --- a/src/cli/connect.ts +++ b/src/cli/connect.ts @@ -29,6 +29,8 @@ import { takeFlag, takeIntegerOption, takeOption, + terminalSafeError, + terminalSafeText, type RuntimeApiDeps, } from "./runtime-api"; @@ -212,7 +214,7 @@ function readinessLine(status: ClientConnectionStatus): string { : status.readiness === "incompatible" ? "not ready" : "unverified"; - return `Local Codex CLI: ${label}${status.readinessReason ? ` (${status.readinessReason})` : ""}`; + return `Local Codex CLI: ${label}${status.readinessReason ? ` (${terminalSafeText(status.readinessReason)})` : ""}`; } export type ConnectCompletionReport = { @@ -249,9 +251,10 @@ export function connectCompletionReport( if (readiness.kind === "unverified") { // Not a failure. A client with no observable Codex CLI is a working configuration, and the // write-time gate deliberately lets it through; saying so is the honest middle report. - return { lines: [connected, `Local Codex CLI: unverified (${readiness.reason}).`], failure: null }; + return { lines: [connected, `Local Codex CLI: unverified (${terminalSafeText(readiness.reason)}).`], failure: null }; } - const verdict = `Local Codex CLI: not ready (${readiness.reason})`; + const safeReason = terminalSafeText(readiness.reason); + const verdict = `Local Codex CLI: not ready (${safeReason})`; if (!selectedClients.includes("codex")) { return { lines: [connected, `${verdict} This connection selected ${selectedClients.join(", ")}, so nothing here launches Codex.`], @@ -260,7 +263,7 @@ export function connectCompletionReport( } return { lines: [verdict, `The connection to ${connection.serverUrl} as key ${connection.apiKeyId} was saved; run 'ocx connect status' to see it.`], - failure: `client_not_ready: ${readiness.reason}`, + failure: `client_not_ready: ${safeReason}`, }; } @@ -344,6 +347,10 @@ async function runConnect(argv: string[], deps: ClientCommandDeps): Promise { + // Compatibility refusals happen before the completion report and reach stderr. + // Keep the domain error untouched; render its message only at the CLI boundary. + throw terminalSafeError(error); }); // The hub and the credential are proven at this point; the local runtime is not. Reporting // only the first half is what #4207 was filed for, so the catalog now on disk is checked diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 9eaab69fd5..576bf96bb0 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -28,7 +28,7 @@ import { restoreNativeCodexAsync } from "../codex/inject"; import { stripGrokConfig } from "../grok/inject"; import { handleRestartScopeAfterWrite, readRestartScope, type RestartScope } from "./restart-scope"; import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job"; -import { isJsonOption, takeFlag } from "./runtime-api"; +import { isJsonOption, takeFlag, terminalSafeError } from "./runtime-api"; import type { ClientConnectionState } from "../client/state"; import { OCX_NATIVE_REPLAY_RECOVERY_NOTE } from "../responses/compaction"; @@ -405,7 +405,11 @@ const commandRunners: Record = { // types it as `number | string`; only a numeric code means anything here. return typeof process.exitCode === "number" ? process.exitCode : 0; } catch (error) { - console.error(`Connected sync failed without local fallback: ${error instanceof Error ? error.message : String(error)}`); + // The refresh path reaches the same hub catalog `ocx connect` validates, so a rejected + // reasoning level arrives here as hub-supplied text. Rendering it through the shared + // terminal boundary is what keeps the routine refresh from forging output; the domain + // error itself is left alone for callers that inspect it. + console.error(`Connected sync failed without local fallback: ${terminalSafeError(error).message}`); return 1; } } diff --git a/src/cli/runtime-api.ts b/src/cli/runtime-api.ts index 7b05d56b9f..3da3fb29cc 100644 --- a/src/cli/runtime-api.ts +++ b/src/cli/runtime-api.ts @@ -350,6 +350,31 @@ export function printData(value: unknown, wantsJson: boolean, lines?: string[]): else for (const line of lines) console.log(line); } +/** + * Render untrusted diagnostic text without letting it control the operator's terminal. Catalog + * values are hub-supplied and surface on more than one CLI path -- first-time `ocx connect` and the + * connected `ocx sync` refresh both print them -- so the escaping sits beside `printData`, at the + * one boundary that already separates human output from structured output. Structured output keeps + * the exact value: escaping is a rendering decision for a tty, not a change to the data. + */ +export function terminalSafeText(value: string): string { + return value.replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/g, character => { + const code = character.charCodeAt(0); + return code <= 0x7f + ? `\\x${code.toString(16).padStart(2, "0")}` + : `\\u${code.toString(16).padStart(4, "0")}`; + }); +} + +/** + * The same rendering for a failure about to be printed or rethrown. The original is kept as + * `cause` rather than discarded, so a caller that inspects the domain error still reads the exact + * message and fields it threw. + */ +export function terminalSafeError(error: unknown): Error { + return new Error(terminalSafeText(error instanceof Error ? error.message : String(error)), { cause: error }); +} + /** Compact human view for safe management DTOs; JSON remains available for complete fidelity. */ export function summaryLines(value: unknown, prefix = "", depth = 0): string[] { if (!value || typeof value !== "object" || depth > 1) return [`${prefix || "value"}: ${String(value)}`]; diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 76fef247f4..00a0b78dbe 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -8,6 +8,8 @@ That projection does not migrate existing user-selected Desktop configuration or Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. +Claude-only connections keep their existing non-failing readiness policy; displayed catalog reasons follow the [terminal rendering contract](../runtime.md#cli-readiness-diagnostics) whether they surface at connect time or on a later refresh. + ## Connected Claude Desktop profiles Connected `ocx claude desktop apply` reads the hub's Desktop snapshot and writes the hub origin diff --git a/structure/config.md b/structure/config.md index f799ebd2c5..ac643d3c7f 100644 --- a/structure/config.md +++ b/structure/config.md @@ -3,6 +3,8 @@ The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +Connected-client catalog diagnostics use the [terminal rendering contract](runtime.md#cli-readiness-diagnostics) on the first connection and on every `ocx sync` refresh; stored catalog values are unchanged. + ## Config surface ### OpenCodex home and live process state diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 17e54e3664..2789f3bd11 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -5,6 +5,8 @@ is scoped to canonical ChatGPT Responses forwarding; other source-area behavior Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. +Human-readable connect and sync-refresh diagnostics follow the [terminal rendering contract](../runtime.md#cli-readiness-diagnostics), with regression coverage for both paths in `tests/cli/cli-connect-readiness.test.ts`. + ## Public docs The public documentation site lives in `docs-site/` and is built with Astro + Starlight. English is diff --git a/structure/runtime.md b/structure/runtime.md index ea80d0a46e..26c38f2737 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -9,6 +9,10 @@ it requires no runtime lifecycle change or new configuration option. Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. +## 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. + ## Entrypoints | Path | Responsibility | diff --git a/tests/cli/cli-connect-readiness.test.ts b/tests/cli/cli-connect-readiness.test.ts index 548cdb716d..bb739cdbf0 100644 --- a/tests/cli/cli-connect-readiness.test.ts +++ b/tests/cli/cli-connect-readiness.test.ts @@ -10,7 +10,7 @@ * status surface itself, in a real client home, with the ladder injected so no Codex process is * spawned to observe it. */ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdtempSync, writeFileSync } from "node:fs"; @@ -18,8 +18,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoRoot } from "../helpers/repo-root"; -import { INTERNAL_DEADLINE_MS } from "../helpers/test-budget"; +import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; import { connectCompletionReport } from "../../src/cli/connect"; +import { dispatchCommand } from "../../src/cli/dispatch"; +import type { CliDispatchDeps } from "../../src/cli/dispatch"; +import { ClientCatalogIncompatibleError } from "../../src/client/catalog-compatibility"; import type { ClientCatalogReadiness } from "../../src/client/catalog-compatibility"; /** Codex CLI 0.135.0's ladder, verbatim from the parse error in the issue. */ @@ -33,6 +36,9 @@ const CATALOG_WITH_MAX = JSON.stringify({ type ProbeResult = { lines: string[]; + exitCode?: number; + errors: string[]; + catalogUnchanged?: boolean; status: { state: string; catalog: string; @@ -50,6 +56,7 @@ function runStatusProbe(options: { connected: boolean; ladder: string[] | null | "forbidden"; catalog?: string; + connectRejectCatalog?: string; }): ProbeResult { const opencodexHome = mkdtempSync(join(tmpdir(), "ocx-readiness-home-")); const codexHome = mkdtempSync(join(tmpdir(), "ocx-readiness-codex-")); @@ -78,7 +85,9 @@ function runStatusProbe(options: { }, } : { port: 10100, providers: {}, defaultProvider: "openai" }), "utf8"); - writeFileSync(join(opencodexHome, "service-api-token"), `${token}\n`, { mode: 0o600 }); + if (!options.connectRejectCatalog) { + writeFileSync(join(opencodexHome, "service-api-token"), `${token}\n`, { mode: 0o600 }); + } writeFileSync(join(codexHome, "opencodex-catalog.json"), catalog, "utf8"); const script = ` @@ -89,20 +98,54 @@ function runStatusProbe(options: { : ladder === null ? () => null : () => new Set(ladder); const lifecycleLockDeps = { lockPath: process.env.OPENCODEX_HOME + "/lifecycle.sqlite" }; const captured = []; + const errors = []; const real = console.log; + const realError = console.error; (async () => { + let exitCode, catalogUnchanged; console.log = (...parts) => captured.push(parts.join(" ")); + console.error = (...parts) => errors.push(parts.join(" ")); try { - await handleConnectCommand(["status"], { lifecycleLockDeps, catalogProbeDeps: { supportedEfforts } }); + if (process.env.REJECT_CATALOG) { + const fs = require("node:fs"); + const { Readable } = require("node:stream"); + const catalogPath = process.env.CODEX_HOME + "/opencodex-catalog.json"; + const before = fs.readFileSync(catalogPath, "utf8"); + const fetchImpl = async (input, init = {}) => { + const url = String(input); + if (url.endsWith("/readyz")) return Response.json({ + service: "opencodex", version: "0.0.0", uptime: 1, pid: 1, port: 443, + status: "ready", protocol: 1, minimumClientProtocol: 1, + managementUrl: "https://hub.example.test", + }); + if (url.endsWith("/api/keys") && init.method === "POST") return Response.json({ + id: "fixture-key", name: "fixture", key: "ocx_data_" + "a".repeat(40), + createdAt: "2026-09-13T00:00:00.000Z", + }, { status: 201 }); + if (url.endsWith("/v1/catalog")) return new Response(process.env.REJECT_CATALOG, { + headers: { "content-type": "application/json" }, + }); + if (init.method === "DELETE") return Response.json({ ok: true }); + throw new Error("unexpected fixture request"); + }; + exitCode = await handleConnectCommand(["https://hub.example.test", "--admin-token-stdin", "--clients", "codex"], { + lifecycleLockDeps, catalogProbeDeps: { supportedEfforts }, + stdinImpl: Readable.from(["ocx_admin_fixture" + String.fromCharCode(10)]), fetchImpl, + }); + catalogUnchanged = fs.readFileSync(catalogPath, "utf8") === before; + } else { + await handleConnectCommand(["status"], { lifecycleLockDeps, catalogProbeDeps: { supportedEfforts } }); + } } finally { console.log = real; + console.error = realError; } const status = collectClientConnectionStatus( Date.parse("2026-08-28T00:00:10.000Z"), lifecycleLockDeps, { supportedEfforts }, ); - console.log(JSON.stringify({ lines: captured, status })); + console.log(JSON.stringify({ lines: captured, status, exitCode, errors, catalogUnchanged })); })(); `; @@ -118,6 +161,7 @@ function runStatusProbe(options: { ...process.env, OPENCODEX_HOME: opencodexHome, CODEX_HOME: codexHome, + REJECT_CATALOG: options.connectRejectCatalog ?? "", // Matches the existing client fixtures: no probe may reach the operator's real Claude // Desktop configuration, even transitively. OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop"), @@ -133,6 +177,24 @@ function runStatusProbe(options: { } describe("#4207 connected-client readiness", () => { + test("first-time connect escapes a rejected hub catalog before stderr output", () => { + const probe = runStatusProbe({ + connected: false, + ladder: OLD_CLI, + connectRejectCatalog: JSON.stringify({ + models: [{ slug: "gpt-5.6-sol", supported_reasoning_levels: [{ effort: "bad\nFORGED\x1b[2J" }] }], + }), + }); + expect(probe.exitCode).toBe(1); + expect(probe.catalogUnchanged).toBe(true); + expect(probe.status.state).toBe("disconnected"); + expect(probe.lines).toEqual([]); + expect(probe.errors[0]).toContain("catalog_incompatible:"); + expect(probe.errors[0]).toContain("bad\\x0aFORGED\\x1b[2J"); + expect(probe.errors[0]).toContain("gpt-5.6-sol"); + expect(probe.errors.join(" ")).not.toMatch(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/); + }, SPAWN_BUDGET_MS); + test("an installed catalog the local CLI rejects is not reported as ready", () => { const probe = runStatusProbe({ connected: true, ladder: OLD_CLI }); @@ -159,6 +221,21 @@ describe("#4207 connected-client readiness", () => { expect(probe.lines.find(line => line.startsWith("Hub:"))).toBeDefined(); }); + test("terminal controls in catalog effort names remain data in status diagnostics", () => { + const effort = "rogue\nFORGED\x1b]52;c;SGVsbG8=\x07\u2028after"; + const probe = runStatusProbe({ + connected: true, + ladder: OLD_CLI, + catalog: JSON.stringify({ + models: [{ slug: "gpt-5.6-sol", supported_reasoning_levels: [{ effort: "high" }, { effort }] }], + }), + }); + + expect(probe.status.readinessReason).toContain(effort); + expect(probe.lines[1]).toContain("rogue\\x0aFORGED\\x1b]52;c;SGVsbG8=\\x07\\u2028after"); + expect(probe.lines[1]).not.toMatch(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/); + }); + test("a catalog the local CLI accepts is ready, with nothing to explain", () => { const probe = runStatusProbe({ connected: true, ladder: NEW_CLI }); @@ -239,6 +316,24 @@ describe("#4207 what ocx connect reports when the local CLI cannot use the catal expect(report.lines.join(" ")).toContain("was saved"); }); + test("completion diagnostics escape controls without changing readiness or failure policy", () => { + const reason = "진단 café\nFORGED\x1b]52;c;SGVsbG8=\x07\x00\x7f\x85\u2028\u2029"; + const safe = "진단 café\\x0aFORGED\\x1b]52;c;SGVsbG8=\\x07\\x00\\x7f\\u0085\\u2028\\u2029"; + const verdict: ClientCatalogReadiness = { + kind: "incompatible", reason, unsupportedEfforts: [reason], affectedModels: ["gpt-5.6-sol"], + }; + const failed = connectCompletionReport(connection, ["codex"], verdict); + expect(failed.failure).toBe("client_not_ready: " + safe); + expect(failed.lines[0]).toContain(safe); + const claude = connectCompletionReport(connection, ["claude"], verdict); + expect(claude.failure).toBeNull(); + expect(claude.lines.join(" ")).toContain(safe); + const unknown = connectCompletionReport(connection, ["codex"], { kind: "unverified", reason }); + expect(unknown.failure).toBeNull(); + expect(unknown.lines[1]).toContain(safe); + expect(verdict.reason).toBe(reason); + }); + test("a Claude-only connection is told, but not failed, by an old Codex CLI", () => { // Nothing in this connection launches Codex, so a stale binary elsewhere on PATH is not a // reason to fail an operator's Claude Desktop setup. @@ -249,3 +344,73 @@ describe("#4207 what ocx connect reports when the local CLI cannot use the catal expect(report.lines[0]).toContain("Connected to"); }); }); + +/** + * #4451 review: escaping first-time `ocx connect` left the routine path open. An already-connected + * client refreshes with `ocx sync`, and that runner catches the same catalog-derived + * `ClientCatalogIncompatibleError` and writes its message straight to stderr. A hub that names a + * reasoning level containing a newline and a CSI sequence therefore still forges terminal output on + * every refresh, which is the same defect the connect path was fixed for. + */ +describe("#4451 the connected-sync refresh shares the connect terminal boundary", () => { + /** A hub-supplied effort name that ends a line, forges a success, and erases its own traces. */ + const HOSTILE_EFFORT = "max\nConnected to https://attacker.example\x1b[2Krogue\u2028tail"; + const ESCAPED_EFFORT = "max\\x0aConnected to https://attacker.example\\x1b[2Krogue\\u2028tail"; + + /** + * Drives the real `sync` runner. Both modules the runner imports are stubbed rather than staged + * on disk: the connection state decides which branch runs, and the refusal is the domain error + * the hub's catalog produces, so no hub, token, or Codex process is needed to reach the boundary + * under test. + */ + async function runConnectedSync(): Promise<{ code: number; errors: string[]; thrown: ClientCatalogIncompatibleError }> { + const state = await import("../../src/client/state"); + const clientConnect = await import("../../src/client/connect"); + const thrown = new ClientCatalogIncompatibleError([HOSTILE_EFFORT], ["gpt-5.6-sol"]); + const errors: string[] = []; + const stateSpy = spyOn(state, "readClientConnectionState").mockReturnValue({ + kind: "connected", + value: { serverUrl: "https://hub.example.test", apiKeyId: "client-key-1" }, + } as unknown as ReturnType); + const syncSpy = spyOn(clientConnect, "syncConnectedClient").mockImplementation(async () => { throw thrown; }); + const errorSpy = spyOn(console, "error").mockImplementation((...parts: unknown[]) => { + errors.push(parts.map(part => String(part)).join(" ")); + }); + try { + const args = ["sync"]; + const code = await dispatchCommand({ kind: "command", command: "sync", args }, { + args, + // A connected client refreshes through the hub; reaching local proxy discovery would mean + // the branch under test was never entered. + findLiveProxy: async () => { throw new Error("the connected branch must not probe a local proxy"); }, + } as unknown as CliDispatchDeps); + return { code, errors, thrown }; + } finally { + errorSpy.mockRestore(); + syncSpy.mockRestore(); + stateSpy.mockRestore(); + } + } + + test("a control-bearing catalog refusal reaches stderr escaped, exactly as on the connect path", async () => { + const { code, errors } = await runConnectedSync(); + + expect(code).toBe(1); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("Connected sync failed without local fallback: catalog_incompatible:"); + expect(errors[0]).toContain(ESCAPED_EFFORT); + expect(errors[0]).toContain("gpt-5.6-sol"); + // The whole point of the boundary: nothing the hub named is still a control sequence at the tty. + expect(errors.join(" ")).not.toMatch(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/); + }); + + test("the domain error is rendered for display only, never rewritten", async () => { + // Escaping is a rendering decision at the CLI edge. The thrown error keeps its exact message + // and fields so programmatic callers of syncConnectedClient are unaffected. + const { thrown } = await runConnectedSync(); + + expect(thrown.message).toContain(HOSTILE_EFFORT); + expect(thrown.unsupportedEfforts).toEqual([HOSTILE_EFFORT]); + expect(thrown.name).toBe("ClientCatalogIncompatibleError"); + }); +}); From daf735d67bff0ffa1095f0f5d06e21e8a070283c Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:34:00 +0900 Subject: [PATCH 2/2] docs(cli): specify diagnostic escaping in translated guides --- docs-site/src/content/docs/ja/guides/remote-hub.md | 2 +- docs-site/src/content/docs/ko/guides/remote-hub.md | 2 +- docs-site/src/content/docs/ru/guides/remote-hub.md | 2 +- docs-site/src/content/docs/zh-cn/guides/remote-hub.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/ja/guides/remote-hub.md b/docs-site/src/content/docs/ja/guides/remote-hub.md index 1c14c39e70..63843513d1 100644 --- a/docs-site/src/content/docs/ja/guides/remote-hub.md +++ b/docs-site/src/content/docs/ja/guides/remote-hub.md @@ -15,7 +15,7 @@ ocx connect status ocx sync ``` -準備状況を人が読む出力では、カタログ値に含まれる制御文字を目に見える 16 進エスケープとして表示します。初回の接続だけでなく、`ocx sync` が取得し直したハブのカタログを拒否したときも同じです。JSON 形式の状態には元の診断値をそのまま残します。 +準備状況を人が読む出力では、カタログ値に含まれる C0/C1 制御文字、DEL、Unicode の行・段落区切り文字(U+2028、U+2029)を目に見える 16 進エスケープとして表示します。初回の接続だけでなく、`ocx sync` が取得し直したハブのカタログを拒否したときも同じです。JSON 形式の状態には元の診断値をそのまま残します。 発行されたキーは所有者だけが読める `service-api-token` に保存され、`config.json` には入りません。接続中の使用量は hub 側で同じ `apiKeyId` に絞り込まれ、切断後はローカル保存分を表示します。両者はミラーリングされません。 diff --git a/docs-site/src/content/docs/ko/guides/remote-hub.md b/docs-site/src/content/docs/ko/guides/remote-hub.md index 18fcb31a14..bf8b7178ce 100644 --- a/docs-site/src/content/docs/ko/guides/remote-hub.md +++ b/docs-site/src/content/docs/ko/guides/remote-hub.md @@ -32,7 +32,7 @@ ocx connect status ocx sync ``` -준비 상태를 사람이 읽는 출력에서는 카탈로그 값의 제어문자를 눈에 보이는 16진수 이스케이프로 표시합니다. 처음 연결할 때뿐 아니라 `ocx sync`가 새로 받은 허브 카탈로그를 거부할 때도 같습니다. JSON 상태에는 원래 진단값을 그대로 유지합니다. +준비 상태를 사람이 읽는 출력에서는 카탈로그 값의 C0/C1 제어문자, DEL, 유니코드 줄·문단 구분자(U+2028, U+2029)를 눈에 보이는 16진수 이스케이프로 표시합니다. 처음 연결할 때뿐 아니라 `ocx sync`가 새로 받은 허브 카탈로그를 거부할 때도 같습니다. JSON 상태에는 원래 진단값을 그대로 유지합니다. 이 줄을 직접 만들 필요는 없습니다. 허브에서 `ocx hub invite`를 실행하면 코드를 발급하고, 두 Origin이 모두 채워진 명령을 그대로 출력합니다. [다른 컴퓨터 초대하기](#다른-컴퓨터-초대하기)를 보세요. diff --git a/docs-site/src/content/docs/ru/guides/remote-hub.md b/docs-site/src/content/docs/ru/guides/remote-hub.md index 42e753adeb..49b7f7d35b 100644 --- a/docs-site/src/content/docs/ru/guides/remote-hub.md +++ b/docs-site/src/content/docs/ru/guides/remote-hub.md @@ -15,7 +15,7 @@ ocx connect status ocx sync ``` -В читаемой человеком диагностике готовности управляющие символы из значений каталога показываются как видимые шестнадцатеричные escape-последовательности — и при первом подключении, и когда `ocx sync` отклоняет обновлённый каталог hub. В JSON-статусе исходное значение диагностики сохраняется без изменений. +В читаемой человеком диагностике готовности управляющие символы C0/C1, DEL и разделители строк и абзацев Unicode (U+2028 и U+2029) из значений каталога показываются как видимые шестнадцатеричные escape-последовательности — и при первом подключении, и когда `ocx sync` отклоняет обновлённый каталог hub. В JSON-статусе исходное значение диагностики сохраняется без изменений. Ключ клиента записывается в защищённый `service-api-token`, а не в `config.json`. При подключении статистика читается с hub и фильтруется по `apiKeyId`; после отключения используется локальное хранилище. Зеркалирования нет. diff --git a/docs-site/src/content/docs/zh-cn/guides/remote-hub.md b/docs-site/src/content/docs/zh-cn/guides/remote-hub.md index 5e4bb66e6a..f9b86c1e27 100644 --- a/docs-site/src/content/docs/zh-cn/guides/remote-hub.md +++ b/docs-site/src/content/docs/zh-cn/guides/remote-hub.md @@ -15,7 +15,7 @@ ocx connect status ocx sync ``` -面向人阅读的就绪诊断会把目录值中的控制字符显示为可见的十六进制转义,首次连接时如此,`ocx sync` 拒绝重新获取的 hub 目录时也一样。JSON 状态仍保留原始的诊断值。 +面向人阅读的就绪诊断会把目录值中的 C0/C1 控制字符、DEL 以及 Unicode 行分隔符和段落分隔符(U+2028、U+2029)显示为可见的十六进制转义,首次连接时如此,`ocx sync` 拒绝重新获取的 hub 目录时也一样。JSON 状态仍保留原始的诊断值。 客户端密钥写入仅所有者可读的 `service-api-token`,绝不会写入 `config.json`。连接期间,使用记录来自 hub 并按稳定的 `apiKeyId` 过滤;断开后显示本地记录。两者不会镜像。