diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 2b8545629c..817c5d3427 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -154,6 +154,8 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `unsafeAllowNativeLocalExec?` | `boolean` | Ancien booléen de Cursor, équivalent à `nativeLocalExec: "on"` uniquement lorsque le champ plus récent n'est pas défini. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Politique d'exécution locale de Cursor. `off` est la valeur par défaut ; actuellement, `codex-sandbox` échoue de manière sûre comme `off`. | +La création et le remplacement d’un fournisseur (`POST /api/providers`) valident `responsesPath` et `chatCompletionsPath` avant de modifier la configuration en mémoire ou sur disque. Les mêmes règles de chemin s’appliquent au chargement d’un fichier de configuration. + Les fournisseurs à clé API peuvent détenir une clé littérale ou une référence à une variable d'environnement. Les fournisseurs OAuth utilisent le magasin d'identifiants alimenté par `ocx login` ; le comportement de lancement de Claude Code avec abonnement est configuré sous [`claudeCode.authMode`](/fr/reference/configuration/server/#claude-code-claudecode). diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 9b5acfa274..4588819dc5 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -146,6 +146,8 @@ account を削除しても mapping は保持され、同じ id を再追加す | `unsafeAllowNativeLocalExec?` | `boolean` |カーソルのレガシー ブール値。新しいフィールドが設定されていない場合のみ、`nativeLocalExec: "on"` と同等です。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` |カーソルのローカル実行ポリシー。 `off` がデフォルトです。 `codex-sandbox` は現在、`off` と同様にフェールクローズされます。 | +プロバイダーの登録・置換(`POST /api/providers`)では、メモリやファイルの設定を変更する前に `responsesPath` と `chatCompletionsPath` を検証します。 設定ファイルの読み込みにも同じ経路の規則が適用されます。 + API キープロバイダーは、リテラルキーまたは環境参照を保持する場合があります。 OAuth プロバイダーは、`ocx login` によって設定された資格情報ストアを使用します。サブスクリプションに基づくクロード コードの起動動作は、[`claudeCode.authMode`](/reference/configuration/server/#claude-code) で構成されます。 ## プロバイダーによるアウトバウンドの安全性診断 diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index b6657ba58d..9453eafb9c 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -146,6 +146,8 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 레거시 불리언입니다. 더 새로운 필드가 설정되지 않았을 때만 `nativeLocalExec: "on"`과 같습니다. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 로컬 실행 정책입니다. 기본값은 `off`입니다. `codex-sandbox`는 현재 `off`처럼 실패를 닫습니다. | +공급자 등록·교체(`POST /api/providers`)는 `responsesPath`와 `chatCompletionsPath`를 검증한 뒤 메모리와 파일의 설정을 변경합니다. 설정 파일을 읽을 때도 같은 경로 규칙을 적용합니다. + API 키 공급자는 리터럴 키나 환경 참조를 둘 수 있습니다. OAuth 공급자는 `ocx login`으로 채워지는 자격 증명 저장소를 사용합니다. 구독 기반 Claude Code 실행 동작은 [`claudeCode.authMode`](/reference/configuration/server/#claude-code)에서 설정합니다. ## 공급자 진단용 외부 요청 안전성 diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 7eb8a12524..9841dbeeb6 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -224,6 +224,8 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor local-exec policy. `off` is default; `codex-sandbox` currently fails closed like `off`. | +Provider registration and replacement (`POST /api/providers`) validate `responsesPath` and `chatCompletionsPath` before changing live configuration or disk state. The same path rules apply when loading a configuration file. + With `webSearchBridge` enabled, a search continuation stays bound to the API-key selection that served the first request. Changing the selected key, its reference or resolved value, authentication mode, or base URL during search or provider pacing ends the turn with a bridge error before another diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index ca389d9ffd..c3436e57f9 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -159,6 +159,8 @@ cross-route credential fallback не существует. Строки API GPT- | `unsafeAllowNativeLocalExec?` | `boolean` | Legacy boolean Cursor, эквивалентен `nativeLocalExec: "on"` только если новое поле не задано. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Политика local-exec для Cursor. `off` — дефолт; `codex-sandbox` сейчас ведёт себя fail-closed как `off`. | +Регистрация и замена провайдера (`POST /api/providers`) проверяют `responsesPath` и `chatCompletionsPath` до изменения конфигурации в памяти или на диске. Те же правила путей применяются при загрузке файла конфигурации. + Провайдеры с API-key могут хранить literal key или environment-reference. OAuth-провайдеры используют credential store, заполняемый через `ocx login`; поведение subscription-backed launcher'а Claude Code настраивается через diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 3126b9046c..4d1df79e2e 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -160,6 +160,8 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor eski boolean değeri, yalnızca daha yeni alan ayarlanmadığında `nativeLocalExec: "on"` değerine eşdeğerdir. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor yerel yürütme politikası. `off` varsayılandır; `codex-sandbox` şu anda `off` gibi kapalı olarak başarısız olur. | +Sağlayıcı kaydı ve değiştirme (`POST /api/providers`), bellekteki veya diskteki yapılandırmayı değiştirmeden önce `responsesPath` ve `chatCompletionsPath` değerlerini doğrular. Aynı yol kuralları yapılandırma dosyası yüklenirken de uygulanır. + API anahtarı sağlayıcıları değişmez bir anahtar veya bir ortam referansı tutabilir. OAuth sağlayıcıları `ocx login` tarafından doldurulan kimlik bilgisi deposunu kullanır; abonelik destekli Claude Code başlatma davranışı diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index b28a5a2af4..9fcc59729f 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -146,6 +146,8 @@ selector,而不是分配一个新名称。 | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 旧布尔值;仅当更新字段未设置时,等同于 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本地执行策略。`off` 是默认值;`codex-sandbox` 目前会像 `off` 一样失败关闭。 | +注册或替换提供商(`POST /api/providers`)时,会先验证 `responsesPath` 和 `chatCompletionsPath`,再修改内存或磁盘中的配置。 加载配置文件时也适用同样的路径规则。 + API key 提供者可以持有字面量 key,或环境引用。OAuth 提供者使用由 `ocx login` 填充的凭据存储;基于订阅的 Claude Code 启动行为在 [`claudeCode.authMode`](/reference/configuration/server/#claude-code) 下配置。 ## 提供者诊断出站安全性 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 0680fa35aa..c6490f5b18 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -116,6 +116,8 @@ ocx models provider openrouter on | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 舊版布林值,僅在較新欄位未設定時等同於 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本機執行政策。`off` 為預設;`codex-sandbox` 目前像 `off` 般 fail closed。 | +註冊或替換供應商(`POST /api/providers`)時,會先驗證 `responsesPath` 和 `chatCompletionsPath`,再修改記憶體或磁碟中的設定。 載入設定檔時也適用相同的路徑規則。 + API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用由 `ocx login` 填入的憑證存放;訂閱支援的 Claude Code 啟動行為在 [`claudeCode.authMode`](/zh-tw/reference/configuration/server/#claude-code) 下設定。 ## 供應商診斷對外安全 diff --git a/src/config.ts b/src/config.ts index 4f851e4ffb..4bf26d7794 100644 --- a/src/config.ts +++ b/src/config.ts @@ -731,8 +731,12 @@ export { * Shared shape check for the two relative send-path overrides. `field` names the * offending key so the message stays specific to what the user actually wrote. */ -function providerRelativeSendPathConfigError(field: string, value: string | undefined): string | null { +export function providerRelativeSendPathConfigError( + field: "responsesPath" | "chatCompletionsPath", + value: unknown, +): string | null { if (value === undefined) return null; + if (typeof value !== "string") return `${field} must be a string`; if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) || value.includes("://")) { return `${field} must be a relative path without a URL scheme`; } diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index be6bfd3fca..86eec115ae 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -7,6 +7,7 @@ import { codexAutoStartEnabled, modelPreferHostedToolsConfigError, providerModelCostsConfigError, + providerRelativeSendPathConfigError, providerWebSearchBridgeConfigError, requestPacingConfigError, retryOn429PolicyConfigError, @@ -754,6 +755,10 @@ export function providerManagementConfigError( } const destinationError = providerDestinationConfigError(name, typed); if (destinationError) return `provider ${name} ${destinationError}`; + for (const field of ["responsesPath", "chatCompletionsPath"] as const) { + const sendPathError = providerRelativeSendPathConfigError(field, raw[field]); + if (sendPathError) return `provider ${JSON.stringify(redactSecretString(name))} ${sendPathError}`; + } const headersError = providerHeadersConfigError(typed.headers); if (headersError) return `provider ${name} ${headersError}`; const retryOn429Error = retryOn429PolicyConfigError(raw.retryOn429); diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 908633f265..b1b6ddbb5d 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -1,5 +1,7 @@ # Adapter Registry Authority +Management provider-validation calls use the [shared relative send-path validation](../config.md#provider-relative-send-paths) before persistence. + The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/catalog.md b/structure/catalog.md index 47a827426b..76b05a1fc4 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -1,5 +1,7 @@ # Model Catalog +Management provider-validation calls use the [shared relative send-path validation](config.md#provider-relative-send-paths) before persistence. + The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index f64a278757..db48923e38 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -1,5 +1,7 @@ # Claude Desktop Integration +Management provider-validation calls use the [shared relative send-path validation](../config.md#provider-relative-send-paths) before persistence. + The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/config.md b/structure/config.md index a4c0b97ead..245d409df7 100644 --- a/structure/config.md +++ b/structure/config.md @@ -228,6 +228,17 @@ hand-edited `config.json` must accept and reject the same provider shapes. > Decision record: [ADR-0020](decisions/ADR-0020-provider-validation-ownership.md) +## Provider relative send paths + +`src/config.ts` exports `providerRelativeSendPathConfigError` for the schema loader and +`src/server/auth-cors.ts` management validator. Both `responsesPath` and `chatCompletionsPath` +must be strings beginning with `/`, without a scheme, query or fragment; omission is allowed. +Provider registration/replacement rejects invalid values before DNS, persistence or catalog +refresh. Editor PATCH checks also validate retained paths when they revalidate a merged provider; +pacing-only and other existing validation bypasses are unchanged. No send-path PATCH setter is added. +`tests/server/management-provider-validation.test.ts` covers rejection without live/disk mutation +and valid-path persistence/reload through the actual management handler. + ## Restore `ocx stop`, `ocx restore` / `ocx eject`, `ocx service stop`, and `ocx service uninstall` must strip diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index fdbdee6b32..508582ee88 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -1,5 +1,7 @@ # Images Data Plane +Management provider-validation calls use the [shared relative send-path validation](../config.md#provider-relative-send-paths) before persistence. + The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index aa9aa15f52..1e95bd6cb0 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -1,5 +1,7 @@ # Inbound Compatibility Surfaces +Management provider-validation calls use the [shared relative send-path validation](../config.md#provider-relative-send-paths) before persistence. + The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 0496735405..867271a039 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,7 +1,7 @@ # GUI And Management API The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Management provider-validation calls use the [shared relative send-path validation](config.md#provider-relative-send-paths) before persistence. ## Dashboard serving diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index e9d8fb7c00..12759f3108 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -1,5 +1,7 @@ # Background Service And Sidecars +Management provider-validation calls use the [shared relative send-path validation](../config.md#provider-relative-send-paths) before persistence. + The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/overview.md b/structure/overview.md index 0151115adc..f60d7686e8 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -1,5 +1,7 @@ # Overview +Management provider-validation calls use the [shared relative send-path validation](config.md#provider-relative-send-paths) before persistence. + The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index a1824746dc..69702aa8de 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -1,5 +1,7 @@ # OpenAI Provider Account Modes +Management provider-validation calls use the [shared relative send-path validation](../config.md#provider-relative-send-paths) before persistence. + The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 0ca554f4e0..9712df2e0d 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -1,5 +1,7 @@ # xAI Grok Provider +Management provider-validation calls use the [shared relative send-path validation](../config.md#provider-relative-send-paths) before persistence. + The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/runtime.md b/structure/runtime.md index aa977c51df..e21b69ba05 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,5 +1,7 @@ # Runtime +Management provider-validation calls use the [shared relative send-path validation](config.md#provider-relative-send-paths) before persistence. + The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/subagents.md b/structure/subagents.md index 69047b076b..bc692b5f91 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -1,5 +1,7 @@ # Subagents And Multi-Agent Surface +Management provider-validation calls use the [shared relative send-path validation](config.md#provider-relative-send-paths) before persistence. + ## Plaintext V2 agent messages `src/responses/plaintext-v2-agent-messages.ts` owns the experimental, configuration-only diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index e758afeaf2..cefe643616 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -1,5 +1,7 @@ # Byte Accounting +Management provider-validation calls use the [shared relative send-path validation](../config.md#provider-relative-send-paths) before persistence. + How opencodex measures request and stream bytes without allocating copies solely to count them. These contracts are shared by request parsing, SSE rewriting, the provider adapters and the translator budget, which is why so many documents link here rather than restating them. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index b98bf57473..3d17b25139 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -1,5 +1,7 @@ # Transport Inventory +Management provider-validation calls use the [shared relative send-path validation](../config.md#provider-relative-send-paths) before persistence. + The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 1bc2b4de0d..a865ea7c34 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -1,7 +1,7 @@ # Responses Transport The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Management provider-validation calls use the [shared relative send-path validation](../config.md#provider-relative-send-paths) before persistence. Plaintext collaboration restoration treats a null namespace as absent, rejects non-string namespace types, and restores the native namespace/name pair before HTTP/WS delivery and continuation publication. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 0b54c97058..7be990e83d 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -1,5 +1,7 @@ # Streaming Health And WebSocket +Management provider-validation calls use the [shared relative send-path validation](../config.md#provider-relative-send-paths) before persistence. + The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts index e4ecb0625d..c824fa566d 100644 --- a/tests/server/management-provider-validation.test.ts +++ b/tests/server/management-provider-validation.test.ts @@ -142,6 +142,94 @@ afterEach(() => { if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); }); +describe("relative send paths at the management write boundary", () => { + const provider = { adapter: "openai-responses" as const, baseUrl: "https://relay.example.test/v1" }; + const fields = ["responsesPath", "chatCompletionsPath"] as const; + + test.each(fields)("rejects invalid %s values through the shared management validator", field => { + expect(providerManagementConfigError("relay", provider)).toBeNull(); + expect(providerManagementConfigError("relay", { ...provider, [field]: "/custom/send" })).toBeNull(); + for (const value of ["@other.example.test/send", "https://other.example.test/send", "/send?query=1", "/send#fragment", "", 42, null, {}]) { + expect(providerManagementConfigError("relay", { ...provider, [field]: value })).toContain(field); + } + }); + + for (const field of fields) { + for (const mode of ["create", "replace"]) { + test(`${mode} rejects invalid ${field} before changing memory or disk`, async () => { + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const cfg: OcxConfig = { port: 10100, hostname: "127.0.0.1", defaultProvider: "stable", + providers: { stable: { ...provider, responsesPath: "/existing", chatCompletionsPath: "/existing-chat" } } }; + saveConfig(cfg); + const beforeMemory = structuredClone(cfg); + const beforeDisk = readFileSync(join(TEST_DIR, "config.json")); + const resolved = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + let refreshes = 0; + try { + for (const value of ["@other.example.test/send", "https://other.example.test/send", "/send?query=1", "/send#fragment", "", 42]) { + const name = mode === "create" ? "new-provider" : "stable"; + const url = new URL("http://127.0.0.1/api/providers"); + const response = await handleManagementAPI(new Request(url, { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ name, provider: { ...provider, [field]: value } }), + }), url, cfg, { createManagementConvergeCodex: catalogConvergenceFactory(() => { refreshes++; }) }); + expect(response?.status).toBe(400); + expect(await response!.json()).toMatchObject({ error: expect.stringContaining(field) }); + expect(cfg).toEqual(beforeMemory); + expect(readFileSync(join(TEST_DIR, "config.json"))).toEqual(beforeDisk); + } + expect(resolved).not.toHaveBeenCalled(); + expect(refreshes).toBe(0); + } finally { resolved.mockRestore(); } + }); + } + } + + test.each(fields)("PATCH revalidates an existing invalid %s without changing state", async field => { + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const cfg: OcxConfig = { port: 10100, hostname: "127.0.0.1", defaultProvider: "stable", + providers: { stable: { ...provider, [field]: "@other.example.test/send" } } }; + // Model a live row accepted before the write-boundary fix. PATCH edits supported + // transport fields; it does not itself expose a send-path setter. + saveConfig(cfg); + const beforeMemory = structuredClone(cfg); + const beforeDisk = readFileSync(join(TEST_DIR, "config.json")); + const resolved = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + let refreshes = 0; + try { + const url = new URL("http://127.0.0.1/api/providers?name=stable"); + const response = await handleManagementAPI(new Request(url, { method: "PATCH", headers: { "content-type": "application/json" }, + body: JSON.stringify({ baseUrl: "https://replacement.example.test/v1" }), + }), url, cfg, { createManagementConvergeCodex: catalogConvergenceFactory(() => { refreshes++; }) }); + expect(response?.status).toBe(400); + expect(await response!.json()).toMatchObject({ error: expect.stringContaining(field) }); + expect(cfg).toEqual(beforeMemory); + expect(readFileSync(join(TEST_DIR, "config.json"))).toEqual(beforeDisk); + expect(resolved).not.toHaveBeenCalled(); + expect(refreshes).toBe(0); + } finally { resolved.mockRestore(); } + }); + + test("valid relative send paths remain persistable and reloadable", async () => { + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const cfg: OcxConfig = { port: 10100, hostname: "127.0.0.1", defaultProvider: "stable", providers: { stable: { ...provider } } }; + saveConfig(cfg); + const resolved = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + try { + const url = new URL("http://127.0.0.1/api/providers"); + const response = await handleManagementAPI(new Request(url, { method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "custom-paths", provider: { ...provider, responsesPath: "/custom/responses", chatCompletionsPath: "/custom/chat" } }), + }), url, cfg, { createManagementConvergeCodex: catalogConvergenceFactory() }); + expect(response?.status).toBe(200); + expect(cfg.providers["custom-paths"]?.responsesPath).toBe("/custom/responses"); + expect(loadConfig().providers["custom-paths"]?.chatCompletionsPath).toBe("/custom/chat"); + } finally { resolved.mockRestore(); } + }); +}); + describe("provider quota routing state", () => { function quotaConfig(name = "openrouter", baseUrl = "https://openrouter.ai/api/v1"): OcxConfig { return { port: 10100, defaultProvider: name, providers: { [name]: {