From 71ac00af8f823133662baae0e126eb50b9173b64 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:38:04 +0800 Subject: [PATCH 1/4] feat: manage provider headers via PATCH and ocx provider edit --headers PATCH /api/providers now accepts a headers field, shallow-merged onto the existing block (null or {} clears it), and ocx provider edit gains --headers . Custom providers such as Agent Router can restore required fingerprint headers through the management plane instead of hand-editing config.json. POST /api/providers already round-trips headers; validation reuses providerHeadersConfigError. --- .../ja/reference/cli/providers-accounts.md | 2 +- .../docs/ja/reference/management-api.md | 2 +- .../ko/reference/cli/providers-accounts.md | 2 +- .../docs/ko/reference/management-api.md | 2 +- .../docs/reference/cli/providers-accounts.md | 2 +- .../content/docs/reference/management-api.md | 2 +- .../ru/reference/cli/providers-accounts.md | 2 +- .../docs/ru/reference/management-api.md | 2 +- .../zh-cn/reference/cli/providers-accounts.md | 2 +- .../docs/zh-cn/reference/management-api.md | 2 +- src/cli/provider-runtime.ts | 19 +++++- src/server/management/provider-routes.ts | 16 +++++ tests/cli-headless-parity.test.ts | 30 +++++++++ tests/management-provider-validation.test.ts | 63 +++++++++++++++++++ 14 files changed, 137 insertions(+), 11 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md index 26876baca4..d9af8e1af2 100644 --- a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md @@ -15,7 +15,7 @@ description: プロバイダー構成、資格情報、クォータ、および | --- | --- | --- | | `list` | `--json` |構成されたプロバイダーと残りのレジストリ エントリを一覧表示します。 | | `add ` | `--adapter `、`--base-url `、`--api-key `、`--default-model `、`--set-default`、`--force`、`--json`、`--sync` |レジストリ/カスタムプロバイダーを追加します。 `--force` は上書きします。 `--sync` は、実行中のプロキシを人間出力モードで更新します。 | -| `edit ` |プロバイダーフィールドフラグ、`--json` |キー プールを置き換えずに、検証済みのライブ プロバイダー フィールドを編集します。 | +| `edit ` |プロバイダーフィールドフラグ、`--headers `、`--json` |キー プールを置き換えずに、検証済みのライブ プロバイダー フィールドを編集します。`--headers` はカスタム要求ヘッダーをマージします。`{}` または `-` を渡すとクリアします。 | | `test ` | `--json` |実際の上流モデルのエンドポイントを調査します。 | | `show ` | `--json` | API キーをマスクして設定を表示します。 | | `remove ` | `--json` |デフォルト以外のプロバイダーを削除します。最後のプロバイダーは削除できません。 | diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index 05b36e898d..56f23e6146 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -166,7 +166,7 @@ Authorization: Bearer | --- | --- | --- | | `GET /api/providers` |編集されたプロバイダー設定と検出状態をリストする | — | | `POST /api/providers` |検証済みプロバイダーを 1 つ追加または置換し、必要に応じてそれをデフォルトにします。 400 無効または危険な宛先または構成。 409 名前空間の衝突 | -| `PATCH /api/providers?name=...` |許可されたプロバイダー フィールド、有効/デフォルト状態、または OpenAI アカウント モードを更新します。 400 無効なフィールドまたは遷移。 404 不明なプロバイダ | +| `PATCH /api/providers?name=...` |許可されたプロバイダー フィールド(マージされる `headers` ブロックを含む)、有効/デフォルト状態、または OpenAI アカウント モードを更新します。 400 無効なフィールドまたは遷移。 404 不明なプロバイダ | | `DELETE /api/providers?name=...` |プロバイダーを削除し、可能な場合はデフォルトを再割り当てします。 404 不明なプロバイダー。 409 `last_provider`; 409 `provider_has_dependent_combos` | | `POST /api/providers/test?name=...` |制限されたライブプロバイダー接続/モデル検出プローブを実行する | 404 不明なプロバイダー。障害は通常、`ok: false` の証拠として返されます。 | `GET /api/provider-quotas` |プロバイダー クォータ レポートを読む。 `refresh=1` 強制更新 | — | diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index d1679cd464..5947c9cfef 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -15,7 +15,7 @@ description: 제공자 설정, 자격 증명, 할당량, 모델 카탈로그 명 | --- | --- | --- | | `list` | `--json` | 설정된 제공자와 남아 있는 레지스트리 항목을 나열합니다. | | `add ` | `--adapter `, `--base-url `, `--api-key `, `--default-model `, `--set-default`, `--force`, `--json`, `--sync` | 레지스트리/사용자 지정 제공자를 추가합니다. `--force`는 덮어쓰고, `--sync`는 사람이 읽는 출력 모드에서 실행 중인 프록시를 새로 고칩니다. | -| `edit ` | 제공자 필드 플래그, `--json` | 키 풀을 바꾸지 않고 검증된 실시간 제공자 필드를 수정합니다. | +| `edit ` | 제공자 필드 플래그, `--headers `, `--json` | 키 풀을 바꾸지 않고 검증된 실시간 제공자 필드를 수정합니다. `--headers`는 사용자 지정 요청 헤더를 병합하며, `{}` 또는 `-`로 지울 수 있습니다. | | `test ` | `--json` | 실제 상위 모델 엔드포인트를 확인합니다. | | `show ` | `--json` | API 키를 마스킹한 설정을 보여줍니다. | | `remove ` | `--json` | 기본값이 아닌 제공자를 제거합니다. 마지막 제공자는 제거할 수 없습니다. | diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index d5f0f0ecfc..334c73f094 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -166,7 +166,7 @@ Authorization: Bearer | --- | --- | --- | | `GET /api/providers` | redacted된 provider 구성과 discovery 상태를 나열합니다 | — | | `POST /api/providers` | 검증된 provider 하나를 추가하거나 교체하고, 선택적으로 기본 provider로 설정합니다 | 400 잘못되었거나 위험한 대상 또는 구성; 409 namespace 충돌 | -| `PATCH /api/providers?name=...` | 허용된 provider 필드, enabled/default 상태, 또는 OpenAI account mode를 업데이트합니다 | 400 잘못된 필드 또는 전환; 404 알 수 없는 provider | +| `PATCH /api/providers?name=...` | 허용된 provider 필드(병합되는 `headers` 블록 포함), enabled/default 상태, 또는 OpenAI account mode를 업데이트합니다 | 400 잘못된 필드 또는 전환; 404 알 수 없는 provider | | `DELETE /api/providers?name=...` | provider를 삭제하고, 가능하면 기본 provider를 재지정합니다 | 404 알 수 없는 provider; 409 `last_provider`; 409 `provider_has_dependent_combos` | | `POST /api/providers/test?name=...` | 제한된 live provider connectivity/model-discovery 탐색을 수행합니다 | 404 알 수 없는 provider; 실패는 보통 `ok: false` 증거로 반환됩니다 | | `GET /api/provider-quotas` | provider quota 보고서를 읽습니다. `refresh=1`은 새로 고침을 강제합니다 | — | diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 1fa5e222b5..c109c3508b 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -16,7 +16,7 @@ both `--adapter` and `--base-url`. | --- | --- | --- | | `list` | `--json` | List configured providers and the remaining registry entries. | | `add ` | `--adapter `, `--base-url `, `--api-key `, `--default-model `, `--set-default`, `--force`, `--json`, `--sync` | Add a registry/custom provider. `--force` overwrites; `--sync` refreshes a running proxy in human-output mode. | -| `edit ` | provider field flags, `--json` | Edit validated live provider fields without replacing key pools. | +| `edit ` | provider field flags, `--headers `, `--json` | Edit validated live provider fields without replacing key pools. `--headers` merges custom request headers; pass `{}` or `-` to clear them. | | `test ` | `--json` | Probe the real upstream model endpoint. | | `show ` | `--json` | Show config with API keys masked. | | `remove ` | `--json` | Remove a non-default provider; the last provider cannot be removed. | diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 586466ccff..2635b719af 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -183,7 +183,7 @@ keys are not returned to dashboard clients. | --- | --- | --- | | `GET /api/providers` | List redacted provider configuration and discovery state | — | | `POST /api/providers` | Add or replace one validated provider and optionally make it default | 400 invalid/dangerous destination or config; 409 namespace collision | -| `PATCH /api/providers?name=...` | Update allowed provider fields, enabled/default state, or OpenAI account mode | 400 invalid field or transition; 404 unknown provider | +| `PATCH /api/providers?name=...` | Update allowed provider fields (including a merged `headers` block), enabled/default state, or OpenAI account mode | 400 invalid field or transition; 404 unknown provider | | `DELETE /api/providers?name=...` | Delete a provider, reassigning the default when possible | 404 unknown provider; 409 `last_provider`; 409 `provider_has_dependent_combos` | | `POST /api/providers/test?name=...` | Perform a bounded live provider connectivity/model-discovery probe | 404 unknown provider; failures are normally returned as `ok: false` evidence | | `GET /api/provider-quotas` | Read provider quota reports; `refresh=1` forces refresh | — | diff --git a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md index 35d84e020a..6bfe9d9546 100644 --- a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md @@ -17,7 +17,7 @@ pool'ами и контролируют каталог моделей, кото | --- | --- | --- | | `list` | `--json` | Показать настроенных провайдеров и оставшиеся записи registry. | | `add ` | `--adapter `, `--base-url `, `--api-key `, `--default-model `, `--set-default`, `--force`, `--json`, `--sync` | Добавить registry/custom-провайдера. `--force` перезаписывает; `--sync` обновляет живой прокси в human-output mode. | -| `edit ` | provider field flags, `--json` | Изменить валидированные live-поля провайдера, не заменяя key-pool'ы. | +| `edit ` | provider field flags, `--headers `, `--json` | Изменить валидированные live-поля провайдера, не заменяя key-pool'ы. `--headers` объединяет пользовательские request-header'ы; передайте `{}` или `-`, чтобы очистить их. | | `test ` | `--json` | Пробный запрос к реальному upstream model-endpoint'у. | | `show ` | `--json` | Показать конфиг с замаскированными API-key'ами. | | `remove ` | `--json` | Удалить не-default-провайдера; последний провайдер удалить нельзя. | diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index 4b0b962d87..dbd13ac09a 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -185,7 +185,7 @@ Endpoint'ы storage cleanup могут перемещать или навсег | --- | --- | --- | | `GET /api/providers` | Список redacted provider config'ов и состояния discovery | — | | `POST /api/providers` | Добавить или заменить одного валидированного провайдера и при желании сделать его default | 400 invalid/dangerous destination or config; 409 namespace collision | -| `PATCH /api/providers?name=...` | Обновить допустимые поля провайдера, enabled/default state или OpenAI account mode | 400 invalid field or transition; 404 unknown provider | +| `PATCH /api/providers?name=...` | Обновить допустимые поля провайдера (включая объединяемый блок `headers`), enabled/default state или OpenAI account mode | 400 invalid field or transition; 404 unknown provider | | `DELETE /api/providers?name=...` | Удалить провайдера, при возможности переназначив default | 404 unknown provider; 409 `last_provider`; 409 `provider_has_dependent_combos` | | `POST /api/providers/test?name=...` | Выполнить ограниченный live-probe connectivity/model-discovery для провайдера | 404 unknown provider; сбои обычно возвращаются как evidence с `ok: false` | | `GET /api/provider-quotas` | Прочитать отчёты по provider quota; `refresh=1` форсирует refresh | — | diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md index 31446c048a..720b63a697 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md @@ -16,7 +16,7 @@ description: 提供方配置、凭据、配额,以及模型目录命令。 | --- | --- | --- | | `list` | `--json` | 列出已配置的提供方以及剩余的注册表条目。 | | `add ` | `--adapter `, `--base-url `, `--api-key `, `--default-model `, `--set-default`, `--force`, `--json`, `--sync` | 添加一个注册表/自定义提供方。`--force` 会覆盖;`--sync` 会在有人类输出模式运行的代理上刷新配置。 | -| `edit ` | 提供方字段标志,`--json` | 在不替换密钥池的情况下,编辑经过校验的在线提供方字段。 | +| `edit ` | 提供方字段标志,`--headers `,`--json` | 在不替换密钥池的情况下,编辑经过校验的在线提供方字段。`--headers` 会合并自定义请求头;传入 `{}` 或 `-` 可清空。 | | `test ` | `--json` | 探测真实的上游模型端点。 | | `show ` | `--json` | 显示已屏蔽 API 密钥的配置。 | | `remove ` | `--json` | 移除一个非默认提供方;最后一个提供方不能被移除。 | diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index 704515d2c0..8f68041943 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -166,7 +166,7 @@ Authorization: Bearer | --- | --- | --- | | `GET /api/providers` | 列出已脱敏的 provider 配置和发现状态 | — | | `POST /api/providers` | 添加或替换一个已验证的 provider,并可选地将其设为默认 | 400 目标或配置无效/危险;409 命名空间冲突 | -| `PATCH /api/providers?name=...` | 更新允许的 provider 字段、启用/默认状态,或 OpenAI 账户模式 | 400 字段或转换无效;404 未知 provider | +| `PATCH /api/providers?name=...` | 更新允许的 provider 字段(包括合并的 `headers` 块)、启用/默认状态,或 OpenAI 账户模式 | 400 字段或转换无效;404 未知 provider | | `DELETE /api/providers?name=...` | 删除一个 provider,并在可能时重新分配默认值 | 404 未知 provider;409 `last_provider`;409 `provider_has_dependent_combos` | | `POST /api/providers/test?name=...` | 执行一个有上限的在线 provider 连通性/模型发现探测 | 404 未知 provider;失败通常以 `ok: false` 证据返回 | | `GET /api/provider-quotas` | 读取 provider 配额报告;`refresh=1` 会强制刷新 | — | diff --git a/src/cli/provider-runtime.ts b/src/cli/provider-runtime.ts index 7a90666514..95e2479bf7 100644 --- a/src/cli/provider-runtime.ts +++ b/src/cli/provider-runtime.ts @@ -16,7 +16,8 @@ const USAGE = `Usage: ocx provider edit [--adapter ] [--base-url ] [--default-model ] [--auth-mode ] [--note ] [--api-key-transport ] - [--enabled ] [--live-models ] [--allow-private-network ] [--json] + [--headers ] [--enabled ] [--live-models ] + [--allow-private-network ] [--json] ocx provider test [--json] ocx provider quota [--refresh] [--json] ocx provider presets [--json] @@ -39,6 +40,7 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise { const authMode = cleared(takeOption(args, "--auth-mode")); const note = cleared(takeOption(args, "--note")); const apiKeyTransport = cleared(takeOption(args, "--api-key-transport")); + const headers = takeOption(args, "--headers"); const enabled = takeBooleanOption(args, "--enabled"); const liveModels = takeBooleanOption(args, "--live-models"); const allowPrivateNetwork = takeBooleanOption(args, "--allow-private-network"); @@ -49,6 +51,21 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise { if (authMode !== undefined) patch.authMode = authMode; if (note !== undefined) patch.note = note; if (apiKeyTransport !== undefined) patch.apiKeyTransport = apiKeyTransport; + if (headers !== undefined) { + if (headers === "-") { + patch.headers = null; + } else { + let parsed: unknown; + try { parsed = JSON.parse(headers); } catch { throw new CliUsageError("--headers must be valid JSON"); } + if (parsed === null) { + patch.headers = null; + } else if (typeof parsed !== "object" || Array.isArray(parsed)) { + throw new CliUsageError("--headers must be a JSON object like {\"X-Custom\":\"value\"}"); + } else { + patch.headers = parsed; + } + } + } if (enabled !== undefined) patch.disabled = !enabled; if (liveModels !== undefined) patch.liveModels = liveModels; if (allowPrivateNetwork !== undefined) patch.allowPrivateNetwork = allowPrivateNetwork; diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 022c0ece57..99bd301d0d 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -286,6 +286,22 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; + } + touched = true; + } + if (!touched) return jsonResponse({ error: "no recognized fields to update" }, 400); // A disabled-only toggle preserves the v2 fast lane for non-openai providers: it changes diff --git a/tests/cli-headless-parity.test.ts b/tests/cli-headless-parity.test.ts index b0a02e86f2..9b5a563106 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -119,6 +119,36 @@ describe("headless GUI parity CLI", () => { }]); }); + test("provider edit --headers sends the parsed block and - clears it", async () => { + const runtime = fakeRuntime(); + const code = await handleProviderRuntimeCommand("edit", [ + "agw", "--headers", '{"x-app":"cli","anthropic-version":"2023-06-01"}', "--json", + ], runtime.deps); + expect(code).toBe(0); + expect(runtime.requests).toEqual([{ + path: "/api/providers?name=agw", + method: "PATCH", + body: { headers: { "x-app": "cli", "anthropic-version": "2023-06-01" } }, + }]); + + const clearRuntime = fakeRuntime(); + const clearCode = await handleProviderRuntimeCommand("edit", ["agw", "--headers", "-", "--json"], clearRuntime.deps); + expect(clearCode).toBe(0); + expect(clearRuntime.requests[0]?.body).toEqual({ headers: null }); + }); + + test("provider edit rejects malformed --headers JSON without a request", async () => { + const runtime = fakeRuntime(); + const code = await handleProviderRuntimeCommand("edit", ["agw", "--headers", "{not json"], runtime.deps); + expect(code).toBe(2); + expect(runtime.requests).toEqual([]); + + const arrayRuntime = fakeRuntime(); + const arrayCode = await handleProviderRuntimeCommand("edit", ["agw", "--headers", '["x-app"]'], arrayRuntime.deps); + expect(arrayCode).toBe(2); + expect(arrayRuntime.requests).toEqual([]); + }); + test("provider test treats a static catalog as neutral", async () => { const runtime = fakeRuntime(() => ({ applicable: false, reason: "static_catalog", latencyMs: 0 })); const code = await handleProviderRuntimeCommand("test", ["google-antigravity", "--json"], runtime.deps); diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index cf8195b0d4..d5667626de 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -1951,6 +1951,69 @@ describe("provider management validation", () => { // Unknown-only bodies are rejected. expect((await patch("extra", { bogus: 1 }))?.status).toBe(400); }); + test("provider PATCH manages custom headers with merge and clear semantics", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { + openai: { ...canonicalDirect }, + agw: { adapter: "openai-chat", baseUrl: "https://agw.example.test/v1", apiKey: "sk-agw" }, + }, + }; + saveConfig(liveConfig); + let catalogRefreshes = 0; + const patch = async (name: string, body: unknown) => { + const req = new Request(`http://127.0.0.1/api/providers?name=${name}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return handleManagementAPI(req, new URL(req.url), liveConfig, { + refreshCodexCatalog: async () => { catalogRefreshes += 1; }, + }); + }; + + // Set a fresh headers block. + const set = await patch("agw", { headers: { "X-Custom": "v1", "anthropic-version": "2023-06-01" } }); + expect(set?.status).toBe(200); + expect(liveConfig.providers.agw.headers).toEqual({ "X-Custom": "v1", "anthropic-version": "2023-06-01" }); + + // Later patches merge, so adding one fingerprint header never drops the rest. + const merge = await patch("agw", { headers: { "x-app": "cli" } }); + expect(merge?.status).toBe(200); + expect(liveConfig.providers.agw.headers).toEqual({ + "X-Custom": "v1", + "anthropic-version": "2023-06-01", + "x-app": "cli", + }); + + // null and empty object both clear the whole block. + expect((await patch("agw", { headers: null }))?.status).toBe(200); + expect(liveConfig.providers.agw.headers).toBeUndefined(); + expect((await patch("agw", { headers: { "X-A": "b" } }))?.status).toBe(200); + expect((await patch("agw", { headers: {} }))?.status).toBe(200); + expect(liveConfig.providers.agw.headers).toBeUndefined(); + + // Invalid shapes, sensitive headers, CRLF values, and non-string values are rejected. + for (const invalid of [ + "nope", + [], + { Authorization: "Bearer sk" }, + { "X-Bad": "a\r\nb" }, + { "bad name": "v" }, + { "X-N": 42 }, + ]) { + const rejected = await patch("agw", { headers: invalid }); + expect(rejected?.status).toBe(400); + } + expect(liveConfig.providers.agw.headers).toBeUndefined(); + expect(catalogRefreshes).toBeGreaterThan(0); + }); test("provider context-cap API persists toggles and annotates model rows", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); From e56dfbdc1bc8525805495c694ca2a56d78fd540b Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:53:01 +0800 Subject: [PATCH 2/4] fix(provider): address headers PATCH review threads - redact --headers values from CLI usage errors so tokens never hit stderr (P1) - merge header blocks case-insensitively so a casing-only PATCH replaces the existing key instead of leaving a combined duplicate for Headers (P2) - restore registry static headers when clearing custom headers so transports like opencode-free keep their client marker (P2) - re-apply the field mask under the config mutation lock immediately before saving so concurrent PATCHes merge instead of clobbering each other (major) - regression tests for redaction, casing merge, static-header clear, and concurrent PATCH persistence --- src/cli/runtime-api.ts | 7 +- src/server/management/provider-routes.ts | 316 ++++++++++++------- tests/cli-headless-parity.test.ts | 38 ++- tests/management-provider-validation.test.ts | 109 +++++++ 4 files changed, 346 insertions(+), 124 deletions(-) diff --git a/src/cli/runtime-api.ts b/src/cli/runtime-api.ts index cd65422da3..a4753bbe58 100644 --- a/src/cli/runtime-api.ts +++ b/src/cli/runtime-api.ts @@ -127,15 +127,16 @@ export function csv(value: string | undefined): string[] | undefined { } /** - * Options whose VALUE is a credential, listed here so a parse error never - * prints one. + * Options whose VALUE is a credential (or can carry one), listed here so a parse + * error never prints one. `--headers` belongs on the list because custom header + * values may contain tokens or account identifiers. * * `takeOption` only understands `--flag value`. `--flag=value` therefore falls * through to `rejectArgs`, which reports the offending argument verbatim — for * `--code=https://…?code=SECRET` that writes the authorization code to stderr, * which is the exact exposure the stdin path exists to avoid. */ -const SECRET_OPTIONS = ["--code"]; +const SECRET_OPTIONS = ["--code", "--headers"]; /** * Replace credential values before they are reported back. diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 99bd301d0d..10e51da04d 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -11,6 +11,7 @@ import { providerBaseUrlConfigError, providerHeadersConfigError, saveConfigPreservingClaudeCode, + withConfigMutationLockSync, } from "../../config"; import { clearLoginState, @@ -27,7 +28,7 @@ import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; import { ProviderOutboundPolicyError, providerOutboundGet, providerRedirectError } from "../../lib/provider-outbound"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; import { deriveProviderPresets } from "../../providers/derive"; -import { providerCodexAccountMode } from "../../providers/registry"; +import { providerCodexAccountMode, providerMatchesRegistryTransport } from "../../providers/registry"; import { extractModelEnvelopeRows, extractProviderModelItems, @@ -70,6 +71,167 @@ import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, C import type { ManagementContext } from "./context"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +type ProviderPatchApplication = + | { error: string } + | { + next: OcxProviderConfig; + touched: boolean; + editorTouched: boolean; + enablingOpenAi: boolean; + headersTouched: boolean; + }; + +/** + * Apply the recognized PATCH field mask onto a provider copy. The caller runs this once + * for validation and again inside the config mutation lock against the newest provider, + * so a concurrent PATCH cannot be erased by a save of a stale snapshot. Only synchronous + * checks live here; the async destination probe stays in the route. + */ +function applyProviderPatchFields( + name: string, + provider: OcxProviderConfig, + rawBody: Record, + keys: string[], + config: OcxConfig, +): ProviderPatchApplication { + const next: OcxProviderConfig = { ...provider }; + let touched = false; + let headersTouched = false; + + if (Object.hasOwn(rawBody, "disabled")) { + if (typeof rawBody.disabled !== "boolean") return { error: "disabled must be a boolean" }; + if (rawBody.disabled && name === config.defaultProvider) { + return { error: "cannot disable the default provider; set another default first" }; + } + next.disabled = rawBody.disabled; + touched = true; + } + if (Object.hasOwn(rawBody, "adapter")) { + if (typeof rawBody.adapter !== "string" || !rawBody.adapter.trim()) return { error: "adapter must be a non-empty string" }; + next.adapter = rawBody.adapter.trim(); + touched = true; + } + if (Object.hasOwn(rawBody, "baseUrl")) { + if (typeof rawBody.baseUrl !== "string" || !rawBody.baseUrl.trim()) return { error: "baseUrl must be a non-empty string" }; + next.baseUrl = rawBody.baseUrl.trim(); + touched = true; + } + if (Object.hasOwn(rawBody, "defaultModel")) { + if (typeof rawBody.defaultModel !== "string") return { error: "defaultModel must be a string" }; + const dm = rawBody.defaultModel.trim(); + if (dm) next.defaultModel = dm; + else delete next.defaultModel; + touched = true; + } + if (Object.hasOwn(rawBody, "authMode")) { + if (typeof rawBody.authMode !== "string") return { error: "authMode must be a string" }; + const mode = rawBody.authMode.trim(); + if (mode === "key" || mode === "forward" || mode === "oauth" || mode === "local") { + next.authMode = mode; + touched = true; + } else if (mode === "") { + delete next.authMode; + touched = true; + } else { + return { error: "authMode must be key, forward, oauth, or local" }; + } + } + if (Object.hasOwn(rawBody, "apiKeyTransport")) { + const transport = rawBody.apiKeyTransport; + if (transport === "x-api-key" || transport === "bearer") { + next.apiKeyTransport = transport; + touched = true; + } else if (transport === "") { + delete next.apiKeyTransport; + touched = true; + } else { + return { error: "apiKeyTransport must be x-api-key, bearer, or empty to clear" }; + } + } + if (Object.hasOwn(rawBody, "note")) { + if (typeof rawBody.note !== "string") return { error: "note must be a string" }; + const note = rawBody.note.trim(); + if (note) next.note = note; + else delete next.note; + touched = true; + } + if (Object.hasOwn(rawBody, "allowPrivateNetwork")) { + if (typeof rawBody.allowPrivateNetwork !== "boolean") return { error: "allowPrivateNetwork must be a boolean" }; + next.allowPrivateNetwork = rawBody.allowPrivateNetwork; + touched = true; + } + if (Object.hasOwn(rawBody, "liveModels")) { + if (typeof rawBody.liveModels !== "boolean") return { error: "liveModels must be a boolean" }; + next.liveModels = rawBody.liveModels; + touched = true; + } + + // headers is the one object-valued field in the mask. PATCH semantics merge it + // shallowly into the existing block so a single fingerprint header can be added + // without wiping the rest; null or an empty object clears user-managed headers. + if (Object.hasOwn(rawBody, "headers")) { + const headersValue = rawBody.headers; + if (headersValue === null || (isPlainRecord(headersValue) && Object.keys(headersValue).length === 0)) { + // Registry-owned static metadata (e.g. opencode-free's x-opencode-client marker) + // is not user-managed: restoring it keeps the upstream transport intact after a + // clear instead of deleting the whole block. + const entry = getProviderRegistryEntry(name); + if (entry?.staticHeaders && providerMatchesRegistryTransport(name, next)) { + next.headers = { ...entry.staticHeaders }; + } else { + delete next.headers; + } + } else { + if (!isPlainRecord(headersValue)) return { error: "headers must be an object" }; + const headersError = providerHeadersConfigError(headersValue); + if (headersError) return { error: headersError }; + // Header names are case-insensitive on the wire. Drop any existing key whose + // lowercase name collides with an incoming one, or Headers normalization would + // send a combined "x-custom: v1, v2" value upstream. + const incoming = new Map( + Object.entries(headersValue as Record).map(([key, value]) => [key.toLowerCase(), [key, value] as const]), + ); + const merged: Record = {}; + for (const [key, value] of Object.entries(next.headers ?? {})) { + if (!incoming.has(key.toLowerCase())) merged[key] = value; + } + for (const [key, value] of incoming.values()) merged[key] = value; + next.headers = merged; + } + touched = true; + headersTouched = true; + } + + if (!touched) return { error: "no recognized fields to update" }; + + // A disabled-only toggle preserves the v2 fast lane for non-openai providers: it changes + // routing eligibility, not the provider shape. Re-enabling `openai` is different — a + // malformed disabled row must not come back online unchanged, so canonicalize/reject + // against the same built-in gate used by mode PATCH and POST. + const editorTouched = keys.some(key => key !== "disabled"); + const enablingOpenAi = name === "openai" + && Object.hasOwn(rawBody, "disabled") + && rawBody.disabled === false + && provider.disabled === true; + if (!editorTouched && enablingOpenAi) { + if (!isCanonicalOpenAiForwardProvider(next)) { + return { error: "provider openai must be the canonical built-in provider" }; + } + // Persist the byte-identical canonical URL so config.ts startup checks (case-sensitive) + // accept the row after we fill mode. Equivalent hosts like CHATGPT.com/:443 normalize here. + next.baseUrl = CODEX_FORWARD_BASE_URL; + // Fill missing mode so a disabled canonical row becomes a complete live openai entry. + if (next.codexAccountMode !== "pool" && next.codexAccountMode !== "direct") { + next.codexAccountMode = "pool"; + } + if (next.disabled === false) delete next.disabled; + // Canonical openai never uses private-network opt-in; drop a stale flag that + // was ignored for the DNS probe so it cannot linger on the live row. + delete next.allowPrivateNetwork; + } + return { next, touched, editorTouched, enablingOpenAi, headersTouched }; +} + export async function handleProviderRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx; @@ -213,122 +375,16 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; - } - touched = true; - } - - if (!touched) return jsonResponse({ error: "no recognized fields to update" }, 400); - - // A disabled-only toggle preserves the v2 fast lane for non-openai providers: it changes - // routing eligibility, not the provider shape. Re-enabling `openai` is different — a - // malformed disabled row must not come back online unchanged, so canonicalize/reject - // against the same built-in gate used by mode PATCH and POST. - const editorTouched = keys.some(key => key !== "disabled"); - const enablingOpenAi = name === "openai" - && Object.hasOwn(rawBody, "disabled") - && rawBody.disabled === false - && config.providers[name]?.disabled === true; - if (editorTouched) { + if (applied.editorTouched) { const providerError = providerManagementConfigError(name, next); if (providerError) return jsonResponse({ error: providerError }, 400); const resolvedError = await providerDestinationResolvedError(name, next); if (resolvedError) return jsonResponse({ error: resolvedError }, 400); - } else if (enablingOpenAi) { - if (!isCanonicalOpenAiForwardProvider(next)) { - return jsonResponse({ error: "provider openai must be the canonical built-in provider" }, 400); - } - // Persist the byte-identical canonical URL so config.ts startup checks (case-sensitive) - // accept the row after we fill mode. Equivalent hosts like CHATGPT.com/:443 normalize here. - next.baseUrl = CODEX_FORWARD_BASE_URL; - // Fill missing mode so a disabled canonical row becomes a complete live openai entry. - if (next.codexAccountMode !== "pool" && next.codexAccountMode !== "direct") { - next.codexAccountMode = "pool"; - } + } else if (applied.enablingOpenAi) { // Same DNS gate as POST: Clash fake-IP only. Never honor a persisted // allowPrivateNetwork on this path — it must not bypass the built-in guard. const resolvedError = await providerDestinationResolvedError( @@ -337,17 +393,37 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { + const replay = applyProviderPatchFields(name, config.providers[name]!, rawBody, keys, config); + if ("error" in replay) { + replayError = replay.error; + return; + } + if (replay.editorTouched) { + const syncError = providerManagementConfigError(name, replay.next); + if (syncError) { + replayError = syncError; + return; + } + } else if (replay.enablingOpenAi && !isCanonicalOpenAiForwardProvider(replay.next)) { + replayError = "provider openai must be the canonical built-in provider"; + return; + } + // A PATCH that managed headers owns the resulting block: the clear path restores + // registry static headers, so exact-match stripping must not erase them again. + config.providers[name] = replay.headersTouched ? replay.next : stripRegistryOnlyStaticHeaders(name, replay.next); + saveConfigPreservingClaudeCode(config); + }); + if (replayError !== undefined) return jsonResponse({ error: replayError }, 409); reconcileLiveStateStores(); - if (editorTouched) { + if (applied.editorTouched) { const { clearModelCache } = await import("../../codex/model-cache"); clearModelCache(name); } diff --git a/tests/cli-headless-parity.test.ts b/tests/cli-headless-parity.test.ts index 9b5a563106..0064c64a66 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -149,6 +149,42 @@ describe("headless GUI parity CLI", () => { expect(arrayRuntime.requests).toEqual([]); }); + test("provider edit usage errors redact --headers values", async () => { + const errorSpy = spyOn(console, "error"); + try { + // `--headers={...}` is not consumed by takeOption, so the value lands in the + // leftovers rejectArgs reports. Header values can carry tokens, so they must + // never be echoed to stderr. + const runtime = fakeRuntime(); + const code = await handleProviderRuntimeCommand( + "edit", + ["agw", "--headers={\"x-app\":\"cli\",\"X-Token\":\"sk-leak-value\"}"], + runtime.deps, + ); + expect(code).toBe(2); + expect(runtime.requests).toEqual([]); + const stderr = errorSpy.mock.calls.map(call => String(call[0])).join("\n"); + expect(stderr).toContain("--headers="); + expect(stderr).not.toContain("sk-leak-value"); + expect(stderr).not.toContain("X-Token"); + + errorSpy.mockClear(); + // Repeating the option leaves the second flag AND its value in the leftovers. + const repeatRuntime = fakeRuntime(); + const repeatCode = await handleProviderRuntimeCommand( + "edit", + ["agw", "--headers", "{\"X-Token\":\"sk-leak-value\"}", "--headers", "{\"X-Other\":\"v\"}"], + repeatRuntime.deps, + ); + expect(repeatCode).toBe(2); + const repeatStderr = errorSpy.mock.calls.map(call => String(call[0])).join("\n"); + expect(repeatStderr).toContain("--headers "); + expect(repeatStderr).not.toContain("sk-leak-value"); + } finally { + errorSpy.mockRestore(); + } + }); + test("provider test treats a static catalog as neutral", async () => { const runtime = fakeRuntime(() => ({ applicable: false, reason: "static_catalog", latencyMs: 0 })); const code = await handleProviderRuntimeCommand("test", ["google-antigravity", "--json"], runtime.deps); diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index d5667626de..245d6f704a 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -2014,6 +2014,115 @@ describe("provider management validation", () => { expect(liveConfig.providers.agw.headers).toBeUndefined(); expect(catalogRefreshes).toBeGreaterThan(0); }); + test("provider PATCH merges headers case-insensitively", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { + openai: { ...canonicalDirect }, + hdr: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:9/v1", allowPrivateNetwork: true }, + }, + }; + saveConfig(liveConfig); + const patch = async (name: string, body: unknown) => { + const req = new Request(`http://127.0.0.1/api/providers?name=${name}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return handleManagementAPI(req, new URL(req.url), liveConfig, { + refreshCodexCatalog: async () => {}, + }); + }; + + expect((await patch("hdr", { headers: { "X-Foo": "old" } }))?.status).toBe(200); + expect(liveConfig.providers.hdr.headers).toEqual({ "X-Foo": "old" }); + // A casing-only update must replace the existing key, not leave both behind for + // Headers normalization to combine into "x-foo: old, new". + expect((await patch("hdr", { headers: { "x-foo": "new" } }))?.status).toBe(200); + expect(liveConfig.providers.hdr.headers).toEqual({ "x-foo": "new" }); + expect(Object.keys(liveConfig.providers.hdr.headers!)).toHaveLength(1); + }); + test("provider PATCH clear keeps registry static headers", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { + openai: { ...canonicalDirect }, + "opencode-free": { + adapter: "openai-chat", + baseUrl: "https://opencode.ai/zen/v1", + authMode: "key", + allowPrivateNetwork: true, + headers: { "x-opencode-client": "desktop", "X-User": "v1" }, + }, + }, + }; + saveConfig(liveConfig); + const patch = async (name: string, body: unknown) => { + const req = new Request(`http://127.0.0.1/api/providers?name=${name}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return handleManagementAPI(req, new URL(req.url), liveConfig, { + refreshCodexCatalog: async () => {}, + }); + }; + + // Clearing user-managed headers must not delete the registry-owned static + // metadata (opencode-free's x-opencode-client marker) the transport relies on. + expect((await patch("opencode-free", { headers: null }))?.status).toBe(200); + expect(liveConfig.providers["opencode-free"].headers).toEqual({ "x-opencode-client": "desktop" }); + const saved = JSON.parse(readFileSync(join(TEST_DIR, "config.json"), "utf8")) as OcxConfig; + expect(saved.providers["opencode-free"]?.headers).toEqual({ "x-opencode-client": "desktop" }); + }); + test("concurrent provider PATCHes merge different headers", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { + openai: { ...canonicalDirect }, + hdr: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:9/v1", allowPrivateNetwork: true }, + }, + }; + saveConfig(liveConfig); + const patch = async (name: string, body: unknown) => { + const req = new Request(`http://127.0.0.1/api/providers?name=${name}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return handleManagementAPI(req, new URL(req.url), liveConfig, { + refreshCodexCatalog: async () => {}, + }); + }; + + // Both requests snapshot the same provider before either saves; the lock-scoped + // re-apply must merge them instead of letting the later save erase the first. + const [first, second] = await Promise.all([ + patch("hdr", { headers: { "X-A": "a" } }), + patch("hdr", { headers: { "X-B": "b" } }), + ]); + expect(first?.status).toBe(200); + expect(second?.status).toBe(200); + expect(liveConfig.providers.hdr.headers).toEqual({ "X-A": "a", "X-B": "b" }); + }); test("provider context-cap API persists toggles and annotates model rows", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); From 2640c44cfed63471dc97c5da47b8b143d1a16e74 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 5 Aug 2026 07:20:55 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(providers):=20expose=20hasHeaders=20on?= =?UTF-8?q?=20GET=20/api/providers=20=E2=80=94=20presence=20only=20(#959)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopts PR #961's headers management (authorship preserved) with one hardening slice: the providers list reports whether a provider has custom headers but never their names or values, with a sentinel serialization test. --- src/server/management/provider-routes.ts | 2 ++ tests/management-provider-validation.test.ts | 33 ++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 10e51da04d..cab9390a85 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -244,6 +244,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise ({ name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel, hasApiKey: !!p.apiKey, + // Presence only (#959 review): header names and values never leave the process. + hasHeaders: !!p.headers && Object.keys(p.headers).length > 0, allowPrivateNetwork: p.allowPrivateNetwork === true, liveModels: p.liveModels !== false, models: p.models ?? [], diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 245d6f704a..8598ec33da 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -2014,6 +2014,39 @@ describe("provider management validation", () => { expect(liveConfig.providers.agw.headers).toBeUndefined(); expect(catalogRefreshes).toBeGreaterThan(0); }); + + test("GET /api/providers exposes hasHeaders but never header names or values (#959)", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const sentinelName = "x-fingerprint-sentinel"; + const sentinelValue = "sentinel-secret-header-value"; + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { + openai: { ...canonicalDirect }, + hdr: { + adapter: "openai-chat", + baseUrl: "http://127.0.0.1:9/v1", + allowPrivateNetwork: true, + headers: { [sentinelName]: sentinelValue }, + }, + }, + }; + saveConfig(liveConfig); + const req = new Request("http://127.0.0.1/api/providers", { method: "GET" }); + const res = await handleManagementAPI(req, new URL(req.url), liveConfig, {}); + expect(res?.status).toBe(200); + const raw = await res!.text(); + const rows = JSON.parse(raw) as { name: string; hasHeaders?: boolean }[]; + expect(rows.find(row => row.name === "hdr")?.hasHeaders).toBe(true); + expect(rows.find(row => row.name === "openai")?.hasHeaders).toBe(false); + expect(raw).not.toContain(sentinelName); + expect(raw).not.toContain(sentinelValue); + }); test("provider PATCH merges headers case-insensitively", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); From 870f4775fb52eb6343df1d481ca7ce94dd02bdfe Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 5 Aug 2026 09:58:46 +0900 Subject: [PATCH 4/4] docs(providers): state the non-secret contract for custom headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1033 review blocker. The validator already rejects standard credential header names and points at apiKey/authMode, but nothing said so where a user would read it, and a code comment claimed header values 'may contain tokens' — which reads as sanction for exactly the use the validator refuses. Documents the contract in all five locales: --headers is non-secret request metadata; an arbitrary name like X-My-Token cannot be recognized by the validator, so the boundary is the user's to respect; and names the two concrete exposures (the JSON is argv, so it reaches shell history and the process list before any redaction runs, and header values persist in config.json in cleartext unlike API keys). The comment is corrected to say the entry is defensive rather than a supported credential channel. --- .../ja/reference/cli/providers-accounts.md | 17 +++++++++++++++++ .../ko/reference/cli/providers-accounts.md | 17 +++++++++++++++++ .../docs/reference/cli/providers-accounts.md | 19 +++++++++++++++++++ .../ru/reference/cli/providers-accounts.md | 19 +++++++++++++++++++ .../zh-cn/reference/cli/providers-accounts.md | 14 ++++++++++++++ src/cli/runtime-api.ts | 6 ++++-- 6 files changed, 90 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md index d9af8e1af2..da47276f06 100644 --- a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md @@ -35,6 +35,23 @@ ocx models --provider anthropic --json ocx models live --provider ark --json ``` +:::caution[カスタムヘッダーは認証情報の経路ではありません] +`--headers` は秘密ではないリクエストメタデータ用です — ルーティングヒント、テナントや +プロジェクトのセレクター、トレース ID など。認証情報を入れる場所ではなく、バリデーターは +標準的な認証ヘッダー名(`Authorization`、`X-Api-Key`、`Cookie` など)を +`apiKey` / `authMode` を使うよう案内して拒否します。 + +ただし `X-My-Token` のような任意の名前までは判別できないため、その境界は利用者が守る +必要があります。理由は 2 つです。 + +- JSON はコマンドライン引数なので、秘密を入れるとシェル履歴とプロセス一覧に残ります。 + CLI が何かを伏せるより先に、同じマシンの別プロセスが読み取れます。 +- ヘッダー値は `config.json` に平文で保存されます。専用の保存・マスキング経路を持つ + API キーとは異なります。 + +秘密にあたる値は `--api-key` か OAuth ログインを使ってください。 +::: + ## 認証 ### `ocx login ` diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index 5947c9cfef..3dec77c9a7 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -35,6 +35,23 @@ ocx models --provider anthropic --json ocx models live --provider ark --json ``` +:::caution[커스텀 헤더는 자격증명 통로가 아닙니다] +`--headers`는 비밀이 아닌 요청 메타데이터용입니다 — 라우팅 힌트, 테넌트나 프로젝트 +선택자, 추적 id 같은 것들이요. 인증 정보를 넣는 자리가 아니고, 검증기는 표준 자격증명 +헤더 이름(`Authorization`, `X-Api-Key`, `Cookie` 등)을 `apiKey` / `authMode`를 +쓰라는 안내와 함께 거부합니다. + +다만 `X-My-Token` 같은 임의 이름까지 알아볼 수는 없으니 그 경계는 사용자가 지켜야 +합니다. 이유는 두 가지입니다. + +- JSON이 명령줄 인자라서, 비밀이 들어가면 셸 히스토리와 프로세스 목록에 남습니다. + CLI가 무엇을 가리기도 전에 같은 머신의 다른 프로세스가 읽을 수 있습니다. +- 헤더 값은 `config.json`에 평문으로 저장됩니다. 별도 저장·마스킹 경로가 있는 + API 키와 다릅니다. + +비밀에 해당하는 값은 `--api-key`나 OAuth 로그인을 쓰세요. +::: + ## 인증 ### `ocx login ` diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index c109c3508b..b85548a0a0 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -36,6 +36,25 @@ ocx models --provider anthropic --json ocx models live --provider ark --json ``` +:::caution[Custom headers are not a credential channel] +`--headers` is for non-secret request metadata — routing hints, tenant or +project selectors, tracing ids. It is **not** a place to put authentication +material, and the validator rejects the standard credential header names +(`Authorization`, `X-Api-Key`, `Cookie`, and the rest) with a pointer to +`apiKey` / `authMode`. + +The validator cannot recognize an arbitrary name such as `X-My-Token`, so the +boundary is yours to respect. Two reasons it matters: + +- The JSON is a command-line argument, so a secret in it lands in shell history + and in the process list, where any other process on the machine can read it + before the CLI ever redacts anything. +- Header values are persisted in `config.json` in cleartext, unlike API keys, + which have their own storage and masking path. + +Use `--api-key` or an OAuth login for anything secret. +::: + ## Authentication ### `ocx login ` diff --git a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md index 6bfe9d9546..b7acda4d0f 100644 --- a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md @@ -37,6 +37,25 @@ ocx models --provider anthropic --json ocx models live --provider ark --json ``` +:::caution[Пользовательские заголовки — не канал для учётных данных] +`--headers` предназначен для несекретных метаданных запроса — подсказок +маршрутизации, селекторов тенанта или проекта, идентификаторов трассировки. Это не +место для аутентификационных данных: валидатор отклоняет стандартные имена +заголовков с учётными данными (`Authorization`, `X-Api-Key`, `Cookie` и другие), +указывая на `apiKey` / `authMode`. + +Произвольное имя вроде `X-My-Token` валидатор распознать не может, поэтому границу +соблюдает пользователь. Две причины, почему это важно: + +- JSON передаётся как аргумент командной строки, поэтому секрет попадает в историю + оболочки и в список процессов, где его прочитает любой другой процесс на машине — + ещё до того, как CLI что-либо скроет. +- Значения заголовков сохраняются в `config.json` открытым текстом, в отличие от + API-ключей с их собственным путём хранения и маскирования. + +Для всего секретного используйте `--api-key` или вход через OAuth. +::: + ## Аутентификация ### `ocx login ` diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md index 720b63a697..49a32ff14c 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md @@ -36,6 +36,20 @@ ocx models --provider anthropic --json ocx models live --provider ark --json ``` +:::caution[自定义请求头不是凭据通道] +`--headers` 用于非机密的请求元数据 —— 路由提示、租户或项目选择器、追踪 ID 等。它不是 +存放认证信息的地方,校验器会拒绝标准凭据请求头名称(`Authorization`、`X-Api-Key`、 +`Cookie` 等),并提示改用 `apiKey` / `authMode`。 + +但校验器无法识别 `X-My-Token` 这类任意名称,因此这条边界需要你自己遵守。原因有两点: + +- 该 JSON 是命令行参数,机密会留在 shell 历史和进程列表中;在 CLI 做任何脱敏之前, + 同一台机器上的其他进程就能读到。 +- 请求头的值以明文保存在 `config.json` 中,这与拥有独立存储和脱敏路径的 API 密钥不同。 + +任何机密内容请使用 `--api-key` 或 OAuth 登录。 +::: + ## 认证 ### `ocx login ` diff --git a/src/cli/runtime-api.ts b/src/cli/runtime-api.ts index a4753bbe58..9c74fc7d1b 100644 --- a/src/cli/runtime-api.ts +++ b/src/cli/runtime-api.ts @@ -128,8 +128,10 @@ export function csv(value: string | undefined): string[] | undefined { /** * Options whose VALUE is a credential (or can carry one), listed here so a parse - * error never prints one. `--headers` belongs on the list because custom header - * values may contain tokens or account identifiers. + * error never prints one. `--headers` belongs on the list defensively: custom + * headers are documented as non-secret metadata and the validator rejects the + * standard credential names, but it cannot recognize an arbitrary one such as + * `X-My-Token`, so a parse error must not echo the value back either way. * * `takeOption` only understands `--flag value`. `--flag=value` therefore falls * through to `rejectArgs`, which reports the offending argument verbatim — for