From e70f1256925790477d861d2cfe592d1e1d8b536d Mon Sep 17 00:00:00 2001 From: Vadim Rogachyov Date: Tue, 6 Oct 2026 10:51:56 +0300 Subject: [PATCH 1/4] feat: allow arbitrary HTTPS JEV decision endpoints --- docs-site/src/content/docs/guides/combos.md | 30 +++++++++++++++++-- .../docs/reference/configuration/routing.md | 2 +- src/combos/jev-decision-contract.ts | 12 ++++++-- src/combos/jev.ts | 3 +- src/combos/types.ts | 2 +- structure/providers-and-adapters.md | 2 +- structure/providers/jev-decision.md | 4 +-- .../gui/combo-workspace-jev-decision.test.ts | 15 +++++++++- .../routing/jev-decision-destination.test.ts | 28 ++++++++++++++++- .../jev-decision-provider-combo.test.ts | 7 +++-- 10 files changed, 90 insertions(+), 15 deletions(-) diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 8a294b7563a..06a49bbc573 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -316,7 +316,9 @@ Combo's `decisionProvider`: } ``` -- The row's `baseUrl` must be the full decision endpoint and its path must end in `/systemone`. +- The row's `baseUrl` must be the full decision endpoint. HTTPS services may use any path, + such as `/v1/decisions`; plain HTTP endpoints must still end in `/systemone`. + Userinfo, query strings, and fragments are not accepted in decision URLs. The decision model is `defaultModel`, else the first `models` entry; a row with neither is treated as unusable and fails open without a request (`jev-latest` is TypeSafe's model and is never sent to a self-hosted host). The row is a decision service only: it is never published as a @@ -344,9 +346,31 @@ Combo's `decisionProvider`: The provider's **Test connection** sends a bounded probe decision to its endpoint. **Create JEV Auto** on a System One provider (keyless rows included) prefills that row as the -decision provider. Disabled rows, endpoints not ending in `/systemone`, and rows without a model +decision provider. Disabled rows, invalid decision URLs, and rows without a model are shown with a reason and cannot be picked. +For an alternative hosted service with the same decision request/response contract, add a separate +provider row and select it with the Combo's `decisionProvider`: + +```json +{ + "providers": { + "alternative-decider": { + "adapter": "jev-decision", + "baseUrl": "https://decisions.example/v1/decisions", + "apiKey": "${ALTERNATIVE_DECISION_KEY}", + "defaultModel": "decision-model-v1", + "liveModels": false + } + } +} +``` + +Set `decisionProvider: "alternative-decider"` on the existing JEV Combo. Its own credential and +model accompany the bounded decision state; TypeSafe environment credentials are never inherited. +The `jev` id remains canonical. HTTPS/TLS, outbound destination restrictions, redirect refusal, +request/response bounds, timeout, cancellation, and the eligible-choice allowlist still apply. + [Laya MLX](https://github.com/mizorewww/laya-mlx) runs typed decisions on Apple Silicon. To use it with this method, expose it through a System One-compatible HTTP wrapper, then configure a row like `ollama-tev1` above with the wrapper's full `/systemone` URL and accepted model id as `defaultModel`. @@ -794,7 +818,7 @@ Combos are stored in the top-level `combos` object, keyed by combo id: | `alias` | No | none | Optional trimmed public model id; use the alias rules above. An empty value is stored as no alias. | | `nativeAlias` | No | `false` | Explicitly permit a currently supported bare native `alias` to take routing and catalog precedence. Never inferred from the alias. | | `displayName` | No | none | Bounded display-only catalog label. Required and non-empty when `nativeAlias` is true. | -| `decisionProvider` | No | `"jev"` | JEV only. Provider id of the decision service: `"jev"` (TypeSafe, valid without a provider row; the same as omission) or a configured `adapter: "jev-decision"` row with a `/systemone` `baseUrl`, such as a self-hosted Ollama `tev1`. | +| `decisionProvider` | No | `"jev"` | JEV only. Provider id of the decision service: `"jev"` (TypeSafe, valid without a provider row; the same as omission) or a configured `adapter: "jev-decision"` row with a full HTTPS decision URL or a local HTTP `/systemone` URL, such as a self-hosted Ollama `tev1`. | | `decisionTimeoutMs` | No | `4000` | JEV only. Integer from 1000 to 120000: the decision deadline before failing open to the first eligible target. | ## Troubleshooting diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index 1b7c6d1de8f..e0d0535b850 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -151,7 +151,7 @@ namespace, and cannot use reserved bare native families such as `gpt-*`, `o1-*`, | `alias?` | `string` | — | Optional public model id in place of the canonical picker slug. | | `nativeAlias?` | `boolean` | `false` | Let a currently supported bare native id take precedence only for that unqualified id. Bare `gpt-5.6-*` ids use Codex Pool/Direct credentials. Account-qualified routes remain distinct. Provider-qualified routes such as `openai-apikey/gpt-5.6-*` use their configured API-key route and never fall through to the native alias. | | `displayName?` | `string` | — | Display-only catalog label, required and non-empty for a native alias. | -| `decisionProvider?` | `string` | `"jev"` | `strategy: "jev"` only. `"jev"` (the same as omitting it, and stored as omission) is the TypeSafe decision service, valid without a provider row; any other value must name a configured provider with `adapter: "jev-decision"` whose `baseUrl` ends in `/systemone`. | +| `decisionProvider?` | `string` | `"jev"` | `strategy: "jev"` only. `"jev"` (the same as omitting it, and stored as omission) is the TypeSafe decision service, valid without a provider row; any other value must name a configured provider with `adapter: "jev-decision"` and a full HTTPS decision `baseUrl` (any path) or a local HTTP `/systemone` endpoint. Userinfo, query strings, and fragments are refused. | | `decisionModel?` | `string` | unset | `strategy: "jev"` only, mutually exclusive with `decisionProvider`. An ordinary opencodex route (for example `ollama/qwen3:4b`) asked to pick one offered option as JSON. It runs with the selected provider's stored credentials, never the caller's, and cannot resolve to this combo, any JEV combo, or a `jev-decision` row. | | `decisionTimeoutMs?` | `number` | `4000` | `strategy: "jev"` only. Decision deadline before failing open, 1000–120000 ms. | diff --git a/src/combos/jev-decision-contract.ts b/src/combos/jev-decision-contract.ts index d7423a7cd2e..2d546523b4a 100644 --- a/src/combos/jev-decision-contract.ts +++ b/src/combos/jev-decision-contract.ts @@ -10,10 +10,18 @@ export const JEV_DECISION_TIMEOUT_MAX_MS = 120_000; /** Decision deadline when a combo sets no `decisionTimeoutMs`. */ export const JEV_DECISION_TIMEOUT_DEFAULT_MS = 4_000; -/** Whether a self-hosted decision endpoint follows the documented Jev `/systemone` path. */ +/** HTTPS decision services may use any path; local cleartext keeps the `/systemone` contract. */ export function isSystemOneEndpoint(baseUrl: string): boolean { try { - return new URL(baseUrl.trim()).pathname.replace(/\/+$/, "").endsWith("/systemone"); + const url = new URL(baseUrl.trim()); + if (url.username || url.password || url.search || url.hash) return false; + // URL canonicalizes address literals; keep this import-free for the dashboard bundle. + const host = url.hostname; + const local = host === "localhost" || host === "[::1]" + || /^(?:(?:127|10)\.\d+|172\.(?:1[6-9]|2\d|3[01])|192\.168)\.\d+\.\d+$/.test(host) + || /^\[f[cd][\da-f]{2}:/.test(host) || /^\[::ffff:7f[\da-f]{2}:/.test(host); + return url.protocol === "https:" + || url.protocol === "http:" && local && url.pathname.replace(/\/+$/, "").endsWith("/systemone"); } catch { return false; } diff --git a/src/combos/jev.ts b/src/combos/jev.ts index d04e9cd0cd6..5449e4cb20f 100644 --- a/src/combos/jev.ts +++ b/src/combos/jev.ts @@ -674,7 +674,7 @@ function selfHostedApiKey(name: string, apiKey: string | undefined): string | un * only while the row still matches the registry transport, and the environment fallbacks exist * only for that URL. A retargeted `jev` row therefore keeps today's behavior instead of becoming a * custom destination. Any other id must be an enabled `jev-decision` row whose baseUrl is a - * `/systemone` endpoint and which names its own model; only its own key may accompany it, so no + * full HTTPS decision endpoint (or a local HTTP `/systemone` endpoint) and names its own model; only its own key may accompany it, so no * TypeSafe credential can reach a self-hosted service. `undefined` means no usable decision * service (reported through the existing `missing_key` gate); `null` means the request's * destination scope refused it before any credential access. @@ -707,6 +707,7 @@ function jevDecisionEndpoint( }; } if (configured?.adapter !== "jev-decision" || typeof configured.baseUrl !== "string") return undefined; + if (configured.authMode !== undefined && configured.authMode !== "key") return undefined; const url = configured.baseUrl.trim().replace(/\/+$/, ""); if (!url || !isSystemOneEndpoint(url)) return undefined; // `jev-latest` is TypeSafe's model name; a self-hosted host must name its own. diff --git a/src/combos/types.ts b/src/combos/types.ts index 7b8912c9e4b..6353db3b7a5 100644 --- a/src/combos/types.ts +++ b/src/combos/types.ts @@ -302,7 +302,7 @@ export function comboConfigIssues( } else if (!isSystemOneEndpoint(String(providers[decisionProvider]?.baseUrl ?? ""))) { issues.push({ path: ["decisionProvider"], - message: `decisionProvider "${decisionProvider}" baseUrl must be the full decision endpoint ending in /systemone`, + message: `decisionProvider "${decisionProvider}" baseUrl must be a full HTTPS decision endpoint or an HTTP /systemone endpoint`, }); } else if (options.requireUsableDecisionService && providers[decisionProvider]?.disabled === true) { issues.push({ diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 6fe108beb52..89c045b4adf 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -319,7 +319,7 @@ and the upstream URL through `handleResponses`. ## TypeSafe JEV decision provider -The JEV Combo decision contract (TypeSafe, self-hosted System One rows, and opencodex-model +The JEV Combo decision contract (TypeSafe, compatible HTTPS services with any endpoint path, local System One rows, and opencodex-model decision backends) lives in [JEV Decision Routing](providers/jev-decision.md). ## Preset notes diff --git a/structure/providers/jev-decision.md b/structure/providers/jev-decision.md index 1913a988cfc..660b0c7b732 100644 --- a/structure/providers/jev-decision.md +++ b/structure/providers/jev-decision.md @@ -21,8 +21,8 @@ canonical registry transport, with `TYPESAFE_API_KEY` and the standard provider- either credential through the JEV client; a retargeted `jev` row is ignored, never a custom destination. Automated coverage mocks TypeSafe; live-key behavior is an operator smoke boundary. A Combo's `decisionProvider` selects the service: omitted or `"jev"` (stored as omission) is that canonical path with `jev-latest`; any other id must be an enabled `jev-decision` row with a full -`/systemone` `baseUrl` and a `defaultModel`/`models[0]`, sending only its own `apiKey` (a TypeSafe -env reference or foreign keychain entry makes it unusable). `allowLocalCleartextPost` in +full HTTPS decision `baseUrl` (any path) or a local HTTP `/systemone` URL and a `defaultModel`/`models[0]`, sending only its own `apiKey` (a TypeSafe +env reference or foreign keychain entry makes it unusable). Non-key authentication modes and URLs with userinfo, query strings, or fragments are refused before a send. Shared validation rejects public HTTP hosts; the local-literal check is tested against the transport allowlist. `allowLocalCleartextPost` in `src/lib/provider-outbound.ts` admits `http:` only with the row's explicit `allowPrivateNetwork`, a `localhost`/loopback/RFC 1918/ULA host whose answers stay in that set, and no proxy. Options go out as strings (Ollama requires them); under 2 or over 26 fail open locally (`no_choices`/`invalid`), unusable diff --git a/tests/gui/combo-workspace-jev-decision.test.ts b/tests/gui/combo-workspace-jev-decision.test.ts index 40b824c72de..4cc42d6a00a 100644 --- a/tests/gui/combo-workspace-jev-decision.test.ts +++ b/tests/gui/combo-workspace-jev-decision.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { localCleartextAddressAllowed } from "../../src/lib/provider-outbound"; import { JEV_DECISION_TIMEOUT_DEFAULT_MS as SERVER_TIMEOUT_DEFAULT_MS, JEV_DECISION_TIMEOUT_MAX_MS as SERVER_TIMEOUT_MAX_MS, @@ -160,10 +161,22 @@ describe("JEV decision service in the combo workspace", () => { }); test("the GUI endpoint check is the server's", () => { - for (const url of ["http://h/v1/systemone", "http://h/v1/systemone/", "http://h/v1", "not a url", ""]) { + for (const url of ["https://decisions.example/v1/decisions", "http://localhost/v1/systemone", "http://127.0.0.1/v1/systemone/", "http://h/v1", "not a url", ""]) { expect(jevDecisionRowIssue({ adapter: "jev-decision", baseUrl: url, defaultModel: "m" }) === null) .toBe(isSystemOneEndpoint(url)); } + for (const url of ["http://decisions.example/v1/systemone", "http://10.example.com/v1/systemone", "ftp://decisions.example/v1/systemone", "https://user:pass@example.test/v1/decisions", "https://decisions.example/v1/decisions?key=secret", "https://decisions.example/v1/decisions#fragment"]) { + expect(isSystemOneEndpoint(url)).toBeFalse(); + } + }); + + test("HTTP endpoint literals match the transport's local address allowlist", () => { + for (const address of ["127.0.0.1", "127.255.255.254", "10.0.0.1", "172.16.0.1", "172.31.255.254", "192.168.1.1", + "::1", "::ffff:127.0.0.1", "fc00::1", "fdff::1", "172.15.0.1", "172.32.0.1", "192.169.1.1", "8.8.8.8", + "0.0.0.0", "100.64.0.1", "169.254.169.254", "198.18.0.1", "::", "fe80::1", "::ffff:10.0.0.1", "64:ff9b::1"]) { + const host = address.includes(":") ? `[${address}]` : address; + expect(isSystemOneEndpoint(`http://${host}/v1/systemone`)).toBe(localCleartextAddressAllowed(address)); + } }); test("the read-only summary names the service, its endpoint and timeout", () => { diff --git a/tests/routing/jev-decision-destination.test.ts b/tests/routing/jev-decision-destination.test.ts index 77cfb01610d..a01e9d44683 100644 --- a/tests/routing/jev-decision-destination.test.ts +++ b/tests/routing/jev-decision-destination.test.ts @@ -10,7 +10,7 @@ import { import type { OcxConfig, OcxProviderConfig } from "../../src/types"; type JevPost = NonNullable; -const CUSTOM_URL = "https://decider.example/v1/systemone"; +const CUSTOM_URL = "https://decider.example/v1/decisions"; const ENV_SECRETS = { TYPESAFE_API_KEY: "typesafe-environment-secret", JEV_API_KEY: "jev-environment-secret", @@ -86,6 +86,32 @@ function expectNoTypeSafeSecrets(init: Parameters[3]) { } describe("JEV decision destination credential ownership", () => { + test.each(["adapter", "authMode"] as const)("an incompatible selected %s never sends or falls back to TypeSafe", async (field) => { + const row = { ...customRow }; + if (field === "adapter") row.adapter = "openai-chat"; + else row.authMode = "oauth"; + const { calls, post } = recordingPost(); + expect(await resolveJevDecision({ + body: { input: "Choose a target." }, candidates, fallback, + config: configWith("custom-decider", row), decisionProvider: "custom-decider", post, + })).toMatchObject({ ...fallback, gate: "missing_key" }); + expect(calls).toHaveLength(0); + }); + + test("a custom HTTPS path preserves caller cancellation by identity", async () => { + const controller = new AbortController(); + const reason = new DOMException("caller stopped", "AbortError"); + const post: JevPost = async (_name, _provider, url, init) => { + expect(url).toBe(CUSTOM_URL); + controller.abort(reason); + throw init.signal?.reason; + }; + await expect(resolveJevDecision({ + body: { input: "Choose a target." }, candidates, fallback, + config: configWith("custom-decider", customRow), decisionProvider: "custom-decider", post, signal: controller.signal, + })).rejects.toBe(reason); + }); + for (const apiKey of ["custom-secret", undefined]) { test(`self-hosted row with ${apiKey ? "its own key" : "no key"} never borrows TypeSafe environment secrets`, async () => { const { calls, post } = recordingPost(); diff --git a/tests/routing/jev-decision-provider-combo.test.ts b/tests/routing/jev-decision-provider-combo.test.ts index 8393e46addb..a71470ff5d7 100644 --- a/tests/routing/jev-decision-provider-combo.test.ts +++ b/tests/routing/jev-decision-provider-combo.test.ts @@ -71,6 +71,9 @@ describe("JEV decisionProvider combo validation", () => { expect(issuesFor({ strategy: "jev", decisionProvider: "jev" })).toEqual([]); expect(issuesFor({ strategy: "jev" })).toEqual([]); expect(issuesFor({ strategy: "jev", decisionProvider: null, decisionTimeoutMs: null })).toEqual([]); + const rows = providers(); + rows.remote = { ...selfHostedRow, baseUrl: "https://decisions.example/v1/decisions", apiKey: "remote-secret" }; + expect(issuesFor({ strategy: "jev", decisionProvider: "remote" }, rows)).toEqual([]); }); test("rejects unknown rows, non-decision adapters, and use outside the jev strategy", () => { @@ -87,7 +90,7 @@ describe("JEV decisionProvider combo validation", () => { expect(issuesFor({ strategy: "jev" }, rows)).toEqual([]); rows.local = { adapter: "jev-decision", baseUrl: "http://127.0.0.1:11434/v1", allowPrivateNetwork: true }; expect(issuesFor({ strategy: "jev", decisionProvider: "local" }, rows)).toEqual([ - { path: ["decisionProvider"], message: 'decisionProvider "local" baseUrl must be the full decision endpoint ending in /systemone' }, + { path: ["decisionProvider"], message: 'decisionProvider "local" baseUrl must be a full HTTPS decision endpoint or an HTTP /systemone endpoint' }, ]); expect(issuesFor({ strategy: "failover", decisionProvider: "ollama-tev1" }, rows)).toEqual([ { path: ["decisionProvider"], message: 'decisionProvider is only valid with strategy "jev"' }, @@ -291,7 +294,7 @@ describe("JEV decisionProvider management round-trip", () => { }); expect(endpoint.status).toBe(400); expect((await endpoint.json() as { error: string }).error).toContain( - 'combos.custom.decisionProvider: decisionProvider "ollama-tev1" baseUrl must be the full decision endpoint', + 'combos.custom.decisionProvider: decisionProvider "ollama-tev1" baseUrl must be a full HTTPS decision endpoint', ); expect(cfg.providers["ollama-tev1"]).toMatchObject({ adapter: "jev-decision", baseUrl: selfHostedRow.baseUrl }); expect(readFileSync(getConfigPath(), "utf8")).toBe(before); From 4a1c39e4eddd02db61bc675e90e0cc4fc51d3905 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 8 Oct 2026 09:52:00 +0900 Subject: [PATCH 2/4] fix(jev): keep exact HTTPS decision paths and refuse empty URL delimiters Review follow-up on #6384: - Reject URLs whose raw text carries an empty query, fragment, or userinfo delimiter; WHATWG URL drops those, so the parsed-field check missed them. - Send an arbitrary HTTPS decision path exactly as configured (a trailing slash is significant); /systemone keeps its trailing-slash normalization. Discovery reports the same URL. - Drop the runtime refusal of non-key authMode values. The decision request only ever carries the row's own apiKey, so the refusal protected nothing and made rows that the dashboard and save validation accept unusable at runtime. Cover oauth/local/forward rows sending only their own key. --- src/combos/jev-decision-contract.ts | 16 +++++++++- src/combos/jev.ts | 4 +-- src/server/management/decision-routes.ts | 3 +- structure/providers/jev-decision.md | 2 +- .../gui/combo-workspace-jev-decision.test.ts | 5 +++- .../routing/jev-decision-destination.test.ts | 29 ++++++++++++++++--- 6 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/combos/jev-decision-contract.ts b/src/combos/jev-decision-contract.ts index 2d546523b4a..593d1e616c0 100644 --- a/src/combos/jev-decision-contract.ts +++ b/src/combos/jev-decision-contract.ts @@ -13,7 +13,11 @@ export const JEV_DECISION_TIMEOUT_DEFAULT_MS = 4_000; /** HTTPS decision services may use any path; local cleartext keeps the `/systemone` contract. */ export function isSystemOneEndpoint(baseUrl: string): boolean { try { - const url = new URL(baseUrl.trim()); + const raw = baseUrl.trim(); + // URL drops empty delimiters ("?", "#", "@"), so check the raw text before parsing. + const authority = raw.replace(/^[a-z][a-z\d+.-]*:[/\\]*/i, "").split(/[/\\]/, 1)[0] ?? ""; + if (/[?#]/.test(raw) || authority.includes("@")) return false; + const url = new URL(raw); if (url.username || url.password || url.search || url.hash) return false; // URL canonicalizes address literals; keep this import-free for the dashboard bundle. const host = url.hostname; @@ -26,3 +30,13 @@ export function isSystemOneEndpoint(baseUrl: string): boolean { return false; } } + +/** + * The URL a decision row is sent to. A `/systemone` path keeps its historical trailing-slash + * normalization; any other HTTPS path is the operator's exact endpoint. + */ +export function jevDecisionEndpointUrl(baseUrl: string): string { + const raw = baseUrl.trim(); + const stripped = raw.replace(/\/+$/, ""); + return stripped.endsWith("/systemone") ? stripped : raw; +} diff --git a/src/combos/jev.ts b/src/combos/jev.ts index 5449e4cb20f..1d330d870cf 100644 --- a/src/combos/jev.ts +++ b/src/combos/jev.ts @@ -10,6 +10,7 @@ import { } from "../providers/api-key-resolve"; import { providerMatchesRegistryTransport } from "../providers/registry"; import type { OcxComboDefaultEffort, OcxConfig, OcxProviderConfig } from "../types"; +import { jevDecisionEndpointUrl } from "./jev-decision-contract"; import { isSystemOneEndpoint, JEV_DECISION_TIMEOUT_DEFAULT_MS, @@ -707,8 +708,7 @@ function jevDecisionEndpoint( }; } if (configured?.adapter !== "jev-decision" || typeof configured.baseUrl !== "string") return undefined; - if (configured.authMode !== undefined && configured.authMode !== "key") return undefined; - const url = configured.baseUrl.trim().replace(/\/+$/, ""); + const url = jevDecisionEndpointUrl(configured.baseUrl); if (!url || !isSystemOneEndpoint(url)) return undefined; // `jev-latest` is TypeSafe's model name; a self-hosted host must name its own. const model = configured.defaultModel?.trim() || configured.models?.[0]?.trim(); diff --git a/src/server/management/decision-routes.ts b/src/server/management/decision-routes.ts index a08286809db..cf448c25b1d 100644 --- a/src/server/management/decision-routes.ts +++ b/src/server/management/decision-routes.ts @@ -1,4 +1,5 @@ import { jsonResponse } from "../auth-cors"; +import { jevDecisionEndpointUrl } from "../../combos/jev-decision-contract"; import type { ManagementContext } from "./context"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import { isPlainRecord } from "./shared"; @@ -29,7 +30,7 @@ function decisionServiceIssue( provider: DecisionRow, isSystemOneEndpoint: (url: string) => boolean, ): { url: string; model: string; issue?: "disabled" | "endpoint" | "model" } { - const url = typeof provider.baseUrl === "string" ? provider.baseUrl.trim().replace(/\/+$/, "") : ""; + const url = typeof provider.baseUrl === "string" ? jevDecisionEndpointUrl(provider.baseUrl) : ""; const model = provider.defaultModel?.trim() || provider.models?.[0]?.trim() || ""; const issue = provider.disabled === true ? "disabled" diff --git a/structure/providers/jev-decision.md b/structure/providers/jev-decision.md index 660b0c7b732..d7470af28ac 100644 --- a/structure/providers/jev-decision.md +++ b/structure/providers/jev-decision.md @@ -22,7 +22,7 @@ either credential through the JEV client; a retargeted `jev` row is ignored, nev destination. Automated coverage mocks TypeSafe; live-key behavior is an operator smoke boundary. A Combo's `decisionProvider` selects the service: omitted or `"jev"` (stored as omission) is that canonical path with `jev-latest`; any other id must be an enabled `jev-decision` row with a full full HTTPS decision `baseUrl` (any path) or a local HTTP `/systemone` URL and a `defaultModel`/`models[0]`, sending only its own `apiKey` (a TypeSafe -env reference or foreign keychain entry makes it unusable). Non-key authentication modes and URLs with userinfo, query strings, or fragments are refused before a send. Shared validation rejects public HTTP hosts; the local-literal check is tested against the transport allowlist. `allowLocalCleartextPost` in +env reference or foreign keychain entry makes it unusable). The row's `authMode` attaches no other credential: only its own `apiKey` is sent, as a Bearer header. URLs with userinfo, query strings, or fragments (including an empty `?`, `#`, or `@`) are refused before a send; an HTTPS path is sent exactly as configured, while a `/systemone` path keeps its trailing-slash normalization. Shared validation rejects public HTTP hosts; the local-literal check is tested against the transport allowlist. `allowLocalCleartextPost` in `src/lib/provider-outbound.ts` admits `http:` only with the row's explicit `allowPrivateNetwork`, a `localhost`/loopback/RFC 1918/ULA host whose answers stay in that set, and no proxy. Options go out as strings (Ollama requires them); under 2 or over 26 fail open locally (`no_choices`/`invalid`), unusable diff --git a/tests/gui/combo-workspace-jev-decision.test.ts b/tests/gui/combo-workspace-jev-decision.test.ts index 4cc42d6a00a..a70b2f21a1a 100644 --- a/tests/gui/combo-workspace-jev-decision.test.ts +++ b/tests/gui/combo-workspace-jev-decision.test.ts @@ -165,9 +165,12 @@ describe("JEV decision service in the combo workspace", () => { expect(jevDecisionRowIssue({ adapter: "jev-decision", baseUrl: url, defaultModel: "m" }) === null) .toBe(isSystemOneEndpoint(url)); } - for (const url of ["http://decisions.example/v1/systemone", "http://10.example.com/v1/systemone", "ftp://decisions.example/v1/systemone", "https://user:pass@example.test/v1/decisions", "https://decisions.example/v1/decisions?key=secret", "https://decisions.example/v1/decisions#fragment"]) { + for (const url of ["http://decisions.example/v1/systemone", "http://10.example.com/v1/systemone", "ftp://decisions.example/v1/systemone", "https://user:pass@example.test/v1/decisions", "https://decisions.example/v1/decisions?key=secret", "https://decisions.example/v1/decisions#fragment", + "https://decisions.example/v1/decisions?", "https://decisions.example/v1/decisions#", "https://decisions.example/v1/decisions?#", + "https://@decisions.example/v1/decisions", "https://:@decisions.example/v1/decisions"]) { expect(isSystemOneEndpoint(url)).toBeFalse(); } + expect(isSystemOneEndpoint("https://decisions.example/v1/user@decisions")).toBeTrue(); }); test("HTTP endpoint literals match the transport's local address allowlist", () => { diff --git a/tests/routing/jev-decision-destination.test.ts b/tests/routing/jev-decision-destination.test.ts index a01e9d44683..9910476c30e 100644 --- a/tests/routing/jev-decision-destination.test.ts +++ b/tests/routing/jev-decision-destination.test.ts @@ -86,10 +86,8 @@ function expectNoTypeSafeSecrets(init: Parameters[3]) { } describe("JEV decision destination credential ownership", () => { - test.each(["adapter", "authMode"] as const)("an incompatible selected %s never sends or falls back to TypeSafe", async (field) => { - const row = { ...customRow }; - if (field === "adapter") row.adapter = "openai-chat"; - else row.authMode = "oauth"; + test("an incompatible selected adapter never sends or falls back to TypeSafe", async () => { + const row: OcxProviderConfig = { ...customRow, adapter: "openai-chat" }; const { calls, post } = recordingPost(); expect(await resolveJevDecision({ body: { input: "Choose a target." }, candidates, fallback, @@ -98,6 +96,29 @@ describe("JEV decision destination credential ownership", () => { expect(calls).toHaveLength(0); }); + test.each(["oauth", "local", "forward"] as const)("authMode %s sends only the row's own key to its own endpoint", async (authMode) => { + const { calls, post } = recordingPost(); + expect((await resolveJevDecision({ + body: { input: "Choose a target." }, candidates, fallback, + config: configWith("custom-decider", { ...customRow, authMode }), decisionProvider: "custom-decider", post, + })).gate).toBe("apply"); + expect(calls.map(call => call.url)).toEqual([CUSTOM_URL]); + expect(new Headers(calls[0]!.init.headers).get("authorization")).toBe("Bearer custom-secret"); + expectNoTypeSafeSecrets(calls[0]!.init); + }); + + test.each([ + [`${CUSTOM_URL}/`, `${CUSTOM_URL}/`], + ["https://decider.example/v1/systemone/", "https://decider.example/v1/systemone"], + ])("baseUrl %s is sent to %s", async (baseUrl, expected) => { + const { calls, post } = recordingPost(); + await resolveJevDecision({ + body: { input: "Choose a target." }, candidates, fallback, + config: configWith("custom-decider", { ...customRow, baseUrl }), decisionProvider: "custom-decider", post, + }); + expect(calls.map(call => call.url)).toEqual([expected]); + }); + test("a custom HTTPS path preserves caller cancellation by identity", async () => { const controller = new AbortController(); const reason = new DOMException("caller stopped", "AbortError"); From 6f4397c40ddacf9a92cbe1bab0bcfb4cc5157147 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 8 Oct 2026 09:58:17 +0900 Subject: [PATCH 3/4] fix(jev): refuse control characters before the decision URL userinfo check URL strips tab, CR and LF before parsing, so https://@host evaded the raw empty-userinfo check. Refuse any C0 control or DEL in the configured URL, and cover the validation and runtime send boundaries. --- src/combos/jev-decision-contract.ts | 4 +++- tests/gui/combo-workspace-jev-decision.test.ts | 3 ++- tests/routing/jev-decision-destination.test.ts | 12 ++++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/combos/jev-decision-contract.ts b/src/combos/jev-decision-contract.ts index 593d1e616c0..bb290c5aeb9 100644 --- a/src/combos/jev-decision-contract.ts +++ b/src/combos/jev-decision-contract.ts @@ -14,7 +14,9 @@ export const JEV_DECISION_TIMEOUT_DEFAULT_MS = 4_000; export function isSystemOneEndpoint(baseUrl: string): boolean { try { const raw = baseUrl.trim(); - // URL drops empty delimiters ("?", "#", "@"), so check the raw text before parsing. + // URL drops empty delimiters ("?", "#", "@") and strips tab/CR/LF, so check the raw text first. + // eslint-disable-next-line no-control-regex + if (/[\u0000-\u001f\u007f]/.test(raw)) return false; const authority = raw.replace(/^[a-z][a-z\d+.-]*:[/\\]*/i, "").split(/[/\\]/, 1)[0] ?? ""; if (/[?#]/.test(raw) || authority.includes("@")) return false; const url = new URL(raw); diff --git a/tests/gui/combo-workspace-jev-decision.test.ts b/tests/gui/combo-workspace-jev-decision.test.ts index a70b2f21a1a..396e6756cac 100644 --- a/tests/gui/combo-workspace-jev-decision.test.ts +++ b/tests/gui/combo-workspace-jev-decision.test.ts @@ -167,7 +167,8 @@ describe("JEV decision service in the combo workspace", () => { } for (const url of ["http://decisions.example/v1/systemone", "http://10.example.com/v1/systemone", "ftp://decisions.example/v1/systemone", "https://user:pass@example.test/v1/decisions", "https://decisions.example/v1/decisions?key=secret", "https://decisions.example/v1/decisions#fragment", "https://decisions.example/v1/decisions?", "https://decisions.example/v1/decisions#", "https://decisions.example/v1/decisions?#", - "https://@decisions.example/v1/decisions", "https://:@decisions.example/v1/decisions"]) { + "https://@decisions.example/v1/decisions", "https://:@decisions.example/v1/decisions", + "https:\t//@decisions.example/v1/decisions", "https:\n//:@decisions.example/v1/decisions", "https://decisions.example/v1/de\rcisions"]) { expect(isSystemOneEndpoint(url)).toBeFalse(); } expect(isSystemOneEndpoint("https://decisions.example/v1/user@decisions")).toBeTrue(); diff --git a/tests/routing/jev-decision-destination.test.ts b/tests/routing/jev-decision-destination.test.ts index 9910476c30e..0f91984debd 100644 --- a/tests/routing/jev-decision-destination.test.ts +++ b/tests/routing/jev-decision-destination.test.ts @@ -119,6 +119,18 @@ describe("JEV decision destination credential ownership", () => { expect(calls.map(call => call.url)).toEqual([expected]); }); + test.each(["https:\t//@decider.example/v1/decisions", "https://decider.example/v1/decisions?", "https://:@decider.example/v1/decisions"])( + "baseUrl %j with a stripped delimiter never sends", + async (baseUrl) => { + const { calls, post } = recordingPost(); + expect(await resolveJevDecision({ + body: { input: "Choose a target." }, candidates, fallback, + config: configWith("custom-decider", { ...customRow, baseUrl }), decisionProvider: "custom-decider", post, + })).toMatchObject({ ...fallback, gate: "missing_key" }); + expect(calls).toHaveLength(0); + }, + ); + test("a custom HTTPS path preserves caller cancellation by identity", async () => { const controller = new AbortController(); const reason = new DOMException("caller stopped", "AbortError"); From 4bf2eb2843266042581028b79feba7c3dfe96aa4 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 8 Oct 2026 10:26:06 +0900 Subject: [PATCH 4/4] chore(jev): drop the lint suppression on the control-character check src/ is not linted for no-control-regex; src/protocols/dto.ts uses the same expression unsuppressed. --- src/combos/jev-decision-contract.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/combos/jev-decision-contract.ts b/src/combos/jev-decision-contract.ts index bb290c5aeb9..33e3aa59ee4 100644 --- a/src/combos/jev-decision-contract.ts +++ b/src/combos/jev-decision-contract.ts @@ -15,7 +15,6 @@ export function isSystemOneEndpoint(baseUrl: string): boolean { try { const raw = baseUrl.trim(); // URL drops empty delimiters ("?", "#", "@") and strips tab/CR/LF, so check the raw text first. - // eslint-disable-next-line no-control-regex if (/[\u0000-\u001f\u007f]/.test(raw)) return false; const authority = raw.replace(/^[a-z][a-z\d+.-]*:[/\\]*/i, "").split(/[/\\]/, 1)[0] ?? ""; if (/[?#]/.test(raw) || authority.includes("@")) return false;