diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8e5674371..5b3262d8b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -297,7 +297,7 @@ Inline and referenced Skill ZIPs use the same confidential initialization snapsh Core validates portable manifests and bounded regular-file archives, returns only safe Skill metadata, and freezes content before native preparation. The Runtime owns `/environment/initialization/capabilities/skills/`; setup and native tools may read but -not modify this tree. Skill-only public Plugin ZIPs preserve their complete package +not modify this tree. Public Plugin ZIPs preserve their complete package layout and reuse the shared archive and portable Skill parsers. Core keeps safe Plugin metadata separate from encrypted archives. Templates inherit or replace Plugin and capability-directory lists through the same hosted resolver. @@ -315,11 +315,49 @@ Adapters register only selected Skill roots without changing the execution loop. Codex uses explicit extra roots; MiniMax projects its native catalog; Claude creates one controlled envelope per package with real directories and immutable hardlinks under content. Its explicit paths remain inside that envelope; original native -control files are not activated. Keep native MCP discovery disabled. Unsupported -native activation fails explicitly. Public Plugin MCP remains separate qualification, -not silent partial activation or a generic plugin framework. - -Name, enabled/disabled/exact-domain restricted network, initial files, inline/referenced Skills, skill-only Plugins, workspace capability directories and env/setup/system/npm/Python are +control files are not activated. Keep automatic native MCP discovery disabled. +Explicit Environment MCP declarations +follow the separately qualified transport path below; unsupported native activation +fails explicitly, without silent partial activation or a generic plugin framework. + +Environment-origin MCP declarations use the shared Plugin parser and frozen +installed packages. The installation manifest retains selected MCP package roots; +Runtime re-parses those protected packages without another configuration copy, +credential cache or lifecycle ledger. Native adapters must explicitly qualify and +project supported declarations before public admission. Parent-directory Skill +discovery does not activate nested Plugin MCP configuration. + +The packaged stdio entry enters the existing initialization sandbox before parsing +or executing a server. It mounts only the fixed static daemon helper, which resolves +the selected installed declaration, reads explicitly selected initialized user +variables, applies package-relative cwd and replaces itself with the server. Native +MCP stdin/stdout pass directly to the sandbox; no protocol forwarding loop, +Provider command or second Plugin parser is introduced. The existing Python stdio +entry remains a single-threaded launcher and monitors the native parent process +through Linux pidfds. Native Codex recycles spawning threads, so binding bwrap's +parent-death signal directly to those threads kills live MCP connections. The +launcher gives bwrap a stable parent, retains its parent-death/PID namespace cleanup, +and kills and reaps only that child when the native process exits. A pre-exec +parent-death signal and expected-parent PID check close the reproduced fork-to-exec +orphan window; bind libc before fork and keep this entry single-threaded. Missing +pidfd support fails closed. This fixes the OS process lifetime boundary without adding a +Core execution owner, retry or reconnect loop. +Model/daemon launch variables are never credential sources. Hosted stdio requires +enabled network; native HTTP requires its own qualified network and redirect behavior. +Claude composes the existing MCP identity/observation profile with its workspace +profile. Verify the complete native inventory before admitting tool identities; +MCP allowlist patterns never grant local Bash or file authority. Only declared +random bearer references enter native query env, with native Bash denial retained. +Environment-origin literal HTTP headers remain rejected for the pinned Claude +client because its interpolation and cross-origin forwarding change their meaning. +MiniMax accepts environment stdio only. Its adapter reads the existing Session-private +native runtime-name registry for exact first-frame identities and cross-checks +completed native results. Reuse existing observation and cancellation settlement; +never fabricate a delayed start event, guess normalized identities or add a registry +of our own. Its unqualified HTTP transport remains rejected. +These mechanisms alone do not establish public MCP support or full compatibility. + +Name, enabled/disabled/exact-domain restricted network, initial files, inline/referenced Skills, Plugins, workspace capability directories and env/setup/system/npm/Python are implemented independently of remaining installation fields. Reject unsupported inputs rather than persisting them for silent omission; expand inline and template initialization together in separately qualified @@ -1485,7 +1523,7 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti the selected harness. Omitted/null service tier currently uses `auto`; complete upstream default/error/retry conformance and remaining MCP/web-search variants remain gaps. Unknown/unsupported variants fail explicitly. No product lookup is permitted. -- Public HTTP MCP uses the native harness client and tool loop. The supported +- Service-origin public HTTP MCP uses the native harness client and tool loop. The supported execution profiles are Codex with `environment:none` or `self_hosted`, and Claude SDK with `environment:none`. Both require an explicit `service` connection origin and a trusted service-side harness. The execution device is @@ -2133,7 +2171,8 @@ Claude hosted functions compose the existing SDK function bridge with the native workspace sandbox. Only declared function tools and the verified native tool inventory are available. The bundle advertises this combination separately from basic workspace execution; function preparations require that verified combination. -External hosted MCP remains unqualified. Function callbacks do not change file, +Service-origin hosted MCP remains unqualified; Environment Plugin declarations +use the separately qualified initialization path above. Function callbacks do not change file, credential, history, subagent or network authority. ### Claude dedicated Docker Runtime @@ -2166,8 +2205,9 @@ Registration combines that contract with the verified operator binding. Core use an explicit accepted engine profile independently of advertisements. This profile supports native Bash/Read/Edit, preparation, shared Files/Artifacts, cancellation and same-history continuation. The separately advertised `workspace_functions` -combination supports declared public functions with text results. External HTTP -MCP remains unqualified here; its existing `none` support is retained. +combination supports declared public functions with text results. Service-origin +HTTP MCP remains unqualified here; its existing `none` support is retained. +Environment Plugin MCP follows the separately qualified transport path above. Native Bash network access uses the harness's HTTP proxy; no alternate networking or tool loop is implemented by Core. diff --git a/apps/parsar-daemon/internal/agent/claudesdk/mcp.go b/apps/parsar-daemon/internal/agent/claudesdk/mcp.go index 351803250..5c77f2fcd 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/mcp.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/mcp.go @@ -24,8 +24,12 @@ func validateMCP(req proto.PromptRequestPayload) error { if !req.DisableExecutionEnvironment || req.RemoteEnvironment != nil { return fmt.Errorf("claudesdk: HTTP MCP requires environment:none") } + return validateMCPServers(*req.MCPHTTPServers) +} + +func validateMCPServers(servers []proto.MCPHTTPServer) error { labels := map[string]bool{} - for _, server := range *req.MCPHTTPServers { + for _, server := range servers { endpoint, err := url.Parse(server.ServerURL) if !mcpLabel.MatchString(server.ServerLabel) || server.ServerLabel == "functions" || labels[server.ServerLabel] || err != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") || endpoint.Hostname() == "" || endpoint.User != nil || @@ -51,7 +55,7 @@ func validateMCP(req proto.PromptRequestPayload) error { // process environment and are expanded by the native HTTP client. type mcpHTTPServer struct { ServerLabel string `json:"server_label"` - ServerURL string `json:"server_url"` + ServerURL string `json:"server_url,omitempty"` AllowedTools *[]string `json:"allowed_tools"` Required bool `json:"required,omitempty"` BearerTokenEnvVar string `json:"bearer_token_env_var,omitempty"` @@ -84,12 +88,12 @@ type mcpState struct { func (m *mcpState) receive(event bridgeEvent, start startRequest, emit func(string, any)) error { n := event.Observation - if start.MCPHTTPServers == nil || n == nil || n.Kind != "mcp" || event.ID == "" || !json.Valid(n.Arguments) || + if n == nil || n.Kind != "mcp" || event.ID == "" || !json.Valid(n.Arguments) || !json.Valid(n.Output) || !json.Valid(n.Error) { return fmt.Errorf("claudesdk: invalid MCP observation") } declared := false - for _, server := range *start.MCPHTTPServers { + for _, server := range start.declaredMCP() { if server.ServerLabel == n.Server && n.Name != "" && (server.AllowedTools == nil || slices.Contains(*server.AllowedTools, n.Name)) { declared = true break diff --git a/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment.go b/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment.go new file mode 100644 index 000000000..5b8004e28 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment.go @@ -0,0 +1,72 @@ +package claudesdk + +import ( + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// Environment servers originate in frozen installed packages. Only native +// transport projection crosses this private bridge, never package configuration. +type environmentMCPServer struct { + mcpHTTPServer + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` +} + +func prepareEnvironmentMCP(environment *proto.LocalEnvironment) ([]environmentMCPServer, []string, error) { + if environment == nil || len(environment.MCP) == 0 { + return nil, nil, nil + } + if environment.NetworkAccess != "enabled" { + return nil, nil, fmt.Errorf("claudesdk: environment MCP requires enabled network") + } + var servers []environmentMCPServer + var env []string + labels := map[string]bool{} + for _, item := range environment.MCP { + declaration := item.Server + if !mcpLabel.MatchString(declaration.Name) || declaration.Name == "functions" || labels[declaration.Name] { + return nil, nil, fmt.Errorf("claudesdk: unsupported environment MCP identity") + } + labels[declaration.Name] = true + switch declaration.Type { + case "stdio": + command, args := localworkspace.MCPStdioCommand(item) + servers = append(servers, environmentMCPServer{mcpHTTPServer: mcpHTTPServer{ServerLabel: declaration.Name}, Command: command, Args: args}) + case "http": + // The pinned native client expands literal headers again and forwards + // custom headers across origins. Do not reinterpret their public meaning. + if len(declaration.HTTPHeaders) != 0 { + return nil, nil, fmt.Errorf("claudesdk: environment MCP literal HTTP headers are not supported") + } + if declaration.BearerTokenEnvVar != "" && item.BearerToken == nil { + return nil, nil, fmt.Errorf("claudesdk: environment MCP credential unavailable") + } + http := []proto.MCPHTTPServer{{ServerLabel: declaration.Name, ServerURL: declaration.URL, BearerToken: item.BearerToken}} + if err := validateMCPServers(http); err != nil { + return nil, nil, err + } + projected, credentials := prepareMCPHTTP(&http) + servers = append(servers, environmentMCPServer{mcpHTTPServer: (*projected)[0]}) + env = append(env, credentials...) + default: + return nil, nil, fmt.Errorf("claudesdk: unsupported environment MCP transport") + } + } + return servers, env, nil +} + +func (start startRequest) declaredMCP() []mcpHTTPServer { + var servers []mcpHTTPServer + if start.MCPHTTPServers != nil { + servers = append(servers, (*start.MCPHTTPServers)...) + } + if start.Workspace != nil { + for _, server := range start.Workspace.MCP { + servers = append(servers, server.mcpHTTPServer) + } + } + return servers +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment_test.go b/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment_test.go new file mode 100644 index 000000000..202590fa7 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment_test.go @@ -0,0 +1,93 @@ +package claudesdk + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" +) + +func TestEnvironmentMCPUsesInstalledLauncherAndSelectedCredential(t *testing.T) { + config := workspaceFixture(t) + config.Workspace.NetworkAccess = "enabled" + req := workspaceRequest() + token := "selected-user-token" + t.Setenv("MCP_TOKEN", "unselected-native-token") + req.LocalEnvironment = &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{ + {PackageRoot: "plugins/local", Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: "untrusted-package-command", Args: []string{"package-argument"}, EnvVars: []string{"MCP_TOKEN"}}}, + {PackageRoot: "plugins/remote", Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp", BearerTokenEnvVar: "MCP_TOKEN"}, BearerToken: &token}, + }} + start, env, err := prepare(config, req) + if err != nil { + t.Fatal(err) + } + if start.MCPHTTPServers != nil || len(start.Workspace.MCP) != 2 { + t.Fatal("environment declarations changed authority") + } + stdio := start.Workspace.MCP[0] + if stdio.Command != "/usr/bin/python3" || len(stdio.Args) != 6 || stdio.Args[4] != "plugins/local" || stdio.Args[5] != "local" { + t.Fatal("stdio bypassed the shared installed entry") + } + raw, _ := json.Marshal(start) + for _, forbidden := range []string{token, "unselected-native-token", "untrusted-package-command", "package-argument", `"MCP_TOKEN"`} { + if strings.Contains(string(raw), forbidden) { + t.Fatal("private request contains package input or credential values") + } + } + reference := start.Workspace.MCP[1].BearerTokenEnvVar + found := false + for _, entry := range env { + if entry == reference+"="+token { + found = true + } + if strings.Contains(entry, "unselected-native-token") { + t.Fatal("inherited native credential") + } + } + if !found || !strings.HasPrefix(reference, "PARSAR_MCP_BEARER_") || len(start.declaredMCP()) != 2 { + t.Fatal("selected credential or observation declarations missing") + } +} + +func TestEnvironmentMCPRejectsUnqualifiedCombinations(t *testing.T) { + for _, mutate := range []func(*proto.LocalEnvironment){ + func(e *proto.LocalEnvironment) { e.NetworkAccess = "restricted" }, + func(e *proto.LocalEnvironment) { e.MCP[0].Server.Name = "functions" }, + func(e *proto.LocalEnvironment) { e.MCP = append(e.MCP, e.MCP[0]) }, + func(e *proto.LocalEnvironment) { e.MCP[0].Server.Type = "sse" }, + func(e *proto.LocalEnvironment) { e.MCP[0].Server.HTTPHeaders = map[string]string{"X-Key": "literal"} }, + func(e *proto.LocalEnvironment) { e.MCP[0].Server.BearerTokenEnvVar = "MISSING" }, + func(e *proto.LocalEnvironment) { + token := "token" + e.MCP[0].BearerToken = &token + e.MCP[0].Server.URL = "http://example.invalid/mcp" + }, + } { + environment := &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{{PackageRoot: "plugins/remote", Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp"}}}} + mutate(environment) + if _, _, err := prepareEnvironmentMCP(environment); err == nil { + t.Fatal("unsupported declaration accepted") + } + } +} + +func TestEnvironmentMCPObservationsUseInstalledDeclarations(t *testing.T) { + start := startRequest{Workspace: &workspaceProfile{MCP: []environmentMCPServer{{mcpHTTPServer: mcpHTTPServer{ServerLabel: "installed"}}}}, observeFunctions: true} + state := mcpState{calls: map[string]proto.ToolObservation{}} + observation := proto.ToolObservation{Kind: "mcp", Name: "echo", Server: "installed", Status: "in_progress", Arguments: json.RawMessage(`{}`), Output: json.RawMessage(`null`), Error: json.RawMessage(`null`)} + var emitted []proto.ToolCallPayload + emit := func(_ string, payload any) { emitted = append(emitted, payload.(proto.ToolCallPayload)) } + if err := state.receive(bridgeEvent{ID: "native-call", Stage: "before", Observation: &observation}, start, emit); err != nil { + t.Fatal(err) + } + state.close(start, emit) + if len(emitted) != 2 || emitted[1].ID != "native-call" || emitted[1].Observation.Status != "incomplete" { + t.Fatal("interrupted environment call lost identity") + } + observation.Server = "undeclared" + if err := state.receive(bridgeEvent{ID: "other", Stage: "before", Observation: &observation}, start, emit); err == nil { + t.Fatal("undeclared observation accepted") + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go index beb433f0b..241a6dfc0 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go @@ -30,6 +30,7 @@ type WorkspaceConfig struct { } type workspaceProfile struct { + MCP []environmentMCPServer `json:"mcp,omitempty"` Skills []agentcapabilities.InstalledSkill `json:"skills,omitempty"` ToolEnvironment bool `json:"tool_environment,omitempty"` SystemPackages bool `json:"system_packages,omitempty"` @@ -67,7 +68,12 @@ func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspace if req.LocalEnvironment != nil { profile.Skills = req.LocalEnvironment.Skills } - return profile, env, nil + servers, credentials, err := prepareEnvironmentMCP(req.LocalEnvironment) + if err != nil { + return nil, nil, err + } + profile.MCP = servers + return profile, append(env, credentials...), nil } func workspaceCwd(w *WorkspaceConfig) string { diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_config.go b/apps/parsar-daemon/internal/agent/codex/mcp_config.go index 5217da02d..538791f10 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_config.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_config.go @@ -22,6 +22,8 @@ type mcpServerConfig struct { EnabledTools *[]string Required bool BearerTokenEnvVar string + EnvHTTPHeaders map[string]string + ApproveTools bool } // writeCodexMCPConfig writes a `[mcp_servers.]` TOML table per @@ -49,10 +51,13 @@ func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) e b.WriteString("[mcp_servers.") b.WriteString(tomlQuoteString(name)) b.WriteString("]\n") + if srv.ApproveTools { + b.WriteString("default_tools_approval_mode = \"approve\"\n") + } + if srv.Required { + b.WriteString("required = true\n") + } if srv.URL != "" { - if srv.Required { - b.WriteString("required = true\n") - } b.WriteString(`url = `) b.WriteString(tomlQuoteString(srv.URL)) b.WriteByte('\n') @@ -71,23 +76,8 @@ func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) e } b.WriteString("]\n") } - if len(srv.Headers) > 0 { - headerKeys := make([]string, 0, len(srv.Headers)) - for key := range srv.Headers { - headerKeys = append(headerKeys, key) - } - sort.Strings(headerKeys) - b.WriteString("http_headers = {") - for index, key := range headerKeys { - if index > 0 { - b.WriteString(", ") - } - b.WriteString(tomlQuoteString(key)) - b.WriteString(" = ") - b.WriteString(tomlQuoteString(srv.Headers[key])) - } - b.WriteString("}\n") - } + writeMCPHeaderMap(&b, "http_headers", srv.Headers) + writeMCPHeaderMap(&b, "env_http_headers", srv.EnvHTTPHeaders) b.WriteByte('\n') continue } @@ -127,6 +117,25 @@ func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) e return appendConfigTOML(path, b.String()) } +func writeMCPHeaderMap(b *strings.Builder, field string, values map[string]string) { + if len(values) == 0 { + return + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + b.WriteString(field + " = {") + for i, key := range keys { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(tomlQuoteString(key) + " = " + tomlQuoteString(values[key])) + } + b.WriteString("}\n") +} + // tomlQuoteString returns a TOML basic-string literal (double-quoted) // with the documented escape set applied — \" \\ \n \r \t plus // \uXXXX for control chars. Matches the TOML 1.0 spec for basic strings. diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_environment.go b/apps/parsar-daemon/internal/agent/codex/mcp_environment.go new file mode 100644 index 000000000..ff5e589bf --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/mcp_environment.go @@ -0,0 +1,62 @@ +package codex + +import ( + "crypto/rand" + "errors" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// mergeEnvironmentMCP translates only qualified Runtime declarations. Credential +// references enter the config; values enter the private native env after probes. +func mergeEnvironmentMCP(servers map[string]mcpServerConfig, local *proto.LocalEnvironment) (map[string]mcpServerConfig, []string, error) { + if local == nil || len(local.MCP) == 0 { + return servers, nil, nil + } + if local.NetworkAccess != "enabled" { + return nil, nil, errors.New("codex: environment MCP requires enabled network") + } + if servers == nil { + servers = map[string]mcpServerConfig{} + } + var env []string + for _, item := range local.MCP { + declaration := item.Server + name := declaration.Name + if name == "" || strings.TrimSpace(name) != name || name == "codex_apps" { + return nil, nil, errors.New("codex: unsupported environment MCP identity") + } + if _, exists := servers[name]; exists { + return nil, nil, errors.New("codex: ambiguous environment MCP identity") + } + server := mcpServerConfig{Name: name, ApproveTools: true} + switch declaration.Type { + case "stdio": + server.Command, server.Args = localworkspace.MCPStdioCommand(item) + case "http": + server.URL = declaration.URL + if item.BearerToken != nil { + if !strings.HasPrefix(server.URL, "https://") || !agent.ValidMCPHTTPBearerToken(*item.BearerToken) { + return nil, nil, errors.New("codex: unsupported environment MCP bearer") + } + server.BearerTokenEnvVar = "PARSAR_MCP_BEARER_" + rand.Text() + env = append(env, server.BearerTokenEnvVar+"="+*item.BearerToken) + } + server.EnvHTTPHeaders = map[string]string{} + for key, value := range declaration.HTTPHeaders { + // The public value stays literal. This native-only env reference + // keeps its bytes out of generated configuration and argv. + reference := "PARSAR_MCP_HEADER_" + rand.Text() + server.EnvHTTPHeaders[key] = reference + env = append(env, reference+"="+value) + } + default: + return nil, nil, errors.New("codex: unsupported environment MCP transport") + } + servers[name] = server + } + return servers, env, nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go new file mode 100644 index 000000000..e0e2a2a53 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go @@ -0,0 +1,66 @@ +package codex + +import ( + "encoding/json" + "slices" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" +) + +func TestEnvironmentMCPProjectsIsolatedStdioAndPrivateHTTPReferences(t *testing.T) { + token := "user-token" + local := &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{ + {PackageRoot: "plugins/0", Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: "must-not-be-native-command", Args: []string{"private-argument"}}}, + {PackageRoot: "plugins/1", BearerToken: &token, Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.com/mcp", HTTPHeaders: map[string]string{"X-Key": "literal-${DO_NOT_EXPAND}"}}}, + }} + servers, env, err := mergeEnvironmentMCP(nil, local) + if err != nil || len(servers) != 2 || len(env) != 2 { + t.Fatal("environment declarations were not projected", err) + } + if servers["local"].Command != "/usr/bin/python3" || !servers["local"].ApproveTools || + !slices.Equal(servers["local"].Args, []string{"-I", "-S", "/usr/local/bin/agents-api-runtime-initialize", "stdio", "plugins/0", "local"}) { + t.Fatal("native stdio bypasses the packaged launcher") + } + remote := servers["remote"] + if remote.BearerTokenEnvVar == "" || remote.EnvHTTPHeaders["X-Key"] == "" || + !slices.Contains(env, remote.BearerTokenEnvVar+"="+token) || + !slices.Contains(env, remote.EnvHTTPHeaders["X-Key"]+"=literal-${DO_NOT_EXPAND}") { + t.Fatal("private header values changed") + } + fixture := map[string]any{"config": map[string]any{ + "mcp_oauth_credentials_store": "file", "features": map[string]bool{"plugins": false, "apps": false}, + "mcp_servers": map[string]any{ + "local": map[string]any{"command": servers["local"].Command, "args": servers["local"].Args, "environment_id": "local", "enabled": true, "tool_timeout_sec": nil, "default_tools_approval_mode": "approve"}, + "remote": map[string]any{"url": remote.URL, "environment_id": "local", "enabled": true, "tool_timeout_sec": nil, "default_tools_approval_mode": "approve", "bearer_token_env_var": remote.BearerTokenEnvVar, "env_http_headers": remote.EnvHTTPHeaders}, + }, + }} + raw, _ := json.Marshal(fixture) + if !matchesMCPConfig(raw, servers) { + t.Fatal("qualified native projection rejected") + } + corrupt := strings.Replace(string(raw), "agents-api-runtime-initialize", "untrusted-launcher", 1) + if matchesMCPConfig(json.RawMessage(corrupt), servers) { + t.Fatal("different native launcher accepted") + } +} + +func TestEnvironmentMCPRejectsUnqualifiedNetworkAndCredentialChanges(t *testing.T) { + for _, access := range []string{"", "restricted", "disabled"} { + local := &proto.LocalEnvironment{NetworkAccess: access, MCP: []proto.EnvironmentMCP{{Server: agentplugin.MCPServer{Name: "local", Type: "stdio"}}}} + if _, _, err := mergeEnvironmentMCP(nil, local); err == nil { + t.Errorf("unqualified MCP network accepted: %s", access) + } + } + token := "user-token" + local := &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{{BearerToken: &token, Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "http://example.com/mcp"}}}} + if _, _, err := mergeEnvironmentMCP(nil, local); err == nil { + t.Fatal("plaintext bearer accepted") + } + local.MCP[0].Server.URL = "https://example.com/mcp" + if _, _, err := mergeEnvironmentMCP(map[string]mcpServerConfig{"remote": {}}, local); err == nil { + t.Fatal("service and environment identity collision accepted") + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http.go b/apps/parsar-daemon/internal/agent/codex/mcp_http.go index 8e6931c5a..69098b3ec 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_http.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_http.go @@ -52,7 +52,7 @@ func publicMCPHTTPServers(req proto.PromptRequestPayload) (map[string]mcpServerC return servers, nil } -func configureMCPHTTP(plan *SessionPlan, servers map[string]mcpServerConfig) error { +func configureMCP(plan *SessionPlan, servers map[string]mcpServerConfig) error { var codexHome string for _, entry := range plan.Env { if value, ok := strings.CutPrefix(entry, "CODEX_HOME="); ok { @@ -80,6 +80,6 @@ func configureMCPHTTP(plan *SessionPlan, servers map[string]mcpServerConfig) err } } plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"mcp_oauth_credentials_store", `"file"`}) - plan.mcpHTTPServers = servers + plan.mcpServers = servers return nil } diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http_bearer_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_http_bearer_test.go index 6e655eff4..22848f853 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_http_bearer_test.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_http_bearer_test.go @@ -39,7 +39,7 @@ func TestMCPHTTPBearerPlanSeparatesServersAndProcesses(t *testing.T) { } args, _ := json.Marshal(plan.ExtraConfig) for i, server := range servers[:2] { - ref := plan.mcpHTTPServers[server.ServerLabel].BearerTokenEnvVar + ref := plan.mcpServers[server.ServerLabel].BearerTokenEnvVar if !strings.HasPrefix(ref, "PARSAR_MCP_BEARER_") || seen[ref] || !slices.Contains(plan.Env, ref+"="+tokens[i]) { t.Fatal("missing exact per-server secret or reused native reference") } @@ -51,7 +51,7 @@ func TestMCPHTTPBearerPlanSeparatesServersAndProcesses(t *testing.T) { t.Fatal("secret reached configuration/arguments or reference was omitted") } } - if plan.mcpHTTPServers["public"].BearerTokenEnvVar != "" || strings.Count(string(config), "bearer_token_env_var") != 2 { + if plan.mcpServers["public"].BearerTokenEnvVar != "" || strings.Count(string(config), "bearer_token_env_var") != 2 { t.Fatal("credential-free server received authentication") } plan.Cleanup() @@ -97,7 +97,7 @@ func TestMCPHTTPBearerDoesNotReachModelCatalogProbe(t *testing.T) { t.Fatal("catalog probe inherited bearer or failed", err) } defer plan.Cleanup() - if !slices.Contains(plan.Env, plan.mcpHTTPServers["tools"].BearerTokenEnvVar+"="+token) { + if !slices.Contains(plan.Env, plan.mcpServers["tools"].BearerTokenEnvVar+"="+token) { t.Fatal("app-server did not receive bearer after catalog probe") } } @@ -128,7 +128,7 @@ func TestMCPHTTPBearerPreflightMatchesOnlyItsServerReference(t *testing.T) { first["http_headers_helper"] = "operator-helper" } raw, err := json.Marshal(response) - if err != nil || matchesMCPHTTPConfig(raw, servers) != (mutation == "none") { + if err != nil || matchesMCPConfig(raw, servers) != (mutation == "none") { t.Fatal("incorrect authenticated configuration decision", err) } }) diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight.go b/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight.go index 8fb917e53..7aeae62aa 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight.go @@ -11,7 +11,7 @@ import ( // config/read loads the same cwd and CLI layers without MCP discovery. Native // mcpServerStatus/list instead opens eager discovery connections; do not use it // to decide whether undeclared servers are safe to contact. -func verifyMCPHTTPConfig(ctx context.Context, rpc *JSONRPCClient, plan SessionPlan) error { +func verifyMCPConfig(ctx context.Context, rpc *JSONRPCClient, plan SessionPlan) error { check, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() raw, err := rpc.Request(check, "config/read", map[string]any{"cwd": plan.Cwd, "includeLayers": false}) @@ -19,13 +19,13 @@ func verifyMCPHTTPConfig(ctx context.Context, rpc *JSONRPCClient, plan SessionPl // Native configuration errors and responses can contain operator secrets. return errors.New("codex: cannot verify public MCP configuration") } - if !matchesMCPHTTPConfig(raw, plan.mcpHTTPServers) { + if !matchesMCPConfig(raw, plan.mcpServers) { return errors.New("codex: effective native MCP configuration differs from the public declaration") } return nil } -func matchesMCPHTTPConfig(raw json.RawMessage, declared map[string]mcpServerConfig) bool { +func matchesMCPConfig(raw json.RawMessage, declared map[string]mcpServerConfig) bool { var response struct { Config struct { Servers map[string]map[string]any `json:"mcp_servers"` @@ -42,12 +42,32 @@ func matchesMCPHTTPConfig(raw json.RawMessage, declared map[string]mcpServerConf } for name, expected := range declared { server, exists := config.Servers[name] - if !exists || server["url"] != expected.URL || server["environment_id"] != "local" || server["enabled"] != true { + if !exists || server["environment_id"] != "local" || server["enabled"] != true { return false } - delete(server, "url") + if expected.URL != "" { + if server["url"] != expected.URL || !matchesMCPHeaderMap(server, "http_headers", expected.Headers) || + !matchesMCPHeaderMap(server, "env_http_headers", expected.EnvHTTPHeaders) { + return false + } + delete(server, "url") + } else { + args, err := json.Marshal(expected.Args) + var want any + if err != nil || json.Unmarshal(args, &want) != nil || server["command"] != expected.Command || !reflect.DeepEqual(server["args"], want) { + return false + } + delete(server, "command") + delete(server, "args") + } delete(server, "environment_id") delete(server, "enabled") + if expected.ApproveTools { + if server["default_tools_approval_mode"] != "approve" { + return false + } + delete(server, "default_tools_approval_mode") + } if expected.Required { if server["required"] != true { return false @@ -102,3 +122,19 @@ func matchesMCPHTTPConfig(raw json.RawMessage, declared map[string]mcpServerConf } return true } + +func matchesMCPHeaderMap(server map[string]any, field string, expected map[string]string) bool { + actual, present := server[field] + if len(expected) == 0 { + return !present + } + want := make(map[string]any, len(expected)) + for key, value := range expected { + want[key] = value + } + if !reflect.DeepEqual(actual, want) { + return false + } + delete(server, field) + return true +} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight_test.go index bcc43e300..012e84451 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight_test.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight_test.go @@ -53,20 +53,20 @@ func TestPublicMCPHTTPEffectiveConfiguration(t *testing.T) { if err != nil { t.Fatal(err) } - if matchesMCPHTTPConfig(data, servers) != (mutation == "none") { + if matchesMCPConfig(data, servers) != (mutation == "none") { t.Fatal("effective configuration decision differed", mutation) } }) } } for _, raw := range []string{`null`, `{}`, `{"config":{"mcp_servers":[]}}`, `not json`} { - if matchesMCPHTTPConfig(json.RawMessage(raw), map[string]mcpServerConfig{}) { + if matchesMCPConfig(json.RawMessage(raw), map[string]mcpServerConfig{}) { t.Fatal("missing or malformed native proof accepted") } } empty := map[string]mcpServerConfig{} data, _ := json.Marshal(mcpHTTPConfigResponse(empty)) - if !matchesMCPHTTPConfig(data, empty) { + if !matchesMCPConfig(data, empty) { t.Fatal("explicit empty declaration rejected") } } @@ -91,7 +91,7 @@ func TestPublicMCPHTTPRequiredConfigurationCannotBeWeakened(t *testing.T) { delete(server, "required") } raw, err := json.Marshal(response) - if err != nil || matchesMCPHTTPConfig(raw, servers) != (value == true) { + if err != nil || matchesMCPConfig(raw, servers) != (value == true) { t.Fatal("required initialization was weakened or rejected", value, err) } } @@ -102,7 +102,7 @@ func TestPublicMCPHTTPPreflightRedactsNativeErrors(t *testing.T) { defer cleanup() done := make(chan error, 1) go func() { - done <- verifyMCPHTTPConfig(t.Context(), client.JSONRPCClient, SessionPlan{Cwd: "/private/workspace"}) + done <- verifyMCPConfig(t.Context(), client.JSONRPCClient, SessionPlan{Cwd: "/private/workspace"}) }() var req struct { ID string `json:"id"` diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_http_test.go index 893956936..8ba1b4de7 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_http_test.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_http_test.go @@ -57,7 +57,7 @@ func TestPublicMCPHTTPPlanOwnsConfigurationAndPreservesHistory(t *testing.T) { t.Fatal("operator MCP was rendered") } tools[0] = "mutated" - if (*plan.mcpHTTPServers["docs.server"].EnabledTools)[0] != "lookup.docs" { + if (*plan.mcpServers["docs.server"].EnabledTools)[0] != "lookup.docs" { t.Fatal("prepared allowlist retained caller-owned memory") } history := filepath.Join(home, "retained-history.jsonl") diff --git a/apps/parsar-daemon/internal/agent/codex/options.go b/apps/parsar-daemon/internal/agent/codex/options.go index 8f71ccd4c..2a8408a22 100644 --- a/apps/parsar-daemon/internal/agent/codex/options.go +++ b/apps/parsar-daemon/internal/agent/codex/options.go @@ -39,8 +39,8 @@ type SessionPlan struct { EnableFeatures []string DisableFeatures []string - // Non-nil for typed service-side HTTP MCP, including private bearer references. - mcpHTTPServers map[string]mcpServerConfig + // Non-nil for declared service or Environment MCP, including private references. + mcpServers map[string]mcpServerConfig // Model is the slug to request on thread/start. Empty inherits the // codex.config.toml default. diff --git a/apps/parsar-daemon/internal/agent/codex/preparation.go b/apps/parsar-daemon/internal/agent/codex/preparation.go index 081719c30..eb25bee4e 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation.go @@ -161,8 +161,8 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg return p.preparationFailed(err) } } - if plan.mcpHTTPServers != nil { - if err := verifyMCPHTTPConfig(cancelCtx, rpc, plan); err != nil { + if plan.mcpServers != nil { + if err := verifyMCPConfig(cancelCtx, rpc, plan); err != nil { cancelFn() _ = rpc.Close() plan.Cleanup() diff --git a/apps/parsar-daemon/internal/agent/codex/session_items.go b/apps/parsar-daemon/internal/agent/codex/session_items.go index 140816eb3..f0b88a853 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_items.go +++ b/apps/parsar-daemon/internal/agent/codex/session_items.go @@ -16,8 +16,8 @@ import ( // (TypeDelta / TypeThinking), and completed-item bodies anchor the // final text for the done event. type ItemBuffers struct { - Reasoning map[string]string - AgentText map[string]string + Reasoning map[string]string + AgentText map[string]string } // NewItemBuffers returns an empty buffer set. diff --git a/apps/parsar-daemon/internal/agent/codex/session_plan.go b/apps/parsar-daemon/internal/agent/codex/session_plan.go index 122d3eb8a..a4e556e55 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_plan.go +++ b/apps/parsar-daemon/internal/agent/codex/session_plan.go @@ -54,8 +54,13 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg disableSubagents(&plan) } mcpBearerEnv := prepareMCPHTTPBearer(mcpServers, req.MCPHTTPServers) + mcpServers, environmentMCPEnv, err := mergeEnvironmentMCP(mcpServers, req.LocalEnvironment) + if err != nil { + plan.Cleanup() + return SessionPlan{}, nil, err + } if mcpServers != nil { - if err := configureMCPHTTP(&plan, mcpServers); err != nil { + if err := configureMCP(&plan, mcpServers); err != nil { plan.Cleanup() return SessionPlan{}, nil, err } @@ -95,6 +100,7 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg configureRemoteEnvironment(&plan, *req.RemoteEnvironment) } plan.Env = append(plan.Env, mcpBearerEnv...) + plan.Env = append(plan.Env, environmentMCPEnv...) if cfg.runtimeNetwork.Access == "restricted" { if err := prepareManagedNetwork(&plan, cfg.runtimeNetwork); err != nil { plan.Cleanup() diff --git a/apps/parsar-daemon/internal/agent/codex/session_thread.go b/apps/parsar-daemon/internal/agent/codex/session_thread.go index 3d7898be3..6b466caec 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_thread.go +++ b/apps/parsar-daemon/internal/agent/codex/session_thread.go @@ -39,7 +39,7 @@ func (s *Session) resumeThread(threadID string, plan SessionPlan) error { Permissions: plan.Permissions, DeveloperInstructions: plan.SystemPrompt, } - if plan.mcpHTTPServers != nil { + if plan.mcpServers != nil { // Resolve the same project configuration checked before native startup. params.Cwd = plan.Cwd } diff --git a/apps/parsar-daemon/internal/agent/mcode/environment_mcp.go b/apps/parsar-daemon/internal/agent/mcode/environment_mcp.go new file mode 100644 index 000000000..3922d0ac3 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/environment_mcp.go @@ -0,0 +1,38 @@ +package mcode + +import ( + "fmt" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func environmentMCP(local *proto.LocalEnvironment) ([]map[string]any, error) { + if local == nil || len(local.MCP) == 0 { + return nil, nil + } + if local.NetworkAccess != "enabled" { + return nil, fmt.Errorf("mcode: environment MCP requires enabled network") + } + servers := make([]map[string]any, 0, len(local.MCP)) + names := map[string]bool{"parsar_workspace": true} + for _, item := range local.MCP { + name := item.Server.Name + if name == "" || strings.TrimSpace(name) != name || names[name] { + return nil, fmt.Errorf("mcode: ambiguous or unsupported environment MCP identity") + } + names[name] = true + if item.Server.Type == "http" { + return nil, fmt.Errorf("mcode: environment HTTP MCP is not qualified") + } + if item.Server.Type != "stdio" || item.BearerToken != nil { + return nil, fmt.Errorf("mcode: unsupported environment MCP transport") + } + command, args := localworkspace.MCPStdioCommand(item) + // The fixed launcher resolves the declaration and selected user variables + // inside the sandbox. Native process variables are never tool input. + servers = append(servers, map[string]any{"name": name, "command": command, "args": args, "env": []map[string]string{}}) + } + return servers, nil +} diff --git a/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go b/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go new file mode 100644 index 000000000..cc82cb780 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go @@ -0,0 +1,163 @@ +package mcode + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" +) + +func environmentMCPFixture() proto.EnvironmentMCP { + return proto.EnvironmentMCP{PackageRoot: "plugins/fixture", Server: agentplugin.MCPServer{ + Name: "proof.server", Type: "stdio", Command: "never-exec-before-sandbox", Args: []string{"private-argument"}, + EnvVars: []string{"USER_SELECTED"}, CWD: "resources", + }} +} + +func TestEnvironmentMCPUsesFixedLauncherForNewAndLoadedSessions(t *testing.T) { + for _, resume := range []bool{false, true} { + t.Run(map[bool]string{false: "new", true: "load"}[resume], func(t *testing.T) { + c, req, record := workspaceFixture(t) + c.Network, req.LocalEnvironment.NetworkAccess = "enabled", "enabled" + req.LocalEnvironment.MCP = []proto.EnvironmentMCP{environmentMCPFixture()} + if resume { + req.AgentSessionID = "native-1" + } + t.Setenv("USER_SELECTED", "must-not-resolve-from-daemon") + t.Setenv("MODEL_SECRET", "must-not-forward") + resource, err := NewPreparationFactory(c)(t.Context(), req) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = resource.Close() }) + raw, err := os.ReadFile(record + ".session") + if err != nil { + t.Fatal(err) + } + var params struct { + SessionID string `json:"sessionId"` + MCP []struct { + Name, Command string + Args []string + Env []map[string]string + } `json:"mcpServers"` + } + if json.Unmarshal(raw, ¶ms) != nil || len(params.MCP) != 2 || params.MCP[0].Name != "parsar_workspace" { + t.Fatal("environment MCP displaced workspace tools") + } + server := params.MCP[1] + if server.Name != "proof.server" || server.Command != "/usr/bin/python3" || server.Env == nil || len(server.Env) != 0 || + !reflect.DeepEqual(server.Args, []string{"-I", "-S", "/usr/local/bin/agents-api-runtime-initialize", "stdio", "plugins/fixture", "proof.server"}) { + t.Fatal("ACP declaration bypassed the fixed isolated launcher") + } + if (params.SessionID != "") != resume || strings.Contains(string(raw), "must-not") || strings.Contains(string(raw), "private-argument") { + t.Fatal("native attachment changed identity or exposed private inputs") + } + }) + } +} + +func TestEnvironmentMCPRejectsUnqualifiedAuthorityBeforePreparation(t *testing.T) { + for _, name := range []string{"http", "http-bearer", "restricted", "disabled", "duplicate", "reserved"} { + t.Run(name, func(t *testing.T) { + c, req, _ := workspaceFixture(t) + c.Network, req.LocalEnvironment.NetworkAccess = "enabled", "enabled" + req.LocalEnvironment.MCP = []proto.EnvironmentMCP{environmentMCPFixture()} + switch name { + case "http", "http-bearer": + req.LocalEnvironment.MCP[0].Server = agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp"} + if name == "http-bearer" { + token := "confidential-http-token" + req.LocalEnvironment.MCP[0].BearerToken = &token + } + case "restricted", "disabled": + c.Network, req.LocalEnvironment.NetworkAccess = name, name + case "duplicate": + req.LocalEnvironment.MCP = append(req.LocalEnvironment.MCP, environmentMCPFixture()) + case "reserved": + req.LocalEnvironment.MCP[0].Server.Name = "parsar_workspace" + } + if _, err := prepareWorkspaceOptions(t.Context(), c, req); err == nil || strings.Contains(err.Error(), "confidential-http-token") { + t.Fatal("unqualified declaration accepted or credential exposed") + } + }) + } +} + +func TestEnvironmentMCPCancelSettlesPendingObservationBeforeDone(t *testing.T) { + c, req, _ := workspaceFixture(t) + c.Network, req.LocalEnvironment.NetworkAccess = "enabled", "enabled" + req.LocalEnvironment.MCP = []proto.EnvironmentMCP{environmentMCPFixture()} + req.ObserveToolObservations = true + script, err := os.ReadFile(c.Binary) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(c.Binary, []byte(strings.Replace(string(script), "HELPER=prepared", "HELPER=prepared-mcp-cancel", 1)), 0700); err != nil { + t.Fatal(err) + } + resource, err := NewPreparationFactory(c)(t.Context(), req) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + out := make(chan proto.Envelope, 16) + session, err := resource.Start(ctx, "run", "invoke and wait", out) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = session.Cancel(context.Background()) }) + select { + case event := <-out: + var call proto.ToolCallPayload + if event.Type != proto.TypeToolCall || json.Unmarshal(event.Payload, &call) != nil || call.Stage != "before" || call.Observation == nil || call.Observation.Server != "proof.server" { + t.Fatal("real-time MCP start missing", event.Type) + } + case <-ctx.Done(): + t.Fatal("MCP start was not observed") + } + if err := session.Cancel(ctx); err != nil { + t.Fatal(err) + } + closed, done := false, false + for event := range out { + if event.Type == proto.TypeToolCall { + var call proto.ToolCallPayload + _ = json.Unmarshal(event.Payload, &call) + if call.ID != "native-call" || call.Stage != "after" || call.Observation.Status != "incomplete" { + t.Fatal("cancel lost pending MCP identity or status") + } + closed = true + } + if event.Type == proto.TypeDone { + if !closed { + t.Fatal("Done preceded incomplete MCP observation") + } + done = true + } + } + if !closed || !done { + t.Fatal("cancellation did not settle observations") + } +} + +func writeMCPRegistry(path string, entries ...map[string]any) error { + raw, err := json.Marshal(map[string]any{"version": 1, "servers": entries}) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(path, "mcp-runtime-names.json"), raw, 0600) +} + +func mcpRegistryEntry(server, segment, tool, toolSegment string) map[string]any { + key, _ := json.Marshal([]string{"configured", server}) + return map[string]any{"key": string(key), "raw": server, "segment": segment, "tools": []map[string]string{{"raw": tool, "segment": toolSegment}}} +} diff --git a/apps/parsar-daemon/internal/agent/mcode/events.go b/apps/parsar-daemon/internal/agent/mcode/events.go index 5a6b83e2a..0f6a4751a 100644 --- a/apps/parsar-daemon/internal/agent/mcode/events.go +++ b/apps/parsar-daemon/internal/agent/mcode/events.go @@ -1,6 +1,7 @@ package mcode import ( + "bytes" "encoding/json" "fmt" "net/url" @@ -25,7 +26,9 @@ func (s *Session) handle(frame rpcFrame) error { return nil } var event sessionUpdate - if err := json.Unmarshal(frame.Params, &event); err != nil { + decoder := json.NewDecoder(bytes.NewReader(frame.Params)) + decoder.UseNumber() + if err := decoder.Decode(&event); err != nil { return fmt.Errorf("mcode: invalid session update") } if event.SessionID != s.sessionID { @@ -47,14 +50,14 @@ func (s *Session) handle(frame rpcFrame) error { s.sequence++ s.emit(proto.TypeThinking, proto.ThinkingPayload{Text: event.Update.Content.Text, Sequence: s.sequence}) case "tool_call", "tool_call_update": - s.emitTool(event.Update.toolUpdate) + return s.emitTool(event.Update.toolUpdate) } return nil } -func (s *Session) emitTool(update toolUpdate) { +func (s *Session) emitTool(update toolUpdate) error { if update.ID == "" || s.completedTools[update.ID] { - return + return nil } previous, started := s.tools[update.ID] if update.Name == "" { @@ -67,18 +70,33 @@ func (s *Session) emitTool(update toolUpdate) { update.RawInput = previous.RawInput } if s.req.ObserveToolObservations { - started = workspaceToolObservation(previous, "before") != nil + update.mcp = previous.mcp + if update.mcp != nil && update.Name != previous.Name { + return fmt.Errorf("mcode: native MCP call identity changed") + } + if update.mcp == nil { + var err error + update.mcp, err = s.environmentMCPIdentity(update.Name) + if err != nil { + return err + } + } + started = previous.mcp != nil || workspaceToolObservation(previous, "before") != nil } if !started { - s.emitToolStage(update, "before") + if err := s.emitToolStage(update, "before"); err != nil { + return err + } } + s.tools[update.ID] = update if update.Status == "completed" || update.Status == "failed" { - s.emitToolStage(update, "after") + if err := s.emitToolStage(update, "after"); err != nil { + return err + } delete(s.tools, update.ID) s.completedTools[update.ID] = true - } else { - s.tools[update.ID] = update } + return nil } func (s *Session) askPermission(frame rpcFrame) error { diff --git a/apps/parsar-daemon/internal/agent/mcode/mcp_observations.go b/apps/parsar-daemon/internal/agent/mcode/mcp_observations.go new file mode 100644 index 000000000..fe8196dcb --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/mcp_observations.go @@ -0,0 +1,132 @@ +package mcode + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type mcpToolIdentity struct{ server, tool string } + +// MiniMax 0.4.12 atomically writes these assignments before exposing tools. +// Read its exact mapping instead of reversing lossy native name normalization. +func (s *Session) environmentMCPIdentity(name string) (*mcpToolIdentity, error) { + if s.req.LocalEnvironment == nil || len(s.req.LocalEnvironment.MCP) == 0 || + !strings.HasPrefix(name, "mcp__") || strings.HasPrefix(name, "mcp__parsar_workspace__") { + return nil, nil + } + raw, err := os.ReadFile(filepath.Join(s.opts.DataDir, "mcp-runtime-names.json")) + if err != nil { + return nil, fmt.Errorf("mcode: native MCP identity registry unavailable") + } + var registry struct { + Version int `json:"version"` + Servers []struct { + Key, Raw, Segment string + Tools []struct{ Raw, Segment string } + } `json:"servers"` + } + if json.Unmarshal(raw, ®istry) != nil || registry.Version != 1 { + return nil, fmt.Errorf("mcode: invalid native MCP identity registry") + } + var found *mcpToolIdentity + for _, server := range registry.Servers { + for _, tool := range server.Tools { + if "mcp__"+server.Segment+"__"+tool.Segment != name { + continue + } + var key []string + declared := 0 + for _, item := range s.req.LocalEnvironment.MCP { + if item.Server.Name == server.Raw && item.Server.Type == "stdio" { + declared++ + } + } + if found != nil || declared != 1 || server.Raw == "parsar_workspace" || tool.Raw == "" || + json.Unmarshal([]byte(server.Key), &key) != nil || len(key) != 2 || key[0] != "configured" || key[1] != server.Raw { + return nil, fmt.Errorf("mcode: ambiguous or undeclared native MCP identity") + } + found = &mcpToolIdentity{server: server.Raw, tool: tool.Raw} + } + } + if found == nil { + return nil, fmt.Errorf("mcode: native MCP identity is missing") + } + return found, nil +} + +func environmentMCPObservation(update toolUpdate, stage string) (*proto.ToolObservation, error) { + if update.mcp == nil { + return nil, nil + } + arguments, err := json.Marshal(update.RawInput) + if err != nil { + return nil, fmt.Errorf("mcode: invalid native MCP arguments") + } + n := &proto.ToolObservation{Kind: "mcp", Status: "in_progress", Server: update.mcp.server, Name: update.mcp.tool, + Arguments: arguments, Output: json.RawMessage("null"), Error: json.RawMessage("null")} + if stage == "before" { + return n, nil + } + n.Status = update.Status + if update.Status == "incomplete" { + return n, nil + } + raw, err := json.Marshal(update.RawOutput) + var output struct { + Details *struct { + Server string `json:"server"` + Tool string `json:"tool"` + MCP json.RawMessage `json:"mcp"` + IsError bool `json:"is_error"` + } `json:"details"` + } + if err != nil || json.Unmarshal(raw, &output) != nil { + return nil, fmt.Errorf("mcode: invalid native MCP result") + } + if output.Details == nil && update.Status == "failed" { + // Transport failures may have no MCP response. The start registry still + // identifies the real call, so retain the native failure without guessing. + n.Error = raw + return n, nil + } + if output.Details == nil || output.Details.Server != n.Server || output.Details.Tool != n.Name || + len(output.Details.MCP) == 0 || string(output.Details.MCP) == "null" { + return nil, fmt.Errorf("mcode: native MCP result identity does not match its call") + } + n.Output = output.Details.MCP + var result struct { + IsError bool `json:"isError"` + } + if json.Unmarshal(n.Output, &result) != nil { + return nil, fmt.Errorf("mcode: invalid native MCP content") + } + if result.IsError || output.Details.IsError { + n.Status = "failed" + } + return n, nil +} + +// The existing Session owner calls this after native settlement. No pending +// declared call disappears merely because cancellation omitted a result frame. +func (s *Session) finishEnvironmentMCP() { + ids := make([]string, 0, len(s.tools)) + for id, call := range s.tools { + if call.mcp != nil { + ids = append(ids, id) + } + } + slices.Sort(ids) + for _, id := range ids { + call := s.tools[id] + call.Status, call.RawOutput = "incomplete", nil + _ = s.emitToolStage(call, "after") + delete(s.tools, id) + s.completedTools[id] = true + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/mcp_observations_test.go b/apps/parsar-daemon/internal/agent/mcode/mcp_observations_test.go new file mode 100644 index 000000000..ed6b32aa9 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/mcp_observations_test.go @@ -0,0 +1,187 @@ +package mcode + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func mcpObservationSession(t *testing.T) (*Session, chan proto.Envelope) { + t.Helper() + out := make(chan proto.Envelope, 16) + s := &Session{ctx: context.Background(), opts: launchOptions{DataDir: t.TempDir()}, + req: proto.PromptRequestPayload{RunID: "run", ObserveToolObservations: true, + LocalEnvironment: &proto.LocalEnvironment{MCP: []proto.EnvironmentMCP{environmentMCPFixture()}}}, + out: out, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}, active: true, sessionID: "native-session"} + if err := writeMCPRegistry(s.opts.DataDir, mcpRegistryEntry("proof.server", "proof_server_2", "read.status", "read_status_2")); err != nil { + t.Fatal(err) + } + return s, out +} + +func receiveMCPObservation(t *testing.T, out chan proto.Envelope, stage, status string) *proto.ToolObservation { + t.Helper() + if len(out) == 0 { + t.Fatal("MCP observation was not emitted immediately") + } + var call proto.ToolCallPayload + if json.Unmarshal((<-out).Payload, &call) != nil || call.ID != "native-call" || call.Stage != stage || call.Observation == nil { + t.Fatal("MCP native identity or stage lost") + } + n := call.Observation + if n.Kind != "mcp" || n.Server != "proof.server" || n.Name != "read.status" || n.Status != status { + t.Fatalf("unexpected MCP observation: %+v", n) + } + return n +} + +func mcpNativeResult(server, tool string, isError bool) map[string]any { + return map[string]any{"details": map[string]any{"server": server, "tool": tool, "mcp": map[string]any{ + "content": []map[string]string{{"type": "text", "text": "native result"}}, "isError": isError, + }}} +} + +func TestEnvironmentMCPUsesNativeRegistryBeforeResultAndRetainsErrors(t *testing.T) { + for _, failure := range []string{"none", "tool", "transport"} { + t.Run(failure, func(t *testing.T) { + s, out := mcpObservationSession(t) + if err := s.emitTool(toolUpdate{ID: "native-call", Name: "mcp__proof_server_2__read_status_2"}); err != nil { + t.Fatal(err) + } + receiveMCPObservation(t, out, "before", "in_progress") + // Native startup may precede complete arguments; identity is already + // authoritative without trying to reverse either collision suffix. + if err := s.emitTool(toolUpdate{ID: "native-call", Status: "in_progress", RawInput: map[string]any{"value": 7}}); err != nil || len(out) != 0 { + t.Fatal("duplicate start or rejected argument update", err) + } + update := toolUpdate{ID: "native-call", Status: "completed", RawOutput: mcpNativeResult("proof.server", "read.status", failure == "tool")} + status := "completed" + if failure != "none" { + status = "failed" + } + if failure == "transport" { + update.Status, update.RawOutput = "failed", map[string]any{"error": "native transport failed"} + } + if err := s.emitTool(update); err != nil { + t.Fatal(err) + } + n := receiveMCPObservation(t, out, "after", status) + if string(n.Arguments) != `{"value":7}` || (failure == "transport" && !strings.Contains(string(n.Error), "native transport failed")) || + (failure != "transport" && !strings.Contains(string(n.Output), "native result")) { + t.Fatal("native arguments, content or transport failure lost") + } + if err := s.emitTool(update); err != nil || len(out) != 0 { + t.Fatal("duplicate completion replayed") + } + }) + } +} + +func TestEnvironmentMCPIdentityRequiresUniqueCurrentConfiguredAssignment(t *testing.T) { + for _, mutation := range []string{"missing-file", "missing-tool", "ambiguous", "foreign-server", "plugin-key", "wrong-key", "bad-version", "invalid-json"} { + t.Run(mutation, func(t *testing.T) { + s, out := mcpObservationSession(t) + entry := mcpRegistryEntry("proof.server", "proof_server_2", "read.status", "read_status_2") + path := filepath.Join(s.opts.DataDir, "mcp-runtime-names.json") + var err error + switch mutation { + case "missing-file": + err = os.Remove(path) + case "missing-tool": + err = writeMCPRegistry(s.opts.DataDir) + case "ambiguous": + err = writeMCPRegistry(s.opts.DataDir, entry, entry) + case "foreign-server": + err = writeMCPRegistry(s.opts.DataDir, mcpRegistryEntry("foreign", "proof_server_2", "read.status", "read_status_2")) + case "plugin-key", "wrong-key": + entry["key"] = `["plugin","proof.server"]` + if mutation == "wrong-key" { + entry["key"] = `["configured","foreign"]` + } + err = writeMCPRegistry(s.opts.DataDir, entry) + case "bad-version": + err = os.WriteFile(path, []byte(`{"version":2,"servers":[]}`), 0600) + case "invalid-json": + err = os.WriteFile(path, []byte(`private-invalid-registry`), 0600) + } + if err != nil { + t.Fatal(err) + } + if err := s.emitTool(toolUpdate{ID: "native-call", Name: "mcp__proof_server_2__read_status_2"}); err == nil || strings.Contains(err.Error(), "private-invalid-registry") || len(out) != 0 { + t.Fatal("invalid registry became a public MCP call or leaked raw data") + } + }) + } +} + +func TestEnvironmentMCPResultMustMatchStartAndUnsettledCallsCloseOnce(t *testing.T) { + for _, change := range []string{"server", "tool", "missing-details", "native-name", "cancel"} { + t.Run(change, func(t *testing.T) { + s, out := mcpObservationSession(t) + if err := s.emitTool(toolUpdate{ID: "native-call", Name: "mcp__proof_server_2__read_status_2", RawInput: map[string]any{"key": "value"}}); err != nil { + t.Fatal(err) + } + receiveMCPObservation(t, out, "before", "in_progress") + update := toolUpdate{ID: "native-call", Status: "completed", RawOutput: mcpNativeResult("proof.server", "read.status", false)} + switch change { + case "server": + update.RawOutput = mcpNativeResult("foreign", "read.status", false) + case "tool": + update.RawOutput = mcpNativeResult("proof.server", "other", false) + case "missing-details": + update.RawOutput = map[string]any{"content": "cannot-establish-identity"} + case "native-name": + update.Name = "mcp__proof_server_2__other" + } + if change != "cancel" { + if err := s.emitTool(update); err == nil || len(out) != 0 { + t.Fatal("inconsistent result was accepted") + } + } + s.finishEnvironmentMCP() + n := receiveMCPObservation(t, out, "after", "incomplete") + if string(n.Arguments) != `{"key":"value"}` || string(n.Output) != "null" { + t.Fatal("incomplete call lost arguments or invented output") + } + s.finishEnvironmentMCP() + if len(out) != 0 { + t.Fatal("incomplete observation duplicated") + } + }) + } +} + +func TestEnvironmentMCPDoesNotPublishInternalWorkspaceUtilities(t *testing.T) { + s, out := mcpObservationSession(t) + for _, name := range []string{"workspace_read", "workspace_write", "workspace_edit", "workspace_glob", "workspace_grep"} { + if err := s.emitTool(toolUpdate{ID: name, Name: "mcp__parsar_workspace__" + name, Status: "completed"}); err != nil { + t.Fatal(err) + } + } + if len(out) != 0 { + t.Fatal("internal workspace utility became a public MCP call") + } +} + +func TestEnvironmentMCPNativeJSONRetainsIntegerPrecision(t *testing.T) { + s, out := mcpObservationSession(t) + frames := []string{ + `{"sessionId":"native-session","update":{"sessionUpdate":"tool_call","toolCallId":"native-call","name":"mcp__proof_server_2__read_status_2","rawInput":{"value":9007199254740993}}}`, + `{"sessionId":"native-session","update":{"sessionUpdate":"tool_call_update","toolCallId":"native-call","status":"completed","rawOutput":{"details":{"server":"proof.server","tool":"read.status","mcp":{"structuredContent":{"value":9007199254740993},"content":[],"isError":false}}}}}`, + } + for _, raw := range frames { + if err := s.handle(rpcFrame{Method: "session/update", Params: json.RawMessage(raw)}); err != nil { + t.Fatal(err) + } + } + before := receiveMCPObservation(t, out, "before", "in_progress") + after := receiveMCPObservation(t, out, "after", "completed") + if !strings.Contains(string(before.Arguments), "9007199254740993") || !strings.Contains(string(after.Output), "9007199254740993") { + t.Fatal("MCP structured number rounded by native observation decoding") + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/protocol.go b/apps/parsar-daemon/internal/agent/mcode/protocol.go index cba538344..95c9f629a 100644 --- a/apps/parsar-daemon/internal/agent/mcode/protocol.go +++ b/apps/parsar-daemon/internal/agent/mcode/protocol.go @@ -30,6 +30,7 @@ type configOption struct { } type toolUpdate struct { + mcp *mcpToolIdentity ID string `json:"toolCallId"` Name string `json:"name"` Title string `json:"title"` diff --git a/apps/parsar-daemon/internal/agent/mcode/session.go b/apps/parsar-daemon/internal/agent/mcode/session.go index ffe148782..08d74a625 100644 --- a/apps/parsar-daemon/internal/agent/mcode/session.go +++ b/apps/parsar-daemon/internal/agent/mcode/session.go @@ -142,6 +142,7 @@ func (s *Session) run(p *prepared) { s.process.Cancel() <-s.exited } + s.finishEnvironmentMCP() if s.out == nil { return } diff --git a/apps/parsar-daemon/internal/agent/mcode/session_test.go b/apps/parsar-daemon/internal/agent/mcode/session_test.go index 06a0d46f9..1240966c8 100644 --- a/apps/parsar-daemon/internal/agent/mcode/session_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/session_test.go @@ -220,12 +220,22 @@ func TestMCodeProcess(t *testing.T) { } _, _ = f.WriteString(frame.Method + "\n") _ = f.Close() + if frame.Method == "session/new" || frame.Method == "session/load" { + if os.WriteFile(record+".session", frame.Params, 0600) != nil { + os.Exit(11) + } + } } result := any(map[string]any{}) switch frame.Method { case "initialize": result = map[string]int{"protocolVersion": 1} case "session/new", "session/load": + if scenario == "prepared-mcp-cancel" { + if writeMCPRegistry(os.Getenv("MINIMAX_DATA_DIR"), mcpRegistryEntry("proof.server", "proof_server", "read.status", "read_status")) != nil { + os.Exit(12) + } + } if frame.Method == "session/load" { update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "OLD HISTORY"}}) } @@ -248,9 +258,13 @@ func TestMCodeProcess(t *testing.T) { Prompt []map[string]string `json:"prompt"` } _ = json.Unmarshal(frame.Params, &input) - if strict := scenario == "strict-cancel" || scenario == "prepared"; (strict && len(input.Prompt) != 2) || (!strict && len(input.Prompt) != 1) { + if strict := scenario == "strict-cancel" || strings.HasPrefix(scenario, "prepared"); (strict && len(input.Prompt) != 2) || (!strict && len(input.Prompt) != 1) { os.Exit(9) } + if scenario == "prepared-mcp-cancel" { + update("tool_call", map[string]any{"toolCallId": "native-call", "name": "mcp__proof_server__read_status", "status": "in_progress", "rawInput": map[string]any{}}) + continue + } if scenario == "steering" || scenario == "steer-rejected" || scenario == "steer-lost" || scenario == "strict-cancel" { promptID = frame.ID update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "ready"}}) diff --git a/apps/parsar-daemon/internal/agent/mcode/tool_observations.go b/apps/parsar-daemon/internal/agent/mcode/tool_observations.go index d48b386bb..bfdb24100 100644 --- a/apps/parsar-daemon/internal/agent/mcode/tool_observations.go +++ b/apps/parsar-daemon/internal/agent/mcode/tool_observations.go @@ -6,19 +6,27 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) -func (s *Session) emitToolStage(update toolUpdate, stage string) { +func (s *Session) emitToolStage(update toolUpdate, stage string) error { payload := proto.ToolCallPayload{ID: update.ID, Name: update.Name, Stage: stage, Args: update.RawInput} if stage == "after" { payload.Result = map[string]any{"output": update.RawOutput, "status": update.Status} } if s.req.ObserveToolObservations { payload.Observation = workspaceToolObservation(update, stage) + if payload.Observation == nil { + var err error + payload.Observation, err = environmentMCPObservation(update, stage) + if err != nil { + return err + } + } // Native task/skill bookkeeping has no qualified public item mapping. if payload.Observation == nil { - return + return nil } } s.emit(proto.TypeToolCall, payload) + return nil } func workspaceToolObservation(update toolUpdate, stage string) *proto.ToolObservation { diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace.go b/apps/parsar-daemon/internal/agent/mcode/workspace.go index 75ab13151..2b7be21af 100644 --- a/apps/parsar-daemon/internal/agent/mcode/workspace.go +++ b/apps/parsar-daemon/internal/agent/mcode/workspace.go @@ -54,6 +54,10 @@ func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.P if !req.StrictResume || req.LocalEnvironment == nil || req.WorkDir != c.Directory || req.DisableExecutionEnvironment || !(agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Equal(agentnetwork.Policy{Access: req.LocalEnvironment.NetworkAccess, AllowedDomains: req.LocalEnvironment.AllowedDomains}) || req.RemoteEnvironment != nil || req.WorkspaceReadOnly { return launchOptions{}, fmt.Errorf("mcode: execution does not match the dedicated workspace") } + servers, err := environmentMCP(req.LocalEnvironment) + if err != nil { + return launchOptions{}, err + } // Reuse public option validation and private Session state provisioning. Native // cwd remains private; only the internal MCP worker receives the public workspace. private := req @@ -120,5 +124,6 @@ func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.P return opts, err } opts.MCP = []map[string]any{{"name": "parsar_workspace", "command": c.Node, "args": []string{c.Bridge, path}, "env": []map[string]string{}}} + opts.MCP = append(opts.MCP, servers...) return opts, nil } diff --git a/apps/parsar-daemon/internal/cli/root.go b/apps/parsar-daemon/internal/cli/root.go index 128b6720d..2719d2d36 100644 --- a/apps/parsar-daemon/internal/cli/root.go +++ b/apps/parsar-daemon/internal/cli/root.go @@ -34,6 +34,7 @@ func defaultRunContext() *runContext { // likely flow connect → status → stop / logs → logout. var commands = []command{ {name: "runtime-capabilities", summary: "Install frozen capabilities in the packaged Runtime", run: runRuntimeCapabilities}, + {name: "runtime-mcp-exec", summary: "Execute installed MCP inside the packaged Runtime sandbox", run: runRuntimeMCP}, {name: "placement", summary: "Enroll or retire an explicitly managed local execution placement", run: runPlacement}, {name: "connect", summary: "Pair, open the reverse WebSocket, and start serving prompts", run: runConnect}, {name: "status", summary: "Print the paired profile and daemon state", run: runStatus}, diff --git a/apps/parsar-daemon/internal/cli/root_test.go b/apps/parsar-daemon/internal/cli/root_test.go index 250fb6680..ffa12cdbf 100644 --- a/apps/parsar-daemon/internal/cli/root_test.go +++ b/apps/parsar-daemon/internal/cli/root_test.go @@ -61,6 +61,7 @@ func TestSubcommandsAreRegistered(t *testing.T) { // the public CLI surface is the shipped contract. want := map[string]bool{ "runtime-capabilities": false, + "runtime-mcp-exec": false, "placement": false, "connect": false, "status": false, diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp.go b/apps/parsar-daemon/internal/cli/runtime_mcp.go new file mode 100644 index 000000000..b8f6e7483 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/runtime_mcp.go @@ -0,0 +1,85 @@ +package cli + +import ( + "errors" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" +) + +var errRuntimeMCP = errors.New("environment MCP unavailable") + +type mcpInvocation struct { + command string + args []string + cwd string + env []string +} + +// runRuntimeMCP executes only inside the packaged initializer's sandbox. It +// never consumes MCP stdin, opens a daemon connection or owns a child process. +func runRuntimeMCP(_ *runContext, args []string) error { + if len(args) != 2 { + return errRuntimeMCP + } + root, err := os.OpenRoot(agentcapabilities.Directory) + if err != nil { + return errRuntimeMCP + } + manifest, err := agentcapabilities.Load(root) + root.Close() + if err != nil { + return errRuntimeMCP + } + values, err := localworkspace.ReadToolEnvironment() + if err != nil { + return errRuntimeMCP + } + invocation, err := resolveMCPInvocation(manifest, args[0], args[1], values) + if err != nil { + return errRuntimeMCP + } + return execRuntimeMCP(invocation) +} + +func resolveMCPInvocation(manifest agentcapabilities.Manifest, pkg, name string, values map[string]string) (mcpInvocation, error) { + for _, installed := range manifest.MCP { + server := installed.Server + if installed.PackageRoot != pkg || server.Name != name { + continue + } + if server.Type != "stdio" { + return mcpInvocation{}, errRuntimeMCP + } + // Defaults locate installed dependencies. Other user values require an + // explicit env_vars declaration; the native launcher's env is never read. + env := map[string]string{ + "PATH": "/environment/packages/npm/bin:/environment/packages/python/bin:/usr/local/bin:/usr/bin:/bin", + "PYTHONPATH": "/environment/packages/python", + "HOME": "/tmp", + "LANG": "C.UTF-8", + } + for _, key := range server.EnvVars { + value, exists := values[key] + if !exists || strings.ContainsRune(value, 0) { + return mcpInvocation{}, errRuntimeMCP + } + env[key] = value + } + cwd := server.CWD + if !filepath.IsAbs(cwd) { + cwd = filepath.Join(agentcapabilities.Directory, installed.PackageRoot, cwd) + } + result := mcpInvocation{command: server.Command, args: append([]string{server.Command}, server.Args...), cwd: cwd} + for key, value := range env { + result.env = append(result.env, key+"="+value) + } + sort.Strings(result.env) + return result, nil + } + return mcpInvocation{}, errRuntimeMCP +} diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_linux.go b/apps/parsar-daemon/internal/cli/runtime_mcp_linux.go new file mode 100644 index 000000000..73396f8dc --- /dev/null +++ b/apps/parsar-daemon/internal/cli/runtime_mcp_linux.go @@ -0,0 +1,31 @@ +//go:build linux + +package cli + +import ( + "os" + "os/exec" + "strings" + "syscall" +) + +func execRuntimeMCP(invocation mcpInvocation) error { + if os.Chdir(invocation.cwd) != nil { + return errRuntimeMCP + } + os.Clearenv() + for _, entry := range invocation.env { + key, value, _ := strings.Cut(entry, "=") + if os.Setenv(key, value) != nil { + return errRuntimeMCP + } + } + command, err := exec.LookPath(invocation.command) + if err != nil { + return errRuntimeMCP + } + if syscall.Exec(command, invocation.args, invocation.env) != nil { + return errRuntimeMCP + } + return nil +} diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_other.go b/apps/parsar-daemon/internal/cli/runtime_mcp_other.go new file mode 100644 index 000000000..e8fb291ad --- /dev/null +++ b/apps/parsar-daemon/internal/cli/runtime_mcp_other.go @@ -0,0 +1,5 @@ +//go:build !linux + +package cli + +func execRuntimeMCP(mcpInvocation) error { return errRuntimeMCP } diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_test.go b/apps/parsar-daemon/internal/cli/runtime_mcp_test.go new file mode 100644 index 000000000..39b43ec54 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/runtime_mcp_test.go @@ -0,0 +1,43 @@ +package cli + +import ( + "slices" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" +) + +func TestRuntimeMCPSelectsOnlyInstalledUserEnvironment(t *testing.T) { + manifest := agentcapabilities.Manifest{MCP: []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ + Name: "local", Type: "stdio", Command: "python3", Args: []string{"proof.py", "${LITERAL}"}, EnvVars: []string{"SELECTED"}, CWD: "./server", + }}}} + t.Setenv("SELECTED", "private-native-value") + t.Setenv("NATIVE_ONLY", "private-native-value") + got, err := resolveMCPInvocation(manifest, "plugins/0", "local", map[string]string{"SELECTED": "user-value", "UNDECLARED": "not-injected"}) + if err != nil || got.command != "python3" || got.cwd != agentcapabilities.Directory+"/plugins/0/server" || + !slices.Equal(got.args, []string{"python3", "proof.py", "${LITERAL}"}) || !slices.Contains(got.env, "SELECTED=user-value") || + slices.Contains(got.env, "UNDECLARED=not-injected") || slices.Contains(got.env, "NATIVE_ONLY=private-native-value") { + t.Fatalf("incorrect isolated invocation: %+v %v", got, err) + } + if _, err := resolveMCPInvocation(manifest, "plugins/0", "local", map[string]string{}); err == nil { + t.Fatal("missing user value fell back to native environment") + } + if _, err := resolveMCPInvocation(manifest, "plugins/1", "local", map[string]string{"SELECTED": "x"}); err == nil { + t.Fatal("undeclared package selected") + } +} + +func TestRuntimeMCPKeepsAbsoluteCWDAndRejectsHTTP(t *testing.T) { + manifest := agentcapabilities.Manifest{MCP: []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ + Name: "local", Type: "stdio", Command: "python3", CWD: "/workspace", + }}}} + got, err := resolveMCPInvocation(manifest, "plugins/0", "local", nil) + if err != nil || got.cwd != "/workspace" { + t.Fatalf("absolute cwd changed: %+v %v", got, err) + } + manifest.MCP[0].Server.Type = "http" + if _, err := resolveMCPInvocation(manifest, "plugins/0", "local", nil); err == nil { + t.Fatal("HTTP configuration treated as a process") + } +} diff --git a/apps/parsar-daemon/internal/localworkspace/binding.go b/apps/parsar-daemon/internal/localworkspace/binding.go index 0fdd46bae..8edbdf8ea 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding.go +++ b/apps/parsar-daemon/internal/localworkspace/binding.go @@ -110,12 +110,26 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa } local := *r.LocalEnvironment local.Skills = nil + local.MCP = nil if local.Capabilities { - var err error - local.Skills, err = LoadSkills() + manifest, err := LoadCapabilities() if err != nil { return r, err } + local.Skills = manifest.Skills + if len(manifest.MCP) != 0 { + if b.NetworkPolicy().Access != "enabled" { + return r, errors.New("environment MCP requires qualified enabled-network execution") + } + values, err := ReadToolEnvironment() + if err != nil { + return r, err + } + local.MCP, err = resolveEnvironmentMCP(manifest.MCP, values) + if err != nil { + return r, err + } + } } r.LocalEnvironment = &local r.WorkDir = b.workspace diff --git a/apps/parsar-daemon/internal/localworkspace/capabilities_test.go b/apps/parsar-daemon/internal/localworkspace/capabilities_test.go index 6457339e7..be69fcb28 100644 --- a/apps/parsar-daemon/internal/localworkspace/capabilities_test.go +++ b/apps/parsar-daemon/internal/localworkspace/capabilities_test.go @@ -6,17 +6,20 @@ import ( "testing" "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) func TestCapabilityPathsStayRuntimeOwnedAndDoNotGateReads(t *testing.T) { binding, request := testBinding(t) request.LocalEnvironment.Skills = []agentcapabilities.InstalledSkill{{RelativeRoot: "caller/private", PackageRoot: "caller"}} + secret := "private-mcp-marker" + request.LocalEnvironment.MCP = []proto.EnvironmentMCP{{PackageRoot: "caller/private", BearerToken: &secret}} raw, err := json.Marshal(request.LocalEnvironment) - if err != nil || bytes.Contains(raw, []byte("caller")) || bytes.Contains(raw, []byte("skills")) { + if err != nil || bytes.Contains(raw, []byte("caller")) || bytes.Contains(raw, []byte("skills")) || bytes.Contains(raw, []byte(secret)) { t.Fatal("Runtime paths crossed the public daemon descriptor", err) } configured, err := binding.Configure(request) - if err != nil || len(configured.LocalEnvironment.Skills) != 0 { + if err != nil || len(configured.LocalEnvironment.Skills) != 0 || len(configured.LocalEnvironment.MCP) != 0 { t.Fatal("execution accepted caller-supplied Skill paths", err) } request.LocalEnvironment.Capabilities = true diff --git a/apps/parsar-daemon/internal/localworkspace/initialization.go b/apps/parsar-daemon/internal/localworkspace/initialization.go index d673ce39d..c0002fda2 100644 --- a/apps/parsar-daemon/internal/localworkspace/initialization.go +++ b/apps/parsar-daemon/internal/localworkspace/initialization.go @@ -7,6 +7,21 @@ import ( "path/filepath" ) +// ReadToolEnvironment selects the immutable user configuration. It never reads +// process environment or introduces live-execution prerequisites for Files. +func ReadToolEnvironment() (map[string]string, error) { + info, err := os.Lstat(ToolEnvironmentJSON) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0222 != 0 || info.Size() > 1<<20 { + return nil, errors.New("initialized user environment unavailable") + } + body, err := os.ReadFile(ToolEnvironmentJSON) + var values map[string]string + if err != nil || json.Unmarshal(body, &values) != nil || values == nil { + return nil, errors.New("initialized user environment unavailable") + } + return values, nil +} + // These paths belong to the packaged Runtime, not a harness or public template. const ( InitializationDirectory = "/environment/initialization" diff --git a/apps/parsar-daemon/internal/localworkspace/mcp.go b/apps/parsar-daemon/internal/localworkspace/mcp.go new file mode 100644 index 000000000..d3faa03e3 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/mcp.go @@ -0,0 +1,45 @@ +package localworkspace + +import ( + "errors" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +const MCPInitializer = "/usr/local/bin/agents-api-runtime-initialize" + +// MCPStdioCommand contains only installed identities. The server's executable, +// arguments and selected user variables are resolved after entering isolation. +func MCPStdioCommand(server proto.EnvironmentMCP) (string, []string) { + return "/usr/bin/python3", []string{"-I", "-S", MCPInitializer, "stdio", server.PackageRoot, server.Server.Name} +} + +func resolveEnvironmentMCP(installed []agentcapabilities.InstalledMCP, values map[string]string) ([]proto.EnvironmentMCP, error) { + result := make([]proto.EnvironmentMCP, 0, len(installed)) + names := map[string]bool{} + for _, item := range installed { + server := item.Server + if names[server.Name] { + return nil, errors.New("ambiguous environment MCP server identity") + } + names[server.Name] = true + resolved := proto.EnvironmentMCP{PackageRoot: item.PackageRoot, Server: server} + variables := append([]string{}, server.EnvVars...) + if server.BearerTokenEnvVar != "" { + variables = append(variables, server.BearerTokenEnvVar) + } + for _, name := range variables { + value, exists := values[name] + if !exists || strings.ContainsRune(value, 0) { + return nil, errors.New("declared environment MCP variable unavailable") + } + if name == server.BearerTokenEnvVar { + resolved.BearerToken = &value + } + } + result = append(result, resolved) + } + return result, nil +} diff --git a/apps/parsar-daemon/internal/localworkspace/mcp_test.go b/apps/parsar-daemon/internal/localworkspace/mcp_test.go new file mode 100644 index 000000000..a7d85cef4 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/mcp_test.go @@ -0,0 +1,38 @@ +package localworkspace + +import ( + "slices" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" +) + +func TestEnvironmentMCPCredentialsNeverFallBackToNativeEnv(t *testing.T) { + t.Setenv("PLUGIN_TOKEN", "native-private-value") + installed := []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ + Name: "remote", Type: "http", URL: "https://example.com/mcp", BearerTokenEnvVar: "PLUGIN_TOKEN", + }}} + if _, err := resolveEnvironmentMCP(installed, nil); err == nil { + t.Fatal("native credential became a user Plugin credential") + } + servers, err := resolveEnvironmentMCP(installed, map[string]string{"PLUGIN_TOKEN": "user-token"}) + if err != nil || len(servers) != 1 || servers[0].BearerToken == nil || *servers[0].BearerToken != "user-token" { + t.Fatal("initialized credential was not selected", err) + } + installed = append(installed, agentcapabilities.InstalledMCP{PackageRoot: "plugins/1", Server: installed[0].Server}) + if _, err := resolveEnvironmentMCP(installed, map[string]string{"PLUGIN_TOKEN": "user-token"}); err == nil { + t.Fatal("ambiguous server identity accepted") + } +} + +func TestMCPStdioLauncherContainsOnlyInstalledIdentity(t *testing.T) { + server := proto.EnvironmentMCP{PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ + Name: "package_tool", Type: "stdio", Command: "untrusted-command", Args: []string{"private-argument"}, + }} + command, args := MCPStdioCommand(server) + if command != "/usr/bin/python3" || !slices.Equal(args, []string{"-I", "-S", MCPInitializer, "stdio", "plugins/0", "package_tool"}) { + t.Fatal("native configuration included untrusted process configuration") + } +} diff --git a/apps/parsar-daemon/internal/localworkspace/skills.go b/apps/parsar-daemon/internal/localworkspace/skills.go index bc3c89eb7..cefa50acd 100644 --- a/apps/parsar-daemon/internal/localworkspace/skills.go +++ b/apps/parsar-daemon/internal/localworkspace/skills.go @@ -9,22 +9,18 @@ import ( const CapabilityDirectory = agentcapabilities.Directory -// LoadSkills consumes only the packaged installation after binding authorization. -func LoadSkills() ([]agentcapabilities.InstalledSkill, error) { +// LoadCapabilities consumes the packaged installation after binding authorization. +func LoadCapabilities() (agentcapabilities.Manifest, error) { actual, err := filepath.EvalSymlinks(CapabilityDirectory) if err != nil || actual != CapabilityDirectory { - return nil, agentcapabilities.ErrInvalid + return agentcapabilities.Manifest{}, agentcapabilities.ErrInvalid } root, err := os.OpenRoot(CapabilityDirectory) if err != nil { - return nil, agentcapabilities.ErrInvalid + return agentcapabilities.Manifest{}, agentcapabilities.ErrInvalid } defer root.Close() - manifest, err := agentcapabilities.Load(root) - if err != nil { - return nil, err - } - return append([]agentcapabilities.InstalledSkill{}, manifest.Skills...), nil + return agentcapabilities.Load(root) } func SkillPath(skill agentcapabilities.InstalledSkill) string { diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 7390f0e10..f3109f4eb 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -158,9 +158,9 @@ user-managed enrollment remain outside this qualification. | Area | Missing or unverified scope | | --- | --- | | Subagents / multi_agent | Six public child read operations, enabled execution, child lifecycle/interactions and full recovery; deferred outside the MVP | -| Environment Templates | Plugin MCP, unsupported restricted hostname forms, unqualified installation overrides/null network and exact hosted errors remain gaps. CRUD/list, files, env/setup/system/npm/Python, inline/referenced Skills, skill-only Plugins, workspace capability directories and Session references have accepted coverage | +| Environment Templates | Unsupported restricted hostname forms, unqualified installation overrides/null network and exact hosted errors remain gaps. CRUD/list, files, env/setup/system/npm/Python, inline/referenced Skills, Plugins, workspace capability directories and Session references have recorded coverage. Environment Plugin MCP transport and placement limits are [listed separately](environment-templates.md#environment-origin-mcp-plugins) | | Input and configuration | Non-text initial input, broader content/configuration unions, structured output and reasoning/verbosity combinations | -| Tools and interactions | Deferred functions, other tool types, effective tool-set enforcement and result/cancel publication ordering; MiniMax public functions/MCP remain unsupported | +| Tools and interactions | Deferred functions, other tool types, effective tool-set enforcement and result/cancel publication ordering; MiniMax public functions and service-origin MCP remain unsupported | | Vault and Credentials | OAuth/refresh, archive semantics, revocation/concurrent mutation and exact hosted selection/error behavior; static bearer CRUD/token replacement is already present | | Existing resources | Full Item/SSE/Usage variants, omitted/null/default/error semantics, pagination and overlapping lifecycle behavior beyond recorded cases | @@ -391,8 +391,8 @@ operation and placement; native support is not public admission by itself. | Engine | Qualified placements and limits | | --- | --- | | `codex` (default) | `none`, the bounded official `self_hosted` path and Docker/E2B `openai_hosted`; public functions with ordered text/image results; service-origin HTTP MCP on `none`/`self_hosted`, not hosted; supported verbosity follows the native policy below | -| `claude_sdk` | `none` and Docker/E2B `openai_hosted`; medium verbosity, object-root function schemas and text-only function results; anonymous/static-bearer HTTP MCP with either required value on `none`; hosted HTTP MCP remains unsupported | -| `mcode` | `none` text and Docker/E2B `openai_hosted` workspace execution; medium verbosity; public functions/MCP, image input and complete public usage breakdown remain unsupported | +| `claude_sdk` | `none` and Docker/E2B `openai_hosted`; medium verbosity, object-root function schemas and text-only function results; anonymous/static-bearer service-origin HTTP MCP with either required value on `none`; hosted service-origin HTTP MCP remains unsupported | +| `mcode` | `none` text and Docker/E2B `openai_hosted` workspace execution; medium verbosity; public functions/service-origin MCP, image input and complete public usage breakdown remain unsupported | All three hosted profiles reuse the [Docker](environments.md#basic-public-docker-hosted-profile) or [E2B](environments.md#basic-public-e2b-hosted-profile) provider lifecycle, @@ -405,9 +405,13 @@ operator setup: [Codex](../../services/agents-api/deploy/codex/README.md), images as pinned templates. The shared initialization path supports env/setup and system/npm/Python packages; see the [evidence and limits](environment-templates.md#verification). Remaining -unsupported startup installations, unqualified restricted hostname forms and hosted public HTTP MCP -remain outside these accepted profiles. MiniMax's private MCP tool bridge -is internal transport, not public MCP support. +unsupported startup installations, unqualified restricted hostname forms and hosted +service-origin HTTP MCP remain outside these accepted profiles. Environment-origin +MCP Plugins have a separate [Docker qualification and transport matrix](environment-templates.md#environment-origin-mcp-plugins): +stdio on all three harnesses, Codex HTTP with literal headers or HTTPS bearer, +and Claude anonymous HTTP or HTTPS bearer without literal headers. This batch +does not qualify those new Plugin paths on E2B. MiniMax's private workspace MCP +bridge remains internal transport, distinct from installed Environment MCP servers. The [Codex self-hosted profile](environments.md) remains distinct from managed Docker/E2B and from future user-managed Runtime enrollment. Product `claude_code` diff --git a/contracts/agents-api/environment-templates.md b/contracts/agents-api/environment-templates.md index a21112c71..dd2f836ba 100644 --- a/contracts/agents-api/environment-templates.md +++ b/contracts/agents-api/environment-templates.md @@ -169,7 +169,8 @@ isolated workspace tool worker. No Provider or model/tool loop is added. Codex nested `SKILL.md` discovery, `agents/openai.yaml` native dependency configuration and Claude inline/fenced shell preprocessing are not qualified in this batch and explicitly fail adapter preparation. Other files are not interpreted as a public plugin installation. -The skill-only Plugin and capability-directory batch below extends installation; Plugin MCP remains unqualified. Native built-in Skill visibility +The initial skill-only Plugin and capability-directory batch below extends installation; +environment-origin MCP has a separate qualification boundary described below. Native built-in Skill visibility is not evidence of exact public tool-set parity. Qualification probes alone do not establish complete public support; record real service acceptance separately. @@ -204,9 +205,8 @@ roots into a hardlinked content tree. Native component configuration is never passed wholesale to a harness. Provider APIs and the native execution loops are unchanged. No new installation/recovery lifecycle or framework is introduced. -This batch supports skill-only packages. Populated MCP configuration and other -unqualified activation reject explicitly. An empty MCP map is inert; accepting it -does not qualify MCP. Archives retain the shared 5 MiB compressed/20 MiB expanded/ +The original accepted batch supported skill-only packages and inert empty MCP maps. +Environment-origin MCP uses the separate transport path below. Archives retain the shared 5 MiB compressed/20 MiB expanded/ 1,000-entry limits. Plugin lists are limited to 50 entries and 10 MiB compressed; combined installed capabilities are limited to 50 Skills and 50 MiB. Limits are implementation bounds. Native shell preprocessing, dependency activation and @@ -230,10 +230,97 @@ Evidence is under `~/.parsar/remediation/20260921/template-plugins/`: fixture is `services/agents-api/tests/official_environment_plugins.py`; operator runners reuse existing standalone acceptance and private model configuration. A user-authorized reused-context GPT-6 Astra high independent review of all 60 -changed files found no material actionable findings. Plugin MCP, portable root -`plugin.json` applicability and the unconfirmed semantics above remain gaps; +changed files found no material actionable findings. Those historical results do +not qualify Plugin MCP. Portable root `plugin.json` applicability and the unconfirmed +semantics above remain gaps; these results do not establish complete Environment Templates or protocol compatibility. +## Environment-origin MCP Plugins + +MCP-only and combined Skill/MCP packages use the same encrypted template/Session +snapshot and installation as Skill-only Plugins. The fixed format remains +`.codex-plugin/plugin.json` with `mcpServers: "./.mcp.json"`, or an omitted path +using the root `.mcp.json`. The file contains `mcpServers` keyed by server name. +An exact capability-directory Plugin root activates its MCP declarations; selecting +a parent directory discovers Skills without activating every nested MCP server. + +The common parser accepts HTTP `url`, `bearer_token_env_var`, literal `http_headers`, +and stdio `command`, `args`, selected `env_vars`, package-relative `cwd`. It does not +resolve credentials. Runtime reloads frozen installed packages and resolves selected +values only from initialized caller env. A missing value fails instead of using a +model or daemon variable. Public `env_http_headers` remains unsupported; an adapter +may privately use native reference fields without changing public literal values. +Caller-owned env remains available to caller code under the normal env contract. + +Stdio executes through the existing Runtime sandbox and a fixed static helper, +which applies selected env/cwd and directly execs the package command. A small +single-threaded launcher monitors the native parent process with Linux pidfds; +bwrap retains its parent-death protection against that stable launcher. This avoids +killing MCP servers when Codex recycles a spawning thread. Native MCP owns the +transport; the launcher does not parse or forward protocol messages. Dependencies must be installed in the ordinary +Environment initialization flow. The current implemented transport boundaries are: + +| Adapter | Environment MCP implementation | +| --- | --- | +| Codex | Stdio; HTTP with literal headers and HTTPS bearer references | +| Claude Code | Stdio; anonymous HTTP or HTTPS bearer, without literal custom headers | +| MiniMax Code | Stdio; HTTP explicitly rejected pending safe native qualification | + +All current Environment MCP execution requires enabled network. Restricted/disabled +HTTP and hosted stdio under a restricted/disabled policy are not qualified. Claude +literal headers are rejected because the pinned client expands them again and +forwards custom headers across origins. MiniMax ACP does not enable the native +custom-header redirect protection. Duplicate global server identities are rejected; +official duplicate namespace and required/optional connection-failure semantics +remain unconfirmed. These are implementation limits, not changes to the upstream +protocol or claims of equal optional feature sets. + +### Docker qualification (2026-09-21) + +The fixed OpenAI SDK 3.13.0 and raw HTTP passed against independent Core, +PostgreSQL and Docker Runtimes using real Kimi K3 (Codex/Claude) and MiniMax-M2.7 +(MiniMax Code). Each stdio run exercised MCP-only, combined Skill/MCP and exact +capability-directory packages, selected env/cwd, Files/Artifacts, tenant checks +and positive private credential/history canaries. Removing mutable sources and +the template, then restarting Core and Runtime, retained installed tools and native +history without replaying setup. Public cancellation stopped owned tool effects; +duplicate cancellation remained stable. SDK/raw Items and live event ordering +were compared without replacing native failed/incomplete tool observations. + +Separate real Kimi HTTPS runs covered inline/template configuration and cold +continuation: Codex literal headers plus bearer, and Claude anonymous plus bearer. +Codex preserved same-origin credentials and rejected cross-origin redirects. +Private CA trust and hostname validation remained enabled. These runs used the +same Core/daemon/adapters before the final stdio-only launcher correction and +Codex shell hook fix; affected paths were then qualified separately on final images. + +Evidence is under `~/.parsar/remediation/20260921/template-plugin-mcp/`: + +| Profile | Passed public run | +| --- | --- | +| Codex stdio | `docker/codex/plugin-d1anzd48/result.json` | +| Claude stdio | `docker/claude/plugin-l05txyye/result.json` | +| MiniMax stdio | `docker/mcode/plugin-ttnduvtn/result.json` | +| Codex HTTPS | `final-codex-http/run-8tldu8v2/result.json` | +| Claude HTTPS | `final-claude-http/run-67b8vj1o/result.json` | + +Core binary SHA-256: `4b0b3360bdc776e715d9239c3b9564cd7760dd4cef6a42d1a5b82db565f84912`. +Daemon: `d6ac4eeb7657884ab3704bcdc74456044e8e702f2f89b2d30486f29ea5fe083c`. +Per-run records retain exact Runtime image IDs and cleanup results. The final +`make-check-final.log` passed; OpenAPI regeneration and focused Go/Claude tests +also passed. Real Linux process checks cover idle creator-thread exit, native and +wrapper exit, active-call cleanup and the reproduced pre-exec orphan window. +`services/agents-api/deploy/runtime/initialize_stdio_test.py` retains that OS +regression; it requires a disposable Linux packaged Runtime, not a model fixture. +The Codex environment hook additionally passed 30 actual sh/Bash cases after its +Bash-specific `eval --` failed under native `/bin/sh`. + +The reusable public fixture is +`services/agents-api/tests/official_environment_plugin_mcp.py`. Mechanism probes +and failed attempts remain separate evidence. This batch does not qualify new +Plugin MCP paths on E2B, service-origin hosted MCP, OAuth, unlisted transports or +complete upstream protocol compatibility. + ## Packaged Runtime initialization contract Template handlers and stores resolve public configuration without choosing a @@ -331,9 +418,9 @@ policy on recovery; resource tests alone do not establish execution compatibilit ## Explicit gaps and evidence boundaries -Nonempty `capability_directories` and `plugins` remain unsupported -for both templates and inline initialization. Skill references use the shared -initialization flow described above. The separate live Files API remains +Templates and inline initialization share Plugin, capability-directory and Skill +reference installation. Unsupported native activation and unqualified protocol +semantics remain explicit gaps as described above. The separate live Files API remains available after initialization. Unsupported requests reject without echoing payloads. The [hosted guide](https://developers.openai.com/api/docs/guides/agents-api/environments/openai-hosted) diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index 47863f305..0e9c0ddf1 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -2261,12 +2261,11 @@ paths: description: Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential env, ordered setup_commands, system/npm/Python packages - inline/referenced Skill ZIPs, skill-only Plugin ZIPs and workspace-contained - capability directories. Omitted/null network defaults to enabled. Restricted - network requires 1–100 exact ASCII hostnames; other host forms and populated - unsupported installations are rejected before persistence without echoing - input. No compute is allocated. Exact hosted error/retry semantics remain - unverified. + inline/referenced Skill ZIPs, Plugin ZIPs and workspace-contained capability + directories. Omitted/null network defaults to enabled. Restricted network + requires 1–100 exact ASCII hostnames; other host forms and populated unsupported + installations are rejected before persistence without echoing input. No compute + is allocated. Exact hosted error/retry semantics remain unverified. parameters: - description: agents=v1 in: header @@ -2403,8 +2402,9 @@ paths: archives are encrypted separately and omitted from responses. Plugins and capability directories replace as lists; null/empty clears. Plugin archives are encrypted and omitted from responses. Capability directories are snapshotted - after setup; Plugin MCP activation remains unsupported. Exact hosted no-op - timestamp behavior remains unverified. + after setup. Environment MCP execution requires a qualified native transport + and runtime network policy. Exact hosted no-op timestamp behavior remains + unverified. parameters: - description: agents=v1 in: header diff --git a/internal/agentcapabilities/install.go b/internal/agentcapabilities/install.go index e6d26750b..b5badc74a 100644 --- a/internal/agentcapabilities/install.go +++ b/internal/agentcapabilities/install.go @@ -91,6 +91,9 @@ func Finalize(workspace, installed *os.Root, input Input) error { } func addPlugin(manifest *Manifest, root string, bundle agentplugin.Bundle) error { + if len(bundle.MCP) != 0 && manifest.addMCPPackage(root) != nil { + return ErrInvalid + } for _, skill := range bundle.Skills { if err := manifest.add(skill.Metadata, path.Join(root, skill.RelativeRoot), root); err != nil { return err @@ -140,26 +143,46 @@ func Load(root *os.Root) (Manifest, error) { if err != nil { return Manifest{}, err } - packages := map[string]bool{} + packages := map[string][]agentbundle.File{} total := 0 + loadPackage := func(name string) ([]agentbundle.File, error) { + if files, ok := packages[name]; ok { + return files, nil + } + files, err := ReadTree(root, name, true) + if err != nil { + return nil, err + } + for _, file := range files { + total += len(file.Data) + } + if total > MaxSnapshotBytes { + return nil, ErrInvalid + } + packages[name] = files + return files, nil + } for _, skill := range manifest.Skills { - if !packages[skill.PackageRoot] { - files, err := ReadTree(root, skill.PackageRoot, true) - if err != nil { - return Manifest{}, err - } - for _, file := range files { - total += len(file.Data) - } - if total > MaxSnapshotBytes { - return Manifest{}, ErrInvalid - } - packages[skill.PackageRoot] = true + if _, err := loadPackage(skill.PackageRoot); err != nil { + return Manifest{}, err } body, err := root.ReadFile(skill.RelativeRoot + "/SKILL.md") if err != nil || agentskill.ValidateManifest(body, skill.Metadata) != nil { return Manifest{}, ErrInvalid } } + for _, name := range manifest.Plugins { + files, err := loadPackage(name) + if err != nil { + return Manifest{}, err + } + bundle, err := agentplugin.Inspect(files) + if err != nil || len(bundle.MCP) == 0 { + return Manifest{}, ErrInvalid + } + for _, server := range bundle.MCP { + manifest.MCP = append(manifest.MCP, InstalledMCP{PackageRoot: name, Server: server}) + } + } return manifest, nil } diff --git a/internal/agentcapabilities/install_test.go b/internal/agentcapabilities/install_test.go index 5272c7551..3e510f519 100644 --- a/internal/agentcapabilities/install_test.go +++ b/internal/agentcapabilities/install_test.go @@ -68,8 +68,16 @@ func TestDirectoryDiscoveryDoesNotActivateChildPluginMCP(t *testing.T) { if err != nil || len(manifest.Skills) != 1 || manifest.Skills[0].RelativeRoot != "directories/0/child/skills/proof" { t.Fatalf("parent Skill discovery failed: %+v %v", manifest, err) } - if err := Finalize(workspace, openTestRoot(t), Input{Directories: []string{"/workspace/parent/child"}}); err == nil { - t.Fatal("explicit Plugin root silently dropped declared MCP") + if len(manifest.MCP) != 0 || len(manifest.Plugins) != 0 { + t.Fatal("parent directory activated child Plugin MCP") + } + exact := openTestRoot(t) + if err := Finalize(workspace, exact, Input{Directories: []string{"/workspace/parent/child"}}); err != nil { + t.Fatal(err) + } + manifest, err = Load(exact) + if err != nil || len(manifest.MCP) != 1 || manifest.MCP[0].Server.Name != "remote" { + t.Fatalf("exact Plugin MCP declaration lost: %+v %v", manifest, err) } } diff --git a/internal/agentcapabilities/manifest.go b/internal/agentcapabilities/manifest.go index 321025435..3ecda2e7e 100644 --- a/internal/agentcapabilities/manifest.go +++ b/internal/agentcapabilities/manifest.go @@ -29,6 +29,15 @@ type InstalledSkill struct { type Manifest struct { Version int `json:"version"` Skills []InstalledSkill `json:"skills"` + Plugins []string `json:"plugins,omitempty"` + MCP []InstalledMCP `json:"-"` +} + +// InstalledMCP is resolved from a frozen package at load time. The manifest +// stores the package root, not a second copy of configuration or credentials. +type InstalledMCP struct { + PackageRoot string + Server agentplugin.MCPServer } // Input describes frozen sources; directory contents are observed after setup. @@ -81,5 +90,23 @@ func decodeManifest(body []byte) (Manifest, error) { return Manifest{}, ErrInvalid } } + for _, root := range result.Plugins { + if checked.addMCPPackage(root) != nil { + return Manifest{}, ErrInvalid + } + } return result, nil } + +func (m *Manifest) addMCPPackage(root string) error { + if !validRelative(root) || len(m.Plugins) >= 100 { + return ErrInvalid + } + for _, old := range m.Plugins { + if old == root { + return ErrInvalid + } + } + m.Plugins = append(m.Plugins, root) + return nil +} diff --git a/internal/agentcapabilities/mcp_test.go b/internal/agentcapabilities/mcp_test.go new file mode 100644 index 000000000..65f80dfe2 --- /dev/null +++ b/internal/agentcapabilities/mcp_test.go @@ -0,0 +1,51 @@ +package agentcapabilities + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +func TestMCPOnlyInstalledPackageSurvivesSourceDeletion(t *testing.T) { + workspace, installed := openTestRoot(t), openTestRoot(t) + writeWorkspace(t, workspace, "plugin/.codex-plugin/plugin.json", []byte(`{"name":"native-tools","description":"Package tools.","mcpServers":"./.mcp.json"}`)) + writeWorkspace(t, workspace, "plugin/.mcp.json", []byte(`{"mcpServers":{"proof":{"command":"python3","args":["proof.py"],"env_vars":["PLUGIN_TOKEN"]}}}`)) + writeWorkspace(t, workspace, "plugin/proof.py", []byte("# Preserved server resource.\n")) + if err := Finalize(workspace, installed, Input{Directories: []string{"/workspace/plugin"}}); err != nil { + t.Fatal(err) + } + before, err := Load(installed) + if err != nil || len(before.Skills) != 0 || len(before.MCP) != 1 || before.MCP[0].PackageRoot != "directories/0" || before.MCP[0].Server.Name != "proof" { + t.Fatalf("MCP-only installed package lost: %+v %v", before, err) + } + body, err := installed.ReadFile(ManifestName) + if err != nil || strings.Contains(string(body), "PLUGIN_TOKEN") || strings.Contains(string(body), "command") { + t.Fatalf("manifest duplicates server configuration: %s %v", body, err) + } + if err := workspace.RemoveAll("plugin"); err != nil { + t.Fatal(err) + } + after, err := Load(installed) + if err != nil || !reflect.DeepEqual(before, after) { + t.Fatalf("load changed frozen MCP configuration: %+v %v", after, err) + } + if err := installed.Remove("directories/0/.mcp.json"); err != nil { + t.Fatal(err) + } + if _, err := Load(installed); err == nil { + t.Fatal("missing retained declaration accepted") + } +} + +func TestInstalledMCPManifestRejectsInvalidPackageRoots(t *testing.T) { + for _, roots := range [][]string{{"../private"}, {"/private"}, {"plugins/0", "plugins/0"}} { + body, err := json.Marshal(Manifest{Version: 1, Plugins: roots}) + if err != nil { + t.Fatal(err) + } + if _, err := decodeManifest(body); err == nil { + t.Errorf("invalid package roots accepted: %v", roots) + } + } +} diff --git a/internal/agentdaemon/proto/environment.go b/internal/agentdaemon/proto/environment.go index a064784f4..0d2feafec 100644 --- a/internal/agentdaemon/proto/environment.go +++ b/internal/agentdaemon/proto/environment.go @@ -1,6 +1,9 @@ package proto -import "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" +import ( + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" +) // LocalEnvironment references a deployment-bound workspace; it never supplies a path. type LocalEnvironment struct { @@ -9,6 +12,8 @@ type LocalEnvironment struct { Capabilities bool `json:"capabilities,omitempty"` // Skills is resolved by the bound daemon; wire input cannot supply paths. Skills []agentcapabilities.InstalledSkill `json:"-"` + // MCP is resolved from the same protected installation, never from wire input. + MCP []EnvironmentMCP `json:"-"` // ToolEnvironment consumes Core-completed confidential initialization. ToolEnvironment bool `json:"tool_environment,omitempty"` // SystemPackages requires the installed Runtime tool root during execution. @@ -18,6 +23,14 @@ type LocalEnvironment struct { AllowedDomains []string `json:"allowed_domains,omitempty"` } +// EnvironmentMCP is transient Runtime configuration. Do not log it: HTTP headers +// and the selected user bearer may be confidential. It is not agent.tools MCP. +type EnvironmentMCP struct { + PackageRoot string + Server agentplugin.MCPServer + BearerToken *string +} + func (r PromptRequestPayload) EnvironmentID() string { if r.LocalEnvironment != nil { return r.LocalEnvironment.ID diff --git a/internal/agentplugin/bundle.go b/internal/agentplugin/bundle.go index 4c459762d..f0a2a901d 100644 --- a/internal/agentplugin/bundle.go +++ b/internal/agentplugin/bundle.go @@ -32,6 +32,7 @@ type Bundle struct { Metadata Metadata Files []agentbundle.File Skills []Skill + MCP []MCPServer } // Read validates the archive and its declared identity without native loading. @@ -74,10 +75,14 @@ func Inspect(files []agentbundle.File) (Bundle, error) { return Bundle{}, ErrInvalid } roots, err := skillRoots(manifest.Skills) - if err != nil || rejectMCP(manifest.MCP, members) != nil { + if err != nil { + return Bundle{}, ErrInvalid + } + servers, err := readMCP(manifest.MCP, members) + if err != nil { return Bundle{}, ErrInvalid } - result := Bundle{Metadata: Metadata{Type: "inline", Name: manifest.Name, Description: manifest.Description}, Files: files} + result := Bundle{Metadata: Metadata{Type: "inline", Name: manifest.Name, Description: manifest.Description}, Files: files, MCP: servers} seen := map[string]bool{} for _, file := range files { if path.Base(file.Path) != "SKILL.md" { @@ -98,7 +103,7 @@ func Inspect(files []agentbundle.File) (Bundle, error) { seen[metadata.Name] = true result.Skills = append(result.Skills, Skill{Metadata: metadata, RelativeRoot: root}) } - if len(result.Skills) == 0 || len(result.Skills) > 50 { + if (len(result.Skills) == 0 && (len(roots) != 0 || len(result.MCP) == 0)) || len(result.Skills) > 50 { return Bundle{}, ErrInvalid } sort.Slice(result.Skills, func(i, j int) bool { return result.Skills[i].RelativeRoot < result.Skills[j].RelativeRoot }) @@ -106,6 +111,9 @@ func Inspect(files []agentbundle.File) (Bundle, error) { } func skillRoots(raw json.RawMessage) ([]string, error) { + if len(raw) == 0 { + return nil, nil + } var roots []string var single string if json.Unmarshal(raw, &single) == nil { @@ -137,32 +145,6 @@ func relativeDeclaration(value string) (string, error) { return value, nil } -func rejectMCP(raw json.RawMessage, files map[string][]byte) error { - config := ".mcp.json" - declared := len(raw) != 0 - if declared { - if json.Unmarshal(raw, &config) != nil { - return ErrInvalid - } - var err error - config, err = relativeDeclaration(config) - if err != nil { - return err - } - } - body, exists := files[config] - if !exists && !declared { - return nil - } - var input struct { - Servers map[string]json.RawMessage `json:"mcpServers"` - } - if decodeObject(body, &input) != nil || input.Servers == nil || len(input.Servers) != 0 { - return ErrInvalid - } - return nil -} - func decodeObject(body []byte, output any) error { body = bytes.TrimSpace(body) if len(body) == 0 || len(body) > 256<<10 || body[0] != '{' { diff --git a/internal/agentplugin/bundle_test.go b/internal/agentplugin/bundle_test.go index 7db4c8124..01c5002a2 100644 --- a/internal/agentplugin/bundle_test.go +++ b/internal/agentplugin/bundle_test.go @@ -38,7 +38,7 @@ func TestPluginRejectsUnqualifiedActivationAndUnsafeLayout(t *testing.T) { body string }{ {"declared MCP", ".codex-plugin/plugin.json", `{"name":"proof","description":"Use shared resources.","skills":"./skills","mcpServers":"./missing.json"}`}, - {"default MCP", ".mcp.json", `{"mcpServers":{"remote":{"type":"http","url":"https://example.com/mcp"}}}`}, + {"unqualified MCP field", ".mcp.json", `{"mcpServers":{"remote":{"type":"http","url":"https://example.com/mcp","env_http_headers":{"Authorization":"SECRET"}}}}`}, {"hooks", ".codex-plugin/plugin.json", `{"name":"proof","description":"Use shared resources.","skills":"./skills","hooks":"./hooks.json"}`}, {"escaping declaration", ".codex-plugin/plugin.json", `{"name":"proof","description":"Use shared resources.","skills":"./../private"}`}, {"absolute declaration", ".codex-plugin/plugin.json", `{"name":"proof","description":"Use shared resources.","skills":"/private"}`}, diff --git a/internal/agentplugin/mcp.go b/internal/agentplugin/mcp.go new file mode 100644 index 000000000..b85119ae6 --- /dev/null +++ b/internal/agentplugin/mcp.go @@ -0,0 +1,122 @@ +package agentplugin + +import ( + "encoding/json" + "net/url" + "path" + "regexp" + "sort" + "strings" + + "golang.org/x/net/http/httpguts" +) + +// MCPServer is an inert package declaration. Environment variables are resolved +// from installed user configuration by Runtime, never from the parser's process. +type MCPServer struct { + Name string `json:"-"` + Type string `json:"type,omitempty"` + URL string `json:"url,omitempty"` + BearerTokenEnvVar string `json:"bearer_token_env_var,omitempty"` + HTTPHeaders map[string]string `json:"http_headers,omitempty"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + EnvVars []string `json:"env_vars,omitempty"` + CWD string `json:"cwd,omitempty"` +} + +var environmentVariable = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +func readMCP(raw json.RawMessage, files map[string][]byte) ([]MCPServer, error) { + config := ".mcp.json" + declared := len(raw) != 0 + if declared { + if json.Unmarshal(raw, &config) != nil { + return nil, ErrInvalid + } + var err error + config, err = relativeDeclaration(config) + if err != nil { + return nil, err + } + } + body, exists := files[config] + if !exists && !declared { + return nil, nil + } + var input struct { + Servers map[string]json.RawMessage `json:"mcpServers"` + } + if decodeObject(body, &input) != nil || input.Servers == nil || len(input.Servers) > 50 { + return nil, ErrInvalid + } + names := make([]string, 0, len(input.Servers)) + for name := range input.Servers { + names = append(names, name) + } + sort.Strings(names) + servers := make([]MCPServer, 0, len(names)) + for _, name := range names { + var server MCPServer + if name == "" || len(name) > 128 || strings.ContainsAny(name, "\x00\r\n") || decodeObject(input.Servers[name], &server) != nil { + return nil, ErrInvalid + } + server.Name = name + if err := server.validate(); err != nil { + return nil, err + } + servers = append(servers, server) + } + return servers, nil +} + +func (s *MCPServer) validate() error { + if s.Type == "" && s.Command != "" { + s.Type = "stdio" + } + switch s.Type { + case "http": + endpoint, err := url.Parse(s.URL) + if err != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") || endpoint.Hostname() == "" || endpoint.User != nil || endpoint.Fragment != "" || + s.Command != "" || s.Args != nil || s.EnvVars != nil || s.CWD != "" || + (s.BearerTokenEnvVar != "" && !environmentVariable.MatchString(s.BearerTokenEnvVar)) { + return ErrInvalid + } + seen := map[string]bool{} + for name, value := range s.HTTPHeaders { + lower := strings.ToLower(name) + if !httpguts.ValidHeaderFieldName(name) || !httpguts.ValidHeaderFieldValue(value) || seen[lower] || + (lower == "authorization" && s.BearerTokenEnvVar != "") { + return ErrInvalid + } + seen[lower] = true + } + case "stdio": + if strings.TrimSpace(s.Command) == "" || strings.ContainsRune(s.Command, 0) || s.URL != "" || s.BearerTokenEnvVar != "" || s.HTTPHeaders != nil { + return ErrInvalid + } + for _, arg := range s.Args { + if strings.ContainsRune(arg, 0) { + return ErrInvalid + } + } + for _, name := range s.EnvVars { + if !environmentVariable.MatchString(name) { + return ErrInvalid + } + } + if strings.ContainsAny(s.CWD, "\\\x00\r\n") { + return ErrInvalid + } + if s.CWD != "" && !path.IsAbs(s.CWD) { + for _, part := range strings.Split(s.CWD, "/") { + if part == ".." { + return ErrInvalid + } + } + } + default: + return ErrInvalid + } + return nil +} diff --git a/internal/agentplugin/mcp_test.go b/internal/agentplugin/mcp_test.go new file mode 100644 index 000000000..91aed8d9e --- /dev/null +++ b/internal/agentplugin/mcp_test.go @@ -0,0 +1,61 @@ +package agentplugin + +import ( + "reflect" + "testing" +) + +func TestMCPOnlyPluginPreservesEnvironmentDeclarations(t *testing.T) { + files := map[string][]byte{ + ".codex-plugin/plugin.json": []byte(`{"name":"proof","description":"Use shared resources.","mcpServers":"./config/servers.json"}`), + "config/servers.json": []byte(`{"mcpServers":{ + "remote":{"type":"http","url":"https://example.com/mcp","bearer_token_env_var":"PLUGIN_TOKEN","http_headers":{"X-Literal":"${DO_NOT_EXPAND}"}}, + "local":{"command":"python3","args":["server.py","--value","literal $VALUE"],"env_vars":["PLUGIN_VALUE"],"cwd":"./server"} + }}`), + "server/server.py": []byte("# Package resource, never imported by the parser.\n"), + } + t.Setenv("DO_NOT_EXPAND", "private-native-value") + t.Setenv("PLUGIN_TOKEN", "must-not-resolve-here") + bundle, err := Read(pluginArchive(t, files, 0600), Metadata{Type: "inline", Name: "proof", Description: "Use shared resources."}) + if err != nil || len(bundle.Skills) != 0 || len(bundle.MCP) != 2 { + t.Fatalf("MCP-only bundle: %+v %v", bundle, err) + } + local, remote := bundle.MCP[0], bundle.MCP[1] + if local.Name != "local" || local.Type != "stdio" || local.CWD != "./server" || + !reflect.DeepEqual(local.Args, []string{"server.py", "--value", "literal $VALUE"}) || !reflect.DeepEqual(local.EnvVars, []string{"PLUGIN_VALUE"}) || + remote.Name != "remote" || remote.BearerTokenEnvVar != "PLUGIN_TOKEN" || remote.HTTPHeaders["X-Literal"] != "${DO_NOT_EXPAND}" { + t.Fatalf("declaration changed: %+v", bundle.MCP) + } +} + +func TestPluginCombinesSkillAndMCPWithoutActivatingOtherFiles(t *testing.T) { + files := pluginFixture() + files[".mcp.json"] = []byte(`{"mcpServers":{"remote":{"type":"http","url":"https://example.com/mcp"}}}`) + files["hooks/hooks.json"] = []byte(`{"must_remain_inert":true}`) + bundle, err := Read(pluginArchive(t, files, 0600), Metadata{Type: "inline", Name: "proof", Description: "Use shared resources."}) + if err != nil || len(bundle.Skills) != 2 || len(bundle.MCP) != 1 { + t.Fatalf("combined package: %+v %v", bundle, err) + } +} + +func TestMCPRejectsUnsupportedAuthorityAndMalformedTransport(t *testing.T) { + for _, input := range []string{ + `{"type":"http","url":"https://user:secret@example.com/mcp"}`, + `{"type":"http","url":"file:///private"}`, + `{"type":"http","url":"https://example.com/mcp","command":"sh"}`, + `{"type":"http","url":"https://example.com/mcp","http_headers":{"X-Key":"injected\r\nheader"}}`, + `{"type":"http","url":"https://example.com/mcp","http_headers":{"X-Key":"a","x-key":"b"}}`, + `{"type":"http","url":"https://example.com/mcp","env_http_headers":{"X-Key":"SECRET"}}`, + `{"type":"stdio","command":"python3","env":{"KEY":"inline value"}}`, + `{"type":"stdio","command":"python3","env_vars":["BAD-NAME"]}`, + `{"type":"stdio","command":"python3","cwd":"../private"}`, + `{"type":"stdio","command":"python3","args":["bad\u0000argument"]}`, + `{"type":"sse","url":"https://example.com"}`, + } { + files := pluginFixture() + files[".mcp.json"] = []byte(`{"mcpServers":{"test":` + input + `}}`) + if _, err := Read(pluginArchive(t, files, 0600), Metadata{Type: "inline", Name: "proof", Description: "Use shared resources."}); err == nil { + t.Errorf("unsupported declaration accepted: %s", input) + } + } +} diff --git a/packages/claude-sdk-adapter/src/adapter.ts b/packages/claude-sdk-adapter/src/adapter.ts index 59fd12a2b..99f377e37 100644 --- a/packages/claude-sdk-adapter/src/adapter.ts +++ b/packages/claude-sdk-adapter/src/adapter.ts @@ -32,7 +32,9 @@ export type Event = export async function execute(request: Start | Prepare, emit: (event: Event) => Promise, abort: AbortController, functions = new FunctionBridge(emit), inputs = new Inputs(immediatePrompt(request)), reads = new WorkspaceReads(emit, abort), directories = new WorkspaceDirectories(emit, abort)): Promise { const definitions = (request.functions ?? []).map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.parameters })); const names = definitions.map(tool => `mcp__functions__${tool.name}`); - const workspace = request.workspace === undefined ? undefined : new WorkspaceProfile(request.cwd, request.workspace, names); + const declarations = request.workspace?.mcp ?? request.mcp_http_servers; + const profile = declarations === undefined ? undefined : new MCPProfile(declarations, names); + const workspace = request.workspace === undefined ? undefined : new WorkspaceProfile(request.cwd, request.workspace, names, profile); const commands = workspace ? new CommandObserver() : undefined; if (request.type === "prepare" && !workspace) throw new Error("invalid_request"); if (workspace && "mcp_http_servers" in request) throw new Error("invalid_request"); @@ -50,7 +52,6 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => } const mcpServers: Record = Object.create(null); if (definitions.length) mcpServers.functions = createFunctionServer(definitions, functions.invoke); - const profile = request.mcp_http_servers === undefined ? undefined : new MCPProfile(request.mcp_http_servers, names); const mcp = profile ? new MCPObserver(profile.identities) : undefined; if (profile) Object.assign(mcpServers, profile.servers); const children: Promise[] = []; @@ -73,7 +74,7 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => systemPrompt: request.system_prompt, ...(request.resume ? { resume: request.resume } : {}), tools: [], allowedTools: profile?.allowed ?? names, strictMcpConfig: true, settingSources: [], - ...(profile ? { + ...(profile && !workspace ? { agent: "parsar_root", disallowedTools: profile.denied, hooks: { PreToolUse: [{ hooks: [profile.beforeTool] }] }, agents: { parsar_root: { description: "Execution root.", prompt: request.system_prompt, @@ -119,7 +120,7 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => if (message.type === "system" && message.subtype === "init") { nativeID = message.session_id; if (!nativeID || (request.resume && nativeID !== request.resume)) throw new Error("unexpected native session"); - if (workspace) workspace.verify(message.tools, message.mcp_servers); + if (workspace) workspace.verify(message.tools, profile ? await stream.mcpServerStatus() : message.mcp_servers, nativeID); else if (profile) profile.verify(message.tools, await stream.mcpServerStatus(), nativeID); else if (message.tools.length !== names.length || message.tools.some(name => !names.includes(name)) || message.mcp_servers.length !== (definitions.length ? 1 : 0) || diff --git a/packages/claude-sdk-adapter/src/mcp.ts b/packages/claude-sdk-adapter/src/mcp.ts index b7ddc3e12..5cebf280f 100644 --- a/packages/claude-sdk-adapter/src/mcp.ts +++ b/packages/claude-sdk-adapter/src/mcp.ts @@ -1,3 +1,4 @@ +import type { StdioServer } from "./mcp_environment.js"; import type { HookCallback, McpServerConfig, McpServerStatus } from "@anthropic-ai/claude-agent-sdk"; export type HTTPServer = { @@ -51,6 +52,7 @@ export class MCPProfile { readonly denied: string[] = []; readonly identities = new Map(); private sessionID = ""; + private localTools = new Set(); private admitted = false; private release!: (ready: boolean) => void; private readonly ready = new Promise(resolve => { this.release = resolve; }); @@ -65,7 +67,7 @@ export class MCPProfile { if (!signal.aborted && await Promise.race([this.ready, interrupted]) && !signal.aborted && this.admitted && input.hook_event_name === "PreToolUse" && input.agent_id === undefined && input.session_id === this.sessionID && (id === undefined || id === input.tool_use_id) && - (this.identities.has(input.tool_name) || this.functions.includes(input.tool_name))) return {}; + (this.identities.has(input.tool_name) || this.functions.includes(input.tool_name) || this.localTools.has(input.tool_name))) return {}; return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Tool is outside the verified execution profile." } }; } finally { @@ -73,30 +75,35 @@ export class MCPProfile { } }; - constructor(private readonly declarations: HTTPServer[], private readonly functions: string[]) { + constructor(private readonly declarations: (HTTPServer | StdioServer)[], private readonly functions: string[]) { this.allowed = [...functions]; for (const server of declarations) { const prefix = `mcp__${server.server_label}__`; - const reference = server.bearer_token_env_var; - if (reference && !process.env[reference]) throw new Error("missing MCP credential environment"); - // An explicit empty Authorization suppresses native OAuth and automatic auth. - // Keep bearer references literal: SDK server configuration enters native argv. - this.servers[server.server_label] = { type: "http", url: server.server_url, alwaysLoad: true, - headers: { Authorization: reference ? `Bearer \${${reference}}` : "" } }; + if ("command" in server) { + this.servers[server.server_label] = { type: "stdio", command: server.command, args: [...server.args], env: {} }; + } else { + const reference = server.bearer_token_env_var; + if (reference && !process.env[reference]) throw new Error("missing MCP credential environment"); + // An explicit empty Authorization suppresses native OAuth and automatic auth. + // Keep bearer references literal: SDK server configuration enters native argv. + this.servers[server.server_label] = { type: "http", url: server.server_url, alwaysLoad: true, + headers: { Authorization: reference ? `Bearer \${${reference}}` : "" } }; + } if (server.allowed_tools === null) this.allowed.push(prefix + "*"); else if (!server.allowed_tools.length) this.denied.push(prefix + "*"); else this.allowed.push(...server.allowed_tools.map(name => nativeToolName(server.server_label, name))); } } - verify(inventory: string[], statuses: McpServerStatus[], sessionID: string): void { + verify(inventory: string[], statuses: McpServerStatus[], sessionID: string, localTools: readonly string[] = []): void { // Native status.config can contain expanded headers. Retain only identities; // never publish or persist the private SDK control response. this.admitted = false; const expected = new Map(); const declared = new Map(this.declarations.map(server => [server.server_label, server])); const seen = new Set(); - const nativeNames = new Set(this.functions); + const baseline = [...this.functions, ...localTools]; + const nativeNames = new Set(baseline); for (const status of statuses) { if (seen.has(status.name)) throw new Error("duplicate native MCP server"); seen.add(status.name); @@ -115,19 +122,20 @@ export class MCPProfile { } } if (seen.size !== declared.size + (this.functions.length ? 1 : 0) || - inventory.length !== expected.size + this.functions.length || new Set(inventory).size !== inventory.length || - inventory.some(name => !expected.has(name) && !this.functions.includes(name))) { + inventory.length !== expected.size + baseline.length || new Set(inventory).size !== inventory.length || + inventory.some(name => !expected.has(name) && !baseline.includes(name))) { throw new Error("unexpected native MCP inventory"); } this.identities.clear(); for (const [name, identity] of expected) this.identities.set(name, identity); + this.localTools = new Set(localTools); this.sessionID = sessionID; this.admitted = true; this.release(true); } verifyRequired(statuses: McpServerStatus[]): void { - for (const server of this.declarations.filter(server => server.required)) { + for (const server of this.declarations.filter(server => "required" in server && server.required)) { const matches = statuses.filter(status => status.name === server.server_label); if (matches.length !== 1 || matches[0].status !== "connected") { throw new Error("required native MCP server unavailable"); @@ -135,5 +143,11 @@ export class MCPProfile { } } + permits(name: string): boolean { return this.admitted && this.identities.has(name); } + + credentialReferences(): string[] { + return this.declarations.flatMap(server => "bearer_token_env_var" in server && server.bearer_token_env_var ? [server.bearer_token_env_var] : []); + } + close(): void { this.admitted = false; this.release(false); } } diff --git a/packages/claude-sdk-adapter/src/mcp_environment.ts b/packages/claude-sdk-adapter/src/mcp_environment.ts new file mode 100644 index 000000000..006bf1b05 --- /dev/null +++ b/packages/claude-sdk-adapter/src/mcp_environment.ts @@ -0,0 +1,36 @@ +import { parseHTTPServers, type HTTPServer } from "./mcp.js"; + +export type StdioServer = { + server_label: string; + command: string; + args: string[]; + allowed_tools: null; +}; +export type EnvironmentMCPServer = HTTPServer | StdioServer; + +// This private projection accepts only the Runtime-owned sandbox entry. Package +// command/env/cwd are resolved by the shared Go helper after entering isolation. +export function parseEnvironmentMCP(value: unknown): EnvironmentMCPServer[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) throw new Error("invalid_request"); + const labels = new Set(); + for (const server of value) { + if (!server || typeof server !== "object" || labels.has(server.server_label)) throw new Error("invalid_request"); + if ("command" in server) { + if (Object.keys(server).some(key => !["server_label", "command", "args", "allowed_tools"].includes(key)) || + typeof server.server_label !== "string" || !/^[a-zA-Z0-9_-]+$/.test(server.server_label) || + server.server_label === "functions" || server.allowed_tools !== null || server.command !== "/usr/bin/python3" || + !Array.isArray(server.args) || server.args.length !== 6 || + server.args[0] !== "-I" || server.args[1] !== "-S" || + server.args[2] !== "/usr/local/bin/agents-api-runtime-initialize" || server.args[3] !== "stdio" || + typeof server.args[4] !== "string" || !server.args[4] || server.args[4].startsWith("/") || + server.args[4].split("/").some((part: string) => !part || part === "." || part === "..") || + /[\x00-\x1f\x7f\\]/.test(server.args[4]) || server.args[5] !== server.server_label) throw new Error("invalid_request"); + } else { + parseHTTPServers([server]); + if (server.allowed_tools !== null || server.required !== undefined) throw new Error("invalid_request"); + } + labels.add(server.server_label); + } + return value; +} diff --git a/packages/claude-sdk-adapter/src/request.ts b/packages/claude-sdk-adapter/src/request.ts index 655c96e11..83d873c80 100644 --- a/packages/claude-sdk-adapter/src/request.ts +++ b/packages/claude-sdk-adapter/src/request.ts @@ -20,7 +20,7 @@ export type Prepare = Omit & { type: "pr // MCP startup confirms its hooks before the native input iterator yields. export function immediatePrompt(request: Start | Prepare): string | undefined { - return request.type === "start" && request.mcp_http_servers === undefined ? request.prompt : undefined; + return request.type === "start" && request.mcp_http_servers === undefined && !request.workspace?.mcp?.length ? request.prompt : undefined; } export function parseRequest(line: string): Start | Prepare { diff --git a/packages/claude-sdk-adapter/src/workspace.ts b/packages/claude-sdk-adapter/src/workspace.ts index 4acac0d3e..5a800a6a2 100644 --- a/packages/claude-sdk-adapter/src/workspace.ts +++ b/packages/claude-sdk-adapter/src/workspace.ts @@ -1,3 +1,5 @@ +import { parseEnvironmentMCP, type EnvironmentMCPServer } from "./mcp_environment.js"; +import type { MCPProfile } from "./mcp.js"; import { parseSkills, workspaceSkills, type WorkspaceSkill } from "./workspace_skills.js"; import type { CanUseTool, HookCallback, Options } from "@anthropic-ai/claude-agent-sdk"; import { lstatSync, realpathSync, statSync } from "node:fs"; @@ -12,6 +14,7 @@ export type Workspace = { dependency_path: string; env_names: string[]; skills?: WorkspaceSkill[]; + mcp?: EnvironmentMCPServer[]; tool_environment?: boolean; system_packages?: boolean; network_access?: "enabled" | "disabled" | "restricted"; @@ -45,7 +48,7 @@ export function parseWorkspace(value: unknown, cwd: string): Workspace | undefin if (value === undefined) return undefined; if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_request"); const config = value as Record; - if (Object.keys(config).some(key => !["home", "state", "scratch", "protected_dirs", "dependency_path", "env_names", "network_access", "allowed_domains", "tool_environment", "system_packages", "skills"].includes(key)) || + if (Object.keys(config).some(key => !["home", "state", "scratch", "protected_dirs", "dependency_path", "env_names", "network_access", "allowed_domains", "tool_environment", "system_packages", "skills", "mcp"].includes(key)) || (config.tool_environment !== undefined && typeof config.tool_environment !== "boolean") || (config.system_packages !== undefined && typeof config.system_packages !== "boolean") || (config.system_packages === true && config.tool_environment !== true) || @@ -54,6 +57,8 @@ export function parseWorkspace(value: unknown, cwd: string): Workspace | undefin typeof config.dependency_path !== "string" || !config.dependency_path || config.env_names.some(name => typeof name !== "string" || !environmentNames.has(name)) || new Set(config.env_names).size !== config.env_names.length) throw new Error("invalid_request"); + const mcp = parseEnvironmentMCP(config.mcp); + if (mcp?.length && config.network_access !== "enabled") throw new Error("invalid_request"); const domains = config.allowed_domains ?? []; const hostname = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i; if (!Array.isArray(domains) || (config.network_access === "restricted" @@ -76,7 +81,7 @@ export class WorkspaceProfile { readonly options: Options; private readonly skillNames: readonly string[]; - constructor(private readonly cwd: string, private readonly config: Workspace, private readonly functions: readonly string[] = []) { + constructor(private readonly cwd: string, private readonly config: Workspace, private readonly functions: readonly string[] = [], private readonly mcp?: MCPProfile) { config = parseWorkspace(config, cwd)!; // SDK history lookup reads the bridge environment, independently of query.env. if (process.env.HOME !== config.home || process.env.CLAUDE_CONFIG_DIR !== config.state || @@ -91,6 +96,10 @@ export class WorkspaceProfile { if (value === undefined) throw new Error("invalid_request"); env[name] = value; } + for (const reference of mcp?.credentialReferences() ?? []) { + if (!process.env[reference]) throw new Error("invalid_request"); + env[reference] = process.env[reference]!; + } if (config.system_packages) { env.CLAUDE_CODE_SHELL_PREFIX = "/usr/local/bin/agents-api-tool-root"; env.PARSAR_RUNTIME_TOOL_SCRATCH = config.scratch; @@ -101,7 +110,7 @@ export class WorkspaceProfile { const protectedRoots = [config.home, config.state, ...config.protected_dirs]; this.options = { env, tools: [...nativeTools, ...skillTools], - ...(skills ? { plugins: skills.paths.map(path => ({ type: "local" as const, path, skipMcpDiscovery: true })) } : {}), allowedTools: [...functions], mcpServers: {}, strictMcpConfig: true, + ...(skills ? { plugins: skills.paths.map(path => ({ type: "local" as const, path, skipMcpDiscovery: true })) } : {}), allowedTools: mcp?.allowed ?? [...functions], mcpServers: {}, strictMcpConfig: true, settingSources: [], permissionMode: "default", persistSession: true, settings: { ...(skills ? { disableSkillShellExecution: true } : {}), @@ -116,10 +125,10 @@ export class WorkspaceProfile { enabled: true, failIfUnavailable: true, autoAllowBashIfSandboxed: false, allowUnsandboxedCommands: false, excludedCommands: [], enableWeakerNestedSandbox: false, enableWeakerNetworkIsolation: false, filesystem: { disabled: false, allowWrite: [cwd, config.scratch, ...(config.tool_environment ? ["/environment/packages"] : [])], denyRead: protectedRoots, - denyWrite: [...protectedRoots, ...(skills ? ["/environment/initialization/capabilities"] : []), + denyWrite: [...protectedRoots, ...(skills || config.mcp?.length ? ["/environment/initialization/capabilities"] : []), ...(config.system_packages ? ["/environment/packages/system"] : [])], allowRead: [] }, credentials: { - envVars: [...new Set([...credentialNames, ...config.env_names])].map(name => ({ name, mode: "deny" })), + envVars: [...new Set([...credentialNames, ...config.env_names, ...(mcp?.credentialReferences() ?? [])])].map(name => ({ name, mode: "deny" })), files: protectedRoots.map(path => ({ path, mode: "deny" })), }, network: { allowedDomains: config.network_access === "enabled" ? ["*"] : config.network_access === "restricted" ? [...config.allowed_domains!] : [], strictAllowlist: true, allowAllUnixSockets: false, allowLocalBinding: false }, @@ -129,7 +138,11 @@ export class WorkspaceProfile { }; } - verify(tools: string[], servers: { name: string; status: string }[]): void { + verify(tools: string[], servers: { name: string; status: string; tools?: { name: string }[] }[], sessionID = ""): void { + if (this.mcp) { + this.mcp.verify(tools, servers as Parameters[1], sessionID, [...nativeTools, ...(this.skillNames.length ? ["Skill"] : [])]); + return; + } const expected = [...nativeTools, ...this.functions, ...(this.skillNames.length ? ["Skill"] : [])]; if (servers.length !== (this.functions.length ? 1 : 0) || servers.some(server => server.name !== "functions" || server.status !== "connected") || @@ -145,6 +158,12 @@ export class WorkspaceProfile { }; readonly beforeTool: HookCallback = async (input, id, { signal }) => { + if (this.mcp) { + const admission = await this.mcp.beforeTool(input, id, { signal }); + if ("hookSpecificOutput" in admission && admission.hookSpecificOutput?.hookEventName === "PreToolUse" && + admission.hookSpecificOutput.permissionDecision === "deny") return admission; + if (input.hook_event_name === "PreToolUse" && this.mcp.permits(input.tool_name)) return admission; + } if (!signal.aborted && input.hook_event_name === "PreToolUse" && input.agent_id === undefined && (id === undefined || id === input.tool_use_id) && this.permits(input.tool_name, input.tool_input)) { if (input.tool_name === "Bash" && this.config.tool_environment) { @@ -166,7 +185,7 @@ export class WorkspaceProfile { private permits(name: string, value: unknown): boolean { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const input = value as Record; - if (this.functions.includes(name)) return true; + if (this.functions.includes(name) || this.mcp?.permits(name)) return true; if (name === "Skill") return typeof input.skill === "string" && this.skillNames.includes(input.skill); if (name === "Bash") return typeof input.command === "string" && !!input.command.trim() && (input.run_in_background === undefined || input.run_in_background === false) && diff --git a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs new file mode 100644 index 000000000..6a23d60dd --- /dev/null +++ b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MCPProfile } from "../dist/mcp.js"; +import { parseEnvironmentMCP } from "../dist/mcp_environment.js"; +import { immediatePrompt, parseStart } from "../dist/request.js"; +import { WorkspaceProfile } from "../dist/workspace.js"; + +const stdio = { server_label: "installed", command: "/usr/bin/python3", allowed_tools: null, + args: ["-I", "-S", "/usr/local/bin/agents-api-runtime-initialize", "stdio", "plugins/installed", "installed"] }; +const native = "mcp__installed__echo_v1"; +const statuses = [{ name: "installed", status: "connected", tools: [{ name: "echo.v1" }] }]; +const baseline = ["Bash", "Read", "Edit"]; + +function fixture(t, declarations = [stdio]) { + const root = realpathSync(mkdtempSync(join(tmpdir(), "parsar-mcp-workspace-"))); + const dirs = Object.fromEntries(["work", "home", "state", "scratch", "secrets", "deps"].map(name => { + const path = join(root, name); mkdirSync(path); return [name, path]; + })); + const config = { home: dirs.home, state: dirs.state, scratch: dirs.scratch, protected_dirs: [dirs.secrets], + dependency_path: dirs.deps, env_names: ["ANTHROPIC_AUTH_TOKEN"], network_access: "enabled", mcp: declarations }; + const previous = process.env; + process.env = { HOME: dirs.home, CLAUDE_CONFIG_DIR: dirs.state, ANTHROPIC_AUTH_TOKEN: "model-secret" }; + t.after(() => { process.env = previous; rmSync(root, { recursive: true, force: true }); }); + const request = { type: "start", prompt: "fixture", model: "fixture", system_prompt: "", cwd: dirs.work, workspace: config }; + return { dirs, config, request }; +} + +test("installed MCP private projection cannot launch arbitrary unsandboxed commands", t => { + const { request } = fixture(t); + assert.deepEqual(parseStart(JSON.stringify(request)), request); + assert.equal(immediatePrompt(request), undefined); + assert.deepEqual(parseEnvironmentMCP([stdio]), [stdio]); + for (const value of [[stdio, stdio], [{ ...stdio, command: "/bin/sh" }], [{ ...stdio, env: { TOKEN: "secret" } }], + [{ ...stdio, args: ["-c", "untrusted"] }], [{ ...stdio, allowed_tools: ["*"] }], + [{ ...stdio, server_url: "https://example.invalid" }], [{ ...stdio, args: [...stdio.args.slice(0, 4), "../escape", "installed"] }]]) { + assert.throws(() => parseEnvironmentMCP(value), /invalid_request/); + } + assert.throws(() => parseStart(JSON.stringify({ ...request, workspace: { ...request.workspace, network_access: "disabled" } })), /invalid_request/); +}); + +test("combined inventory admits exact MCP identities without granting local file authority", async t => { + const { dirs, config } = fixture(t); + const mcp = new MCPProfile([stdio], []); + const workspace = new WorkspaceProfile(dirs.work, config, [], mcp); + const signal = new AbortController().signal; + const input = { hook_event_name: "PreToolUse", session_id: "session", tool_use_id: "call", tool_name: native, tool_input: {} }; + let ready = false; + const waiting = workspace.beforeTool(input, "call", { signal }).then(value => { ready = true; return value; }); + await Promise.resolve(); + assert.equal(ready, false); + workspace.verify([...baseline, native], statuses, "session"); + assert.deepEqual(await waiting, {}); + assert.deepEqual(mcp.identities.get(native), { server: "installed", name: "echo.v1" }); + assert.equal((await workspace.canUseTool(native, {}, { signal })).behavior, "allow"); + assert.equal((await workspace.canUseTool("mcp__installed__undeclared", {}, { signal })).behavior, "deny"); + assert.equal((await workspace.canUseTool("Read", { file_path: join(dirs.secrets, "model.key") }, { signal })).behavior, "deny"); + assert.equal((await workspace.canUseTool("Bash", { command: "pwd", dangerouslyDisableSandbox: true }, { signal })).behavior, "deny"); + assert.equal((await workspace.beforeTool({ ...input, tool_name: "Read", tool_input: { file_path: "ok.txt" } }, "call", { signal })).hookSpecificOutput.updatedInput.file_path, join(dirs.work, "ok.txt")); + for (const fields of [{ session_id: "other" }, { agent_id: "child" }, { tool_name: "mcp__ambient__echo" }]) { + assert.equal((await workspace.beforeTool({ ...input, ...fields }, "call", { signal })).hookSpecificOutput.permissionDecision, "deny"); + } + assert.deepEqual(workspace.options.allowedTools, ["mcp__installed__*"]); + assert.deepEqual(workspace.options.tools, baseline); + assert.ok(workspace.options.sandbox.filesystem.denyWrite.includes("/environment/initialization/capabilities")); + assert.equal(workspace.options.sandbox.allowUnsandboxedCommands, false); + mcp.close(); + assert.equal((await workspace.canUseTool(native, {}, { signal })).behavior, "deny"); +}); + +test("combined inventory rejects extra servers, tools and normalized identity collisions", t => { + const { dirs, config } = fixture(t); + for (const [tools, servers] of [ + [[...baseline, native, "Write"], statuses], + [[...baseline, native], [...statuses, { name: "ambient", status: "connected", tools: [] }]], + [[...baseline, native], [{ ...statuses[0], tools: [{ name: "echo.v1" }, { name: "echo_v1" }] }]], + [[...baseline, native], [{ ...statuses[0], status: "pending" }]], + ]) { + const mcp = new MCPProfile([stdio], []); + assert.throws(() => new WorkspaceProfile(dirs.work, config, [], mcp).verify(tools, servers, "session")); + mcp.close(); + } +}); + +test("workspace bearer references reach native HTTP and stay denied to Bash", t => { + const reference = "PARSAR_MCP_BEARER_ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const http = { server_label: "remote", server_url: "https://example.invalid/mcp", allowed_tools: null, bearer_token_env_var: reference }; + const { dirs, config } = fixture(t, [http]); + process.env[reference] = "selected-user-token"; + process.env.PARSAR_MCP_BEARER_UNSELECTED = "other-token"; + const mcp = new MCPProfile([http], []); + const workspace = new WorkspaceProfile(dirs.work, config, [], mcp); + assert.equal(workspace.options.env[reference], "selected-user-token"); + assert.equal(workspace.options.env.PARSAR_MCP_BEARER_UNSELECTED, undefined); + assert.deepEqual(mcp.servers.remote.headers, { Authorization: `Bearer \${${reference}}` }); + assert.ok(workspace.options.sandbox.credentials.envVars.some(entry => entry.name === reference && entry.mode === "deny")); + assert.equal(JSON.stringify(mcp.servers).includes("selected-user-token"), false); + delete process.env[reference]; + assert.throws(() => new WorkspaceProfile(dirs.work, config, [], mcp), /invalid_request/); +}); + +test("MCP identity validation preserves host functions and Bash environment wrapping", async t => { + const { dirs, config } = fixture(t); + const functions = ["mcp__functions__lookup"]; + const mcp = new MCPProfile([stdio], functions); + const workspace = new WorkspaceProfile(dirs.work, { ...config, tool_environment: true }, functions, mcp); + workspace.verify([...baseline, native, ...functions], [...statuses, { name: "functions", status: "connected" }], "session"); + const signal = new AbortController().signal; + const input = { hook_event_name: "PreToolUse", session_id: "session", tool_use_id: "call", tool_name: "Bash", tool_input: { command: "printf ok" } }; + assert.match((await workspace.beforeTool(input, "call", { signal })).hookSpecificOutput.updatedInput.command, /^\. \/environment\/initialization\/tool-env.sh/); + assert.equal((await workspace.canUseTool(functions[0], {}, { signal })).behavior, "allow"); + mcp.close(); +}); diff --git a/services/agents-api/README.md b/services/agents-api/README.md index 6eebe57ec..a42e4e6f6 100644 --- a/services/agents-api/README.md +++ b/services/agents-api/README.md @@ -636,7 +636,11 @@ See [Environment contracts and remaining work](../../contracts/agents-api/enviro ### HTTP MCP execution -MCP runs on trusted service-side compute. Codex supports `environment:{"type":"none"}` +This section covers `agent.tools` with `connection_origin: "service"`. +Environment-origin Plugin declarations use the separate +[initialization and transport contract](../../contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins). + +Service-origin MCP runs on trusted service-side compute. Codex supports `environment:{"type":"none"}` or a `self_hosted` Environment; Claude SDK supports HTTP MCP with `environment:{"type":"none"}`. Inline or saved Agent tools may declare: diff --git a/services/agents-api/deploy/codex/tool-env.py b/services/agents-api/deploy/codex/tool-env.py index 56e1fd4de..36b451b59 100644 --- a/services/agents-api/deploy/codex/tool-env.py +++ b/services/agents-api/deploy/codex/tool-env.py @@ -15,7 +15,9 @@ raise ValueError('invalid hook input') if not Path('/environment/initialization/tool-env.sh').is_file(): raise ValueError('missing tool environment') - rewritten = '. /environment/initialization/tool-env.sh && eval -- ' + shlex.quote(command) + # POSIX sh does not accept eval --. A leading space prevents option parsing + # while preserving the native shell, cwd and command text. + rewritten = '. /environment/initialization/tool-env.sh && eval ' + shlex.quote(' ' + command) if os.environ.get('PARSAR_RUNTIME_SYSTEM_PACKAGES') == '1': rewritten = '/usr/bin/python3 -I -S /usr/local/bin/agents-api-tool-root ' + shlex.quote(command) print(json.dumps({'hookSpecificOutput': {'hookEventName': 'PreToolUse', diff --git a/services/agents-api/deploy/runtime/initialize.py b/services/agents-api/deploy/runtime/initialize.py index caba7df92..04a492293 100644 --- a/services/agents-api/deploy/runtime/initialize.py +++ b/services/agents-api/deploy/runtime/initialize.py @@ -4,12 +4,15 @@ operation; it never retries, schedules, selects a harness or interprets templates. """ import base64 +import ctypes import hashlib import json import os from pathlib import Path import re import runpy +import select +import signal import shlex import subprocess import sys @@ -215,7 +218,76 @@ def run(request): stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) +def stdio_lifetime(args): + """Bind sandbox lifetime to the native process, not its transient spawn thread.""" + parent = os.getppid() + if parent <= 1: + raise ValueError('native parent unavailable') + parent_fd = os.pidfd_open(parent) + child_fd = None + child = None + try: + watched = select.poll() + watched.register(parent_fd, select.POLLIN) + if os.getppid() != parent or watched.poll(0): + raise ValueError('native parent exited') + # Keep exited children waitable until their pidfd has been acquired. + signal.signal(signal.SIGCHLD, signal.SIG_DFL) + # Bind before fork. This entry is single-threaded; the child only sets + # its death signal and checks the captured parent before exec. bwrap sets + # its own signal later, leaving a demonstrated startup gap without this. + owner = os.getpid() + prctl = ctypes.CDLL(None).prctl + prctl.argtypes = [ctypes.c_int] + [ctypes.c_ulong] * 4 + prctl.restype = ctypes.c_int + + def bind_parent(): + if prctl(1, signal.SIGKILL, 0, 0, 0) != 0 or os.getppid() != owner: + os._exit(1) + + # No protocol forwarding. bwrap's parent-death signal now targets this + # stable launcher; its PID namespace owns descendants. + child = subprocess.Popen(args, env=BASE_ENV, close_fds=True, preexec_fn=bind_parent) + child_fd = os.pidfd_open(child.pid) + watched.register(child_fd, select.POLLIN) + events = dict(watched.poll()) + if parent_fd in events: + child.kill() + child.wait() + return 1 + result = child.wait() + return result if result >= 0 else 128 - result + finally: + if child is not None: + if child.poll() is None: + child.kill() + child.wait() + if child_fd is not None: + os.close(child_fd) + os.close(parent_fd) + + +def stdio(package, server): + """Preserve the native MCP descriptors while entering the existing sandbox.""" + roots() + args = sandbox('enabled', '/workspace') + helper = '/tmp/agents-api-mcp-exec' + # System-package roots predate daemon installation. Mount only the fixed + # static helper, never native configuration, credentials or Runtime state. + args[-1:-1] = ['--ro-bind', '/usr/local/bin/parsar-daemon', helper] + args += [helper, 'runtime-mcp-exec', package, server] + return stdio_lifetime(args) + + def main(): + if len(sys.argv) != 1: + try: + if len(sys.argv) != 4 or sys.argv[1] != 'stdio': + raise ValueError('invalid stdio invocation') + return stdio(sys.argv[2], sys.argv[3]) + except Exception: + print('Environment MCP unavailable', file=sys.stderr) + return 1 try: raw = sys.stdin.buffer.read(MAX_INPUT + 1) if len(raw) > MAX_INPUT: diff --git a/services/agents-api/deploy/runtime/initialize_stdio_test.py b/services/agents-api/deploy/runtime/initialize_stdio_test.py new file mode 100644 index 000000000..1f331d4d7 --- /dev/null +++ b/services/agents-api/deploy/runtime/initialize_stdio_test.py @@ -0,0 +1,150 @@ +"""Real stdio lifetime checks: python3 -I -S in a disposable packaged Runtime. +Requires writable /environment roots and the deployed nested-sandbox profile. +No native harness or model is involved. +""" +import json +import os, sys +from pathlib import Path +import runpy +import select, signal +import subprocess +import tempfile, threading, time + +HELPER = '/usr/local/bin/agents-api-runtime-initialize' +SELF = ['/usr/bin/python3', '-I', '-S', str(Path(__file__).resolve())] +WRITER = "import sys,time\nfor n in range(300):\n with open(sys.argv[1],'ab') as f: f.write(b'x')\n time.sleep(.1)\n" +SERVER = """import subprocess,sys +subprocess.Popen([sys.executable,'-I','-S','-c',sys.argv[2],sys.argv[1]], + start_new_session=True,stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) +for line in sys.stdin: + print(line.rstrip(),flush=True) +""" + +def until(check): + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + value = check() + if value: + return value + time.sleep(.01) + raise AssertionError('Process condition did not settle within five seconds') + +def exited(fd): + poll = select.poll() + poll.register(fd, select.POLLIN) + assert poll.poll(5000), 'Owned process survived' + +def close_process(fd): + if fd is not None: + try: + signal.pidfd_send_signal(fd, signal.SIGKILL) + except ProcessLookupError: + pass + os.close(fd) + +def guard(directory, mode): + runtime = runpy.run_path(HELPER) + workspace = '/workspace/' + directory.name + if mode == 'startup': + # Stop the actual forked child before its parent binding, without + # replacing Popen, prctl, exec or any production process operation. + def stop_before_binding(frame, event, arg): + if event == 'call' and frame.f_code.co_name == 'bind_parent': + sys.settrace(None) + (directory / 'stopped').write_text(str(os.getpid())) + os.kill(os.getpid(), signal.SIGSTOP) + return stop_before_binding + sys.settrace(stop_before_binding) + command = ['/bin/sh', '-c', 'echo escaped > ' + workspace + '/escaped'] + elif mode.startswith('exit'): + command = ['/bin/sh', '-c', 'exit ' + mode[4:]] + else: + command = ['/usr/bin/python3', '-I', '-S', '-c', SERVER, workspace + '/ticks', WRITER] + return runtime['stdio_lifetime'](runtime['sandbox']('enabled', '/workspace') + command) + +def native_parent(directory): + signal.signal(signal.SIGUSR1, lambda *_: sys.exit(0)) + receipt = {} + def start(): + receipt['creator_tid'] = threading.get_native_id() + child = subprocess.Popen(SELF + ['guard', str(directory), 'server']) + receipt['wrapper_pid'] = child.pid + until(lambda: (directory / 'ticks').exists()) # Exit the thread only after bwrap is active. + thread = threading.Thread(target=start) + thread.start() + thread.join() + until(lambda: not Path('/proc', str(os.getpid()), 'task', str(receipt['creator_tid'])).exists()) + (directory / 'ready').write_text(json.dumps(receipt)) + signal.pause() + +def parent_exit(directory, death): + parent = subprocess.Popen(SELF + ['parent', str(directory)], stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + wrapper_fd = None + try: + until(lambda: (directory / 'ready').exists() and (directory / 'ready').stat().st_size) + receipt = json.loads((directory / 'ready').read_text()) + wrapper_fd = os.pidfd_open(receipt['wrapper_pid']) + ticks = directory / 'ticks' + until(lambda: ticks.exists() and ticks.stat().st_size >= 3) + parent.stdin.write(b'creator-thread-exited\n') + parent.stdin.flush() + assert select.select([parent.stdout], [], [], 5)[0], 'Inherited stdout closed or stalled' + assert parent.stdout.readline() == b'creator-thread-exited\n' + before = ticks.stat().st_size + until(lambda: ticks.stat().st_size > before) + parent.send_signal(death) + assert parent.wait(timeout=5) == (0 if death == signal.SIGUSR1 else -signal.SIGKILL) + exited(wrapper_fd) + time.sleep(.2) + stopped = ticks.read_bytes() + time.sleep(.4) + assert ticks.read_bytes() == stopped, 'Detached sandbox descendant survived parent exit' + finally: + if parent.poll() is None: + parent.kill() + parent.wait(timeout=5) + close_process(wrapper_fd) + for stream in (parent.stdin, parent.stdout, parent.stderr): + stream.close() + +def startup_kill(directory): + wrapper = subprocess.Popen(SELF + ['guard', str(directory), 'startup']) + child_fd = None + try: + until(lambda: (directory / 'stopped').exists() and (directory / 'stopped').stat().st_size) + child = int((directory / 'stopped').read_text()) + child_fd = os.pidfd_open(child) + until(lambda: '\nState:\tT' in Path('/proc', str(child), 'status').read_text()) + wrapper.kill() + wrapper.wait(timeout=5) + signal.pidfd_send_signal(child_fd, signal.SIGCONT) + exited(child_fd) + assert not (directory / 'escaped').exists(), 'Child executed after launcher died before exec' + finally: + if wrapper.poll() is None: + wrapper.kill() + wrapper.wait(timeout=5) + close_process(child_fd) + +def main(): + for name in ('workspace', 'packages', 'initialization'): + Path('/environment', name).mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(prefix='stdio-lifetime-', dir='/environment/workspace') as work: + directory = Path(work) + for code in (0, 7): + result = subprocess.run(SELF + ['guard', work, 'exit' + str(code)], capture_output=True, timeout=5) + assert result.returncode == code and not result.stdout, 'Immediate exit or stdout changed' + for death in (signal.SIGUSR1, signal.SIGKILL): + for name in ('ready', 'ticks'): + (directory / name).unlink(missing_ok=True) + parent_exit(directory, death) + startup_kill(directory) + print(json.dumps({'stdio_lifetime': 'passed', 'creator_thread_exit': True, + 'parent_normal_and_kill': True, 'immediate_exit': True, 'preexec_launcher_kill': True})) + +if __name__ == '__main__': + if len(sys.argv) > 1: + sys.exit(guard(Path(sys.argv[2]), sys.argv[3]) if sys.argv[1] == 'guard' else native_parent(Path(sys.argv[2]))) + main() diff --git a/services/agents-api/internal/api/environment_templates.go b/services/agents-api/internal/api/environment_templates.go index 8c16603a9..d2e47c0e8 100644 --- a/services/agents-api/internal/api/environment_templates.go +++ b/services/agents-api/internal/api/environment_templates.go @@ -87,14 +87,14 @@ func readTemplateInput(w http.ResponseWriter, r *http.Request) (store.Environmen } in, err := decodeTemplateInput(raw) if err != nil { - writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", "Template fields are invalid or require unsupported initialization. Name, enabled/disabled or exact-domain restricted network, initial files, env, system/npm/Python packages, setup commands inline/referenced Skill ZIPs, skill-only Plugin ZIPs and workspace capability directories are supported.") + writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", "Template fields are invalid or require unsupported initialization. Name, enabled/disabled or exact-domain restricted network, initial files, env, system/npm/Python packages, setup commands inline/referenced Skill ZIPs, Plugin ZIPs and workspace capability directories are supported.") return in, false } return in, true } // @Summary Create an Environment Template -// @Description Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential env, ordered setup_commands, system/npm/Python packages inline/referenced Skill ZIPs, skill-only Plugin ZIPs and workspace-contained capability directories. Omitted/null network defaults to enabled. Restricted network requires 1–100 exact ASCII hostnames; other host forms and populated unsupported installations are rejected before persistence without echoing input. No compute is allocated. Exact hosted error/retry semantics remain unverified. +// @Description Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential env, ordered setup_commands, system/npm/Python packages inline/referenced Skill ZIPs, Plugin ZIPs and workspace-contained capability directories. Omitted/null network defaults to enabled. Restricted network requires 1–100 exact ASCII hostnames; other host forms and populated unsupported installations are rejected before persistence without echoing input. No compute is allocated. Exact hosted error/retry semantics remain unverified. // @Tags Environment Templates // @Accept json // @Produce json @@ -140,7 +140,7 @@ func (h *Handler) getEnvironmentTemplate(w http.ResponseWriter, r *http.Request) } // @Summary Update an Environment Template -// @Description Supplied fields replace atomically; omitted fields remain unchanged. Null name clears and null network resets to the pinned enabled default. Existing Session snapshots and creation retries remain unchanged. Initial files replace as a list; null/empty clears. File data is encrypted separately and excluded from response metadata. Skills replace as a list; null/empty clears. Skill archives are encrypted separately and omitted from responses. Plugins and capability directories replace as lists; null/empty clears. Plugin archives are encrypted and omitted from responses. Capability directories are snapshotted after setup; Plugin MCP activation remains unsupported. Exact hosted no-op timestamp behavior remains unverified. +// @Description Supplied fields replace atomically; omitted fields remain unchanged. Null name clears and null network resets to the pinned enabled default. Existing Session snapshots and creation retries remain unchanged. Initial files replace as a list; null/empty clears. File data is encrypted separately and excluded from response metadata. Skills replace as a list; null/empty clears. Skill archives are encrypted separately and omitted from responses. Plugins and capability directories replace as lists; null/empty clears. Plugin archives are encrypted and omitted from responses. Capability directories are snapshotted after setup. Environment MCP execution requires a qualified native transport and runtime network policy. Exact hosted no-op timestamp behavior remains unverified. // @Tags Environment Templates // @Accept json // @Produce json diff --git a/services/agents-api/tests/official_environment_plugin_mcp.py b/services/agents-api/tests/official_environment_plugin_mcp.py new file mode 100644 index 000000000..f777c76b3 --- /dev/null +++ b/services/agents-api/tests/official_environment_plugin_mcp.py @@ -0,0 +1,375 @@ +"""Fixtures for real native Plugin MCP acceptance through the pinned public API. + +The caller owns Core, Runtime, positive private canaries, model calls, recovery and +cancellation. Reuse official_environment_files.verify_environment_files with +{path: len(body) for path, body in fixture['outputs'].items()}, and +official_session_artifacts.verify_session_artifacts with those same bytes. +These helpers never emulate a model or claim native acceptance from fixture checks. +""" +import base64 +import hashlib +import io +import json +import secrets +import shlex +import zipfile + +from official_environment_plugins import verify_plugin_resources + + +_CHECKS = ['selected_user_env', 'unselected_env_absent', 'native_env_absent', + 'package_cwd', 'private_contents_denied', 'installed_package_readonly'] +_SERVER = r'''import hashlib, json, os, subprocess, sys +from pathlib import Path + +root = Path(__file__).resolve().parent +config = json.loads((root / 'resources/proof.json').read_text()) + +def verify(marker): + assert marker == config['marker'], 'wrong invocation marker' + selected = os.environ.get('PLUGIN_MCP_SELECTED', '') + assert selected and hashlib.sha256(selected.encode()).hexdigest() == config['selected_sha256'], 'selected user env missing or wrong' + assert 'PLUGIN_MCP_UNSELECTED' not in os.environ, 'unselected user env present' + prefixes = ('PARSAR_', 'CODEX_', 'OPENAI_', 'ANTHROPIC_', 'MINIMAX_', 'MOONSHOT_', 'KIMI_') + assert not any(name.startswith(prefixes) or name == 'NATIVE_MCP_CANARY' for name in os.environ), 'native environment present' + assert Path.cwd() == root / 'resources', 'relative package cwd not applied' + assert json.loads(Path('proof.json').read_text()) == config, 'cwd resource differs' + private_paths = json.loads(Path('/workspace/plugin-isolation-paths.json').read_text()) + assert set(private_paths) == {'staging', 'native_history', 'daemon_auth'}, 'positive private paths missing' + assert all(isinstance(path, str) and path.startswith('/') for path in private_paths.values()), 'invalid private paths' + for path in private_paths.values(): + try: + with open(path, 'rb') as stream: + exposed = stream.read(1) + except OSError: + continue + assert not exposed, 'private Runtime content readable' + for path in [root / '.codex-plugin/plugin.json', root / '.mcp.json', root / 'resources/proof.json']: + try: + with path.open('r+b'): + pass + except OSError: + continue + raise AssertionError('installed package writable') + return {'marker': marker, 'server': config['server'], 'checks': config['checks']} + +def invoke(name, arguments): + proof = verify(arguments.get('marker')) + if name == 'verify': + directory = Path('/workspace/outputs') + directory.mkdir(exist_ok=True) + (directory / (config['server'] + '.json')).write_text(json.dumps(proof, sort_keys=True) + '\n') + elif name == 'hold': + directory = Path('/workspace/plugin-mcp-hold') + directory.mkdir(exist_ok=True) + (directory / 'invocation.json').write_text(json.dumps(proof, sort_keys=True) + '\n') + # Only a real tools/call starts this detached descendant. Its bounded + # lifetime prevents a failed operator run from leaving an endless probe. + writer = "import json,sys,time\nfrom pathlib import Path\np=Path(sys.argv[1])\nfor tick in range(720):\n with p.open('a') as f: f.write(json.dumps({'marker':sys.argv[2],'tick':tick})+'\\n')\n time.sleep(0.25)\n" + subprocess.run([sys.executable, '-I', '-S', '-c', writer, + str(directory / 'ticks.jsonl'), proof['marker']], + start_new_session=True, check=True) + else: + raise AssertionError('unknown tool') + return {'content': [{'type': 'text', 'text': json.dumps(proof, sort_keys=True)}], + 'structuredContent': proof, 'isError': False} + +for line in sys.stdin: + request = json.loads(line) + if 'id' not in request: + continue + method, params = request.get('method'), request.get('params', {}) + response = {'jsonrpc': '2.0', 'id': request['id']} + try: + if method == 'initialize': + response['result'] = {'protocolVersion': params['protocolVersion'], + 'capabilities': {'tools': {}}, + 'serverInfo': {'name': config['server'], 'version': '1.0.0'}} + elif method == 'ping': + response['result'] = {} + elif method == 'tools/list': + response['result'] = {'tools': [{'name': name, + 'description': description, + 'inputSchema': {'type': 'object', 'properties': {'marker': {'type': 'string'}}, + 'required': ['marker'], 'additionalProperties': False}} + for name, description in [('verify', 'Verify the installed package and isolated environment; write workspace proof.'), + ('hold', 'Write invocation proof and keep a descendant writing for cancellation verification.')]]} + elif method == 'tools/call': + try: + response['result'] = invoke(params['name'], params.get('arguments', {})) + except Exception as error: + # Exception text, paths and environment values may be private. + response['result'] = {'content': [{'type': 'text', 'text': 'PLUGIN_MCP_CHECK_FAILED:' + type(error).__name__}], 'isError': True} + else: + response['error'] = {'code': -32601, 'message': 'Method not found'} + except Exception: + response['error'] = {'code': -32602, 'message': 'Invalid request'} + print(json.dumps(response), flush=True) +''' + + +def _skill(name, marker, output): + script = ("from pathlib import Path\n" + "root = Path(__file__).resolve().parent\n" + "assert '/initialization/capabilities/' in str(root)\n" + "Path('/workspace/outputs').mkdir(exist_ok=True)\n" + "Path(" + repr(output) + ").write_bytes((root / 'proof.txt').read_bytes())\n" + "print('INSTALLED_PLUGIN_SKILL_VERIFIED')\n") + manifest = ('---\nname: ' + name + '\ndescription: Execute the installed ' + name + ' proof script.\n---\n' + 'Run `python3 check.py` from this installed Skill directory using native tools. ' + 'Use this packaged script; do not recreate it.\n') + return {'SKILL.md': manifest, 'check.py': script, 'proof.txt': marker + '\n'} + + +def _package(server, marker, selected, skill=False): + manifest = {'name': server, 'description': 'Native MCP isolation proof.', 'mcpServers': './.mcp.json'} + files = {'.mcp.json': json.dumps({'mcpServers': {server: { + 'command': 'python3', 'args': ['../server.py'], 'cwd': 'resources', + 'env_vars': ['PLUGIN_MCP_SELECTED']}}}), 'server.py': _SERVER, + 'resources/proof.json': json.dumps({'server': server, 'marker': marker, + 'selected_sha256': hashlib.sha256(selected.encode()).hexdigest(), 'checks': _CHECKS})} + if skill: + manifest['skills'] = ['./skills'] + files.update({'skills/combined/' + path: body for path, body in _skill( + 'combined-mcp-skill', marker, '/workspace/outputs/combined-skill.txt').items()}) + files['.codex-plugin/plugin.json'] = json.dumps(manifest) + return files + + +def _inline_plugin(name, files): + archive = io.BytesIO() + with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED) as output: + for path, body in files.items(): + output.writestr('proof/' + path, body) + return {'type': 'inline', 'name': name, 'description': 'Native MCP isolation proof.', + 'source': {'type': 'base64', 'media_type': 'application/zip', + 'data': base64.b64encode(archive.getvalue()).decode()}} + + +def plugin_mcp_fixture(): + """Return one hosted configuration and exact expected public proof bytes. + + Before a native Turn, the runner must create nonempty private canary files + outside tool authority and publish only their paths in plugin-isolation-paths.json. + Never log the returned env or source bodies. The marker itself is nonsecret. + """ + marker = 'plugin-mcp-proof-' + secrets.token_hex(20) + env = {'PLUGIN_MCP_SELECTED': 'selected-' + secrets.token_hex(24), + 'PLUGIN_MCP_UNSELECTED': 'unselected-' + secrets.token_hex(24)} + servers = ['mcp_only_proof', 'combined_proof', 'generated_proof'] + plugins = [_inline_plugin(name, _package(name, marker, env['PLUGIN_MCP_SELECTED'], skill=index == 1)) + for index, name in enumerate(servers[:2])] + exact, parent = '/workspace/generated/mcp-exact', '/workspace/generated/skill-parent' + generated = {exact + '/' + path: body for path, body in _package( + servers[2], marker, env['PLUGIN_MCP_SELECTED']).items()} + # Selecting the parent discovers a nested Skill, but never the child's MCP. + child = _package('unselected_child_mcp', marker, env['PLUGIN_MCP_SELECTED']) + child_manifest = json.loads(child['.codex-plugin/plugin.json']) + child_manifest['skills'] = ['./skills'] + child['.codex-plugin/plugin.json'] = json.dumps(child_manifest) + child.update({'skills/parent/' + path: body for path, body in _skill( + 'parent-discovered-skill', marker, '/workspace/outputs/parent-skill.txt').items()}) + generated.update({parent + '/child/' + path: body for path, body in child.items()}) + seed = json.dumps(generated) + initial = [{'type': 'inline', 'path': '/workspace/plugin-mcp-seed.json', + 'data': base64.b64encode(seed.encode()).decode()}] + setup = ("import json\nfrom pathlib import Path\n" + "for name, body in json.loads(Path('/workspace/plugin-mcp-seed.json').read_text()).items():\n" + " p = Path(name)\n p.parent.mkdir(parents=True, exist_ok=True)\n p.write_text(body)\n" + "p = Path('/workspace/plugin-mcp-setup-count')\n" + "p.write_text(str(int(p.read_text()) + 1) if p.exists() else '1')\n") + outputs = {'/workspace/outputs/' + server + '.json': (json.dumps( + {'marker': marker, 'server': server, 'checks': _CHECKS}, sort_keys=True) + '\n').encode() + for server in servers} + outputs.update({path: (marker + '\n').encode() for path in [ + '/workspace/outputs/combined-skill.txt', '/workspace/outputs/parent-skill.txt']}) + prompt = ('Use the installed combined-mcp-skill and parent-discovered-skill Skills and run their packaged ' + 'check.py scripts exactly as instructed. Then call the native MCP verify tool on each of ' + + ', '.join(servers) + ' exactly once, in that order, with {"marker": ' + json.dumps(marker) + '}. ' + 'Use real native MCP calls; do not recreate servers, scripts or proof files. Do not call hold. ' + 'Report only the server names and success or failure, never environment values.') + return {'plugins': plugins, 'files': initial, 'setup_commands': [{'command': 'python3 -c ' + shlex.quote(setup)}], + 'capability_directories': [exact, parent], 'env': env, 'marker': marker, + 'servers': servers, 'forbidden_servers': ['unselected_child_mcp'], 'outputs': outputs, + 'prompt': prompt, 'source_paths': ['/workspace/generated', '/workspace/plugin-mcp-seed.json'], + 'hold_server': servers[0], 'hold_paths': {'invocation': '/workspace/plugin-mcp-hold/invocation.json', + 'ticks': '/workspace/plugin-mcp-hold/ticks.jsonl'}, + 'hold_prompt': 'Call the native MCP hold tool on ' + servers[0] + ' exactly once with {"marker": ' + + json.dumps(marker) + '}. Wait for it; do not use shell tools or recreate its effects.'} + + +def _assert_private_body(value, fixture): + serialized = json.dumps(value) + secrets_to_check = list(fixture['env'].values()) + [plugin['source']['data'] for plugin in fixture['plugins']] + secrets_to_check += [item['data'] for item in fixture['files']] + assert all(secret not in serialized for secret in secrets_to_check), 'Private initialization body exposed' + + +def verify_plugin_mcp_metadata(client, session, fixture): + expected = [{key: plugin[key] for key in ['type', 'name', 'description']} for plugin in fixture['plugins']] + resource = client.beta.agents.environments.retrieve(session.environment.id).to_dict() + assert session.to_dict()['environment']['capability_directories'] == fixture['capability_directories'] + for value in [session.to_dict()['environment'], resource]: + assert value['plugins'] == expected + assert value['skills'] == [], 'Configured metadata must not expose discovered Skill inventory' + assert 'env' not in value and 'setup_commands' not in value + _assert_private_body(value, fixture) + return {'plugins': expected, 'capability_directories': fixture['capability_directories']} + + +def verify_plugin_mcp_resources(client, foreign, http, fixture=None): + """Reuse Skill Plugin CRUD/isolation checks; add MCP-only and combined bodies. + + Public absence is not proof of encryption at rest. The runner must separately + inspect its task-owned database rows using the existing encrypted-body checks. + """ + verify_plugin_resources(client, foreign, http) + fixture = fixture or plugin_mcp_fixture() + api = client.beta.agents.environments.templates + configuration = {key: fixture[key] for key in ['plugins', 'files', 'setup_commands', 'capability_directories', 'env']} + template = api.create(**configuration) + endpoint = str(client.base_url).rstrip('/') + '/agents/environments/templates/' + template.id + headers = {'Authorization': 'Bearer ' + client.api_key, 'OpenAI-Beta': 'agents=v1'} + expected = [{key: plugin[key] for key in ['type', 'name', 'description']} for plugin in fixture['plugins']] + try: + response = http.get(endpoint, headers=headers) + assert response.status_code == 200 + bodies = [template.to_dict(), api.retrieve(template.id).to_dict(), response.json()] + bodies.append(api.update(template.id, name='Preserved MCP packages').to_dict()) + for body in bodies: + assert body['plugins'] == expected + assert body['capability_directories'] == fixture['capability_directories'] + assert 'env' not in body and 'setup_commands' not in body + _assert_private_body(body, fixture) + for method in ['GET', 'POST', 'DELETE']: + response = http.request(method, endpoint, + headers={**headers, 'Authorization': 'Bearer ' + foreign.api_key}, + **({'json': {'plugins': []}} if method == 'POST' else {})) + assert response.status_code == 404 + _assert_private_body(response.json(), fixture) + assert api.retrieve(template.id).to_dict()['plugins'] == expected + return {'plugins': expected, 'public_bodies_private': True, 'foreign_status': 404} + finally: + api.delete(template.id) + + +def _tool_proof(output): + # Claude retains the native content list; Codex/MiniMax retain the MCP object. + # Assert the same fixture result without rewriting the persisted native output. + assert isinstance(output, (dict, list)), 'Unexpected native MCP result shape' + if isinstance(output, dict): + assert output.get('isError') is not True, 'Native MCP reported a tool error' + content = output if isinstance(output, list) else output.get('content') + if isinstance(content, str): + # Claude SDK preserves this native result as text plus structuredContent. + proof = json.loads(content) + assert output.get('structuredContent') == proof, 'Native structured and text results differ' + return proof + assert isinstance(content, list) and len(content) == 1 and content[0]['type'] == 'text' + proof = json.loads(content[0]['text']) + if isinstance(output, dict) and 'structuredContent' in output: + assert output['structuredContent'] == proof, 'Native structured and text results differ' + return proof + + +def verify_plugin_mcp_items(client, http, session_id, turn_id, fixture, *, + expected_status='completed', tool='verify', servers=None, + events=None, expected_turn_status='completed'): + """Check exact native identities, saved result bodies, and SDK/raw ordering. + + For cancellation, pass tool='hold', servers=[fixture['hold_server']] and the + precise expected settled status. Observe hold_paths growth before cancellation + and stability afterwards in the runner; Items alone cannot prove effect cleanup. + Pass recorded event.to_dict() values to verify item.added < item.done < the + terminal Turn event. A cancellation run must set expected_turn_status='cancelled'. + """ + assert tool in ['verify', 'hold'] + assert expected_status in ['completed', 'failed', 'incomplete', 'in_progress'] + servers = fixture['servers'] if servers is None else servers + assert servers and len(set(servers)) == len(servers) + items = client.beta.agents.sessions.items + saved = [item.to_dict() for item in items.list(session_id, limit=3, order='asc')] + assert len({item['id'] for item in saved}) == len(saved) + assert [item.to_dict() for item in items.list(session_id, limit=5)] == list(reversed(saved)) + endpoint = str(client.base_url).rstrip('/') + '/agents/sessions/' + session_id + '/items' + headers = {'Authorization': 'Bearer ' + client.api_key, 'OpenAI-Beta': 'agents=v1'} + raw, params = [], {'limit': 3, 'order': 'asc'} + for _ in range(len(saved) + 1): + response = http.get(endpoint, headers=headers, params=params) + assert response.status_code == 200 + page = response.json() + raw.extend(page['data']) + if not page['has_more']: + break + assert page['data'] and page['data'][-1]['id'] != params.get('after'), 'Items cursor did not advance' + params['after'] = page['data'][-1]['id'] + else: + raise AssertionError('Items pagination did not terminate') + assert raw == saved, 'SDK and raw saved Items differ' + _assert_private_body(saved, fixture) + assert not any(item.get('server_label') in fixture['forbidden_servers'] for item in saved) + group = [item for item in saved if item['turn_id'] == turn_id] + assert group and group[0]['type'] == 'message' and group[0]['role'] == 'user' + calls = [item for item in group if item['type'] == 'mcp_call'] + assert [item['server_label'] for item in calls] == servers, 'Native server identities or requested invocation order differ' + for item in calls: + assert item['id'] and item['turn_id'] == turn_id + assert item['name'] == tool and item['status'] == expected_status + assert item['arguments'] == {'marker': fixture['marker']} + if expected_status == 'completed': + assert item.get('error') is None + assert _tool_proof(item['output']) == { + 'marker': fixture['marker'], 'server': item['server_label'], 'checks': _CHECKS} + if expected_status == 'completed': + answers = [item for item in group if item['type'] == 'message' and item.get('role') == 'assistant'] + assert answers + assert answers[-1]['status'] == 'completed' + evidence = {'session_id': session_id, 'turn_id': turn_id, 'calls': calls, + 'item_ids_asc': [item['id'] for item in saved], 'sdk_raw_equal': True} + if events is not None: + assert expected_turn_status in ['completed', 'cancelled', 'failed'] + assert expected_status != 'in_progress', 'Pass settled Items for terminal stream assertions' + _assert_private_body(events, fixture) + assert len({event['event_id'] for event in events}) == len(events), 'Duplicate SSE event identity' + terminals = [(index, event) for index, event in enumerate(events) + if event['type'] in ['agent.session.turn.' + status for status in ['completed', 'cancelled', 'failed']] + and event['turn']['id'] == turn_id] + assert len(terminals) == 1 and terminals[0][1]['type'] == 'agent.session.turn.' + expected_turn_status + transitions, completed_call_positions = [], [] + for call in calls: + added = [(index, event) for index, event in enumerate(events) + if event['type'] == 'agent.session.turn.item.added' and event['item']['id'] == call['id']] + done = [(index, event) for index, event in enumerate(events) + if event['type'] == 'agent.session.turn.item.done' and event['item']['id'] == call['id']] + assert len(added) == len(done) == 1, 'Missing or duplicate native MCP transitions' + start, end = added[0], done[0] + assert start[0] < end[0] < terminals[0][0], 'Native MCP and terminal Turn ordering differs' + assert start[1]['item']['status'] == 'in_progress', 'No observable native MCP start' + # Native ACP can identify a call before its arguments arrive. + assert start[1]['item']['arguments'] in [None, {}, {'marker': fixture['marker']}], 'Unexpected initial native MCP arguments' + for event in [start[1], end[1]]: + assert event['turn_id'] == turn_id + assert event['item']['type'] == 'mcp_call' + assert event['item']['server_label'] == call['server_label'] and event['item']['name'] == tool + assert end[1]['item'] == call, 'Saved MCP item differs from its completed SSE item' + assert type(start[1]['output_index']) is int and start[1]['output_index'] == end[1]['output_index'] + completed_call_positions.append(end[0]) + transitions.append({'item_id': call['id'], 'added_event_id': start[1]['event_id'], + 'done_event_id': end[1]['event_id'], 'output_index': start[1]['output_index']}) + if expected_status == 'completed': + # Items retain their first-observation position while streaming. + # A native assistant may start before tools and complete after them. + completed_answers = [(index, event) for index, event in enumerate(events) + if event['type'] == 'agent.session.turn.item.done' + and event['turn_id'] == turn_id + and event['item']['type'] == 'message' + and event['item'].get('role') == 'assistant' + and event['item']['status'] == 'completed'] + assert completed_answers, 'Missing completed assistant SSE item' + answer_position, answer_event = completed_answers[-1] + assert max(completed_call_positions) < answer_position < terminals[0][0], 'Assistant completion must follow MCP results and precede the terminal Turn' + assert answer_event['item'] in answers, 'Completed assistant SSE item differs from saved Items' + evidence['assistant_done_event_id'] = answer_event['event_id'] + evidence['transitions'] = transitions + evidence['terminal_event_id'] = terminals[0][1]['event_id'] + return evidence