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..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 @@ -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` |デフォルト以外のプロバイダーを削除します。最後のプロバイダーは削除できません。 | @@ -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/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..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 @@ -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` | 기본값이 아닌 제공자를 제거합니다. 마지막 제공자는 제거할 수 없습니다. | @@ -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/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..b85548a0a0 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. | @@ -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/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..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 @@ -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-провайдера; последний провайдер удалить нельзя. | @@ -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/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..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 @@ -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` | 移除一个非默认提供方;最后一个提供方不能被移除。 | @@ -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/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/cli/runtime-api.ts b/src/cli/runtime-api.ts index cd65422da3..9c74fc7d1b 100644 --- a/src/cli/runtime-api.ts +++ b/src/cli/runtime-api.ts @@ -127,15 +127,18 @@ 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 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 * `--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 022c0ece57..cab9390a85 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; @@ -82,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 ?? [], @@ -213,106 +377,16 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise 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( @@ -321,17 +395,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 b0a02e86f2..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"; @@ -119,6 +119,72 @@ 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 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 cf8195b0d4..8598ec33da 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -1951,6 +1951,211 @@ 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("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 }); + 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 });