From 34de2e0ddf4bdb895dabb966e69c02f79a10f896 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 1 Oct 2026 02:53:52 +0000 Subject: [PATCH 1/3] Declare the MiniMax Code agent-host view and its view Executor Discover fills View from the installed node, CLI and workspace bridge: the closure holds node's directory, the harness directory and node's library directories, with the ELF interpreter as an Exec overlay. The view Executor writes the native configuration into the Session home through os.Root, runs node cli.js acp through ViewSession.Launch with a closed environment, takes MCP only from ViewSession.MCP and keeps the workspace worker local. The Subagent history reader runs on the host as the Session user. The native patch ignores the workspace's project .mcp.json under protected-mcp-v1. --- .../internal/agent/mcode/declaration.go | 3 + .../agent/mcode/discovery_workspace.go | 49 ++-- .../internal/agent/mcode/environment_mcp.go | 31 ++- apps/daemon/internal/agent/mcode/execution.go | 3 - apps/daemon/internal/agent/mcode/executor.go | 47 ++-- .../internal/agent/mcode/mcp_observations.go | 16 +- .../agent/mcode/mcp_observations_test.go | 8 + apps/daemon/internal/agent/mcode/options.go | 159 ++++++++--- .../internal/agent/mcode/owner_other.go | 15 ++ .../daemon/internal/agent/mcode/owner_unix.go | 29 ++ apps/daemon/internal/agent/mcode/session.go | 9 +- apps/daemon/internal/agent/mcode/subagents.go | 40 ++- apps/daemon/internal/agent/mcode/view.go | 252 ++++++++++++++++++ .../internal/agent/mcode/view_loader.go | 102 +++++++ apps/daemon/internal/agent/mcode/view_test.go | 162 +++++++++++ apps/daemon/internal/agent/mcode/workspace.go | 85 +++--- packages/mcode-harness/README.md | 2 +- packages/mcode-harness/patch-native.mjs | 5 + 18 files changed, 856 insertions(+), 161 deletions(-) create mode 100644 apps/daemon/internal/agent/mcode/owner_other.go create mode 100644 apps/daemon/internal/agent/mcode/owner_unix.go create mode 100644 apps/daemon/internal/agent/mcode/view.go create mode 100644 apps/daemon/internal/agent/mcode/view_loader.go create mode 100644 apps/daemon/internal/agent/mcode/view_test.go diff --git a/apps/daemon/internal/agent/mcode/declaration.go b/apps/daemon/internal/agent/mcode/declaration.go index 69209159f..5215c4a1c 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(parent, 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 4438aa643..9f6060436 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 c675465c8..7f388c803 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/execution.go b/apps/daemon/internal/agent/mcode/execution.go index a54bfc742..ae1874216 100644 --- a/apps/daemon/internal/agent/mcode/execution.go +++ b/apps/daemon/internal/agent/mcode/execution.go @@ -23,9 +23,6 @@ func validateExecutionRequest(req proto.PromptRequestPayload) error { if req.MaxConcurrentSubagents == nil || *req.MaxConcurrentSubagents < 1 { return fmt.Errorf("mcode: Subagent concurrency limit is required") } - if _, _, err := subagentReader(); err != nil { - return err - } } if mode := optionString(req.AgentOptions, "mode"); mode != "" { return fmt.Errorf("mcode: text execution uses default native permissions") diff --git a/apps/daemon/internal/agent/mcode/executor.go b/apps/daemon/internal/agent/mcode/executor.go index 69cf28357..ffd1e1a7d 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 364d8995b..f4c8e01be 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 1c790f731..240c8913d 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 86305f17d..535f04a8b 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,19 @@ 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. reader is that view's Subagent history reader; nil + // selects the deployment's. + home string + reader *historyReader } func prepareOptions(ctx context.Context, req proto.PromptRequestPayload) (launchOptions, error) { @@ -29,17 +43,14 @@ 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 { + if req.StrictResume && !req.DisableSubagents { + if _, err := deploymentHistoryReader(); 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 +72,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 +157,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/owner_other.go b/apps/daemon/internal/agent/mcode/owner_other.go new file mode 100644 index 000000000..29d7dfb0e --- /dev/null +++ b/apps/daemon/internal/agent/mcode/owner_other.go @@ -0,0 +1,15 @@ +//go:build !unix + +package mcode + +import ( + "fmt" + "os/exec" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" +) + +// runAsOwner is unavailable: agent-host views run on Linux. +func runAsOwner(*exec.Cmd, string) error { + return fmt.Errorf("%w: mcode agent-host views run on Linux", agent.ErrUnsupportedOperation) +} diff --git a/apps/daemon/internal/agent/mcode/owner_unix.go b/apps/daemon/internal/agent/mcode/owner_unix.go new file mode 100644 index 000000000..9413685b6 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/owner_unix.go @@ -0,0 +1,29 @@ +//go:build unix + +package mcode + +import ( + "fmt" + "os" + "os/exec" + "syscall" +) + +// runAsOwner runs command as the user and group that own dir, with no +// supplementary groups. An agent-host view gives the Session home to the +// Session user, and the daemon reads it as that user. +func runAsOwner(command *exec.Cmd, dir string) error { + info, err := os.Lstat(dir) + if err != nil { + return err + } + owner, ok := info.Sys().(*syscall.Stat_t) + if !ok || !info.IsDir() { + return fmt.Errorf("mcode: Session home is not a directory") + } + if int(owner.Uid) == os.Geteuid() && int(owner.Gid) == os.Getegid() { + return nil + } + command.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: owner.Uid, Gid: owner.Gid, Groups: []uint32{}}} + return nil +} diff --git a/apps/daemon/internal/agent/mcode/session.go b/apps/daemon/internal/agent/mcode/session.go index d93877cfa..977aca9ee 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/subagents.go b/apps/daemon/internal/agent/mcode/subagents.go index 65c05a7b2..61d5ddfad 100644 --- a/apps/daemon/internal/agent/mcode/subagents.go +++ b/apps/daemon/internal/agent/mcode/subagents.go @@ -52,28 +52,52 @@ type nativeSubagentTask struct { Metadata struct{ ChildSessionID, ParentSessionID, ParentTurnID, SubTurnID, ExecutionMode string } } -func subagentReader() (string, string, error) { +// historyReader runs the pinned Subagent history reader on the daemon host. +type historyReader struct { + node, script string + // home, when set, is an agent-host view's Session home. The reader then + // runs as the home's owner, the Session user, with an empty environment. + home string +} + +func deploymentHistoryReader() (historyReader, error) { node, bridge := os.Getenv("OAC_RUNTIME_MCODE_NODE"), os.Getenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE") reader := filepath.Join(filepath.Dir(bridge), "subagent-snapshot.mjs") for _, path := range []string{node, bridge, reader} { resolved, err := filepath.EvalSymlinks(path) if err != nil || !filepath.IsAbs(path) || resolved != path { - return "", "", fmt.Errorf("mcode: protected Subagent reader is unavailable") + return historyReader{}, fmt.Errorf("mcode: protected Subagent reader is unavailable") } } - return node, reader, nil + return historyReader{node: node, script: reader}, nil +} + +func (r historyReader) command(ctx context.Context, dataDir, session string) (*exec.Cmd, error) { + command := exec.CommandContext(ctx, r.node, "--disable-warning=ExperimentalWarning", r.script, dataDir, session) + if r.home == "" { + command.Env = executionEnvironment() + return command, nil + } + command.Env = []string{} + return command, runAsOwner(command, r.home) } func (s *Session) readSubagents(ctx context.Context) (nativeSubagentSnapshot, error) { var snapshot nativeSubagentSnapshot - node, reader, err := subagentReader() - if err != nil { - return snapshot, err + reader := s.opts.reader + if reader == nil { + deployment, err := deploymentHistoryReader() + if err != nil { + return snapshot, err + } + reader = &deployment } ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() - command := exec.CommandContext(ctx, node, "--disable-warning=ExperimentalWarning", reader, s.opts.DataDir, s.sessionID) - command.Env = executionEnvironment() + command, err := reader.command(ctx, s.opts.DataDir, s.sessionID) + if err != nil { + return snapshot, fmt.Errorf("mcode: child history reader is unavailable") + } stdout, err := command.StdoutPipe() if err != nil { return snapshot, fmt.Errorf("mcode: child history reader is unavailable") diff --git a/apps/daemon/internal/agent/mcode/view.go b/apps/daemon/internal/agent/mcode/view.go new file mode 100644 index 000000000..78ef8c112 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/view.go @@ -0,0 +1,252 @@ +package mcode + +import ( + "context" + "errors" + "fmt" + "os" + "path" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "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" + viewLibraryMount = "node-lib" + 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 + overlays []agent.ViewOverlay + masks []agent.ViewMask + // libraryPath is LD_LIBRARY_PATH in the view; empty for a static node. + libraryPath string + // node, cli, bridge and assets are view paths: assets holds the CLI's + // builtin skills and agents. + node, cli, bridge, assets string + reader historyReader +} + +func discoverView(parent context.Context, options agent.DiscoveryOptions) *agent.View { + ctx, cancel := context.WithTimeout(parent, 15*time.Second) + defer cancel() + install, err := findViewInstall(ctx) + if err != nil { + fmt.Fprintf(options.Stderr, "oac-daemon: mcode agent-host view unavailable: %v\n", err) + return nil + } + view := install.view() + return &view +} + +func findViewInstall(ctx context.Context) (viewInstall, error) { + if runtime.GOOS != "linux" { + return viewInstall{}, errors.New("agent-host views run on Linux") + } + programs, err := findPrograms() + if err != nil { + return viewInstall{}, err + } + node, err := filepath.EvalSymlinks(programs.node) + if err != nil { + return viewInstall{}, err + } + loader, err := findLoader(ctx, node) // TODO(viewloader) + if err != nil { + return viewInstall{}, err + } + return newViewInstall(node, programs.binary, programs.bridge, loader) +} + +// newViewInstall presents node's directory, the harness directory holding the +// bridge and the CLI, and node's library directories as the closure. node is +// a resolved host path. +func newViewInstall(node, cli, bridge string, loader nodeLoader) (viewInstall, error) { + var i viewInstall + 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) + } + } + i.reader = historyReader{node: node, script: filepath.Join(harness, "subagent-snapshot.mjs")} + if info, err := os.Lstat(i.reader.script); err != nil || !info.Mode().IsRegular() { + return i, errors.New("the Subagent history reader is missing") + } + + // TODO(viewloader): take the loader's overlay, closure, masks and + // LD_LIBRARY_PATH from the shared view loader. + if loader.interp == "" { + return i, nil + } + i.overlays = []agent.ViewOverlay{{Path: loader.interp, Source: loader.source, Exec: true}} + // The loader reads both from the sandbox's /etc otherwise. + i.masks = []agent.ViewMask{{Path: "/etc/ld.so.cache"}, {Path: "/etc/ld.so.preload"}} + mounts := map[string]string{nodeMount.HostDir: nodeMount.Path(), harnessMount.HostDir: harnessMount.Path()} + var libraryPath []string + for _, dir := range loader.libraries { + mount, ok := mounts[dir] + if !ok { + name := viewLibraryMount + if n := len(i.closure) - 1; n > 1 { + name += "-" + strconv.Itoa(n) + } + m := agent.ViewMount{Name: name, HostDir: dir} + i.closure = append(i.closure, m) + mount, mounts[dir] = m.Path(), m.Path() + } + if !slices.Contains(libraryPath, mount) { + libraryPath = append(libraryPath, mount) + } + } + i.libraryPath = strings.Join(libraryPath, ":") + return i, nil +} + +func (i viewInstall) view() agent.View { + masks := append([]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}, + }, i.masks...) + // No forwarded tool needs a Harness variable, so ForwardEnv is empty. + return agent.View{ + Closure: slices.Clone(i.closure), + Overlays: slices.Clone(i.overlays), + Masks: masks, + LocalExec: []string{i.node}, + Shims: []string{"git", "rg"}, + ShimPaths: []string{"/bin/bash"}, + Proxy: agent.ViewProxyNone, + Executor: i.executor, + } +} + +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) + } + 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.libraryPath != "" { + opts.Env = append(opts.Env, "LD_LIBRARY_PATH="+i.libraryPath) + } + opts.Env = append(opts.Env, nativeEnvironment(private, dataDir)...) + if !req.DisableSubagents { + reader := i.reader + reader.home = session.Home.Host + opts.reader = &reader + } + 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_loader.go b/apps/daemon/internal/agent/mcode/view_loader.go new file mode 100644 index 000000000..a3b238070 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/view_loader.go @@ -0,0 +1,102 @@ +package mcode + +import ( + "context" + "debug/elf" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" +) + +// TODO(viewloader): replace this file with the shared view loader. + +// nodeLoader is how the host loads a dynamic node: the ELF interpreter the +// binary names, the host file presenting it, and the resolved directories its +// libraries load from. A static node has none. +type nodeLoader struct { + interp, source string + libraries []string +} + +func findLoader(ctx context.Context, node string) (nodeLoader, error) { + var loader nodeLoader + file, err := elf.Open(node) + if err != nil { + return loader, err + } + defer file.Close() + for _, prog := range file.Progs { + if prog.Type == elf.PT_INTERP { + raw, err := io.ReadAll(io.LimitReader(prog.Open(), 4096)) + if err != nil { + return loader, err + } + loader.interp = strings.TrimRight(string(raw), "\x00") + } + } + if loader.interp == "" { + return loader, nil + } + if !filepath.IsAbs(loader.interp) || filepath.Clean(loader.interp) != loader.interp { + return loader, fmt.Errorf("node's interpreter %q is not a clean absolute path", loader.interp) + } + if loader.source, err = filepath.EvalSymlinks(loader.interp); err != nil { + return loader, err + } + // The view resolves the same libraries through LD_LIBRARY_PATH alone. + command := exec.CommandContext(ctx, loader.source, "--list", node) + command.Env = []string{} + out, err := command.Output() + if err != nil { + return loader, fmt.Errorf("node's libraries: %w", err) + } + for line := range strings.Lines(string(out)) { + _, resolved, ok := strings.Cut(strings.TrimSpace(line), " => ") + if !ok { + continue + } + library, _, _ := strings.Cut(resolved, " (") + if !filepath.IsAbs(library) { + return loader, fmt.Errorf("node's library %s", strings.TrimSpace(line)) + } + dir, err := libraryDir(library) + if err != nil { + return loader, err + } + if !slices.Contains(loader.libraries, dir) { + loader.libraries = append(loader.libraries, dir) + } + } + return loader, nil +} + +// libraryDir returns the resolved directory holding library. The library's +// name may link only to other names in that directory, so presenting the +// directory presents the library. +func libraryDir(library string) (string, error) { + dir, err := filepath.EvalSymlinks(filepath.Dir(library)) + if err != nil { + return "", err + } + name := filepath.Base(library) + for range 8 { + info, err := os.Lstat(filepath.Join(dir, name)) + if err != nil { + return "", err + } + if info.Mode().IsRegular() { + return dir, nil + } + if info.Mode()&os.ModeSymlink == 0 { + break + } + if name, err = os.Readlink(filepath.Join(dir, name)); err != nil || strings.ContainsRune(name, '/') { + break + } + } + return "", fmt.Errorf("node's library %s does not resolve within its directory", library) +} 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 000000000..de927bc8c --- /dev/null +++ b/apps/daemon/internal/agent/mcode/view_test.go @@ -0,0 +1,162 @@ +package mcode + +import ( + "encoding/json" + "errors" + "io/fs" + "os" + "os/exec" + "path" + "path/filepath" + "runtime" + "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/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", "subagent-snapshot.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) + } + } + install, err := newViewInstall(filepath.Join(bin, "node"), filepath.Join(harness, "native/cli.js"), filepath.Join(harness, "bridge.mjs"), + nodeLoader{interp: "/lib64/ld-linux-x86-64.so.2", source: filepath.Join(libs, "ld.so"), libraries: []string{libs}}) + 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, req.DisableSubagents, req.MaxConcurrentSubagents = false, false, new(2) + 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)} + 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)) { + 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) + } +} + +func TestViewLoaderListsHostNode(t *testing.T) { + node, err := exec.LookPath("node") + if runtime.GOOS != "linux" || err != nil { + t.Skip("needs node on Linux") + } + if node, err = filepath.EvalSymlinks(node); err != nil { + t.Fatal(err) + } + loader, err := findLoader(t.Context(), node) + if err != nil { + t.Fatal(err) + } + if loader.interp == "" { + t.Skip("node is static") + } + if loader.source == "" || len(loader.libraries) == 0 { + t.Fatalf("loader = %+v", loader) + } +} diff --git a/apps/daemon/internal/agent/mcode/workspace.go b/apps/daemon/internal/agent/mcode/workspace.go index 5c8f5aa6d..ef7c77faa 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 d0893d1c8..2d1806094 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 13700c1bc..3c38b73d0 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, From e33da39ce8a303423de3048f96442a8a9d2fd3b6 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 1 Oct 2026 02:56:31 +0000 Subject: [PATCH 2/3] Keep the MiniMax Code Subagent reader out of agent-host views The reader ran on the agent host as the Session user, outside the view's containment. A Session has one live view, which runs only the CLI, so a view Executor rejects enabled Subagents with ErrUnsupportedOperation and starts no reader. --- apps/daemon/internal/agent/mcode/execution.go | 3 ++ apps/daemon/internal/agent/mcode/options.go | 11 +---- .../internal/agent/mcode/owner_other.go | 15 ------- .../daemon/internal/agent/mcode/owner_unix.go | 29 -------------- apps/daemon/internal/agent/mcode/subagents.go | 40 ++++--------------- apps/daemon/internal/agent/mcode/view.go | 16 +++----- apps/daemon/internal/agent/mcode/view_test.go | 9 ++++- 7 files changed, 25 insertions(+), 98 deletions(-) delete mode 100644 apps/daemon/internal/agent/mcode/owner_other.go delete mode 100644 apps/daemon/internal/agent/mcode/owner_unix.go diff --git a/apps/daemon/internal/agent/mcode/execution.go b/apps/daemon/internal/agent/mcode/execution.go index ae1874216..a54bfc742 100644 --- a/apps/daemon/internal/agent/mcode/execution.go +++ b/apps/daemon/internal/agent/mcode/execution.go @@ -23,6 +23,9 @@ func validateExecutionRequest(req proto.PromptRequestPayload) error { if req.MaxConcurrentSubagents == nil || *req.MaxConcurrentSubagents < 1 { return fmt.Errorf("mcode: Subagent concurrency limit is required") } + if _, _, err := subagentReader(); err != nil { + return err + } } if mode := optionString(req.AgentOptions, "mode"); mode != "" { return fmt.Errorf("mcode: text execution uses default native permissions") diff --git a/apps/daemon/internal/agent/mcode/options.go b/apps/daemon/internal/agent/mcode/options.go index 535f04a8b..4f2975062 100644 --- a/apps/daemon/internal/agent/mcode/options.go +++ b/apps/daemon/internal/agent/mcode/options.go @@ -31,10 +31,8 @@ type launchOptions struct { 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. reader is that view's Subagent history reader; nil - // selects the deployment's. - home string - reader *historyReader + // only within it. + home string } func prepareOptions(ctx context.Context, req proto.PromptRequestPayload) (launchOptions, error) { @@ -46,11 +44,6 @@ func prepareOptionsWithSkills(ctx context.Context, req proto.PromptRequestPayloa if err := validateOptions(req); err != nil { return result, err } - if req.StrictResume && !req.DisableSubagents { - if _, err := deploymentHistoryReader(); err != nil { - return result, err - } - } root, err := agent.ManagedSkillsRoot("mcode", req.AgentStateKey, req.ConversationID, req.RunID) if err != nil { return result, err diff --git a/apps/daemon/internal/agent/mcode/owner_other.go b/apps/daemon/internal/agent/mcode/owner_other.go deleted file mode 100644 index 29d7dfb0e..000000000 --- a/apps/daemon/internal/agent/mcode/owner_other.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build !unix - -package mcode - -import ( - "fmt" - "os/exec" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" -) - -// runAsOwner is unavailable: agent-host views run on Linux. -func runAsOwner(*exec.Cmd, string) error { - return fmt.Errorf("%w: mcode agent-host views run on Linux", agent.ErrUnsupportedOperation) -} diff --git a/apps/daemon/internal/agent/mcode/owner_unix.go b/apps/daemon/internal/agent/mcode/owner_unix.go deleted file mode 100644 index 9413685b6..000000000 --- a/apps/daemon/internal/agent/mcode/owner_unix.go +++ /dev/null @@ -1,29 +0,0 @@ -//go:build unix - -package mcode - -import ( - "fmt" - "os" - "os/exec" - "syscall" -) - -// runAsOwner runs command as the user and group that own dir, with no -// supplementary groups. An agent-host view gives the Session home to the -// Session user, and the daemon reads it as that user. -func runAsOwner(command *exec.Cmd, dir string) error { - info, err := os.Lstat(dir) - if err != nil { - return err - } - owner, ok := info.Sys().(*syscall.Stat_t) - if !ok || !info.IsDir() { - return fmt.Errorf("mcode: Session home is not a directory") - } - if int(owner.Uid) == os.Geteuid() && int(owner.Gid) == os.Getegid() { - return nil - } - command.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: owner.Uid, Gid: owner.Gid, Groups: []uint32{}}} - return nil -} diff --git a/apps/daemon/internal/agent/mcode/subagents.go b/apps/daemon/internal/agent/mcode/subagents.go index 61d5ddfad..65c05a7b2 100644 --- a/apps/daemon/internal/agent/mcode/subagents.go +++ b/apps/daemon/internal/agent/mcode/subagents.go @@ -52,52 +52,28 @@ type nativeSubagentTask struct { Metadata struct{ ChildSessionID, ParentSessionID, ParentTurnID, SubTurnID, ExecutionMode string } } -// historyReader runs the pinned Subagent history reader on the daemon host. -type historyReader struct { - node, script string - // home, when set, is an agent-host view's Session home. The reader then - // runs as the home's owner, the Session user, with an empty environment. - home string -} - -func deploymentHistoryReader() (historyReader, error) { +func subagentReader() (string, string, error) { node, bridge := os.Getenv("OAC_RUNTIME_MCODE_NODE"), os.Getenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE") reader := filepath.Join(filepath.Dir(bridge), "subagent-snapshot.mjs") for _, path := range []string{node, bridge, reader} { resolved, err := filepath.EvalSymlinks(path) if err != nil || !filepath.IsAbs(path) || resolved != path { - return historyReader{}, fmt.Errorf("mcode: protected Subagent reader is unavailable") + return "", "", fmt.Errorf("mcode: protected Subagent reader is unavailable") } } - return historyReader{node: node, script: reader}, nil -} - -func (r historyReader) command(ctx context.Context, dataDir, session string) (*exec.Cmd, error) { - command := exec.CommandContext(ctx, r.node, "--disable-warning=ExperimentalWarning", r.script, dataDir, session) - if r.home == "" { - command.Env = executionEnvironment() - return command, nil - } - command.Env = []string{} - return command, runAsOwner(command, r.home) + return node, reader, nil } func (s *Session) readSubagents(ctx context.Context) (nativeSubagentSnapshot, error) { var snapshot nativeSubagentSnapshot - reader := s.opts.reader - if reader == nil { - deployment, err := deploymentHistoryReader() - if err != nil { - return snapshot, err - } - reader = &deployment + node, reader, err := subagentReader() + if err != nil { + return snapshot, err } ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() - command, err := reader.command(ctx, s.opts.DataDir, s.sessionID) - if err != nil { - return snapshot, fmt.Errorf("mcode: child history reader is unavailable") - } + command := exec.CommandContext(ctx, node, "--disable-warning=ExperimentalWarning", reader, s.opts.DataDir, s.sessionID) + command.Env = executionEnvironment() stdout, err := command.StdoutPipe() if err != nil { return snapshot, fmt.Errorf("mcode: child history reader is unavailable") diff --git a/apps/daemon/internal/agent/mcode/view.go b/apps/daemon/internal/agent/mcode/view.go index 78ef8c112..2539c6b47 100644 --- a/apps/daemon/internal/agent/mcode/view.go +++ b/apps/daemon/internal/agent/mcode/view.go @@ -41,7 +41,6 @@ type viewInstall struct { // node, cli, bridge and assets are view paths: assets holds the CLI's // builtin skills and agents. node, cli, bridge, assets string - reader historyReader } func discoverView(parent context.Context, options agent.DiscoveryOptions) *agent.View { @@ -115,11 +114,6 @@ func newViewInstall(node, cli, bridge string, loader nodeLoader) (viewInstall, e return i, fmt.Errorf("the CLI's builtin %s are missing", dir) } } - i.reader = historyReader{node: node, script: filepath.Join(harness, "subagent-snapshot.mjs")} - if info, err := os.Lstat(i.reader.script); err != nil || !info.Mode().IsRegular() { - return i, errors.New("the Subagent history reader is missing") - } - // TODO(viewloader): take the loader's overlay, closure, masks and // LD_LIBRARY_PATH from the shared view loader. if loader.interp == "" { @@ -191,6 +185,11 @@ func (i viewInstall) prepare(_ context.Context, req proto.PromptRequestPayload, 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") @@ -241,11 +240,6 @@ func (i viewInstall) prepare(_ context.Context, req proto.PromptRequestPayload, opts.Env = append(opts.Env, "LD_LIBRARY_PATH="+i.libraryPath) } opts.Env = append(opts.Env, nativeEnvironment(private, dataDir)...) - if !req.DisableSubagents { - reader := i.reader - reader.home = session.Home.Host - opts.reader = &reader - } 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 index de927bc8c..0f06f7f6c 100644 --- a/apps/daemon/internal/agent/mcode/view_test.go +++ b/apps/daemon/internal/agent/mcode/view_test.go @@ -26,7 +26,7 @@ const viewRealKey = "sk-view-real-key-sentinel" 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", "subagent-snapshot.mjs", "native/cli.js", "native/assets/skills/.keep", "native/assets/agents/.keep", filepath.Join(bin, "node")} { + 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) } @@ -56,7 +56,7 @@ func viewFixture(t *testing.T) (viewInstall, agent.View, proto.PromptRequestPayl t.Setenv("ANTHROPIC_API_KEY", viewRealKey) req := executionRequest(t) req.RunID, req.Input, req.ConversationID = "", nil, "" - req.DisableExecutionEnvironment, req.DisableSubagents, req.MaxConcurrentSubagents = false, false, new(2) + 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 @@ -74,6 +74,11 @@ func TestViewLaunchesNodeWithGatewayOnly(t *testing.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) } From 021d538bcbb8c1b54629fdd7a77f7e1677bc3f3f Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 1 Oct 2026 03:00:52 +0000 Subject: [PATCH 3/3] Take the MiniMax Code view's ELF loader from viewloader Discovery asks viewloader.For for node's interpreter overlay, lib closure, loader masks and LD_LIBRARY_PATH, and declares no view when node's libraries are not all in the interpreter's directory. The adapter's own ld.so --list loader is removed. --- .../internal/agent/mcode/declaration.go | 2 +- apps/daemon/internal/agent/mcode/view.go | 109 +++++++----------- .../internal/agent/mcode/view_loader.go | 102 ---------------- apps/daemon/internal/agent/mcode/view_test.go | 32 ++--- 4 files changed, 47 insertions(+), 198 deletions(-) delete mode 100644 apps/daemon/internal/agent/mcode/view_loader.go diff --git a/apps/daemon/internal/agent/mcode/declaration.go b/apps/daemon/internal/agent/mcode/declaration.go index 5215c4a1c..276a7b0d7 100644 --- a/apps/daemon/internal/agent/mcode/declaration.go +++ b/apps/daemon/internal/agent/mcode/declaration.go @@ -77,7 +77,7 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, r if runtime.Info.Available { runtime.Executor = NewExecutorFactory(workspace) if SupportsExecution(version) { - runtime.View = discoverView(parent, options) + runtime.View = discoverView(options) } } if workspace != nil { diff --git a/apps/daemon/internal/agent/mcode/view.go b/apps/daemon/internal/agent/mcode/view.go index 2539c6b47..0651cc89d 100644 --- a/apps/daemon/internal/agent/mcode/view.go +++ b/apps/daemon/internal/agent/mcode/view.go @@ -7,13 +7,10 @@ import ( "os" "path" "path/filepath" - "runtime" "slices" - "strconv" - "strings" - "time" "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" ) @@ -25,7 +22,6 @@ import ( const ( viewNodeMount = "node" viewHarnessMount = "mcode-harness" - viewLibraryMount = "node-lib" viewDataName = "data" viewTempName = "tmp" viewPIName = "pi-agent" @@ -33,52 +29,51 @@ const ( // viewInstall is the trusted MiniMax Code install as a view presents it. type viewInstall struct { - closure []agent.ViewMount - overlays []agent.ViewOverlay - masks []agent.ViewMask - // libraryPath is LD_LIBRARY_PATH in the view; empty for a static node. - libraryPath string + 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(parent context.Context, options agent.DiscoveryOptions) *agent.View { - ctx, cancel := context.WithTimeout(parent, 15*time.Second) - defer cancel() - install, err := findViewInstall(ctx) +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 } - view := install.view() - return &view + return view } -func findViewInstall(ctx context.Context) (viewInstall, error) { - if runtime.GOOS != "linux" { - return viewInstall{}, errors.New("agent-host views run on Linux") - } +func findView() (*agent.View, error) { programs, err := findPrograms() if err != nil { - return viewInstall{}, err + return nil, err } node, err := filepath.EvalSymlinks(programs.node) if err != nil { - return viewInstall{}, err + return nil, err } - loader, err := findLoader(ctx, node) // TODO(viewloader) + loader, err := viewloader.For(node) if err != nil { - return viewInstall{}, err + return nil, err } - return newViewInstall(node, programs.binary, programs.bridge, loader) + 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, the harness directory holding the -// bridge and the CLI, and node's library directories as the closure. node is -// a resolved host path. -func newViewInstall(node, cli, bridge string, loader nodeLoader) (viewInstall, error) { - var i viewInstall +// 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") } @@ -114,55 +109,29 @@ func newViewInstall(node, cli, bridge string, loader nodeLoader) (viewInstall, e return i, fmt.Errorf("the CLI's builtin %s are missing", dir) } } - // TODO(viewloader): take the loader's overlay, closure, masks and - // LD_LIBRARY_PATH from the shared view loader. - if loader.interp == "" { - return i, nil - } - i.overlays = []agent.ViewOverlay{{Path: loader.interp, Source: loader.source, Exec: true}} - // The loader reads both from the sandbox's /etc otherwise. - i.masks = []agent.ViewMask{{Path: "/etc/ld.so.cache"}, {Path: "/etc/ld.so.preload"}} - mounts := map[string]string{nodeMount.HostDir: nodeMount.Path(), harnessMount.HostDir: harnessMount.Path()} - var libraryPath []string - for _, dir := range loader.libraries { - mount, ok := mounts[dir] - if !ok { - name := viewLibraryMount - if n := len(i.closure) - 1; n > 1 { - name += "-" + strconv.Itoa(n) - } - m := agent.ViewMount{Name: name, HostDir: dir} - i.closure = append(i.closure, m) - mount, mounts[dir] = m.Path(), m.Path() - } - if !slices.Contains(libraryPath, mount) { - libraryPath = append(libraryPath, mount) - } - } - i.libraryPath = strings.Join(libraryPath, ":") return i, nil } func (i viewInstall) view() agent.View { - masks := append([]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}, - }, i.masks...) // No forwarded tool needs a Harness variable, so ForwardEnv is empty. - return agent.View{ - Closure: slices.Clone(i.closure), - Overlays: slices.Clone(i.overlays), - Masks: masks, + 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) { @@ -236,8 +205,8 @@ func (i viewInstall) prepare(_ context.Context, req proto.PromptRequestPayload, "MAVIS_BUILTIN_AGENTS_DIR=" + path.Join(i.assets, "agents"), "MAVIS_BUILTIN_AGENTS_V2_DIR=" + path.Join(i.assets, "agents"), } - if i.libraryPath != "" { - opts.Env = append(opts.Env, "LD_LIBRARY_PATH="+i.libraryPath) + 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"} diff --git a/apps/daemon/internal/agent/mcode/view_loader.go b/apps/daemon/internal/agent/mcode/view_loader.go deleted file mode 100644 index a3b238070..000000000 --- a/apps/daemon/internal/agent/mcode/view_loader.go +++ /dev/null @@ -1,102 +0,0 @@ -package mcode - -import ( - "context" - "debug/elf" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "slices" - "strings" -) - -// TODO(viewloader): replace this file with the shared view loader. - -// nodeLoader is how the host loads a dynamic node: the ELF interpreter the -// binary names, the host file presenting it, and the resolved directories its -// libraries load from. A static node has none. -type nodeLoader struct { - interp, source string - libraries []string -} - -func findLoader(ctx context.Context, node string) (nodeLoader, error) { - var loader nodeLoader - file, err := elf.Open(node) - if err != nil { - return loader, err - } - defer file.Close() - for _, prog := range file.Progs { - if prog.Type == elf.PT_INTERP { - raw, err := io.ReadAll(io.LimitReader(prog.Open(), 4096)) - if err != nil { - return loader, err - } - loader.interp = strings.TrimRight(string(raw), "\x00") - } - } - if loader.interp == "" { - return loader, nil - } - if !filepath.IsAbs(loader.interp) || filepath.Clean(loader.interp) != loader.interp { - return loader, fmt.Errorf("node's interpreter %q is not a clean absolute path", loader.interp) - } - if loader.source, err = filepath.EvalSymlinks(loader.interp); err != nil { - return loader, err - } - // The view resolves the same libraries through LD_LIBRARY_PATH alone. - command := exec.CommandContext(ctx, loader.source, "--list", node) - command.Env = []string{} - out, err := command.Output() - if err != nil { - return loader, fmt.Errorf("node's libraries: %w", err) - } - for line := range strings.Lines(string(out)) { - _, resolved, ok := strings.Cut(strings.TrimSpace(line), " => ") - if !ok { - continue - } - library, _, _ := strings.Cut(resolved, " (") - if !filepath.IsAbs(library) { - return loader, fmt.Errorf("node's library %s", strings.TrimSpace(line)) - } - dir, err := libraryDir(library) - if err != nil { - return loader, err - } - if !slices.Contains(loader.libraries, dir) { - loader.libraries = append(loader.libraries, dir) - } - } - return loader, nil -} - -// libraryDir returns the resolved directory holding library. The library's -// name may link only to other names in that directory, so presenting the -// directory presents the library. -func libraryDir(library string) (string, error) { - dir, err := filepath.EvalSymlinks(filepath.Dir(library)) - if err != nil { - return "", err - } - name := filepath.Base(library) - for range 8 { - info, err := os.Lstat(filepath.Join(dir, name)) - if err != nil { - return "", err - } - if info.Mode().IsRegular() { - return dir, nil - } - if info.Mode()&os.ModeSymlink == 0 { - break - } - if name, err = os.Readlink(filepath.Join(dir, name)); err != nil || strings.ContainsRune(name, '/') { - break - } - } - return "", fmt.Errorf("node's library %s does not resolve within its directory", library) -} diff --git a/apps/daemon/internal/agent/mcode/view_test.go b/apps/daemon/internal/agent/mcode/view_test.go index 0f06f7f6c..5b20c1882 100644 --- a/apps/daemon/internal/agent/mcode/view_test.go +++ b/apps/daemon/internal/agent/mcode/view_test.go @@ -5,16 +5,15 @@ import ( "errors" "io/fs" "os" - "os/exec" "path" "path/filepath" - "runtime" "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" ) @@ -37,8 +36,10 @@ func viewFixture(t *testing.T) (viewInstall, agent.View, proto.PromptRequestPayl t.Fatal(err) } } - install, err := newViewInstall(filepath.Join(bin, "node"), filepath.Join(harness, "native/cli.js"), filepath.Join(harness, "bridge.mjs"), - nodeLoader{interp: "/lib64/ld-linux-x86-64.so.2", source: filepath.Join(libs, "ld.so"), libraries: []string{libs}}) + 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) } @@ -87,7 +88,8 @@ func TestViewLaunchesNodeWithGatewayOnly(t *testing.T) { 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)) { + 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")) @@ -145,23 +147,3 @@ func TestViewDoesNotFollowHomeLinks(t *testing.T) { t.Fatalf("outside file = %q (%v)", raw, err) } } - -func TestViewLoaderListsHostNode(t *testing.T) { - node, err := exec.LookPath("node") - if runtime.GOOS != "linux" || err != nil { - t.Skip("needs node on Linux") - } - if node, err = filepath.EvalSymlinks(node); err != nil { - t.Fatal(err) - } - loader, err := findLoader(t.Context(), node) - if err != nil { - t.Fatal(err) - } - if loader.interp == "" { - t.Skip("node is static") - } - if loader.source == "" || len(loader.libraries) == 0 { - t.Fatalf("loader = %+v", loader) - } -}