diff --git a/apps/daemon/internal/agent/mcode/declaration.go b/apps/daemon/internal/agent/mcode/declaration.go index 69209159..276a7b0d 100644 --- a/apps/daemon/internal/agent/mcode/declaration.go +++ b/apps/daemon/internal/agent/mcode/declaration.go @@ -76,6 +76,9 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, r workspace := discoverWorkspace(parent, options, runtime) if runtime.Info.Available { runtime.Executor = NewExecutorFactory(workspace) + if SupportsExecution(version) { + runtime.View = discoverView(options) + } } if workspace != nil { runtime.SessionCapabilityContext = false diff --git a/apps/daemon/internal/agent/mcode/discovery_workspace.go b/apps/daemon/internal/agent/mcode/discovery_workspace.go index 4438aa64..9f606043 100644 --- a/apps/daemon/internal/agent/mcode/discovery_workspace.go +++ b/apps/daemon/internal/agent/mcode/discovery_workspace.go @@ -36,31 +36,12 @@ func discoverWorkspace(parent context.Context, options agent.DiscoveryOptions, r fail(err) return nil } - node := os.Getenv("OAC_RUNTIME_MCODE_NODE") - if node == "" { - node = "node" - } - node, err = exec.LookPath(node) - if err != nil { - fail(err) - return nil - } - node, err = filepath.Abs(node) + programs, err := findPrograms() if err != nil { fail(err) return nil } - binary, err := exec.LookPath(binpath.MCode()) - if err != nil { - fail(err) - return nil - } - binary, err = filepath.Abs(binary) - if err != nil { - fail(err) - return nil - } - c, err := ConfigureLocal(binary, node, os.Getenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE"), root, os.Getenv("OAC_RUNTIME_WORKSPACE"), binding.NetworkPolicy()) + c, err := ConfigureLocal(programs.binary, programs.node, programs.bridge, root, os.Getenv("OAC_RUNTIME_WORKSPACE"), binding.NetworkPolicy()) if err == nil { err = CheckWorkspace(parent, c) } @@ -75,3 +56,29 @@ func discoverWorkspace(parent context.Context, options agent.DiscoveryOptions, r caps.WorkspaceReadPreparation = proto.CapabilitySupported return &c } + +// programs are the installed node, CLI entry and workspace bridge, as absolute +// host paths. +type programs struct{ node, binary, bridge string } + +func findPrograms() (programs, error) { + node := os.Getenv("OAC_RUNTIME_MCODE_NODE") + if node == "" { + node = "node" + } + node, err := exec.LookPath(node) + if err != nil { + return programs{}, err + } + if node, err = filepath.Abs(node); err != nil { + return programs{}, err + } + binary, err := exec.LookPath(binpath.MCode()) + if err != nil { + return programs{}, err + } + if binary, err = filepath.Abs(binary); err != nil { + return programs{}, err + } + return programs{node: node, binary: binary, bridge: os.Getenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE")}, nil +} diff --git a/apps/daemon/internal/agent/mcode/environment_mcp.go b/apps/daemon/internal/agent/mcode/environment_mcp.go index c675465c..7f388c80 100644 --- a/apps/daemon/internal/agent/mcode/environment_mcp.go +++ b/apps/daemon/internal/agent/mcode/environment_mcp.go @@ -10,26 +10,35 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func runtimeMCP(req proto.PromptRequestPayload) ([]map[string]any, error) { +func runtimeMCP(req proto.PromptRequestPayload) ([]map[string]any, []agent.MCPBinding, error) { bindings, err := agent.ResolveMCPBindings(req) if err != nil { - return nil, err + 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 + }) + 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) { 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") } - if binding.Transport == "http" { - server, err := environmentHTTPMCP(binding) - if err != nil { - return nil, err - } - servers = append(servers, server) - } else { - command, args := localworkspace.MCPStdioCommand(*binding.Stdio) - servers = append(servers, map[string]any{"name": binding.ServerLabel, "command": command, "args": args, "env": []map[string]string{}}) + render := environmentHTTPMCP + if binding.Transport != "http" { + render = stdio + } + server, err := render(binding) + if err != nil { + return nil, err } + servers = append(servers, server) } return servers, nil } diff --git a/apps/daemon/internal/agent/mcode/executor.go b/apps/daemon/internal/agent/mcode/executor.go index 69cf2835..ffd1e1a7 100644 --- a/apps/daemon/internal/agent/mcode/executor.go +++ b/apps/daemon/internal/agent/mcode/executor.go @@ -36,30 +36,37 @@ func NewExecutorFactory(config *WorkspaceConfig) agent.ExecutorFactory { frozen = &value } return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { - if ctx == nil { - ctx = context.Background() - } - if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { - return nil, fmt.Errorf("mcode: Executor configuration cannot contain Turn input") - } - var err error - var opts launchOptions binary := defaultBinary() - if frozen == nil { - opts, err = prepareOptions(ctx, req) - } else { + if frozen != nil { binary = frozen.Binary - opts, err = prepareWorkspaceOptions(ctx, *frozen, req) - } - if err != nil { - return nil, err } - resource, err := newExecutor(ctx, req, opts, binary) - if resource == nil { - return nil, err - } - return resource, err + return startExecutor(ctx, req, binary, func(ctx context.Context) (launchOptions, error) { + if frozen == nil { + return prepareOptions(ctx, req) + } + return prepareWorkspaceOptions(ctx, *frozen, req) + }) + } +} + +// startExecutor prepares the native owner for req, which carries no Turn +// input, and starts binary. +func startExecutor(ctx context.Context, req proto.PromptRequestPayload, binary string, prepare func(context.Context) (launchOptions, error)) (agent.Executor, error) { + if ctx == nil { + ctx = context.Background() + } + if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { + return nil, fmt.Errorf("mcode: Executor configuration cannot contain Turn input") + } + opts, err := prepare(ctx) + if err != nil { + return nil, err + } + resource, err := newExecutor(ctx, req, opts, binary) + if resource == nil { + return nil, err } + return resource, err } func newExecutor(ctx context.Context, req proto.PromptRequestPayload, opts launchOptions, binary string) (*executor, error) { diff --git a/apps/daemon/internal/agent/mcode/mcp_observations.go b/apps/daemon/internal/agent/mcode/mcp_observations.go index 364d8995..f4c8e01b 100644 --- a/apps/daemon/internal/agent/mcode/mcp_observations.go +++ b/apps/daemon/internal/agent/mcode/mcp_observations.go @@ -3,12 +3,9 @@ package mcode import ( "encoding/json" "fmt" - "os" - "path/filepath" "slices" "strings" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -17,18 +14,11 @@ 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 || - !strings.HasPrefix(name, "mcp__") || strings.HasPrefix(name, "mcp__oac_workspace__") { + bindings := s.opts.bindings + if len(bindings) == 0 || !strings.HasPrefix(name, "mcp__") || strings.HasPrefix(name, "mcp__oac_workspace__") { return nil, nil } - bindings, err := agent.ResolveMCPBindings(s.req) - if err != nil { - return nil, err - } - if len(bindings) == 0 { - return nil, nil - } - raw, err := os.ReadFile(filepath.Join(s.opts.DataDir, "mcp-runtime-names.json")) + raw, err := s.opts.readData("mcp-runtime-names.json") if err != nil { return nil, fmt.Errorf("mcode: native MCP identity registry unavailable") } diff --git a/apps/daemon/internal/agent/mcode/mcp_observations_test.go b/apps/daemon/internal/agent/mcode/mcp_observations_test.go index 1c790f73..240c8913 100644 --- a/apps/daemon/internal/agent/mcode/mcp_observations_test.go +++ b/apps/daemon/internal/agent/mcode/mcp_observations_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -18,6 +19,7 @@ func mcpObservationSession(t *testing.T) (*Session, chan proto.Envelope) { req: proto.PromptRequestPayload{RunID: "run", ObserveToolObservations: true, LocalEnvironment: &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{environmentMCPFixture()}}}, out: out, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}, active: true, sessionID: "native-session"} + resolveTestBindings(s) if err := writeMCPRegistry(s.opts.DataDir, mcpRegistryEntry("proof.server", "proof_server_2", "read.status", "read_status_2")); err != nil { t.Fatal(err) } @@ -200,4 +202,10 @@ func usePublicMCP(s *Session) { s.req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ ConnectionOrigin: "environment", ServerLabel: "proof.server", ServerURL: "https://mcp.example.test", }} + resolveTestBindings(s) +} + +// resolveTestBindings records the request's bindings as preparation does. +func resolveTestBindings(s *Session) { + s.opts.bindings, _ = agent.ResolveMCPBindings(s.req) } diff --git a/apps/daemon/internal/agent/mcode/options.go b/apps/daemon/internal/agent/mcode/options.go index 86305f17..4f297506 100644 --- a/apps/daemon/internal/agent/mcode/options.go +++ b/apps/daemon/internal/agent/mcode/options.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/managedskills" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" harnessconfiguration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" @@ -21,6 +22,17 @@ type launchOptions struct { Dir, DataDir, Model string Env []string MCP []map[string]any + // bindings are the effective MCP bindings rendered into MCP; native MCP + // tool calls are observed against them. + bindings []agent.MCPBinding + // start runs the native process; nil selects clirunner.Start. script is + // the CLI entry when the binary is node rather than the CLI itself. + start func(clirunner.StartOptions) (*clirunner.Process, error) + script string + // home is an agent-host view's Session home on the host. It contains + // DataDir and belongs to the Session user, so the daemon reads DataDir + // only within it. + home string } func prepareOptions(ctx context.Context, req proto.PromptRequestPayload) (launchOptions, error) { @@ -29,17 +41,9 @@ func prepareOptions(ctx context.Context, req proto.PromptRequestPayload) (launch func prepareOptionsWithSkills(ctx context.Context, req proto.PromptRequestPayload, managedSkills bool) (launchOptions, error) { var result launchOptions - if _, err := harnessconfiguration.Configuration().PrepareHarnessConfig(req.AgentOptions); err != nil { + if err := validateOptions(req); err != nil { return result, err } - if req.StrictResume { - if err := validateExecutionRequest(req); err != nil { - return result, err - } - } - if req.Input.HasImages() { - return result, fmt.Errorf("mcode: ACP does not support attachments") - } root, err := agent.ManagedSkillsRoot("mcode", req.AgentStateKey, req.ConversationID, req.RunID) if err != nil { return result, err @@ -61,25 +65,74 @@ func prepareOptionsWithSkills(ctx context.Context, req proto.PromptRequestPayloa return result, fmt.Errorf("mcode: one or more configured Skills could not be installed") } } + data, err := os.OpenRoot(result.DataDir) + if err != nil { + return result, err + } + defer data.Close() + if result.Model, err = writeNativeConfig(req, data); err != nil { + return result, err + } + opts := req.AgentOptions + result.Env = append([]string{}, os.Environ()...) + if req.StrictResume { + result.Env = executionEnvironment() + } + if raw := opts["env"]; raw != nil && !req.StrictResume { + env, ok := raw.(map[string]any) + if !ok { + return result, fmt.Errorf("mcode: env must be an object") + } + for key, rawValue := range env { + value, ok := rawValue.(string) + if !ok || key == "" || strings.ContainsAny(key, "=\x00") || strings.ContainsRune(value, 0) { + return result, fmt.Errorf("mcode: invalid environment entry") + } + result.Env = append(result.Env, key+"="+value) + } + } + result.Env = append(result.Env, nativeEnvironment(req, result.DataDir)...) + result.MCP, err = mcpServers(opts["mcp_servers"]) + return result, err +} + +func validateOptions(req proto.PromptRequestPayload) error { + if _, err := harnessconfiguration.Configuration().PrepareHarnessConfig(req.AgentOptions); err != nil { + return err + } + if req.StrictResume { + if err := validateExecutionRequest(req); err != nil { + return err + } + } + if req.Input.HasImages() { + return fmt.Errorf("mcode: ACP does not support attachments") + } + return nil +} + +// writeNativeConfig writes the instructions and native configuration into the +// data directory and returns the model. +func writeNativeConfig(req proto.PromptRequestPayload, data *os.Root) (string, error) { opts := req.AgentOptions prompt := optionString(opts, "system_prompt") if override := optionString(opts, "override_system_prompt"); override != "" { prompt = override } if len(prompt) > 32*1024 { - return result, fmt.Errorf("mcode: combined instructions exceed the CLI's 32 KiB limit") + return "", fmt.Errorf("mcode: combined instructions exceed the CLI's 32 KiB limit") } - if err := os.WriteFile(filepath.Join(result.DataDir, "AGENTS.md"), []byte(prompt), 0o600); err != nil { - return result, err + if err := data.WriteFile("AGENTS.md", []byte(prompt), 0o600); err != nil { + return "", err } config := map[string]any{"logLevel": "error", "skills": map[string]any{"external": map[string]any{"enabled": false}}} - result.Model = optionString(opts, "model") - if result.Model == "" { - return result, fmt.Errorf("mcode: model is required") + model := optionString(opts, "model") + if model == "" { + return "", fmt.Errorf("mcode: model is required") } - provider, err := modelProviderConfig(opts["model_provider"], result.Model) + provider, err := modelProviderConfig(opts["model_provider"], model) if err != nil { - return result, err + return "", err } config["custom_provider"] = map[string]any{"oac": provider} if req.StrictResume { @@ -97,48 +150,61 @@ func prepareOptionsWithSkills(ctx context.Context, req proto.PromptRequestPayloa mode = "auto" } if mode != "auto" && mode != "default" && mode != "bypassPermissions" { - return result, fmt.Errorf("mcode: unsupported permission mode") + return "", fmt.Errorf("mcode: unsupported permission mode") } config["permissionMode"] = mode - data, err := json.Marshal(config) + raw, err := json.Marshal(config) if err != nil { - return result, err + return "", err } - if err := os.WriteFile(filepath.Join(result.DataDir, "config.yaml"), data, 0o600); err != nil { - return result, err + if err := data.WriteFile("config.yaml", raw, 0o600); err != nil { + return "", err } - result.Env = append([]string{}, os.Environ()...) if req.StrictResume { - result.Env = append(executionEnvironment(), "OAC_RUNTIME_MCODE_TOOL_POLICY=protected-mcp-v1") - if err := os.WriteFile(filepath.Join(result.DataDir, "mcp.json"), []byte(`{"mcpServers":{}}`), 0o600); err != nil { - return result, err + if err := data.WriteFile("mcp.json", []byte(`{"mcpServers":{}}`), 0o600); err != nil { + return "", err } + } + return model, nil +} + +// nativeEnvironment is the adapter's own native environment, with dataDir as +// the native process sees its data directory. The adapter owns the native +// state location, including after cold resume. +func nativeEnvironment(req proto.PromptRequestPayload, dataDir string) []string { + var env []string + if req.StrictResume { + env = append(env, "OAC_RUNTIME_MCODE_TOOL_POLICY=protected-mcp-v1") if !req.DisableSubagents { - result.Env = append(result.Env, "OAC_RUNTIME_MCODE_MAX_SUBAGENTS="+strconv.Itoa(*req.MaxConcurrentSubagents)) + env = append(env, "OAC_RUNTIME_MCODE_MAX_SUBAGENTS="+strconv.Itoa(*req.MaxConcurrentSubagents)) } else { - result.Env = append(result.Env, "OAC_RUNTIME_MCODE_MAX_SUBAGENTS=0") - } - } - if raw := opts["env"]; raw != nil && !req.StrictResume { - env, ok := raw.(map[string]any) - if !ok { - return result, fmt.Errorf("mcode: env must be an object") - } - for key, rawValue := range env { - value, ok := rawValue.(string) - if !ok || key == "" || strings.ContainsAny(key, "=\x00") || strings.ContainsRune(value, 0) { - return result, fmt.Errorf("mcode: invalid environment entry") - } - result.Env = append(result.Env, key+"="+value) + env = append(env, "OAC_RUNTIME_MCODE_MAX_SUBAGENTS=0") } } - // The adapter owns the native state location, including after cold resume. - result.Env = append(result.Env, "MINIMAX_DATA_DIR="+result.DataDir) + env = append(env, "MINIMAX_DATA_DIR="+dataDir) if req.StrictResume { - result.Env = append(result.Env, "HOME="+result.DataDir, "USERPROFILE="+result.DataDir) + env = append(env, "HOME="+dataDir, "USERPROFILE="+dataDir) } - result.MCP, err = mcpServers(opts["mcp_servers"]) - return result, err + return env +} + +// readData reads a file the native process wrote in its data directory, +// without leaving the Session home in a view. +func (o launchOptions) readData(name string) ([]byte, error) { + trusted := o.home + if trusted == "" { + trusted = o.DataDir + } + rel, err := filepath.Rel(trusted, filepath.Join(o.DataDir, name)) + if err != nil { + return nil, err + } + root, err := os.OpenRoot(trusted) + if err != nil { + return nil, err + } + defer root.Close() + return root.ReadFile(rel) } func optionString(options map[string]any, key string) string { diff --git a/apps/daemon/internal/agent/mcode/session.go b/apps/daemon/internal/agent/mcode/session.go index d93877cf..977aca9e 100644 --- a/apps/daemon/internal/agent/mcode/session.go +++ b/apps/daemon/internal/agent/mcode/session.go @@ -80,7 +80,14 @@ func newSession(ctx context.Context, req proto.PromptRequestPayload, out chan<- } func launch(ctx context.Context, req proto.PromptRequestPayload, opts launchOptions, binary string, out chan<- proto.Envelope) (*Session, error) { - process, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{"acp"}, Dir: opts.Dir, Env: opts.Env, NeedStdin: true, OwnProcessGroup: req.StrictResume}) + start, args := opts.start, []string{"acp"} + if start == nil { + start = clirunner.Start + } + if opts.script != "" { + args = []string{opts.script, "acp"} + } + process, err := start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: args, Dir: opts.Dir, Env: opts.Env, NeedStdin: true, OwnProcessGroup: req.StrictResume}) if err != nil { return nil, err } diff --git a/apps/daemon/internal/agent/mcode/view.go b/apps/daemon/internal/agent/mcode/view.go new file mode 100644 index 00000000..0651cc89 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/view.go @@ -0,0 +1,215 @@ +package mcode + +import ( + "context" + "errors" + "fmt" + "os" + "path" + "path/filepath" + "slices" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/viewloader" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +// In an agent-host view, node runs the CLI from the closure and the native +// data directory lives in the Session home. The workspace worker runs beside +// the CLI in the same view; bash, rg and git run in the sandbox. + +// Closure names and Session home directories. +const ( + viewNodeMount = "node" + viewHarnessMount = "mcode-harness" + viewDataName = "data" + viewTempName = "tmp" + viewPIName = "pi-agent" +) + +// viewInstall is the trusted MiniMax Code install as a view presents it. +type viewInstall struct { + closure []agent.ViewMount + loader viewloader.Fragment + // node, cli, bridge and assets are view paths: assets holds the CLI's + // builtin skills and agents. + node, cli, bridge, assets string +} + +func discoverView(options agent.DiscoveryOptions) *agent.View { + view, err := findView() + if err != nil { + fmt.Fprintf(options.Stderr, "oac-daemon: mcode agent-host view unavailable: %v\n", err) + return nil + } + return view +} + +func findView() (*agent.View, error) { + programs, err := findPrograms() + if err != nil { + return nil, err + } + node, err := filepath.EvalSymlinks(programs.node) + if err != nil { + return nil, err + } + loader, err := viewloader.For(node) + if err != nil { + return nil, err + } + install, err := newViewInstall(node, programs.binary, programs.bridge, loader) + if err != nil { + return nil, err + } + view := install.view() + if err := view.Validate(); err != nil { + return nil, err + } + return &view, nil +} + +// newViewInstall presents node's directory and the harness directory holding +// the bridge and the CLI as the closure, with loader for a dynamic node. node +// is a resolved host path. +func newViewInstall(node, cli, bridge string, loader viewloader.Fragment) (viewInstall, error) { + i := viewInstall{loader: loader} + if !filepath.IsAbs(bridge) { + return i, errors.New("the workspace bridge is not configured") + } + harness, err := filepath.EvalSymlinks(filepath.Dir(bridge)) + if err != nil { + return i, err + } + nodeMount := agent.ViewMount{Name: viewNodeMount, HostDir: filepath.Dir(node)} + harnessMount := agent.ViewMount{Name: viewHarnessMount, HostDir: harness} + i.closure = []agent.ViewMount{nodeMount, harnessMount} + i.node = path.Join(nodeMount.Path(), filepath.Base(node)) + inHarness := func(file string) (string, error) { + resolved, err := filepath.EvalSymlinks(file) + if err != nil { + return "", err + } + rel, err := filepath.Rel(harness, resolved) + if err != nil || !filepath.IsLocal(rel) { + return "", fmt.Errorf("%s is outside the harness directory %s", file, harness) + } + return path.Join(harnessMount.Path(), filepath.ToSlash(rel)), nil + } + if i.cli, err = inHarness(cli); err != nil { + return i, err + } + if i.bridge, err = inHarness(bridge); err != nil { + return i, err + } + i.assets = path.Join(path.Dir(i.cli), "assets") + resolvedCLI, _ := filepath.EvalSymlinks(cli) + for _, dir := range []string{"skills", "agents"} { + if info, err := os.Stat(filepath.Join(filepath.Dir(resolvedCLI), "assets", dir)); err != nil || !info.IsDir() { + return i, fmt.Errorf("the CLI's builtin %s are missing", dir) + } + } + return i, nil +} + +func (i viewInstall) view() agent.View { + // No forwarded tool needs a Harness variable, so ForwardEnv is empty. + view := agent.View{ + Closure: slices.Clone(i.closure), + Masks: []agent.ViewMask{ + // The CLI probes these for builtin assets and its install + // receipt before its own copy. + {Path: "/assets", Dir: true}, {Path: "/opt/assets", Dir: true}, {Path: "/install.json"}, {Path: "/opt/install.json"}, + // Node resolves a package the closure lacks, such as the + // worker's optional ripgrep, from /node_modules. + {Path: "/node_modules", Dir: true}, + }, + LocalExec: []string{i.node}, + Shims: []string{"git", "rg"}, + ShimPaths: []string{"/bin/bash"}, + Proxy: agent.ViewProxyNone, + Executor: i.executor, + } + i.loader.AddTo(&view) + return view +} + +func (i viewInstall) executor(ctx context.Context, req proto.PromptRequestPayload, session agent.ViewSession) (agent.Executor, error) { + return startExecutor(ctx, req, i.node, func(ctx context.Context) (launchOptions, error) { + return i.prepare(ctx, req, session) + }) +} + +// 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. +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 local.NetworkAccess != "enabled" || len(local.AllowedDomains) != 0 { + return launchOptions{}, fmt.Errorf("%w: a MiniMax Code view requires unrestricted workspace network", agent.ErrUnsupportedOperation) + } + if len(local.Skills) != 0 { + return launchOptions{}, fmt.Errorf("%w: a MiniMax Code view does not install Capabilities", agent.ErrUnsupportedOperation) + } + // Subagent settlement reads native history with a second process while + // the CLI runs, and a Session has one live view, which runs only the CLI. + if !req.DisableSubagents { + return launchOptions{}, fmt.Errorf("%w: a MiniMax Code view does not run Subagents", agent.ErrUnsupportedOperation) + } + workspace := local.WorkspaceRoot + if !path.IsAbs(workspace) || path.Clean(workspace) != workspace || workspace == "/" { + 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) + }) + if err != nil { + return launchOptions{}, err + } + private := req + private.LocalEnvironment, private.DisableExecutionEnvironment, private.MCPHTTPServers = nil, true, nil + if err := validateOptions(private); err != nil { + return launchOptions{}, err + } + + // The Session user owns the home after the first Launch; stay within it. + home, err := os.OpenRoot(session.Home.Host) + if err != nil { + return launchOptions{}, err + } + defer home.Close() + for _, dir := range []string{viewDataName, viewTempName, viewPIName} { + if err := home.MkdirAll(dir, 0o700); err != nil { + return launchOptions{}, err + } + } + data, err := home.OpenRoot(viewDataName) + if err != nil { + return launchOptions{}, err + } + defer data.Close() + opts := launchOptions{Dir: workspace, DataDir: filepath.Join(session.Home.Host, viewDataName), bindings: session.MCP, + start: session.Launch, script: i.cli, home: session.Home.Host} + if opts.Model, err = writeNativeConfig(private, data); err != nil { + return opts, err + } + dataDir, tempDir := path.Join(session.Home.View, viewDataName), path.Join(session.Home.View, viewTempName) + opts.Env = []string{ + "PATH=" + path.Join(agent.ViewPrivateRoot, agent.ViewShimName), + "TMPDIR=" + tempDir, + "PI_CODING_AGENT_DIR=" + path.Join(session.Home.View, viewPIName), + "MAVIS_BUILTIN_SKILLS_DIR=" + path.Join(i.assets, "skills"), + "MAVIS_BUILTIN_AGENTS_DIR=" + path.Join(i.assets, "agents"), + "MAVIS_BUILTIN_AGENTS_V2_DIR=" + path.Join(i.assets, "agents"), + } + if i.loader.LibraryPath != "" { + opts.Env = append(opts.Env, "LD_LIBRARY_PATH="+i.loader.LibraryPath) + } + opts.Env = append(opts.Env, nativeEnvironment(private, dataDir)...) + profile := map[string]any{"workspace": workspace, "scratch": tempDir, "network": "enabled"} + tools := workspaceTools{node: i.node, bridge: i.bridge, profile: path.Join(dataDir, "workspace-profile.json")} + return opts, writeWorkspaceTools(&opts, data, req, tools, profile, nil, servers) +} diff --git a/apps/daemon/internal/agent/mcode/view_test.go b/apps/daemon/internal/agent/mcode/view_test.go new file mode 100644 index 00000000..5b20c188 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/view_test.go @@ -0,0 +1,149 @@ +package mcode + +import ( + "encoding/json" + "errors" + "io/fs" + "os" + "path" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "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/modelprovider" +) + +const viewRealKey = "sk-view-real-key-sentinel" + +// viewFixture registers a view over a fake install and returns it with a +// request as the agent host hands it over. +func viewFixture(t *testing.T) (viewInstall, agent.View, proto.PromptRequestPayload) { + t.Helper() + harness, bin, libs := t.TempDir(), t.TempDir(), t.TempDir() + for _, file := range []string{"bridge.mjs", "native/cli.js", "native/assets/skills/.keep", "native/assets/agents/.keep", filepath.Join(bin, "node")} { + if !filepath.IsAbs(file) { + file = filepath.Join(harness, file) + } + if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, nil, 0o755); err != nil { + t.Fatal(err) + } + } + lib := agent.ViewMount{Name: viewloader.MountName, HostDir: libs} + loader := viewloader.Fragment{Closure: []agent.ViewMount{lib}, LibraryPath: lib.Path(), + Overlays: []agent.ViewOverlay{{Path: "/lib64/ld-linux-x86-64.so.2", Source: filepath.Join(libs, "ld.so"), Exec: true}}} + install, err := newViewInstall(filepath.Join(bin, "node"), filepath.Join(harness, "native/cli.js"), filepath.Join(harness, "bridge.mjs"), loader) + if err != nil { + t.Fatal(err) + } + declared := install.view() + registry := agent.NewRegistry() + info := Declaration.Info + info.Available = true + registry.Register(Declaration, agent.Runtime{Info: info, Session: Factory, View: &declared}) + view, err := registry.ResolveView("mcode") + if err != nil { + t.Fatal(err) + } + + t.Setenv("OAC_TEST_VIEW_SENTINEL", "daemon-only") + t.Setenv("ANTHROPIC_API_KEY", viewRealKey) + req := executionRequest(t) + req.RunID, req.Input, req.ConversationID = "", nil, "" + req.DisableExecutionEnvironment = false + req.LocalEnvironment = &proto.LocalEnvironment{WorkspaceRoot: "/workspace", NetworkAccess: "enabled"} + req.AgentOptions["model_provider"] = map[string]any{"protocol": "anthropic", "base_url": "http://127.0.0.1:4101", "api_key": modelprovider.Placeholder, "context_window": 64000, "max_output_tokens": 4096} + return install, view, req +} + +func viewSession(launched *clirunner.StartOptions) agent.ViewSession { + return agent.ViewSession{Launch: func(options clirunner.StartOptions) (*clirunner.Process, error) { + *launched = options + return nil, errors.New("launch recorded") + }} +} + +func TestViewLaunchesNodeWithGatewayOnly(t *testing.T) { + install, view, req := viewFixture(t) + var launched clirunner.StartOptions + session := viewSession(&launched) + session.Home = agent.ViewDir{Host: t.TempDir(), View: path.Join(agent.ViewPrivateRoot, agent.ViewHomeName)} + subagents := req + subagents.DisableSubagents, subagents.MaxConcurrentSubagents = false, new(2) + if _, err := view.Executor(t.Context(), subagents, session); !errors.Is(err, agent.ErrUnsupportedOperation) || launched.Binary != "" { + t.Fatalf("Executor with Subagents = %v, launched %q", err, launched.Binary) + } + if _, err := view.Executor(t.Context(), req, session); err == nil || err.Error() != "launch recorded" { + t.Fatalf("Executor = %v", err) + } + + if !slices.Contains(view.LocalExec, launched.Binary) || !slices.Equal(launched.Args, []string{install.cli, "acp"}) || path.Base(install.cli) != "cli.js" || launched.Dir != "/workspace" { + t.Fatalf("launched %s %q in %s", launched.Binary, launched.Args, launched.Dir) + } + env := strings.Join(launched.Env, "\n") + if strings.Contains(env, "OAC_TEST_VIEW_SENTINEL") || strings.Contains(env, viewRealKey) || !slices.Contains(launched.Env, "HOME="+path.Join(session.Home.View, viewDataName)) || + !slices.Contains(launched.Env, "LD_LIBRARY_PATH="+install.loader.LibraryPath) { + t.Fatalf("environment is not closed: %q", launched.Env) + } + config, err := os.ReadFile(filepath.Join(session.Home.Host, viewDataName, "config.yaml")) + if err != nil { + t.Fatal(err) + } + var native struct { + Provider struct { + OAC struct { + Options struct{ BaseURL, APIKey string } + } `json:"oac"` + } `json:"custom_provider"` + } + if err := json.Unmarshal(config, &native); err != nil || native.Provider.OAC.Options.BaseURL != "http://127.0.0.1:4101" || native.Provider.OAC.Options.APIKey != modelprovider.Placeholder { + t.Fatalf("native provider = %+v (%v)", native.Provider.OAC.Options, err) + } + profile, err := os.ReadFile(filepath.Join(session.Home.Host, viewDataName, "workspace-profile.json")) + if err != nil || strings.Contains(string(profile), "toolEnvFile") { + t.Fatalf("profile = %s (%v)", profile, err) + } + err = filepath.WalkDir(session.Home.Host, func(file string, entry fs.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err + } + if raw, err := os.ReadFile(file); err != nil || strings.Contains(string(raw), viewRealKey) { + t.Errorf("%s holds the real key (%v)", file, err) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestViewDoesNotFollowHomeLinks(t *testing.T) { + _, view, req := viewFixture(t) + outside := filepath.Join(t.TempDir(), "outside") + if err := os.WriteFile(outside, []byte("unchanged"), 0o600); err != nil { + t.Fatal(err) + } + home := t.TempDir() + if err := os.Mkdir(filepath.Join(home, viewDataName), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(home, viewDataName, "config.yaml")); err != nil { + t.Fatal(err) + } + var launched clirunner.StartOptions + session := viewSession(&launched) + session.Home = agent.ViewDir{Host: home, View: path.Join(agent.ViewPrivateRoot, agent.ViewHomeName)} + if _, err := view.Executor(t.Context(), req, session); err == nil || launched.Binary != "" { + t.Fatalf("Executor = %v, launched %q", err, launched.Binary) + } + if raw, err := os.ReadFile(outside); err != nil || string(raw) != "unchanged" { + t.Fatalf("outside file = %q (%v)", raw, err) + } +} diff --git a/apps/daemon/internal/agent/mcode/workspace.go b/apps/daemon/internal/agent/mcode/workspace.go index 5c8f5aa6..ef7c77fa 100644 --- a/apps/daemon/internal/agent/mcode/workspace.go +++ b/apps/daemon/internal/agent/mcode/workspace.go @@ -57,7 +57,7 @@ func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.P if !req.StrictResume || req.LocalEnvironment == nil || req.LocalEnvironment.WorkspaceRoot != c.Directory || req.DisableExecutionEnvironment || !(agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Equal(agentnetwork.Policy{Access: req.LocalEnvironment.NetworkAccess, AllowedDomains: req.LocalEnvironment.AllowedDomains}) || req.WorkspaceReadOnly { return launchOptions{}, fmt.Errorf("mcode: execution does not match the dedicated workspace") } - servers, err := runtimeMCP(req) + servers, bindings, err := runtimeMCP(req) if err != nil { return launchOptions{}, err } @@ -71,7 +71,8 @@ func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.P if err != nil { return opts, err } - opts.Dir = c.Directory + opts.Dir, opts.bindings = c.Directory, bindings + var skills []string if len(req.LocalEnvironment.Skills) > 0 { root := filepath.Join(opts.DataDir, "skills") if err := os.MkdirAll(root, 0700); err != nil { @@ -89,59 +90,63 @@ func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.P } else if err := os.Symlink(target, link); err != nil { return opts, err } + skills = append(skills, skill.Metadata.Name) } } - - raw, err := os.ReadFile(filepath.Join(opts.DataDir, "config.yaml")) + profile := map[string]any{"capabilityRoot": req.LocalEnvironment.CapabilityRoot, "workspace": c.Directory, "scratch": c.Scratch, "network": c.Network, "allowedDomains": (agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Hosts(), "skills": len(skills) > 0} + file, err := localworkspace.ToolEnvironmentFile() + if err != nil { + return opts, err + } + if file != "" { + profile["toolEnvFile"] = file + } + data, err := os.OpenRoot(opts.DataDir) if err != nil { return opts, err } + defer data.Close() + return opts, writeWorkspaceTools(&opts, data, req, workspaceTools{node: c.Node, bridge: c.Bridge, profile: filepath.Join(opts.DataDir, "workspace-profile.json")}, profile, skills, servers) +} + +// workspaceTools are the workspace bridge as the native process runs it, and +// the bridge's profile path. +type workspaceTools struct{ node, bridge, profile string } + +// writeWorkspaceTools turns the native configuration in data over to the +// workspace bridge: native permissions and sandbox are off, the bridge's +// profile is written, and oac_workspace precedes the Session's servers. +func writeWorkspaceTools(opts *launchOptions, data *os.Root, req proto.PromptRequestPayload, tools workspaceTools, profile map[string]any, skills []string, servers []map[string]any) error { + raw, err := data.ReadFile("config.yaml") + if err != nil { + return err + } var config map[string]any if err = json.Unmarshal(raw, &config); err != nil { - return opts, err + return err } config["permissionMode"] = "bypassPermissions" config["sandbox"] = map[string]bool{"enabled": false} - if len(req.LocalEnvironment.Skills) > 0 { + if len(skills) > 0 { selected := config["agents"].(map[string]any)["default"].(map[string]any) - names := make([]string, 0, len(req.LocalEnvironment.Skills)) - for _, skill := range req.LocalEnvironment.Skills { - names = append(names, skill.Metadata.Name) - } - selected["skills"] = names + selected["skills"] = skills for _, key := range []string{"tools", "builtinTools"} { selected[key] = append(selected[key].([]any), "skill") } } - raw, err = json.Marshal(config) - if err != nil { - return opts, err - } - if err = os.WriteFile(filepath.Join(opts.DataDir, "config.yaml"), raw, 0600); err != nil { - return opts, err + if raw, err = json.Marshal(config); err != nil { + return err } - profile := map[string]any{"capabilityRoot": req.LocalEnvironment.CapabilityRoot, "workspace": "/workspace", "scratch": c.Scratch, "network": c.Network, "allowedDomains": (agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Hosts(), "skills": len(req.LocalEnvironment.Skills) > 0} - - profile["workspace"] = c.Directory - { - file, err := localworkspace.ToolEnvironmentFile() - if err != nil { - return opts, err - } - if file != "" { - profile["toolEnvFile"] = file - } + if err = data.WriteFile("config.yaml", raw, 0600); err != nil { + return err } - - raw, err = json.Marshal(profile) - if err != nil { - return opts, err + if raw, err = json.Marshal(profile); err != nil { + return err } - path := filepath.Join(opts.DataDir, "workspace-profile.json") - if err = os.WriteFile(path, raw, 0600); err != nil { - return opts, err + if err = data.WriteFile("workspace-profile.json", raw, 0600); err != nil { + return err } - opts.MCP = []map[string]any{{"name": "oac_workspace", "command": c.Node, "args": []string{c.Bridge, path}, "env": []map[string]string{}}} + opts.MCP = []map[string]any{{"name": "oac_workspace", "command": tools.node, "args": []string{tools.bridge, tools.profile}, "env": []map[string]string{}}} opts.MCP = append(opts.MCP, servers...) if !req.DisableSubagents { // This native data directory belongs to one public Session and its @@ -153,11 +158,11 @@ func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.P } raw, err := json.Marshal(map[string]any{"mcpServers": configured}) if err != nil { - return opts, err + return err } - if err := os.WriteFile(filepath.Join(opts.DataDir, "mcp.json"), raw, 0o600); err != nil { - return opts, err + if err := data.WriteFile("mcp.json", raw, 0o600); err != nil { + return err } } - return opts, nil + return nil } diff --git a/packages/mcode-harness/README.md b/packages/mcode-harness/README.md index d0893d1c..2d180609 100644 --- a/packages/mcode-harness/README.md +++ b/packages/mcode-harness/README.md @@ -2,7 +2,7 @@ This companion package lets the daemon run MiniMax Code with OpenAgentCore's workspace. MiniMax Code keeps its own ACP Session, model loop and history. The package supplies a trusted MCP server (`bridge.mjs`), which the daemon registers as `oac_workspace` (its MCP server info names it `oac-workspace`). It exposes six native MiniMax Code tools rooted at the Session's workspace: `workspace_read`, `workspace_write`, `workspace_edit`, `workspace_bash`, `workspace_grep` and `workspace_glob`. The tools run as the daemon's user with ordinary permissions; the package adds no inner sandbox. The [MiniMax Code Runtime](../../services/core/deploy/mcode/README.md) guide owns the Runtime image, configuration and qualified deployment. -One patch script (`patch-native.mjs`) makes three edits to the pinned native CLI source. The SQLite task-admission transaction enforces the daemon's Subagent concurrency limit before child work starts; foreground, background, nested and idle-child append admissions share that transaction, and terminal native tasks release capacity. ACP initialization reports `oac/subagents` metadata: its version, the applied workspace tool policy and the admission limit. The native tool catalog applies the `protected-mcp-v1` tool gate described under [Subagents and cancellation](#subagents-and-cancellation). No second model or scheduling loop is introduced. Hosted public execution is not qualified by this package alone. +One patch script (`patch-native.mjs`) makes four edits to the pinned native CLI source. The SQLite task-admission transaction enforces the daemon's Subagent concurrency limit before child work starts; foreground, background, nested and idle-child append admissions share that transaction, and terminal native tasks release capacity. ACP initialization reports `oac/subagents` metadata: its version, the applied workspace tool policy and the admission limit. The native tool catalog applies the `protected-mcp-v1` tool gate described under [Subagents and cancellation](#subagents-and-cancellation). Under the same policy, the CLI ignores the workspace's project `.mcp.json`, so the Session's MCP comes only from the daemon. No second model or scheduling loop is introduced. Hosted public execution is not qualified by this package alone. ## Workspace tools diff --git a/packages/mcode-harness/patch-native.mjs b/packages/mcode-harness/patch-native.mjs index 13700c1b..3c38b73d 100644 --- a/packages/mcode-harness/patch-native.mjs +++ b/packages/mcode-harness/patch-native.mjs @@ -38,6 +38,11 @@ writeFileSync(catalogPath, withWorkspace.replace(gate, ` if (process.env.OAC_RU if (source === 'builtin-matrix') return false; } ${gate}`)); +const projectPath = join(root, 'packages/local-runtime-v2/src/service/mcp/project-mcp.service.ts'); +const project = readFileSync(projectPath, 'utf8'); +const projectGate = ' if (!context?.workspaceRoot || !context.sessionId) return {};'; +if (project.split(projectGate).length !== 2) throw new Error('Pinned native project MCP source changed'); +writeFileSync(projectPath, project.replace(projectGate, " if (process.env.OAC_RUNTIME_MCODE_TOOL_POLICY === 'protected-mcp-v1' || !context?.workspaceRoot || !context.sessionId) return {};")); copyFileSync(join(here, 'native-subagent-admission.mjs'), join(root, 'packages/local-runtime/src/background-task/oac-subagent-admission.mjs')); const digest = name => createHash('sha256').update(readFileSync(join(here, name))).digest('hex'); writeFileSync(join(root, '.oac-native-patch.json'), JSON.stringify({ revision: pin.revision,