diff --git a/apps/daemon/internal/agent/claudesdk/mcp_environment.go b/apps/daemon/internal/agent/claudesdk/mcp_environment.go index 5c9e1db31..73720fe3c 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_environment.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_environment.go @@ -21,6 +21,13 @@ func prepareRuntimeMCP(req proto.PromptRequestPayload) ([]environmentMCPServer, if err != nil { return nil, nil, err } + return mcpServers(bindings, localworkspace.MCPStdioCommand) +} + +// mcpServers renders resolved bindings, each stdio binding with the command +// and arguments stdio gives it and each credential in a private environment +// variable. +func mcpServers(bindings []agent.MCPBinding, stdio func(proto.EnvironmentMCP) (string, []string)) ([]environmentMCPServer, []string, error) { var servers []environmentMCPServer var env []string for _, binding := range bindings { @@ -28,7 +35,7 @@ func prepareRuntimeMCP(req proto.PromptRequestPayload) ([]environmentMCPServer, return nil, nil, fmt.Errorf("claudesdk: unsupported MCP identity") } if binding.Stdio != nil { - command, args := localworkspace.MCPStdioCommand(*binding.Stdio) + command, args := stdio(*binding.Stdio) servers = append(servers, environmentMCPServer{mcpHTTPServer: mcpHTTPServer{ServerLabel: binding.ServerLabel}, Command: command, Args: args}) continue } diff --git a/apps/daemon/internal/agent/claudesdk/view.go b/apps/daemon/internal/agent/claudesdk/view.go index d9ca20e6e..f859518d8 100644 --- a/apps/daemon/internal/agent/claudesdk/view.go +++ b/apps/daemon/internal/agent/claudesdk/view.go @@ -7,6 +7,7 @@ import ( "fmt" "io/fs" "os" + "path" "path/filepath" "slices" "strings" @@ -83,12 +84,12 @@ func declareView(probe Config, info RuntimeInfo, node, root, bridge string, load ForwardEnv: []string{"CLAUDECODE", "GIT_EDITOR"}, Proxy: agent.ViewProxyEnv, Capabilities: agent.ViewCapabilities{ - EnvironmentNone: proto.CapabilityUnsupported, + EnvironmentNone: proto.CapabilitySupported, Skills: proto.CapabilityUnsupported, FunctionTools: proto.CapabilityFromBool(info.SupportsWorkspaceFunctions()), FunctionResultImages: proto.CapabilityFromBool(info.SupportsFunctionResultImages()), ToolSearch: proto.CapabilityFromBool(info.SupportsWorkspaceToolSearch()), - StdioMCP: proto.CapabilityUnsupported, + StdioMCP: proto.CapabilitySupported, }, } loader.AddTo(view) @@ -115,15 +116,18 @@ func newViewExecutorFactory(probe Config, layout viewLayout) agent.ViewExecutorF } } -// prepareView builds the workspace profile for one Session from the request -// and the view: the workspace is the sandbox's, MCP comes only from the view, +// prepareView builds the start request for one Session from the request and +// the view: the workspace profile in the sandbox's workspace, or no workspace +// in the work directory with environment none. MCP comes only from the view, // and the environment is closed. func prepareView(layout viewLayout, req proto.PromptRequestPayload, view agent.ViewSession) (startRequest, []string, error) { environment := req.LocalEnvironment - if environment == nil || !workspacePathSyntax(environment.WorkspaceRoot) || req.DisableExecutionEnvironment || view.Launch == nil || view.Proxy == "" { - return startRequest{}, nil, errors.New("claudesdk: a view Executor requires the sandbox workspace, Launch and the gateway proxy") + if (environment == nil) != req.DisableExecutionEnvironment || environment != nil && !workspacePathSyntax(environment.WorkspaceRoot) || view.Launch == nil || view.Proxy == "" { + return startRequest{}, nil, errors.New("claudesdk: a view Executor requires the sandbox workspace or environment none, Launch and the gateway proxy") } - servers, err := viewMCP(view.MCP) + // The gateway adds each credential and header, and the Harness runs each + // stdio alias without arguments. + servers, _, err := mcpServers(view.MCP, func(stdio proto.EnvironmentMCP) (string, []string) { return stdio.Server.Command, nil }) if err != nil { return startRequest{}, nil, err } @@ -134,33 +138,24 @@ func prepareView(layout viewLayout, req proto.PromptRequestPayload, view agent.V if err := viewHome(view.Home); err != nil { return startRequest{}, nil, err } - profile, env := viewEnvironment(layout, view.Home.View, view.Proxy, provider) + profile, env := viewEnvironment(layout, view.Home.View, view.Proxy, provider, environment != nil) + if environment == nil { + // Environment none has no Environment MCP, so each server is HTTP. + start.Cwd = path.Join(view.Home.View, agent.ViewWorkName) + if len(servers) != 0 { + http := make([]mcpHTTPServer, 0, len(servers)) + for _, server := range servers { + http = append(http, server.mcpHTTPServer) + } + start.MCPHTTPServers = &http + } + return start, env, nil + } profile.NetworkAccess, profile.MCP = environment.NetworkAccess, servers start.Workspace, start.Cwd = profile, environment.WorkspaceRoot return start, env, nil } -// viewMCP renders the gateway's HTTP endpoints. The gateway adds each -// credential and header, so none is rendered here. -func viewMCP(bindings []agent.MCPBinding) ([]environmentMCPServer, error) { - declarations := make([]proto.MCPHTTPServer, 0, len(bindings)) - for _, binding := range bindings { - if binding.Transport != "http" || binding.Stdio != nil { - return nil, fmt.Errorf("%w: %s MCP in an agent-host view", agent.ErrUnsupportedOperation, binding.Transport) - } - declarations = append(declarations, proto.MCPHTTPServer{ServerLabel: binding.ServerLabel, ServerURL: binding.ServerURL, AllowedTools: binding.AllowedTools, Required: binding.Required}) - } - if err := validateMCPServers(declarations); err != nil { - return nil, err - } - projected, _ := prepareMCPHTTP(&declarations) - servers := make([]environmentMCPServer, 0, len(*projected)) - for _, server := range *projected { - servers = append(servers, environmentMCPServer{mcpHTTPServer: server}) - } - return servers, nil -} - // viewHome lays out the native directories. A later Executor finds the tree // the Session uid has owned, so every operation stays inside one os.Root and // an existing entry must be a directory, not a link. @@ -185,23 +180,28 @@ func viewHome(home agent.ViewDir) error { } // viewEnvironment is the complete Harness environment. home is the Session -// home's view path. -func viewEnvironment(layout viewLayout, home, proxy string, provider []string) (*workspaceProfile, []string) { +// home's view path, and workspace adds what the workspace tools need. +func viewEnvironment(layout viewLayout, home, proxy string, provider []string, workspace bool) (*workspaceProfile, []string) { shims := agent.ViewPrivateRoot + "/" + agent.ViewShimName profile := &workspaceProfile{Home: home + "/home", State: home + "/config", Scratch: home + "/tmp", EnvNames: []string{}, AllowedDomains: []string{}} env := []string{ "PATH=" + shims, "HOME=" + profile.Home, "TMPDIR=" + profile.Scratch, "CLAUDE_CONFIG_DIR=" + profile.State, // The messaging socket path stays local and short (C5). "XDG_RUNTIME_DIR=" + home + "/xdg", - // Bash runs the sandbox shell through the shim (C3). - "SHELL=" + shims + "/bash", "CLAUDE_CODE_SHELL=" + shims + "/bash", - // The shell's cwd file must be at the same path on both sides (C4). - "CLAUDE_CODE_TMPDIR=/tmp/oac-claude-" + strings.ToLower(rand.Text()), "CLAUDE_CODE_CERT_STORE=bundled", // C2 - "USE_BUILTIN_RIPGREP=0", // C7: rg runs through its shim "CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=1", // C9 "CLAUDE_CODE_TOOL_MEMORY_LIMIT=0", // C13 } + if workspace { + env = append(env, + // Bash runs the sandbox shell through the shim (C3). + "SHELL="+shims+"/bash", "CLAUDE_CODE_SHELL="+shims+"/bash", + // The shell's cwd file must be at the same path on both sides + // (C4). An empty root has no /tmp, where Claude Code creates it. + "CLAUDE_CODE_TMPDIR=/tmp/oac-claude-"+strings.ToLower(rand.Text()), + "USE_BUILTIN_RIPGREP=0", // C7: rg runs through its shim + ) + } env = append(env, nativeFlags...) if layout.libraries != "" { env = append(env, "LD_LIBRARY_PATH="+layout.libraries) diff --git a/apps/daemon/internal/agent/claudesdk/view_test.go b/apps/daemon/internal/agent/claudesdk/view_test.go index 573aeb21c..98f28329c 100644 --- a/apps/daemon/internal/agent/claudesdk/view_test.go +++ b/apps/daemon/internal/agent/claudesdk/view_test.go @@ -6,7 +6,6 @@ import ( "bufio" "context" "encoding/json" - "errors" "fmt" "io" "io/fs" @@ -106,10 +105,39 @@ func TestViewExecutorLaunchesAClosedGatewayEnvironment(t *testing.T) { t.Fatal("the real key reached the view") } + // The Harness runs a stdio binding's alias without arguments. + docs := session.MCP session.MCP = []agent.MCPBinding{{ServerLabel: "local", Transport: "stdio", Stdio: &proto.EnvironmentMCP{ Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: agent.ViewAlias(0)}}}} - if _, err := view.Executor(t.Context(), req, session); !errors.Is(err, agent.ErrUnsupportedOperation) { - t.Fatalf("stdio MCP = %v, want ErrUnsupportedOperation", err) + stdio, err := view.Executor(t.Context(), req, session) + if err != nil { + t.Fatal(err) + } + defer stdio.Close(ctx) + var stdioStart startRequest + if err := json.Unmarshal(<-requests, &stdioStart); err != nil || stdioStart.Workspace == nil || len(stdioStart.Workspace.MCP) != 1 || + stdioStart.Workspace.MCP[0].Command != agent.ViewAlias(0) || stdioStart.Workspace.MCP[0].Args != nil { + t.Fatalf("stdio MCP = %+v, %v", stdioStart.Workspace, err) + } + + // With environment none the bridge runs without a workspace in the work directory. + none := req + none.LocalEnvironment, none.DisableExecutionEnvironment = nil, true + session.MCP = docs + noneExecutor, err := view.Executor(t.Context(), none, session) + if err != nil { + t.Fatal(err) + } + defer noneExecutor.Close(ctx) + var noneStart startRequest + if err := json.Unmarshal(<-requests, &noneStart); err != nil || launched.Dir != "/.oac/home/work" || noneStart.Cwd != launched.Dir || noneStart.Workspace != nil || + noneStart.MCPHTTPServers == nil || len(*noneStart.MCPHTTPServers) != 1 || (*noneStart.MCPHTTPServers)[0].ServerURL != "http://127.0.0.1:17102/mcp/docs" { + t.Fatalf("environment none launched in %q with %+v, %v", launched.Dir, noneStart, err) + } + for _, entry := range launched.Env { + if strings.HasPrefix(entry, "CLAUDE_CODE_TMPDIR=") || strings.HasPrefix(entry, "SHELL=") { + t.Errorf("environment none sets the workspace tools' %s", entry) + } } // A link the Session uid planted in its home is never followed. diff --git a/apps/daemon/internal/agent/codex/mcp_http.go b/apps/daemon/internal/agent/codex/mcp_http.go index c86df681f..f10c5ebc8 100644 --- a/apps/daemon/internal/agent/codex/mcp_http.go +++ b/apps/daemon/internal/agent/codex/mcp_http.go @@ -21,12 +21,13 @@ func runtimeMCPServers(req proto.PromptRequestPayload) (map[string]mcpServerConf if bindings == nil { return nil, nil, nil } - return mcpServersFromBindings(bindings) + return mcpServersFromBindings(bindings, localworkspace.MCPStdioCommand) } -// mcpServersFromBindings renders resolved bindings, with each credential in a -// private environment variable. -func mcpServersFromBindings(bindings []agent.MCPBinding) (map[string]mcpServerConfig, []string, error) { +// mcpServersFromBindings renders resolved bindings, each stdio binding with +// the command and arguments stdio gives it and each credential in a private +// environment variable. +func mcpServersFromBindings(bindings []agent.MCPBinding, stdio func(proto.EnvironmentMCP) (string, []string)) (map[string]mcpServerConfig, []string, error) { servers := make(map[string]mcpServerConfig, len(bindings)) var env []string for _, binding := range bindings { @@ -35,7 +36,7 @@ func mcpServersFromBindings(bindings []agent.MCPBinding) (map[string]mcpServerCo } server := mcpServerConfig{Name: binding.ServerLabel, URL: binding.ServerURL, Required: binding.Required, EnabledTools: binding.AllowedTools, ApproveTools: binding.ConnectionOrigin == "environment"} if binding.Stdio != nil { - server.Command, server.Args = localworkspace.MCPStdioCommand(*binding.Stdio) + server.Command, server.Args = stdio(*binding.Stdio) } if binding.BearerToken != nil { server.BearerTokenEnvVar = "OAC_RUNTIME_MCP_BEARER_" + rand.Text() diff --git a/apps/daemon/internal/agent/codex/options.go b/apps/daemon/internal/agent/codex/options.go index 132779246..4bf686d28 100644 --- a/apps/daemon/internal/agent/codex/options.go +++ b/apps/daemon/internal/agent/codex/options.go @@ -19,8 +19,9 @@ import ( // the daemon's PromptRequestPayload. type SessionPlan struct { // Cwd is the working directory passed to codex and the spawned - // app-server: the bound workspace root for an Environment request and - // the Session's private CODEX_HOME for environment:none. + // app-server: the bound workspace root for an Environment request and, + // for environment:none, the Session's private CODEX_HOME or a view's work + // directory. Cwd string // Env is the environment slice (KEY=value) the plan adds. A local @@ -122,6 +123,9 @@ func buildSessionPlan(req proto.PromptRequestPayload, allocHome func() (agent.Vi } plan.home = home plan.Env = []string{"DISABLE_TELEMETRY=1", "CODEX_HOME=" + home.View} + if req.DisableExecutionEnvironment { + plan.Env = append(plan.Env, "CODEX_EXEC_SERVER_URL=none") + } if prepared.Provider != nil { if err := writeCodexProviderConfig(home.Host, nativeProvider(*prepared.Provider)); err != nil { return plan, err diff --git a/apps/daemon/internal/agent/codex/session_plan.go b/apps/daemon/internal/agent/codex/session_plan.go index e4f3b9ea7..ab1cdc452 100644 --- a/apps/daemon/internal/agent/codex/session_plan.go +++ b/apps/daemon/internal/agent/codex/session_plan.go @@ -71,9 +71,6 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg } } - if req.DisableExecutionEnvironment { - plan.Env = append(plan.Env, "CODEX_EXEC_SERVER_URL=none") - } var skillRoots []string if req.LocalEnvironment != nil && len(req.LocalEnvironment.Skills) > 0 { if err := verifyHostedSkills(req.LocalEnvironment.Skills); err != nil { diff --git a/apps/daemon/internal/agent/codex/view.go b/apps/daemon/internal/agent/codex/view.go index 3c3872b8a..499d847e1 100644 --- a/apps/daemon/internal/agent/codex/view.go +++ b/apps/daemon/internal/agent/codex/view.go @@ -87,12 +87,12 @@ func newView(binary string, codeModeHost bool) agent.View { ForwardEnv: slices.Clone(viewForwardEnv), Proxy: agent.ViewProxyEnv, Capabilities: agent.ViewCapabilities{ - EnvironmentNone: proto.CapabilityUnsupported, + EnvironmentNone: proto.CapabilitySupported, Skills: proto.CapabilityUnsupported, FunctionTools: proto.CapabilitySupported, FunctionResultImages: proto.CapabilitySupported, ToolSearch: proto.CapabilityUnsupported, - StdioMCP: proto.CapabilityUnsupported, + StdioMCP: proto.CapabilitySupported, }, Executor: func(ctx context.Context, req proto.PromptRequestPayload, session agent.ViewSession) (agent.Executor, error) { cfg := defaultSessionConfig() @@ -129,26 +129,27 @@ func staticELF(name string) bool { } // prepareViewPlan builds the plan for codex in the view: the Environment's -// workspace as cwd, CODEX_HOME and TMPDIR in the Session home, MCP only from -// the Session, and a closed environment. +// workspace as cwd, or the work directory with environment none, CODEX_HOME +// and TMPDIR in the Session home, MCP only from the Session, and a closed +// environment. func prepareViewPlan(ctx context.Context, req proto.PromptRequestPayload, cfg sessionConfig) (SessionPlan, error) { view := cfg.view - local := req.LocalEnvironment - if local == nil || req.DisableExecutionEnvironment || !path.IsAbs(local.WorkspaceRoot) { - return SessionPlan{}, fmt.Errorf("%w: codex: a view runs in an Environment workspace", agent.ErrUnsupportedOperation) - } if !filepath.IsAbs(view.Home.Host) || !path.IsAbs(view.Home.View) { return SessionPlan{}, errors.New("codex: view home must be absolute") } + cwd := path.Join(view.Home.View, agent.ViewWorkName) + if local := req.LocalEnvironment; local != nil { + cwd = local.WorkspaceRoot + } + if (req.LocalEnvironment == nil) != req.DisableExecutionEnvironment || !path.IsAbs(cwd) { + return SessionPlan{}, fmt.Errorf("%w: codex: a view runs in an Environment workspace or with environment none", agent.ErrUnsupportedOperation) + } if _, err := runtimePermissionProfile(req); err != nil { return SessionPlan{}, err } - for _, binding := range view.MCP { - if binding.Transport != "http" || binding.Stdio != nil { - return SessionPlan{}, fmt.Errorf("%w: codex: stdio MCP %q in a view", agent.ErrUnsupportedOperation, binding.ServerLabel) - } - } - servers, _, err := mcpServersFromBindings(view.MCP) + // The Harness runs each stdio alias without arguments, which the native + // configuration reports as an empty list. + servers, _, err := mcpServersFromBindings(view.MCP, func(stdio proto.EnvironmentMCP) (string, []string) { return stdio.Server.Command, []string{} }) if err != nil { return SessionPlan{}, err } @@ -161,7 +162,7 @@ func prepareViewPlan(ctx context.Context, req proto.PromptRequestPayload, cfg se return SessionPlan{}, err } disableProgrammaticTools(&plan, req.ExecutionControls) - plan.Cwd = local.WorkspaceRoot + plan.Cwd = cwd plan.Sandbox = SandboxDangerFullAcces plan.Permissions = "" plan.ApprovalPolicy = AskForApproval{String: "never"} @@ -182,7 +183,8 @@ func prepareViewPlan(ctx context.Context, req proto.PromptRequestPayload, cfg se } } // No login shell, and no ancestor walk above the workspace over the - // mount. No trust entry is written, so the project stays untrusted. + // mount. The adapter writes no trust entry; Codex keeps its native + // project trust and records its own on thread/start. plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"allow_login_shell", "false"}, [2]string{"project_root_markers", "[]"}) if req.ExecutionControls != nil { if err := viewModelVerbosity(ctx, cfg.codexBinary, &plan, req.ModelProvider); err != nil { diff --git a/apps/daemon/internal/agent/codex/view_test.go b/apps/daemon/internal/agent/codex/view_test.go index 326490432..a2a995f34 100644 --- a/apps/daemon/internal/agent/codex/view_test.go +++ b/apps/daemon/internal/agent/codex/view_test.go @@ -122,7 +122,14 @@ func TestViewExecutorLaunchesInTheSessionView(t *testing.T) { session.MCP = []agent.MCPBinding{{ServerLabel: "local", ConnectionOrigin: "environment", CredentialAuthority: "none", Transport: "stdio", Stdio: &proto.EnvironmentMCP{ Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: agent.ViewAlias(0)}}}} - if _, err := view.Executor(t.Context(), req, session); !errors.Is(err, agent.ErrUnsupportedOperation) || len(launched) != 1 { - t.Fatalf("stdio MCP: launches %d, err %v", len(launched), err) + req.LocalEnvironment, req.DisableExecutionEnvironment = nil, true + if _, err := view.Executor(t.Context(), req, session); err == nil || len(launched) != 2 { + t.Fatalf("environment none: launches %d, err %v", len(launched), err) + } + if launch := launched[1]; launch.Dir != "/.oac/home/work" || !slices.Contains(launch.Env, "CODEX_EXEC_SERVER_URL=none") { + t.Fatalf("environment none launched in %q with %v", launch.Dir, launch.Env) + } + if config, err := os.ReadFile(planted); err != nil || !strings.Contains(string(config), "command = \"/.oac/bin/oac-mcp-0\"\n\n") { + t.Fatalf("stdio alias config.toml: %v\n%s", err, config) } } diff --git a/apps/daemon/internal/agent/mcode/environment_mcp.go b/apps/daemon/internal/agent/mcode/environment_mcp.go index 7f388c803..78def82a3 100644 --- a/apps/daemon/internal/agent/mcode/environment_mcp.go +++ b/apps/daemon/internal/agent/mcode/environment_mcp.go @@ -15,26 +15,24 @@ func runtimeMCP(req proto.PromptRequestPayload) ([]map[string]any, []agent.MCPBi if err != nil { return nil, nil, err } - servers, err := workspaceMCP(bindings, func(binding agent.MCPBinding) (map[string]any, error) { - command, args := localworkspace.MCPStdioCommand(*binding.Stdio) - return map[string]any{"name": binding.ServerLabel, "command": command, "args": args, "env": []map[string]string{}}, nil - }) + servers, err := workspaceMCP(bindings, localworkspace.MCPStdioCommand) return servers, bindings, err } -// workspaceMCP renders the Session's MCP bindings as ACP servers; stdio -// renders a stdio binding. -func workspaceMCP(bindings []agent.MCPBinding, stdio func(agent.MCPBinding) (map[string]any, error)) ([]map[string]any, error) { +// workspaceMCP renders the Session's MCP bindings as ACP servers, each stdio +// binding with the command and arguments stdio gives it. +func workspaceMCP(bindings []agent.MCPBinding, stdio func(proto.EnvironmentMCP) (string, []string)) ([]map[string]any, error) { var servers []map[string]any for _, binding := range bindings { if binding.ServerLabel == "oac_workspace" || binding.ConnectionOrigin != "environment" || binding.AllowedTools != nil || binding.Required { return nil, fmt.Errorf("mcode: unsupported MCP binding") } - render := environmentHTTPMCP if binding.Transport != "http" { - render = stdio + command, args := stdio(*binding.Stdio) + servers = append(servers, map[string]any{"name": binding.ServerLabel, "command": command, "args": args, "env": []map[string]string{}}) + continue } - server, err := render(binding) + server, err := environmentHTTPMCP(binding) if err != nil { return nil, err } diff --git a/apps/daemon/internal/agent/mcode/view.go b/apps/daemon/internal/agent/mcode/view.go index 6e32f6b8d..da96527ac 100644 --- a/apps/daemon/internal/agent/mcode/view.go +++ b/apps/daemon/internal/agent/mcode/view.go @@ -131,12 +131,12 @@ func (i viewInstall) view() agent.View { ShimPaths: []string{"/bin/bash"}, Proxy: agent.ViewProxyNone, Capabilities: agent.ViewCapabilities{ - EnvironmentNone: proto.CapabilityUnsupported, + EnvironmentNone: proto.CapabilitySupported, Skills: proto.CapabilityUnsupported, FunctionTools: proto.CapabilityUnsupported, FunctionResultImages: proto.CapabilityUnsupported, ToolSearch: proto.CapabilityUnsupported, - StdioMCP: proto.CapabilityUnsupported, + StdioMCP: proto.CapabilitySupported, }, Executor: i.executor, } @@ -152,19 +152,23 @@ func (i viewInstall) executor(ctx context.Context, req proto.PromptRequestPayloa // prepare writes the native configuration into the Session home and renders // a closed environment. The CLI and its worker use the gateway the request -// names and the MCP in session; the request's workspace is the sandbox's. +// names and the MCP in session. The request's workspace is the sandbox's, and +// the workspace tools present it; with environment none the CLI runs in the +// work directory without them. func (i viewInstall) prepare(_ context.Context, req proto.PromptRequestPayload, session agent.ViewSession) (launchOptions, error) { local := req.LocalEnvironment - if !req.StrictResume || local == nil || req.DisableExecutionEnvironment || req.WorkspaceReadOnly { - return launchOptions{}, fmt.Errorf("%w: a MiniMax Code view runs Agents API execution in a writable Environment workspace", agent.ErrUnsupportedOperation) + if !req.StrictResume || (local == nil) != req.DisableExecutionEnvironment || req.WorkspaceReadOnly { + return launchOptions{}, fmt.Errorf("%w: a MiniMax Code view runs Agents API execution in a writable Environment workspace or with environment none", agent.ErrUnsupportedOperation) } - workspace := local.WorkspaceRoot - if !path.IsAbs(workspace) || path.Clean(workspace) != workspace || workspace == "/" { + dir := path.Join(session.Home.View, agent.ViewWorkName) + if local != nil { + dir = local.WorkspaceRoot + } + if !path.IsAbs(dir) || path.Clean(dir) != dir || dir == "/" { return launchOptions{}, errors.New("mcode: the workspace is not a canonical absolute path") } - servers, err := workspaceMCP(session.MCP, func(agent.MCPBinding) (map[string]any, error) { - return nil, fmt.Errorf("%w: a MiniMax Code view does not run stdio MCP", agent.ErrUnsupportedOperation) - }) + // The Harness runs each stdio alias without arguments. + servers, err := workspaceMCP(session.MCP, func(stdio proto.EnvironmentMCP) (string, []string) { return stdio.Server.Command, []string{} }) if err != nil { return launchOptions{}, err } @@ -191,15 +195,20 @@ func (i viewInstall) prepare(_ context.Context, req proto.PromptRequestPayload, return launchOptions{}, err } defer data.Close() - opts := launchOptions{Dir: workspace, DataDir: filepath.Join(session.Home.Host, viewDataName), bindings: session.MCP, + opts := launchOptions{Dir: dir, DataDir: filepath.Join(session.Home.Host, viewDataName), bindings: session.MCP, start: session.Launch, script: i.cli, home: session.Home.Host} dataDir, tempDir := path.Join(session.Home.View, viewDataName), path.Join(session.Home.View, viewTempName) - tools := workspaceTools{node: i.node, bridge: i.bridge, profile: map[string]any{"workspace": workspace, "scratch": tempDir, "network": "enabled"}} - if err := writeNativeConfig(private, prepared, data, dataDir, &tools); err != nil { + var tools *workspaceTools + opts.MCP = []map[string]any{} + if local != nil { + tools = &workspaceTools{node: i.node, bridge: i.bridge, profile: map[string]any{"workspace": dir, "scratch": tempDir, "network": "enabled"}} + opts.MCP = append(opts.MCP, tools.server(dataDir)) + } + if err := writeNativeConfig(private, prepared, data, dataDir, tools); err != nil { return opts, err } opts.Model = prepared.Model - opts.MCP = append([]map[string]any{tools.server(dataDir)}, servers...) + opts.MCP = append(opts.MCP, servers...) opts.Env = []string{ "PATH=" + path.Join(agent.ViewPrivateRoot, agent.ViewShimName), "TMPDIR=" + tempDir, diff --git a/apps/daemon/internal/agent/mcode/view_test.go b/apps/daemon/internal/agent/mcode/view_test.go index 263b8f25c..d6fd3d47a 100644 --- a/apps/daemon/internal/agent/mcode/view_test.go +++ b/apps/daemon/internal/agent/mcode/view_test.go @@ -15,6 +15,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/viewloader" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) @@ -119,6 +120,28 @@ func TestViewLaunchesNodeWithGatewayOnly(t *testing.T) { } } +// With environment none the CLI runs in the work directory without the +// workspace tools, and it runs each stdio binding's alias without arguments. +func TestViewRunsEnvironmentNoneAndStdioAliases(t *testing.T) { + install, _, req := viewFixture(t) + session := agent.ViewSession{Home: agent.ViewDir{Host: t.TempDir(), View: path.Join(agent.ViewPrivateRoot, agent.ViewHomeName)}} + none := req + none.LocalEnvironment, none.DisableExecutionEnvironment = nil, true + opts, err := install.prepare(t.Context(), none, session) + if err != nil || opts.Dir != "/.oac/home/work" || opts.MCP == nil || len(opts.MCP) != 0 { + t.Fatalf("environment none runs in %q with MCP %v: %v", opts.Dir, opts.MCP, err) + } + + session.MCP = []agent.MCPBinding{{ServerLabel: "local", ConnectionOrigin: "environment", CredentialAuthority: "none", Transport: "stdio", Stdio: &proto.EnvironmentMCP{ + Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: agent.ViewAlias(0)}}}} + if opts, err = install.prepare(t.Context(), req, session); err != nil || len(opts.MCP) != 2 || opts.MCP[0]["name"] != "oac_workspace" || opts.MCP[1]["command"] != agent.ViewAlias(0) { + t.Fatalf("stdio MCP = %v: %v", opts.MCP, err) + } + if args, ok := opts.MCP[1]["args"].([]string); !ok || args == nil || len(args) != 0 { + t.Fatalf("the stdio alias runs with arguments %#v", opts.MCP[1]["args"]) + } +} + func TestViewReadsSubagentsBesideTheCLI(t *testing.T) { install, _, req := viewFixture(t) req.DisableSubagents, req.MaxConcurrentSubagents = false, new(2) diff --git a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go index 5478627f1..a6466e6f2 100644 --- a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go +++ b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go @@ -19,6 +19,7 @@ import ( "image" "image/png" "io" + "io/fs" "log/slog" "math/big" "net/http/httptest" @@ -39,6 +40,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/sessionview" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/sessionview/sessionviewtest" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxbootstrap" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxfs" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink" @@ -122,7 +124,11 @@ func TestHarnessSessionsAgainstTheSandbox(t *testing.T) { // the value and the status. A view that declares function tools runs a second // Turn in a new Executor, which resumes the Session's native history, and // calls a function there. A view that declares tool search runs a Turn in -// another Session that finds the function, deferred, with tool search. +// another Session that finds the function, deferred, with tool search. A view +// that declares environment none answers a Turn in a Session without an +// Environment, and its native state names the work directory. +// A view that declares stdio MCP calls a tool of a stdio MCP server that runs +// in the sandbox. func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, kind string, caps agent.ViewCapabilities, model proto.PromptRequestPayload) { name := "qualify-" + kind + ".txt" value, content := strings.ToLower(rand.Text()), "qualified "+strings.ToLower(rand.Text()[:12]) @@ -175,8 +181,51 @@ func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, done, calls := search.turn(t, "tool-search", k.prompt("Search your tools for the function that looks up support tickets"), k) k.check(t, done, calls) } + if caps.EnvironmentNone.IsSupported() { + none := configuration + none.LocalEnvironment, none.DisableExecutionEnvironment, none.FunctionTools, none.ToolSearch = nil, true, nil, false + s := sb.session(h, cfg, agenthost.Environment{}, none) + done, _ := s.turn(t, "environment-none", "What is 17 times 23? Answer with exactly one line: PRODUCT=", k) + if !strings.Contains(done.Content, "PRODUCT=391") { + t.Errorf("the answer %q does not report PRODUCT=391", done.Content) + } + s.checkCwd(t, agent.ViewPrivateRoot+"/"+agent.ViewHomeName+"/"+agent.ViewWorkName) + } + if caps.StdioMCP.IsSupported() { + code := strings.ToLower(rand.Text()[:12]) + stdio := configuration + stdio.FunctionTools, stdio.ToolSearch = nil, false + s := sb.session(h, cfg, env, stdio) + s.mcp = []proto.EnvironmentMCP{{InstallationRoot: workspace, Server: agentplugin.MCPServer{Name: "qualify", Type: "stdio", Command: "python3", Args: []string{"-c", mcpServer, code}}}} + done, _ := s.turn(t, "stdio-mcp", "Call the reveal_code tool of the qualify MCP server once.\nAnswer with exactly one line: CODE=", k) + if !strings.Contains(done.Content, "CODE="+code) { + t.Errorf("the answer %q does not report CODE=%s", done.Content, code) + } + } } +// mcpServer is a stdio MCP server whose one tool returns the code in its +// argument. +const mcpServer = `import json, sys +code = sys.argv[1] +for line in iter(sys.stdin.readline, ""): + msg = json.loads(line) if line.strip() else {} + if "id" not in msg or "method" not in msg: + continue + method, reply = msg["method"], {"jsonrpc": "2.0", "id": msg["id"]} + if method == "initialize": + reply["result"] = {"protocolVersion": msg["params"]["protocolVersion"], "capabilities": {"tools": {}}, "serverInfo": {"name": "qualify", "version": "1"}} + elif method == "tools/list": + reply["result"] = {"tools": [{"name": "reveal_code", "description": "Returns the qualification code.", "inputSchema": {"type": "object", "properties": {}}}]} + elif method == "tools/call": + reply["result"] = {"content": [{"type": "text", "text": "The code is " + code + "."}]} + elif method == "ping": + reply["result"] = {} + else: + reply["error"] = {"code": -32601, "message": "method not found"} + print(json.dumps(reply), flush=True) +` + // lookupTicket is the function the function Turns call. var lookupTicket = proto.FunctionTool{Name: "lookup_ticket", Description: "Looks up a support ticket by its number.", Parameters: json.RawMessage(`{"type":"object","properties":{"ticket":{"type":"string","description":"The ticket number"}},"required":["ticket"],"additionalProperties":false}`)} @@ -227,6 +276,9 @@ type session struct { env agenthost.Environment id string configuration proto.PromptRequestPayload + // mcp is the installed MCP that the Environment's preparation resolves + // into each request; the wire does not carry it. + mcp []proto.EnvironmentMCP } func (sb *sandbox) session(h *agenthost.Host, cfg agenthost.Config, env agenthost.Environment, configuration proto.PromptRequestPayload) *session { @@ -242,10 +294,13 @@ func (sb *sandbox) session(h *agenthost.Host, cfg agenthost.Config, env agenthos func (s *session) turn(t *testing.T, run, prompt string, k ticket) (proto.DonePayload, []proto.FunctionCallPayload) { t.Helper() out := make(sender, 256) - router, err := dispatch.New(dispatch.Config{Sender: out, SessionEnvironments: true, Log: s.cfg.Log, - Registry: s.h.Registry(func(proto.PromptRequestPayload) (agenthost.Binding, agenthost.Environment, error) { - return s.binding, s.env, nil - })}) + reg := s.h.Registry(func(proto.PromptRequestPayload) (agenthost.Binding, agenthost.Environment, error) { + return s.binding, s.env, nil + }) + if s.mcp != nil { + reg = withMCP(t, reg, s.mcp) + } + router, err := dispatch.New(dispatch.Config{Sender: out, SessionEnvironments: true, Log: s.cfg.Log, Registry: reg}) if err != nil { t.Fatal(err) } @@ -266,6 +321,51 @@ func (s *session) turn(t *testing.T, run, prompt string, k ticket) (proto.DonePa return done, calls } +// withMCP wraps reg so that each request's Environment carries mcp. +func withMCP(t *testing.T, reg *agent.Registry, mcp []proto.EnvironmentMCP) *agent.Registry { + wrapped := agent.NewRegistry() + for _, info := range reg.SupportedAgentKinds() { + configuration, err := reg.Configuration(info.Kind) + direct, directErr := reg.Resolve(info.Kind) + factory, factoryErr := reg.ResolveExecutor(info.Kind) + if err := errors.Join(err, directErr, factoryErr); err != nil { + t.Fatal(err) + } + wrapped.RegisterKind(info, configuration, direct) + wrapped.RegisterExecutor(info.Kind, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + local := *req.LocalEnvironment + local.MCP = mcp + req.LocalEnvironment = &local + return factory(ctx, req) + }) + } + return wrapped +} + +// checkCwd checks that the Harness's native state in the Session home names +// cwd, which the adapter writes into none of its files there. +func (s *session) checkCwd(t *testing.T, cwd string) { + t.Helper() + want := []byte(cwd) + home := filepath.Join(s.cfg.StateDir, "sessions", s.binding.SessionID.String(), agent.ViewHomeName) + var found string + err := filepath.WalkDir(home, func(name string, entry fs.DirEntry, err error) error { + if err != nil || found != "" || !entry.Type().IsRegular() { + return err + } + if body, err := os.ReadFile(name); err != nil || bytes.Contains(body, want) { + found = name + return err + } + return nil + }) + if err != nil || found == "" { + t.Errorf("no native state in %s names %s: %v", home, want, err) + return + } + t.Logf("%s names %s", found, want) +} + func handle(t *testing.T, router *dispatch.Router, typ, id string, payload any) { t.Helper() e, err := proto.NewEnvelope(typ, id, payload) diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index c39df42bb..167802c13 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -367,7 +367,7 @@ Run the adapter's Turns, cancellation and continuation in a view, then qualify e | `Home` | Native history and configuration stay under `/.oac/home`, and a later Executor in the same Session continues from them. | | `Capabilities` | Each supported feature runs a Turn through dispatch: environment none in the empty-root view, Skills, function calls and results, tool search, and each stdio binding under its alias. | -`scripts/qualify-agent-host.sh` runs each Harness's Turns through the daemon's dispatch against the [agent-host and sandbox images](../../docs/maintainers.md#runtime-images-and-helpers). The `agenthostqualify` test binary runs as the agent host with the [agent-host container's flags](../../docs/configuration.md#agent-host-container), and the sandbox image serves the sandbox. The first Turn writes a file and reports the output and exit status of a failing command whose values only the sandbox's tool environment holds. When the view declares function tools, a second Turn runs in a new Executor that resumes the Session's native history and calls a function; the test returns a text, image and text result through dispatch, and the answer must report both texts. When the view declares tool search, a Turn in another Session finds the deferred function with tool search and calls it. The Link runs over WSS with a CA the test generates. The test also checks the cgroup v2 delegation: the container's own read-only cgroup fails with `ErrUnsupported`, and in a delegated directory the agent host ends a cgroup left behind with `cgroup.kill`. Set `OAC_AGENT_HOST_IMAGE` and `OAC_SANDBOX_IMAGE` to the two images, `OAC_QUALIFY_KEY_FILE` to the model key's file and, for each Harness to qualify, `OAC_QUALIFY_CLAUDE_SDK`, `OAC_QUALIFY_CODEX` or `OAC_QUALIFY_MCODE` to its `model` and `model_provider` without `api_key`. The gateway dials model providers directly, so on a host whose only egress is an HTTP proxy, set `OAC_QUALIFY_PROXY` to it and the test tunnels the providers' hosts through it. +`scripts/qualify-agent-host.sh` runs each Harness's Turns through the daemon's dispatch against the [agent-host and sandbox images](../../docs/maintainers.md#runtime-images-and-helpers). The `agenthostqualify` test binary runs as the agent host with the [agent-host container's flags](../../docs/configuration.md#agent-host-container), and the sandbox image serves the sandbox. The first Turn writes a file and reports the output and exit status of a failing command whose values only the sandbox's tool environment holds. When the view declares function tools, a second Turn runs in a new Executor that resumes the Session's native history and calls a function; the test returns a text, image and text result through dispatch, and the answer must report both texts. When the view declares tool search, a Turn in another Session finds the deferred function with tool search and calls it. When the view declares environment none, a Turn in a Session without an Environment answers through the model, and the Harness's native state in the Session home must name its working directory, `/.oac/home/work`. When the view declares stdio MCP, the test gives a Session's Environment one installed stdio MCP server, a script that runs in the sandbox, and the answer must report the code its one tool returns. The Link runs over WSS with a CA the test generates. The test also checks the cgroup v2 delegation: the container's own read-only cgroup fails with `ErrUnsupported`, and in a delegated directory the agent host ends a cgroup left behind with `cgroup.kill`. Set `OAC_AGENT_HOST_IMAGE` and `OAC_SANDBOX_IMAGE` to the two images, `OAC_QUALIFY_KEY_FILE` to the model key's file and, for each Harness to qualify, `OAC_QUALIFY_CLAUDE_SDK`, `OAC_QUALIFY_CODEX` or `OAC_QUALIFY_MCODE` to its `model` and `model_provider` without `api_key`. The gateway dials model providers directly, so on a host whose only egress is an HTTP proxy, set `OAC_QUALIFY_PROXY` to it and the test tunnels the providers' hosts through it. ## Native references diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 3f18bf5fe..f804ed316 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- title: "将原生 Harness 添加到 OpenAgentCore" source: contracts/agents-api/harness-onboarding.md -source_hash: b89f8241275419330cf55481c11add0a40b58cf06fa0544ed8e0d7cb3839d3a3 +source_hash: a936fab8c8a3c52387de4acdc16c76a6287beb894aae1002ef34e0b6264f3636 --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code)。**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。本文档定义 Runtime–Harness 协议:适配器接口及其生命周期义务、注册、Core 资格认定和验收。[Harness capabilities](harness-capabilities.md) 记录了当前每个 Harness 支持的功能。 @@ -369,7 +369,7 @@ stdio 绑定在沙箱中以其别名运行。`ViewSession.MCP` 中索引为 `i` | `Home` | 原生历史和配置保存在 `/.oac/home` 下,同一 Session 中后续的 Executor 从中继续。 | | `Capabilities` | 每项受支持的功能都通过 dispatch 运行一个 Turn:空根视图中的 Environment none、Skills、函数调用及其结果、工具搜索,以及每个以别名运行的 stdio 绑定。 | -`scripts/qualify-agent-host.sh` 针对 [agent-host 和沙箱镜像](../../../docs/zh/maintainers.md#runtime-images-and-helpers),通过守护进程的 dispatch 运行每个 Harness 的 Turn。`agenthostqualify` 测试二进制以 [agent-host 容器的参数](../../../docs/zh/configuration.md#agent-host-container)作为 agent host 运行,沙箱镜像提供沙箱。第一个 Turn 写入一个文件,并报告一个失败命令的输出和退出状态,这两个值只存在于沙箱的工具环境中。视图声明函数工具时,第二个 Turn 在新的 Executor 中运行,该 Executor 恢复 Session 的原生历史并调用一个函数;测试通过 dispatch 返回文本、图片、文本组成的结果,回答必须报告两段文本。视图声明工具搜索时,另一个 Session 中的 Turn 用工具搜索找到延迟加载的函数并调用它。Link 通过 WSS 运行,使用测试生成的 CA。测试还会检查 cgroup v2 委派:容器自己的只读 cgroup 以 `ErrUnsupported` 失败;在委派目录中,agent host 用 `cgroup.kill` 结束遗留的 cgroup。将 `OAC_AGENT_HOST_IMAGE` 和 `OAC_SANDBOX_IMAGE` 设为这两个镜像,将 `OAC_QUALIFY_KEY_FILE` 设为模型密钥文件,并为每个要认定的 Harness 将 `OAC_QUALIFY_CLAUDE_SDK`、`OAC_QUALIFY_CODEX` 或 `OAC_QUALIFY_MCODE` 设为其 `model` 和不含 `api_key` 的 `model_provider`。网关直接连接模型提供商,因此在唯一出口是 HTTP 代理的主机上,将 `OAC_QUALIFY_PROXY` 设为该代理,测试会通过它为提供商的主机建立隧道。 +`scripts/qualify-agent-host.sh` 针对 [agent-host 和沙箱镜像](../../../docs/zh/maintainers.md#runtime-images-and-helpers),通过守护进程的 dispatch 运行每个 Harness 的 Turn。`agenthostqualify` 测试二进制以 [agent-host 容器的参数](../../../docs/zh/configuration.md#agent-host-container)作为 agent host 运行,沙箱镜像提供沙箱。第一个 Turn 写入一个文件,并报告一个失败命令的输出和退出状态,这两个值只存在于沙箱的工具环境中。视图声明函数工具时,第二个 Turn 在新的 Executor 中运行,该 Executor 恢复 Session 的原生历史并调用一个函数;测试通过 dispatch 返回文本、图片、文本组成的结果,回答必须报告两段文本。视图声明工具搜索时,另一个 Session 中的 Turn 用工具搜索找到延迟加载的函数并调用它。视图声明 environment none 时,一个没有 Environment 的 Session 中的 Turn 通过模型作答,且 Session home 中 Harness 的原生状态必须写明其工作目录 `/.oac/home/work`。视图声明 stdio MCP 时,测试为一个 Session 的 Environment 提供一个已安装的 stdio MCP 服务器,即在沙箱中运行的脚本,回答必须报告其唯一工具返回的代码。Link 通过 WSS 运行,使用测试生成的 CA。测试还会检查 cgroup v2 委派:容器自己的只读 cgroup 以 `ErrUnsupported` 失败;在委派目录中,agent host 用 `cgroup.kill` 结束遗留的 cgroup。将 `OAC_AGENT_HOST_IMAGE` 和 `OAC_SANDBOX_IMAGE` 设为这两个镜像,将 `OAC_QUALIFY_KEY_FILE` 设为模型密钥文件,并为每个要认定的 Harness 将 `OAC_QUALIFY_CLAUDE_SDK`、`OAC_QUALIFY_CODEX` 或 `OAC_QUALIFY_MCODE` 设为其 `model` 和不含 `api_key` 的 `model_provider`。网关直接连接模型提供商,因此在唯一出口是 HTTP 代理的主机上,将 `OAC_QUALIFY_PROXY` 设为该代理,测试会通过它为提供商的主机建立隧道。 ## 原生参考 {#native-references} diff --git a/packages/claude-sdk-adapter/src/mcp.ts b/packages/claude-sdk-adapter/src/mcp.ts index 453122181..14270651c 100644 --- a/packages/claude-sdk-adapter/src/mcp.ts +++ b/packages/claude-sdk-adapter/src/mcp.ts @@ -80,7 +80,7 @@ export class MCPProfile { for (const server of declarations) { const prefix = `mcp__${server.server_label}__`; if ("command" in server) { - this.servers[server.server_label] = { type: "stdio", command: server.command, args: [...server.args], env: {}, alwaysLoad: true }; + this.servers[server.server_label] = { type: "stdio", command: server.command, args: [...(server.args ?? [])], env: {}, alwaysLoad: true }; } else { const reference = server.bearer_token_env_var; if (reference && !process.env[reference]) throw new Error("missing MCP credential environment"); diff --git a/packages/claude-sdk-adapter/src/mcp_environment.ts b/packages/claude-sdk-adapter/src/mcp_environment.ts index b949b6e7c..8597eb1fe 100644 --- a/packages/claude-sdk-adapter/src/mcp_environment.ts +++ b/packages/claude-sdk-adapter/src/mcp_environment.ts @@ -4,12 +4,14 @@ import { parseHTTPServers, type HTTPServer } from "./mcp.js"; export type StdioServer = { server_label: string; command: string; - args: string[]; + // Absent for an agent-host view's alias, which runs without arguments. + args?: string[]; allowed_tools: null; }; export type EnvironmentMCPServer = HTTPServer | StdioServer; // The Runtime launcher resolves installed package identities and their commands. +// In an agent-host view, the process broker resolves the alias instead. export function parseEnvironmentMCP(value: unknown): EnvironmentMCPServer[] | undefined { if (value === undefined) return undefined; if (!Array.isArray(value)) throw new Error("invalid_request"); @@ -19,11 +21,11 @@ export function parseEnvironmentMCP(value: unknown): EnvironmentMCPServer[] | un 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 || - typeof server.command !== "string" || !isAbsolute(server.command) || normalize(server.command) !== server.command || - !Array.isArray(server.args) || server.args.length !== 4 || server.args[0] !== "runtime-mcp-exec" || + typeof server.command !== "string" || !isAbsolute(server.command) || normalize(server.command) !== server.command || /[\x00-\x1f\x7f]/.test(server.command) || + ("args" in server && (!Array.isArray(server.args) || server.args.length !== 4 || server.args[0] !== "runtime-mcp-exec" || typeof server.args[1] !== "string" || !isAbsolute(server.args[1]) || normalize(server.args[1]) !== server.args[1] || server.args[1] === parse(server.args[1]).root || typeof server.args[2] !== "string" || !server.args[2] || /[\\]/.test(server.args[2]) || server.args[2].split("/").some((part: string) => !part || part === "." || part === "..") || - server.args[3] !== server.server_label || [server.command, ...server.args].some((part: string) => /[\x00-\x1f\x7f]/.test(part))) throw new Error("invalid_request"); + server.args[3] !== server.server_label || server.args.some((part: string) => /[\x00-\x1f\x7f]/.test(part))))) throw new Error("invalid_request"); labels.add(server.server_label); } else { diff --git a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs index 14bb1e6d1..1d9563c2d 100644 --- a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs @@ -35,7 +35,11 @@ test("installed MCP projection uses the common Runtime launcher", t => { output_format: { type: "json_schema", schema: { type: "object" } } })), /invalid_request/); assert.equal(immediateInput(request), undefined); assert.deepEqual(parseEnvironmentMCP([stdio]), [stdio]); - for (const value of [[stdio, stdio], [{ ...stdio, command: "relative" }], [{ ...stdio, env: { TOKEN: "secret" } }], + // An agent-host view's alias runs without arguments. + const alias = { server_label: "installed", command: "/.oac/bin/oac-mcp-0", allowed_tools: null }; + assert.deepEqual(parseEnvironmentMCP([alias]), [alias]); + assert.deepEqual(new MCPProfile([alias], []).servers.installed.args, []); + for (const value of [[stdio, stdio], [{ ...stdio, command: "relative" }], [{ ...stdio, command: "/bin/line\n" }], [{ ...stdio, env: { TOKEN: "secret" } }], [{ ...stdio, args: ["-c", "untrusted"] }], [{ ...stdio, allowed_tools: ["*"] }], [{ ...stdio, server_url: "https://example.invalid" }], [{ ...stdio, args: [...stdio.args.slice(0, 2), "../escape", "installed"] }]]) { assert.throws(() => parseEnvironmentMCP(value), /invalid_request/);