diff --git a/apps/daemon/internal/agent/claudesdk/local_test.go b/apps/daemon/internal/agent/claudesdk/local_test.go index 2d6408645..4075aabd0 100644 --- a/apps/daemon/internal/agent/claudesdk/local_test.go +++ b/apps/daemon/internal/agent/claudesdk/local_test.go @@ -75,7 +75,7 @@ func TestWorkspaceProviderCredentialsReplaceAmbientSelection(t *testing.T) { if !slices.Contains(env, "ANTHROPIC_AUTH_TOKEN=selected-secret") || slices.Contains(env, "ANTHROPIC_AUTH_TOKEN=selected-provider-fixture") { t.Fatal("provider selection was not exclusive") } - for _, value := range []any{nil, "secret", map[string]any{"protocol": "anthropic", "base_url": "http://provider.example", "api_key": "secret"}, map[string]any{"protocol": "anthropic", "base_url": "https://user:pass@provider.example", "api_key": "secret"}} { + for _, value := range []any{nil, "secret", map[string]any{"protocol": "anthropic", "base_url": "ftp://provider.example", "api_key": "secret"}, map[string]any{"protocol": "anthropic", "base_url": "https://user:pass@provider.example", "api_key": "secret"}} { req.AgentOptions["model_provider"] = value if _, _, err := prepare(config, req); err == nil || strings.Contains(err.Error(), "secret") { t.Fatal("unsafe provider accepted or disclosed") diff --git a/apps/daemon/internal/agent/mcode/model_provider_test.go b/apps/daemon/internal/agent/mcode/model_provider_test.go index 715bf181c..4cc4eb833 100644 --- a/apps/daemon/internal/agent/mcode/model_provider_test.go +++ b/apps/daemon/internal/agent/mcode/model_provider_test.go @@ -50,7 +50,7 @@ func TestOptionsRejectInvalidProvider(t *testing.T) { }{ {"unknown protocol", "protocol", "unknown"}, {"protocol alias", "protocol", "chat-completions"}, - {"remote HTTP", "base_url", "http://provider.example/v1"}, + {"unsupported scheme", "base_url", "ftp://provider.example/v1"}, {"empty key", "api_key", ""}, {"missing context", "context_window", 0}, {"missing output", "max_output_tokens", 0}, diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index a3de5cafa..8f9147ad7 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -518,7 +518,8 @@ const HARNESS_PROVIDER = /^\/harnesses\/([^/]+)\/model-configuration$/; const PROVIDER_FIELDS = new Set(["protocol", "base_url", "api_key", "context_window", "max_output_tokens"]); /** Core's one message for a body that is not a complete provider; it never echoes a value. */ const PROVIDER_SHAPE = "The body must be a complete model provider: protocol, base_url, api_key and optional nonnegative context_window and max_output_tokens."; -const httpsBase = (value) => { try { const url = new URL(value); return url.protocol === "https:" && Boolean(url.hostname) && !url.username && !url.password && !url.search && !url.hash; } catch { return false; } }; +/** Core admits http and https on any host, without credentials, query or fragment. */ +const providerBaseUrl = (value) => { try { const url = new URL(value); const scheme = url.protocol === "https:" || url.protocol === "http:"; return scheme && Boolean(url.hostname) && !url.username && !url.password && !url.search && !url.hash; } catch { return false; } }; /** An omitted limit, or a nonnegative integer that fits Core's int32. */ const tokenLimit = (value) => value === undefined || (Number.isInteger(value) && value >= 0 && value <= 2 ** 31 - 1); const jsonKind = (value) => Array.isArray(value) ? "an array" : typeof value === "string" ? "a string" : typeof value === "boolean" ? "a boolean" : Number.isInteger(value) ? "an integer" : "a number"; @@ -527,7 +528,7 @@ const jsonKind = (value) => Array.isArray(value) ? "an array" : typeof value === function providerProblem(harness, input) { if (Object.keys(input).some((key) => !PROVIDER_FIELDS.has(key)) || ["protocol", "base_url", "api_key"].some((key) => typeof input[key] !== "string") || !tokenLimit(input.context_window) || !tokenLimit(input.max_output_tokens)) return PROVIDER_SHAPE; - if (!httpsBase(input.base_url)) return "model provider requires an HTTPS base_url without credentials, query or fragment"; + if (!providerBaseUrl(input.base_url)) return "model provider requires an http or https base_url without credentials, query or fragment"; if (!["anthropic", "responses", "chat_completions"].includes(input.protocol)) return "unsupported model provider protocol"; if (!input.api_key.trim() || Buffer.byteLength(input.api_key) > 16384 || /[\0\r\n]/.test(input.api_key)) return "invalid model provider API key"; if ((input.max_output_tokens ?? 0) > (input.context_window ?? 0)) return "invalid model token limits"; diff --git a/apps/web/src/features/system/ModelProviderDialog.tsx b/apps/web/src/features/system/ModelProviderDialog.tsx index 3e555058e..b29d2190d 100644 --- a/apps/web/src/features/system/ModelProviderDialog.tsx +++ b/apps/web/src/features/system/ModelProviderDialog.tsx @@ -24,12 +24,12 @@ function tokenLimit(text: string): { value: number | undefined; problem: "whole" return number > INT32_MAX ? { value: undefined, problem: "large" } : { value: number, problem: null }; } -function isProviderUrl(value: string): boolean { +export function isProviderUrl(value: string): boolean { try { const url = new URL(value); - const authority = /^https:\/\/([^/]+)/iu.exec(value)?.[1]; - return authority !== undefined && url.protocol === "https:" && url.hostname !== "" && !authority.includes("@") - && !/[\\\s?#]/u.test(value); + const authority = /^https?:\/\/([^/]+)/iu.exec(value)?.[1]; + return authority !== undefined && (url.protocol === "https:" || url.protocol === "http:") && url.hostname !== "" + && !authority.includes("@") && !/[\\\s?#]/u.test(value); } catch { return false; } @@ -38,7 +38,7 @@ function isProviderUrl(value: string): boolean { /** * Sets or replaces one harness's model configuration. Non-secret fields start from the * current provider; the API key never does. The protocol describes the upstream - * model provider. The form checks the HTTPS provider URL and whole-number + * model provider. The form checks the HTTP or HTTPS provider URL and whole-number * limits within Core's range, with max output no larger than the context window. * Core's typed rejection is shown beside its field, or beside the form * when no editable field applies. Enter saves; a save in flight blocks another. diff --git a/apps/web/src/features/system/model-provider-dialog.test.ts b/apps/web/src/features/system/model-provider-dialog.test.ts new file mode 100644 index 000000000..8fafc56c6 --- /dev/null +++ b/apps/web/src/features/system/model-provider-dialog.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { isProviderUrl } from "./ModelProviderDialog"; + +// The dialog must accept exactly what Core's admission rule accepts: an http or +// https URL with a host and no credentials, query or fragment. It previously +// rejected plain http, which blocked the form for an operator-chosen endpoint. +describe("model provider base URL", () => { + it.each([ + "http://192.168.20.15:3721", + "https://api.example.com/v1", + "http://localhost:7351", + "http://10.0.0.7:8080/v1", + "http://[::1]:8443/v1", + ])("accepts %s", (url) => expect(isProviderUrl(url)).toBe(true)); + + it.each([ + "ftp://192.168.20.15:3721", + "http://user:pw@192.168.20.15:3721", + "http://192.168.20.15:3721/?key=secret", + "http://192.168.20.15:3721/#frag", + "http:///v1", + "http://host/ v1", + "not a url", + "", + ])("rejects %s", (url) => expect(isProviderUrl(url)).toBe(false)); +}); diff --git a/apps/web/src/i18n/locales/en/core-errors.ts b/apps/web/src/i18n/locales/en/core-errors.ts index ba11e4086..7c1477b89 100644 --- a/apps/web/src/i18n/locales/en/core-errors.ts +++ b/apps/web/src/i18n/locales/en/core-errors.ts @@ -12,7 +12,7 @@ export const coreErrors = { "model_configuration_model_invalid": "Enter a model ID of at most 1024 UTF-8 bytes without control characters.", "harness_config_invalid": "Check the supported native fields and their values. The JSON object must be at most 16 KiB and cannot redefine Core-managed settings.", "invalid_model_provider": "Enter the complete model provider configuration.", - "model_provider_base_url_invalid": "Use an HTTPS URL without credentials, query parameters or a fragment.", + "model_provider_base_url_invalid": "Use an HTTP or HTTPS URL without credentials, query parameters or a fragment.", "model_provider_protocol_unsupported": "This protocol is not supported by the harness.", "protocols": "Allowed protocols: {{protocols}}.", "model_provider_api_key_invalid": "Enter a valid API key without control characters.", diff --git a/apps/web/src/i18n/locales/en/system.ts b/apps/web/src/i18n/locales/en/system.ts index 01539ccb9..0ac0cac45 100644 --- a/apps/web/src/i18n/locales/en/system.ts +++ b/apps/web/src/i18n/locales/en/system.ts @@ -79,7 +79,7 @@ export const system = { setTitle: "Set default model configuration for {{harness}}", replaceTitle: "Replace default model configuration for {{harness}}", modelName: "Used for new Sessions when the application does not specify a model. Enter an exact model ID accepted by this provider.", - baseUrlInvalid: "Enter an HTTPS URL with a hostname and no credentials, query or fragment, such as https://api.example.com/v1.", + baseUrlInvalid: "Enter an HTTP or HTTPS URL with a hostname and no credentials, query or fragment, such as http://192.168.20.15:3721.", apiKeyHelp: "Required every time. Core encrypts it and never shows it again.", contextHelp: "The model's context window, in tokens. Optional.", contextHelpRequired: "The model's context window, in tokens. This harness requires it.", diff --git a/apps/web/src/i18n/locales/zh-CN/core-errors.ts b/apps/web/src/i18n/locales/zh-CN/core-errors.ts index 4334ca3c1..42bd7adc8 100644 --- a/apps/web/src/i18n/locales/zh-CN/core-errors.ts +++ b/apps/web/src/i18n/locales/zh-CN/core-errors.ts @@ -12,7 +12,7 @@ export const coreErrors = { "model_configuration_model_invalid": "请输入模型 ID,最多 1024 个 UTF-8 字节,且不能包含控制字符。", "harness_config_invalid": "请检查支持的原生字段及其取值。JSON 对象不能超过 16 KiB,也不能重复定义 Core 管理的设置。", "invalid_model_provider": "请填写完整的模型服务配置。", - "model_provider_base_url_invalid": "请使用 HTTPS 地址,不要包含凭证、查询参数或片段。", + "model_provider_base_url_invalid": "请使用 HTTP 或 HTTPS 地址,地址不要包含凭证、查询参数或片段。", "model_provider_protocol_unsupported": "此执行引擎不支持该协议。", "protocols": "支持的协议:{{protocols}}。", "model_provider_api_key_invalid": "请输入有效的 API Key,不要包含控制字符。", diff --git a/apps/web/src/i18n/locales/zh-CN/system.ts b/apps/web/src/i18n/locales/zh-CN/system.ts index 072fd56c5..95877ab69 100644 --- a/apps/web/src/i18n/locales/zh-CN/system.ts +++ b/apps/web/src/i18n/locales/zh-CN/system.ts @@ -81,7 +81,7 @@ export const system: TranslationShape = { setTitle: "设置 {{harness}} 的默认模型配置", replaceTitle: "替换 {{harness}} 的默认模型配置", modelName: "应用未指定模型时,新会话使用此模型。请填写该服务接受的准确模型 ID。", - baseUrlInvalid: "请输入包含主机名、不含账号密码、查询参数或片段的 HTTPS URL,例如 https://api.example.com/v1。", + baseUrlInvalid: "请输入包含主机名、不含账号密码、查询参数或片段的 HTTP 或 HTTPS URL,例如 http://192.168.20.15:3721。", apiKeyHelp: "每次都必须填写。Core 会加密保存,之后不再显示。", contextHelp: "模型的上下文窗口,单位为 token。可选。", contextHelpRequired: "模型的上下文窗口,单位为 token。此 harness 必须填写。", diff --git a/contracts/agents-api/core-errors.md b/contracts/agents-api/core-errors.md index b8eddd89d..fdae254f7 100644 --- a/contracts/agents-api/core-errors.md +++ b/contracts/agents-api/core-errors.md @@ -58,7 +58,7 @@ Each code returns HTTP 400 with `type: "invalid_request_error"`. A missing, malf | `invalid_name` | `name` | `max_length`: 128 for Projects and nodes, 80 for Project keys | The name failed the resource's validator | | `invalid_node_capacity` | `max_active` or `max_retained` | `min`: 1, `max`: 1000000 | Capacity is invalid; retained capacity must also be at least active capacity | | `invalid_model_provider` | null | omitted | A complete model-provider bundle is required | -| `model_provider_base_url_invalid` | `base_url` | omitted | Requires HTTPS without credentials, query or fragment | +| `model_provider_base_url_invalid` | `base_url` | omitted | Requires HTTP or HTTPS, without credentials, query or fragment | | `model_provider_protocol_unsupported` | `protocol` | `harness` and `allowed_protocols`, from the build's adapter catalog | The protocol is unknown or unsupported by the selected Harness | | `model_provider_api_key_invalid` | `api_key` | `max_length`: 16384 | The key is empty, too long or contains a prohibited character | | `model_provider_token_limits_invalid` | `context_window` or `max_output_tokens` | omitted | Limits are invalid, or the Harness requires positive limits that are missing | diff --git a/contracts/agents-api/model-execution.md b/contracts/agents-api/model-execution.md index 5be0513f1..888074400 100644 --- a/contracts/agents-api/model-execution.md +++ b/contracts/agents-api/model-execution.md @@ -80,7 +80,7 @@ New hosted requests, and requests that omit the inline model, record caller inte ``` - `protocol` names the upstream API (`anthropic`, `responses` or `chat_completions`), not an engine. The selected Harness must support it natively. -- `base_url` uses HTTPS with a valid host, without credentials, query or fragment. +- `base_url` uses HTTP or HTTPS with a valid host, without credentials, query or fragment. The scheme is the operator's choice: a self-hosted provider on another host may be plain HTTP. - `api_key` is nonempty, at most 16 KiB and contains no NUL, CR or LF. - `context_window` and `max_output_tokens` are optional nonnegative integers, with output no larger than context; both must be positive for MiniMax Code. Use the real model's limits. - `agent.model` is the exact provider model ID; a supplied value always replaces the deployment model. diff --git a/contracts/agents-api/v1/model_execution.go b/contracts/agents-api/v1/model_execution.go index d3c5f22da..084193824 100644 --- a/contracts/agents-api/v1/model_execution.go +++ b/contracts/agents-api/v1/model_execution.go @@ -42,7 +42,7 @@ func (p *ModelProviderInput) validate(registry harnessconfig.Registry) error { return &ModelProviderError{Code: "invalid_model_provider", Param: "", message: "model_provider is required"} } if !validModelProviderBaseURL(p.BaseURL) { - return &ModelProviderError{Code: "model_provider_base_url_invalid", Param: "base_url", message: "model provider requires an HTTPS base_url without credentials, query or fragment"} + return &ModelProviderError{Code: "model_provider_base_url_invalid", Param: "base_url", message: "model provider requires an http or https base_url without credentials, query or fragment"} } if !registry.SupportsProtocol(p.Protocol) { return &ModelProviderError{Code: "model_provider_protocol_unsupported", Param: "protocol", message: "unsupported model provider protocol"} diff --git a/contracts/agents-api/v1/model_execution_test.go b/contracts/agents-api/v1/model_execution_test.go index 8db6938ea..a082746b1 100644 --- a/contracts/agents-api/v1/model_execution_test.go +++ b/contracts/agents-api/v1/model_execution_test.go @@ -27,13 +27,16 @@ func TestModelExecutionValidation(t *testing.T) { t.Fatalf("unsupported protocol or harness accepted: %s/%s", tc.protocol, tc.harness) } } - for _, url := range []string{"http://example.com", "https://user:pass@example.com", "https://example.com?key=secret", "https://example.com#secret", "https://", + for _, url := range []string{"ftp://example.com", "file:///etc/passwd", "example.com/v1", "//example.com/v1", + "https://user:pass@example.com", "https://example.com?key=secret", "https://example.com#secret", "https://", "http://", "https://example.com:99999/v1", "https://example.com:0/v1", "https://xn--.test", "https://xn--a.test", "https://a..b", "https://999.1.1.1"} { if (&ModelProviderInput{Protocol: "responses", BaseURL: url, APIKey: "secret"}).Validate() == nil { t.Fatal("unsafe or unusable provider URL accepted", url) } } - for _, url := range []string{"https://example.com:8443/v1", "https://127.0.0.1/v1", "https://[::1]:8443/v1", "https://model_gateway.internal/v1", "https://bücher.example/v1"} { + for _, url := range []string{"https://example.com:8443/v1", "https://127.0.0.1/v1", "https://[::1]:8443/v1", "https://model_gateway.internal/v1", "https://bücher.example/v1", + "http://127.0.0.1:7351", "http://127.0.0.1:7351/v1", "http://localhost:8080/v1", "http://[::1]:8080", "http://LOCALHOST/v1", + "http://example.com/v1", "http://10.0.0.5:8080/v1", "http://192.168.1.10/v1", "http://[fd00::1]/v1", "http://model_gateway.internal/v1"} { if err := (&ModelProviderInput{Protocol: "responses", BaseURL: url, APIKey: "secret"}).Validate(); err != nil { t.Fatal("valid provider URL rejected", url, err) } diff --git a/contracts/agents-api/v1/model_provider_admission.go b/contracts/agents-api/v1/model_provider_admission.go index fa91a1cc2..c573f923d 100644 --- a/contracts/agents-api/v1/model_provider_admission.go +++ b/contracts/agents-api/v1/model_provider_admission.go @@ -9,7 +9,14 @@ import ( "golang.org/x/net/idna" ) -const providerScheme = "https" +// providerSchemes are the schemes a model provider base_url may use. The +// operator chooses whether the endpoint is served over TLS; a self-hosted +// provider on another host is as valid a target as a public one. Credentials, +// query and fragment stay rejected either way. +var providerSchemes = map[string]bool{ + "https": true, + "http": true, +} // providerHost converts a domain as URL host parsing does (UTS #46 without // hyphen or STD3 restrictions), rejecting invalid labels such as bad punycode. @@ -52,7 +59,10 @@ func ModelProviderRequired(environment string) bool { func validModelProviderBaseURL(base string) bool { u, err := url.Parse(base) - return err == nil && u.Scheme == providerScheme && validModelProviderHost(u) && u.User == nil && u.RawQuery == "" && u.Fragment == "" && !strings.ContainsAny(base, "\x00\r\n") + if err != nil || u.User != nil || u.RawQuery != "" || u.Fragment != "" || !validModelProviderHost(u) || strings.ContainsAny(base, "\x00\r\n") { + return false + } + return providerSchemes[u.Scheme] } // validModelProviderHost requires a usable host: an IP address, or a domain diff --git a/contracts/agents-api/v1/model_provider_error_test.go b/contracts/agents-api/v1/model_provider_error_test.go index bab331fdf..4248dfcf3 100644 --- a/contracts/agents-api/v1/model_provider_error_test.go +++ b/contracts/agents-api/v1/model_provider_error_test.go @@ -11,8 +11,8 @@ func TestModelProviderErrorMessagesAndPrecedence(t *testing.T) { name, harness, code, param, message string change func(*ModelProviderInput) }{ - {"url first", "codex", "model_provider_base_url_invalid", "base_url", "model provider requires an HTTPS base_url without credentials, query or fragment", func(p *ModelProviderInput) { - p.BaseURL = "http://private.example" + {"url first", "codex", "model_provider_base_url_invalid", "base_url", "model provider requires an http or https base_url without credentials, query or fragment", func(p *ModelProviderInput) { + p.BaseURL = "ftp://private.example" p.Protocol = "private" p.APIKey = "" }}, diff --git a/contracts/agents-api/zh/core-errors.md b/contracts/agents-api/zh/core-errors.md index d90ac52b0..1756cbb61 100644 --- a/contracts/agents-api/zh/core-errors.md +++ b/contracts/agents-api/zh/core-errors.md @@ -60,7 +60,7 @@ Web 的控制台服务器在 `/core` 路径上发生自身故障时使用此封 | `invalid_name` | `name` | `max_length`:Projects 和节点为 128,Project 键为 80 | 名称未通过相应资源的验证器 | | `invalid_node_capacity` | `max_active` 或 `max_retained` | `min`:1,`max`:1000000 | 容量无效;保留容量还必须至少等于活动容量 | | `invalid_model_provider` | null | 省略 | 必须提供完整的模型提供商配置包 | -| `model_provider_base_url_invalid` | `base_url` | 省略 | 必须使用 HTTPS,且不得包含凭据、查询或片段 | +| `model_provider_base_url_invalid` | `base_url` | 省略 | 必须使用 HTTP 或 HTTPS,且不得包含凭据、查询或片段 | | `model_provider_protocol_unsupported` | `protocol` | `harness` 和 `allowed_protocols`,来自该构建的适配器目录 | 协议未知,或所选 Harness 不支持该协议 | | `model_provider_api_key_invalid` | `api_key` | `max_length`:16384 | 密钥为空、过长或包含禁止字符 | | `model_provider_token_limits_invalid` | `context_window` 或 `max_output_tokens` | 省略 | 限制无效,或 Harness 要求的正数限制缺失 | diff --git a/contracts/agents-api/zh/model-execution.md b/contracts/agents-api/zh/model-execution.md index 0b29d25a3..8738df785 100644 --- a/contracts/agents-api/zh/model-execution.md +++ b/contracts/agents-api/zh/model-execution.md @@ -82,7 +82,7 @@ Core 从同一个数据库快照读取 Agent 配置和加密配置包;显式 ``` - `protocol` 指定上游 API(`anthropic`、`responses` 或 `chat_completions`),而不是引擎。所选 Harness 必须原生支持它。 -- `base_url` 使用 HTTPS 和有效主机名,且不得包含凭据、查询参数或片段。 +- `base_url` 使用 HTTP 或 HTTPS 和有效主机名,且不得包含凭据、查询参数或片段。scheme 由管理员选择:部署在其他主机上的自建 provider 也可以使用明文 HTTP。 - `api_key` 不得为空,最长为 16 KiB,并且不得包含 NUL、CR 或 LF。 - `context_window` 和 `max_output_tokens` 是可选的非负整数,输出限制不得大于上下文限制;对于 MiniMax Code,两者都必须为正数。请使用真实模型的限制。 - `agent.model` 是准确的提供商模型 ID;只要提供该值,就始终会替换部署模型。 diff --git a/internal/modelprovider/config.go b/internal/modelprovider/config.go index 386277d8f..259287a61 100644 --- a/internal/modelprovider/config.go +++ b/internal/modelprovider/config.go @@ -5,7 +5,6 @@ import ( "bytes" "encoding/json" "errors" - "net" "net/url" "strings" ) @@ -65,9 +64,9 @@ func (p Provider) Validate() error { if err != nil || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.ContainsAny(p.BaseURL, "\x00\r\n") { return ErrConfiguration } - // Remote providers require HTTPS. Runtime-local providers may use loopback - // HTTP; Core retains stricter public provider admission. - if u.Scheme != "https" && !(u.Scheme == "http" && net.ParseIP(u.Hostname()).IsLoopback()) { + // The scheme carries no admission decision here: an operator may point a + // Harness at a plain-HTTP endpoint, including one on another host. + if u.Scheme != "https" && u.Scheme != "http" { return ErrConfiguration } if strings.TrimSpace(p.APIKey) == "" || len(p.APIKey) > 16384 || strings.ContainsAny(p.APIKey, "\x00\r\n") { diff --git a/internal/modelprovider/config_test.go b/internal/modelprovider/config_test.go index 8b6312608..5b5b881ff 100644 --- a/internal/modelprovider/config_test.go +++ b/internal/modelprovider/config_test.go @@ -25,7 +25,7 @@ func TestParseProviderValidatesFrozenBundle(t *testing.T) { {"unknown field", "native_options", map[string]any{}}, {"alias", "protocol", "openai"}, {"missing key", "api_key", ""}, {"newline key", "api_key", "fixture\nkey"}, {"URL credentials", "base_url", "https://user:secret@model.example/v1"}, - {"remote HTTP", "base_url", "http://model.example/v1"}, + {"unsupported scheme", "base_url", "ftp://model.example/v1"}, {"query", "base_url", "https://model.example/v1?key=secret"}, {"fragment", "base_url", "https://model.example/v1#secret"}, {"negative limit", "context_window", -1}, {"excess output", "max_output_tokens", 64001}, diff --git a/packages/agents-client/src/admin-client.test.ts b/packages/agents-client/src/admin-client.test.ts index 5089dfdff..d6531f9d2 100644 --- a/packages/agents-client/src/admin-client.test.ts +++ b/packages/agents-client/src/admin-client.test.ts @@ -165,7 +165,7 @@ describe("AdminClient transport boundary", () => { const input = { model: "test-model", harness_config: { model_reasoning_effort: "high" }, model_provider: { protocol: "responses", base_url: "https://model.example/v1", api_key: "write-only", context_window: 200000, max_output_tokens: 8000 } } as const; expect(await client.setHarnessModelConfiguration("codex", input)).toEqual(provider); expect(JSON.parse(fetch.mock.calls[0]![1]!.body as string)).toEqual(input); - for (const unsafe of [{ ...provider, api_key: "leak" }, { ...provider, harness: "mcode" }, { ...provider, model_provider: { ...provider.model_provider, api_key_configured: false } }, { ...provider, model_provider: { ...provider.model_provider, base_url: "http://model.example/v1" } }, { ...provider, model_provider: { ...provider.model_provider, api_key: "leak" } }, { ...provider, model: "" }, { ...provider, harness_config: [] }, { ...provider, extra: 1 }]) { + for (const unsafe of [{ ...provider, api_key: "leak" }, { ...provider, harness: "mcode" }, { ...provider, model_provider: { ...provider.model_provider, api_key_configured: false } }, { ...provider, model_provider: { ...provider.model_provider, base_url: "https://user:pw@model.example/v1" } }, { ...provider, model_provider: { ...provider.model_provider, api_key: "leak" } }, { ...provider, model: "" }, { ...provider, harness_config: [] }, { ...provider, extra: 1 }]) { await expect(clientWith(unsafe).client.retrieveHarnessModelConfiguration("codex")).rejects.toMatchObject({ code: "invalid_admin_response" }); } for (const unsafe of [{ ...harnesses, data: [{ ...harnesses.data[1], model_configuration: { ...provider, api_key: "leak" } }] }, { ...harnesses, data: [harnesses.data[1], harnesses.data[1]] }, { data: harnesses.data }]) { @@ -242,7 +242,7 @@ describe("AdminClient response contracts", () => { // Core once accepted hosts and ports that URL parsing rejects; one must not fail the list. const stored = ["https://p.test:99999/v1", "https://xn--.test", "https://[::1]:8443/v1", "HTTPS://p.test/v1?"].map((url, index) => withURL(url, `agent-${index}`)); expect((await clientWith(page(stored)).client.listAgents(projectId)).data).toEqual(stored); - for (const url of ["http://p.test", "https://user:pw@p.test", "https://p.test/?key=secret", "https://p.test/#secret", "https://:443/v1"]) { + for (const url of ["https://user:pw@p.test", "https://p.test/?key=secret", "https://p.test/#secret", "https://:443/v1", "ftp://p.test"]) { await expect(clientWith(page([withURL(url)])).client.listAgents(projectId)).rejects.toBeInstanceOf(AgentCoreError); } }); diff --git a/packages/agents-client/src/execution-configuration-projection.ts b/packages/agents-client/src/execution-configuration-projection.ts index 5ae4c5000..4be8afcd9 100644 --- a/packages/agents-client/src/execution-configuration-projection.ts +++ b/packages/agents-client/src/execution-configuration-projection.ts @@ -14,12 +14,12 @@ function selection(value: unknown, invalid: Invalid): SessionExecutionConfigurat } // Splits an absolute URL as RFC 3986 appendix B does, without parsing its host. -const baseURLPattern = /^https:\/\/([^/?#]*)[^?#]*(?:\?([^#]*))?(?:#([\s\S]*))?$/iu; +const baseURLPattern = /^https?:\/\/([^/?#]*)[^?#]*(?:\?([^#]*))?(?:#([\s\S]*))?$/iu; /** - * Checks a stored base URL no more strictly than Core's write rule: HTTPS with a - * host and no credentials, query or fragment. Host syntax is Core's to enforce; - * a value an earlier Core accepted must not fail a whole list. + * Checks a stored base URL no more strictly than Core's write rule: an HTTP or + * HTTPS URL with a host and no credentials, query or fragment. Host syntax is + * Core's to enforce; a value an earlier Core accepted must not fail a whole list. */ function safeBaseURL(value: string): boolean { const match = baseURLPattern.exec(value); diff --git a/packages/agents-client/src/execution-configuration.test.ts b/packages/agents-client/src/execution-configuration.test.ts index c3938f2f9..6a332b679 100644 --- a/packages/agents-client/src/execution-configuration.test.ts +++ b/packages/agents-client/src/execution-configuration.test.ts @@ -37,6 +37,12 @@ describe("frozen execution configuration", () => { expect(await clientReturning(value).retrieveSessionExecutionConfiguration(projectId, id)).toEqual(value); } }); + it.each(["http://model.example/v1", "https://model.example/v1", "http://192.168.20.15:3721"])( + "accepts the operator-chosen provider scheme %s", async (baseURL) => { + const value = structuredClone(snapshot); + value.model_provider.configuration!.base_url = baseURL; + expect(await clientReturning(value).retrieveSessionExecutionConfiguration(projectId, id)).toEqual(value); + }); it.each([ { status: "redacted", source: "deployment", configuration: null }, { status: "available", source: "deployment", configuration: { protocol: "responses", base_url: "https://deployment.example/v1", api_key_configured: true } }, diff --git a/services/core/internal/api/core_model_provider_validation_test.go b/services/core/internal/api/core_model_provider_validation_test.go index f4d01ac45..92844ce93 100644 --- a/services/core/internal/api/core_model_provider_validation_test.go +++ b/services/core/internal/api/core_model_provider_validation_test.go @@ -49,8 +49,8 @@ func TestCoreModelProviderValidationFields(t *testing.T) { details map[string]any }{ {"bundle", "codex", `{"api_key":"private-key"}`, "invalid_model_provider", "", nil}, - {"negative shape", "codex", `{"protocol":"responses","base_url":"http://private-url","api_key":"private-key","context_window":-1}`, "invalid_model_provider", "", nil}, - {"url first", "codex", `{"protocol":"private-protocol","base_url":"http://private-url","api_key":"private-key"}`, "model_provider_base_url_invalid", "base_url", nil}, + {"negative shape", "codex", `{"protocol":"responses","base_url":"https://example.test","api_key":"private-key","context_window":-1}`, "invalid_model_provider", "", nil}, + {"url first", "codex", `{"protocol":"private-protocol","base_url":"ftp://private-url.example","api_key":"private-key"}`, "model_provider_base_url_invalid", "base_url", nil}, {"protocol", "codex", `{"protocol":"private-protocol","base_url":"https://example.test","api_key":"private-key"}`, "model_provider_protocol_unsupported", "protocol", map[string]any{"harness": "codex", "allowed_protocols": []any{"responses"}}}, {"key", "codex", `{"protocol":"responses","base_url":"https://example.test","api_key":"private-key\n"}`, "model_provider_api_key_invalid", "api_key", map[string]any{"max_length": float64(16384)}}, {"output", "codex", `{"protocol":"responses","base_url":"https://example.test","api_key":"private-key","context_window":1,"max_output_tokens":2}`, "model_provider_token_limits_invalid", "max_output_tokens", nil}, diff --git a/services/core/internal/api/model_provider_public_validation_test.go b/services/core/internal/api/model_provider_public_validation_test.go index e334d368b..b4ac94979 100644 --- a/services/core/internal/api/model_provider_public_validation_test.go +++ b/services/core/internal/api/model_provider_public_validation_test.go @@ -7,7 +7,7 @@ import ( func TestModelProviderPublicValidation(t *testing.T) { for _, tc := range []struct{ name, provider string }{ - {"url", `{"protocol":"responses","base_url":"http://private-url.example","api_key":"private-key"}`}, + {"url", `{"protocol":"responses","base_url":"ftp://private-url.example","api_key":"private-key"}`}, {"protocol", `{"protocol":"private-protocol","base_url":"https://example.test","api_key":"private-key"}`}, {"key", `{"protocol":"responses","base_url":"https://example.test","api_key":""}`}, {"limits", `{"protocol":"responses","base_url":"https://example.test","api_key":"private-key","context_window":1,"max_output_tokens":2}`}, @@ -17,7 +17,7 @@ func TestModelProviderPublicValidation(t *testing.T) { body := `{"agent":{"model":"fixture"},"environment":{"type":"openai_hosted"},"input":"hello","x_agents_core":{"model_provider":` + tc.provider + `}}` out := credentialRequest(h, http.MethodPost, "/v1/agents/sessions", body) messages := map[string]string{ - "url": "model provider requires an HTTPS base_url without credentials, query or fragment", + "url": "model provider requires an http or https base_url without credentials, query or fragment", "protocol": "unsupported model provider protocol", "key": "invalid model provider API key", "limits": "invalid model token limits", } diff --git a/services/core/internal/api/saved_provider_test.go b/services/core/internal/api/saved_provider_test.go index 5624721c0..b36fdbae8 100644 --- a/services/core/internal/api/saved_provider_test.go +++ b/services/core/internal/api/saved_provider_test.go @@ -134,7 +134,7 @@ func TestSavedProviderInvalidInput(t *testing.T) { `{"model_provider":{"protocol":"responses","base_url":"https://example.test","api_key":"saved-provider-secret","api_key_configured":true}}`, `{"model_provider":{"protocol":"responses","base_url":"https://example.test","api_key":"saved-provider-secret","context_window":null}}`, `{"model_provider":{"protocol":"responses","base_url":"https://example.test","api_key":null}}`, - `{"model_provider":{"protocol":"responses","base_url":"http://example.test","api_key":"saved-provider-secret"}}`, + `{"model_provider":{"protocol":"responses","base_url":"ftp://example.test","api_key":"saved-provider-secret"}}`, `{"model_provider":{"protocol":"responses","base_url":"https://user:saved-provider-secret@example.test","api_key":"saved-provider-secret"}}`, `{"model_provider":{"protocol":"responses","base_url":"https://example.test","api_key":"saved-provider-secret","API_KEY":"secret"}}`, `{"model_provider":{"protocol":"responses","base_url":"https://example.test","api_key":"saved-provider-secret","api_key":"other"}}`,