diff --git a/apps/daemon/internal/agent/claudesdk/provider.go b/apps/daemon/internal/agent/claudesdk/provider.go index 0d78a0fe3..18114ea1e 100644 --- a/apps/daemon/internal/agent/claudesdk/provider.go +++ b/apps/daemon/internal/agent/claudesdk/provider.go @@ -1,6 +1,7 @@ package claudesdk import ( + "sort" "strings" harnessconfiguration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk" @@ -12,7 +13,17 @@ func providerEnvironment(value any) ([]string, error) { if err != nil { return nil, err } - return []string{"ANTHROPIC_BASE_URL=" + provider.BaseURL, "ANTHROPIC_AUTH_TOKEN=" + provider.APIKey}, nil + names := make([]string, 0, len(provider.Headers)) + for name := range provider.Headers { + names = append(names, name) + } + sort.Strings(names) + headers := make([]string, 0, len(names)) + for _, name := range names { + headers = append(headers, name+": "+provider.Headers[name]) + } + // An explicit empty value clears any previous native header selection. + return []string{"ANTHROPIC_BASE_URL=" + provider.BaseURL, "ANTHROPIC_AUTH_TOKEN=" + provider.APIKey, "ANTHROPIC_CUSTOM_HEADERS=" + strings.Join(headers, "\n")}, nil } func withProvider(env, provider []string) []string { @@ -22,7 +33,7 @@ func withProvider(env, provider []string) []string { out := make([]string, 0, len(env)+len(provider)) for _, entry := range env { key, _, _ := strings.Cut(entry, "=") - if key != "ANTHROPIC_API_KEY" && key != "ANTHROPIC_AUTH_TOKEN" && key != "ANTHROPIC_BASE_URL" { + if key != "ANTHROPIC_API_KEY" && key != "ANTHROPIC_AUTH_TOKEN" && key != "ANTHROPIC_BASE_URL" && key != "ANTHROPIC_CUSTOM_HEADERS" { out = append(out, entry) } } diff --git a/apps/daemon/internal/agent/claudesdk/provider_test.go b/apps/daemon/internal/agent/claudesdk/provider_test.go new file mode 100644 index 000000000..e719738b7 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/provider_test.go @@ -0,0 +1,63 @@ +package claudesdk + +import ( + "slices" + "strings" + "testing" +) + +func TestProviderHeadersEveryPreparation(t *testing.T) { + raw := map[string]any{"protocol": "anthropic", "base_url": "https://example.test", "api_key": "fixture", "headers": map[string]string{"X-B": "two:colon", "X-A": "one", "X-Empty": ""}} + for range 3 { + provider, err := providerEnvironment(raw) + if err != nil { + t.Fatal(err) + } + env := withProvider([]string{"ANTHROPIC_CUSTOM_HEADERS=X-Stale: wrong", "OTHER=keep"}, provider) + if !slices.Contains(env, "ANTHROPIC_CUSTOM_HEADERS=X-A: one\nX-B: two:colon\nX-Empty: ") { + t.Fatal("headers missing or changed") + } + for _, entry := range env { + if strings.Contains(entry, "X-Stale") { + t.Fatal("stale headers retained") + } + } + } + delete(raw, "headers") + provider, err := providerEnvironment(raw) + if err != nil || !slices.Contains(provider, "ANTHROPIC_CUSTOM_HEADERS=") { + t.Fatal("ambient headers were not cleared") + } +} +func TestWorkspaceProviderHeadersReachNativeOptions(t *testing.T) { + config := workspaceFixture(t) + t.Setenv("ANTHROPIC_CUSTOM_HEADERS", "X-Stale: ambient") + config.Env = append(config.Env, "ANTHROPIC_CUSTOM_HEADERS=X-Stale: config") + req := workspaceRequest() + req.AgentOptions["model_provider"] = map[string]any{"protocol": "anthropic", "base_url": "https://example.test", "api_key": "fixture", "headers": map[string]string{"X-Activity": "daily-free"}} + start, env, err := prepare(config, req) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(start.Workspace.EnvNames, "ANTHROPIC_CUSTOM_HEADERS") || !slices.Contains(env, "ANTHROPIC_CUSTOM_HEADERS=X-Activity: daily-free") { + t.Fatal("headers missing from native workspace projection") + } + for _, entry := range env { + if strings.Contains(entry, "X-Stale") { + t.Fatal("ambient header selection survived") + } + } + delete(req.AgentOptions["model_provider"].(map[string]any), "headers") + _, env, err = prepare(config, req) + if err != nil || !slices.Contains(env, "ANTHROPIC_CUSTOM_HEADERS=") { + t.Fatal("headerless provider did not clear ambient headers", err) + } +} +func TestProviderHeadersRejectAuthenticationAndMalformedValues(t *testing.T) { + for _, headers := range []any{map[string]string{"Authorization": "override"}, map[string]string{"Anthropic-Version": "override"}, map[string]string{"X-Activity": "bad\nvalue"}, map[string]any{"X-Activity": nil}} { + _, err := providerEnvironment(map[string]any{"protocol": "anthropic", "base_url": "https://example.test", "api_key": "fixture", "headers": headers}) + if err == nil { + t.Fatal("invalid header accepted") + } + } +} diff --git a/apps/daemon/internal/agent/claudesdk/workspace.go b/apps/daemon/internal/agent/claudesdk/workspace.go index ba63e73de..dddda2cfc 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace.go +++ b/apps/daemon/internal/agent/claudesdk/workspace.go @@ -110,7 +110,7 @@ func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { env := []string{} for _, entry := range os.Environ() { name, _, _ := strings.Cut(entry, "=") - if name != "ANTHROPIC_API_KEY" && name != "ANTHROPIC_AUTH_TOKEN" && name != "CLAUDE_CODE_OAUTH_TOKEN" && name != "CLAUDE_CONFIG_DIR" && name != "HOME" && name != "TMPDIR" { + if name != "ANTHROPIC_API_KEY" && name != "ANTHROPIC_AUTH_TOKEN" && name != "ANTHROPIC_CUSTOM_HEADERS" && name != "CLAUDE_CODE_OAUTH_TOKEN" && name != "CLAUDE_CONFIG_DIR" && name != "HOME" && name != "TMPDIR" { env = append(env, entry) } } @@ -130,7 +130,7 @@ func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { func workspaceEnvName(name string) bool { switch name { - case "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", + case "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY": return true diff --git a/apps/daemon/internal/agent/codex/model_headers_native_test.go b/apps/daemon/internal/agent/codex/model_headers_native_test.go new file mode 100644 index 000000000..8d45c6e5d --- /dev/null +++ b/apps/daemon/internal/agent/codex/model_headers_native_test.go @@ -0,0 +1,111 @@ +package codex + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// The pinned native binary talks to a synthetic Responses HTTP endpoint. +func TestNativeModelHeadersEveryTurn(t *testing.T) { + binary := os.Getenv("OAC_TEST_CODEX_BINARY") + if binary == "" { + t.Skip("requires pinned Codex binary") + } + if version, err := exec.Command(binary, "--version").Output(); err != nil || strings.TrimSpace(string(version)) != "codex-cli 0.153.4" { + t.Fatal("requires Codex 0.153.4") + } + var attempts atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Sandbase-Benefit") != "daily-free" || r.Header.Get("X-Literal") != "${unchanged}:value" { + t.Error("model request lost headers") + } + n := attempts.Add(1) + if n == 1 { + w.WriteHeader(500) + _, _ = w.Write([]byte(`{"error":{"message":"synthetic retry","type":"server_error"}}`)) + return + } + w.Header().Set("Content-Type", "text/event-stream") + item := map[string]any{"id": fmt.Sprintf("msg_%d", n), "type": "message", "role": "assistant", "status": "completed", "content": []any{map[string]any{"type": "output_text", "text": "HEADER-OK", "annotations": []any{}}}} + if n == 2 { + item = map[string]any{"id": "fc_fixture", "type": "function_call", "call_id": "call_fixture", "name": "lookup", "arguments": "{}", "status": "completed"} + } + response := map[string]any{"id": fmt.Sprintf("resp_%d", n), "object": "response", "status": "completed", "output": []any{item}, "usage": map[string]any{"input_tokens": 10, "output_tokens": 2, "total_tokens": 12, "input_tokens_details": map[string]int{"cached_tokens": 0}}} + for _, event := range []map[string]any{{"type": "response.created", "response": map[string]any{"id": response["id"], "status": "in_progress", "output": []any{}}}, {"type": "response.output_item.added", "output_index": 0, "item": item}, {"type": "response.output_text.delta", "item_id": item["id"], "output_index": 0, "content_index": 0, "delta": "HEADER-OK"}, {"type": "response.output_item.done", "output_index": 0, "item": item}, {"type": "response.completed", "response": response}} { + body, _ := json.Marshal(event) + _, _ = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event["type"], body) + } + })) + defer server.Close() + t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) + for _, name := range []string{"CODEX_EXEC_SERVER_URL", "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL", "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID", "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN"} { + t.Setenv(name, "") + } + cfg := defaultSessionConfig(testStateRoot(t)) + cfg.codexBinary = binary + cfg.logger = slog.New(slog.DiscardHandler) + req := proto.PromptRequestPayload{AgentKind: "codex", AgentStateKey: "header-native", DisableExecutionEnvironment: true, DisableSubagents: true, FunctionTools: []proto.FunctionTool{{Name: "lookup", Description: "Fixture", Parameters: json.RawMessage(`{"type":"object","properties":{}}`)}}, AgentOptions: map[string]any{"model": "gpt-5.5", "model_provider": map[string]any{"protocol": "responses", "base_url": server.URL + "/v1", "api_key": "fixture", "headers": map[string]string{"X-Sandbase-Benefit": "daily-free", "X-Literal": "${unchanged}:value"}}}} + ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) + defer cancel() + e, err := newExecutor(ctx, req, cfg) + if err != nil { + t.Fatal(err) + } + defer func() { _ = e.Close(context.Background()) }() + for i := 0; i < 3; i++ { + output := make(chan proto.Envelope, 256) + turn, err := e.StartTurn(ctx, fmt.Sprintf("turn-%d", i), proto.TextInput("Reply HEADER-OK."), output) + if err != nil { + t.Fatal(err) + } + var done proto.DonePayload + for event := range output { + if event.Type == proto.TypeFunctionCall { + var call proto.FunctionCallPayload + _ = event.DecodePayload(&call) + resultText := "fixture result" + if err := turn.(*Session).SubmitFunctionResult(ctx, proto.FunctionResultPayload{CallID: call.CallID, Success: true, Content: []proto.InputContent{{Type: "input_text", Text: &resultText}}}); err != nil { + t.Fatal(err) + } + } + if event.Type == proto.TypeError { + t.Error("native turn failed") + } + if event.Type == proto.TypeDone { + _ = event.DecodePayload(&done) + } + } + if _, err = turn.AwaitSettlement(ctx); err != nil { + t.Fatal(err) + } + if done.Content != "HEADER-OK" { + t.Fatalf("unexpected output %q", done.Content) + } + if i == 1 { + req.AgentSessionID = turn.(*Session).currentThreadID() + req.RequireExistingNativeSession = true + if err = e.Close(ctx); err != nil { + t.Fatal(err) + } + e, err = newExecutor(ctx, req, cfg) + if err != nil { + t.Fatal(err) + } + } + } + if attempts.Load() < 5 { + t.Fatal("retry and three turns not observed") + } +} diff --git a/apps/daemon/internal/agent/codex/options.go b/apps/daemon/internal/agent/codex/options.go index d3b87d438..0f7f26d26 100644 --- a/apps/daemon/internal/agent/codex/options.go +++ b/apps/daemon/internal/agent/codex/options.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "slices" + "sort" "strings" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -123,7 +124,18 @@ func BuildSessionPlan(stateRoot, agentStateKey string, opts map[string]any, cont // shell_environment_policy is applied. Do not persist provider credentials. plan.DisableFeatures = append(plan.DisableFeatures, "shell_snapshot") env = append(env, providerAPIKeyEnv+"="+provider.BearerToken) - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"shell_environment_policy.exclude", "[" + strconv(providerAPIKeyEnv) + "]"}) + excluded := []string{strconv(providerAPIKeyEnv)} + names := make([]string, 0, len(provider.HTTPHeaders)) + for name := range provider.HTTPHeaders { + names = append(names, name) + } + sort.Strings(names) + for i, name := range names { + reference := providerHeaderEnv(i) + env = append(env, reference+"="+provider.HTTPHeaders[name]) + excluded = append(excluded, strconv(reference)) + } + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"shell_environment_policy.exclude", "[" + strings.Join(excluded, ",") + "]"}) } plan.Env = env @@ -203,7 +215,7 @@ func normaliseProviderConfig(raw any) (providerConfig, bool, error) { if err != nil { return providerConfig{}, false, err } - return providerConfig{BaseURL: provider.BaseURL, BearerToken: provider.APIKey, WireAPI: "responses"}, true, nil + return providerConfig{BaseURL: provider.BaseURL, BearerToken: provider.APIKey, WireAPI: "responses", HTTPHeaders: provider.Headers}, true, nil } func stringOpt(opts map[string]any, key string) string { diff --git a/apps/daemon/internal/agent/codex/provider_config.go b/apps/daemon/internal/agent/codex/provider_config.go index bfdfe692f..42ca09b38 100644 --- a/apps/daemon/internal/agent/codex/provider_config.go +++ b/apps/daemon/internal/agent/codex/provider_config.go @@ -33,8 +33,7 @@ type providerConfig struct { BaseURL string // BearerToken is supplied only through the native env_key mechanism. BearerToken string - // HTTPHeaders is forwarded as `[model_providers.oac.http_headers]`. - // Keys / values rendered as TOML basic strings. + // HTTPHeaders are supplied through native environment references. HTTPHeaders map[string]string // QueryParams is forwarded as `[model_providers.oac.query_params]`, // used by Azure (api-version=2025-04-01-preview). @@ -109,11 +108,11 @@ func writeCodexProviderConfig(codexHome string, cfg providerConfig) error { sort.Strings(keys) b.WriteString("\n[model_providers.") b.WriteString(oacProviderSlug) - b.WriteString(".http_headers]\n") - for _, k := range keys { + b.WriteString(".env_http_headers]\n") + for i, k := range keys { b.WriteString(tomlQuoteString(k)) b.WriteString(" = ") - b.WriteString(tomlQuoteString(cfg.HTTPHeaders[k])) + b.WriteString(tomlQuoteString(providerHeaderEnv(i))) b.WriteByte('\n') } } @@ -160,3 +159,5 @@ func appendConfigTOML(path string, body string) error { } return nil } + +func providerHeaderEnv(index int) string { return fmt.Sprintf("OAC_CODEX_MODEL_HEADER_%d", index) } diff --git a/apps/daemon/internal/agent/codex/provider_config_test.go b/apps/daemon/internal/agent/codex/provider_config_test.go index 7eef92ede..a68dbb7e9 100644 --- a/apps/daemon/internal/agent/codex/provider_config_test.go +++ b/apps/daemon/internal/agent/codex/provider_config_test.go @@ -74,9 +74,9 @@ func TestWriteCodexProviderConfig_FullProvider(t *testing.T) { `name = "mygw"`, `base_url = "https://platform-api.example.com/v1"`, `env_key = "OAC_CODEX_PROVIDER_API_KEY"`, - `[model_providers.oac.http_headers]`, - `"X-Sub-Module" = "codex-internal"`, - `"X-Request-ID" = "abc"`, + `[model_providers.oac.env_http_headers]`, + `"X-Sub-Module" = "OAC_CODEX_MODEL_HEADER_1"`, + `"X-Request-ID" = "OAC_CODEX_MODEL_HEADER_0"`, `[model_providers.oac.query_params]`, `"api-version" = "2025-04-01-preview"`, } { @@ -288,3 +288,31 @@ func TestExplicitStateRootSurvivesRuntimeReplacement(t *testing.T) { t.Fatal("unavailable explicit root fell back") } } + +func TestProviderHeadersRebuiltForEveryTurn(t *testing.T) { + root := t.TempDir() + opts := map[string]any{"model": "fixture", "model_provider": map[string]any{"protocol": "responses", "base_url": "https://example.test/v1", "api_key": "fixture", "headers": map[string]string{"X-Activity": "private-header-canary"}}} + for range 3 { + plan, err := BuildSessionPlan(root, "persistent", opts, nil) + if err != nil { + t.Fatal(err) + } + body := mustReadFile(t, filepath.Join(nativeHomeFromPlan(plan), "config.toml")) + if strings.Contains(body, "private-header-canary") || !strings.Contains(body, "env_http_headers") { + t.Fatal("unsafe header projection") + } + if !slices.Contains(plan.Env, "OAC_CODEX_MODEL_HEADER_0=private-header-canary") { + t.Fatal("missing header environment") + } + found := false + for _, option := range plan.ExtraConfig { + if option[0] == "shell_environment_policy.exclude" && strings.Contains(option[1], "OAC_CODEX_MODEL_HEADER_0") { + found = true + } + } + if !found { + t.Fatal("header exposed to tools") + } + plan.Cleanup() + } +} diff --git a/apps/daemon/internal/agent/mcode/model_provider.go b/apps/daemon/internal/agent/mcode/model_provider.go index b25385f01..231198586 100644 --- a/apps/daemon/internal/agent/mcode/model_provider.go +++ b/apps/daemon/internal/agent/mcode/model_provider.go @@ -2,6 +2,7 @@ package mcode import ( "fmt" + "maps" harnessconfiguration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" @@ -21,9 +22,13 @@ func modelProviderConfig(raw any, model string) (map[string]any, error) { case modelprovider.Responses: api = "openai-responses" } + options := map[string]any{"baseURL": provider.BaseURL, "apiKey": provider.APIKey} + if len(provider.Headers) > 0 { + options["headers"] = maps.Clone(provider.Headers) + } return map[string]any{ "name": "Configured provider", "kind": "custom", "enabled": true, "api": api, - "options": map[string]any{"baseURL": provider.BaseURL, "apiKey": provider.APIKey}, + "options": options, "models": map[string]any{model: map[string]any{ "name": model, "tool_call": true, "limit": map[string]any{"context": provider.ContextWindow, "output": provider.MaxOutputTokens}, diff --git a/apps/daemon/internal/agent/mcode/model_provider_test.go b/apps/daemon/internal/agent/mcode/model_provider_test.go index bdef5fa6c..ada80d770 100644 --- a/apps/daemon/internal/agent/mcode/model_provider_test.go +++ b/apps/daemon/internal/agent/mcode/model_provider_test.go @@ -15,6 +15,7 @@ func TestOptionsModelProviderProtocols(t *testing.T) { t.Run(tc.protocol, func(t *testing.T) { req := testRequest(t) req.AgentOptions["model_provider"].(map[string]any)["protocol"] = tc.protocol + req.AgentOptions["model_provider"].(map[string]any)["headers"] = map[string]string{"X-Activity": "private-header"} req.AgentOptions["model"] = "chosen-model" opts, err := prepareOptions(testStateRoot(t), req) if err != nil { @@ -30,7 +31,7 @@ func TestOptionsModelProviderProtocols(t *testing.T) { } want := map[string]any{"oac": map[string]any{ "name": "Configured provider", "kind": "custom", "enabled": true, "api": tc.api, - "options": map[string]any{"baseURL": "https://provider.example/v1", "apiKey": "fixture-key"}, + "options": map[string]any{"baseURL": "https://provider.example/v1", "apiKey": "fixture-key", "headers": map[string]any{"X-Activity": "private-header"}}, "models": map[string]any{"chosen-model": map[string]any{ "name": "chosen-model", "tool_call": true, "limit": map[string]any{"context": float64(64000), "output": float64(4096)}, diff --git a/apps/daemon/internal/agent/mcode/native_test.go b/apps/daemon/internal/agent/mcode/native_test.go index d1fcc2cd2..ba3c33247 100644 --- a/apps/daemon/internal/agent/mcode/native_test.go +++ b/apps/daemon/internal/agent/mcode/native_test.go @@ -7,8 +7,11 @@ import ( "net/http" "net/http/httptest" "os" + "os/exec" + "path/filepath" "strings" "sync" + "sync/atomic" "testing" "time" @@ -16,7 +19,9 @@ import ( ) // Opt in with the installed native CLI; the default test gate uses protocol fixtures. -func TestNativeMCodeACP(t *testing.T) { +func TestNativeMCodeACP(t *testing.T) { testNativeMCodeHeaders(t, "anthropic", false) } +func TestNativeMCodeChatHeaders(t *testing.T) { testNativeMCodeHeaders(t, "chat_completions", true) } +func testNativeMCodeHeaders(t *testing.T, protocol string, workspace bool) { binary := os.Getenv("OAC_TEST_MCODE_INTEGRATION_BIN") if binary == "" { t.Skip("set OAC_TEST_MCODE_INTEGRATION_BIN to run native ACP smoke test") @@ -25,7 +30,27 @@ func TestNativeMCodeACP(t *testing.T) { t.Setenv("OAC_RUNTIME_MCODE_BIN", binary) var mu sync.Mutex var requests []string + attempts := 0 + var toolSent atomic.Bool model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + if r.Header.Get("X-Sandbase-Benefit") != "daily-free" || r.Header.Get("X-Literal") != "${unchanged}:value" { + t.Error("native model request lost frozen headers") + } + if strings.HasSuffix(r.URL.Path, "/input_tokens") { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"input_tokens":10}`)) + return + } + mu.Lock() + attempts++ + attempt := attempts + mu.Unlock() + if attempt == 1 { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":{"type":"api_error","message":"fixture retry"}}`)) + return + } var body map[string]any if json.NewDecoder(r.Body).Decode(&body) != nil { w.WriteHeader(400) @@ -35,17 +60,64 @@ func TestNativeMCodeACP(t *testing.T) { mu.Lock() requests = append(requests, string(raw)) mu.Unlock() - writeNativeResponse(w) + if protocol == "chat_completions" { + tool := "" + if !toolSent.Load() { + for _, raw := range body["tools"].([]any) { + function, ok := raw.(map[string]any)["function"].(map[string]any) + if !ok { + function = raw.(map[string]any) + } + name, _ := function["name"].(string) + if strings.HasSuffix(name, "workspace_read") && toolSent.CompareAndSwap(false, true) { + tool = name + break + } + } + + } + writeNativeChatResponse(w, tool) + } else { + writeNativeResponse(w) + } })) defer model.Close() - req.AgentOptions["model_provider"] = map[string]any{"protocol": "anthropic", "base_url": model.URL, "api_key": "fixture-only", "context_window": 64000, "max_output_tokens": 4096} + req.AgentOptions["model_provider"] = map[string]any{"protocol": protocol, "base_url": model.URL, "api_key": "fixture-only", "headers": map[string]string{"X-Sandbase-Benefit": "daily-free", "X-Literal": "${unchanged}:value"}, "context_window": 64000, "max_output_tokens": 4096} req.AgentOptions["system_prompt"] = "SP-MCODE-672: reply as instructed." req.Input = proto.TextInput("Reply OAC-MCODE-OK.") + + req.RunID, req.Input = "", nil + stateRoot := testStateRoot(t) + ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) + defer cancel() + var config *WorkspaceConfig + if workspace { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "fixture.txt"), []byte("HEADER-TOOL-CONTINUATION"), 0600); err != nil { + t.Fatal(err) + } + node, err := exec.LookPath("node") + if err != nil { + t.Fatal(err) + } + config = &WorkspaceConfig{Binary: binary, Node: node, Bridge: filepath.Join(filepath.Dir(filepath.Dir(binary)), "bridge.mjs"), Directory: root, Network: "enabled", Scratch: t.TempDir()} + req.DisableExecutionEnvironment = false + req.LocalEnvironment = &proto.LocalEnvironment{ID: "headers-workspace", WorkspaceRoot: root, NetworkAccess: "enabled"} + } + factory := NewExecutorFactory(stateRoot, config) + owner, err := factory(ctx, req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = owner.Close(context.Background()) }() + runNumber := 0 run := func() proto.DonePayload { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) t.Cleanup(cancel) out := make(chan proto.Envelope, 64) - if _, err := startTurn(t, ctx, req, out); err != nil { + runNumber++ + turn, err := owner.StartTurn(ctx, fmt.Sprintf("headers-%d", runNumber), proto.TextInput("Reply OAC-MCODE-OK."), out) + if err != nil { t.Fatal(err) } var done proto.DonePayload @@ -57,6 +129,9 @@ func TestNativeMCodeACP(t *testing.T) { _ = json.Unmarshal(event.Payload, &done) } } + if _, err = turn.AwaitSettlement(ctx); err != nil { + t.Fatal(err) + } if done.Content != "OAC-MCODE-OK" { t.Fatalf("native output=%q", done.Content) } @@ -66,6 +141,10 @@ func TestNativeMCodeACP(t *testing.T) { return done } done := run() + run() + if err = owner.Close(ctx); err != nil { + t.Fatal(err) + } mu.Lock() firstRequests := strings.Join(requests, "\n") requests = nil @@ -73,11 +152,15 @@ func TestNativeMCodeACP(t *testing.T) { if !strings.Contains(firstRequests, "SP-MCODE-672") { t.Fatal("native instructions missing") } - req.RunID = "run-2" + req.RunID = "" req.AgentSessionID = done.Metadata[proto.DoneMetaAgentSessionID].(string) req.AgentOptions["system_prompt"] = "SP-MCODE-NEW: reply concisely." req.AgentOptions["model"] = "fixture-new" - req.Input = proto.TextInput("Now reply OAC-MCODE-OK.") + req.Input = nil + owner, err = factory(ctx, req) + if err != nil { + t.Fatal(err) + } run() mu.Lock() resumed := strings.Join(requests, "\n") @@ -88,7 +171,16 @@ func TestNativeMCodeACP(t *testing.T) { if !strings.Contains(resumed, `"model":"fixture-new"`) { t.Fatal("resume did not use the updated model") } - t.Log("native new/resume, model and instructions verified") + mu.Lock() + attemptCount := attempts + mu.Unlock() + if workspace && (!strings.Contains(firstRequests, "HEADER-TOOL-CONTINUATION") || attemptCount < 5) { + t.Fatalf("native tool continuation was not captured attempts=%d", attemptCount) + } + if attemptCount < 4 { + t.Fatal("retry and resume were not observed") + } + t.Log("native new/retry/resume model request headers verified") } func writeNativeResponse(w http.ResponseWriter) { @@ -104,3 +196,18 @@ func writeNativeResponse(w http.ResponseWriter) { send("message_delta", map[string]any{"type": "message_delta", "delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil}, "usage": map[string]int{"output_tokens": 8}}) send("message_stop", map[string]string{"type": "message_stop"}) } + +func writeNativeChatResponse(w http.ResponseWriter, tool string) { + w.Header().Set("Content-Type", "text/event-stream") + delta := map[string]any{"role": "assistant", "content": "OAC-MCODE-OK"} + finish := "stop" + if tool != "" { + delta = map[string]any{"role": "assistant", "tool_calls": []any{map[string]any{"index": 0, "id": "call_fixture", "type": "function", "function": map[string]any{"name": tool, "arguments": `{"path":"fixture.txt"}`}}}} + finish = "tool_calls" + } + for _, choice := range []map[string]any{{"index": 0, "delta": delta, "finish_reason": nil}, {"index": 0, "delta": map[string]any{}, "finish_reason": finish}} { + body, _ := json.Marshal(map[string]any{"id": "chatcmpl_fixture", "object": "chat.completion.chunk", "created": 1, "model": "fixture", "choices": []any{choice}, "usage": map[string]int{"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}}) + _, _ = fmt.Fprintf(w, "data: %s\n\n", body) + } + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") +} diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index 1c3b6515d..82addb6bf 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -1786,6 +1786,10 @@ definitions: - ProviderResourceNotFound - ProviderRequestTimeout - ProviderInvalidRequest + modelprovider.Headers: + additionalProperties: + type: string + type: object modelprovider.Protocol: enum: - anthropic @@ -2589,6 +2593,11 @@ definitions: type: string context_window: type: integer + headers: + allOf: + - $ref: '#/definitions/modelprovider.Headers' + description: Headers carry non-sensitive metadata. Resource responses omit them, but tools may read them. + x-nullable: true max_output_tokens: type: integer protocol: diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index 0e49a7b6f..fab2c054d 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -160,3 +160,7 @@ Each item is Core's deliberate or native behavior where the official service beh - Environment file list defaults (limit 20, the workspace root as the default path, no recursion, page-token invalidation), the 50 MiB `file_id` copy limit and the check order on create. - Artifact capture of hard links, special files and a linked `outputs` directory, republication after changed bytes, and content headers and ranges. - Vault and Credential error and retry semantics, pagination under concurrent writes, visibility after deletion, exact-URL matching against the official normalization, OAuth refresh timing and errors, and restricted-key scopes. + +## Model header qualification + +The [model request header contract](./model-execution.md#model-request-headers) carries non-sensitive metadata through native client configuration. Opt-in synthetic HTTP capture tests exercise pinned Codex 0.153.4, Claude SDK 0.3.269 and MiniMax Chat Completions across retries, tool continuations, multiple messages on one executor/query, and close/resume. Claude's Go preparation tests verify the frozen header projection, workspace allowlist and removal of stale environment values; its native SDK capture confirms both model delivery and Bash visibility. This is intentionally not a tool confidentiality boundary. MiniMax's Anthropic capture exercises retries, executor reuse and close/resume; its Responses transport has configuration tests and source-level coverage but no native HTTP capture. These tests use synthetic responses; they do not establish live provider or deployed Runtime acceptance. diff --git a/contracts/agents-api/model-execution.md b/contracts/agents-api/model-execution.md index c755e703d..745ee17cf 100644 --- a/contracts/agents-api/model-execution.md +++ b/contracts/agents-api/model-execution.md @@ -140,3 +140,11 @@ These are best-effort Core receipt times after the terminal commit, not provider ## Acceptance `TestNativeModelProtocolPublicExecution` (`services/core/tests/integration/model_protocol_native_test.go`) with `services/core/tests/official_model_protocol_native.py` runs each Harness against real provider APIs through the pinned official client. It runs when `OAC_TEST_OFFICIAL_SDK_PYTHON`, `OAC_TEST_NATIVE_DAEMON_BIN`, `OAC_TEST_NATIVE_PROOF_DIR` and `OAC_TEST_MODEL_PROTOCOL_OPTIONS` are set; the last names a private model settings file. Never commit those settings or print their values. + +## Model request headers + +The optional write-only `model_provider.headers` object supplies literal HTTP headers for model requests. Use it only for non-sensitive metadata such as activity identifiers; never put credentials, bearer tokens or confidential content in it. Native tools may read these values and may include them in tool output. It is part of the complete provider bundle: Core encrypts it with the API key, freezes it at Session creation, and supplies it on every dispatch, including recovered Sessions. Subsequent messages inherit that frozen bundle; messages cannot replace headers. Saved Agent and deployment-default responses omit both header names and values. Changing a saved Agent or deployment default affects new Sessions only. + +Headers contain at most 32 entries. Names use HTTP token syntax and are at most 128 bytes; values contain printable ASCII, are at most 4096 bytes, and cannot have leading or trailing spaces. Empty values are allowed. The combined names and values must fit within 16384 bytes. Case-insensitive duplicate names are rejected; accepted names keep their submitted spelling. Authentication, transport and SDK-owned headers are reserved: `anthropic-version`, `anthropic-beta`, `openai-beta`, `authorization`, `proxy-authorization`, `x-api-key`, `api-key`, `x-goog-api-key`, `host`, `connection`, `proxy-connection`, `keep-alive`, `proxy-authenticate`, `te`, `trailer`, `transfer-encoding`, `upgrade`, `content-length`, `content-type`, `accept`, `accept-encoding`, `cookie`, `set-cookie` and `user-agent`. Invalid input returns `model_provider_headers_invalid` without echoing submitted values. + +Codex uses native `env_http_headers` references with values excluded from its shell environment. MiniMax uses provider `options.headers` in its private runtime configuration. Claude SDK uses native `ANTHROPIC_CUSTOM_HEADERS`, which also reaches Bash tools. An explicit empty value clears ambient or previous provider headers when the frozen bundle has none. Each adapter declares header support through `harnessconfig.Configuration.AcceptsProviderHeaders`; Core does not branch on Harness names. The native model client applies these headers to each model call, including retries and tool continuations. Core does not automatically attach these headers to MCP, tool HTTP requests or other destinations. Tools may read the metadata and forward it or include it in output. Headers do not confer authorization or select a billing policy in Core. The application and model endpoint own those decisions. diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index f528a16d1..b0921a45c 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -17938,6 +17938,12 @@ "additionalProperties": false, "description": "Confirmation that a vault credential was deleted." }, + "modelprovider.Headers": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, "modelprovider.Protocol": { "enum": [ "anthropic", @@ -18086,6 +18092,21 @@ "context_window": { "type": "integer" }, + "headers": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/modelprovider.Headers" + } + ], + "description": "Headers carry non-sensitive metadata. Resource responses omit them, but tools may read them." + }, + { + "type": "null" + } + ] + }, "max_output_tokens": { "type": "integer" }, diff --git a/contracts/agents-api/v1/model_execution.go b/contracts/agents-api/v1/model_execution.go index 66b75e64b..4bc93dd6e 100644 --- a/contracts/agents-api/v1/model_execution.go +++ b/contracts/agents-api/v1/model_execution.go @@ -27,11 +27,13 @@ type SessionExecutionInput struct { } type ModelProviderInput struct { - Protocol modelprovider.Protocol `json:"protocol" binding:"required"` - BaseURL string `json:"base_url" binding:"required"` - APIKey string `json:"api_key" binding:"required"` - ContextWindow int32 `json:"context_window,omitempty"` - MaxOutputTokens int32 `json:"max_output_tokens,omitempty"` + Protocol modelprovider.Protocol `json:"protocol" binding:"required"` + BaseURL string `json:"base_url" binding:"required"` + APIKey string `json:"api_key" binding:"required"` + // Headers carry non-sensitive metadata. Resource responses omit them, but tools may read them. + Headers modelprovider.Headers `json:"headers,omitempty" extensions:"x-nullable"` + ContextWindow int32 `json:"context_window,omitempty"` + MaxOutputTokens int32 `json:"max_output_tokens,omitempty"` } func (p *ModelProviderInput) Validate() error { @@ -51,6 +53,9 @@ func (p *ModelProviderInput) validate(registry harnessconfig.Registry) error { if strings.TrimSpace(p.APIKey) == "" || len(p.APIKey) > 16384 || strings.ContainsAny(p.APIKey, "\x00\r\n") { return &ModelProviderError{Code: "model_provider_api_key_invalid", Param: "api_key", message: "invalid model provider API key"} } + if modelprovider.ValidateHeaders(p.Headers) != nil { + return &ModelProviderError{Code: "model_provider_headers_invalid", Param: "headers", message: "invalid model provider headers"} + } if p.ContextWindow < 0 || p.MaxOutputTokens < 0 || (p.MaxOutputTokens > p.ContextWindow) { param := "max_output_tokens" if p.ContextWindow < 0 { @@ -72,6 +77,9 @@ func (p *ModelProviderInput) ValidateHarnessWithRegistry(harness string, registr return err } configuration, _ := registry.Lookup(harness) + if err := configuration.ValidateProviderHeaders(p.Headers); err != nil { + return &ModelProviderError{Code: "model_provider_headers_unsupported", Param: "headers", message: err.Error()} + } if err := configuration.ValidateProtocol(string(p.Protocol)); err != nil { return &ModelProviderError{Code: "model_provider_protocol_unsupported", Param: "protocol", message: err.Error()} } diff --git a/contracts/agents-api/v1/model_provider_error_test.go b/contracts/agents-api/v1/model_provider_error_test.go index bab331fdf..70c6fa937 100644 --- a/contracts/agents-api/v1/model_provider_error_test.go +++ b/contracts/agents-api/v1/model_provider_error_test.go @@ -1,7 +1,9 @@ package v1 import ( + "encoding/json" "errors" + "strings" "testing" ) @@ -42,3 +44,39 @@ func TestModelProviderErrorMessagesAndPrecedence(t *testing.T) { t.Fatal(err) } } + +func TestModelProviderHeadersAreWriteOnly(t *testing.T) { + provider := &ModelProviderInput{Protocol: "responses", BaseURL: "https://example.test", APIKey: "fixture", Headers: map[string]string{"X-Secret": "private-header-canary"}} + if err := provider.Validate(); err != nil { + t.Fatal(err) + } + body, err := json.Marshal(provider.SafeView()) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(body), "X-Secret") || strings.Contains(string(body), "private-header-canary") || strings.Contains(string(body), "headers") { + t.Fatal("safe view disclosed headers") + } + for _, raw := range []string{`{"protocol":"responses","base_url":"https://example.test","api_key":"fixture","headers":{"X-A":null}}`, `{"protocol":"responses","base_url":"https://example.test","api_key":"fixture","headers":{"X-A":"a","X-A":"b"}}`} { + var input ModelProviderInput + if json.Unmarshal([]byte(raw), &input) == nil { + t.Fatal("invalid headers decoded") + } + } +} + +func TestClaudeProviderHeadersAdmission(t *testing.T) { + provider := &ModelProviderInput{Protocol: "anthropic", BaseURL: "https://example.test", APIKey: "fixture", Headers: map[string]string{"X-Activity": "daily-free"}} + if err := provider.ValidateHarness("claude_sdk"); err != nil { + t.Fatal(err) + } + provider.Headers = nil + if err := provider.ValidateHarness("claude_sdk"); err != nil { + t.Fatal(err) + } + provider.Headers = map[string]string{"Authorization": "override"} + var typed *ModelProviderError + if err := provider.ValidateHarness("claude_sdk"); !errors.As(err, &typed) || typed.Code != "model_provider_headers_invalid" { + t.Fatal("authentication override accepted") + } +} diff --git a/contracts/agents-api/v1/upstream_contract_test.go b/contracts/agents-api/v1/upstream_contract_test.go index 79d88fed1..a3a55fd13 100644 --- a/contracts/agents-api/v1/upstream_contract_test.go +++ b/contracts/agents-api/v1/upstream_contract_test.go @@ -46,7 +46,7 @@ func TestPublicSchemaPreservesOfficialDefinitions(t *testing.T) { owners := map[string]bool{"AgentResource": true, "CreateAgentParams": true, "UpdateAgentParams": true, "SessionAgentConfigParam": true, "SessionAgentResource": true, "CreateAgentSessionParams": true, "SessionResource": true} // Extension dependencies retain their producing Go package names. for name, value := range schemas { - if strings.HasPrefix(name, "v1.") || name == "modelprovider.Protocol" { + if strings.HasPrefix(name, "v1.") || name == "modelprovider.Protocol" || name == "modelprovider.Headers" { continue } schema := value.(map[string]any) @@ -132,3 +132,44 @@ func TestPublicOperationsPreserveOfficialContract(t *testing.T) { t.Fatalf("expected pinned 58 operations, got %d, extra paths %v", count, published) } } + +func TestModelHeadersSchemaIsExplicitCoreExtension(t *testing.T) { + upstream := contractDocument(t, "upstream/openapi.json")["components"].(map[string]any)["schemas"].(map[string]any) + public := contractDocument(t, "openapi.yaml")["components"].(map[string]any)["schemas"].(map[string]any) + if _, exists := upstream["modelprovider.Headers"]; exists { + t.Fatal("Core extension collides with official schema") + } + want := map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}} + if !reflect.DeepEqual(public["modelprovider.Headers"], want) { + t.Fatal("Core header extension must remain a string map") + } +} + +func TestModelHeadersSchemaPreservesNullableInput(t *testing.T) { + schemas := contractDocument(t, "openapi.yaml")["components"].(map[string]any)["schemas"].(map[string]any) + input := schemas["v1.ModelProviderInput"].(map[string]any)["properties"].(map[string]any) + variants, ok := input["headers"].(map[string]any)["anyOf"].([]any) + if !ok || len(variants) != 2 { + t.Fatal("headers input must accept its map type or null") + } + nullable, ref := false, false + for _, raw := range variants { + variant := raw.(map[string]any) + if variant["type"] == "null" { + nullable = true + } + if all, ok := variant["allOf"].([]any); ok && len(all) == 1 && all[0].(map[string]any)["$ref"] == "#/components/schemas/modelprovider.Headers" { + ref = true + } + } + if !nullable || !ref { + t.Fatal("nullable headers lost its string-map reference") + } + generated, err := os.ReadFile("../../../packages/agents-client/src/generated/public-api.ts") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(generated), "headers?: Headers | null;") { + t.Fatal("TypeScript client must accept omitted, null, and map headers") + } +} diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index ba0881f15..f1bba06f4 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "Agents API 覆盖台账" source: contracts/agents-api/index.md -source_hash: cea3a2dc0e4fab5ef4891531d1111a74531fe10b6e21209f27b572b28b9a8d79 +source_hash: 001be7cc7060c4591d1fe053b8de6fd3eea31eccaa4ed9f9fe246fb5efae8298 --- Core 旨在以下方固定版本为准支持完整的 OpenAI Agents API([public API rule](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/AGENTS.md#public-api))。本台账记录 Core 对各项资源实现了哪些内容、哪些契约保存其详细信息,并列出相对于 OpenAI 服务的所有已知差异和所有未解决缺口。[API namespaces and credentials](../../../docs/zh/api/index.md) 说明谁调用哪些 API;[Agents API guide](../../../docs/zh/api/public-agent-api.md) 介绍使用方法。 @@ -165,3 +165,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - Environment file 列表的默认值(limit 为 20、默认路径为工作区根目录、不递归、page-token 失效)、50 MiB 的 `file_id` 复制上限,以及创建时的检查顺序。 - Artifact 对硬链接、特殊文件和链接形式的 `outputs` 目录的捕获,内容字节变化后的重新发布,以及内容标头和范围。 - Vault 和 Credential 的错误与重试语义、并发写入下的分页、删除后的可见性、依据官方规范化处理进行精确 URL 匹配、OAuth 刷新的时机和错误,以及受限密钥范围。 + +## Model header qualification + +[模型请求 Header 协议](./model-execution.md#model-request-headers) 通过原生客户端配置传递非敏感元数据。可选的 synthetic HTTP 捕获测试覆盖固定版本 Codex 0.153.4、Claude SDK 0.3.269 和 MiniMax Chat Completions 的重试、工具续调、同一 executor/query 内的多条消息,以及关闭后恢复。Claude 的 Go 准备阶段测试验证冻结 Header 投影、workspace 允许项及旧环境值清除;原生 SDK 捕获同时确认模型请求传递与 Bash 可读性。这里不构成工具保密边界。MiniMax Anthropic 捕获测试覆盖重试、executor 复用与关闭后恢复;Responses 传输有配置测试和源码证据,尚未完成原生 HTTP 捕获。这些测试使用模拟响应,不代表真实模型服务或已部署 Runtime 的验收。 diff --git a/contracts/agents-api/zh/model-execution.md b/contracts/agents-api/zh/model-execution.md index d0e6126ad..23ba924aa 100644 --- a/contracts/agents-api/zh/model-execution.md +++ b/contracts/agents-api/zh/model-execution.md @@ -1,7 +1,7 @@ --- title: "模型执行" source: contracts/agents-api/model-execution.md -source_hash: d26140f4fdaa6d0c68dbeff52a2318ed213799966b224302eb04b9ec718f5fc3 +source_hash: 1d10a63348861b53281f173f58dacc682b79453c8ea282b768e31d7ef475e1dd --- 每个 Session 都运行一个 Harness,并使用一个模型提供商。Core 通过三个固定版本上游协议未定义的 Core 扩展来选择它们:`x_agents_core.harness` 选择 Harness,`x_agents_core.model_provider` 提供端点和密钥,`x_agents_core.harness_config` 携带原生模型参数。Core 没有提供商目录、模型别名解析或产品权限模型;除 Session 和已保存 Agent 配置包外,唯一存储的配置包是每个 Harness 的一个 [deployment default](#deployment-defaults)。本文档定义 Harness—模型提供商协议:[`internal/modelprovider/config.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/modelprovider/config.go) 负责验证冻结的提供商连接,每个 Harness 则通过 [`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/harnessconfig/harness.go) 声明其协议和原生参数。 @@ -142,3 +142,11 @@ Harness 和默认模型读取结果包含可空的 `last_used_at`、`last_error_ ## 验收 {#acceptance} `TestNativeModelProtocolPublicExecution`(`services/core/tests/integration/model_protocol_native_test.go`)结合 `services/core/tests/official_model_protocol_native.py`,通过固定版本的官方客户端针对真实提供商 API 运行每个 Harness。当 `OAC_TEST_OFFICIAL_SDK_PYTHON`、`OAC_TEST_NATIVE_DAEMON_BIN`、`OAC_TEST_NATIVE_PROOF_DIR` 和 `OAC_TEST_MODEL_PROTOCOL_OPTIONS` 均已设置时运行;最后一项指定一个私有模型设置文件。绝不提交这些设置或打印其值。 + +## Model request headers + +可选的只写 `model_provider.headers` 对象为模型请求提供字面 HTTP Header。它只能用于活动标识等非敏感元数据;不得放入凭据、令牌或机密内容。原生工具可能读取这些值,也可能在工具输出中包含它们。它属于完整的 Provider 配置:Core 将它与 API Key 一起加密,在 Session 创建时冻结,并在每次调度(包括恢复后的 Session)中传递。后续消息继承这一冻结配置,不能覆盖 Header。保存的 Agent 和部署默认配置的响应均不返回 Header 名称或值。修改 Agent 或部署默认配置只影响新 Session。 + +Header 最多包含 32 项。名称遵循 HTTP token 语法,最多 128 字节;值只能包含可打印 ASCII,最多 4096 字节,且首尾不能有空格。允许空值。所有名称和值的总长度不能超过 16384 字节。不区分大小写的重复名称会被拒绝;接受的名称保留原始拼写。以下认证、传输和 SDK 管理的 Header 为保留项:`anthropic-version`, `anthropic-beta`, `openai-beta`, `authorization`、`proxy-authorization`、`x-api-key`、`api-key`、`x-goog-api-key`、`host`、`connection`、`proxy-connection`、`keep-alive`、`proxy-authenticate`、`te`、`trailer`、`transfer-encoding`、`upgrade`、`content-length`、`content-type`、`accept`、`accept-encoding`、`cookie`、`set-cookie` 和 `user-agent`。无效输入返回 `model_provider_headers_invalid`,不会回显提交的值。 + +Codex 使用原生 `env_http_headers` 环境变量引用,并将值排除在 Shell 环境之外。MiniMax 使用私有 Runtime 配置中的 Provider `options.headers`。Claude SDK 使用原生 `ANTHROPIC_CUSTOM_HEADERS`,该变量也会进入 Bash 工具。当冻结配置不含 Header 时,显式空值会清除环境中或之前 Provider 的 Header。各适配器通过 `harnessconfig.Configuration.AcceptsProviderHeaders` 声明支持情况,Core 不按 Harness 名称分支。原生模型客户端在每次模型调用(包括重试和工具续调)中应用 Header。Core 不会自动将这些 Header 附加到 MCP、工具 HTTP 请求或其他目标。工具可能读取元数据后自行转发或放入输出。Header 不代表授权或在 Core 中选择计费规则;这些决策由应用和模型服务端负责。 diff --git a/internal/harnessconfig/claudesdk/configuration.go b/internal/harnessconfig/claudesdk/configuration.go index 4b3608162..dd9d091d9 100644 --- a/internal/harnessconfig/claudesdk/configuration.go +++ b/internal/harnessconfig/claudesdk/configuration.go @@ -4,7 +4,7 @@ package claudesdk import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" func Configuration() harnessconfig.Configuration { - return harnessconfig.Configuration{ValidateNativeConfig: validateNativeConfig, Providers: []harnessconfig.Provider{ + return harnessconfig.Configuration{AcceptsProviderHeaders: true, ValidateNativeConfig: validateNativeConfig, Providers: []harnessconfig.Provider{ {Protocol: "anthropic"}, }} } diff --git a/internal/harnessconfig/codex/configuration.go b/internal/harnessconfig/codex/configuration.go index 1d74c449f..f2588ddda 100644 --- a/internal/harnessconfig/codex/configuration.go +++ b/internal/harnessconfig/codex/configuration.go @@ -4,7 +4,7 @@ package codex import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" func Configuration() harnessconfig.Configuration { - return harnessconfig.Configuration{ValidateNativeConfig: validateNativeConfig, Providers: []harnessconfig.Provider{ + return harnessconfig.Configuration{AcceptsProviderHeaders: true, ValidateNativeConfig: validateNativeConfig, Providers: []harnessconfig.Provider{ {Protocol: "responses"}, }} } diff --git a/internal/harnessconfig/harness.go b/internal/harnessconfig/harness.go index 53c9365f3..7511ea3ff 100644 --- a/internal/harnessconfig/harness.go +++ b/internal/harnessconfig/harness.go @@ -51,6 +51,8 @@ type Provider struct { // only for direct adapters whose model connection remains native-owned. type Configuration struct { Providers []Provider + // AcceptsProviderHeaders declares native delivery of non-sensitive model request metadata. + AcceptsProviderHeaders bool // ValidateNativeConfig belongs to the selected adapter, never Core. ValidateNativeConfig func(proto.HarnessConfig) bool } diff --git a/internal/harnessconfig/mcode/configuration.go b/internal/harnessconfig/mcode/configuration.go index a188579ad..ef62f60f0 100644 --- a/internal/harnessconfig/mcode/configuration.go +++ b/internal/harnessconfig/mcode/configuration.go @@ -4,7 +4,7 @@ package mcode import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" func Configuration() harnessconfig.Configuration { - return harnessconfig.Configuration{Providers: []harnessconfig.Provider{ + return harnessconfig.Configuration{AcceptsProviderHeaders: true, Providers: []harnessconfig.Provider{ {Protocol: "anthropic", RequiresTokenLimits: true}, {Protocol: "responses", RequiresTokenLimits: true}, {Protocol: "chat_completions", RequiresTokenLimits: true}, diff --git a/internal/harnessconfig/provider.go b/internal/harnessconfig/provider.go index 919809935..3fdb6d250 100644 --- a/internal/harnessconfig/provider.go +++ b/internal/harnessconfig/provider.go @@ -1,6 +1,18 @@ package harnessconfig -import "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" +import ( + "errors" + "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" +) + +var ErrProviderHeadersUnsupported = errors.New("selected harness does not support model request headers") + +func (c Configuration) ValidateProviderHeaders(headers map[string]string) error { + if len(headers) > 0 && !c.AcceptsProviderHeaders { + return ErrProviderHeadersUnsupported + } + return nil +} func (c Configuration) AcceptsHarnessConfig() bool { return c.ValidateNativeConfig != nil } @@ -14,5 +26,8 @@ func (c Configuration) ParseProvider(raw any) (modelprovider.Provider, error) { if err := c.Validate(string(provider.Protocol), provider.ContextWindow, provider.MaxOutputTokens); err != nil { return modelprovider.Provider{}, err } + if err := c.ValidateProviderHeaders(provider.Headers); err != nil { + return modelprovider.Provider{}, err + } return provider, nil } diff --git a/internal/modelprovider/config.go b/internal/modelprovider/config.go index 595beb289..434b19f85 100644 --- a/internal/modelprovider/config.go +++ b/internal/modelprovider/config.go @@ -25,6 +25,7 @@ type Provider struct { Protocol Protocol `json:"protocol"` BaseURL string `json:"base_url"` APIKey string `json:"api_key"` + Headers Headers `json:"headers,omitempty"` ContextWindow int32 `json:"context_window,omitempty"` MaxOutputTokens int32 `json:"max_output_tokens,omitempty"` } @@ -73,5 +74,87 @@ func (p Provider) Validate() error { if p.ContextWindow < 0 || p.MaxOutputTokens < 0 || p.MaxOutputTokens > p.ContextWindow { return ErrConfiguration } + return ValidateHeaders(p.Headers) +} + +// ValidateHeaders validates non-sensitive, literal model request metadata. +// Errors deliberately omit names and values supplied by callers. +func ValidateHeaders(headers map[string]string) error { + if len(headers) > 32 { + return ErrConfiguration + } + seen := make(map[string]bool, len(headers)) + total := 0 + for name, value := range headers { + lower := strings.ToLower(name) + if len(name) == 0 || len(name) > 128 || seen[lower] || len(value) > 4096 || strings.TrimSpace(value) != value { + return ErrConfiguration + } + seen[lower] = true + switch lower { + case "anthropic-version", "anthropic-beta", "openai-beta", "x-goog-api-key", "proxy-connection", "authorization", "proxy-authorization", "x-api-key", "api-key", "host", "connection", "keep-alive", "proxy-authenticate", "te", "trailer", "transfer-encoding", "upgrade", "content-length", "content-type", "accept", "accept-encoding", "cookie", "set-cookie", "user-agent": + return ErrConfiguration + } + for _, c := range name { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("!#$%&'*+-.^_`|~", c)) { + return ErrConfiguration + } + } + for _, c := range value { + if c < 0x20 || c > 0x7e { + return ErrConfiguration + } + } + total += len(name) + len(value) + if total > 16384 { + return ErrConfiguration + } + } + return nil +} + +// Headers decodes only string-valued objects, without silently converting null +// entries to empty strings or overwriting repeated field names. +type Headers map[string]string + +func (h *Headers) UnmarshalJSON(data []byte) error { + if bytes.Equal(bytes.TrimSpace(data), []byte("null")) { + *h = nil + return nil + } + decoder := json.NewDecoder(bytes.NewReader(data)) + token, err := decoder.Token() + if err != nil || token != json.Delim('{') { + return ErrConfiguration + } + result := Headers{} + seen := map[string]bool{} + for decoder.More() { + token, err = decoder.Token() + if err != nil { + return ErrConfiguration + } + name, ok := token.(string) + if !ok || seen[strings.ToLower(name)] { + return ErrConfiguration + } + seen[strings.ToLower(name)] = true + token, err = decoder.Token() + if err != nil { + return ErrConfiguration + } + value, ok := token.(string) + if !ok { + return ErrConfiguration + } + result[name] = value + } + if _, err = decoder.Token(); err != nil { + return ErrConfiguration + } + if err = ValidateHeaders(result); err != nil { + return err + } + *h = result return nil } diff --git a/internal/modelprovider/config_test.go b/internal/modelprovider/config_test.go index 8b6312608..a7c4c4cc5 100644 --- a/internal/modelprovider/config_test.go +++ b/internal/modelprovider/config_test.go @@ -1,7 +1,10 @@ package modelprovider import ( + "encoding/json" "errors" + "reflect" + "strings" "testing" ) @@ -13,7 +16,7 @@ func TestParseProviderValidatesFrozenBundle(t *testing.T) { raw["protocol"] = string(protocol) got, err := ParseProvider(raw) want := Provider{Protocol: protocol, BaseURL: "https://model.example/v1", APIKey: "fixture-upstream-key", ContextWindow: 64000, MaxOutputTokens: 4096} - if err != nil || got != want { + if err != nil || !reflect.DeepEqual(got, want) { t.Fatalf("bundle changed or rejected: %v", err) } }) @@ -53,3 +56,43 @@ func copyBundle(raw map[string]any) map[string]any { } return out } + +func TestHeadersValidation(t *testing.T) { + for _, headers := range []map[string]string{nil, {}, {"X-Sandbase-Benefit": "daily-free", "X-Empty": "", "X-Literal": "${literal}:a"}} { + if err := ValidateHeaders(headers); err != nil { + t.Fatal(err) + } + } + invalid := []map[string]string{{"X-A": "a", "x-a": "b"}, {"X-A": "\r\nInjected: true"}, {"X-A": " spaced"}, {"X-A": "tail "}, {"X-A": "非ASCII"}, {"Bad:Name": "x"}, {"": "x"}, {strings.Repeat("X", 129): "x"}, {"X-A": strings.Repeat("a", 4097)}} + for _, name := range []string{"Authorization", "X-API-Key", "X-Goog-Api-Key", "Proxy-Connection", "Host", "Cookie", "Content-Type", "Accept", "Transfer-Encoding", "User-Agent"} { + invalid = append(invalid, map[string]string{name: "private-canary"}) + } + many := map[string]string{} + for i := 0; i < 33; i++ { + many["X-"+strings.Repeat("a", i)] = "x" + } + invalid = append(invalid, many) + large := map[string]string{} + for i := 0; i < 5; i++ { + large["X-"+strings.Repeat("a", i)] = strings.Repeat("a", 4096) + } + invalid = append(invalid, large) + for _, headers := range invalid { + if err := ValidateHeaders(headers); err != ErrConfiguration { + t.Fatal("invalid headers accepted or disclosed") + } + } +} + +func TestHeadersJSONRejectsLossyInput(t *testing.T) { + for _, raw := range []string{`{"X-A":null}`, `{"X-A":3}`, `{"X-A":[]}`, `{"X-A":"a","X-A":"b"}`, `{"X-A":"a","x-a":"b"}`, `[]`} { + var headers Headers + if json.Unmarshal([]byte(raw), &headers) == nil { + t.Fatal("lossy headers JSON accepted") + } + } + var headers Headers + if json.Unmarshal([]byte(`{"X-A":""}`), &headers) != nil || headers["X-A"] != "" { + t.Fatal("explicit empty string must remain valid") + } +} diff --git a/packages/agents-client/src/generated/public-api.ts b/packages/agents-client/src/generated/public-api.ts index 915162dd4..791a4c428 100644 --- a/packages/agents-client/src/generated/public-api.ts +++ b/packages/agents-client/src/generated/public-api.ts @@ -505,6 +505,7 @@ export type FunctionCallOutputParam = string | InputContentParam[]; export type FunctionCallOutputResource = string | InputContentResource[]; export const functionCallStatusResourceValues = ["in_progress", "completed", "failed", "incomplete"] as const; export type FunctionCallStatusResource = (typeof functionCallStatusResourceValues)[number]; +export type Headers = Record; export type HostedEnvironmentFileParam = HostedEnvironmentFileParamFileId | HostedEnvironmentFileParamInline; export interface HostedEnvironmentFileParamFileId { type: "file_id"; @@ -777,10 +778,11 @@ export interface ModelProviderInput { api_key: string; base_url: string; context_window?: number; + headers?: Headers | null; max_output_tokens?: number; protocol: Protocol; } -export const modelProviderInputFields = ["api_key", "base_url", "context_window", "max_output_tokens", "protocol"] as const; +export const modelProviderInputFields = ["api_key", "base_url", "context_window", "headers", "max_output_tokens", "protocol"] as const; export const modelProviderInputRequired = ["api_key", "base_url", "protocol"] as const; export interface ModelProviderView { api_key_configured: boolean; diff --git a/packages/claude-sdk-adapter/src/workspace.ts b/packages/claude-sdk-adapter/src/workspace.ts index f26d85424..2f2b7148d 100644 --- a/packages/claude-sdk-adapter/src/workspace.ts +++ b/packages/claude-sdk-adapter/src/workspace.ts @@ -20,7 +20,7 @@ export type Workspace = { }; const environmentNames = new Set([ - "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", + "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", ]); diff --git a/packages/claude-sdk-adapter/tests/model_headers_native.test.mjs b/packages/claude-sdk-adapter/tests/model_headers_native.test.mjs new file mode 100644 index 000000000..de8b92a86 --- /dev/null +++ b/packages/claude-sdk-adapter/tests/model_headers_native.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +// The pinned SDK runs its real model/tool loop against synthetic HTTP responses. +// Headers are non-sensitive metadata; native tools may read them. +// This qualifies transport against synthetic responses, not live provider acceptance. +test('native activity headers survive retry, tool continuation, reused messages and resume; tools may read them', { skip: process.env.OAC_TEST_CLAUDE_HEADERS_NATIVE !== '1', timeout: 120000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'oac-header-native-')); + let attempts = 0; + let toolSent = false; + const received = []; + let toolEnvironmentExposed = false; + const server = createServer(async (req, res) => { + if (!req.url.includes('/messages')) { res.writeHead(200, {'content-type':'application/json'}); res.end('{}'); return; } + const chunks = []; for await (const chunk of req) chunks.push(chunk); + const body = JSON.parse(Buffer.concat(chunks).toString()); + for (const message of body.messages ?? []) for (const content of Array.isArray(message.content) ? message.content : []) { + if (content.type === 'tool_result' && JSON.stringify(content.content).includes('HEADER-ENV-EXPOSED')) toolEnvironmentExposed = true; + } + received.push(req.headers); + attempts++; + if (attempts === 1) { res.writeHead(500, {'content-type':'application/json'}); res.end(JSON.stringify({type:'error',error:{type:'api_error',message:'synthetic retry'}})); return; } + const useTool = !toolSent; toolSent = true; + const content = useTool ? {type:'tool_use',id:'tool_fixture',name:'Bash',input:{command:'if test -n "${ANTHROPIC_CUSTOM_HEADERS+x}"; then printf HEADER-ENV-EXPOSED; else printf HEADER-ENV-PROTECTED; fi',description:'Read fixture'}} : {type:'text',text:'HEADER-OK'}; + const message = {id:'msg_fixture_'+attempts,type:'message',role:'assistant',model:body.model,content:[content],stop_reason:useTool?'tool_use':'end_turn',stop_sequence:null,usage:{input_tokens:10,output_tokens:10}}; + if (!body.stream) { res.writeHead(200, {'content-type':'application/json'}); res.end(JSON.stringify(message)); return; } + res.writeHead(200, {'content-type':'text/event-stream'}); + const send = (type, data) => res.write(`event: ${type}\ndata: ${JSON.stringify({type,...data})}\n\n`); + send('message_start',{message:{...message,content:[],stop_reason:null}}); + send('content_block_start',{index:0,content_block:useTool?{...content,input:{}}:{type:'text',text:''}}); + send('content_block_delta',{index:0,delta:useTool?{type:'input_json_delta',partial_json:JSON.stringify(content.input)}:{type:'text_delta',text:content.text}}); + send('content_block_stop',{index:0}); send('message_delta',{delta:{stop_reason:message.stop_reason,stop_sequence:null},usage:{output_tokens:10}}); send('message_stop',{}); res.end(); + }); + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); + const endpoint = `http://127.0.0.1:${server.address().port}`; + const env = {...process.env, HOME:root, CLAUDE_CONFIG_DIR:join(root,'state'), ANTHROPIC_BASE_URL:endpoint,ANTHROPIC_AUTH_TOKEN:'fixture-only',ANTHROPIC_CUSTOM_HEADERS:'X-Sandbase-Benefit: daily-free\nX-Literal: ${unchanged}:value\nX-Empty: ',CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC:'1',DISABLE_TELEMETRY:'1',DISABLE_ERROR_REPORTING:'1'}; + delete env.ANTHROPIC_API_KEY; delete env.CLAUDE_CODE_OAUTH_TOKEN; + const { query } = await import('@anthropic-ai/claude-agent-sdk'); + let session; + try { + for (let turn=0;turn<2;turn++) { + let release; const ready = new Promise(resolve => { release = resolve; }); + async function* messages() { yield {type:'user',message:{role:'user',content:'Reply HEADER-OK.'},parent_tool_use_id:null}; await ready; yield {type:'user',message:{role:'user',content:'Reply HEADER-OK again.'},parent_tool_use_id:null}; } + const stream = query({prompt:turn===0?messages():'Reply HEADER-OK.',options:{cwd:root,env,model:'claude-sonnet-4-5',tools:['Bash'],allowedTools:['Bash'],permissionMode:'default',settingSources:[],maxTurns:4,...(session?{resume:session}:{})}}); + let result; + for await (const event of stream) { if(event.session_id)session=event.session_id; if(event.type==='result'){result=event;release();} } + assert.equal(result?.is_error,false); + } + assert.equal(toolEnvironmentExposed, true, 'native tool visibility must be explicit in this qualification'); + assert.ok(attempts>=5, 'retry + tool continuation + reused query + resumed turn were not captured'); + for (const headers of received) { + assert.equal(headers['x-sandbase-benefit'],'daily-free'); + assert.equal(headers['x-literal'],'${unchanged}:value'); + assert.equal(headers['x-empty'],''); + } + } finally { server.closeAllConnections(); await new Promise(resolve=>server.close(resolve)); await rm(root,{recursive:true,force:true}); } +}); diff --git a/packages/claude-sdk-adapter/tests/workspace.test.mjs b/packages/claude-sdk-adapter/tests/workspace.test.mjs index 750d578ba..817c8e0cc 100644 --- a/packages/claude-sdk-adapter/tests/workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace.test.mjs @@ -221,3 +221,15 @@ test("workspace discovery preserves native file tools and the function boundary" assert.equal((await profile.canUseTool("ToolSearch", {}, {...context, agentID:"child"})).behavior,"deny"); assert.equal((await profile.canUseTool("ToolSearch", {}, {...context, signal:AbortSignal.abort()})).behavior,"deny"); }); + +test("workspace forwards selected activity headers and preserves explicit empty selection", t => { + const { dirs, config } = fixture(t); + config.env_names.push("ANTHROPIC_CUSTOM_HEADERS"); + config.tool_env = { ANTHROPIC_CUSTOM_HEADERS: "X-Stale: tool-env" }; + for (const selected of ["X-Activity: daily-free\nX-Empty: ", ""]) { + process.env.ANTHROPIC_CUSTOM_HEADERS = selected; + assert.equal(new WorkspaceProfile(dirs.workspace, config).options.env.ANTHROPIC_CUSTOM_HEADERS, selected); + } + delete process.env.ANTHROPIC_CUSTOM_HEADERS; + assert.throws(() => new WorkspaceProfile(dirs.workspace, config), /invalid_request/); +}); 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..5a9e743cd 100644 --- a/services/core/internal/api/core_model_provider_validation_test.go +++ b/services/core/internal/api/core_model_provider_validation_test.go @@ -16,11 +16,13 @@ import ( type coreProviderValidationStore struct { t testing.TB writes int + record modelconfiguration.Record } -func (s *coreProviderValidationStore) Replace(context.Context, modelconfiguration.Record) (modelconfiguration.Configuration, error) { +func (s *coreProviderValidationStore) Replace(_ context.Context, record modelconfiguration.Record) (modelconfiguration.Configuration, error) { s.writes++ - return modelconfiguration.Configuration{}, nil + s.record = record + return modelconfiguration.Configuration{Harness: record.Harness, Provider: record.Provider, Model: record.Model, HarnessConfig: record.HarnessConfig}, nil } func (s *coreProviderValidationStore) Delete(context.Context, string) error { diff --git a/services/core/internal/api/harness_model_providers.go b/services/core/internal/api/harness_model_providers.go index 57614a5e7..582047bee 100644 --- a/services/core/internal/api/harness_model_providers.go +++ b/services/core/internal/api/harness_model_providers.go @@ -144,6 +144,7 @@ func (h *Handler) getHarnessModelConfiguration(w http.ResponseWriter, r *http.Re var modelProviderInputShape = shape{kind: objectValue, members: []member{ {"protocol", requiredString}, {"base_url", requiredString}, {"api_key", requiredString}, + {"headers", shape{kind: mapValue, nullable: true, items: &shape{kind: stringValue}}}, {"context_window", shape{kind: integerValue, minimum: 0}}, {"max_output_tokens", shape{kind: integerValue, minimum: 0}}, }} diff --git a/services/core/internal/api/model_headers_inputs_test.go b/services/core/internal/api/model_headers_inputs_test.go new file mode 100644 index 000000000..7c8cd4b34 --- /dev/null +++ b/services/core/internal/api/model_headers_inputs_test.go @@ -0,0 +1,89 @@ +package api + +import ( + "encoding/json" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/google/uuid" + "net/http" + "strings" + "testing" +) + +func TestModelHeadersDeploymentAndSavedAgentHTTP(t *testing.T) { + for _, headers := range []string{`{"X-Activity":"private-header-canary"}`, `null`, `{"X-Activity":null}`, `"wrong"`, `{"X-A":"one","x-a":"two"}`} { + valid := headers == `{"X-Activity":"private-header-canary"}` || headers == `null` + provider := strings.TrimSuffix(savedProviderFixture, "}") + `,"headers":` + headers + `}` + s := &coreProviderValidationStore{t: t} + h, _, _ := adminTestHandler(t, s.configure) + out := projectKeyHTTP(h, http.MethodPut, "/core/v1/harnesses/codex/model-configuration", "admin", `{"model":"fixture","model_provider":`+provider+`}`) + if valid && (out.Code != 200 || s.writes != 1) || !valid && (out.Code != 400 || s.writes != 0) { + t.Fatalf("deployment headers status=%d writes=%d", out.Code, s.writes) + } + if valid && headers != "null" && s.record.Configuration.ModelProvider.Headers["X-Activity"] != "private-header-canary" { + t.Fatal("deployment store lost headers") + } + if strings.Contains(out.Body.String(), "private-header-canary") || strings.Contains(out.Body.String(), "X-Activity") { + t.Fatal("deployment response disclosed headers") + } + for _, path := range []string{"/v1/agents", "/v1/agents/" + uuid.NewString()} { + saved := &savedProviderStore{} + handler, _, _ := testHandler(t, saved.serve) + raw := `{"model":"fixture","x_agents_core":{"harness":"codex","model_provider":` + provider + `}}` + response := credentialRequest(handler, http.MethodPost, path, raw) + want := 200 + if path == "/v1/agents" { + want = 201 + } + if valid && (response.Code != want || saved.provider == nil) || !valid && (response.Code != 400 || saved.provider != nil) { + t.Fatalf("saved headers path=%s status=%d", path, response.Code) + } + if valid && headers != "null" && saved.provider.Headers["X-Activity"] != "private-header-canary" { + t.Fatal("saved store lost headers") + } + for _, body := range []string{response.Body.String(), string(saved.saved.Configuration)} { + if strings.Contains(body, "private-header-canary") || strings.Contains(body, "X-Activity") { + t.Fatal("saved safe view disclosed headers") + } + } + } + } +} + +func TestModelHeadersClaudeAcceptedAcrossProviderInputRoutes(t *testing.T) { + provider := strings.Replace(strings.TrimSuffix(savedProviderFixture, "}")+`,"headers":{"X-Activity":"daily-free"}}`, `"responses"`, `"anthropic"`, 1) + raw := `{"model":"fixture","x_agents_core":{"harness":"claude_sdk","model_provider":` + provider + `}}` + s := &coreProviderValidationStore{t: t} + h, _, _ := adminTestHandler(t, s.configure) + out := projectKeyHTTP(h, http.MethodPut, "/core/v1/harnesses/claude_sdk/model-configuration", "admin", `{"model":"fixture","model_provider":`+provider+`}`) + if out.Code != 200 || s.writes != 1 || s.record.Configuration.ModelProvider.Headers["X-Activity"] != "daily-free" { + t.Fatalf("deployment Claude header status %d", out.Code) + } + if strings.Contains(out.Body.String(), "daily-free") { + t.Fatal("deployment response disclosed metadata") + } + for _, path := range []string{"/v1/agents", "/v1/agents/" + uuid.NewString()} { + saved := &savedProviderStore{} + handler, _, _ := testHandler(t, saved.serve) + response := credentialRequest(handler, http.MethodPost, path, raw) + want := 200 + if path == "/v1/agents" { + want = 201 + } + if response.Code != want || saved.provider == nil || saved.provider.Headers["X-Activity"] != "daily-free" || strings.Contains(response.Body.String(), "daily-free") { + t.Fatalf("saved Claude header status %d", response.Code) + } + } + var create v1.CreateAgentRequest + if err := validateSavedCoreInput([]byte(raw)); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(raw), &create); err != nil { + t.Fatal(err) + } + if _, err := resolveSavedAgent(create); err != nil { + t.Fatal(err) + } + if _, err := resolveAgentUpdate([]byte(raw)); err != nil { + t.Fatal(err) + } +} diff --git a/services/core/internal/api/model_headers_qa_test.go b/services/core/internal/api/model_headers_qa_test.go new file mode 100644 index 000000000..248ea01e3 --- /dev/null +++ b/services/core/internal/api/model_headers_qa_test.go @@ -0,0 +1,20 @@ +package api + +import ( + "encoding/json" + "testing" +) + +func TestQASessionModelHeadersStrictRawPath(t *testing.T) { + for _, headers := range []string{`{"X-A":null}`, `{"X-A":"one","X-A":"two"}`, `{"X-A":"one","x-a":"two"}`, `{"Anthropic-Version":"override"}`} { + raw := `{"agent":{"model":"fixture"},"environment":{"type":"openai_hosted"},"x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://model.example/v1","api_key":"fixture","headers":` + headers + `}}}` + var decoded decodedSessionRequest + err := json.Unmarshal([]byte(raw), &decoded) + if err == nil { + _, err = decoded.validated() + } + if err == nil { + t.Fatal("invalid header input accepted through raw Session decoder") + } + } +} diff --git a/services/core/internal/execution/model_execution.go b/services/core/internal/execution/model_execution.go index 674d3abcb..c4a30dcc9 100644 --- a/services/core/internal/execution/model_execution.go +++ b/services/core/internal/execution/model_execution.go @@ -2,6 +2,7 @@ package execution import ( "context" + "maps" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" @@ -19,8 +20,12 @@ func resolvedSessionModelOptions(provider *v1.ModelProviderInput, engine string) if err := provider.ValidateHarness(engine); err != nil { return nil, err } - return map[string]any{"model_provider": map[string]any{ + bundle := map[string]any{ "protocol": string(provider.Protocol), "base_url": provider.BaseURL, "api_key": provider.APIKey, "context_window": provider.ContextWindow, "max_output_tokens": provider.MaxOutputTokens, - }}, nil + } + if len(provider.Headers) != 0 { + bundle["headers"] = maps.Clone(provider.Headers) + } + return map[string]any{"model_provider": bundle}, nil } diff --git a/services/core/internal/execution/model_execution_test.go b/services/core/internal/execution/model_execution_test.go index b7da9a67e..cda559676 100644 --- a/services/core/internal/execution/model_execution_test.go +++ b/services/core/internal/execution/model_execution_test.go @@ -35,7 +35,7 @@ func TestSessionModelOptionsPreserveUpstreamBundleForEveryHarness(t *testing.T) for _, engine := range []string{"codex", "claude_sdk", "mcode"} { for _, protocol := range []string{"anthropic", "responses", "chat_completions"} { t.Run(engine+"/"+protocol, func(t *testing.T) { - provider := &v1.ModelProviderInput{Protocol: modelprovider.Protocol(protocol), BaseURL: "https://example.com/v1", APIKey: "private-key", ContextWindow: 200000, MaxOutputTokens: 8000} + provider := &v1.ModelProviderInput{Protocol: modelprovider.Protocol(protocol), BaseURL: "https://example.com/v1", APIKey: "private-key", Headers: map[string]string{"X-Activity": "private-header"}, ContextWindow: 200000, MaxOutputTokens: 8000} got, err := resolvedSessionModelOptions(provider, engine) native := engine == "mcode" || engine == "codex" && protocol == "responses" || engine == "claude_sdk" && protocol == "anthropic" if !native { @@ -49,7 +49,7 @@ func TestSessionModelOptionsPreserveUpstreamBundleForEveryHarness(t *testing.T) return } want := map[string]any{"model_provider": map[string]any{ - "protocol": protocol, "base_url": provider.BaseURL, "api_key": provider.APIKey, + "protocol": protocol, "base_url": provider.BaseURL, "api_key": provider.APIKey, "headers": provider.Headers, "context_window": provider.ContextWindow, "max_output_tokens": provider.MaxOutputTokens, }} if err != nil || !reflect.DeepEqual(got, want) { diff --git a/services/core/internal/persistence/postgres/agentpg/store_test.go b/services/core/internal/persistence/postgres/agentpg/store_test.go index 98d3c9572..d5d91c917 100644 --- a/services/core/internal/persistence/postgres/agentpg/store_test.go +++ b/services/core/internal/persistence/postgres/agentpg/store_test.go @@ -345,7 +345,7 @@ func TestAgentModelExecutionAtomicEncryptedSnapshot(t *testing.T) { } return current, inherited } - if _, inherited := snapshot(store); inherited == nil || *inherited != *provider { + if _, inherited := snapshot(store); inherited == nil || !reflect.DeepEqual(inherited, provider) { t.Fatal("provider snapshot mismatch") } if _, err := service.Update(ctx, agents.UpdateCommand{TenantID: tenant, AgentID: agent.ID, Configuration: []byte(`{"model":"new-model"}`)}); err != nil { @@ -361,7 +361,7 @@ func TestAgentModelExecutionAtomicEncryptedSnapshot(t *testing.T) { if _, err := service.Update(ctx, replace(uuid.NewString())); !errors.Is(err, agents.ErrNotFound) { t.Fatal("foreign tenant replacement accepted", err) } - if current, inherited := snapshot(store); inherited == nil || *inherited != *provider || !bytes.Contains(current.Configuration, []byte("new-model")) { + if current, inherited := snapshot(store); inherited == nil || !reflect.DeepEqual(inherited, provider) || !bytes.Contains(current.Configuration, []byte("new-model")) { t.Fatal("failed replacement changed snapshot") } // A database rejection after secret replacement rolls both writes back. @@ -369,7 +369,7 @@ func TestAgentModelExecutionAtomicEncryptedSnapshot(t *testing.T) { if _, err := service.Update(ctx, agents.UpdateCommand{TenantID: tenant, AgentID: agent.ID, Configuration: invalidPatch, ModelProvider: &agents.ModelProviderChange{Provider: replacement}}); !errors.Is(err, textvalue.ErrUnstorable) { t.Fatal("unstorable configuration accepted", err) } - if current, inherited := snapshot(store); inherited == nil || *inherited != *provider || !bytes.Contains(current.Configuration, []byte("new-model")) { + if current, inherited := snapshot(store); inherited == nil || !reflect.DeepEqual(inherited, provider) || !bytes.Contains(current.Configuration, []byte("new-model")) { t.Fatal("database rejection left a partial replacement") } if _, err := service.Update(ctx, agents.UpdateCommand{TenantID: tenant, AgentID: agent.ID, Configuration: []byte(`{"x_agents_core":{"harness":"claude_sdk"}}`)}); !errors.Is(err, agents.ErrInvalidInput) { @@ -380,13 +380,13 @@ func TestAgentModelExecutionAtomicEncryptedSnapshot(t *testing.T) { if _, err := service.Update(ctx, agents.UpdateCommand{TenantID: tenant, AgentID: agent.ID, Configuration: patch, ModelProvider: &agents.ModelProviderChange{Provider: replacement}}); err != nil { t.Fatal(err) } - if current, inherited := snapshot(store); inherited == nil || *inherited != *replacement || !bytes.Contains(current.Configuration, []byte(`"harness": "codex"`)) { + if current, inherited := snapshot(store); inherited == nil || !reflect.DeepEqual(inherited, replacement) || !bytes.Contains(current.Configuration, []byte(`"harness": "codex"`)) { t.Fatal("provider-only replacement failed") } if _, err := service.Update(ctx, agents.UpdateCommand{TenantID: tenant, AgentID: agent.ID, Configuration: []byte(`{"x_agents_core":{"harness":"codex"}}`)}); err != nil { t.Fatal(err) } - if _, inherited := snapshot(store); inherited == nil || *inherited != *replacement { + if _, inherited := snapshot(store); inherited == nil || !reflect.DeepEqual(inherited, replacement) { t.Fatal("harness-only update lost provider") } if _, err := service.Update(ctx, agents.UpdateCommand{TenantID: tenant, AgentID: agent.ID, Configuration: []byte(`{"x_agents_core":{"model_provider":null}}`), ModelProvider: &agents.ModelProviderChange{}}); err != nil { diff --git a/services/core/internal/persistence/postgres/modelconfigurationpg/store_test.go b/services/core/internal/persistence/postgres/modelconfigurationpg/store_test.go index 685e3e724..bfdfb15d9 100644 --- a/services/core/internal/persistence/postgres/modelconfigurationpg/store_test.go +++ b/services/core/internal/persistence/postgres/modelconfigurationpg/store_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "reflect" "testing" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" @@ -28,7 +29,7 @@ func TestDefaultsAreEncryptedAuditedAndReplaced(t *testing.T) { if listed := f.list(t); len(listed) != 1 || listed[0].Provider != *provider.SafeView() { t.Fatal("safe fields not listed", listed) } - if got := f.resolve(t); got == nil || *got.Provider != provider { + if got := f.resolve(t); got == nil || !reflect.DeepEqual(*got.Provider, provider) { t.Fatal("default did not open", got) } if got, err := f.service.Resolve(t.Context(), "mcode"); err != nil || got != nil { @@ -37,7 +38,7 @@ func TestDefaultsAreEncryptedAuditedAndReplaced(t *testing.T) { before := f.resolve(t).Revision replacement := v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://replacement.example/v1", APIKey: "replacement-key"} f.replace(t, replacement) - if got := f.resolve(t); *got.Provider != replacement || got.Revision == before { + if got := f.resolve(t); !reflect.DeepEqual(*got.Provider, replacement) || got.Revision == before { t.Fatal("replacement not complete", got) } for range 2 { diff --git a/services/core/tests/integration/deployment_model_providers_http_test.go b/services/core/tests/integration/deployment_model_providers_http_test.go index 64344aa6d..f0d3d3727 100644 --- a/services/core/tests/integration/deployment_model_providers_http_test.go +++ b/services/core/tests/integration/deployment_model_providers_http_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "net/http/httptest" + "reflect" "strings" "testing" @@ -335,7 +336,7 @@ func TestDeploymentProviderResolutionPairsRevisionDuringReplacement(t *testing.T t.Fatal("tuple revision changed", err) } frozen, err := sessionAdapter(st).SessionModelExecution(t.Context(), tenant, session.ID) - if err != nil || frozen == nil || *frozen != provider { + if err != nil || frozen == nil || !reflect.DeepEqual(*frozen, provider) { t.Fatal("tuple bundle changed", err) } for _, private := range []string{"original-fixture-key", "replacement-fixture-key", revision.String(), "deployment_provider_revision"} { diff --git a/services/core/tests/integration/session_creation_identity_test.go b/services/core/tests/integration/session_creation_identity_test.go index 159c8e7f9..ad3a28f7f 100644 --- a/services/core/tests/integration/session_creation_identity_test.go +++ b/services/core/tests/integration/session_creation_identity_test.go @@ -2,6 +2,7 @@ package integration import ( "encoding/json" + "reflect" "testing" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" @@ -90,7 +91,7 @@ func TestSessionCreationKeepsItsResolvedDeploymentRevision(t *testing.T) { t.Fatal("tuple revision changed during insertion") } frozen, err := sessionAdapter(s).SessionModelExecution(t.Context(), tenant, stale.ID) - if err != nil || frozen == nil || *frozen != *before.Provider { + if err != nil || frozen == nil || !reflect.DeepEqual(frozen, before.Provider) { t.Fatal("frozen tuple bundle changed", err) } observe(stale.ID, sessions.TurnFailed, "engine_failed", "authentication_error") @@ -110,7 +111,7 @@ func TestSessionCreationKeepsItsResolvedDeploymentRevision(t *testing.T) { t.Fatal("revision reused") } frozenProvider, err := sessionAdapter(s).SessionModelExecution(t.Context(), tenant, session.ID) - if err != nil || frozenProvider == nil || *frozenProvider != *input.ModelProvider { + if err != nil || frozenProvider == nil || !reflect.DeepEqual(frozenProvider, input.ModelProvider) { t.Fatal("replacement changed the Session bundle", err) } observe(session.ID, sessions.TurnCompleted, "", "") diff --git a/services/core/tests/integration/session_model_options_test.go b/services/core/tests/integration/session_model_options_test.go index 7ba034b94..7f43af47d 100644 --- a/services/core/tests/integration/session_model_options_test.go +++ b/services/core/tests/integration/session_model_options_test.go @@ -2,6 +2,7 @@ package integration import ( "encoding/json" + "reflect" "testing" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" @@ -20,7 +21,7 @@ func TestSessionModelExecutionStoresOnlyProviderBundle(t *testing.T) { t.Fatal(err) } flat, err := sessionAdapter(st).SessionModelExecution(ctx, tenant, session.ID) - if err != nil || flat == nil || *flat != *provider { + if err != nil || flat == nil || !reflect.DeepEqual(flat, provider) { t.Fatal("new Session did not store a flat provider bundle", err) } options := map[string]any{"codex_provider": map[string]any{