From c7c0262cae176a31075dd88f1e970e9822af94ae Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 1 Oct 2026 02:50:45 +0000 Subject: [PATCH 1/2] Declare the Claude agent-host view and render ANTHROPIC_API_KEY The Claude SDK adapter declares its agent-host View from the probed install: node, the bridge bundle and the SDK's native claude as the closure, with their shared ELF loader and library directory when they are dynamic. The view Executor builds the workspace profile per Session from the request, lays out {config,home,tmp,xdg} in the Session home through one os.Root, sets a closed environment pointed at the gateway, takes MCP only from the Session and launches through ViewSession.Launch. The runtime check reports the native binary's bundle-relative path, and the bridge requires capability_root only for skills. Provider credentials render as ANTHROPIC_API_KEY on every path. --- .../internal/agent/claudesdk/declaration.go | 6 + .../internal/agent/claudesdk/executor.go | 77 +++--- .../internal/agent/claudesdk/local_test.go | 2 +- .../internal/agent/claudesdk/options.go | 108 +++++---- .../internal/agent/claudesdk/provider.go | 6 +- .../internal/agent/claudesdk/readiness.go | 16 +- .../agent/claudesdk/readiness_test.go | 2 + .../internal/agent/claudesdk/session.go | 8 +- apps/daemon/internal/agent/claudesdk/view.go | 225 ++++++++++++++++++ .../agent/claudesdk/view_loader_linux.go | 107 +++++++++ .../agent/claudesdk/view_loader_other.go | 13 + .../internal/agent/claudesdk/view_test.go | 187 +++++++++++++++ .../internal/agent/claudesdk/workspace.go | 7 +- .../claude-sdk-adapter/src/runtime_check.ts | 2 +- packages/claude-sdk-adapter/src/workspace.ts | 2 +- .../tests/mcp_workspace.test.mjs | 4 + 16 files changed, 678 insertions(+), 94 deletions(-) create mode 100644 apps/daemon/internal/agent/claudesdk/view.go create mode 100644 apps/daemon/internal/agent/claudesdk/view_loader_linux.go create mode 100644 apps/daemon/internal/agent/claudesdk/view_loader_other.go create mode 100644 apps/daemon/internal/agent/claudesdk/view_test.go diff --git a/apps/daemon/internal/agent/claudesdk/declaration.go b/apps/daemon/internal/agent/claudesdk/declaration.go index 1360c2cbb..30ebc5151 100644 --- a/apps/daemon/internal/agent/claudesdk/declaration.go +++ b/apps/daemon/internal/agent/claudesdk/declaration.go @@ -148,6 +148,12 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, d out.Preparation = NewPreparationFactory(config) out.WorkspaceReadPreparation = true } + // The view runs the same install; its probe stays on this host. + if view, err := newView(Config{Node: node, Entrypoint: entrypoint}, info); err != nil { + fmt.Fprintf(options.Stderr, "oac-daemon: Claude SDK agent-host view unavailable: %v\n", err) + } else { + out.View = view + } fmt.Fprintf(options.Stdout, "Claude SDK preflight ok (SDK %s, %s)\n", info.SDK, info.Native) return out diff --git a/apps/daemon/internal/agent/claudesdk/executor.go b/apps/daemon/internal/agent/claudesdk/executor.go index 34240d0c5..7ade50fba 100644 --- a/apps/daemon/internal/agent/claudesdk/executor.go +++ b/apps/daemon/internal/agent/claudesdk/executor.go @@ -36,46 +36,59 @@ func NewExecutorFactory(config Config) agent.ExecutorFactory { if ctx == nil { ctx = context.Background() } - if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { - return nil, errors.New("claudesdk: Executor preparation cannot submit input") - } - start, env, err := prepareConfiguration(config, req) - if err != nil { - return nil, err - } - info, err := checked.check(ctx, config) - if err != nil { - return nil, err - } - if err = validateExecutorFeatures(info, start); err != nil { + if err := preparationOnly(req); err != nil { return nil, err } - start.Type = "executor_prepare" - base, err := launch(ctx, config, start, env) + start, env, err := prepareConfiguration(config, req) if err != nil { return nil, err } - base.reads.supported = slices.Contains(info.Features, "workspace_read") - base.directories.supported = slices.Contains(info.Features, "workspace_directory") - e := &executor{base: base, start: start, ready: make(chan error, 1), done: make(chan struct{}), nativeID: start.Resume} - go e.read() - if err = e.write(start); err == nil { - select { - case err = <-e.ready: - case <-ctx.Done(): - err = ctx.Err() - } + return startExecutor(ctx, checked, config, start, func() (*session, error) { return launch(ctx, config, start, env) }) + } +} + +func preparationOnly(req proto.PromptRequestPayload) error { + if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { + return errors.New("claudesdk: Executor preparation cannot submit input") + } + return nil +} + +// startExecutor checks the installed bridge against probe, starts it through +// run and waits until it is ready for Turns. +func startExecutor(ctx context.Context, checked *runtimeCheckCache, probe Config, start startRequest, run func() (*session, error)) (agent.Executor, error) { + info, err := checked.check(ctx, probe) + if err != nil { + return nil, err + } + if err = validateExecutorFeatures(info, start); err != nil { + return nil, err + } + start.Type = "executor_prepare" + base, err := run() + if err != nil { + return nil, err + } + base.reads.supported = slices.Contains(info.Features, "workspace_read") + base.directories.supported = slices.Contains(info.Features, "workspace_directory") + e := &executor{base: base, start: start, ready: make(chan error, 1), done: make(chan struct{}), nativeID: start.Resume} + go e.read() + if err = e.write(start); err == nil { + select { + case err = <-e.ready: + case <-ctx.Done(): + err = ctx.Err() } - if err != nil { - closeCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if closeErr := e.Close(closeCtx); closeErr != nil { - return e, errors.Join(err, closeErr) - } - return nil, err + } + if err != nil { + closeCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if closeErr := e.Close(closeCtx); closeErr != nil { + return e, errors.Join(err, closeErr) } - return e, nil + return nil, err } + return e, nil } func validateExecutorFeatures(info RuntimeInfo, start startRequest) error { diff --git a/apps/daemon/internal/agent/claudesdk/local_test.go b/apps/daemon/internal/agent/claudesdk/local_test.go index 2d6408645..3f82b1642 100644 --- a/apps/daemon/internal/agent/claudesdk/local_test.go +++ b/apps/daemon/internal/agent/claudesdk/local_test.go @@ -72,7 +72,7 @@ func TestWorkspaceProviderCredentialsReplaceAmbientSelection(t *testing.T) { if strings.Contains(string(raw), "selected-secret") || !slices.Equal(original, config.Env) { t.Fatal("provider leaked or mutated shared configuration") } - if !slices.Contains(env, "ANTHROPIC_AUTH_TOKEN=selected-secret") || slices.Contains(env, "ANTHROPIC_AUTH_TOKEN=selected-provider-fixture") { + if !slices.Contains(env, "ANTHROPIC_API_KEY=selected-secret") || slices.ContainsFunc(env, func(entry string) bool { return strings.HasPrefix(entry, "ANTHROPIC_AUTH_TOKEN=") }) { t.Fatal("provider selection was not exclusive") } for _, value := range []any{nil, "secret", map[string]any{"protocol": "anthropic", "base_url": "http://provider.example", "api_key": "secret"}, map[string]any{"protocol": "anthropic", "base_url": "https://user:pass@provider.example", "api_key": "secret"}} { diff --git a/apps/daemon/internal/agent/claudesdk/options.go b/apps/daemon/internal/agent/claudesdk/options.go index 5f9bae245..0e8af9336 100644 --- a/apps/daemon/internal/agent/claudesdk/options.go +++ b/apps/daemon/internal/agent/claudesdk/options.go @@ -55,6 +55,62 @@ func prepare(config Config, req proto.PromptRequestPayload) (startRequest, []str } func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startRequest, []string, error) { + start, provider, err := prepareOptions(req, req.MCPHTTPServers != nil || (req.LocalEnvironment != nil && len(req.LocalEnvironment.MCP) != 0)) + if err != nil { + return startRequest{}, nil, err + } + if !filepath.IsAbs(config.Entrypoint) { + return startRequest{}, nil, fmt.Errorf("claudesdk: SDK entrypoint must be absolute") + } + config.Env = withProvider(config.Env, provider) + if config.Workspace != nil { + profile, env, err := prepareWorkspace(config, req) + if err != nil { + return startRequest{}, nil, err + } + start.Workspace = profile + start.Cwd = workspaceCwd(config.Workspace) + return start, env, nil + } + if req.LocalEnvironment != nil || req.RequireExistingNativeSession { + return startRequest{}, nil, fmt.Errorf("claudesdk: local execution and history recovery require a dedicated workspace") + } + root, err := paths.Root() + if err != nil { + return startRequest{}, nil, err + } + relative, err := filepath.Rel(root, config.StateDir) + if err != nil || !filepath.IsAbs(root) || !filepath.IsAbs(config.StateDir) || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return startRequest{}, nil, fmt.Errorf("claudesdk: SDK state must be in a managed runtime subdirectory") + } + start.Cwd = filepath.Join(config.StateDir, "work") + for _, dir := range []string{config.StateDir, filepath.Join(config.StateDir, "tmp"), start.Cwd} { + if err := os.MkdirAll(dir, 0o700); err != nil { + return startRequest{}, nil, err + } + } + env := withProvider(append(append([]string{}, os.Environ()...), config.Env...), provider) + env = append(env, "CLAUDE_CONFIG_DIR="+config.StateDir, "TMPDIR="+filepath.Join(config.StateDir, "tmp"), "DISABLE_TELEMETRY=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1") + projectedMCP, mcpEnv, err := prepareRuntimeMCP(req) + if err != nil { + return startRequest{}, nil, err + } + if req.MCPHTTPServers != nil { + servers := make([]mcpHTTPServer, 0, len(projectedMCP)) + for _, server := range projectedMCP { + servers = append(servers, server.mcpHTTPServer) + } + start.MCPHTTPServers = &servers + } + env = append(env, mcpEnv...) + return start, env, nil +} + +// prepareOptions validates the request's execution options and renders the +// selected model provider. mcp reports whether the Executor serves MCP, which +// an agent-host view takes from its Session rather than the request. +func prepareOptions(req proto.PromptRequestPayload, mcp bool) (startRequest, []string, error) { + skills := req.LocalEnvironment != nil && len(req.LocalEnvironment.Skills) != 0 start := startRequest{Type: "start", Resume: req.AgentSessionID, RequireHistory: req.RequireExistingNativeSession, ObserveMessages: req.ObserveMessages, Functions: req.FunctionTools, observeFunctions: req.ObserveToolObservations} fail := func(reason string) (startRequest, []string, error) { return startRequest{}, nil, fmt.Errorf("claudesdk: %s", reason) @@ -71,7 +127,7 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR return startRequest{}, nil, err } if req.ToolSearch { - if (req.LocalEnvironment != nil && (len(req.LocalEnvironment.Skills) != 0 || len(req.LocalEnvironment.MCP) != 0)) || req.MCPHTTPServers != nil || !req.DisableSubagents || (req.ExecutionControls != nil && req.ExecutionControls.OutputFormat != nil) { + if skills || mcp || !req.DisableSubagents || (req.ExecutionControls != nil && req.ExecutionControls.OutputFormat != nil) { return fail("tool discovery requires the single-agent text/function profile") } start.ToolSearch = true @@ -86,7 +142,7 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR } if req.ExecutionControls != nil && req.ExecutionControls.OutputFormat != nil { format := req.ExecutionControls.OutputFormat - if format.Type != "json_schema" || !req.ObserveMessages || !req.DisableSubagents || req.MCPHTTPServers != nil || (req.LocalEnvironment != nil && (len(req.LocalEnvironment.MCP) != 0 || len(req.LocalEnvironment.Skills) != 0)) { + if format.Type != "json_schema" || !req.ObserveMessages || !req.DisableSubagents || mcp || skills { return fail("structured output requires the qualified message-observing single-agent function profile") } if err := proto.ValidateBinary64Schema(format.Schema); err != nil { @@ -95,7 +151,7 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR start.OutputFormat = format } if req.ObserveSubagentIdentities { - if req.DisableSubagents || len(req.FunctionTools) != 0 || req.MCPHTTPServers != nil || (req.LocalEnvironment != nil && len(req.LocalEnvironment.MCP) != 0) { + if req.DisableSubagents || len(req.FunctionTools) != 0 || mcp { return fail("subagent execution does not support this tool combination") } limit := 6 @@ -142,49 +198,5 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR if strings.TrimSpace(start.Model) == "" { return fail("model is required") } - if !filepath.IsAbs(config.Entrypoint) { - return fail("SDK entrypoint must be absolute") - } - config.Env = withProvider(config.Env, provider) - if config.Workspace != nil { - profile, env, err := prepareWorkspace(config, req) - if err != nil { - return startRequest{}, nil, err - } - start.Workspace = profile - start.Cwd = workspaceCwd(config.Workspace) - return start, env, nil - } - if req.LocalEnvironment != nil || req.RequireExistingNativeSession { - return fail("local execution and history recovery require a dedicated workspace") - } - root, err := paths.Root() - if err != nil { - return startRequest{}, nil, err - } - relative, err := filepath.Rel(root, config.StateDir) - if err != nil || !filepath.IsAbs(root) || !filepath.IsAbs(config.StateDir) || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - return fail("SDK state must be in a managed runtime subdirectory") - } - start.Cwd = filepath.Join(config.StateDir, "work") - for _, dir := range []string{config.StateDir, filepath.Join(config.StateDir, "tmp"), start.Cwd} { - if err := os.MkdirAll(dir, 0o700); err != nil { - return startRequest{}, nil, err - } - } - env := withProvider(append(append([]string{}, os.Environ()...), config.Env...), provider) - env = append(env, "CLAUDE_CONFIG_DIR="+config.StateDir, "TMPDIR="+filepath.Join(config.StateDir, "tmp"), "DISABLE_TELEMETRY=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1") - projectedMCP, mcpEnv, err := prepareRuntimeMCP(req) - if err != nil { - return startRequest{}, nil, err - } - if req.MCPHTTPServers != nil { - servers := make([]mcpHTTPServer, 0, len(projectedMCP)) - for _, server := range projectedMCP { - servers = append(servers, server.mcpHTTPServer) - } - start.MCPHTTPServers = &servers - } - env = append(env, mcpEnv...) - return start, env, nil + return start, provider, nil } diff --git a/apps/daemon/internal/agent/claudesdk/provider.go b/apps/daemon/internal/agent/claudesdk/provider.go index 0d78a0fe3..68258dcb8 100644 --- a/apps/daemon/internal/agent/claudesdk/provider.go +++ b/apps/daemon/internal/agent/claudesdk/provider.go @@ -6,13 +6,15 @@ import ( harnessconfiguration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk" ) -// Validate the frozen native provider before producing launch variables. +// Validate the frozen native provider before producing launch variables. The +// key renders as ANTHROPIC_API_KEY, which Claude Code sends as the X-Api-Key +// header that the anthropic protocol declares. func providerEnvironment(value any) ([]string, error) { provider, err := harnessconfiguration.Configuration().ParseProvider(value) if err != nil { return nil, err } - return []string{"ANTHROPIC_BASE_URL=" + provider.BaseURL, "ANTHROPIC_AUTH_TOKEN=" + provider.APIKey}, nil + return []string{"ANTHROPIC_BASE_URL=" + provider.BaseURL, "ANTHROPIC_API_KEY=" + provider.APIKey}, nil } func withProvider(env, provider []string) []string { diff --git a/apps/daemon/internal/agent/claudesdk/readiness.go b/apps/daemon/internal/agent/claudesdk/readiness.go index 5c9668990..ab520bf04 100644 --- a/apps/daemon/internal/agent/claudesdk/readiness.go +++ b/apps/daemon/internal/agent/claudesdk/readiness.go @@ -16,13 +16,15 @@ import ( // RuntimeInfo describes a successful local probe, not provider authentication or // execution capability. Versions are checked against the installed pinned manifest. type RuntimeInfo struct { - Type string `json:"type"` - Protocol int `json:"protocol"` - Node string `json:"node"` - SDK string `json:"sdk"` - MCP string `json:"mcp"` - Native string `json:"native"` - Features []string `json:"features"` + Type string `json:"type"` + Protocol int `json:"protocol"` + Node string `json:"node"` + SDK string `json:"sdk"` + MCP string `json:"mcp"` + Native string `json:"native"` + // NativePath is the SDK's native Claude Code binary, relative to the bundle root. + NativePath string `json:"native_path"` + Features []string `json:"features"` } func (info RuntimeInfo) SupportsFunctionResultImages() bool { diff --git a/apps/daemon/internal/agent/claudesdk/readiness_test.go b/apps/daemon/internal/agent/claudesdk/readiness_test.go index 49b0d5260..2f33d1ad9 100644 --- a/apps/daemon/internal/agent/claudesdk/readiness_test.go +++ b/apps/daemon/internal/agent/claudesdk/readiness_test.go @@ -145,6 +145,8 @@ func runReadinessHelper() { time.Sleep(time.Minute) case "wait": time.Sleep(time.Minute) + case "view": + _, _ = fmt.Fprintln(os.Stdout, strings.TrimSuffix(readyReport, "}")+`,"native_path":"native/claude","features":["workspace_tools","workspace_prepare","workspace_command_observations","local_runtime_v2","mcp_http_tools","workspace_mcp_http"]}`) default: os.Exit(3) } diff --git a/apps/daemon/internal/agent/claudesdk/session.go b/apps/daemon/internal/agent/claudesdk/session.go index 311952092..31d1619dd 100644 --- a/apps/daemon/internal/agent/claudesdk/session.go +++ b/apps/daemon/internal/agent/claudesdk/session.go @@ -86,7 +86,13 @@ func launch(ctx context.Context, config Config, start startRequest, env []string if binary == "" { binary = "node" } - process, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{config.Entrypoint}, Dir: start.Cwd, Env: env, NeedStdin: true, OwnProcessGroup: true}) + return startSession(clirunner.Start, clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{config.Entrypoint}, Dir: start.Cwd, Env: env, NeedStdin: true, OwnProcessGroup: true}) +} + +// startSession runs the bridge through start: clirunner.Start, or an agent-host +// view's Launch. +func startSession(start func(clirunner.StartOptions) (*clirunner.Process, error), options clirunner.StartOptions) (*session, error) { + process, err := start(options) if err != nil { return nil, err } diff --git a/apps/daemon/internal/agent/claudesdk/view.go b/apps/daemon/internal/agent/claudesdk/view.go new file mode 100644 index 000000000..4b63b40b2 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/view.go @@ -0,0 +1,225 @@ +package claudesdk + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "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/internal/agentdaemon/proto" +) + +// In an agent-host view, node, the bridge and the SDK's native claude run from +// the closure. Everything else Claude Code runs goes to the sandbox through the +// shims, and its model and MCP traffic goes to the Session's gateway. + +// viewLayout holds the view paths the view Executor launches with. +type viewLayout struct { + node, bridge string + // libraries is LD_LIBRARY_PATH, empty when the closure binaries are static. + libraries string +} + +// viewLoader is the ELF interpreter the closure's dynamic binaries share and +// the host directory that holds every library they load. +type viewLoader struct { + Interp, Source, LibDir string +} + +// viewHomeDirs are the native directories under the Session home. +var viewHomeDirs = []string{"config", "home", "tmp", "xdg"} + +// newView declares the view from the probed install: probe's Node and +// Entrypoint, and the native binary the runtime check reported. +func newView(probe Config, info RuntimeInfo) (*agent.View, error) { + node, err := filepath.EvalSymlinks(probe.Node) + if err != nil { + return nil, err + } + entrypoint, err := filepath.EvalSymlinks(probe.Entrypoint) + if err != nil { + return nil, err + } + root := filepath.Dir(filepath.Dir(entrypoint)) + native := filepath.Join(root, info.NativePath) + if !filepath.IsLocal(info.NativePath) || !executableFile(node) || !executableFile(native) { + return nil, errors.New("Node or the native Claude Code binary is not an executable file in the bundle") + } + if resolved, err := filepath.EvalSymlinks(native); err != nil || resolved != native { + return nil, errors.New("the native Claude Code path leaves the bundle") + } + loader, err := closureLoader(node, native) + if err != nil { + return nil, err + } + bridge, err := filepath.Rel(root, entrypoint) + if err != nil { + return nil, err + } + view := declareView(probe, node, root, filepath.ToSlash(bridge), filepath.ToSlash(info.NativePath), loader) + if err := view.Validate(); err != nil { + return nil, err + } + return view, nil +} + +func declareView(probe Config, node, root, bridge, native string, loader viewLoader) *agent.View { + nodeMount := agent.ViewMount{Name: "node", HostDir: filepath.Dir(node)} + bundle := agent.ViewMount{Name: "claude-sdk", HostDir: root} + layout := viewLayout{node: nodeMount.Path() + "/" + filepath.Base(node), bridge: bundle.Path() + "/" + bridge} + view := &agent.View{ + Closure: []agent.ViewMount{nodeMount, bundle}, + // The managed policy tier would let the sandbox inject settings (C1). + Masks: []agent.ViewMask{{Path: "/etc/claude-code", Dir: true}}, + LocalExec: []string{layout.node, bundle.Path() + "/" + native}, + // ps stays local so the kill tree never signals sandbox PIDs (C8). + Shims: []string{"bash", "rg", "git"}, + ForwardEnv: []string{"CLAUDECODE", "GIT_EDITOR"}, + Proxy: agent.ViewProxyEnv, + } + if loader.Interp != "" { + lib := agent.ViewMount{Name: "lib", HostDir: loader.LibDir} + view.Closure = append(view.Closure, lib) + view.Overlays = []agent.ViewOverlay{{Path: loader.Interp, Source: loader.Source, Exec: true}} + layout.libraries = lib.Path() + } + view.Executor = newViewExecutorFactory(probe, layout) + return view +} + +func newViewExecutorFactory(probe Config, layout viewLayout) agent.ViewExecutorFactory { + checked := &runtimeCheckCache{} + return func(ctx context.Context, req proto.PromptRequestPayload, view agent.ViewSession) (agent.Executor, error) { + if ctx == nil { + ctx = context.Background() + } + if err := preparationOnly(req); err != nil { + return nil, err + } + start, env, err := prepareView(layout, req, view) + if err != nil { + return nil, err + } + return startExecutor(ctx, checked, probe, start, func() (*session, error) { + return startSession(view.Launch, clirunner.StartOptions{Parent: ctx, Binary: layout.node, Args: []string{layout.bridge}, Dir: start.Cwd, Env: env, NeedStdin: true, OwnProcessGroup: true}) + }) + } +} + +// prepareView builds the workspace profile for one Session from the request +// and the view: the workspace is the sandbox's, MCP comes only from the view, +// and the environment is closed. +func prepareView(layout viewLayout, req proto.PromptRequestPayload, view agent.ViewSession) (startRequest, []string, error) { + environment := req.LocalEnvironment + if environment == nil || !workspacePathSyntax(environment.WorkspaceRoot) || req.DisableExecutionEnvironment || view.Launch == nil || view.Proxy == "" { + return startRequest{}, nil, errors.New("claudesdk: a view Executor requires the sandbox workspace, Launch and the gateway proxy") + } + if environment.Capabilities || len(environment.Skills) != 0 || environment.CapabilityRoot != "" { + return startRequest{}, nil, fmt.Errorf("%w: installed Capabilities in an agent-host view", agent.ErrUnsupportedOperation) + } + if (environment.NetworkAccess != "" && environment.NetworkAccess != "enabled") || len(environment.AllowedDomains) != 0 { + return startRequest{}, nil, fmt.Errorf("%w: restricted network in an agent-host view", agent.ErrUnsupportedOperation) + } + servers, err := viewMCP(view.MCP) + if err != nil { + return startRequest{}, nil, err + } + start, provider, err := prepareOptions(req, len(servers) != 0) + if err != nil { + return startRequest{}, nil, err + } + if err := viewHome(view.Home); err != nil { + return startRequest{}, nil, err + } + profile, env := viewEnvironment(layout, view.Home.View, view.Proxy, provider) + profile.NetworkAccess, profile.MCP = environment.NetworkAccess, servers + start.Workspace, start.Cwd = profile, environment.WorkspaceRoot + return start, env, nil +} + +// viewMCP renders the gateway's HTTP endpoints. The gateway adds each +// credential and header, so none is rendered here. +func viewMCP(bindings []agent.MCPBinding) ([]environmentMCPServer, error) { + declarations := make([]proto.MCPHTTPServer, 0, len(bindings)) + for _, binding := range bindings { + if binding.Transport != "http" || binding.Stdio != nil { + return nil, fmt.Errorf("%w: %s MCP in an agent-host view", agent.ErrUnsupportedOperation, binding.Transport) + } + declarations = append(declarations, proto.MCPHTTPServer{ServerLabel: binding.ServerLabel, ServerURL: binding.ServerURL, AllowedTools: binding.AllowedTools, Required: binding.Required}) + } + if err := validateMCPServers(declarations); err != nil { + return nil, err + } + projected, _ := prepareMCPHTTP(&declarations) + servers := make([]environmentMCPServer, 0, len(*projected)) + for _, server := range *projected { + servers = append(servers, environmentMCPServer{mcpHTTPServer: server}) + } + return servers, nil +} + +// viewHome lays out the native directories. A later Executor finds the tree +// the Session uid has owned, so every operation stays inside one os.Root and +// an existing entry must be a directory, not a link. +func viewHome(home agent.ViewDir) error { + if !workspacePathSyntax(home.Host) || !workspacePathSyntax(home.View) { + return errors.New("claudesdk: invalid Session home") + } + root, err := os.OpenRoot(home.Host) + if err != nil { + return err + } + defer root.Close() + for _, name := range viewHomeDirs { + if err := root.Mkdir(name, 0o700); err != nil && !errors.Is(err, fs.ErrExist) { + return err + } + if info, err := root.Lstat(name); err != nil || !info.IsDir() { + return fmt.Errorf("claudesdk: Session home entry %s is not a directory", name) + } + } + return nil +} + +// viewEnvironment is the complete Harness environment. home is the Session +// home's view path. +func viewEnvironment(layout viewLayout, home, proxy string, provider []string) (*workspaceProfile, []string) { + shims := agent.ViewPrivateRoot + "/" + agent.ViewShimName + profile := &workspaceProfile{Home: home + "/home", State: home + "/config", Scratch: home + "/tmp", EnvNames: []string{}, AllowedDomains: []string{}} + env := []string{ + "PATH=" + shims, "HOME=" + profile.Home, "TMPDIR=" + profile.Scratch, "CLAUDE_CONFIG_DIR=" + profile.State, + // The messaging socket path stays local and short (C5). + "XDG_RUNTIME_DIR=" + home + "/xdg", + // Bash runs the sandbox shell through the shim (C3). + "SHELL=" + shims + "/bash", "CLAUDE_CODE_SHELL=" + shims + "/bash", + // The shell's cwd file must be at the same path on both sides (C4). + "CLAUDE_CODE_TMPDIR=/tmp/oac-claude-" + strings.ToLower(rand.Text()), + "CLAUDE_CODE_CERT_STORE=bundled", // C2 + "USE_BUILTIN_RIPGREP=0", // C7: rg runs through its shim + "CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=1", // C9 + "CLAUDE_CODE_TOOL_MEMORY_LIMIT=0", // C13 + } + env = append(env, nativeFlags...) + if layout.libraries != "" { + env = append(env, "LD_LIBRARY_PATH="+layout.libraries) + } + selected := append(slices.Clone(provider), "HTTPS_PROXY="+proxy, "HTTP_PROXY="+proxy, "NO_PROXY=127.0.0.1,localhost") + for _, entry := range selected { + name, _, _ := strings.Cut(entry, "=") + profile.EnvNames = append(profile.EnvNames, name) + } + env = append(env, selected...) + return profile, append(env, "https_proxy="+proxy, "http_proxy="+proxy, "no_proxy=127.0.0.1,localhost") +} + +func executableFile(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() && info.Mode().Perm()&0o111 != 0 +} diff --git a/apps/daemon/internal/agent/claudesdk/view_loader_linux.go b/apps/daemon/internal/agent/claudesdk/view_loader_linux.go new file mode 100644 index 000000000..42413edb1 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/view_loader_linux.go @@ -0,0 +1,107 @@ +//go:build linux + +package claudesdk + +import ( + "debug/elf" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// closureLoader finds the ELF interpreter that the dynamic binaries share and +// checks that the interpreter's directory holds every library they need, so +// the view loads nothing from the sandbox's files. +func closureLoader(binaries ...string) (viewLoader, error) { + var loader viewLoader + var pending []string + for _, binary := range binaries { + interp, needed, err := elfDependencies(binary) + if err != nil { + return viewLoader{}, err + } + if interp == "" { + if len(needed) != 0 { + return viewLoader{}, fmt.Errorf("%s needs libraries but no interpreter", binary) + } + continue + } + if loader.Interp != "" && loader.Interp != interp { + return viewLoader{}, fmt.Errorf("the closure binaries need different ELF interpreters") + } + loader.Interp = interp + pending = append(pending, needed...) + } + if loader.Interp == "" { + return viewLoader{}, nil + } + source, err := filepath.EvalSymlinks(loader.Interp) + if err != nil { + return viewLoader{}, err + } + loader.Source, loader.LibDir = source, filepath.Dir(source) + seen := map[string]bool{} + for len(pending) > 0 { + name := pending[len(pending)-1] + pending = pending[:len(pending)-1] + if seen[name] { + continue + } + seen[name] = true + path, err := libraryFile(loader.LibDir, name) + if err != nil { + return viewLoader{}, fmt.Errorf("library %s is not in %s", name, loader.LibDir) + } + _, needed, err := elfDependencies(path) + if err != nil { + return viewLoader{}, err + } + pending = append(pending, needed...) + } + return loader, nil +} + +func elfDependencies(path string) (string, []string, error) { + file, err := elf.Open(path) + if err != nil { + return "", nil, err + } + defer file.Close() + interp := "" + for _, prog := range file.Progs { + if prog.Type == elf.PT_INTERP { + raw, err := io.ReadAll(prog.Open()) + if err != nil { + return "", nil, err + } + interp = strings.TrimRight(string(raw), "\x00") + } + } + needed, err := file.ImportedLibraries() + return interp, needed, err +} + +// libraryFile resolves name in dir through links to other names in dir only, +// because the view presents dir at another path. +func libraryFile(dir, name string) (string, error) { + for range 8 { + if !filepath.IsLocal(name) || filepath.Base(name) != name { + return "", fmt.Errorf("library link %q leaves its directory", name) + } + path := filepath.Join(dir, name) + info, err := os.Lstat(path) + if err != nil { + return "", err + } + if info.Mode()&fs.ModeSymlink == 0 { + return path, nil + } + if name, err = os.Readlink(path); err != nil { + return "", err + } + } + return "", fmt.Errorf("library %s has too many links", name) +} diff --git a/apps/daemon/internal/agent/claudesdk/view_loader_other.go b/apps/daemon/internal/agent/claudesdk/view_loader_other.go new file mode 100644 index 000000000..13398f7eb --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/view_loader_other.go @@ -0,0 +1,13 @@ +//go:build !linux + +package claudesdk + +import ( + "fmt" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" +) + +func closureLoader(...string) (viewLoader, error) { + return viewLoader{}, fmt.Errorf("%w: agent-host views run on Linux", agent.ErrUnsupportedOperation) +} diff --git a/apps/daemon/internal/agent/claudesdk/view_test.go b/apps/daemon/internal/agent/claudesdk/view_test.go new file mode 100644 index 000000000..bd38a546d --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/view_test.go @@ -0,0 +1,187 @@ +//go:build unix + +package claudesdk + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "syscall" + "testing" + "time" + + "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" +) + +func TestViewExecutorLaunchesAClosedGatewayEnvironment(t *testing.T) { + const realKey = "sk-ant-real-key-sentinel" + t.Setenv("ANTHROPIC_API_KEY", realKey) + t.Setenv("OAC_VIEW_SENTINEL", "host-environment") + view := resolveTestView(t) + home := t.TempDir() + var launched clirunner.StartOptions + requests := make(chan []byte, 1) + session := agent.ViewSession{ + Home: agent.ViewDir{Host: home, View: agent.ViewPrivateRoot + "/" + agent.ViewHomeName}, + Proxy: "http://127.0.0.1:17100", + MCP: []agent.MCPBinding{{ServerLabel: "docs", Transport: "http", ServerURL: "http://127.0.0.1:17102/mcp/docs"}}, + Launch: func(options clirunner.StartOptions) (*clirunner.Process, error) { + launched = options + return startViewBridge(options, requests) + }, + } + req := proto.PromptRequestPayload{DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{ID: "environment", WorkspaceRoot: "/workspace", NetworkAccess: "enabled"}, + AgentOptions: map[string]any{"model": "fixture", "model_provider": map[string]any{"protocol": "anthropic", "base_url": "http://127.0.0.1:17101", "api_key": modelprovider.Placeholder}}} + executor, err := view.Executor(t.Context(), req, session) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + defer executor.Close(ctx) + request := <-requests + + if !slices.Contains(view.LocalExec, launched.Binary) || !slices.Equal(launched.Args, []string{agent.ViewPrivateRoot + "/claude-sdk/dist/main.js"}) || launched.Dir != "/workspace" || !launched.OwnProcessGroup { + t.Fatalf("launch = %+v, want the closure's node running the bridge in the workspace", launched) + } + env := map[string]string{} + for _, entry := range launched.Env { + name, value, _ := strings.Cut(entry, "=") + env[name] = value + } + shell := agent.ViewPrivateRoot + "/" + agent.ViewShimName + "/bash" + for name, want := range map[string]string{ + "ANTHROPIC_BASE_URL": "http://127.0.0.1:17101", "ANTHROPIC_API_KEY": modelprovider.Placeholder, + "HOME": session.Home.View + "/home", "CLAUDE_CONFIG_DIR": session.Home.View + "/config", "TMPDIR": session.Home.View + "/tmp", "XDG_RUNTIME_DIR": session.Home.View + "/xdg", + "PATH": agent.ViewPrivateRoot + "/" + agent.ViewShimName, "LD_LIBRARY_PATH": agent.ViewPrivateRoot + "/lib", + "HTTPS_PROXY": session.Proxy, "http_proxy": session.Proxy, "NO_PROXY": "127.0.0.1,localhost", + "CLAUDE_CODE_CERT_STORE": "bundled", "SHELL": shell, "CLAUDE_CODE_SHELL": shell, "USE_BUILTIN_RIPGREP": "0", + "CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS": "1", "CLAUDE_CODE_TOOL_MEMORY_LIMIT": "0", + } { + if env[name] != want { + t.Errorf("%s = %q, want %q", name, env[name], want) + } + } + if _, ok := env["ANTHROPIC_AUTH_TOKEN"]; ok || !strings.HasPrefix(env["CLAUDE_CODE_TMPDIR"], "/tmp/oac-claude-") { + t.Errorf("ANTHROPIC_AUTH_TOKEN set or CLAUDE_CODE_TMPDIR %q outside the shared /tmp", env["CLAUDE_CODE_TMPDIR"]) + } + if _, ok := env["OAC_VIEW_SENTINEL"]; ok { + t.Error("the agent host's environment reached the Harness") + } + + var start startRequest + if err := json.Unmarshal(request, &start); err != nil || start.Type != "executor_prepare" || start.Cwd != "/workspace" || start.Workspace == nil || + start.Workspace.Home != env["HOME"] || start.Workspace.State != env["CLAUDE_CONFIG_DIR"] || len(start.Workspace.MCP) != 1 || + start.Workspace.MCP[0].ServerURL != "http://127.0.0.1:17102/mcp/docs" || start.Workspace.MCP[0].BearerTokenEnvVar != "" { + t.Fatalf("bridge request = %s, %v", request, err) + } + for _, name := range viewHomeDirs { + if info, err := os.Lstat(filepath.Join(home, name)); err != nil || !info.IsDir() { + t.Errorf("home %s: %v", name, err) + } + } + leaked := strings.Contains(strings.Join(append(launched.Args, launched.Env...), "\n")+string(request), realKey) + _ = filepath.WalkDir(home, func(path string, entry fs.DirEntry, err error) error { + if err == nil && !entry.IsDir() { + raw, _ := os.ReadFile(path) + leaked = leaked || strings.Contains(string(raw), realKey) + } + return nil + }) + if leaked { + t.Fatal("the real key reached the view") + } + + session.MCP = []agent.MCPBinding{{ServerLabel: "local", Transport: "stdio", Stdio: &proto.EnvironmentMCP{}}} + if _, err := view.Executor(t.Context(), req, session); !errors.Is(err, agent.ErrUnsupportedOperation) { + t.Fatalf("stdio MCP = %v, want ErrUnsupportedOperation", err) + } + + // A link the Session uid planted in its home is never followed. + session.MCP, session.Home.Host = nil, t.TempDir() + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(session.Home.Host, "config")); err != nil { + t.Fatal(err) + } + if _, err := view.Executor(t.Context(), req, session); err == nil || !strings.Contains(err.Error(), "config") { + t.Fatalf("planted config link = %v, want a failed preparation", err) + } + if entries, err := os.ReadDir(outside); err != nil || len(entries) != 0 { + t.Fatalf("outside the home = %v, %v, want it unchanged", entries, err) + } +} + +// resolveTestView registers the declaration with a view over a probe fixture +// and resolves it, so the Registry's gateway check runs before the factory. +func resolveTestView(t *testing.T) agent.View { + t.Helper() + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + probe := Config{Node: filepath.Join(root, "bin", "node"), Entrypoint: filepath.Join(root, "bundle", "dist", "main.js")} + binary, err := filepath.EvalSymlinks(os.Args[0]) + if err != nil { + t.Fatal(err) + } + for path, content := range map[string]string{ + probe.Node: "#!/bin/sh\nexport GO_CLAUDE_READINESS_HELPER=1 READINESS_MODE=view\nexec '" + strings.ReplaceAll(binary, "'", "'\\''") + "' \"$@\"\n", + probe.Entrypoint: "", filepath.Join(root, "bundle", "dist", "runtime_check.js"): "", filepath.Join(root, "bundle", "native", "claude"): "", + filepath.Join(root, "lib", "ld.so"): "", + } { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil || os.WriteFile(path, []byte(content), 0o700) != nil { + t.Fatal(path, err) + } + } + loader := viewLoader{Interp: "/lib64/ld-linux-x86-64.so.2", Source: filepath.Join(root, "lib", "ld.so"), LibDir: filepath.Join(root, "lib")} + declared := declareView(probe, probe.Node, filepath.Join(root, "bundle"), "dist/main.js", "native/claude", loader) + registry := agent.NewRegistry() + registry.Register(Declaration, agent.Runtime{Info: Declaration.Info, Session: NewFactory(probe), View: declared}) + view, err := registry.ResolveView(Declaration.Info.Kind) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(view.LocalExec, agent.ViewPrivateRoot+"/claude-sdk/native/claude") { + t.Fatalf("LocalExec = %v, want the native claude", view.LocalExec) + } + return view +} + +// startViewBridge stands in for the bridge in a view: it records the +// preparation request, reports ready and ends when signalled. +func startViewBridge(options clirunner.StartOptions, requests chan<- []byte) (*clirunner.Process, error) { + stdinReader, stdinWriter := io.Pipe() + stdoutReader, stdoutWriter := io.Pipe() + bridge := &viewBridge{ended: make(chan struct{})} + go func() { + line, _ := bufio.NewReader(stdinReader).ReadBytes('\n') + requests <- line + _, _ = fmt.Fprintln(stdoutWriter, `{"type":"executor_ready","protocol":3}`) + <-bridge.ended + _ = stdoutWriter.Close() + _ = stdinReader.Close() + }() + return clirunner.FromHandle(bridge, clirunner.HandleOptions{Parent: options.Parent, Stdin: stdinWriter, Stdout: stdoutReader, Stderr: io.NopCloser(strings.NewReader(""))}) +} + +type viewBridge struct { + once sync.Once + ended chan struct{} +} + +func (b *viewBridge) Signal(syscall.Signal) error { b.end(); return nil } +func (b *viewBridge) Wait() (int, error) { <-b.ended; return 0, nil } +func (b *viewBridge) Close() error { b.end(); return nil } +func (b *viewBridge) end() { b.once.Do(func() { close(b.ended) }) } diff --git a/apps/daemon/internal/agent/claudesdk/workspace.go b/apps/daemon/internal/agent/claudesdk/workspace.go index ba63e73de..5476f2188 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace.go +++ b/apps/daemon/internal/agent/claudesdk/workspace.go @@ -114,7 +114,8 @@ func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { env = append(env, entry) } } - env = append(env, "HOME="+w.HomeDir, "TMPDIR="+w.ScratchDir, "CLAUDE_CONFIG_DIR="+config.StateDir, "DISABLE_TELEMETRY=1", "DISABLE_ERROR_REPORTING=1", "DISABLE_AUTOUPDATER=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1", "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1") + env = append(env, "HOME="+w.HomeDir, "TMPDIR="+w.ScratchDir, "CLAUDE_CONFIG_DIR="+config.StateDir) + env = append(env, nativeFlags...) seen := map[string]bool{} for _, entry := range config.Env { name, _, ok := strings.Cut(entry, "=") @@ -128,6 +129,10 @@ func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { return profile, env, nil } +// nativeFlags turn off Claude Code's telemetry, error reports, updates and +// background work in a workspace profile. +var nativeFlags = []string{"DISABLE_TELEMETRY=1", "DISABLE_ERROR_REPORTING=1", "DISABLE_AUTOUPDATER=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1", "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1"} + func workspaceEnvName(name string) bool { switch name { case "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", diff --git a/packages/claude-sdk-adapter/src/runtime_check.ts b/packages/claude-sdk-adapter/src/runtime_check.ts index 730030d6d..c20f3eab8 100644 --- a/packages/claude-sdk-adapter/src/runtime_check.ts +++ b/packages/claude-sdk-adapter/src/runtime_check.ts @@ -45,7 +45,7 @@ try { assert.equal(smoke.error, undefined, "bridge_unavailable"); assert.equal(smoke.status, 0, "bridge_unavailable"); assert.deepEqual(JSON.parse(smoke.stdout), { type: "error", code: "invalid_request" }); - process.stdout.write(JSON.stringify({ type: "runtime_ready", protocol: 3, features: ["executor_reuse", ...(["linux", "darwin", "win32"].includes(process.platform) ? ["workspace_directory", "local_runtime_v2", "workspace_functions", "workspace_structured_output", "workspace_tool_search", "workspace_mcp_http"] : []), "message_images", "function_result_images", "tool_search", "structured_output", "subagent_resources", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"], node: process.versions.node, sdk: sdk.version, mcp: mcp.version, native: nativeVersion }) + "\n"); + process.stdout.write(JSON.stringify({ type: "runtime_ready", protocol: 3, features: ["executor_reuse", ...(["linux", "darwin", "win32"].includes(process.platform) ? ["workspace_directory", "local_runtime_v2", "workspace_functions", "workspace_structured_output", "workspace_tool_search", "workspace_mcp_http"] : []), "message_images", "function_result_images", "tool_search", "structured_output", "subagent_resources", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"], node: process.versions.node, sdk: sdk.version, mcp: mcp.version, native: nativeVersion, native_path: relative(root, binary) }) + "\n"); } catch { // Native diagnostics can include operator environment; never forward them. process.stdout.write(JSON.stringify({ type: "runtime_unavailable" }) + "\n"); diff --git a/packages/claude-sdk-adapter/src/workspace.ts b/packages/claude-sdk-adapter/src/workspace.ts index f26d85424..06d1bfa61 100644 --- a/packages/claude-sdk-adapter/src/workspace.ts +++ b/packages/claude-sdk-adapter/src/workspace.ts @@ -53,7 +53,7 @@ export function parseWorkspace(value: unknown, cwd: string): Workspace | undefin if (config.tool_env !== undefined && (!config.tool_env || typeof config.tool_env !== "object" || Array.isArray(config.tool_env) || Object.values(config.tool_env).some(value => typeof value !== "string"))) throw new Error("invalid_request"); const mcp = parseEnvironmentMCP(config.mcp); if (config.capability_root !== undefined) directory(config.capability_root, false); - if ((Array.isArray(config.skills) && config.skills.length || mcp?.length) && !config.capability_root) throw new Error("invalid_request"); + if (Array.isArray(config.skills) && config.skills.length && !config.capability_root) throw new Error("invalid_request"); if (mcp?.length && config.network_access !== "enabled") throw new Error("invalid_request"); const domains = config.allowed_domains ?? []; if (!Array.isArray(domains) || domains.length !== 0) throw new Error("invalid_request"); diff --git a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs index 49bc7e805..14bb1e6d1 100644 --- a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs @@ -47,6 +47,10 @@ test("installed MCP projection uses the common Runtime launcher", t => { } } assert.throws(() => parseStart(JSON.stringify({ ...request, workspace: { ...request.workspace, network_access: "disabled" } })), /invalid_request/); + // HTTP MCP needs no installed Capabilities. + const { capability_root: _, ...uninstalled } = request.workspace; + const http = { server_label: "docs", server_url: "http://127.0.0.1:4100/mcp/docs", allowed_tools: null }; + assert.doesNotThrow(() => parseStart(JSON.stringify({ ...request, workspace: { ...uninstalled, mcp: [http] } }))); }); test("combined inventory admits exact MCP identities with host file authority", async t => { From f7128d722c288bcccbba2d2e45ee868e91d19f62 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 1 Oct 2026 02:55:55 +0000 Subject: [PATCH 2/2] Share the agent-host view loader fragment viewloader.For reads the closure binaries' ELF headers and returns what a view adds for them: the interpreter's host directory as the lib closure mount, the interpreter overlay, empty masks over /etc/ld.so.preload and /etc/ld.so.cache, and the LD_LIBRARY_PATH value. Layouts it cannot present, and hosts other than Linux, return ErrUnsupportedOperation. The Claude SDK view now uses it. --- apps/daemon/internal/agent/claudesdk/view.go | 20 ++------ .../agent/claudesdk/view_loader_other.go | 13 ----- .../internal/agent/claudesdk/view_test.go | 4 +- .../internal/agent/viewloader/fragment.go | 38 ++++++++++++++ .../loader_linux.go} | 50 +++++++++++-------- .../agent/viewloader/loader_linux_test.go | 46 +++++++++++++++++ .../internal/agent/viewloader/loader_other.go | 14 ++++++ contracts/agents-api/harness-onboarding.md | 2 +- 8 files changed, 135 insertions(+), 52 deletions(-) delete mode 100644 apps/daemon/internal/agent/claudesdk/view_loader_other.go create mode 100644 apps/daemon/internal/agent/viewloader/fragment.go rename apps/daemon/internal/agent/{claudesdk/view_loader_linux.go => viewloader/loader_linux.go} (59%) create mode 100644 apps/daemon/internal/agent/viewloader/loader_linux_test.go create mode 100644 apps/daemon/internal/agent/viewloader/loader_other.go diff --git a/apps/daemon/internal/agent/claudesdk/view.go b/apps/daemon/internal/agent/claudesdk/view.go index 4b63b40b2..b0a85689b 100644 --- a/apps/daemon/internal/agent/claudesdk/view.go +++ b/apps/daemon/internal/agent/claudesdk/view.go @@ -13,6 +13,7 @@ import ( "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" ) @@ -27,12 +28,6 @@ type viewLayout struct { libraries string } -// viewLoader is the ELF interpreter the closure's dynamic binaries share and -// the host directory that holds every library they load. -type viewLoader struct { - Interp, Source, LibDir string -} - // viewHomeDirs are the native directories under the Session home. var viewHomeDirs = []string{"config", "home", "tmp", "xdg"} @@ -55,7 +50,7 @@ func newView(probe Config, info RuntimeInfo) (*agent.View, error) { if resolved, err := filepath.EvalSymlinks(native); err != nil || resolved != native { return nil, errors.New("the native Claude Code path leaves the bundle") } - loader, err := closureLoader(node, native) + loader, err := viewloader.For(node, native) if err != nil { return nil, err } @@ -70,10 +65,10 @@ func newView(probe Config, info RuntimeInfo) (*agent.View, error) { return view, nil } -func declareView(probe Config, node, root, bridge, native string, loader viewLoader) *agent.View { +func declareView(probe Config, node, root, bridge, native string, loader viewloader.Fragment) *agent.View { nodeMount := agent.ViewMount{Name: "node", HostDir: filepath.Dir(node)} bundle := agent.ViewMount{Name: "claude-sdk", HostDir: root} - layout := viewLayout{node: nodeMount.Path() + "/" + filepath.Base(node), bridge: bundle.Path() + "/" + bridge} + layout := viewLayout{node: nodeMount.Path() + "/" + filepath.Base(node), bridge: bundle.Path() + "/" + bridge, libraries: loader.LibraryPath} view := &agent.View{ Closure: []agent.ViewMount{nodeMount, bundle}, // The managed policy tier would let the sandbox inject settings (C1). @@ -84,12 +79,7 @@ func declareView(probe Config, node, root, bridge, native string, loader viewLoa ForwardEnv: []string{"CLAUDECODE", "GIT_EDITOR"}, Proxy: agent.ViewProxyEnv, } - if loader.Interp != "" { - lib := agent.ViewMount{Name: "lib", HostDir: loader.LibDir} - view.Closure = append(view.Closure, lib) - view.Overlays = []agent.ViewOverlay{{Path: loader.Interp, Source: loader.Source, Exec: true}} - layout.libraries = lib.Path() - } + loader.AddTo(view) view.Executor = newViewExecutorFactory(probe, layout) return view } diff --git a/apps/daemon/internal/agent/claudesdk/view_loader_other.go b/apps/daemon/internal/agent/claudesdk/view_loader_other.go deleted file mode 100644 index 13398f7eb..000000000 --- a/apps/daemon/internal/agent/claudesdk/view_loader_other.go +++ /dev/null @@ -1,13 +0,0 @@ -//go:build !linux - -package claudesdk - -import ( - "fmt" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" -) - -func closureLoader(...string) (viewLoader, error) { - return viewLoader{}, fmt.Errorf("%w: agent-host views run on Linux", agent.ErrUnsupportedOperation) -} diff --git a/apps/daemon/internal/agent/claudesdk/view_test.go b/apps/daemon/internal/agent/claudesdk/view_test.go index bd38a546d..0eed6c185 100644 --- a/apps/daemon/internal/agent/claudesdk/view_test.go +++ b/apps/daemon/internal/agent/claudesdk/view_test.go @@ -21,6 +21,7 @@ import ( "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" ) @@ -145,7 +146,8 @@ func resolveTestView(t *testing.T) agent.View { t.Fatal(path, err) } } - loader := viewLoader{Interp: "/lib64/ld-linux-x86-64.so.2", Source: filepath.Join(root, "lib", "ld.so"), LibDir: filepath.Join(root, "lib")} + lib := agent.ViewMount{Name: viewloader.MountName, HostDir: filepath.Join(root, "lib")} + loader := viewloader.Fragment{Closure: []agent.ViewMount{lib}, Overlays: []agent.ViewOverlay{{Path: "/lib64/ld-linux-x86-64.so.2", Source: filepath.Join(root, "lib", "ld.so"), Exec: true}}, LibraryPath: lib.Path()} declared := declareView(probe, probe.Node, filepath.Join(root, "bundle"), "dist/main.js", "native/claude", loader) registry := agent.NewRegistry() registry.Register(Declaration, agent.Runtime{Info: Declaration.Info, Session: NewFactory(probe), View: declared}) diff --git a/apps/daemon/internal/agent/viewloader/fragment.go b/apps/daemon/internal/agent/viewloader/fragment.go new file mode 100644 index 000000000..c4255b59a --- /dev/null +++ b/apps/daemon/internal/agent/viewloader/fragment.go @@ -0,0 +1,38 @@ +// Package viewloader gives an agent-host view the host's ELF loader for its +// dynamic closure binaries, so nothing they load comes from the sandbox. +package viewloader + +import "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + +// MountName is the closure mount that presents the host library directory. +const MountName = "lib" + +// Fragment is what a view adds for its dynamic closure binaries. The zero +// Fragment, for static binaries, adds nothing. +type Fragment struct { + Closure []agent.ViewMount + Overlays []agent.ViewOverlay + Masks []agent.ViewMask + // LibraryPath is the view's LD_LIBRARY_PATH, empty for static binaries. + LibraryPath string +} + +// AddTo appends the fragment's mounts, overlays and masks to view. +func (f Fragment) AddTo(view *agent.View) { + view.Closure = append(view.Closure, f.Closure...) + view.Overlays = append(view.Overlays, f.Overlays...) + view.Masks = append(view.Masks, f.Masks...) +} + +// fragment presents libDir, which holds every library, and source at the +// interpreter path interp. The masks keep the sandbox's preload list and +// library cache away from the loader. +func fragment(interp, source, libDir string) Fragment { + lib := agent.ViewMount{Name: MountName, HostDir: libDir} + return Fragment{ + Closure: []agent.ViewMount{lib}, + Overlays: []agent.ViewOverlay{{Path: interp, Source: source, Exec: true}}, + Masks: []agent.ViewMask{{Path: "/etc/ld.so.preload"}, {Path: "/etc/ld.so.cache"}}, + LibraryPath: lib.Path(), + } +} diff --git a/apps/daemon/internal/agent/claudesdk/view_loader_linux.go b/apps/daemon/internal/agent/viewloader/loader_linux.go similarity index 59% rename from apps/daemon/internal/agent/claudesdk/view_loader_linux.go rename to apps/daemon/internal/agent/viewloader/loader_linux.go index 42413edb1..032e5011d 100644 --- a/apps/daemon/internal/agent/claudesdk/view_loader_linux.go +++ b/apps/daemon/internal/agent/viewloader/loader_linux.go @@ -1,6 +1,6 @@ //go:build linux -package claudesdk +package viewloader import ( "debug/elf" @@ -10,39 +10,41 @@ import ( "os" "path/filepath" "strings" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" ) -// closureLoader finds the ELF interpreter that the dynamic binaries share and -// checks that the interpreter's directory holds every library they need, so -// the view loads nothing from the sandbox's files. -func closureLoader(binaries ...string) (viewLoader, error) { - var loader viewLoader +// For returns the fragment for the host binaries. The dynamic ones must share +// one ELF interpreter, and the interpreter's directory must hold every library +// they need. Any other layout is unsupported. +func For(binaries ...string) (Fragment, error) { + interp := "" var pending []string for _, binary := range binaries { - interp, needed, err := elfDependencies(binary) + next, needed, err := elfDependencies(binary) if err != nil { - return viewLoader{}, err + return Fragment{}, err } - if interp == "" { + if next == "" { if len(needed) != 0 { - return viewLoader{}, fmt.Errorf("%s needs libraries but no interpreter", binary) + return Fragment{}, unsupported("%s needs libraries but no interpreter", binary) } continue } - if loader.Interp != "" && loader.Interp != interp { - return viewLoader{}, fmt.Errorf("the closure binaries need different ELF interpreters") + if interp != "" && interp != next { + return Fragment{}, unsupported("the binaries need different ELF interpreters") } - loader.Interp = interp + interp = next pending = append(pending, needed...) } - if loader.Interp == "" { - return viewLoader{}, nil + if interp == "" { + return Fragment{}, nil } - source, err := filepath.EvalSymlinks(loader.Interp) + source, err := filepath.EvalSymlinks(interp) if err != nil { - return viewLoader{}, err + return Fragment{}, err } - loader.Source, loader.LibDir = source, filepath.Dir(source) + libDir := filepath.Dir(source) seen := map[string]bool{} for len(pending) > 0 { name := pending[len(pending)-1] @@ -51,17 +53,17 @@ func closureLoader(binaries ...string) (viewLoader, error) { continue } seen[name] = true - path, err := libraryFile(loader.LibDir, name) + path, err := libraryFile(libDir, name) if err != nil { - return viewLoader{}, fmt.Errorf("library %s is not in %s", name, loader.LibDir) + return Fragment{}, unsupported("library %s is not in %s: %v", name, libDir, err) } _, needed, err := elfDependencies(path) if err != nil { - return viewLoader{}, err + return Fragment{}, err } pending = append(pending, needed...) } - return loader, nil + return fragment(interp, source, libDir), nil } func elfDependencies(path string) (string, []string, error) { @@ -105,3 +107,7 @@ func libraryFile(dir, name string) (string, error) { } return "", fmt.Errorf("library %s has too many links", name) } + +func unsupported(format string, args ...any) error { + return fmt.Errorf("%w: %s", agent.ErrUnsupportedOperation, fmt.Sprintf(format, args...)) +} diff --git a/apps/daemon/internal/agent/viewloader/loader_linux_test.go b/apps/daemon/internal/agent/viewloader/loader_linux_test.go new file mode 100644 index 000000000..23832f780 --- /dev/null +++ b/apps/daemon/internal/agent/viewloader/loader_linux_test.go @@ -0,0 +1,46 @@ +//go:build linux + +package viewloader + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestForPresentsTheHostLoader(t *testing.T) { + fragment, err := For("/bin/sh") + if errors.Is(err, agent.ErrUnsupportedOperation) { + t.Skipf("this host's loader layout is unsupported: %v", err) + } + if err != nil { + t.Fatal(err) + } + if fragment.LibraryPath == "" { + t.Skip("/bin/sh is static on this host") + } + if len(fragment.Closure) != 1 || fragment.LibraryPath != fragment.Closure[0].Path() || len(fragment.Overlays) != 1 || !fragment.Overlays[0].Exec || + filepath.Dir(fragment.Overlays[0].Source) != fragment.Closure[0].HostDir { + t.Fatalf("fragment = %+v, want the interpreter overlaid from the library mount", fragment) + } + if info, err := os.Stat(fragment.Overlays[0].Source); err != nil || !info.Mode().IsRegular() { + t.Fatalf("interpreter source: %v", err) + } + if !slices.Equal(fragment.Masks, []agent.ViewMask{{Path: "/etc/ld.so.preload"}, {Path: "/etc/ld.so.cache"}}) { + t.Fatalf("masks = %+v", fragment.Masks) + } + view := agent.View{Proxy: agent.ViewProxyNone, LocalExec: []string{fragment.Overlays[0].Path}, + Executor: func(context.Context, proto.PromptRequestPayload, agent.ViewSession) (agent.Executor, error) { + return nil, nil + }} + fragment.AddTo(&view) + if err := view.Validate(); err != nil { + t.Fatal(err) + } +} diff --git a/apps/daemon/internal/agent/viewloader/loader_other.go b/apps/daemon/internal/agent/viewloader/loader_other.go new file mode 100644 index 000000000..a26aad1dd --- /dev/null +++ b/apps/daemon/internal/agent/viewloader/loader_other.go @@ -0,0 +1,14 @@ +//go:build !linux + +package viewloader + +import ( + "fmt" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" +) + +// For reports that agent-host views run only on Linux. +func For(...string) (Fragment, error) { + return Fragment{}, fmt.Errorf("%w: agent-host views run on Linux", agent.ErrUnsupportedOperation) +} diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index 649499676..22a443313 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -287,7 +287,7 @@ An agent host runs the Harness outside the sandbox, in a per-Session view. The v ### Executables -Only mount flags grant execution. The closure, `Exec` overlays and the shim are read-only and are the only executable mounts; the sandbox's files and the home are noexec. `Launch` accepts only a `LocalExec` path as `Binary`. A dynamic binary, such as `node`, needs its ELF interpreter as an `Exec` overlay at its `PT_INTERP` path, and every library it loads in the closure, reached through `LD_LIBRARY_PATH`. Nothing loads from the sandbox's files. +Only mount flags grant execution. The closure, `Exec` overlays and the shim are read-only and are the only executable mounts; the sandbox's files and the home are noexec. `Launch` accepts only a `LocalExec` path as `Binary`. A dynamic binary, such as `node`, needs its ELF interpreter as an `Exec` overlay at its `PT_INTERP` path, and every library it loads in the closure, reached through `LD_LIBRARY_PATH`. Nothing loads from the sandbox's files. `viewloader.For` builds this from the binaries' ELF headers: the interpreter's host directory as the `lib` closure mount, the interpreter overlay, empty masks over `/etc/ld.so.preload` and `/etc/ld.so.cache`, and the `LD_LIBRARY_PATH` value. A layout it cannot present, such as a library outside the interpreter's directory, returns `ErrUnsupportedOperation`. ### Shims