From caf739cf904cf22c3924f622b28306e930608c53 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sat, 19 Sep 2026 10:31:14 +0800 Subject: [PATCH] feat(agents-api): add Claude Code colocated Docker runtime --- CONTRIBUTING.md | 70 ++++++++++++--- .../internal/agent/claudesdk/local.go | 38 ++++++++ .../internal/agent/claudesdk/local_test.go | 66 ++++++++++++++ .../internal/agent/claudesdk/options.go | 20 ++++- .../internal/agent/claudesdk/provider.go | 44 +++++++++ .../internal/agent/claudesdk/readiness.go | 4 + .../internal/agent/claudesdk/workspace.go | 40 +++++++-- apps/parsar-daemon/internal/cli/claude_sdk.go | 37 ++++++++ contracts/agents-api/openapi.yaml | 11 +-- packages/claude-sdk-adapter/src/adapter.ts | 9 ++ packages/claude-sdk-adapter/src/recovery.ts | 11 +++ packages/claude-sdk-adapter/src/request.ts | 5 +- .../claude-sdk-adapter/src/runtime_check.ts | 2 +- packages/claude-sdk-adapter/src/workspace.ts | 6 +- .../tests/recovery.test.mjs | 25 ++++++ .../tests/workspace.test.mjs | 16 ++++ scripts/build-claude-runtime.sh | 32 +++++++ scripts/check-claude-sdk-runtime.mjs | 2 +- .../agents-api/cmd/server/managed_runtimes.go | 13 +-- services/agents-api/deploy/claude/Dockerfile | 23 +++++ services/agents-api/deploy/claude/README.md | 85 ++++++++++++++++++ services/agents-api/internal/api/handler.go | 2 +- .../agents-api/internal/api/handler_test.go | 6 +- .../internal/execution/engine_profile.go | 87 ++++++++++++++++++ .../internal/execution/engine_profile_test.go | 33 +++++++ .../agents-api/internal/execution/support.go | 90 +++++-------------- .../internal/sandbox/docker/provider.go | 12 +++ 27 files changed, 680 insertions(+), 109 deletions(-) create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/local.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/local_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/provider.go create mode 100644 packages/claude-sdk-adapter/src/recovery.ts create mode 100644 packages/claude-sdk-adapter/tests/recovery.test.mjs create mode 100755 scripts/build-claude-runtime.sh create mode 100644 services/agents-api/deploy/claude/Dockerfile create mode 100644 services/agents-api/deploy/claude/README.md create mode 100644 services/agents-api/internal/execution/engine_profile.go create mode 100644 services/agents-api/internal/execution/engine_profile_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 76c35ffb..7d7fc755 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -381,10 +381,10 @@ release and recovery semantics for Codex and Claude. Retain each harness's nativ implementation behind its adapter. Core acts on verified capabilities and runtime conditions; a capability declaration alone never grants public feature admission. Extend existing interfaces during related functional work without introducing a -second framework or a broad rewrite. The current MVP accepts Codex only, with -Docker-hosted delivery first and E2B afterward. Preserve existing Claude code and -evidence; further Claude integration is deferred and hosted remote-executor -separation is outside the current roadmap. +second framework or a broad rewrite. The completed MVP remains Codex/Docker. +The next accepted engine profile is Claude SDK on the same dedicated Docker +Runtime; qualify each image with the common full-loop acceptance before deploying. +E2B, other engines and hosted remote-executor separation remain outside this batch. Later engines must satisfy the same applicable acceptance contract while keeping their suitable native deployment layout. @@ -1577,7 +1577,8 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti The operator-selected `AGENTS_API_ENGINE` is separate from the requested model. Public execution supports Codex and Claude SDK with environment `none`, plus - the Codex self-hosted text/function profile defined above; reject unsupported + the Codex self-hosted text/function profile and the qualified Codex/Claude dedicated + Docker hosted profiles; reject unsupported input/environment/agent options explicitly. - `packages/agents-client/v1` configures the pinned official `openai-go` Session service. Use SDK request/response types, pagination and errors directly rather @@ -1927,6 +1928,47 @@ must close their query, await their native child and drain observations before publishing completion. Process groups are lifecycle supervision, not OS isolation or containment of descendants that deliberately leave the group. +### Claude dedicated Docker Runtime + +Build the pinned SDK bundle with `scripts/build-claude-sdk-runtime.sh`, then use +`scripts/build-claude-runtime.sh` with the existing shared workspace helper build. +See [deployment and engine onboarding](services/agents-api/deploy/claude/README.md). +The helper executables retain their historical Codex names; their local directory, +write and export operations are shared and do not launch an engine. + +`PARSAR_CLAUDE_SDK_WORKSPACE=managed` requires the shared dedicated local binding, +canonical workspace and its same-inode `/workspace` mount, and explicit immutable +network policy. Native history, home and scratch live separately under +`PARSAR_HOME/runtime/claude-sdk`; daemon authentication stays under +`PARSAR_HOME/parsar-daemon`. The trusted image and protected staging directory +remain outside writable workspace roots. No product state or native user profile +is imported. The separate unbound `environment:none` profile keeps its behavior. + +The Docker operator option `nested_sandbox` is false by default. The qualified +Claude image requires it: Docker supplies an init process and permits nested procfs +mounting by removing its outer `/proc` masks/read-only submounts. `/sys/firmware` +and powercap remain masked; the root and sysfs mounts remain read-only, capabilities +remain dropped, and the existing seccomp/no-new-privileges policy remains enabled. +Do not enable privileged mode, weaken the native sandbox, mount host process state, +or apply host-global policy changes. This is an image deployment prerequisite, +not a public API option or an engine-name branch in the Provider. + +The SDK adapter advertises `local_runtime_v1` only for its Linux bridge contract. +Registration combines that contract with the verified operator binding. Core uses +an explicit accepted engine profile independently of advertisements. This profile +supports native Bash/Read/Edit, preparation, shared Files/Artifacts, cancellation +and same-history continuation. Functions and MCP in this workspace profile remain +unqualified and reject before persistence; their existing `none` support is retained. +Native Bash network access uses the harness's HTTP proxy; no alternate networking +or tool loop is implemented by Core. + +Recovery uses the SDK's history APIs. An explicitly supplied native identity must +exist. If Core requires existing history without having recorded an identity, the +adapter accepts only one nonempty native history for the exact bound cwd. Missing, +foreign, ambiguous or metadata-only history rejects before model input. The Runtime +volume and shared Environment/Session binding establish ownership; this lookup +cannot select another Session's home or infer ownership from a model response. + ### Claude SDK adapter foundation `packages/claude-sdk-adapter` privately owns the pinned official TypeScript SDK @@ -1948,8 +1990,8 @@ SDK/native child release and output draining precede daemon completion. qualified placement. It enables only native Bash/Read/Edit in the existing SDK loop. The entire factory must already run inside an outer mount/process boundary that excludes application, daemon and other-tenant credentials and host policy. -The factory does not create that boundary. Public discovery/admission remains -unchanged; cwd and request options cannot select the workspace policy. +The factory does not create that boundary. The dedicated Docker profile below +selects this binding at startup; cwd and request options cannot select its policy. The workspace, managed history, runtime home, scratch and protected secret roots must be pre-existing canonical, separate directories. Runtime code and dependency search paths must remain outside those roots and be read-only in the placement. @@ -1972,8 +2014,8 @@ The deployment must retain these controls, including the SDK-owned hook. Functions, MCP and remote-environment combinations are rejected in this private profile until separately qualified. The existing `none` profile retains its behavior. Packaged `workspace_tools` establishes bridge support only, not host -isolation or a public capability. Public command/file Item delivery, public -preparation, shared placement quotas and Files ownership remain separate work. +isolation or a public capability. The dedicated Runtime integration composes +public preparation, shared placement quotas, command Items and Files ownership. `TestLiveClaudeWorkspaceFactory` is explicit real-provider acceptance inside a qualified placement, including effects, cancellation and same-history continuation. @@ -2051,7 +2093,8 @@ are returned only from distinguishable filesystem outcomes; unknown results stop owner. Each operation closes its directory and intermediate descriptors before a successful receipt; the root descriptor remains owned until bridge release. Caller cancellation, Start transfer and owner shutdown retain the existing workspace -read settlement rules. This adapter gap fill does not enable public Claude Files. +read settlement rules. This adapter gap fill alone does not enable public Claude Files; the dedicated +Runtime integration supplies public placement and ownership. With `ObserveToolObservations`, private workspace execution requires the packaged `workspace_command_observations` feature and emits the existing neutral command @@ -2165,8 +2208,11 @@ schemas. Function-result batches normalize through the existing shared parser an reject non-text content before any batch write, preserving pending calls and retry identity. These are implementation limits, not changes to the upstream contract. Do not bypass them by dropping fields, changing model identity or fabricating usage. -The server owns no provider credential: operators configure the daemon's native SDK -provider environment. Product `claude_code` and product execution are unchanged. +Operators may configure the daemon provider environment or the existing transient +`AGENTS_API_EXECUTION_OPTIONS_FILE` with adapter-owned `claude_provider` +(`base_url` HTTPS and `bearer_token`). Core forwards these opaque options without +persisting them in Session configuration. The adapter exclusively selects the +provider environment and removes credentials from native tool environments. Product `claude_code` and product execution are unchanged. The public profile accepts only text, explicit model/system instructions, managed state, exact native resume and declared functions with ordered text results, and the HTTP MCP subset diff --git a/apps/parsar-daemon/internal/agent/claudesdk/local.go b/apps/parsar-daemon/internal/agent/claudesdk/local.go new file mode 100644 index 00000000..e119a826 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/local.go @@ -0,0 +1,38 @@ +package claudesdk + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// ConfigureLocal selects the qualified, dedicated Runtime layout. The shared +// localworkspace binding still authorizes every request against its Session. +func ConfigureLocal(config Config, root, workspace, network, staging string) (Config, error) { + config.StateDir = filepath.Join(root, "runtime", "claude-sdk", "history") + config.Workspace = &WorkspaceConfig{ + Directory: workspace, PublicDirectory: "/workspace", NetworkAccess: network, + HomeDir: filepath.Join(root, "runtime", "claude-sdk", "home"), + ScratchDir: filepath.Join(root, "runtime", "claude-sdk", "scratch"), + ProtectedDirs: []string{filepath.Join(root, "parsar-daemon"), staging}, + DependencyPath: "/usr/local/bin:/usr/bin:/bin", + } + if network != "enabled" && network != "disabled" { + return Config{}, fmt.Errorf("claudesdk: dedicated Runtime requires an explicit network policy") + } + for _, dir := range []string{config.StateDir, config.Workspace.HomeDir, config.Workspace.ScratchDir} { + if err := os.MkdirAll(dir, 0700); err != nil { + return Config{}, err + } + } + config.Env = nil + for _, entry := range os.Environ() { + name, _, _ := strings.Cut(entry, "=") + if workspaceEnvName(name) { + config.Env = append(config.Env, entry) + } + } + _, _, err := workspaceEnvironment(config) + return config, err +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/local_test.go b/apps/parsar-daemon/internal/agent/claudesdk/local_test.go new file mode 100644 index 00000000..9ad72415 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/local_test.go @@ -0,0 +1,66 @@ +package claudesdk + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestLocalWorkspaceBindingNetworkAndRequiredHistory(t *testing.T) { + config := workspaceFixture(t) + config.Workspace.PublicDirectory = config.Workspace.Directory + config.Workspace.NetworkAccess = "enabled" + req := workspaceRequest() + req.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "enabled"} + req.RequireExistingNativeSession = true + start, _, err := prepare(config, req) + if err != nil || !start.RequireHistory || start.Workspace.NetworkAccess != "enabled" { + t.Fatal(start, err) + } + req.LocalEnvironment.NetworkAccess = "disabled" + if _, _, err := prepare(config, req); err == nil { + t.Fatal("accepted different Runtime network policy") + } + req.LocalEnvironment.NetworkAccess = "enabled" + config.Workspace.PublicDirectory = config.Workspace.HomeDir + if _, _, err := prepare(config, req); err == nil { + t.Fatal("accepted a different public workspace") + } + alias := filepath.Join(filepath.Dir(config.Workspace.Directory), "alias") + if err := os.Symlink(config.Workspace.Directory, alias); err != nil { + t.Fatal(err) + } + config.Workspace.PublicDirectory = alias + if _, _, err := prepare(config, req); err == nil { + t.Fatal("accepted mutable workspace alias") + } +} + +func TestWorkspaceProviderCredentialsReplaceAmbientSelection(t *testing.T) { + config := workspaceFixture(t) + original := slices.Clone(config.Env) + req := workspaceRequest() + req.AgentOptions["claude_provider"] = map[string]any{"base_url": "https://provider.example/anthropic", "bearer_token": "selected-secret"} + start, env, err := prepare(config, req) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(start) + 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") { + t.Fatal("provider selection was not exclusive") + } + for _, value := range []any{nil, "secret", map[string]any{"base_url": "http://provider.example", "bearer_token": "secret"}, map[string]any{"base_url": "https://user:pass@provider.example", "bearer_token": "secret"}} { + req.AgentOptions["claude_provider"] = value + if _, _, err := prepare(config, req); err == nil || strings.Contains(err.Error(), "secret") { + t.Fatal("unsafe provider accepted or disclosed") + } + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/options.go b/apps/parsar-daemon/internal/agent/claudesdk/options.go index 77599457..b0e15ad8 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/options.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/options.go @@ -29,6 +29,7 @@ type startRequest struct { Functions []proto.FunctionTool `json:"functions,omitempty"` MCPHTTPServers *[]mcpHTTPServer `json:"mcp_http_servers,omitempty"` Workspace *workspaceProfile `json:"workspace,omitempty"` + RequireHistory bool `json:"require_history,omitempty"` observeFunctions bool } @@ -45,7 +46,7 @@ func prepare(config Config, req proto.PromptRequestPayload) (startRequest, []str } func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startRequest, []string, error) { - start := startRequest{Type: "start", Resume: req.AgentSessionID, ObserveMessages: req.ObserveMessages, Functions: req.FunctionTools, observeFunctions: req.ObserveToolObservations} + 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) } @@ -65,7 +66,16 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR if err := validateFunctions(req.FunctionTools); err != nil { return startRequest{}, nil, err } + var provider []string for name, raw := range req.AgentOptions { + if name == "claude_provider" { + var err error + provider, err = providerEnvironment(raw) + if err != nil { + return startRequest{}, nil, err + } + continue + } if name == "system_prompt" && raw == nil { continue } @@ -88,15 +98,19 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR 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 = config.Workspace.Directory + 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 @@ -124,7 +138,7 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR return startRequest{}, nil, err } } - env := append(append([]string{}, os.Environ()...), config.Env...) + 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") var mcpEnv []string start.MCPHTTPServers, mcpEnv = prepareMCPHTTP(req.MCPHTTPServers) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/provider.go b/apps/parsar-daemon/internal/agent/claudesdk/provider.go new file mode 100644 index 00000000..45f6b362 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/provider.go @@ -0,0 +1,44 @@ +package claudesdk + +import ( + "fmt" + "net/url" + "strings" +) + +// Provider options are transient operator input, never public Agent fields or +// native tool environment. The workspace sandbox removes these variables. +func providerEnvironment(value any) ([]string, error) { + fail := func() ([]string, error) { return nil, fmt.Errorf("claudesdk: invalid provider configuration") } + options, ok := value.(map[string]any) + if !ok || len(options) != 2 { + return fail() + } + base, ok := options["base_url"].(string) + if !ok { + return fail() + } + token, ok := options["bearer_token"].(string) + if !ok || strings.TrimSpace(token) == "" || strings.ContainsAny(token, "\x00\r\n") { + return fail() + } + u, err := url.Parse(base) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return fail() + } + return []string{"ANTHROPIC_BASE_URL=" + base, "ANTHROPIC_AUTH_TOKEN=" + token}, nil +} + +func withProvider(env, provider []string) []string { + if provider == nil { + return env + } + out := make([]string, 0, len(env)+len(provider)) + for _, entry := range env { + key, _, _ := strings.Cut(entry, "=") + if key != "ANTHROPIC_API_KEY" && key != "ANTHROPIC_AUTH_TOKEN" && key != "ANTHROPIC_BASE_URL" { + out = append(out, entry) + } + } + return append(out, provider...) +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/readiness.go b/apps/parsar-daemon/internal/agent/claudesdk/readiness.go index 817f14b0..599dd8fe 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/readiness.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/readiness.go @@ -45,6 +45,10 @@ func (info RuntimeInfo) supportsWorkspaceCommands() bool { return info.supportsWorkspacePreparation() && slices.Contains(info.Features, "workspace_command_observations") } +func (info RuntimeInfo) SupportsLocalRuntime() bool { + return info.supportsWorkspaceCommands() && slices.Contains(info.Features, "local_runtime_v1") +} + // CheckRuntime checks the packaged companion and exact execution entrypoint. // It does not create Session state, register an engine or make a model request. func CheckRuntime(ctx context.Context, config Config) (RuntimeInfo, error) { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go index d3d946fa..63c6f841 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go @@ -13,13 +13,16 @@ import ( // WorkspaceConfig binds one trusted private placement. It does not create an // isolation boundary or authorize a public Environment. The operator must place // the entire factory inside the qualified outer mount/process boundary first. -// All directories must already exist, be canonical and be mutually disjoint. +// State directories must already exist, be canonical and be mutually disjoint. +// PublicDirectory may name a second mount of the same workspace inode. type WorkspaceConfig struct { - Directory string - HomeDir string - ScratchDir string - ProtectedDirs []string - DependencyPath string + Directory string + PublicDirectory string + NetworkAccess string + HomeDir string + ScratchDir string + ProtectedDirs []string + DependencyPath string } type workspaceProfile struct { @@ -29,6 +32,7 @@ type workspaceProfile struct { ProtectedDirs []string `json:"protected_dirs"` DependencyPath string `json:"dependency_path"` EnvNames []string `json:"env_names"` + NetworkAccess string `json:"network_access,omitempty"` } func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspaceProfile, []string, error) { @@ -38,9 +42,19 @@ func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspace if req.WorkDir != "" && req.WorkDir != config.Workspace.Directory { return nil, nil, fmt.Errorf("claudesdk: work_dir conflicts with the trusted workspace binding") } + if req.LocalEnvironment != nil && (config.Workspace.NetworkAccess == "" || req.LocalEnvironment.NetworkAccess != config.Workspace.NetworkAccess) { + return nil, nil, fmt.Errorf("claudesdk: local Runtime network policy mismatch") + } return workspaceEnvironment(config) } +func workspaceCwd(w *WorkspaceConfig) string { + if w.PublicDirectory != "" { + return w.PublicDirectory + } + return w.Directory +} + // Workspace Env is a replacement, unlike the existing none profile's overlay. // Only explicitly selected provider settings reach either readiness or execution. func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { @@ -51,6 +65,16 @@ func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { if w == nil || !filepath.IsAbs(config.Node) || !filepath.IsAbs(config.Entrypoint) { return fail() } + if w.NetworkAccess != "" && w.NetworkAccess != "disabled" && w.NetworkAccess != "enabled" { + return fail() + } + if w.PublicDirectory != "" { + actual, err := os.Stat(w.Directory) + alias, aliasErr := os.Stat(w.PublicDirectory) + if !canonicalWorkspaceDir(w.Directory) || !canonicalWorkspaceDir(w.PublicDirectory) || err != nil || aliasErr != nil || !os.SameFile(actual, alias) { + return fail() + } + } runtimeDir := filepath.Dir(filepath.Dir(config.Entrypoint)) if config.Entrypoint != filepath.Join(runtimeDir, "dist", "main.js") || !canonicalWorkspaceDir(runtimeDir) { return fail() @@ -65,7 +89,7 @@ func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { return fail() } } - roots := append([]string{w.Directory, config.StateDir, w.HomeDir, w.ScratchDir}, w.ProtectedDirs...) + roots := append([]string{workspaceCwd(w), config.StateDir, w.HomeDir, w.ScratchDir}, w.ProtectedDirs...) for i, dir := range roots { if !canonicalWorkspaceDir(dir) || pathContains(runtimeDir, dir) || pathContains(dir, runtimeDir) { return fail() @@ -111,7 +135,7 @@ func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { } dependencyPath := strings.Join(dependencies, string(os.PathListSeparator)) profile := &workspaceProfile{Home: w.HomeDir, State: config.StateDir, Scratch: w.ScratchDir, - ProtectedDirs: append([]string{}, w.ProtectedDirs...), DependencyPath: dependencyPath, EnvNames: []string{}} + ProtectedDirs: append([]string{}, w.ProtectedDirs...), DependencyPath: dependencyPath, EnvNames: []string{}, NetworkAccess: w.NetworkAccess} env := []string{"PATH=" + dependencyPath, "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"} diff --git a/apps/parsar-daemon/internal/cli/claude_sdk.go b/apps/parsar-daemon/internal/cli/claude_sdk.go index 9f57e890..544a76fe 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk.go @@ -9,6 +9,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudesdk" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) @@ -58,6 +59,27 @@ func discoverClaudeSDK(rc *runContext, profile string, check func(context.Contex return fail(err) } out.Config = claudesdk.Config{Node: node, Entrypoint: entrypoint, StateDir: filepath.Join(profileDir, "runtime", "claude-sdk")} + if mode := os.Getenv("PARSAR_CLAUDE_SDK_WORKSPACE"); mode != "" { + if mode != "managed" { + return fail(fmt.Errorf("unsupported Claude SDK workspace profile")) + } + binding, err := localworkspace.Load() + if err != nil || binding == nil { + return fail(fmt.Errorf("Claude SDK workspace requires a dedicated local Runtime binding")) + } + root, err := paths.Root() + if err != nil { + return fail(err) + } + out.Config.Node, err = filepath.EvalSymlinks(node) + if err != nil { + return fail(err) + } + out.Config, err = claudesdk.ConfigureLocal(out.Config, root, os.Getenv("PARSAR_RUNTIME_WORKSPACE"), binding.NetworkAccess(), os.Getenv("PARSAR_RUNTIME_STAGING")) + if err != nil { + return fail(err) + } + } if check == nil { check = claudesdk.CheckRuntime } @@ -65,9 +87,21 @@ func discoverClaudeSDK(rc *runContext, profile string, check func(context.Contex if err != nil { return fail(err) } + if out.Config.Workspace != nil { + if !info.SupportsLocalRuntime() { + return fail(fmt.Errorf("Claude SDK bundle does not support the local Runtime contract")) + } + caps := &out.Info.Capabilities + caps.EnvironmentNone, caps.FunctionTools = false, false + caps.Preparation, caps.LocalEnvironment, caps.LocalEnvironmentNetworkPolicy = true, true, true + caps.WorkspaceReadPreparation, caps.NativeSessionRecovery = true, true + } out.Info.Available, out.Info.Version = true, info.SDK out.Info.Capabilities.MCPHTTPTools = info.SupportsHTTPMCP() out.Info.Capabilities.MCPHTTPBearerAuth = info.SupportsHTTPMCPBearer() + if out.Config.Workspace != nil { + out.Info.Capabilities.MCPHTTPTools, out.Info.Capabilities.MCPHTTPBearerAuth = false, false + } fmt.Fprintf(rc.stdout, "Claude SDK preflight ok (SDK %s, %s)\n", info.SDK, info.Native) return out } @@ -83,4 +117,7 @@ func registerClaudeSDK(registry *agent.Registry, discovery *claudeSDKDiscovery) } } registry.RegisterKind(discovery.Info, factory) + if discovery.Info.Available && discovery.Info.Capabilities.LocalEnvironment { + registry.RegisterPreparation("claude_sdk", true, claudesdk.NewPreparationFactory(discovery.Config)) + } } diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index 570f26c9..e94ba497 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -1933,11 +1933,12 @@ paths: recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. Creation retries observe future events without replay; retry with stream=false to retrieve the Session. Non-text - initial input remains unsupported. Basic Codex openai_hosted creation requires - an explicitly configured managed provider. Idle Sessions provision automatically; - initial provisioning has no caller connection action. Network defaults to - enabled; disabled is also supported, while restricted domains and populated - startup installations are rejected. + initial input remains unsupported. Basic Codex and Claude SDK openai_hosted + creation requires an explicitly configured managed provider. The Claude workspace + profile currently rejects function and MCP tools. Idle Sessions provision + automatically; initial provisioning has no caller connection action. Network + defaults to enabled; disabled is also supported, while restricted domains + and populated startup installations are rejected. parameters: - description: agents=v1 in: header diff --git a/packages/claude-sdk-adapter/src/adapter.ts b/packages/claude-sdk-adapter/src/adapter.ts index 03feed80..55de2933 100644 --- a/packages/claude-sdk-adapter/src/adapter.ts +++ b/packages/claude-sdk-adapter/src/adapter.ts @@ -12,6 +12,7 @@ import { MCPObserver, type MCPEvent } from "./mcp_observer.js"; import { CommandObserver, type CommandEvent } from "./command_observer.js"; import { WorkspaceProfile } from "./workspace.js"; import type { Prepare, Start } from "./request.js"; +import { recoverSession } from "./recovery.js"; export { parseStart, type Start } from "./request.js"; export type Event = @@ -33,6 +34,14 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => const commands = workspace ? new CommandObserver() : undefined; if (request.type === "prepare" && !workspace) throw new Error("invalid_request"); if (workspace && ("functions" in request || "mcp_http_servers" in request)) throw new Error("invalid_request"); + if (request.require_history && !request.resume) { + const recovered = await recoverSession(request.cwd); + if (!recovered) { + await emit({ type: "error", code: "history_unavailable" }); + return; + } + request = { ...request, resume: recovered }; + } if (request.resume && !await getSessionInfo(request.resume, { dir: request.cwd })) { await emit({ type: "error", code: "history_unavailable" }); return; diff --git a/packages/claude-sdk-adapter/src/recovery.ts b/packages/claude-sdk-adapter/src/recovery.ts new file mode 100644 index 00000000..c1d547ee --- /dev/null +++ b/packages/claude-sdk-adapter/src/recovery.ts @@ -0,0 +1,11 @@ + +// The deployment gives this API Session its own native state directory. +export async function recoverSession(cwd: string): Promise { + const { getSessionInfo, getSessionMessages, listSessions } = await import("@anthropic-ai/claude-agent-sdk"); + const sessions = await listSessions({ dir: cwd, includeWorktrees: false, limit: 2 }); + if (sessions.length !== 1 || sessions[0].cwd !== cwd) return undefined; + const id = sessions[0].sessionId; + const info = await getSessionInfo(id, { dir: cwd }); + if (!info || info.cwd !== cwd || (await getSessionMessages(id, { dir: cwd, limit: 1 })).length === 0) return undefined; + return id; +} diff --git a/packages/claude-sdk-adapter/src/request.ts b/packages/claude-sdk-adapter/src/request.ts index 385f7ffb..84554fe3 100644 --- a/packages/claude-sdk-adapter/src/request.ts +++ b/packages/claude-sdk-adapter/src/request.ts @@ -10,6 +10,7 @@ export type Start = { system_prompt: string; cwd: string; resume?: string; + require_history?: boolean; observe_messages?: boolean; functions?: { name: string; description: string; parameters: Tool["inputSchema"] }[]; mcp_http_servers?: HTTPServer[]; @@ -21,7 +22,7 @@ export function parseRequest(line: string): Start | Prepare { const value: unknown = JSON.parse(line); if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_request"); const request = value as Record; - const allowed = new Set(["type", "prompt", "model", "system_prompt", "cwd", "resume", "observe_messages", "functions", "mcp_http_servers", "workspace"]); + const allowed = new Set(["type", "prompt", "model", "system_prompt", "cwd", "resume", "require_history", "observe_messages", "functions", "mcp_http_servers", "workspace"]); if (Object.keys(request).some(key => !allowed.has(key)) || (request.type !== "start" && request.type !== "prepare") || (request.type === "start" ? typeof request.prompt !== "string" || !request.prompt.trim() : "prompt" in request) || @@ -29,12 +30,14 @@ export function parseRequest(line: string): Start | Prepare { typeof request.system_prompt !== "string" || typeof request.cwd !== "string" || !isAbsolute(request.cwd) || (request.observe_messages !== undefined && typeof request.observe_messages !== "boolean") || + (request.require_history !== undefined && typeof request.require_history !== "boolean") || (request.resume !== undefined && (typeof request.resume !== "string" || !request.resume))) throw new Error("invalid_request"); if (request.functions !== undefined && (!Array.isArray(request.functions) || request.functions.some(tool => !tool || typeof tool.name !== "string" || !tool.name || typeof tool.description !== "string" || !tool.parameters || tool.parameters.type !== "object"))) throw new Error("invalid_request"); parseHTTPServers(request.mcp_http_servers); const workspace = parseWorkspace(request.workspace, request.cwd); + if (request.require_history && !workspace) throw new Error("invalid_request"); if ((workspace && ("functions" in request || "mcp_http_servers" in request)) || (request.type === "prepare" && !workspace)) throw new Error("invalid_request"); return request as Start | Prepare; diff --git a/packages/claude-sdk-adapter/src/runtime_check.ts b/packages/claude-sdk-adapter/src/runtime_check.ts index 44ee2da5..a351bb3d 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: 1, features: [...(process.platform === "linux" ? ["workspace_directory"] : []), "mcp_http_tools", "mcp_http_bearer_auth", "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: 1, features: [...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1"] : []), "mcp_http_tools", "mcp_http_bearer_auth", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"], node: process.versions.node, sdk: sdk.version, mcp: mcp.version, native: nativeVersion }) + "\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 e3b13cc1..25352065 100644 --- a/packages/claude-sdk-adapter/src/workspace.ts +++ b/packages/claude-sdk-adapter/src/workspace.ts @@ -9,6 +9,7 @@ export type Workspace = { protected_dirs: string[]; dependency_path: string; env_names: string[]; + network_access?: "enabled" | "disabled"; }; const environmentNames = new Set([ @@ -38,7 +39,8 @@ export function parseWorkspace(value: unknown, cwd: string): Workspace | undefin if (value === undefined) return undefined; if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_request"); const config = value as Record; - if (Object.keys(config).some(key => !["home", "state", "scratch", "protected_dirs", "dependency_path", "env_names"].includes(key)) || + if (Object.keys(config).some(key => !["home", "state", "scratch", "protected_dirs", "dependency_path", "env_names", "network_access"].includes(key)) || + (config.network_access !== undefined && config.network_access !== "enabled" && config.network_access !== "disabled") || !Array.isArray(config.protected_dirs) || !Array.isArray(config.env_names) || typeof config.dependency_path !== "string" || !config.dependency_path || config.env_names.some(name => typeof name !== "string" || !environmentNames.has(name)) || @@ -95,7 +97,7 @@ export class WorkspaceProfile { envVars: [...new Set([...credentialNames, ...config.env_names])].map(name => ({ name, mode: "deny" })), files: protectedRoots.map(path => ({ path, mode: "deny" })), }, - network: { allowedDomains: [], strictAllowlist: true, allowAllUnixSockets: false, allowLocalBinding: false }, + network: { allowedDomains: config.network_access === "enabled" ? ["*"] : [], strictAllowlist: true, allowAllUnixSockets: false, allowLocalBinding: false }, }, canUseTool: this.canUseTool, hooks: { PreToolUse: [{ hooks: [this.beforeTool] }] }, diff --git a/packages/claude-sdk-adapter/tests/recovery.test.mjs b/packages/claude-sdk-adapter/tests/recovery.test.mjs new file mode 100644 index 00000000..91fc8585 --- /dev/null +++ b/packages/claude-sdk-adapter/tests/recovery.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +// Controlled history inventories test recovery admission, not native execution. +const fixture = ` +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +const mode=process.argv[1]; +const source='export async function listSessions(o){return globalThis.inventory(o);} export async function getSessionInfo(id,o){return globalThis.info(id,o);} export async function getSessionMessages(id,o){return globalThis.messages(id,o);}'; +registerHooks({resolve(s,c,next){return s==='@anthropic-ai/claude-agent-sdk'?{url:'data:text/javascript,'+encodeURIComponent(source),shortCircuit:true}:next(s,c);}}); +let reads=0; +globalThis.inventory=o=>{assert.deepEqual(o,{dir:'/workspace',includeWorktrees:false,limit:2});return mode==='empty'?[]:mode==='ambiguous'?[{sessionId:'a',cwd:'/workspace'},{sessionId:'b',cwd:'/workspace'}]:[{sessionId:'a',cwd:mode==='foreign'?'/other':'/workspace'}];}; +globalThis.info=(id,o)=>{reads++;assert.equal(id,'a');assert.deepEqual(o,{dir:'/workspace'});return mode==='missing'?undefined:{sessionId:'a',cwd:mode==='changed'?'/other':'/workspace'};}; +globalThis.messages=(id,o)=>{assert.equal(id,'a');assert.deepEqual(o,{dir:'/workspace',limit:1});return mode==='metadata-only'?[]:[{type:'user'}];}; +const {recoverSession}=await import('./dist/recovery.js'); +assert.equal(await recoverSession('/workspace'),mode==='valid'?'a':undefined); +if(['empty','ambiguous','foreign'].includes(mode))assert.equal(reads,0); +`; +for (const mode of ["empty", "ambiguous", "foreign", "missing", "changed", "metadata-only", "valid"]) { + test(`recovery inventory: ${mode}`, () => { + const child=spawnSync(process.execPath,["--input-type=module","-e",fixture,mode],{encoding:"utf8",timeout:5000}); + assert.equal(child.status,0,child.stderr||child.error?.message); + }); +} diff --git a/packages/claude-sdk-adapter/tests/workspace.test.mjs b/packages/claude-sdk-adapter/tests/workspace.test.mjs index 4afcac85..3bff5101 100644 --- a/packages/claude-sdk-adapter/tests/workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace.test.mjs @@ -159,3 +159,19 @@ test("workspace permissions and pre-tool hook reject outside paths and unsafe Ba assert.equal((await profile.canUseTool("Bash", { command: "true" }, { ...options, agentID: "child" })).behavior, "deny"); assert.equal((await profile.canUseTool("Bash", { command: "true" }, { ...options, signal: AbortSignal.abort() })).behavior, "deny"); }); + +test("dedicated Runtime carries an explicit native network policy", t => { + const { dirs, config, request } = fixture(t); + for (const network_access of ["enabled", "disabled"]) { + const workspace = { ...config, network_access }; + const parsed = parseStart(JSON.stringify({ ...request, workspace, require_history: true })); + assert.equal(parsed.require_history, true); + const options = new WorkspaceProfile(dirs.workspace, workspace).options; + assert.deepEqual(options.sandbox.network.allowedDomains, network_access === "enabled" ? ["*"] : []); + assert.equal(options.sandbox.enableWeakerNestedSandbox, false); + assert.equal(options.sandbox.allowUnsandboxedCommands, false); + } + assert.throws(() => parseStart(JSON.stringify({ ...request, workspace: { ...config, network_access: "restricted" } })), /invalid_request/); + const { workspace, ...none } = request; + assert.throws(() => parseStart(JSON.stringify({ ...none, require_history: true })), /invalid_request/); +}); diff --git a/scripts/build-claude-runtime.sh b/scripts/build-claude-runtime.sh new file mode 100755 index 00000000..c04fd36e --- /dev/null +++ b/scripts/build-claude-runtime.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +runtime_root="${PARSAR_HOME:-$HOME/.parsar}" +output_dir="${AGENTS_RUNTIME_BUILD_DIR:-$runtime_root/build/claude-runtime}" +sdk_dir="${CLAUDE_SDK_BUILD_DIR:-$runtime_root/build/claude-sdk-runtime}" +helpers_dir="${AGENTS_EXECUTOR_BUILD_DIR:-$runtime_root/build/agents-executor}" +for directory in "$runtime_root" "$output_dir" "$sdk_dir" "$helpers_dir"; do + [[ "$directory" == /* ]] || { printf 'Expected absolute build directory: %s\n' "$directory" >&2; exit 1; } +done +mkdir -p "$runtime_root/cache/agents-runtime-builds" +context="$(mktemp -d "$runtime_root/cache/agents-runtime-builds/claude.XXXXXX")" +trap 'rm -rf "$context"' EXIT +archive=claude-sdk-runtime-linux-x64-glibc.tar.gz +(cd "$sdk_dir" && sha256sum -c "$archive.sha256") +mkdir "$context/claude-sdk" +tar -xzf "$sdk_dir/$archive" -C "$context/claude-sdk" +node "$repo_root/scripts/check-claude-sdk-runtime.mjs" "$context/claude-sdk" +( + cd "$repo_root" + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=readonly -trimpath \ + -o "$context/parsar-daemon" ./apps/parsar-daemon/cmd/parsar-daemon +) +for helper in agents-api-codex-directory agents-api-codex-write agents-api-workspace-export; do + test -x "$helpers_dir/$helper" + cp "$helpers_dir/$helper" "$context/" +done +cp "$repo_root/services/agents-api/deploy/claude/Dockerfile" "$context/Dockerfile" +mkdir -p "$output_dir" +cp -R "$context/." "$output_dir/" +printf 'Claude Runtime image context: %s\n' "$output_dir" diff --git a/scripts/check-claude-sdk-runtime.mjs b/scripts/check-claude-sdk-runtime.mjs index fcf620f9..4751e066 100644 --- a/scripts/check-claude-sdk-runtime.mjs +++ b/scripts/check-claude-sdk-runtime.mjs @@ -23,7 +23,7 @@ assert.equal(probe.status, 0, "Exported runtime is unavailable"); const report = JSON.parse(probe.stdout); assert.equal(report.type, "runtime_ready"); assert.equal(report.protocol, 1); -assert.deepEqual(report.features, [...(process.platform === "linux" ? ["workspace_directory"] : []), "mcp_http_tools", "mcp_http_bearer_auth", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"]); +assert.deepEqual(report.features, [...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1"] : []), "mcp_http_tools", "mcp_http_bearer_auth", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"]); assert.equal(report.sdk, source.dependencies["@anthropic-ai/claude-agent-sdk"]); assert.equal(report.mcp, source.dependencies["@modelcontextprotocol/sdk"]); console.log(`Verified exported SDK ${report.sdk}, MCP ${report.mcp}, ${report.native}`); diff --git a/services/agents-api/cmd/server/managed_runtimes.go b/services/agents-api/cmd/server/managed_runtimes.go index 2d6d9982..8047d46a 100644 --- a/services/agents-api/cmd/server/managed_runtimes.go +++ b/services/agents-api/cmd/server/managed_runtimes.go @@ -21,11 +21,12 @@ type managedRuntimeConfig struct { } type managedDockerConfig struct { - Host string `json:"host"` - Image string `json:"image"` - Network string `json:"network"` - SeccompFile string `json:"seccomp_file"` - ExtraHosts []string `json:"extra_hosts"` + Host string `json:"host"` + Image string `json:"image"` + Network string `json:"network"` + SeccompFile string `json:"seccomp_file"` + ExtraHosts []string `json:"extra_hosts"` + NestedSandbox bool `json:"nested_sandbox"` } // Each provider key pins an explicit Docker endpoint, independently of ambient @@ -79,7 +80,7 @@ func managedRuntimes() (*execution.RuntimeProviders, func(), error) { return nil, func() {}, errors.New("invalid managed Docker endpoint") } clients = append(clients, c) - provider, err := sandboxdocker.New(c, sandboxdocker.Config{InstallationID: key, Image: entry.Image, Network: entry.Network, Seccomp: string(seccomp), ExtraHosts: entry.ExtraHosts}) + provider, err := sandboxdocker.New(c, sandboxdocker.Config{InstallationID: key, Image: entry.Image, Network: entry.Network, Seccomp: string(seccomp), ExtraHosts: entry.ExtraHosts, NestedSandbox: entry.NestedSandbox}) if err != nil { closeAll() return nil, func() {}, errors.New("invalid managed Docker provider configuration") diff --git a/services/agents-api/deploy/claude/Dockerfile b/services/agents-api/deploy/claude/Dockerfile new file mode 100644 index 00000000..544b838f --- /dev/null +++ b/services/agents-api/deploy/claude/Dockerfile @@ -0,0 +1,23 @@ +# Build context contains only the daemon, shared helpers and pinned SDK bundle. +FROM node:22.23.1-bookworm-slim@sha256:8607a9064d4a571140998ae9e52a3b3fcf9cff361d04642d5971e6cd76d39e27 +USER root +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates bash git python3 ripgrep bubblewrap socat \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /environment/workspace /workspace /home/runtime +COPY --chmod=0555 parsar-daemon agents-api-codex-directory agents-api-codex-write agents-api-workspace-export /usr/local/bin/ +COPY claude-sdk /opt/claude-sdk +ENV HOME=/home/runtime PARSAR_HOME=/home/runtime/.parsar \ + PARSAR_CLAUDE_SDK_NODE=/usr/local/bin/node \ + PARSAR_CLAUDE_SDK_ENTRYPOINT=/opt/claude-sdk/dist/main.js \ + PARSAR_CLAUDE_SDK_WORKSPACE=managed \ + PARSAR_RUNTIME_WORKSPACE=/environment/workspace \ + PARSAR_RUNTIME_DIRECTORY_HELPER=/usr/local/bin/agents-api-codex-directory \ + PARSAR_RUNTIME_WRITE_HELPER=/usr/local/bin/agents-api-codex-write \ + PARSAR_RUNTIME_EXPORT_HELPER=/usr/local/bin/agents-api-workspace-export \ + PARSAR_RUNTIME_STAGING=/environment/staging +USER 1000:1000 +RUN node /opt/claude-sdk/dist/runtime_check.js /opt/claude-sdk/dist/main.js +WORKDIR /environment/workspace +ENTRYPOINT ["/usr/local/bin/parsar-daemon"] +CMD ["connect", "--profile", "default"] diff --git a/services/agents-api/deploy/claude/README.md b/services/agents-api/deploy/claude/README.md new file mode 100644 index 00000000..724494c5 --- /dev/null +++ b/services/agents-api/deploy/claude/README.md @@ -0,0 +1,85 @@ +# Claude Code on the dedicated Runtime + +Core is independently deployed. Each managed Session receives one Docker Runtime +containing the daemon, pinned Claude Agent SDK/native harness and local workspace. +The SDK owns the model/tool loop. Public clients use the same Agents API contract. + +## Build and configure + +On Linux amd64, build the existing shared workspace helpers, then run: + +```sh +bash scripts/build-claude-sdk-runtime.sh +bash scripts/build-claude-runtime.sh +docker build --platform linux/amd64 -t agents-runtime:claude \ + "${PARSAR_HOME:-$HOME/.parsar}/build/claude-runtime" +``` + +The bundle pins SDK `0.3.269` and native Claude Code `2.1.269`. The Dockerfile pins +Node's Linux amd64 manifest. Keep the exported SDK bundle immutable. Configure +Core's existing managed Runtime file with the resulting immutable image ID, a +private Docker network, the existing `deploy/codex/seccomp.json`, and +`"nested_sandbox": true`. This option enables child reaping and the outer procfs +layout required by native bubblewrap; all native sandbox restrictions remain on. +It is disabled for existing deployments unless explicitly selected. + +Set `AGENTS_API_ENGINE=claude_sdk`. In a private file selected by +`AGENTS_API_EXECUTION_OPTIONS_FILE`, configure the provider: + +```json +{ + "claude_provider": { + "base_url": "https://api.anthropic.com", + "bearer_token": "REPLACE_WITH_OPERATOR_SECRET" + } +} +``` + +Use the endpoint and model supported by your actual provider. Do not bake keys +into the image. Core forwards this adapter-owned option transiently; it is not a +public Agent field. Workspace tool processes cannot inherit the provider secret. +Follow the existing Core setup for independent PostgreSQL credentials, migrations, +API authentication and managed Runtime enrollment. Parsar is not a dependency. + +The supported hosted profile accepts text execution with medium verbosity and +native Bash/Read/Edit. Files and Artifacts use the shared public interfaces. +Workspace functions/MCP, multi-agent, structured output, user-managed enrollment +and other environment installations remain explicit gaps. The existing +`environment:none` function/MCP profile is separate. This is not complete upstream +protocol compatibility. + +## Adding another native engine + +Start with the existing `agent.Factory`, `agent.Session` and +`agent.PreparationFactory` interfaces; do not create another scheduler or loop. +Registration itself is small: + +```go +registry.RegisterKind(descriptor, adapter.NewFactory(config)) +registry.RegisterPreparation(descriptor.Kind, true, adapter.NewPreparationFactory(config)) +``` + +These calls refer to the existing daemon registry. `descriptor` must advertise +only capabilities established by your adapter and deployment tests. A dedicated +local Runtime automatically reuses workspace authorization, idle directory reads, +file writes and output export; it does not need an engine-specific Files API. + +The adapter translates native configuration and events, owns its child processes, +confirms input receipts, and implements cancellation and preparation ownership. +`Prepared.Start` transfers ownership once; `Close` releases only unused preparation; +`PreparedCancellation.Cancel` follows the same resource through transfer and waits +for output settlement. Reuse the shared process runner and existing error types. +Native history belongs to the bound API Session, not the device descriptor. + +Add the qualified placement and value constraints to the small execution +`acceptedEngine` declaration. That declaration gates public support independently +of Runtime capability flags. Do not add engine branches to handlers, stores, +resource managers or scheduling. A genuinely new public capability may require a +bounded extension of the shared contract; registration alone cannot make an +incompatible harness conformant. + +Acceptance must use the pinned official OpenAI client, raw HTTP and a real model: +execute and continue; list/create files and retrieve artifacts; cancel and prove +process settlement; reconnect/restart without replay; verify tenant, credential and +history isolation. Missing history fails closed. Run focused race tests, `make +check` and independent review before merging. Keep unsupported combinations explicit. diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index ff1c0c67..e99333e2 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -111,7 +111,7 @@ func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ... // createSession atomically reserves or admits initial text with the Session. // @Summary Create an execution Session -// @Description Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, disabled multi_agent, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with required=false and separately advertised MCP/bearer runtime support. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null, but its values must be strings. Initial input accepts a string or user-message array containing text. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Omitted or null input creates an idle Session. With stream=true, returns live Session events starting at creation; disconnect does not cancel execution. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; unrelated inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. Creation retries observe future events without replay; retry with stream=false to retrieve the Session. Non-text initial input remains unsupported. Basic Codex openai_hosted creation requires an explicitly configured managed provider. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled is also supported, while restricted domains and populated startup installations are rejected. +// @Description Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, disabled multi_agent, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with required=false and separately advertised MCP/bearer runtime support. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null, but its values must be strings. Initial input accepts a string or user-message array containing text. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Omitted or null input creates an idle Session. With stream=true, returns live Session events starting at creation; disconnect does not cancel execution. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; unrelated inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. Creation retries observe future events without replay; retry with stream=false to retrieve the Session. Non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile currently rejects function and MCP tools. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled is also supported, while restricted domains and populated startup installations are rejected. // @Tags Sessions // @Accept json // @Produce json,text/event-stream diff --git a/services/agents-api/internal/api/handler_test.go b/services/agents-api/internal/api/handler_test.go index f050c78f..9ff2af1a 100644 --- a/services/agents-api/internal/api/handler_test.go +++ b/services/agents-api/internal/api/handler_test.go @@ -48,7 +48,7 @@ func testHandler(t *testing.T, options ...Option) (http.Handler, *recordingStore t.Fatal(err) } s := &recordingStore{} - h, err := NewHandler(s, auth, "claude_code", options...) + h, err := NewHandler(s, auth, "codex", options...) if err != nil { t.Fatal(err) } @@ -65,7 +65,7 @@ func TestHTTPConfigurationAndTenantIdentity(t *testing.T) { request.Header.Set("X-Tenant-ID", "untrusted-tenant") w := httptest.NewRecorder() h.ServeHTTP(w, request) - if w.Code != http.StatusOK || s.tenant != tenant || s.input.Engine != "claude_code" || s.input.IdempotencyKey != "retry-key" { + if w.Code != http.StatusOK || s.tenant != tenant || s.input.Engine != "codex" || s.input.IdempotencyKey != "retry-key" { t.Fatalf("request = %d %s; tenant=%s, engine=%s", w.Code, w.Body, s.tenant, s.input.Engine) } var response v1.Session @@ -88,7 +88,7 @@ func TestHTTPRejectsUntrustedOrUnsupportedRequests(t *testing.T) { {"missing beta", "Bearer test-api-key", "", "/v1/agents/sessions", valid, 400}, {"tenant query", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions?tenant_id=other", valid, 400}, {"tenant body", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"agent":`, `"tenant_id":"other","agent":`, 1), 400}, - {"hosted environment on unqualified engine", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"none"`, `"openai_hosted"`, 1), 400}, + {"hosted environment without managed deployment", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"none"`, `"openai_hosted"`, 1), 503}, {"self-hosted environment", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"none"`, `"self_hosted"`, 1), 400}, {"initial input", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"agent":`, `"input":"run it","agent":`, 1), 503}, {"stream unavailable", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"agent":`, `"stream":true,"agent":`, 1), 503}, diff --git a/services/agents-api/internal/execution/engine_profile.go b/services/agents-api/internal/execution/engine_profile.go new file mode 100644 index 00000000..2e5e36d4 --- /dev/null +++ b/services/agents-api/internal/execution/engine_profile.go @@ -0,0 +1,87 @@ +package execution + +import ( + "encoding/json" + "errors" + "slices" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// Accepted profiles are explicit public qualifications, not inferred from a +// Runtime advertisement. Native configuration and execution remain in adapters. +type engineProfile struct { + placements []string + validateConfiguration func(Snapshot) error + validateInputs func([]store.Input) error + webSearchControl, textVerbosity bool +} + +func (p engineProfile) accepts(placement string) bool { + return slices.Contains(p.placements, placement) +} +func acceptedEngine(engine string) (engineProfile, bool) { + switch engine { + case "codex": + return engineProfile{placements: []string{"none", "self_hosted", "openai_hosted"}, webSearchControl: true, textVerbosity: true}, true + case "claude_sdk": + return engineProfile{placements: []string{"none", "openai_hosted"}, validateConfiguration: validateClaudeConfiguration, validateInputs: validateClaudeInputs}, true + default: + return engineProfile{}, false + } +} + +func validateClaudeConfiguration(snapshot Snapshot) error { + agent := snapshot.Agent + if snapshot.Environment == nil || (snapshot.Environment.Type != "none" && snapshot.Environment.Type != "openai_hosted") || snapshot.Daemon != nil || strings.TrimSpace(agent.Model) == "" { + return store.ErrInvalidInput + } + if agent.Text.Verbosity != "" && agent.Text.Verbosity != "medium" { + return errors.New("The configured engine currently supports medium text verbosity only.") + } + if agent.MultiAgent.Enabled || agent.MultiAgent.MaxConcurrentSubagents != nil || agent.Reasoning.Effort != nil || agent.Reasoning.Summary != nil || (agent.ServiceTier != "" && agent.ServiceTier != "auto") || (agent.Text.Format.Type != "" && agent.Text.Format.Type != "text") { + return store.ErrInvalidInput + } + tools, mcp, err := executionTools(agent.Tools) + if err != nil { + return err + } + if snapshot.Environment.Type == "openai_hosted" && (len(tools) != 0 || len(mcp) != 0) { + return errors.New("The configured workspace profile does not support function or MCP tools.") + } + if err := validateClaudeMCP(mcp); err != nil { + return err + } + for _, tool := range tools { + var schema struct { + Type string `json:"type"` + } + if json.Unmarshal(tool.Parameters, &schema) != nil || schema.Type != "object" { + return errors.New("The configured engine currently requires function schemas with root type object.") + } + } + return nil +} + +func validateClaudeInputs(inputs []store.Input) error { + for _, input := range inputs { + if input.Kind != "tool_result" { + continue + } + var value store.FunctionResultInput + if json.Unmarshal(input.Payload, &value) != nil { + return store.ErrInvalidInput + } + result, err := functionResult(store.FunctionCall{CallID: value.CallID, Result: value.Result}) + if err != nil { + return store.ErrInvalidInput + } + for _, part := range result.Content { + if part.Type != "input_text" { + return store.ErrInvalidInput + } + } + } + return nil +} diff --git a/services/agents-api/internal/execution/engine_profile_test.go b/services/agents-api/internal/execution/engine_profile_test.go new file mode 100644 index 00000000..7ea0489a --- /dev/null +++ b/services/agents-api/internal/execution/engine_profile_test.go @@ -0,0 +1,33 @@ +package execution + +import ( + "encoding/json" + "testing" +) + +func TestAcceptedEnginePlacements(t *testing.T) { + for _, engine := range []string{"codex", "claude_sdk", "unregistered"} { + for _, placement := range []string{"none", "openai_hosted", "self_hosted"} { + raw := json.RawMessage(`{"agent":{"model":"fixture"},"environment":{"type":"` + placement + `"`) + if placement == "self_hosted" { + raw = append(raw, []byte(`,"workspace_directory":"/work"`)...) + } + raw = append(raw, []byte(`}}`)...) + want := engine != "unregistered" && !(engine == "claude_sdk" && placement == "self_hosted") + if err := ValidateSessionConfiguration(engine, raw); (err == nil) != want { + t.Fatalf("%s/%s: %v", engine, placement, err) + } + } + } +} + +func TestClaudeHostedToolsRequireSeparateQualification(t *testing.T) { + for _, tools := range []string{`[{"type":"function","name":"f","parameters":{"type":"object"},"defer_loading":false}]`, `[{"type":"mcp","server_label":"s","transport":{"type":"http","server_url":"https://example.test/mcp"},"connection_origin":"service"}]`} { + for _, placement := range []string{"none", "openai_hosted"} { + raw := json.RawMessage(`{"agent":{"model":"fixture","tools":` + tools + `},"environment":{"type":"` + placement + `"}}`) + if err := ValidateSessionConfiguration("claude_sdk", raw); (err == nil) != (placement == "none") { + t.Fatalf("%s: %v", placement, err) + } + } + } +} diff --git a/services/agents-api/internal/execution/support.go b/services/agents-api/internal/execution/support.go index cd8fdb91..7ec4a0d0 100644 --- a/services/agents-api/internal/execution/support.go +++ b/services/agents-api/internal/execution/support.go @@ -17,12 +17,16 @@ func ValidateSessionConfiguration(engine string, configuration json.RawMessage) if json.Unmarshal(configuration, &snapshot) != nil { return store.ErrInvalidInput } + profile, ok := acceptedEngine(engine) + if !ok || (snapshot.Environment != nil && !profile.accepts(snapshot.Environment.Type)) { + return store.ErrInvalidInput + } _, err := mcpCredentialBindings(engine, snapshot) if err != nil { return err } if snapshot.Environment != nil && snapshot.Environment.Type == "self_hosted" { - if engine != "codex" || snapshot.Daemon != nil || strings.TrimSpace(snapshot.Agent.Model) == "" || !path.IsAbs(snapshot.Environment.WorkspaceDirectory) || strings.ContainsAny(snapshot.Environment.WorkspaceDirectory, "\x00\r\n\\") || len(snapshot.Environment.CapabilityDirectories) != 0 { + if snapshot.Daemon != nil || strings.TrimSpace(snapshot.Agent.Model) == "" || !path.IsAbs(snapshot.Environment.WorkspaceDirectory) || strings.ContainsAny(snapshot.Environment.WorkspaceDirectory, "\x00\r\n\\") || len(snapshot.Environment.CapabilityDirectories) != 0 { return store.ErrInvalidInput } _, _, err := executionTools(snapshot.Agent.Tools) @@ -31,7 +35,7 @@ func ValidateSessionConfiguration(engine string, configuration json.RawMessage) if snapshot.Environment != nil && snapshot.Environment.Type == "openai_hosted" { // Only this placement/engine combination has current native qualification. // Runtime capability checks still apply before any execution claim. - if engine != "codex" || snapshot.Daemon != nil || strings.TrimSpace(snapshot.Agent.Model) == "" { + if snapshot.Daemon != nil || strings.TrimSpace(snapshot.Agent.Model) == "" { return store.ErrInvalidInput } configuration, err := json.Marshal(snapshot.Environment) @@ -39,77 +43,31 @@ func ValidateSessionConfiguration(engine string, configuration json.RawMessage) return store.ErrInvalidInput } } - if engine != "claude_sdk" { + if profile.validateConfiguration == nil { _, mcp, err := executionTools(snapshot.Agent.Tools) - if len(mcp) != 0 && (engine != "codex" || snapshot.Environment == nil || snapshot.Environment.Type != "none" || snapshot.Daemon != nil) { + if len(mcp) != 0 && (snapshot.Environment == nil || snapshot.Environment.Type != "none" || snapshot.Daemon != nil) { return errors.New("HTTP MCP execution currently requires the Codex service-side environment:none profile") } return err } - return validateClaudeConfiguration(snapshot) -} - -func validateClaudeConfiguration(snapshot Snapshot) error { - agent := snapshot.Agent - if snapshot.Environment == nil || snapshot.Environment.Type != "none" || snapshot.Daemon != nil || strings.TrimSpace(agent.Model) == "" { - return store.ErrInvalidInput - } - if agent.Text.Verbosity != "" && agent.Text.Verbosity != "medium" { - return errors.New("The configured engine currently supports medium text verbosity only.") - } - if agent.MultiAgent.Enabled || agent.MultiAgent.MaxConcurrentSubagents != nil || agent.Reasoning.Effort != nil || agent.Reasoning.Summary != nil || (agent.ServiceTier != "" && agent.ServiceTier != "auto") || (agent.Text.Format.Type != "" && agent.Text.Format.Type != "text") { - return store.ErrInvalidInput - } - tools, mcp, err := executionTools(agent.Tools) - if err != nil { - return err - } - if err := validateClaudeMCP(mcp); err != nil { - return err - } - for _, tool := range tools { - var schema struct { - Type string `json:"type"` - } - if json.Unmarshal(tool.Parameters, &schema) != nil || schema.Type != "object" { - return errors.New("The configured engine currently requires function schemas with root type object.") - } - } - return nil + return profile.validateConfiguration(snapshot) } func canAdmitInputs(engine string, configuration json.RawMessage) bool { var snapshot Snapshot - if (engine != "codex" && engine != "claude_sdk") || json.Unmarshal(configuration, &snapshot) != nil || snapshot.Environment == nil || snapshot.Environment.Type != "none" || snapshot.Daemon != nil { + if json.Unmarshal(configuration, &snapshot) != nil || snapshot.Environment == nil || snapshot.Environment.Type != "none" || snapshot.Daemon != nil { return false } - if _, err := mcpCredentialBindings(engine, snapshot); err != nil { - return false - } - return engine != "claude_sdk" || validateClaudeConfiguration(snapshot) == nil + return ValidateSessionConfiguration(engine, configuration) == nil } func validateEngineInputs(engine string, inputs []store.Input) error { - if engine != "claude_sdk" { - return nil + profile, ok := acceptedEngine(engine) + if !ok { + return store.ErrInvalidInput } - for _, input := range inputs { - if input.Kind != "tool_result" { - continue - } - var value store.FunctionResultInput - if json.Unmarshal(input.Payload, &value) != nil { - return store.ErrInvalidInput - } - result, err := functionResult(store.FunctionCall{CallID: value.CallID, Result: value.Result}) - if err != nil { - return store.ErrInvalidInput - } - for _, part := range result.Content { - if part.Type != "input_text" { - return store.ErrInvalidInput - } - } + if profile.validateInputs != nil { + return profile.validateInputs(inputs) } return nil } @@ -120,14 +78,14 @@ func engineCapabilities(peer *gateway.Session, engine string, snapshot Snapshot) fail := func(message string) (device.KindCapabilities, error) { return device.KindCapabilities{}, errors.New(message) } - switch engine { - case "codex": - case "claude_sdk": - if err := validateClaudeConfiguration(snapshot); err != nil { + profile, ok := acceptedEngine(engine) + if !ok || (snapshot.Environment != nil && !profile.accepts(snapshot.Environment.Type)) { + return fail("execution engine placement is not supported") + } + if profile.validateConfiguration != nil { + if err := profile.validateConfiguration(snapshot); err != nil { return device.KindCapabilities{}, err } - default: - return fail("execution engine is not supported") } info, found, known := peer.AgentKindStatus(engine) caps := info.Capabilities @@ -137,10 +95,10 @@ func engineCapabilities(peer *gateway.Session, engine string, snapshot Snapshot) if !caps.ExecutionControls { return fail("device must advertise execution_controls") } - if engine == "codex" && !caps.WebSearchControl { + if profile.webSearchControl && !caps.WebSearchControl { return fail("device must advertise web_search_control") } - if engine == "codex" && !caps.TextVerbosity { + if profile.textVerbosity && !caps.TextVerbosity { return fail("device must advertise text_verbosity") } if !caps.ToolObservations { diff --git a/services/agents-api/internal/sandbox/docker/provider.go b/services/agents-api/internal/sandbox/docker/provider.go index 4ee09945..6e96c791 100644 --- a/services/agents-api/internal/sandbox/docker/provider.go +++ b/services/agents-api/internal/sandbox/docker/provider.go @@ -27,6 +27,7 @@ const labelPrefix = "io.parsar.agents-api." type Config struct { InstallationID, Image, Network, Seccomp string ExtraHosts []string + NestedSandbox bool } type Provider struct { client *client.Client @@ -135,9 +136,20 @@ func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Inf } } limit := int64(128) + // A nested native sandbox must mount its own procfs. Keep sysfs secrets + // masked; only this qualified image profile opts out of Docker's proc masks. + var masked, readonly []string + var init *bool + if p.config.NestedSandbox { + masked = []string{"/sys/firmware", "/sys/devices/virtual/powercap"} + readonly = []string{} + enabled := true + init = &enabled + } v, e := p.client.ContainerCreate(ctx, client.ContainerCreateOptions{Name: name, Image: p.config.Image, Config: &container.Config{User: "1000:1000", WorkingDir: "/environment/workspace", Labels: p.labels(b.Reference), Env: []string{"PARSAR_RUNTIME_ENVIRONMENT_ID=" + b.EnvironmentID, "PARSAR_RUNTIME_SESSION_ID=" + b.SessionID, "PARSAR_RUNTIME_NETWORK_ACCESS=" + b.NetworkAccess}}, HostConfig: &container.HostConfig{ReadonlyRootfs: true, CapDrop: []string{"ALL"}, SecurityOpt: []string{"no-new-privileges", "seccomp=" + p.config.Seccomp, "apparmor=unconfined"}, NetworkMode: container.NetworkMode(p.config.Network), ExtraHosts: p.config.ExtraHosts, + MaskedPaths: masked, ReadonlyPaths: readonly, Init: init, Resources: container.Resources{PidsLimit: &limit, Memory: 2 * 1024 * 1024 * 1024, NanoCPUs: 2 * 1000000000}, Tmpfs: map[string]string{"/tmp": "rw,nosuid,nodev,size=128m"}, Mounts: []mount.Mount{ {Type: mount.TypeVolume, Source: name + "-home", Target: "/home"},