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..b0a85689b --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/view.go @@ -0,0 +1,215 @@ +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/apps/daemon/internal/agent/viewloader" + "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 +} + +// 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 := viewloader.For(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.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, libraries: loader.LibraryPath} + 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, + } + loader.AddTo(view) + 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_test.go b/apps/daemon/internal/agent/claudesdk/view_test.go new file mode 100644 index 000000000..0eed6c185 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/view_test.go @@ -0,0 +1,189 @@ +//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/apps/daemon/internal/agent/viewloader" + "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) + } + } + 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}) + 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/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/viewloader/loader_linux.go b/apps/daemon/internal/agent/viewloader/loader_linux.go new file mode 100644 index 000000000..032e5011d --- /dev/null +++ b/apps/daemon/internal/agent/viewloader/loader_linux.go @@ -0,0 +1,113 @@ +//go:build linux + +package viewloader + +import ( + "debug/elf" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" +) + +// 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 { + next, needed, err := elfDependencies(binary) + if err != nil { + return Fragment{}, err + } + if next == "" { + if len(needed) != 0 { + return Fragment{}, unsupported("%s needs libraries but no interpreter", binary) + } + continue + } + if interp != "" && interp != next { + return Fragment{}, unsupported("the binaries need different ELF interpreters") + } + interp = next + pending = append(pending, needed...) + } + if interp == "" { + return Fragment{}, nil + } + source, err := filepath.EvalSymlinks(interp) + if err != nil { + return Fragment{}, err + } + libDir := 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(libDir, name) + if err != nil { + return Fragment{}, unsupported("library %s is not in %s: %v", name, libDir, err) + } + _, needed, err := elfDependencies(path) + if err != nil { + return Fragment{}, err + } + pending = append(pending, needed...) + } + return fragment(interp, source, libDir), 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) +} + +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 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 => {