Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/daemon/internal/agent/claudesdk/local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/agent/mcode/model_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
5 changes: 3 additions & 2 deletions apps/web/e2e/fixture-console.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/features/system/ModelProviderDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/features/system/model-provider-dialog.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
2 changes: 1 addition & 1 deletion apps/web/src/i18n/locales/en/core-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/i18n/locales/en/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/i18n/locales/zh-CN/core-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,不要包含控制字符。",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/i18n/locales/zh-CN/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export const system: TranslationShape<typeof english> = {
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 必须填写。",
Expand Down
2 changes: 1 addition & 1 deletion contracts/agents-api/core-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion contracts/agents-api/model-execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion contracts/agents-api/v1/model_execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
7 changes: 5 additions & 2 deletions contracts/agents-api/v1/model_execution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
14 changes: 12 additions & 2 deletions contracts/agents-api/v1/model_provider_admission.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions contracts/agents-api/v1/model_provider_error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
}},
Expand Down
2 changes: 1 addition & 1 deletion contracts/agents-api/zh/core-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 要求的正数限制缺失 |
Expand Down
2 changes: 1 addition & 1 deletion contracts/agents-api/zh/model-execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;只要提供该值,就始终会替换部署模型。
Expand Down
7 changes: 3 additions & 4 deletions internal/modelprovider/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"bytes"
"encoding/json"
"errors"
"net"
"net/url"
"strings"
)
Expand Down Expand Up @@ -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") {
Expand Down
2 changes: 1 addition & 1 deletion internal/modelprovider/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
Loading