From fc7905b03e4f4ab2df7687660bc858c5f41772c3 Mon Sep 17 00:00:00 2001 From: velrith Date: Sun, 4 Oct 2026 14:55:43 +0800 Subject: [PATCH 1/4] Allow an HTTP model provider base_url on a loopback host A deployment default model provider had to use https, with no exception: validModelProviderBaseURL compared the scheme against a single "https" constant, so PUT /core/v1/harnesses/{harness}/model-configuration rejected a reachable local endpoint with model_provider_base_url_invalid. Plain HTTP is already acceptable elsewhere on this path. #408 let OAC_PUBLIC_URL use it on loopback and private ranges, and the credential gateway added in #343 serves the Harness over loopback in plaintext by design: the Harness never holds the key, so the relay is a local HTTP service. The provider base_url was the one remaining surface that mapped http to invalid unconditionally. Admit http only where the URL cannot leave the host: localhost, or a loopback IP. A private-network address stays HTTPS because other hosts can reach it. https keeps working everywhere it did, and credentials, query and fragment are still rejected on both schemes. Docs, the console error copy, the e2e fixture that mirrors Core's rule, and the contract and Core API tests follow. --- apps/web/e2e/fixture-console.mjs | 6 ++-- apps/web/src/i18n/locales/en/core-errors.ts | 2 +- .../web/src/i18n/locales/zh-CN/core-errors.ts | 2 +- contracts/agents-api/core-errors.md | 2 +- contracts/agents-api/model-execution.md | 2 +- contracts/agents-api/v1/model_execution.go | 2 +- .../agents-api/v1/model_execution_test.go | 6 ++-- .../agents-api/v1/model_provider_admission.go | 33 +++++++++++++++++-- .../v1/model_provider_error_test.go | 2 +- contracts/agents-api/zh/core-errors.md | 2 +- contracts/agents-api/zh/model-execution.md | 2 +- .../core_model_provider_validation_test.go | 4 +-- .../model_provider_public_validation_test.go | 2 +- 13 files changed, 50 insertions(+), 17 deletions(-) diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index a3de5cafa..dfa6383f0 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -518,7 +518,9 @@ 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; } }; +const loopbackHost = (host) => { const name = host.replace(/^\[|\]$/g, "").toLowerCase(); return name === "localhost" || name === "::1" || /^127\./.test(name); }; +/** Core admits https anywhere, and http on a loopback host, without credentials, query or fragment. */ +const providerBaseUrl = (value) => { try { const url = new URL(value); const scheme = url.protocol === "https:" || (url.protocol === "http:" && loopbackHost(url.hostname)); 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 +529,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 https base_url, or http on a loopback host, 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/i18n/locales/en/core-errors.ts b/apps/web/src/i18n/locales/en/core-errors.ts index ba11e4086..c9b802c40 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 HTTPS URL, or HTTP on a loopback host when the model runs on this machine, 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/zh-CN/core-errors.ts b/apps/web/src/i18n/locales/zh-CN/core-errors.ts index 4334ca3c1..eaad259b1 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": "请使用 HTTPS 地址;模型服务在本机时可使用回环地址的 HTTP。地址不要包含凭证、查询参数或片段。", "model_provider_protocol_unsupported": "此执行引擎不支持该协议。", "protocols": "支持的协议:{{protocols}}。", "model_provider_api_key_invalid": "请输入有效的 API Key,不要包含控制字符。", diff --git a/contracts/agents-api/core-errors.md b/contracts/agents-api/core-errors.md index b8eddd89d..cdd85e2cb 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 HTTPS, or HTTP on a loopback host, 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..5d4a25ac3 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 HTTPS with a valid host, without credentials, query or fragment. HTTP is admitted on a loopback host (`localhost`, `127.0.0.0/8`, `::1`), for a model provider running on the same machine. - `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..c2f668a25 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 https base_url, or http on a loopback host, 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..70fb65f84 100644 --- a/contracts/agents-api/v1/model_execution_test.go +++ b/contracts/agents-api/v1/model_execution_test.go @@ -27,13 +27,15 @@ 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{"http://example.com", "http://10.0.0.5:8080/v1", "http://192.168.1.10/v1", "http://[fd00::1]/v1", "http://model_gateway.internal/v1", + "https://user:pass@example.com", "https://example.com?key=secret", "https://example.com#secret", "https://", "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"} { 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..0da19d86e 100644 --- a/contracts/agents-api/v1/model_provider_admission.go +++ b/contracts/agents-api/v1/model_provider_admission.go @@ -9,7 +9,15 @@ import ( "golang.org/x/net/idna" ) -const providerScheme = "https" +// providerScheme is the scheme a model provider base_url must use. Plain HTTP +// is admitted only where the URL cannot leave the host: the model path already +// serves the Harness over loopback in plaintext (the credential gateway), so a +// loopback provider is the same trust boundary. Anything reachable from the +// network, including a private-network address, stays HTTPS. +const ( + providerScheme = "https" + providerPlainScheme = "http" +) // 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 +60,28 @@ 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 + } + switch u.Scheme { + case providerScheme: + return true + case providerPlainScheme: + return loopbackModelProviderHost(u.Hostname()) + default: + return false + } +} + +// loopbackModelProviderHost reports whether an HTTP provider base_url stays on +// the machine. Only a loopback host qualifies; a private-network address is +// still reachable by other hosts, so it keeps the HTTPS requirement. +func loopbackModelProviderHost(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() } // 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..8ce1d81bc 100644 --- a/contracts/agents-api/v1/model_provider_error_test.go +++ b/contracts/agents-api/v1/model_provider_error_test.go @@ -11,7 +11,7 @@ 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) { + {"url first", "codex", "model_provider_base_url_invalid", "base_url", "model provider requires an https base_url, or http on a loopback host, without credentials, query or fragment", func(p *ModelProviderInput) { p.BaseURL = "http://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..f925a0777 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` | 省略 | 必须使用 HTTPS,或回环主机上的 HTTP,且不得包含凭据、查询或片段 | | `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..4463ed63e 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` 使用 HTTPS 和有效主机名,且不得包含凭据、查询参数或片段。当模型服务运行在本机时,回环主机(`localhost`、`127.0.0.0/8`、`::1`)上允许使用 HTTP。 - `api_key` 不得为空,最长为 16 KiB,并且不得包含 NUL、CR 或 LF。 - `context_window` 和 `max_output_tokens` 是可选的非负整数,输出限制不得大于上下文限制;对于 MiniMax Code,两者都必须为正数。请使用真实模型的限制。 - `agent.model` 是准确的提供商模型 ID;只要提供该值,就始终会替换部署模型。 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..c194a7899 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":"http://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..757bbc34a 100644 --- a/services/core/internal/api/model_provider_public_validation_test.go +++ b/services/core/internal/api/model_provider_public_validation_test.go @@ -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 https base_url, or http on a loopback host, without credentials, query or fragment", "protocol": "unsupported model provider protocol", "key": "invalid model provider API key", "limits": "invalid model token limits", } From fba59274bfef1a8787c7045a68f08bd4f74901c5 Mon Sep 17 00:00:00 2001 From: velrith Date: Sun, 4 Oct 2026 15:13:54 +0800 Subject: [PATCH 2/4] Accept http and https model provider base_urls on any host A deployment default model provider could only use https, with a loopback-only exception for http. That blocked a legitimate setup: a self-hosted provider on another host in the same network, reachable only over plain HTTP. The scheme now carries no admission decision. Core admits http and https on any host, and the Runtime-side provider validation in internal/modelprovider matches. Host, port, credential, query, fragment and control-character checks are unchanged. The operator owns the endpoint, so the transport choice is theirs. - contracts/agents-api/v1/model_provider_admission.go: replace the loopback scheme branch with an admitted-scheme set - contracts/agents-api/v1/model_execution.go, contract docs (EN/ZH), console i18n and the e2e fixture: reword the error to name http and https without a loopback qualifier - tests: remote http is now an accepted URL; the invalid-URL cases use an unsupported scheme instead --- .../internal/agent/claudesdk/local_test.go | 2 +- .../agent/mcode/model_provider_test.go | 2 +- apps/web/e2e/fixture-console.mjs | 7 ++-- apps/web/src/i18n/locales/en/core-errors.ts | 2 +- .../web/src/i18n/locales/zh-CN/core-errors.ts | 2 +- contracts/agents-api/core-errors.md | 2 +- contracts/agents-api/model-execution.md | 2 +- contracts/agents-api/v1/model_execution.go | 2 +- .../agents-api/v1/model_execution_test.go | 7 ++-- .../agents-api/v1/model_provider_admission.go | 37 +++++-------------- .../v1/model_provider_error_test.go | 4 +- contracts/agents-api/zh/core-errors.md | 2 +- contracts/agents-api/zh/model-execution.md | 2 +- internal/modelprovider/config.go | 7 ++-- internal/modelprovider/config_test.go | 2 +- .../core_model_provider_validation_test.go | 2 +- .../model_provider_public_validation_test.go | 4 +- .../core/internal/api/saved_provider_test.go | 2 +- 18 files changed, 35 insertions(+), 55 deletions(-) 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 dfa6383f0..8f9147ad7 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -518,9 +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 loopbackHost = (host) => { const name = host.replace(/^\[|\]$/g, "").toLowerCase(); return name === "localhost" || name === "::1" || /^127\./.test(name); }; -/** Core admits https anywhere, and http on a loopback host, without credentials, query or fragment. */ -const providerBaseUrl = (value) => { try { const url = new URL(value); const scheme = url.protocol === "https:" || (url.protocol === "http:" && loopbackHost(url.hostname)); return scheme && 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"; @@ -529,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 (!providerBaseUrl(input.base_url)) return "model provider requires an https base_url, or http on a loopback host, 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/i18n/locales/en/core-errors.ts b/apps/web/src/i18n/locales/en/core-errors.ts index c9b802c40..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, or HTTP on a loopback host when the model runs on this machine, 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/zh-CN/core-errors.ts b/apps/web/src/i18n/locales/zh-CN/core-errors.ts index eaad259b1..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 地址;模型服务在本机时可使用回环地址的 HTTP。地址不要包含凭证、查询参数或片段。", + "model_provider_base_url_invalid": "请使用 HTTP 或 HTTPS 地址,地址不要包含凭证、查询参数或片段。", "model_provider_protocol_unsupported": "此执行引擎不支持该协议。", "protocols": "支持的协议:{{protocols}}。", "model_provider_api_key_invalid": "请输入有效的 API Key,不要包含控制字符。", diff --git a/contracts/agents-api/core-errors.md b/contracts/agents-api/core-errors.md index cdd85e2cb..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, or HTTP on a loopback host, 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 5d4a25ac3..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. HTTP is admitted on a loopback host (`localhost`, `127.0.0.0/8`, `::1`), for a model provider running on the same machine. +- `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 c2f668a25..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, or http on a loopback host, 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 70fb65f84..a082746b1 100644 --- a/contracts/agents-api/v1/model_execution_test.go +++ b/contracts/agents-api/v1/model_execution_test.go @@ -27,15 +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", "http://10.0.0.5:8080/v1", "http://192.168.1.10/v1", "http://[fd00::1]/v1", "http://model_gateway.internal/v1", - "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", - "http://127.0.0.1:7351", "http://127.0.0.1:7351/v1", "http://localhost:8080/v1", "http://[::1]:8080", "http://LOCALHOST/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 0da19d86e..c573f923d 100644 --- a/contracts/agents-api/v1/model_provider_admission.go +++ b/contracts/agents-api/v1/model_provider_admission.go @@ -9,15 +9,14 @@ import ( "golang.org/x/net/idna" ) -// providerScheme is the scheme a model provider base_url must use. Plain HTTP -// is admitted only where the URL cannot leave the host: the model path already -// serves the Harness over loopback in plaintext (the credential gateway), so a -// loopback provider is the same trust boundary. Anything reachable from the -// network, including a private-network address, stays HTTPS. -const ( - providerScheme = "https" - providerPlainScheme = "http" -) +// 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. @@ -63,25 +62,7 @@ func validModelProviderBaseURL(base string) bool { if err != nil || u.User != nil || u.RawQuery != "" || u.Fragment != "" || !validModelProviderHost(u) || strings.ContainsAny(base, "\x00\r\n") { return false } - switch u.Scheme { - case providerScheme: - return true - case providerPlainScheme: - return loopbackModelProviderHost(u.Hostname()) - default: - return false - } -} - -// loopbackModelProviderHost reports whether an HTTP provider base_url stays on -// the machine. Only a loopback host qualifies; a private-network address is -// still reachable by other hosts, so it keeps the HTTPS requirement. -func loopbackModelProviderHost(host string) bool { - if strings.EqualFold(host, "localhost") { - return true - } - ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() + 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 8ce1d81bc..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, or http on a loopback host, 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 f925a0777..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,或回环主机上的 HTTP,且不得包含凭据、查询或片段 | +| `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 4463ed63e..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 和有效主机名,且不得包含凭据、查询参数或片段。当模型服务运行在本机时,回环主机(`localhost`、`127.0.0.0/8`、`::1`)上允许使用 HTTP。 +- `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/services/core/internal/api/core_model_provider_validation_test.go b/services/core/internal/api/core_model_provider_validation_test.go index c194a7899..92844ce93 100644 --- a/services/core/internal/api/core_model_provider_validation_test.go +++ b/services/core/internal/api/core_model_provider_validation_test.go @@ -50,7 +50,7 @@ func TestCoreModelProviderValidationFields(t *testing.T) { }{ {"bundle", "codex", `{"api_key":"private-key"}`, "invalid_model_provider", "", 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":"http://private-url.example","api_key":"private-key"}`, "model_provider_base_url_invalid", "base_url", 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 757bbc34a..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, or http on a loopback host, 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"}}`, From 24f172bc45453d27ffa9913728fe4c428e2fed2d Mon Sep 17 00:00:00 2001 From: velrith Date: Sun, 4 Oct 2026 16:09:21 +0800 Subject: [PATCH 3/4] Accept http model provider base_urls in the console projection The admission rule widened to http in the previous commits, but the console client still rejected any stored base URL that was not https. Core accepted an http configuration while /core/v1/harnesses failed projection, so the console showed "Core returned an invalid administration response" for the whole harness list and the deployment default model configuration could not load. Widen the client-side pattern to http/https, mirroring the Core write rule, and update the tests that encoded the https-only contract. --- packages/agents-client/src/admin-client.test.ts | 4 ++-- .../src/execution-configuration-projection.ts | 8 ++++---- .../agents-client/src/execution-configuration.test.ts | 6 ++++++ 3 files changed, 12 insertions(+), 6 deletions(-) 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 } }, From 1f302bd2feb47c5ce22d460b7016f71436d56d7b Mon Sep 17 00:00:00 2001 From: velrith Date: Sun, 4 Oct 2026 16:20:31 +0800 Subject: [PATCH 4/4] Accept http base_urls in the default model configuration form The console's write form still enforced the old HTTPS-only rule in isProviderUrl, so saving an operator-chosen http endpoint was blocked before the request reached Core. The rejection text also still told the administrator to use HTTPS, and the example was a public host. Widen the form check to http/https, update the guidance text and example, and cover the accepted and rejected shapes. --- .../features/system/ModelProviderDialog.tsx | 10 +++---- .../system/model-provider-dialog.test.ts | 26 +++++++++++++++++++ apps/web/src/i18n/locales/en/system.ts | 2 +- apps/web/src/i18n/locales/zh-CN/system.ts | 2 +- 4 files changed, 33 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/features/system/model-provider-dialog.test.ts 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/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/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 必须填写。",