From 21d67b94360c9089baeb173cbbc8a69cbfa677e0 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:00:09 +0000 Subject: [PATCH 01/11] feat(runtime): add native daemon platforms and unify bypass execution --- .github/workflows/native-daemon.yml | 46 +++ CONTRIBUTING.md | 14 +- Makefile | 1 - .../internal/agent/claudesdk/executor.go | 1 - .../internal/agent/claudesdk/local.go | 12 +- .../internal/agent/claudesdk/local_test.go | 10 +- .../agent/claudesdk/mcp_environment_test.go | 7 +- .../agent/claudesdk/preparation_test.go | 2 +- .../internal/agent/claudesdk/provider.go | 2 +- .../internal/agent/claudesdk/workspace.go | 134 +++---- .../agent/claudesdk/workspace_launch_test.go | 110 +----- .../claudesdk/workspace_live_linux_test.go | 2 +- .../agent/claudesdk/workspace_test.go | 18 +- .../internal/agent/clirunner/process.go | 3 +- .../agent/clirunner/process_group_other.go | 2 +- ...nux_test.go => process_group_unix_test.go} | 22 +- .../agent/clirunner/process_group_windows.go | 168 +++++++++ .../clirunner/process_group_windows_test.go | 141 ++++++++ .../clirunner/process_observe_darwin_test.go | 23 ++ .../clirunner/process_observe_linux_test.go | 30 ++ .../agent/codex/capability_root_test.go | 13 - .../internal/agent/codex/environment.go | 6 - .../internal/agent/codex/environment_local.go | 13 +- .../internal/agent/codex/executor.go | 2 +- .../internal/agent/codex/managed_network.go | 113 ------ .../agent/codex/managed_network_live_test.go | 111 ------ .../agent/codex/managed_network_test.go | 71 ---- .../agent/codex/mcp_environment_test.go | 8 +- .../internal/agent/codex/native_hooks.go | 39 +++ .../internal/agent/codex/options.go | 1 - .../agent/codex/permission_profile.go | 50 +-- .../agent/codex/permission_profile_test.go | 106 ++---- .../internal/agent/codex/preparation.go | 12 +- .../agent/codex/preparation_router_test.go | 3 - .../internal/agent/codex/session.go | 19 +- .../internal/agent/codex/session_plan.go | 47 +-- .../agent/codex/session_policy_test.go | 13 +- .../internal/agent/codex/subagent_profile.go | 11 +- .../agent/codex/subagent_profile_test.go | 4 +- .../internal/agent/codex/tool_environment.go | 97 ------ .../agent/codex/tool_environment_test.go | 108 ------ .../internal/agent/mcode/environment_mcp.go | 2 +- .../agent/mcode/environment_mcp_test.go | 7 +- .../internal/agent/mcode/execution.go | 12 +- .../internal/agent/mcode/execution_test.go | 9 +- .../internal/agent/mcode/executor.go | 1 - .../internal/agent/mcode/preparation_test.go | 4 +- .../internal/agent/mcode/workspace.go | 52 ++- .../agent/mcode/workspace_network_test.go | 40 +-- apps/parsar-daemon/internal/auth/store.go | 23 +- .../parsar-daemon/internal/auth/store_test.go | 9 +- apps/parsar-daemon/internal/cli/claude_sdk.go | 15 +- apps/parsar-daemon/internal/cli/connect.go | 17 +- .../internal/cli/connect_environment.go | 4 +- .../cli/connect_environment_binding.go | 94 ++--- .../cli/connect_environment_binding_test.go | 21 +- .../internal/cli/connect_environment_test.go | 14 +- .../internal/cli/connect_suspend_test.go | 2 + .../internal/cli/mcode_workspace.go | 19 +- .../internal/cli/native_install.go | 140 ++++++++ .../internal/cli/native_install_test.go | 59 ++++ apps/parsar-daemon/internal/cli/root.go | 4 +- apps/parsar-daemon/internal/cli/root_test.go | 2 + .../parsar-daemon/internal/cli/runtime_mcp.go | 19 +- .../internal/cli/runtime_mcp_other.go | 5 - .../internal/cli/runtime_mcp_test.go | 8 +- ...ntime_mcp_linux.go => runtime_mcp_unix.go} | 2 +- .../internal/cli/runtime_mcp_windows.go | 18 + apps/parsar-daemon/internal/cli/stop.go | 14 +- .../internal/cli/suspend_control.go | 2 + .../internal/cli/suspend_process_other.go | 34 +- apps/parsar-daemon/internal/daemonize/fork.go | 51 ++- .../internal/daemonize/fork_test.go | 15 +- .../internal/daemonize/fork_unix.go | 13 + .../internal/daemonize/fork_windows.go | 64 ++++ .../internal/daemonize/helpers_test.go | 112 ++---- .../internal/daemonize/logfile.go | 16 +- .../internal/daemonize/logfile_test.go | 21 +- .../internal/daemonize/notify_unix.go | 16 + .../internal/daemonize/notify_windows.go | 70 ++++ .../internal/daemonize/pidfile.go | 157 ++++----- .../internal/daemonize/pidfile_test.go | 198 ++++------- .../internal/daemonize/process_darwin.go | 52 +++ .../internal/daemonize/process_linux.go | 68 ++++ .../internal/daemonize/process_windows.go | 81 +++++ .../internal/dispatch/local_directory_test.go | 35 +- .../dispatch/native_file_results_test.go | 53 +++ .../dispatch/workspace_export_test.go | 76 ++-- .../internal/dispatch/workspace_write_test.go | 78 ++--- .../internal/localworkspace/binding.go | 69 +--- .../internal/localworkspace/binding_test.go | 21 +- .../internal/localworkspace/capabilities.go | 45 +-- .../capability_preparation_test.go | 23 +- .../internal/localworkspace/directory.go | 27 +- .../localworkspace/directory_native_test.go | 62 ++-- .../internal/localworkspace/export.go | 64 +--- .../internal/localworkspace/export_test.go | 173 ++++----- .../internal/localworkspace/initialization.go | 19 +- .../internal/localworkspace/mcp.go | 10 +- .../internal/localworkspace/mcp_test.go | 7 +- .../internal/localworkspace/native_binding.go | 42 +++ .../internal/localworkspace/native_files.go | 329 ++++++++++++++++++ .../localworkspace/snapshot_marker.go | 69 +--- .../localworkspace/snapshot_marker_test.go | 40 +-- .../internal/localworkspace/write.go | 27 +- .../internal/localworkspace/write_test.go | 191 +++------- apps/parsar-daemon/internal/paths/paths.go | 11 +- .../internal/paths/paths_test.go | 17 +- docs/api/public-agent-api.md | 5 +- docs/design-principles.md | 16 + docs/getting-started/README.md | 1 + docs/getting-started/operations.md | 13 +- docs/getting-started/self-hosted.md | 209 +++++------ docs/self-hosted-native.md | 139 ++++++++ docs/web/protocol-coverage.md | 6 + internal/agentcapabilities/manifest.go | 7 +- internal/agentcapabilities/tree.go | 8 +- internal/agentdaemon/proto/environment.go | 2 + internal/runtimefs/runtimefs.go | 148 ++++++++ internal/runtimefs/runtimefs_test.go | 117 +++++++ internal/runtimefs/runtimefs_unix.go | 40 +++ internal/runtimefs/runtimefs_unix_test.go | 46 +++ internal/runtimefs/runtimefs_windows.go | 54 +++ internal/runtimefs/runtimefs_windows_test.go | 16 + packages/claude-sdk-adapter/SUBAGENTS.md | 28 +- .../claude-sdk-adapter/src/mcp_environment.ts | 27 +- .../claude-sdk-adapter/src/runtime_check.ts | 2 +- packages/claude-sdk-adapter/src/workspace.ts | 112 ++---- .../src/workspace_directories.ts | 31 +- .../tests/mcp_workspace.test.mjs | 35 +- .../tests/preparation.test.mjs | 6 +- .../tests/workspace.test.mjs | 142 ++------ .../tests/workspace_directories.test.mjs | 45 +-- .../tests/workspace_execution.test.mjs | 8 +- packages/mcode-harness/bridge.mjs | 3 +- packages/mcode-harness/build-sandbox.mjs | 18 - packages/mcode-harness/check.mjs | 8 +- packages/mcode-harness/launch.mjs | 24 +- packages/mcode-harness/launch.test.mjs | 20 ++ packages/mcode-harness/sandbox-entry.ts | 1 - packages/mcode-harness/tool-executor.mjs | 4 +- packages/mcode-harness/worker.ts | 16 - scripts/build-agents-runtime.sh | 2 - scripts/build-claude-runtime.sh | 1 - scripts/build-mcode-harness.sh | 2 - scripts/core-distribution-manifest.py | 5 - services/agents-api/deploy/claude/Dockerfile | 1 - .../agents-api/deploy/claude/shell-prefix.py | 29 -- .../deploy/claude/shell_prefix_test.py | 33 -- services/agents-api/deploy/codex/Dockerfile | 7 +- .../agents-api/deploy/codex/requirements.toml | 42 --- services/agents-api/deploy/codex/tool-env.py | 29 -- .../agents-api/deploy/runtime/initialize.py | 229 +++--------- .../deploy/runtime/initialize_receipt_test.py | 219 +++++------- .../deploy/runtime/initialize_stdio_test.py | 150 -------- .../deploy/runtime/initialize_test.py | 56 +-- .../agents-api/internal/execution/support.go | 3 - 157 files changed, 3389 insertions(+), 3445 deletions(-) create mode 100644 .github/workflows/native-daemon.yml rename apps/parsar-daemon/internal/agent/clirunner/{process_group_linux_test.go => process_group_unix_test.go} (91%) create mode 100644 apps/parsar-daemon/internal/agent/clirunner/process_group_windows.go create mode 100644 apps/parsar-daemon/internal/agent/clirunner/process_group_windows_test.go create mode 100644 apps/parsar-daemon/internal/agent/clirunner/process_observe_darwin_test.go create mode 100644 apps/parsar-daemon/internal/agent/clirunner/process_observe_linux_test.go delete mode 100644 apps/parsar-daemon/internal/agent/codex/capability_root_test.go delete mode 100644 apps/parsar-daemon/internal/agent/codex/managed_network.go delete mode 100644 apps/parsar-daemon/internal/agent/codex/managed_network_live_test.go delete mode 100644 apps/parsar-daemon/internal/agent/codex/managed_network_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/native_hooks.go delete mode 100644 apps/parsar-daemon/internal/agent/codex/tool_environment.go delete mode 100644 apps/parsar-daemon/internal/agent/codex/tool_environment_test.go create mode 100644 apps/parsar-daemon/internal/cli/native_install.go create mode 100644 apps/parsar-daemon/internal/cli/native_install_test.go delete mode 100644 apps/parsar-daemon/internal/cli/runtime_mcp_other.go rename apps/parsar-daemon/internal/cli/{runtime_mcp_linux.go => runtime_mcp_unix.go} (96%) create mode 100644 apps/parsar-daemon/internal/cli/runtime_mcp_windows.go create mode 100644 apps/parsar-daemon/internal/daemonize/fork_unix.go create mode 100644 apps/parsar-daemon/internal/daemonize/fork_windows.go create mode 100644 apps/parsar-daemon/internal/daemonize/notify_unix.go create mode 100644 apps/parsar-daemon/internal/daemonize/notify_windows.go create mode 100644 apps/parsar-daemon/internal/daemonize/process_darwin.go create mode 100644 apps/parsar-daemon/internal/daemonize/process_linux.go create mode 100644 apps/parsar-daemon/internal/daemonize/process_windows.go create mode 100644 apps/parsar-daemon/internal/dispatch/native_file_results_test.go create mode 100644 apps/parsar-daemon/internal/localworkspace/native_binding.go create mode 100644 apps/parsar-daemon/internal/localworkspace/native_files.go create mode 100644 docs/self-hosted-native.md create mode 100644 internal/runtimefs/runtimefs.go create mode 100644 internal/runtimefs/runtimefs_test.go create mode 100644 internal/runtimefs/runtimefs_unix.go create mode 100644 internal/runtimefs/runtimefs_unix_test.go create mode 100644 internal/runtimefs/runtimefs_windows.go create mode 100644 internal/runtimefs/runtimefs_windows_test.go delete mode 100644 packages/mcode-harness/build-sandbox.mjs create mode 100644 packages/mcode-harness/launch.test.mjs delete mode 100644 packages/mcode-harness/sandbox-entry.ts delete mode 100644 services/agents-api/deploy/claude/shell-prefix.py delete mode 100644 services/agents-api/deploy/claude/shell_prefix_test.py delete mode 100644 services/agents-api/deploy/codex/requirements.toml delete mode 100644 services/agents-api/deploy/codex/tool-env.py delete mode 100644 services/agents-api/deploy/runtime/initialize_stdio_test.py diff --git a/.github/workflows/native-daemon.yml b/.github/workflows/native-daemon.yml new file mode 100644 index 000000000..1c93ddd2b --- /dev/null +++ b/.github/workflows/native-daemon.yml @@ -0,0 +1,46 @@ +name: native-daemon + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + native: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-14, windows-2022] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - name: Build the native daemon + run: go build -o "$RUNNER_TEMP/oac-daemon${{ runner.os == 'Windows' && '.exe' || '' }}" ./apps/parsar-daemon/cmd/parsar-daemon + - name: Native filesystem, authentication and process lifecycle + run: >- + go test -race -count=1 + ./internal/runtimefs + ./apps/parsar-daemon/internal/auth + ./apps/parsar-daemon/internal/paths + ./apps/parsar-daemon/internal/daemonize + ./apps/parsar-daemon/internal/agent/clirunner + - name: Native capability snapshots, files and installation + run: >- + go test -race -count=1 + ./apps/parsar-daemon/internal/localworkspace + ./apps/parsar-daemon/internal/cli + -run 'TestNative|TestPreparationFreezesLocalContentsAcrossReconnect|TestSnapshotMarker' + - uses: actions/upload-artifact@v6 + with: + name: oac-daemon-${{ runner.os }}-${{ runner.arch }} + path: ${{ runner.temp }}/oac-daemon* + if-no-files-found: error diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3bcc4d162..36658674e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,9 +34,17 @@ local directories. Core resolves configuration, versions and resources; Runtime uses one parser and `installed.json` snapshot to supply Skill paths and MCP declarations to adapters. Preparation must complete before execution; reconnect reuses installed contents, and new Sessions freeze a new -configuration snapshot. The protocol is platform-neutral; the implementation uses the existing packaged -Linux Runtime layout for both environment origins. Windows/macOS installation and -isolation remain separate work. +configuration snapshot. The protocol is platform-neutral. Self-hosted daemons run natively on Linux, +Windows and macOS; managed Providers remain Linux-only. The daemon executes with +its launching user's permissions. It does not sandbox tools, files or networks, +including on Linux. Managed isolation belongs to the outer Docker/E2B Environment, +created by Core through a Provider. Harness adapters use bypass execution on all +platforms. Runtime authentication, process cleanup and state consistency remain +required; none constitutes isolation from tools running as the same user. +Installation layout may differ, but Core preparation and execution cannot branch +on operating system or Environment source. Windows acceptance requires native CI +builds and automated tests; cross-compilation alone is insufficient. Record the +absence of manual Windows acceptance until an actual machine is tested. - Keep component boundaries explicit through shared interfaces and versioned protocols. Register implementations behind those interfaces. Adding an diff --git a/Makefile b/Makefile index d72d18bdd..0fc278baf 100644 --- a/Makefile +++ b/Makefile @@ -73,7 +73,6 @@ node-deps: pnpm install --frozen-lockfile check-claude-sdk: node-deps - python3 services/agents-api/deploy/claude/shell_prefix_test.py pnpm --filter @parsar/claude-sdk-adapter test $(MAKE) build-claude-sdk-runtime diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor.go b/apps/parsar-daemon/internal/agent/claudesdk/executor.go index 0abc0ca47..beef6fb3e 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/executor.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/executor.go @@ -29,7 +29,6 @@ func NewExecutorFactory(config Config) agent.ExecutorFactory { config.Env = slices.Clone(config.Env) if config.Workspace != nil { workspace := *config.Workspace - workspace.ProtectedDirs = slices.Clone(workspace.ProtectedDirs) config.Workspace = &workspace } checked := &runtimeCheckCache{} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/local.go b/apps/parsar-daemon/internal/agent/claudesdk/local.go index a4e8d1888..1a0bb2ab8 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/local.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/local.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" ) @@ -14,11 +15,12 @@ import ( func ConfigureLocal(config Config, root, workspace string, network agentnetwork.Policy, staging string) (Config, error) { config.StateDir = filepath.Join(root, "runtime", "claude-sdk", "history") config.Workspace = &WorkspaceConfig{ - Directory: workspace, PublicDirectory: "/workspace", NetworkAccess: network.Access, AllowedDomains: network.Hosts(), - HomeDir: filepath.Join(root, "runtime", "claude-sdk", "home"), - ScratchDir: filepath.Join(root, "runtime", "claude-sdk", "scratch"), - ProtectedDirs: []string{filepath.Join(root, "daemon"), staging}, - DependencyPath: "/usr/local/bin:/usr/bin:/bin", + Directory: workspace, PublicDirectory: workspace, NetworkAccess: network.Access, AllowedDomains: network.Hosts(), + HomeDir: filepath.Join(root, "runtime", "claude-sdk", "home"), + ScratchDir: filepath.Join(root, "runtime", "claude-sdk", "scratch"), + } + if binding, err := localworkspace.Load(); err == nil && binding != nil { + config.Workspace.ExecutionMode = binding.ExecutionMode() } if network.Validate() != nil { return Config{}, fmt.Errorf("claudesdk: dedicated Runtime requires an explicit network policy") diff --git a/apps/parsar-daemon/internal/agent/claudesdk/local_test.go b/apps/parsar-daemon/internal/agent/claudesdk/local_test.go index 51c7ca7b0..819fdac36 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/local_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/local_test.go @@ -36,9 +36,10 @@ func TestLocalWorkspaceBindingNetworkAndRequiredHistory(t *testing.T) { t.Fatal(err) } config.Workspace.PublicDirectory = alias - if _, _, err := prepare(config, req); err == nil { - t.Fatal("accepted mutable workspace alias") + if _, _, err := prepare(config, req); err != nil { + t.Fatal("same workspace alias rejected", err) } + } func TestRestrictedWorkspacePolicyUsesExactBoundAuthority(t *testing.T) { @@ -47,9 +48,8 @@ func TestRestrictedWorkspacePolicyUsesExactBoundAuthority(t *testing.T) { config.Workspace.AllowedDomains = []string{"Example.com", "api.example.com", "example.com"} req := workspaceRequest() req.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "restricted", AllowedDomains: []string{"api.example.com", "EXAMPLE.COM"}} - start, _, err := prepare(config, req) - if err != nil || !slices.Equal(start.Workspace.AllowedDomains, []string{"api.example.com", "example.com"}) { - t.Fatal("native policy lost exact bound domains", start, err) + if _, _, err := prepare(config, req); err == nil { + t.Fatal("Runtime must not promise inner network isolation") } for _, domains := range [][]string{{"example.com"}, {"example.org"}, nil} { req.LocalEnvironment.AllowedDomains = domains diff --git a/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment_test.go b/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment_test.go index e3310414b..6cd293b29 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment_test.go @@ -2,6 +2,7 @@ package claudesdk import ( "encoding/json" + "os" "strings" "testing" @@ -27,7 +28,8 @@ func TestEnvironmentMCPUsesInstalledLauncherAndSelectedCredential(t *testing.T) t.Fatal("environment declarations changed authority") } stdio := start.Workspace.MCP[0] - if stdio.Command != "/usr/bin/python3" || len(stdio.Args) != 8 || stdio.Args[4] != "/private/runtime/capabilities" || stdio.Args[5] != "/private/runtime/workspace" || stdio.Args[6] != "plugins/local" || stdio.Args[7] != "local" { + executable, _ := os.Executable() + if stdio.Command != executable || len(stdio.Args) != 4 || stdio.Args[0] != "runtime-mcp-exec" || stdio.Args[1] != "/private/runtime/capabilities" || stdio.Args[2] != "plugins/local" || stdio.Args[3] != "local" { t.Fatal("stdio bypassed the shared installed entry") } raw, _ := json.Marshal(start) @@ -42,9 +44,6 @@ func TestEnvironmentMCPUsesInstalledLauncherAndSelectedCredential(t *testing.T) if entry == reference+"="+token { found = true } - if strings.Contains(entry, "unselected-native-token") { - t.Fatal("inherited native credential") - } } if !found || !strings.HasPrefix(reference, "OAC_RUNTIME_MCP_BEARER_") || len(start.declaredMCP()) != 2 { t.Fatal("selected credential or observation declarations missing") diff --git a/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go b/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go index a5e1406b6..4ca35e982 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go @@ -68,7 +68,7 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { } config.Env[0] = "ANTHROPIC_AUTH_TOKEN=changed" config.Workspace.Directory = "/changed" - config.Workspace.ProtectedDirs[0] = "/changed" + config.Workspace.Directory = "/changed" req.AgentOptions["model"] = "changed" req.AgentSessionID = "changed" out := make(chan proto.Envelope, 16) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/provider.go b/apps/parsar-daemon/internal/agent/claudesdk/provider.go index 45f6b3621..308d45bca 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/provider.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/provider.go @@ -7,7 +7,7 @@ import ( ) // Provider options are transient operator input, never public Agent fields or -// native tool environment. The workspace sandbox removes these variables. +// native process environment. Isolation is the outer environment's responsibility. func providerEnvironment(value any) ([]string, error) { fail := func() ([]string, error) { return nil, fmt.Errorf("claudesdk: invalid provider configuration") } options, ok := value.(map[string]any) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go index 42356a754..195581069 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go @@ -7,7 +7,6 @@ import ( "strings" "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/agentcapabilities" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" @@ -16,37 +15,34 @@ 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. -// State directories must already exist, be canonical and be mutually disjoint. +// State directories must already exist. // PublicDirectory may name a second mount of the same workspace inode. type WorkspaceConfig struct { + ExecutionMode string Directory string PublicDirectory string NetworkAccess string AllowedDomains []string HomeDir string ScratchDir string - ProtectedDirs []string - DependencyPath string } type workspaceProfile struct { - CapabilityRoot string `json:"capability_root,omitempty"` - MCP []environmentMCPServer `json:"mcp,omitempty"` - Skills []agentcapabilities.InstalledSkill `json:"skills,omitempty"` - ToolEnvironment bool `json:"tool_environment,omitempty"` - SystemPackages bool `json:"system_packages,omitempty"` - Home string `json:"home"` - State string `json:"state"` - Scratch string `json:"scratch"` - ProtectedDirs []string `json:"protected_dirs"` - DependencyPath string `json:"dependency_path"` - EnvNames []string `json:"env_names"` - NetworkAccess string `json:"network_access,omitempty"` - AllowedDomains []string `json:"allowed_domains,omitempty"` + ToolEnv map[string]string `json:"tool_env,omitempty"` + ExecutionMode string `json:"execution_mode,omitempty"` + CapabilityRoot string `json:"capability_root,omitempty"` + MCP []environmentMCPServer `json:"mcp,omitempty"` + Skills []agentcapabilities.InstalledSkill `json:"skills,omitempty"` + Home string `json:"home"` + State string `json:"state"` + Scratch string `json:"scratch"` + EnvNames []string `json:"env_names"` + NetworkAccess string `json:"network_access,omitempty"` + AllowedDomains []string `json:"allowed_domains,omitempty"` } func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspaceProfile, []string, error) { - if req.DisableExecutionEnvironment || req.MCPHTTPServers != nil { + if req.DisableExecutionEnvironment || req.MCPHTTPServers != nil || (req.LocalEnvironment != nil && req.LocalEnvironment.ExecutionMode != config.Workspace.ExecutionMode) { return nil, nil, fmt.Errorf("claudesdk: workspace profile does not support the requested execution combination") } if req.WorkDir != "" && req.WorkDir != config.Workspace.Directory { @@ -60,12 +56,16 @@ func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspace return nil, nil, err } if req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment { - if err := localworkspace.VerifyToolEnvironment(req.LocalEnvironment.SystemPackages); err != nil { + binding, loadErr := localworkspace.Load() + if loadErr != nil || binding == nil { + return nil, nil, fmt.Errorf("claudesdk: native Runtime binding unavailable") + } + profile.ToolEnv, err = binding.ReadToolEnvironment() + if err != nil { return nil, nil, err } - profile.ToolEnvironment = true - profile.SystemPackages = req.LocalEnvironment.SystemPackages } + if req.LocalEnvironment != nil { profile.Skills = req.LocalEnvironment.Skills profile.CapabilityRoot = req.LocalEnvironment.CapabilityRoot @@ -92,83 +92,36 @@ func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { return nil, nil, fmt.Errorf("claudesdk: invalid trusted workspace configuration") } w := config.Workspace - if w == nil || !filepath.IsAbs(config.Node) || !filepath.IsAbs(config.Entrypoint) { - return fail() - } - if (w.NetworkAccess != "" || len(w.AllowedDomains) > 0) && (agentnetwork.Policy{Access: w.NetworkAccess, AllowedDomains: w.AllowedDomains}).Validate() != nil { + if w == nil || (w.ExecutionMode != "" && w.ExecutionMode != "native") || (w.NetworkAccess != "" && w.NetworkAccess != "enabled") || len(w.AllowedDomains) != 0 { 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) { + for _, path := range []string{config.Node, config.Entrypoint, filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js")} { + info, err := os.Stat(path) + if !filepath.IsAbs(path) || err != nil || !info.Mode().IsRegular() { return fail() } } - runtimeDir := filepath.Dir(filepath.Dir(config.Entrypoint)) - if config.Entrypoint != filepath.Join(runtimeDir, "dist", "main.js") || !canonicalWorkspaceDir(runtimeDir) { - return fail() - } - // Keep the exact paths used by execution and readiness outside mutable roots. - // A symlinked entrypoint must not select an unchecked sibling companion. - codePaths := []string{config.Node, config.Entrypoint, filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js")} - for _, path := range codePaths { - resolved, err := filepath.EvalSymlinks(path) - info, statErr := os.Stat(path) - if err != nil || resolved != path || statErr != nil || !info.Mode().IsRegular() { + for _, path := range []string{workspaceCwd(w), config.StateDir, w.HomeDir, w.ScratchDir} { + if !canonicalWorkspaceDir(path) { return fail() } } - 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) { + if w.PublicDirectory != "" { + actual, err := os.Stat(w.Directory) + alias, aliasErr := os.Stat(w.PublicDirectory) + if err != nil || aliasErr != nil || !os.SameFile(actual, alias) { return fail() } - for _, previous := range roots[:i] { - if pathContains(previous, dir) || pathContains(dir, previous) { - return fail() - } - } - for _, executable := range codePaths { - if pathContains(dir, executable) { - return fail() - } - } - } - managed, err := paths.Root() - if err != nil { - return fail() - } - managed, err = filepath.EvalSymlinks(managed) - if err != nil || managed == config.StateDir || !pathContains(managed, config.StateDir) { - return fail() } - if w.DependencyPath == "" { - return fail() - } - var dependencies []string - for _, dir := range filepath.SplitList(w.DependencyPath) { - info, err := os.Stat(dir) - if !workspacePathSyntax(dir) || err != nil || !info.IsDir() { - return fail() - } - resolved, err := filepath.EvalSymlinks(dir) - if err != nil { - return fail() + profile := &workspaceProfile{ExecutionMode: w.ExecutionMode, Home: w.HomeDir, State: config.StateDir, Scratch: w.ScratchDir, EnvNames: []string{}, NetworkAccess: w.NetworkAccess, AllowedDomains: []string{}} + env := []string{} + for _, entry := range os.Environ() { + name, _, _ := strings.Cut(entry, "=") + if name != "ANTHROPIC_API_KEY" && name != "ANTHROPIC_AUTH_TOKEN" && name != "CLAUDE_CODE_OAUTH_TOKEN" && name != "CLAUDE_CONFIG_DIR" && name != "HOME" && name != "TMPDIR" { + env = append(env, entry) } - for _, root := range roots { - if pathContains(root, dir) || pathContains(dir, root) || pathContains(root, resolved) || pathContains(resolved, root) { - return fail() - } - } - dependencies = append(dependencies, resolved) - } - 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{}, NetworkAccess: w.NetworkAccess, AllowedDomains: (agentnetwork.Policy{Access: w.NetworkAccess, AllowedDomains: w.AllowedDomains}).Hosts()} - 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"} + } + 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") seen := map[string]bool{} for _, entry := range config.Env { name, _, ok := strings.Cut(entry, "=") @@ -197,19 +150,12 @@ func canonicalWorkspaceDir(dir string) bool { if !workspacePathSyntax(dir) { return false } - resolved, err := filepath.EvalSymlinks(dir) - if err != nil || resolved != dir { - return false - } info, err := os.Stat(dir) return err == nil && info.IsDir() } - func workspacePathSyntax(dir string) bool { - return filepath.IsAbs(dir) && filepath.Clean(dir) == dir && dir != string(filepath.Separator) && - !strings.ContainsAny(dir, "*?[]{}():\\") && strings.IndexFunc(dir, func(r rune) bool { return r < 32 || r == 127 }) == -1 + return filepath.IsAbs(dir) && filepath.Clean(dir) == dir && strings.IndexFunc(dir, func(r rune) bool { return r < 32 || r == 127 }) == -1 } - func pathContains(parent, child string) bool { rel, err := filepath.Rel(parent, child) return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go index 5a3c4ad98..d24d0211c 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go @@ -11,14 +11,14 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) -func TestWorkspaceLaunchAndReadinessExcludeParentEnvironment(t *testing.T) { +func TestWorkspaceLaunchAndReadinessInheritsUserEnvironment(t *testing.T) { config := workspaceFixture(t) t.Setenv("OAC_TEST_PARENT_SECRET", "must-not-inherit") t.Setenv("ANTHROPIC_API_KEY", "unselected") // An owned process fixture verifies both real subprocess launch paths; it is // not a native sandbox or provider acceptance test. script := `#!/bin/sh -test -z "${OAC_TEST_PARENT_SECRET+x}" || exit 21 +test "$OAC_TEST_PARENT_SECRET" = must-not-inherit || exit 21 test -z "${ANTHROPIC_API_KEY+x}" || exit 22 test "$ANTHROPIC_AUTH_TOKEN" = selected-provider-fixture || exit 23 test "$TMPDIR" != "$CLAUDE_CONFIG_DIR/tmp" || exit 24 @@ -71,109 +71,3 @@ esac t.Fatal("old packaged bridge started execution") } } - -func TestWorkspaceRejectsMutableRuntimeAliasesBeforeReadiness(t *testing.T) { - for _, name := range []string{"node", "entrypoint", "scratch-entrypoint", "companion", "dependencies"} { - t.Run(name, func(t *testing.T) { - config := workspaceFixture(t) - marker := filepath.Join(config.Workspace.Directory, "unexpected-launch") - if err := os.WriteFile(config.Node, []byte("#!/bin/sh\nprintf started > '"+marker+"'\n"), 0o700); err != nil { - t.Fatal(err) - } - link, target := "", "" - switch name { - case "node": - link, target = filepath.Join(config.Workspace.Directory, "node"), config.Node - config.Node = link - case "entrypoint", "scratch-entrypoint": - dir := config.Workspace.Directory - if name == "scratch-entrypoint" { - dir = config.Workspace.ScratchDir - } - link, target = filepath.Join(dir, "main.js"), config.Entrypoint - config.Entrypoint = link - if err := os.WriteFile(filepath.Join(dir, "runtime_check.js"), []byte("untrusted companion"), 0o700); err != nil { - t.Fatal(err) - } - case "companion": - link, target = filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js"), filepath.Join(config.Workspace.Directory, "companion.js") - if err := os.Remove(link); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(target, []byte("untrusted companion"), 0o700); err != nil { - t.Fatal(err) - } - case "dependencies": - link, target = filepath.Join(config.Workspace.Directory, "deps"), config.Workspace.DependencyPath - config.Workspace.DependencyPath = link - } - if err := os.Symlink(target, link); err != nil { - t.Fatal(err) - } - if _, err := CheckRuntime(t.Context(), config); err == nil { - t.Fatal("mutable runtime alias passed readiness") - } - if _, err := os.Stat(marker); !os.IsNotExist(err) { - t.Fatal("invalid binding launched a credential-bearing child") - } - }) - } -} - -func TestWorkspaceUsesCanonicalDependencyPaths(t *testing.T) { - config := workspaceFixture(t) - canonical := config.Workspace.DependencyPath - alias := filepath.Join(filepath.Dir(config.StateDir), "dependency-alias") - if err := os.Symlink(canonical, alias); err != nil { - t.Fatal(err) - } - config.Workspace.DependencyPath = alias - start, env, err := prepare(config, workspaceRequest()) - if err != nil { - t.Fatal(err) - } - if start.Workspace.DependencyPath != canonical || env[0] != "PATH="+canonical { - t.Fatal("trusted launch retained a mutable dependency alias") - } -} - -func TestWorkspaceRejectsPackagedRuntimeOverlapBeforeReadiness(t *testing.T) { - for _, name := range []string{"workspace", "state", "home", "scratch", "protected", "runtime-root", "runtime-parent", "unexpected-layout"} { - t.Run(name, func(t *testing.T) { - config := workspaceFixture(t) - marker := filepath.Join(filepath.Dir(config.StateDir), "unexpected-launch") - if err := os.WriteFile(config.Node, []byte("#!/bin/sh\nprintf started > '"+marker+"'\n"), 0o700); err != nil { - t.Fatal(err) - } - runtimeDir := filepath.Dir(filepath.Dir(config.Entrypoint)) - dependencies := filepath.Join(runtimeDir, "node_modules") - switch name { - case "workspace": - config.Workspace.Directory = dependencies - case "state": - config.StateDir = dependencies - case "home": - config.Workspace.HomeDir = dependencies - case "scratch": - config.Workspace.ScratchDir = dependencies - case "protected": - config.Workspace.ProtectedDirs = []string{dependencies} - case "runtime-root": - config.Workspace.Directory = runtimeDir - case "runtime-parent": - config.Workspace.Directory = filepath.Dir(runtimeDir) - case "unexpected-layout": - config.Entrypoint = filepath.Join(filepath.Dir(config.Entrypoint), "other.js") - if err := os.WriteFile(config.Entrypoint, nil, 0o700); err != nil { - t.Fatal(err) - } - } - if _, err := CheckRuntime(t.Context(), config); err == nil { - t.Fatal("invalid runtime binding passed readiness") - } - if _, err := os.Stat(marker); !os.IsNotExist(err) { - t.Fatal("invalid runtime binding launched a credential-bearing child") - } - }) - } -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_live_linux_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_live_linux_test.go index c2a27cc19..c55a0211e 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_live_linux_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_live_linux_test.go @@ -57,7 +57,7 @@ func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { } config := Config{Node: placement.Node, Entrypoint: placement.Entrypoint, StateDir: filepath.Join(root, "state"), Workspace: &WorkspaceConfig{Directory: filepath.Join(root, "workspace"), HomeDir: filepath.Join(root, "home"), - ScratchDir: scratch, ProtectedDirs: []string{filepath.Dir(placement.KeyFile)}, DependencyPath: placement.DependencyPath}, + ScratchDir: scratch}, Env: []string{"ANTHROPIC_BASE_URL=https://api.minimax.cn/anthropic", "ANTHROPIC_API_KEY=", "ANTHROPIC_AUTH_TOKEN=" + strings.TrimSpace(string(key)), "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1", "ANTHROPIC_DEFAULT_SONNET_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_OPUS_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_HAIKU_MODEL=MiniMax-M3"}} if placement.Proxy != "" { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go index ef39f675f..04e2fcfa2 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go @@ -25,7 +25,7 @@ func workspaceFixture(t *testing.T) Config { config := Config{Node: filepath.Join(root, "bin", "node"), Entrypoint: filepath.Join(root, "runtime", "dist", "main.js"), StateDir: filepath.Join(root, "state"), Env: []string{"ANTHROPIC_AUTH_TOKEN=selected-provider-fixture", "ANTHROPIC_BASE_URL=https://example.invalid"}, Workspace: &WorkspaceConfig{Directory: filepath.Join(root, "workspace"), HomeDir: filepath.Join(root, "home"), - ScratchDir: filepath.Join(root, "scratch"), ProtectedDirs: []string{filepath.Join(root, "secrets")}, DependencyPath: filepath.Join(root, "bin")}} + ScratchDir: filepath.Join(root, "scratch")}} for _, name := range []string{config.Node, config.Entrypoint, filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js")} { if err := os.WriteFile(name, nil, 0o700); err != nil { t.Fatal(err) @@ -59,8 +59,8 @@ func TestWorkspaceTrustedBindingAndEnvironment(t *testing.T) { name, value, _ := strings.Cut(item, "=") values[name] = value } - if _, ok := values["OAC_TEST_PARENT_SECRET"]; ok { - t.Fatal("inherited parent credential") + if values["OAC_TEST_PARENT_SECRET"] != "parent-only" { + t.Fatal("lost user environment") } if _, ok := values["ANTHROPIC_API_KEY"]; ok { t.Fatal("inherited unselected provider credential") @@ -76,7 +76,7 @@ func TestWorkspaceTrustedBindingAndEnvironment(t *testing.T) { } func TestWorkspaceRejectsConflictsBeforeSideEffects(t *testing.T) { - for _, name := range []string{"none", "work-dir", "mcp", "caller-policy", "relative", "missing", "overlap", "symlink", "rule-pattern", "ambient-setting", "duplicate-env", "bad-env", "path-empty-component", "path-workspace", "code-in-workspace"} { + for _, name := range []string{"none", "work-dir", "mcp", "caller-policy", "relative", "missing", "ambient-setting", "duplicate-env", "bad-env"} { t.Run(name, func(t *testing.T) { config := workspaceFixture(t) req := workspaceRequest() @@ -112,15 +112,7 @@ func TestWorkspaceRejectsConflictsBeforeSideEffects(t *testing.T) { config.Env = append(config.Env, "ANTHROPIC_AUTH_TOKEN=second") case "bad-env": config.Env = append(config.Env, "ANTHROPIC_API_KEY=bad\x00value") - case "path-empty-component": - config.Workspace.DependencyPath += ":" - case "path-workspace": - config.Workspace.DependencyPath = config.Workspace.Directory - case "code-in-workspace": - config.Node = filepath.Join(config.Workspace.Directory, "node") - if err := os.WriteFile(config.Node, nil, 0o700); err != nil { - t.Fatal(err) - } + } if _, _, err := prepare(config, req); err == nil { t.Fatal("invalid binding or request accepted") diff --git a/apps/parsar-daemon/internal/agent/clirunner/process.go b/apps/parsar-daemon/internal/agent/clirunner/process.go index 384c2ae20..e693df7f5 100644 --- a/apps/parsar-daemon/internal/agent/clirunner/process.go +++ b/apps/parsar-daemon/internal/agent/clirunner/process.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os/exec" + "runtime" "sync" "syscall" "time" @@ -50,7 +51,7 @@ func Start(opts StartOptions) (*Process, error) { opts.KillTimeout = 3 * time.Second } - if opts.OwnProcessGroup { + if opts.OwnProcessGroup || runtime.GOOS == "windows" { return startProcessGroup(opts) } diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_group_other.go b/apps/parsar-daemon/internal/agent/clirunner/process_group_other.go index 8160c61a4..d5763742b 100644 --- a/apps/parsar-daemon/internal/agent/clirunner/process_group_other.go +++ b/apps/parsar-daemon/internal/agent/clirunner/process_group_other.go @@ -1,4 +1,4 @@ -//go:build !unix +//go:build !unix && !windows package clirunner diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_group_linux_test.go b/apps/parsar-daemon/internal/agent/clirunner/process_group_unix_test.go similarity index 91% rename from apps/parsar-daemon/internal/agent/clirunner/process_group_linux_test.go rename to apps/parsar-daemon/internal/agent/clirunner/process_group_unix_test.go index d1e246aba..8a3cbf162 100644 --- a/apps/parsar-daemon/internal/agent/clirunner/process_group_linux_test.go +++ b/apps/parsar-daemon/internal/agent/clirunner/process_group_unix_test.go @@ -1,4 +1,4 @@ -//go:build linux +//go:build linux || darwin package clirunner @@ -12,7 +12,6 @@ import ( "os/signal" "path/filepath" "strconv" - "strings" "syscall" "testing" "time" @@ -163,25 +162,6 @@ func waitChild(t *testing.T, dir string) int { return 0 } -func waitExited(t *testing.T, pid int) { - t.Helper() - deadline := time.Now().Add(3 * time.Second) - for time.Now().Before(deadline) { - value, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) - if os.IsNotExist(err) { - return - } - if err == nil { - fields := strings.Fields(string(value)[strings.LastIndex(string(value), ")")+1:]) - if len(fields) > 0 && fields[0] == "Z" { - return - } - } - time.Sleep(5 * time.Millisecond) - } - t.Fatalf("child %d is still running", pid) -} - func TestOwnedGroupHelper(t *testing.T) { if os.Getenv("GO_WANT_OWNED_GROUP") != "1" { return diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_group_windows.go b/apps/parsar-daemon/internal/agent/clirunner/process_group_windows.go new file mode 100644 index 000000000..796079db9 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/clirunner/process_group_windows.go @@ -0,0 +1,168 @@ +//go:build windows + +package clirunner + +import ( + "context" + "fmt" + "os" + "os/exec" + "sync" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +// The child cannot execute user code until it belongs to the non-inheritable Job. +// Closing the daemon's last Job handle also kills children after a daemon crash. +func startProcessGroup(opts StartOptions) (*Process, error) { + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + return nil, err + } + limits := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{} + limits.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if _, err = windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits))); err != nil { + windows.CloseHandle(job) + return nil, err + } + ctx, cancel := context.WithCancel(opts.Parent) + cmd := exec.CommandContext(ctx, opts.Binary, opts.Args...) + cmd.Dir = opts.Dir + if len(opts.Env) > 0 { + cmd.Env = append([]string{}, opts.Env...) + } + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.CREATE_SUSPENDED | windows.CREATE_NEW_PROCESS_GROUP} + group := &ownedJob{handle: job} + cmd.Cancel = group.cancel + p := &Process{Cmd: cmd, ctx: ctx, cancel: cancel, done: make(chan struct{}), cancelProcess: group.cancel} + stdout, stdoutWriter, err := os.Pipe() + if err != nil { + cancel() + windows.CloseHandle(job) + return nil, err + } + stderr, stderrWriter, err := os.Pipe() + if err != nil { + cancel() + windows.CloseHandle(job) + stdout.Close() + stdoutWriter.Close() + return nil, err + } + p.Stdout, p.Stderr = stdout, stderr + cmd.Stdout, cmd.Stderr = stdoutWriter, stderrWriter + if opts.NeedStdin { + p.Stdin, err = cmd.StdinPipe() + } + if err == nil { + err = cmd.Start() + } + stdoutWriter.Close() + stderrWriter.Close() + if err == nil { + group.mu.Lock() + if group.cancelled { + err = context.Canceled + } + process, openErr := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE|windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(cmd.Process.Pid)) + if openErr != nil { + err = openErr + } + if err == nil { + err = windows.AssignProcessToJobObject(job, process) + } + if openErr == nil { + windows.CloseHandle(process) + } + if err == nil { + err = resumeInitialThread(uint32(cmd.Process.Pid)) + } + group.mu.Unlock() + } + if err != nil { + if cmd.Process != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + } + cancel() + _ = group.cancel() + group.finish() + closePipe(p.Stdin) + stdout.Close() + stderr.Close() + return nil, fmt.Errorf("clirunner: owned Windows process: %w", err) + } + var waitErr error + p.waitProcess = func() error { <-p.done; stdout.Close(); stderr.Close(); return waitErr } + go func() { + waitErr = cmd.Wait() + // A successful leader exit cannot leave detached descendants behind. + _ = group.cancel() + group.finish() + close(p.done) + }() + return p, nil +} + +func resumeInitialThread(pid uint32) error { + snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0) + if err != nil { + return err + } + defer windows.CloseHandle(snapshot) + entry := windows.ThreadEntry32{Size: uint32(unsafe.Sizeof(windows.ThreadEntry32{}))} + for err = windows.Thread32First(snapshot, &entry); err == nil; err = windows.Thread32Next(snapshot, &entry) { + if entry.OwnerProcessID != pid { + continue + } + thread, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, entry.ThreadID) + if err != nil { + return err + } + _, err = windows.ResumeThread(thread) + windows.CloseHandle(thread) + return err + } + return fmt.Errorf("clirunner: suspended process thread unavailable: %w", err) +} + +type ownedJob struct { + mu sync.Mutex + handle windows.Handle + finished bool + cancelled bool +} + +func (g *ownedJob) cancel() error { + g.mu.Lock() + defer g.mu.Unlock() + if g.finished { + return os.ErrProcessDone + } + g.cancelled = true + return windows.TerminateJobObject(g.handle, 1) +} + +// Querying the Job, rather than waiting only for its leader, confirms cleanup. +// An observation failure retains ownership and blocks reconnect. +func (g *ownedJob) finish() { + type accounting struct { + UserTime, KernelTime, PeriodUserTime, PeriodKernelTime int64 + PageFaults, TotalProcesses, ActiveProcesses, TerminatedProcesses uint32 + } + for { + var info accounting + err := windows.QueryInformationJobObject(g.handle, windows.JobObjectBasicAccountingInformation, uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)), nil) + if err == nil && info.ActiveProcesses == 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + g.mu.Lock() + defer g.mu.Unlock() + g.finished = true + windows.CloseHandle(g.handle) +} diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_group_windows_test.go b/apps/parsar-daemon/internal/agent/clirunner/process_group_windows_test.go new file mode 100644 index 000000000..b2d442b54 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/clirunner/process_group_windows_test.go @@ -0,0 +1,141 @@ +//go:build windows + +package clirunner + +import ( + "context" + "fmt" + "golang.org/x/sys/windows" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + "time" +) + +func TestWindowsOwnedTree(t *testing.T) { + for _, mode := range []string{"cancel", "parent-cancel", "leader-exit"} { + t.Run(mode, func(t *testing.T) { + dir := t.TempDir() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + p, err := Start(StartOptions{Parent: ctx, Binary: os.Args[0], Args: []string{"-test.run=^TestWindowsTreeHelper$", "--", "leader", mode, dir}, Env: append(os.Environ(), "OAC_TREE_HELPER=1"), OwnProcessGroup: true}) + if err != nil { + t.Fatal(err) + } + defer func() { p.Cancel(); _ = p.Wait() }() + go io.Copy(io.Discard, p.Stdout) + go io.Copy(io.Discard, p.Stderr) + child := waitWindowsPID(t, filepath.Join(dir, "child")) + handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(child)) + if err != nil { + t.Fatal(err) + } + defer windows.CloseHandle(handle) + switch mode { + case "cancel": + p.Cancel() + p.Cancel() + case "parent-cancel": + cancel() + case "leader-exit": + os.WriteFile(filepath.Join(dir, "exit"), nil, 0600) + } + done := make(chan struct{}) + go func() { _ = p.Wait(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("owned tree did not settle") + } + if status, err := windows.WaitForSingleObject(handle, 0); err != nil || status != windows.WAIT_OBJECT_0 { + t.Fatal("descendant survived confirmed cleanup", status, err) + } + if _, err := os.Stat(filepath.Join(dir, "late")); !os.IsNotExist(err) { + t.Fatal("descendant wrote after cleanup") + } + }) + } +} + +func TestWindowsJobClosesAfterOwnerCrash(t *testing.T) { + dir := t.TempDir() + owner := exec.Command(os.Args[0], "-test.run=^TestWindowsTreeHelper$", "--", "owner", "cancel", dir) + owner.Env = append(os.Environ(), "OAC_TREE_HELPER=1") + if err := owner.Start(); err != nil { + t.Fatal(err) + } + defer func() { _ = owner.Process.Kill(); _ = owner.Wait() }() + child := waitWindowsPID(t, filepath.Join(dir, "child")) + handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(child)) + if err != nil { + t.Fatal(err) + } + defer windows.CloseHandle(handle) + if err = owner.Process.Kill(); err != nil { + t.Fatal(err) + } + if status, err := windows.WaitForSingleObject(handle, 5000); err != nil || status != windows.WAIT_OBJECT_0 { + t.Fatal("Job descendant survived owner crash", status, err) + } +} + +func waitWindowsPID(t *testing.T, path string) int { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + raw, _ := os.ReadFile(path) + pid, err := strconv.Atoi(string(raw)) + if err == nil && pid > 0 { + return pid + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("child did not start") + return 0 +} + +func TestWindowsTreeHelper(t *testing.T) { + if os.Getenv("OAC_TREE_HELPER") != "1" { + return + } + args := os.Args + for len(args) > 0 && args[0] != "--" { + args = args[1:] + } + if len(args) != 4 { + os.Exit(2) + } + role, mode, dir := args[1], args[2], args[3] + switch role { + case "child": + _ = os.WriteFile(filepath.Join(dir, "child"), []byte(strconv.Itoa(os.Getpid())), 0600) + time.Sleep(10 * time.Second) + _ = os.WriteFile(filepath.Join(dir, "late"), nil, 0600) + case "leader": + c := exec.Command(os.Args[0], "-test.run=^TestWindowsTreeHelper$", "--", "child", mode, dir) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if err := c.Start(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(3) + } + for { + if _, err := os.Stat(filepath.Join(dir, "exit")); err == nil { + os.Exit(0) + } + time.Sleep(10 * time.Millisecond) + } + case "owner": + p, err := Start(StartOptions{Parent: context.Background(), Binary: os.Args[0], Args: []string{"-test.run=^TestWindowsTreeHelper$", "--", "leader", mode, dir}, Env: os.Environ(), OwnProcessGroup: true}) + if err != nil { + os.Exit(3) + } + _ = p.Wait() + default: + os.Exit(2) + } + os.Exit(0) +} diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_observe_darwin_test.go b/apps/parsar-daemon/internal/agent/clirunner/process_observe_darwin_test.go new file mode 100644 index 000000000..3b2a25796 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/clirunner/process_observe_darwin_test.go @@ -0,0 +1,23 @@ +//go:build darwin + +package clirunner + +import ( + "errors" + "golang.org/x/sys/unix" + "testing" + "time" +) + +func waitExited(t *testing.T, pid int) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + info, err := unix.SysctlKinfoProc("kern.proc.pid", pid) + if errors.Is(unix.Kill(pid, 0), unix.ESRCH) || errors.Is(err, unix.ESRCH) || err == nil && (info.Proc.P_pid == 0 || info.Proc.P_stat == 5) { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("child %d is still running", pid) +} diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_observe_linux_test.go b/apps/parsar-daemon/internal/agent/clirunner/process_observe_linux_test.go new file mode 100644 index 000000000..9d8818b38 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/clirunner/process_observe_linux_test.go @@ -0,0 +1,30 @@ +//go:build linux + +package clirunner + +import ( + "fmt" + "os" + "strings" + "testing" + "time" +) + +func waitExited(t *testing.T, pid int) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + value, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if os.IsNotExist(err) { + return + } + if err == nil { + fields := strings.Fields(string(value)[strings.LastIndex(string(value), ")")+1:]) + if len(fields) > 0 && fields[0] == "Z" { + return + } + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("child %d is still running", pid) +} diff --git a/apps/parsar-daemon/internal/agent/codex/capability_root_test.go b/apps/parsar-daemon/internal/agent/codex/capability_root_test.go deleted file mode 100644 index 36201a246..000000000 --- a/apps/parsar-daemon/internal/agent/codex/capability_root_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package codex - -import ( - "testing" -) - -func TestCapabilityRootIsOneQuotedReadOnlyNativeKey(t *testing.T) { - var plan SessionPlan - configureCapabilityRoot(&plan, "managed-workspace-enabled", "/tmp/root.with.dot/quote\"inside") - if len(plan.ExtraConfig) != 1 || plan.ExtraConfig[0][0] != "permissions.\"managed-workspace-enabled\".filesystem.\"/tmp/root.with.dot/quote\\\"inside\"" || plan.ExtraConfig[0][1] != "\"read\"" { - t.Fatal("root escaped its read-only key", plan.ExtraConfig) - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/environment.go b/apps/parsar-daemon/internal/agent/codex/environment.go index 2eace2ad1..61f85d962 100644 --- a/apps/parsar-daemon/internal/agent/codex/environment.go +++ b/apps/parsar-daemon/internal/agent/codex/environment.go @@ -39,12 +39,6 @@ func nativeEnvironmentStatus(ctx context.Context, rpc *JSONRPCClient, id string) return result.Status, nil } -func configureRestrictedShellEnvironment(plan *SessionPlan) { - plan.ExtraConfig = append(plan.ExtraConfig, - [2]string{"shell_environment_policy.inherit", `"core"`}, - [2]string{"shell_environment_policy.ignore_default_excludes", "false"}) -} - // Native still recognizes the retired transport variables. Reject them before // setup so inherited or operator options cannot select a separate executor. // The explicit none selector remains part of native execution isolation. diff --git a/apps/parsar-daemon/internal/agent/codex/environment_local.go b/apps/parsar-daemon/internal/agent/codex/environment_local.go index 557a42714..d097cd758 100644 --- a/apps/parsar-daemon/internal/agent/codex/environment_local.go +++ b/apps/parsar-daemon/internal/agent/codex/environment_local.go @@ -9,19 +9,12 @@ import ( // SupportsLocalEnvironment checks deployment prerequisites, not public admission. func SupportsLocalEnvironment(version string) bool { - if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" || !SupportsNativeSessionRecovery(version) || os.Getenv("OAC_RUNTIME_CODEX_PERMISSION_PROFILE") == "" || os.Getenv("OAC_RUNTIME_CODEX_HARNESS_BIN") != "" { + if !SupportsNativeSessionRecovery(version) || os.Getenv("OAC_RUNTIME_CODEX_HARNESS_BIN") != "" || (runtime.GOOS != "linux" && runtime.GOOS != "darwin" && runtime.GOOS != "windows") { return false } binding, err := localworkspace.Load() return err == nil && binding != nil } -// SupportsLocalNetworkPolicy describes the qualified adapter and bound policy; -// actual native preparation still validates the managed requirements. -func SupportsLocalNetworkPolicy(version string) bool { - if !SupportsLocalEnvironment(version) || os.Getenv("OAC_RUNTIME_CODEX_PERMISSION_PROFILE") != "managed-workspace" { - return false - } - binding, err := localworkspace.Load() - return err == nil && binding != nil && binding.NetworkPolicy().Validate() == nil -} +// Network isolation belongs to the outer environment, never this adapter. +func SupportsLocalNetworkPolicy(version string) bool { return false } diff --git a/apps/parsar-daemon/internal/agent/codex/executor.go b/apps/parsar-daemon/internal/agent/codex/executor.go index 2e437c20e..b64ba3756 100644 --- a/apps/parsar-daemon/internal/agent/codex/executor.go +++ b/apps/parsar-daemon/internal/agent/codex/executor.go @@ -68,7 +68,7 @@ func (e *Executor) StartTurn(ctx context.Context, runID string, input proto.Mess } functions := &functionCalls{definitions: base.functions.definitions, names: base.functions.names, pending: map[string]*pendingFunction{}} turnCtx, cancel := context.WithCancel(base.cancelCtx) - s := &Session{executor: e, nativeHome: base.nativeHome, toolEnvironment: base.toolEnvironment, + s := &Session{executor: e, nativeHome: base.nativeHome, functions: functions, observeMessages: base.observeMessages, observeTools: base.observeTools, observeToolObservations: base.observeToolObservations, observeSubagentIdentities: base.observeSubagentIdentities, cfg: base.cfg, rpc: base.rpc, cancelCtx: turnCtx, cancelFn: cancel, diff --git a/apps/parsar-daemon/internal/agent/codex/managed_network.go b/apps/parsar-daemon/internal/agent/codex/managed_network.go deleted file mode 100644 index 2fad0c583..000000000 --- a/apps/parsar-daemon/internal/agent/codex/managed_network.go +++ /dev/null @@ -1,113 +0,0 @@ -package codex - -import ( - "bytes" - "errors" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/BurntSushi/toml" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" -) - -const nativeManagedRequirements = "/etc/codex/requirements.toml" - -// Preserve the image's native filesystem, approval and hook requirements. This -// adapter adds the exact network ceiling; Core never supplies native TOML. -func managedNetworkRequirements(base []byte, policy agentnetwork.Policy) ([]byte, error) { - if policy.Access != "restricted" || policy.Validate() != nil { - return nil, errors.New("codex: invalid restricted network policy") - } - var config map[string]any - if _, err := toml.Decode(string(base), &config); err != nil { - return nil, err - } - if _, exists := config["experimental_network"]; exists { - return nil, errors.New("codex: image already defines managed network requirements") - } - domains := make(map[string]string) - for _, host := range policy.Hosts() { - domains[host] = "allow" - } - var addition bytes.Buffer - err := toml.NewEncoder(&addition).Encode(map[string]any{"experimental_network": map[string]any{ - "enabled": true, "managed_allowed_domains_only": true, - "allow_upstream_proxy": false, "dangerously_allow_all_unix_sockets": false, - "allow_local_binding": false, "domains": domains, - }}) - if err != nil { - return nil, err - } - result := append(append([]byte{}, base...), '\n') - return append(result, addition.Bytes()...), nil -} - -func prepareManagedNetwork(plan *SessionPlan, policy agentnetwork.Policy) error { - var home string - for _, entry := range plan.Env { - if value, found := strings.CutPrefix(entry, "CODEX_HOME="); found { - home = value - } - } - if !filepath.IsAbs(home) { - return errors.New("codex: managed network requires private Session state") - } - base, err := os.ReadFile(nativeManagedRequirements) - if err != nil { - return err - } - contents, err := managedNetworkRequirements(base, policy) - if err != nil { - return err - } - file, err := os.CreateTemp(home, ".managed-network-*.toml") - if err != nil { - return err - } - defer os.Remove(file.Name()) - if _, err = file.Write(contents); err == nil { - err = file.Chmod(0400) - } - closeErr := file.Close() - if err != nil { - return err - } - if closeErr != nil { - return closeErr - } - path := filepath.Join(home, "managed-network.toml") - if err = os.Rename(file.Name(), path); err != nil { - return err - } - // Retain this non-secret file with Session state. Rebuild it from the frozen - // policy on preparation; cleanup never races a process still holding its mount. - plan.managedRequirements = path - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features.network_proxy", "true"}) - return nil -} - -// The existing RPC client owns the sole child, its pipes and teardown. The PID -// namespace terminates native descendants when that owned process is killed. -func configureManagedNetworkProcess(cfg *JSONRPCConfig, requirements string) error { - if requirements == "" { - return nil - } - if !filepath.IsAbs(requirements) { - return errors.New("codex: managed requirements path must be absolute") - } - binary, err := exec.LookPath(cfg.Binary) - if err != nil { - return err - } - binary, err = filepath.Abs(binary) - if err != nil { - return err - } - cfg.Binary = "/usr/bin/bwrap" - cfg.ExtraArgs = append([]string{"--die-with-parent", "--unshare-pid", "--bind", "/", "/", - "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--ro-bind", requirements, - nativeManagedRequirements, binary}, cfg.ExtraArgs...) - return nil -} diff --git a/apps/parsar-daemon/internal/agent/codex/managed_network_live_test.go b/apps/parsar-daemon/internal/agent/codex/managed_network_live_test.go deleted file mode 100644 index 0ff3dbdd0..000000000 --- a/apps/parsar-daemon/internal/agent/codex/managed_network_live_test.go +++ /dev/null @@ -1,111 +0,0 @@ -//go:build linux - -package codex - -import ( - "bytes" - "context" - "os" - "path/filepath" - stdstrconv "strconv" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" -) - -// Run inside the qualified Docker Runtime. This checks the real native process -// and managed requirements, not model execution or public protocol acceptance. -func TestManagedNetworkNativeLifecycle(t *testing.T) { - if os.Getenv("OAC_TEST_CODEX_MANAGED_NETWORK_LIVE") != "1" { - t.Skip("requires the qualified Docker Runtime and native Codex") - } - base, err := os.ReadFile(nativeManagedRequirements) - if err != nil { - t.Fatal(err) - } - for _, mode := range []string{"close", "owner-cancel"} { - t.Run(mode, func(t *testing.T) { - home := t.TempDir() - plan := SessionPlan{Env: []string{"CODEX_HOME=" + home}} - policy := agentnetwork.Policy{Access: "restricted", AllowedDomains: []string{"Example.com"}} - if err := prepareManagedNetwork(&plan, policy); err != nil { - t.Fatal(err) - } - cfg := JSONRPCConfig{Binary: "/usr/local/bin/codex", Env: append(os.Environ(), plan.Env...)} - for _, entry := range plan.ExtraConfig { - cfg.ExtraArgs = append(cfg.ExtraArgs, "-c", entry[0]+"="+entry[1]) - } - if err := configureManagedNetworkProcess(&cfg, plan.managedRequirements); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - client := NewJSONRPCClient(cfg) - defer client.Close() - if _, err := client.Start(ctx, InitializeParams{ - ClientInfo: InitializeClientInfo{Name: "network-lifecycle-test", Version: "1"}, - Capabilities: &InitializeCapabilities{ExperimentalAPI: true}, - }); err != nil { - t.Fatal(err) - } - requirements, err := client.Request(ctx, "configRequirements/read", nil) - if err != nil || !bytes.Contains(requirements, []byte("example.com")) { - t.Fatalf("native policy was not applied: %s: %v", requirements, err) - } - owned := networkNativeProcesses(t, home) - if len(owned) == 0 { - t.Fatal("native child was not observed") - } - if mode == "owner-cancel" { - cancel() - } - if err := client.Close(); err != nil { - t.Fatal(err) - } - deadline := time.Now().Add(5 * time.Second) - for { - survivors := networkNativeProcesses(t, home) - if len(survivors) == 0 { - break - } - if time.Now().After(deadline) { - t.Fatalf("native processes survived %s: %v", mode, survivors) - } - time.Sleep(20 * time.Millisecond) - } - if client.Alive() || client.cmd.ProcessState == nil { - t.Fatal("RPC owner did not settle") - } - }) - } - after, err := os.ReadFile(nativeManagedRequirements) - if err != nil || !bytes.Equal(base, after) { - t.Fatal("image requirements changed", err) - } -} - -func networkNativeProcesses(t *testing.T, home string) []string { - t.Helper() - entries, err := os.ReadDir("/proc") - if err != nil { - t.Fatal(err) - } - var found []string - for _, entry := range entries { - pid, err := stdstrconv.Atoi(entry.Name()) - if err != nil || pid == os.Getpid() { - continue - } - env, err := os.ReadFile(filepath.Join("/proc", entry.Name(), "environ")) - if err == nil { - for _, value := range strings.Split(string(env), "\x00") { - if value == "CODEX_HOME="+home { - found = append(found, entry.Name()) - } - } - } - } - return found -} diff --git a/apps/parsar-daemon/internal/agent/codex/managed_network_test.go b/apps/parsar-daemon/internal/agent/codex/managed_network_test.go deleted file mode 100644 index 6348af028..000000000 --- a/apps/parsar-daemon/internal/agent/codex/managed_network_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package codex - -import ( - "os" - "path/filepath" - "reflect" - "testing" - - "github.com/BurntSushi/toml" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" -) - -func TestManagedNetworkPreservesImageRequirements(t *testing.T) { - base, err := os.ReadFile("../../../../../services/agents-api/deploy/codex/requirements.toml") - if err != nil { - t.Fatal(err) - } - policy := agentnetwork.Policy{Access: "restricted", AllowedDomains: []string{"Example.com", "api.example.com", "example.com"}} - result, err := managedNetworkRequirements(base, policy) - if err != nil { - t.Fatal(err) - } - var original, merged map[string]any - if _, err = toml.Decode(string(base), &original); err != nil { - t.Fatal(err) - } - if _, err = toml.Decode(string(result), &merged); err != nil { - t.Fatal(err) - } - network := merged["experimental_network"].(map[string]any) - delete(merged, "experimental_network") - if !reflect.DeepEqual(merged, original) { - t.Fatal("native filesystem/approval/hooks changed") - } - for _, flag := range []string{"enabled", "managed_allowed_domains_only"} { - if network[flag] != true { - t.Fatal("missing ceiling", flag) - } - } - for _, flag := range []string{"allow_upstream_proxy", "dangerously_allow_all_unix_sockets", "allow_local_binding"} { - if network[flag] != false { - t.Fatal("network escape enabled", flag) - } - } - want := map[string]any{"example.com": "allow", "api.example.com": "allow"} - if !reflect.DeepEqual(network["domains"], want) { - t.Fatal("exact domains changed", network["domains"]) - } - if _, err := managedNetworkRequirements(result, policy); err == nil { - t.Fatal("image ceiling overwritten") - } -} - -func TestManagedNetworkWrapsTheExistingRPCCommand(t *testing.T) { - binary, err := os.Executable() - if err != nil { - t.Fatal(err) - } - path := filepath.Join(t.TempDir(), "managed-network.toml") - cfg := JSONRPCConfig{Binary: binary, ExtraArgs: []string{"-c", "features.network_proxy=true"}, Env: []string{"HOME=/private"}, Cwd: "/workspace"} - if err := configureManagedNetworkProcess(&cfg, path); err != nil { - t.Fatal(err) - } - if cfg.Binary != "/usr/bin/bwrap" || cfg.Cwd != "/workspace" || !reflect.DeepEqual(cfg.Env, []string{"HOME=/private"}) { - t.Fatal("RPC ownership/configuration changed") - } - want := []string{"--die-with-parent", "--unshare-pid", "--bind", "/", "/", "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--ro-bind", path, nativeManagedRequirements, binary, "-c", "features.network_proxy=true"} - if !reflect.DeepEqual(cfg.ExtraArgs, want) { - t.Fatal(cfg.ExtraArgs) - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go index 37f368b81..d583078ca 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go @@ -2,6 +2,7 @@ package codex import ( "encoding/json" + "os" "slices" "strings" "testing" @@ -20,8 +21,9 @@ func TestEnvironmentMCPProjectsIsolatedStdioAndPrivateHTTPReferences(t *testing. if err != nil || len(servers) != 2 || len(env) != 2 { t.Fatal("environment declarations were not projected", err) } - if servers["local"].Command != "/usr/bin/python3" || !servers["local"].ApproveTools || - !slices.Equal(servers["local"].Args, []string{"-I", "-S", "/usr/local/bin/oac-runtime-initialize", "stdio", "/private/runtime/capabilities", "/private/runtime/workspace", "plugins/0", "local"}) { + executable, _ := os.Executable() + if servers["local"].Command != executable || !servers["local"].ApproveTools || + !slices.Equal(servers["local"].Args, []string{"runtime-mcp-exec", "/private/runtime/capabilities", "plugins/0", "local"}) { t.Fatal("native stdio bypasses the packaged launcher") } remote := servers["remote"] @@ -41,7 +43,7 @@ func TestEnvironmentMCPProjectsIsolatedStdioAndPrivateHTTPReferences(t *testing. if !matchesMCPConfig(raw, servers) { t.Fatal("qualified native projection rejected") } - corrupt := strings.Replace(string(raw), "oac-runtime-initialize", "untrusted-launcher", 1) + corrupt := strings.Replace(string(raw), "runtime-mcp-exec", "untrusted-launcher", 1) if matchesMCPConfig(json.RawMessage(corrupt), servers) { t.Fatal("different native launcher accepted") } diff --git a/apps/parsar-daemon/internal/agent/codex/native_hooks.go b/apps/parsar-daemon/internal/agent/codex/native_hooks.go new file mode 100644 index 000000000..962ca1969 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/native_hooks.go @@ -0,0 +1,39 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "time" +) + +type nativeToolHook struct { + EventName string `json:"eventName"` + Command string `json:"command"` + Matcher string `json:"matcher"` + Enabled bool `json:"enabled"` + IsManaged bool `json:"isManaged"` + Async bool `json:"async"` + SourcePath string `json:"sourcePath"` + TrustStatus string `json:"trustStatus"` +} + +func readNativeToolHooks(ctx context.Context, rpc *JSONRPCClient, cwd string) ([]nativeToolHook, error) { + operation, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + raw, err := rpc.Request(operation, "hooks/list", map[string]any{"cwds": []string{cwd}}) + if err != nil { + return nil, errors.New("codex: initialized tool hook unavailable") + } + var response struct { + Data []struct { + CWD string `json:"cwd"` + Hooks []nativeToolHook `json:"hooks"` + Errors []json.RawMessage `json:"errors"` + } `json:"data"` + } + if json.Unmarshal(raw, &response) != nil || len(response.Data) != 1 || response.Data[0].CWD != cwd || len(response.Data[0].Errors) != 0 { + return nil, errors.New("codex: initialized tool hook configuration unavailable") + } + return response.Data[0].Hooks, nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/options.go b/apps/parsar-daemon/internal/agent/codex/options.go index 001a9c837..e46e553af 100644 --- a/apps/parsar-daemon/internal/agent/codex/options.go +++ b/apps/parsar-daemon/internal/agent/codex/options.go @@ -15,7 +15,6 @@ import ( // SessionPlan holds the resolved per-prompt launch plan derived from // the daemon's PromptRequestPayload. type SessionPlan struct { - managedRequirements string // Cwd is the validated working directory passed to codex (and to // the spawned app-server). Empty when the caller provided no work_dir. diff --git a/apps/parsar-daemon/internal/agent/codex/permission_profile.go b/apps/parsar-daemon/internal/agent/codex/permission_profile.go index 953442a22..a5d7a76d9 100644 --- a/apps/parsar-daemon/internal/agent/codex/permission_profile.go +++ b/apps/parsar-daemon/internal/agent/codex/permission_profile.go @@ -1,52 +1,14 @@ package codex import ( - "errors" - "strings" - + "fmt" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" ) -// The deployment selects a native named profile; request options cannot select it. -// Native managed requirements must constrain its definition and allowed profiles. -// This selection does not establish workspace authority or public admission. -func validatePermissionProfile(req proto.PromptRequestPayload, profile string) error { - if req.LocalEnvironment != nil && profile == "" { - return errors.New("codex: local Environment requires deployment-managed permissions") - } - if profile == "" { - return nil - } - if strings.TrimSpace(profile) != profile || strings.HasPrefix(profile, ":") { - return errors.New("codex: deployment permissions require a named native profile") - } - if req.DisableExecutionEnvironment || req.WorkspaceReadOnly { - return errors.New("codex: deployment permission profile requires local execution") - } - return nil -} - -// managedPermissionProfile maps a validated deployment binding to native policy. -// Public or prompt options cannot choose a native profile or widen that binding. -func managedPermissionProfile(req proto.PromptRequestPayload, cfg sessionConfig) (string, error) { - profile := cfg.permissionProfile - if cfg.runtimeNetworkError != nil { - return "", cfg.runtimeNetworkError - } - if cfg.runtimeNetwork.Access != "" { - if req.LocalEnvironment == nil || !cfg.runtimeNetwork.Equal(agentnetwork.Policy{Access: req.LocalEnvironment.NetworkAccess, AllowedDomains: req.LocalEnvironment.AllowedDomains}) || profile != "managed-workspace" { - return "", errors.New("codex: Runtime network policy mismatch") - } - switch cfg.runtimeNetwork.Access { - case "disabled": - case "enabled", "restricted": - profile = "managed-workspace-enabled" - default: - return "", errors.New("codex: unsupported Runtime network policy") - } - } else if req.LocalEnvironment != nil { - return "", errors.New("codex: Runtime has no bound network policy") +// Runtime execution uses the current user; isolation belongs to its outer host. +func runtimePermissionProfile(req proto.PromptRequestPayload) (string, error) { + if req.LocalEnvironment != nil && (req.DisableExecutionEnvironment || req.WorkspaceReadOnly || req.LocalEnvironment.NetworkAccess != "enabled" || len(req.LocalEnvironment.AllowedDomains) != 0) { + return "", fmt.Errorf("codex: Runtime execution requires unrestricted host access") } - return profile, validatePermissionProfile(req, profile) + return "", nil } diff --git a/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go b/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go index 585c0179b..56e80fd55 100644 --- a/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go +++ b/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go @@ -1,92 +1,32 @@ package codex import ( - "context" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" - "os" - "path/filepath" - "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "testing" ) -func TestPermissionProfileRejectsIncompatiblePreparationBeforeState(t *testing.T) { - for _, tc := range []struct { - name string - profile string - req proto.PromptRequestPayload - }{ - {"builtin", ":danger-full-access", proto.PromptRequestPayload{}}, - {"whitespace", " ", proto.PromptRequestPayload{}}, - {"none", "managed-workspace", proto.PromptRequestPayload{DisableExecutionEnvironment: true}}, - {"read-owner", "managed-workspace", proto.PromptRequestPayload{WorkspaceReadOnly: true}}, - } { - t.Run(tc.name, func(t *testing.T) { - root := filepath.Join(t.TempDir(), "uncreated") - t.Setenv("OAC_RUNTIME_HOME", root) - if _, _, err := prepareSessionPlan(context.Background(), tc.req, sessionConfig{permissionProfile: tc.profile}); err == nil { - t.Fatal("incompatible profile accepted") - } - if _, err := os.Stat(root); !os.IsNotExist(err) { - t.Fatal("rejection created state", err) - } - }) - } -} - -func TestPermissionProfileSelectsNativeStartupConfig(t *testing.T) { +func TestRuntimeUsesHostPermissions(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - t.Setenv("OAC_RUNTIME_CODEX_PERMISSION_PROFILE", "managed-workspace") - plan, _, err := prepareSessionPlan(context.Background(), proto.PromptRequestPayload{AgentStateKey: "session", DisableSubagents: true}, defaultSessionConfig()) - if err != nil { - t.Fatal(err) - } - defer plan.Cleanup() - config := map[string]string{} - for _, kv := range plan.ExtraConfig { - config[kv[0]] = kv[1] - } - if config["default_permissions"] != `"managed-workspace"` || plan.Sandbox != "" || plan.Permissions != "managed-workspace" { - t.Fatal("native managed selection missing") - } - if config["shell_environment_policy.inherit"] != `"core"` || config["shell_environment_policy.ignore_default_excludes"] != "false" { - t.Fatal("shell can inherit model credentials") - } - if config["features.shell_snapshot"] != "false" { - t.Fatal("managed shell depends on inaccessible private snapshots") - } -} - -func TestManagedNetworkPolicySelectsNativeProfileAndRejectsMismatchBeforeState(t *testing.T) { - for _, mode := range []string{"enabled", "disabled"} { - t.Run(mode, func(t *testing.T) { - root := filepath.Join(t.TempDir(), "uncreated") - t.Setenv("OAC_RUNTIME_HOME", root) - req := proto.PromptRequestPayload{AgentStateKey: "session", LocalEnvironment: &proto.LocalEnvironment{ID: "environment", NetworkAccess: mode}} - cfg := sessionConfig{permissionProfile: "managed-workspace", runtimeNetwork: agentnetwork.Policy{Access: mode}} - wrong := req - wrong.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "restricted"} - if _, _, err := prepareSessionPlan(t.Context(), wrong, cfg); err == nil { - t.Fatal("policy mismatch accepted") - } - if _, err := os.Stat(root); !os.IsNotExist(err) { - t.Fatal("policy rejection created native state") - } - plan, _, err := prepareSessionPlan(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - defer plan.Cleanup() - expected := "managed-workspace" - if mode == "enabled" { - expected += "-enabled" - } - if plan.Permissions != expected || plan.Sandbox != "" { - t.Fatal("wrong native profile", plan.Permissions) - } - if _, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{permissionProfile: "managed-workspace"}); err == nil { - t.Fatal("unbound Runtime accepted explicit policy") - } - }) + for _, mode := range []string{"", "native"} { + req := proto.PromptRequestPayload{AgentStateKey: "session", DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{ExecutionMode: mode, NetworkAccess: "enabled"}} + plan, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{}) + if err != nil { + t.Fatal(err) + } + if plan.Sandbox != "danger-full-access" || plan.Permissions != "" || plan.ApprovalPolicy.String != "never" { + t.Fatal("Runtime must bypass inner sandbox", plan) + } + for _, kv := range plan.ExtraConfig { + if kv[0] == "default_permissions" { + t.Fatal("obsolete permission wrapper", kv) + } + } + plan.Cleanup() + for _, network := range []string{"disabled", "restricted"} { + req.LocalEnvironment.NetworkAccess = network + if _, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{}); err == nil { + t.Fatal("unsupported network admitted") + } + } } } diff --git a/apps/parsar-daemon/internal/agent/codex/preparation.go b/apps/parsar-daemon/internal/agent/codex/preparation.go index 2d143f97b..5f7578418 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation.go @@ -83,17 +83,11 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg for _, kv := range plan.ExtraConfig { rpcCfg.ExtraArgs = append(rpcCfg.ExtraArgs, "-c", kv[0]+"="+kv[1]) } - if err := configureManagedNetworkProcess(&rpcCfg, plan.managedRequirements); err != nil { - cancelFn() - plan.Cleanup() - return nil, err - } rpc := NewJSONRPCClient(rpcCfg) s := &Session{ nativeHome: nativeHomeFromPlan(plan), - toolEnvironment: req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment, functions: functions, observeMessages: req.ObserveMessages, observeTools: req.ObserveTools, @@ -138,11 +132,7 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg return p.preparationFailed(err) } } - if s.toolEnvironment { - if err := verifyToolEnvironmentHook(cancelCtx, rpc, plan.Cwd); err != nil { - return p.preparationFailed(err) - } - } + if plan.mcpServers != nil { if err := verifyMCPConfig(cancelCtx, rpc, plan); err != nil { return p.preparationFailed(err) diff --git a/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go b/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go index b3410f785..fbf5dcbbe 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go @@ -5,7 +5,6 @@ import ( "errors" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" "github.com/google/uuid" "os" "path/filepath" @@ -58,8 +57,6 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { req.AgentStateKey = "agents-api-" + session req.DisableExecutionEnvironment = false req.LocalEnvironment = &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", NetworkAccess: "enabled", CapabilitySources: &agentcapabilities.Input{}} - cfg.permissionProfile = "managed-workspace" - cfg.runtimeNetwork = agentnetwork.Policy{Access: "enabled"} registry := agent.NewRegistry() registry.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{LocalEnvironment: true, FunctionTools: true}}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { return nil, errors.New("ordinary Factory must not run") diff --git a/apps/parsar-daemon/internal/agent/codex/session.go b/apps/parsar-daemon/internal/agent/codex/session.go index 4d1e95ea6..aa38c1c96 100644 --- a/apps/parsar-daemon/internal/agent/codex/session.go +++ b/apps/parsar-daemon/internal/agent/codex/session.go @@ -5,16 +5,13 @@ import ( "encoding/json" "fmt" "log/slog" - "os" "strings" "sync" "sync/atomic" "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" ) @@ -26,20 +23,14 @@ const terminalSendTimeout = 2 * time.Second // sessionConfig is the cross-cutting knob bag — production callers go // through Factory which uses defaults. type sessionConfig struct { - codexBinary string - permissionProfile string - runtimeNetwork agentnetwork.Policy - runtimeNetworkError error - logger *slog.Logger - killTimeout time.Duration + codexBinary string + logger *slog.Logger + killTimeout time.Duration } func defaultSessionConfig() sessionConfig { - policy, err := localworkspace.RuntimeNetworkPolicy() return sessionConfig{ - codexBinary: defaultBinary(), - permissionProfile: os.Getenv("OAC_RUNTIME_CODEX_PERMISSION_PROFILE"), - runtimeNetwork: policy, runtimeNetworkError: err, + codexBinary: defaultBinary(), logger: obslog.Bg(), killTimeout: rpcKillTimeout, } @@ -73,7 +64,6 @@ type Session struct { operationMu sync.Mutex operations sync.WaitGroup operationsClosed bool - toolEnvironment bool nativeHome string subagents *subagentObservations observeSubagentIdentities bool @@ -160,7 +150,6 @@ func (s *Session) registerHandlers() { rpc.OnNotification("item/reasoning/summaryTextDelta", s.onReasoningDelta) rpc.OnNotification("thread/tokenUsage/updated", s.onUsageUpdated) rpc.OnNotification("error", s.onErrorNotif) - rpc.OnNotification("hook/completed", s.onToolEnvironmentHook) s.onServerRequest("item/commandExecution/requestApproval", s.handleCodexCommandApproval) s.onServerRequest("item/fileChange/requestApproval", s.handleCodexFileApproval) diff --git a/apps/parsar-daemon/internal/agent/codex/session_plan.go b/apps/parsar-daemon/internal/agent/codex/session_plan.go index b0550f894..8b7c1bfb8 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_plan.go +++ b/apps/parsar-daemon/internal/agent/codex/session_plan.go @@ -12,7 +12,7 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg if err := validateNativeTransportEnvironment(req); err != nil { return SessionPlan{}, nil, err } - profile, err := managedPermissionProfile(req, cfg) + _, err := runtimePermissionProfile(req) if err != nil { return SessionPlan{}, nil, err } @@ -29,32 +29,26 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg return SessionPlan{}, nil, err } disableProgrammaticTools(&plan, req.ExecutionControls) - if profile != "" { - plan.Sandbox = "" - plan.Permissions = profile - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"default_permissions", tomlQuoteString(profile)}) - configureRestrictedShellEnvironment(&plan) - if req.LocalEnvironment != nil && req.LocalEnvironment.CapabilityRoot != "" { - configureCapabilityRoot(&plan, profile, req.LocalEnvironment.CapabilityRoot) - } - // Native login-shell snapshots live outside the managed tool filesystem. - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features.shell_snapshot", "false"}) + if req.LocalEnvironment != nil { + plan.Sandbox = "danger-full-access" + plan.Permissions = "" + plan.ApprovalPolicy = AskForApproval{String: "never"} } if req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment { - if err := localworkspace.VerifyToolEnvironment(req.LocalEnvironment.SystemPackages); err != nil { + binding, err := localworkspace.Load() + if err != nil || binding == nil { + plan.Cleanup() + return SessionPlan{}, nil, fmt.Errorf("codex: native Runtime binding unavailable") + } + values, err := binding.ReadToolEnvironment() + if err != nil { plan.Cleanup() return SessionPlan{}, nil, err } - plan.Env = append(plan.Env, "OAC_RUNTIME_TOOL_ENV=1") - if req.LocalEnvironment.SystemPackages { - if err := prepareSystemToolAnchor(); err != nil { - plan.Cleanup() - return SessionPlan{}, nil, err - } - plan.Env = append(plan.Env, "OAC_RUNTIME_SYSTEM_PACKAGES=1") + for key, value := range values { + plan.Env = append(plan.Env, key+"="+value) } - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features.hooks", "true"}) } if req.DisableSubagents { @@ -105,17 +99,6 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg plan.Env = append(plan.Env, mcpBearerEnv...) plan.Env = append(plan.Env, environmentMCPEnv...) - if cfg.runtimeNetwork.Access == "restricted" { - if err := prepareManagedNetwork(&plan, cfg.runtimeNetwork); err != nil { - plan.Cleanup() - return SessionPlan{}, nil, err - } - } - return plan, skillRoots, nil -} -// The Runtime supplies this resolved root after validating its binding. Quote the -// path as one TOML key; neither public options nor source directories select it. -func configureCapabilityRoot(plan *SessionPlan, profile, root string) { - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"permissions." + tomlQuoteString(profile) + ".filesystem." + tomlQuoteString(root), tomlQuoteString("read")}) + return plan, skillRoots, nil } diff --git a/apps/parsar-daemon/internal/agent/codex/session_policy_test.go b/apps/parsar-daemon/internal/agent/codex/session_policy_test.go index 9156aca5a..869b47ecf 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_policy_test.go +++ b/apps/parsar-daemon/internal/agent/codex/session_policy_test.go @@ -16,7 +16,7 @@ func TestThreadRequestsApplyDeploymentPolicy(t *testing.T) { for _, method := range []string{"thread/start", "thread/resume"} { t.Run(method, func(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - plan, _, err := prepareSessionPlan(context.Background(), proto.PromptRequestPayload{AgentStateKey: "conv/agent/codex", DisableSubagents: true, AgentOptions: map[string]any{"permissions": ":danger-full-access"}}, sessionConfig{permissionProfile: profile}) + plan, _, err := prepareSessionPlan(context.Background(), proto.PromptRequestPayload{AgentStateKey: "conv/agent/codex", DisableSubagents: true, AgentOptions: map[string]any{"permissions": ":danger-full-access"}}, sessionConfig{}) if err != nil { t.Fatal(err) } @@ -48,15 +48,10 @@ func TestThreadRequestsApplyDeploymentPolicy(t *testing.T) { if request.Params["approvalPolicy"] != "never" { t.Fatal("approval policy changed") } - if profile == "" { - if request.Params["sandbox"] != "danger-full-access" || request.Params["permissions"] != nil { - t.Fatalf("default policy changed: %+v", request.Params) - } - } else { - if request.Params["sandbox"] != nil || request.Params["permissions"] != profile { - t.Fatalf("managed policy lost: %+v", request.Params) - } + if request.Params["sandbox"] != "danger-full-access" || request.Params["permissions"] != nil { + t.Fatalf("bypass policy changed: %+v", request.Params) } + if method == "thread/resume" && request.Params["threadId"] != "old-thread" { t.Fatalf("resume lost thread ID: %+v", request.Params) } diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_profile.go b/apps/parsar-daemon/internal/agent/codex/subagent_profile.go index 905027d35..648e97071 100644 --- a/apps/parsar-daemon/internal/agent/codex/subagent_profile.go +++ b/apps/parsar-daemon/internal/agent/codex/subagent_profile.go @@ -15,9 +15,6 @@ func configureSubagentObservations(plan *SessionPlan, req proto.PromptRequestPay if !req.ObserveSubagentIdentities || req.DisableSubagents { return nil } - if req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment { - return errors.New("codex: multi_agent with initialized tool environment requires verified child hook failure handling") - } for _, feature := range []string{"hooks", "plugins", "code_mode", "code_mode_only", "code_mode_prewarm", "multi_agent_v2"} { plan.EnableFeatures = slices.DeleteFunc(plan.EnableFeatures, func(value string) bool { return value == feature }) if !slices.Contains(plan.DisableFeatures, feature) { @@ -44,12 +41,8 @@ func verifySubagentObservationProfile(ctx context.Context, rpc *JSONRPCClient, c if err != nil { return err } - // Packaged managed requirements may force hooks on. Only the immutable - // Bash pre-hook is admissible; managed-only discovery must survive reloads. - for _, hook := range hooks { - if !hook.isRuntimeToolEnvironmentHook() { - return errors.New("codex: subagent observation requires all hooks except the packaged Bash pre-hook disabled") - } + if len(hooks) != 0 { + return errors.New("codex: subagent observation requires hooks disabled") } raw, err := rpc.Request(ctx, "configRequirements/read", nil) diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_profile_test.go b/apps/parsar-daemon/internal/agent/codex/subagent_profile_test.go index 62613cbd7..fb903f2c3 100644 --- a/apps/parsar-daemon/internal/agent/codex/subagent_profile_test.go +++ b/apps/parsar-daemon/internal/agent/codex/subagent_profile_test.go @@ -26,8 +26,8 @@ func TestSubagentProfileOverridesUnsafeNativeFeatures(t *testing.T) { func TestSubagentProfileDoesNotDisableRequiredToolEnvironment(t *testing.T) { request := proto.PromptRequestPayload{ObserveSubagentIdentities: true, LocalEnvironment: &proto.LocalEnvironment{ToolEnvironment: true}} - if err := configureSubagentObservations(&SessionPlan{}, request); err == nil { - t.Fatal("incompatible hook profile accepted") + if err := configureSubagentObservations(&SessionPlan{}, request); err != nil { + t.Fatal("ordinary environment must not require hooks", err) } request.DisableSubagents = true if err := configureSubagentObservations(&SessionPlan{}, request); err != nil { diff --git a/apps/parsar-daemon/internal/agent/codex/tool_environment.go b/apps/parsar-daemon/internal/agent/codex/tool_environment.go deleted file mode 100644 index 7af5efbd7..000000000 --- a/apps/parsar-daemon/internal/agent/codex/tool_environment.go +++ /dev/null @@ -1,97 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "errors" - "os" - "path/filepath" - "time" -) - -const toolEnvironmentHookSource = "/etc/codex/runtime-hooks" -const toolEnvironmentHookCommand = "/usr/bin/python3 -I -S /etc/codex/tool-env.py" - -func prepareSystemToolAnchor() error { - const anchor = "/tmp/oac-tool-root" - if err := os.Mkdir(anchor, 0500); err != nil && !errors.Is(err, os.ErrExist) { - return errors.New("codex: system tool temporary anchor unavailable") - } - actual, err := filepath.EvalSymlinks(anchor) - entries, readErr := os.ReadDir(anchor) - if err != nil || actual != anchor || readErr != nil || len(entries) != 0 { - return errors.New("codex: system tool temporary anchor is not an empty canonical directory") - } - return nil -} - -type nativeToolHook struct { - EventName string `json:"eventName"` - Command string `json:"command"` - Matcher string `json:"matcher"` - Enabled bool `json:"enabled"` - IsManaged bool `json:"isManaged"` - Async bool `json:"async"` - SourcePath string `json:"sourcePath"` - TrustStatus string `json:"trustStatus"` -} - -func (hook nativeToolHook) isRuntimeToolEnvironmentHook() bool { - return hook.EventName == "preToolUse" && hook.Command == toolEnvironmentHookCommand && hook.Matcher == "^Bash$" && hook.Enabled && hook.IsManaged && !hook.Async && hook.SourcePath == toolEnvironmentHookSource && hook.TrustStatus == "managed" -} - -func readNativeToolHooks(ctx context.Context, rpc *JSONRPCClient, cwd string) ([]nativeToolHook, error) { - operation, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - raw, err := rpc.Request(operation, "hooks/list", map[string]any{"cwds": []string{cwd}}) - if err != nil { - return nil, errors.New("codex: initialized tool hook unavailable") - } - var response struct { - Data []struct { - CWD string `json:"cwd"` - Hooks []nativeToolHook `json:"hooks"` - Errors []json.RawMessage `json:"errors"` - } `json:"data"` - } - if json.Unmarshal(raw, &response) != nil || len(response.Data) != 1 || response.Data[0].CWD != cwd || len(response.Data[0].Errors) != 0 { - return nil, errors.New("codex: initialized tool hook configuration unavailable") - } - return response.Data[0].Hooks, nil -} - -func verifyToolEnvironmentHook(ctx context.Context, rpc *JSONRPCClient, cwd string) error { - hooks, err := readNativeToolHooks(ctx, rpc, cwd) - if err != nil { - return err - } - for _, hook := range hooks { - if hook.isRuntimeToolEnvironmentHook() { - return nil - } - } - return errors.New("codex: required initialized tool hook missing") -} - -func (s *Session) onToolEnvironmentHook(raw json.RawMessage) { - if !s.toolEnvironment { - return - } - var notification struct { - ThreadID string `json:"threadId"` - TurnID string `json:"turnId"` - Run struct { - SourcePath string `json:"sourcePath"` - Status string `json:"status"` - } `json:"run"` - } - if json.Unmarshal(raw, ¬ification) != nil || notification.Run.SourcePath != toolEnvironmentHookSource || !s.isRootTurn(notification.ThreadID, notification.TurnID) { - return - } - if notification.Run.Status == "failed" { - // Native hook process failures can run the original command before this - // notification. Stop subsequent work and report failure, never success. - s.emitTerminal("codex: initialized tool configuration failed; prior command effects may exist", true) - s.cancelFn() - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/tool_environment_test.go b/apps/parsar-daemon/internal/agent/codex/tool_environment_test.go deleted file mode 100644 index 376e27ed7..000000000 --- a/apps/parsar-daemon/internal/agent/codex/tool_environment_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestInitializedToolHookReadiness(t *testing.T) { - for _, mode := range []string{"ready", "empty", "disabled", "unmanaged", "async", "wrong command", "post hook", "wrong matcher", "wrong source", "untrusted", "errors"} { - t.Run(mode, func(t *testing.T) { - client, server, cleanup := NewTestClient() - defer cleanup() - ctx, cancel := context.WithTimeout(t.Context(), time.Second) - defer cancel() - done := make(chan error, 1) - go func() { done <- verifyToolEnvironmentHook(ctx, client.JSONRPCClient, "/workspace") }() - var request struct { - ID, Method string - Params struct { - CWDs []string `json:"cwds"` - } - } - if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { - t.Fatal(err) - } - if request.Method != "hooks/list" || len(request.Params.CWDs) != 1 || request.Params.CWDs[0] != "/workspace" { - t.Fatal("wrong hook lookup") - } - hook := map[string]any{"eventName": "preToolUse", "command": toolEnvironmentHookCommand, "matcher": "^Bash$", "enabled": true, "isManaged": true, "async": false, "sourcePath": toolEnvironmentHookSource, "trustStatus": "managed"} - entry := map[string]any{"cwd": "/workspace", "hooks": []any{hook}, "errors": []any{}} - switch mode { - case "empty": - entry["hooks"] = []any{} - case "post hook": - hook["eventName"] = "postToolUse" - case "wrong matcher": - hook["matcher"] = ".*" - case "wrong source": - hook["sourcePath"] = "/workspace/hooks" - case "untrusted": - hook["trustStatus"] = "untrusted" - case "disabled": - hook["enabled"] = false - case "unmanaged": - hook["isManaged"] = false - case "async": - hook["async"] = true - case "wrong command": - hook["command"] = "/workspace/untrusted" - case "errors": - entry["errors"] = []any{"cannot load hook"} - } - if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": map[string]any{"data": []any{entry}}}); err != nil { - t.Fatal(err) - } - if err := <-done; (err == nil) != (mode == "ready") { - t.Fatal("hook admission", mode, err) - } - go func() { done <- verifySubagentObservationProfile(ctx, client.JSONRPCClient, "/workspace") }() - if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { - t.Fatal(err) - } - if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": map[string]any{"data": []any{entry}}}); err != nil { - t.Fatal(err) - } - if mode == "ready" || mode == "empty" { - if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { - t.Fatal(err) - } - if request.Method != "configRequirements/read" { - t.Fatal(request.Method) - } - if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": map[string]any{"requirements": map[string]any{"allowManagedHooksOnly": true, "featureRequirements": map[string]bool{"hooks": true}}}}); err != nil { - t.Fatal(err) - } - } - - if err := <-done; (err == nil) != (mode == "ready" || mode == "empty") { - t.Fatal("subagent hook admission", mode, err) - } - - }) - } -} - -func TestInitializedToolHookFailureStopsRunWithoutSuccess(t *testing.T) { - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - out := make(chan proto.Envelope, 4) - s := &Session{toolEnvironment: true, runID: "run", cancelCtx: ctx, cancelFn: cancel, out: out, cfg: defaultSessionConfig(), bufs: NewItemBuffers()} - s.setThreadID("thread") - s.beginRootTurn("thread", "turn") - s.onToolEnvironmentHook(json.RawMessage(`{"threadId":"foreign","turnId":"turn","run":{"sourcePath":"/etc/codex/runtime-hooks","status":"failed"}}`)) - if s.terminal.Load() { - t.Fatal("foreign hook ended run") - } - s.onToolEnvironmentHook(json.RawMessage(`{"threadId":"thread","turnId":"turn","run":{"sourcePath":"/etc/codex/runtime-hooks","status":"failed"}}`)) - if !s.terminal.Load() || ctx.Err() == nil { - t.Fatal("failed hook did not stop execution") - } - if len(out) != 2 || (<-out).Type != proto.TypeError || (<-out).Type != proto.TypeDone { - t.Fatal("failed hook reported wrong terminal events") - } -} diff --git a/apps/parsar-daemon/internal/agent/mcode/environment_mcp.go b/apps/parsar-daemon/internal/agent/mcode/environment_mcp.go index c69396f3d..7fc33a999 100644 --- a/apps/parsar-daemon/internal/agent/mcode/environment_mcp.go +++ b/apps/parsar-daemon/internal/agent/mcode/environment_mcp.go @@ -31,7 +31,7 @@ func environmentMCP(local *proto.LocalEnvironment) ([]map[string]any, error) { } command, args := localworkspace.MCPStdioCommand(item) // The fixed launcher resolves the declaration and selected user variables - // inside the sandbox. Native process variables are never tool input. + // from the installed snapshot in the shared Runtime launcher. servers = append(servers, map[string]any{"name": name, "command": command, "args": args, "env": []map[string]string{}}) } return servers, nil diff --git a/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go b/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go index 3b78994a6..3b22c5517 100644 --- a/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go @@ -53,9 +53,10 @@ func TestEnvironmentMCPUsesFixedLauncherForNewAndLoadedSessions(t *testing.T) { t.Fatal("environment MCP displaced workspace tools") } server := params.MCP[1] - if server.Name != "proof.server" || server.Command != "/usr/bin/python3" || server.Env == nil || len(server.Env) != 0 || - !reflect.DeepEqual(server.Args, []string{"-I", "-S", "/usr/local/bin/oac-runtime-initialize", "stdio", "/private/runtime/capabilities", "/private/runtime/workspace", "plugins/fixture", "proof.server"}) { - t.Fatal("ACP declaration bypassed the fixed isolated launcher") + executable, _ := os.Executable() + if server.Name != "proof.server" || server.Command != executable || server.Env == nil || len(server.Env) != 0 || + !reflect.DeepEqual(server.Args, []string{"runtime-mcp-exec", "/private/runtime/capabilities", "plugins/fixture", "proof.server"}) { + t.Fatal("ACP declaration bypassed the shared Runtime launcher") } if (params.SessionID != "") != resume || strings.Contains(string(raw), "must-not") || strings.Contains(string(raw), "private-argument") { t.Fatal("native attachment changed identity or exposed private inputs") diff --git a/apps/parsar-daemon/internal/agent/mcode/execution.go b/apps/parsar-daemon/internal/agent/mcode/execution.go index 6a3eb80c4..4d2987310 100644 --- a/apps/parsar-daemon/internal/agent/mcode/execution.go +++ b/apps/parsar-daemon/internal/agent/mcode/execution.go @@ -48,16 +48,8 @@ func configureTextExecution(config map[string]any) { config["beta"] = map[string]bool{"browserUseTooling": false, "mcodeTools": false, "threadGoal": false} } -// Only process and model-network essentials cross into the native child. -func executionEnvironment() []string { - var env []string - for _, key := range []string{"PATH", "LANG", "LC_ALL", "TMPDIR", "TMP", "TEMP", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"} { - if value, ok := os.LookupEnv(key); ok { - env = append(env, key+"="+value) - } - } - return env -} +// Harness children use the daemon user's ordinary environment. +func executionEnvironment() []string { return os.Environ() } // ACP commands are only recognized for a single text block. Public input must // remain user text; ordinary product Sessions retain their native command behavior. diff --git a/apps/parsar-daemon/internal/agent/mcode/execution_test.go b/apps/parsar-daemon/internal/agent/mcode/execution_test.go index 3e704750e..083897f94 100644 --- a/apps/parsar-daemon/internal/agent/mcode/execution_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/execution_test.go @@ -17,7 +17,7 @@ func executionRequest(t *testing.T) proto.PromptRequestPayload { return r } -func TestExecutionOptionsExcludeAmbientAuthority(t *testing.T) { +func TestExecutionOptionsInheritUserEnvironment(t *testing.T) { r := executionRequest(t) t.Setenv("OAC_TEST_SECRET_CANARY", "secret") t.Setenv("NODE_OPTIONS", "--import=untrusted") @@ -25,11 +25,10 @@ func TestExecutionOptionsExcludeAmbientAuthority(t *testing.T) { if err != nil { t.Fatal(err) } - for _, e := range opts.Env { - if strings.HasPrefix(e, "OAC_TEST_SECRET_CANARY=") || strings.HasPrefix(e, "NODE_OPTIONS=") { - t.Fatal("ambient authority inherited") - } + if !strings.Contains(strings.Join(opts.Env, "\n"), "OAC_TEST_SECRET_CANARY=secret") { + t.Fatal("user environment lost") } + data, err := os.ReadFile(filepath.Join(opts.DataDir, "config.yaml")) if err != nil { t.Fatal(err) diff --git a/apps/parsar-daemon/internal/agent/mcode/executor.go b/apps/parsar-daemon/internal/agent/mcode/executor.go index 632bb6a40..ed1b42c69 100644 --- a/apps/parsar-daemon/internal/agent/mcode/executor.go +++ b/apps/parsar-daemon/internal/agent/mcode/executor.go @@ -32,7 +32,6 @@ func NewExecutorFactory(config *WorkspaceConfig) agent.ExecutorFactory { var frozen *WorkspaceConfig if config != nil { value := *config - value.ProtectedDirs = append([]string(nil), config.ProtectedDirs...) value.AllowedDomains = append([]string(nil), config.AllowedDomains...) frozen = &value } diff --git a/apps/parsar-daemon/internal/agent/mcode/preparation_test.go b/apps/parsar-daemon/internal/agent/mcode/preparation_test.go index 65ea1a87e..4fe282689 100644 --- a/apps/parsar-daemon/internal/agent/mcode/preparation_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/preparation_test.go @@ -18,7 +18,7 @@ func workspaceFixture(t *testing.T) (WorkspaceConfig, proto.PromptRequestPayload r := executionRequest(t) r.RunID, r.Input, r.ConversationID = "", nil, "" r.DisableExecutionEnvironment = false - r.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "disabled"} + r.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "enabled"} r.WorkDir = t.TempDir() record := filepath.Join(t.TempDir(), "calls") exe, err := os.Executable() @@ -31,7 +31,7 @@ func workspaceFixture(t *testing.T) (WorkspaceConfig, proto.PromptRequestPayload if err := os.WriteFile(binary, []byte(script), 0700); err != nil { t.Fatal(err) } - return WorkspaceConfig{Binary: binary, Node: "/usr/bin/node", Bridge: "/opt/bridge.mjs", Directory: r.WorkDir, Network: "disabled", Scratch: t.TempDir(), ProtectedDirs: []string{os.Getenv("OAC_RUNTIME_HOME")}}, r, record + return WorkspaceConfig{Binary: binary, Node: "/usr/bin/node", Bridge: "/opt/bridge.mjs", Directory: r.WorkDir, Network: "enabled", Scratch: t.TempDir()}, r, record } func TestPreparedWorkspaceHasOneInputAndOutputOwner(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace.go b/apps/parsar-daemon/internal/agent/mcode/workspace.go index 79ed46902..78a72e57d 100644 --- a/apps/parsar-daemon/internal/agent/mcode/workspace.go +++ b/apps/parsar-daemon/internal/agent/mcode/workspace.go @@ -6,8 +6,7 @@ import ( "fmt" "os" "path/filepath" - "slices" - "strings" + "runtime" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" @@ -16,15 +15,21 @@ import ( // WorkspaceConfig is frozen deployment input, separate from public Agent options. type WorkspaceConfig struct { + ExecutionMode string Binary, Node, Bridge, Directory, Network, Scratch string - ProtectedDirs []string AllowedDomains []string } func ConfigureLocal(binary, node, bridge, root, workspace string, network agentnetwork.Policy, staging string) (WorkspaceConfig, error) { c := WorkspaceConfig{Binary: binary, Node: node, Bridge: bridge, Directory: workspace, Network: network.Access, AllowedDomains: network.Hosts(), - Scratch: filepath.Join(root, "runtime", "mcode-tools", "scratch"), - ProtectedDirs: []string{filepath.Join(root, "daemon"), filepath.Join(root, "runtime", "mcode"), filepath.Dir(workspace), staging}} + Scratch: filepath.Join(root, "runtime", "mcode-tools", "scratch")} + binding, bindingErr := localworkspace.Load() + if bindingErr == nil && binding != nil { + c.ExecutionMode = binding.ExecutionMode() + } + if runtime.GOOS == "windows" || network.Access != "enabled" || len(network.AllowedDomains) != 0 { + return c, fmt.Errorf("mcode: native execution requires Linux or macOS and unrestricted host access") + } if network.Validate() != nil { return c, fmt.Errorf("mcode: explicit workspace network policy is required") } @@ -34,16 +39,16 @@ func ConfigureLocal(binary, node, bridge, root, workspace string, network agentn } } for _, path := range []string{binary, node, bridge} { - resolved, err := filepath.EvalSymlinks(path) - if err != nil || resolved != path || strings.HasPrefix(path, workspace+"/") || strings.HasPrefix(path, "/workspace/") { - return c, fmt.Errorf("mcode: runtime programs must be canonical paths outside the workspace") + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + return c, fmt.Errorf("mcode: runtime program unavailable") } } bound, err := os.Stat(workspace) - public, publicErr := os.Stat("/workspace") - if err != nil || publicErr != nil || !bound.IsDir() || !os.SameFile(bound, public) { - return c, fmt.Errorf("mcode: public workspace alias does not match the Runtime binding") + if err != nil || !bound.IsDir() { + return c, fmt.Errorf("mcode: workspace directory unavailable") } + if err := os.MkdirAll(c.Scratch, 0700); err != nil { return c, err } @@ -51,6 +56,12 @@ func ConfigureLocal(binary, node, bridge, root, workspace string, network agentn } func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.PromptRequestPayload) (launchOptions, error) { + if req.LocalEnvironment != nil && req.LocalEnvironment.ExecutionMode != c.ExecutionMode { + return launchOptions{}, fmt.Errorf("mcode: execution mode mismatch") + } + if c.Network != "enabled" || len(c.AllowedDomains) != 0 { + return launchOptions{}, fmt.Errorf("mcode: Runtime does not implement network isolation") + } if !req.StrictResume || req.LocalEnvironment == nil || req.WorkDir != c.Directory || req.DisableExecutionEnvironment || !(agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Equal(agentnetwork.Policy{Access: req.LocalEnvironment.NetworkAccess, AllowedDomains: req.LocalEnvironment.AllowedDomains}) || req.WorkspaceReadOnly { return launchOptions{}, fmt.Errorf("mcode: execution does not match the dedicated workspace") } @@ -103,18 +114,21 @@ func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.P if err = os.WriteFile(filepath.Join(opts.DataDir, "config.yaml"), raw, 0600); err != nil { return opts, err } - profile := map[string]any{"capabilityRoot": req.LocalEnvironment.CapabilityRoot, "workspace": "/workspace", "scratch": c.Scratch, "network": c.Network, "allowedDomains": (agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Hosts(), "protectedDirs": slices.Clone(c.ProtectedDirs), "skills": len(req.LocalEnvironment.Skills) > 0} + profile := map[string]any{"capabilityRoot": req.LocalEnvironment.CapabilityRoot, "workspace": "/workspace", "scratch": c.Scratch, "network": c.Network, "allowedDomains": (agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Hosts(), "skills": len(req.LocalEnvironment.Skills) > 0} + + profile["workspace"] = c.Directory if req.LocalEnvironment.ToolEnvironment { - if err := localworkspace.VerifyToolEnvironment(req.LocalEnvironment.SystemPackages); err != nil { + binding, err := localworkspace.Load() + if err != nil || binding == nil { + return opts, fmt.Errorf("mcode: Runtime binding unavailable") + } + values, err := binding.ReadToolEnvironment() + if err != nil { return opts, err } - profile["toolEnvironment"] = true - profile["systemPackages"] = req.LocalEnvironment.SystemPackages - // Initialization exposes only user env/packages; private staging and - // daemon/native history remain explicitly denied. - protected := slices.Clone(c.ProtectedDirs) - profile["protectedDirs"] = slices.DeleteFunc(protected, func(path string) bool { return path == "/environment" }) + profile["toolEnv"] = values } + raw, err = json.Marshal(profile) if err != nil { return opts, err diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace_network_test.go b/apps/parsar-daemon/internal/agent/mcode/workspace_network_test.go index 199e6e857..a42d387cd 100644 --- a/apps/parsar-daemon/internal/agent/mcode/workspace_network_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/workspace_network_test.go @@ -1,38 +1,14 @@ package mcode -import ( - "encoding/json" - "os" - "path/filepath" - "slices" - "testing" -) +import "testing" -func TestRestrictedWorkspacePolicyUsesExactBoundAuthority(t *testing.T) { - config, req, _ := workspaceFixture(t) - config.Network = "restricted" - config.AllowedDomains = []string{"Example.com", "api.example.com", "example.com"} - req.LocalEnvironment.NetworkAccess = "restricted" - req.LocalEnvironment.AllowedDomains = []string{"api.example.com", "EXAMPLE.COM"} - opts, err := prepareWorkspaceOptions(t.Context(), config, req) - if err != nil { - t.Fatal(err) - } - raw, err := os.ReadFile(filepath.Join(opts.DataDir, "workspace-profile.json")) - if err != nil { - t.Fatal(err) - } - var profile struct { - Network string `json:"network"` - Domains []string `json:"allowedDomains"` - } - if err := json.Unmarshal(raw, &profile); err != nil || profile.Network != "restricted" || !slices.Equal(profile.Domains, []string{"api.example.com", "example.com"}) { - t.Fatal("native policy lost bound authority", profile, err) - } - for _, domains := range [][]string{{"example.com"}, {"example.org"}, nil} { - req.LocalEnvironment.AllowedDomains = domains - if _, err := prepareWorkspaceOptions(t.Context(), config, req); err == nil { - t.Fatal("different policy entered bound Runtime", domains) +func TestWorkspaceRejectsInnerNetworkIsolation(t *testing.T) { + for _, access := range []string{"disabled", "restricted"} { + c, req, _ := workspaceFixture(t) + c.Network = access + req.LocalEnvironment.NetworkAccess = access + if _, err := prepareWorkspaceOptions(t.Context(), c, req); err == nil { + t.Fatal("unsupported network isolation accepted") } } } diff --git a/apps/parsar-daemon/internal/auth/store.go b/apps/parsar-daemon/internal/auth/store.go index 910e63305..1dea39040 100644 --- a/apps/parsar-daemon/internal/auth/store.go +++ b/apps/parsar-daemon/internal/auth/store.go @@ -11,10 +11,10 @@ import ( "errors" "fmt" "os" - "path/filepath" "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" ) // Profile is the on-disk representation of one paired credential. @@ -68,21 +68,22 @@ func Save(profile string, p Profile) error { if err != nil { return err } - authFile := filepath.Join(dir, "auth.json") - tmp := authFile + ".tmp" raw, err := json.MarshalIndent(p, "", " ") if err != nil { - return fmt.Errorf("auth: marshal profile: %w", err) + return errors.New("auth: could not encode profile") } - // os.WriteFile with the final mode in one shot, so umask is - // irrelevant. - if err := os.WriteFile(tmp, raw, 0o600); err != nil { - return fmt.Errorf("auth: write tmp: %w", err) + if err = runtimefs.EnsurePrivateDir(dir); err != nil { + return errors.New("auth: private profile unavailable") } - if err := os.Rename(tmp, authFile); err != nil { - _ = os.Remove(tmp) - return fmt.Errorf("auth: rename: %w", err) + held, err := os.OpenRoot(dir) + if err != nil { + return errors.New("auth: private profile unavailable") + } + defer held.Close() + if err = runtimefs.WritePrivateAtomic(held, "auth.json", raw); err != nil { + return errors.New("auth: private profile write failed") } + return nil } diff --git a/apps/parsar-daemon/internal/auth/store_test.go b/apps/parsar-daemon/internal/auth/store_test.go index 0f87ba2ae..f9d28e404 100644 --- a/apps/parsar-daemon/internal/auth/store_test.go +++ b/apps/parsar-daemon/internal/auth/store_test.go @@ -5,6 +5,7 @@ import ( "io/fs" "os" "path/filepath" + "runtime" "testing" "time" @@ -60,9 +61,9 @@ func TestSaveSetsRestrictivePerms(t *testing.T) { if err != nil { t.Fatalf("stat auth.json: %v", err) } - // auth.json holds the long-lived runner_credential — anything - // but 0600 leaks it on a shared CI box. - if mode := info.Mode().Perm(); mode != 0o600 { + // Unix stores credentials with 0600; Windows uses normal account ACLs, + // which are not represented by Go permission bits. + if mode := info.Mode().Perm(); runtime.GOOS != "windows" && mode != 0o600 { t.Errorf("auth.json perm = %o, want 0600", mode) } } @@ -109,7 +110,7 @@ func TestSaveOverwritesAndHealsPerms(t *testing.T) { if err != nil { t.Fatalf("stat: %v", err) } - if mode := info.Mode().Perm(); mode != 0o600 { + if mode := info.Mode().Perm(); runtime.GOOS != "windows" && mode != 0o600 { t.Errorf("perm after re-save = %o, want 0600 (healing failed)", mode) } got, err := auth.Load("default") diff --git a/apps/parsar-daemon/internal/cli/claude_sdk.go b/apps/parsar-daemon/internal/cli/claude_sdk.go index 746288935..88b0e461f 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk.go @@ -60,14 +60,11 @@ 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("OAC_RUNTIME_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")) - } + binding, err := localworkspace.Load() + if err != nil { + return fail(err) + } + if binding != nil { root, err := paths.Root() if err != nil { return fail(err) @@ -94,7 +91,7 @@ func discoverClaudeSDK(rc *runContext, profile string, check func(context.Contex } caps := &out.Info.Capabilities caps.EnvironmentNone, caps.FunctionTools = false, info.SupportsWorkspaceFunctions() - caps.Preparation, caps.LocalEnvironment, caps.LocalEnvironmentNetworkPolicy = true, true, true + caps.Preparation, caps.LocalEnvironment = true, true caps.WorkspaceReadPreparation, caps.NativeSessionRecovery = true, true } out.Info.Available, out.Info.Version = true, info.SDK diff --git a/apps/parsar-daemon/internal/cli/connect.go b/apps/parsar-daemon/internal/cli/connect.go index 61f0ebe4a..ad2b67ad1 100644 --- a/apps/parsar-daemon/internal/cli/connect.go +++ b/apps/parsar-daemon/internal/cli/connect.go @@ -6,9 +6,7 @@ import ( "fmt" "log/slog" "os" - "os/signal" "strings" - "syscall" "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" @@ -259,22 +257,9 @@ func mainLoopRemote(rc *runContext, profile string, prof auth.Profile, agentCLIs Out: rc.stderr, }) - rootCtx, cancel := context.WithCancel(context.Background()) + rootCtx, cancel := daemonize.NotifyContext(context.Background()) defer cancel() - // Honour SIGINT / SIGTERM as graceful shutdown. - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - go func() { - select { - case sig := <-sigCh: - obslog.Bg().Info("received signal, shutting down", "signal", sig.String()) - cancel() - case <-rootCtx.Done(): - } - signal.Stop(sigCh) - }() - bootCtx, bootCancel := context.WithTimeout(rootCtx, bootstrapTimeout) var boot *transport.BootstrapResponse var err error diff --git a/apps/parsar-daemon/internal/cli/connect_environment.go b/apps/parsar-daemon/internal/cli/connect_environment.go index bfdb5a263..72ea39eb7 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment.go +++ b/apps/parsar-daemon/internal/cli/connect_environment.go @@ -11,9 +11,7 @@ import ( "net/http" "net/url" "os" - "os/signal" "strings" - "syscall" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/daemonize" @@ -207,7 +205,7 @@ func environmentRejection(err error, keyID, environment string, selfHosted bool) // restarts every exit, so an exit would loop; a parked Runtime still restarts // after a reboot, makes one enrollment request and parks again. func parkEnvironment(stderr io.Writer, message string) error { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + ctx, stop := daemonize.NotifyContext(context.Background()) defer stop() fmt.Fprintln(stderr, "oac-daemon: "+message) <-ctx.Done() diff --git a/apps/parsar-daemon/internal/cli/connect_environment_binding.go b/apps/parsar-daemon/internal/cli/connect_environment_binding.go index 3eb887058..9d79bed2f 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment_binding.go +++ b/apps/parsar-daemon/internal/cli/connect_environment_binding.go @@ -1,17 +1,15 @@ package cli import ( - "bytes" "encoding/json" "errors" - "io" "os" "path/filepath" - "syscall" "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/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" ) // This receipt contains identity only; executor credentials remain in their file. @@ -40,27 +38,7 @@ func checkEnvironmentTarget(remote, environment string) error { } func readEnvironmentPrivateFile(path string) ([]byte, error) { - if !filepath.IsAbs(path) || filepath.Clean(path) != path { - return nil, errors.New("absolute private file required") - } - f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) - if err != nil { - return nil, err - } - defer f.Close() - info, err := f.Stat() - if err != nil { - return nil, err - } - st, ok := info.Sys().(*syscall.Stat_t) - if !ok || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || st.Uid != uint32(os.Getuid()) || st.Nlink != 1 { - return nil, errors.New("private owned regular file required") - } - raw, err := io.ReadAll(io.LimitReader(f, 16*1024+1)) - if err != nil || len(raw) > 16*1024 { - return nil, errors.New("private file exceeds limit") - } - return raw, nil + return runtimefs.ReadPrivatePath(path, 16*1024) } func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credentialFile string) error { @@ -68,30 +46,20 @@ func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credenti if err != nil || !filepath.IsAbs(root) { return errors.New("connect: absolute Runtime state directory required") } - if err = os.MkdirAll(root, 0700); err != nil { + if err = runtimefs.EnsurePrivateDir(root); err != nil { return errors.New("connect: Runtime state directory unavailable") } workspace := os.Getenv("OAC_RUNTIME_WORKSPACE") if workspace == "/" || agentcapabilities.ValidateLocalDirectories([]string{workspace}) != nil { return errors.New("connect: clean absolute Runtime workspace required") } - canonical, err := filepath.EvalSymlinks(workspace) info, statErr := os.Stat(workspace) - if err != nil || canonical != workspace || statErr != nil || !info.IsDir() { - return errors.New("connect: existing canonical Runtime workspace directory required") + if statErr != nil || !info.IsDir() { + return errors.New("connect: existing Runtime workspace required") } if bound.WorkspaceDirectory != "/workspace" && bound.WorkspaceDirectory != workspace { return errors.New("connect: enrollment does not match the Runtime workspace") } - initialization := localworkspace.InitializationDirectory - if resolved, resolveErr := filepath.EvalSymlinks(initialization); resolveErr == nil { - initialization = resolved - } else if !errors.Is(resolveErr, os.ErrNotExist) { - return errors.New("connect: protected initialization directory unavailable") - } - if environmentPathsOverlap(workspace, initialization) { - return errors.New("connect: Runtime workspace overlaps protected initialization") - } for key, value := range map[string]string{ "OAC_RUNTIME_ENVIRONMENT_ID": bound.EnvironmentID, "OAC_RUNTIME_SESSION_ID": bound.SessionID, @@ -104,30 +72,6 @@ func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credenti if domains := os.Getenv("OAC_RUNTIME_ALLOWED_DOMAINS"); domains != "" && domains != "[]" { return errors.New("connect: conflicting Runtime network domains") } - resolvedRoot, err := filepath.Abs(root) - if err != nil { - return errors.New("connect: Runtime state directory unavailable") - } - for _, path := range []string{resolvedRoot, credentialFile} { - resolved, e := filepath.EvalSymlinks(path) - if e != nil { - return errors.New("connect: private Runtime path unavailable") - } - for _, public := range []string{"/workspace", workspace} { - if environmentPathsOverlap(public, resolved) { - return errors.New("connect: private Runtime state cannot overlap the workspace") - } - } - } - private, err := filepath.EvalSymlinks(filepath.Join(root, "daemon")) - credentialPath, credentialErr := filepath.EvalSymlinks(credentialFile) - if err != nil || credentialErr != nil { - return errors.New("connect: protected daemon credential directory required") - } - relative, err := filepath.Rel(private, credentialPath) - if err != nil || !filepath.IsLocal(relative) || relative == "." { - return errors.New("connect: executor credential must be inside the protected daemon directory") - } capabilityDirectory := os.Getenv("OAC_RUNTIME_CAPABILITY_DIRECTORY") if capabilityDirectory == "" { capabilityDirectory = localworkspace.CapabilityDirectory @@ -146,7 +90,7 @@ func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credenti } local, err := localworkspace.Load() if err != nil || local == nil { - return errors.New("connect: packaged Runtime binding or helpers unavailable") + return errors.New("connect: Runtime binding unavailable") } return nil } @@ -163,19 +107,20 @@ func environmentPathsOverlap(first, second string) bool { } func saveEnvironmentBinding(root string, want environmentBinding) error { - // All three native sandboxes protect this existing daemon state directory. + // Store the binding with the other daemon state. dir := filepath.Join(root, "daemon") - if err := os.MkdirAll(dir, 0700); err != nil { + if err := runtimefs.EnsurePrivateDir(dir); err != nil { return errors.New("connect: Runtime state directory unavailable") } for _, path := range []string{root, dir} { - info, e := os.Lstat(path) - if e != nil || !info.IsDir() || info.Mode().Perm()&0077 != 0 { - return errors.New("connect: Runtime state directory must be private") + held, err := os.OpenRoot(path) + if err != nil { + return errors.New("connect: Runtime state directory unavailable") } - st, ok := info.Sys().(*syscall.Stat_t) - if !ok || st.Uid != uint32(os.Getuid()) { - return errors.New("connect: Runtime state directory must be owned") + err = runtimefs.PrivateDirectory(held) + held.Close() + if err != nil { + return errors.New("connect: Runtime state directory must be private and owned") } } path := filepath.Join(dir, "environment.json") @@ -206,14 +151,19 @@ func saveEnvironmentBinding(root string, want environmentBinding) error { } } data, _ := json.Marshal(want) - f, e := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, 0600) + held, e := os.OpenRoot(dir) + if e != nil { + return errors.New("connect: Runtime binding directory unavailable") + } + defer held.Close() + f, e := runtimefs.OpenPrivate(held, "environment.json", os.O_WRONLY|os.O_CREATE|os.O_EXCL) if errors.Is(e, os.ErrExist) { return saveEnvironmentBinding(root, want) } if e != nil { return errors.New("connect: could not establish Runtime binding") } - _, e = io.Copy(f, bytes.NewReader(data)) + _, e = f.Write(data) if e == nil { e = f.Sync() } diff --git a/apps/parsar-daemon/internal/cli/connect_environment_binding_test.go b/apps/parsar-daemon/internal/cli/connect_environment_binding_test.go index b9223181e..aedd3ce06 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment_binding_test.go +++ b/apps/parsar-daemon/internal/cli/connect_environment_binding_test.go @@ -75,7 +75,7 @@ func TestBindEnvironmentRuntimeUsesCanonicalOperatorWorkspace(t *testing.T) { } } -func TestBindEnvironmentRuntimeRejectsUnsafeOrMismatchedWorkspace(t *testing.T) { +func TestBindEnvironmentRuntimeChecksIdentityAndPathValidity(t *testing.T) { for _, kind := range []string{"relative", "unclean", "missing", "file", "symlink", "parent symlink", "workspace contains private", "workspace inside private", "foreign enrollment", "credential outside private", "identity conflict"} { t.Run(kind, func(t *testing.T) { root, workspace, credential, bound := environmentRuntimeFixture(t) @@ -125,12 +125,21 @@ func TestBindEnvironmentRuntimeRejectsUnsafeOrMismatchedWorkspace(t *testing.T) t.Setenv("OAC_RUNTIME_ENVIRONMENT_ID", uuid.NewString()) } t.Setenv("OAC_RUNTIME_WORKSPACE", selected) - if err := bindEnvironmentRuntime("wss://core/api/v1/agent-daemon/ws", bound, credential); err == nil { - t.Fatal("unsafe binding accepted") - } - if _, err := os.Lstat(filepath.Join(root, "daemon", "environment.json")); !os.IsNotExist(err) { - t.Fatal("invalid binding published identity", err) + allowed := kind == "symlink" || kind == "parent symlink" || kind == "workspace contains private" || kind == "workspace inside private" || kind == "credential outside private" + err := bindEnvironmentRuntime("wss://core/api/v1/agent-daemon/ws", bound, credential) + if allowed { + if err != nil { + t.Fatal("operator layout rejected", err) + } + } else { + if err == nil { + t.Fatal("invalid binding accepted") + } + if _, err := os.Stat(filepath.Join(root, "daemon", "environment.json")); !os.IsNotExist(err) { + t.Fatal("invalid binding published identity", err) + } } + }) } } diff --git a/apps/parsar-daemon/internal/cli/connect_environment_test.go b/apps/parsar-daemon/internal/cli/connect_environment_test.go index 7cefe7261..2415ff942 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment_test.go +++ b/apps/parsar-daemon/internal/cli/connect_environment_test.go @@ -138,14 +138,14 @@ func TestExecutorCredentialFile(t *testing.T) { if err := os.Symlink(path, link); err != nil { t.Fatal(err) } - if _, _, err := executorCredential(link, environment); err == nil { - t.Fatal("symlink accepted") + if _, _, err := executorCredential(link, environment); err != nil { + t.Fatal("operator symlink rejected", err) } if err := os.Chmod(path, 0644); err != nil { t.Fatal(err) } - if _, _, err := executorCredential(path, environment); err == nil { - t.Fatal("public credential accepted") + if _, _, err := executorCredential(path, environment); err != nil { + t.Fatal("operator permissions rejected", err) } } @@ -186,7 +186,7 @@ func TestEnvironmentBindingPreservesIdentityAndHistory(t *testing.T) { } } -func TestEnvironmentBindingAllowsPackagedFilesAndRejectsUnsafeReceipt(t *testing.T) { +func TestEnvironmentBindingAllowsOperatorFilePermissions(t *testing.T) { root := t.TempDir() if err := os.Chmod(root, 0700); err != nil { t.Fatal(err) @@ -202,8 +202,8 @@ func TestEnvironmentBindingAllowsPackagedFilesAndRejectsUnsafeReceipt(t *testing if err := os.Chmod(path, 0644); err != nil { t.Fatal(err) } - if err := saveEnvironmentBinding(root, want); err == nil { - t.Fatal("public receipt accepted") + if err := saveEnvironmentBinding(root, want); err != nil { + t.Fatal("operator permissions rejected", err) } } diff --git a/apps/parsar-daemon/internal/cli/connect_suspend_test.go b/apps/parsar-daemon/internal/cli/connect_suspend_test.go index 072c1fc78..80e1961c5 100644 --- a/apps/parsar-daemon/internal/cli/connect_suspend_test.go +++ b/apps/parsar-daemon/internal/cli/connect_suspend_test.go @@ -1,3 +1,5 @@ +//go:build linux + package cli import ( diff --git a/apps/parsar-daemon/internal/cli/mcode_workspace.go b/apps/parsar-daemon/internal/cli/mcode_workspace.go index 9e7d45f8d..50b19078e 100644 --- a/apps/parsar-daemon/internal/cli/mcode_workspace.go +++ b/apps/parsar-daemon/internal/cli/mcode_workspace.go @@ -14,21 +14,20 @@ import ( ) func discoverMCodeWorkspace(rc *runContext, discovery *agentCLIDiscovery) { - mode := os.Getenv("OAC_RUNTIME_MCODE_WORKSPACE") - if mode == "" { - return - } fail := func(err error) { discovery.MCode.Available = false fmt.Fprintf(rc.stderr, "oac-daemon: mcode workspace unavailable: %v\n", err) } - if mode != "managed" || !discovery.MCode.Available || !mcode.SupportsExecution(discovery.MCode.Version) { - fail(fmt.Errorf("managed execution requires the qualified native version and opt-in")) + binding, err := localworkspace.Load() + if err != nil { + fail(err) + return + } + if binding == nil { return } - binding, err := localworkspace.Load() - if err != nil || binding == nil { - fail(fmt.Errorf("dedicated local Runtime binding required")) + if !discovery.MCode.Available || !mcode.SupportsExecution(discovery.MCode.Version) { + fail(fmt.Errorf("local execution requires the qualified native version")) return } root, err := paths.Root() @@ -71,6 +70,6 @@ func discoverMCodeWorkspace(rc *runContext, discovery *agentCLIDiscovery) { discovery.MCodeWorkspace = &c caps := &discovery.MCode.Capabilities caps.EnvironmentNone = false - caps.Preparation, caps.LocalEnvironment, caps.LocalEnvironmentNetworkPolicy = true, true, true + caps.Preparation, caps.LocalEnvironment = true, true caps.WorkspaceReadPreparation = true } diff --git a/apps/parsar-daemon/internal/cli/native_install.go b/apps/parsar-daemon/internal/cli/native_install.go new file mode 100644 index 000000000..2b55e8cf2 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_install.go @@ -0,0 +1,140 @@ +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" +) + +// Native installation is a user-owned configuration, not an OS service or sandbox. +// The same binary can be run by an operator's preferred service manager. +type nativeInstallation struct { + Version string `json:"version"` + Remote string `json:"remote_url"` + Environment string `json:"environment_id"` + Workspace string `json:"workspace_directory"` + Credential string `json:"credential_file"` + CapabilityDirectory string `json:"capability_directory"` + ToolEnvironmentFile string `json:"tool_environment_file,omitempty"` +} + +func nativeInstallationPath() (string, error) { + root, err := paths.Root() + if err != nil { + return "", err + } + return filepath.Join(root, "daemon", "installation.json"), nil +} + +func runInstall(rc *runContext, args []string) error { + flags := newFlagSet("install") + remote := flags.String("remote", "", "Environment remote_url from Core") + environment := flags.String("environment-id", "", "Environment ID from Core") + workspace := flags.String("workspace", "", "existing absolute workspace directory") + credential := flags.String("credential-file", "", "absolute executor credential JSON file") + capabilities := flags.String("capability-directory", "", "installed capability snapshot directory (defaults to Runtime state)") + toolEnv := flags.String("tool-env-file", "", "optional absolute JSON file with tool environment variables") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return errors.New("install: unexpected positional arguments") + } + if _, err := environmentBase(*remote); err != nil { + return err + } + if !environmentUUID(*environment) { + return errors.New("install: canonical Environment ID required") + } + root, err := paths.Root() + if err != nil { + return err + } + if *capabilities == "" { + *capabilities = filepath.Join(root, "capabilities") + } + for _, path := range []string{*workspace, *credential, *capabilities} { + if runtimefs.ValidateLocalPath(path) != nil { + return errors.New("install: clean absolute paths required") + } + } + info, err := os.Stat(*workspace) + if err != nil || !info.IsDir() { + return errors.New("install: existing workspace directory required") + } + if _, _, err = executorCredential(*credential, *environment); err != nil { + return err + } + if *toolEnv != "" && runtimefs.ValidateLocalPath(*toolEnv) != nil { + return errors.New("install: absolute tool environment file required") + } + config := nativeInstallation{Version: Version, Remote: *remote, Environment: *environment, Workspace: *workspace, Credential: *credential, CapabilityDirectory: *capabilities, ToolEnvironmentFile: *toolEnv} + path, err := nativeInstallationPath() + if err != nil { + return err + } + if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + held, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return err + } + defer held.Close() + unlock, err := runtimefs.LockDirectory(held) + if err != nil { + return err + } + defer unlock() + if _, err = held.Stat(filepath.Base(path)); err == nil { + return errors.New("install: installation already exists; in-place upgrades are unsupported, use its current binary or reinstall explicitly") + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + raw, _ := json.MarshalIndent(config, "", " ") + if err = runtimefs.WritePrivateAtomic(held, filepath.Base(path), raw); err != nil { + return err + } + fmt.Fprintln(rc.stdout, "Installed daemon configuration:", path) + fmt.Fprintln(rc.stdout, "Run oac-daemon start; use stop, status and logs with the same OAC_RUNTIME_HOME.") + return nil +} + +func runStart(rc *runContext, args []string) error { + flags := newFlagSet("start") + foreground := flags.Bool("foreground", false, "stay attached to this terminal") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return errors.New("start: unexpected positional arguments") + } + path, err := nativeInstallationPath() + if err != nil { + return err + } + raw, err := os.ReadFile(path) + var config nativeInstallation + if err != nil || decodeEnvironmentJSON(raw, &config) != nil { + return errors.New("start: installation configuration unavailable; run install first") + } + if config.Version != Version { + return errors.New("start: this installation version is unsupported; reinstall explicitly") + } + for key, value := range map[string]string{ + "OAC_RUNTIME_EXECUTION_MODE": "native", + "OAC_RUNTIME_WORKSPACE": config.Workspace, + "OAC_RUNTIME_CAPABILITY_DIRECTORY": config.CapabilityDirectory, + "OAC_RUNTIME_TOOL_ENV_FILE": config.ToolEnvironmentFile, + } { + if err = os.Setenv(key, value); err != nil { + return err + } + } + return runEnvironmentConnect(rc, paths.DefaultProfile, !*foreground, config.Remote, config.Environment, config.Credential, false) +} diff --git a/apps/parsar-daemon/internal/cli/native_install_test.go b/apps/parsar-daemon/internal/cli/native_install_test.go new file mode 100644 index 000000000..21d21403f --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_install_test.go @@ -0,0 +1,59 @@ +package cli + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/google/uuid" +) + +func TestNativeInstallationPersistsWithoutStartingOrDeleting(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + workspace := filepath.Join(root, "workspace") + if err := os.MkdirAll(workspace, 0700); err != nil { + t.Fatal(err) + } + key := filepath.Join(root, "credential.json") + environment := uuid.NewString() + body, _ := json.Marshal(map[string]string{"key_id": uuid.NewString(), "executor_token": "test-token", "environment_id": environment}) + if err := os.WriteFile(key, body, 0600); err != nil { + t.Fatal(err) + } + output := new(bytes.Buffer) + rc := &runContext{stdout: output, stderr: output} + args := []string{"--remote", "ws://localhost:12345/api/v1/agent-daemon/ws", "--environment-id", environment, "--workspace", workspace, "--credential-file", key} + if err := runInstall(rc, args); err != nil { + t.Fatal(err) + } + path, _ := nativeInstallationPath() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var installed nativeInstallation + if json.Unmarshal(raw, &installed) != nil || installed.Environment != environment || installed.Workspace != workspace { + t.Fatal("installation did not persist its configuration") + } + if err := runInstall(rc, args); err == nil { + t.Fatal("silently replaced existing installation") + } + again, _ := os.ReadFile(path) + if !bytes.Equal(raw, again) { + t.Fatal("reinstall changed existing data") + } + if _, err := os.Stat(key); err != nil { + t.Fatal("credential file removed") + } + installed.Version = "unsupported-old-version" + invalid, _ := json.Marshal(installed) + if err := os.WriteFile(path, invalid, 0600); err != nil { + t.Fatal(err) + } + if err := runStart(rc, []string{"--foreground"}); err == nil { + t.Fatal("old installation accepted") + } +} diff --git a/apps/parsar-daemon/internal/cli/root.go b/apps/parsar-daemon/internal/cli/root.go index 46f72f553..441da1bc7 100644 --- a/apps/parsar-daemon/internal/cli/root.go +++ b/apps/parsar-daemon/internal/cli/root.go @@ -33,8 +33,10 @@ func defaultRunContext() *runContext { // commands lists subcommands in --help render order: the user's // likely flow connect → status → stop / logs → logout. var commands = []command{ + {name: "install", summary: "Configure a native self-hosted daemon", run: runInstall}, + {name: "start", summary: "Start the installed native daemon", run: runStart}, {name: "resume", summary: "Wake one planned hosted suspension", run: runResume}, - {name: "runtime-mcp-exec", summary: "Execute installed MCP inside the packaged Runtime sandbox", run: runRuntimeMCP}, + {name: "runtime-mcp-exec", summary: "Execute an installed MCP server", run: runRuntimeMCP}, {name: "placement", summary: "Enroll or retire an explicitly managed local execution placement", run: runPlacement}, {name: "connect", summary: "Pair, open the reverse WebSocket, and start serving prompts", run: runConnect}, {name: "status", summary: "Print the paired profile and daemon state", run: runStatus}, diff --git a/apps/parsar-daemon/internal/cli/root_test.go b/apps/parsar-daemon/internal/cli/root_test.go index 77f1be614..c27176d35 100644 --- a/apps/parsar-daemon/internal/cli/root_test.go +++ b/apps/parsar-daemon/internal/cli/root_test.go @@ -60,6 +60,8 @@ func TestSubcommandsAreRegistered(t *testing.T) { // Guards against dropping a subcommand off the commands slice — // the public CLI surface is the shipped contract. want := map[string]bool{ + "install": false, + "start": false, "resume": false, "runtime-mcp-exec": false, "placement": false, diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp.go b/apps/parsar-daemon/internal/cli/runtime_mcp.go index fcd030536..dd78bc0af 100644 --- a/apps/parsar-daemon/internal/cli/runtime_mcp.go +++ b/apps/parsar-daemon/internal/cli/runtime_mcp.go @@ -20,8 +20,7 @@ type mcpInvocation struct { env []string } -// runRuntimeMCP executes only inside the packaged initializer's sandbox. It -// never consumes MCP stdin, opens a daemon connection or owns a child process. +// runRuntimeMCP forwards stdio to the selected installed MCP server. func runRuntimeMCP(_ *runContext, args []string) error { if len(args) != 3 || args[0] == "/" || agentcapabilities.ValidateLocalDirectories([]string{args[0]}) != nil { return errRuntimeMCP @@ -58,14 +57,16 @@ func resolveMCPInvocation(manifest agentcapabilities.Manifest, installationRoot, if server.Type != "stdio" { return mcpInvocation{}, errRuntimeMCP } - // Defaults locate installed dependencies. Other user values require an - // explicit env_vars declaration; the native launcher's env is never read. - env := map[string]string{ - "PATH": "/environment/packages/npm/bin:/environment/packages/python/bin:/usr/local/bin:/usr/bin:/bin", - "PYTHONPATH": "/environment/packages/python", - "HOME": "/tmp", - "LANG": "C.UTF-8", + // MCP runs under the same user environment as the daemon. Explicit + // capability variables override inherited values. + env := map[string]string{} + for _, entry := range os.Environ() { + key, value, ok := strings.Cut(entry, "=") + if ok { + env[key] = value + } } + for _, key := range server.EnvVars { value, exists := values[key] if !exists || strings.ContainsRune(value, 0) { diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_other.go b/apps/parsar-daemon/internal/cli/runtime_mcp_other.go deleted file mode 100644 index e8fb291ad..000000000 --- a/apps/parsar-daemon/internal/cli/runtime_mcp_other.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build !linux - -package cli - -func execRuntimeMCP(mcpInvocation) error { return errRuntimeMCP } diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_test.go b/apps/parsar-daemon/internal/cli/runtime_mcp_test.go index 960b517a5..ec8366c79 100644 --- a/apps/parsar-daemon/internal/cli/runtime_mcp_test.go +++ b/apps/parsar-daemon/internal/cli/runtime_mcp_test.go @@ -8,7 +8,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" ) -func TestRuntimeMCPSelectsOnlyInstalledUserEnvironment(t *testing.T) { +func TestRuntimeMCPOverlaysExplicitVariablesOnUserEnvironment(t *testing.T) { manifest := agentcapabilities.Manifest{MCP: []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ Name: "local", Type: "stdio", Command: "python3", Args: []string{"proof.py", "${LITERAL}"}, EnvVars: []string{"SELECTED"}, CWD: "./server", }}}} @@ -17,8 +17,8 @@ func TestRuntimeMCPSelectsOnlyInstalledUserEnvironment(t *testing.T) { got, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/0", "local", map[string]string{"SELECTED": "user-value", "UNDECLARED": "not-injected"}) if err != nil || got.command != "python3" || got.cwd != "/private/runtime/capabilities/plugins/0/server" || !slices.Equal(got.args, []string{"python3", "proof.py", "${LITERAL}"}) || !slices.Contains(got.env, "SELECTED=user-value") || - slices.Contains(got.env, "UNDECLARED=not-injected") || slices.Contains(got.env, "NATIVE_ONLY=private-native-value") { - t.Fatalf("incorrect isolated invocation: %+v %v", got, err) + slices.Contains(got.env, "UNDECLARED=not-injected") || !slices.Contains(got.env, "NATIVE_ONLY=private-native-value") { + t.Fatalf("incorrect invocation configuration: %v", err) } if _, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/0", "local", map[string]string{}); err == nil { t.Fatal("missing user value fell back to native environment") @@ -34,7 +34,7 @@ func TestRuntimeMCPKeepsAbsoluteCWDAndRejectsHTTP(t *testing.T) { }}}} got, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/0", "local", nil) if err != nil || got.cwd != "/workspace" { - t.Fatalf("absolute cwd changed: %+v %v", got, err) + t.Fatalf("absolute cwd changed: %v", err) } manifest.MCP[0].Server.Type = "http" if _, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/0", "local", nil); err == nil { diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_linux.go b/apps/parsar-daemon/internal/cli/runtime_mcp_unix.go similarity index 96% rename from apps/parsar-daemon/internal/cli/runtime_mcp_linux.go rename to apps/parsar-daemon/internal/cli/runtime_mcp_unix.go index 73396f8dc..9c7d1df9d 100644 --- a/apps/parsar-daemon/internal/cli/runtime_mcp_linux.go +++ b/apps/parsar-daemon/internal/cli/runtime_mcp_unix.go @@ -1,4 +1,4 @@ -//go:build linux +//go:build !windows package cli diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_windows.go b/apps/parsar-daemon/internal/cli/runtime_mcp_windows.go new file mode 100644 index 000000000..a1f3f9c49 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/runtime_mcp_windows.go @@ -0,0 +1,18 @@ +//go:build windows + +package cli + +import ( + "os" + "os/exec" +) + +func execRuntimeMCP(invocation mcpInvocation) error { + command := exec.Command(invocation.command, invocation.args[1:]...) + command.Dir, command.Env = invocation.cwd, invocation.env + command.Stdin, command.Stdout, command.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := command.Run(); err != nil { + return errRuntimeMCP + } + return nil +} diff --git a/apps/parsar-daemon/internal/cli/stop.go b/apps/parsar-daemon/internal/cli/stop.go index 125cb4c49..e58f32301 100644 --- a/apps/parsar-daemon/internal/cli/stop.go +++ b/apps/parsar-daemon/internal/cli/stop.go @@ -9,11 +9,8 @@ import ( "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" ) -// runStop sends SIGTERM to the pid in ~/.oac/daemon// -// connect.pid, escalates to SIGKILL after killTimeout, then removes -// the pidfile. Idempotent: missing / stale pidfile cleans up and -// exits 0 — the user's goal is "no background daemon" and that holds -// either way. +// runStop requests cleanup from the recorded daemon identity. A timeout keeps +// the ownership record; only confirmed exit permits removing it. func runStop(ctx *runContext, args []string) error { fs := newFlagSet("stop") profile := fs.String("profile", paths.DefaultProfile, "profile name to stop") @@ -44,13 +41,10 @@ func runStop(ctx *runContext, args []string) error { return fmt.Errorf("stop: read pidfile: %w", err) } - fmt.Fprintf(ctx.stdout, "oac-daemon: sending SIGTERM to pid=%d\n", pid) - if err := daemonize.SignalAndWait(pid, killTimeout); err != nil { + fmt.Fprintf(ctx.stdout, "oac-daemon: requesting stop for pid=%d\n", pid) + if err := daemonize.StopPIDFile(pidPath, killTimeout); err != nil { return fmt.Errorf("stop: signal: %w", err) } - if err := daemonize.RemovePIDFile(pidPath); err != nil { - return fmt.Errorf("stop: remove pidfile: %w", err) - } fmt.Fprintln(ctx.stdout, "oac-daemon: stopped") return nil } diff --git a/apps/parsar-daemon/internal/cli/suspend_control.go b/apps/parsar-daemon/internal/cli/suspend_control.go index 745ffa172..eedb7a545 100644 --- a/apps/parsar-daemon/internal/cli/suspend_control.go +++ b/apps/parsar-daemon/internal/cli/suspend_control.go @@ -1,3 +1,5 @@ +//go:build linux + package cli import ( diff --git a/apps/parsar-daemon/internal/cli/suspend_process_other.go b/apps/parsar-daemon/internal/cli/suspend_process_other.go index cdf1bc1f4..42073245d 100644 --- a/apps/parsar-daemon/internal/cli/suspend_process_other.go +++ b/apps/parsar-daemon/internal/cli/suspend_process_other.go @@ -2,11 +2,37 @@ package cli -import "errors" +import ( + "context" + "errors" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "os" +) -func suspendProcessStart(int) (string, error) { - return "", errors.New("hosted suspension requires Linux") +const suspendControlEnv = "OAC_RUNTIME_DAEMON_SUSPEND_PID_FILE" + +type suspendIdentity struct { + EnvironmentID string + SuspendID string +} +type suspendControl struct { + identity suspendIdentity + signal chan os.Signal + lastResumed *proto.EnvironmentSuspendPayload +} + +func newSuspendControl() (*suspendControl, error) { + if os.Getenv(suspendControlEnv) != "" { + return nil, errors.New("hosted suspension requires Linux") + } + return nil, nil +} +func (c *suspendControl) Arm(proto.EnvironmentSuspendPayload) error { + return errors.New("hosted suspension requires Linux") } -func signalSuspendedProcess(suspendIdentity) error { +func (c *suspendControl) Wait(context.Context) error { return errors.New("hosted suspension requires Linux") } +func (c *suspendControl) Disarm() error { return errors.New("hosted suspension requires Linux") } +func (c *suspendControl) Close() {} +func runResume(*runContext, []string) error { return errors.New("hosted suspension requires Linux") } diff --git a/apps/parsar-daemon/internal/daemonize/fork.go b/apps/parsar-daemon/internal/daemonize/fork.go index 5e8380cb5..8803c7ee1 100644 --- a/apps/parsar-daemon/internal/daemonize/fork.go +++ b/apps/parsar-daemon/internal/daemonize/fork.go @@ -1,5 +1,5 @@ // Package daemonize gives `oac-daemon connect -b` a no-cgo way to -// detach from the controlling terminal on macOS + Linux. Strategy is +// detach from the controlling terminal on macOS, Linux and Windows. Strategy is // re-exec-the-binary rather than POSIX double-fork: the parent opens // connect.log + connect.pid, then starts a fresh copy of its own // argv with stdio redirected to the log file and a sentinel env var @@ -16,7 +16,7 @@ import ( "fmt" "os" "os/exec" - "syscall" + "strings" ) // BackgroundSentinelEnv is set by the parent on the child's @@ -38,8 +38,8 @@ type ReExecOptions struct { // /dev/null; stdout+stderr are appended to this file (0o600). LogPath string - // PIDPath is the absolute pidfile path. The parent writes the - // child's PID here before returning; existing files are replaced + // PIDPath is the absolute process-record path. The parent writes the + // child identity here before returning; existing files are replaced // atomically. PIDPath string @@ -63,7 +63,7 @@ func Spawn(argv []string, opts ReExecOptions) (int, error) { return 0, errors.New("daemonize.Spawn: LogPath and PIDPath required") } - logFile, err := os.OpenFile(opts.LogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + logFile, err := openPrivateLog(opts.LogPath) if err != nil { return 0, fmt.Errorf("daemonize: open log %s: %w", opts.LogPath, err) } @@ -96,29 +96,42 @@ func Spawn(argv []string, opts ReExecOptions) (int, error) { cmd.Stdin = devNull cmd.Stdout = logFile cmd.Stderr = logFile - cmd.SysProcAttr = &syscall.SysProcAttr{ - Setsid: true, // new session → no controlling tty + cleanup, ready, err := configureBackground(cmd) + if err != nil { + return 0, err } + defer cleanup() if err := cmd.Start(); err != nil { return 0, fmt.Errorf("daemonize: start child: %w", err) } pid := cmd.Process.Pid - - // Release rather than Wait — don't take zombie reaping duties. - // Init takes over once the parent exits. - if err := cmd.Process.Release(); err != nil { - // Non-fatal; child is already running. Log to the caller's - // chain so it shows up in stderr but doesn't fail the spawn. - fmt.Fprintf(os.Stderr, "daemonize: warning: release child: %v\n", err) + if err := ready(); err != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + return 0, err + } + identity, err := identifyProcess(pid) + if err != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + return 0, err + } + for _, entry := range cmd.Env { + if strings.HasPrefix(entry, stopEventEnv+"=") { + identity.StopEvent = strings.TrimPrefix(entry, stopEventEnv+"=") + } } - if err := WritePIDFile(opts.PIDPath, pid); err != nil { - // Kill the child so we don't leave a daemon the user can't - // `stop` without ps-grepping. - _ = syscall.Kill(pid, syscall.SIGTERM) - return 0, fmt.Errorf("daemonize: write pidfile (child killed): %w", err) + if err := writeIdentity(opts.PIDPath, identity); err != nil { + // The parent still owns this exact process handle until publication succeeds. + _ = cmd.Process.Kill() + _ = cmd.Wait() + return 0, fmt.Errorf("daemonize: write process record: %w", err) + } + if err := cmd.Process.Release(); err != nil { + fmt.Fprintf(os.Stderr, "daemonize: release child handle: %v\n", err) } return pid, nil diff --git a/apps/parsar-daemon/internal/daemonize/fork_test.go b/apps/parsar-daemon/internal/daemonize/fork_test.go index 278c90a40..7ad546605 100644 --- a/apps/parsar-daemon/internal/daemonize/fork_test.go +++ b/apps/parsar-daemon/internal/daemonize/fork_test.go @@ -4,13 +4,12 @@ import ( "os" "path/filepath" "strings" - "syscall" "testing" "time" ) func TestSpawnReExecsWithSentinelAndPIDFile(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) logPath := filepath.Join(dir, "child.log") pidPath := filepath.Join(dir, "child.pid") @@ -29,7 +28,11 @@ func TestSpawnReExecsWithSentinelAndPIDFile(t *testing.T) { t.Fatalf("Spawn: %v", err) } defer func() { - _ = syscall.Kill(pid, syscall.SIGTERM) + p, _ := os.FindProcess(pid) + if p != nil { + _ = p.Kill() + _, _ = p.Wait() + } }() if pid <= 0 { @@ -63,9 +66,9 @@ func TestSpawnReExecsWithSentinelAndPIDFile(t *testing.T) { t.Errorf("log missing stderr marker; got %q", string(logBody)) } - // SignalAndWait should now drop the child cleanly. - if err := SignalAndWait(pid, 2*time.Second); err != nil { - t.Fatalf("SignalAndWait: %v", err) + // StopPIDFile should now drop the child cleanly. + if err := StopPIDFile(pidPath, 2*time.Second); err != nil { + t.Fatalf("StopPIDFile: %v", err) } } diff --git a/apps/parsar-daemon/internal/daemonize/fork_unix.go b/apps/parsar-daemon/internal/daemonize/fork_unix.go new file mode 100644 index 000000000..d729a25be --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/fork_unix.go @@ -0,0 +1,13 @@ +//go:build unix + +package daemonize + +import ( + "os/exec" + "syscall" +) + +func configureBackground(cmd *exec.Cmd) (func(), func() error, error) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + return func() {}, func() error { return nil }, nil +} diff --git a/apps/parsar-daemon/internal/daemonize/fork_windows.go b/apps/parsar-daemon/internal/daemonize/fork_windows.go new file mode 100644 index 000000000..6217b80bf --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/fork_windows.go @@ -0,0 +1,64 @@ +//go:build windows + +package daemonize + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "os/exec" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +func configureBackground(cmd *exec.Cmd) (func(), func() error, error) { + var nonce [24]byte + if _, err := rand.Read(nonce[:]); err != nil { + return nil, nil, err + } + name := `Local\OpenAgentCore-daemon-` + hex.EncodeToString(nonce[:]) + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return nil, nil, err + } + sd, err := windows.SecurityDescriptorFromString("D:P(A;;GA;;;" + user.User.Sid.String() + ")") + if err != nil { + return nil, nil, err + } + attr := windows.SecurityAttributes{Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: sd} + stopName, _ := windows.UTF16PtrFromString(name) + event, err := windows.CreateEvent(&attr, 1, 0, stopName) + if err != nil { + return nil, nil, err + } + readyName, _ := windows.UTF16PtrFromString(name + "-ready") + ready, err := windows.CreateEvent(&attr, 1, 0, readyName) + if err != nil { + windows.CloseHandle(event) + return nil, nil, err + } + clean := func() { windows.CloseHandle(event); windows.CloseHandle(ready) } + filtered := cmd.Env[:0] + for _, entry := range cmd.Env { + if !strings.HasPrefix(entry, stopEventEnv+"=") { + filtered = append(filtered, entry) + } + } + cmd.Env = append(filtered, stopEventEnv+"="+name) + // A short-lived installer must not own the detached daemon through its Job. + // If an enclosing Job forbids breakaway, Start fails instead of losing ownership. + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_BREAKAWAY_FROM_JOB} + return clean, func() error { + status, err := windows.WaitForSingleObject(ready, 60000) + if err != nil { + return err + } + if status != windows.WAIT_OBJECT_0 { + return errors.New("daemonize: background child did not establish its stop endpoint") + } + return nil + }, nil +} diff --git a/apps/parsar-daemon/internal/daemonize/helpers_test.go b/apps/parsar-daemon/internal/daemonize/helpers_test.go index 68bf9ea7c..6f32354cc 100644 --- a/apps/parsar-daemon/internal/daemonize/helpers_test.go +++ b/apps/parsar-daemon/internal/daemonize/helpers_test.go @@ -1,109 +1,47 @@ package daemonize import ( + "context" "fmt" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" "os" - "os/exec" - "os/signal" - "strconv" - "syscall" + "path/filepath" "testing" "time" ) -// spawnTestChildEnv flips the test binary into "child" mode for fork -// tests: TestMain writes a marker, blocks until SIGTERM/SIGINT, exits. -// Lets Spawn re-exec the test binary as the child. const spawnTestChildEnv = "OAC_TEST_DAEMON_SPAWN_CHILD" func TestMain(m *testing.M) { - if os.Getenv(spawnTestChildEnv) == "1" { - runSpawnTestChild() + if mode := os.Getenv(spawnTestChildEnv); mode != "" { + _, firstStop := NotifyContext(context.Background()) + firstStop() + ctx, stop := NotifyContext(context.Background()) + defer stop() + fmt.Fprintln(os.Stdout, "spawn-test-child:stdout") + fmt.Fprintln(os.Stderr, "spawn-test-child:stderr") + if mode == "ignore" { + time.Sleep(15 * time.Second) + return + } + select { + case <-ctx.Done(): + case <-time.After(15 * time.Second): + } return } os.Exit(m.Run()) } -func runSpawnTestChild() { - fmt.Fprintln(os.Stdout, "spawn-test-child:stdout") - fmt.Fprintln(os.Stderr, "spawn-test-child:stderr") - - // Honour SIGTERM so the parent test can clean us up. Fallback - // deadline so a stranded child doesn't survive a crashing test. - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) - select { - case <-sigCh: - case <-time.After(15 * time.Second): - } - os.Exit(0) -} - -// startSleepingChild launches a child that sleeps for dur and exits. -// dur=0 → `sh -c true` for testing "signal a process that's already -// gone" paths. sh because we need a real kernel PID with /proc entry. -func startSleepingChild(t *testing.T, dur time.Duration) *exec.Cmd { - t.Helper() - var cmd *exec.Cmd - if dur <= 0 { - cmd = exec.Command("sh", "-c", "true") - } else { - // Convert to whole seconds when possible, sub-second via sleep - // arg with decimal (Linux/macOS sh both accept "sleep 0.05"). - secs := dur.Seconds() - cmd = exec.Command("sh", "-c", "sleep "+strconv.FormatFloat(secs, 'f', 3, 64)) - } - if err := cmd.Start(); err != nil { - t.Fatalf("startSleepingChild: %v", err) - } - return cmd -} - -// startTrapChild launches a child that ignores SIGTERM, forcing the -// SIGKILL escalation path. Waits for READY on stdout so a fast caller -// doesn't race the trap install. -func startTrapChild(t *testing.T) *exec.Cmd { +func privateTempDir(t *testing.T) string { t.Helper() - cmd := exec.Command("sh", "-c", "trap '' TERM; echo READY; sleep 30") - stdout, err := cmd.StdoutPipe() + parent, err := filepath.EvalSymlinks(t.TempDir()) if err != nil { - t.Fatalf("startTrapChild StdoutPipe: %v", err) + t.Fatal(err) } - if err := cmd.Start(); err != nil { - t.Fatalf("startTrapChild Start: %v", err) - } - // Read up to READY with a hard deadline so a broken shell can't - // hang the test. - readyCh := make(chan error, 1) - go func() { - buf := make([]byte, 16) - var got []byte - for { - n, err := stdout.Read(buf) - if n > 0 { - got = append(got, buf[:n]...) - if len(got) >= 5 && string(got[:5]) == "READY" { - readyCh <- nil - return - } - } - if err != nil { - readyCh <- err - return - } - } - }() - select { - case err := <-readyCh: - if err != nil { - _ = cmd.Process.Kill() - _, _ = cmd.Process.Wait() - t.Fatalf("startTrapChild waiting READY: %v", err) - } - case <-time.After(2 * time.Second): - _ = cmd.Process.Kill() - _, _ = cmd.Process.Wait() - t.Fatalf("startTrapChild: timed out waiting for READY") + dir := filepath.Join(parent, "private") + if err := runtimefs.EnsurePrivateDir(dir); err != nil { + t.Fatal(err) } - return cmd + return dir } diff --git a/apps/parsar-daemon/internal/daemonize/logfile.go b/apps/parsar-daemon/internal/daemonize/logfile.go index 65e25c061..c83de6f6a 100644 --- a/apps/parsar-daemon/internal/daemonize/logfile.go +++ b/apps/parsar-daemon/internal/daemonize/logfile.go @@ -4,6 +4,7 @@ import ( "bufio" "errors" "fmt" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" "io" "os" "path/filepath" @@ -137,11 +138,11 @@ func lastLinesOffset(f *os.File, n int) (int64, error) { // instead of "no such file". func EnsureLogFile(path string) error { if dir := filepath.Dir(path); dir != "" && dir != "." { - if err := os.MkdirAll(dir, 0o700); err != nil { + if err := runtimefs.EnsurePrivateDir(dir); err != nil { return fmt.Errorf("daemonize.EnsureLogFile: %w", err) } } - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + f, err := openPrivateLog(path) if err != nil { return fmt.Errorf("daemonize.EnsureLogFile: %w", err) } @@ -152,7 +153,7 @@ func EnsureLogFile(path string) error { // trailing newline if missing. Returns errors despite the name — // kept short because it's used in startup hot paths. func MustWriteLine(path string, line string) error { - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + f, err := openPrivateLog(path) if err != nil { return err } @@ -168,3 +169,12 @@ func MustWriteLine(path string, line string) error { } return bw.Flush() } + +func openPrivateLog(path string) (*os.File, error) { + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return nil, err + } + defer root.Close() + return runtimefs.OpenPrivate(root, filepath.Base(path), os.O_APPEND|os.O_CREATE|os.O_WRONLY) +} diff --git a/apps/parsar-daemon/internal/daemonize/logfile_test.go b/apps/parsar-daemon/internal/daemonize/logfile_test.go index 3b5f5ce0b..a13bdca48 100644 --- a/apps/parsar-daemon/internal/daemonize/logfile_test.go +++ b/apps/parsar-daemon/internal/daemonize/logfile_test.go @@ -4,6 +4,7 @@ import ( "bytes" "os" "path/filepath" + "runtime" "strings" "sync" "testing" @@ -11,7 +12,7 @@ import ( ) func TestTailReturnsLastNLinesAndExitsWithoutFollow(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "tail.log") var b strings.Builder for i := 1; i <= 10; i++ { @@ -35,7 +36,7 @@ func TestTailReturnsLastNLinesAndExitsWithoutFollow(t *testing.T) { } func TestTailLastLinesZeroPrintsNothingHistorical(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "tail.log") if err := os.WriteFile(path, []byte("a\nb\nc\n"), 0o600); err != nil { t.Fatalf("seed: %v", err) @@ -51,7 +52,7 @@ func TestTailLastLinesZeroPrintsNothingHistorical(t *testing.T) { } func TestTailLastLinesGreaterThanFilePrintsAll(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "tail.log") if err := os.WriteFile(path, []byte("a\nb\nc\n"), 0o600); err != nil { t.Fatalf("seed: %v", err) @@ -66,7 +67,7 @@ func TestTailLastLinesGreaterThanFilePrintsAll(t *testing.T) { } func TestTailFollowPicksUpAppendedBytes(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "tail.log") if err := os.WriteFile(path, []byte("initial\n"), 0o600); err != nil { t.Fatalf("seed: %v", err) @@ -128,14 +129,14 @@ func TestTailFollowPicksUpAppendedBytes(t *testing.T) { func TestTailErrorsOnMissingFile(t *testing.T) { var buf bytes.Buffer - err := Tail(filepath.Join(t.TempDir(), "nope.log"), TailOptions{LastLines: 1}, nil, &buf) + err := Tail(filepath.Join(privateTempDir(t), "nope.log"), TailOptions{LastLines: 1}, nil, &buf) if err == nil { t.Fatalf("Tail on missing file succeeded") } } func TestEnsureLogFileCreatesEmpty0600(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "fresh.log") if err := EnsureLogFile(path); err != nil { t.Fatalf("EnsureLogFile: %v", err) @@ -147,13 +148,13 @@ func TestEnsureLogFileCreatesEmpty0600(t *testing.T) { if info.Size() != 0 { t.Errorf("size = %d, want 0", info.Size()) } - if info.Mode().Perm() != 0o600 { + if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { t.Errorf("mode = %o, want 0600", info.Mode().Perm()) } } func TestEnsureLogFileIdempotentOnExisting(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "exists.log") if err := os.WriteFile(path, []byte("preexisting\n"), 0o600); err != nil { t.Fatalf("seed: %v", err) @@ -171,7 +172,7 @@ func TestEnsureLogFileCreatesMissingParentDir(t *testing.T) { // Regression: first-ever `oac-daemon connect -b` on a host without // ~/.oac/daemon// used to fail with ENOENT — // O_CREATE only creates the file leaf. - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "missing", "deeper", "fresh.log") if err := EnsureLogFile(path); err != nil { t.Fatalf("EnsureLogFile on missing parent: %v", err) @@ -182,7 +183,7 @@ func TestEnsureLogFileCreatesMissingParentDir(t *testing.T) { } func TestMustWriteLineAppendsTrailingNewline(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "ml.log") if err := MustWriteLine(path, "no-newline"); err != nil { t.Fatalf("MustWriteLine: %v", err) diff --git a/apps/parsar-daemon/internal/daemonize/notify_unix.go b/apps/parsar-daemon/internal/daemonize/notify_unix.go new file mode 100644 index 000000000..0e63ee4f6 --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/notify_unix.go @@ -0,0 +1,16 @@ +//go:build unix + +package daemonize + +import ( + "context" + "os" + "os/signal" + "syscall" +) + +const stopEventEnv = "OAC_RUNTIME_DAEMON_STOP_EVENT" + +func NotifyContext(parent context.Context) (context.Context, context.CancelFunc) { + return signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM) +} diff --git a/apps/parsar-daemon/internal/daemonize/notify_windows.go b/apps/parsar-daemon/internal/daemonize/notify_windows.go new file mode 100644 index 000000000..cff5d173e --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/notify_windows.go @@ -0,0 +1,70 @@ +//go:build windows + +package daemonize + +import ( + "context" + "os" + "os/signal" + "sync" + + "golang.org/x/sys/windows" +) + +const stopEventEnv = "OAC_RUNTIME_DAEMON_STOP_EVENT" + +// This process-lifetime handle survives transitions from serving to parked. +// Windows releases it on process exit, including abrupt termination. +var backgroundStop struct { + once sync.Once + handle windows.Handle + err error +} + +func openBackgroundStop(name string) (windows.Handle, error) { + backgroundStop.once.Do(func() { + var stopName *uint16 + stopName, backgroundStop.err = windows.UTF16PtrFromString(name) + if backgroundStop.err != nil { + return + } + backgroundStop.handle, backgroundStop.err = windows.OpenEvent(windows.SYNCHRONIZE, false, stopName) + if backgroundStop.err != nil { + return + } + readyName, _ := windows.UTF16PtrFromString(name + "-ready") + ready, err := windows.OpenEvent(windows.EVENT_MODIFY_STATE, false, readyName) + if err != nil { + backgroundStop.err = err + return + } + backgroundStop.err = windows.SetEvent(ready) + windows.CloseHandle(ready) + }) + return backgroundStop.handle, backgroundStop.err +} + +// NotifyContext serves both foreground Ctrl-C and an owned background stop event. +// The ready event is signalled only after the child holds its own stop handle. +func NotifyContext(parent context.Context) (context.Context, context.CancelFunc) { + ctx, cancel := signal.NotifyContext(parent, os.Interrupt) + name := os.Getenv(stopEventEnv) + if name == "" { + return ctx, cancel + } + event, err := openBackgroundStop(name) + if err != nil { + cancel() + return ctx, cancel + } + go func() { + for ctx.Err() == nil { + status, err := windows.WaitForSingleObject(event, 100) + if err != nil || status == windows.WAIT_OBJECT_0 { + cancel() + return + } + } + }() + return ctx, cancel +} diff --git a/apps/parsar-daemon/internal/daemonize/pidfile.go b/apps/parsar-daemon/internal/daemonize/pidfile.go index b9d9665b5..0a5f548e3 100644 --- a/apps/parsar-daemon/internal/daemonize/pidfile.go +++ b/apps/parsar-daemon/internal/daemonize/pidfile.go @@ -1,123 +1,102 @@ package daemonize import ( + "encoding/json" "errors" "fmt" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" "os" - "strconv" - "strings" - "syscall" + "path/filepath" "time" ) -// ErrStaleOrCorrupt is returned by ReadPIDFile when the file exists -// but its contents don't look like a live PID. Wraps the underlying -// parse error or syscall.ESRCH for errors.Is. -var ErrStaleOrCorrupt = errors.New("daemonize: pidfile stale or corrupt") - -// ErrNotRunning is returned by IsAlive when the PID has no process. +var ErrStaleOrCorrupt = errors.New("daemonize: unsupported, stale or corrupt process record") var ErrNotRunning = errors.New("daemonize: process not running") -// WritePIDFile writes pid to path atomically. The temp file lives in -// the same directory so the rename is on the same filesystem -// (required by os.Rename for atomicity). 0o600 matches the profile -// dir's privacy. +type processIdentity struct { + PID int `json:"pid"` + Start string `json:"start"` + StopEvent string `json:"stop_event,omitempty"` +} + func WritePIDFile(path string, pid int) error { - if path == "" { - return errors.New("daemonize.WritePIDFile: empty path") + identity, err := identifyProcess(pid) + if err != nil { + return err } - if pid <= 0 { - return fmt.Errorf("daemonize.WritePIDFile: invalid pid %d", pid) + identity.StopEvent = os.Getenv(stopEventEnv) + return writeIdentity(path, identity) +} + +func writeIdentity(path string, identity processIdentity) error { + if path == "" { + return errors.New("daemonize: process record path required") } - tmp := path + ".tmp" - body := []byte(strconv.Itoa(pid) + "\n") - if err := os.WriteFile(tmp, body, 0o600); err != nil { - return fmt.Errorf("daemonize: write tmp pidfile: %w", err) + raw, err := json.Marshal(identity) + if err != nil { + return err } - if err := os.Rename(tmp, path); err != nil { - _ = os.Remove(tmp) - return fmt.Errorf("daemonize: rename pidfile: %w", err) + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return err } - return nil + defer root.Close() + return runtimefs.WritePrivateAtomic(root, filepath.Base(path), raw) } -// ReadPIDFile reads, parses, and liveness-checks the pidfile. -// Missing file returns os.ErrNotExist verbatim; a file whose pid has -// no live process returns ErrStaleOrCorrupt wrapping ErrNotRunning. -func ReadPIDFile(path string) (int, error) { - raw, err := os.ReadFile(path) +func readIdentity(path string) (processIdentity, error) { + raw, err := runtimefs.ReadPrivatePath(path, 4096) if err != nil { - return 0, err + return processIdentity{}, err + } + var value processIdentity + if json.Unmarshal(raw, &value) != nil || value.PID <= 0 || value.Start == "" { + return processIdentity{}, ErrStaleOrCorrupt } - pid, parseErr := strconv.Atoi(strings.TrimSpace(string(raw))) - if parseErr != nil || pid <= 0 { - return 0, fmt.Errorf("%w: %q", ErrStaleOrCorrupt, strings.TrimSpace(string(raw))) + actual, err := identifyProcess(value.PID) + if err != nil { + if errors.Is(err, ErrNotRunning) { + return processIdentity{}, fmt.Errorf("%w: %w", ErrStaleOrCorrupt, err) + } + return processIdentity{}, err } - if err := IsAlive(pid); err != nil { - return pid, fmt.Errorf("%w: pid=%d: %w", ErrStaleOrCorrupt, pid, err) + if actual.Start != value.Start { + return processIdentity{}, ErrStaleOrCorrupt } - return pid, nil + return value, nil +} + +func ReadPIDFile(path string) (int, error) { + identity, err := readIdentity(path) + return identity.PID, err } -// IsAlive returns nil if pid corresponds to a process this user can -// signal. Wraps ErrNotRunning when the process is gone; other errors -// (e.g. EPERM) returned verbatim. -func IsAlive(pid int) error { - if pid <= 0 { - return fmt.Errorf("daemonize.IsAlive: invalid pid %d", pid) +func IsAlive(pid int) error { _, err := identifyProcess(pid); return err } + +// StopPIDFile never removes ownership before the exact process has exited. +// A cleanup timeout leaves the record available for observation and a later stop. +func StopPIDFile(path string, timeout time.Duration) error { + identity, err := readIdentity(path) + if err != nil { + return err } - // signal 0 is the POSIX "does this process exist?" probe: - // nil → exists, signalable - // ESRCH → no such process - // EPERM → exists but owned by another user - if err := syscall.Kill(pid, syscall.Signal(0)); err != nil { - if errors.Is(err, syscall.ESRCH) { - return fmt.Errorf("%w (pid=%d)", ErrNotRunning, pid) - } + if err = stopProcess(identity, timeout); err != nil { return err } - return nil + raw, err := runtimefs.ReadPrivatePath(path, 4096) + if err != nil { + return err + } + var current processIdentity + if json.Unmarshal(raw, ¤t) != nil || current != identity { + return errors.New("daemonize: process ownership changed during stop") + } + return RemovePIDFile(path) } -// RemovePIDFile deletes the pidfile. Missing files aren't an error so -// `oac-daemon stop` is idempotent. func RemovePIDFile(path string) error { if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("daemonize: remove pidfile: %w", err) - } - return nil -} - -// SignalAndWait SIGTERMs pid, polls for exit on 100ms cadence up to -// timeout, then SIGKILLs if still alive. Caller is responsible for -// removing the pidfile after success. -func SignalAndWait(pid int, timeout time.Duration) error { - if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { - if errors.Is(err, syscall.ESRCH) { - return nil - } - return fmt.Errorf("daemonize: SIGTERM pid=%d: %w", pid, err) - } - - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if err := IsAlive(pid); err != nil { - if errors.Is(err, ErrNotRunning) { - return nil - } - return err - } - time.Sleep(100 * time.Millisecond) - } - - if err := syscall.Kill(pid, syscall.SIGKILL); err != nil { - if errors.Is(err, syscall.ESRCH) { - return nil - } - return fmt.Errorf("daemonize: SIGKILL pid=%d: %w", pid, err) + return err } - // Grace window for the kernel to reap before any subsequent - // IsAlive call. - time.Sleep(50 * time.Millisecond) return nil } diff --git a/apps/parsar-daemon/internal/daemonize/pidfile_test.go b/apps/parsar-daemon/internal/daemonize/pidfile_test.go index 1a8906b58..f390a7837 100644 --- a/apps/parsar-daemon/internal/daemonize/pidfile_test.go +++ b/apps/parsar-daemon/internal/daemonize/pidfile_test.go @@ -1,168 +1,98 @@ package daemonize import ( + "encoding/json" "errors" "os" "path/filepath" - "syscall" "testing" "time" ) -func TestWriteAndReadPIDFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "connect.pid") - pid := os.Getpid() - - if err := WritePIDFile(path, pid); err != nil { - t.Fatalf("WritePIDFile: %v", err) - } - - got, err := ReadPIDFile(path) - if err != nil { - t.Fatalf("ReadPIDFile: %v", err) +func TestProcessRecordIdentity(t *testing.T) { + path := filepath.Join(privateTempDir(t), "connect.pid") + if err := WritePIDFile(path, os.Getpid()); err != nil { + t.Fatal(err) } - if got != pid { - t.Fatalf("ReadPIDFile = %d, want %d", got, pid) + pid, err := ReadPIDFile(path) + if err != nil || pid != os.Getpid() { + t.Fatal(pid, err) } - - info, err := os.Stat(path) + raw, err := os.ReadFile(path) if err != nil { - t.Fatalf("stat: %v", err) + t.Fatal(err) } - if info.Mode().Perm() != 0o600 { - t.Errorf("pidfile mode = %o, want 0600", info.Mode().Perm()) + var identity processIdentity + if json.Unmarshal(raw, &identity) != nil || identity.Start == "" { + t.Fatal("missing process start identity") } -} - -func TestReadPIDFileMissingReturnsErrNotExist(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "nope.pid") - _, err := ReadPIDFile(path) - if !errors.Is(err, os.ErrNotExist) { - t.Fatalf("err = %v, want os.ErrNotExist", err) + identity.Start += "different" + raw, _ = json.Marshal(identity) + if err = os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) } -} - -func TestReadPIDFileCorruptReturnsStaleOrCorrupt(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "bad.pid") - if err := os.WriteFile(path, []byte("not a number\n"), 0o600); err != nil { - t.Fatalf("seed: %v", err) + if err = StopPIDFile(path, time.Second); !errors.Is(err, ErrStaleOrCorrupt) { + t.Fatal("stale identity was accepted", err) } - _, err := ReadPIDFile(path) - if !errors.Is(err, ErrStaleOrCorrupt) { - t.Fatalf("err = %v, want ErrStaleOrCorrupt", err) + if err = IsAlive(os.Getpid()); err != nil { + t.Fatal("unrelated process was affected", err) } -} - -func TestReadPIDFileStaleReturnsStaleOrCorrupt(t *testing.T) { - // Pick a PID almost certainly dead. WritePIDFile rejects 0 so we - // write manually. - dir := t.TempDir() - path := filepath.Join(dir, "stale.pid") - if err := os.WriteFile(path, []byte("99999999\n"), 0o600); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := ReadPIDFile(path); !errors.Is(err, ErrStaleOrCorrupt) { - // Skip if 99999999 happens to exist. - t.Logf("ReadPIDFile = %v (PID 99999999 may exist on this host); skipping", err) - t.SkipNow() + if _, err = os.Stat(path); err != nil { + t.Fatal("unconfirmed record removed", err) } } -func TestIsAliveTrueForOurselves(t *testing.T) { - if err := IsAlive(os.Getpid()); err != nil { - t.Fatalf("IsAlive(self) = %v, want nil", err) +func TestProcessRecordRejectsOldAndMalformed(t *testing.T) { + path := filepath.Join(privateTempDir(t), "connect.pid") + if _, err := ReadPIDFile(path); !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) } -} - -func TestIsAliveFalseForDeadPID(t *testing.T) { - err := IsAlive(99999999) - if err == nil { - t.Skip("PID 99999999 unexpectedly exists; skipping") - } - if !errors.Is(err, ErrNotRunning) { - t.Fatalf("err = %v, want ErrNotRunning", err) - } -} - -func TestRemovePIDFileIdempotent(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "x.pid") - if err := WritePIDFile(path, os.Getpid()); err != nil { - t.Fatalf("write: %v", err) + for _, raw := range []string{"123\n", "{}", "not-json"} { + if err := os.WriteFile(path, []byte(raw), 0600); err != nil { + t.Fatal(err) + } + if _, err := ReadPIDFile(path); !errors.Is(err, ErrStaleOrCorrupt) { + t.Fatal(raw, err) + } } if err := RemovePIDFile(path); err != nil { - t.Fatalf("first remove: %v", err) + t.Fatal(err) } - // Second remove on missing file should be a no-op. if err := RemovePIDFile(path); err != nil { - t.Fatalf("second remove: %v", err) + t.Fatal(err) } } -func TestSignalAndWaitKillsChild(t *testing.T) { - // Launch a sleeping subshell so we have a real PID to signal. - cmd := startSleepingChild(t, 30*time.Second) - pid := cmd.Process.Pid - defer func() { - _ = cmd.Process.Kill() - _, _ = cmd.Process.Wait() - }() - - if err := SignalAndWait(pid, 2*time.Second); err != nil { - t.Fatalf("SignalAndWait: %v", err) - } - // In production, init reaps the child so IsAlive returns - // ErrNotRunning. In tests we're the parent so the child becomes - // a zombie — Wait() reaps it. - if _, err := cmd.Process.Wait(); err != nil { - t.Fatalf("Wait after SignalAndWait: %v", err) - } - if err := IsAlive(pid); err == nil { - t.Fatalf("child still alive after SignalAndWait+Wait") - } else if !errors.Is(err, ErrNotRunning) { - t.Fatalf("IsAlive err = %v, want ErrNotRunning", err) +func TestStopTimeoutRetainsProcessRecord(t *testing.T) { + dir := privateTempDir(t) + path := filepath.Join(dir, "connect.pid") + pid, err := Spawn([]string{"daemon", "child"}, ReExecOptions{LogPath: filepath.Join(dir, "log"), PIDPath: path, ExtraEnv: []string{spawnTestChildEnv + "=ignore"}}) + if err != nil { + t.Fatal(err) } -} - -func TestSignalAndWaitEscalatesToSIGKILL(t *testing.T) { - // Child traps SIGTERM and refuses to exit. 300ms timeout so the - // escalation path fires quickly. - cmd := startTrapChild(t) - pid := cmd.Process.Pid defer func() { - _ = cmd.Process.Kill() - _, _ = cmd.Process.Wait() + p, _ := os.FindProcess(pid) + if p != nil { + _ = p.Kill() + _, _ = p.Wait() + } }() - - start := time.Now() - if err := SignalAndWait(pid, 300*time.Millisecond); err != nil { - t.Fatalf("SignalAndWait: %v", err) - } - if d := time.Since(start); d < 250*time.Millisecond { - t.Errorf("escalation happened too early (took %s); SIGTERM grace period not honoured", d) - } - // Reap so the kernel reuses the PID. ProcessState confirms SIGKILL. - state, err := cmd.Process.Wait() - if err != nil { - t.Fatalf("Wait after escalation: %v", err) - } - ws, ok := state.Sys().(syscall.WaitStatus) - if !ok || !ws.Signaled() || ws.Signal() != syscall.SIGKILL { - t.Fatalf("child exit = %v, want killed by SIGKILL", state) - } -} - -func TestSignalAndWaitNoopOnAlreadyDead(t *testing.T) { - cmd := startSleepingChild(t, 0) - pid := cmd.Process.Pid - // Wait for child to actually be gone before signalling so we - // test the ESRCH branch rather than a race. - _, _ = cmd.Process.Wait() - - if err := SignalAndWait(pid, time.Second); err != nil { - t.Fatalf("SignalAndWait on dead pid: %v, want nil", err) + // Unix has no readiness event; wait until the child installs its signal handler. + deadline := time.Now().Add(3 * time.Second) + for { + raw, _ := os.ReadFile(filepath.Join(dir, "log")) + if len(raw) > 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("child not ready") + } + time.Sleep(10 * time.Millisecond) + } + if err = StopPIDFile(path, 100*time.Millisecond); err == nil { + t.Fatal("unconfirmed stop succeeded") + } + if got, err := ReadPIDFile(path); err != nil || got != pid { + t.Fatal("timeout lost ownership", got, err) } } diff --git a/apps/parsar-daemon/internal/daemonize/process_darwin.go b/apps/parsar-daemon/internal/daemonize/process_darwin.go new file mode 100644 index 000000000..5945827f4 --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/process_darwin.go @@ -0,0 +1,52 @@ +//go:build darwin + +package daemonize + +import ( + "errors" + "fmt" + "time" + + "golang.org/x/sys/unix" +) + +func identifyProcess(pid int) (processIdentity, error) { + info, err := unix.SysctlKinfoProc("kern.proc.pid", pid) + if errors.Is(err, unix.ESRCH) || (err == nil && (info.Proc.P_pid == 0 || info.Proc.P_stat == 5)) { + return processIdentity{}, ErrNotRunning + } + if err != nil { + if errors.Is(unix.Kill(pid, 0), unix.ESRCH) { + return processIdentity{}, ErrNotRunning + } + return processIdentity{}, err + } + return processIdentity{PID: pid, Start: fmt.Sprintf("%d:%d", info.Proc.P_starttime.Sec, info.Proc.P_starttime.Usec)}, nil +} + +func stopProcess(identity processIdentity, timeout time.Duration) error { + current, err := identifyProcess(identity.PID) + if errors.Is(err, ErrNotRunning) { + return nil + } + if err != nil || current.Start != identity.Start { + return ErrStaleOrCorrupt + } + // Darwin has no pidfd; identity is checked immediately before signalling. + // PID reuse between this check and kill remains an OS-level race. + if err = unix.Kill(identity.PID, unix.SIGTERM); err != nil && !errors.Is(err, unix.ESRCH) { + return err + } + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + current, err = identifyProcess(identity.PID) + if errors.Is(err, ErrNotRunning) || (err == nil && current.Start != identity.Start) { + return nil + } + if err != nil { + return err + } + time.Sleep(20 * time.Millisecond) + } + return errors.New("daemonize: cleanup unconfirmed; process record retained") +} diff --git a/apps/parsar-daemon/internal/daemonize/process_linux.go b/apps/parsar-daemon/internal/daemonize/process_linux.go new file mode 100644 index 000000000..685cd8e4c --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/process_linux.go @@ -0,0 +1,68 @@ +//go:build linux + +package daemonize + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" + "time" + + "golang.org/x/sys/unix" +) + +func identifyProcess(pid int) (processIdentity, error) { + raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if errors.Is(err, os.ErrNotExist) { + return processIdentity{}, ErrNotRunning + } + if err != nil { + return processIdentity{}, err + } + end := strings.LastIndexByte(string(raw), ')') + if end < 0 { + return processIdentity{}, errors.New("daemonize: invalid process identity") + } + fields := strings.Fields(string(raw[end+1:])) + if len(fields) < 20 || fields[0] == "Z" || fields[0] == "X" { + return processIdentity{}, ErrNotRunning + } + if _, err := strconv.ParseUint(fields[19], 10, 64); err != nil { + return processIdentity{}, err + } + return processIdentity{PID: pid, Start: fields[19]}, nil +} + +func stopProcess(identity processIdentity, timeout time.Duration) error { + handle, err := unix.PidfdOpen(identity.PID, 0) + if errors.Is(err, unix.ESRCH) { + return nil + } + if err != nil { + return err + } + defer unix.Close(handle) + current, err := identifyProcess(identity.PID) + if errors.Is(err, ErrNotRunning) { + return nil + } + if err != nil || current.Start != identity.Start { + return ErrStaleOrCorrupt + } + if err = unix.PidfdSendSignal(handle, unix.SIGTERM, nil, 0); err != nil && !errors.Is(err, unix.ESRCH) { + return err + } + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + n, err := unix.Poll([]unix.PollFd{{Fd: int32(handle), Events: unix.POLLIN}}, 20) + if err != nil && !errors.Is(err, unix.EINTR) { + return err + } + if n > 0 { + return nil + } + } + return errors.New("daemonize: cleanup unconfirmed; process record retained") +} diff --git a/apps/parsar-daemon/internal/daemonize/process_windows.go b/apps/parsar-daemon/internal/daemonize/process_windows.go new file mode 100644 index 000000000..5a2a052bf --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/process_windows.go @@ -0,0 +1,81 @@ +//go:build windows + +package daemonize + +import ( + "errors" + "fmt" + "time" + + "golang.org/x/sys/windows" +) + +func processStart(handle windows.Handle) (string, error) { + var creation, exit, kernel, user windows.Filetime + if err := windows.GetProcessTimes(handle, &creation, &exit, &kernel, &user); err != nil { + return "", err + } + status, err := windows.WaitForSingleObject(handle, 0) + if err != nil { + return "", err + } + if status == windows.WAIT_OBJECT_0 { + return "", ErrNotRunning + } + return fmt.Sprintf("%d:%d", creation.HighDateTime, creation.LowDateTime), nil +} +func identifyProcess(pid int) (processIdentity, error) { + if pid <= 0 { + return processIdentity{}, ErrNotRunning + } + handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, uint32(pid)) + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + return processIdentity{}, ErrNotRunning + } + if err != nil { + return processIdentity{}, err + } + defer windows.CloseHandle(handle) + start, err := processStart(handle) + return processIdentity{PID: pid, Start: start}, err +} +func stopProcess(identity processIdentity, timeout time.Duration) error { + handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, uint32(identity.PID)) + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + return nil + } + if err != nil { + return err + } + defer windows.CloseHandle(handle) + start, err := processStart(handle) + if errors.Is(err, ErrNotRunning) { + return nil + } + if err != nil || start != identity.Start { + return ErrStaleOrCorrupt + } + if identity.StopEvent == "" { + return errors.New("daemonize: process has no owned stop endpoint") + } + name, err := windows.UTF16PtrFromString(identity.StopEvent) + if err != nil { + return err + } + event, err := windows.OpenEvent(windows.EVENT_MODIFY_STATE, false, name) + if err != nil { + return err + } + defer windows.CloseHandle(event) + if err = windows.SetEvent(event); err != nil { + return err + } + status, err := windows.WaitForSingleObject(handle, uint32(max(timeout.Milliseconds(), 1))) + if err != nil { + return err + } + if status != windows.WAIT_OBJECT_0 { + return errors.New("daemonize: cleanup unconfirmed; process record retained") + } + return nil +} diff --git a/apps/parsar-daemon/internal/dispatch/local_directory_test.go b/apps/parsar-daemon/internal/dispatch/local_directory_test.go index 56605ca58..3e6d359e8 100644 --- a/apps/parsar-daemon/internal/dispatch/local_directory_test.go +++ b/apps/parsar-daemon/internal/dispatch/local_directory_test.go @@ -17,10 +17,8 @@ import ( ) func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing.T) { - workspace, helper := t.TempDir(), filepath.Join(t.TempDir(), "directory") - if err := os.WriteFile(helper, []byte("#!/bin/sh\nprintf '%s' '{\"version\":1,\"directory\":{\"entries\":[],\"truncated\":false}}'\n"), 0o700); err != nil { - t.Fatal(err) - } + workspace, helper := t.TempDir(), "" + environment, session := uuid.NewString(), uuid.NewString() binding, err := localworkspace.New(environment, session, workspace, helper) if err != nil { @@ -52,6 +50,14 @@ func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing if got := waitWorkspaceRead(t, sender, "list"); got.Outcome != "completed" || got.Directory == nil { t.Fatal("idle directory unavailable", got) } + if err := os.WriteFile(filepath.Join(workspace, "bytes"), []byte{0, 255, 17}, 0600); err != nil { + t.Fatal(err) + } + content := proto.WorkspaceReadPayload{EnvironmentID: environment, Handle: ready.Handle, Path: "bytes", MaxBytes: 2} + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "content", content)) + if got := waitWorkspaceRead(t, sender, "content"); got.Outcome != "completed" || !got.Truncated || !got.CloseAcknowledged || len(got.Data) != 2 || got.Data[1] != 255 { + t.Fatal("file prefix unavailable", got) + } read.EnvironmentID = uuid.NewString() _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "foreign", read)) if got := waitWorkspaceRead(t, sender, "foreign"); got.Outcome != "rejected" { @@ -92,17 +98,8 @@ func waitWorkspaceRead(t *testing.T, sender *recSender, id string) proto.Workspa } func TestLocalDirectoryKeepsNotDirectorySeparateFromFailures(t *testing.T) { - workspace, helper := t.TempDir(), filepath.Join(t.TempDir(), "directory") - script := `#!/bin/sh -case "$2" in - missing) printf '%s' '{"version":1,"error":"not_directory"}' ;; - invalid) printf '%s' '{"version":1,"error":"invalid_path"}' ;; - gone) printf '%s' '{"version":1,"error":"not_found"}' ;; - denied) printf '%s' '{"version":1,"error":"permission_denied"}' ;; - broken) printf '%s' '{"version":1,"error":"native_error"}' ;; -esac -` - if err := os.WriteFile(helper, []byte(script), 0o700); err != nil { + workspace, helper := t.TempDir(), "" + if err := os.WriteFile(filepath.Join(workspace, "file"), nil, 0600); err != nil { t.Fatal(err) } environment, session := uuid.NewString(), uuid.NewString() @@ -129,11 +126,9 @@ esac } ready := waitPreparationStatus(t, sender, "idle", "ready", "") for path, want := range map[string]proto.WorkspaceReadResultPayload{ - "missing": {Outcome: "rejected", ErrorCode: proto.WorkspaceReadNotDirectory}, - "invalid": {Outcome: "rejected", ErrorCode: "invalid_request"}, - "gone": {Outcome: "rejected", ErrorCode: "not_found"}, - "denied": {Outcome: "rejected", ErrorCode: "permission_denied"}, - "broken": {Outcome: "unknown", ErrorCode: "read_unconfirmed"}, + "missing": {Outcome: "rejected", ErrorCode: proto.WorkspaceReadNotDirectory}, + "file": {Outcome: "rejected", ErrorCode: proto.WorkspaceReadNotDirectory}, + "../invalid": {Outcome: "rejected", ErrorCode: "invalid_request"}, } { read := proto.WorkspaceReadPayload{EnvironmentID: environment, Handle: ready.Handle, Operation: "directory", Path: path, MaxEntries: 10} _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, path, read)) diff --git a/apps/parsar-daemon/internal/dispatch/native_file_results_test.go b/apps/parsar-daemon/internal/dispatch/native_file_results_test.go new file mode 100644 index 000000000..e0f05f9e6 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/native_file_results_test.go @@ -0,0 +1,53 @@ +package dispatch + +import ( + "errors" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" + "io/fs" + "testing" +) + +func TestNativeDirectoryFailureMapping(t *testing.T) { + for _, test := range []struct { + err error + outcome, code string + }{ + {agent.ErrWorkspaceNotDirectory, "rejected", proto.WorkspaceReadNotDirectory}, + {agent.ErrWorkspaceReadInvalid, "rejected", "invalid_request"}, + {fs.ErrNotExist, "rejected", "not_found"}, + {fs.ErrPermission, "rejected", "permission_denied"}, + {agent.ErrWorkspaceReadUncertain, "unknown", "read_unconfirmed"}, + } { + got := workspaceReadResult(agent.WorkspaceReadResult{}, test.err, 0) + if got.Outcome != test.outcome || got.ErrorCode != test.code { + t.Fatal(test.err, got) + } + } +} +func TestLocalUploadUnknownRetainsOwner(t *testing.T) { + sender := exportSender{make(chan proto.Envelope, 8)} + r, err := New(Config{Registry: agent.NewRegistry(), Sender: sender}) + if err != nil { + t.Fatal(err) + } + got := workspaceWriteResult(agent.WorkspaceWriteResult{}, errors.New("unconfirmed mutation"), 3) + if got.Outcome != "unknown" { + t.Fatal(got) + } + r.workspaceWrite = &workspaceUpload{envelope: proto.Envelope{ID: uuid.NewString()}, finished: true, uncertain: true} + request := proto.WorkspaceWritePayload{Step: "begin", EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Path: "file", SizeBytes: 0, SHA256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"} + env, _ := proto.NewEnvelope(proto.TypeWorkspaceWrite, uuid.NewString(), request) + if err = r.Handle(t.Context(), env); err != nil { + t.Fatal(err) + } + reply := <-sender.replies + var result proto.WorkspaceWriteResultPayload + if err = reply.DecodePayload(&result); err != nil || result.Outcome != "rejected" || result.ErrorCode != "write_capacity" { + t.Fatal(result, err) + } + if err = r.Shutdown(t.Context()); err == nil { + t.Fatal("shutdown declared uncertain mutation settled") + } +} diff --git a/apps/parsar-daemon/internal/dispatch/workspace_export_test.go b/apps/parsar-daemon/internal/dispatch/workspace_export_test.go index f8202a2af..8f8edbd69 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_export_test.go +++ b/apps/parsar-daemon/internal/dispatch/workspace_export_test.go @@ -1,7 +1,10 @@ package dispatch import ( + "archive/tar" + "bytes" "context" + "io" "os" "path/filepath" "testing" @@ -26,23 +29,25 @@ func (s exportSender) Send(ctx context.Context, env proto.Envelope) error { func exporterRouter(t *testing.T, program string) (*Router, exportSender, proto.WorkspaceExportPayload) { t.Helper() - workspace, err := filepath.EvalSymlinks(t.TempDir()) - if err != nil { + workspace := t.TempDir() + if err := os.Mkdir(filepath.Join(workspace, "outputs"), 0700); err != nil { t.Fatal(err) } - dir, err := filepath.EvalSymlinks(t.TempDir()) - if err != nil { + if err := os.WriteFile(filepath.Join(workspace, "outputs", "a"), make([]byte, 131089), 0600); err != nil { t.Fatal(err) } - helper := filepath.Join(dir, "export") - if err := os.WriteFile(helper, []byte("#!/bin/sh\n"+program+"\n"), 0700); err != nil { - t.Fatal(err) + if program == "failure" { + f, err := os.Create(filepath.Join(workspace, "outputs", "z")) + if err != nil { + t.Fatal(err) + } + if err = f.Truncate(201 << 20); err != nil { + t.Fatal(err) + } + f.Close() } environment, session := uuid.NewString(), uuid.NewString() - for key, value := range map[string]string{"OAC_RUNTIME_ENVIRONMENT_ID": environment, "OAC_RUNTIME_SESSION_ID": session, "OAC_RUNTIME_WORKSPACE": workspace, "OAC_RUNTIME_DIRECTORY_HELPER": helper, "OAC_RUNTIME_EXPORT_HELPER": helper, "OAC_RUNTIME_WRITE_HELPER": "", "OAC_RUNTIME_STAGING": "", "OAC_RUNTIME_NETWORK_ACCESS": ""} { - t.Setenv(key, value) - } - binding, err := localworkspace.Load() + binding, err := localworkspace.New(environment, session, workspace, "") if err != nil { t.Fatal(err) } @@ -92,7 +97,7 @@ func readExport(t *testing.T, s exportSender) proto.WorkspaceExportResultPayload } func TestWorkspaceExportUsesExactReadPreparationAndPullsBoundedBytes(t *testing.T) { - r, s, request := exporterRouter(t, "head -c 131089 /dev/zero") + r, s, request := exporterRouter(t, "normal") for _, field := range []string{"environment", "handle"} { bad := request if field == "environment" { @@ -108,6 +113,7 @@ func TestWorkspaceExportUsesExactReadPreparationAndPullsBoundedBytes(t *testing. id := uuid.NewString() sendExport(t, r, id, request) var offset int64 + var archive bytes.Buffer for { p := readExport(t, s) if p.Offset != offset { @@ -119,11 +125,7 @@ func TestWorkspaceExportUsesExactReadPreparationAndPullsBoundedBytes(t *testing. if p.Outcome != "chunk" || len(p.Data) == 0 || len(p.Data) > proto.WorkspaceExportChunkBytes { t.Fatal("invalid chunk") } - for _, b := range p.Data { - if b != 0 { - t.Fatal("wrong byte") - } - } + archive.Write(p.Data) offset += int64(len(p.Data)) select { case <-s.replies: @@ -132,27 +134,43 @@ func TestWorkspaceExportUsesExactReadPreparationAndPullsBoundedBytes(t *testing. } sendExport(t, r, id, proto.WorkspaceExportPayload{Step: "next", Offset: offset}) } - if offset != 131089 { - t.Fatal("truncated export", offset) + reader := tar.NewReader(&archive) + header, err := reader.Next() + if err != nil || header.Name != "outputs/a" || header.Size != 131089 { + t.Fatal("invalid archive", header, err) + } + data, err := io.ReadAll(reader) + if err != nil || !bytes.Equal(data, make([]byte, 131089)) { + t.Fatal("truncated artifact", err) + } + if _, err = reader.Next(); err != io.EOF { + t.Fatal("unexpected extra artifact", err) } } func TestWorkspaceExportFailureAfterBytesCannotComplete(t *testing.T) { - r, s, request := exporterRouter(t, "printf abc; exit 1") + r, s, request := exporterRouter(t, "failure") id := uuid.NewString() sendExport(t, r, id, request) - p := readExport(t, s) - if p.Outcome != "chunk" || string(p.Data) != "abc" { - t.Fatal("missing prefix", p) - } - sendExport(t, r, id, proto.WorkspaceExportPayload{Step: "next", Offset: 3}) - if p := readExport(t, s); p.Outcome != "failed" { - t.Fatal("failed process appeared complete", p) + var offset int64 + for { + p := readExport(t, s) + if p.Outcome == "failed" { + if offset == 0 { + t.Fatal("no prefix before failure") + } + break + } + if p.Outcome != "chunk" || p.Offset != offset { + t.Fatal("failed export appeared complete", p) + } + offset += int64(len(p.Data)) + sendExport(t, r, id, proto.WorkspaceExportPayload{Step: "next", Offset: offset}) } } -func TestWorkspaceExportCancelUnblocksProcessAndReleasesCapacity(t *testing.T) { - r, _, request := exporterRouter(t, "exec sleep 30") +func TestWorkspaceExportCancelUnblocksWriterAndReleasesCapacity(t *testing.T) { + r, _, request := exporterRouter(t, "normal") id := uuid.NewString() sendExport(t, r, id, request) sendExport(t, r, id, proto.WorkspaceExportPayload{Step: "cancel"}) diff --git a/apps/parsar-daemon/internal/dispatch/workspace_write_test.go b/apps/parsar-daemon/internal/dispatch/workspace_write_test.go index ee32da350..c629d421f 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_write_test.go +++ b/apps/parsar-daemon/internal/dispatch/workspace_write_test.go @@ -16,31 +16,11 @@ import ( "github.com/google/uuid" ) -func localWriterRouter(t *testing.T, response string) (*dispatch.Router, *recSender, proto.WorkspaceWritePayload, string) { +func localWriterRouter(t *testing.T) (*dispatch.Router, *recSender, proto.WorkspaceWritePayload, string) { t.Helper() - parent, err := filepath.EvalSymlinks(t.TempDir()) - if err != nil { - t.Fatal(err) - } - workspace, staging := filepath.Join(parent, "workspace"), filepath.Join(parent, "staging") - for _, p := range []string{workspace, staging} { - if err := os.Mkdir(p, 0700); err != nil { - t.Fatal(err) - } - } - helperRoot, err := filepath.EvalSymlinks(t.TempDir()) - if err != nil { - t.Fatal(err) - } - helper := filepath.Join(helperRoot, "helper") - if err := os.WriteFile(helper, []byte("#!/bin/sh\ncat >/dev/null\ntouch \"$1/invoked\"\nprintf '%s' '"+response+"'\n"), 0700); err != nil { - t.Fatal(err) - } + workspace := t.TempDir() environment, session := uuid.NewString(), uuid.NewString() - for k, v := range map[string]string{"OAC_RUNTIME_ENVIRONMENT_ID": environment, "OAC_RUNTIME_SESSION_ID": session, "OAC_RUNTIME_WORKSPACE": workspace, "OAC_RUNTIME_DIRECTORY_HELPER": helper, "OAC_RUNTIME_WRITE_HELPER": helper, "OAC_RUNTIME_STAGING": staging} { - t.Setenv(k, v) - } - binding, err := localworkspace.Load() + binding, err := localworkspace.New(environment, session, workspace, "") if err != nil { t.Fatal(err) } @@ -81,7 +61,7 @@ func waitWorkspaceWrite(t *testing.T, sender *recSender, id, outcome string) pro } func TestLocalUploadRequiresExactScopeAndCompleteBody(t *testing.T) { - r, sender, request, workspace := localWriterRouter(t, `{"version":1,"outcome":"completed","size_bytes":3}`) + r, sender, request, workspace := localWriterRouter(t) for _, field := range []string{"environment", "session"} { bad := request if field == "environment" { @@ -108,8 +88,8 @@ func TestLocalUploadRequiresExactScopeAndCompleteBody(t *testing.T) { t.Fatal(err) } } - if _, err := os.Stat(filepath.Join(workspace, "invoked")); !os.IsNotExist(err) { - t.Fatal("helper started before commit") + if _, err := os.Stat(filepath.Join(workspace, "file")); !os.IsNotExist(err) { + t.Fatal("file created before commit") } if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, proto.WorkspaceWritePayload{Step: "commit"})); err != nil { t.Fatal(err) @@ -117,15 +97,15 @@ func TestLocalUploadRequiresExactScopeAndCompleteBody(t *testing.T) { if got := waitWorkspaceWrite(t, sender, id, "completed"); got.SizeBytes != 3 { t.Fatal(got) } - if _, err := os.Stat(filepath.Join(workspace, "invoked")); err != nil { - t.Fatal("helper not called", err) + if data, err := os.ReadFile(filepath.Join(workspace, "file")); err != nil || string(data) != "abc" { + t.Fatal("committed bytes differ", err) } } func TestLocalUploadRejectsReorderedOrCorruptBodiesWithoutMutation(t *testing.T) { for _, mode := range []string{"offset", "digest", "short"} { t.Run(mode, func(t *testing.T) { - r, sender, request, workspace := localWriterRouter(t, `{"version":1,"outcome":"completed","size_bytes":3}`) + r, sender, request, workspace := localWriterRouter(t) id := uuid.NewString() _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, request)) chunk := proto.WorkspaceWritePayload{Step: "chunk", Data: []byte("abc")} @@ -140,32 +120,13 @@ func TestLocalUploadRejectsReorderedOrCorruptBodiesWithoutMutation(t *testing.T) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, chunk)) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, proto.WorkspaceWritePayload{Step: "commit"})) waitWorkspaceWrite(t, sender, id, "rejected") - if _, err := os.Stat(filepath.Join(workspace, "invoked")); !os.IsNotExist(err) { - t.Fatal("bad transfer invoked helper") + if _, err := os.Stat(filepath.Join(workspace, "file")); !os.IsNotExist(err) { + t.Fatal("bad transfer mutated workspace") } }) } } -func TestLocalUploadUnknownRetainsOwner(t *testing.T) { - r, sender, request, _ := localWriterRouter(t, `{"version":1,"outcome":"unknown","error":"write_failed"}`) - id := uuid.NewString() - for _, p := range []proto.WorkspaceWritePayload{request, {Step: "chunk", Data: []byte("abc")}, {Step: "commit"}} { - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, p)); err != nil { - t.Fatal(err) - } - } - waitWorkspaceWrite(t, sender, id, "unknown") - next := uuid.NewString() - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, next, request)) - if got := waitWorkspaceWrite(t, sender, next, "rejected"); got.ErrorCode != "write_capacity" { - t.Fatal(got) - } - if err := r.Shutdown(t.Context()); err == nil { - t.Fatal("shutdown declared uncertain mutation settled") - } -} - func TestLocalUploadReportsDestinationConflictsAndReleasesOwner(t *testing.T) { for helperError, reason := range map[string]string{ "destination_directory": proto.WorkspaceWriteReasonDirectory, @@ -173,7 +134,22 @@ func TestLocalUploadReportsDestinationConflictsAndReleasesOwner(t *testing.T) { "write_failed": "", } { t.Run(helperError, func(t *testing.T) { - r, sender, request, _ := localWriterRouter(t, `{"version":1,"outcome":"failed","error":"`+helperError+`"}`) + r, sender, request, workspace := localWriterRouter(t) + switch helperError { + case "destination_directory": + if err := os.Mkdir(filepath.Join(workspace, "file"), 0700); err != nil { + t.Fatal(err) + } + case "unsafe_destination": + if err := os.WriteFile(filepath.Join(workspace, "file"), []byte("existing"), 0600); err != nil { + t.Fatal(err) + } + case "write_failed": + if err := os.WriteFile(filepath.Join(workspace, "parent"), nil, 0600); err != nil { + t.Fatal(err) + } + request.Path = "parent/file" + } id := uuid.NewString() for _, p := range []proto.WorkspaceWritePayload{request, {Step: "chunk", Data: []byte("abc")}, {Step: "commit"}} { if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, p)); err != nil { diff --git a/apps/parsar-daemon/internal/localworkspace/binding.go b/apps/parsar-daemon/internal/localworkspace/binding.go index c533216d4..bd1a90aa2 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding.go +++ b/apps/parsar-daemon/internal/localworkspace/binding.go @@ -12,11 +12,11 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" - "github.com/google/uuid" ) // Binding freezes operator-owned identity and paths for one Runtime lifetime. type Binding struct { + executionMode string environment string networkAccess string allowedDomains []string @@ -30,48 +30,27 @@ type Binding struct { } func New(environment, session, workspace, helper string) (*Binding, error) { - for _, id := range []string{environment, session} { - parsed, err := uuid.Parse(id) - if err != nil || parsed == uuid.Nil || parsed.String() != id { - return nil, errors.New("local workspace requires canonical resource identities") - } - } - for _, name := range []string{workspace, helper} { - if !filepath.IsAbs(name) || filepath.Clean(name) != name || name == "/" || strings.ContainsAny(name, "\x00\r\n\\") { - return nil, errors.New("local workspace requires clean absolute deployment paths") - } - } - root, err := os.Lstat(workspace) - if err != nil || !root.IsDir() || root.Mode()&os.ModeSymlink != 0 { - return nil, errors.New("local workspace root must be an existing directory") + root, err := paths.Root() + if err != nil { + return nil, err } - program, err := os.Stat(helper) - if err != nil || !program.Mode().IsRegular() || program.Mode().Perm()&0111 == 0 || strings.HasPrefix(helper, workspace+string(filepath.Separator)) { - return nil, errors.New("local workspace helper must be executable outside the workspace") + b, err := newNativeBinding(environment, session, workspace, filepath.Join(root, "capabilities")) + if err != nil { + return nil, err } - return &Binding{environment: environment, stateKey: "agents-api-" + session, workspace: workspace, helper: helper, capabilityRoot: CapabilityDirectory}, nil + b.helper = helper + b.executionMode = os.Getenv("OAC_RUNTIME_EXECUTION_MODE") + return b, nil } -// NewWithCapabilityDirectory freezes an operator-owned installation layout. -// Core and capability transfer messages cannot choose this destination. +// NewWithCapabilityDirectory freezes paths selected by the Runtime operator. func NewWithCapabilityDirectory(environment, session, workspace, helper, directory string) (*Binding, error) { - b, err := New(environment, session, workspace, helper) + b, err := newNativeBinding(environment, session, workspace, directory) if err != nil { return nil, err } - if agentcapabilities.ValidateLocalDirectories([]string{directory}) != nil || directory == "/" || containsPath(workspace, directory) || containsPath(directory, workspace) || containsPath(PackageDirectory, directory) || containsPath(directory, PackageDirectory) { - return nil, agentcapabilities.ErrInvalid - } - private, err := paths.Root() - if err != nil || !filepath.IsAbs(private) || containsPath(private, directory) || containsPath(directory, private) { - return nil, agentcapabilities.ErrInvalid - } - for _, name := range []string{workspace, private, directory} { - if !canonicalExistingParent(name) { - return nil, agentcapabilities.ErrInvalid - } - } - b.capabilityRoot = directory + b.helper = helper + b.executionMode = os.Getenv("OAC_RUNTIME_EXECUTION_MODE") return b, nil } @@ -97,22 +76,9 @@ func Load() (*Binding, error) { } b.networkAccess = network b.allowedDomains = policy.Hosts() - if exportHelper != "" { - // Reuse the startup executable/root checks; this grants no caller authority. - if _, err := New(values[0], values[1], values[2], exportHelper); err != nil { - return nil, err - } - resolved, err := filepath.EvalSymlinks(exportHelper) - if err != nil || resolved != exportHelper { - return nil, errors.New("workspace exporter must be a canonical executable") - } - b.exportHelper = exportHelper - } - if writeHelper != "" || staging != "" { - if err := b.bindWriter(writeHelper, staging); err != nil { - return nil, err - } - } + b.exportHelper = exportHelper + b.writer.helper, b.writer.staging = writeHelper, staging + return b, nil } @@ -134,6 +100,7 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa } if !r.WorkspaceReadOnly { local := *r.LocalEnvironment + local.ExecutionMode = b.ExecutionMode() if local.WorkspaceDirectory != "/workspace" && local.WorkspaceDirectory != b.workspace { return r, errors.New("request does not match the local workspace selection") } diff --git a/apps/parsar-daemon/internal/localworkspace/binding_test.go b/apps/parsar-daemon/internal/localworkspace/binding_test.go index 4e0da0b2d..e07b0dbea 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding_test.go +++ b/apps/parsar-daemon/internal/localworkspace/binding_test.go @@ -123,23 +123,14 @@ func TestBindingAllowsRetainedExecutor(t *testing.T) { } } -func TestCapabilityLayoutExcludesWritableAndPrivateRoots(t *testing.T) { +func TestCapabilityLayoutUsesOperatorDirectories(t *testing.T) { b, _ := testBinding(t) - private := os.Getenv("OAC_RUNTIME_HOME") - for _, directory := range []string{b.workspace, filepath.Join(b.workspace, "caps"), private, filepath.Join(private, "caps"), PackageDirectory, filepath.Join(PackageDirectory, "caps"), "/"} { - if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, b.helper, directory); err == nil { - t.Fatal("unsafe installation layout accepted") + for _, directory := range []string{filepath.Join(b.workspace, "capabilities"), filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "capabilities")} { + if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, "", directory); err != nil { + t.Fatal(err) } } - root := t.TempDir() - alias := filepath.Join(root, "alias") - if err := os.Symlink(t.TempDir(), alias); err != nil { - t.Fatal(err) - } - if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, b.helper, filepath.Join(alias, "new")); err == nil { - t.Fatal("installation parent alias accepted") - } - if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, b.helper, t.TempDir()); err != nil { - t.Fatal(err) + if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, "", "relative"); err == nil { + t.Fatal("relative installation path accepted") } } diff --git a/apps/parsar-daemon/internal/localworkspace/capabilities.go b/apps/parsar-daemon/internal/localworkspace/capabilities.go index e762b177f..c2b79390c 100644 --- a/apps/parsar-daemon/internal/localworkspace/capabilities.go +++ b/apps/parsar-daemon/internal/localworkspace/capabilities.go @@ -6,11 +6,10 @@ import ( "os" "path/filepath" "strings" - "syscall" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" ) // Prepare runs under the admitted executor's lifetime, before native startup. @@ -46,11 +45,12 @@ func (b *Binding) Prepare(ctx context.Context, r proto.PromptRequestPayload) (pr } local := *r.LocalEnvironment local.Skills, local.MCP, local.CapabilityRoot = manifest.Skills, nil, b.capabilityRoot + local.ExecutionMode = b.ExecutionMode() for i := range local.Skills { local.Skills[i].InstallationRoot = b.capabilityRoot } if local.ToolEnvironment { - if err = VerifyToolEnvironment(local.SystemPackages); err != nil { + if _, err = b.ReadToolEnvironment(); err != nil { return r, err } } @@ -58,7 +58,7 @@ func (b *Binding) Prepare(ctx context.Context, r proto.PromptRequestPayload) (pr if b.NetworkPolicy().Access != "enabled" { return r, agentcapabilities.ErrInvalid } - values, readErr := ReadOptionalToolEnvironment() + values, readErr := b.ReadToolEnvironment() if readErr != nil { return r, readErr } @@ -66,6 +66,7 @@ func (b *Binding) Prepare(ctx context.Context, r proto.PromptRequestPayload) (pr for i := range local.MCP { local.MCP[i].InstallationRoot = b.capabilityRoot local.MCP[i].WorkspaceRoot = b.workspace + local.MCP[i].ExecutionMode = b.ExecutionMode() } if err != nil { return r, err @@ -155,7 +156,7 @@ func (b *Binding) openCapabilities(ctx context.Context, completed bool) (*os.Roo if err := ctx.Err(); err != nil { return nil, nil, err } - if b.capabilityRoot == "" || !filepath.IsAbs(b.capabilityRoot) || !canonicalExistingParent(b.capabilityRoot) { + if b.capabilityRoot == "" || runtimefs.ValidateLocalPath(b.capabilityRoot) != nil { return nil, nil, agentcapabilities.ErrInvalid } if !completed { @@ -163,25 +164,16 @@ func (b *Binding) openCapabilities(ctx context.Context, completed bool) (*os.Roo return nil, nil, agentcapabilities.ErrInvalid } } - actual, err := filepath.EvalSymlinks(b.capabilityRoot) - if err != nil || actual != b.capabilityRoot { - return nil, nil, agentcapabilities.ErrInvalid - } root, err := os.OpenRoot(b.capabilityRoot) if err != nil { return nil, nil, agentcapabilities.ErrInvalid } - dir, err := root.Open(".") + unlock, err := runtimefs.LockDirectory(root) if err != nil { root.Close() return nil, nil, agentcapabilities.ErrInvalid } - if err = syscall.Flock(int(dir.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { - dir.Close() - root.Close() - return nil, nil, agentcapabilities.ErrInvalid - } - return root, func() { _ = syscall.Flock(int(dir.Fd()), syscall.LOCK_UN); dir.Close(); root.Close() }, nil + return root, func() { unlock(); root.Close() }, nil } func containsPath(parent, child string) bool { @@ -196,27 +188,10 @@ func (b *Binding) resolveCapabilityDirectory(source string) (*os.Root, error) { if source == "/workspace" || strings.HasPrefix(source, "/workspace/") { source = filepath.Join(b.workspace, strings.TrimPrefix(source, "/workspace")) } - actual, err := filepath.EvalSymlinks(source) - if err != nil || actual != source { + // Refuse recursive installation into the selected source itself. + if containsPath(source, b.capabilityRoot) || containsPath(b.capabilityRoot, source) { return nil, agentcapabilities.ErrInvalid } - private, err := paths.Root() - if err != nil || !filepath.IsAbs(private) { - return nil, agentcapabilities.ErrInvalid - } - // Protect both descendants and ancestors: selecting a parent must not copy - // credentials, native history, installed capabilities or initialization env. - for _, protected := range []string{private, b.capabilityRoot, InitializationDirectory} { - canonical, e := filepath.EvalSymlinks(protected) - if e == nil { - protected = canonical - } else if !errors.Is(e, os.ErrNotExist) { - return nil, agentcapabilities.ErrInvalid - } - if containsPath(source, protected) || containsPath(protected, source) { - return nil, agentcapabilities.ErrInvalid - } - } root, err := os.OpenRoot(source) if err != nil { return nil, agentcapabilities.ErrInvalid diff --git a/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go b/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go index 656d5ddd5..35870e6a3 100644 --- a/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go +++ b/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go @@ -83,27 +83,22 @@ func TestPreparationFreezesLocalContentsAcrossReconnect(t *testing.T) { } } -func TestPreparationRejectsPrivateSourcesAndLeavesFailuresInert(t *testing.T) { +func TestPreparationUsesOperatorSourcesAndLeavesFailuresInert(t *testing.T) { b, req := testBinding(t) private := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", private) writeSourceSkill(t, private, "private") - for _, source := range []string{private, filepath.Dir(private), b.capabilityRoot, InitializationDirectory} { - if root, err := b.resolveCapabilityDirectory(source); err == nil { - root.Close() - t.Fatal("protected source accepted") - } + held, err := b.resolveCapabilityDirectory(private) + if err != nil { + t.Fatal("operator source rejected", err) + } + held.Close() + if held, err = b.resolveCapabilityDirectory(b.capabilityRoot); err == nil { + held.Close() + t.Fatal("recursive snapshot source accepted") } source := filepath.Join(b.workspace, "selected") writeSourceSkill(t, source, "valid") - link := filepath.Join(b.workspace, "link") - if err := os.Symlink(source, link); err != nil { - t.Fatal(err) - } - if root, err := b.resolveCapabilityDirectory(link); err == nil { - root.Close() - t.Fatal("source alias accepted") - } root, err := b.resolveCapabilityDirectory("/workspace/selected") if err != nil { t.Fatal("logical workspace source rejected", err) diff --git a/apps/parsar-daemon/internal/localworkspace/directory.go b/apps/parsar-daemon/internal/localworkspace/directory.go index b747c3821..8ce8ac130 100644 --- a/apps/parsar-daemon/internal/localworkspace/directory.go +++ b/apps/parsar-daemon/internal/localworkspace/directory.go @@ -6,10 +6,7 @@ import ( "encoding/json" "io" "io/fs" - "os/exec" - "strconv" "strings" - "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" @@ -19,29 +16,7 @@ func (b *Binding) ListWorkspaceDirectory(ctx context.Context, path string, limit if limit < 1 || limit > proto.WorkspaceDirectoryMaxEntries || len(path) > 4096 || strings.ContainsAny(path, "\\\x00\r\n") || (path != "" && (path == "." || !fs.ValidPath(path))) { return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadInvalid } - operation, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - cmd := exec.CommandContext(operation, b.helper, b.workspace, path, strconv.Itoa(limit)) - cmd.Dir = "/" - cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C.UTF-8"} - cmd.WaitDelay = time.Second - pipe, err := cmd.StdoutPipe() - if err != nil { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnavailable - } - if err := cmd.Start(); err != nil { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnavailable - } - const maxOutput = 4 << 20 - data, err := io.ReadAll(io.LimitReader(pipe, maxOutput+1)) - if err != nil || len(data) > maxOutput { - _ = cmd.Process.Kill() - } - waitErr := cmd.Wait() - if err != nil || waitErr != nil || len(data) > maxOutput { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUncertain - } - return decodeDirectory(data, limit) + return b.listNativeDirectory(ctx, path, limit) } func decodeDirectory(data []byte, limit int) (agent.WorkspaceDirectoryResult, error) { diff --git a/apps/parsar-daemon/internal/localworkspace/directory_native_test.go b/apps/parsar-daemon/internal/localworkspace/directory_native_test.go index efc2f7bf5..142c69062 100644 --- a/apps/parsar-daemon/internal/localworkspace/directory_native_test.go +++ b/apps/parsar-daemon/internal/localworkspace/directory_native_test.go @@ -2,61 +2,55 @@ package localworkspace import ( "errors" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "os" "path/filepath" "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/google/uuid" ) -func TestNativeLocalDirectoryConfinement(t *testing.T) { - helper := os.Getenv("OAC_TEST_LOCAL_DIRECTORY_HELPER") - if helper == "" { - t.Skip("actual pinned directory helper required") - } - root, outside := t.TempDir(), t.TempDir() - if err := os.Mkdir(filepath.Join(root, "nested"), 0o700); err != nil { +func TestNativeDirectoryAPI(t *testing.T) { + b := nativeFileBinding(t) + if err := os.Mkdir(filepath.Join(b.workspace, "nested"), 0700); err != nil { t.Fatal(err) } - for _, path := range []string{filepath.Join(root, "nested", "data.bin"), filepath.Join(outside, "secret")} { - if err := os.WriteFile(path, []byte{0, 1, 255, 17}, 0o600); err != nil { - t.Fatal(err) - } - } - if err := os.Symlink(outside, filepath.Join(root, "escape")); err != nil { - t.Fatal(err) - } - b, err := New(uuid.NewString(), uuid.NewString(), root, helper) - if err != nil { + if err := os.WriteFile(filepath.Join(b.workspace, "nested", "data.bin"), []byte{0, 1, 255, 17}, 0600); err != nil { t.Fatal(err) } got, err := b.ListWorkspaceDirectory(t.Context(), "nested", 10) if err != nil || got.Truncated || len(got.Entries) != 1 || got.Entries[0].Name != "data.bin" || got.Entries[0].SizeBytes == nil || *got.Entries[0].SizeBytes != 4 { - t.Fatalf("native file metadata: %+v %v", got, err) + t.Fatal(got, err) } - if err := os.WriteFile(filepath.Join(root, "file.txt"), []byte("x"), 0o600); err != nil { - t.Fatal(err) - } - // A link, a regular file and a missing path are not listable directories; - // the external link target is never listed. - for _, path := range []string{"escape", "escape/secret", "file.txt", "missing", "missing/deeper"} { + for _, path := range []string{"missing", "nested/data.bin"} { if _, err := b.ListWorkspaceDirectory(t.Context(), path, 10); !errors.Is(err, agent.ErrWorkspaceNotDirectory) { - t.Fatal("native helper classified a non-directory path differently", path, err) + t.Fatal(path, err) } } + if err := os.WriteFile(filepath.Join(b.workspace, "second"), nil, 0600); err != nil { + t.Fatal(err) + } got, err = b.ListWorkspaceDirectory(t.Context(), "", 1) if err != nil || !got.Truncated || len(got.Entries) != 1 { - t.Fatalf("native directory bound: %+v %v", got, err) + t.Fatal(got, err) } - if err := os.Rename(root, root+"-original"); err != nil { +} + +func TestNativeFileReadPrefixAndEntryKinds(t *testing.T) { + b := nativeFileBinding(t) + if _, err := b.WriteWorkspaceFile(t.Context(), "nested/file", []byte{0, 255, 17}); err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = os.RemoveAll(root + "-original") }) - if err := os.Symlink(outside, root); err != nil { + got, err := b.ReadWorkspaceFile(t.Context(), "nested/file", 2) + if err != nil || !got.Truncated || len(got.Data) != 2 || got.Data[1] != 255 { + t.Fatal(got, err) + } + got, err = b.ReadWorkspaceFile(t.Context(), "nested/file", 3) + if err != nil || got.Truncated || len(got.Data) != 3 { + t.Fatal(got, err) + } + if _, err = b.ReadWorkspaceFile(t.Context(), "nested", 3); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { t.Fatal(err) } - if _, err := b.ListWorkspaceDirectory(t.Context(), "", 10); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { - t.Fatal("native helper followed a replaced root or hid it as an empty directory", err) + if _, err = b.ReadWorkspaceFile(t.Context(), "missing", 3); !os.IsNotExist(err) { + t.Fatal(err) } } diff --git a/apps/parsar-daemon/internal/localworkspace/export.go b/apps/parsar-daemon/internal/localworkspace/export.go index 104750dee..015e020ab 100644 --- a/apps/parsar-daemon/internal/localworkspace/export.go +++ b/apps/parsar-daemon/internal/localworkspace/export.go @@ -3,71 +3,16 @@ package localworkspace import ( "context" "errors" - "io" - "os" - "os/exec" - "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "io" ) -func (b *Binding) CanExport() bool { return b != nil && b.exportHelper != "" } - -// ExportOutputs streams only from the deployment-owned root; successful exit is mandatory. +func (b *Binding) CanExport() bool { return b != nil && b.workspace != "" } func (b *Binding) ExportOutputs(ctx context.Context, output io.Writer) error { if !b.CanExport() || output == nil { return errors.New("workspace export unavailable") } - cmd := exec.CommandContext(ctx, b.exportHelper, b.workspace) - cmd.Dir = "/" - cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C.UTF-8"} - cmd.WaitDelay = time.Second - stdout, childOutput, err := os.Pipe() - if err != nil { - return err - } - defer stdout.Close() - defer childOutput.Close() - cmd.Stdout = childOutput - if err := cmd.Start(); err != nil { - return err - } - _ = childOutput.Close() - stop := context.AfterFunc(ctx, func() { _ = stdout.Close() }) - defer stop() - exited := make(chan struct{}) - settled := make(chan error, 1) - go func() { - err := cmd.Wait() - // Bound a pending read when a descendant inherits stdout after helper exit. - _ = stdout.SetReadDeadline(time.Now().Add(time.Second)) - close(exited) - settled <- err - }() - // Own the pipe so exec.Wait cannot close buffered output during transport - // backpressure. Only actual pipe reads have a post-exit cleanup deadline. - _, copyErr := io.Copy(&exportWriter{output: output}, exportReader{stdout, exited}) - if copyErr != nil { - _ = cmd.Process.Kill() - } - return errors.Join(copyErr, <-settled) -} - -type exportReader struct { - pipe *os.File - exited <-chan struct{} -} - -func (r exportReader) Read(data []byte) (int, error) { - select { - case <-r.exited: - // Consumer delays do not consume the inherited-pipe cleanup allowance. - if err := r.pipe.SetReadDeadline(time.Now().Add(time.Second)); err != nil { - return 0, err - } - default: - } - return r.pipe.Read(data) + return b.exportNativeOutputs(ctx, &exportWriter{output: output}) } type exportWriter struct { @@ -76,6 +21,9 @@ type exportWriter struct { } func (w *exportWriter) Write(data []byte) (int, error) { + if len(data) == 0 { + return 0, nil + } if int64(len(data)) > proto.WorkspaceExportMaxBytes-w.size { return 0, errors.New("workspace export exceeds bound") } diff --git a/apps/parsar-daemon/internal/localworkspace/export_test.go b/apps/parsar-daemon/internal/localworkspace/export_test.go index df17b348e..f396f9f2d 100644 --- a/apps/parsar-daemon/internal/localworkspace/export_test.go +++ b/apps/parsar-daemon/internal/localworkspace/export_test.go @@ -1,111 +1,100 @@ package localworkspace import ( + "archive/tar" "bytes" "context" "errors" "io" "os" "path/filepath" - "strconv" - "strings" "testing" "time" ) -func exportBinding(t *testing.T, program string) *Binding { +func outputBinding(t *testing.T) *Binding { t.Helper() - b, _ := testBinding(t) - b.exportHelper = b.helper - if err := os.WriteFile(b.helper, []byte("#!/bin/sh\n"+program+"\n"), 0700); err != nil { + b := nativeFileBinding(t) + if _, err := b.WriteWorkspaceFile(t.Context(), "outputs/nested/proof.bin", []byte{0, 255, 17}); err != nil { t.Fatal(err) } - return b -} - -type heldExportWriter struct { - bytes.Buffer - entered chan struct{} - release chan struct{} -} - -func (w *heldExportWriter) Write(p []byte) (int, error) { - if w.entered != nil { - close(w.entered) - w.entered = nil - <-w.release - } - return w.Buffer.Write(p) -} - -func TestExportOutputsAllowsConsumerBackpressureAfterHelperExit(t *testing.T) { - b := exportBinding(t, "head -c 3072 /dev/zero") - entered, release := make(chan struct{}), make(chan struct{}) - w := &heldExportWriter{entered: entered, release: release} - done := make(chan error, 1) - go func() { done <- b.ExportOutputs(t.Context(), w) }() - select { - case <-entered: - case <-time.After(3 * time.Second): - close(release) - t.Fatal("helper produced no output") - } - // The helper can exit while the pull transport is waiting for its consumer. - time.Sleep(1500 * time.Millisecond) - close(release) - if err := <-done; err != nil { + if _, err := b.WriteWorkspaceFile(t.Context(), "outputs/empty", nil); err != nil { t.Fatal(err) } - if !bytes.Equal(w.Bytes(), make([]byte, 3072)) { - t.Fatalf("incomplete output: %d bytes", w.Len()) - } + return b } - -func TestExportOutputsRequiresSuccessfulHelperExit(t *testing.T) { - b := exportBinding(t, "printf complete-prefix; exit 7") +func TestNativeOutputArchive(t *testing.T) { + b := outputBinding(t) var output bytes.Buffer - if err := b.ExportOutputs(t.Context(), &output); err == nil || output.String() != "complete-prefix" { - t.Fatalf("failed helper accepted: output=%q error=%v", output.String(), err) + if err := b.ExportOutputs(t.Context(), &output); err != nil { + t.Fatal(err) } -} - -func TestExportOutputsBoundsInheritedPipeAfterHelperExit(t *testing.T) { - pidFile := filepath.Join(t.TempDir(), "descendant.pid") - b := exportBinding(t, "sleep 30 &\necho $! > '"+pidFile+"'\nprintf prefix") - t.Cleanup(func() { - data, err := os.ReadFile(pidFile) + reader := tar.NewReader(&output) + got := map[string][]byte{} + for { + header, err := reader.Next() + if err == io.EOF { + break + } if err != nil { - return + t.Fatal(err) } - pid, err := strconv.Atoi(strings.TrimSpace(string(data))) - if err == nil { - if process, err := os.FindProcess(pid); err == nil { - _ = process.Kill() - } + if header.Typeflag != tar.TypeReg { + t.Fatal(header) } - }) - ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) - defer cancel() + raw, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + got[header.Name] = raw + } + if len(got) != 2 || !bytes.Equal(got["outputs/nested/proof.bin"], []byte{0, 255, 17}) { + t.Fatal(got) + } +} +func TestNativeExportMissingOutputsAndBound(t *testing.T) { + b := nativeFileBinding(t) var output bytes.Buffer - err := b.ExportOutputs(ctx, &output) - if !errors.Is(err, os.ErrDeadlineExceeded) || ctx.Err() != nil || output.String() != "prefix" { - t.Fatalf("inherited pipe not bounded: output=%q error=%v context=%v", output.String(), err, ctx.Err()) + if err := b.ExportOutputs(t.Context(), &output); err != nil { + t.Fatal(err) + } + if _, err := tar.NewReader(&output).Next(); err != io.EOF { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(b.workspace, "outputs"), 0700); err != nil { + t.Fatal(err) + } + f, err := os.Create(filepath.Join(b.workspace, "outputs", "large")) + if err != nil { + t.Fatal(err) + } + if err = f.Truncate(artifactFileBytes + 1); err != nil { + t.Fatal(err) + } + f.Close() + if err = b.ExportOutputs(t.Context(), io.Discard); err == nil { + t.Fatal("oversize artifact accepted") } } -func TestExportOutputsCancellationUsesConsumerCloseContract(t *testing.T) { - b := exportBinding(t, "printf prefix; exec sleep 30") +type failedExportWriter struct{ err error } + +func (w failedExportWriter) Write([]byte) (int, error) { return 0, w.err } +func TestNativeExportCancellationAndConsumerFailure(t *testing.T) { + b := outputBinding(t) + want := errors.New("consumer rejected") + if err := b.ExportOutputs(t.Context(), failedExportWriter{want}); !errors.Is(err, want) { + t.Fatal(err) + } ctx, cancel := context.WithCancel(t.Context()) - defer cancel() reader, writer := io.Pipe() defer reader.Close() - // The dispatch owner closes the consumer on cancellation, releasing Write. stop := context.AfterFunc(ctx, func() { _ = reader.CloseWithError(ctx.Err()) }) defer stop() done := make(chan error, 1) go func() { err := b.ExportOutputs(ctx, writer); _ = writer.CloseWithError(err); done <- err }() - got := make([]byte, 1) - if _, err := reader.Read(got); err != nil { + var prefix [1]byte + if _, err := reader.Read(prefix[:]); err != nil { t.Fatal(err) } cancel() @@ -114,21 +103,35 @@ func TestExportOutputsCancellationUsesConsumerCloseContract(t *testing.T) { if err == nil { t.Fatal("cancelled export succeeded") } - case <-time.After(3 * time.Second): - t.Fatal("cancellation did not settle exporter") + case <-time.After(time.Second): + t.Fatal("export did not settle") } } -type failedExportWriter struct{ err error } - -func (w failedExportWriter) Write([]byte) (int, error) { return 0, w.err } +type changingOutput struct { + bytes.Buffer + change func() +} -func TestExportOutputsConsumerFailureStopsHelper(t *testing.T) { - b := exportBinding(t, "printf prefix; exec sleep 30") - want := errors.New("consumer rejected output") - ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) - defer cancel() - if err := b.ExportOutputs(ctx, failedExportWriter{want}); !errors.Is(err, want) || ctx.Err() != nil { - t.Fatalf("consumer failure did not stop helper: %v context=%v", err, ctx.Err()) +func (w *changingOutput) Write(data []byte) (int, error) { + if w.change != nil { + change := w.change + w.change = nil + change() + } + return w.Buffer.Write(data) +} +func TestNativeExportRejectsChangedFile(t *testing.T) { + b := nativeFileBinding(t) + if _, err := b.WriteWorkspaceFile(t.Context(), "outputs/file", []byte("before")); err != nil { + t.Fatal(err) + } + w := &changingOutput{change: func() { + if err := os.WriteFile(filepath.Join(b.workspace, "outputs", "file"), []byte("changed-length"), 0600); err != nil { + t.Error(err) + } + }} + if err := b.ExportOutputs(t.Context(), w); err == nil { + t.Fatal("changed artifact accepted") } } diff --git a/apps/parsar-daemon/internal/localworkspace/initialization.go b/apps/parsar-daemon/internal/localworkspace/initialization.go index b1f87f0de..f7d4612e5 100644 --- a/apps/parsar-daemon/internal/localworkspace/initialization.go +++ b/apps/parsar-daemon/internal/localworkspace/initialization.go @@ -10,11 +10,11 @@ import ( // ReadToolEnvironment selects the immutable user configuration. It never reads // process environment or introduces live-execution prerequisites for Files. func ReadToolEnvironment() (map[string]string, error) { - info, err := os.Lstat(ToolEnvironmentJSON) - if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0222 != 0 || info.Size() > 1<<20 { + info, err := os.Stat(toolEnvironmentPath()) + if err != nil || !info.Mode().IsRegular() || info.Size() > 1<<20 { return nil, errors.New("initialized user environment unavailable") } - body, err := os.ReadFile(ToolEnvironmentJSON) + body, err := os.ReadFile(toolEnvironmentPath()) var values map[string]string if err != nil || json.Unmarshal(body, &values) != nil || values == nil { return nil, errors.New("initialized user environment unavailable") @@ -50,7 +50,7 @@ func VerifyToolEnvironment(systemPackages bool) error { if !info.IsDir() { return errors.New("initialized tool directory unavailable") } - } else if !info.Mode().IsRegular() || info.Mode().Perm()&0222 != 0 || info.Size() > 1024*1024 { + } else if !info.Mode().IsRegular() || info.Size() > 1024*1024 { return errors.New("initialized tool configuration is not immutable") } } @@ -69,8 +69,17 @@ func VerifyToolEnvironment(systemPackages bool) error { // ReadOptionalToolEnvironment permits a directory-only Runtime without user // variables. Declared MCP variables still require the immutable explicit file. func ReadOptionalToolEnvironment() (map[string]string, error) { - if _, err := os.Lstat(ToolEnvironmentJSON); errors.Is(err, os.ErrNotExist) { + if _, err := os.Stat(toolEnvironmentPath()); errors.Is(err, os.ErrNotExist) { return map[string]string{}, nil } return ReadToolEnvironment() } + +// Native installations can select an explicit tool configuration file. Packaged +// installations retain their existing resource layout, without a sandbox. +func toolEnvironmentPath() string { + if path := os.Getenv("OAC_RUNTIME_TOOL_ENV_FILE"); path != "" { + return path + } + return ToolEnvironmentJSON +} diff --git a/apps/parsar-daemon/internal/localworkspace/mcp.go b/apps/parsar-daemon/internal/localworkspace/mcp.go index b196fd857..146d1e812 100644 --- a/apps/parsar-daemon/internal/localworkspace/mcp.go +++ b/apps/parsar-daemon/internal/localworkspace/mcp.go @@ -2,6 +2,7 @@ package localworkspace import ( "errors" + "os" "strings" "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" @@ -10,10 +11,13 @@ import ( const MCPInitializer = "/usr/local/bin/oac-runtime-initialize" -// MCPStdioCommand contains only installed identities. The server's executable, -// arguments and selected user variables are resolved after entering isolation. +// MCPStdioCommand resolves the common installed manifest in the daemon. func MCPStdioCommand(server proto.EnvironmentMCP) (string, []string) { - return "/usr/bin/python3", []string{"-I", "-S", MCPInitializer, "stdio", server.InstallationRoot, server.WorkspaceRoot, server.PackageRoot, server.Server.Name} + executable, err := os.Executable() + if err != nil { + return "", nil + } + return executable, []string{"runtime-mcp-exec", server.InstallationRoot, server.PackageRoot, server.Server.Name} } func resolveEnvironmentMCP(installed []agentcapabilities.InstalledMCP, values map[string]string) ([]proto.EnvironmentMCP, error) { diff --git a/apps/parsar-daemon/internal/localworkspace/mcp_test.go b/apps/parsar-daemon/internal/localworkspace/mcp_test.go index 36834c4de..77293a5e0 100644 --- a/apps/parsar-daemon/internal/localworkspace/mcp_test.go +++ b/apps/parsar-daemon/internal/localworkspace/mcp_test.go @@ -1,6 +1,7 @@ package localworkspace import ( + "os" "slices" "testing" @@ -32,7 +33,11 @@ func TestMCPStdioLauncherContainsOnlyInstalledIdentity(t *testing.T) { Name: "package_tool", Type: "stdio", Command: "untrusted-command", Args: []string{"private-argument"}, }} command, args := MCPStdioCommand(server) - if command != "/usr/bin/python3" || !slices.Equal(args, []string{"-I", "-S", MCPInitializer, "stdio", "/private/runtime/capabilities", "/private/runtime/workspace", "plugins/0", "package_tool"}) { + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if command != executable || !slices.Equal(args, []string{"runtime-mcp-exec", "/private/runtime/capabilities", "plugins/0", "package_tool"}) { t.Fatal("native configuration included untrusted process configuration") } } diff --git a/apps/parsar-daemon/internal/localworkspace/native_binding.go b/apps/parsar-daemon/internal/localworkspace/native_binding.go new file mode 100644 index 000000000..1dcf54df3 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/native_binding.go @@ -0,0 +1,42 @@ +package localworkspace + +import ( + "errors" + "os" + "path/filepath" + + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" + "github.com/google/uuid" +) + +// ExecutionMode selects the installation layout, never a permission boundary. +func (b *Binding) ExecutionMode() string { + if b == nil { + return "" + } + return b.executionMode +} + +func newNativeBinding(environment, session, workspace, capabilities string) (*Binding, error) { + for _, value := range []string{environment, session} { + id, err := uuid.Parse(value) + if err != nil || id == uuid.Nil || id.String() != value { + return nil, errors.New("local workspace requires canonical resource identities") + } + } + for _, path := range []string{workspace, capabilities} { + if runtimefs.ValidateLocalPath(path) != nil || filepath.Dir(path) == path { + return nil, errors.New("local workspace requires absolute installation paths") + } + } + info, err := os.Stat(workspace) + if err != nil || !info.IsDir() { + return nil, errors.New("local workspace root must be an existing directory") + } + return &Binding{environment: environment, stateKey: "agents-api-" + session, workspace: workspace, capabilityRoot: capabilities, executionMode: "native", writer: &fileWriter{}}, nil +} + +// ReadToolEnvironment reads explicit initialization values for this installation. +func (b *Binding) ReadToolEnvironment() (map[string]string, error) { + return ReadOptionalToolEnvironment() +} diff --git a/apps/parsar-daemon/internal/localworkspace/native_files.go b/apps/parsar-daemon/internal/localworkspace/native_files.go new file mode 100644 index 000000000..c40bf1e5d --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/native_files.go @@ -0,0 +1,329 @@ +package localworkspace + +import ( + "archive/tar" + "context" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +// Logical API paths stay slash-separated on every host. os.Root anchors API +// file operations to the selected workspace; it does not constrain native tools. +func nativeAPIPath(path string) (string, error) { + if path == "" { + return ".", nil + } + if path == "." || len(path) > 4096 || !fs.ValidPath(path) || strings.ContainsAny(path, "\\\x00\r\n") { + return "", fs.ErrInvalid + } + return filepath.Localize(path) +} + +func (b *Binding) listNativeDirectory(ctx context.Context, path string, limit int) (agent.WorkspaceDirectoryResult, error) { + result := agent.WorkspaceDirectoryResult{Entries: []agent.WorkspaceDirectoryEntry{}} + if b == nil || ctx.Err() != nil { + return result, agent.ErrWorkspaceReadUnavailable + } + local, err := nativeAPIPath(path) + if err != nil { + return result, agent.ErrWorkspaceReadInvalid + } + root, err := os.OpenRoot(b.workspace) + if err != nil { + return result, agent.ErrWorkspaceReadUnavailable + } + defer root.Close() + if local != "." { + partial := "" + for _, part := range strings.Split(path, "/") { + partial = filepath.Join(partial, part) + info, err := root.Lstat(partial) + if errors.Is(err, fs.ErrPermission) { + return result, fs.ErrPermission + } + if err != nil || !info.IsDir() { + return result, agent.ErrWorkspaceNotDirectory + } + } + } + dir, err := root.Open(local) + if errors.Is(err, fs.ErrPermission) { + return result, fs.ErrPermission + } + if err != nil { + return result, agent.ErrWorkspaceNotDirectory + } + defer dir.Close() + entries, err := dir.ReadDir(limit + 1) + if err != nil && err != io.EOF { + return result, agent.ErrWorkspaceNotDirectory + } + if ctx.Err() != nil { + return result, agent.ErrWorkspaceReadUnavailable + } + result.Truncated = len(entries) > limit + if result.Truncated { + entries = entries[:limit] + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + wire := &proto.WorkspaceDirectoryResult{Entries: []proto.WorkspaceDirectoryEntry{}, Truncated: result.Truncated} + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + return result, agent.ErrWorkspaceReadUncertain + } + item := proto.WorkspaceDirectoryEntry{Name: entry.Name(), Kind: "other"} + switch { + case info.Mode().IsRegular(): + item.Kind = "file" + size := info.Size() + item.SizeBytes = &size + case info.IsDir(): + item.Kind = "directory" + case info.Mode()&os.ModeSymlink != 0: + item.Kind = "symlink" + } + wire.Entries = append(wire.Entries, item) + } + if !proto.ValidWorkspaceDirectory(wire, limit) { + return result, agent.ErrWorkspaceReadInvalid + } + for _, item := range wire.Entries { + result.Entries = append(result.Entries, agent.WorkspaceDirectoryEntry{Name: item.Name, Kind: item.Kind, SizeBytes: item.SizeBytes}) + } + return result, nil +} + +func (b *Binding) writeNativeFile(ctx context.Context, path string, data []byte) (agent.WorkspaceWriteResult, error) { + result := agent.WorkspaceWriteResult{} + local, err := nativeAPIPath(path) + if err != nil { + return result, agent.ErrWorkspaceWriteInvalid + } + root, err := os.OpenRoot(b.workspace) + if err != nil { + return result, agent.ErrWorkspaceWriteUnavailable + } + defer root.Close() + if err = root.MkdirAll(filepath.Dir(local), 0700); err != nil { + return result, agent.ErrWorkspaceWriteRejected + } + temporary := ".oac-write-" + uuid.NewString() + file, err := root.OpenFile(temporary, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return result, agent.ErrWorkspaceWriteRejected + } + defer root.Remove(temporary) + _, err = file.Write(data) + if err == nil { + err = file.Sync() + } + closeErr := file.Close() + if err != nil || closeErr != nil { + return result, agent.ErrWorkspaceWriteRejected + } + // Link publishes complete bytes without replacing an existing destination. + if err = root.Link(temporary, local); err != nil { + if info, e := root.Lstat(local); e == nil { + if info.IsDir() { + return result, agent.ErrWorkspaceWriteDirectory + } + return result, agent.ErrWorkspaceWriteUnsafe + } + return result, agent.ErrWorkspaceWriteRejected + } + return agent.WorkspaceWriteResult{SizeBytes: int64(len(data))}, nil +} + +const artifactFileBytes int64 = 200 << 20 +const artifactBatchBytes int64 = 500 << 20 +const artifactEntries = 4096 + +type nativeExport struct { + ctx context.Context + root *os.Root + archive *tar.Writer + entries int + bytes int64 +} + +func (b *Binding) exportNativeOutputs(ctx context.Context, output io.Writer) error { + if err := ctx.Err(); err != nil { + return err + } + root, err := os.OpenRoot(b.workspace) + if err != nil { + return err + } + defer root.Close() + archive := tar.NewWriter(output) + info, err := root.Lstat("outputs") + if errors.Is(err, fs.ErrNotExist) { + return archive.Close() + } + if err != nil { + return err + } + if !info.IsDir() { + return errors.New("workspace outputs is not a directory") + } + exporter := nativeExport{ctx: ctx, root: root, archive: archive} + if err = exporter.walk("outputs", 0); err != nil { + return err + } + if err = ctx.Err(); err != nil { + return err + } + return archive.Close() +} +func (x *nativeExport) walk(path string, depth int) error { + if err := x.ctx.Err(); err != nil { + return err + } + if depth > 64 || len(path) > 4096 { + return errors.New("workspace export exceeds traversal bound") + } + dir, err := x.root.Open(path) + if err != nil { + return err + } + entries, err := dir.ReadDir(artifactEntries + 1) + _ = dir.Close() + if err != nil && err != io.EOF { + return err + } + x.entries += len(entries) + if x.entries > artifactEntries { + return errors.New("workspace export exceeds entry bound") + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + for _, entry := range entries { + name := path + "/" + entry.Name() + if _, err := nativeAPIPath(name); err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + switch { + case info.Mode()&os.ModeSymlink != 0: + continue + case info.IsDir(): + if err = x.walk(name, depth+1); err != nil { + return err + } + case info.Mode().IsRegular(): + if err = x.append(name); err != nil { + return err + } + default: + return errors.New("workspace output is not a regular file") + } + } + return nil +} +func (x *nativeExport) append(path string) error { + if err := x.ctx.Err(); err != nil { + return err + } + file, err := x.root.Open(path) + if err != nil { + return err + } + defer file.Close() + before, err := file.Stat() + if err != nil { + return err + } + size := before.Size() + if !before.Mode().IsRegular() || size < 0 || size > artifactFileBytes || size > artifactBatchBytes-x.bytes { + return errors.New("workspace export exceeds file bound") + } + x.bytes += size + if err = x.archive.WriteHeader(&tar.Header{Name: path, Typeflag: tar.TypeReg, Mode: 0600, Size: size, Format: tar.FormatPAX}); err != nil { + return err + } + if _, err = io.CopyN(x.archive, nativeContextReader{ctx: x.ctx, reader: file}, size); err != nil { + return err + } + after, err := file.Stat() + if err != nil { + return err + } + if after.Size() != size || !after.ModTime().Equal(before.ModTime()) { + return errors.New("workspace output changed during export") + } + return nil +} + +type nativeContextReader struct { + ctx context.Context + reader io.Reader +} + +func (r nativeContextReader) Read(data []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.reader.Read(data) +} + +// ReadWorkspaceFile returns the existing protocol's bounded prefix; the wire +// has no offset field. Non-regular entries cannot become unbounded streams. +func (b *Binding) ReadWorkspaceFile(ctx context.Context, path string, limit int) (agent.WorkspaceReadResult, error) { + result := agent.WorkspaceReadResult{} + if b == nil || ctx.Err() != nil { + return result, agent.ErrWorkspaceReadUnavailable + } + if path == "" || limit < 1 || limit > proto.WorkspaceReadMaxBytes { + return result, agent.ErrWorkspaceReadInvalid + } + local, err := nativeAPIPath(path) + if err != nil { + return result, agent.ErrWorkspaceReadInvalid + } + root, err := os.OpenRoot(b.workspace) + if err != nil { + return result, agent.ErrWorkspaceReadUnavailable + } + defer root.Close() + info, err := root.Lstat(local) + if err != nil { + return result, err + } + if !info.Mode().IsRegular() { + return result, agent.ErrWorkspaceReadInvalid + } + file, err := root.Open(local) + if err != nil { + return result, err + } + defer file.Close() + info, err = file.Stat() + if err != nil { + return result, agent.ErrWorkspaceReadUncertain + } + if !info.Mode().IsRegular() { + return result, agent.ErrWorkspaceReadInvalid + } + raw, err := io.ReadAll(io.LimitReader(nativeContextReader{ctx: ctx, reader: file}, int64(limit)+1)) + if err != nil { + return result, agent.ErrWorkspaceReadUncertain + } + result.Truncated = len(raw) > limit + if result.Truncated { + raw = raw[:limit] + } + result.Data = raw + return result, nil +} diff --git a/apps/parsar-daemon/internal/localworkspace/snapshot_marker.go b/apps/parsar-daemon/internal/localworkspace/snapshot_marker.go index 2aaab5e8f..a55443dd6 100644 --- a/apps/parsar-daemon/internal/localworkspace/snapshot_marker.go +++ b/apps/parsar-daemon/internal/localworkspace/snapshot_marker.go @@ -5,11 +5,10 @@ import ( "errors" "io" "os" - "path/filepath" - "syscall" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" "github.com/google/uuid" ) @@ -17,7 +16,7 @@ import ( // only the operator root, never capability configuration or a second inventory. type snapshotMarker struct { directory *os.Root - lock *os.File + unlock func() name string root string completed bool @@ -32,12 +31,7 @@ func (b *Binding) openSnapshotMarker() (*snapshotMarker, error) { } } private, err := paths.Root() - if err != nil || agentcapabilities.ValidateLocalDirectories([]string{private}) != nil || private == "/" || - !canonicalExistingParent(private) { - return nil, agentcapabilities.ErrInvalid - } - canonical, err := filepath.EvalSymlinks(private) - if err != nil || canonical != private { + if err != nil || agentcapabilities.ValidateLocalDirectories([]string{private}) != nil { return nil, agentcapabilities.ErrInvalid } current, err := os.OpenRoot(private) @@ -49,30 +43,10 @@ func (b *Binding) openSnapshotMarker() (*snapshotMarker, error) { current.Close() return nil, agentcapabilities.ErrInvalid } - err = current.Mkdir(child, 0700) - if err != nil && !errors.Is(err, os.ErrExist) { - current.Close() - return nil, agentcapabilities.ErrInvalid - } - created := err == nil - info, err := current.Lstat(child) - if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + if err = runtimefs.MkdirPrivate(current, child); err != nil || runtimefs.SyncDirectory(current) != nil { current.Close() return nil, agentcapabilities.ErrInvalid } - if created { - parent, openErr := current.Open(".") - if openErr != nil { - current.Close() - return nil, agentcapabilities.ErrInvalid - } - syncErr := parent.Sync() - parent.Close() - if syncErr != nil { - current.Close() - return nil, agentcapabilities.ErrInvalid - } - } next, err := current.OpenRoot(child) current.Close() if err != nil { @@ -84,17 +58,12 @@ func (b *Binding) openSnapshotMarker() (*snapshotMarker, error) { current.Close() return nil, agentcapabilities.ErrInvalid } - lock, err := current.Open(".") + unlock, err := runtimefs.LockDirectory(current) if err != nil { current.Close() return nil, agentcapabilities.ErrInvalid } - if syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) != nil { - lock.Close() - current.Close() - return nil, agentcapabilities.ErrInvalid - } - marker := &snapshotMarker{directory: current, lock: lock, name: identity.EnvironmentID + "-" + identity.SessionID + ".json", root: b.capabilityRoot} + marker := &snapshotMarker{directory: current, unlock: unlock, name: identity.EnvironmentID + "-" + identity.SessionID + ".json", root: b.capabilityRoot} completed, err := marker.read() if err != nil { marker.close() @@ -104,22 +73,10 @@ func (b *Binding) openSnapshotMarker() (*snapshotMarker, error) { return marker, nil } -func privateSnapshotDirectory(root *os.Root) bool { - file, err := root.Open(".") - if err != nil { - return false - } - defer file.Close() - info, err := file.Stat() - if err != nil || !info.IsDir() || info.Mode().Perm()&0077 != 0 { - return false - } - stat, ok := info.Sys().(*syscall.Stat_t) - return ok && stat.Uid == uint32(os.Getuid()) -} +func privateSnapshotDirectory(root *os.Root) bool { return runtimefs.PrivateDirectory(root) == nil } func (m *snapshotMarker) read() (bool, error) { - file, err := m.directory.OpenFile(m.name, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + file, err := runtimefs.OpenPrivate(m.directory, m.name, os.O_RDONLY) if errors.Is(err, os.ErrNotExist) { return false, nil } @@ -131,8 +88,7 @@ func (m *snapshotMarker) read() (bool, error) { if err != nil { return false, err } - stat, ok := info.Sys().(*syscall.Stat_t) - if !ok || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || stat.Uid != uint32(os.Getuid()) || stat.Nlink != 1 || info.Size() > 8192 { + if info.Size() > 8192 { return false, agentcapabilities.ErrInvalid } // Exact bytes also reject duplicate members, trailing data and extra fields. @@ -156,14 +112,14 @@ func (m *snapshotMarker) complete() error { if err != nil { return agentcapabilities.ErrInvalid } - file, err := m.directory.OpenFile(m.name, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, 0600) + file, err := runtimefs.OpenPrivate(m.directory, m.name, os.O_WRONLY|os.O_CREATE|os.O_EXCL) if err != nil { return agentcapabilities.ErrInvalid } _, writeErr := file.Write(append(data, '\n')) syncErr := file.Sync() closeErr := file.Close() - if writeErr != nil || syncErr != nil || closeErr != nil || m.lock.Sync() != nil { + if writeErr != nil || syncErr != nil || closeErr != nil || runtimefs.SyncDirectory(m.directory) != nil { return agentcapabilities.ErrInvalid } m.completed = true @@ -171,7 +127,6 @@ func (m *snapshotMarker) complete() error { } func (m *snapshotMarker) close() { - _ = syscall.Flock(int(m.lock.Fd()), syscall.LOCK_UN) - m.lock.Close() + m.unlock() m.directory.Close() } diff --git a/apps/parsar-daemon/internal/localworkspace/snapshot_marker_test.go b/apps/parsar-daemon/internal/localworkspace/snapshot_marker_test.go index ca0b6696c..9c9cf40b4 100644 --- a/apps/parsar-daemon/internal/localworkspace/snapshot_marker_test.go +++ b/apps/parsar-daemon/internal/localworkspace/snapshot_marker_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "runtime" "testing" "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" @@ -29,7 +30,7 @@ func markerPath(b *Binding) string { identity := b.capabilityIdentity() return filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "daemon", "capability-installations", identity.EnvironmentID+"-"+identity.SessionID+".json") } -func TestSnapshotMarkerPrivateRoundTrip(t *testing.T) { +func TestSnapshotMarkerRoundTrip(t *testing.T) { b, _ := markerBinding(t) m, err := b.openSnapshotMarker() if err != nil { @@ -43,7 +44,7 @@ func TestSnapshotMarkerPrivateRoundTrip(t *testing.T) { } m.close() info, err := os.Stat(markerPath(b)) - if err != nil || info.Mode().Perm() != 0600 { + if err != nil || runtime.GOOS != "windows" && info.Mode().Perm() != 0600 { t.Fatal("marker not private", err) } raw, err := os.ReadFile(markerPath(b)) @@ -151,8 +152,8 @@ func TestSnapshotMarkerBackfillsOnlyVerifiedManifest(t *testing.T) { b.capabilityRoot = previousRoot } -func TestSnapshotMarkerRefusesDamagedOrAliasedPrivateState(t *testing.T) { - for _, kind := range []string{"corrupt", "foreign root", "public file", "symlink file", "hardlink file", "symlink directory", "symlink private root"} { +func TestSnapshotMarkerRefusesDamagedState(t *testing.T) { + for _, kind := range []string{"corrupt", "foreign root"} { t.Run(kind, func(t *testing.T) { b, request := markerBinding(t) if _, err := b.Prepare(t.Context(), request); err != nil { @@ -168,36 +169,7 @@ func TestSnapshotMarkerRefusesDamagedOrAliasedPrivateState(t *testing.T) { if err := os.WriteFile(name, []byte("{\"capability_root\":\"/another\"}\n"), 0600); err != nil { t.Fatal(err) } - case "public file": - if err := os.Chmod(name, 0644); err != nil { - t.Fatal(err) - } - case "symlink file": - saved := filepath.Join(t.TempDir(), "saved") - if err := os.Rename(name, saved); err != nil { - t.Fatal(err) - } - if err := os.Symlink(saved, name); err != nil { - t.Fatal(err) - } - case "hardlink file": - if err := os.Link(name, filepath.Join(t.TempDir(), "linked")); err != nil { - t.Fatal(err) - } - case "symlink directory": - saved := filepath.Join(filepath.Dir(filepath.Dir(name)), "moved") - if err := os.Rename(filepath.Dir(name), saved); err != nil { - t.Fatal(err) - } - if err := os.Symlink(saved, filepath.Dir(name)); err != nil { - t.Fatal(err) - } - case "symlink private root": - alias := filepath.Join(t.TempDir(), "private") - if err := os.Symlink(os.Getenv("OAC_RUNTIME_HOME"), alias); err != nil { - t.Fatal(err) - } - t.Setenv("OAC_RUNTIME_HOME", alias) + } before, _ := os.ReadFile(name) if _, err := b.Prepare(t.Context(), request); err == nil { diff --git a/apps/parsar-daemon/internal/localworkspace/write.go b/apps/parsar-daemon/internal/localworkspace/write.go index 15aaf726d..4ea1b191e 100644 --- a/apps/parsar-daemon/internal/localworkspace/write.go +++ b/apps/parsar-daemon/internal/localworkspace/write.go @@ -3,15 +3,11 @@ package localworkspace import ( "bytes" "context" - "crypto/sha256" "encoding/json" "errors" "io" "io/fs" - "os/exec" - "strconv" "strings" - "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" @@ -47,27 +43,8 @@ func (b *Binding) WriteWorkspaceFile(ctx context.Context, path string, data []by return result, agent.ErrWorkspaceWriteUncertain } defer func() { w.uncertain = errors.Is(err, agent.ErrWorkspaceWriteUncertain) }() - // Once admitted, observer cancellation cannot turn a possible commit into a - // rejection. Missing results poison this Runtime writer, even after local reap. - operation, cancel := context.WithTimeout(context.WithoutCancel(ctx), 65*time.Second) - defer cancel() - digest := sha256.Sum256(data) - // This writer serves only public Files.create, so it always selects the - // helper's create mode: create missing parents and never replace. - cmd := exec.CommandContext(operation, w.helper, b.workspace, path, strconv.Itoa(len(data)), w.staging, "create") - cmd.Dir = "/" - cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C.UTF-8"} - cmd.Stdin = io.MultiReader(bytes.NewReader(data), bytes.NewReader(digest[:])) - output := &writeOutput{} - cmd.Stdout = output - cmd.WaitDelay = time.Second - if err := cmd.Start(); err != nil { - return result, agent.ErrWorkspaceWriteUnavailable - } - if err := cmd.Wait(); err != nil { - return result, agent.ErrWorkspaceWriteUncertain - } - return decodeWrite(output.Bytes(), len(data)) + // Once admitted, finish this synchronous mutation before returning ownership. + return b.writeNativeFile(context.WithoutCancel(ctx), path, data) } // A malformed helper cannot grow the daemon's output buffer without bound. diff --git a/apps/parsar-daemon/internal/localworkspace/write_test.go b/apps/parsar-daemon/internal/localworkspace/write_test.go index 3e46a4164..0cf675e23 100644 --- a/apps/parsar-daemon/internal/localworkspace/write_test.go +++ b/apps/parsar-daemon/internal/localworkspace/write_test.go @@ -5,12 +5,10 @@ import ( "context" "errors" "fmt" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "os" "path/filepath" "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" ) func writableBinding(t *testing.T, script string) *Binding { @@ -41,101 +39,6 @@ func writableBinding(t *testing.T, script string) *Binding { return b } -func TestWriteRejectsUnsafeBindings(t *testing.T) { - b := writableBinding(t, "exit 1\n") - link := filepath.Join(filepath.Dir(b.workspace), "alias") - if err := os.Symlink(b.writer.staging, link); err != nil { - t.Fatal(err) - } - inside := filepath.Join(b.workspace, "helper") - if err := os.WriteFile(inside, []byte("#!/bin/sh\n"), 0700); err != nil { - t.Fatal(err) - } - for _, pair := range [][2]string{{"", b.writer.staging}, {b.writer.helper, ""}, {b.writer.helper, b.workspace}, {b.writer.helper, link}, {inside, b.writer.staging}, {b.writer.helper, filepath.Dir(b.workspace)}} { - if err := b.bindWriter(pair[0], pair[1]); err == nil { - t.Fatalf("unsafe writer accepted: %q", pair) - } - } -} - -func TestWriteReceiptAndCredentialBoundary(t *testing.T) { - t.Setenv("OAC_TEST_PRIVATE_CREDENTIAL", "synthetic-secret") - // Files.create always selects the helper's explicit create mode. - b := writableBinding(t, "[ -z \"$OAC_TEST_PRIVATE_CREDENTIAL\" ] || exit 13\n[ \"$#\" = 5 ] && [ \"$5\" = create ] || exit 14\ncat >/dev/null\nprintf '%s' '{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":3}'\n") - result, err := b.WriteWorkspaceFile(t.Context(), "file", []byte{0, 1, 2}) - if err != nil || result.SizeBytes != 3 { - t.Fatalf("write: %+v %v", result, err) - } - for _, p := range []string{"", ".", "..", "/etc/passwd", "a/../b", "a//b", "a\\b", "a\nb"} { - if _, err := b.WriteWorkspaceFile(t.Context(), p, nil); !errors.Is(err, agent.ErrWorkspaceWriteInvalid) { - t.Fatalf("path %q: %v", p, err) - } - } - if _, err := b.WriteWorkspaceFile(t.Context(), "file", make([]byte, WriteMaxBytes+1)); !errors.Is(err, agent.ErrWorkspaceWriteInvalid) { - t.Fatal(err) - } -} - -func TestWriteDetachmentAndConcurrentAdmission(t *testing.T) { - b := writableBinding(t, "cat >/dev/null\ntouch \"$1/started\"\nwhile [ ! -f \"$1/release\" ]; do sleep 0.01; done\nprintf '%s' '{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":0}'\n") - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - done := make(chan error, 1) - go func() { _, err := b.WriteWorkspaceFile(ctx, "file", nil); done <- err }() - defer os.WriteFile(filepath.Join(b.workspace, "release"), nil, 0600) - deadline := time.After(5 * time.Second) - for { - if _, err := os.Stat(filepath.Join(b.workspace, "started")); err == nil { - break - } - select { - case <-deadline: - t.Fatal("helper did not start") - case <-time.After(5 * time.Millisecond): - } - } - cancel() - if _, err := b.WriteWorkspaceFile(t.Context(), "other", nil); !errors.Is(err, agent.ErrWorkspaceWriteBusy) { - t.Fatalf("overlap: %v", err) - } - if err := os.WriteFile(filepath.Join(b.workspace, "release"), nil, 0600); err != nil { - t.Fatal(err) - } - select { - case err := <-done: - if err != nil { - t.Fatal("detached admitted write lost receipt", err) - } - case <-time.After(5 * time.Second): - t.Fatal("write did not finish") - } -} - -func TestWriteUncertaintyStopsSuccessor(t *testing.T) { - for name, script := range map[string]string{ - "missing": "cat >/dev/null\nexit 0\n", - "wrong-size": "cat >/dev/null\nprintf '%s' '{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":8}'\n", - "overflow": "cat >/dev/null\nhead -c 2048 /dev/zero\n", - "exit": "cat >/dev/null\nprintf '%s' '{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":0}'\nexit 1\n", - } { - t.Run(name, func(t *testing.T) { - b := writableBinding(t, script) - if _, err := b.WriteWorkspaceFile(t.Context(), "file", nil); !errors.Is(err, agent.ErrWorkspaceWriteUncertain) { - t.Fatalf("first: %v", err) - } - if err := os.WriteFile(b.writer.helper, []byte("#!/bin/sh\ntouch \"$1/forbidden\"\n"), 0700); err != nil { - t.Fatal(err) - } - if _, err := b.WriteWorkspaceFile(t.Context(), "other", nil); !errors.Is(err, agent.ErrWorkspaceWriteUncertain) { - t.Fatalf("successor: %v", err) - } - if _, err := os.Stat(filepath.Join(b.workspace, "forbidden")); !os.IsNotExist(err) { - t.Fatal("unknown write admitted successor") - } - }) - } -} - func TestWriteReceiptValidation(t *testing.T) { for _, response := range []string{ `{"version":1,"outcome":"completed"}`, @@ -165,61 +68,69 @@ func TestWriteReceiptValidation(t *testing.T) { } } -func TestLocalWriteNativeInstaller(t *testing.T) { - helper := os.Getenv("OAC_TEST_LOCAL_WRITE_HELPER") - if helper == "" { - t.Skip("requires the built native installer") +func nativeFileBinding(t *testing.T) *Binding { + t.Helper() + return &Binding{workspace: t.TempDir(), writer: &fileWriter{}} +} +func TestNativeFileCreateAndNoReplace(t *testing.T) { + b := nativeFileBinding(t) + for _, size := range []int{0, 3, (1 << 20) + 17, WriteMaxBytes} { + name := fmt.Sprintf("nested/file-%d", size) + data := bytes.Repeat([]byte{17}, size) + got, err := b.WriteWorkspaceFile(t.Context(), name, data) + if err != nil || got.SizeBytes != int64(size) { + t.Fatal(size, got, err) + } + if _, err = b.WriteWorkspaceFile(t.Context(), name, []byte("overwrite")); !errors.Is(err, agent.ErrWorkspaceWriteUnsafe) { + t.Fatal("existing file replaced", err) + } + raw, err := os.ReadFile(filepath.Join(b.workspace, filepath.FromSlash(name))) + if err != nil || !bytes.Equal(raw, data) { + t.Fatal("file content changed", err) + } } - b := writableBinding(t, "exit 1\n") - if err := b.bindWriter(helper, b.writer.staging); err != nil { + if _, err := b.WriteWorkspaceFile(t.Context(), "nested", nil); !errors.Is(err, agent.ErrWorkspaceWriteDirectory) { t.Fatal(err) } - escape := filepath.Join(b.workspace, "escape") - if err := os.Symlink(b.writer.staging, escape); err != nil { + if _, err := b.WriteWorkspaceFile(t.Context(), "after-rejection", nil); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(b.workspace, "setup.txt"), []byte("setup"), 0600); err != nil { + entries, err := os.ReadDir(b.workspace) + if err != nil { t.Fatal(err) } - // Missing parents are created; an existing destination is never replaced. - for _, size := range []int{0, 3, (1 << 20) + 17, WriteMaxBytes} { - path := fmt.Sprintf("n1/n2/file-%d", size) - data := bytes.Repeat([]byte{0, 255, 17}, (size+2)/3)[:size] - got, err := b.WriteWorkspaceFile(t.Context(), path, data) - if err != nil || got.SizeBytes != int64(size) { - t.Fatalf("size %d: %+v %v", size, got, err) + for _, entry := range entries { + if len(entry.Name()) >= 11 && entry.Name()[:11] == ".oac-write-" { + t.Fatal("temporary retained") } - actual, err := os.ReadFile(filepath.Join(b.workspace, path)) - if err != nil || !bytes.Equal(actual, data) { - t.Fatalf("size %d bytes differ: %v", size, err) - } - } - if info, err := os.Lstat(filepath.Join(b.workspace, "n1")); err != nil || !info.IsDir() || info.Mode().Perm() != 0700 { - t.Fatal("parent directory", info, err) } - for path, want := range map[string]error{ - "n1/n2/file-3": agent.ErrWorkspaceWriteUnsafe, - "setup.txt": agent.ErrWorkspaceWriteUnsafe, - "escape": agent.ErrWorkspaceWriteUnsafe, - "escape/new": agent.ErrWorkspaceWriteUnsafe, - "n1/n2": agent.ErrWorkspaceWriteDirectory, - "n1": agent.ErrWorkspaceWriteDirectory, - } { - if _, err := b.WriteWorkspaceFile(t.Context(), path, []byte("denied")); err != want { - t.Fatalf("%s: %v", path, err) +} +func TestNativeFileAdmission(t *testing.T) { + b := nativeFileBinding(t) + for _, path := range []string{"", ".", "..", "/etc/passwd", "a/../b", "a//b", "a\\b", "a\nb"} { + if _, err := b.WriteWorkspaceFile(t.Context(), path, nil); !errors.Is(err, agent.ErrWorkspaceWriteInvalid) { + t.Fatal(path, err) } } - if data, err := os.ReadFile(filepath.Join(b.workspace, "n1/n2/file-3")); err != nil || !bytes.Equal(data, []byte{0, 255, 17}) { - t.Fatal("existing file changed", err) + if _, err := b.WriteWorkspaceFile(t.Context(), "large", make([]byte, WriteMaxBytes+1)); !errors.Is(err, agent.ErrWorkspaceWriteInvalid) { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := b.WriteWorkspaceFile(ctx, "cancelled", nil); !errors.Is(err, agent.ErrWorkspaceWriteUnavailable) { + t.Fatal(err) } - if data, err := os.ReadFile(filepath.Join(b.workspace, "setup.txt")); err != nil || string(data) != "setup" { - t.Fatal("untracked file changed", err) + if _, err := os.Stat(filepath.Join(b.workspace, "cancelled")); !os.IsNotExist(err) { + t.Fatal("cancelled request mutated workspace") } - if _, err := b.WriteWorkspaceFile(t.Context(), "after-rejection", nil); err != nil { - t.Fatal("known rejection blocked next write", err) + b.writer.mu.Lock() + _, err := b.WriteWorkspaceFile(t.Context(), "busy", nil) + b.writer.mu.Unlock() + if !errors.Is(err, agent.ErrWorkspaceWriteBusy) { + t.Fatal(err) } - entries, err := os.ReadDir(b.writer.staging) - if err != nil || len(entries) != 0 { - t.Fatal("staging retained successful/known-rejected data", err) + b.writer.uncertain = true + if _, err = b.WriteWorkspaceFile(t.Context(), "uncertain", nil); !errors.Is(err, agent.ErrWorkspaceWriteUncertain) { + t.Fatal(err) } } diff --git a/apps/parsar-daemon/internal/paths/paths.go b/apps/parsar-daemon/internal/paths/paths.go index c302e2564..6b48dc6bb 100644 --- a/apps/parsar-daemon/internal/paths/paths.go +++ b/apps/parsar-daemon/internal/paths/paths.go @@ -8,9 +8,11 @@ package paths import ( "fmt" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" "os" "path/filepath" "regexp" + "strings" ) const DefaultProfile = "default" @@ -25,7 +27,9 @@ func ValidateProfile(name string) error { if name == "" { return fmt.Errorf("profile name must not be empty") } - if !profilePattern.MatchString(name) { + base := strings.ToUpper(strings.SplitN(name, ".", 2)[0]) + reserved := base == "CON" || base == "PRN" || base == "AUX" || base == "NUL" || (len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) && base[3] >= '1' && base[3] <= '9') + if strings.HasSuffix(name, ".") || reserved || !profilePattern.MatchString(name) { return fmt.Errorf("profile %q must match %s", name, profilePattern.String()) } return nil @@ -35,6 +39,9 @@ func ValidateProfile(name string) error { // sandbox environments without a writable home. func Root() (string, error) { if override := os.Getenv("OAC_RUNTIME_HOME"); override != "" { + if !filepath.IsAbs(override) || filepath.Clean(override) != override { + return "", fmt.Errorf("OAC_RUNTIME_HOME must be a clean absolute directory") + } return override, nil } home, err := os.UserHomeDir() @@ -64,7 +71,7 @@ func EnsureProfileDir(profile string) (string, error) { if err != nil { return "", err } - if err := os.MkdirAll(dir, 0o700); err != nil { + if err := runtimefs.EnsurePrivateDir(dir); err != nil { return "", fmt.Errorf("create profile dir %s: %w", dir, err) } return dir, nil diff --git a/apps/parsar-daemon/internal/paths/paths_test.go b/apps/parsar-daemon/internal/paths/paths_test.go index 053f4b94d..4f42c4057 100644 --- a/apps/parsar-daemon/internal/paths/paths_test.go +++ b/apps/parsar-daemon/internal/paths/paths_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "runtime" "strings" "testing" @@ -15,7 +16,10 @@ import ( // we want. func withTempHome(t *testing.T) string { t.Helper() - dir := t.TempDir() + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } t.Setenv("OAC_RUNTIME_HOME", dir) return dir } @@ -29,7 +33,7 @@ func TestValidateProfile(t *testing.T) { } bad := []string{ "", - "../escape", + "../escape", ".", "..", "profile.", "CON", "nul.json", "com1", "LPT9", "with/slash", "with\\backslash", "with space", @@ -102,7 +106,7 @@ func TestEnsureProfileDirCreates0700(t *testing.T) { if !info.IsDir() { t.Fatalf("EnsureProfileDir returned %q which is not a directory", dir) } - if mode := info.Mode().Perm(); mode != 0o700 { + if mode := info.Mode().Perm(); runtime.GOOS != "windows" && mode != 0o700 { t.Errorf("EnsureProfileDir mode = %o, want 0700", mode) } @@ -128,3 +132,10 @@ func TestInvalidProfileShortCircuits(t *testing.T) { t.Fatal("EnsureProfileDir accepted invalid profile name") } } + +func TestRootRejectsRelativeOverride(t *testing.T) { + t.Setenv("OAC_RUNTIME_HOME", "relative") + if _, err := paths.Root(); err == nil { + t.Fatal("relative private home accepted") + } +} diff --git a/docs/api/public-agent-api.md b/docs/api/public-agent-api.md index d62ff1a79..37aed4732 100644 --- a/docs/api/public-agent-api.md +++ b/docs/api/public-agent-api.md @@ -85,7 +85,10 @@ read. `openai_hosted` keeps the official wire name and means Core-managed compute here. The deployment chooses E2B or its own Docker/microsandbox nodes. A public `self_hosted` Environment uses caller-owned compute and the same colocated -Runtime. For a `self_hosted` Session, the deployment operator issues the +Runtime on Linux, macOS or Windows; managed Providers remain Linux-only. Native +Runtime execution uses the launching user's permissions without an inner sandbox. +See [native installation and validation status](../self-hosted-native.md). +For a `self_hosted` Session, the deployment operator issues the Environment's executor credential with the Core key; see [executor credentials](../../contracts/agents-api/environment-executor-credentials.md). Our daemon transport and the supported native harness differences are diff --git a/docs/design-principles.md b/docs/design-principles.md index bccbf88b9..16a43ac74 100644 --- a/docs/design-principles.md +++ b/docs/design-principles.md @@ -63,6 +63,22 @@ create, copy or edit arbitrary assets, start a Session, send an event, cancel work, or read stored credentials. Project keys share all assets within their Project; nothing is shared or copied across Projects. +## Runtime and outer isolation + +The same daemon and Core protocol serve self-hosted Linux, macOS and Windows. +OS differences belong to Runtime implementations; harness differences belong to +adapters. Managed Providers remain Linux-only. Runtime prepares capabilities and +executes work; Providers create, bootstrap and reclaim outer Environments. + +The daemon runs tools with its launching user's permissions. It does not add a +filesystem, permission or network sandbox, including on Linux. Outer Docker/E2B/ +microsandbox Environments provide managed isolation. Credential authentication, +ordinary private storage, atomic writes, locks and process cleanup remain required, +but do not protect Runtime data from tools running as the same user. Local +capability snapshots share the managed parser and do not create a second security +boundary. See the [native Runtime guide](self-hosted-native.md) for current platform +validation limits. + ## Secrets and evidence Credential values, model credentials and confidential template initialization are diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index b7bb5dc03..07c002e4a 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -10,6 +10,7 @@ applications call Core with. | [Install Core and Web](install.md) | Administrators | Prerequisites, download, the installer and every option, HTTPS and the reverse proxy, a local trial, first sign-in, what is created on disk | | [Nodes](nodes.md) | Administrators | Adding a node with one command, sudo and no-sudo modes, removal, logs and troubleshooting | | [Self-hosted executors](self-hosted.md) | Administrators and application owners | Connecting an application's own machine to a `self_hosted` Session, rotating and revoking its credential | +| [Native Runtime installation](../self-hosted-native.md) | Executor operators | Linux, macOS and Windows installation commands, lifecycle and current validation limits | | [Operations](operations.md) | Administrators | The `oac` command, the Core key, backups, current-release repair and troubleshooting | | [Configuration reference](../configuration.md) | Administrators | Every `config.json` setting and every runtime setting in Web | | [Call the API](quickstart.md) | Application developers | `OPENAI_BASE_URL` and `OPENAI_API_KEY`, running a Session, model providers and `x_agents_core` | diff --git a/docs/getting-started/operations.md b/docs/getting-started/operations.md index 1a8975074..fe93ad8a9 100644 --- a/docs/getting-started/operations.md +++ b/docs/getting-started/operations.md @@ -222,11 +222,14 @@ sign-in sessions live in Web's memory and last 12 hours. microsandbox uses an explicit network policy: public egress, the Core and DNS ports the Runtime needs, and no inbound or private-network access. Private model or MCP endpoints -need an explicit policy change. A Session's own network policy is separate. - -The Docker node uses the nested-sandbox Runtime policy. Its service account needs the -host's Docker daemon, and Core has no Docker socket. Install nodes on trusted hosts and -don't share their Docker access with untrusted users. +need an explicit policy change. The daemon does not enforce Session-level +`disabled` or `restricted` networking. Combinations without the required outer +network enforcement are unsupported; a requested mode is not an isolation guarantee. + +Docker isolates the Runtime at the outer container boundary. The daemon does not +add an inner filesystem, permission or network sandbox. The node's service account +needs the host's Docker daemon, and Core has no Docker socket. Install nodes on +trusted hosts and do not share their Docker access with untrusted users. A Core restart leaves node services and resident microVMs running. A host reboot, the end of a user manager or the loss of a running microVM is not a Core restart and is not diff --git a/docs/getting-started/self-hosted.md b/docs/getting-started/self-hosted.md index 4425ca8bd..cc22d12c0 100644 --- a/docs/getting-started/self-hosted.md +++ b/docs/getting-started/self-hosted.md @@ -1,35 +1,27 @@ # Self-hosted executors -A `self_hosted` Session runs on a machine the application owns, the executor host, -instead of a node. The application creates the Session through `/v1`; the -administrator issues an executor credential for it in Web; one command on the host -starts a Runtime container that connects to Core. The Runtime is the same one nodes -use: daemon, native harness and workspace. - -An executor credential works for one Session's environment only. It can't call the -Agent API, the Core API or node enrollment. - -## Before you start - -- **Core has an HTTPS public URL.** The Session's `remote_url` is - `wss:///api/v1/agent-daemon/ws`, and the installer connects only over - `wss://`. Web shows no command until Core has a reachable public URL. -- **Web holds the installer and the Runtime files.** The command downloads the - installer, the Runtime launcher and the image from Core's public URL, which the - reverse proxy sends to Web (in a [split deployment](install.md#split-deployment), to - the Web host); install from the offline bundle. A console without the self-hosted - installer shows no **Connect a host** section at all. -- **The host** runs Linux amd64 with Python 3.9+, `curl`, `sha256sum` and the Docker - CLI, with Docker usable through `/var/run/docker.sock` by the non-root user that runs - the installer; the installer refuses root. It reaches the public URL over HTTPS. -- **The Session carries its model provider**, in the request or saved on its Agent. - Default models never apply to self-hosted Sessions, and creating one without a - provider fails with 400 `model_provider_required`. +A `self_hosted` Session runs on a machine the application owns. The application +creates the Session through `/v1`; the administrator issues an executor credential +in Web or through `/core/v1`; the host runs `oac-daemon` with that credential. +Linux, macOS and Windows use the same Runtime protocol. Core-managed Providers +remain Linux-only. + +The daemon runs with its launching user's permissions and adds no inner +filesystem, permission or network sandbox. Its tools may access that user's files +and Runtime credentials. Managed isolation belongs to the outer Environment; +self-hosted operators choose any outer isolation they need. See the +[native installation guide](../self-hosted-native.md) for prerequisites, commands +and the current platform validation status. + +An executor credential works for one Environment only. It cannot call the Agent +API, Core API or node enrollment. Default model providers never apply to +self-hosted Sessions: the request or Agent must carry its own model provider. +Otherwise creation fails with 400 `model_provider_required`. ## Connect a host -1. **The application creates the Session**, with its Project API key and its own model - provider: +1. Create an existing workspace on the executor host. The application creates a + Session using that host's absolute path and its own Project API key: ```python import os @@ -37,7 +29,10 @@ Agent API, the Core API or node enrollment. client = OpenAI() # reads OPENAI_BASE_URL and OPENAI_API_KEY session = client.beta.agents.sessions.create( - environment={"type": "self_hosted", "workspace_directory": "/workspace"}, + environment={ + "type": "self_hosted", + "workspace_directory": os.environ["EXECUTOR_WORKSPACE"], + }, extra_body={ "agent": {"model": os.environ["MODEL_NAME"], "x_agents_core": {"harness": "codex"}}, "x_agents_core": {"model_provider": { @@ -50,92 +45,60 @@ Agent API, the Core API or node enrollment. print(session.id, session.environment.id, session.environment.remote_url) ``` -2. **In Web**, open **Session log**, then the Session's page. In its - **Executor credentials** section, **Connect a host** shows a command in a - **Terminal** block; copy it and run it on the executor host: - - ```sh - (umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT - curl -fsS --max-time 30 --max-filesize 1048576 'https://core.example/node-install/self-hosted-install.pyz' -o "$d/install.pyz" && - printf '%s %s\n' '' "$d/install.pyz" | sha256sum -c --status && - python3 "$d/install.pyz" --source-url 'https://core.example' --environment-id '' --remote 'wss://core.example/api/v1/agent-daemon/ws') - ``` - - The command contains no secret. It downloads the installer from your console and - checks its SHA-256 before running it. -3. **Issue the credential.** Select **Issue credential**. The **Executor credential** - dialog shows it once: choose **Copy credential**. -4. **Paste it at the installer's hidden prompt** and press Enter. The prompt accepts the - compact or the pretty-printed credential and never echoes it. Then choose **Done** in - the dialog; after that the credential can't be shown again. Closing the dialog with - × keeps the credential on the page until you choose **Done**. - -The installer downloads the Runtime from the console and checks it, starts a container -named `oac-selfhost-<32 hex digits>`, and waits until Core confirms that the -Environment is connected. The Session then runs its Turns there. Rerunning the same -command resumes the same installation. It refuses, with "This installation belongs to -another Environment or distribution", once Core runs another release or its public URL -changed: the host's installation is tied to both. Moving an executor to a new release -or address isn't supported yet. A fresh installation would start a new container -without the old workspace and native history, so the Session can't continue there: -stop the old container, create a new self-hosted Session and connect a host for it. - -For automation without a terminal, choose **Download credential file**, which saves -`executor-credential-.json`, make it private -(`chmod 600`), and add `--credential-file /absolute/path/to/that-file.json` to the -installer line. The path must not go through a symbolic link. - -The host keeps its state in `~/.oac/self-hosted//` -(`--install-dir` chooses another absolute directory). Keep it with the container's -volumes: they hold the credential, workspace and native history. The Runtime's log is -`docker logs `. A connected Environment proves the connection, not that the -model works; send the Session a Turn to test execution. - -Core delivers the Session's model provider only over this Environment's executor -connection. The host keeps it in the Runtime's harness home, where the model's tools -and the Files API can't read it but the host's owner can. +2. In Web, open **Session log**, then the Session's **Executor credentials** + section. Choose **Issue credential**, then **Download credential file**. Core + returns the credential only once; retain it privately before choosing **Done**. +3. On the host, run `oac-daemon install` with the returned `remote_url`, Environment + ID, matching workspace and absolute credential-file path, then `oac-daemon start`. + The [native guide](../self-hosted-native.md#install-and-start) has Linux/macOS and + PowerShell examples. No Docker installation is required for this native path. + +The console's existing **Connect a host** download command targets the Linux +container installer, not the native installation command. Use the native guide for +this workflow; downloading an executor credential is independent of the console's +installer assets. Core must be reachable from the host: `wss://` is required +outside loopback, while a local Core can use a loopback `ws://` URL. + +A connected Environment proves only the machine connection. Send a Turn to check +the selected harness and model. Core supplies the Session's model provider over +this Environment's authenticated executor connection. The host's user and tools +running with that user's permissions can access locally stored Runtime data. ## Local capability directories -A self-hosted Session may supply `capability_directories` alongside its -`workspace_directory`, for example: +A self-hosted Session may supply `capability_directories` alongside its workspace: ```python environment = { "type": "self_hosted", - "workspace_directory": "/workspace", - "capability_directories": ["/workspace/capabilities"], + "workspace_directory": os.environ["EXECUTOR_WORKSPACE"], + "capability_directories": [os.environ["EXECUTOR_CAPABILITIES"]], } ``` -The API accepts portable absolute Unix, Windows drive and UNC source paths without -checking Core's filesystem. The current Linux Runtime requires canonical local Unix -paths and applies its own access checks; this does not add Windows support. -These paths refer to directories visible inside that Runtime. Populate them before -first execution. `/workspace` maps to its bound workspace; an operator-prepared -Runtime may use its exact canonical absolute workspace instead. Directory aliases, -private credentials, native history and protected initialization trees are refused. -This does not mount arbitrary host paths into the container or add a new installer. -Self-hosted public input accepts local directories, not managed Skill/Plugin archives -or hosted Templates. - -The current implementation uses the packaged Linux helpers and protected -`installed.json`, by default under `/environment/initialization/capabilities`. -Operators may freeze another root with `OAC_RUNTIME_CAPABILITY_DIRECTORY`; it must -stay separate from the workspace and private Runtime state and is not a public API -setting. Runtime snapshots sources -before native execution and supplies the resulting Skill and MCP inventory through -the same parser as managed bundles. Reconnect reuses installed contents even after -source edits; a new Session captures its own snapshot. A missing or inconsistent -snapshot fails preparation without silently reinstalling. Local discovery does not -create entries in the public API-managed installation arrays. +Core accepts absolute Unix, Windows drive and UNC source paths without checking +its own filesystem. Runtime validates them using the executor host's path syntax. +Populate these directories before first execution. They are ordinary paths visible +to that process; naming a directory does not mount it or create a sandbox. +Self-hosted public input accepts local directories, not managed Skill/Plugin +archives or hosted Templates. + +Runtime snapshots sources before native execution and uses the same parser and +`installed.json` format as managed bundles to supply Skills and Plugin MCP. +The native installer defaults the snapshot destination to `capabilities` under +`OAC_RUNTIME_HOME`; `--capability-directory` selects another local destination. +It is an operator setting, not a public API write destination. Reconnect reuses +installed contents even after source edits; a new Session captures its own +configuration. A missing or inconsistent snapshot fails preparation rather than +silently reinstalling. Local discovery does not create entries in the public +API-managed installation arrays. Snapshot file modes do not isolate the snapshot +from tools running as the same user. Closing an executor, cancelling a Turn or losing its connection preserves the -snapshot and workspace. The compute owner remains responsible for explicit resource -cleanup. This source-level capability behavior requires its own real acceptance; -[earlier qualification](../../contracts/agents-api/user-managed-runtime-v1.md) covered -only its recorded inputs and binaries, not local directory preparation or additional -platforms. +snapshot and workspace. The compute owner remains responsible for explicit +cleanup. [Historical qualification](../../contracts/agents-api/user-managed-runtime-v1.md) +records only its stated inputs and binaries; it does not qualify the current native +platforms or local capability preparation. ## Rotate or revoke @@ -144,25 +107,16 @@ platforms. | **Rotate** | The credential gets a new secret; the old secret stops working at once. Rotating a revoked credential restores it | | **Revoke** | The credential stops working at once | -After either, the Runtime disconnects and stops retrying: its log shows one message -that names the fix, and the container keeps running without a restart loop. To -reconnect the host: - -1. **Rotate** the same credential in Web and copy the new secret. -2. Rerun the same install command on the host and paste it. +To reconnect a native installation, stop it with `oac-daemon stop`, rotate the same +credential, replace the JSON at its configured credential-file path, and run +`oac-daemon start`. Keep the same `OAC_RUNTIME_HOME` for every command. Issuing a +new credential does not reconnect an Environment already bound to its first +credential; rotate that credential instead. Do not run `install` again over the +existing installation. -The installer puts the new credential into the same container, which reconnects with -its workspace and history. Issuing a *new* credential doesn't reconnect an Environment -that has already connected: it stays bound to its first credential, and the installer -says to rotate that one. - -To remove the Runtime instead, stop its container: -`docker --host unix:///var/run/docker.sock stop `. Deleting the Session does -not remove containers or volumes on the executor host. - -In an archived project, Web shows "This project is archived, so executor credentials -can't be issued or rotated." Only **Revoke** remains; a host that already holds a -credential keeps it when you rerun the command. +Stopping the daemon keeps its workspace and native history. Deleting a Session +does not remove host files. In an archived Project, credentials cannot be issued +or rotated; revocation remains available. ## Without Web @@ -185,15 +139,10 @@ credentials with `GET` before trying again. The [credential contract](../../contracts/agents-api/environment-executor-credentials.md) has every rule and error. - ## Historical executor installations -The current installer does not upgrade or adopt an executor from an older release. -It refuses when the same Environment retains state under the old -`~/.parsar/self-hosted` directory, including when another `--install-dir` is supplied. -Preserve the old container, files and native history; provision a fresh self-hosted -Session and connect its executor separately. There is no supported cross-version -continuation or automatic data deletion. - -Credential replacement for executors created by the current installer updates and -restarts the same owned container. It does not authorize adopting an older executor. +The native installer supports its current version only. It does not adopt or +upgrade an older daemon or container installation. Preserve old files, containers +and native history; use a fresh Runtime home and Session when moving versions. +Historical container-installer instructions and qualification records describe +those earlier artifacts, not acceptance of the current native daemon. diff --git a/docs/self-hosted-native.md b/docs/self-hosted-native.md new file mode 100644 index 000000000..cf8c9e364 --- /dev/null +++ b/docs/self-hosted-native.md @@ -0,0 +1,139 @@ +# Native self-hosted Runtime + +A self-hosted Runtime runs the same `oac-daemon` on Linux, macOS or Windows. +Core uses the same Environment protocol on all three platforms; native harness +adapters handle harness differences. Core-managed Providers remain Linux-only. + +The daemon runs tools with the permissions of the user who starts it. It adds no +filesystem, permission or network sandbox. Tools can access whatever that user +can access, including Runtime state and credentials. Authentication, ordinary +credential storage, atomic state writes and process cleanup do not isolate tools +from the host. When isolation is needed, run the Runtime inside an outer sandbox; +Core creates that outer boundary for managed Docker/E2B/microsandbox Environments. +The daemon does not enforce `disabled` or `restricted` network modes; combinations +without corresponding outer enforcement are unsupported. + +## Prerequisites and validation status + +Use a current-version daemon binary for the host's OS and architecture, and install +the native harness selected by the Session. The daemon installer saves local +configuration; it does not install the harness, create a container, or register an +OS service. Native harness dependencies must already be available to its process. +Claude on Windows requires Git Bash. The MiniMax adapter does not currently support +Windows. + +| Platform | Current validation status | +| --- | --- | +| Linux | Existing Runtime platform; the removal of inner sandboxing needs the current change's focused checks | +| macOS | Native daemon lifecycle and Files tests passed with the race detector; native harness validation is in progress | +| Windows | Native CI build and automated execution tests are required and have not yet passed for this change; cross-compilation is not acceptance, and no manual Windows acceptance is recorded | + +These are validation results for the current implementation work, not a claim that +every harness/model combination has passed on every platform. + +Create a `self_hosted` Session with its model provider and an existing absolute +`workspace_directory` on the executor host. Obtain its `environment.id` and +`environment.remote_url`, then have the Core administrator issue an +[executor credential](getting-started/self-hosted.md#without-web). Save the returned +JSON as a private local file. It contains `key_id`, `environment_id` and +`executor_token`; do not put the token in command arguments. + +Use the exact `remote_url` from Core. It uses `wss://` outside loopback; `ws://` is +accepted only for a loopback Core. The Environment ID must be a canonical UUID. +Paths must be clean absolute paths in the host's syntax, and the workspace must +already exist. The install workspace must match the Session's configured workspace. + +## Install and start + +Choose a separate `OAC_RUNTIME_HOME` for each installation. It contains local +configuration, connection state, logs and the default capability snapshot. Keep +this same value for install, start, status, logs and stop. + +On Linux or macOS: + +```sh +export OAC_RUNTIME_HOME="$HOME/.oac/runtime-example" +mkdir -p "$HOME/agent-workspace" +chmod 600 "$HOME/executor-credential.json" +oac-daemon install \ + --remote 'wss://core.example/api/v1/agent-daemon/ws' \ + --environment-id '11111111-2222-4333-8444-555555555555' \ + --workspace "$HOME/agent-workspace" \ + --credential-file "$HOME/executor-credential.json" +oac-daemon start +``` + +On Windows, use native absolute paths in PowerShell: + +```powershell +$env:OAC_RUNTIME_HOME = "$HOME\.oac\runtime-example" +New-Item -ItemType Directory -Force "$HOME\agent-workspace" | Out-Null +oac-daemon.exe install ` + --remote 'wss://core.example/api/v1/agent-daemon/ws' ` + --environment-id '11111111-2222-4333-8444-555555555555' ` + --workspace "$HOME\agent-workspace" ` + --credential-file "$HOME\executor-credential.json" +oac-daemon.exe start +``` + +Replace the example URL and UUID with the Session's actual values. On Windows, keep +the credential file under the intended user's normal private storage permissions. +The Windows commands describe the installation interface; they do not replace the +pending native CI validation above. + +Optional install flags: + +| Flag | Meaning | +| --- | --- | +| `--capability-directory ABS` | Local destination for the installed capability snapshot; defaults to `capabilities` under `OAC_RUNTIME_HOME`. This is an operator setting, not a public API destination | +| `--tool-env-file ABS` | JSON object of string-valued tool environment variables. Use it for explicitly configured tool/MCP variables; it is not a dotenv or shell file | + +For example, a tool environment file can contain `{"EXAMPLE_SETTING":"value"}`. +Treat files containing credentials as secrets. Retain the credential and tool +configuration files at their configured paths; installation records those paths. + +`oac-daemon start --foreground` stays attached to the terminal instead of starting +in the background. An operator can use their own service manager to run it; the +native installer does not configure one. + +## Operate the same installation + +With the same `OAC_RUNTIME_HOME` still set: + +```sh +oac-daemon status +oac-daemon logs -n 100 +oac-daemon logs -f +oac-daemon stop +``` + +Use `oac-daemon.exe` for these commands on Windows. A connected Environment proves +the machine connection, not model execution: send a Turn to validate the selected +native harness and model. Stopping the daemon preserves the workspace, native +history and capability snapshot; deleting a Session does not remove host files. + +After credential rotation, stop the daemon, replace the JSON at the configured +credential path with the newly issued credential for the same `key_id`, then start +it again. Do not rerun `install` over an existing installation. Revocation prevents +the old credential from reconnecting. + +## Capabilities and versions + +`capability_directories` supplies local source directories when the Session is +created. Runtime snapshots them before execution and resolves Skills and Plugin +MCP from the common `installed.json` format used for managed bundles. Paths use the +executor host's native syntax, not Core's filesystem. Reconnect reuses the snapshot; +source edits do not silently replace it. A new Session captures new configuration. +See [local capability directories](getting-started/self-hosted.md#local-capability-directories). +Read-only snapshot modes are an integrity hint, not protection from the same user. + +Installation is current-version only. It refuses an existing installation, and +`start` refuses configuration from another daemon version. There is no in-place +upgrade, adoption of a historical container installation, or cross-version native +history migration. Preserve an old installation's files and use a fresh Runtime +home and Session when moving versions. + +Managed system-package installation remains under its existing contract while the +ordinary apt ownership decision is pending. This guide does not claim that apt has +been migrated or that a native daemon can install packages without the launching +user's required permissions. diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index 11d12b547..089fda97a 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -81,6 +81,12 @@ Resource-specific boundaries: ## Executor credentials +The credential operations below also serve the native daemon on Linux, macOS and +Windows. The existing **Connect a host** download command is the Linux container +installer; use the [native installation guide](../self-hosted-native.md) for +`oac-daemon install` and lifecycle commands. Native credential rotation replaces +the configured credential file and restarts the daemon, without rerunning install. + Core issues the credentials of a self_hosted executor, and the console is where the administrator does it: the **Executor credentials** section of a Session page, shown only when the Session's environment is `self_hosted`, for diff --git a/internal/agentcapabilities/manifest.go b/internal/agentcapabilities/manifest.go index e499388ce..5b3da3129 100644 --- a/internal/agentcapabilities/manifest.go +++ b/internal/agentcapabilities/manifest.go @@ -6,9 +6,9 @@ import ( "encoding/hex" "encoding/json" "errors" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" "io/fs" "os" - "path" "strings" "unicode/utf8" @@ -81,7 +81,7 @@ func ValidateDirectories(directories []string) error { } // ValidateLocalDirectories checks source spelling, not local access authority. -// This Linux implementation requires canonical Unix paths. The Runtime resolver +// Runtime requires canonical paths for its own operating system. The resolver // owns directory access and protected-root exclusions. func ValidateLocalDirectories(directories []string) error { if len(directories) > 50 { @@ -89,8 +89,7 @@ func ValidateLocalDirectories(directories []string) error { } seen := map[string]bool{} for _, directory := range directories { - if !path.IsAbs(directory) || len(directory) > 4096 || !utf8.ValidString(directory) || - path.Clean(directory) != directory || strings.ContainsAny(directory, "\\\x00\r\n") || seen[directory] { + if runtimefs.ValidateLocalPath(directory) != nil || len(directory) > 4096 || !utf8.ValidString(directory) || seen[directory] { return ErrInvalid } seen[directory] = true diff --git a/internal/agentcapabilities/tree.go b/internal/agentcapabilities/tree.go index 61ace6284..793261937 100644 --- a/internal/agentcapabilities/tree.go +++ b/internal/agentcapabilities/tree.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/MiniMax-AI-Dev/parsar/internal/agentbundle" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" ) // ReadTree stays inside an already-owned root and rejects aliases/special files. @@ -124,13 +125,12 @@ func writeFile(root *os.Root, name string, body []byte, mode fs.FileMode) error } func syncDirectory(root *os.Root, name string) error { - file, err := root.Open(name) + directory, err := root.OpenRoot(name) if err != nil { return ErrInvalid } - err = file.Sync() - closeErr := file.Close() - if err != nil || closeErr != nil { + defer directory.Close() + if runtimefs.SyncDirectory(directory) != nil { return ErrInvalid } return nil diff --git a/internal/agentdaemon/proto/environment.go b/internal/agentdaemon/proto/environment.go index 74772af6a..11712269f 100644 --- a/internal/agentdaemon/proto/environment.go +++ b/internal/agentdaemon/proto/environment.go @@ -8,6 +8,7 @@ import ( // LocalEnvironment names a frozen workspace selection. Runtime must verify it // against the bound local root before resolving capabilities or native execution. type LocalEnvironment struct { + ExecutionMode string `json:"-"` CapabilityRoot string `json:"-"` ID string `json:"id"` WorkspaceDirectory string `json:"workspace_directory"` @@ -31,6 +32,7 @@ type LocalEnvironment struct { // EnvironmentMCP is transient Runtime configuration. Do not log it: HTTP headers // and the selected user bearer may be confidential. It is not agent.tools MCP. type EnvironmentMCP struct { + ExecutionMode string `json:"-"` InstallationRoot string WorkspaceRoot string PackageRoot string diff --git a/internal/runtimefs/runtimefs.go b/internal/runtimefs/runtimefs.go new file mode 100644 index 000000000..093369431 --- /dev/null +++ b/internal/runtimefs/runtimefs.go @@ -0,0 +1,148 @@ +// Package runtimefs provides ordinary Runtime state files and process locks. +// It does not restrict the launching user or implement a sandbox. +package runtimefs + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "io" + "os" + "strings" +) + +var ErrUnsafe = errors.New("Runtime state path unavailable or invalid") + +func validName(name string) bool { + return name != "" && name != "." && name != ".." && !strings.ContainsAny(name, "/\\\x00") +} + +// ReadPrivate reads a bounded regular state file. +func ReadPrivate(root *os.Root, name string, limit int64) ([]byte, error) { + file, err := OpenPrivate(root, name, os.O_RDONLY) + if err != nil { + return nil, err + } + defer file.Close() + raw, err := io.ReadAll(io.LimitReader(file, limit+1)) + if err != nil || int64(len(raw)) > limit { + return nil, ErrUnsafe + } + return raw, nil +} + +// ReadPrivatePath is for startup credentials outside an already-open root. +func ReadPrivatePath(path string, limit int64) ([]byte, error) { + if ValidateLocalPath(path) != nil { + return nil, ErrUnsafe + } + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() { + return nil, ErrUnsafe + } + raw, err := io.ReadAll(io.LimitReader(file, limit+1)) + if err != nil || int64(len(raw)) > limit { + return nil, ErrUnsafe + } + return raw, nil +} + +// WritePrivateAtomic writes a fresh state file before replacing the name. +// An old permissive target is replaced, never opened or repaired in place. +func WritePrivateAtomic(root *os.Root, name string, data []byte) error { + if !validName(name) || PrivateDirectory(root) != nil { + return ErrUnsafe + } + var suffix [12]byte + if _, err := rand.Read(suffix[:]); err != nil { + return ErrUnsafe + } + temporary := "." + name + "-" + hex.EncodeToString(suffix[:]) + ".tmp" + file, err := OpenPrivate(root, temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL) + if err != nil { + return err + } + defer root.Remove(temporary) + _, err = file.Write(data) + if err == nil { + err = file.Sync() + } + closeErr := file.Close() + if err != nil || closeErr != nil { + return ErrUnsafe + } + if err = replacePrivate(root, temporary, name); err != nil { + return err + } + return SyncDirectory(root) +} + +// EnsurePrivateDir creates state directories with normal account permissions. +func EnsurePrivateDir(path string) error { + if ValidateLocalPath(path) != nil { + return ErrUnsafe + } + if err := ensurePrivateDir(path); err != nil { + return err + } + root, err := os.OpenRoot(path) + if err != nil { + return err + } + defer root.Close() + return PrivateDirectory(root) +} + +// PrivateDirectory checks the storage type, not the account's access policy. +func PrivateDirectory(root *os.Root) error { + info, err := root.Stat(".") + if err != nil { + return err + } + if !info.IsDir() { + return ErrUnsafe + } + return nil +} + +// OpenPrivate uses default credential-file permissions when creating a file. +// Existing files retain the launching user's normal filesystem permissions. +func OpenPrivate(root *os.Root, name string, flags int) (*os.File, error) { + if !validName(name) { + return nil, ErrUnsafe + } + file, err := root.OpenFile(name, flags, 0600) + if err != nil { + return nil, err + } + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() { + file.Close() + return nil, ErrUnsafe + } + return file, nil +} + +func ensurePrivateDir(path string) error { return os.MkdirAll(path, 0700) } + +func MkdirPrivate(root *os.Root, name string) error { + if !validName(name) { + return ErrUnsafe + } + if err := root.Mkdir(name, 0700); err != nil && !errors.Is(err, os.ErrExist) { + return err + } + info, err := root.Stat(name) + if err != nil { + return err + } + if !info.IsDir() { + return ErrUnsafe + } + return nil +} diff --git a/internal/runtimefs/runtimefs_test.go b/internal/runtimefs/runtimefs_test.go new file mode 100644 index 000000000..8a25f4ac5 --- /dev/null +++ b/internal/runtimefs/runtimefs_test.go @@ -0,0 +1,117 @@ +package runtimefs + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func privateRoot(t *testing.T) (*os.Root, string) { + t.Helper() + parent, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + name := filepath.Join(parent, "private") + if err = EnsurePrivateDir(name); err != nil { + t.Fatal(err) + } + root, err := os.OpenRoot(name) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { root.Close() }) + return root, name +} + +func TestPrivateAtomicWriteAndExclusiveCreate(t *testing.T) { + root, _ := privateRoot(t) + for _, body := range []string{"first", "replacement"} { + if err := WritePrivateAtomic(root, "credential.json", []byte(body)); err != nil { + t.Fatal(err) + } + got, err := ReadPrivate(root, "credential.json", 100) + if err != nil || string(got) != body { + t.Fatal("private replacement failed", err) + } + } + if _, err := ReadPrivate(root, "credential.json", 3); err == nil { + t.Fatal("oversize accepted") + } + if _, err := OpenPrivate(root, "credential.json", os.O_WRONLY|os.O_CREATE|os.O_EXCL); !errors.Is(err, os.ErrExist) { + t.Fatal("exclusive create replaced file", err) + } + for _, name := range []string{"../escape", "nested/file", ".", "", `nested\file`} { + if _, err := OpenPrivate(root, name, os.O_RDONLY); err == nil { + t.Fatal("invalid name accepted") + } + } + entries, err := os.ReadDir(root.Name()) + if err != nil || len(entries) != 1 { + t.Fatal("temporary publication left behind", err) + } +} + +func TestPrivateDirectoryLockAcrossProcesses(t *testing.T) { + root, name := privateRoot(t) + unlock, err := LockDirectory(root) + if err != nil { + t.Fatal(err) + } + held := true + defer func() { + if held { + unlock() + } + }() + child := func(expect string) { + t.Helper() + cmd := exec.Command(os.Args[0], "-test.run=^TestPrivateLockChild$") + cmd.Env = append(os.Environ(), "OAC_TEST_PRIVATE_LOCK="+name, "OAC_TEST_PRIVATE_LOCK_EXPECT="+expect) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("lock child failed: %v %s", err, out) + } + } + child("busy") + unlock() + held = false + child("available") +} + +func TestPrivateLockChild(t *testing.T) { + name := os.Getenv("OAC_TEST_PRIVATE_LOCK") + if name == "" { + return + } + root, err := os.OpenRoot(name) + if err != nil { + t.Fatal(err) + } + defer root.Close() + unlock, err := LockDirectory(root) + if os.Getenv("OAC_TEST_PRIVATE_LOCK_EXPECT") == "busy" { + if err == nil { + unlock() + t.Fatal("concurrent lock admitted") + } + } else { + if err != nil { + t.Fatal(err) + } + unlock() + } +} + +func TestLocalPathValidation(t *testing.T) { + for _, path := range []string{"", "relative", "a/../b", "/tmp/line\n"} { + if ValidateLocalPath(path) == nil { + t.Fatal("ambiguous path accepted") + } + } + _, name := privateRoot(t) + if err := ValidateLocalPath(name); err != nil { + t.Fatal(err) + } +} diff --git a/internal/runtimefs/runtimefs_unix.go b/internal/runtimefs/runtimefs_unix.go new file mode 100644 index 000000000..91448d0f0 --- /dev/null +++ b/internal/runtimefs/runtimefs_unix.go @@ -0,0 +1,40 @@ +//go:build unix + +package runtimefs + +import ( + "os" + "path/filepath" + "strings" + "syscall" +) + +func ValidateLocalPath(path string) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.ContainsAny(path, "\\\x00\r\n") { + return ErrUnsafe + } + return nil +} + +func LockDirectory(root *os.Root) (func(), error) { + f, err := root.Open(".") + if err != nil { + return nil, err + } + if err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + f.Close() + return nil, err + } + return func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN); _ = f.Close() }, nil +} + +func SyncDirectory(root *os.Root) error { + f, err := root.Open(".") + if err != nil { + return err + } + defer f.Close() + return f.Sync() +} + +func replacePrivate(root *os.Root, from, to string) error { return root.Rename(from, to) } diff --git a/internal/runtimefs/runtimefs_unix_test.go b/internal/runtimefs/runtimefs_unix_test.go new file mode 100644 index 000000000..a9337f2ab --- /dev/null +++ b/internal/runtimefs/runtimefs_unix_test.go @@ -0,0 +1,46 @@ +//go:build unix + +package runtimefs + +import ( + "os" + "path/filepath" + "testing" +) + +func TestUnixDefaultStatePermissionsAndUserSelectedFiles(t *testing.T) { + root, name := privateRoot(t) + if err := WritePrivateAtomic(root, "credential", []byte("value")); err != nil { + t.Fatal(err) + } + info, err := root.Stat("credential") + if err != nil || info.Mode().Perm() != 0600 { + t.Fatal("default credential mode", err) + } + if err := os.Chmod(filepath.Join(name, "credential"), 0644); err != nil { + t.Fatal(err) + } + if _, err := ReadPrivate(root, "credential", 100); err != nil { + t.Fatal("user-selected mode rejected", err) + } + linked := filepath.Join(t.TempDir(), "linked") + if err := os.Link(filepath.Join(name, "credential"), linked); err != nil { + t.Fatal(err) + } + if _, err := ReadPrivate(root, "credential", 100); err != nil { + t.Fatal("user-selected link rejected", err) + } + alias := filepath.Join(name, "alias") + if err := os.Symlink("credential", alias); err != nil { + t.Fatal(err) + } + if _, err := ReadPrivate(root, "alias", 100); err != nil { + t.Fatal("user-selected alias rejected", err) + } + if err := os.Chmod(name, 0755); err != nil { + t.Fatal(err) + } + if err := EnsurePrivateDir(name); err != nil { + t.Fatal("user-selected directory mode rejected", err) + } +} diff --git a/internal/runtimefs/runtimefs_windows.go b/internal/runtimefs/runtimefs_windows.go new file mode 100644 index 000000000..6d79718b0 --- /dev/null +++ b/internal/runtimefs/runtimefs_windows.go @@ -0,0 +1,54 @@ +package runtimefs + +import ( + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/windows" +) + +// ValidateLocalPath checks local Windows spelling, not execution authority. +func ValidateLocalPath(path string) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.HasPrefix(path, `\\?\`) || strings.HasPrefix(path, `\\.\`) || strings.ContainsAny(path, "\x00\r\n*?\"<>|") { + return ErrUnsafe + } + tail := strings.TrimPrefix(path, filepath.VolumeName(path)) + for _, part := range strings.FieldsFunc(tail, func(r rune) bool { return r == '/' || r == '\\' }) { + if strings.Contains(part, ":") || strings.TrimRight(part, " .") != part { + return ErrUnsafe + } + } + return nil +} + +func LockDirectory(root *os.Root) (func(), error) { + // Windows cannot byte-lock a directory. A stable sidecar avoids polluting the + // capability inventory and is not removed while another process may open it. + parent, err := os.OpenRoot(filepath.Dir(root.Name())) + if err != nil { + return nil, err + } + defer parent.Close() + name := "." + filepath.Base(root.Name()) + ".runtime.lock" + f, err := OpenPrivate(parent, name, os.O_RDWR|os.O_CREATE) + if err != nil { + return nil, err + } + overlapped := new(windows.Overlapped) + if err = windows.LockFileEx(windows.Handle(f.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, overlapped); err != nil { + f.Close() + return nil, err + } + return func() { _ = windows.UnlockFileEx(windows.Handle(f.Fd()), 0, 1, 0, overlapped); _ = f.Close() }, nil +} + +// Windows has no POSIX directory fsync. The writer flushes the file before +// publication; this does not promise directory durability across power loss. +func SyncDirectory(*os.Root) error { return nil } + +func replacePrivate(root *os.Root, from, to string) error { + // Go's Windows os.Root.Rename uses handle-relative FileRenameInformation with + // ReplaceIfExists, keeping publication within the held directory. + return root.Rename(from, to) +} diff --git a/internal/runtimefs/runtimefs_windows_test.go b/internal/runtimefs/runtimefs_windows_test.go new file mode 100644 index 000000000..4fada6949 --- /dev/null +++ b/internal/runtimefs/runtimefs_windows_test.go @@ -0,0 +1,16 @@ +package runtimefs + +import "testing" + +func TestWindowsLocalPathsRejectAlternateSpellings(t *testing.T) { + for _, path := range []string{`C:\private\file:stream`, `C:\private\name.`, `C:\private\name `, `\\?\C:\private`, `\\.\pipe\name`, `C:relative`} { + if ValidateLocalPath(path) == nil { + t.Fatalf("unsafe Windows path accepted: %q", path) + } + } + for _, path := range []string{`C:\private\root`, `\\server\share\private`} { + if err := ValidateLocalPath(path); err != nil { + t.Fatal("native path rejected", err) + } + } +} diff --git a/packages/claude-sdk-adapter/SUBAGENTS.md b/packages/claude-sdk-adapter/SUBAGENTS.md index 40e37d9f0..3514af3ab 100644 --- a/packages/claude-sdk-adapter/SUBAGENTS.md +++ b/packages/claude-sdk-adapter/SUBAGENTS.md @@ -32,23 +32,27 @@ Native background execution, alternate agent types, worktree isolation and per-call model overrides are not admitted in this profile. -Workspace children use Bash under the existing WorkspaceProfile hooks and native -sandbox. They do not receive unrestricted private-file tools. The parent retains -its existing workspace tool policy. Functions and MCP with subagents are not -qualified combinations and are rejected explicitly. This does not affect the -existing functions/MCP paths without subagents. +Workspace children use native Bash with the same launching-user permissions as +the parent. The daemon and adapter add no inner filesystem, permission or network +sandbox. Workspace hooks retain their execution and event responsibilities, but +are not a private-file boundary. Tools can access Runtime state that the host user +can access. Managed isolation belongs to the outer Environment. Functions and MCP +with subagents are not qualified combinations and are rejected explicitly; this +does not affect existing functions/MCP paths without subagents. Claude on Windows +requires Git Bash; native Windows validation remains pending. -The adapter mechanism qualification uses real Kimi calls in the accepted Docker -Runtime, including two child identities, their own histories, workspace writes, -private credential/history/proc-read denial, strict concurrent admission and -same-ID continuation from a new process. These checks are distinct from the -Core public resource, pagination and isolation acceptance performed by the -integration suite. +The earlier adapter mechanism qualification is historical evidence for its tested +Docker Runtime and binaries. It used real Kimi calls for two child identities, +their histories, workspace writes, private credential/history/proc-read denial, +strict concurrent admission and same-ID continuation from a new process. Its +private-file denial results describe the former inner sandbox and are not current +behavior. They do not qualify the current bypass execution or additional native +platforms. Core public resource and pagination acceptance remain separate checks. Cancellation uses an adapter-owned effect receipt only after the existing query owner confirms native process exit. The fixed native history can end at a tool call without a cancellation result or timestamp. Each receipt is linked into -the protected native history directory atomically, without overwriting an +the native history directory atomically, without overwriting an earlier receipt, and records the child, own Turn, spawn call and confirmed effect time. Native records remain unchanged. Replay uses that same timestamp; it never obtains a new cancellation time by observing an unfinished history. Child diff --git a/packages/claude-sdk-adapter/src/mcp_environment.ts b/packages/claude-sdk-adapter/src/mcp_environment.ts index b60623d3d..4484c1674 100644 --- a/packages/claude-sdk-adapter/src/mcp_environment.ts +++ b/packages/claude-sdk-adapter/src/mcp_environment.ts @@ -1,4 +1,4 @@ -import { posix } from "node:path"; +import { isAbsolute, normalize, parse } from "node:path"; import { parseHTTPServers, type HTTPServer } from "./mcp.js"; export type StdioServer = { @@ -9,8 +9,7 @@ export type StdioServer = { }; export type EnvironmentMCPServer = HTTPServer | StdioServer; -// This private projection accepts only the Runtime-owned sandbox entry. Package -// command/env/cwd are resolved by the shared Go helper after entering isolation. +// The Runtime launcher resolves installed package identities and their commands. export function parseEnvironmentMCP(value: unknown): EnvironmentMCPServer[] | undefined { if (value === undefined) return undefined; if (!Array.isArray(value)) throw new Error("invalid_request"); @@ -18,19 +17,15 @@ export function parseEnvironmentMCP(value: unknown): EnvironmentMCPServer[] | un for (const server of value) { if (!server || typeof server !== "object" || labels.has(server.server_label)) throw new Error("invalid_request"); if ("command" in server) { - if (Object.keys(server).some(key => !["server_label", "command", "args", "allowed_tools"].includes(key)) || - typeof server.server_label !== "string" || !/^[a-zA-Z0-9_-]+$/.test(server.server_label) || - server.server_label === "functions" || server.allowed_tools !== null || server.command !== "/usr/bin/python3" || - !Array.isArray(server.args) || server.args.length !== 8 || - server.args[0] !== "-I" || server.args[1] !== "-S" || - server.args[2] !== "/usr/local/bin/oac-runtime-initialize" || server.args[3] !== "stdio" || - typeof server.args[4] !== "string" || !posix.isAbsolute(server.args[4]) || server.args[4].endsWith("/") || posix.normalize(server.args[4]) !== server.args[4] || - /[\x00-\x1f\x7f\\]/.test(server.args[4]) || - typeof server.args[5] !== "string" || !posix.isAbsolute(server.args[5]) || server.args[5].endsWith("/") || posix.normalize(server.args[5]) !== server.args[5] || - /[\x00-\x1f\x7f\\]/.test(server.args[5]) || - typeof server.args[6] !== "string" || !server.args[6] || server.args[6].startsWith("/") || - server.args[6].split("/").some((part: string) => !part || part === "." || part === "..") || - /[\x00-\x1f\x7f\\]/.test(server.args[6]) || server.args[7] !== server.server_label) throw new Error("invalid_request"); + if (Object.keys(server).some(key => !["server_label", "command", "args", "allowed_tools"].includes(key)) || + typeof server.server_label !== "string" || !/^[a-zA-Z0-9_-]+$/.test(server.server_label) || server.server_label === "functions" || server.allowed_tools !== null || + typeof server.command !== "string" || !isAbsolute(server.command) || normalize(server.command) !== server.command || + !Array.isArray(server.args) || server.args.length !== 4 || server.args[0] !== "runtime-mcp-exec" || + typeof server.args[1] !== "string" || !isAbsolute(server.args[1]) || normalize(server.args[1]) !== server.args[1] || server.args[1] === parse(server.args[1]).root || + typeof server.args[2] !== "string" || !server.args[2] || /[\\]/.test(server.args[2]) || server.args[2].split("/").some((part: string) => !part || part === "." || part === "..") || + server.args[3] !== server.server_label || [server.command, ...server.args].some((part: string) => /[\x00-\x1f\x7f]/.test(part))) throw new Error("invalid_request"); + labels.add(server.server_label); + } else { parseHTTPServers([server]); if (server.allowed_tools !== null || server.required !== undefined) throw new Error("invalid_request"); diff --git a/packages/claude-sdk-adapter/src/runtime_check.ts b/packages/claude-sdk-adapter/src/runtime_check.ts index dc6caec94..7e3ec1560 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", ...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions", "workspace_structured_output"] : []), "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_v1", "workspace_functions", "workspace_structured_output"] : []), "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"); } 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 7b525d9d2..0fa9ed9d0 100644 --- a/packages/claude-sdk-adapter/src/workspace.ts +++ b/packages/claude-sdk-adapter/src/workspace.ts @@ -3,22 +3,19 @@ import { parseEnvironmentMCP, type EnvironmentMCPServer } from "./mcp_environmen import type { MCPProfile } from "./mcp.js"; import { parseSkills, workspaceSkills, type WorkspaceSkill } from "./workspace_skills.js"; import type { CanUseTool, HookCallback, Options } from "@anthropic-ai/claude-agent-sdk"; -import { lstatSync, realpathSync, statSync } from "node:fs"; -import { isIP } from "node:net"; -import { dirname, isAbsolute, join, resolve } from "node:path"; +import { realpathSync, statSync } from "node:fs"; +import { isAbsolute, parse, resolve } from "node:path"; export type Workspace = { + execution_mode?: "native"; + tool_env?: Record; home: string; state: string; scratch: string; - protected_dirs: string[]; - dependency_path: string; env_names: string[]; skills?: WorkspaceSkill[]; capability_root?: string; mcp?: EnvironmentMCPServer[]; - tool_environment?: boolean; - system_packages?: boolean; network_access?: "enabled" | "disabled" | "restricted"; allowed_domains?: string[]; }; @@ -28,15 +25,13 @@ const environmentNames = new Set([ "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", ]); -const credentialNames = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", - "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS"]; const nativeTools = ["Bash", "Read", "Edit"]; const denial = "Tool is outside the workspace execution profile."; -const invalidPath = /[\x00-\x1f\x7f\\:*?\[\]{}()]/; -const contains = (root: string, path: string) => path === root || path.startsWith(root + "/"); +const invalidPath = /[\x00-\x1f\x7f]/; + function directory(value: unknown, canonical: boolean): string { - if (typeof value !== "string" || !isAbsolute(value) || value === "/" || invalidPath.test(value)) { + if (typeof value !== "string" || !isAbsolute(value) || value === parse(value).root || invalidPath.test(value)) { throw new Error("invalid_request"); } try { @@ -50,39 +45,23 @@ 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", "network_access", "allowed_domains", "tool_environment", "system_packages", "skills", "mcp", "capability_root"].includes(key)) || - (config.tool_environment !== undefined && typeof config.tool_environment !== "boolean") || - (config.system_packages !== undefined && typeof config.system_packages !== "boolean") || - (config.system_packages === true && config.tool_environment !== true) || + if (Object.keys(config).some(key => !["tool_env", "execution_mode", "home", "state", "scratch", "env_names", "network_access", "allowed_domains", "skills", "mcp", "capability_root"].includes(key)) || + (config.execution_mode !== undefined && config.execution_mode !== "native") || (config.network_access !== undefined && config.network_access !== "enabled" && config.network_access !== "disabled" && config.network_access !== "restricted") || - !Array.isArray(config.protected_dirs) || !Array.isArray(config.env_names) || - typeof config.dependency_path !== "string" || !config.dependency_path || + !Array.isArray(config.env_names) || config.env_names.some(name => typeof name !== "string" || !environmentNames.has(name)) || new Set(config.env_names).size !== config.env_names.length) throw new Error("invalid_request"); + if (config.network_access !== undefined && config.network_access !== "enabled") throw new Error("invalid_request"); + 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) { - const capabilityRoot = directory(config.capability_root, true); - if ([cwd, config.home, config.state, config.scratch].some(root => - typeof root === "string" && (contains(root, capabilityRoot) || contains(capabilityRoot, root)))) throw new Error("invalid_request"); - } + 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 (mcp?.length && config.network_access !== "enabled") throw new Error("invalid_request"); const domains = config.allowed_domains ?? []; - const hostname = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i; - if (!Array.isArray(domains) || (config.network_access === "restricted" - ? domains.length < 1 || domains.length > 100 || domains.some(host => typeof host !== "string" || !hostname.test(host) || host.trim() !== host || isIP(host) !== 0) - : domains.length !== 0)) throw new Error("invalid_request"); - const roots = [cwd, config.home, config.state, config.scratch, ...config.protected_dirs].map(path => directory(path, true)); - if (roots.some((root, index) => roots.some((other, otherIndex) => index !== otherIndex && contains(root, other)))) { - throw new Error("invalid_request"); - } - const dependencies = config.dependency_path.split(":").map(path => { - const actual = directory(path, false); - if (roots.some(root => contains(root, resolve(path)) || contains(resolve(path), root) || - contains(root, actual) || contains(actual, root))) throw new Error("invalid_request"); - return actual; - }); - return { ...config, ...(config.skills === undefined ? {} : { skills: parseSkills(config.skills) }), dependency_path: dependencies.join(":") } as Workspace; + if (!Array.isArray(domains) || domains.length !== 0) throw new Error("invalid_request"); + for(const root of [cwd,config.home,config.state,config.scratch]) directory(root,false); + return { ...config, ...(config.skills === undefined ? {} : { skills: parseSkills(config.skills) }) } as Workspace; + } export class WorkspaceProfile { @@ -95,10 +74,12 @@ export class WorkspaceProfile { if (process.env.HOME !== config.home || process.env.CLAUDE_CONFIG_DIR !== config.state || process.env.CLAUDE_CODE_PROJECT_DIR_NAME !== undefined) throw new Error("invalid_request"); const env: Record = { - PATH: config.dependency_path, HOME: config.home, TMPDIR: config.scratch, CLAUDE_CONFIG_DIR: config.state, + ...Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string,string] => entry[1] !== undefined)), + HOME: config.home, TMPDIR: config.scratch, CLAUDE_CONFIG_DIR: config.state, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", DISABLE_TELEMETRY: "1", DISABLE_ERROR_REPORTING: "1", DISABLE_AUTOUPDATER: "1", CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1", }; + Object.assign(env, config.tool_env ?? {}); for (const name of config.env_names) { const value = process.env[name]; if (value === undefined) throw new Error("invalid_request"); @@ -108,39 +89,16 @@ export class WorkspaceProfile { if (!process.env[reference]) throw new Error("invalid_request"); env[reference] = process.env[reference]!; } - if (config.system_packages) { - env.CLAUDE_CODE_SHELL_PREFIX = "/usr/local/bin/oac-claude-shell-prefix"; - env.OAC_RUNTIME_TOOL_SCRATCH = config.scratch; - } const skills = workspaceSkills(config.skills ?? [], config.capability_root ?? ""); this.skillNames = skills?.names ?? []; const skillTools = skills ? ["Skill"] : []; - const protectedRoots = [config.home, config.state, ...config.protected_dirs]; this.options = { env, tools: [...nativeTools, ...skillTools, ...(subagents ? ["Agent", "SendMessage"] : [])], ...(skills ? { plugins: skills.paths.map(path => ({ type: "local" as const, path, skipMcpDiscovery: true })) } : {}), allowedTools: mcp?.allowed ?? [...functions], mcpServers: {}, strictMcpConfig: true, - settingSources: [], permissionMode: "default", persistSession: true, - settings: { - ...(skills ? { disableSkillShellExecution: true } : {}), - permissions: { - blockReadsOutsideWorkingDirectories: true, disableBypassPermissionsMode: "disable", - deny: [...protectedRoots, "/proc", "/sys"].flatMap(path => [ - `Read(/${path})`, `Read(/${path}/**)`, `Edit(/${path})`, `Edit(/${path}/**)`, - ]), - }, - }, - sandbox: { - enabled: true, failIfUnavailable: true, autoAllowBashIfSandboxed: false, allowUnsandboxedCommands: false, - excludedCommands: [], enableWeakerNestedSandbox: false, enableWeakerNetworkIsolation: false, - filesystem: { disabled: false, allowWrite: [cwd, config.scratch, ...(config.tool_environment ? ["/environment/packages"] : [])], denyRead: protectedRoots, - denyWrite: [...protectedRoots, ...(config.capability_root ? [config.capability_root] : []), - ...(config.system_packages ? ["/environment/packages/system"] : [])], allowRead: [] }, - credentials: { - envVars: [...new Set([...credentialNames, ...config.env_names, ...(mcp?.credentialReferences() ?? [])])].map(name => ({ name, mode: "deny" })), - files: protectedRoots.map(path => ({ path, mode: "deny" })), - }, - network: { allowedDomains: config.network_access === "enabled" ? ["*"] : config.network_access === "restricted" ? [...config.allowed_domains!] : [], strictAllowlist: true, allowAllUnixSockets: false, allowLocalBinding: false }, - }, + settingSources: [], permissionMode: "bypassPermissions", persistSession: true, + settings: {}, + sandbox: { enabled: false }, + allowDangerouslySkipPermissions: true, canUseTool: this.canUseTool, hooks: { PreToolUse: [{ hooks: [this.beforeTool] }] }, }; @@ -174,12 +132,6 @@ export class WorkspaceProfile { } if (!signal.aborted && input.hook_event_name === "PreToolUse" && (input.agent_id === undefined || this.subagents?.permitsActor(input.agent_id)) && (id === undefined || id === input.tool_use_id) && this.permits(input.tool_name, input.tool_input)) { - if (input.tool_name === "Bash" && this.config.tool_environment) { - const toolInput = input.tool_input as Record; - const quote = (text: string) => "'" + text.replaceAll("'", "'\\''") + "'"; - return { hookSpecificOutput: { hookEventName: "PreToolUse", updatedInput: { ...toolInput, - command: ". /environment/initialization/tool-env.sh && eval -- " + quote(toolInput.command as string) } } }; - } return input.tool_name !== "Read" && input.tool_name !== "Edit" ? {} : { hookSpecificOutput: { hookEventName: "PreToolUse", updatedInput: this.absoluteInput(input.tool_name, input.tool_input as Record) } }; } @@ -201,20 +153,6 @@ export class WorkspaceProfile { (input.dangerouslyDisableSandbox === undefined || input.dangerouslyDisableSandbox === false); if ((name !== "Read" && name !== "Edit") || typeof input.file_path !== "string" || !input.file_path || /[\x00-\x1f]/.test(input.file_path)) return false; - const path = resolve(this.cwd, input.file_path); - if (!contains(this.cwd, path)) return false; - let existing = path; - const missing: string[] = []; - try { - while (true) { - try { lstatSync(existing); break; } - catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT" || existing === this.cwd) return false; - missing.unshift(existing.slice(dirname(existing).length + 1)); - existing = dirname(existing); - } - } - return contains(this.cwd, join(realpathSync(existing), ...missing)); - } catch { return false; } + return true; } } diff --git a/packages/claude-sdk-adapter/src/workspace_directories.ts b/packages/claude-sdk-adapter/src/workspace_directories.ts index c36c48cbc..355bf0c0a 100644 --- a/packages/claude-sdk-adapter/src/workspace_directories.ts +++ b/packages/claude-sdk-adapter/src/workspace_directories.ts @@ -1,24 +1,21 @@ -import { constants } from "node:fs"; -import { lstat, open, opendir, type FileHandle } from "node:fs/promises"; +import { join, isAbsolute } from "node:path"; +import { lstat, opendir, stat } from "node:fs/promises"; export type WorkspaceDirectoryEntry = { name: string; kind: "file" | "directory" | "symlink" | "other"; size_bytes?: number }; export type WorkspaceDirectoryEvent = { type: "workspace_directory"; id: string } & ( { entries: WorkspaceDirectoryEntry[]; truncated: boolean } | { error: "invalid" | "busy" | "unavailable" | "uncertain" | "not_found" | "permission" } ); -const flags = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW; -const anchored = (handle: FileHandle) => `/proc/self/fd/${handle.fd}`; - export class WorkspaceDirectories { - private root?: FileHandle; + private root?: string; private stopped = false; private pending?: Promise; constructor(private readonly emit: (event: WorkspaceDirectoryEvent) => Promise, private readonly abort: AbortController) {} async bind(root: string): Promise { - if (process.platform !== "linux" || this.root || this.stopped) throw new Error("directory listing unavailable"); - this.root = await open(root, flags); + if (this.root || this.stopped || !isAbsolute(root) || !(await stat(root)).isDirectory()) throw new Error("directory listing unavailable"); + this.root = root; } submit(value: Record): void { @@ -49,15 +46,8 @@ export class WorkspaceDirectories { } private async enumerate(directory: string, maxEntries: number): Promise<{ entries: WorkspaceDirectoryEntry[]; truncated: boolean }> { - const handles: FileHandle[] = []; - try { - let parent = this.root!; - for (const component of directory === "" ? [] : directory.split("/")) { - parent = await open(`${anchored(parent)}/${component}`, flags); - handles.push(parent); - } - // Node supports byte names, but its opendir typings omit the buffer encoding. - const dir = await opendir(anchored(parent), { encoding: "buffer" as BufferEncoding }); + const path = join(this.root!, ...directory.split("/")); + const dir = await opendir(path, { encoding: "buffer" as BufferEncoding }); try { const entries: WorkspaceDirectoryEntry[] = []; while (true) { @@ -69,22 +59,17 @@ export class WorkspaceDirectories { if (!Buffer.isBuffer(rawName)) throw new Error("invalid entry name"); const name = rawName.toString("utf8"); if (!Buffer.from(name).equals(rawName) || !name || /[\x00/]/.test(name) || name === "." || name === "..") throw new Error("unsupported entry name"); - const stat = await lstat(`${anchored(parent)}/${name}`); + const stat = await lstat(join(path,name)); const kind = stat.isFile() ? "file" : stat.isDirectory() ? "directory" : stat.isSymbolicLink() ? "symlink" : "other"; if (kind === "file" && (!Number.isSafeInteger(stat.size) || stat.size < 0)) throw new Error("invalid file size"); entries.push({ name, kind, ...(kind === "file" ? { size_bytes: stat.size } : {}) }); } } finally { await dir.close(); } - } finally { - const closed = await Promise.allSettled(handles.reverse().map(handle => handle.close())); - if (closed.some(result => result.status === "rejected")) throw new Error("directory close failed"); - } } async close(): Promise { this.stopped = true; await this.pending; - await this.root?.close(); this.root = undefined; } } diff --git a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs index d037130c4..c765b4872 100644 --- a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs @@ -8,8 +8,8 @@ import { parseEnvironmentMCP } from "../dist/mcp_environment.js"; import { immediateInput, parseStart } from "../dist/request.js"; import { WorkspaceProfile } from "../dist/workspace.js"; -const stdio = { server_label: "installed", command: "/usr/bin/python3", allowed_tools: null, - args: ["-I", "-S", "/usr/local/bin/oac-runtime-initialize", "stdio", "/private/runtime/capabilities", "/private/runtime/workspace", "plugins/installed", "installed"] }; +const stdio = { server_label: "installed", command: process.execPath, allowed_tools: null, + args: ["runtime-mcp-exec", join(process.cwd(), "capabilities"), "plugins/installed", "installed"] }; const native = "mcp__installed__echo_v1"; const statuses = [{ name: "installed", status: "connected", tools: [{ name: "echo.v1" }] }]; const baseline = ["Bash", "Read", "Edit"]; @@ -19,8 +19,8 @@ function fixture(t, declarations = [stdio]) { const dirs = Object.fromEntries(["work", "home", "state", "scratch", "secrets", "deps", "capabilities"].map(name => { const path = join(root, name); mkdirSync(path); return [name, path]; })); - const config = { home: dirs.home, state: dirs.state, scratch: dirs.scratch, protected_dirs: [dirs.secrets], - capability_root: dirs.capabilities, dependency_path: dirs.deps, env_names: ["ANTHROPIC_AUTH_TOKEN"], network_access: "enabled", mcp: declarations }; + const config = { home: dirs.home, state: dirs.state, scratch: dirs.scratch, + capability_root: dirs.capabilities, env_names: ["ANTHROPIC_AUTH_TOKEN"], network_access: "enabled", mcp: declarations }; const previous = process.env; process.env = { HOME: dirs.home, CLAUDE_CONFIG_DIR: dirs.state, ANTHROPIC_AUTH_TOKEN: "model-secret" }; t.after(() => { process.env = previous; rmSync(root, { recursive: true, force: true }); }); @@ -35,13 +35,13 @@ test("installed MCP private projection cannot launch arbitrary unsandboxed comma output_format: { type: "json_schema", schema: { type: "object" } } })), /invalid_request/); assert.equal(immediateInput(request), undefined); assert.deepEqual(parseEnvironmentMCP([stdio]), [stdio]); - for (const value of [[stdio, stdio], [{ ...stdio, command: "/bin/sh" }], [{ ...stdio, env: { TOKEN: "secret" } }], + for (const value of [[stdio, stdio], [{ ...stdio, command: "relative" }], [{ ...stdio, env: { TOKEN: "secret" } }], [{ ...stdio, args: ["-c", "untrusted"] }], [{ ...stdio, allowed_tools: ["*"] }], - [{ ...stdio, server_url: "https://example.invalid" }], [{ ...stdio, args: [...stdio.args.slice(0, 6), "../escape", "installed"] }]]) { + [{ ...stdio, server_url: "https://example.invalid" }], [{ ...stdio, args: [...stdio.args.slice(0, 2), "../escape", "installed"] }]]) { assert.throws(() => parseEnvironmentMCP(value), /invalid_request/); } - for (const index of [4, 5]) { - for (const invalid of ["/", "relative", "/tmp/../escape", "/tmp/root/", "/tmp/line\n"]) { + for (const index of [1]) { + for (const invalid of ["/", "relative", "/tmp/../escape", "/tmp/line\n"]) { const args = [...stdio.args]; args[index] = invalid; assert.throws(() => parseEnvironmentMCP([{ ...stdio, args }]), /invalid_request/); } @@ -49,7 +49,7 @@ test("installed MCP private projection cannot launch arbitrary unsandboxed comma assert.throws(() => parseStart(JSON.stringify({ ...request, workspace: { ...request.workspace, network_access: "disabled" } })), /invalid_request/); }); -test("combined inventory admits exact MCP identities without granting local file authority", async t => { +test("combined inventory admits exact MCP identities with host file authority", async t => { const { dirs, config } = fixture(t); const mcp = new MCPProfile([stdio], []); const workspace = new WorkspaceProfile(dirs.work, config, [], mcp); @@ -64,7 +64,7 @@ test("combined inventory admits exact MCP identities without granting local file assert.deepEqual(mcp.identities.get(native), { server: "installed", name: "echo.v1" }); assert.equal((await workspace.canUseTool(native, {}, { signal })).behavior, "allow"); assert.equal((await workspace.canUseTool("mcp__installed__undeclared", {}, { signal })).behavior, "deny"); - assert.equal((await workspace.canUseTool("Read", { file_path: join(dirs.secrets, "model.key") }, { signal })).behavior, "deny"); + assert.equal((await workspace.canUseTool("Read", { file_path: join(dirs.secrets, "model.key") }, { signal })).behavior, "allow"); assert.equal((await workspace.canUseTool("Bash", { command: "pwd", dangerouslyDisableSandbox: true }, { signal })).behavior, "deny"); assert.equal((await workspace.beforeTool({ ...input, tool_name: "Read", tool_input: { file_path: "ok.txt" } }, "call", { signal })).hookSpecificOutput.updatedInput.file_path, join(dirs.work, "ok.txt")); for (const fields of [{ session_id: "other" }, { agent_id: "child" }, { tool_name: "mcp__ambient__echo" }]) { @@ -72,8 +72,7 @@ test("combined inventory admits exact MCP identities without granting local file } assert.deepEqual(workspace.options.allowedTools, ["mcp__installed__*"]); assert.deepEqual(workspace.options.tools, baseline); - assert.ok(workspace.options.sandbox.filesystem.denyWrite.includes(dirs.capabilities)); - assert.equal(workspace.options.sandbox.allowUnsandboxedCommands, false); + assert.deepEqual(workspace.options.sandbox, {enabled:false}); mcp.close(); assert.equal((await workspace.canUseTool(native, {}, { signal })).behavior, "deny"); }); @@ -92,7 +91,7 @@ test("combined inventory rejects extra servers, tools and normalized identity co } }); -test("workspace bearer references reach native HTTP and stay denied to Bash", t => { +test("workspace bearer references reach native HTTP without leaking values into the request", t => { const reference = "OAC_RUNTIME_MCP_BEARER_ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const http = { server_label: "remote", server_url: "https://example.invalid/mcp", allowed_tools: null, bearer_token_env_var: reference }; const { dirs, config } = fixture(t, [http]); @@ -101,23 +100,23 @@ test("workspace bearer references reach native HTTP and stay denied to Bash", t const mcp = new MCPProfile([http], []); const workspace = new WorkspaceProfile(dirs.work, config, [], mcp); assert.equal(workspace.options.env[reference], "selected-user-token"); - assert.equal(workspace.options.env.OAC_RUNTIME_MCP_BEARER_UNSELECTED, undefined); + assert.equal(workspace.options.env.OAC_RUNTIME_MCP_BEARER_UNSELECTED, "other-token"); assert.deepEqual(mcp.servers.remote.headers, { Authorization: `Bearer \${${reference}}` }); - assert.ok(workspace.options.sandbox.credentials.envVars.some(entry => entry.name === reference && entry.mode === "deny")); assert.equal(JSON.stringify(mcp.servers).includes("selected-user-token"), false); delete process.env[reference]; assert.throws(() => new WorkspaceProfile(dirs.work, config, [], mcp), /invalid_request/); }); -test("MCP identity validation preserves host functions and Bash environment wrapping", async t => { +test("MCP identity validation preserves host functions and ordinary child environment", async t => { const { dirs, config } = fixture(t); const functions = ["mcp__functions__lookup"]; const mcp = new MCPProfile([stdio], functions); - const workspace = new WorkspaceProfile(dirs.work, { ...config, tool_environment: true }, functions, mcp); + const workspace = new WorkspaceProfile(dirs.work, { ...config, tool_env: { USER_VALUE: "initialized" } }, functions, mcp); workspace.verify([...baseline, native, ...functions], [...statuses, { name: "functions", status: "connected" }], "session"); const signal = new AbortController().signal; const input = { hook_event_name: "PreToolUse", session_id: "session", tool_use_id: "call", tool_name: "Bash", tool_input: { command: "printf ok" } }; - assert.match((await workspace.beforeTool(input, "call", { signal })).hookSpecificOutput.updatedInput.command, /^\. \/environment\/initialization\/tool-env.sh/); + assert.deepEqual(await workspace.beforeTool(input, "call", { signal }), {}); + assert.equal(workspace.options.env.USER_VALUE, "initialized"); assert.equal((await workspace.canUseTool(functions[0], {}, { signal })).behavior, "allow"); mcp.close(); }); diff --git a/packages/claude-sdk-adapter/tests/preparation.test.mjs b/packages/claude-sdk-adapter/tests/preparation.test.mjs index 1ae8bb3f1..8b7a4a590 100644 --- a/packages/claude-sdk-adapter/tests/preparation.test.mjs +++ b/packages/claude-sdk-adapter/tests/preparation.test.mjs @@ -20,10 +20,10 @@ registerHooks({ resolve(specifier, context, next) { globalThis.startupFixture = async ({options, initializeTimeoutMs}) => { assert.equal(initializeTimeoutMs,15000); assert.equal(options.model,"fixed-model"); - assert.equal(options.env.UNSELECTED_CANARY,undefined); + assert.equal(options.env.UNSELECTED_CANARY,"must-not-inherit"); assert.equal(options.env.HOME,process.env.HOME); assert.deepEqual(options.tools,["Bash","Read","Edit"]); - assert.equal(options.sandbox.failIfUnavailable,true); + assert.equal(options.sandbox.enabled,false); assert.equal(options.hooks.PreToolUse.length,1); const child = options.spawnClaudeCodeProcess({command:process.execPath,env:options.env,signal:options.abortController.signal, args:["-e","process.stdin.resume();process.stdin.on('end',()=>process.exit(0));"]}); @@ -88,7 +88,7 @@ function placement() { const path = join(root, name); mkdirSync(path); return [name, path]; })); return { root, request: { type: "prepare", model: "fixed-model", system_prompt: "", cwd: dirs.workspace, - workspace: { home: dirs.home, state: dirs.state, scratch: dirs.scratch, dependency_path: dirs.deps, protected_dirs: [], env_names: [] } } }; + workspace: { home: dirs.home, state: dirs.state, scratch: dirs.scratch, env_names: [] } } }; } test("prepare validates a workspace-only immutable configuration and prompt-only Start", () => { diff --git a/packages/claude-sdk-adapter/tests/workspace.test.mjs b/packages/claude-sdk-adapter/tests/workspace.test.mjs index 745b8c3e3..36f51f77a 100644 --- a/packages/claude-sdk-adapter/tests/workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace.test.mjs @@ -13,8 +13,8 @@ function fixture(t) { mkdirSync(path); return [name, path]; })); - const config = { home: dirs.home, state: dirs.state, scratch: dirs.scratch, protected_dirs: [dirs.protected], - dependency_path: dirs.deps, env_names: ["ANTHROPIC_API_KEY", "HTTP_PROXY"] }; + const config = { home: dirs.home, state: dirs.state, scratch: dirs.scratch, + env_names: ["ANTHROPIC_API_KEY", "HTTP_PROXY"] }; const request = { type: "start", input: [{ content: [{ type: "input_text", text: "fixture" }] }], model: "fixture", system_prompt: "", cwd: dirs.workspace, workspace: config }; const previous = process.env; process.env = { HOME: dirs.home, CLAUDE_CONFIG_DIR: dirs.state, ANTHROPIC_API_KEY: "fixture-secret", @@ -42,45 +42,21 @@ test("workspace is explicit, typed and rejects external MCP declarations", t => { ...ordinary, functions: [], mcp_http_servers: [] }); }); -test("workspace roots are existing canonical directories with no overlap or rule syntax", t => { - const { root, dirs, config } = fixture(t); - const alias = join(root, "alias"); - symlinkSync(dirs.home, alias); - const child = join(dirs.workspace, "child"); - mkdirSync(child); - const file = join(root, "file"); - writeFileSync(file, "fixture"); - for (const home of ["relative", "/", dirs.home + "/", dirs.home + "/../home", join(root, "missing"), - dirs.workspace, child, root, alias, file, dirs.home + "*", dirs.home + "\n"]) { - assert.throws(() => parseWorkspace({ ...config, home }, dirs.workspace), /invalid_request/); - } - assert.throws(() => parseWorkspace({ ...config, protected_dirs: [dirs.protected, dirs.protected] }, dirs.workspace), /invalid_request/); - for (const path of ["", ":" + dirs.deps, dirs.deps + ":", "relative", dirs.home, child, root]) { - assert.throws(() => parseWorkspace({ ...config, dependency_path: path }, dirs.workspace), /invalid_request/); - } - const depsAlias = join(root, "deps-alias"); - symlinkSync(dirs.deps, depsAlias); - assert.equal(parseWorkspace({ ...config, dependency_path: depsAlias }, dirs.workspace).dependency_path, dirs.deps); - assert.equal(new WorkspaceProfile(dirs.workspace, { ...config, dependency_path: depsAlias }).options.env.PATH, dirs.deps); - for (const mutable of [dirs.workspace, dirs.scratch]) { - const unsafeAlias = join(mutable, "deps-alias"); - symlinkSync(dirs.deps, unsafeAlias); - assert.throws(() => new WorkspaceProfile(dirs.workspace, { ...config, dependency_path: unsafeAlias }), /invalid_request/); - } - assert.throws(() => parseWorkspace({ ...config, dependency_path: alias }, dirs.workspace), /invalid_request/); +test("workspace paths use ordinary host directories", t => { + const {root,dirs,config}=fixture(t); + for(const home of ["relative",join(root,"missing")]) assert.throws(()=>parseWorkspace({...config,home},dirs.workspace),/invalid_request/); + const alias=join(root,"alias");symlinkSync(dirs.home,alias); + assert.equal(parseWorkspace({...config,home:alias},dirs.workspace).home,alias); + assert.equal(parseWorkspace({...config,scratch:dirs.home},dirs.workspace).scratch,dirs.home); }); test("workspace environment copies only selected refs and fixed values", t => { const { dirs, config } = fixture(t); const options = new WorkspaceProfile(dirs.workspace, config).options; - assert.deepEqual(options.env, { - PATH: dirs.deps, HOME: dirs.home, TMPDIR: dirs.scratch, CLAUDE_CONFIG_DIR: dirs.state, - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", DISABLE_TELEMETRY: "1", DISABLE_ERROR_REPORTING: "1", - DISABLE_AUTOUPDATER: "1", CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1", - ANTHROPIC_API_KEY: "fixture-secret", HTTP_PROXY: "http://fixture-proxy", - }); - assert.ok(options.sandbox.credentials.envVars.some(entry => entry.name === "ANTHROPIC_AUTH_TOKEN" && entry.mode === "deny")); - assert.ok(options.sandbox.credentials.envVars.some(entry => entry.name === "HTTP_PROXY" && entry.mode === "deny")); + assert.equal(options.env.UNSELECTED_CANARY,"must-not-inherit"); + assert.equal(options.env.HOME,dirs.home); + assert.equal(options.env.ANTHROPIC_API_KEY,"fixture-secret"); + delete process.env.ANTHROPIC_API_KEY; assert.throws(() => new WorkspaceProfile(dirs.workspace, config), /invalid_request/); process.env.ANTHROPIC_API_KEY = "fixture-secret"; @@ -103,24 +79,12 @@ test("workspace native options keep the strict sandbox and exact native inventor assert.deepEqual(options.settingSources, []); assert.deepEqual(options.mcpServers, {}); assert.equal(options.strictMcpConfig, true); - assert.equal(options.permissionMode, "default"); + assert.equal(options.permissionMode, "bypassPermissions"); assert.equal(options.persistSession, true); - assert.equal(options.sandbox.enabled, true); - assert.equal(options.sandbox.failIfUnavailable, true); - for (const key of ["autoAllowBashIfSandboxed", "allowUnsandboxedCommands", "enableWeakerNestedSandbox", "enableWeakerNetworkIsolation"]) { - assert.equal(options.sandbox[key], false); - } - assert.deepEqual(options.sandbox.excludedCommands, []); - assert.deepEqual(options.sandbox.filesystem, { disabled: false, allowWrite: [dirs.workspace, dirs.scratch], - denyRead: [dirs.home, dirs.state, dirs.protected], denyWrite: [dirs.home, dirs.state, dirs.protected], allowRead: [] }); - assert.deepEqual(options.sandbox.network, - { allowedDomains: [], strictAllowlist: true, allowAllUnixSockets: false, allowLocalBinding: false }); - assert.equal(options.settings.permissions.blockReadsOutsideWorkingDirectories, true); - assert.equal(options.settings.permissions.disableBypassPermissionsMode, "disable"); - for (const path of [dirs.home, dirs.state, dirs.protected, "/proc", "/sys"]) { - assert.ok(options.settings.permissions.deny.includes(`Read(/${path}/**)`)); - assert.ok(options.settings.permissions.deny.includes(`Edit(/${path}/**)`)); - } + assert.equal(options.sandbox.enabled, false); + assert.deepEqual(options.sandbox, { enabled: false }); + assert.equal(options.allowDangerouslySkipPermissions, true); + assert.deepEqual(options.settings, {}); profile.verify(["Read", "Edit", "Bash"], []); for (const tools of [[], ["Bash", "Read", "Read"], ["Bash", "Read", "Write"], ["Bash", "Read", "Edit", "Agent"]]) { assert.throws(() => profile.verify(tools, []), /unexpected native workspace inventory/); @@ -178,9 +142,7 @@ test("workspace permissions and pre-tool hook reject outside paths and unsafe Ba } for (const [name, input] of [["Bash", { command: "true", run_in_background: true }], ["Bash", { command: "true", dangerouslyDisableSandbox: true }], ["Bash", { command: "true", run_in_background: "false" }], - ["Write", { file_path: "file.txt" }], ["Read", { file_path: "../protected/value" }], - ["Read", { file_path: join(dirs.home, "credentials") }], ["Edit", { file_path: "escape/new" }], - ["Read", { file_path: "dangling" }], ["Edit", { file_path: "dangling/new" }]]) { + ["Write", { file_path: "file.txt" }]]) { assert.equal((await profile.canUseTool(name, input, options)).behavior, "deny"); assert.equal((await hook(name, input)).hookSpecificOutput.permissionDecision, "deny"); } @@ -188,29 +150,10 @@ test("workspace permissions and pre-tool hook reject outside paths and unsafe Ba 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 restricted = { ...config, network_access: "restricted", allowed_domains: ["example.com", "api.example.com"] }; - const parsed = parseStart(JSON.stringify({ ...request, workspace: restricted })); - assert.deepEqual(parsed.workspace.allowed_domains, restricted.allowed_domains); - const network = new WorkspaceProfile(dirs.workspace, restricted).options.sandbox.network; - assert.deepEqual(network, { allowedDomains: restricted.allowed_domains, strictAllowlist: true, allowAllUnixSockets: false, allowLocalBinding: false }); - for (const allowed_domains of [["*"], ["*.example.com"], ["example.com:443"], ["127.0.0.1"], ["example.com\n"], []]) { - assert.throws(() => parseStart(JSON.stringify({ ...request, workspace: { ...restricted, allowed_domains } })), /invalid_request/); - } - assert.throws(() => parseStart(JSON.stringify({ ...request, workspace: { ...restricted, network_access: "enabled" } })), /invalid_request/); - const { workspace, ...none } = request; - assert.throws(() => parseStart(JSON.stringify({ ...none, require_history: true })), /invalid_request/); +test("Runtime rejects network isolation requests", t => { + const {dirs,config}=fixture(t); + assert.deepEqual(new WorkspaceProfile(dirs.workspace,{...config,network_access:"enabled"}).options.sandbox,{enabled:false}); + for(const network_access of ["disabled","restricted"]) assert.throws(()=>parseWorkspace({...config,network_access,allowed_domains:network_access==="restricted"?["example.com"]:[]},dirs.workspace),/invalid_request/); }); test("workspace functions retain native sandbox and exact tool authority", async t => { @@ -233,37 +176,24 @@ test("workspace functions retain native sandbox and exact tool authority", async assert.deepEqual(await profile.beforeTool(event, "call", options), {}); assert.equal((await profile.beforeTool({ ...event, tool_name: "mcp__external__lookup" }, "call", options)).hookSpecificOutput.permissionDecision, "deny"); assert.equal((await profile.canUseTool("Bash", { command: "true", dangerouslyDisableSandbox: true }, options)).behavior, "deny"); - assert.equal((await profile.canUseTool("Read", { file_path: dirs.state + "/history" }, options)).behavior, "deny"); - assert.equal(profile.options.sandbox.failIfUnavailable, true); - assert.equal(profile.options.sandbox.allowUnsandboxedCommands, false); + assert.equal((await profile.canUseTool("Read", { file_path: dirs.state + "/history" }, options)).behavior, "allow"); controller.abort(); assert.equal((await profile.canUseTool("mcp__functions__lookup", input, options)).behavior, "deny"); }); -test("initialized user env is applied inside native Bash, never SDK spawn env", async t => { - const { dirs, config } = fixture(t); - const profile = new WorkspaceProfile(dirs.workspace, { ...config, tool_environment: true }); - assert.equal(profile.options.env.PYTHONPATH, undefined); - assert.equal(profile.options.env.PATH, dirs.deps); - assert.ok(profile.options.sandbox.filesystem.allowWrite.includes("/environment/packages")); - const input = { hook_event_name: "PreToolUse", tool_name: "Bash", tool_use_id: "tool", - tool_input: { command: "printf '%s' 'quoted value'", timeout: 1000 } }; - const result = await profile.beforeTool(input, "tool", { signal: new AbortController().signal }); - assert.equal(result.hookSpecificOutput.updatedInput.timeout, 1000); - assert.equal(result.hookSpecificOutput.updatedInput.command, - ". /environment/initialization/tool-env.sh && eval -- 'printf '\\''%s'\\'' '\\''quoted value'\\'''" ); - const denied = await profile.beforeTool({ ...input, tool_input: { command: "id", dangerouslyDisableSandbox: true } }, "tool", { signal: new AbortController().signal }); - assert.equal(denied.hookSpecificOutput.permissionDecision, "deny"); +test("initialized environment is ordinary child environment on every layout", t => { + const {dirs,config}=fixture(t); + for(const execution_mode of [undefined,"native"]) { + const profile=new WorkspaceProfile(dirs.workspace,{...config,execution_mode,tool_env:{USER_VALUE:"initialized"}}); + assert.equal(profile.options.env.USER_VALUE,"initialized"); + assert.deepEqual(profile.options.sandbox,{enabled:false}); + } }); -test("installed system tools retain the native prefix boundary and existing scratch", t => { - const { dirs, config } = fixture(t); - assert.throws(() => new WorkspaceProfile(dirs.workspace, { ...config, system_packages: true }), /invalid_request/); - const profile = new WorkspaceProfile(dirs.workspace, { ...config, tool_environment: true, system_packages: true }); - assert.equal(profile.options.env.CLAUDE_CODE_SHELL_PREFIX, "/usr/local/bin/oac-claude-shell-prefix"); - assert.equal(profile.options.env.OAC_RUNTIME_TOOL_SCRATCH, dirs.scratch); - assert.equal(profile.options.env.TMPDIR, dirs.scratch); - assert.ok(profile.options.sandbox.filesystem.denyWrite.includes("/environment/packages/system")); - assert.equal(profile.options.env.PYTHONPATH, undefined); - assert.equal(profile.options.sandbox.failIfUnavailable, true); +test("host tools can access paths outside the workspace", async t => { + const {dirs,config}=fixture(t); + const profile=new WorkspaceProfile(dirs.workspace,config); + for(const file_path of ["../protected/value",join(dirs.home,"credentials")]) { + assert.equal((await profile.canUseTool("Read",{file_path},{signal:new AbortController().signal})).behavior,"allow"); + } }); diff --git a/packages/claude-sdk-adapter/tests/workspace_directories.test.mjs b/packages/claude-sdk-adapter/tests/workspace_directories.test.mjs index 87781e616..d9c3ae1bb 100644 --- a/packages/claude-sdk-adapter/tests/workspace_directories.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace_directories.test.mjs @@ -24,7 +24,7 @@ async function fixture(t) { } }; } -test("bounded literal metadata, missing directory, and traversal denial", { skip: process.platform !== "linux" }, async t => { +test("bounded literal metadata, missing directory, and traversal denial", async t => { const f = await fixture(t); await mkdir(join(f.workspace, "nested")); await writeFile(join(f.workspace, "binary"), Buffer.from([0, 255, 0])); @@ -41,51 +41,12 @@ test("bounded literal metadata, missing directory, and traversal denial", { skip assert.deepEqual((await f.list("nested")).entries, []); assert.equal((await f.list("", 1)).truncated, true); assert.equal((await f.list("absent")).error, "not_found"); - for (const path of ["outside", "outside/protected", "inside"]) assert.ok(["invalid", "permission"].includes((await f.list(path)).error)); + assert.deepEqual((await f.list("inside")).entries, []); for (const path of ["/", "a//b", ".", "..", "a/../b", "a\\b", "a\n"]) assert.equal((await f.list(path)).error, "invalid"); for (const limit of [0, -1, 1001, 1.1]) assert.equal((await f.list("", limit)).error, "invalid"); }); -test("frozen root survives replacement and closes descriptors", { skip: process.platform !== "linux" }, async t => { - const baseline = (await readdir("/proc/self/fd")).length; - const f = await fixture(t); - await mkdir(join(f.workspace, "nested")); - await writeFile(join(f.workspace, "nested", "owned"), "ok"); - await rename(f.workspace, join(f.root, "original")); - await mkdir(f.workspace); - await writeFile(join(f.workspace, "wrong"), "no"); - for (let i = 0; i < 20; i++) assert.deepEqual((await f.list("nested")).entries, [{ name: "owned", kind: "file", size_bytes: 2 }]); - await f.directories.close(); - assert.equal((await f.list()).error, "unavailable"); - assert.equal((await readdir("/proc/self/fd")).length, baseline); -}); - -test("concurrent parent symlink swaps never enumerate the outside target", { skip: process.platform !== "linux" }, async t => { - const f = await fixture(t); - const nested = join(f.workspace, "nested"), parked = join(f.workspace, "parked"); - await mkdir(nested); - await writeFile(join(nested, "owned"), "ok"); - await mkdir(join(f.root, "protected")); - await writeFile(join(f.root, "protected", "secret"), "outside"); - let stop = false; - const swapping = (async () => { - while (!stop) { - await rename(nested, parked); - await symlink(join(f.root, "protected"), nested); - await rm(nested); - await rename(parked, nested); - } - })(); - try { - for (let i = 0; i < 100; i++) { - const result = await f.list("nested"); - if (result.error) assert.ok(["not_found", "permission", "invalid"].includes(result.error)); - else assert.deepEqual(result.entries, [{ name: "owned", kind: "file", size_bytes: 2 }]); - } - } finally { stop = true; await swapping; } -}); - -test("literal Unicode names are preserved and undecodable names fail explicitly", { skip: process.platform !== "linux" }, async t => { +test("literal Unicode names are preserved and undecodable names fail explicitly", {skip:process.platform === "win32"}, async t => { const f = await fixture(t); await writeFile(join(f.workspace, "字\ufffd"), "ok"); assert.deepEqual((await f.list()).entries, [{ name: "字\ufffd", kind: "file", size_bytes: 2 }]); diff --git a/packages/claude-sdk-adapter/tests/workspace_execution.test.mjs b/packages/claude-sdk-adapter/tests/workspace_execution.test.mjs index 24a16b872..50ff156d8 100644 --- a/packages/claude-sdk-adapter/tests/workspace_execution.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace_execution.test.mjs @@ -20,7 +20,7 @@ const dirs = Object.fromEntries(["workspace", "home", "state", "scratch", "deps" })); const mode = process.argv[1]; const workspace = { home: dirs.home, state: dirs.state, scratch: dirs.scratch, - protected_dirs: [], dependency_path: dirs.deps, env_names: ["ANTHROPIC_API_KEY"] }; + env_names: ["ANTHROPIC_API_KEY"] }; const request = { type: "start", input: [{ content: [{ type: "input_text", text: "fixture" }] }], model: "fixture", system_prompt: "", cwd: dirs.workspace, workspace, ...(mode.startsWith("resume") ? { resume: "native" } : {}) }; process.env.HOME = dirs.home; @@ -40,14 +40,14 @@ globalThis.historyFixture = async (id, options) => { }; globalThis.queryFixture = ({prompt, options}) => { calls.push("query"); - assert.equal(options.env.UNSELECTED_CANARY, undefined); + assert.equal(options.env.UNSELECTED_CANARY, "must-not-inherit"); assert.equal(options.env.ANTHROPIC_API_KEY, "fixture-secret"); assert.equal(options.env.HOME, dirs.home); assert.deepEqual(options.tools, ["Bash", "Read", "Edit"]); - assert.equal(options.sandbox.failIfUnavailable, true); + assert.equal(options.sandbox.enabled, false); assert.equal(options.resume, request.resume); const child = options.spawnClaudeCodeProcess({ command: process.execPath, env: options.env, signal: abort.signal, - args: ["-e", "const assert=require('node:assert/strict');assert.equal(process.env.UNSELECTED_CANARY,undefined);assert.equal(process.env.ANTHROPIC_API_KEY,'fixture-secret');process.stdin.resume();process.stdin.on('end',()=>process.exit(0));"] }); + args: ["-e", "const assert=require('node:assert/strict');assert.equal(process.env.UNSELECTED_CANARY,'must-not-inherit');assert.equal(process.env.ANTHROPIC_API_KEY,'fixture-secret');process.stdin.resume();process.stdin.on('end',()=>process.exit(0));"] }); return { close() { child.stdin.end(); }, async *[Symbol.asyncIterator]() { const first = (await prompt[Symbol.asyncIterator]().next()).value; yield { type: "system", subtype: "init", session_id: "native", mcp_servers: mode === "extra-mcp" ? [{name:"other",status:"connected"}] : [], diff --git a/packages/mcode-harness/bridge.mjs b/packages/mcode-harness/bridge.mjs index f843c1fb0..89b9bd3b9 100644 --- a/packages/mcode-harness/bridge.mjs +++ b/packages/mcode-harness/bridge.mjs @@ -7,13 +7,14 @@ import { ToolExecutor } from './tool-executor.mjs'; const profile = process.argv[2]; if (!profile || !isAbsolute(profile)) throw new Error('Private workspace profile is required'); +const workspace = JSON.parse(readFileSync(profile, 'utf8')).workspace; const definitions = JSON.parse(readFileSync(new URL('./dist/tools.json', import.meta.url), 'utf8')); const tools = new Map(definitions.map(tool => ['workspace_' + tool.name, tool])); const executor = new ToolExecutor(profile); const server = new Server({ name: 'oac-workspace', version: '1' }, { capabilities: { tools: {} } }); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...tools].map(([name, tool]) => ({ ...tool, name, - description: 'Bound working directory: /workspace. ' + tool.description })), + description: 'Bound working directory: ' + workspace + '. ' + tool.description })), })); server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { const tool = tools.get(request.params.name); diff --git a/packages/mcode-harness/build-sandbox.mjs b/packages/mcode-harness/build-sandbox.mjs deleted file mode 100644 index 25deca882..000000000 --- a/packages/mcode-harness/build-sandbox.mjs +++ /dev/null @@ -1,18 +0,0 @@ -import { build } from 'esbuild'; -import { mkdirSync, cpSync, writeFileSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -const here = dirname(fileURLToPath(import.meta.url)); -const source = process.env.MCODE_SOURCE; -if (!source) throw new Error('MCODE_SOURCE is required'); -mkdirSync(resolve(here, 'dist'), { recursive: true }); -const result = await build({ entryPoints: [resolve(here, 'sandbox-entry.ts')], - outfile: resolve(here, 'dist/sandbox.mjs'), bundle: true, platform: 'node', format: 'esm', - target: 'node22', nodePaths: [resolve(here, 'node_modules')], metafile: true, - plugins: [{ name: 'sandbox-source', setup(b) { b.onResolve({ filter: /^@sandbox\// }, () => - ({ path: resolve(source, 'third_party/sandbox-runtime/src/sandbox/sandbox-manager.ts') })); } }], - banner: { js: 'import { createRequire as srtCreateRequire } from "node:module"; const require = srtCreateRequire(import.meta.url);' }, - logLevel: 'info' }); -cpSync(resolve(source, 'third_party/sandbox-runtime/vendor/seccomp'), resolve(here, 'dist/vendor/seccomp'), { recursive: true }); -cpSync(resolve(source, 'third_party/sandbox-runtime/vendor/java-proxy-agent'), resolve(here, 'dist/vendor/java-proxy-agent'), { recursive: true }); -writeFileSync(resolve(here, 'dist/sandbox-metafile.json'), JSON.stringify(result.metafile, null, 2) + '\n'); diff --git a/packages/mcode-harness/check.mjs b/packages/mcode-harness/check.mjs index ea2d489a4..fb6fede76 100644 --- a/packages/mcode-harness/check.mjs +++ b/packages/mcode-harness/check.mjs @@ -3,16 +3,16 @@ import { accessSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; -if (process.platform !== 'linux' || process.arch !== 'x64') throw new Error('Linux x86_64 is required'); +if (!['linux', 'darwin'].includes(process.platform)) throw new Error('MiniMax native execution requires Linux or macOS'); for (const file of ['bridge.mjs', 'launch.mjs', 'tool-executor.mjs', 'dist/worker.mjs', - 'dist/sandbox.mjs', 'dist/vendor/seccomp/x64/apply-seccomp', 'subagent-snapshot.mjs', + 'subagent-snapshot.mjs', 'native/cli.js', 'native-patch.json']) accessSync(new URL(file, import.meta.url)); -for (const command of ['bwrap', 'socat', 'rg']) execFileSync('which', [command], { stdio: 'ignore' }); +for (const command of ['bash', 'rg']) execFileSync('which', [command], { stdio: 'ignore' }); const pin = JSON.parse(readFileSync(new URL('source.json', import.meta.url))); const patch = JSON.parse(readFileSync(new URL('native-patch.json', import.meta.url))); if (patch.revision !== pin.revision || !/^[a-f0-9]{64}$/.test(patch.files?.['patch-native.mjs'] ?? '') || !/^[a-f0-9]{64}$/.test(patch.files?.['native-subagent-admission.mjs'] ?? '')) throw new Error('Native admission patch provenance mismatch'); const tools = JSON.parse(execFileSync(process.execPath, - [fileURLToPath(new URL('dist/worker.mjs', import.meta.url)), '/workspace', '--describe'], { maxBuffer: 1024 * 1024 })); + [fileURLToPath(new URL('dist/worker.mjs', import.meta.url)), process.cwd(), '--describe'], { maxBuffer: 1024 * 1024 })); if (tools.map(t => t.name).sort().join(',') !== 'bash,edit,glob,grep,read,write') throw new Error('Native tool inventory mismatch'); process.stdout.write(JSON.stringify({ protocol: 1, native: pin.version, source: pin.revision }) + '\n'); diff --git a/packages/mcode-harness/launch.mjs b/packages/mcode-harness/launch.mjs index 81fb52b44..d9ad7defa 100644 --- a/packages/mcode-harness/launch.mjs +++ b/packages/mcode-harness/launch.mjs @@ -1,4 +1,3 @@ -import { SandboxManager } from './dist/sandbox.mjs'; import { spawn } from 'node:child_process'; import { readFileSync, mkdirSync } from 'node:fs'; import { isIP } from 'node:net'; @@ -6,18 +5,18 @@ import { dirname, join, isAbsolute, normalize } from 'node:path'; import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const profile = JSON.parse(readFileSync(process.argv[2], 'utf8')); -if (profile.workspace !== process.argv[3] || profile.workspace !== '/workspace') +if (!isAbsolute(profile.workspace) || normalize(profile.workspace) !== profile.workspace || (process.argv[3] && profile.workspace !== process.argv[3])) throw new Error('Workspace profile does not match execution binding'); -if (!['disabled','enabled','restricted'].includes(profile.network)) throw new Error('Invalid network policy'); +if (profile.network !== 'enabled') throw new Error('Invalid network policy'); const domains=profile.allowedDomains??[]; const hostname=/^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i; if (!Array.isArray(domains) || (profile.network==='restricted' ? domains.length<1 || domains.length>100 || domains.some(host=>typeof host!=='string'||!hostname.test(host)||host.trim()!==host||isIP(host)!==0) : domains.length!==0)) throw new Error('Invalid network domains'); -const baseEnv = {PATH:'/usr/local/bin:/usr/bin:/bin',HOME:profile.scratch,TMPDIR:profile.scratch,LANG:'C.UTF-8'}; -if (profile.systemPackages) { - if (!profile.toolEnvironment) throw new Error('System packages require initialized tool configuration'); - baseEnv.OAC_RUNTIME_TOOL_SCRATCH=profile.scratch; +const baseEnv = {...process.env}; +if (profile.toolEnv !== undefined) { + if (!profile.toolEnv || typeof profile.toolEnv !== 'object' || Array.isArray(profile.toolEnv) || Object.values(profile.toolEnv).some(value=>typeof value!=='string')) throw new Error('Invalid tool environment'); + Object.assign(baseEnv,profile.toolEnv); } if (profile.capabilityRoot && (typeof profile.capabilityRoot !== 'string' || !isAbsolute(profile.capabilityRoot) || normalize(profile.capabilityRoot) !== profile.capabilityRoot || profile.capabilityRoot === '/' || /[\\\x00-\x1f\x7f]/.test(profile.capabilityRoot))) throw new Error('Invalid capability root'); if (profile.skills && !profile.capabilityRoot) throw new Error('Missing capability root'); @@ -27,19 +26,10 @@ const cancel=()=>{cancelled=true;child?.kill('SIGKILL');}; process.on('SIGTERM',cancel);process.on('SIGINT',cancel); try { mkdirSync(profile.scratch,{recursive:true}); - await SandboxManager.initialize({ - network:{allowedDomains:domains,deniedDomains:profile.network==='disabled'?['*']:[],allowAll:profile.network==='enabled',strictAllowlist:true,allowAllUnixSockets:false,allowLocalBinding:false}, - filesystem:{denyRead:profile.protectedDirs,allowRead:profile.capabilityRoot?[profile.capabilityRoot]:[],allowWrite:[profile.workspace,profile.scratch,...(profile.toolEnvironment ? ['/environment/packages'] : [])],denyWrite:[...(profile.capabilityRoot ? [profile.capabilityRoot] : []),...(profile.systemPackages ? ['/environment/packages/system'] : [])]}, - seccomp:{applyPath:join(here,'dist/vendor/seccomp/x64/apply-seccomp')}, - },undefined,false); - const quote=s=>"'"+s.replaceAll("'","'\\''")+"'"; - const command=[process.execPath,join(here,'dist/worker.mjs'),profile.workspace,...(profile.toolEnvironment ? ['--tool-environment'] : []),...(profile.systemPackages ? ['--system-packages'] : [])].map(quote).join(' '); - const wrapped=await SandboxManager.wrapWithSandbox(command,'/bin/bash',undefined,undefined,{baseEnv,sandboxTempDir:profile.scratch}); if (cancelled) throw new Error('Cancelled before workspace tool start'); - child=spawn('/bin/bash',['-c','exec '+wrapped],{cwd:profile.workspace,env:baseEnv,stdio:['pipe','pipe','pipe']}); + child=spawn(process.execPath,[join(here,'dist/worker.mjs'),profile.workspace],{cwd:profile.workspace,env:baseEnv,stdio:['pipe','pipe','pipe']}); process.stdin.pipe(child.stdin);child.stdout.pipe(process.stdout);child.stderr.pipe(process.stderr); child.stdin.on('error',()=>cancel()); const status=await new Promise((resolve,reject)=>{child.on('error',reject);child.on('close',resolve);}); process.exitCode=cancelled?1:(status??1); } catch(error) {process.stderr.write(String(error)+'\n');process.exitCode=1;} -finally {await SandboxManager.reset();} diff --git a/packages/mcode-harness/launch.test.mjs b/packages/mcode-harness/launch.test.mjs new file mode 100644 index 000000000..ea930c6f4 --- /dev/null +++ b/packages/mcode-harness/launch.test.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {mkdtemp,mkdir,writeFile,copyFile,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {ToolExecutor} from './tool-executor.mjs'; + +test('Runtime directly executes host worker with bound cwd and initialized environment',async t=>{ + const root=await mkdtemp(join(tmpdir(),'mcode-host-')); + t.after(()=>rm(root,{recursive:true,force:true})); + await mkdir(join(root,'dist')); + const workspace=join(root,'workspace');await mkdir(workspace); + const launcher=join(root,'launch.mjs');await copyFile(new URL('./launch.mjs',import.meta.url),launcher); + await writeFile(join(root,'dist','worker.mjs'),`import {readFileSync} from 'node:fs';const request=JSON.parse(readFileSync(0,'utf8'));console.log(JSON.stringify({tool_name:request.tool,text:JSON.stringify({cwd:process.cwd(),value:process.env.INITIALIZED}),content:[]}));`); + const profile=join(root,'profile.json'); + await writeFile(profile,JSON.stringify({workspace,scratch:join(root,'scratch'),network:'enabled',toolEnv:{INITIALIZED:'ordinary'}})); + const executor=new ToolExecutor(profile,launcher);t.after(()=>executor.close()); + const result=await executor.execute('bash',{}); + assert.deepEqual(JSON.parse(result.text),{cwd:workspace,value:'ordinary'}); +}); diff --git a/packages/mcode-harness/sandbox-entry.ts b/packages/mcode-harness/sandbox-entry.ts deleted file mode 100644 index 75c6242d7..000000000 --- a/packages/mcode-harness/sandbox-entry.ts +++ /dev/null @@ -1 +0,0 @@ -export { SandboxManager } from '@sandbox/sandbox-manager'; diff --git a/packages/mcode-harness/tool-executor.mjs b/packages/mcode-harness/tool-executor.mjs index db0e2e36d..9e02a9109 100644 --- a/packages/mcode-harness/tool-executor.mjs +++ b/packages/mcode-harness/tool-executor.mjs @@ -19,8 +19,8 @@ export class ToolExecutor { signal?.throwIfAborted(); const request = JSON.stringify({ tool, input }); if (Buffer.byteLength(request) > limit) throw new Error('Workspace tool input exceeds limit'); - const child = spawn(process.execPath, [this.entrypoint, this.profile, '/workspace'], { - env: { PATH: '/usr/local/bin:/usr/bin:/bin', HOME: '/tmp', LANG: 'C.UTF-8' }, + const child = spawn(process.execPath, [this.entrypoint, this.profile], { + env: process.env, stdio: ['pipe', 'pipe', 'pipe'], }); const call = { stop: () => child.kill('SIGTERM') }; diff --git a/packages/mcode-harness/worker.ts b/packages/mcode-harness/worker.ts index 29ded576a..612e6418b 100644 --- a/packages/mcode-harness/worker.ts +++ b/packages/mcode-harness/worker.ts @@ -20,22 +20,6 @@ const tool = tools.find(value => value.def.name === request.tool); if (!tool || !request.input || typeof request.input !== 'object' || Array.isArray(request.input) || !isRuntimeToolInputValid(tools, request.tool, request.input)) throw new Error('invalid tool request'); -if (process.argv[3] === '--tool-environment') { - const systemShell = process.argv.includes('--system-packages') && request.tool === 'bash'; - const env = JSON.parse(readFileSync('/environment/initialization/tool-env.json', 'utf8')); - for (const [name, value] of Object.entries(env)) { - if (typeof value !== 'string') throw new Error('invalid initialized tool environment'); - if (!systemShell) process.env[name] = value; - } - // The native Bash boundary strips native identity variables even in mode:off. - // Reapply user values in the already isolated shell without changing tools. - if (request.tool === 'bash') { - const quote = (text: string) => "'" + text.replaceAll("'", "'\\''") + "'"; - request.input.command = systemShell - ? '/usr/bin/python3 -I -S /usr/local/bin/oac-tool-root ' + quote(request.input.command) - : '. /environment/initialization/tool-env.sh && eval -- ' + quote(request.input.command); - } -} const context = { sessionId: 'worker', turnId: 'call', allowBashAutoPromotion: false, canConsumeBackgroundBashOutput: false }; try { diff --git a/scripts/build-agents-runtime.sh b/scripts/build-agents-runtime.sh index b5d66d443..e4899682b 100755 --- a/scripts/build-agents-runtime.sh +++ b/scripts/build-agents-runtime.sh @@ -34,8 +34,6 @@ trap 'rm -rf "$context"' EXIT cp "$helpers_dir/oac-codex-directory" "$helpers_dir/oac-codex-write" "$helpers_dir/oac-workspace-export" "$context/" cp "$native_dir/bin/codex" "$context/codex" cp -R "$native_dir/codex-resources" "$context/codex-resources" -cp "$repo_root/services/agents-api/deploy/codex/tool-env.py" "$context/tool-env.py" -cp "$repo_root/services/agents-api/deploy/codex/requirements.toml" "$context/requirements.toml" cp "$repo_root/services/agents-api/deploy/codex/Dockerfile" "$context/Dockerfile" cp "$repo_root/services/agents-api/deploy/runtime/initialize.py" "$context/runtime-initialize.py" cp "$repo_root/services/agents-api/deploy/runtime/build-system-seed.py" "$repo_root/services/agents-api/deploy/runtime/tool-root.py" "$context/" diff --git a/scripts/build-claude-runtime.sh b/scripts/build-claude-runtime.sh index 4311ccc4b..5f81b5021 100755 --- a/scripts/build-claude-runtime.sh +++ b/scripts/build-claude-runtime.sh @@ -29,7 +29,6 @@ done cp "$repo_root/services/agents-api/deploy/claude/Dockerfile" "$context/Dockerfile" cp "$repo_root/services/agents-api/deploy/runtime/initialize.py" "$context/runtime-initialize.py" cp "$repo_root/services/agents-api/deploy/runtime/build-system-seed.py" "$repo_root/services/agents-api/deploy/runtime/tool-root.py" "$context/" -cp "$repo_root/services/agents-api/deploy/claude/shell-prefix.py" "$context/" mkdir -p "$output_dir" cp -R "$context/." "$output_dir/" printf 'Claude Runtime image context: %s\n' "$output_dir" diff --git a/scripts/build-mcode-harness.sh b/scripts/build-mcode-harness.sh index 9f2bfd1de..32ea55c3f 100644 --- a/scripts/build-mcode-harness.sh +++ b/scripts/build-mcode-harness.sh @@ -34,7 +34,6 @@ test "$(node "$native/cli.js" --version)" = 0.4.12 cd "$context" npm ci --no-audit --no-fund MCODE_SOURCE="$context/upstream" node build.mjs - MCODE_SOURCE="$context/upstream" node build-sandbox.mjs node dist/worker.mjs /workspace --describe > dist/tools.json npm prune --omit=dev --no-audit --no-fund ) @@ -48,7 +47,6 @@ cp -R "$context/upstream/dist/." "$artifact/native/" cp -R "$native/node_modules" "$artifact/native/" cp "$context/upstream/.oac-native-patch.json" "$artifact/native-patch.json" cp "$context/upstream/LICENSE" "$artifact/UPSTREAM_LICENSE" -cp "$context/upstream/third_party/sandbox-runtime/LICENSE" "$artifact/SANDBOX_LICENSE" cp "$context/upstream/third_party/pi-mono/LICENSE" "$artifact/PI_LICENSE" python3 - "$artifact" "$package" <<'PY' import hashlib,json,pathlib,sys diff --git a/scripts/core-distribution-manifest.py b/scripts/core-distribution-manifest.py index 2807ba9cc..78e9411dc 100644 --- a/scripts/core-distribution-manifest.py +++ b/scripts/core-distribution-manifest.py @@ -167,11 +167,6 @@ def verify_runtime(image, daemon, helpers, source): files["/usr/local/bin/oac-runtime-initialize"] = source / "services/agents-api/deploy/runtime/initialize.py" files["/usr/local/bin/oac-tool-root"] = source / "services/agents-api/deploy/runtime/tool-root.py" environment = dict(value.split("=", 1) for value in details["Config"]["Env"] if "=" in value) - if "OAC_RUNTIME_CODEX_BIN" in environment: - files["/etc/codex/requirements.toml"] = source / "services/agents-api/deploy/codex/requirements.toml" - files["/etc/codex/tool-env.py"] = source / "services/agents-api/deploy/codex/tool-env.py" - if "OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT" in environment: - files["/usr/local/bin/oac-claude-shell-prefix"] = source / "services/agents-api/deploy/claude/shell-prefix.py" if "OAC_RUNTIME_MCODE_BIN" in environment: for name in ("launch.mjs", "bridge.mjs", "check.mjs", "tool-executor.mjs", "subagent-snapshot.mjs", "source.json"): files["/opt/mcode-harness/" + name] = source / "packages/mcode-harness" / name diff --git a/services/agents-api/deploy/claude/Dockerfile b/services/agents-api/deploy/claude/Dockerfile index 40005ecc9..22ff7cd15 100644 --- a/services/agents-api/deploy/claude/Dockerfile +++ b/services/agents-api/deploy/claude/Dockerfile @@ -8,7 +8,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY build-system-seed.py /tmp/build-system-seed.py RUN python3 -I -S /tmp/build-system-seed.py && rm /tmp/build-system-seed.py COPY --chmod=0555 tool-root.py /usr/local/bin/oac-tool-root -COPY --chmod=0555 shell-prefix.py /usr/local/bin/oac-claude-shell-prefix COPY --chmod=0555 oac-daemon oac-codex-directory oac-codex-write oac-workspace-export /usr/local/bin/ COPY claude-sdk /opt/claude-sdk COPY --chmod=0444 runtime-initialize.py /usr/local/bin/oac-runtime-initialize diff --git a/services/agents-api/deploy/claude/shell-prefix.py b/services/agents-api/deploy/claude/shell-prefix.py deleted file mode 100644 index ddf5be271..000000000 --- a/services/agents-api/deploy/claude/shell-prefix.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/python3 -I -"""Keep Claude's native Bash prefix from rewrapping the isolated MCP entry.""" -import os -import shlex -import sys - - -MCP_ENTRY = ['/usr/bin/python3', '-I', '-S', - '/usr/local/bin/oac-runtime-initialize', 'stdio'] -TOOL_ROOT = '/usr/local/bin/oac-tool-root' - - -def invocation(command): - try: - args = shlex.split(command, comments=False) - except ValueError: - args = [] - if len(args) == 7 and args[:5] == MCP_ENTRY: - # The existing isolated helper authorizes the frozen package/server. - return args - # Preserve the complete native Bash command, including its cwd receipt. - return [TOOL_ROOT, command] - - -if __name__ == '__main__': - if len(sys.argv) != 2: - sys.exit(1) - argv = invocation(sys.argv[1]) - os.execv(argv[0], argv) diff --git a/services/agents-api/deploy/claude/shell_prefix_test.py b/services/agents-api/deploy/claude/shell_prefix_test.py deleted file mode 100644 index d946d4c82..000000000 --- a/services/agents-api/deploy/claude/shell_prefix_test.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Verify the boundary between a native MCP argv and a complete Bash command.""" -from pathlib import Path -import runpy -import shlex -import unittest - - -prefix = runpy.run_path(str(Path(__file__).with_name('shell-prefix.py'))) -resolve = prefix['invocation'] -entry = prefix['MCP_ENTRY'] + ['plugins/0', 'installed'] - - -class ShellPrefixTest(unittest.TestCase): - def test_native_stdio_exec_preserves_arguments(self): - self.assertEqual(resolve(shlex.join(entry)), entry) - # Never interpret shell syntax inside quoted package/server arguments. - unusual = prefix['MCP_ENTRY'] + ['plugins/$(touch marker)', 'server;exit'] - self.assertEqual(resolve(shlex.join(unusual)), unusual) - - def test_shell_commands_remain_inside_tool_root(self): - original = shlex.join(entry) - for command in [original + ' && pwd', original + '; id', original + ' extra', - original + ' # comment', 'exec ' + original, - original.replace('/usr/bin/python3', 'python3'), - original.replace(' -I -S ', ' -I '), - 'eval ' + shlex.quote(original) + '; pwd -P > /tmp/cwd', - "printf '%s\\n' 'a && b'; exit 7", "unterminated '"]: - with self.subTest(command=command): - self.assertEqual(resolve(command), [prefix['TOOL_ROOT'], command]) - - -if __name__ == '__main__': - unittest.main() diff --git a/services/agents-api/deploy/codex/Dockerfile b/services/agents-api/deploy/codex/Dockerfile index 90de93f93..482866f2a 100644 --- a/services/agents-api/deploy/codex/Dockerfile +++ b/services/agents-api/deploy/codex/Dockerfile @@ -12,19 +12,16 @@ RUN python3 -I -S /tmp/build-system-seed.py && rm /tmp/build-system-seed.py COPY --chmod=0555 tool-root.py /usr/local/bin/oac-tool-root COPY --chmod=0555 oac-daemon oac-codex-directory oac-codex-write oac-workspace-export codex /usr/local/bin/ COPY codex-resources /usr/local/codex-resources -COPY requirements.toml /etc/codex/requirements.toml -COPY --chmod=0444 tool-env.py /etc/codex/tool-env.py -RUN chmod 0555 /etc/codex && chmod 0444 /etc/codex/requirements.toml COPY --chmod=0444 runtime-initialize.py /usr/local/bin/oac-runtime-initialize ENV HOME=/home/runtime OAC_RUNTIME_HOME=/home/runtime/.oac \ - OAC_RUNTIME_CODEX_BIN=/usr/local/bin/codex OAC_RUNTIME_CODEX_PERMISSION_PROFILE=managed-workspace \ + OAC_RUNTIME_CODEX_BIN=/usr/local/bin/codex \ OAC_RUNTIME_WORKSPACE=/environment/workspace \ OAC_RUNTIME_DIRECTORY_HELPER=/usr/local/bin/oac-codex-directory \ OAC_RUNTIME_WRITE_HELPER=/usr/local/bin/oac-codex-write \ OAC_RUNTIME_EXPORT_HELPER=/usr/local/bin/oac-workspace-export \ OAC_RUNTIME_STAGING=/environment/staging USER 1000:1000 -RUN test -r /etc/codex/requirements.toml && test "$(codex --version)" = "codex-cli 0.153.4" +RUN test "$(codex --version)" = "codex-cli 0.153.4" WORKDIR /environment/workspace ENTRYPOINT ["/usr/local/bin/oac-daemon"] CMD ["connect", "--profile", "default"] diff --git a/services/agents-api/deploy/codex/requirements.toml b/services/agents-api/deploy/codex/requirements.toml deleted file mode 100644 index a51cd9bf5..000000000 --- a/services/agents-api/deploy/codex/requirements.toml +++ /dev/null @@ -1,42 +0,0 @@ -allow_managed_hooks_only = true -default_permissions = "managed-workspace" -allowed_approval_policies = ["never"] -[allowed_permission_profiles] -managed-workspace = true -managed-workspace-enabled = true -[permissions.managed-workspace.filesystem] -":minimal" = "read" -"/environment/workspace" = "write" -"/workspace" = "write" -"/environment/initialization" = "read" -"/environment/packages" = "write" -"/environment/packages/system" = "read" -"/tmp/oac-tool-root" = "read" -[permissions.managed-workspace.network] -enabled = false -[permissions.managed-workspace-enabled] -extends = "managed-workspace" -[permissions.managed-workspace-enabled.network] -enabled = true -[permissions.filesystem] -deny_read = [ - "/environment/staging", - "/home/*/.oac/daemon/*/auth.json", - "/home/*/.oac/daemon/agent-sessions/*/config.toml", - "/home/*/.oac/daemon/agent-sessions/*/sessions", - "/home/*/.oac/daemon/agent-sessions/*/archived_sessions", -] - -[features] -hooks = true - -[hooks] -managed_dir = "/etc/codex/runtime-hooks" - -[[hooks.PreToolUse]] -matcher = "^Bash$" - -[[hooks.PreToolUse.hooks]] -type = "command" -command = "/usr/bin/python3 -I -S /etc/codex/tool-env.py" -timeout = 5 diff --git a/services/agents-api/deploy/codex/tool-env.py b/services/agents-api/deploy/codex/tool-env.py deleted file mode 100644 index b720d3461..000000000 --- a/services/agents-api/deploy/codex/tool-env.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Managed native hook: transform Bash input, never execute untrusted code.""" -import json -import os -from pathlib import Path -import shlex -import sys - -try: - if os.environ.get('OAC_RUNTIME_TOOL_ENV') != '1': - print('{}') - sys.exit(0) - value = json.load(sys.stdin) - command = value['tool_input']['command'] - if value.get('hook_event_name') != 'PreToolUse' or value.get('tool_name') != 'Bash' or not isinstance(command, str): - raise ValueError('invalid hook input') - if not Path('/environment/initialization/tool-env.sh').is_file(): - raise ValueError('missing tool environment') - # POSIX sh does not accept eval --. A leading space prevents option parsing - # while preserving the native shell, cwd and command text. - rewritten = '. /environment/initialization/tool-env.sh && eval ' + shlex.quote(' ' + command) - if os.environ.get('OAC_RUNTIME_SYSTEM_PACKAGES') == '1': - rewritten = '/usr/bin/python3 -I -S /usr/local/bin/oac-tool-root ' + shlex.quote(command) - print(json.dumps({'hookSpecificOutput': {'hookEventName': 'PreToolUse', - 'permissionDecision': 'allow', 'updatedInput': { - 'command': rewritten}}})) -except Exception: - # Native exit 2 denies the tool. Never return a partial rewrite or input. - print('Initialized tool configuration unavailable', file=sys.stderr) - sys.exit(2) diff --git a/services/agents-api/deploy/runtime/initialize.py b/services/agents-api/deploy/runtime/initialize.py index 901501885..86d3e7d54 100644 --- a/services/agents-api/deploy/runtime/initialize.py +++ b/services/agents-api/deploy/runtime/initialize.py @@ -3,14 +3,11 @@ Core owns sequencing and the completion ledger. This helper executes one bounded operation; it never retries, schedules, selects a harness or interprets templates. """ -import ctypes import json import os from pathlib import Path import re import runpy -import select -import signal import shlex import subprocess import sys @@ -21,28 +18,13 @@ ENV_FILE = CONFIG / 'tool-env.sh' MAX_INPUT = 32 * 1024 * 1024 BASE_ENV = {'PATH': '/usr/local/bin:/usr/bin:/bin', 'HOME': '/tmp', 'LANG': 'C.UTF-8'} -DIRECTORY = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC - - -def open_directory(parent, name): - """Do not follow user-created directory aliases outside the Runtime roots.""" - return os.open(name, DIRECTORY, dir_fd=parent) +DIRECTORY = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC def roots(workspace=None): - root = os.open('/', DIRECTORY) - try: - environment = open_directory(root, 'environment') - finally: - os.close(root) - try: - for name in (('workspace', 'packages', 'initialization') if workspace is None else ('packages', 'initialization')): - child = open_directory(environment, name) - os.close(child) - finally: - os.close(environment) - if workspace is not None: - os.close(os.open(workspace, DIRECTORY)) + for directory in (Path(workspace) if workspace else ROOT / 'workspace', PACKAGES, CONFIG): + if not directory.is_dir(): + raise ValueError('invalid Runtime directory') def configure(env): @@ -52,23 +34,23 @@ def configure(env): for name, value in env.items() ): raise ValueError('invalid environment') - # These Runtime-owned defaults are applied inside the sandbox, never to its - # launcher. Public reserved-name validation belongs to Core. + # Public reserved-name validation belongs to Core. Only the configured + # environment and these defaults are passed to initialization commands. values = { - 'PATH': '/environment/packages/npm/bin:/environment/packages/python/bin:' + BASE_ENV['PATH'], + 'PATH': str(PACKAGES / 'npm/bin') + ':' + str(PACKAGES / 'python/bin') + ':' + BASE_ENV['PATH'], **env, - 'PYTHONPATH': '/environment/packages/python' + (':' + env['PYTHONPATH'] if env.get('PYTHONPATH') else ''), + 'PYTHONPATH': str(PACKAGES / 'python') + (':' + env['PYTHONPATH'] if env.get('PYTHONPATH') else ''), } materialized = json.dumps(values, ensure_ascii=True) script = ''.join('export ' + name + '=' + shlex.quote(value) + '\n' for name, value in sorted(values.items())) directory = os.open(CONFIG, DIRECTORY) try: - fd = os.open('tool-env.sh', os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o400, dir_fd=directory) + fd = os.open('tool-env.sh', os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o400, dir_fd=directory) with os.fdopen(fd, 'w') as stream: stream.write(script) stream.flush() os.fsync(stream.fileno()) - fd = os.open('tool-env.json', os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o400, dir_fd=directory) + fd = os.open('tool-env.json', os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o400, dir_fd=directory) with os.fdopen(fd, 'w') as stream: stream.write(materialized) stream.flush() @@ -78,33 +60,20 @@ def configure(env): os.close(directory) -def sandbox(network, cwd, workspace=None): - if network not in ('enabled', 'disabled') or not isinstance(cwd, str) or not cwd.startswith('/') or '\x00' in cwd: - raise ValueError('invalid execution configuration') - if (CONFIG / 'system-root.json').exists(): - tools = runpy.run_path('/usr/local/bin/oac-tool-root') - if not tools['installed'](): - raise ValueError('system tools unavailable') - return tools['initialization_sandbox'](cwd, network, workspace=workspace or str(ROOT / 'workspace')) - # One packaging contract for every Provider/harness. No native state, daemon - # credential, staging payload or parent process is visible in this mount map. - args = ['/usr/bin/bwrap', '--unshare-user', '--unshare-pid', '--unshare-ipc', '--unshare-uts', - '--die-with-parent', '--new-session', '--cap-drop', 'ALL', '--clearenv', - '--ro-bind', '/usr', '/usr', '--symlink', 'usr/bin', '/bin', - '--symlink', 'usr/sbin', '/sbin', '--symlink', 'usr/lib', '/lib', - '--symlink', 'usr/lib64', '/lib64', '--dir', '/etc', - '--ro-bind', '/etc/resolv.conf', '/etc/resolv.conf', - '--ro-bind', '/etc/hosts', '/etc/hosts', '--ro-bind', '/etc/ssl', '/etc/ssl', - '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--dir', '/home', - '--dir', '/environment', '--bind', workspace or str(ROOT / 'workspace'), '/workspace', - '--bind', str(PACKAGES), str(PACKAGES), '--ro-bind', str(CONFIG), str(CONFIG)] - if workspace is not None and workspace != '/workspace': - args += ['--bind', workspace, workspace] - if network == 'disabled': - args += ['--unshare-net'] - for key, value in BASE_ENV.items(): - args += ['--setenv', key, value] - return args + ['--chdir', cwd, '--'] +class StepFailure(Exception): + """A launched initialization command failed; never retain its text or output.""" + + def __init__(self, exit_code): + self.exit_code = exit_code + super().__init__('initialization command failed') + + +def execution_directory(cwd, workspace): + if (not isinstance(cwd, str) or os.path.normpath(cwd) != cwd + or any(ord(c) < 32 or ord(c) == 127 or c == '\\' for c in cwd) + or not (cwd == '/workspace' or cwd.startswith('/workspace/'))): + raise ValueError('invalid execution directory') + return str(Path(workspace or ROOT / 'workspace') / cwd.removeprefix('/workspace').lstrip('/')) def run(request, workspace=None, capability_root=None): @@ -115,152 +84,58 @@ def run(request, workspace=None, capability_root=None): if action == 'system': runpy.run_path('/usr/local/bin/oac-tool-root')['install'](request['packages'], workspace=workspace or str(ROOT / 'workspace')) return - args = sandbox(request['network'], request.get('cwd', '/workspace'), workspace=workspace) - expose_capabilities(args, capability_root) + if action not in ('setup', 'npm', 'python'): + raise ValueError('invalid action') + if request['network'] not in ('enabled', 'disabled'): + raise ValueError('invalid execution configuration') + cwd = execution_directory(request.get('cwd', '/workspace'), workspace) if action == 'setup': command = request['command'] if not isinstance(command, str) or not command or '\x00' in command: raise ValueError('invalid setup command') - # Source confidential values only inside isolation. eval preserves the - # shell's cwd, unlike replacing the native shell with a child wrapper. - args += ['/bin/bash', '--noprofile', '--norc', '-c', - '. /environment/initialization/tool-env.sh && eval -- "$1"', '--', command] - elif action in ('npm', 'python'): + args = ['/bin/bash', '--noprofile', '--norc', '-c', command] + else: packages = request['packages'] if not isinstance(packages, list) or not packages or any( not isinstance(package, str) or not package or '\x00' in package or package.startswith('-') for package in packages ): raise ValueError('invalid packages') - command = ['npm', 'install', '--global', '--prefix', str(PACKAGES / 'npm'), '--', *packages] + args = ['npm', 'install', '--global', '--prefix', str(PACKAGES / 'npm'), '--', *packages] if action == 'python': - command = ['/usr/bin/python3', '-m', 'pip', 'install', '--disable-pip-version-check', - '--no-input', '--target', str(PACKAGES / 'python'), '--', *packages] - args += ['/bin/bash', '--noprofile', '--norc', '-c', - '. /environment/initialization/tool-env.sh && exec "$@"', '--', *command] - else: - raise ValueError('invalid action') - # Setup/package output can contain arbitrary confidential values. The public - # completion receipt deliberately contains no child stdout/stderr or command. - subprocess.run(args, env=BASE_ENV, stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) + args = ['/usr/bin/python3', '-m', 'pip', 'install', '--disable-pip-version-check', + '--no-input', '--target', str(PACKAGES / 'python'), '--', *packages] + env = {**BASE_ENV, **json.loads((CONFIG / 'tool-env.json').read_text())} + # Execution uses the launching user's permissions. The outer Environment owns + # isolation; command output is still excluded from the completion receipt. + try: + subprocess.run(args, cwd=cwd, env=env, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) + except subprocess.CalledProcessError as error: + raise StepFailure(error.returncode) from None def failed_receipt(error): - """Report only the integer exit status of a step run inside the isolation sandbox. - - Setup commands and package managers (including apt from the system tool root) - run through bwrap. Runtime-internal helpers such as seed extraction and the - Skill writer do not, so their failures stay generic. The exception itself is - never serialized: it can contain the command, input or captured output. - """ + """Expose a command's exit status, never its command, input or output.""" receipt = {'version': 1, 'outcome': 'failed'} - code = getattr(error, 'returncode', None) + code = getattr(error, 'exit_code', None) + # The system installer retains its existing implementation until its package + # ownership is migrated; other internal helper failures remain generic. if (isinstance(error, subprocess.CalledProcessError) and isinstance(error.cmd, list) - and error.cmd[:1] == ['/usr/bin/bwrap'] and type(code) is int and 0 < code < 256): + and error.cmd[:1] == ['/usr/bin/bwrap']): + code = error.returncode + if type(code) is int and 0 < code < 256: receipt['exit_code'] = code return json.dumps(receipt, separators=(',', ':')) -def stdio_lifetime(args): - """Bind sandbox lifetime to the native process, not its transient spawn thread.""" - parent = os.getppid() - if parent <= 1: - raise ValueError('native parent unavailable') - parent_fd = os.pidfd_open(parent) - child_fd = None - child = None - try: - watched = select.poll() - watched.register(parent_fd, select.POLLIN) - if os.getppid() != parent or watched.poll(0): - raise ValueError('native parent exited') - # Keep exited children waitable until their pidfd has been acquired. - signal.signal(signal.SIGCHLD, signal.SIG_DFL) - # Bind before fork. This entry is single-threaded; the child only sets - # its death signal and checks the captured parent before exec. bwrap sets - # its own signal later, leaving a demonstrated startup gap without this. - owner = os.getpid() - prctl = ctypes.CDLL(None).prctl - prctl.argtypes = [ctypes.c_int] + [ctypes.c_ulong] * 4 - prctl.restype = ctypes.c_int - - def bind_parent(): - if prctl(1, signal.SIGKILL, 0, 0, 0) != 0 or os.getppid() != owner: - os._exit(1) - - # No protocol forwarding. bwrap's parent-death signal now targets this - # stable launcher; its PID namespace owns descendants. - child = subprocess.Popen(args, env=BASE_ENV, close_fds=True, preexec_fn=bind_parent) - child_fd = os.pidfd_open(child.pid) - watched.register(child_fd, select.POLLIN) - events = dict(watched.poll()) - if parent_fd in events: - child.kill() - child.wait() - return 1 - result = child.wait() - return result if result >= 0 else 128 - result - finally: - if child is not None: - if child.poll() is None: - child.kill() - child.wait() - if child_fd is not None: - os.close(child_fd) - os.close(parent_fd) - - -def expose_capabilities(args, capability_root): - """Expose only the Runtime-owned capability tree, including custom layouts.""" - if capability_root is None: - return - path = Path(capability_root) - if (not capability_root.startswith('/') or capability_root == '/' - or os.path.normpath(capability_root) != capability_root - or any(ord(c) < 32 or ord(c) == 127 or c == '\\' for c in capability_root) - or path.resolve() != path): - raise ValueError('invalid Runtime capability directory') - # Configuration may precede the first bundle import or empty finalization. - if path.exists(): - if not path.is_dir(): - raise ValueError('invalid Runtime capability directory') - args[-1:-1] = ['--ro-bind', capability_root, capability_root] - - -def stdio(installation_root, workspace, package, server): - """Preserve the native MCP descriptors while entering the existing sandbox.""" - for value in (installation_root, workspace): - if (not isinstance(value, str) or not value.startswith('/') or value == '/' - or os.path.normpath(value) != value or any(ord(c) < 32 or ord(c) == 127 or c == '\\' for c in value)): - raise ValueError('invalid Runtime directory') - path = Path(value) - if path.resolve(strict=True) != path or not path.is_dir(): - raise ValueError('invalid Runtime directory') - roots(workspace) - args = sandbox('enabled', '/workspace', workspace=workspace) - helper = '/tmp/oac-mcp-exec' - # System-package roots predate daemon installation. Mount only the fixed - # static helper, never native configuration, credentials or Runtime state. - expose_capabilities(args, installation_root) - args[-1:-1] = ['--ro-bind', '/usr/local/bin/oac-daemon', helper] - args += [helper, 'runtime-mcp-exec', installation_root, package, server] - return stdio_lifetime(args) - - def main(): workspace, capability_root = None, None if len(sys.argv) == 4 and sys.argv[1] == 'initialize': workspace, capability_root = sys.argv[2:] - elif len(sys.argv) != 1: - try: - if len(sys.argv) != 6 or sys.argv[1] != 'stdio': - raise ValueError('invalid stdio invocation') - return stdio(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]) - except Exception: - print('Environment MCP unavailable', file=sys.stderr) - return 1 try: + if len(sys.argv) != 1 and not (len(sys.argv) == 4 and sys.argv[1] == 'initialize'): + raise ValueError('invalid initialization invocation') raw = sys.stdin.buffer.read(MAX_INPUT + 1) if len(raw) > MAX_INPUT: raise ValueError('input too large') @@ -271,7 +146,7 @@ def main(): path = Path(workspace) if (not workspace.startswith('/') or workspace == '/' or os.path.normpath(workspace) != workspace or any(ord(c) < 32 or ord(c) == 127 or c == '\\' for c in workspace) - or path.resolve(strict=True) != path or not path.is_dir()): + or not path.is_dir()): raise ValueError('invalid Runtime workspace') if workspace is None: roots() diff --git a/services/agents-api/deploy/runtime/initialize_receipt_test.py b/services/agents-api/deploy/runtime/initialize_receipt_test.py index 2a6b51f3d..e730838a6 100644 --- a/services/agents-api/deploy/runtime/initialize_receipt_test.py +++ b/services/agents-api/deploy/runtime/initialize_receipt_test.py @@ -1,13 +1,9 @@ -"""Host-runnable checks of the initializer's failure receipt. - -The receipt may carry only the integer exit status of a sandboxed step, never its -command, input or output. Real isolation is covered by initialize_test.py inside a -packaged Runtime; these checks need no Runtime and run with plain python3. -""" +"""Host-runnable initialization and finite-receipt checks; no packages downloaded.""" import contextlib import importlib.util import io import json +import os from pathlib import Path import subprocess import tempfile @@ -18,11 +14,9 @@ initialize = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(initialize) CANARY = 'CANARY-initializer-receipt-5d1c' -SANDBOXED = ['/usr/bin/bwrap', '--unshare-user', '--', '/bin/bash', '-c', 'echo ' + CANARY + '; exit 3'] def invoke(failure): - """Run main() with a request whose step raises failure; return (code, stdout, stderr).""" request = json.dumps({'version': 1, 'action': 'setup', 'network': 'enabled', 'command': 'echo ' + CANARY}) stdin = mock.Mock() stdin.buffer = io.BytesIO(request.encode()) @@ -34,16 +28,8 @@ def invoke(failure): return code, stdout.getvalue(), stderr.getvalue() -class FailureReceiptTest(unittest.TestCase): - def assert_receipt(self, failure, expected): - code, stdout, stderr = invoke(failure) - self.assertEqual(code, 1) - self.assertEqual(stderr, '') - self.assertEqual(json.loads(stdout), expected) - self.assertNotIn(CANARY, stdout) - self.assertNotIn('echo', stdout) - - def test_configure_does_not_create_or_change_runtime_capabilities(self): +class InitializationTest(unittest.TestCase): + def test_configure_preserves_runtime_capabilities_and_freezes_environment(self): with tempfile.TemporaryDirectory() as directory: config = Path(directory) capabilities = config / 'capabilities' @@ -51,78 +37,82 @@ def test_configure_does_not_create_or_change_runtime_capabilities(self): manifest = capabilities / 'installed.json' manifest.write_bytes(b'fixed-runtime-fixture') with mock.patch.object(initialize, 'CONFIG', config): - initialize.configure({'VALUE': 'configured'}) + initialize.configure({'VALUE': "configured'\n$(false)"}) self.assertEqual(manifest.read_bytes(), b'fixed-runtime-fixture') self.assertFalse((capabilities / 'skills').exists()) - self.assertIn('configured', (config / 'tool-env.sh').read_text()) + self.assertEqual(json.loads((config / 'tool-env.json').read_text())['VALUE'], "configured'\n$(false)") with self.assertRaises(FileExistsError): initialize.configure({'VALUE': 'changed'}) - def test_skill_is_not_a_provider_initializer_action(self): - with mock.patch.object(initialize, 'sandbox', return_value=[]), \ - mock.patch.object(initialize.subprocess, 'run') as run: + def test_skill_is_not_an_initializer_action(self): + with mock.patch.object(initialize.subprocess, 'run') as run: with self.assertRaisesRegex(ValueError, 'invalid action'): initialize.run({'action': 'skill', 'network': 'enabled'}) run.assert_not_called() - def test_stdio_forwards_and_mounts_resolved_runtime_directories(self): + def test_setup_executes_directly_with_resolved_cwd_and_configured_environment(self): with tempfile.TemporaryDirectory() as directory: - base = Path(directory).resolve() - root, workspace = base / 'capabilities', base / 'actual-work' - root.mkdir() - workspace.mkdir() - root, workspace = str(root), str(workspace) - with mock.patch.object(initialize, 'roots') as roots, \ - mock.patch.object(initialize, 'sandbox', return_value=['bwrap', '--']) as sandbox, \ - mock.patch.object(initialize, 'stdio_lifetime', return_value=0) as launch: - with mock.patch.object(initialize.sys, 'argv', ['initialize', 'stdio', root, workspace, 'plugins/0', 'server']): - self.assertEqual(initialize.main(), 0) - roots.assert_called_once_with(workspace) - sandbox.assert_called_once_with('enabled', '/workspace', workspace=workspace) - argv = launch.call_args.args[0] - self.assertEqual(argv[-5:], ['/tmp/oac-mcp-exec', 'runtime-mcp-exec', root, 'plugins/0', 'server']) - self.assertIn(['--ro-bind', root, root], [argv[i:i+3] for i in range(len(argv)-2)]) - alias = base / 'alias' - alias.symlink_to(workspace, target_is_directory=True) - for invalid in ('/', 'relative', '/tmp/../secret', workspace + '/', workspace + '\n', str(alias)): - for values in ((invalid, workspace), (root, invalid)): - with self.assertRaises(ValueError): - initialize.stdio(*values, 'plugins/0', 'server') - - def test_stdio_mounts_workspace_at_native_alias_and_physical_path(self): + base = Path(directory) + workspace, config, packages, capabilities = [base / name for name in ('work', 'config', 'packages', 'caps')] + for path in (workspace / 'sub', config, packages, capabilities): + path.mkdir(parents=True) + skill = capabilities / 'proof.sh' + skill.write_text('#!/bin/sh\nprintf skill-proof') + secret = base / 'same-user-file' + secret.write_text('ordinary-user-access') + import shlex + command = ('/bin/sh ' + shlex.quote(str(skill)) + ' > skill-result; ' + 'cat ' + shlex.quote(str(secret)) + ' > readable; ' + 'printf "%s" "$VALUE" > value; pwd > cwd; ' + 'test -z "${DAEMON_PRIVATE_CANARY+x}"') + with mock.patch.object(initialize, 'CONFIG', config), mock.patch.object(initialize, 'PACKAGES', packages), \ + mock.patch.dict(os.environ, {'DAEMON_PRIVATE_CANARY': CANARY}): + initialize.configure({'VALUE': "'\n$(false)"}) + initialize.run({'action': 'setup', 'network': 'disabled', 'cwd': '/workspace/sub', 'command': command}, + workspace=str(workspace), capability_root=str(capabilities)) + self.assertEqual((workspace / 'sub/skill-result').read_text(), 'skill-proof') + self.assertEqual((workspace / 'sub/readable').read_text(), 'ordinary-user-access') + self.assertEqual((workspace / 'sub/value').read_text(), "'\n$(false)") + self.assertEqual((workspace / 'sub/cwd').read_text(), str(workspace / 'sub') + '\n') + + def test_package_managers_use_direct_argv_and_frozen_environment(self): with tempfile.TemporaryDirectory() as directory: config = Path(directory) - workspace = str(config / 'actual-work') - with mock.patch.object(initialize, 'CONFIG', config): - args = initialize.sandbox('enabled', '/workspace', workspace=workspace) - mounts = [args[i:i+3] for i in range(len(args)-2)] - self.assertIn(['--bind', workspace, '/workspace'], mounts) - self.assertIn(['--bind', workspace, workspace], mounts) - self.assertNotIn(['--bind', '/environment/workspace', '/workspace'], mounts) - self.assertIn(['--ro-bind', str(config), str(config)], mounts) - default = initialize.sandbox('enabled', '/workspace') - self.assertIn(['--bind', '/environment/workspace', '/workspace'], - [default[i:i+3] for i in range(len(default)-2)]) - tools = initialize.runpy.run_path(str(Path(__file__).with_name('tool-root.py'))) - (config / 'system-root.json').write_text('{"version":1}') - tools['installed'] = lambda: True - with mock.patch.object(initialize.runpy, 'run_path', return_value=tools): - args = initialize.sandbox('enabled', '/workspace', workspace=workspace) - mounts = [args[i:i+3] for i in range(len(args)-2)] - self.assertIn(['--bind', workspace, '/workspace'], mounts) - self.assertIn(['--bind', workspace, workspace], mounts) - self.assertIn(['--ro-bind', '/environment/initialization', '/environment/initialization'], mounts) - self.assertIn(['--chdir', '/workspace'], [args[i:i+2] for i in range(len(args)-1)]) + (config / 'tool-env.json').write_text(json.dumps({'VALUE': CANARY})) + for action, executable in (('npm', 'npm'), ('python', '/usr/bin/python3')): + with self.subTest(action=action), mock.patch.object(initialize, 'CONFIG', config), \ + mock.patch.object(initialize.subprocess, 'run') as run: + initialize.run({'action': action, 'network': 'enabled', 'packages': ['valid-package']}, workspace=directory) + self.assertEqual(run.call_args.args[0][0], executable) + self.assertEqual(run.call_args.args[0][-2:], ['--', 'valid-package']) + self.assertEqual(run.call_args.kwargs['cwd'], directory) + self.assertEqual(run.call_args.kwargs['env']['VALUE'], CANARY) + for channel in ('stdin', 'stdout', 'stderr'): + self.assertEqual(run.call_args.kwargs[channel], subprocess.DEVNULL) + with self.assertRaises(ValueError): + initialize.run({'action': action, 'network': 'enabled', 'packages': ['--bad']}, workspace=directory) + + def test_system_still_delegates_to_existing_installer(self): + install = mock.Mock() + with mock.patch.object(initialize.runpy, 'run_path', return_value={'install': install}) as load: + initialize.run({'action': 'system', 'packages': ['jq']}, workspace='/actual/work') + load.assert_called_once_with('/usr/local/bin/oac-tool-root') + install.assert_called_once_with(['jq'], workspace='/actual/work') + + def test_cwd_uses_only_logical_workspace_paths(self): + self.assertEqual(initialize.execution_directory('/workspace/sub', '/actual'), '/actual/sub') + for invalid in ('/workspace/../outside', '/workspace/', '/elsewhere', 'relative', '/workspace/x\n'): + with self.subTest(cwd=invalid), self.assertRaises(ValueError): + initialize.execution_directory(invalid, '/actual') def test_private_initialization_uses_bound_workspace_and_empty_environment(self): with tempfile.TemporaryDirectory() as directory: - workspace = str(Path(directory).resolve()) + workspace = str(Path(directory)) stdin = mock.Mock() stdin.buffer = io.BytesIO(b'{"version":1,"action":"configure"}') with mock.patch.object(initialize.sys, 'argv', ['initialize', 'initialize', workspace, workspace + '/caps']), \ mock.patch.object(initialize.sys, 'stdin', stdin), \ - mock.patch.object(initialize, 'roots') as roots, \ - mock.patch.object(initialize, 'run') as run, \ + mock.patch.object(initialize, 'roots') as roots, mock.patch.object(initialize, 'run') as run, \ contextlib.redirect_stdout(io.StringIO()) as output: self.assertEqual(initialize.main(), 0) roots.assert_called_once_with(workspace) @@ -132,63 +122,42 @@ def test_private_initialization_uses_bound_workspace_and_empty_environment(self) initialize.run({'action': 'configure'}, workspace=workspace) configure.assert_called_once_with({}) - def test_setup_mounts_custom_capabilities_read_only(self): - with tempfile.TemporaryDirectory() as directory: - base = Path(directory).resolve() - capabilities = base / 'capabilities' - capabilities.mkdir() - for system in (False, True): - with self.subTest(system=system), \ - mock.patch.object(initialize, 'sandbox', return_value=['system-root' if system else 'bwrap', '--']), \ - mock.patch.object(initialize.subprocess, 'run') as execute: - initialize.run({'action': 'setup', 'network': 'enabled', 'command': 'true'}, - workspace=str(base / 'workspace'), capability_root=str(capabilities)) - args = execute.call_args.args[0] - self.assertIn(['--ro-bind', str(capabilities), str(capabilities)], - [args[i:i+3] for i in range(len(args)-2)]) - self.assertLess(args.index(str(capabilities)), args.index('--')) - alias = base / 'alias' - alias.symlink_to(capabilities, target_is_directory=True) - for invalid in ('relative', '/', str(alias), str(base / 'capabilities' / '..')): - with self.assertRaises(ValueError): - initialize.expose_capabilities(['bwrap', '--'], invalid) - args = ['bwrap', '--'] - initialize.expose_capabilities(args, str(base / 'not-installed')) - self.assertEqual(args, ['bwrap', '--']) - - def test_sandboxed_step_reports_only_its_exit_status(self): + def test_invalid_invocation_and_oversized_input_remain_finite(self): + for argv, data in ((['initialize', 'stdio', 'unused'], b''), (['initialize'], b'x' * 33)): + stdin = mock.Mock() + stdin.buffer = io.BytesIO(data) + with mock.patch.object(initialize.sys, 'argv', argv), mock.patch.object(initialize.sys, 'stdin', stdin), \ + mock.patch.object(initialize, 'MAX_INPUT', 32), contextlib.redirect_stdout(io.StringIO()) as output: + self.assertEqual(initialize.main(), 1) + self.assertEqual(json.loads(output.getvalue()), {'version': 1, 'outcome': 'failed'}) + + def test_step_reports_only_its_exit_status(self): for status in (1, 3, 100, 255): - failure = subprocess.CalledProcessError(status, SANDBOXED, output=CANARY.encode(), stderr=CANARY.encode()) - self.assert_receipt(failure, {'version': 1, 'outcome': 'failed', 'exit_code': status}) - # The receipt keeps its compact, stable encoding. - _, stdout, _ = invoke(subprocess.CalledProcessError(3, SANDBOXED)) - self.assertEqual(stdout, '{"version":1,"outcome":"failed","exit_code":3}\n') + code, stdout, stderr = invoke(initialize.StepFailure(status)) + self.assertEqual(code, 1) + self.assertEqual(stderr, '') + self.assertEqual(json.loads(stdout), {'version': 1, 'outcome': 'failed', 'exit_code': status}) + self.assertNotIn(CANARY, stdout) + self.assertEqual(invoke(initialize.StepFailure(3))[1], '{"version":1,"outcome":"failed","exit_code":3}\n') def test_runtime_helpers_signals_and_other_errors_stay_generic(self): - generic = {'version': 1, 'outcome': 'failed'} - for failure in ( - subprocess.CalledProcessError(2, ['/usr/bin/tar', '-xzf', CANARY]), - subprocess.CalledProcessError(1, ['/usr/local/bin/oac-codex-write', CANARY]), - subprocess.CalledProcessError(-9, SANDBOXED), - subprocess.CalledProcessError(256, SANDBOXED), - subprocess.CalledProcessError(3, ' '.join(SANDBOXED)), - subprocess.TimeoutExpired(SANDBOXED, 120, output=CANARY.encode()), - ValueError(CANARY), - OSError(CANARY), - ): - with self.subTest(failure=type(failure).__name__): - self.assert_receipt(failure, generic) - _, stdout, _ = invoke(ValueError(CANARY)) - self.assertEqual(stdout, '{"version":1,"outcome":"failed"}\n') - - def test_real_child_output_never_reaches_the_receipt(self): - # A real process failure, launched like run() with discarded output. - try: - subprocess.run(['/bin/sh', '-c', 'echo ' + CANARY + '; echo ' + CANARY + ' >&2; exit 7'], - stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) - except subprocess.CalledProcessError as error: - failure = subprocess.CalledProcessError(error.returncode, ['/usr/bin/bwrap', '--', *error.cmd]) - self.assert_receipt(failure, {'version': 1, 'outcome': 'failed', 'exit_code': 7}) + for failure in (initialize.StepFailure(-9), initialize.StepFailure(256), + subprocess.CalledProcessError(2, ['/usr/bin/tar', CANARY]), + subprocess.TimeoutExpired([CANARY], 120), ValueError(CANARY), OSError(CANARY)): + code, stdout, stderr = invoke(failure) + self.assertEqual((code, stderr), (1, '')) + self.assertEqual(json.loads(stdout), {'version': 1, 'outcome': 'failed'}) + self.assertNotIn(CANARY, stdout) + + def test_real_child_failure_has_finite_receipt(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) + (config / 'tool-env.json').write_text('{}') + with mock.patch.object(initialize, 'CONFIG', config): + with self.assertRaises(initialize.StepFailure) as failure: + initialize.run({'action': 'setup', 'network': 'enabled', + 'command': 'echo ' + CANARY + '; echo ' + CANARY + ' >&2; exit 7'}, workspace=directory) + self.assertEqual(initialize.failed_receipt(failure.exception), '{"version":1,"outcome":"failed","exit_code":7}') if __name__ == '__main__': diff --git a/services/agents-api/deploy/runtime/initialize_stdio_test.py b/services/agents-api/deploy/runtime/initialize_stdio_test.py deleted file mode 100644 index d79091bd5..000000000 --- a/services/agents-api/deploy/runtime/initialize_stdio_test.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Real stdio lifetime checks: python3 -I -S in a disposable packaged Runtime. -Requires writable /environment roots and the deployed nested-sandbox profile. -No native harness or model is involved. -""" -import json -import os, sys -from pathlib import Path -import runpy -import select, signal -import subprocess -import tempfile, threading, time - -HELPER = '/usr/local/bin/oac-runtime-initialize' -SELF = ['/usr/bin/python3', '-I', '-S', str(Path(__file__).resolve())] -WRITER = "import sys,time\nfor n in range(300):\n with open(sys.argv[1],'ab') as f: f.write(b'x')\n time.sleep(.1)\n" -SERVER = """import subprocess,sys -subprocess.Popen([sys.executable,'-I','-S','-c',sys.argv[2],sys.argv[1]], - start_new_session=True,stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) -for line in sys.stdin: - print(line.rstrip(),flush=True) -""" - -def until(check): - deadline = time.monotonic() + 5 - while time.monotonic() < deadline: - value = check() - if value: - return value - time.sleep(.01) - raise AssertionError('Process condition did not settle within five seconds') - -def exited(fd): - poll = select.poll() - poll.register(fd, select.POLLIN) - assert poll.poll(5000), 'Owned process survived' - -def close_process(fd): - if fd is not None: - try: - signal.pidfd_send_signal(fd, signal.SIGKILL) - except ProcessLookupError: - pass - os.close(fd) - -def guard(directory, mode): - runtime = runpy.run_path(HELPER) - workspace = '/workspace/' + directory.name - if mode == 'startup': - # Stop the actual forked child before its parent binding, without - # replacing Popen, prctl, exec or any production process operation. - def stop_before_binding(frame, event, arg): - if event == 'call' and frame.f_code.co_name == 'bind_parent': - sys.settrace(None) - (directory / 'stopped').write_text(str(os.getpid())) - os.kill(os.getpid(), signal.SIGSTOP) - return stop_before_binding - sys.settrace(stop_before_binding) - command = ['/bin/sh', '-c', 'echo escaped > ' + workspace + '/escaped'] - elif mode.startswith('exit'): - command = ['/bin/sh', '-c', 'exit ' + mode[4:]] - else: - command = ['/usr/bin/python3', '-I', '-S', '-c', SERVER, workspace + '/ticks', WRITER] - return runtime['stdio_lifetime'](runtime['sandbox']('enabled', '/workspace') + command) - -def native_parent(directory): - signal.signal(signal.SIGUSR1, lambda *_: sys.exit(0)) - receipt = {} - def start(): - receipt['creator_tid'] = threading.get_native_id() - child = subprocess.Popen(SELF + ['guard', str(directory), 'server']) - receipt['wrapper_pid'] = child.pid - until(lambda: (directory / 'ticks').exists()) # Exit the thread only after bwrap is active. - thread = threading.Thread(target=start) - thread.start() - thread.join() - until(lambda: not Path('/proc', str(os.getpid()), 'task', str(receipt['creator_tid'])).exists()) - (directory / 'ready').write_text(json.dumps(receipt)) - signal.pause() - -def parent_exit(directory, death): - parent = subprocess.Popen(SELF + ['parent', str(directory)], stdin=subprocess.PIPE, - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - wrapper_fd = None - try: - until(lambda: (directory / 'ready').exists() and (directory / 'ready').stat().st_size) - receipt = json.loads((directory / 'ready').read_text()) - wrapper_fd = os.pidfd_open(receipt['wrapper_pid']) - ticks = directory / 'ticks' - until(lambda: ticks.exists() and ticks.stat().st_size >= 3) - parent.stdin.write(b'creator-thread-exited\n') - parent.stdin.flush() - assert select.select([parent.stdout], [], [], 5)[0], 'Inherited stdout closed or stalled' - assert parent.stdout.readline() == b'creator-thread-exited\n' - before = ticks.stat().st_size - until(lambda: ticks.stat().st_size > before) - parent.send_signal(death) - assert parent.wait(timeout=5) == (0 if death == signal.SIGUSR1 else -signal.SIGKILL) - exited(wrapper_fd) - time.sleep(.2) - stopped = ticks.read_bytes() - time.sleep(.4) - assert ticks.read_bytes() == stopped, 'Detached sandbox descendant survived parent exit' - finally: - if parent.poll() is None: - parent.kill() - parent.wait(timeout=5) - close_process(wrapper_fd) - for stream in (parent.stdin, parent.stdout, parent.stderr): - stream.close() - -def startup_kill(directory): - wrapper = subprocess.Popen(SELF + ['guard', str(directory), 'startup']) - child_fd = None - try: - until(lambda: (directory / 'stopped').exists() and (directory / 'stopped').stat().st_size) - child = int((directory / 'stopped').read_text()) - child_fd = os.pidfd_open(child) - until(lambda: '\nState:\tT' in Path('/proc', str(child), 'status').read_text()) - wrapper.kill() - wrapper.wait(timeout=5) - signal.pidfd_send_signal(child_fd, signal.SIGCONT) - exited(child_fd) - assert not (directory / 'escaped').exists(), 'Child executed after launcher died before exec' - finally: - if wrapper.poll() is None: - wrapper.kill() - wrapper.wait(timeout=5) - close_process(child_fd) - -def main(): - for name in ('workspace', 'packages', 'initialization'): - Path('/environment', name).mkdir(exist_ok=True) - with tempfile.TemporaryDirectory(prefix='stdio-lifetime-', dir='/environment/workspace') as work: - directory = Path(work) - for code in (0, 7): - result = subprocess.run(SELF + ['guard', work, 'exit' + str(code)], capture_output=True, timeout=5) - assert result.returncode == code and not result.stdout, 'Immediate exit or stdout changed' - for death in (signal.SIGUSR1, signal.SIGKILL): - for name in ('ready', 'ticks'): - (directory / name).unlink(missing_ok=True) - parent_exit(directory, death) - startup_kill(directory) - print(json.dumps({'stdio_lifetime': 'passed', 'creator_thread_exit': True, - 'parent_normal_and_kill': True, 'immediate_exit': True, 'preexec_launcher_kill': True})) - -if __name__ == '__main__': - if len(sys.argv) > 1: - sys.exit(guard(Path(sys.argv[2]), sys.argv[3]) if sys.argv[1] == 'guard' else native_parent(Path(sys.argv[2]))) - main() diff --git a/services/agents-api/deploy/runtime/initialize_test.py b/services/agents-api/deploy/runtime/initialize_test.py index 722d9fb7f..2410779f2 100644 --- a/services/agents-api/deploy/runtime/initialize_test.py +++ b/services/agents-api/deploy/runtime/initialize_test.py @@ -1,7 +1,7 @@ """Real Linux initialization checks; run inside a disposable packaged Runtime. The fixture must expose writable workspace/packages/initialization roots and -support the same nested isolation as its deployed Provider. No model is mocked; +execute with the launching user's permissions. No model is mocked; these checks exercise initialization only, not public native-model acceptance. """ import json @@ -9,7 +9,6 @@ from pathlib import Path import subprocess import sys -import time HELPER = '/usr/local/bin/oac-runtime-initialize' CANARY = 'private-initialization-canary-47a8' @@ -21,7 +20,7 @@ def invoke(action, *, succeeds=True, exit_code=None, **fields): text=True, capture_output=True, timeout=120) expected = {'version': 1, 'outcome': 'completed' if succeeds else 'failed'} if exit_code is not None: - # A failed sandboxed step reports only its exit status, never its output. + # A failed initialization step reports only its exit status, never its output. expected['exit_code'] = exit_code assert result.returncode == (0 if succeeds else 1), (action, result.returncode) assert result.stderr == '', (action, 'unexpected stderr') @@ -47,67 +46,38 @@ def main(): (skill / 'SKILL.md').chmod(0o400) (skill / 'scripts/check.sh').write_text('#!/bin/sh\nprintf skill-proof') (skill / 'scripts/check.sh').chmod(0o500) - invoke('setup', command='/environment/initialization/capabilities/skills/proof/scripts/check.sh > /workspace/skill-result') + invoke('setup', command='/environment/initialization/capabilities/skills/proof/scripts/check.sh > skill-result') assert Path('/environment/workspace/skill-result').read_text() == 'skill-proof' if '--system' in sys.argv: invoke('system', packages=['jq', 'build-essential', 'libpq-dev']) invoke('system', succeeds=False, packages=['jq']) - invoke('setup', command='''set -eu -test ! -e /usr/local/bin/oac-daemon -test ! -e /usr/local/bin/oac-tool-root -test ! -e /opt/agents-runtime/system-root.tar.gz -printf '{"value":42}' | jq -e '.value == 42' -printf '#include \nint main(void){return PQlibVersion() > 0 ? 0 : 1;}\n' > /workspace/link.c -cc -I/usr/include/postgresql /workspace/link.c -lpq -o /workspace/link -/workspace/link -! touch /usr/bin/changed -! touch /environment/packages/system/usr/bin/changed -node -e 'if (1 + 1 !== 2) process.exit(1)' -''') + # The system installer currently retains its own package-root contract. + # Its native execution checks are migrated with that installer. + # Re-entry must not replace confidential configuration after any effects. invoke('configure', succeeds=False, env={'INITIALIZATION_VALUE': 'changed'}) invoke('setup', command='printf "%s" "$INITIALIZATION_VALUE" > first; printf secret; printf secret >&2') assert Path('/environment/workspace/first').read_text() == CANARY - check = '''import os, pathlib, socket -for path in ('/environment/private/credential', '/environment/staging/request', '/home/runtime/.oac'): - assert not pathlib.Path(path).exists(), path + check = '''import os, pathlib +for path in ('/environment/private/credential', '/environment/staging/request'): + assert pathlib.Path(path).read_text() == 'private-initialization-canary-47a8' assert 'DAEMON_PRIVATE_CANARY' not in os.environ assert os.environ['INITIALIZATION_VALUE'] == 'private-initialization-canary-47a8' assert os.environ['WITH_QUOTES'] == "'\\n$(false)" -for p in pathlib.Path('/proc').glob('[0-9]*/environ'): - assert b'DAEMON_PRIVATE_CANARY=' not in p.read_bytes() -for path in ('/usr/bin/untrusted', '/environment/initialization/tool-env.sh', '/environment/initialization/capabilities/skills/proof/SKILL.md'): - try: pathlib.Path(path).write_text('bad') - except OSError: pass - else: raise AssertionError(path) pathlib.Path('/environment/packages/visible').write_text('ok') -assert len(socket.if_nameindex()) == 1 ''' Path('/environment/workspace/check.py').write_text(check) - invoke('setup', network='disabled', command='/usr/bin/python3 /workspace/check.py') + invoke('setup', network='disabled', command='/usr/bin/python3 check.py') # Shell cwd is explicit and ordered effects survive between invocations. Path('/environment/workspace/sub').mkdir() - if '--system' in sys.argv: - for cwd in ['/environment/workspace', '/environment/workspace/sub']: - result = subprocess.run( - ['/usr/bin/bwrap', '--bind', '/', '/', - '--bind', '/environment/workspace', '/workspace', '--', - '/usr/bin/python3', '-I', '/usr/local/bin/oac-tool-root', - "pwd; printf '{\"value\":42}' | jq -r .value"], - cwd=cwd, capture_output=True, text=True, timeout=15) - assert result.returncode == 0, result.stderr - assert result.stdout == cwd + '\n42\n', result.stdout invoke('setup', cwd='/workspace/sub', command='test -f ../first && pwd > second') - assert Path('/environment/workspace/sub/second').read_text() == '/workspace/sub\n' + assert Path('/environment/workspace/sub/second').read_text() == '/environment/workspace/sub\n' invoke('setup', succeeds=False, exit_code=7, command='echo secret; echo secret >&2; exit 7') invoke('setup', succeeds=False, exit_code=3, command='echo "$INITIALIZATION_VALUE"; echo "$INITIALIZATION_VALUE" >&2; exit 3') - # bwrap reports its own failure to enter the missing cwd as status 1. - invoke('setup', succeeds=False, exit_code=1, cwd='/missing', command='touch /workspace/should-not-exist') + # Failure to enter the requested cwd is a generic launcher failure. + invoke('setup', succeeds=False, cwd='/workspace/missing', command='touch should-not-exist') assert not Path('/environment/workspace/should-not-exist').exists() - invoke('setup', command='setsid /bin/bash -c "sleep 2; touch /workspace/descendant" >/dev/null 2>&1 &') - time.sleep(3) - assert not Path('/environment/workspace/descendant').exists(), 'detached setup descendant survived' if '--packages' in sys.argv: # Actual public registries, not synthetic package fixtures. invoke('npm', packages=['is-number@7.0.0']) diff --git a/services/agents-api/internal/execution/support.go b/services/agents-api/internal/execution/support.go index 9982f8f81..37f68a25f 100644 --- a/services/agents-api/internal/execution/support.go +++ b/services/agents-api/internal/execution/support.go @@ -130,9 +130,6 @@ func (p Policy) engineCapabilities(peer *gateway.Session, engine string, snapsho if !caps.Preparation || !caps.LocalEnvironment || !caps.WorkspaceReadPreparation || !caps.WorkspaceOutputExport { return fail("device must advertise local preparation, workspace reads and output export") } - if !caps.LocalEnvironmentNetworkPolicy { - return fail("device must advertise local_environment_network_policy") - } } if snapshot.Environment != nil && snapshot.Environment.Type == "none" && !caps.EnvironmentNone { return fail("device must advertise environment_none") From f2c89fc2fadca5acab7d0e47f4eb75c602e2a44f Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:16:52 +0000 Subject: [PATCH 02/11] refactor(runtime): share user-level preparation and remove system installation --- .github/workflows/native-daemon.yml | 23 +- CONTRIBUTING.md | 218 ++++-------- Makefile | 1 - .../internal/agent/claudesdk/local.go | 4 - .../internal/agent/claudesdk/workspace.go | 15 +- .../agent/clirunner/process_group_windows.go | 41 ++- .../agent/clirunner/process_job_windows.go | 67 ++++ .../agent/codex/permission_profile_test.go | 37 +- .../internal/agent/codex/session_plan.go | 4 + .../internal/agent/mcode/workspace.go | 10 +- .../internal/cli/native_install.go | 1 - .../internal/daemonize/fork_windows.go | 20 +- .../dispatch/workspace_directory_test.go | 9 +- .../internal/localworkspace/binding.go | 6 +- .../internal/localworkspace/capabilities.go | 2 - .../internal/localworkspace/initialization.go | 115 ++++--- .../internal/localworkspace/mcp.go | 2 - .../internal/localworkspace/native_binding.go | 10 +- .../localworkspace/runtime_initialization.go | 202 ++++++++++- .../runtime_initialization_linux.go | 174 ---------- .../runtime_initialization_linux_test.go | 194 ----------- .../runtime_initialization_other.go | 16 - .../runtime_initialization_process.go | 147 ++++++++ .../runtime_initialization_test.go | 318 ++++++++++++++++++ contracts/agents-api/core.openapi.yaml | 5 - contracts/agents-api/openapi.yaml | 81 ++--- .../agents-api/v1/environment_templates.go | 1 - contracts/agents-api/v1/environments.go | 1 - docs/self-hosted-native.md | 26 +- internal/agentdaemon/proto/environment.go | 4 - internal/agentdaemon/proto/runtime_prepare.go | 2 +- .../agentdaemon/proto/runtime_prepare_test.go | 3 +- packages/claude-sdk-adapter/src/workspace.ts | 9 +- .../tests/workspace.test.mjs | 7 +- packages/mcode-harness/launch.test.mjs | 4 +- scripts/build-agents-api.sh | 2 +- scripts/build-agents-runtime.sh | 2 - scripts/build-claude-runtime.sh | 2 - scripts/build-mcode-runtime.sh | 2 - scripts/core-distribution-manifest.py | 2 - scripts/native-harness-smoke.mjs | 116 +++++++ services/agents-api/deploy/claude/Dockerfile | 8 +- services/agents-api/deploy/claude/README.md | 26 +- services/agents-api/deploy/codex/Dockerfile | 8 +- services/agents-api/deploy/codex/README.md | 169 ++++------ services/agents-api/deploy/e2b/README.md | 16 +- .../agents-api/deploy/e2b/build-template.py | 2 +- services/agents-api/deploy/e2b/init.py | 1 - services/agents-api/deploy/mcode/Dockerfile | 8 +- services/agents-api/deploy/mcode/README.md | 26 +- .../agents-api/deploy/microsandbox/README.md | 11 +- .../deploy/runtime/build-system-seed.py | 46 --- .../agents-api/deploy/runtime/initialize.py | 165 --------- .../deploy/runtime/initialize_receipt_test.py | 164 --------- .../deploy/runtime/initialize_test.py | 93 ----- .../agents-api/deploy/runtime/tool-root.py | 118 ------- .../internal/api/environment_packages_test.go | 63 ++++ .../internal/api/environment_request.go | 7 + .../internal/api/environment_setup.go | 25 +- .../internal/api/environment_templates.go | 4 +- .../api/environment_templates_test.go | 2 +- services/agents-api/internal/api/handler.go | 2 +- .../internal/api/hosted_environment_test.go | 5 +- .../internal/api/session_template.go | 4 - .../api/session_template_composition_test.go | 10 +- .../execution/environment_placement.go | 7 +- .../execution/environment_placement_test.go | 53 +-- .../internal/execution/runtime_setup.go | 4 +- .../internal/execution/runtime_setup_test.go | 2 +- .../store/environment_packages_test.go | 39 +++ .../internal/store/environment_setup.go | 20 +- .../internal/store/environment_setup_test.go | 4 +- .../internal/store/environment_templates.go | 2 +- .../store/provisioning_failure_detail.go | 2 +- .../store/runtime_environment_terminal.go | 4 +- .../runtime_environment_terminal_test.go | 1 - .../store/runtime_initialization_test.go | 4 +- .../store/template_composition_public_test.go | 2 +- 78 files changed, 1461 insertions(+), 1571 deletions(-) create mode 100644 apps/parsar-daemon/internal/agent/clirunner/process_job_windows.go delete mode 100644 apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux.go delete mode 100644 apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux_test.go delete mode 100644 apps/parsar-daemon/internal/localworkspace/runtime_initialization_other.go create mode 100644 apps/parsar-daemon/internal/localworkspace/runtime_initialization_process.go create mode 100644 apps/parsar-daemon/internal/localworkspace/runtime_initialization_test.go create mode 100644 scripts/native-harness-smoke.mjs delete mode 100644 services/agents-api/deploy/runtime/build-system-seed.py delete mode 100644 services/agents-api/deploy/runtime/initialize.py delete mode 100644 services/agents-api/deploy/runtime/initialize_receipt_test.py delete mode 100644 services/agents-api/deploy/runtime/initialize_test.py delete mode 100644 services/agents-api/deploy/runtime/tool-root.py create mode 100644 services/agents-api/internal/api/environment_packages_test.go create mode 100644 services/agents-api/internal/store/environment_packages_test.go diff --git a/.github/workflows/native-daemon.yml b/.github/workflows/native-daemon.yml index 1c93ddd2b..65c667800 100644 --- a/.github/workflows/native-daemon.yml +++ b/.github/workflows/native-daemon.yml @@ -14,7 +14,7 @@ jobs: matrix: os: [ubuntu-22.04, macos-14, windows-2022] runs-on: ${{ matrix.os }} - timeout-minutes: 20 + timeout-minutes: 30 defaults: run: shell: bash @@ -38,7 +38,26 @@ jobs: go test -race -count=1 ./apps/parsar-daemon/internal/localworkspace ./apps/parsar-daemon/internal/cli - -run 'TestNative|TestPreparationFreezesLocalContentsAcrossReconnect|TestSnapshotMarker' + -run 'TestNative|TestRuntimeInit|TestRuntimePreparationRejects|TestPreparationFreezesLocalContentsAcrossReconnect|TestSnapshotMarker' + - uses: actions/setup-node@v6 + with: + node-version: '22' + - name: Install pinned native Harness dependencies + run: | + npm install --global pnpm@10.30.3 + pnpm install --frozen-lockfile --filter @parsar/claude-sdk-adapter... + pnpm --filter @parsar/claude-sdk-adapter build + npm install --prefix "$RUNNER_TEMP/native-tools" --no-save @openai/codex@0.153.4 + mkdir -p "$RUNNER_TEMP/claude-runtime" + cp packages/claude-sdk-adapter/package.json "$RUNNER_TEMP/claude-runtime/" + cp -R packages/claude-sdk-adapter/dist "$RUNNER_TEMP/claude-runtime/" + npm install --prefix "$RUNNER_TEMP/claude-runtime" --omit=dev --ignore-scripts --package-lock=false + - name: Verify native Harness startup without model requests + run: | + if [[ "$RUNNER_OS" == Windows ]]; then + export CLAUDE_CODE_GIT_BASH_PATH='C:\Program Files\Git\bin\bash.exe' + fi + node scripts/native-harness-smoke.mjs --codex-package "$RUNNER_TEMP/native-tools/node_modules/@openai/codex" --claude-runtime "$RUNNER_TEMP/claude-runtime" - uses: actions/upload-artifact@v6 with: name: oac-daemon-${{ runner.os }}-${{ runner.arch }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 36658674e..9a21c40a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -664,14 +664,14 @@ installation directories are not portable public paths. Inline and referenced Skill ZIPs use the same confidential initialization snapshot and installer. Core validates portable manifests and bounded regular-file archives, returns only safe Skill metadata, and freezes content before native preparation. The Runtime -owns `/environment/initialization/capabilities/skills/`; setup and native tools may read but -not modify this tree. Public Plugin ZIPs preserve their complete package +owns `/environment/initialization/capabilities/skills/`; the manifest validates the installed snapshot before reuse. This is state +consistency, not protection from the launching user. Public Plugin ZIPs preserve their complete package layout and reuse the shared archive and portable Skill parsers. Core keeps safe Plugin metadata separate from encrypted archives. Templates inherit or replace Plugin and capability-directory lists through the same hosted resolver. After ordered setup, the existing initializer snapshots declared workspace-contained -capability directories into protected storage and writes one installed manifest. +capability directories into Runtime storage and writes one installed manifest. This is an initialization artifact, not a second lifecycle owner or database ledger. Directory bytes are observed after setup; they are not frozen at Session creation. The common daemon resolves the manifest only for executable preparation and passes @@ -690,40 +690,21 @@ fails explicitly, without silent partial activation or a generic plugin framewor Environment-origin MCP declarations use the shared Plugin parser and frozen installed packages. The installation manifest retains selected MCP package roots; -Runtime re-parses those protected packages without another configuration copy, +Runtime re-parses those installed packages without another configuration copy, credential cache or lifecycle ledger. Native adapters must explicitly qualify and project supported declarations before public admission. Parent-directory Skill discovery does not activate nested Plugin MCP configuration. -The packaged stdio entry enters the existing initialization sandbox before parsing -or executing a server. It mounts only the fixed static daemon helper, which resolves -the selected installed declaration, reads explicitly selected initialized user -variables, applies package-relative cwd and replaces itself with the server. Native -MCP stdin/stdout pass directly to the sandbox; no protocol forwarding loop, -Provider command or second Plugin parser is introduced. The existing Python stdio -entry remains a single-threaded launcher and monitors the native parent process -through Linux pidfds. Native Codex recycles spawning threads, so binding bwrap's -parent-death signal directly to those threads kills live MCP connections. The -launcher gives bwrap a stable parent, retains its parent-death/PID namespace cleanup, -and kills and reaps only that child when the native process exits. A pre-exec -parent-death signal and expected-parent PID check close the reproduced fork-to-exec -orphan window; bind libc before fork and keep this entry single-threaded. Missing -pidfd support fails closed. This fixes the OS process lifetime boundary without adding a -Core execution owner, retry or reconnect loop. -Model/daemon launch variables are never credential sources. Hosted stdio requires -enabled network; native HTTP requires its own qualified network and redirect behavior. -Claude composes the existing MCP identity/observation profile with its workspace -profile. Verify the complete native inventory before admitting tool identities; -MCP allowlist patterns never grant local Bash or file authority. Only declared -random bearer references enter native query env, with native Bash denial retained. -With system packages, Claude uses its packaged shell-prefix dispatcher. Only the -exact fixed Runtime stdio invocation bypasses the tool root; it immediately execs -the existing isolated launcher. All other full command strings pass unchanged to -the tool root. The native Bash sandbox and cwd receipt remain in their original -order. Do not move wrapping into Bash input hooks or add a directory-state owner. -Keep this native distinction in the adapter; Core and the common launcher do not -select an engine. Per-server env cannot disable the native global prefix because -Claude selects the executable before merging that env. +The common daemon stdio entry resolves the installed declaration and launches its +server under the same user permissions as the Harness. Unix replaces the helper +process; Windows forwards native stdio within the owned process tree. Explicit +initialized values override the selected declaration's variables. There is no +Python sandbox launcher, mount policy, shell prefix or separate credential sandbox. +Process groups and Windows Jobs own cancellation and descendant cleanup only. +Claude composes MCP identity and observation with the same workspace profile; +MiniMax keeps its existing supported transport qualification. A public capability +advertisement must still match the installed engine's actual supported transport. + Environment-origin literal HTTP headers remain rejected for the pinned Claude client because its interpolation and cross-origin forwarding change their meaning. MiniMax accepts environment stdio only. Its adapter reads the existing Session-private @@ -733,7 +714,7 @@ never fabricate a delayed start event, guess normalized identities or add a regi of our own. Its unqualified HTTP transport remains rejected. These mechanisms alone do not establish public MCP support or full compatibility. -Name, enabled/disabled/exact-domain restricted network, initial files, inline/referenced Skills, Plugins, workspace capability directories and env/setup/system/npm/Python are +Name, initial files, inline/referenced Skills, Plugins, workspace capability directories and env/setup/npm/Python are implemented independently of remaining installation fields. Reject unsupported inputs rather than persisting them for silent omission; expand inline and template initialization together in separately qualified @@ -1254,13 +1235,12 @@ continue bounded scans for late resources without issuing another Create. This conservative internal lifecycle does not define user-managed enrollment or prove complete upstream expiry/error semantics. -Qualify the actual Docker/native sandbox before default cutover: real model -execution, file access, owned cancellation, restart with retained native history -and files, and rejection when required history is missing. Generated code and file -tools must not read daemon/model credentials, foreign Session history or another -tenant's workspace. Same-container placement, matching UID, mode bits, directory -bindings and capability flags do not prove isolation. Retain failed probes and -unverified limits; private functionality is not public hosted acceptance. +Qualify the actual Runtime before cutover: execution, file access, owned +cancellation, restart with retained history and files, and clear failure when +required history is missing. Daemon tools run with the launching account's full +permissions. Managed isolation is provided by the outer Environment. A user who +installs on a host does not receive a sandbox or protection from their own tools. +Keep failed probes and unverified platform combinations explicit. Before migration, archive existing edits and validation evidence. Reuse verified authorization, resource/lifecycle ownership and safe filesystem primitives as @@ -1269,38 +1249,16 @@ mechanisms. Remove superseded unused code, configuration, tests, scripts and task-owned temporary resources as each replacement is accepted. Preserve necessary regressions, still-used official capabilities, product data and others' work. -The opt-in Codex deployment selector `OAC_RUNTIME_CODEX_PERMISSION_PROFILE` chooses a -native named profile at harness startup and on both new/resumed threads, omitting -the legacy sandbox override. It is operator configuration, never a prompt option, -and rejects remote, none and temporary read preparations. Native managed -requirements own allowed profiles and deny-read enforcement. Keep the selector -unset for existing deployments. Managed native shells disable shell snapshots, -whose private files are inaccessible to tool execution; retain normal native shell -startup without granting tools access to harness state. -The [co-location qualification inputs](services/agents-api/deploy/codex/README.md) -record the pinned native/Docker prerequisites and limits; this switch alone does -not admit hosted Environments or authorize a workspace. - -A managed Runtime's network policy is immutable deployment input, transferred -through the provider-neutral bootstrap and checked against execution preparation. -The shared policy includes enabled, disabled and an exact-host restricted allowlist. -Core preserves public spelling/order/duplicates and only permits Template overrides -that narrow authority. Adapters translate a normalized copy into native settings; -Core and Docker never select native profile names. Every hosted execution peer -must support `local_environment_network_policy` and receive the complete bound -policy; missing policy never falls back to enabled or an older peer path. Read-only -workspace access does not require execution network policy. A declaration alone -does not qualify an image or admit public hosted creation. - -Codex restricted networking uses its native managed network requirements and proxy. -Its adapter preserves the image's filesystem, approval and hook requirements and -adds the frozen exact-host ceiling in Session-private state. The existing RPC -client owns a bubblewrap child that mounts those requirements read-only and runs -the stock native app-server in a PID namespace; teardown retains the same owner. -Preparation regenerates the non-secret requirements from the frozen policy. Keep -the file with Session state so cleanup cannot race a running child's mount. -Claude and MiniMax translate the same policy into their native sandbox allowlists. -These translations do not add a Core network service or model/tool loop. +All Harness adapters use bypass execution. Do not restore named Codex permission +profiles, bubblewrap wrappers, native Claude sandbox settings or MiniMax +SandboxManager branches. There is one execution path for every Environment origin. +Resource paths are ordinary operator configuration, not a permission boundary. + +The daemon does not enforce disabled or restricted network policies. Such a +combination must be rejected unless its outer Environment implementation provides +and qualifies the requested behavior. Do not advertise daemon-level network +isolation or silently run a restricted request with unrestricted semantics. +The normal self-hosted combination uses the host's existing network access. A dedicated local Runtime uses one Environment-scoped device credential and an immutable binding to that Environment's Session. It is excluded from general @@ -1315,8 +1273,7 @@ freezes the Environment, Session and workspace root; requests cannot supply a replacement root. The V1 path uses only the exact local Environment reference. Use the same preparation/start lifecycle for native execution and the existing bounded workspace controls for directory access. Local idle directory -reads use the existing filesystem helper directly, with no model credentials or -temporary harness. These private capabilities alone do not authorize public requests or establish +reads use the common Go filesystem implementation without a temporary Harness. These private capabilities alone do not authorize public requests or establish Provider lifecycle. Self-hosted enrollment supplies the exact local binding. Core rechecks the persisted Environment/device binding for preparation and active reads; capability discovery cannot select or authorize a general device for this @@ -3357,8 +3314,8 @@ runs its lifecycle tests and script checks, while its exact-source Linux build a Docker qualification (including `native.test.mjs` under both network policies) are required when the companion changes. -Claude hosted functions compose the existing SDK function bridge with the native -workspace sandbox. Only declared function tools and the verified native tool +Claude hosted functions compose the existing SDK function bridge with the common +workspace execution profile. Only declared function tools and the verified native tool inventory are available. The bundle advertises this combination separately from basic workspace execution; function preparations require that verified combination. Service-origin hosted MCP remains unqualified; Environment Plugin declarations @@ -3368,38 +3325,20 @@ credential, history, subagent or network authority. ### 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. - -`OAC_RUNTIME_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 -`OAC_RUNTIME_HOME/runtime/claude-sdk`; daemon authentication stays under -`OAC_RUNTIME_HOME/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. The separately advertised `workspace_functions` -combination supports declared public functions with text results. Service-origin -HTTP MCP remains unqualified here; its existing `none` support is retained. -Environment Plugin MCP follows the separately qualified transport path above. -Native Bash network access uses the harness's HTTP proxy; no alternate networking -or tool loop is implemented by Core. +`scripts/build-claude-runtime.sh`. Native self-hosted installations use the same +bundle and daemon protocol. See [engine setup](services/agents-api/deploy/claude/README.md). +The shared local binding selects the actual workspace. SDK history, home and +scratch remain under `OAC_RUNTIME_HOME/runtime/claude-sdk` for session ownership, +without restricting tools. Authentication remains under `OAC_RUNTIME_HOME/daemon`. +The daemon requires neither nested sandbox privileges nor host security changes. + +The adapter advertises `local_runtime_v1` after checking the installed bridge and +native binary. Windows additionally needs Git Bash for its Bash tool. Registration +combines that check with the local binding; Core consumes the same readiness +contract on every platform. The profile supports Bash/Read/Edit, preparation, +shared Files/Artifacts, cancellation and history continuation. Other capabilities +remain subject to their actual engine qualification; bypass execution does not +silently qualify a new MCP or function combination. 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 @@ -3452,7 +3391,7 @@ features, not new Core lifecycle or public protocol variants. The current qualified path is Claude SDK, `environment:none` or Core-managed Docker `openai_hosted`, medium verbosity, single Agent, with optional ordinary function tools and text results. The workspace uses its existing preparation and -native sandbox with only the SDK's configured `StructuredOutput` tool added to +bypass execution profile with the SDK's configured `StructuredOutput` tool added to inventory and permission checks. Frozen schemas reach preparation before the input handoff; Start cannot replace them. Skills, Plugins, capability directories, HTTP MCP, Subagent/tool-discovery combinations and schemas without an explicit @@ -3578,24 +3517,17 @@ The qualified live workspace fixture also checks binary, empty and bounded reads before input and during real execution, plus effects before cancellation and reads on fresh-process history continuation. -The optional private `agent.WorkspaceDirectoryLister` requires the Linux-only -`workspace_directory` bridge feature on the same Executor. -The pinned SDK has no directory-enumeration control; its fuzzy file suggestions are -not an inventory. This narrow adapter operation therefore reads metadata inside the -already qualified co-located mount/process boundary. It pins the configured workspace -root and opens each relative directory component with `O_DIRECTORY | O_NOFOLLOW`, -using `/proc/self/fd` paths anchored to held descriptors. It rejects directory symlink -traversal; `lstat` reports a symlink entry itself without following its target. -The fixed workspace policy requires canonical, disjoint protected roots and excludes -dynamic permission replacement and remote-workspace fallback. It does not implement -an additional permission engine or authorize an unqualified placement. +The optional private `agent.WorkspaceDirectoryLister` uses the portable +`workspace_directory` bridge feature. Node reads directory metadata under the +selected workspace; there is no Linux `/proc/self/fd` dependency. Public daemon +Files operations share the Go Binding implementation on all platforms. Their +relative path and result bounds define the Files API, not Harness permissions. Directory requests are bounded to 8 KiB and 1,000 immediate entries, with explicit truncation, literal names, kinds, and sizes only for regular files. They do not promise ordering, snapshots, recursion, or public pagination. Missing and permission errors are returned only from distinguishable filesystem outcomes; unknown results stop the -owner. Each operation closes its directory and intermediate descriptors before a -successful receipt; the root descriptor remains owned until bridge release. +owner. Each operation closes its directory before a successful receipt. Caller cancellation, Turn transitions and owner shutdown retain the existing workspace read settlement rules. This adapter gap fill alone does not enable public Claude Files; the dedicated Runtime integration supplies public placement and ownership. @@ -3945,15 +3877,14 @@ Providers only place, create, bootstrap, inspect, renew and reclaim resources; t do not execute Core initialization commands. The common runner uses only neutral Environment/Session identity and a Runtime peer, with no Provider, deployment or OS branch. Harness differences belong to native adapters. Package installation and -setup retain enabled provisioning network access; the requested network policy -constrains native execution after setup. +setup use the host network; the daemon does not introduce a network sandbox. A missing authenticated Runtime connection waits before capability initialization is claimed; an operation whose effect is unknown is not replayed. Self-hosted capability directories remain in immutable Environment configuration and never create a managed initialization row or allocation. The private `runtime_prepare`/`runtime_prepare_result` exchange carries typed -initial files, configure/system/npm/python/setup operations, inert Skill/Plugin +initial files, configure/npm/python/setup operations, inert Skill/Plugin archives and finalization selections, with canonical Session and Environment identities. Files and setup working directories use logical `/workspace` addresses; setup commands are explicit typed input, while executable selection and physical @@ -3966,25 +3897,22 @@ filesystem work through settlement, including disconnect or cancellation. The source-selection protocol accepts portable absolute Unix, Windows drive and UNC paths without consulting Core's filesystem. Runtime interprets and authorizes -local paths. The current implementation is Linux-only, with its existing packaged -helpers and layout; the interface imposes no Linux executable or platform field. -The common Linux binding authorizes local directories, excluding aliases, Runtime -credentials, native history and initialization roots or their ancestors. One shared -parser writes `installed.json` below the operator-owned capability directory -(default `/environment/initialization/capabilities`, selected at startup through -`OAC_RUNTIME_CAPABILITY_DIRECTORY`). The root cannot overlap the workspace or -private Runtime state and is never a public input. All adapters consume the same -resolved installation root. The manifest -binds Session/Environment identities and the ordered source-selection digest. -A private completion receipt records only the installation root outside the -installed tree, so a deleted snapshot cannot be mistaken for first preparation. -It is written durably before execution, contains no capability configuration and -is never exposed as a second inventory. Filesystem locking prevents overlapping -installation. A partial, invalid or foreign -snapshot fails closed without deleting files or silently reinstalling. Directory -sources are copied only during first preparation; reconnect loads protected installed -contents and validates the selection without rereading source directories. A new -Session uses its own Environment and fresh snapshot. +local paths. Linux, Windows and macOS use the same preparation implementation, +parser and installed manifest. Runtime paths are chosen by the operator and never +by a transfer request. Installation and package directories default under +`OAC_RUNTIME_HOME`; packaged images can select their existing directories through +explicit Runtime configuration. The manifest binds Session/Environment identities +and the ordered source-selection digest. A completion receipt prevents a deleted +snapshot from being mistaken for first preparation, while filesystem locking +prevents concurrent installation. Invalid, partial or foreign snapshots fail +without deleting data or silently reinstalling. Reconnection loads installed +contents without rereading sources; a new Session captures a fresh snapshot. + +The daemon runs as its launching account and never uses sudo or elevates its +permissions. Only user-directory dependencies are installed during preparation. +System dependencies belong in managed image/template builds or must be installed +by the self-hosted user. `packages.system` is rejected explicitly, including in +templates; it is not ignored or translated into a privileged operation. Synchronous executor admission validates the frozen descriptor only. The admitted asynchronous preparation owner ensures capabilities are ready before invoking the diff --git a/Makefile b/Makefile index 0fc278baf..7ecc7e6dc 100644 --- a/Makefile +++ b/Makefile @@ -60,7 +60,6 @@ check-agents-api: build-agents-api # Persistence integration tests include bounded lifecycle waits that together exceed Go's 10m default. go test ./services/agents-api/... ./packages/agents-client/... -count=1 -timeout=20m PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s services/agents-api/tests -p 'official_diagnostics_test.py' - PYTHONDONTWRITEBYTECODE=1 python3 services/agents-api/deploy/runtime/initialize_receipt_test.py PYTHONDONTWRITEBYTECODE=1 python3 services/agents-api/deploy/e2b/managed_init_test.py docker-build-agents-api: diff --git a/apps/parsar-daemon/internal/agent/claudesdk/local.go b/apps/parsar-daemon/internal/agent/claudesdk/local.go index 1a0bb2ab8..35a3d6f42 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/local.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/local.go @@ -6,7 +6,6 @@ import ( "path/filepath" "strings" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" ) @@ -19,9 +18,6 @@ func ConfigureLocal(config Config, root, workspace string, network agentnetwork. HomeDir: filepath.Join(root, "runtime", "claude-sdk", "home"), ScratchDir: filepath.Join(root, "runtime", "claude-sdk", "scratch"), } - if binding, err := localworkspace.Load(); err == nil && binding != nil { - config.Workspace.ExecutionMode = binding.ExecutionMode() - } if network.Validate() != nil { return Config{}, fmt.Errorf("claudesdk: dedicated Runtime requires an explicit network policy") } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go index 195581069..c6cc95c09 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go @@ -18,7 +18,6 @@ import ( // State directories must already exist. // PublicDirectory may name a second mount of the same workspace inode. type WorkspaceConfig struct { - ExecutionMode string Directory string PublicDirectory string NetworkAccess string @@ -29,7 +28,6 @@ type WorkspaceConfig struct { type workspaceProfile struct { ToolEnv map[string]string `json:"tool_env,omitempty"` - ExecutionMode string `json:"execution_mode,omitempty"` CapabilityRoot string `json:"capability_root,omitempty"` MCP []environmentMCPServer `json:"mcp,omitempty"` Skills []agentcapabilities.InstalledSkill `json:"skills,omitempty"` @@ -42,7 +40,7 @@ type workspaceProfile struct { } func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspaceProfile, []string, error) { - if req.DisableExecutionEnvironment || req.MCPHTTPServers != nil || (req.LocalEnvironment != nil && req.LocalEnvironment.ExecutionMode != config.Workspace.ExecutionMode) { + if req.DisableExecutionEnvironment || req.MCPHTTPServers != nil { return nil, nil, fmt.Errorf("claudesdk: workspace profile does not support the requested execution combination") } if req.WorkDir != "" && req.WorkDir != config.Workspace.Directory { @@ -85,14 +83,13 @@ func workspaceCwd(w *WorkspaceConfig) string { 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. +// Harness state paths and selected model credentials overlay the user environment. func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { fail := func() (*workspaceProfile, []string, error) { return nil, nil, fmt.Errorf("claudesdk: invalid trusted workspace configuration") } w := config.Workspace - if w == nil || (w.ExecutionMode != "" && w.ExecutionMode != "native") || (w.NetworkAccess != "" && w.NetworkAccess != "enabled") || len(w.AllowedDomains) != 0 { + if w == nil || (w.NetworkAccess != "" && w.NetworkAccess != "enabled") || len(w.AllowedDomains) != 0 { return fail() } for _, path := range []string{config.Node, config.Entrypoint, filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js")} { @@ -113,7 +110,7 @@ func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { return fail() } } - profile := &workspaceProfile{ExecutionMode: w.ExecutionMode, Home: w.HomeDir, State: config.StateDir, Scratch: w.ScratchDir, EnvNames: []string{}, NetworkAccess: w.NetworkAccess, AllowedDomains: []string{}} + profile := &workspaceProfile{Home: w.HomeDir, State: config.StateDir, Scratch: w.ScratchDir, EnvNames: []string{}, NetworkAccess: w.NetworkAccess, AllowedDomains: []string{}} env := []string{} for _, entry := range os.Environ() { name, _, _ := strings.Cut(entry, "=") @@ -156,7 +153,3 @@ func canonicalWorkspaceDir(dir string) bool { func workspacePathSyntax(dir string) bool { return filepath.IsAbs(dir) && filepath.Clean(dir) == dir && strings.IndexFunc(dir, func(r rune) bool { return r < 32 || r == 127 }) == -1 } -func pathContains(parent, child string) bool { - rel, err := filepath.Rel(parent, child) - return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) -} diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_group_windows.go b/apps/parsar-daemon/internal/agent/clirunner/process_group_windows.go index 796079db9..4604a23ef 100644 --- a/apps/parsar-daemon/internal/agent/clirunner/process_group_windows.go +++ b/apps/parsar-daemon/internal/agent/clirunner/process_group_windows.go @@ -101,8 +101,9 @@ func startProcessGroup(opts StartOptions) (*Process, error) { waitErr = cmd.Wait() // A successful leader exit cannot leave detached descendants behind. _ = group.cancel() - group.finish() - close(p.done) + if group.finish() { + close(p.done) + } }() return p, nil } @@ -130,10 +131,12 @@ func resumeInitialThread(pid uint32) error { } type ownedJob struct { - mu sync.Mutex - handle windows.Handle - finished bool - cancelled bool + mu sync.Mutex + handle windows.Handle + finished bool + cancelled bool + processes []windows.Handle + observationErr error } func (g *ownedJob) cancel() error { @@ -142,13 +145,29 @@ func (g *ownedJob) cancel() error { if g.finished { return os.ErrProcessDone } + if !g.cancelled { + // Hold process handles before termination removes them from the Job list. + g.processes, g.observationErr = jobProcessHandles(g.handle) + } g.cancelled = true return windows.TerminateJobObject(g.handle, 1) } -// Querying the Job, rather than waiting only for its leader, confirms cleanup. -// An observation failure retains ownership and blocks reconnect. -func (g *ownedJob) finish() { +// Job accounting can reach zero before the process objects become signaled. +// Keep ownership until both observations confirm cleanup; never wait with mu held. +func (g *ownedJob) finish() bool { + g.mu.Lock() + processes, observationErr := g.processes, g.observationErr + g.mu.Unlock() + if observationErr != nil { + return false + } + for _, process := range processes { + status, err := windows.WaitForSingleObject(process, windows.INFINITE) + if err != nil || status != windows.WAIT_OBJECT_0 { + return false + } + } type accounting struct { UserTime, KernelTime, PeriodUserTime, PeriodKernelTime int64 PageFaults, TotalProcesses, ActiveProcesses, TerminatedProcesses uint32 @@ -164,5 +183,9 @@ func (g *ownedJob) finish() { g.mu.Lock() defer g.mu.Unlock() g.finished = true + for _, process := range processes { + windows.CloseHandle(process) + } windows.CloseHandle(g.handle) + return true } diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_job_windows.go b/apps/parsar-daemon/internal/agent/clirunner/process_job_windows.go new file mode 100644 index 000000000..ccc3bae11 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/clirunner/process_job_windows.go @@ -0,0 +1,67 @@ +//go:build windows + +package clirunner + +import ( + "errors" + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +var isProcessInJob = windows.NewLazySystemDLL("kernel32.dll").NewProc("IsProcessInJob") + +// A PID list alone cannot confirm exit: retain handles until termination signals +// each process. Check membership after opening to avoid a recycled foreign PID. +func jobProcessHandles(job windows.Handle) ([]windows.Handle, error) { + pids, err := jobProcessIDs(job) + if err != nil { + return nil, err + } + var handles []windows.Handle + for _, pid := range pids { + process, err := windows.OpenProcess(windows.SYNCHRONIZE|windows.PROCESS_QUERY_INFORMATION, false, uint32(pid)) + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + continue + } + if err != nil { + return handles, err + } + var belongs int32 + ok, _, queryErr := isProcessInJob.Call(uintptr(process), uintptr(job), uintptr(unsafe.Pointer(&belongs))) + if ok == 0 { + windows.CloseHandle(process) + return handles, queryErr + } + if belongs == 0 { + windows.CloseHandle(process) + continue + } + handles = append(handles, process) + } + return handles, nil +} + +func jobProcessIDs(job windows.Handle) ([]uintptr, error) { + type processList struct { + Assigned, Count uint32 + First uintptr + } + for size := 64; ; size *= 2 { + // uintptr storage keeps the variable-length Windows structure aligned. + storage := make([]uintptr, size+2) + list := (*processList)(unsafe.Pointer(&storage[0])) + err := windows.QueryInformationJobObject(job, windows.JobObjectBasicProcessIdList, uintptr(unsafe.Pointer(list)), uint32(len(storage)*int(unsafe.Sizeof(uintptr(0)))), nil) + if errors.Is(err, windows.ERROR_MORE_DATA) || (err == nil && list.Count < list.Assigned) { + continue + } + if err != nil { + return nil, err + } + if list.Count > uint32(size) { + return nil, fmt.Errorf("clirunner: invalid Job process count") + } + return append([]uintptr(nil), unsafe.Slice(&list.First, int(list.Count))...), nil + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go b/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go index 56e80fd55..3d597d6a8 100644 --- a/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go +++ b/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go @@ -2,13 +2,16 @@ package codex import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "os" + "path/filepath" + "strings" "testing" ) func TestRuntimeUsesHostPermissions(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - for _, mode := range []string{"", "native"} { - req := proto.PromptRequestPayload{AgentStateKey: "session", DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{ExecutionMode: mode, NetworkAccess: "enabled"}} + { + req := proto.PromptRequestPayload{AgentStateKey: "session", DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{NetworkAccess: "enabled"}} plan, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{}) if err != nil { t.Fatal(err) @@ -30,3 +33,33 @@ func TestRuntimeUsesHostPermissions(t *testing.T) { } } } + +func TestInitializedEnvironmentCannotRedirectNativeHistory(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + workspace := filepath.Join(root, "workspace") + if err := os.Mkdir(workspace, 0700); err != nil { + t.Fatal(err) + } + config := filepath.Join(root, "tool-env.json") + if err := os.WriteFile(config, []byte(`{"CODEX_HOME":"wrong","HOME":"wrong","USERPROFILE":"wrong","USER_VALUE":"ready"}`), 0600); err != nil { + t.Fatal(err) + } + for key, value := range map[string]string{"OAC_RUNTIME_ENVIRONMENT_ID": "b3d154b8-543b-4248-97b1-665f9f418d52", "OAC_RUNTIME_SESSION_ID": "33e02e0d-6fc8-4904-9d7a-4b61b9094ae0", "OAC_RUNTIME_WORKSPACE": workspace, "OAC_RUNTIME_CAPABILITY_DIRECTORY": filepath.Join(root, "capabilities"), "OAC_RUNTIME_NETWORK_ACCESS": "enabled", "OAC_RUNTIME_TOOL_ENV_FILE": config} { + t.Setenv(key, value) + } + req := proto.PromptRequestPayload{AgentStateKey: "session", DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{NetworkAccess: "enabled", ToolEnvironment: true}} + plan, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{}) + if err != nil { + t.Fatal(err) + } + defer plan.Cleanup() + values := map[string]string{} + for _, entry := range plan.Env { + key, value, _ := strings.Cut(entry, "=") + values[key] = value + } + if values["CODEX_HOME"] == "" || values["CODEX_HOME"] == "wrong" || values["HOME"] == "wrong" || values["USERPROFILE"] == "wrong" || values["USER_VALUE"] != "ready" { + t.Fatal("initialization changed native state identity") + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_plan.go b/apps/parsar-daemon/internal/agent/codex/session_plan.go index 8b7c1bfb8..263aa3bd9 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_plan.go +++ b/apps/parsar-daemon/internal/agent/codex/session_plan.go @@ -3,6 +3,7 @@ package codex import ( "context" "fmt" + "strings" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" @@ -47,6 +48,9 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg return SessionPlan{}, nil, err } for key, value := range values { + if strings.EqualFold(key, "CODEX_HOME") || strings.EqualFold(key, "HOME") || strings.EqualFold(key, "USERPROFILE") { + continue + } plan.Env = append(plan.Env, key+"="+value) } } diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace.go b/apps/parsar-daemon/internal/agent/mcode/workspace.go index 78a72e57d..9af591662 100644 --- a/apps/parsar-daemon/internal/agent/mcode/workspace.go +++ b/apps/parsar-daemon/internal/agent/mcode/workspace.go @@ -15,7 +15,6 @@ import ( // WorkspaceConfig is frozen deployment input, separate from public Agent options. type WorkspaceConfig struct { - ExecutionMode string Binary, Node, Bridge, Directory, Network, Scratch string AllowedDomains []string } @@ -23,17 +22,13 @@ type WorkspaceConfig struct { func ConfigureLocal(binary, node, bridge, root, workspace string, network agentnetwork.Policy, staging string) (WorkspaceConfig, error) { c := WorkspaceConfig{Binary: binary, Node: node, Bridge: bridge, Directory: workspace, Network: network.Access, AllowedDomains: network.Hosts(), Scratch: filepath.Join(root, "runtime", "mcode-tools", "scratch")} - binding, bindingErr := localworkspace.Load() - if bindingErr == nil && binding != nil { - c.ExecutionMode = binding.ExecutionMode() - } if runtime.GOOS == "windows" || network.Access != "enabled" || len(network.AllowedDomains) != 0 { return c, fmt.Errorf("mcode: native execution requires Linux or macOS and unrestricted host access") } if network.Validate() != nil { return c, fmt.Errorf("mcode: explicit workspace network policy is required") } - for _, path := range []string{binary, node, bridge, root, workspace, staging} { + for _, path := range []string{binary, node, bridge, root, workspace} { if !filepath.IsAbs(path) || filepath.Clean(path) != path || path == "/" { return c, fmt.Errorf("mcode: canonical absolute deployment paths are required") } @@ -56,9 +51,6 @@ func ConfigureLocal(binary, node, bridge, root, workspace string, network agentn } func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.PromptRequestPayload) (launchOptions, error) { - if req.LocalEnvironment != nil && req.LocalEnvironment.ExecutionMode != c.ExecutionMode { - return launchOptions{}, fmt.Errorf("mcode: execution mode mismatch") - } if c.Network != "enabled" || len(c.AllowedDomains) != 0 { return launchOptions{}, fmt.Errorf("mcode: Runtime does not implement network isolation") } diff --git a/apps/parsar-daemon/internal/cli/native_install.go b/apps/parsar-daemon/internal/cli/native_install.go index 2b55e8cf2..e1273a284 100644 --- a/apps/parsar-daemon/internal/cli/native_install.go +++ b/apps/parsar-daemon/internal/cli/native_install.go @@ -127,7 +127,6 @@ func runStart(rc *runContext, args []string) error { return errors.New("start: this installation version is unsupported; reinstall explicitly") } for key, value := range map[string]string{ - "OAC_RUNTIME_EXECUTION_MODE": "native", "OAC_RUNTIME_WORKSPACE": config.Workspace, "OAC_RUNTIME_CAPABILITY_DIRECTORY": config.CapabilityDirectory, "OAC_RUNTIME_TOOL_ENV_FILE": config.ToolEnvironmentFile, diff --git a/apps/parsar-daemon/internal/daemonize/fork_windows.go b/apps/parsar-daemon/internal/daemonize/fork_windows.go index 6217b80bf..727d707a1 100644 --- a/apps/parsar-daemon/internal/daemonize/fork_windows.go +++ b/apps/parsar-daemon/internal/daemonize/fork_windows.go @@ -9,7 +9,6 @@ import ( "os/exec" "strings" "syscall" - "unsafe" "golang.org/x/sys/windows" ) @@ -20,22 +19,13 @@ func configureBackground(cmd *exec.Cmd) (func(), func() error, error) { return nil, nil, err } name := `Local\OpenAgentCore-daemon-` + hex.EncodeToString(nonce[:]) - user, err := windows.GetCurrentProcessToken().GetTokenUser() - if err != nil { - return nil, nil, err - } - sd, err := windows.SecurityDescriptorFromString("D:P(A;;GA;;;" + user.User.Sid.String() + ")") - if err != nil { - return nil, nil, err - } - attr := windows.SecurityAttributes{Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: sd} stopName, _ := windows.UTF16PtrFromString(name) - event, err := windows.CreateEvent(&attr, 1, 0, stopName) + event, err := windows.CreateEvent(nil, 1, 0, stopName) if err != nil { return nil, nil, err } readyName, _ := windows.UTF16PtrFromString(name + "-ready") - ready, err := windows.CreateEvent(&attr, 1, 0, readyName) + ready, err := windows.CreateEvent(nil, 1, 0, readyName) if err != nil { windows.CloseHandle(event) return nil, nil, err @@ -48,9 +38,9 @@ func configureBackground(cmd *exec.Cmd) (func(), func() error, error) { } } cmd.Env = append(filtered, stopEventEnv+"="+name) - // A short-lived installer must not own the detached daemon through its Job. - // If an enclosing Job forbids breakaway, Start fails instead of losing ownership. - cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_BREAKAWAY_FROM_JOB} + // Detach from the console without escaping a CI or service host Job. + // Such a host can still end the daemon when its own Job is closed. + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP} return clean, func() error { status, err := windows.WaitForSingleObject(ready, 60000) if err != nil { diff --git a/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go b/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go index 28a14c02a..9af962dd9 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go +++ b/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go @@ -3,6 +3,8 @@ package dispatch_test import ( "context" "fmt" + "os" + "path/filepath" "testing" "time" @@ -17,9 +19,14 @@ func TestWorkspaceDirectoryRetainsEnvironmentAndTransferredOwner(t *testing.T) { return &fakeSession{out: out, ctx: ctx, closeOutOnCancel: true}, nil } r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + for _, name := range []string{"file", "second"} { + if err := os.WriteFile(filepath.Join(os.Getenv("OAC_RUNTIME_WORKSPACE"), name), []byte("abc"), 0600); err != nil { + t.Fatal(err) + } + } _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "prepare", preparationRequest())) ready := waitPreparationStatus(t, sender, "prepare", "ready", "") - request := proto.WorkspaceReadPayload{Operation: "directory", Handle: ready.Handle, EnvironmentID: preparationEnvironmentID, MaxEntries: 2} + request := proto.WorkspaceReadPayload{Operation: "directory", Handle: ready.Handle, EnvironmentID: preparationEnvironmentID, MaxEntries: 1} for _, phase := range []string{"idle", "active"} { _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, phase, request)) result := waitWorkspaceRead(t, sender, phase) diff --git a/apps/parsar-daemon/internal/localworkspace/binding.go b/apps/parsar-daemon/internal/localworkspace/binding.go index bd1a90aa2..e8702b689 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding.go +++ b/apps/parsar-daemon/internal/localworkspace/binding.go @@ -16,7 +16,6 @@ import ( // Binding freezes operator-owned identity and paths for one Runtime lifetime. type Binding struct { - executionMode string environment string networkAccess string allowedDomains []string @@ -39,7 +38,6 @@ func New(environment, session, workspace, helper string) (*Binding, error) { return nil, err } b.helper = helper - b.executionMode = os.Getenv("OAC_RUNTIME_EXECUTION_MODE") return b, nil } @@ -50,7 +48,6 @@ func NewWithCapabilityDirectory(environment, session, workspace, helper, directo return nil, err } b.helper = helper - b.executionMode = os.Getenv("OAC_RUNTIME_EXECUTION_MODE") return b, nil } @@ -100,7 +97,6 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa } if !r.WorkspaceReadOnly { local := *r.LocalEnvironment - local.ExecutionMode = b.ExecutionMode() if local.WorkspaceDirectory != "/workspace" && local.WorkspaceDirectory != b.workspace { return r, errors.New("request does not match the local workspace selection") } @@ -109,7 +105,7 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa } sources := *local.CapabilitySources present := len(sources.Skills)+len(sources.Plugins)+len(sources.Directories) > 0 - if present != local.Capabilities || local.SystemPackages && !local.ToolEnvironment { + if present != local.Capabilities { return r, agentcapabilities.ErrInvalid } local.Skills, local.MCP, local.CapabilityRoot = nil, nil, "" diff --git a/apps/parsar-daemon/internal/localworkspace/capabilities.go b/apps/parsar-daemon/internal/localworkspace/capabilities.go index c2b79390c..9cf8b9ae9 100644 --- a/apps/parsar-daemon/internal/localworkspace/capabilities.go +++ b/apps/parsar-daemon/internal/localworkspace/capabilities.go @@ -45,7 +45,6 @@ func (b *Binding) Prepare(ctx context.Context, r proto.PromptRequestPayload) (pr } local := *r.LocalEnvironment local.Skills, local.MCP, local.CapabilityRoot = manifest.Skills, nil, b.capabilityRoot - local.ExecutionMode = b.ExecutionMode() for i := range local.Skills { local.Skills[i].InstallationRoot = b.capabilityRoot } @@ -66,7 +65,6 @@ func (b *Binding) Prepare(ctx context.Context, r proto.PromptRequestPayload) (pr for i := range local.MCP { local.MCP[i].InstallationRoot = b.capabilityRoot local.MCP[i].WorkspaceRoot = b.workspace - local.MCP[i].ExecutionMode = b.ExecutionMode() } if err != nil { return r, err diff --git a/apps/parsar-daemon/internal/localworkspace/initialization.go b/apps/parsar-daemon/internal/localworkspace/initialization.go index f7d4612e5..338ad7c78 100644 --- a/apps/parsar-daemon/internal/localworkspace/initialization.go +++ b/apps/parsar-daemon/internal/localworkspace/initialization.go @@ -5,81 +5,86 @@ import ( "errors" "os" "path/filepath" + "runtime" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" ) -// ReadToolEnvironment selects the immutable user configuration. It never reads -// process environment or introduces live-execution prerequisites for Files. +// InitializationDirectory resolves the operator's local resource layout. The +// protocol and preparation operations do not distinguish Environment sources. +func InitializationDirectory() (string, error) { + return initializationPath("OAC_RUNTIME_INITIALIZATION_DIRECTORY", "initialization") +} +func PackageDirectory() (string, error) { + return initializationPath("OAC_RUNTIME_PACKAGE_DIRECTORY", "packages") +} +func initializationPath(setting, name string) (string, error) { + if path := os.Getenv(setting); path != "" { + if runtimefs.ValidateLocalPath(path) != nil { + return "", errors.New("invalid Runtime initialization directory") + } + return path, nil + } + root, err := paths.Root() + if err != nil { + return "", err + } + return filepath.Join(root, name), nil +} + +// ReadToolEnvironment reads bounded explicit configuration, never ambient secrets. func ReadToolEnvironment() (map[string]string, error) { - info, err := os.Stat(toolEnvironmentPath()) - if err != nil || !info.Mode().IsRegular() || info.Size() > 1<<20 { - return nil, errors.New("initialized user environment unavailable") + path, err := toolEnvironmentPath() + if err != nil { + return nil, err } - body, err := os.ReadFile(toolEnvironmentPath()) + body, err := runtimefs.ReadPrivatePath(path, 1<<20) var values map[string]string - if err != nil || json.Unmarshal(body, &values) != nil || values == nil { + if err != nil || json.Unmarshal(body, &values) != nil || values == nil || !validToolEnvironment(values) { return nil, errors.New("initialized user environment unavailable") } return values, nil } - -// These paths belong to the packaged Runtime, not a harness or public template. -const ( - InitializationDirectory = "/environment/initialization" - ToolEnvironmentShell = InitializationDirectory + "/tool-env.sh" - ToolEnvironmentJSON = InitializationDirectory + "/tool-env.json" - PackageDirectory = "/environment/packages" - SystemPackageDirectory = PackageDirectory + "/system" - SystemPackageReceipt = InitializationDirectory + "/system-root.json" - SystemToolLauncher = "/usr/local/bin/oac-tool-root" -) - -// VerifyToolEnvironment is required only for execution consuming initialized -// tool configuration. It never makes Files reads depend on execution setup. -func VerifyToolEnvironment(systemPackages bool) error { - paths := []string{InitializationDirectory, PackageDirectory, ToolEnvironmentShell, ToolEnvironmentJSON} - if systemPackages { - paths = append(paths, SystemPackageDirectory, SystemPackageReceipt, SystemToolLauncher) - } - for _, path := range paths { - actual, err := filepath.EvalSymlinks(path) - info, statErr := os.Lstat(path) - if err != nil || statErr != nil || actual != path { - return errors.New("initialized tool configuration unavailable") - } - if path == InitializationDirectory || path == PackageDirectory || path == SystemPackageDirectory { - if !info.IsDir() { - return errors.New("initialized tool directory unavailable") +func validToolEnvironment(values map[string]string) bool { + seen := map[string]bool{} + for key, value := range values { + if runtime.GOOS == "windows" { + folded := strings.ToUpper(key) + if seen[folded] { + return false } - } else if !info.Mode().IsRegular() || info.Size() > 1024*1024 { - return errors.New("initialized tool configuration is not immutable") - } - } - if systemPackages { - raw, err := os.ReadFile(SystemPackageReceipt) - var receipt struct { - Version int `json:"version"` + seen[folded] = true } - if err != nil || json.Unmarshal(raw, &receipt) != nil || receipt.Version != 1 { - return errors.New("installed system tools unavailable") + if key == "" || strings.ContainsAny(key, "=\x00\r\n") || strings.ContainsRune(value, 0) { + return false } } - return nil + return true } -// ReadOptionalToolEnvironment permits a directory-only Runtime without user -// variables. Declared MCP variables still require the immutable explicit file. +// ReadOptionalToolEnvironment permits a Runtime without explicit user variables. func ReadOptionalToolEnvironment() (map[string]string, error) { - if _, err := os.Stat(toolEnvironmentPath()); errors.Is(err, os.ErrNotExist) { + path, err := toolEnvironmentPath() + if err != nil { + return nil, err + } + if _, err = os.Stat(path); errors.Is(err, os.ErrNotExist) { return map[string]string{}, nil } return ReadToolEnvironment() } - -// Native installations can select an explicit tool configuration file. Packaged -// installations retain their existing resource layout, without a sandbox. -func toolEnvironmentPath() string { +func toolEnvironmentPath() (string, error) { if path := os.Getenv("OAC_RUNTIME_TOOL_ENV_FILE"); path != "" { - return path + if runtimefs.ValidateLocalPath(path) != nil { + return "", errors.New("invalid Runtime tool environment file") + } + return path, nil + } + directory, err := InitializationDirectory() + if err != nil { + return "", err } - return ToolEnvironmentJSON + return filepath.Join(directory, "tool-env.json"), nil } diff --git a/apps/parsar-daemon/internal/localworkspace/mcp.go b/apps/parsar-daemon/internal/localworkspace/mcp.go index 146d1e812..38040e2d5 100644 --- a/apps/parsar-daemon/internal/localworkspace/mcp.go +++ b/apps/parsar-daemon/internal/localworkspace/mcp.go @@ -9,8 +9,6 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) -const MCPInitializer = "/usr/local/bin/oac-runtime-initialize" - // MCPStdioCommand resolves the common installed manifest in the daemon. func MCPStdioCommand(server proto.EnvironmentMCP) (string, []string) { executable, err := os.Executable() diff --git a/apps/parsar-daemon/internal/localworkspace/native_binding.go b/apps/parsar-daemon/internal/localworkspace/native_binding.go index 1dcf54df3..bf39d35b5 100644 --- a/apps/parsar-daemon/internal/localworkspace/native_binding.go +++ b/apps/parsar-daemon/internal/localworkspace/native_binding.go @@ -9,14 +9,6 @@ import ( "github.com/google/uuid" ) -// ExecutionMode selects the installation layout, never a permission boundary. -func (b *Binding) ExecutionMode() string { - if b == nil { - return "" - } - return b.executionMode -} - func newNativeBinding(environment, session, workspace, capabilities string) (*Binding, error) { for _, value := range []string{environment, session} { id, err := uuid.Parse(value) @@ -33,7 +25,7 @@ func newNativeBinding(environment, session, workspace, capabilities string) (*Bi if err != nil || !info.IsDir() { return nil, errors.New("local workspace root must be an existing directory") } - return &Binding{environment: environment, stateKey: "agents-api-" + session, workspace: workspace, capabilityRoot: capabilities, executionMode: "native", writer: &fileWriter{}}, nil + return &Binding{environment: environment, stateKey: "agents-api-" + session, workspace: workspace, capabilityRoot: capabilities, writer: &fileWriter{}}, nil } // ReadToolEnvironment reads explicit initialization values for this installation. diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization.go index c8c639eb1..6f3925aa4 100644 --- a/apps/parsar-daemon/internal/localworkspace/runtime_initialization.go +++ b/apps/parsar-daemon/internal/localworkspace/runtime_initialization.go @@ -1,11 +1,209 @@ package localworkspace -import "errors" +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" +) // InitializationFailure contains only a confirmed step's safe exit status. type InitializationFailure struct{ ExitCode *int } func (*InitializationFailure) Error() string { return "Runtime initialization failed" } -// ErrInitializationUnconfirmed means execution or its receipt was not confirmed. var ErrInitializationUnconfirmed = errors.New("Runtime initialization unconfirmed") + +func (b *Binding) initializeRuntime(ctx context.Context, input proto.RuntimeInitialization) error { + raw, err := json.Marshal(input) + if err != nil || len(raw) > proto.RuntimePrepareMaxFrameBytes { + return agentcapabilities.ErrInvalid + } + if ctx.Err() != nil { + return ErrInitializationUnconfirmed + } + if input.Action == "configure" { + return configureRuntime(input.Env) + } + if input.Action != "setup" && input.Action != "npm" && input.Action != "python" { + return agentcapabilities.ErrInvalid + } + if input.Network != "enabled" && input.Network != "disabled" { + return agentcapabilities.ErrInvalid + } + directory, err := b.initializationCWD(input.CWD) + if err != nil { + return err + } + values, err := ReadToolEnvironment() + if err != nil { + return &InitializationFailure{} + } + packages, err := PackageDirectory() + if err != nil { + return &InitializationFailure{} + } + var binary string + var args []string + switch input.Action { + case "setup": + if input.Command == "" || strings.ContainsRune(input.Command, 0) { + return agentcapabilities.ErrInvalid + } + binary, err = initializationBash() + args = []string{"--noprofile", "--norc", "-c", input.Command} + case "npm", "python": + if len(input.Packages) == 0 { + return agentcapabilities.ErrInvalid + } + for _, value := range input.Packages { + if value == "" || strings.HasPrefix(value, "-") || strings.ContainsAny(value, "\x00\r\n") { + return agentcapabilities.ErrInvalid + } + } + if err = os.MkdirAll(packages, 0700); err != nil { + return &InitializationFailure{} + } + if input.Action == "npm" { + binary, args, err = initializationNPM() + args = append(args, "install", "--global", "--prefix", filepath.Join(packages, "npm"), "--") + } else { + binary, err = initializationPython() + args = []string{"-m", "pip", "install", "--disable-pip-version-check", "--no-input", "--target", filepath.Join(packages, "python"), "--"} + } + args = append(args, input.Packages...) + } + if err != nil { + return err + } + return runInitializationProcess(ctx, binary, args, directory, initializationEnvironment(values)) +} + +func (b *Binding) initializationCWD(value string) (string, error) { + if value == "" || value == "/workspace" { + return b.workspace, nil + } + if !strings.HasPrefix(value, "/workspace/") { + return "", agentcapabilities.ErrInvalid + } + relative, err := nativeAPIPath(strings.TrimPrefix(value, "/workspace/")) + if err != nil { + return "", agentcapabilities.ErrInvalid + } + return filepath.Join(b.workspace, relative), nil +} + +func configureRuntime(values map[string]string) error { + if !validToolEnvironment(values) { + return agentcapabilities.ErrInvalid + } + path, err := toolEnvironmentPath() + if err != nil { + return &InitializationFailure{} + } + packages, err := PackageDirectory() + if err != nil { + return &InitializationFailure{} + } + if os.MkdirAll(filepath.Dir(path), 0700) != nil || os.MkdirAll(packages, 0700) != nil { + return &InitializationFailure{} + } + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return &InitializationFailure{} + } + defer root.Close() + unlock, err := runtimefs.LockDirectory(root) + if err != nil { + return &InitializationFailure{} + } + defer unlock() + if _, err = root.Stat(filepath.Base(path)); !errors.Is(err, os.ErrNotExist) { + return &InitializationFailure{} + } + configured := make(map[string]string, len(values)+2) + for key, value := range values { + if runtime.GOOS == "windows" { + key = strings.ToUpper(key) + } + configured[key] = value + } + separator := string(os.PathListSeparator) + npmBin := filepath.Join(packages, "npm", "bin") + pythonBin := filepath.Join(packages, "python", "bin") + if runtime.GOOS == "windows" { + npmBin = filepath.Join(packages, "npm") + pythonBin = filepath.Join(packages, "python", "Scripts") + } + pathValue, exists := configured["PATH"] + if !exists { + pathValue = os.Getenv("PATH") + } + configured["PATH"] = npmBin + separator + pythonBin + separator + pathValue + pythonPath := configured["PYTHONPATH"] + configured["PYTHONPATH"] = filepath.Join(packages, "python") + if pythonPath != "" { + configured["PYTHONPATH"] += separator + pythonPath + } + raw, err := json.Marshal(configured) + if err != nil || len(raw) > proto.RuntimePrepareMaxFrameBytes { + return agentcapabilities.ErrInvalid + } + if runtimefs.WritePrivateAtomic(root, filepath.Base(path), raw) != nil { + return ErrInitializationUnconfirmed + } + return nil +} + +func (b *Binding) installInitialFile(ctx context.Context, input proto.RuntimeInitialFile, data []byte) (err error) { + if b.writer == nil || !strings.HasPrefix(input.Path, "/workspace/") || len(data) > proto.RuntimePrepareMaxBytes { + return agentcapabilities.ErrInvalid + } + relative, err := nativeAPIPath(strings.TrimPrefix(input.Path, "/workspace/")) + if err != nil { + return agentcapabilities.ErrInvalid + } + if ctx.Err() != nil { + return ErrInitializationUnconfirmed + } + w := b.writer + if !w.mu.TryLock() { + return agentcapabilities.ErrInvalid + } + defer w.mu.Unlock() + if w.uncertain { + return ErrInitializationUnconfirmed + } + defer func() { w.uncertain = errors.Is(err, ErrInitializationUnconfirmed) }() + root, err := os.OpenRoot(b.workspace) + if err != nil { + return &InitializationFailure{} + } + defer root.Close() + if root.MkdirAll(filepath.Dir(relative), 0700) != nil { + return &InitializationFailure{} + } + parent, err := root.OpenRoot(filepath.Dir(relative)) + if err != nil { + return &InitializationFailure{} + } + defer parent.Close() + // Initial files replace existing contents, unlike public Files create. Finish + // the synchronous atomic write before returning even if cancellation arrives. + if runtimefs.WritePrivateAtomic(parent, filepath.Base(relative), data) != nil { + return ErrInitializationUnconfirmed + } + return nil +} + +func initializationDependency(name string) error { + return fmt.Errorf("Runtime initialization requires %s: %w", name, &InitializationFailure{}) +} diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux.go deleted file mode 100644 index 34603e821..000000000 --- a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux.go +++ /dev/null @@ -1,174 +0,0 @@ -//go:build linux - -package localworkspace - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/json" - "errors" - "io" - "strconv" - "strings" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func (b *Binding) initializeRuntime(ctx context.Context, input proto.RuntimeInitialization) error { - payload := struct { - Version int `json:"version"` - proto.RuntimeInitialization - }{Version: 1, RuntimeInitialization: input} - raw, err := json.Marshal(payload) - if err != nil || len(raw) > proto.RuntimePrepareMaxFrameBytes { - return agentcapabilities.ErrInvalid - } - result, err := runInitializationProcess(ctx, "/usr/bin/python3", []string{"-I", "-S", MCPInitializer, "initialize", b.workspace, b.capabilityRoot}, bytes.NewReader(raw)) - if err != nil { - return err - } - return decodeInitialization(result) -} - -func (b *Binding) installInitialFile(ctx context.Context, input proto.RuntimeInitialFile, data []byte) (err error) { - if b.writer == nil || !strings.HasPrefix(input.Path, "/workspace/") || len(data) > proto.RuntimePrepareMaxBytes { - return agentcapabilities.ErrInvalid - } - w := b.writer - if !w.mu.TryLock() { - return agentcapabilities.ErrInvalid - } - defer w.mu.Unlock() - if w.uncertain { - return ErrInitializationUnconfirmed - } - defer func() { w.uncertain = errors.Is(err, ErrInitializationUnconfirmed) }() - digest := sha256.Sum256(data) - result, err := runInitializationProcess(ctx, "/usr/bin/python3", []string{"-I", "-S", "-c", initialFileInstaller, - b.workspace, strings.TrimPrefix(input.Path, "/workspace/"), strconv.Itoa(len(data)), w.staging, w.helper}, - io.MultiReader(bytes.NewReader(data), bytes.NewReader(digest[:]))) - if err != nil { - return err - } - if result.exit != 0 || len(result.stderr) != 0 { - return ErrInitializationUnconfirmed - } - _, err = decodeWrite(result.stdout, len(data)) - if errors.Is(err, agent.ErrWorkspaceWriteRejected) { - return &InitializationFailure{} - } - if err != nil { - return ErrInitializationUnconfirmed - } - return nil -} - -// This only creates fd-anchored parents, then delegates replacement to the -// existing atomic writer. Deployment paths come exclusively from Binding. -const initialFileInstaller = `import os, sys -workspace, path, size, staging, helper = sys.argv[1:] -parts = path.split('/') -if any(not p or p in ('.', '..') or any(c in p for c in ('\\', '\x00', '\r', '\n')) for p in parts): - raise SystemExit(2) -flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC -fd = os.open('/', flags) -for component in workspace.lstrip('/').split('/'): - child = os.open(component, flags, dir_fd=fd) - os.close(fd) - fd = child -for component in parts[:-1]: - try: - child = os.open(component, flags, dir_fd=fd) - except FileNotFoundError: - os.mkdir(component, mode=0o700, dir_fd=fd) - child = os.open(component, flags, dir_fd=fd) - os.close(fd) - fd = child -os.close(fd) -os.execv(helper, [helper, workspace, path, size, staging]) -` - -type initializationResult struct { - stdout, stderr []byte - exit int -} - -// runInitializationProcess uses the existing process-group owner and joins all -// readers/writers before returning. No child output becomes a public error. -func runInitializationProcess(ctx context.Context, binary string, args []string, input io.Reader) (initializationResult, error) { - result := initializationResult{exit: -1} - operation, cancel := context.WithTimeout(ctx, 30*time.Minute) - defer cancel() - if operation.Err() != nil { - return result, ErrInitializationUnconfirmed - } - process, err := clirunner.Start(clirunner.StartOptions{Parent: operation, Binary: binary, Args: args, - Dir: "/", Env: []string{"PATH=/usr/bin:/bin", "LANG=C.UTF-8"}, NeedStdin: true, - OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond}) - if err != nil { - return result, ErrInitializationUnconfirmed - } - defer process.Cancel() - inputDone := make(chan error, 1) - go func() { - _, err := io.Copy(process.Stdin, input) - closeErr := process.Stdin.Close() - if err == nil { - err = closeErr - } - inputDone <- err - }() - type output struct { - data []byte - err error - } - read := func(stream io.Reader, done chan<- output) { - raw, err := io.ReadAll(io.LimitReader(stream, 1025)) - if err != nil || len(raw) > 1024 { - process.Cancel() - } - _, _ = io.Copy(io.Discard, stream) - done <- output{raw, err} - } - stdout, stderr := make(chan output, 1), make(chan output, 1) - go read(process.Stdout, stdout) - go read(process.Stderr, stderr) - out, diagnostic := <-stdout, <-stderr - inputErr := <-inputDone - _ = process.Wait() - result.stdout, result.stderr = out.data, diagnostic.data - result.exit = process.Cmd.ProcessState.ExitCode() - if operation.Err() != nil || inputErr != nil || out.err != nil || diagnostic.err != nil || - len(out.data) > 1024 || len(diagnostic.data) > 1024 || result.exit < 0 { - return result, ErrInitializationUnconfirmed - } - return result, nil -} - -func decodeInitialization(result initializationResult) error { - var receipt struct { - Version int `json:"version"` - Outcome string `json:"outcome"` - ExitCode *int `json:"exit_code"` - } - decoder := json.NewDecoder(bytes.NewReader(result.stdout)) - decoder.DisallowUnknownFields() - if len(result.stderr) != 0 || decoder.Decode(&receipt) != nil || decoder.Decode(new(any)) != io.EOF || receipt.Version != 1 { - return ErrInitializationUnconfirmed - } - if result.exit == 0 && receipt.Outcome == "completed" && receipt.ExitCode == nil { - return nil - } - if result.exit == 1 && receipt.Outcome == "failed" { - if receipt.ExitCode != nil && (*receipt.ExitCode < 1 || *receipt.ExitCode > 255) { - return ErrInitializationUnconfirmed - } - return &InitializationFailure{ExitCode: receipt.ExitCode} - } - return ErrInitializationUnconfirmed -} diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux_test.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux_test.go deleted file mode 100644 index c318ae0f8..000000000 --- a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux_test.go +++ /dev/null @@ -1,194 +0,0 @@ -//go:build linux - -package localworkspace - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestRuntimeInitializationSafeReceipts(t *testing.T) { - for _, tc := range []struct { - raw string - exit int - failed bool - code int - }{ - {`{"version":1,"outcome":"completed"}`, 0, false, 0}, - {`{"version":1,"outcome":"failed","exit_code":3}`, 1, true, 3}, - {`{"version":1,"outcome":"failed"}`, 1, true, 0}, - } { - err := decodeInitialization(initializationResult{stdout: []byte(tc.raw), exit: tc.exit}) - var failure *InitializationFailure - if tc.failed { - if !errors.As(err, &failure) || tc.code != 0 && (failure.ExitCode == nil || *failure.ExitCode != tc.code) { - t.Fatal("failed receipt", err) - } - } else if err != nil { - t.Fatal(err) - } - } - for _, raw := range []string{ - `{"version":1,"outcome":"failed","exit_code":0}`, - `{"version":1,"outcome":"failed","exit_code":256}`, - `{"version":1,"outcome":"failed","exit_code":3,"stderr":"secret"}`, - `{"version":1,"outcome":"completed","exit_code":3}`, - `{"version":1,"outcome":"failed"} {}`, - } { - if !errors.Is(decodeInitialization(initializationResult{stdout: []byte(raw), exit: 1}), ErrInitializationUnconfirmed) { - t.Fatal("unsafe receipt") - } - } -} - -func TestRuntimeInitializationProcessDrainsAndCancels(t *testing.T) { - t.Setenv("RUNTIME_TEST_PRIVATE", "do-not-inherit") - result, err := runInitializationProcess(t.Context(), "/bin/sh", []string{"-c", `test -z "$RUNTIME_TEST_PRIVATE" || exit 4; cat >/dev/null; printf '{"version":1,"outcome":"completed"}'`}, strings.NewReader("{}")) - if err != nil || decodeInitialization(result) != nil { - t.Fatal("clean process", err) - } - _, err = runInitializationProcess(t.Context(), "/bin/sh", []string{"-c", "head -c 2048 /dev/zero; sleep 5"}, strings.NewReader("")) - if !errors.Is(err, ErrInitializationUnconfirmed) { - t.Fatal("unbounded output accepted", err) - } - directory := t.TempDir() - ctx, cancel := context.WithCancel(t.Context()) - done := make(chan error, 1) - go func() { - _, err := runInitializationProcess(ctx, "/bin/sh", []string{"-c", `(trap '' TERM; sleep 1; touch "$1/late") & touch "$1/started"; wait`, "fixture", directory}, strings.NewReader("{}")) - done <- err - }() - deadline := time.Now().Add(3 * time.Second) - for { - if _, err := os.Stat(filepath.Join(directory, "started")); err == nil { - break - } - if time.Now().After(deadline) { - cancel() - <-done - t.Fatal("child did not start") - } - time.Sleep(5 * time.Millisecond) - } - cancel() - if err := <-done; !errors.Is(err, ErrInitializationUnconfirmed) { - t.Fatal("cancellation confirmed", err) - } - time.Sleep(1100 * time.Millisecond) - if _, err := os.Stat(filepath.Join(directory, "late")); !os.IsNotExist(err) { - t.Fatal("child survived return", err) - } -} - -func TestRuntimeInitializationPreservesSetupNetworkPhase(t *testing.T) { - for _, policy := range []string{"disabled", "restricted"} { - t.Run(policy, func(t *testing.T) { - b, _ := testBinding(t) - b.networkAccess = policy - t.Setenv("OAC_RUNTIME_NETWORK_ACCESS", policy) - // Initialization's typed network remains independent of native policy. - script := "import json, os, sys; request=json.load(sys.stdin); assert request['network']=='enabled'; assert 'OAC_RUNTIME_NETWORK_ACCESS' not in os.environ; print('{\"version\":1,\"outcome\":\"completed\"}')" - result, err := runInitializationProcess(t.Context(), "/usr/bin/python3", []string{"-I", "-S", "-c", script}, - strings.NewReader(`{"version":1,"action":"setup","network":"enabled","command":"true","cwd":"/workspace"}`)) - if err != nil || decodeInitialization(result) != nil || b.NetworkPolicy().Access != policy { - t.Fatal("initialization changed native network policy", err) - } - marker, err := b.openSnapshotMarker() - if err != nil { - t.Fatal(err) - } - if err = marker.complete(); err != nil { - marker.close() - t.Fatal(err) - } - marker.close() - input := proto.RuntimePreparePayload{Step: "begin", EnvironmentID: b.environment, SessionID: b.capabilityIdentity().SessionID, - Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: "setup", Network: "enabled", Command: "true", CWD: "/workspace"}} - if err = b.ApplyRuntimePreparation(t.Context(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) { - t.Fatal("initialization reopened after native admission", err) - } - }) - } -} - -func TestRuntimeInitialFileUsesBoundAtomicWriterAndAnchoredParents(t *testing.T) { - b := writableBinding(t, `[ "$#" = 4 ] || exit 7 -[ -z "$RUNTIME_TEST_PRIVATE" ] || exit 8 -cat >/dev/null -printf '{"version":1,"outcome":"completed","size_bytes":3}' -`) - t.Setenv("RUNTIME_TEST_PRIVATE", "secret") - if err := b.installInitialFile(t.Context(), proto.RuntimeInitialFile{Path: "/workspace/nested/child/file"}, []byte("abc")); err != nil { - t.Fatal(err) - } - info, err := os.Stat(filepath.Join(b.workspace, "nested/child")) - if err != nil || !info.IsDir() || info.Mode().Perm() != 0700 { - t.Fatal("parents", err) - } - outside := t.TempDir() - if err := os.Symlink(outside, filepath.Join(b.workspace, "escape")); err != nil { - t.Fatal(err) - } - if err := b.installInitialFile(t.Context(), proto.RuntimeInitialFile{Path: "/workspace/escape/new/file"}, []byte("abc")); err == nil { - t.Fatal("alias accepted") - } - if _, err := os.Stat(filepath.Join(outside, "new")); !os.IsNotExist(err) { - t.Fatal("escaped workspace") - } -} - -func TestRuntimePreparationRejectsFilesAfterFinalization(t *testing.T) { - b, req := testBinding(t) - configured, err := b.Configure(req) - if err != nil { - t.Fatal(err) - } - if _, err = b.Prepare(t.Context(), configured); err != nil { - t.Fatal(err) - } - digest := sha256.Sum256(nil) - input := proto.RuntimePreparePayload{Step: "begin", EnvironmentID: b.environment, SessionID: b.capabilityIdentity().SessionID, - Action: "file", File: &proto.RuntimeInitialFile{Path: "/workspace/file"}, SHA256: hex.EncodeToString(digest[:])} - if err = b.ApplyRuntimePreparation(t.Context(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) { - t.Fatal("finalized Runtime accepted file", err) - } - input.Action = "initialize" - input.File = nil - input.SHA256 = "" - input.Initialization = &proto.RuntimeInitialization{Action: "configure", Env: map[string]string{}} - if err = b.ApplyRuntimePreparation(t.Context(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) { - t.Fatal("finalized Runtime accepted initialize", err) - } -} - -func TestRuntimeInitialFileNativeAtomicReplacement(t *testing.T) { - helper := os.Getenv("OAC_TEST_LOCAL_WRITE_HELPER") - if helper == "" { - t.Skip("requires built native installer") - } - b := writableBinding(t, "exit 1\n") - if err := b.bindWriter(helper, b.writer.staging); err != nil { - t.Fatal(err) - } - path := "/workspace/n1/n2/file" - for _, body := range [][]byte{nil, []byte("first"), bytes.Repeat([]byte("x"), 1<<20), []byte("replacement")} { - if err := b.installInitialFile(t.Context(), proto.RuntimeInitialFile{Path: path}, body); err != nil { - t.Fatal(err) - } - actual, err := os.ReadFile(filepath.Join(b.workspace, "n1/n2/file")) - if err != nil || !bytes.Equal(actual, body) { - t.Fatal("atomic replacement", err) - } - } -} diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_other.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_other.go deleted file mode 100644 index 16ba0a627..000000000 --- a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_other.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build !linux - -package localworkspace - -import ( - "context" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func (b *Binding) initializeRuntime(context.Context, proto.RuntimeInitialization) error { - return agentcapabilities.ErrInvalid -} -func (b *Binding) installInitialFile(context.Context, proto.RuntimeInitialFile, []byte) error { - return agentcapabilities.ErrInvalid -} diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_process.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_process.go new file mode 100644 index 000000000..73ad59d35 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_process.go @@ -0,0 +1,147 @@ +package localworkspace + +import ( + "context" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" +) + +func initializationBash() (string, error) { + if runtime.GOOS != "windows" { + if binary, err := exec.LookPath("bash"); err == nil { + return binary, nil + } + return "", initializationDependency("bash") + } + // Git Bash preserves setup's Bash syntax. Never substitute cmd.exe, PowerShell + // or the Windows WSL launcher for a missing Bash implementation. + candidates := []string{} + if explicit := os.Getenv("CLAUDE_CODE_GIT_BASH_PATH"); explicit != "" { + candidates = append(candidates, explicit) + } else if git, err := exec.LookPath("git.exe"); err == nil { + candidates = append(candidates, filepath.Join(filepath.Dir(git), "..", "bin", "bash.exe"), filepath.Join(filepath.Dir(git), "bash.exe")) + } + for _, candidate := range candidates { + candidate = filepath.Clean(candidate) + if runtimefs.ValidateLocalPath(candidate) != nil { + continue + } + if info, err := os.Stat(candidate); err == nil && info.Mode().IsRegular() { + return candidate, nil + } + } + return "", initializationDependency("Git Bash") +} + +func initializationPython() (string, error) { + for _, name := range []string{"python3", "python"} { + if binary, err := exec.LookPath(name); err == nil { + return binary, nil + } + } + return "", initializationDependency("Python with pip") +} + +func initializationNPM() (string, []string, error) { + node, err := exec.LookPath("node") + if err != nil { + return "", nil, initializationDependency("Node.js") + } + npm, err := exec.LookPath("npm") + if err != nil { + return "", nil, initializationDependency("npm") + } + if runtime.GOOS != "windows" { + return npm, nil, nil + } + // npm's Windows launcher is a .cmd file. Use the installed CLI with Node rather + // than reinterpret package arguments through cmd.exe. + cli := filepath.Join(filepath.Dir(npm), "node_modules", "npm", "bin", "npm-cli.js") + if info, err := os.Stat(cli); err != nil || !info.Mode().IsRegular() { + return "", nil, initializationDependency("npm CLI") + } + return node, []string{cli}, nil +} + +func initializationEnvironment(configured map[string]string) []string { + values := map[string]string{} + for _, key := range []string{"PATH", "HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", "PATHEXT", "LANG"} { + if value := os.Getenv(key); value != "" { + values[key] = value + } + } + if runtime.GOOS != "windows" && values["LANG"] == "" { + values["LANG"] = "C.UTF-8" + } + for key, value := range configured { + if runtime.GOOS == "windows" { + for existing := range values { + if strings.EqualFold(existing, key) { + delete(values, existing) + } + } + } + values[key] = value + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + result := make([]string, 0, len(keys)) + for _, key := range keys { + result = append(result, key+"="+values[key]) + } + return result +} + +// The shared process owner settles the leader and descendants. Readers finish +// before return; output is discarded with constant memory, never put in errors. +func runInitializationProcess(ctx context.Context, binary string, args []string, directory string, env []string) error { + operation, cancel := context.WithTimeout(ctx, 30*time.Minute) + defer cancel() + if operation.Err() != nil { + return ErrInitializationUnconfirmed + } + if info, err := os.Stat(directory); err != nil || !info.IsDir() { + return &InitializationFailure{} + } + process, err := clirunner.Start(clirunner.StartOptions{Parent: operation, Binary: binary, Args: args, + Dir: directory, Env: env, OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond}) + if err != nil { + return ErrInitializationUnconfirmed + } + defer process.Cancel() + finished := make(chan error, 2) + for _, stream := range []io.Reader{process.Stdout, process.Stderr} { + go func(stream io.Reader) { + _, err := io.Copy(io.Discard, stream) + if err != nil { + process.Cancel() + } + finished <- err + }(stream) + } + first, second := <-finished, <-finished + _ = process.Wait() + if operation.Err() != nil || first != nil || second != nil || process.Cmd.ProcessState == nil { + return ErrInitializationUnconfirmed + } + code := process.Cmd.ProcessState.ExitCode() + if code == 0 { + return nil + } + if code < 1 || code > 255 { + return ErrInitializationUnconfirmed + } + return &InitializationFailure{ExitCode: &code} +} diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_test.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_test.go new file mode 100644 index 000000000..762ec6854 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_test.go @@ -0,0 +1,318 @@ +package localworkspace + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// The test executable doubles as a native package-manager fixture on all OSes. +// Dispatch before testing parses npm/pip arguments; no dependencies are fetched. +func init() { + if os.Getenv("OAC_INITIALIZATION_PACKAGE_FIXTURE") != "1" { + return + } + raw, _ := json.Marshal(os.Args[1:]) + if os.WriteFile(os.Getenv("OAC_INITIALIZATION_PACKAGE_RECEIPT"), raw, 0600) != nil { + os.Exit(9) + } + os.Exit(0) +} + +func TestRuntimeInitializationPackageTargets(t *testing.T) { + b := initializationFixture(t) + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + source, err := os.ReadFile(binary) + if err != nil { + t.Fatal(err) + } + bin := t.TempDir() + suffix := "" + if runtime.GOOS == "windows" { + suffix = ".exe" + } + for _, name := range []string{"node", "npm", "python3"} { + if err = os.WriteFile(filepath.Join(bin, name+suffix), source, 0700); err != nil { + t.Fatal(err) + } + } + if runtime.GOOS == "windows" { + cli := filepath.Join(bin, "node_modules", "npm", "bin", "npm-cli.js") + if err = os.MkdirAll(filepath.Dir(cli), 0700); err != nil { + t.Fatal(err) + } + if err = os.WriteFile(cli, nil, 0600); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", bin) + receipt := filepath.Join(t.TempDir(), "args.json") + if err = b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"OAC_INITIALIZATION_PACKAGE_FIXTURE": "1", "OAC_INITIALIZATION_PACKAGE_RECEIPT": receipt}}); err != nil { + t.Fatal(err) + } + packages, _ := PackageDirectory() + for _, action := range []string{"npm", "python"} { + if err = b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: action, Network: "enabled", Packages: []string{"name with spaces", "second"}}); err != nil { + t.Fatal(action, err) + } + raw, err := os.ReadFile(receipt) + if err != nil { + t.Fatal(err) + } + var args []string + if json.Unmarshal(raw, &args) != nil { + t.Fatal("invalid fixture receipt") + } + flag, target := "--prefix", filepath.Join(packages, "npm") + if action == "python" { + flag, target = "--target", filepath.Join(packages, "python") + } + found := false + for i := 0; i+1 < len(args); i++ { + if args[i] == flag && args[i+1] == target { + found = true + } + } + if !found || len(args) < 3 || args[len(args)-3] != "--" || args[len(args)-2] != "name with spaces" || args[len(args)-1] != "second" { + t.Fatal("package argv or local target mismatch", args) + } + } +} + +func TestRuntimeInitializationChild(t *testing.T) { + mode := os.Getenv("OAC_INITIALIZATION_FIXTURE") + if mode == "" { + return + } + directory := os.Getenv("OAC_INITIALIZATION_FIXTURE_DIR") + switch mode { + case "complete": + if os.Getenv("RUNTIME_TEST_PRIVATE") != "" { + os.Exit(9) + } + os.Stdout.Write(bytes.Repeat([]byte("private-output"), 10000)) + os.Stderr.Write(bytes.Repeat([]byte("private-error"), 10000)) + case "fail": + os.Exit(7) + case "child": + time.Sleep(time.Second) + _ = os.WriteFile(filepath.Join(directory, "late"), []byte("bad"), 0600) + case "parent": + binary, _ := os.Executable() + child := exec.Command(binary, "-test.run=^TestRuntimeInitializationChild$") + child.Env = append(initializationEnvironment(nil), "OAC_INITIALIZATION_FIXTURE=child", "OAC_INITIALIZATION_FIXTURE_DIR="+directory) + child.Stdout, child.Stderr = os.Stdout, os.Stderr + if child.Start() != nil { + os.Exit(8) + } + _ = os.WriteFile(filepath.Join(directory, "started"), []byte("ready"), 0600) + _ = child.Wait() + } + os.Exit(0) +} + +func initializationFixture(t *testing.T) *Binding { + t.Helper() + t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) + t.Setenv("OAC_RUNTIME_INITIALIZATION_DIRECTORY", "") + t.Setenv("OAC_RUNTIME_PACKAGE_DIRECTORY", "") + t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", "") + return &Binding{workspace: t.TempDir(), writer: &fileWriter{}} +} + +func TestRuntimeInitializationConfigurationAndDirectories(t *testing.T) { + b := initializationFixture(t) + config, err := InitializationDirectory() + if err != nil || config != filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "initialization") { + t.Fatal(config, err) + } + packages, err := PackageDirectory() + if err != nil || packages != filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "packages") { + t.Fatal(packages, err) + } + env := map[string]string{"VALUE": "'\n$(not-a-command)", "PYTHONPATH": "operator-path"} + if err = b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: env}); err != nil { + t.Fatal(err) + } + values, err := ReadToolEnvironment() + if err != nil || values["VALUE"] != env["VALUE"] || !strings.HasPrefix(values["PYTHONPATH"], filepath.Join(packages, "python")) { + t.Fatal("configuration mismatch", err) + } + if b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"VALUE": "changed"}}) == nil { + t.Fatal("configuration replaced") + } + values, _ = ReadToolEnvironment() + if values["VALUE"] != env["VALUE"] { + t.Fatal("configuration changed") + } + for setting, resolver := range map[string]func() (string, error){"OAC_RUNTIME_INITIALIZATION_DIRECTORY": InitializationDirectory, "OAC_RUNTIME_PACKAGE_DIRECTORY": PackageDirectory} { + selected := t.TempDir() + t.Setenv(setting, selected) + if actual, err := resolver(); err != nil || actual != selected { + t.Fatal("operator directory ignored", err) + } + } +} + +func TestRuntimeInitializationExplicitToolEnvironment(t *testing.T) { + initializationFixture(t) + file := filepath.Join(t.TempDir(), "explicit.json") + if err := os.WriteFile(file, []byte(`{"DECLARED":"value"}`), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", file) + env, err := ReadOptionalToolEnvironment() + if err != nil || env["DECLARED"] != "value" { + t.Fatal("explicit tool environment", err) + } +} + +func TestRuntimeInitializationSetupUsesBashAndPhysicalWorkspace(t *testing.T) { + b := initializationFixture(t) + if _, err := initializationBash(); err != nil { + t.Skip("Bash unavailable; native dependency failure is tested separately") + } + if err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"VALUE": "configured"}}); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(b.workspace, "sub"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(b.workspace, "proof.sh"), []byte("printf skill-proof"), 0600); err != nil { + t.Fatal(err) + } + err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "setup", Network: "enabled", CWD: "/workspace/sub", Command: `. ../proof.sh > proof; printf '%s' "$VALUE" > value`}) + if err != nil { + t.Fatal(err) + } + for name, want := range map[string]string{"proof": "skill-proof", "value": "configured"} { + raw, err := os.ReadFile(filepath.Join(b.workspace, "sub", name)) + if err != nil || string(raw) != want { + t.Fatal(name, err) + } + } +} + +func TestRuntimeInitializationMissingDependenciesAndInvalidRequests(t *testing.T) { + b := initializationFixture(t) + t.Setenv("PATH", t.TempDir()) + t.Setenv("CLAUDE_CODE_GIT_BASH_PATH", "") + if err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure"}); err != nil { + t.Fatal(err) + } + for _, input := range []proto.RuntimeInitialization{{Action: "setup", Network: "enabled", Command: "true"}, {Action: "npm", Network: "enabled", Packages: []string{"valid"}}, {Action: "python", Network: "enabled", Packages: []string{"valid"}}} { + var failed *InitializationFailure + if err := b.initializeRuntime(t.Context(), input); !errors.As(err, &failed) || !strings.Contains(err.Error(), "requires") { + t.Fatal("missing dependency was not explicit", input.Action, err) + } + } + for _, input := range []proto.RuntimeInitialization{{Action: "system"}, {Action: "setup", Network: "enabled", Command: "true", CWD: "/workspace/../outside"}, {Action: "npm", Network: "enabled", Packages: []string{"--unsafe"}}} { + if !errors.Is(b.initializeRuntime(t.Context(), input), agentcapabilities.ErrInvalid) { + t.Fatal("invalid request accepted", input.Action) + } + } +} + +func TestRuntimeInitializationProcessSettlesAndDiscardsOutput(t *testing.T) { + t.Setenv("RUNTIME_TEST_PRIVATE", "do-not-inherit") + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + directory := t.TempDir() + env := append(initializationEnvironment(nil), "OAC_INITIALIZATION_FIXTURE=complete") + if err = runInitializationProcess(t.Context(), binary, []string{"-test.run=^TestRuntimeInitializationChild$"}, directory, env); err != nil { + t.Fatal(err) + } + env = append(initializationEnvironment(nil), "OAC_INITIALIZATION_FIXTURE=fail") + err = runInitializationProcess(t.Context(), binary, []string{"-test.run=^TestRuntimeInitializationChild$"}, directory, env) + var failed *InitializationFailure + if !errors.As(err, &failed) || failed.ExitCode == nil || *failed.ExitCode != 7 { + t.Fatal("exit status", err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + env = append(initializationEnvironment(nil), "OAC_INITIALIZATION_FIXTURE=parent", "OAC_INITIALIZATION_FIXTURE_DIR="+directory) + done := make(chan error, 1) + go func() { + done <- runInitializationProcess(ctx, binary, []string{"-test.run=^TestRuntimeInitializationChild$"}, directory, env) + }() + deadline := time.Now().Add(5 * time.Second) + for { + if _, err = os.Stat(filepath.Join(directory, "started")); err == nil { + break + } + if time.Now().After(deadline) { + cancel() + <-done + t.Fatal("fixture startup timeout") + } + time.Sleep(5 * time.Millisecond) + } + cancel() + if err = <-done; !errors.Is(err, ErrInitializationUnconfirmed) { + t.Fatal("cancel should be unknown", err) + } + time.Sleep(1100 * time.Millisecond) + if _, err = os.Stat(filepath.Join(directory, "late")); !os.IsNotExist(err) { + t.Fatal("descendant survived initialization") + } +} + +func TestRuntimeInitialFileAtomicReplacement(t *testing.T) { + b := initializationFixture(t) + path := "/workspace/nested/child/file" + for _, body := range [][]byte{nil, []byte("first"), bytes.Repeat([]byte("x"), 1<<20), []byte("replacement")} { + if err := b.installInitialFile(t.Context(), proto.RuntimeInitialFile{Path: path}, body); err != nil { + t.Fatal(err) + } + actual, err := os.ReadFile(filepath.Join(b.workspace, "nested", "child", "file")) + if err != nil || !bytes.Equal(actual, body) { + t.Fatal("atomic replacement", err) + } + } + for _, invalid := range []string{"/elsewhere/file", "/workspace/../outside", "/workspace/a\\b"} { + if !errors.Is(b.installInitialFile(t.Context(), proto.RuntimeInitialFile{Path: invalid}, []byte("x")), agentcapabilities.ErrInvalid) { + t.Fatal("invalid path accepted") + } + } +} +func TestRuntimePreparationRejectsFilesAfterFinalization(t *testing.T) { + b, req := testBinding(t) + configured, err := b.Configure(req) + if err != nil { + t.Fatal(err) + } + if _, err = b.Prepare(t.Context(), configured); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(nil) + input := proto.RuntimePreparePayload{Step: "begin", EnvironmentID: b.environment, SessionID: b.capabilityIdentity().SessionID, + Action: "file", File: &proto.RuntimeInitialFile{Path: "/workspace/file"}, SHA256: hex.EncodeToString(digest[:])} + if err = b.ApplyRuntimePreparation(t.Context(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) { + t.Fatal("finalized Runtime accepted file", err) + } + input.Action = "initialize" + input.File = nil + input.SHA256 = "" + input.Initialization = &proto.RuntimeInitialization{Action: "configure", Env: map[string]string{}} + if err = b.ApplyRuntimePreparation(t.Context(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) { + t.Fatal("finalized Runtime accepted initialize", err) + } +} diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index d8dcda463..57bf5cdda 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -1697,14 +1697,9 @@ definitions: items: type: string type: array - system: - items: - type: string - type: array required: - npm - python - - system type: object v1.EnvironmentTemplate: properties: diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index c24705ceb..4cd47065e 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -532,14 +532,9 @@ definitions: items: type: string type: array - system: - items: - type: string - type: array required: - npm - python - - system type: object v1.EnvironmentPackagesInput: properties: @@ -553,11 +548,6 @@ definitions: type: string type: array x-nullable: true - system: - items: - type: string - type: array - x-nullable: true type: object v1.EnvironmentTemplate: properties: @@ -2674,13 +2664,15 @@ paths: - application/json description: Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id - files, confidential env, ordered setup_commands, system/npm/Python packages - inline/referenced Skill ZIPs, Plugin ZIPs and workspace-contained capability - directories. Omitted/null network defaults to enabled. Restricted network - requires 1–100 exact ASCII hostnames; other host forms and populated unsupported - installations are rejected before persistence without echoing input. Network - policy rejections return invalid_request_error with a null param. No compute - is allocated. Exact hosted error/retry semantics remain unverified. + files, confidential env, ordered setup_commands, npm/Python packages inline/referenced + Skill ZIPs, Plugin ZIPs and workspace-contained capability directories. Omitted/null + network defaults to enabled. Restricted network requires 1–100 exact ASCII + hostnames; other host forms and populated unsupported installations are rejected + before persistence without echoing input. Network policy rejections return + invalid_request_error with a null param. System dependencies must be preinstalled + in the sandbox image or template, or on the host machine; packages.system + is rejected. No compute is allocated. Exact hosted error/retry semantics remain + unverified. parameters: - description: agents=v1 in: header @@ -3028,34 +3020,35 @@ paths: provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported - hostname forms and startup installations are rejected. Confidential env, system/npm/Python + hostname forms and startup installations are rejected. Confidential env, npm/Python packages and ordered setup commands use the shared initialization lifecycle; - requested network applies after setup. Initial inline and tenant-owned file_id - files freeze encrypted bytes before provisioning, then install through the - common Core lifecycle before native execution or live Files access. With a - template reference, omitted/null files, env, packages and setup_commands inherit. - Non-null files and command lists replace; env overlays by key; each package - manager inherits on omission/null and otherwise replaces its list. Empty lists - clear their selected field. Tenant-owned environment_template_id references - inherit omitted/null network and allow only narrowing overrides. Inline hosted - network:null retains the enabled default; updating a Template with network:null - resets its saved policy to enabled. Core freezes effective configuration; - template updates/deletion do not alter Session snapshots or same-intent creation - retries. Inline or tenant-owned skill_reference Skills share initialization. - Templates preserve default/latest/explicit selectors; Session creation freezes - concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory - list omission/null inherit; a non-null list replaces, including empty-list - clearing. Omitted/null Skill version selectors resolve the default version. - Source deletion/default updates cannot change committed Session Skill contents. - Deferred function discovery uses type-only tool_search and per-function defer_loading - in the qualified single-agent Claude environment:none function profile, including - qualified inline image messages and text results. Explicit web_search mode - disabled and programmatic_tool_calling enabled false use frozen common Runtime - controls. Enabled forms, including those saved on an Agent, remain unqualified - and reject before any write unless the Session replaces tools. Omitted programmatic - configuration preserves native behavior, a documented difference from the - official default-on behavior. Other combinations remain unqualified; see the - operation coverage. + requested network applies after setup. System dependencies must be preinstalled + in the sandbox image or template, or on the host machine; packages.system + is rejected. Initial inline and tenant-owned file_id files freeze encrypted + bytes before provisioning, then install through the common Core lifecycle + before native execution or live Files access. With a template reference, omitted/null + files, env, packages and setup_commands inherit. Non-null files and command + lists replace; env overlays by key; each package manager inherits on omission/null + and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned + environment_template_id references inherit omitted/null network and allow + only narrowing overrides. Inline hosted network:null retains the enabled default; + updating a Template with network:null resets its saved policy to enabled. + Core freezes effective configuration; template updates/deletion do not alter + Session snapshots or same-intent creation retries. Inline or tenant-owned + skill_reference Skills share initialization. Templates preserve default/latest/explicit + selectors; Session creation freezes concrete metadata and encrypted content + atomically. Skill, Plugin and capability-directory list omission/null inherit; + a non-null list replaces, including empty-list clearing. Omitted/null Skill + version selectors resolve the default version. Source deletion/default updates + cannot change committed Session Skill contents. Deferred function discovery + uses type-only tool_search and per-function defer_loading in the qualified + single-agent Claude environment:none function profile, including qualified + inline image messages and text results. Explicit web_search mode disabled + and programmatic_tool_calling enabled false use frozen common Runtime controls. + Enabled forms, including those saved on an Agent, remain unqualified and reject + before any write unless the Session replaces tools. Omitted programmatic configuration + preserves native behavior, a documented difference from the official default-on + behavior. Other combinations remain unqualified; see the operation coverage. parameters: - description: agents=v1 in: header diff --git a/contracts/agents-api/v1/environment_templates.go b/contracts/agents-api/v1/environment_templates.go index 05e013312..b2710750e 100644 --- a/contracts/agents-api/v1/environment_templates.go +++ b/contracts/agents-api/v1/environment_templates.go @@ -20,7 +20,6 @@ type EnvironmentTemplateRequest struct { type EnvironmentPackagesInput struct { NPM []string `json:"npm,omitempty" extensions:"x-nullable"` Python []string `json:"python,omitempty" extensions:"x-nullable"` - System []string `json:"system,omitempty" extensions:"x-nullable"` } // EnvironmentTemplate returns safe configuration metadata only. diff --git a/contracts/agents-api/v1/environments.go b/contracts/agents-api/v1/environments.go index 85f1861a5..50ccbada8 100644 --- a/contracts/agents-api/v1/environments.go +++ b/contracts/agents-api/v1/environments.go @@ -28,5 +28,4 @@ type EnvironmentNetworkInput struct { type EnvironmentPackages struct { NPM []string `json:"npm" binding:"required"` Python []string `json:"python" binding:"required"` - System []string `json:"system" binding:"required"` } diff --git a/docs/self-hosted-native.md b/docs/self-hosted-native.md index cf8c9e364..b536e3811 100644 --- a/docs/self-hosted-native.md +++ b/docs/self-hosted-native.md @@ -92,6 +92,22 @@ For example, a tool environment file can contain `{"EXAMPLE_SETTING":"value"}`. Treat files containing credentials as secrets. Retain the credential and tool configuration files at their configured paths; installation records those paths. +Runtime preparation uses one Go implementation on all platforms. Its local +initialization and package directories default to `initialization` and `packages` +under `OAC_RUNTIME_HOME`. Operators can select absolute paths with +`OAC_RUNTIME_INITIALIZATION_DIRECTORY` and `OAC_RUNTIME_PACKAGE_DIRECTORY`; +managed images set these to `/environment/initialization` and +`/environment/packages`. They change storage layout, not execution permissions. +`OAC_RUNTIME_TOOL_ENV_FILE` selects an explicit tool-variable JSON file; preparation +does not overwrite an existing configuration. + +npm and Python packages install only into the Runtime package directory using +npm's prefix and pip's target options. Node/npm and Python/pip must already be +installed. Setup requires Bash; on Windows it requires Git Bash, found from Git's +installation or `CLAUDE_CODE_GIT_BASH_PATH`. Missing dependencies fail preparation; +Runtime does not substitute PowerShell, cmd.exe or WSL for Bash setup. + + `oac-daemon start --foreground` stays attached to the terminal instead of starting in the background. An operator can use their own service manager to run it; the native installer does not configure one. @@ -133,7 +149,9 @@ upgrade, adoption of a historical container installation, or cross-version nativ history migration. Preserve an old installation's files and use a fresh Runtime home and Session when moving versions. -Managed system-package installation remains under its existing contract while the -ordinary apt ownership decision is pending. This guide does not claim that apt has -been migrated or that a native daemon can install packages without the launching -user's required permissions. +System dependencies must be present before Runtime execution: install them while +building the managed image/template, or prepare them as the self-hosted user. +Runtime does not run apt, request sudo or elevate the daemon's privileges. +`system_packages` is unsupported; a missing dependency fails the operation that +requires it. npm/Python package installation and setup retain their supported +initialization flow using the launching user's existing permissions. diff --git a/internal/agentdaemon/proto/environment.go b/internal/agentdaemon/proto/environment.go index 11712269f..5507b0f18 100644 --- a/internal/agentdaemon/proto/environment.go +++ b/internal/agentdaemon/proto/environment.go @@ -8,7 +8,6 @@ import ( // LocalEnvironment names a frozen workspace selection. Runtime must verify it // against the bound local root before resolving capabilities or native execution. type LocalEnvironment struct { - ExecutionMode string `json:"-"` CapabilityRoot string `json:"-"` ID string `json:"id"` WorkspaceDirectory string `json:"workspace_directory"` @@ -22,8 +21,6 @@ type LocalEnvironment struct { MCP []EnvironmentMCP `json:"-"` // ToolEnvironment consumes Core-completed confidential initialization. ToolEnvironment bool `json:"tool_environment,omitempty"` - // SystemPackages requires the installed Runtime tool root during execution. - SystemPackages bool `json:"system_packages,omitempty"` // NetworkAccess must match the immutable Runtime policy for execution. NetworkAccess string `json:"network_access,omitempty"` AllowedDomains []string `json:"allowed_domains,omitempty"` @@ -32,7 +29,6 @@ type LocalEnvironment struct { // EnvironmentMCP is transient Runtime configuration. Do not log it: HTTP headers // and the selected user bearer may be confidential. It is not agent.tools MCP. type EnvironmentMCP struct { - ExecutionMode string `json:"-"` InstallationRoot string WorkspaceRoot string PackageRoot string diff --git a/internal/agentdaemon/proto/runtime_prepare.go b/internal/agentdaemon/proto/runtime_prepare.go index 921f75efc..c5e17b0f9 100644 --- a/internal/agentdaemon/proto/runtime_prepare.go +++ b/internal/agentdaemon/proto/runtime_prepare.go @@ -196,7 +196,7 @@ func validRuntimeInitialization(p RuntimeInitialization) bool { } } return true - case "system", "npm", "python": + case "npm", "python": if p.Env != nil || p.Command != "" || p.CWD != "" || len(p.Packages) == 0 || len(p.Packages) > 1000 || p.Network == "" { return false } diff --git a/internal/agentdaemon/proto/runtime_prepare_test.go b/internal/agentdaemon/proto/runtime_prepare_test.go index 588520ca8..51efa53cc 100644 --- a/internal/agentdaemon/proto/runtime_prepare_test.go +++ b/internal/agentdaemon/proto/runtime_prepare_test.go @@ -176,7 +176,6 @@ func TestRuntimePreparationInitialActions(t *testing.T) { base := RuntimePreparePayload{Step: "begin", EnvironmentID: uuid.NewString(), SessionID: uuid.NewString()} for _, initialization := range []RuntimeInitialization{ {Action: "configure", Env: map[string]string{"EXAMPLE": "value"}}, - {Action: "system", Network: "enabled", Packages: []string{"git"}}, {Action: "npm", Network: "disabled", Packages: []string{"typescript"}}, {Action: "python", Network: "enabled", Packages: []string{"requests"}}, {Action: "setup", Network: "enabled", Command: "echo done"}, @@ -204,7 +203,7 @@ func TestRuntimePreparationInitialActions(t *testing.T) { {Action: "exec", Command: "echo done"}, {Action: "configure", Packages: []string{"git"}}, {Action: "configure", Env: map[string]string{"A=B": "value"}}, - {Action: "system", Network: "enabled"}, + {Action: "system", Network: "enabled", Packages: []string{"git"}}, {Action: "npm", Network: "invalid", Packages: []string{"a"}}, {Action: "python", Network: "enabled", Packages: []string{"--help"}}, {Action: "setup", Network: "enabled", Command: "x", CWD: "/environment"}, diff --git a/packages/claude-sdk-adapter/src/workspace.ts b/packages/claude-sdk-adapter/src/workspace.ts index 0fa9ed9d0..5e7cb5078 100644 --- a/packages/claude-sdk-adapter/src/workspace.ts +++ b/packages/claude-sdk-adapter/src/workspace.ts @@ -7,7 +7,6 @@ import { realpathSync, statSync } from "node:fs"; import { isAbsolute, parse, resolve } from "node:path"; export type Workspace = { - execution_mode?: "native"; tool_env?: Record; home: string; state: string; @@ -45,8 +44,7 @@ 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 => !["tool_env", "execution_mode", "home", "state", "scratch", "env_names", "network_access", "allowed_domains", "skills", "mcp", "capability_root"].includes(key)) || - (config.execution_mode !== undefined && config.execution_mode !== "native") || + if (Object.keys(config).some(key => !["tool_env", "home", "state", "scratch", "env_names", "network_access", "allowed_domains", "skills", "mcp", "capability_root"].includes(key)) || (config.network_access !== undefined && config.network_access !== "enabled" && config.network_access !== "disabled" && config.network_access !== "restricted") || !Array.isArray(config.env_names) || config.env_names.some(name => typeof name !== "string" || !environmentNames.has(name)) || @@ -79,7 +77,10 @@ export class WorkspaceProfile { CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", DISABLE_TELEMETRY: "1", DISABLE_ERROR_REPORTING: "1", DISABLE_AUTOUPDATER: "1", CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1", }; - Object.assign(env, config.tool_env ?? {}); + for (const [name, value] of Object.entries(config.tool_env ?? {})) { + // Initialization cannot redirect the native Session history lookup. + if (!["HOME", "USERPROFILE", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_PROJECT_DIR_NAME"].includes(name.toUpperCase())) env[name] = value; + } for (const name of config.env_names) { const value = process.env[name]; if (value === undefined) throw new Error("invalid_request"); diff --git a/packages/claude-sdk-adapter/tests/workspace.test.mjs b/packages/claude-sdk-adapter/tests/workspace.test.mjs index 36f51f77a..53b669d56 100644 --- a/packages/claude-sdk-adapter/tests/workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace.test.mjs @@ -183,9 +183,12 @@ test("workspace functions retain native sandbox and exact tool authority", async test("initialized environment is ordinary child environment on every layout", t => { const {dirs,config}=fixture(t); - for(const execution_mode of [undefined,"native"]) { - const profile=new WorkspaceProfile(dirs.workspace,{...config,execution_mode,tool_env:{USER_VALUE:"initialized"}}); + { + const profile=new WorkspaceProfile(dirs.workspace,{...config,tool_env:{USER_VALUE:"initialized",HOME:"/wrong",CLAUDE_CONFIG_DIR:"/wrong",home:"/wrong"}}); assert.equal(profile.options.env.USER_VALUE,"initialized"); + assert.equal(profile.options.env.HOME,dirs.home); + assert.equal(profile.options.env.CLAUDE_CONFIG_DIR,dirs.state); + assert.equal(profile.options.env.home,undefined); assert.deepEqual(profile.options.sandbox,{enabled:false}); } }); diff --git a/packages/mcode-harness/launch.test.mjs b/packages/mcode-harness/launch.test.mjs index ea930c6f4..647c8fcf3 100644 --- a/packages/mcode-harness/launch.test.mjs +++ b/packages/mcode-harness/launch.test.mjs @@ -1,12 +1,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import {mkdtemp,mkdir,writeFile,copyFile,rm} from 'node:fs/promises'; +import {mkdtemp,mkdir,writeFile,copyFile,rm,realpath} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {ToolExecutor} from './tool-executor.mjs'; test('Runtime directly executes host worker with bound cwd and initialized environment',async t=>{ - const root=await mkdtemp(join(tmpdir(),'mcode-host-')); + const root=await realpath(await mkdtemp(join(tmpdir(),'mcode-host-'))); t.after(()=>rm(root,{recursive:true,force:true})); await mkdir(join(root,'dist')); const workspace=join(root,'workspace');await mkdir(workspace); diff --git a/scripts/build-agents-api.sh b/scripts/build-agents-api.sh index e6bb8add7..e675ada78 100755 --- a/scripts/build-agents-api.sh +++ b/scripts/build-agents-api.sh @@ -27,7 +27,7 @@ tar -C "$repo_root" -cf - \ go.mod go.sum \ contracts/agents-api/v1 \ internal/agentdaemon/device internal/agentdaemon/gateway internal/agentdaemon/proto \ - internal/agentnetwork internal/agentbundle internal/agentcapabilities internal/agentplugin internal/agentskill internal/harnessconfig internal/obs/log services/agents-api \ + internal/runtimefs internal/agentnetwork internal/agentbundle internal/agentcapabilities internal/agentplugin internal/agentskill internal/harnessconfig internal/obs/log services/agents-api \ | tar -C "$build_context" -xf - ( diff --git a/scripts/build-agents-runtime.sh b/scripts/build-agents-runtime.sh index e4899682b..3f812dafc 100755 --- a/scripts/build-agents-runtime.sh +++ b/scripts/build-agents-runtime.sh @@ -35,8 +35,6 @@ cp "$helpers_dir/oac-codex-directory" "$helpers_dir/oac-codex-write" "$helpers_d cp "$native_dir/bin/codex" "$context/codex" cp -R "$native_dir/codex-resources" "$context/codex-resources" cp "$repo_root/services/agents-api/deploy/codex/Dockerfile" "$context/Dockerfile" -cp "$repo_root/services/agents-api/deploy/runtime/initialize.py" "$context/runtime-initialize.py" -cp "$repo_root/services/agents-api/deploy/runtime/build-system-seed.py" "$repo_root/services/agents-api/deploy/runtime/tool-root.py" "$context/" # Preserve the previous bundle if compilation or validation failed. mkdir -p "$output_dir" cp -R "$context/." "$output_dir/" diff --git a/scripts/build-claude-runtime.sh b/scripts/build-claude-runtime.sh index 5f81b5021..025f5b9ee 100755 --- a/scripts/build-claude-runtime.sh +++ b/scripts/build-claude-runtime.sh @@ -27,8 +27,6 @@ for helper in oac-codex-directory oac-codex-write oac-workspace-export; do cp "$helpers_dir/$helper" "$context/" done cp "$repo_root/services/agents-api/deploy/claude/Dockerfile" "$context/Dockerfile" -cp "$repo_root/services/agents-api/deploy/runtime/initialize.py" "$context/runtime-initialize.py" -cp "$repo_root/services/agents-api/deploy/runtime/build-system-seed.py" "$repo_root/services/agents-api/deploy/runtime/tool-root.py" "$context/" mkdir -p "$output_dir" cp -R "$context/." "$output_dir/" printf 'Claude Runtime image context: %s\n' "$output_dir" diff --git a/scripts/build-mcode-runtime.sh b/scripts/build-mcode-runtime.sh index 3126f7875..49412c48f 100644 --- a/scripts/build-mcode-runtime.sh +++ b/scripts/build-mcode-runtime.sh @@ -28,8 +28,6 @@ for helper in oac-codex-directory oac-codex-write oac-workspace-export; do cp "$helpers/$helper" "$context/" done cp "$repo_root/services/agents-api/deploy/mcode/Dockerfile" "$context/Dockerfile" -cp "$repo_root/services/agents-api/deploy/runtime/initialize.py" "$context/runtime-initialize.py" -cp "$repo_root/services/agents-api/deploy/runtime/build-system-seed.py" "$repo_root/services/agents-api/deploy/runtime/tool-root.py" "$context/" mkdir -p "$output" cp -R "$context/." "$output/" printf 'MiniMax Code Runtime image context: %s\n' "$output" diff --git a/scripts/core-distribution-manifest.py b/scripts/core-distribution-manifest.py index 78e9411dc..cd8aa7c29 100644 --- a/scripts/core-distribution-manifest.py +++ b/scripts/core-distribution-manifest.py @@ -164,8 +164,6 @@ def verify_runtime(image, daemon, helpers, source): files = {"/usr/local/bin/oac-daemon": pathlib.Path(daemon)} for name in ("oac-codex-directory", "oac-codex-write", "oac-workspace-export"): files["/usr/local/bin/" + name] = helpers / name - files["/usr/local/bin/oac-runtime-initialize"] = source / "services/agents-api/deploy/runtime/initialize.py" - files["/usr/local/bin/oac-tool-root"] = source / "services/agents-api/deploy/runtime/tool-root.py" environment = dict(value.split("=", 1) for value in details["Config"]["Env"] if "=" in value) if "OAC_RUNTIME_MCODE_BIN" in environment: for name in ("launch.mjs", "bridge.mjs", "check.mjs", "tool-executor.mjs", "subagent-snapshot.mjs", "source.json"): diff --git a/scripts/native-harness-smoke.mjs b/scripts/native-harness-smoke.mjs new file mode 100644 index 000000000..76d48c757 --- /dev/null +++ b/scripts/native-harness-smoke.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +// No model requests: validate installed native engines and their startup protocols. +import { spawn, spawnSync } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { createInterface } from 'node:readline'; + +const options = {}; +for (let i = 2; i < process.argv.length; i += 2) { + const key = process.argv[i]; + if (!['--codex-package', '--codex-binary', '--claude-runtime', '--only'].includes(key) || !process.argv[i + 1]) { + throw new Error('Expected --codex-package PATH, --codex-binary PATH, --claude-runtime PATH or --only codex|claude'); + } + options[key] = process.argv[i + 1]; +} +if (options['--only'] && !['codex', 'claude'].includes(options['--only'])) throw new Error('Invalid --only selection'); +const limit = 1024 * 1024; +const failure = code => Object.assign(new Error(code), { safeCode: code }); +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + +async function codexSmoke(root) { + let binary = options['--codex-binary'] ?? 'codex'; + let prefix = []; + if (options['--codex-package']) { + const directory = resolve(options['--codex-package']); + const manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')); + if (manifest.version !== '0.153.4') throw failure('codex_package_version'); + binary = process.execPath; + prefix = [join(directory, 'bin', 'codex.js')]; + } + const home = join(root, 'codex-home'); + const workspace = join(root, 'workspace'); + await mkdir(home); await mkdir(workspace); + const env = { ...process.env, CODEX_HOME: home }; + delete env.OPENAI_API_KEY; delete env.CODEX_API_KEY; + const version = spawnSync(binary, [...prefix, '--version'], { env, encoding: 'utf8', timeout: 15000, maxBuffer: limit }); + if (version.error || version.status !== 0 || version.stdout.trim() !== 'codex-cli 0.153.4') throw failure('codex_native_version'); + const child = spawn(binary, [...prefix, 'app-server', '--listen', 'stdio://'], { env, cwd: workspace, stdio: ['pipe', 'pipe', 'pipe'] }); + const closed = new Promise(resolve => child.once('close', resolve)); + let pending; + let bytes = 0; + let broken; + const rejectPending = code => { broken = failure(code); pending?.reject(broken); }; + child.on('error', () => rejectPending('codex_spawn')); + child.stdin.on('error', () => rejectPending('codex_stdin')); + child.stderr.resume(); + child.stdout.on('data', chunk => { bytes += chunk.length; if (bytes > limit) rejectPending('codex_output_limit'); }); + const lines = createInterface({ input: child.stdout }); + lines.on('line', line => { + try { + const message = JSON.parse(line); + if (pending && message.id === pending.id) { + if (message.error) pending.reject(failure('codex_rpc')); + else pending.resolve(message.result); + } + } catch { rejectPending('codex_invalid_json'); } + }); + child.on('close', () => rejectPending('codex_early_exit')); + let sequence = 0; + const request = async (method, params) => { + if (broken) throw broken; + const id = ++sequence; + let timer; + try { + return await new Promise((resolve, reject) => { + pending = { id, resolve, reject }; + timer = setTimeout(() => reject(failure('codex_rpc_timeout')), 15000); + child.stdin.write(JSON.stringify({ id, method, params }) + '\n'); + }); + } finally { clearTimeout(timer); pending = undefined; } + }; + try { + await request('initialize', { clientInfo: { name: 'parsar-native-smoke', version: '1' }, capabilities: { experimentalApi: true } }); + child.stdin.write(JSON.stringify({ method: 'initialized' }) + '\n'); + const result = await request('thread/start', { cwd: workspace, approvalPolicy: 'never', sandbox: 'danger-full-access', persistExtendedHistory: true }); + if (typeof result?.thread?.id !== 'string' || !result.thread.id) throw failure('codex_thread_identity'); + return { status: 'passed', version: '0.153.4', initialize: true, thread_start: true }; + } finally { + child.stdin.end(); + if (await Promise.race([closed.then(() => true), delay(5000).then(() => false)]) === false) { + if (process.platform === 'win32' && child.pid) spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', timeout: 5000 }); + else child.kill('SIGKILL'); + await Promise.race([closed, delay(5000)]); + } + lines.close(); + } +} + +async function claudeSmoke() { + const root = resolve(options['--claude-runtime'] ?? 'packages/claude-sdk-adapter'); + const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')); + if (manifest.dependencies?.['@anthropic-ai/claude-agent-sdk']?.split('(')[0] !== '0.3.269') throw failure('claude_package_version'); + const result = spawnSync(process.execPath, [join(root, 'dist', 'runtime_check.js')], { + cwd: root, encoding: 'utf8', timeout: 20000, maxBuffer: limit, + }); + if (result.error || result.status !== 0) throw failure('claude_runtime_unavailable'); + let info; + try { info = JSON.parse(result.stdout); } catch { throw failure('claude_invalid_receipt'); } + if (info.type !== 'runtime_ready' || info.sdk !== '0.3.269' || info.native !== '2.1.269 (Claude Code)' || !info.features?.includes('local_runtime_v1')) throw failure('claude_runtime_identity'); + return { status: 'passed', sdk: info.sdk, native: info.native }; +} + +const root = await realpath(await mkdtemp(join(tmpdir(), 'parsar-native-smoke-'))); +const report = { platform: process.platform, arch: process.arch, model_requests: 0 }; +try { + for (const [name, run] of [['codex', () => codexSmoke(root)], ['claude', claudeSmoke]]) { + if (options['--only'] && options['--only'] !== name) continue; + try { report[name] = await run(); } + catch (error) { report[name] = { status: 'failed', code: error.safeCode ?? 'native_smoke_unavailable' }; process.exitCode = 1; } + } +} finally { + await rm(root, { recursive: true, force: true }); +} +// Deliberately exclude child stdout/stderr and request contents from CI output. +console.log(JSON.stringify(report)); diff --git a/services/agents-api/deploy/claude/Dockerfile b/services/agents-api/deploy/claude/Dockerfile index 22ff7cd15..e33d13ed2 100644 --- a/services/agents-api/deploy/claude/Dockerfile +++ b/services/agents-api/deploy/claude/Dockerfile @@ -2,20 +2,18 @@ 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 python3-pip ripgrep bubblewrap socat \ + ca-certificates bash git python3 python3-pip ripgrep \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /environment/workspace /workspace /home/runtime -COPY build-system-seed.py /tmp/build-system-seed.py -RUN python3 -I -S /tmp/build-system-seed.py && rm /tmp/build-system-seed.py -COPY --chmod=0555 tool-root.py /usr/local/bin/oac-tool-root COPY --chmod=0555 oac-daemon oac-codex-directory oac-codex-write oac-workspace-export /usr/local/bin/ COPY claude-sdk /opt/claude-sdk -COPY --chmod=0444 runtime-initialize.py /usr/local/bin/oac-runtime-initialize ENV HOME=/home/runtime OAC_RUNTIME_HOME=/home/runtime/.oac \ OAC_RUNTIME_CLAUDE_SDK_NODE=/usr/local/bin/node \ OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT=/opt/claude-sdk/dist/main.js \ OAC_RUNTIME_CLAUDE_SDK_WORKSPACE=managed \ OAC_RUNTIME_WORKSPACE=/environment/workspace \ + OAC_RUNTIME_INITIALIZATION_DIRECTORY=/environment/initialization \ + OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages \ OAC_RUNTIME_DIRECTORY_HELPER=/usr/local/bin/oac-codex-directory \ OAC_RUNTIME_WRITE_HELPER=/usr/local/bin/oac-codex-write \ OAC_RUNTIME_EXPORT_HELPER=/usr/local/bin/oac-workspace-export \ diff --git a/services/agents-api/deploy/claude/README.md b/services/agents-api/deploy/claude/README.md index 7dec49947..72a87aa21 100644 --- a/services/agents-api/deploy/claude/README.md +++ b/services/agents-api/deploy/claude/README.md @@ -17,11 +17,18 @@ docker build --platform linux/amd64 -t agents-runtime:claude \ 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. +Core's database-owned managed deployment with the resulting immutable Runtime +image. Existing Docker outer security settings are unchanged; the daemon and +native adapter do not add an inner sandbox. Tools run as UID/GID 1000 and can +access files available to that user. + +Install required system dependencies while building the image/template. Runtime +has no automatic apt installation, sudo or elevated daemon permissions; +`system_packages` is unsupported and missing dependencies cause operation failure. +npm/Python package and setup commands run directly with the existing user's +permissions. The packaged image no longer needs bubblewrap or socat for an inner +sandbox. For native self-hosting and platform limits, see the +[native guide](../../../../docs/self-hosted-native.md). Set `OAC_DEFAULT_HARNESS=claude_sdk`. Configure the deployment default model provider with the Core key, in Web or through Core's API: @@ -35,15 +42,16 @@ curl -fsS -X PUT http://127.0.0.1:8091/core/v1/harnesses/claude_sdk/model-provid A Session may instead supply its own `x_agents_core.model_provider`. Use the endpoint and model supported by your actual provider. Do not bake keys into the image. Core freezes the bundle in the Session's encrypted snapshot and delivers it -to the adapter; it is not a public Agent field. Workspace tool processes cannot -inherit the provider secret. +to the adapter; it is not a public Agent field. Ordinary environment projection +does not isolate locally stored credentials from tools running as the same user. 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 and declared public functions with text results. Files and Artifacts use the shared public interfaces. -Workspace HTTP MCP, multi-agent, structured output, user-managed enrollment -and other environment installations remain explicit gaps. The existing +Check the current qualified engine profile for MCP, subagents and structured +output combinations. Historical hosted-profile acceptance does not qualify native +self-hosted platforms or every feature combination. The existing `environment:none` function/MCP profile is separate. This is not complete upstream protocol compatibility. diff --git a/services/agents-api/deploy/codex/Dockerfile b/services/agents-api/deploy/codex/Dockerfile index 482866f2a..32b7304c6 100644 --- a/services/agents-api/deploy/codex/Dockerfile +++ b/services/agents-api/deploy/codex/Dockerfile @@ -2,20 +2,18 @@ # Native package and resources must both be Codex 0.153.4 (linux/amd64). FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates bash git python3 python3-pip nodejs npm ripgrep bubblewrap \ + ca-certificates bash git python3 python3-pip nodejs npm ripgrep \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /environment/workspace /workspace /home/runtime \ && groupadd --gid 1000 runtime \ && useradd --uid 1000 --gid 1000 --home-dir /home/runtime --no-create-home runtime -COPY build-system-seed.py /tmp/build-system-seed.py -RUN python3 -I -S /tmp/build-system-seed.py && rm /tmp/build-system-seed.py -COPY --chmod=0555 tool-root.py /usr/local/bin/oac-tool-root COPY --chmod=0555 oac-daemon oac-codex-directory oac-codex-write oac-workspace-export codex /usr/local/bin/ COPY codex-resources /usr/local/codex-resources -COPY --chmod=0444 runtime-initialize.py /usr/local/bin/oac-runtime-initialize ENV HOME=/home/runtime OAC_RUNTIME_HOME=/home/runtime/.oac \ OAC_RUNTIME_CODEX_BIN=/usr/local/bin/codex \ OAC_RUNTIME_WORKSPACE=/environment/workspace \ + OAC_RUNTIME_INITIALIZATION_DIRECTORY=/environment/initialization \ + OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages \ OAC_RUNTIME_DIRECTORY_HELPER=/usr/local/bin/oac-codex-directory \ OAC_RUNTIME_WRITE_HELPER=/usr/local/bin/oac-codex-write \ OAC_RUNTIME_EXPORT_HELPER=/usr/local/bin/oac-workspace-export \ diff --git a/services/agents-api/deploy/codex/README.md b/services/agents-api/deploy/codex/README.md index 0b63baab9..134caa46c 100644 --- a/services/agents-api/deploy/codex/README.md +++ b/services/agents-api/deploy/codex/README.md @@ -1,102 +1,43 @@ # Codex colocated Runtime -These operator inputs select the V1 native isolation profile. The explicit service -configuration below enables basic Docker-hosted admission; it does not replace -the accepted caller-managed `self_hosted` path. Use one dedicated sandbox and retained -home/workspace per Session. Core stays outside it. Do not mount another Session's -history, host home, Docker socket, or product/Core credentials. - -The shared Runtime and its two management modes are defined in -[the contributor guide](../../../../CONTRIBUTING.md#product-and-execution-service-separation). -User-managed installation and enrollment are outside this qualification batch. - -Use Codex 0.153.4 and its matching `codex-resources` directory. Install the immutable -requirements file at `/etc/codex/requirements.toml`, mount only the authorized -Environment parent at `/environment`, containing `workspace`, private `staging`, tool `initialization` and `packages` -directories on one mount for trusted writes. Retain daemon/native state beneath `/home`. Set -`OAC_RUNTIME_CODEX_PERMISSION_PROFILE=managed-workspace` on the daemon. This operator -setting enables adapter selection of the immutable Runtime network profile at -startup and on both new/resumed threads; -it also filters native shell inheritance to process essentials, retaining default -secret exclusions. Native shell snapshots are disabled for this managed path: -their private storage is unavailable inside the tool sandbox, so loading one would -abort the pinned harness's login-shell wrapper. Ordinary shell startup remains -native. Model credentials remain available to the trusted harness; -it is not a request option or a public capability. Missing/invalid native profiles -must fail, without falling back to an unrestricted run. Ordinary deployments leave -this setting unset. It is incompatible with remote/none/read-only preparations. - -The requirements file is outside the workspace and read-only. It fixes the allowed -profile and denies reads of daemon authentication, generated provider configuration -and native history under the declared daemon state layout. Minimal native reads -exclude other home contents; native helper aliases under the Session tmp directory -remain readable so the pinned harness can start its sandbox and apply_patch. -The workspace and initialized package prefix are writable by native tools; -initialized tool configuration is read-only. Staging and its ancestors -are unavailable for native tool writes; staging is also explicitly denied for reads. -The image includes enabled and disabled native network profiles with the same -filesystem restrictions. Bootstrap freezes `OAC_RUNTIME_NETWORK_ACCESS`; -preparation must match it, and the adapter selects the native profile. Public -omission defaults to enabled. Restricted domains are unimplemented and rejected. -The trusted harness and daemon retain model/Core connectivity with either policy. -Do not expose private stock filesystem RPC as public Files. -Public file access needs the existing bounded, authorized filesystem primitives. - -The Docker candidate uses a non-root user, no capabilities, no-new-privileges, -read-only root, private PID namespace, dedicated bridge networking, bounded tmpfs -and process/memory/CPU limits. Stock Codex's inner bubblewrap sandbox needs user, -mount and PID namespaces. `seccomp.json` retains the Moby default restrictions and -adds clone, unshare, setns, mount, umount2 and pivot_root for that inner sandbox. -No host capability or privileged container is required. On the tested AppArmor -host, Docker's default profile denies namespaced mounts; the candidate uses -container-specific `apparmor=unconfined`. This removes that outer LSM layer, so the -native sandbox, outer namespace/capability restrictions and actual isolation -checks remain required. Do not alter the host-wide AppArmor or seccomp policy. +A managed Session runs the daemon, Codex and workspace in a dedicated outer +Environment. Core stays outside it. Use Codex 0.153.4 and its matching +`codex-resources`. The same daemon supports native self-hosted installations; see +[the native guide](../../../../docs/self-hosted-native.md) for platform status. + +Codex tools run with the daemon user's existing permissions. There is no inner +filesystem, permission or network sandbox, no immutable Codex requirements file, +and no managed Bash wrapper. Tools may read Runtime state that the user can read. +Model credentials still belong in authenticated Runtime configuration, never image +layers. Do not mount another Session's history, host home, Docker socket or Core +credentials into the Environment. + +The existing outer Docker configuration is unchanged: non-root user, dropped +capabilities, no-new-privileges, read-only root, private PID namespace, dedicated +bridge networking, bounded tmpfs and process/memory/CPU limits. Its existing +`nested_sandbox` setting and seccomp file are deployment settings, not evidence +that the daemon adds inner isolation. The container-specific AppArmor exception +in that configuration removes an outer LSM layer; do not change host-wide policy. Seccomp source: [Moby profiles, revision 65adc7e](https://github.com/moby/profiles/blob/65adc7e022c97f55e45c054ff012988027733b87/seccomp/default.json), Apache-2.0. Unmodified source SHA-256: `785b2429264afba4d594320337cb17f144f3c7d51585f9805eef72e28f4f9334`. -The final extra syscall rule is the only change to the parsed upstream profile. - -Before treating this as a qualified deployment, verify native workspace execution, -credential/symlink/process-metadata denial, permission downgrade rejection, real -model execution, cancellation, daemon/container restart with retained history and -files, and missing-history rejection. An alive container or a selected profile is -not an isolation or public API acceptance result. Qualification applies only to -the selected deployment profile, not complete protocol compatibility or another -engine/provider. - -The optional local file writer uses the existing `oac-codex-write` binary -outside `/environment`, with `OAC_RUNTIME_WRITE_HELPER` selecting that immutable -executable and `OAC_RUNTIME_STAGING=/environment/staging`. Set -`OAC_RUNTIME_WORKSPACE=/environment/workspace`; the public file path remains -`/workspace/...` and Core sends only the relative path to the bound Runtime. -Docker mounts the existing volume's `workspace` subdirectory at `/workspace` as -a second view of the same files. The image contains the initial directory before -volume population, so bootstrap works with an empty volume. Native tools receive -only that additional workspace root; staging remains private. Trusted atomic -rename stays within `/environment`. A symlink is insufficient because stock Codex -mounts canonical roots. The qualified Docker version is 29.1.3; the engine must -support volume subpath mounts. Other versions need their own deployment checks. -Workspace and staging must share the same mount for atomic rename. Do not mount -them separately or put daemon/model credentials, native history or other tenants -inside `/environment`. The read-only Runtime can omit both writer settings. - -Hosted Turn output publication additionally requires the immutable -`oac-workspace-export` executable selected by -`OAC_RUNTIME_EXPORT_HELPER`. The Runtime bundle includes it outside the workspace. -It exports regular files below `outputs` through the authenticated daemon connection; -Core stores immutable copies and publishes them with successful Turn completion. -Use a matched Core/Runtime release: older Runtime images without bounded output -export are ineligible for hosted execution. Listing and downloading already -published artifacts uses the execution database and requires no running Environment. -The export capability does not replace the deployment isolation checks above. - -The installer runs as a trusted bounded daemon child with a minimal environment. -Native tools retain their narrower filesystem policy. Qualify direct reads, -symlink and process-root aliases, attempted staging modification, real uploaded -bytes consumed by Codex, cancellation and retained-history restart before using -this writer profile for public admission. Configuration alone is not that proof. +The retained extra syscall rule is unchanged by removal of the inner sandbox. +Historical qualification of private-file denial under the former native sandbox +does not describe current tool permissions or qualify the new deployment. + +The packaged image retains `/environment/workspace`, initialization, package and +staging directories plus daemon/native state under `/home/runtime`. Docker also +mounts the workspace at `/workspace`. Runtime binds public Files/Artifacts to its +configured workspace; native tools execute with ordinary user permissions. The +shared helper artifacts remain in the matched Runtime bundle. The Docker engine +must support volume subpath mounts; its recorded qualification used 29.1.3. + +Published Artifacts are immutable Core-owned copies of successful Turn outputs; +listing and downloading them requires no running Environment. Validate actual +execution, file operations, cancellation, process settlement and retained native +history using the current binary and outer deployment. An alive container alone +is not acceptance. ## Managed Runtime image and Docker adapter @@ -135,11 +76,10 @@ the container, explicitly removes its named volumes and confirms absence. Keep t reference and retry cleanup when an operation fails; an HTTP timeout is not proof that a resource disappeared. Never use broad container or volume pruning. -RunCommand is for trusted initialization, using an explicit context deadline, -argument vector and nonroot user. It preserves nonzero status and limits each -output stream to1MiB. Disconnecting an exec stream does not stop the command: -an unconfirmed result requires allocation cleanup before reuse. Routine agent -execution, cancellation and Files continue through Core/daemon/Runtime. +Provider commands are limited to bootstrap and resource lifecycle. Runtime owns +initial files, configuration, npm/Python packages, setup and capabilities over its +authenticated protocol. An unconfirmed bootstrap result requires allocation +cleanup before reuse; compute readiness does not prove Runtime readiness. ## Standalone operator configuration @@ -185,19 +125,26 @@ With the qualified Codex image and Docker provider configured, create an idle or initial-text Session using `environment: {"type": "openai_hosted"}`. Core commits its identity before automatically provisioning it. Queries expose durable connection status; execution separately prepares the native harness. Session -deletion revokes authority before owned container/volume cleanup. Supported network -policies are enabled, disabled and restricted to exact ASCII hostnames. Templates -and inline configuration share initial files, env, packages, ordered setup and inline -Skills; see the [supported fields and limits](../../../../contracts/agents-api/environment-templates.md). -Other hostname forms, Plugins, Skill references, capability-directory imports and -hosted MCP combinations remain explicit gaps. +deletion revokes authority before owned container/volume cleanup. The daemon does +not enforce `disabled` or `restricted` networking; combinations without matching +outer enforcement are unsupported. Templates and inline configuration share +initial files, env, npm/Python packages, ordered setup and capabilities; see the +[supported fields and limits](../../../../contracts/agents-api/environment-templates.md). ### Environment initialization -Templates and inline env/setup/npm/Python configuration share the packaged Runtime -initializer. Enable the existing `nested_sandbox: true` Docker provider setting -when admitting these configurations: user commands and package hooks require -bubblewrap user/PID/mount isolation. Runtime execution still uses native Codex -isolation. The image includes the trusted initializer and managed native Bash -hook; do not inject user env into the daemon or app-server launcher. -See [initialization contract and limits](../../../../contracts/agents-api/environment-templates.md). +Templates and inline env/setup/npm/Python configuration share the daemon's Go +Runtime preparation implementation on all platforms. The packaged image sets +`OAC_RUNTIME_INITIALIZATION_DIRECTORY=/environment/initialization` and +`OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages` to retain its resource layout. +No Python initialization wrapper is shipped. It runs commands directly as UID/GID 1000 using configured tool +variables; it does not inject those variables into the daemon or native harness +launcher. Cancellation and finite receipts remain Runtime responsibilities. + +System dependencies must be installed during image/template construction. The +daemon never runs apt, sudo or another privilege escalation, and +`system_packages` is unsupported. A missing executable or library fails the +operation requiring it. The image no longer contains a system-root seed or +system-package launcher. Native self-hosted users prepare their own dependencies +before starting the daemon. See +[initialization contract and limits](../../../../contracts/agents-api/environment-templates.md). diff --git a/services/agents-api/deploy/e2b/README.md b/services/agents-api/deploy/e2b/README.md index dfd0400f6..57e125814 100644 --- a/services/agents-api/deploy/e2b/README.md +++ b/services/agents-api/deploy/e2b/README.md @@ -66,6 +66,12 @@ private workspace layout. Its `template` output is an immutable can include several harnesses. No E2B account key, executor key or model credential belongs in a build, template environment, metadata, command argument or log. +System dependencies must be installed when building the template. The builder may +use root during image construction, but the running daemon remains UID/GID 1000 +with no automatic apt, sudo or privilege escalation. Runtime `system_packages` +is unsupported; missing dependencies fail their consuming operation. The template +no longer installs bubblewrap or socat for inner sandboxing. + ## Application-managed: start an existing self-hosted Environment Create a public `self_hosted` Environment through Core and retain its ID and exact @@ -158,9 +164,11 @@ the daemon's argv or inherited environment. Enrollment and immutable local bindi remain daemon responsibilities; startup does not invent device/Session IDs. E2B clears `/run` at boot, so startup records live under `/root/.oac/e2b`. -Native sandboxing remains mandatory. Each actual template must verify private -credential/history isolation, protected binary/config ownership, privilege denial -and stopped descendant effects, rather than infer safety from file modes alone. +The E2B VM is the outer isolation boundary. The daemon and native harness add no +inner filesystem, permission or network sandbox; tools have UID 1000's access to +Runtime state. Verify the actual outer boundary, process cleanup and native +execution for each template. Prior private-file denial results describe the old +inner sandbox and do not qualify the current behavior. The one-shot receipt cannot be reused to replace the daemon or overwrite history. ## Verification scope @@ -175,7 +183,7 @@ These are controlled startup-contract tests: input binding, protected key output unchanged URL, no secret in argv/environment/record, one-shot claim, and retained provider ID on unknown outcomes. They do not create billable resources or qualify E2B security, enrollment or model execution. The [qualification record](../../../../contracts/agents-api/user-managed-runtime-v1.md) -identifies actual three-harness deployment results and their verification limits, +records historical three-harness deployment results and their verification limits, including shared Core credential lifecycle checks and explicit application-owned cleanup. `tests/official_user_runtime.py` supplies the shared public execution checks. The former Core-managed `official_e2b_v1.py` fixture is diff --git a/services/agents-api/deploy/e2b/build-template.py b/services/agents-api/deploy/e2b/build-template.py index bbb9ce81d..9eb56cef4 100644 --- a/services/agents-api/deploy/e2b/build-template.py +++ b/services/agents-api/deploy/e2b/build-template.py @@ -68,7 +68,7 @@ (context / name).write_bytes(Path(__file__).with_name(name).read_bytes()) template = (Template(file_context_path=context).from_image(BASE) .run_cmd('apt-get update && apt-get install -y --no-install-recommends ' - 'ca-certificates bash git python3 python3-pip ripgrep bubblewrap socat util-linux ' + 'ca-certificates bash git python3 python3-pip ripgrep util-linux ' '&& rm -rf /var/lib/apt/lists/*', user='root') .copy('runtime.tar.gz', '/root/runtime.tar.gz', user='root') .copy('runtime-env.json', '/etc/oac-runtime-env.json', user='root') diff --git a/services/agents-api/deploy/e2b/init.py b/services/agents-api/deploy/e2b/init.py index 7de64064b..75d8e1f07 100644 --- a/services/agents-api/deploy/e2b/init.py +++ b/services/agents-api/deploy/e2b/init.py @@ -68,7 +68,6 @@ def prepare_runtime(): # E2B finalization makes /usr/local world-writable after template commands. subprocess.run(['chown', '-R', 'root:root', '/usr/local'], check=True) subprocess.run(['chmod', '-R', 'go-w', '/usr/local'], check=True) - os.chmod('/usr/local/bin/oac-tool-root', 0o555) for protected in ['/usr/bin/envd', '/etc/inittab', '/etc/init.d/rcS']: if protected == '/etc/init.d/rcS' and not Path(protected).exists(): continue diff --git a/services/agents-api/deploy/mcode/Dockerfile b/services/agents-api/deploy/mcode/Dockerfile index 4dfffe635..178d2ad43 100644 --- a/services/agents-api/deploy/mcode/Dockerfile +++ b/services/agents-api/deploy/mcode/Dockerfile @@ -2,15 +2,11 @@ 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 python3-pip ripgrep bubblewrap socat \ + ca-certificates bash git python3 python3-pip ripgrep \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /environment/workspace /workspace /home/runtime -COPY build-system-seed.py /tmp/build-system-seed.py -RUN python3 -I -S /tmp/build-system-seed.py && rm /tmp/build-system-seed.py -COPY --chmod=0555 tool-root.py /usr/local/bin/oac-tool-root COPY --chmod=0555 oac-daemon oac-codex-directory oac-codex-write oac-workspace-export /usr/local/bin/ COPY mcode-harness /opt/mcode-harness -COPY --chmod=0444 runtime-initialize.py /usr/local/bin/oac-runtime-initialize ENV HOME=/home/runtime OAC_RUNTIME_HOME=/home/runtime/.oac \ OAC_RUNTIME_MCODE_NODE=/usr/local/bin/node \ OAC_RUNTIME_MCODE_BIN=/opt/mcode-harness/native/cli.js \ @@ -18,6 +14,8 @@ ENV HOME=/home/runtime OAC_RUNTIME_HOME=/home/runtime/.oac \ OAC_RUNTIME_MCODE_AGENTS_API=1 \ OAC_RUNTIME_MCODE_WORKSPACE=managed \ OAC_RUNTIME_WORKSPACE=/environment/workspace \ + OAC_RUNTIME_INITIALIZATION_DIRECTORY=/environment/initialization \ + OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages \ OAC_RUNTIME_DIRECTORY_HELPER=/usr/local/bin/oac-codex-directory \ OAC_RUNTIME_WRITE_HELPER=/usr/local/bin/oac-codex-write \ OAC_RUNTIME_EXPORT_HELPER=/usr/local/bin/oac-workspace-export \ diff --git a/services/agents-api/deploy/mcode/README.md b/services/agents-api/deploy/mcode/README.md index f5fad2ab1..54765061a 100644 --- a/services/agents-api/deploy/mcode/README.md +++ b/services/agents-api/deploy/mcode/README.md @@ -4,7 +4,8 @@ MiniMax Code uses the same Core/Runtime execution contract as the other engines. The text profile supports `environment:none`; the dedicated Docker profile adds workspace execution and the shared Files/Artifacts path. See [workspace qualification](../../../../contracts/agents-api/mcode-workspace-v1.md) -for exact acceptance evidence and limits. +for historical acceptance evidence and its limits. Inner sandbox denial results +from that qualification do not describe the current bypass profile. Public MCP/functions, image input and native Subagent execution remain outside this batch. @@ -21,7 +22,9 @@ This profile runs on a trusted execution host. Set `OAC_RUNTIME_MCODE_BIN` to that absolute executable and `OAC_RUNTIME_MCODE_AGENTS_API=1` for the daemon. The opt-in only advertises the profile for the qualified version. Use the existing authenticated daemon connection and operator device enrollment; -this is not a new public enrollment API or official `self_hosted` implementation. +native self-hosted installation uses the same Runtime protocol. See the +[native guide](../../../../docs/self-hosted-native.md); MiniMax on Windows remains +unsupported. Set `OAC_DEFAULT_HARNESS=mcode` in the independent Core deployment. Existing Sessions retain their engine. Do not expose a new public harness selector. @@ -48,10 +51,10 @@ change them. Public Files and Artifacts use the common bound workspace helpers. The native process and ACP Session use a private control directory, while six original native tools execute against `/workspace` through one trusted MCP bridge -and the upstream Linux sandbox. MCP is internal transport here; it does not enable -caller-supplied public MCP servers. Project instructions must be read through the +with the daemon user's ordinary permissions and no inner sandbox. MCP is internal +transport here; its presence alone does not enable caller-supplied public MCP servers. Project instructions must be read through the workspace tools. Native automatic project configuration and diff/undo capture do -not apply to this isolated tool path. Exact native-ID continuation remains +not apply to this adapter tool path. Exact native-ID continuation remains required; recovery without a recorded ID fails closed. Native Bash observations become public `command_execution` items after command arguments arrive. Their text output and status are retained; absent native exit code/duration stay unknown. @@ -84,7 +87,15 @@ child inherits only process and model-network essentials; it does not inherit Core/daemon tokens or arbitrary Node startup configuration. The adapter owns its native config, instructions and home and disables external skills, delegated work, web search, builtin file/shell tools, browser tools, mcode-tools and native goals. -The workspace profile adds only its trusted isolated tool bridge. +The workspace profile uses its tool bridge without sandbox isolation. Tools can +read any local state available to the launching user. The outer Environment owns +managed isolation; daemon network modes do not add another network boundary. + +Provide system dependencies at image/template build time or on the self-hosted +machine before execution. Runtime does not run apt or sudo or elevate daemon +permissions. `system_packages` is unsupported; missing dependencies fail the +operation requiring them. npm/Python packages and setup retain direct execution +with the user's existing permissions. The native model may still see `skill`, `task_query`, `task_output` and `task_stop`. The first loads an exact registered skill name and cannot execute a script; the @@ -105,6 +116,9 @@ native ACP context occupancy and cumulative cost are not per-Turn usage. ## Acceptance +Recorded results below are historical evidence for their exact binaries and +profiles, not qualification of the current native platforms or bypass behavior. + The opt-in `TestNativeMCodePublicExecution` uses the fixed official Python SDK, raw HTTP, actual daemon/gateway/Worker and a dedicated PostgreSQL test database. Provide private `OAC_TEST_MCODE_REAL_OPTIONS` (the provider object above plus the diff --git a/services/agents-api/deploy/microsandbox/README.md b/services/agents-api/deploy/microsandbox/README.md index dc0949b4f..fe8e66840 100644 --- a/services/agents-api/deploy/microsandbox/README.md +++ b/services/agents-api/deploy/microsandbox/README.md @@ -101,9 +101,10 @@ substituting a local image for an already saved release. The provider performs the existing Runtime bootstrap, starts the daemon as uid/gid 1000 and creates `/run/oac` as a private control directory. No model, -Core or tenant credential belongs in the image. The ordinary Runtime initializer -and native isolation profile still apply; snapshot restore does not rerun setup -commands or initial file writes. +Core or tenant credential belongs in the image. The Runtime initializer executes +with that user's existing permissions; the microVM is the isolation boundary. +System dependencies belong in the image: Runtime does not run apt or sudo and +rejects `system_packages`. Snapshot restore does not rerun setup or initial files. ## Deployment and node configuration @@ -127,8 +128,8 @@ retention. The private node provider file supplies its absolute helper/runtime/firmware paths, short Runtime home and explicit host network policy. Permit the required Core, model and package-registry endpoints. Creation and restore apply the same host -policy. Native tool-network policy remains separate and uses the existing Runtime -controls. Never repoint a retained backend namespace or overwrite node identity +policy. The daemon does not enforce `disabled` or `restricted` native network +modes; combinations without required outer enforcement are unsupported. Never repoint a retained backend namespace or overwrite node identity to bypass a configuration mismatch. Use the existing [standalone Core setup](../../README.md) for the database, diff --git a/services/agents-api/deploy/runtime/build-system-seed.py b/services/agents-api/deploy/runtime/build-system-seed.py deleted file mode 100644 index b1dd9c954..000000000 --- a/services/agents-api/deploy/runtime/build-system-seed.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Run during image construction, before installing any Runtime or harness code.""" -import hashlib -import json -from pathlib import Path -import tarfile - - -OUTPUT = Path('/opt/agents-runtime') -EXCLUDED = {'etc/hostname', 'etc/hosts', 'etc/resolv.conf', 'etc/machine-id', - 'etc/mtab', 'etc/shadow', 'etc/gshadow', 'opt/agents-runtime'} - - -def member(info): - if any(info.name == path or info.name.startswith(path + '/') for path in EXCLUDED): - return None - return info - - -def main(): - OUTPUT.mkdir(mode=0o755) - seed = OUTPUT / 'system-root.tar.gz' - # Preserve the matching package database and all base tool symlink targets. - with tarfile.open(seed, 'w:gz', compresslevel=1, dereference=False) as archive: - for name in ('usr', 'bin', 'sbin', 'lib', 'lib64', 'opt', 'etc', - 'var/lib/dpkg', 'var/lib/apt', 'var/cache/debconf'): - path = Path('/') / name - if path.exists() or path.is_symlink(): - archive.add(path, arcname=name, filter=member) - for name in ('dev', 'proc', 'sys', 'tmp', 'run', 'home', 'root', 'workspace', - 'environment', 'var/log', 'var/cache/apt/archives/partial', - 'var/lib/apt/lists/partial'): - info = tarfile.TarInfo(name) - info.type, info.mode = tarfile.DIRTYPE, 0o755 - archive.addfile(info) - for name in ('etc/hostname', 'etc/hosts', 'etc/resolv.conf', 'etc/machine-id'): - archive.addfile(tarfile.TarInfo(name)) - seed.chmod(0o444) - with seed.open('rb') as stream: - digest = hashlib.file_digest(stream, 'sha256').hexdigest() - manifest = OUTPUT / 'system-root.json' - manifest.write_text(json.dumps({'version': 1, 'sha256': digest, 'size_bytes': seed.stat().st_size}) + '\n') - manifest.chmod(0o444) - - -if __name__ == '__main__': - main() diff --git a/services/agents-api/deploy/runtime/initialize.py b/services/agents-api/deploy/runtime/initialize.py deleted file mode 100644 index 86d3e7d54..000000000 --- a/services/agents-api/deploy/runtime/initialize.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Trusted hosted initialization. Invoke with /usr/bin/python3 -I -S. - -Core owns sequencing and the completion ledger. This helper executes one bounded -operation; it never retries, schedules, selects a harness or interprets templates. -""" -import json -import os -from pathlib import Path -import re -import runpy -import shlex -import subprocess -import sys - -ROOT = Path('/environment') -CONFIG = ROOT / 'initialization' -PACKAGES = ROOT / 'packages' -ENV_FILE = CONFIG / 'tool-env.sh' -MAX_INPUT = 32 * 1024 * 1024 -BASE_ENV = {'PATH': '/usr/local/bin:/usr/bin:/bin', 'HOME': '/tmp', 'LANG': 'C.UTF-8'} -DIRECTORY = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC - - -def roots(workspace=None): - for directory in (Path(workspace) if workspace else ROOT / 'workspace', PACKAGES, CONFIG): - if not directory.is_dir(): - raise ValueError('invalid Runtime directory') - - -def configure(env): - if not isinstance(env, dict) or any( - not isinstance(name, str) or not re.fullmatch('[A-Za-z_][A-Za-z0-9_]*', name) - or not isinstance(value, str) or '\x00' in value - for name, value in env.items() - ): - raise ValueError('invalid environment') - # Public reserved-name validation belongs to Core. Only the configured - # environment and these defaults are passed to initialization commands. - values = { - 'PATH': str(PACKAGES / 'npm/bin') + ':' + str(PACKAGES / 'python/bin') + ':' + BASE_ENV['PATH'], - **env, - 'PYTHONPATH': str(PACKAGES / 'python') + (':' + env['PYTHONPATH'] if env.get('PYTHONPATH') else ''), - } - materialized = json.dumps(values, ensure_ascii=True) - script = ''.join('export ' + name + '=' + shlex.quote(value) + '\n' for name, value in sorted(values.items())) - directory = os.open(CONFIG, DIRECTORY) - try: - fd = os.open('tool-env.sh', os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o400, dir_fd=directory) - with os.fdopen(fd, 'w') as stream: - stream.write(script) - stream.flush() - os.fsync(stream.fileno()) - fd = os.open('tool-env.json', os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o400, dir_fd=directory) - with os.fdopen(fd, 'w') as stream: - stream.write(materialized) - stream.flush() - os.fsync(stream.fileno()) - os.fsync(directory) - finally: - os.close(directory) - - -class StepFailure(Exception): - """A launched initialization command failed; never retain its text or output.""" - - def __init__(self, exit_code): - self.exit_code = exit_code - super().__init__('initialization command failed') - - -def execution_directory(cwd, workspace): - if (not isinstance(cwd, str) or os.path.normpath(cwd) != cwd - or any(ord(c) < 32 or ord(c) == 127 or c == '\\' for c in cwd) - or not (cwd == '/workspace' or cwd.startswith('/workspace/'))): - raise ValueError('invalid execution directory') - return str(Path(workspace or ROOT / 'workspace') / cwd.removeprefix('/workspace').lstrip('/')) - - -def run(request, workspace=None, capability_root=None): - action = request['action'] - if action == 'configure': - configure(request.get('env', {})) - return - if action == 'system': - runpy.run_path('/usr/local/bin/oac-tool-root')['install'](request['packages'], workspace=workspace or str(ROOT / 'workspace')) - return - if action not in ('setup', 'npm', 'python'): - raise ValueError('invalid action') - if request['network'] not in ('enabled', 'disabled'): - raise ValueError('invalid execution configuration') - cwd = execution_directory(request.get('cwd', '/workspace'), workspace) - if action == 'setup': - command = request['command'] - if not isinstance(command, str) or not command or '\x00' in command: - raise ValueError('invalid setup command') - args = ['/bin/bash', '--noprofile', '--norc', '-c', command] - else: - packages = request['packages'] - if not isinstance(packages, list) or not packages or any( - not isinstance(package, str) or not package or '\x00' in package or package.startswith('-') - for package in packages - ): - raise ValueError('invalid packages') - args = ['npm', 'install', '--global', '--prefix', str(PACKAGES / 'npm'), '--', *packages] - if action == 'python': - args = ['/usr/bin/python3', '-m', 'pip', 'install', '--disable-pip-version-check', - '--no-input', '--target', str(PACKAGES / 'python'), '--', *packages] - env = {**BASE_ENV, **json.loads((CONFIG / 'tool-env.json').read_text())} - # Execution uses the launching user's permissions. The outer Environment owns - # isolation; command output is still excluded from the completion receipt. - try: - subprocess.run(args, cwd=cwd, env=env, stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) - except subprocess.CalledProcessError as error: - raise StepFailure(error.returncode) from None - - -def failed_receipt(error): - """Expose a command's exit status, never its command, input or output.""" - receipt = {'version': 1, 'outcome': 'failed'} - code = getattr(error, 'exit_code', None) - # The system installer retains its existing implementation until its package - # ownership is migrated; other internal helper failures remain generic. - if (isinstance(error, subprocess.CalledProcessError) and isinstance(error.cmd, list) - and error.cmd[:1] == ['/usr/bin/bwrap']): - code = error.returncode - if type(code) is int and 0 < code < 256: - receipt['exit_code'] = code - return json.dumps(receipt, separators=(',', ':')) - - -def main(): - workspace, capability_root = None, None - if len(sys.argv) == 4 and sys.argv[1] == 'initialize': - workspace, capability_root = sys.argv[2:] - try: - if len(sys.argv) != 1 and not (len(sys.argv) == 4 and sys.argv[1] == 'initialize'): - raise ValueError('invalid initialization invocation') - raw = sys.stdin.buffer.read(MAX_INPUT + 1) - if len(raw) > MAX_INPUT: - raise ValueError('input too large') - request = json.loads(raw) - if not isinstance(request, dict) or request.get('version') != 1: - raise ValueError('invalid version') - if workspace is not None: - path = Path(workspace) - if (not workspace.startswith('/') or workspace == '/' or os.path.normpath(workspace) != workspace - or any(ord(c) < 32 or ord(c) == 127 or c == '\\' for c in workspace) - or not path.is_dir()): - raise ValueError('invalid Runtime workspace') - if workspace is None: - roots() - run(request) - else: - roots(workspace) - run(request, workspace=workspace, capability_root=capability_root) - except Exception as error: - print(failed_receipt(error)) - return 1 - print('{"version":1,"outcome":"completed"}') - return 0 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/services/agents-api/deploy/runtime/initialize_receipt_test.py b/services/agents-api/deploy/runtime/initialize_receipt_test.py deleted file mode 100644 index e730838a6..000000000 --- a/services/agents-api/deploy/runtime/initialize_receipt_test.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Host-runnable initialization and finite-receipt checks; no packages downloaded.""" -import contextlib -import importlib.util -import io -import json -import os -from pathlib import Path -import subprocess -import tempfile -import unittest -from unittest import mock - -SPEC = importlib.util.spec_from_file_location('agents_api_runtime_initialize', Path(__file__).with_name('initialize.py')) -initialize = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(initialize) -CANARY = 'CANARY-initializer-receipt-5d1c' - - -def invoke(failure): - request = json.dumps({'version': 1, 'action': 'setup', 'network': 'enabled', 'command': 'echo ' + CANARY}) - stdin = mock.Mock() - stdin.buffer = io.BytesIO(request.encode()) - stdout, stderr = io.StringIO(), io.StringIO() - with mock.patch.object(initialize.sys, 'stdin', stdin), mock.patch.object(initialize.sys, 'argv', ['initialize']), \ - mock.patch.object(initialize, 'roots', lambda: None), mock.patch.object(initialize, 'run', side_effect=failure), \ - contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): - code = initialize.main() - return code, stdout.getvalue(), stderr.getvalue() - - -class InitializationTest(unittest.TestCase): - def test_configure_preserves_runtime_capabilities_and_freezes_environment(self): - with tempfile.TemporaryDirectory() as directory: - config = Path(directory) - capabilities = config / 'capabilities' - capabilities.mkdir() - manifest = capabilities / 'installed.json' - manifest.write_bytes(b'fixed-runtime-fixture') - with mock.patch.object(initialize, 'CONFIG', config): - initialize.configure({'VALUE': "configured'\n$(false)"}) - self.assertEqual(manifest.read_bytes(), b'fixed-runtime-fixture') - self.assertFalse((capabilities / 'skills').exists()) - self.assertEqual(json.loads((config / 'tool-env.json').read_text())['VALUE'], "configured'\n$(false)") - with self.assertRaises(FileExistsError): - initialize.configure({'VALUE': 'changed'}) - - def test_skill_is_not_an_initializer_action(self): - with mock.patch.object(initialize.subprocess, 'run') as run: - with self.assertRaisesRegex(ValueError, 'invalid action'): - initialize.run({'action': 'skill', 'network': 'enabled'}) - run.assert_not_called() - - def test_setup_executes_directly_with_resolved_cwd_and_configured_environment(self): - with tempfile.TemporaryDirectory() as directory: - base = Path(directory) - workspace, config, packages, capabilities = [base / name for name in ('work', 'config', 'packages', 'caps')] - for path in (workspace / 'sub', config, packages, capabilities): - path.mkdir(parents=True) - skill = capabilities / 'proof.sh' - skill.write_text('#!/bin/sh\nprintf skill-proof') - secret = base / 'same-user-file' - secret.write_text('ordinary-user-access') - import shlex - command = ('/bin/sh ' + shlex.quote(str(skill)) + ' > skill-result; ' - 'cat ' + shlex.quote(str(secret)) + ' > readable; ' - 'printf "%s" "$VALUE" > value; pwd > cwd; ' - 'test -z "${DAEMON_PRIVATE_CANARY+x}"') - with mock.patch.object(initialize, 'CONFIG', config), mock.patch.object(initialize, 'PACKAGES', packages), \ - mock.patch.dict(os.environ, {'DAEMON_PRIVATE_CANARY': CANARY}): - initialize.configure({'VALUE': "'\n$(false)"}) - initialize.run({'action': 'setup', 'network': 'disabled', 'cwd': '/workspace/sub', 'command': command}, - workspace=str(workspace), capability_root=str(capabilities)) - self.assertEqual((workspace / 'sub/skill-result').read_text(), 'skill-proof') - self.assertEqual((workspace / 'sub/readable').read_text(), 'ordinary-user-access') - self.assertEqual((workspace / 'sub/value').read_text(), "'\n$(false)") - self.assertEqual((workspace / 'sub/cwd').read_text(), str(workspace / 'sub') + '\n') - - def test_package_managers_use_direct_argv_and_frozen_environment(self): - with tempfile.TemporaryDirectory() as directory: - config = Path(directory) - (config / 'tool-env.json').write_text(json.dumps({'VALUE': CANARY})) - for action, executable in (('npm', 'npm'), ('python', '/usr/bin/python3')): - with self.subTest(action=action), mock.patch.object(initialize, 'CONFIG', config), \ - mock.patch.object(initialize.subprocess, 'run') as run: - initialize.run({'action': action, 'network': 'enabled', 'packages': ['valid-package']}, workspace=directory) - self.assertEqual(run.call_args.args[0][0], executable) - self.assertEqual(run.call_args.args[0][-2:], ['--', 'valid-package']) - self.assertEqual(run.call_args.kwargs['cwd'], directory) - self.assertEqual(run.call_args.kwargs['env']['VALUE'], CANARY) - for channel in ('stdin', 'stdout', 'stderr'): - self.assertEqual(run.call_args.kwargs[channel], subprocess.DEVNULL) - with self.assertRaises(ValueError): - initialize.run({'action': action, 'network': 'enabled', 'packages': ['--bad']}, workspace=directory) - - def test_system_still_delegates_to_existing_installer(self): - install = mock.Mock() - with mock.patch.object(initialize.runpy, 'run_path', return_value={'install': install}) as load: - initialize.run({'action': 'system', 'packages': ['jq']}, workspace='/actual/work') - load.assert_called_once_with('/usr/local/bin/oac-tool-root') - install.assert_called_once_with(['jq'], workspace='/actual/work') - - def test_cwd_uses_only_logical_workspace_paths(self): - self.assertEqual(initialize.execution_directory('/workspace/sub', '/actual'), '/actual/sub') - for invalid in ('/workspace/../outside', '/workspace/', '/elsewhere', 'relative', '/workspace/x\n'): - with self.subTest(cwd=invalid), self.assertRaises(ValueError): - initialize.execution_directory(invalid, '/actual') - - def test_private_initialization_uses_bound_workspace_and_empty_environment(self): - with tempfile.TemporaryDirectory() as directory: - workspace = str(Path(directory)) - stdin = mock.Mock() - stdin.buffer = io.BytesIO(b'{"version":1,"action":"configure"}') - with mock.patch.object(initialize.sys, 'argv', ['initialize', 'initialize', workspace, workspace + '/caps']), \ - mock.patch.object(initialize.sys, 'stdin', stdin), \ - mock.patch.object(initialize, 'roots') as roots, mock.patch.object(initialize, 'run') as run, \ - contextlib.redirect_stdout(io.StringIO()) as output: - self.assertEqual(initialize.main(), 0) - roots.assert_called_once_with(workspace) - run.assert_called_once_with({'version': 1, 'action': 'configure'}, workspace=workspace, capability_root=workspace + '/caps') - self.assertEqual(json.loads(output.getvalue()), {'version': 1, 'outcome': 'completed'}) - with mock.patch.object(initialize, 'configure') as configure: - initialize.run({'action': 'configure'}, workspace=workspace) - configure.assert_called_once_with({}) - - def test_invalid_invocation_and_oversized_input_remain_finite(self): - for argv, data in ((['initialize', 'stdio', 'unused'], b''), (['initialize'], b'x' * 33)): - stdin = mock.Mock() - stdin.buffer = io.BytesIO(data) - with mock.patch.object(initialize.sys, 'argv', argv), mock.patch.object(initialize.sys, 'stdin', stdin), \ - mock.patch.object(initialize, 'MAX_INPUT', 32), contextlib.redirect_stdout(io.StringIO()) as output: - self.assertEqual(initialize.main(), 1) - self.assertEqual(json.loads(output.getvalue()), {'version': 1, 'outcome': 'failed'}) - - def test_step_reports_only_its_exit_status(self): - for status in (1, 3, 100, 255): - code, stdout, stderr = invoke(initialize.StepFailure(status)) - self.assertEqual(code, 1) - self.assertEqual(stderr, '') - self.assertEqual(json.loads(stdout), {'version': 1, 'outcome': 'failed', 'exit_code': status}) - self.assertNotIn(CANARY, stdout) - self.assertEqual(invoke(initialize.StepFailure(3))[1], '{"version":1,"outcome":"failed","exit_code":3}\n') - - def test_runtime_helpers_signals_and_other_errors_stay_generic(self): - for failure in (initialize.StepFailure(-9), initialize.StepFailure(256), - subprocess.CalledProcessError(2, ['/usr/bin/tar', CANARY]), - subprocess.TimeoutExpired([CANARY], 120), ValueError(CANARY), OSError(CANARY)): - code, stdout, stderr = invoke(failure) - self.assertEqual((code, stderr), (1, '')) - self.assertEqual(json.loads(stdout), {'version': 1, 'outcome': 'failed'}) - self.assertNotIn(CANARY, stdout) - - def test_real_child_failure_has_finite_receipt(self): - with tempfile.TemporaryDirectory() as directory: - config = Path(directory) - (config / 'tool-env.json').write_text('{}') - with mock.patch.object(initialize, 'CONFIG', config): - with self.assertRaises(initialize.StepFailure) as failure: - initialize.run({'action': 'setup', 'network': 'enabled', - 'command': 'echo ' + CANARY + '; echo ' + CANARY + ' >&2; exit 7'}, workspace=directory) - self.assertEqual(initialize.failed_receipt(failure.exception), '{"version":1,"outcome":"failed","exit_code":7}') - - -if __name__ == '__main__': - unittest.main() diff --git a/services/agents-api/deploy/runtime/initialize_test.py b/services/agents-api/deploy/runtime/initialize_test.py deleted file mode 100644 index 2410779f2..000000000 --- a/services/agents-api/deploy/runtime/initialize_test.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Real Linux initialization checks; run inside a disposable packaged Runtime. - -The fixture must expose writable workspace/packages/initialization roots and -execute with the launching user's permissions. No model is mocked; -these checks exercise initialization only, not public native-model acceptance. -""" -import json -import os -from pathlib import Path -import subprocess -import sys - -HELPER = '/usr/local/bin/oac-runtime-initialize' -CANARY = 'private-initialization-canary-47a8' - - -def invoke(action, *, succeeds=True, exit_code=None, **fields): - payload = json.dumps({'version': 1, 'action': action, 'network': 'enabled', **fields}) - result = subprocess.run(['/usr/bin/python3', '-I', '-S', HELPER], input=payload, - text=True, capture_output=True, timeout=120) - expected = {'version': 1, 'outcome': 'completed' if succeeds else 'failed'} - if exit_code is not None: - # A failed initialization step reports only its exit status, never its output. - expected['exit_code'] = exit_code - assert result.returncode == (0 if succeeds else 1), (action, result.returncode) - assert result.stderr == '', (action, 'unexpected stderr') - assert json.loads(result.stdout) == expected, (action, result.stdout) - assert CANARY not in result.stdout + result.stderr, 'confidential output exposed' - - -def main(): - for name in ('workspace', 'packages', 'initialization', 'private', 'staging'): - Path('/environment', name).mkdir(exist_ok=True) - Path('/environment/private/credential').write_text(CANARY) - Path('/environment/staging/request').write_text(CANARY) - os.environ['DAEMON_PRIVATE_CANARY'] = CANARY - env = {'INITIALIZATION_VALUE': CANARY, 'WITH_QUOTES': "'\n$(false)"} - if os.environ.get('OAC_TEST_PACKAGE_PROXY'): - env.update(http_proxy=os.environ['OAC_TEST_PACKAGE_PROXY'], - https_proxy=os.environ['OAC_TEST_PACKAGE_PROXY']) - invoke('configure', env=env) - # A fixed Runtime-installed fixture tests setup access, not archive parsing. - skill = Path('/environment/initialization/capabilities/skills/proof') - (skill / 'scripts').mkdir(parents=True) - (skill / 'SKILL.md').write_text('---\nname: proof\ndescription: A proof.\n---\nRead check.sh.') - (skill / 'SKILL.md').chmod(0o400) - (skill / 'scripts/check.sh').write_text('#!/bin/sh\nprintf skill-proof') - (skill / 'scripts/check.sh').chmod(0o500) - invoke('setup', command='/environment/initialization/capabilities/skills/proof/scripts/check.sh > skill-result') - assert Path('/environment/workspace/skill-result').read_text() == 'skill-proof' - if '--system' in sys.argv: - invoke('system', packages=['jq', 'build-essential', 'libpq-dev']) - invoke('system', succeeds=False, packages=['jq']) - # The system installer currently retains its own package-root contract. - # Its native execution checks are migrated with that installer. - - - # Re-entry must not replace confidential configuration after any effects. - invoke('configure', succeeds=False, env={'INITIALIZATION_VALUE': 'changed'}) - invoke('setup', command='printf "%s" "$INITIALIZATION_VALUE" > first; printf secret; printf secret >&2') - assert Path('/environment/workspace/first').read_text() == CANARY - check = '''import os, pathlib -for path in ('/environment/private/credential', '/environment/staging/request'): - assert pathlib.Path(path).read_text() == 'private-initialization-canary-47a8' -assert 'DAEMON_PRIVATE_CANARY' not in os.environ -assert os.environ['INITIALIZATION_VALUE'] == 'private-initialization-canary-47a8' -assert os.environ['WITH_QUOTES'] == "'\\n$(false)" -pathlib.Path('/environment/packages/visible').write_text('ok') -''' - Path('/environment/workspace/check.py').write_text(check) - invoke('setup', network='disabled', command='/usr/bin/python3 check.py') - # Shell cwd is explicit and ordered effects survive between invocations. - Path('/environment/workspace/sub').mkdir() - invoke('setup', cwd='/workspace/sub', command='test -f ../first && pwd > second') - assert Path('/environment/workspace/sub/second').read_text() == '/environment/workspace/sub\n' - invoke('setup', succeeds=False, exit_code=7, command='echo secret; echo secret >&2; exit 7') - invoke('setup', succeeds=False, exit_code=3, command='echo "$INITIALIZATION_VALUE"; echo "$INITIALIZATION_VALUE" >&2; exit 3') - # Failure to enter the requested cwd is a generic launcher failure. - invoke('setup', succeeds=False, cwd='/workspace/missing', command='touch should-not-exist') - assert not Path('/environment/workspace/should-not-exist').exists() - if '--packages' in sys.argv: - # Actual public registries, not synthetic package fixtures. - invoke('npm', packages=['is-number@7.0.0']) - invoke('python', packages=['packaging==26.0']) - # pip's diagnostics name the package and can echo configuration; only its status is reported. - invoke('python', succeeds=False, exit_code=1, packages=['oac-initializer-nonexistent-4f7e-zz']) - invoke('setup', cwd='/workspace/sub', command="node -e \"if (!require('/environment/packages/npm/lib/node_modules/is-number')(42)) process.exit(1)\" && python3 -c 'import packaging; assert packaging.__version__ == \"26.0\"'") - print(json.dumps({'initialization': 'passed', 'real_packages': '--packages' in sys.argv, - 'system_packages': '--system' in sys.argv})) - - -if __name__ == '__main__': - main() diff --git a/services/agents-api/deploy/runtime/tool-root.py b/services/agents-api/deploy/runtime/tool-root.py deleted file mode 100644 index 85cd60cb3..000000000 --- a/services/agents-api/deploy/runtime/tool-root.py +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/python3 -I -"""Trusted entry into an Environment's installed system tools.""" -import hashlib -import json -import os -from pathlib import Path -import subprocess -import sys - - -ROOT = Path('/environment/packages/system') -CONFIG = Path('/environment/initialization') -SEED = Path('/opt/agents-runtime/system-root.tar.gz') -BASE_ENV = {'PATH': '/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', - 'HOME': '/tmp', 'TMPDIR': '/tmp', 'LANG': 'C.UTF-8'} -MARKER = CONFIG / 'system-root.json' - - -def installed(): - try: - if json.loads(MARKER.read_text()) != {'version': 1}: - raise ValueError('invalid system tools receipt') - return True - except FileNotFoundError: - return False - - -def sandbox(cwd, *, writable=False, network='enabled', workspace='/workspace', scratch=None): - if not isinstance(cwd, str) or not cwd.startswith('/') or '\x00' in cwd: - raise ValueError('invalid working directory') - args = ['/usr/bin/bwrap', '--unshare-user', '--uid', '0', '--gid', '0', - '--unshare-pid', '--unshare-ipc', '--unshare-uts', '--die-with-parent', - '--cap-drop', 'ALL', '--bind' if writable else '--ro-bind', str(ROOT), '/', - '--ro-bind', '/etc/resolv.conf', '/etc/resolv.conf', - '--ro-bind', '/etc/hosts', '/etc/hosts', '--ro-bind', '/etc/ssl', '/etc/ssl', - '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--tmpfs', '/home', - '--bind', workspace, '/workspace', '--dir', '/environment', - '--bind', workspace, '/environment/workspace', - '--bind', '/environment/packages', '/environment/packages', - '--ro-bind', str(ROOT), str(ROOT), '--ro-bind', str(CONFIG), str(CONFIG)] - if workspace not in ('/workspace', '/environment/workspace'): - args += ['--bind', workspace, workspace] - if network == 'disabled': - args += ['--unshare-net'] - elif network != 'enabled': - raise ValueError('invalid network configuration') - if scratch: - args += ['--bind', scratch, scratch] - return args + ['--chdir', cwd, '--'] - - -def initialization_sandbox(cwd, network='enabled', *, writable=False, workspace='/environment/workspace'): - args = sandbox(cwd, writable=writable, network=network, workspace=workspace) - args[1:1] = ['--new-session', '--clearenv'] - for key, value in {**BASE_ENV, 'DEBIAN_FRONTEND': 'noninteractive'}.items(): - args[-1:-1] = ['--setenv', key, value] - return args - - -def install(packages, *, workspace='/environment/workspace'): - if not isinstance(packages, list) or not packages or any( - not isinstance(p, str) or not p or p.startswith('-') or '\x00' in p for p in packages - ): - raise ValueError('invalid packages') - manifest = json.loads(SEED.with_name('system-root.json').read_text()) - with SEED.open('rb') as stream: - digest = hashlib.file_digest(stream, 'sha256').hexdigest() - if manifest != {'version': 1, 'sha256': digest, 'size_bytes': SEED.stat().st_size}: - raise ValueError('invalid system seed') - # The immutable build artifact predates credentials; never snapshot a live Runtime. - ROOT.mkdir(mode=0o700) - subprocess.run(['/usr/bin/tar', '--no-same-owner', '--no-same-permissions', '-xzf', str(SEED), '-C', str(ROOT)], - check=True, env=BASE_ENV, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - args = initialization_sandbox('/workspace', writable=True, workspace=workspace) - # Namespace root maps only to the unprivileged Runtime UID; _apt is unmapped. - apt = ['/usr/bin/apt-get', '-o', 'APT::Sandbox::User=root', '-o', 'Acquire::Retries=0'] - for command in (apt + ['update'], apt + ['install', '-y', '--no-install-recommends', '--', *packages]): - subprocess.run(args + ['/bin/bash', '--noprofile', '--norc', '-c', - '. /environment/initialization/tool-env.sh && exec "$@"', '--', *command], - check=True, env=BASE_ENV, stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - fd = os.open(MARKER, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o400) - with os.fdopen(fd, 'w') as stream: - stream.write('{"version":1}\n') - stream.flush() - os.fsync(stream.fileno()) - - -def main(): - if len(sys.argv) != 2 or not installed(): - raise ValueError('system tools unavailable') - env = dict(BASE_ENV) - scratch = os.environ.get('OAC_RUNTIME_TOOL_SCRATCH') - if scratch: - path = Path(scratch) - temporary = Path(os.environ.get('TMPDIR', scratch)) - if not path.is_absolute() or path == Path('/') or path.resolve(strict=True) != path or not path.is_dir(): - raise ValueError('invalid Runtime scratch') - if temporary.resolve(strict=True) != temporary or not temporary.is_dir() or not temporary.is_relative_to(path): - raise ValueError('invalid native temporary directory') - env['TMPDIR'] = str(temporary) - # Native sandbox networking already selected the proxy and namespace. - for key in ('HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', - 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy'): - if key in os.environ: - env[key] = os.environ[key] - args = sandbox(os.getcwd(), scratch=scratch) - args += ['/bin/bash', '--noprofile', '--norc', '-c', - '. /environment/initialization/tool-env.sh && eval -- "$1"', '--', sys.argv[1]] - os.execve(args[0], args, env) - - -if __name__ == '__main__': - try: - main() - except Exception: - print('Initialized system tools unavailable', file=sys.stderr) - sys.exit(1) diff --git a/services/agents-api/internal/api/environment_packages_test.go b/services/agents-api/internal/api/environment_packages_test.go new file mode 100644 index 000000000..454c45983 --- /dev/null +++ b/services/agents-api/internal/api/environment_packages_test.go @@ -0,0 +1,63 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSystemPackagesRejectedAtEveryInput(t *testing.T) { + for _, value := range []string{`null`, `[]`, `["jq"]`} { + packages := `"packages":{"npm":["semver"],"python":["packaging"],"system":` + value + `}` + t.Run(value, func(t *testing.T) { + if _, err := decodeTemplateInput([]byte("{" + packages + "}")); !errors.Is(err, errSystemPackages) { + t.Fatal("template accepted removed system packages") + } + raw := json.RawMessage(`{"type":"openai_hosted",` + packages + "}") + if _, err := decodeSessionEnvironment(raw); !errors.Is(err, errSystemPackages) { + t.Fatal("Session accepted removed system packages") + } + if _, err := storedEnvironment(raw); !errors.Is(err, errSystemPackages) { + t.Fatal("stored public configuration silently ignored system packages") + } + reference := json.RawMessage(`{"type":"openai_hosted","environment_template_id":"saved",` + packages + "}") + if _, _, _, err := decodeTemplateEnvironment(reference); !errors.Is(err, errSystemPackages) { + t.Fatal("template override accepted removed system packages") + } + }) + } + if _, err := decodeTemplateInput([]byte(`{"packages":{"npm":["semver"],"python":["packaging"]}}`)); err != nil { + t.Fatal("supported package managers rejected", err) + } +} + +func TestSystemPackageErrorExplainsPreinstallation(t *testing.T) { + h, _, _ := testHandler(t) + for _, endpoint := range []string{"/v1/agents/environments/templates", "/v1/agents/sessions"} { + body := `{"packages":{"system":["private-package-canary"]}}` + if endpoint == "/v1/agents/sessions" { + body = `{"agent":{"model":"test"},"environment":{"type":"openai_hosted","packages":{"system":["private-package-canary"]}}}` + } + req := httptest.NewRequest(http.MethodPost, endpoint, strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer test-api-key") + req.Header.Set("OpenAI-Beta", "agents=v1") + req.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + h.ServeHTTP(response, req) + var result struct { + Error struct { + Param string `json:"param"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if response.Code != http.StatusBadRequest || result.Error.Param != "packages.system" || result.Error.Message != errSystemPackages.message || strings.Contains(response.Body.String(), "private-package-canary") { + t.Fatal(endpoint, response.Code, response.Body.String()) + } + } +} diff --git a/services/agents-api/internal/api/environment_request.go b/services/agents-api/internal/api/environment_request.go index 9a79b645f..245af23dc 100644 --- a/services/agents-api/internal/api/environment_request.go +++ b/services/agents-api/internal/api/environment_request.go @@ -13,6 +13,13 @@ func decodeSessionEnvironment(raw json.RawMessage) (*v1.Environment, error) { if json.Unmarshal(raw, &environment) != nil { return nil, store.ErrInvalidInput } + var input map[string]json.RawMessage + if json.Unmarshal(raw, &input) != nil { + return nil, store.ErrInvalidInput + } + if err := rejectSystemPackages(input["packages"]); err != nil { + return nil, err + } fields := []string{"type"} switch environment.Type { case "none": diff --git a/services/agents-api/internal/api/environment_setup.go b/services/agents-api/internal/api/environment_setup.go index efdc66ad8..367b948ac 100644 --- a/services/agents-api/internal/api/environment_setup.go +++ b/services/agents-api/internal/api/environment_setup.go @@ -8,8 +8,26 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) +var errSystemPackages = &fieldError{ + param: "packages.system", + message: "Runtime system package installation is not supported. Preinstall system dependencies in the sandbox image or template, or on the host machine.", +} + +func rejectSystemPackages(raw json.RawMessage) error { + var packages map[string]json.RawMessage + if json.Unmarshal(raw, &packages) == nil { + if _, supplied := packages["system"]; supplied { + return errSystemPackages + } + } + return nil +} + func decodeEnvironmentSetup(fields map[string]json.RawMessage) (store.EnvironmentSetup, error) { var result store.EnvironmentSetup + if err := rejectSystemPackages(fields["packages"]); err != nil { + return result, err + } if value, ok := fields["env"]; ok { var entries map[string]*string if json.Unmarshal(value, &entries) != nil { @@ -50,20 +68,17 @@ func decodeEnvironmentSetup(fields map[string]json.RawMessage) (store.Environmen var input struct { NPM []*string `json:"npm"` Python []*string `json:"python"` - System []*string `json:"system"` } - if decodeInputObject(value, &input, "npm", "python", "system") != nil { + if decodeInputObject(value, &input, "npm", "python") != nil { return result, store.ErrInvalidInput } - for name, entries := range map[string][]*string{"npm": input.NPM, "python": input.Python, "system": input.System} { + for name, entries := range map[string][]*string{"npm": input.NPM, "python": input.Python} { var target *[]string switch name { case "npm": target = &result.Packages.NPM case "python": target = &result.Packages.Python - case "system": - target = &result.Packages.System } for _, entry := range entries { if entry == nil { diff --git a/services/agents-api/internal/api/environment_templates.go b/services/agents-api/internal/api/environment_templates.go index d82edc8fc..105019546 100644 --- a/services/agents-api/internal/api/environment_templates.go +++ b/services/agents-api/internal/api/environment_templates.go @@ -79,14 +79,14 @@ func readTemplateInput(w http.ResponseWriter, r *http.Request) (store.Environmen return in, false } if err != nil { - writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", "Template fields are invalid or require unsupported initialization. Name, enabled/disabled or exact-domain restricted network, initial files, env, system/npm/Python packages, setup commands inline/referenced Skill ZIPs, Plugin ZIPs and workspace capability directories are supported.") + writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", "Template fields are invalid or require unsupported initialization. Name, enabled/disabled or exact-domain restricted network, initial files, env, npm/Python packages, setup commands inline/referenced Skill ZIPs, Plugin ZIPs and workspace capability directories are supported.") return in, false } return in, true } // @Summary Create an Environment Template -// @Description Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential env, ordered setup_commands, system/npm/Python packages inline/referenced Skill ZIPs, Plugin ZIPs and workspace-contained capability directories. Omitted/null network defaults to enabled. Restricted network requires 1–100 exact ASCII hostnames; other host forms and populated unsupported installations are rejected before persistence without echoing input. Network policy rejections return invalid_request_error with a null param. No compute is allocated. Exact hosted error/retry semantics remain unverified. +// @Description Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential env, ordered setup_commands, npm/Python packages inline/referenced Skill ZIPs, Plugin ZIPs and workspace-contained capability directories. Omitted/null network defaults to enabled. Restricted network requires 1–100 exact ASCII hostnames; other host forms and populated unsupported installations are rejected before persistence without echoing input. Network policy rejections return invalid_request_error with a null param. System dependencies must be preinstalled in the sandbox image or template, or on the host machine; packages.system is rejected. No compute is allocated. Exact hosted error/retry semantics remain unverified. // @Tags Environment Templates // @Accept json // @Produce json diff --git a/services/agents-api/internal/api/environment_templates_test.go b/services/agents-api/internal/api/environment_templates_test.go index f9cc2f7b2..5ad12449f 100644 --- a/services/agents-api/internal/api/environment_templates_test.go +++ b/services/agents-api/internal/api/environment_templates_test.go @@ -12,7 +12,7 @@ import ( ) func TestTemplateConfigurationRejectsUnqualifiedInputs(t *testing.T) { - for _, raw := range []string{`{"network":{"access":"restricted","allowed_domains":["Example.com","example.com"]}}`, `{}`, `{"packages":{}}`, `{"packages":{"npm":null}}`, `{"packages":{"system":["jq","libpq-dev"]}}`, `{"packages":{"system":null}}`, `{"name":null,"network":null}`, `{"name":"保存","network":{"access":"disabled"},"env":{},"files":[],"setup_commands":[],"packages":{"npm":null}}`} { + for _, raw := range []string{`{"network":{"access":"restricted","allowed_domains":["Example.com","example.com"]}}`, `{}`, `{"packages":{}}`, `{"packages":{"npm":null}}`, `{"name":null,"network":null}`, `{"name":"保存","network":{"access":"disabled"},"env":{},"files":[],"setup_commands":[],"packages":{"npm":null}}`} { if _, err := decodeTemplateInput([]byte(raw)); err != nil { t.Fatalf("supported input: %s: %v", raw, err) } diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index 2a7112fe1..42e4f4079 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -159,7 +159,7 @@ func (h *Handler) routes() *chi.Mux { // createSession atomically reserves or admits initial text with the Session. // @Summary Create an execution Session -// @Description The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. 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, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. 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 native OAuth login remain unsupported. The self_hosted profile uses a qualified native harness, a clean absolute workspace_directory and optional absolute local capability_directories prepared by Runtime, with optional non-deferred function tools and HTTP MCP using 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 boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. 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; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. 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. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. 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; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. 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. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. +// @Description The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. 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, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. 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 native OAuth login remain unsupported. The self_hosted profile uses a qualified native harness, a clean absolute workspace_directory and optional absolute local capability_directories prepared by Runtime, with optional non-deferred function tools and HTTP MCP using 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 boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. 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; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. 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. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. 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; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. 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. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. System dependencies must be preinstalled in the sandbox image or template, or on the host machine; packages.system is rejected. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. // @Tags Sessions // @Accept json // @Produce json,text/event-stream diff --git a/services/agents-api/internal/api/hosted_environment_test.go b/services/agents-api/internal/api/hosted_environment_test.go index 04ea2e235..8146b26cb 100644 --- a/services/agents-api/internal/api/hosted_environment_test.go +++ b/services/agents-api/internal/api/hosted_environment_test.go @@ -16,9 +16,8 @@ import ( func TestHostedEnvironmentDefaultsAndExplicitGaps(t *testing.T) { for _, raw := range []string{ `{"type":"openai_hosted"}`, - `{"type":"openai_hosted","packages":{"system":["jq","libpq-dev"]}}`, `{"type":"openai_hosted","network":null,"env":null,"files":null,"packages":null,"plugins":null,"skills":null,"setup_commands":null,"capability_directories":null}`, - `{"type":"openai_hosted","network":{"access":"enabled","allowed_domains":null},"env":{},"files":[],"packages":{"npm":[],"python":null,"system":[]},"plugins":[],"skills":[],"setup_commands":[],"capability_directories":[]}`, + `{"type":"openai_hosted","network":{"access":"enabled","allowed_domains":null},"env":{},"files":[],"packages":{"npm":[],"python":null},"plugins":[],"skills":[],"setup_commands":[],"capability_directories":[]}`, } { got, err := decodeSessionEnvironment(json.RawMessage(raw)) if err != nil || got.Network == nil || got.Network.Access != "enabled" { @@ -63,7 +62,7 @@ func TestHostedEnvironmentResponseHasPinnedShapeAndNoConnectionAction(t *testing if json.Unmarshal(raw, &got) != nil { t.Fatal("invalid environment JSON") } - expected := map[string]any{"id": "environment", "type": "openai_hosted", "capability_directories": []any{}, "files": []any{}, "plugins": []any{}, "skills": []any{}, "packages": map[string]any{"npm": []any{}, "python": []any{}, "system": []any{}}, "network": map[string]any{"access": "enabled", "allowed_domains": []any{}}} + expected := map[string]any{"id": "environment", "type": "openai_hosted", "capability_directories": []any{}, "files": []any{}, "plugins": []any{}, "skills": []any{}, "packages": map[string]any{"npm": []any{}, "python": []any{}}, "network": map[string]any{"access": "enabled", "allowed_domains": []any{}}} if !reflect.DeepEqual(got, expected) { t.Fatal("hosted response shape changed", string(raw)) } diff --git a/services/agents-api/internal/api/session_template.go b/services/agents-api/internal/api/session_template.go index 75d2dedfe..2cc05d800 100644 --- a/services/agents-api/internal/api/session_template.go +++ b/services/agents-api/internal/api/session_template.go @@ -96,12 +96,8 @@ func (h *Handler) resolveTemplateEnvironment(ctx context.Context, tenant string, if templateFieldOverride(managers, "python") { setup.Packages.Python = input.initialization.Packages.Python } - if templateFieldOverride(managers, "system") { - setup.Packages.System = input.initialization.Packages.System - } setup.Packages.NPM = slices.Clone(setup.Packages.NPM) setup.Packages.Python = slices.Clone(setup.Packages.Python) - setup.Packages.System = slices.Clone(setup.Packages.System) setup.Skills, setup.Plugins, setup.CapabilityDirectories = skills, plugins, directories if err := setup.Validate(); err != nil { return err diff --git a/services/agents-api/internal/api/session_template_composition_test.go b/services/agents-api/internal/api/session_template_composition_test.go index 376ad05ee..57601bdfa 100644 --- a/services/agents-api/internal/api/session_template_composition_test.go +++ b/services/agents-api/internal/api/session_template_composition_test.go @@ -41,7 +41,7 @@ func compositionFixture() *compositionTemplateStore { template: store.EnvironmentTemplate{NetworkAccess: "enabled", Initialization: store.EnvironmentSetup{ Env: map[string]string{"TEMPLATE": "private-template-env", "SHARED": "private-old-value"}, Commands: []store.SetupCommand{{Command: "printf private-template-command"}}, - Packages: v1.EnvironmentPackages{NPM: []string{"semver@7.7.2"}, Python: []string{"packaging==25.0"}, System: []string{"jq"}}, + Packages: v1.EnvironmentPackages{NPM: []string{"semver@7.7.2"}, Python: []string{"packaging==25.0"}}, }}, files: []store.InitialFile{{Type: "inline", Path: "/workspace/template", Data: []byte("private-template-bytes")}}, } @@ -55,8 +55,8 @@ func TestTemplateInlineCompositionRules(t *testing.T) { {name: "omitted"}, {name: "null", fields: `,"env":null,"setup_commands":null,"files":null,"packages":null`}, {name: "empty objects", fields: `,"env":{},"packages":{}`}, - {name: "manager null", fields: `,"packages":{"npm":null,"python": null ,"system":null}`}, - {name: "clear lists", fields: `,"setup_commands":[],"files":[],"packages":{"npm":[],"python":[],"system":[]}`, change: func(s *store.EnvironmentSetup, f *[]store.InitialFile) { + {name: "manager null", fields: `,"packages":{"npm":null,"python": null }`}, + {name: "clear lists", fields: `,"setup_commands":[],"files":[],"packages":{"npm":[],"python":[]}`, change: func(s *store.EnvironmentSetup, f *[]store.InitialFile) { s.Commands, *f = nil, nil s.Packages = v1.EnvironmentPackages{} }}, @@ -66,8 +66,8 @@ func TestTemplateInlineCompositionRules(t *testing.T) { *f = []store.InitialFile{{Type: "inline", Path: "/workspace/inline", Data: []byte("private-inline-bytes")}} s.Packages.NPM, s.Packages.Python = nil, []string{"idna==3.10"} }}, - {name: "mixed managers", fields: `,"packages":{"npm":null,"system":[],"python":["idna==3.10"]}`, change: func(s *store.EnvironmentSetup, _ *[]store.InitialFile) { - s.Packages.Python, s.Packages.System = []string{"idna==3.10"}, nil + {name: "mixed managers", fields: `,"packages":{"npm":null,"python":["idna==3.10"]}`, change: func(s *store.EnvironmentSetup, _ *[]store.InitialFile) { + s.Packages.Python = []string{"idna==3.10"} }}, {name: "overlapping path replaces whole list", fields: `,"files":[{"type":"inline","path":"/workspace/template","data":"bmV3"}]`, change: func(_ *store.EnvironmentSetup, f *[]store.InitialFile) { *f = []store.InitialFile{{Type: "inline", Path: "/workspace/template", Data: []byte("new")}} diff --git a/services/agents-api/internal/execution/environment_placement.go b/services/agents-api/internal/execution/environment_placement.go index ddb9b619b..f29f5ee6e 100644 --- a/services/agents-api/internal/execution/environment_placement.go +++ b/services/agents-api/internal/execution/environment_placement.go @@ -17,7 +17,6 @@ type environmentPlacement struct { Skills []store.EnvironmentSkillMetadata `json:"skills,omitempty"` Type string `json:"type"` ToolEnvironment bool `json:"initialization,omitempty"` - SystemPackages bool `json:"-"` NetworkAccess string `json:"-"` AllowedDomains []string `json:"-"` WorkspaceDirectory string `json:"workspace_directory"` @@ -68,7 +67,6 @@ func parseEnvironmentPlacement(configuration json.RawMessage) (environmentPlacem decoder := json.NewDecoder(bytes.NewReader(configuration)) decoder.DisallowUnknownFields() if decoder.Decode(&local) == nil && agentcapabilities.ValidateDirectories(local.CapabilityDirectories) == nil { - placement.SystemPackages = local.Packages != nil && len(local.Packages.System) > 0 placement.NetworkAccess = "enabled" if local.Network != nil { if (agentnetwork.Policy{Access: local.Network.Access, AllowedDomains: local.Network.AllowedDomains}).Validate() != nil { @@ -95,9 +93,6 @@ func (d *Dispatcher) configurePreparedEnvironment(session store.Session, environ if err != nil || !environmentDeviceMatches(session, environment, bound) { return store.ErrInvalidInput } - if placement.SystemPackages && !placement.ToolEnvironment { - return store.ErrInvalidInput - } sources := &agentcapabilities.Input{Plugins: append([]agentplugin.Metadata(nil), placement.Plugins...), Directories: append([]string(nil), placement.CapabilityDirectories...)} for _, metadata := range placement.Skills { if store.ValidateInstalledSkillMetadata(metadata) != nil { @@ -109,7 +104,7 @@ func (d *Dispatcher) configurePreparedEnvironment(session store.Session, environ ID: environment.ID, WorkspaceDirectory: placement.WorkspaceDirectory, CapabilitySources: sources, Capabilities: len(sources.Skills)+len(sources.Plugins)+len(sources.Directories) > 0, - ToolEnvironment: placement.ToolEnvironment, SystemPackages: placement.SystemPackages, + ToolEnvironment: placement.ToolEnvironment, } req.LocalEnvironment.NetworkAccess = placement.NetworkAccess req.LocalEnvironment.AllowedDomains = append([]string(nil), placement.AllowedDomains...) diff --git a/services/agents-api/internal/execution/environment_placement_test.go b/services/agents-api/internal/execution/environment_placement_test.go index 647e57538..7d78a4854 100644 --- a/services/agents-api/internal/execution/environment_placement_test.go +++ b/services/agents-api/internal/execution/environment_placement_test.go @@ -77,55 +77,12 @@ func TestNetworkPolicySurvivesPreparedBinding(t *testing.T) { } } -func TestSystemPackagesRemainRequiredInExecutionBinding(t *testing.T) { +func TestToolEnvironmentRemainsInExecutionBinding(t *testing.T) { session := store.Session{ID: "session", TenantID: "tenant"} - environment := store.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, - Configuration: []byte(`{"type":"openai_hosted","initialization":true,"packages":{"system":["jq"]}}`)} + environment := store.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, Configuration: []byte(`{"type":"openai_hosted","initialization":true,"packages":{"npm":["is-number"]}}`)} var req proto.PromptRequestPayload - err := (&Dispatcher{}).configurePreparedEnvironment(session, environment, - store.ExecutionDevice{EnvironmentID: environment.ID}, &req) - if err != nil || req.LocalEnvironment == nil || !req.LocalEnvironment.ToolEnvironment || !req.LocalEnvironment.SystemPackages { - t.Fatal("system initialization requirement was lost", err) - } - environment.Configuration = []byte(`{"type":"openai_hosted","packages":{"system":["jq"]}}`) - if !LocalWorkspaceConfiguration(environment.Configuration) { - t.Fatal("public admission requires a private execution receipt") - } - req = proto.PromptRequestPayload{} - if err := (&Dispatcher{}).configurePreparedEnvironment(session, environment, - store.ExecutionDevice{EnvironmentID: environment.ID}, &req); err == nil || req.LocalEnvironment != nil { - t.Fatal("execution without the required initialization was admitted") - } -} - -func TestLocalNetworkDefaultsAndSupportedPolicies(t *testing.T) { - for _, configuration := range []string{`{"type":"openai_hosted"}`, `{"type":"openai_hosted","network":null}`, `{"type":"openai_hosted","network":{"access":"enabled","allowed_domains":[]}}`} { - got, err := parseEnvironmentPlacement([]byte(configuration)) - if err != nil || got.NetworkAccess != "enabled" { - t.Fatal("enabled default lost", got, err) - } - } - got, err := parseEnvironmentPlacement([]byte(`{"type":"openai_hosted","network":{"access":"disabled","allowed_domains":null}}`)) - if err != nil || got.NetworkAccess != "disabled" { - t.Fatal("disabled policy lost", got, err) - } -} - -func TestSelfHostedUsesSameLocalBinding(t *testing.T) { - session := store.Session{ID: "session", TenantID: "tenant"} - for _, workspace := range []string{"/workspace", "/other"} { - environment := store.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, - Configuration: []byte(`{"type":"self_hosted","workspace_directory":"` + workspace + `"}`)} - for _, binding := range []string{"", "foreign", environment.ID} { - var request proto.PromptRequestPayload - err := (&Dispatcher{}).configurePreparedEnvironment(session, environment, store.ExecutionDevice{EnvironmentID: binding}, &request) - valid := binding == environment.ID - if (err == nil) != valid { - t.Fatal(workspace, binding, err) - } - if valid && (request.LocalEnvironment == nil || request.LocalEnvironment.ID != environment.ID || request.LocalEnvironment.NetworkAccess != "enabled" || request.LocalEnvironment.WorkspaceDirectory != workspace || request.LocalEnvironment.CapabilitySources == nil) { - t.Fatal("self-hosted placement did not retain common local contract", request.LocalEnvironment) - } - } + err := (&Dispatcher{}).configurePreparedEnvironment(session, environment, store.ExecutionDevice{EnvironmentID: environment.ID}, &req) + if err != nil || req.LocalEnvironment == nil || !req.LocalEnvironment.ToolEnvironment { + t.Fatal("tool initialization requirement lost", err) } } diff --git a/services/agents-api/internal/execution/runtime_setup.go b/services/agents-api/internal/execution/runtime_setup.go index 4225f3280..4dc4cf6de 100644 --- a/services/agents-api/internal/execution/runtime_setup.go +++ b/services/agents-api/internal/execution/runtime_setup.go @@ -28,7 +28,7 @@ func (operation runtimeSetupOperation) provisioningFailure(exitCode int) store.P action = operation.Request.Initialization.Action } switch action { - case "setup", "python", "npm", "system", "skill": + case "setup", "python", "npm", "skill": return store.ProvisioningFailure{Step: action, Index: operation.Index, ExitCode: exitCode} } return store.ProvisioningFailure{} @@ -57,7 +57,7 @@ func setupOperations(setup store.EnvironmentSetup) []runtimeSetupOperation { for _, packages := range []struct { action string values []string - }{{"system", setup.Packages.System}, {"npm", setup.Packages.NPM}, {"python", setup.Packages.Python}} { + }{{"npm", setup.Packages.NPM}, {"python", setup.Packages.Python}} { if len(packages.values) > 0 { result = append(result, initialize(proto.RuntimeInitialization{Action: packages.action, Network: "enabled", Packages: packages.values})) } diff --git a/services/agents-api/internal/execution/runtime_setup_test.go b/services/agents-api/internal/execution/runtime_setup_test.go index 9a8a44bb2..5955e2fa9 100644 --- a/services/agents-api/internal/execution/runtime_setup_test.go +++ b/services/agents-api/internal/execution/runtime_setup_test.go @@ -53,7 +53,7 @@ func TestRuntimeSetupReceiptOutcomes(t *testing.T) { } } func TestRuntimeSetupFailureLabels(t *testing.T) { - for action, want := range map[string]store.ProvisioningFailure{"setup": {Step: store.ProvisioningSetupCommand, Index: 2, ExitCode: 3}, "python": {Step: store.ProvisioningPythonPackages, Index: 2, ExitCode: 3}, "npm": {Step: store.ProvisioningNPMPackages, Index: 2, ExitCode: 3}, "system": {Step: store.ProvisioningSystemPackages, Index: 2, ExitCode: 3}, "skill": {Step: store.ProvisioningSkill, Index: 2, ExitCode: 3}, "configure": {}, "": {}} { + for action, want := range map[string]store.ProvisioningFailure{"setup": {Step: store.ProvisioningSetupCommand, Index: 2, ExitCode: 3}, "python": {Step: store.ProvisioningPythonPackages, Index: 2, ExitCode: 3}, "npm": {Step: store.ProvisioningNPMPackages, Index: 2, ExitCode: 3}, "skill": {Step: store.ProvisioningSkill, Index: 2, ExitCode: 3}, "configure": {}, "": {}} { op := runtimeSetupOperation{Request: proto.RuntimePreparePayload{Action: action}, Index: 2} if action != "skill" { op.Request = proto.RuntimePreparePayload{Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: action}} diff --git a/services/agents-api/internal/store/environment_packages_test.go b/services/agents-api/internal/store/environment_packages_test.go new file mode 100644 index 000000000..d557bc48e --- /dev/null +++ b/services/agents-api/internal/store/environment_packages_test.go @@ -0,0 +1,39 @@ +package store + +import ( + "bytes" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/google/uuid" +) + +func TestStoredSystemPackagesRejected(t *testing.T) { + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{5}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(nil, cipher) + tenant, session := uuid.NewString(), uuid.NewString() + for _, value := range []string{`null`, `[]`, `["jq"]`} { + packages := []byte(`{"npm":[],"python":[],"system":` + value + "}") + row := templateMetadataRow{Files: []byte("[]"), Skills: []byte("[]"), Plugins: []byte("[]"), Packages: packages} + if _, err := templateFromRow(row, nil); err == nil { + t.Fatal("template silently ignored removed system packages", value) + } + plain := append([]byte(`{"packages":`), packages...) + plain = append(plain, '}') + encrypted, err := cipher.SealEnvironmentSetup(plain, credentialcrypto.EnvironmentSetupBinding{TenantID: tenant, Resource: "session", OwnerID: session, Field: "initialization"}) + if err != nil { + t.Fatal(err) + } + var output EnvironmentSetup + if err := s.openEnvironmentSetup(tenant, "session", session, "initialization", encrypted, &output); err == nil { + t.Fatal("snapshot silently ignored removed system packages", value) + } + } + row := templateMetadataRow{Files: []byte("[]"), Skills: []byte("[]"), Plugins: []byte("[]"), Packages: []byte(`{"npm":["semver"],"python":["packaging"]}`)} + if _, err := templateFromRow(row, nil); err != nil { + t.Fatal("supported stored package managers rejected", err) + } +} diff --git a/services/agents-api/internal/store/environment_setup.go b/services/agents-api/internal/store/environment_setup.go index 541f1fc6f..55c1d52ec 100644 --- a/services/agents-api/internal/store/environment_setup.go +++ b/services/agents-api/internal/store/environment_setup.go @@ -1,6 +1,7 @@ package store import ( + "bytes" "context" "encoding/json" "errors" @@ -36,7 +37,7 @@ type SetupCommand struct { var environmentName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) func (s EnvironmentSetup) Empty() bool { - return len(s.Env)+len(s.Commands)+len(s.Packages.NPM)+len(s.Packages.Python)+len(s.Packages.System)+len(s.Skills)+len(s.Plugins)+len(s.CapabilityDirectories) == 0 + return len(s.Env)+len(s.Commands)+len(s.Packages.NPM)+len(s.Packages.Python)+len(s.Skills)+len(s.Plugins)+len(s.CapabilityDirectories) == 0 } func (s EnvironmentSetup) Validate() error { @@ -66,7 +67,7 @@ func (s EnvironmentSetup) validate(installed bool) error { return ErrInvalidInput } } - for _, packages := range [][]string{s.Packages.NPM, s.Packages.Python, s.Packages.System} { + for _, packages := range [][]string{s.Packages.NPM, s.Packages.Python} { for _, item := range packages { if item == "" || strings.HasPrefix(item, "-") || strings.ContainsRune(item, 0) { return ErrInvalidInput @@ -95,7 +96,7 @@ func (s *Store) openEnvironmentSetup(tenant, resource, id, field string, ciphert if err != nil { return err } - if json.Unmarshal(plaintext, output) != nil { + if decodeSetupJSON(plaintext, output) != nil { return ErrInvalidInput } return nil @@ -146,9 +147,6 @@ func (s EnvironmentSetup) PackageMetadata() v1.EnvironmentPackages { if result.Python == nil { result.Python = []string{} } - if result.System == nil { - result.System = []string{} - } return result } @@ -164,3 +162,13 @@ func (s *Store) sealTemplateSetup(tenant, id string, setup EnvironmentSetup) ([] commands, err := s.sealEnvironmentSetup(tenant, "environment_template", id, "setup_commands", setup.Commands, len(setup.Commands) == 0) return packages, env, commands, err } + +// decodeSetupJSON rejects removed configuration fields instead of silently dropping them. +func decodeSetupJSON(data []byte, output any) error { + if !json.Valid(data) { + return ErrInvalidInput + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + return decoder.Decode(output) +} diff --git a/services/agents-api/internal/store/environment_setup_test.go b/services/agents-api/internal/store/environment_setup_test.go index 28ccd0051..3e957e9b5 100644 --- a/services/agents-api/internal/store/environment_setup_test.go +++ b/services/agents-api/internal/store/environment_setup_test.go @@ -20,13 +20,13 @@ func TestEnvironmentSetupEncryptedSnapshotAndIsolation(t *testing.T) { } s := NewWithCredentialCipher(pool, cipher) tenant, foreign := uuid.NewString(), uuid.NewString() - setup := EnvironmentSetup{Env: map[string]string{"SECRET": "template-env-canary"}, Commands: []SetupCommand{{Command: "printf template-command-canary > result"}}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}, System: []string{"jq", "libpq-dev"}}} + setup := EnvironmentSetup{Env: map[string]string{"SECRET": "template-env-canary"}, Commands: []SetupCommand{{Command: "printf template-command-canary > result"}}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}}} template, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{Initialization: setup, SetEnv: true, SetSetup: true, SetPackages: true}) if err != nil { t.Fatal(err) } public, err := New(pool).GetEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || len(public.Packages.NPM) != 1 || !reflect.DeepEqual(public.Packages.System, setup.Packages.System) || !public.Initialization.Empty() { + if err != nil || len(public.Packages.NPM) != 1 || !reflect.DeepEqual(public.Packages.NPM, setup.Packages.NPM) || !public.Initialization.Empty() { t.Fatal("public metadata requires plaintext or key", err) } resolved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) diff --git a/services/agents-api/internal/store/environment_templates.go b/services/agents-api/internal/store/environment_templates.go index 1c512c3ab..d12f0cfae 100644 --- a/services/agents-api/internal/store/environment_templates.go +++ b/services/agents-api/internal/store/environment_templates.go @@ -64,7 +64,7 @@ func templateFromRow(row templateMetadataRow, err error) (EnvironmentTemplate, e if row.Name.Valid { result.Name = &row.Name.String } - if json.Unmarshal(row.Files, &result.Files) != nil || json.Unmarshal(row.Packages, &result.Packages) != nil || json.Unmarshal(row.Skills, &result.Skills) != nil || json.Unmarshal(row.Plugins, &result.Plugins) != nil { + if json.Unmarshal(row.Files, &result.Files) != nil || decodeSetupJSON(row.Packages, &result.Packages) != nil || json.Unmarshal(row.Skills, &result.Skills) != nil || json.Unmarshal(row.Plugins, &result.Plugins) != nil { return EnvironmentTemplate{}, ErrInvalidInput } return result, nil diff --git a/services/agents-api/internal/store/provisioning_failure_detail.go b/services/agents-api/internal/store/provisioning_failure_detail.go index d3876916f..cd18ef33c 100644 --- a/services/agents-api/internal/store/provisioning_failure_detail.go +++ b/services/agents-api/internal/store/provisioning_failure_detail.go @@ -24,7 +24,7 @@ func (f ProvisioningFailureDetail) sanitized() *ProvisioningFailureDetail { value := *f.Index result.Index = &value } - case ProvisioningPythonPackages, ProvisioningNPMPackages, ProvisioningSystemPackages: + case ProvisioningPythonPackages, ProvisioningNPMPackages: case ProvisioningInitialFile, ProvisioningSkill: value := *f.Step result.Step = &value diff --git a/services/agents-api/internal/store/runtime_environment_terminal.go b/services/agents-api/internal/store/runtime_environment_terminal.go index 4a1414935..7243ae082 100644 --- a/services/agents-api/internal/store/runtime_environment_terminal.go +++ b/services/agents-api/internal/store/runtime_environment_terminal.go @@ -21,7 +21,6 @@ const ( ProvisioningSetupCommand = "setup" ProvisioningPythonPackages = "python" ProvisioningNPMPackages = "npm" - ProvisioningSystemPackages = "system" ProvisioningInitialFile = "file" ProvisioningSkill = "skill" ) @@ -37,13 +36,12 @@ type ProvisioningFailure struct { // reason renders the public Session error. The setup_commands and Python package // labels match observed official errors (which append raw pip output for Python; -// Core never does). The npm, system package, file and Skill labels are unverified. +// Core never does). The npm, file and Skill labels are unverified. // A script step without a reported exit status keeps the generic reason. func (f ProvisioningFailure) reason() string { label := map[string]string{ ProvisioningPythonPackages: "Python package installation", ProvisioningNPMPackages: "npm package installation", - ProvisioningSystemPackages: "System package installation", }[f.Step] if f.Step == ProvisioningSetupCommand && f.Index >= 0 { label = fmt.Sprintf("setup_commands[%d]", f.Index) diff --git a/services/agents-api/internal/store/runtime_environment_terminal_test.go b/services/agents-api/internal/store/runtime_environment_terminal_test.go index 33ea92238..99052e2aa 100644 --- a/services/agents-api/internal/store/runtime_environment_terminal_test.go +++ b/services/agents-api/internal/store/runtime_environment_terminal_test.go @@ -157,7 +157,6 @@ func TestProvisioningFailureReasons(t *testing.T) { {Step: ProvisioningSetupCommand, Index: 12, ExitCode: 1}: `Failed to provision environment: script "setup_commands[12]" failed with exit code 1`, {Step: ProvisioningPythonPackages, ExitCode: 1}: `Failed to provision environment: script "Python package installation" failed with exit code 1`, {Step: ProvisioningNPMPackages, ExitCode: 1}: `Failed to provision environment: script "npm package installation" failed with exit code 1`, - {Step: ProvisioningSystemPackages, ExitCode: 100}: `Failed to provision environment: script "System package installation" failed with exit code 100`, {Step: ProvisioningInitialFile}: "Failed to provision environment: initial file installation failed", {Step: ProvisioningSkill}: "Failed to provision environment: Skill installation failed", // Missing or impossible statuses, unknown steps and old receipts stay generic. diff --git a/services/agents-api/internal/store/runtime_initialization_test.go b/services/agents-api/internal/store/runtime_initialization_test.go index d58d36d33..2dc1fb20e 100644 --- a/services/agents-api/internal/store/runtime_initialization_test.go +++ b/services/agents-api/internal/store/runtime_initialization_test.go @@ -187,7 +187,7 @@ func TestManagedRuntimePreparationAllOperationsUsePeer(t *testing.T) { fileBody := bytes.Repeat([]byte("bounded bytes"), 12000) session, environment := hostedFailureSession(t, s, tenant, store.CreateSessionInput{ InitialFiles: []store.InitialFile{{Type: "inline", Path: "/workspace/first", Data: fileBody}}, - Initialization: store.EnvironmentSetup{Skills: []store.EnvironmentSkill{hostedFailureSkill(t)}, Plugins: []store.EnvironmentPlugin{{Metadata: agentplugin.Metadata{Type: "inline", Name: "plugin", Description: "A plugin."}, Archive: archive.Bytes()}}, Packages: v1.EnvironmentPackages{System: []string{"jq"}, NPM: []string{"is-number@7.0.0"}, Python: []string{"packaging==24.2"}}, Commands: []store.SetupCommand{{Command: "read installed bundles and create directory"}}, CapabilityDirectories: []string{"/workspace/generated"}}, + Initialization: store.EnvironmentSetup{Skills: []store.EnvironmentSkill{hostedFailureSkill(t)}, Plugins: []store.EnvironmentPlugin{{Metadata: agentplugin.Metadata{Type: "inline", Name: "plugin", Description: "A plugin."}, Archive: archive.Bytes()}}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}, Python: []string{"packaging==24.2"}}, Commands: []store.SetupCommand{{Command: "read installed bundles and create directory"}}, CapabilityDirectories: []string{"/workspace/generated"}}, }) provider := &initializingProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}} var actions []string @@ -222,7 +222,7 @@ func TestManagedRuntimePreparationAllOperationsUsePeer(t *testing.T) { break } } - expected := []string{"file", "configure", "skill", "plugin", "system", "npm", "python", "setup", "finalize"} + expected := []string{"file", "configure", "skill", "plugin", "npm", "python", "setup", "finalize"} if !reflect.DeepEqual(actions, expected) || provider.commandCalls.Load() != 0 { t.Fatal("typed ordering or provider isolation", actions, provider.commandCalls.Load()) } diff --git a/services/agents-api/internal/store/template_composition_public_test.go b/services/agents-api/internal/store/template_composition_public_test.go index 920a9e95b..380b18578 100644 --- a/services/agents-api/internal/store/template_composition_public_test.go +++ b/services/agents-api/internal/store/template_composition_public_test.go @@ -86,7 +86,7 @@ func TestTemplateCompositionOfficialClientPostgres(t *testing.T) { for label, id := range receipt.Sessions { env := map[string]string{"TEMPLATE_ONLY": marker + "-template-env", "SHARED": marker + "-template-shared"} commands := []store.SetupCommand{{Command: "printf " + marker + "-template-command", CWD: "/workspace"}} - packages := v1.EnvironmentPackages{Python: []string{"packaging==25.0"}, NPM: []string{"semver@7.7.2"}, System: []string{"jq"}} + packages := v1.EnvironmentPackages{Python: []string{"packaging==25.0"}, NPM: []string{"semver@7.7.2"}} paths := []string{"/workspace/template-only.txt", "/workspace/overlap.txt"} contents := []string{marker + "-template-file", marker + "-source"} switch label { From 0e84edaa2cfff7b09988f465805eaa61e6b31e08 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:27:05 +0000 Subject: [PATCH 03/11] docs(runtime): align native support and dependency preparation --- CONTRIBUTING.md | 176 +++++++------ apps/docs/content/docs/admin-api.mdx | 24 +- apps/docs/content/docs/agents-and-tools.mdx | 40 +-- .../api-reference/environment-templates.mdx | 10 +- .../content/docs/api-reference/sessions.mdx | 32 +-- .../content/docs/bootstrap-projects-keys.mdx | 4 +- apps/docs/content/docs/concepts.mdx | 22 +- apps/docs/content/docs/configure.mdx | 16 +- apps/docs/content/docs/console.mdx | 18 +- .../content/docs/environments-and-files.mdx | 68 ++--- apps/docs/content/docs/execution-model.mdx | 6 +- apps/docs/content/docs/hosted-providers.mdx | 8 +- apps/docs/content/docs/index.mdx | 9 +- apps/docs/content/docs/install.mdx | 6 +- apps/docs/content/docs/meta.json | 1 + apps/docs/content/docs/observability.mdx | 10 +- apps/docs/content/docs/public-api.mdx | 64 ++--- apps/docs/content/docs/quickstart.mdx | 8 +- .../content/docs/self-hosted-execution.mdx | 213 ++++++---------- .../content/docs/self-hosted-execution.zh.mdx | 2 +- apps/docs/content/docs/self-hosted-native.mdx | 159 ++++++++++++ .../content/docs/self-hosted-native.zh.mdx | 10 + apps/docs/content/docs/sessions.mdx | 31 +-- apps/docs/content/docs/troubleshooting.mdx | 19 +- apps/docs/content/guide-sources.json | 55 +++-- apps/docs/openapi/core-api.yaml | 5 - apps/docs/openapi/public-api.yaml | 15 +- apps/docs/openapi/sources.json | 12 +- apps/docs/scripts/generate-guides.mjs | 2 +- apps/docs/scripts/guides.json | 9 +- apps/docs/scripts/verify-docs-facts.mjs | 2 +- contracts/agents-api/README.md | 10 +- .../environment-executor-credentials.md | 6 +- contracts/agents-api/environment-templates.md | 232 ++++++++---------- contracts/agents-api/environments.md | 34 +-- contracts/agents-api/harnesses.md | 6 +- contracts/agents-api/mcode-workspace-v1.md | 6 +- contracts/agents-api/model-execution.md | 7 +- .../official-semantics-alignment.md | 8 +- .../agents-api/user-managed-runtime-v1.md | 5 +- contracts/agents-api/workspace-placement.md | 13 +- docs/getting-started/self-hosted.md | 2 +- docs/self-hosted-native.md | 33 ++- 43 files changed, 780 insertions(+), 638 deletions(-) create mode 100644 apps/docs/content/docs/self-hosted-native.mdx create mode 100644 apps/docs/content/docs/self-hosted-native.zh.mdx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 832a419e6..57272fdcf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -649,37 +649,39 @@ transaction stores a safe reason with the failed Environment and records `environment.failed`, `error` and one `agent.session.failed`; Session reads derive `failed`, that reason and the failure time from the same record, and live streams end after the failed event. The initializer reports only the integer exit status of a -failed sandboxed step. Core composes the reason from a fixed step label and that status, +failed initialization command. Core composes the reason from a fixed step label and that status, never from command, package-manager or file output; unknown effects, timeouts and receipts without a status keep a generic reason. New input then gets the observed 409 `conflict_error`; expiry and pending-input settlement keep their behavior. Completed environments never reinstall initial files on reconnect or native recovery. The typed Runtime preparation protocol carries bounded confidential input. The -daemon selects fixed trusted initializers; Core supplies no executable or host -platform field. User setup and package install hooks -run in the common packaged sandbox, without daemon credentials or native history. -Files, resolved Skills and inline Plugins precede system, npm/Python packages and ordered setup commands. Initialization has -provisioning network access; requested network restrictions apply to native tools -after setup. Confidential env and setup snapshots are encrypted independently of -ordinary metadata. Adapters apply tool env only after isolation, never to the -credential-bearing daemon/native harness launcher. -Reuse the packaged atomic file writer and anchored parent creation across all profiles. - -System packages use one Runtime-owned tool root, separate from trusted daemon and -harness executables. Build its immutable seed from the base image before adding -Runtime/harness code or secrets; include the matching package database and base -tool symlink targets. The shared installer extracts independent inodes and runs -apt/dpkg inside an unprivileged namespace. Package scripts cannot access Runtime -credentials, native history or outer processes. Later setup and native tools enter -the installed root read-only, retaining the authorized workspace and adapter-owned -scratch. `/workspace` and `/environment/workspace` refer to the same authorized -workspace inside that root, preserving native working directories. Native adapters -own entry and existing process cancellation; Core never -selects an engine or Provider for package initialization. No live filesystem -snapshot, second lifecycle owner or package-manager framework is introduced. -Core preserves the system-package requirement in the common execution binding; -a missing installation receipt fails preparation instead of falling back to base -tools. This requirement does not add execution prerequisites to Files reads. +daemon owns the common Go initialization implementation; Core supplies no +executable or host-platform field. Initial files and tool configuration precede +Skill/Plugin bundles, npm/Python packages and ordered setup, followed by capability +directory capture. Commands run directly with the starting account's permissions +and host network. Outer Environments own managed isolation; unsupported network +restrictions must reject rather than silently run unrestricted. Confidential env +and setup snapshots are encrypted independently of ordinary metadata. Adapters +apply explicit tool variables without automatically inheriting daemon credentials; +this does not prevent same-user tools from reading local Runtime state. +Reuse runtimefs atomic replacement and anchored logical-workspace paths for initial +files on every platform. Public Files creation keeps its own non-replacement rule. + +System dependencies must be preinstalled in the managed image/template or by the +self-hosted user. `packages.system` rejects explicitly, including a supplied null +or empty list. Runtime does not run apt, sudo, an unprivileged system-root installer +or any other automatic system-package operation. No daemon privilege increase or +managed-only exception is permitted. Missing dependencies fail the consuming +operation. npm and Python use local prefix/target directories; setup requires Bash +and Windows requires Git Bash, without a substitute shell. + +Initialization and package directories default under `OAC_RUNTIME_HOME` and may be +selected with `OAC_RUNTIME_INITIALIZATION_DIRECTORY` and +`OAC_RUNTIME_PACKAGE_DIRECTORY`. Packaged Linux images select their existing +`/environment` layout through those settings. `OAC_RUNTIME_TOOL_ENV_FILE` selects +explicit configuration. These are resource paths, never Environment-source or OS +switches in Core. Runtime process ownership waits for exit and I/O settlement; +confirmed failures retain only a bounded exit status, never command output. Skills and their immutable versions are Core-owned tenant resources, independent of Sessions and native Skill installations. Serialize version allocation and pointer @@ -711,7 +713,8 @@ installation directories are not portable public paths. Inline and referenced Skill ZIPs use the same confidential initialization snapshot and installer. Core validates portable manifests and bounded regular-file archives, returns only safe Skill metadata, and freezes content before native preparation. The Runtime -owns `/environment/initialization/capabilities/skills/`; the manifest validates the installed snapshot before reuse. This is state +owns `skills/` below its configured capability root; the manifest validates +the installed snapshot before reuse. This is state consistency, not protection from the launching user. Public Plugin ZIPs preserve their complete package layout and reuse the shared archive and portable Skill parsers. Core keeps safe Plugin metadata separate from encrypted archives. Templates inherit or replace @@ -1476,17 +1479,19 @@ failure when the authorized workspace cannot be reached; it never grants public admission merely because an adapter advertises a capability. Use distinct authorization for callers, devices and environment connections. A -co-located harness must not expose broader application credentials or other tenants' -secrets to generated code. Directory bindings and process identities do not provide -filesystem isolation. Preserve or demonstrably restore native history across +managed outer Environment must exclude broader application credentials and other +tenants' secrets. The daemon does not hide its own state from same-user tools. +Directory bindings and process identities do not provide filesystem isolation. +Preserve or demonstrably restore native history across compute replacement; never silently move a bound Session or replay unknown work. Self-hosted compute/files remain caller-owned, with explicit cleanup separate from Session deletion. The full Environment implementation remains pending; follow the [pinned contract and acceptance sequence](contracts/agents-api/environments.md) and the [two-engine placement prerequisites](contracts/agents-api/workspace-placement.md). -For co-location, qualify both deployment isolation and native tool restrictions. -Bash sandbox settings alone do not establish file-tool or whole-harness isolation. -Never enable an environment profile before those boundaries are verified. +For co-location, qualify the outer deployment boundary and the shared Runtime +lifecycle. Native tools use the starting account's permissions; do not claim a +daemon or harness sandbox. Host operators choose their own outer isolation. +Never enable an execution combination whose required outer behavior is unverified. The internal Store creates one Environment with an environment-bearing Session in its creation transaction. The Session upsert selects the retry winner; retries @@ -2128,17 +2133,17 @@ Environment of that Project whose Session exists; anything else is 404. The Project's principal is the credential's execution principal, its scope stays daemon enrollment and connection for that one Environment, and issue, rotate and revoke each record an administrator audit entry in the write's transaction without -the secret. Project API keys cannot issue them. Self-hosted installation reuses -Docker Runtime isolation, owns no sandbox node or Core allocation, and retains -user-owned native history after uncertain launches. Report started, connected and real execution success separately. -Web's install command carries no secret: the installer reads the credential at a -hidden terminal prompt and verifies against the console's -`self_hosted_installer_sha256`. On a permanent rejection the self-hosted daemon -parks (one message, no requests, exit 0 on SIGTERM) rather than exiting into -Docker's `unless-stopped` restart loop. Rerunning the installer with the same key -rotated replaces it in the same stopped container through the launcher's -`replace-credential`, which checks the container's labels, name, volumes and -private directories; never relaunch or adopt history to recover a credential. +the secret. Project API keys cannot issue them. Native self-hosted installation runs the daemon on Linux, macOS or Windows with +its starting account's permissions. It owns no sandbox node or Core allocation, +adds no isolation and retains user-owned native history after uncertain launches. +Report started, connected and real execution success separately. +Native installation uses `oac-daemon install` and `start` with an explicit +credential-file path and the same `OAC_RUNTIME_HOME` for lifecycle commands. It is +current-version only and does not adopt an older container installation. Rotation +replaces the configured credential file for the same key and restarts the daemon; +never create a replacement Session history to recover a credential. The console's +older container-installer command remains a distinct packaged workflow, not the +native installation interface. Report connection and actual execution separately. Core-key executor credential lists expose a required connection observation with never_enrolled, connected or disconnected status, immutable bound key identity, enrollment time and last authenticated heartbeat time. Read credential metadata @@ -2155,8 +2160,8 @@ using only its restricted executor credential. The read checks the exact live Environment/key binding and current authenticated connection; it never enrolls, allocates, wakes a sandbox or grants project resource access. It is an `/api/v1` machine route that reaches Core directly, never through the console. Bounded -polling and reruns retain the original container and history; timeout is a -diagnostic failure, not permission to relaunch. The installation public URL +polling retains the original Runtime identity and history; timeout is a +diagnostic failure, not permission to replay initialization or replace history. The installation public URL (`public_url` in the installation's `config.json`, seeded by `--public-url`, and `OAC_PUBLIC_URL` for Core) is the one origin for applications, nodes, sandbox guests and self-hosted executors, and also the console @@ -3296,7 +3301,7 @@ remain separate. Runtime advertisements alone never enable public operations. Shared dispatch checks capability combinations, not a whitelist of engine names. Harness onboarding does not require feature equality. Verify common lifecycle obligations and use the same public assertions for each declared operation, -retaining native isolation tests where appropriate. Optional native differences +qualifying outer isolation where the deployment requires it. Optional native differences remain independently prioritized capability/protocol work, not onboarding blockers. Keep the complete pinned public protocol target and accepted functionality intact. Never equate accepted parameters with applied native behavior. @@ -3334,32 +3339,34 @@ Runtime recovery then loads the exact owned native history in a new Executor. Do profile. See [deployment and acceptance](services/agents-api/deploy/mcode/README.md). The MiniMax workspace profile builds one CLI from the fixed upstream source and -lockfile through the existing companion packaging path, and isolates native -workspace tools behind its standard MCP client. The process and native Session +lockfile through the existing companion packaging path, and connects native +workspace tools through its standard MCP client. The process and native Session share one private control directory; public workspace files cannot configure that process or become privileged project instructions. A trusted adapter-owned bridge -runs the original six tool implementations in the upstream Linux sandbox, with no -unsandboxed fallback. Keep native history bound to the control directory and Files/ -Artifacts bound to the public workspace. Core and shared file helpers remain engine +runs the original six tool implementations with the launching user's ordinary +permissions. It adds no inner sandbox on any platform. Keep native history bound +to the control directory and Files/Artifacts bound to the public workspace. Core and shared file helpers remain engine neutral. This internal MCP transport does not admit public MCP configuration. Record the upstream revision, native admission patch hashes and worker-source provenance. The bounded patch checks the shared descendant-task limit inside the existing native SQLite admission transaction before start, without another scheduler. ACP initialization must acknowledge the applied limit before input. -The native tool catalog applies the protected workspace policy to every child, -not only the root's configured profile. Only the Session's authorized internal +The native tool catalog selects the same admitted workspace tools for every child, +not only the root's configured profile; this is tool selection, not filesystem +isolation. Only the Session's authorized internal workspace MCP entry crosses the native child selector; this does not grant -external MCP access or bypass the native profile's read/write restrictions. -Initialization must acknowledge that protected tool policy before input as well. -Subagent reads use the Session-private protected native database. Multi-agent +external MCP access. Initialization must acknowledge the admitted tool inventory +before input as well. Subagent reads use the Session's native database; the daemon +does not protect it from other tools running as the same user. Multi-agent workspace execution installs only the existing authorized workspace MCP entry in that private native configuration so children inherit the same tools; public MCP and Environment-origin MCP combinations remain separately qualified. Complete [workspace acceptance](contracts/agents-api/mcode-workspace-v1.md) before enabling hosted execution. The standalone companion uses its own npm lock; `make check` runs its lifecycle tests and script checks, while its exact-source Linux build and -Docker qualification (including `native.test.mjs` under both network policies) -are required when the companion changes. +native qualification for the actual supported platform and outer deployment +are required when the companion changes. Historical tests of both inner network +policies do not qualify the current bypass implementation. Claude hosted functions compose the existing SDK function bridge with the common workspace execution profile. Only declared function tools and the verified native tool @@ -3487,38 +3494,25 @@ Confirmed native cancellation may settle unanswered function calls after result admission closes and callbacks drain. A submitted function result still requires its native application receipt, including when the MCP request aborts. -`claudesdk.Config.Workspace` is a private, trusted operator binding for one -qualified placement. It enables native Bash/Read/Edit and declared host functions -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. 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. -The complete packaged runtime directory, including `node_modules`, must not -overlap any bound root. Its bridge uses the packaged `dist/main.js` layout. -Node, the bridge entrypoint and its readiness companion must use canonical file -paths. Dependency aliases outside mutable roots are resolved before use in PATH; -aliases within mutable roots are rejected even if their current target is safe. -The operator owns allocation, exclusive use and retention; a binding is not -per-Session authorization, a tenant boundary or an idle Files owner. - -For this profile, `Config.Env` replaces inheritance for both readiness and -execution, selecting only supported provider/proxy variables. The adapter fixes -HOME/history/scratch, native enforcement flags and tool inventory; the bridge -request contains variable names, never credential values. Native Bash uses the -strict sandbox with no fallback or weaker isolation. Separate native file-tool -permissions and a session tool hook restrict Read/Edit to the bound workspace -and deny protected roots; background/unsandboxed Bash requests are rejected. -The deployment must retain these controls, including the SDK-owned hook. -External MCP and remote-environment combinations remain 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. 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. +`claudesdk.Config.Workspace` is an operator binding for the selected workspace and +native state. It enables native Bash/Read/Edit and admitted host functions in the +existing SDK loop. Native tools run with the launching user's permissions on all +platforms; there is no inner sandbox, protected-root deny policy or managed shell +wrapper. Managed isolation belongs to the outer Environment, which must exclude +other tenants' and broader application credentials. An ordinary native install +provides no such boundary. Directory selection is not tenant authorization. + +`Config.Env` selects readiness and native process variables. Explicit tool env is +applied to tool execution, but this is not a guarantee that same-user tools cannot +read credentials or history from local files. Workspace hooks retain their event, +identity and lifecycle responsibilities, not security enforcement. Process groups +and Windows Jobs provide cancellation and descendant cleanup, not isolation. +Supported MCP and subagent combinations require their own qualification. The +existing `none` profile keeps its tool inventory. Packaged `workspace_tools` +establishes bridge support, not outer host isolation or public API admission. +The dedicated Runtime composes public preparation, placement quotas, command Items +and Files ownership. Real-provider acceptance verifies effects, cancellation and +same-history continuation for the actual platform and outer deployment. The private bridge accepts `executor_prepare` without model input. It freezes validated configuration and resume identity, checks required history, and retains diff --git a/apps/docs/content/docs/admin-api.mdx b/apps/docs/content/docs/admin-api.mdx index 1e4fe2786..aa70cec65 100644 --- a/apps/docs/content/docs/admin-api.mdx +++ b/apps/docs/content/docs/admin-api.mdx @@ -8,7 +8,7 @@ resource inspection, Project and key management, credential issuance, audit, usa and sandbox operations. It never calls `/v1` or `/api/v1`, and has no Agent execution, copy or arbitrary asset editing operation. -Core request failures use the [Core administration error envelope](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/core-errors.md), +Core request failures use the [Core administration error envelope](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/core-errors.md), including optional typed safe details. Console sign-in endpoints retain their separate error shape described below. @@ -49,9 +49,9 @@ label, but could set any value. Never use it for authorization or as proof of origin. Web calls Core only through the typed Core clients in `packages/agents-client`: -[AdminClient](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/packages/agents-client/src/admin-client.ts) (`/core/v1`), +[AdminClient](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/packages/agents-client/src/admin-client.ts) (`/core/v1`), `SandboxAdminClient` (`/core/v1/sandbox`) and `CoreMetricsClient` -(`/core/v1/metrics`). See [the complete administrator reference](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md) +(`/core/v1/metrics`). See [the complete administrator reference](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md) for methods, fields, filters, pagination and response shapes. Routes below are relative to `/core/v1`: @@ -62,8 +62,8 @@ Routes below are relative to `/core/v1`: | Resource lists/details/deletion | `/projects/{id}/agents`, `/sessions`, `/environment-templates`, `/skills`, `/files`, `/vaults`, including the documented nested reads | | Asset ownership | `GET /projects/{id}/resource-owners` with batched resource IDs | | Key operation history | `GET /projects/{id}/write-operations` with key/resource/time filters | -| Executor credentials | `GET/POST /projects/{id}/environments/{environment_id}/executor-credentials`, `DELETE …/executor-credentials/{key_id}` ([contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-executor-credentials.md)) | -| Deployment model providers | `GET /harnesses`, `GET/PUT/DELETE /harnesses/{harness}/model-provider`; the key is write-only ([contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/model-execution.md#deployment-defaults)) | +| Executor credentials | `GET/POST /projects/{id}/environments/{environment_id}/executor-credentials`, `DELETE …/executor-credentials/{key_id}` ([contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-executor-credentials.md)) | +| Deployment model providers | `GET /harnesses`, `GET/PUT/DELETE /harnesses/{harness}/model-provider`; the key is write-only ([contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/model-execution.md#deployment-defaults)) | | Usage and health | `GET /summary`, `/sandbox/runtime-observations`, `/metrics` | | Administrator audit | `GET /audit-log`; deployment-wide entries have `project_id: null` | @@ -76,9 +76,9 @@ it is operational attribution, not per-key billing. ## Sandbox administration -The [deployment configuration contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/sandbox-deployment.md), -[nodes and sandbox backends reference](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md) -and [generated OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/core.openapi.yaml) +The [deployment configuration contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/sandbox-deployment.md), +[nodes and sandbox backends reference](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/HOSTED-SANDBOX-MANAGER.md) +and [generated OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/core.openapi.yaml) define deployment and node operations: - `GET/POST/PUT /core/v1/sandbox/deployment` and @@ -115,7 +115,7 @@ is a durable serving pin, not proof of current connectivity. Target unknown, fai or update-required state does not by itself invalidate confirmed old-generation service; consume Core's connection/provider facts separately. -Administrators may [archive an individual hosted Session](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md#administrative-session-archive) +Administrators may [archive an individual hosted Session](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md#administrative-session-archive) at the current generation without reset. History and persisted Files/Artifacts survive; unpersisted workspace is lost and the original Session cannot resume. Cancel reset stops further archives, not cleanup already requested. After zero @@ -156,7 +156,7 @@ generated schema; do not interpret every empty or failed read as an absent resou `GET /core/v1/metrics?range=1h|6h|24h|7d` returns Core process, execution queue/slots, PostgreSQL and background-job measurements. It requires the Core key, rejects arbitrary query filters and never grants -Agent execution access. See the [exact measurement contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/core-metrics.md) +Agent execution access. See the [exact measurement contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/core-metrics.md) for complete buckets, null values, units and process-local retention. Frontend implementation is maintained separately; this backend change does not modify Agent metrics or the public Agent API. @@ -165,7 +165,7 @@ Agent metrics or the public Agent API. The Core key reads a node and its host history through `GET /core/v1/sandbox/nodes/{node_id}?range=1h|6h|24h`. See the -[node host history contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/node-host-history.md) +[node host history contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/node-host-history.md) for nullable observations, freshness and aggregation. The node list is unchanged. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/api/web-management.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/api/web-management.md) diff --git a/apps/docs/content/docs/agents-and-tools.mdx b/apps/docs/content/docs/agents-and-tools.mdx index 888915ce6..7b652ad86 100644 --- a/apps/docs/content/docs/agents-and-tools.mdx +++ b/apps/docs/content/docs/agents-and-tools.mdx @@ -4,7 +4,7 @@ description: "Native harness tools, shared declarations and the limits of execut --- Assessed against Core `206474a4c10901442b1faa094281bfb5559082b5` on 2026-09-22. -The contract remains [OpenAI Python 3.13.0 at `d7c41ef`](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/upstream.json), Beta +The contract remains [OpenAI Python 3.13.0 at `d7c41ef`](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/upstream.json), Beta `agents=v1`. This matrix records the bounded execution/tools milestone. It does not establish complete protocol compatibility; the remaining limits below stay open. @@ -22,20 +22,20 @@ and Environment Plugin MCP have separate inventories and qualification. | Operation | Implemented behavior and real qualification | Unsupported or unverified boundary | | --- | --- | --- | -| Initial message input, `sessions.create` | String input and ordered user-message arrays share atomic admission. Codex/Claude text and inline image execution: M1/M2; ordinary MiniMax text: M1/P1. [Input contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/message-input.md), [initial parser](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/api/session_initial_input.go). | Whitespace-only text is admitted verbatim for Codex and rejected at admission for Claude SDK and MiniMax Code ([text content](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/message-input.md#text-content)). Empty parts beside text (SES-08) and local size-limit parity need upstream evidence. Images are only qualified for inline PNG/JPEG on `none` and Core-managed Docker `openai_hosted`; MiniMax, self-hosted images and remote URLs remain gaps. | -| Prepared and active messages, `sessions.events.create` | Ordered `input_text`/`input_image` arrays retain original content and distinct public user Items. HTTP 202 confirms persistence; native receipts establish application. Initial/prepared/active Docker PNG/JPEG: M2; active PNG on `none`: M1. [Shared admission](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/api/inputs.go). | Codex flattens native messages with blank-line separators. Claude can fold or queue native turns; it does not promise Codex's same-native-turn behavior. Public durability does not prove native consumption. Claude SDK and MiniMax Code reject messages without an image or non-whitespace text at admission (`unsupported_or_invalid_configuration`); Codex delivers them unchanged. | -| Structured output, `agent.text.format` | Save/inherit/freeze `{type:json_schema,schema:...}`. Claude SDK object-root, single Agent, medium verbosity, ordinary functions returning text: S1 (`none`) and S2 (Docker, including prepared/active input and Files/Artifacts). Native final text is retained unchanged; its stream follows the official message sequence with the whole text in one `output_text.delta` ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). [Contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/structured-output.md), [profile](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/engine/claude.go). | An explicit non-object root type is a protocol error for every harness ([validation](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Codex/MiniMax, schemas without an object root, schema numbers changed by binary64, self-hosted, Skills/Plugins, MCP, Subagent and discovery combinations reject execution. No output repair, coercion or extra model loop. Arbitrary schema dialects are unverified. | -| Function configuration, saved/inline Agents | Required name/description/schema; `defer_loading` defaults false. Protocol errors, repeated names and explicit non-object root types reject with the official fields ([validation](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Saved references resolve into an immutable Session snapshot. Codex/Claude real calls: F1/F2/M2/S1/S2. [Parser](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/api/function_configuration.go), [saved tools](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/api/saved_tools.go). | MiniMax public functions reject. Claude requires an explicit object root. Local nonblank/512-byte name and 64-definition Session bounds are compatibility gaps. Saving configuration alone does not qualify execution. | -| Function-result admission, `events.create` | Required `turn_id`, `call_id`, `success`; optional nullable `error` and `output`. Output is string or ordered text/image content. Scoped atomic batches retain field presence, original content and retry identity in storage; public result Items and events always carry `output` and `error`, null when not submitted ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). Same result retries are accepted; changed results and results after cancellation return 409 `conflict_error`, including after terminal state. In the caller's Session, an unknown call or a call of another Turn returns 400 `invalid_request_error` without changing the pending action; missing and foreign Sessions stay 404 ([error rows](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23)). F1/F2/M2 plus [controlled SDK/raw checks](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/tests/official_function_inputs.py). [Parser](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/api/function_inputs.go), [Store](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/store/function_inputs.go). | Admission is separate from application and public Item publication. Invalid or unqualified content cannot consume a pending call. Core messages omit the call and executor IDs that official messages name; hosted defaults and publication timing remain unverified. | -| Function text results and native application | Codex waits for a matching live root dynamic-tool completion; Claude waits for a matching live root native tool result. Text/error results, retry/conflict, cancellation and cold continuation: F1/F2. [Receipt contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/function-result-images.md). | Transport writes alone do not confirm application. Confirmation is not provider consumption, crash recovery or exactly-once external effects. No automatic result replay. | +| Initial message input, `sessions.create` | String input and ordered user-message arrays share atomic admission. Codex/Claude text and inline image execution: M1/M2; ordinary MiniMax text: M1/P1. [Input contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/message-input.md), [initial parser](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/api/session_initial_input.go). | Whitespace-only text is admitted verbatim for Codex and rejected at admission for Claude SDK and MiniMax Code ([text content](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/message-input.md#text-content)). Empty parts beside text (SES-08) and local size-limit parity need upstream evidence. Images are only qualified for inline PNG/JPEG on `none` and Core-managed Docker `openai_hosted`; MiniMax, self-hosted images and remote URLs remain gaps. | +| Prepared and active messages, `sessions.events.create` | Ordered `input_text`/`input_image` arrays retain original content and distinct public user Items. HTTP 202 confirms persistence; native receipts establish application. Initial/prepared/active Docker PNG/JPEG: M2; active PNG on `none`: M1. [Shared admission](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/api/inputs.go). | Codex flattens native messages with blank-line separators. Claude can fold or queue native turns; it does not promise Codex's same-native-turn behavior. Public durability does not prove native consumption. Claude SDK and MiniMax Code reject messages without an image or non-whitespace text at admission (`unsupported_or_invalid_configuration`); Codex delivers them unchanged. | +| Structured output, `agent.text.format` | Save/inherit/freeze `{type:json_schema,schema:...}`. Claude SDK object-root, single Agent, medium verbosity, ordinary functions returning text: S1 (`none`) and S2 (Docker, including prepared/active input and Files/Artifacts). Native final text is retained unchanged; its stream follows the official message sequence with the whole text in one `output_text.delta` ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). [Contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/structured-output.md), [profile](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/engine/claude.go). | An explicit non-object root type is a protocol error for every harness ([validation](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Codex/MiniMax, schemas without an object root, schema numbers changed by binary64, self-hosted, Skills/Plugins, MCP, Subagent and discovery combinations reject execution. No output repair, coercion or extra model loop. Arbitrary schema dialects are unverified. | +| Function configuration, saved/inline Agents | Required name/description/schema; `defer_loading` defaults false. Protocol errors, repeated names and explicit non-object root types reject with the official fields ([validation](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Saved references resolve into an immutable Session snapshot. Codex/Claude real calls: F1/F2/M2/S1/S2. [Parser](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/api/function_configuration.go), [saved tools](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/api/saved_tools.go). | MiniMax public functions reject. Claude requires an explicit object root. Local nonblank/512-byte name and 64-definition Session bounds are compatibility gaps. Saving configuration alone does not qualify execution. | +| Function-result admission, `events.create` | Required `turn_id`, `call_id`, `success`; optional nullable `error` and `output`. Output is string or ordered text/image content. Scoped atomic batches retain field presence, original content and retry identity in storage; public result Items and events always carry `output` and `error`, null when not submitted ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). Same result retries are accepted; changed results and results after cancellation return 409 `conflict_error`, including after terminal state. In the caller's Session, an unknown call or a call of another Turn returns 400 `invalid_request_error` without changing the pending action; missing and foreign Sessions stay 404 ([error rows](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23)). F1/F2/M2 plus [controlled SDK/raw checks](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/tests/official_function_inputs.py). [Parser](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/api/function_inputs.go), [Store](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/store/function_inputs.go). | Admission is separate from application and public Item publication. Invalid or unqualified content cannot consume a pending call. Core messages omit the call and executor IDs that official messages name; hosted defaults and publication timing remain unverified. | +| Function text results and native application | Codex waits for a matching live root dynamic-tool completion; Claude waits for a matching live root native tool result. Text/error results, retry/conflict, cancellation and cold continuation: F1/F2. [Receipt contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/function-result-images.md). | Transport writes alone do not confirm application. Confirmation is not provider consumption, crash recovery or exactly-once external effects. No automatic result replay. | | Function image results | Successful ordered inline PNG/JPEG with text, large PNG and image-only JPEG: Codex/Claude `none` F1/F2; Docker M2. Public Items retain submitted bytes. Codex receipt regression: F2. | Claude rejects failed images and remote references before persistence; native resizing may change its image bytes. MiniMax functions and self-hosted image workflows are unqualified. Other Codex image/error/reference combinations cannot be inferred from successful-inline evidence. | -| Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | -| Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/tool-policy.md), [parser](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects at Session admission; saved Agents keep every pinned search mode as resource data (TV-05), and Sessions from such Agents reject unless they replace the tools. A repeated `web_search` is a protocol error. Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | -| Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/README.md#http-mcp-execution), [profiles](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports/origins reject. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | -| Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/user-managed-runtime-v1.md). Native tools stay within the existing colocated Runtime. | Plugin MCP is not Agent service-origin MCP or a public function action. No new optional cross-product is qualified here. Native utility inventories need not be identical. | -| Required action: `function_call` | Session GET/list and Session SSE expose persisted `{arguments,call_id,name,turn_id,type}`. Actions remain pending until native application or cancellation/terminal settlement. [Projection](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/store/function_state.go), [confirmation](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/store/function_results.go); real handling F1/F2/M2/S1/S2/D1; explicit client disconnect/query/reconnect: F3. | Function-call history is not pending-state authority. Client reconnect does not replay events or redo external application effects. | -| Required action: `environment_connection` | Offline waiting input exposes `{environment_id,type}` before Turn creation. Connect the exact Session Environment through our scoped daemon enrollment. Three-harness Docker/E2B execution: E1; explicit initial pending-input/query/connection/native completion: E2; expiry: [controlled initial-input test](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/tests/official_self_hosted_initial.py). | Idle offline Sessions without pending input request nothing. Registration alone is not connectivity or native readiness. Stock `exec-server`/Noise transport is outside the approved V1 route. Exact upstream registration/action-removal timing remains unverified. | -| Stream disconnect and execution loss | SSE is live-only. Reconnect, buffer events, retrieve Session/Turn/Items and deduplicate Item IDs. Worker recovery fails previously claimed work without replay; queued work can remain. [Recovery contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md#public-execution-admission), [connection reconciliation](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/store/environment_connection_recovery.go). | Client disconnect and daemon/Core loss are different cases. F3 qualifies client-stream loss and continued pending function handling; F2 qualifies native process loss with an unapplied saved result. Neither promises replay or recovery of unknown external effects. | +| Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | +| Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/tool-policy.md), [parser](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects at Session admission; saved Agents keep every pinned search mode as resource data (TV-05), and Sessions from such Agents reject unless they replace the tools. A repeated `web_search` is a protocol error. Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | +| Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/README.md#http-mcp-execution), [profiles](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports/origins reject. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | +| Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/user-managed-runtime-v1.md). Native tools stay within the existing colocated Runtime. | Plugin MCP is not Agent service-origin MCP or a public function action. No new optional cross-product is qualified here. Native utility inventories need not be identical. | +| Required action: `function_call` | Session GET/list and Session SSE expose persisted `{arguments,call_id,name,turn_id,type}`. Actions remain pending until native application or cancellation/terminal settlement. [Projection](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/store/function_state.go), [confirmation](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/store/function_results.go); real handling F1/F2/M2/S1/S2/D1; explicit client disconnect/query/reconnect: F3. | Function-call history is not pending-state authority. Client reconnect does not replay events or redo external application effects. | +| Required action: `environment_connection` | Offline waiting input exposes `{environment_id,type}` before Turn creation. Connect the exact Session Environment through our scoped daemon enrollment. Three-harness Docker/E2B execution: E1; explicit initial pending-input/query/connection/native completion: E2; expiry: [controlled initial-input test](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/tests/official_self_hosted_initial.py). | Idle offline Sessions without pending input request nothing. Registration alone is not connectivity or native readiness. Stock `exec-server`/Noise transport is outside the approved V1 route. Exact upstream registration/action-removal timing remains unverified. | +| Stream disconnect and execution loss | SSE is live-only. Reconnect, buffer events, retrieve Session/Turn/Items and deduplicate Item IDs. Worker recovery fails previously claimed work without replay; queued work can remain. [Recovery contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/README.md#public-execution-admission), [connection reconciliation](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/store/environment_connection_recovery.go). | Client disconnect and daemon/Core loss are different cases. F3 qualifies client-stream loss and continued pending function handling; F2 qualifies native process loss with an unapplied saved result. Neither promises replay or recovery of unknown external effects. | ## Required-action recovery contract @@ -52,7 +52,7 @@ application already performed the function, keep and submit that saved result; do not run an external effect again merely because an action remains visible. Core's acknowledgement boundary is native application. For an environment action, connect its exact Environment using the existing enrollment authorization. -[Connection observations](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/store/environment_connections.go) +[Connection observations](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/store/environment_connections.go) fence stale generations; transport connection can clear pre-Turn activity before native preparation. Neither registration nor an action disappearing proves that a model ran successfully. Query the matching Turn and Items for its outcome. @@ -61,8 +61,8 @@ Two timing questions remain open. Repeated `requires_action` notification order and acknowledgement timing are local implementation choices, not proven upstream semantics. Also, an admitted result that is cancelled before native observation can remain internally saved without a public output Item or `item.added`. -[Item publication](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/store/item_projection.go) and -[the existing coverage record](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md#public-function-configuration) preserve +[Item publication](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/store/item_projection.go) and +[the existing coverage record](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/README.md#public-function-configuration) preserve this gap. Successful retry cannot manufacture the missing observation or establish that the model consumed the result. @@ -82,12 +82,12 @@ not a claim that every historical workflow was rerun at the current baseline. | M2 | `20260922/workspace-images/validation-summary.json`; Codex `public-run-_lr5uiy_`, Claude `public-run-sb65p9e9`; candidate `2c295116d66ed9aafc808ec49a8cb6dcb5c674c2`, merge `1adb2489bc3415039440224e9a7905c53e68a557` ([#17](https://github.com/MiniMax-AI/parsar-core/pull/17)). Kimi K3, Codex 0.153.4, Claude SDK 0.3.269/native 2.1.269; Docker seven-Turn image/workspace workflow. Codex's receipt conclusion is superseded by F2. | | F1 | `20260922/function-result-images/validation-summary.json`, `validated/function-image-public-2567456666/public.json`; candidate `d3cdb225bc7628b968b76c7e1b400101894ce8cd`, merge `fd23f169e1ede1b2f1c39a1d9dcce71886e3c006` ([#16](https://github.com/MiniMax-AI/parsar-core/pull/16)). Claude SDK 0.3.269/native 2.1.269 with Kimi K3 on `none`; success PNG/JPEG, failed text, retries, cancellation, history continuation. Earlier Codex transport-only evidence does not qualify current receipts. | | F2 | `20260922/function-result-receipts/validation-summary.json`, `candidate-public-owner-sdk.log`; hosted `public-run-4fn70foy` (104.13s), `none` `function-image-public-945075091` (83.918s). Candidate `6dceb98e1c56469fcc70cd82025ce77fc870ed3d`, merge `206474a4c10901442b1faa094281bfb5559082b5` ([#20](https://github.com/MiniMax-AI/parsar-core/pull/20)); real Kimi, Codex 0.153.4. Qualifies native receipts, not completed provider consumption. | -| F3 | `20260922/execution-tools-closure/pending-actions/validation-summary.json`; Codex `codex/public-run-8gexf5a8` (65.58s), Claude `claude_sdk/public-run-5f2tr7e_` (74.33s), production source `206474a`. The same [public verifier](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/tests/official_pending_actions_native.py) checks disconnected-client pending queries, success/error/cancel, original results, target isolation, retry/conflict and no implicit call replay with real Kimi on Docker. No Core restart or native-loss injection is claimed by these runs. | +| F3 | `20260922/execution-tools-closure/pending-actions/validation-summary.json`; Codex `codex/public-run-8gexf5a8` (65.58s), Claude `claude_sdk/public-run-5f2tr7e_` (74.33s), production source `206474a`. The same [public verifier](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/tests/official_pending_actions_native.py) checks disconnected-client pending queries, success/error/cancel, original results, target isolation, retry/conflict and no implicit call replay with real Kimi on Docker. No Core restart or native-loss injection is claimed by these runs. | | S1 | `20260922/structured-output/acceptance-summary.json`, `structured-public-1905281777/public.json`; real candidate `7f533d46c472f3cdf7c16ce9471c225a2ba7eded`, merge `8716e699dbc34904499c94b6b0b03857bbebfaad` ([#11](https://github.com/MiniMax-AI/parsar-core/pull/11)). Claude/Kimi `none` schema/function execution and cold continuation. Schema-specific active steering was not separately qualified here. | | S2 | `20260922/hosted-structured-output/validation-summary.json`, `validated/public-inline.log`, `public-run-qvq3csf1` (186.09s); candidate `f6d2c3f2a1dc2ff5f177213bd2b20d78496011d0`, merge `aa85d8ecc60e0a8b7f2494b73caa81216ee197e2` ([#18](https://github.com/MiniMax-AI/parsar-core/pull/18)). Claude SDK 0.3.269/native 2.1.269, real Kimi; Docker saved/inline schema, prepared/active input, native files and four completed structured answers. | | D1 | `20260922/deferred-tools/tool-search-public-2039155387/public.json`, `public-image-isolated-tests.log` (86.71s); merge `178507ae7a63d4068e1e82bef9cd256ba398ae00` ([#13](https://github.com/MiniMax-AI/parsar-core/pull/13)). Claude SDK 0.3.269/native 2.1.269/Kimi K3 `none`; text results with initial/active PNGs, cancellation and cold continuation. `claude-native-1790052750` separately records provider schema inventories and native feasibility. | | P1 | `20260922/tool-policy/acceptance.json`, `server-evidence/tool-policy-{codex-2495445746,claude_sdk-2539346823,mcode-3747575401}/public.json`; candidate `eeb432c7495265ae3a9309e32f5b4323611c3245`, merge `4b8754a6eda6696d9b18eb8d583953ac16c6800f` ([#14](https://github.com/MiniMax-AI/parsar-core/pull/14)). Codex/Claude Kimi K3, MiniMax M2.7; four `none` Sessions per harness, native disable controls and cold continuation. | -| E1 | `20260921/self-hosted-onboarding/{source-candidate.json,source-verified.json,live,live-e2b}`; candidate `8f0cd2530d7b58cb7fb3ea124a1fc43dacecca36`. [Exact Docker/E2B run paths and limits](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/user-managed-runtime-v1.md#evidence-and-verification-boundaries): Codex Kimi K3 Responses, Claude Kimi K3 Anthropic-compatible API, MiniMax M2.7, immutable Linux amd64 Runtime builds. Native execution, restart/history, cancellation, Files/Artifacts and isolation; shared credential lifecycle evidence is not a separate live rotation run for every E2B profile. | +| E1 | `20260921/self-hosted-onboarding/{source-candidate.json,source-verified.json,live,live-e2b}`; candidate `8f0cd2530d7b58cb7fb3ea124a1fc43dacecca36`. [Exact Docker/E2B run paths and limits](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/user-managed-runtime-v1.md#evidence-and-verification-boundaries): Codex Kimi K3 Responses, Claude Kimi K3 Anthropic-compatible API, MiniMax M2.7, immutable Linux amd64 Runtime builds. Native execution, restart/history, cancellation, Files/Artifacts and isolation; shared credential lifecycle evidence is not a separate live rotation run for every E2B profile. | | E2 | `20260922/execution-tools-closure/environment-actions/result.json` contains Codex/Claude Kimi passes (13 checks each) and the retained MiniMax direct-network failure; `mcode-relay-retry/result.json` separately passes MiniMax M2.7 (13 checks) after correcting provider routing. Source `206474a`; same shared SDK/raw workflow, isolated Core/PostgreSQL and user-managed Docker. Covers initial creation SSE, client disconnect, exact pending Environment/no Turn or Items, scoped enrollment, one actual completed model Turn and creation retries preserving history. This is connection-action qualification, not another full lifecycle or E2B regression. | Controlled tests establish parser, projection, atomicity and race behavior; native @@ -121,4 +121,4 @@ remain separate limitations. They do not authorize silently relabeling an unprov principal workflow as deferred. OAuth, full Usage, release publication, new tool kinds and new execution loops are outside this closure batch. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/execution-tools.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/execution-tools.md) diff --git a/apps/docs/content/docs/api-reference/environment-templates.mdx b/apps/docs/content/docs/api-reference/environment-templates.mdx index 5e68bd5c2..32e05af1a 100644 --- a/apps/docs/content/docs/api-reference/environment-templates.mdx +++ b/apps/docs/content/docs/api-reference/environment-templates.mdx @@ -20,13 +20,15 @@ _openapi: Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential env, ordered setup_commands, - system/npm/Python packages inline/referenced Skill ZIPs, Plugin ZIPs - and workspace-contained capability directories. Omitted/null network + npm/Python packages inline/referenced Skill ZIPs, Plugin ZIPs and + workspace-contained capability directories. Omitted/null network defaults to enabled. Restricted network requires 1–100 exact ASCII hostnames; other host forms and populated unsupported installations are rejected before persistence without echoing input. Network policy - rejections return invalid_request_error with a null param. No compute - is allocated. Exact hosted error/retry semantics remain unverified. + rejections return invalid_request_error with a null param. System + dependencies must be preinstalled in the sandbox image or template, or + on the host machine; packages.system is rejected. No compute is + allocated. Exact hosted error/retry semantics remain unverified. - content: >- Returns safe tenant-owned configuration metadata without allocating compute. Missing and foreign resources return the same not-found diff --git a/apps/docs/content/docs/api-reference/sessions.mdx b/apps/docs/content/docs/api-reference/sessions.mdx index 5de4d4f81..400565ff7 100644 --- a/apps/docs/content/docs/api-reference/sessions.mdx +++ b/apps/docs/content/docs/api-reference/sessions.mdx @@ -126,22 +126,24 @@ _openapi: enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, - system/npm/Python packages and ordered setup commands use the shared + npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. - Initial inline and tenant-owned file_id files freeze encrypted bytes - before provisioning, then install through the common Core lifecycle - before native execution or live Files access. With a template - reference, omitted/null files, env, packages and setup_commands - inherit. Non-null files and command lists replace; env overlays by - key; each package manager inherits on omission/null and otherwise - replaces its list. Empty lists clear their selected field. - Tenant-owned environment_template_id references inherit omitted/null - network and allow only narrowing overrides. Inline hosted network:null - retains the enabled default; updating a Template with network:null - resets its saved policy to enabled. Core freezes effective - configuration; template updates/deletion do not alter Session - snapshots or same-intent creation retries. Inline or tenant-owned - skill_reference Skills share initialization. Templates preserve + System dependencies must be preinstalled in the sandbox image or + template, or on the host machine; packages.system is rejected. Initial + inline and tenant-owned file_id files freeze encrypted bytes before + provisioning, then install through the common Core lifecycle before + native execution or live Files access. With a template reference, + omitted/null files, env, packages and setup_commands inherit. Non-null + files and command lists replace; env overlays by key; each package + manager inherits on omission/null and otherwise replaces its list. + Empty lists clear their selected field. Tenant-owned + environment_template_id references inherit omitted/null network and + allow only narrowing overrides. Inline hosted network:null retains the + enabled default; updating a Template with network:null resets its + saved policy to enabled. Core freezes effective configuration; + template updates/deletion do not alter Session snapshots or + same-intent creation retries. Inline or tenant-owned skill_reference + Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list diff --git a/apps/docs/content/docs/bootstrap-projects-keys.mdx b/apps/docs/content/docs/bootstrap-projects-keys.mdx index 2194e6472..93eadddbe 100644 --- a/apps/docs/content/docs/bootstrap-projects-keys.mdx +++ b/apps/docs/content/docs/bootstrap-projects-keys.mdx @@ -69,7 +69,7 @@ and display only. Keep deployment, application, node and provider credentials ou Installation creates no Project or application key. An administrator creates a Project and issues named keys on the console's **Projects and keys** page, which -uses the [administrator API](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md), or with that +uses the [administrator API](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md), or with that API directly. The Project owns one tenant and one principal; its keys share assets and permissions. Writes retain each key's provenance. All Projects and application keys live in @@ -98,4 +98,4 @@ management request points to the console's Core key or Core connection. A resour deletion conflict must remain visible; it does not authorize an execution call. Node and daemon `/api/v1` routes and native Runtime interfaces retain their own authentication. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/web/core-connection.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/web/core-connection.md) diff --git a/apps/docs/content/docs/concepts.mdx b/apps/docs/content/docs/concepts.mdx index 6ff1c2e41..357c62d9d 100644 --- a/apps/docs/content/docs/concepts.mdx +++ b/apps/docs/content/docs/concepts.mdx @@ -66,6 +66,22 @@ create, copy or edit arbitrary assets, start a Session, send an event, cancel work, or read stored credentials. Project keys share all assets within their Project; nothing is shared or copied across Projects. +## Runtime and outer isolation + +The same daemon and Core protocol serve self-hosted Linux, macOS and Windows. +OS differences belong to Runtime implementations; harness differences belong to +adapters. Managed Providers remain Linux-only. Runtime prepares capabilities and +executes work; Providers create, bootstrap and reclaim outer Environments. + +The daemon runs tools with its launching user's permissions. It does not add a +filesystem, permission or network sandbox, including on Linux. Outer Docker/E2B/ +microsandbox Environments provide managed isolation. Credential authentication, +ordinary private storage, atomic writes, locks and process cleanup remain required, +but do not protect Runtime data from tools running as the same user. Local +capability snapshots share the managed parser and do not create a second security +boundary. See the [native Runtime guide](/self-hosted-native) for current platform +validation limits. + ## Secrets and evidence Credential values, model credentials and confidential template initialization are @@ -83,12 +99,12 @@ ownership. No request bodies, secrets or file contents enter audit records. Do not add product users, RBAC, cross-Project shared assets, administrator execution, or old private-protocol compatibility to this management model. Existing public -execution and Runtime ownership rules remain in [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/CONTRIBUTING.md). +execution and Runtime ownership rules remain in [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/CONTRIBUTING.md). Native failure classification is adapter-owned and uses finite structured native values. Optional Runtime error metadata is normalized once and retained through normal delivery and journal draining; it never replaces Core terminal authority, cancellation receipts, Usage or native identity. See -[native failure classification](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/native-error-classification.md). +[native failure classification](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/native-error-classification.md). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/design-principles.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/design-principles.md) diff --git a/apps/docs/content/docs/configure.mdx b/apps/docs/content/docs/configure.mdx index 360b9717a..24c5a9cb6 100644 --- a/apps/docs/content/docs/configure.mdx +++ b/apps/docs/content/docs/configure.mdx @@ -116,7 +116,7 @@ output or in Core's settings snapshot. Model providers are not process settings; The schema is -[`deploy/install/config.schema.json`](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/deploy/install/config.schema.json); each +[`deploy/install/config.schema.json`](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/deploy/install/config.schema.json); each installation keeps a copy in `generated/config.schema.json` for editors. Core serves the non-secret settings snapshot, with the path of `config.json` and the apply command, at `GET /core/v1/installation`. @@ -145,7 +145,7 @@ Which harnesses are enabled, and the default one, are process settings Exactly one provider serves the deployment: Docker, microsandbox or E2B. The deployment specification holds the per-sandbox resources and, for Docker and microsandbox, the Runtime release. Web's setup proposes the Standard size from -[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/apps/web/src/features/sandbox/standard-sizes.json) and +[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/apps/web/src/features/sandbox/standard-sizes.json) and derives Small and Large from it; the installer applies the bundle's copy of the same file. The database owns the saved values. @@ -183,8 +183,8 @@ Current Runtime generation coexistence and ownership-scoped garbage collection remain supported. They do not upgrade the installed node program or convert an old installation. History stays, and existing Sessions never move between providers. See the [nodes guide](/hosted-providers#change-the-sandbox-backend-or-size), -[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) -and [deployment contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/sandbox-deployment.md). +[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) +and [deployment contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/sandbox-deployment.md). ### Node capacity @@ -211,7 +211,7 @@ Session freezes the provider it starts with, so a later change affects only new Sessions. Self-hosted Sessions never use it. A hosted or self-hosted Session with no provider is refused with 400 `model_provider_required`. See [model providers](/quickstart#model-providers) and the -[model execution contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/model-execution.md#deployment-defaults). +[model execution contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/model-execution.md#deployment-defaults). The operator file `AGENTS_API_EXECUTION_OPTIONS_FILE` is retired; see [installation version policy](/troubleshooting#installation-version-policy). @@ -231,7 +231,7 @@ A Web-only installation has only `secrets/core.key`, a copy of its Core's key. Core reads only its environment. The installer renders `generated/core.env` from `config.json`; if you run Core yourself (see -[Maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/maintainers.md)), set these variables. Compose +[Maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/maintainers.md)), set these variables. Compose loads the file with `env_file` and systemd with `EnvironmentFile`, so Compose must be 2.26.0 or newer. @@ -246,7 +246,7 @@ loads the file with `env_file` and systemd with `EnvironmentFile`, so Compose mu | `OAC_INSTALLATION_ID` | `state.json`; pinned to the database | | `OAC_SETTINGS_FILE` | `generated/settings.json`, the snapshot Core serves at `GET /core/v1/installation`; Core does not act on it | | `OAC_EXECUTION_CONCURRENCY`, `OAC_DEFAULT_HARNESS`, `OAC_HARNESSES`, `OAC_WRITE_AUDIT_RETENTION`, `OAC_OAUTH_TRUSTED_ORIGINS` | The matching `core.*` settings | -| `OAC_HISTORY_SETTINGS_FILE` | `generated/runtime-history.json` when `core.runtime_history` is set; see the [history contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime-history-api.md) | +| `OAC_HISTORY_SETTINGS_FILE` | `generated/runtime-history.json` when `core.runtime_history` is set; see the [history contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/runtime-history-api.md) | | `OAC_LOG_LEVEL`, `OAC_LOG_FORMAT`, `OAC_LOG_ADD_SOURCE` | `log.*`; Web reads the same three | | `OAC_E2B_STATE_DIR`, `OAC_E2B_PROVIDER_BIN` | The E2B receipt directory and, for native Core, the bundled helper. The E2B key and template live in the database | @@ -268,4 +268,4 @@ paths must be canonical absolute paths without control characters, quotes, backs or wildcards. Keep the installation ID and the database together; Core refuses a missing installation ID when its database already has a deployment. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/configuration.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/configuration.md) diff --git a/apps/docs/content/docs/console.mdx b/apps/docs/content/docs/console.mdx index 4a39b51de..6a6342b0d 100644 --- a/apps/docs/content/docs/console.mdx +++ b/apps/docs/content/docs/console.mdx @@ -27,7 +27,7 @@ management client, and it sends nothing to `/v1`. | Platform | Projects and keys, Nodes, System | Project and key lifecycle; sandbox deployment and nodes; System: the installation's public address, API base URL, ID and source commit (read-only), each harness's default model provider (write-only key) beside its read-only startup state, the sandbox configuration every Project shares: provider, sandbox size, Runtime or E2B template build, idle suspension and reset, and Core's config.json startup settings with where to change them | Missing data is shown as missing (—), never as zero. How each figure is read and -bounded is recorded in [management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/web/protocol-coverage.md). +bounded is recorded in [management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/web/protocol-coverage.md). ## Management scope @@ -60,15 +60,15 @@ only the console server sends it to Core. - [Connection and authentication](/bootstrap-projects-keys) - [Architecture and ownership](/execution-model) -- [Management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/web/protocol-coverage.md) -- [Frontend handoff and acceptance](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/web/roadmap.md) -- [React application](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/apps/web/README.md) +- [Management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/web/protocol-coverage.md) +- [Frontend handoff and acceptance](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/web/roadmap.md) +- [React application](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/apps/web/README.md) -The [administrator API contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md) defines -management routes and resource behavior. The [public API contracts](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md) +The [administrator API contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md) defines +management routes and resource behavior. The [public API contracts](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/README.md) define the separate application interface. See the [design principles](/concepts) -for ownership and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/CONTRIBUTING.md) for required checks. +for ownership and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/CONTRIBUTING.md) for required checks. -OpenAgentCore Web is available under the [MIT License](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/LICENSE). +OpenAgentCore Web is available under the [MIT License](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/LICENSE). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/web/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/web/README.md) diff --git a/apps/docs/content/docs/environments-and-files.mdx b/apps/docs/content/docs/environments-and-files.mdx index 896627ce9..7322707ca 100644 --- a/apps/docs/content/docs/environments-and-files.mdx +++ b/apps/docs/content/docs/environments-and-files.mdx @@ -3,19 +3,19 @@ title: "Environments and files" description: "Execution placement, Environment resources and workspace ownership." --- -This assessment covers the fixed [Python SDK contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/upstream.json), with partial +This assessment covers the fixed [Python SDK contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/upstream.json), with partial coverage. Core-managed Docker runs the three qualified native harnesses. V1 user-managed `self_hosted` enrollment uses the same colocated Runtime for Codex, Claude SDK and MiniMax. `/workspace` names the packaged workspace; a self-hosted Session may instead select its exact canonical physical directory. The -[recorded deployment qualification](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/user-managed-runtime-v1.md) retains its historical +[recorded deployment qualification](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/user-managed-runtime-v1.md) retains its historical source, paths and tested capability scope. For hosted compute, the deployment selects E2B, Docker or microsandbox through -[Hosted Sandbox Manager](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md). +[Hosted Sandbox Manager](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/HOSTED-SANDBOX-MANAGER.md). For the separate caller-managed E2B path, the user owns allocation, renewal and -cleanup through the official SDK and [Runtime packaging](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/deploy/e2b/README.md). -[Templates](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-templates.md) remain a hosted-only resource path; -[Files](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-files.md) reuse the exact authorized local workspace. +cleanup through the official SDK and [Runtime packaging](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/deploy/e2b/README.md). +[Templates](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-templates.md) remain a hosted-only resource path; +[Files](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-files.md) reuse the exact authorized local workspace. The internal Store now owns a durable Environment association for newly created `self_hosted` and `openai_hosted` snapshots, atomically with Session creation. @@ -33,32 +33,35 @@ readiness. Rotation/revocation, Session deletion and ownership loss deny further access without promising immediate cessation of native effects. Native history remains local to the bound Runtime and cannot be replaced on retry. There is no registry/Noise relay or transient service-side harness credential. -See the [enrollment guide](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/README.md#user-managed-runtime-enrollment). +See the [enrollment guide](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/README.md#user-managed-runtime-enrollment). ## Basic public Docker-hosted profile An explicitly configured default managed Docker provider enables `type=openai_hosted` for the qualified Codex, Claude Code and MiniMax Code profiles. Each uses the same -Runtime lifecycle and workspace interfaces with its own native adapter/isolation. -See the [engine profile guides](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md#public-engine-profiles) for setup and limits. -The standalone [operator configuration](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/deploy/codex/README.md#standalone-operator-configuration) +Runtime lifecycle and workspace interfaces with its native adapter. The outer +Environment provides managed isolation; the daemon adds no inner sandbox. +See the [engine profile guides](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/README.md#public-engine-profiles) for setup and limits. +The standalone [operator configuration](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/deploy/codex/README.md#standalone-operator-configuration) selects the qualified immutable Runtime image; advertised capabilities alone do not enable admission. An idle or initial-text creation commits Session, Environment and retry identity before the existing leased Worker provisions its allocation. A committed creation interrupted before bootstrap is recovered without replaying an existing allocation's Create. -Omitted/null network defaults to enabled. Enabled, disabled and exact-host restricted -policies use the same qualified image with adapter-selected immutable native policy. +Omitted/null network defaults to enabled. The daemon does not enforce disabled +or restricted networking. An execution combination must reject unless its outer +Environment implements and qualifies the requested behavior. Templates and inline configuration share initial files, env, packages, ordered setup and inline or tenant-owned referenced Skills through ordered managed initialization. The same authenticated daemon handles initial files, tool configuration, packages, setup, Skill/Plugin import and directory snapshot finalization through `runtime_prepare`. Providers place, create, bootstrap, inspect, renew and reclaim -compute. Package installation and setup retain enabled provisioning network access; -the requested network policy constrains native execution after setup. +compute. npm/Python installation and setup use the host's existing network and +starting account. System dependencies must be preinstalled; `packages.system` +rejects explicitly and never invokes apt, sudo or elevated execution. Unsupported hostname forms and installation combinations reject explicitly; see -the [Template coverage and limits](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-templates.md). Empty/null installation +the [Template coverage and limits](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-templates.md). Empty/null installation defaults produce safe empty metadata, not a live workspace inventory. Service-origin hosted MCP remains unsupported; Environment Plugin MCP has its own qualified transport matrix. @@ -78,11 +81,11 @@ semantics; this profile does not establish complete Environment compatibility. The application creates, renews and destroys its E2B sandbox through the official SDK. It deploys the shared Runtime, then enrolls that Runtime into a `self_hosted` Session. Core neither keeps an E2B allocation nor issues Provider renew/kill calls. -The [E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/deploy/e2b/README.md) owns packaging and +The [E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/deploy/e2b/README.md) owns packaging and user-side lifecycle instructions. Expiry or lost workspace/history must not trigger -transparent replacement or replay. The [new enrollment qualification](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/user-managed-runtime-v1.md) +transparent replacement or replay. The [new enrollment qualification](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/user-managed-runtime-v1.md) records its own real deployment evidence. -The [prior E2B qualification](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md#e2b-v1-qualification) records a historical +The [prior E2B qualification](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/README.md#e2b-v1-qualification) records a historical Core-managed route; it does not establish this caller-managed enrollment result. Current deployment-managed E2B is a separate hosted configuration in the manager guide. @@ -120,7 +123,7 @@ connection alone is not readiness. Retries preserve identity and the five-minute database deadline. HTTP disconnect retains the reservation. Local expired/cancelled outcomes return 409, lost ownership 503, and deletion 404; exact hosted error status/body and pending-input crash recovery are unverified. See the -[canonical wait rules](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/CONTRIBUTING.md#environment-ownership-and-placement). +[canonical wait rules](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/CONTRIBUTING.md#environment-ownership-and-placement). Cancellation-only batches use the existing locked admission and native delivery. An idle cancellation creates no Turn, and retry identity preserves the original @@ -165,11 +168,11 @@ accepts only local `capability_directories` with `workspace_directory`, not mana Skill/Plugin archive fields or a hosted Template. Local directory discovery does not populate the public API-managed `skills` or `plugins` installation arrays. -Both origins use the packaged Linux Runtime's shared parser and protected -`installed.json`. Its default root is `/environment/initialization/capabilities`; -the operator may freeze another root with `OAC_RUNTIME_CAPABILITY_DIRECTORY`, kept -separate from workspace and private Runtime state. Core and public requests cannot -select this installation root. The manifest binds one +Both origins use the common Runtime parser and `installed.json` on Linux, macOS +and Windows. The operator selects the capability root; the native installer +uses `capabilities` under `OAC_RUNTIME_HOME` by default. Core and public requests +cannot select that destination. Read-only snapshot modes are integrity hints, +not protection from the launching user. The manifest binds one Session and Environment to the source-selection digest. The admitted asynchronous preparation owner verifies or creates it before native execution, including for an empty selection. Reconnect and replacement executors reuse installed contents; @@ -189,12 +192,13 @@ Core does not resolve these paths on its own host. The daemon applies its local path and access checks. Initial files and setup working directories use logical `/workspace` paths. Harness differences remain in adapters. -The protocol is platform-neutral, but this implementation retains the existing Linux -helpers, protected initialization storage and isolation requirements. It does not provide -a new native-host installer, another platform layout, or Windows/macOS isolation. -This implementation description does not extend historical real-deployment acceptance; -new managed and self-hosted capability coverage requires its own evidence. See the -[canonical preparation rules](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/CONTRIBUTING.md#shared-runtime-capability-preparation). +The protocol and Go preparation implementation are shared by all three native +platforms. Resource directories are operator settings, not deployment branches. +Managed Providers remain Linux-only. The daemon does not sandbox tools, files or +network access; tools use the starting account's permissions. Native installation +and current validation limits are in the [native guide](/self-hosted-native). +Historical deployment acceptance does not qualify later platform or capability changes. See the +[canonical preparation rules](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/CONTRIBUTING.md#shared-runtime-capability-preparation). ## Contract inventory @@ -249,7 +253,7 @@ from Session and Turn status. ## Ownership and placement decision -The canonical [architecture rules](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/CONTRIBUTING.md#environment-ownership-and-placement) +The canonical [architecture rules](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/CONTRIBUTING.md#environment-ownership-and-placement) keep Environment lifecycle common while leaving process placement and native transport to adapters. Logical ownership does not require a machine per object. @@ -273,4 +277,4 @@ native history independently of disposable compute, or prove native restoration; never treat an Environment ID as a filesystem or history backup. Do not silently move an existing Session away from its bound device. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environments.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environments.md) diff --git a/apps/docs/content/docs/execution-model.mdx b/apps/docs/content/docs/execution-model.mdx index 5fe1f60d6..ad64f9b8d 100644 --- a/apps/docs/content/docs/execution-model.mdx +++ b/apps/docs/content/docs/execution-model.mdx @@ -10,7 +10,7 @@ Agents API independently with their own Project keys. The management backend, `AdminClient` and the React console built on them are implemented. The [design principles](/concepts) define identity and authority. -The [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md) defines exact +The [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md) defines exact routes, payloads, pagination and audit records. ## Request boundaries @@ -106,6 +106,6 @@ comes from the deployment's allocations; the hosted Runtime total counts hosted observation records across projects and reported lifecycle states. These sources have different coverage and refresh independently, so the console does not infer resource retention or cleanup from their difference. -Native execution ownership remains governed by [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/CONTRIBUTING.md). +Native execution ownership remains governed by [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/CONTRIBUTING.md). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/web/architecture.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/web/architecture.md) diff --git a/apps/docs/content/docs/hosted-providers.mdx b/apps/docs/content/docs/hosted-providers.mdx index 53ba8ee54..77e214b8f 100644 --- a/apps/docs/content/docs/hosted-providers.mdx +++ b/apps/docs/content/docs/hosted-providers.mdx @@ -10,7 +10,7 @@ With E2B you need no nodes. Self-hosted executors, which applications run for th own Sessions, are a different thing; see [Self-hosted executors](/self-hosted-execution). You add a node by generating a command in Web and pasting it on the host. The -[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md) covers the +[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/HOSTED-SANDBOX-MANAGER.md) covers the node protocol, manual registration, placement and failure handling. ## Before you add a node @@ -187,7 +187,7 @@ The installation ID is in the command (`--installation-id`) and on the **System* 1. In Web, open **Nodes** and choose **Remove node** on the node's page, or **Remove** in its list row, then **Confirm removal**. Core refuses while the node still holds sandboxes, snapshots or pending cleanup; let them finish, or - [archive their Sessions](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) + [archive their Sessions](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) through the Core API. Removal is permanent: the host can come back only as a new node. 2. Web then shows **Clean up the host** with the uninstall command. Run it on the host: @@ -290,7 +290,7 @@ files. Shared daemon images remain; a host administrator may remove them only af confirming no installation still needs them. Microsandbox image cleanup remains scoped to the installation's private native store and verified ownership receipts. -The [operator reference](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) +The [operator reference](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) describes reset and API behavior. Same-team E2B edits have a separate online Core API path and do not involve node installation. @@ -303,4 +303,4 @@ convert or delete it. Preserve the old installation, Runtime resources and histo provision a fresh node separately through **Add node**. The current installer does not adopt historical resources or remove another installation's state. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/getting-started/nodes.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/getting-started/nodes.md) diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index 4159e280e..07a356c99 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -13,16 +13,17 @@ applications call Core with. | [Install Core and Web](/install) | Administrators | Prerequisites, download, the installer and every option, HTTPS and the reverse proxy, a local trial, first sign-in, what is created on disk | | [Nodes](/hosted-providers) | Administrators | Adding a node with one command, sudo and no-sudo modes, removal, logs and troubleshooting | | [Self-hosted executors](/self-hosted-execution) | Administrators and application owners | Connecting an application's own machine to a `self_hosted` Session, rotating and revoking its credential | +| [Native Runtime installation](/self-hosted-native) | Executor operators | Linux, macOS and Windows installation commands, lifecycle and current validation limits | | [Operations](/troubleshooting) | Administrators | The `oac` command, the Core key, backups, current-release repair and troubleshooting | | [Configuration reference](/configure) | Administrators | Every `config.json` setting and every runtime setting in Web | | [Call the API](/quickstart) | Application developers | `OPENAI_BASE_URL` and `OPENAI_API_KEY`, running a Session, model providers and `x_agents_core` | | [API reference](/public-api) | Developers | The `/v1`, `/core/v1` and `/api/v1` namespaces and their contracts | Deeper references: the -[nodes and sandbox backends](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md) +[nodes and sandbox backends](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/HOSTED-SANDBOX-MANAGER.md) reference for operators, the -[protocol coverage and native differences](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md), and -[maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/maintainers.md) for building distributions +[protocol coverage and native differences](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/README.md), and +[maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/maintainers.md) for building distributions and running Core without the installer. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/getting-started/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/getting-started/README.md) diff --git a/apps/docs/content/docs/install.mdx b/apps/docs/content/docs/install.mdx index c52a6237f..cb8d2e98a 100644 --- a/apps/docs/content/docs/install.mdx +++ b/apps/docs/content/docs/install.mdx @@ -140,7 +140,7 @@ A deployment runs its sandboxes on exactly one backend: | `none` | Nothing yet | Choose the backend on the **Nodes** page in Web | The Standard sizes come from the bundle's copy of Web's -[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/apps/web/src/features/sandbox/standard-sizes.json). The +[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/apps/web/src/features/sandbox/standard-sizes.json). The installer saves the choice through the same administrator API that Web uses once the services are healthy. If Core refuses it, for example because E2B rejects the key, the installer prints Core's message and exits with an error; the services keep @@ -164,7 +164,7 @@ it installs anything: ``` Prepare the template build with the -[E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/deploy/e2b/README.md). To change the backend or +[E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/deploy/e2b/README.md). To change the backend or the sandbox size later, use the Nodes page in Web; see [Nodes](/hosted-providers#change-the-sandbox-backend-or-size). @@ -531,4 +531,4 @@ missing images, restores a missing `oac` command, copies node files newly placed `--install-dir`. It refuses a bundle from another release; see [installation version policy](/troubleshooting#installation-version-policy). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/getting-started/install.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/getting-started/install.md) diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 73b131510..1b46bcbcd 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -17,6 +17,7 @@ "environments-and-files", "hosted-providers", "self-hosted-execution", + "self-hosted-native", "---Administration---", "console", "admin-api", diff --git a/apps/docs/content/docs/observability.mdx b/apps/docs/content/docs/observability.mdx index 658c0b304..c8bebe347 100644 --- a/apps/docs/content/docs/observability.mdx +++ b/apps/docs/content/docs/observability.mdx @@ -7,7 +7,7 @@ Status: Phase 2 and initial Core Web consumption implemented. The current-snapsh `packages/agents-client` projection, and generated `core.openapi.yaml` contract are implemented and consumed by the Dashboard through complete Session/observation identity joins. Durable history uses the separate optional -[Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime-history-api.md); lifecycle controls remain outside +[Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/runtime-history-api.md); lifecycle controls remain outside this phase. These are administrator reads under `/core/v1`, authenticated by the Core key, @@ -92,7 +92,7 @@ Authorization: Bearer ... ``` This returns the same object shape as a list item's `observation`, without the -administrator list's `disk` (see the [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md)). It never starts a Turn, creates +administrator list's `disk` (see the [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md)). It never starts a Turn, creates an Environment, provisions compute, renews a lease, or changes lifecycle state. A valid `environment:none` Session returns `200` with status `unsupported`; the @@ -193,7 +193,7 @@ Use the existing Agents API error envelope. | 400 | `invalid_request_error` / `invalid_request_error` | List: a repeated supported query key, or an empty or invalid limit or order, with the shared Beta list messages. Unknown list query keys are ignored. | | 400 | `invalid_request_error` / `unsupported_parameter` | Single-Session retrieval with any query parameter. | | 401 | `invalid_request_error` / `invalid_admin_key` | Missing or invalid Core key. | -| 404 | `not_found_error` / `not_found_error` | Missing, malformed or foreign Session/cursor, indistinguishably, as for the [Session list cursor](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/list-query-semantics.md#list-cursor-errors--september-23-2026). | +| 404 | `not_found_error` / `not_found_error` | Missing, malformed or foreign Session/cursor, indistinguishably, as for the [Session list cursor](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/list-query-semantics.md#list-cursor-errors--september-23-2026). | | 500 | `server_error` / `internal_error` | Integrity, ownership, or invalid provider evidence. | | 503 | `server_error` / `execution_unavailable` | Required Runtime observation service is not configured, or list collection exceeded its request budget. | @@ -273,10 +273,10 @@ deletion makes the candidate incomplete and prevents publication. - Token usage: use existing Session/Turn Usage. - Billing and cost: product/backend concern. - Historical series in these routes: the optional capability uses the separate - [Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime-history-api.md). + [Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/runtime-history-api.md). - Container logs and command output. - Provider credentials or native configuration. - Start, stop, pause, resume, restart, renew, or delete operations. - Idle classification and automatic shutdown. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime-observability-api.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/runtime-observability-api.md) diff --git a/apps/docs/content/docs/public-api.mdx b/apps/docs/content/docs/public-api.mdx index 964db7ded..a1e90499c 100644 --- a/apps/docs/content/docs/public-api.mdx +++ b/apps/docs/content/docs/public-api.mdx @@ -9,8 +9,8 @@ no credential works in another namespace. | Namespace | Caller | Credential | Contents | Reference | | --- | --- | --- | --- | --- | | `/v1` | Applications (business systems, SDKs) | Project API key | Exactly the pinned official Agents API routes. Core-only fields live only in `x_agents_core` (`harness`, `model_provider`) | [Public API](/sessions) | -| `/core/v1` | Core Web's server and operator scripts | [Core key](/troubleshooting#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes, deployment model providers | [Core API](#core-api), [Web API](/admin-api), [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/core.openapi.yaml) | -| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: node enrollment tokens and executor credentials issued through `/core/v1`, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine connections only: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime.openapi.yaml) | +| `/core/v1` | Core Web's server and operator scripts | [Core key](/troubleshooting#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes, deployment model providers | [Core API](#core-api), [Web API](/admin-api), [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/core.openapi.yaml) | +| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: node enrollment tokens and executor credentials issued through `/core/v1`, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine connections only: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/runtime.openapi.yaml) | A Project API key gets 401 on `/core/v1` and `/api/v1`; the Core key gets 401 on `/v1` and `/api/v1`. Projects own assets. Multiple equally privileged keys share @@ -34,7 +34,7 @@ with the Core key on Core's loopback port, not through the public entry. Applications call every `/v1` route with a Project API key. The routes are exactly the 58 method and path pairs in -[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/upstream-routes.json); the +[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/upstream-routes.json); the [Public API guide](/sessions) groups them by resource. `/v1` has no Core-only route. @@ -45,18 +45,18 @@ with the Core key. | Routes | Contents | Contract | | --- | --- | --- | -| `projects`, `projects/{project_id}[/archive]`, `projects/{project_id}/keys[/{key_id}]` | Projects and their API keys | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/{agents,environment-templates,skills,files,vaults,sessions}/**` | Resource reads and deletion, Session history, artifacts and archive | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/sessions/{session_id}/execution-configuration` | Committed harness and model selection | [Execution configuration](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/execution-configuration.md) | -| `projects/{project_id}/sessions/{session_id}/diagnostics`, `projects/{project_id}/sessions/{session_id}/turns/{turn_id}/diagnostics` | Root failure categories and Item receipt timing | [Session diagnostics](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/session-diagnostics.md) | +| `projects`, `projects/{project_id}[/archive]`, `projects/{project_id}/keys[/{key_id}]` | Projects and their API keys | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/{agents,environment-templates,skills,files,vaults,sessions}/**` | Resource reads and deletion, Session history, artifacts and archive | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/sessions/{session_id}/execution-configuration` | Committed harness and model selection | [Execution configuration](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/execution-configuration.md) | +| `projects/{project_id}/sessions/{session_id}/diagnostics`, `projects/{project_id}/sessions/{session_id}/turns/{turn_id}/diagnostics` | Root failure categories and Item receipt timing | [Session diagnostics](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/session-diagnostics.md) | | `projects/{project_id}/sessions/{session_id}/runtime-observation`, `sandbox/runtime-observations` | Current Runtime observations | [Runtime observations](/observability) | -| `projects/{project_id}/sessions/{session_id}/runtime-history` | Stored Runtime history | [Runtime history](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime-history-api.md) | -| `projects/{project_id}/{resource-owners,write-operations}`, `audit-log`, `summary` | Provenance, write history, administrator audit and usage summary | [Write audit](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/write-audit.md), [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-executor-credentials.md) | -| `installation` | Public URL, API base URL, source commit, the installer's process settings and what is bound to the public URL; available before any deployment | [Installation](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/installation.md) | -| `metrics` | Core's own process metrics | [Core metrics](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/core-metrics.md) | -| `sandbox/deployment[/reset]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/sandbox-deployment.md), [node operations](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/node-host-history.md) | -| `harnesses`, `harnesses/{harness}/model-provider` | Supported harnesses and each harness's deployment default model provider (write-only key) | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/model-execution.md#deployment-defaults) | +| `projects/{project_id}/sessions/{session_id}/runtime-history` | Stored Runtime history | [Runtime history](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/runtime-history-api.md) | +| `projects/{project_id}/{resource-owners,write-operations}`, `audit-log`, `summary` | Provenance, write history, administrator audit and usage summary | [Write audit](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/write-audit.md), [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-executor-credentials.md) | +| `installation` | Public URL, API base URL, source commit, the installer's process settings and what is bound to the public URL; available before any deployment | [Installation](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/installation.md) | +| `metrics` | Core's own process metrics | [Core metrics](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/core-metrics.md) | +| `sandbox/deployment[/reset]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/sandbox-deployment.md), [node operations](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/node-host-history.md) | +| `harnesses`, `harnesses/{harness}/model-provider` | Supported harnesses and each harness's deployment default model provider (write-only key) | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/model-execution.md#deployment-defaults) | ## Machine connection API @@ -65,42 +65,42 @@ the Core key or a Project API key. | Routes | Caller | Credential | Contract | | --- | --- | --- | --- | -| `POST sandbox-node/enroll` | Node installer | One-use enrollment token from `POST /core/v1/sandbox/enrollment-tokens` | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime.openapi.yaml) | -| `GET sandbox-node/configuration` | Node installer and node | Enrollment token, or node credential with `X-OAC-Node-ID` | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/sandbox-deployment.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime.openapi.yaml) | -| `GET sandbox-node/identity`, WebSocket `GET sandbox-node/connect` | Node | Node credential registered at enrollment | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host) | -| `POST agent-daemon/enroll`, `GET agent-daemon/connection` | Self-hosted executor and its installer | Executor credential from `/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-executor-credentials.md) | -| WebSocket `GET agent-daemon/ws`, `POST agent-daemon/bootstrap`, `GET agent-daemon/device-status` | Runtime daemons | Daemon credential: Core writes one into each hosted sandbox it prepares; a self-hosted executor uses its executor credential | [Runtime enrollment](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/README.md#user-managed-runtime-enrollment) | +| `POST sandbox-node/enroll` | Node installer | One-use enrollment token from `POST /core/v1/sandbox/enrollment-tokens` | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/runtime.openapi.yaml) | +| `GET sandbox-node/configuration` | Node installer and node | Enrollment token, or node credential with `X-OAC-Node-ID` | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/sandbox-deployment.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/runtime.openapi.yaml) | +| `GET sandbox-node/identity`, WebSocket `GET sandbox-node/connect` | Node | Node credential registered at enrollment | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host) | +| `POST agent-daemon/enroll`, `GET agent-daemon/connection` | Self-hosted executor and its installer | Executor credential from `/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-executor-credentials.md) | +| WebSocket `GET agent-daemon/ws`, `POST agent-daemon/bootstrap`, `GET agent-daemon/device-status` | Runtime daemons | Daemon credential: Core writes one into each hosted sandbox it prepares; a self-hosted executor uses its executor credential | [Runtime enrollment](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/README.md#user-managed-runtime-enrollment) | ## Contract sources -- [Pinned upstream baseline](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/upstream.json): OpenAI +- [Pinned upstream baseline](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/upstream.json): OpenAI Python SDK 3.13.0, exact upstream commit and `agents=v1`. -- [Pinned routes](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/upstream-routes.json) and - [fields](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/upstream-fields.json): extracted from the +- [Pinned routes](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/upstream-routes.json) and + [fields](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/upstream-fields.json): extracted from the pinned SDK by `scripts/extract-agents-api-upstream.py`. Contract tests require the public OpenAPI to have exactly these routes and to keep every other field inside `x_agents_core`. -- [Public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/openapi.yaml): public schema snapshot; - combine it with the fixed SDK and [operation evidence](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/operation-evidence.md). -- [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/core.openapi.yaml): generated `/core/v1` +- [Public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/openapi.yaml): public schema snapshot; + combine it with the fixed SDK and [operation evidence](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/operation-evidence.md). +- [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/core.openapi.yaml): generated `/core/v1` routes, all authenticated by the Core key. -- [Machine connection OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime.openapi.yaml): - generated `/api/v1` sandbox node routes. The node WebSocket is described in the [generation protocol](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/node-generation-protocol.md); the private +- [Machine connection OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/runtime.openapi.yaml): + generated `/api/v1` sandbox node routes. The node WebSocket is described in the [generation protocol](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/node-generation-protocol.md); the private daemon transport is described in the Runtime credential guide. -- [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md): Project/key +- [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md): Project/key lifecycle, resources, explicit hosted Session archive, summary, errors/deletion preconditions, audit and historical copy provenance. - [Web integration](/admin-api): browser, console and Core boundaries and frontend handoff. -- [Design rules](/concepts) and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/CONTRIBUTING.md): +- [Design rules](/concepts) and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/CONTRIBUTING.md): ownership, security and change requirements. Generated schemas do not establish complete compatibility or real execution -support. The [coverage record](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md) identifies +support. The [coverage record](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/README.md) identifies qualified workflows, native differences and unresolved behavior. Update the relevant contract and this index when adding or moving an API surface. -Core administration failures use the [Core error envelope](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/core-errors.md), +Core administration failures use the [Core error envelope](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/core-errors.md), including typed optional safe details and distinct console proxy rejection codes. The public and machine error contracts remain unchanged. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/api/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/api/README.md) diff --git a/apps/docs/content/docs/quickstart.mdx b/apps/docs/content/docs/quickstart.mdx index 49a88bfca..e8f66cebe 100644 --- a/apps/docs/content/docs/quickstart.mdx +++ b/apps/docs/content/docs/quickstart.mdx @@ -101,8 +101,8 @@ The provider's protocol must match the harness: | Claude Code (`claude_sdk`) | `anthropic` | The provider's exact model ID | | MiniMax Code (`mcode`) | `anthropic` | `context_window` and `max_output_tokens` | -See [harness selection](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/harness-selection.md) and -[model execution](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/model-execution.md) for the full contract. +See [harness selection](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/harness-selection.md) and +[model execution](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/model-execution.md) for the full contract. Selecting a harness doesn't make an unsupported model or operation work. ## Model providers @@ -142,8 +142,8 @@ not replay history. Don't submit the same work as a new Session when a response lost. Files, Artifacts, cancellation and their current limits are in the -[coverage ledger](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md). MCP credentials use the +[coverage ledger](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/README.md). MCP credentials use the separate Vault API; they are neither Project API keys nor model provider keys. The [API index](/public-api) lists every route. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/getting-started/quickstart.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/getting-started/quickstart.md) diff --git a/apps/docs/content/docs/self-hosted-execution.mdx b/apps/docs/content/docs/self-hosted-execution.mdx index a724057ff..7b587b5cd 100644 --- a/apps/docs/content/docs/self-hosted-execution.mdx +++ b/apps/docs/content/docs/self-hosted-execution.mdx @@ -3,36 +3,28 @@ title: "Self-hosted executors" description: "Connect a user-owned Runtime to its Environment with a restricted executor credential." --- -A `self_hosted` Session runs on a machine the application owns, the executor host, -instead of a node. The application creates the Session through `/v1`; the -administrator issues an executor credential for it in Web; one command on the host -starts a Runtime container that connects to Core. The Runtime is the same one nodes -use: daemon, native harness and workspace. - -An executor credential works for one Session's environment only. It can't call the -Agent API, the Core API or node enrollment. - -## Before you start - -- **Core has an HTTPS public URL.** The Session's `remote_url` is - `wss:///api/v1/agent-daemon/ws`, and the installer connects only over - `wss://`. Web shows no command until Core has a reachable public URL. -- **Web holds the installer and the Runtime files.** The command downloads the - installer, the Runtime launcher and the image from Core's public URL, which the - reverse proxy sends to Web (in a [split deployment](/install#split-deployment), to - the Web host); install from the offline bundle. A console without the self-hosted - installer shows no **Connect a host** section at all. -- **The host** runs Linux amd64 with Python 3.9+, `curl`, `sha256sum` and the Docker - CLI, with Docker usable through `/var/run/docker.sock` by the non-root user that runs - the installer; the installer refuses root. It reaches the public URL over HTTPS. -- **The Session carries its model provider**, in the request or saved on its Agent. - Default models never apply to self-hosted Sessions, and creating one without a - provider fails with 400 `model_provider_required`. +A `self_hosted` Session runs on a machine the application owns. The application +creates the Session through `/v1`; the administrator issues an executor credential +in Web or through `/core/v1`; the host runs `oac-daemon` with that credential. +Linux, macOS and Windows use the same Runtime protocol. Core-managed Providers +remain Linux-only. + +The daemon runs with its launching user's permissions and adds no inner +filesystem, permission or network sandbox. Its tools may access that user's files +and Runtime credentials. Managed isolation belongs to the outer Environment; +self-hosted operators choose any outer isolation they need. See the +[native installation guide](/self-hosted-native) for prerequisites, commands +and supported platforms. + +An executor credential works for one Environment only. It cannot call the Agent +API, Core API or node enrollment. Default model providers never apply to +self-hosted Sessions: the request or Agent must carry its own model provider. +Otherwise creation fails with 400 `model_provider_required`. ## Connect a host -1. **The application creates the Session**, with its Project API key and its own model - provider: +1. Create an existing workspace on the executor host. The application creates a + Session using that host's absolute path and its own Project API key: ```python import os @@ -40,7 +32,10 @@ Agent API, the Core API or node enrollment. client = OpenAI() # reads OPENAI_BASE_URL and OPENAI_API_KEY session = client.beta.agents.sessions.create( - environment={"type": "self_hosted", "workspace_directory": "/workspace"}, + environment={ + "type": "self_hosted", + "workspace_directory": os.environ["EXECUTOR_WORKSPACE"], + }, extra_body={ "agent": {"model": os.environ["MODEL_NAME"], "x_agents_core": {"harness": "codex"}}, "x_agents_core": {"model_provider": { @@ -53,92 +48,60 @@ Agent API, the Core API or node enrollment. print(session.id, session.environment.id, session.environment.remote_url) ``` -2. **In Web**, open **Session log**, then the Session's page. In its - **Executor credentials** section, **Connect a host** shows a command in a - **Terminal** block; copy it and run it on the executor host: - - ```sh - (umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT - curl -fsS --max-time 30 --max-filesize 1048576 'https://core.example/node-install/self-hosted-install.pyz' -o "$d/install.pyz" && - printf '%s %s\n' '' "$d/install.pyz" | sha256sum -c --status && - python3 "$d/install.pyz" --source-url 'https://core.example' --environment-id '' --remote 'wss://core.example/api/v1/agent-daemon/ws') - ``` - - The command contains no secret. It downloads the installer from your console and - checks its SHA-256 before running it. -3. **Issue the credential.** Select **Issue credential**. The **Executor credential** - dialog shows it once: choose **Copy credential**. -4. **Paste it at the installer's hidden prompt** and press Enter. The prompt accepts the - compact or the pretty-printed credential and never echoes it. Then choose **Done** in - the dialog; after that the credential can't be shown again. Closing the dialog with - × keeps the credential on the page until you choose **Done**. - -The installer downloads the Runtime from the console and checks it, starts a container -named `oac-selfhost-<32 hex digits>`, and waits until Core confirms that the -Environment is connected. The Session then runs its Turns there. Rerunning the same -command resumes the same installation. It refuses, with "This installation belongs to -another Environment or distribution", once Core runs another release or its public URL -changed: the host's installation is tied to both. Moving an executor to a new release -or address isn't supported yet. A fresh installation would start a new container -without the old workspace and native history, so the Session can't continue there: -stop the old container, create a new self-hosted Session and connect a host for it. - -For automation without a terminal, choose **Download credential file**, which saves -`executor-credential-.json`, make it private -(`chmod 600`), and add `--credential-file /absolute/path/to/that-file.json` to the -installer line. The path must not go through a symbolic link. - -The host keeps its state in `~/.oac/self-hosted//` -(`--install-dir` chooses another absolute directory). Keep it with the container's -volumes: they hold the credential, workspace and native history. The Runtime's log is -`docker logs `. A connected Environment proves the connection, not that the -model works; send the Session a Turn to test execution. - -Core delivers the Session's model provider only over this Environment's executor -connection. The host keeps it in the Runtime's harness home, where the model's tools -and the Files API can't read it but the host's owner can. +2. In Web, open **Session log**, then the Session's **Executor credentials** + section. Choose **Issue credential**, then **Download credential file**. Core + returns the credential only once; retain it privately before choosing **Done**. +3. On the host, run `oac-daemon install` with the returned `remote_url`, Environment + ID, matching workspace and absolute credential-file path, then `oac-daemon start`. + The [native guide](/self-hosted-native#install-and-start) has Linux/macOS and + PowerShell examples. No Docker installation is required for this native path. + +The console's existing **Connect a host** download command targets the Linux +container installer, not the native installation command. Use the native guide for +this workflow; downloading an executor credential is independent of the console's +installer assets. Core must be reachable from the host: `wss://` is required +outside loopback, while a local Core can use a loopback `ws://` URL. + +A connected Environment proves only the machine connection. Send a Turn to check +the selected harness and model. Core supplies the Session's model provider over +this Environment's authenticated executor connection. The host's user and tools +running with that user's permissions can access locally stored Runtime data. ## Local capability directories -A self-hosted Session may supply `capability_directories` alongside its -`workspace_directory`, for example: +A self-hosted Session may supply `capability_directories` alongside its workspace: ```python environment = { "type": "self_hosted", - "workspace_directory": "/workspace", - "capability_directories": ["/workspace/capabilities"], + "workspace_directory": os.environ["EXECUTOR_WORKSPACE"], + "capability_directories": [os.environ["EXECUTOR_CAPABILITIES"]], } ``` -The API accepts portable absolute Unix, Windows drive and UNC source paths without -checking Core's filesystem. The current Linux Runtime requires canonical local Unix -paths and applies its own access checks; this does not add Windows support. -These paths refer to directories visible inside that Runtime. Populate them before -first execution. `/workspace` maps to its bound workspace; an operator-prepared -Runtime may use its exact canonical absolute workspace instead. Directory aliases, -private credentials, native history and protected initialization trees are refused. -This does not mount arbitrary host paths into the container or add a new installer. -Self-hosted public input accepts local directories, not managed Skill/Plugin archives -or hosted Templates. - -The current implementation uses the packaged Linux helpers and protected -`installed.json`, by default under `/environment/initialization/capabilities`. -Operators may freeze another root with `OAC_RUNTIME_CAPABILITY_DIRECTORY`; it must -stay separate from the workspace and private Runtime state and is not a public API -setting. Runtime snapshots sources -before native execution and supplies the resulting Skill and MCP inventory through -the same parser as managed bundles. Reconnect reuses installed contents even after -source edits; a new Session captures its own snapshot. A missing or inconsistent -snapshot fails preparation without silently reinstalling. Local discovery does not -create entries in the public API-managed installation arrays. +Core accepts absolute Unix, Windows drive and UNC source paths without checking +its own filesystem. Runtime validates them using the executor host's path syntax. +Populate these directories before first execution. They are ordinary paths visible +to that process; naming a directory does not mount it or create a sandbox. +Self-hosted public input accepts local directories, not managed Skill/Plugin +archives or hosted Templates. + +Runtime snapshots sources before native execution and uses the same parser and +`installed.json` format as managed bundles to supply Skills and Plugin MCP. +The native installer defaults the snapshot destination to `capabilities` under +`OAC_RUNTIME_HOME`; `--capability-directory` selects another local destination. +It is an operator setting, not a public API write destination. Reconnect reuses +installed contents even after source edits; a new Session captures its own +configuration. A missing or inconsistent snapshot fails preparation rather than +silently reinstalling. Local discovery does not create entries in the public +API-managed installation arrays. Snapshot file modes do not isolate the snapshot +from tools running as the same user. Closing an executor, cancelling a Turn or losing its connection preserves the -snapshot and workspace. The compute owner remains responsible for explicit resource -cleanup. This source-level capability behavior requires its own real acceptance; -[earlier qualification](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/user-managed-runtime-v1.md) covered -only its recorded inputs and binaries, not local directory preparation or additional -platforms. +snapshot and workspace. The compute owner remains responsible for explicit +cleanup. [Historical qualification](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/user-managed-runtime-v1.md) +records only its stated inputs and binaries; it does not qualify the current native +platforms or local capability preparation. ## Rotate or revoke @@ -147,25 +110,16 @@ platforms. | **Rotate** | The credential gets a new secret; the old secret stops working at once. Rotating a revoked credential restores it | | **Revoke** | The credential stops working at once | -After either, the Runtime disconnects and stops retrying: its log shows one message -that names the fix, and the container keeps running without a restart loop. To -reconnect the host: - -1. **Rotate** the same credential in Web and copy the new secret. -2. Rerun the same install command on the host and paste it. +To reconnect a native installation, stop it with `oac-daemon stop`, rotate the same +credential, replace the JSON at its configured credential-file path, and run +`oac-daemon start`. Keep the same `OAC_RUNTIME_HOME` for every command. Issuing a +new credential does not reconnect an Environment already bound to its first +credential; rotate that credential instead. Do not run `install` again over the +existing installation. -The installer puts the new credential into the same container, which reconnects with -its workspace and history. Issuing a *new* credential doesn't reconnect an Environment -that has already connected: it stays bound to its first credential, and the installer -says to rotate that one. - -To remove the Runtime instead, stop its container: -`docker --host unix:///var/run/docker.sock stop `. Deleting the Session does -not remove containers or volumes on the executor host. - -In an archived project, Web shows "This project is archived, so executor credentials -can't be issued or rotated." Only **Revoke** remains; a host that already holds a -credential keeps it when you rerun the command. +Stopping the daemon keeps its workspace and native history. Deleting a Session +does not remove host files. In an archived Project, credentials cannot be issued +or rotated; revocation remains available. ## Without Web @@ -185,20 +139,15 @@ key_id=$(python3 -c 'import uuid; print(uuid.uuid4())'); echo "credential ID: $k Rotate with `{"key_id":"…","rotate":true}` on the same route; revoke with `DELETE …/executor-credentials/`. After an uncertain response, list the credentials with `GET` before trying again. The -[credential contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-executor-credentials.md) +[credential contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-executor-credentials.md) has every rule and error. - ## Historical executor installations -The current installer does not upgrade or adopt an executor from an older release. -It refuses when the same Environment retains state under the old -`~/.parsar/self-hosted` directory, including when another `--install-dir` is supplied. -Preserve the old container, files and native history; provision a fresh self-hosted -Session and connect its executor separately. There is no supported cross-version -continuation or automatic data deletion. - -Credential replacement for executors created by the current installer updates and -restarts the same owned container. It does not authorize adopting an older executor. +The native installer supports its current version only. It does not adopt or +upgrade an older daemon or container installation. Preserve old files, containers +and native history; use a fresh Runtime home and Session when moving versions. +Historical container-installer instructions and qualification records describe +those earlier artifacts, not acceptance of the current native daemon. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/getting-started/self-hosted.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/getting-started/self-hosted.md) diff --git a/apps/docs/content/docs/self-hosted-execution.zh.mdx b/apps/docs/content/docs/self-hosted-execution.zh.mdx index 27012a4b9..86f1481c1 100644 --- a/apps/docs/content/docs/self-hosted-execution.zh.mdx +++ b/apps/docs/content/docs/self-hosted-execution.zh.mdx @@ -3,7 +3,7 @@ title: "Self-hosted executors · 中文导读" description: "当前版本的中文导读;完整操作步骤见英文正文。" --- -self_hosted 执行器由用户拥有,Core 不管理它的容器。通过 Web 为对应 Environment 签发 executor 凭据,安装器通过隐藏输入或私有文件读取凭据;不要把凭据放入命令行。 +self_hosted 执行器由用户拥有,通过 Web 为对应 Environment 签发凭据。Linux、macOS 和 Windows 使用同一 daemon 协议;daemon 以启动账户权限运行,隔离由外层环境负责。凭据通过私有文件提供,不放入命令行。 此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/self-hosted-execution)。 diff --git a/apps/docs/content/docs/self-hosted-native.mdx b/apps/docs/content/docs/self-hosted-native.mdx new file mode 100644 index 000000000..b0e1c908f --- /dev/null +++ b/apps/docs/content/docs/self-hosted-native.mdx @@ -0,0 +1,159 @@ +--- +title: "Native daemon installation" +description: "Install and operate a self-hosted daemon on Linux, macOS or Windows." +--- + +A self-hosted Runtime runs the same `oac-daemon` on Linux, macOS or Windows. +Core uses the same Environment protocol on all three platforms; native harness +adapters handle harness differences. Core-managed Providers remain Linux-only. + +The daemon runs tools with the permissions of the user who starts it. It adds no +filesystem, permission or network sandbox. Tools can access whatever that user +can access, including Runtime state and credentials. Authentication, ordinary +credential storage, atomic state writes and process cleanup do not isolate tools +from the host. When isolation is needed, run the Runtime inside an outer sandbox; +Core creates that outer boundary for managed Docker/E2B/microsandbox Environments. +The daemon does not enforce `disabled` or `restricted` network modes; combinations +without corresponding outer enforcement are unsupported. + +## Supported platforms + +| Platform | Codex | Claude Code | MiniMax Code | +| --- | --- | --- | --- | +| Linux | Supported | Supported | Supported | +| macOS | Supported | Supported | Supported | +| Windows | Supported | Supported | Not supported by the current adapter | + +Native CI passing is the platform support criterion. Windows has not yet had +manual machine acceptance. Managed Providers support Linux only. + +Use a current-version daemon binary for the host's OS and architecture. The daemon +installer saves local configuration; native harnesses and their dependencies must +already be available to its process. Claude on Windows requires Git Bash. + +Create a `self_hosted` Session with its model provider and an existing absolute +`workspace_directory` on the executor host. Obtain its `environment.id` and +`environment.remote_url`, then have the Core administrator issue an +[executor credential](/self-hosted-execution#without-web). Save the returned +JSON as a private local file. It contains `key_id`, `environment_id` and +`executor_token`; do not put the token in command arguments. + +Use the exact `remote_url` from Core. It uses `wss://` outside loopback; `ws://` is +accepted only for a loopback Core. The Environment ID must be a canonical UUID. +Paths must be clean absolute paths in the host's syntax, and the workspace must +already exist. The install workspace must match the Session's configured workspace. + +## Install and start + +Choose a separate `OAC_RUNTIME_HOME` for each installation. It contains local +configuration, connection state, logs and the default capability snapshot. Keep +this same value for install, start, status, logs and stop. + +On Linux or macOS: + +```sh +export OAC_RUNTIME_HOME="$HOME/.oac/runtime-example" +mkdir -p "$HOME/agent-workspace" +chmod 600 "$HOME/executor-credential.json" +oac-daemon install \ + --remote 'wss://core.example/api/v1/agent-daemon/ws' \ + --environment-id '11111111-2222-4333-8444-555555555555' \ + --workspace "$HOME/agent-workspace" \ + --credential-file "$HOME/executor-credential.json" +oac-daemon start +``` + +On Windows, use native absolute paths in PowerShell: + +```powershell +$env:OAC_RUNTIME_HOME = "$HOME\.oac\runtime-example" +New-Item -ItemType Directory -Force "$HOME\agent-workspace" | Out-Null +oac-daemon.exe install ` + --remote 'wss://core.example/api/v1/agent-daemon/ws' ` + --environment-id '11111111-2222-4333-8444-555555555555' ` + --workspace "$HOME\agent-workspace" ` + --credential-file "$HOME\executor-credential.json" +oac-daemon.exe start +``` + +Replace the example URL and UUID with the Session's actual values. On Windows, keep +the credential file under the intended user's normal private storage permissions. + + +Optional install flags: + +| Flag | Meaning | +| --- | --- | +| `--capability-directory ABS` | Local destination for the installed capability snapshot; defaults to `capabilities` under `OAC_RUNTIME_HOME`. This is an operator setting, not a public API destination | +| `--tool-env-file ABS` | JSON object of string-valued tool environment variables. Use it for explicitly configured tool/MCP variables; it is not a dotenv or shell file | + +For example, a tool environment file can contain `{"EXAMPLE_SETTING":"value"}`. +Treat files containing credentials as secrets. Retain the credential and tool +configuration files at their configured paths; installation records those paths. + +Runtime preparation uses one Go implementation on all platforms. Its local +initialization and package directories default to `initialization` and `packages` +under `OAC_RUNTIME_HOME`. Operators can select absolute paths with +`OAC_RUNTIME_INITIALIZATION_DIRECTORY` and `OAC_RUNTIME_PACKAGE_DIRECTORY`; +managed images set these to `/environment/initialization` and +`/environment/packages`. They change storage layout, not execution permissions. +`OAC_RUNTIME_TOOL_ENV_FILE` selects an explicit tool-variable JSON file; preparation +does not overwrite an existing configuration. + +npm and Python packages install only into the Runtime package directory using +npm's prefix and pip's target options. Node/npm and Python/pip must already be +installed. Setup requires Bash; on Windows it requires Git Bash, found from Git's +installation or `CLAUDE_CODE_GIT_BASH_PATH`. Missing dependencies fail preparation; +Runtime does not substitute PowerShell, cmd.exe or WSL for Bash setup. + + +`oac-daemon start --foreground` stays attached to the terminal instead of starting +in the background. An operator can use their own service manager to run it; the +native installer does not configure one. + +## Operate the same installation + +With the same `OAC_RUNTIME_HOME` still set: + +```sh +oac-daemon status +oac-daemon logs -n 100 +oac-daemon logs -f +oac-daemon stop +``` + +Use `oac-daemon.exe` for these commands on Windows. A connected Environment proves +the machine connection, not model execution: send a Turn to validate the selected +native harness and model. Stopping the daemon preserves the workspace, native +history and capability snapshot; deleting a Session does not remove host files. + +After credential rotation, stop the daemon, replace the JSON at the configured +credential path with the newly issued credential for the same `key_id`, then start +it again. Do not rerun `install` over an existing installation. Revocation prevents +the old credential from reconnecting. + +## Capabilities and versions + +`capability_directories` supplies local source directories when the Session is +created. Runtime snapshots them before execution and resolves Skills and Plugin +MCP from the common `installed.json` format used for managed bundles. Paths use the +executor host's native syntax, not Core's filesystem. Reconnect reuses the snapshot; +source edits do not silently replace it. A new Session captures new configuration. +See [local capability directories](/self-hosted-execution#local-capability-directories). +Read-only snapshot modes are an integrity hint, not protection from the same user. + +Installation is current-version only. It refuses an existing installation, and +`start` refuses configuration from another daemon version. There is no in-place +upgrade, adoption of a historical container installation, or cross-version native +history migration. Preserve an old installation's files and use a fresh Runtime +home and Session when moving versions. + +System dependencies must be present before Runtime execution: install them while +building the managed image/template, or prepare them as the self-hosted user. +Runtime does not run apt, request sudo or elevate the daemon's privileges. +`packages.system` is explicitly rejected on Session and Template input, including +an empty or null value; it is never ignored. A missing preinstalled dependency +fails the operation that requires it. npm/Python package installation and setup retain their supported +initialization flow using the launching user's existing permissions. + +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/self-hosted-native.md) diff --git a/apps/docs/content/docs/self-hosted-native.zh.mdx b/apps/docs/content/docs/self-hosted-native.zh.mdx new file mode 100644 index 000000000..1eb15cfee --- /dev/null +++ b/apps/docs/content/docs/self-hosted-native.zh.mdx @@ -0,0 +1,10 @@ +--- +title: "Native daemon installation · 中文导读" +description: "当前版本的中文导读;完整操作步骤见英文正文。" +--- + +使用当前平台的 oac-daemon 安装、启动和停止自托管 Runtime。系统依赖需提前安装,运行时不提权。Windows 已通过原生 CI 自动测试,尚未人工实机验收。 + +此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/self-hosted-native)。 + +[安装](/install) · [配置](/configure) · [API 与凭据](/public-api) · [节点](/hosted-providers) · [应用示例](/quickstart) diff --git a/apps/docs/content/docs/sessions.mdx b/apps/docs/content/docs/sessions.mdx index 9b4751d65..506083997 100644 --- a/apps/docs/content/docs/sessions.mdx +++ b/apps/docs/content/docs/sessions.mdx @@ -34,8 +34,8 @@ leaves the others valid; archiving the Project revokes all its keys. The optiona ## Public resources Paths are relative to `/v1`. Detailed fields, union types and response shapes are -in the [public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/openapi.yaml), fixed SDK source -and linked semantic records. The [resource inventory](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md#upstream-resource-inventory) +in the [public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/openapi.yaml), fixed SDK source +and linked semantic records. The [resource inventory](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/README.md#upstream-resource-inventory) lists each operation and its coverage. | Resource | Route family | Semantics | @@ -44,11 +44,11 @@ lists each operation and its coverage. | Sessions and Turns | `/agents/sessions`, nested `/turns` | Admission, metadata, state and recovery queries | | Items and events | Session `/items`, `/events` | Persisted history and live SSE | | Artifacts | Session `/artifacts` | Captured output metadata, immutable content and deletion | -| Subagents | Session `/subagents`, nested `/turns` and `/items` | Scoped child execution reads; [native limits](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/subagents.md) | -| Environments and workspace Files | `/agents/environments`, nested `/files` | [Environment state](/environments-and-files) and [workspace access](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-files.md) | -| Environment Templates | `/agents/environments/templates` | [Reusable initialization](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-templates.md) | -| Source Files | `/files` | [Uploaded content resources](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/source-files.md), distinct from live workspace files | -| Skills and versions | `/skills`, nested `/versions` | [Bundles and versions](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/file-resource-semantics.md) | +| Subagents | Session `/subagents`, nested `/turns` and `/items` | Scoped child execution reads; [native limits](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/subagents.md) | +| Environments and workspace Files | `/agents/environments`, nested `/files` | [Environment state](/environments-and-files) and [workspace access](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-files.md) | +| Environment Templates | `/agents/environments/templates` | [Reusable initialization](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-templates.md) | +| Source Files | `/files` | [Uploaded content resources](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/source-files.md), distinct from live workspace files | +| Skills and versions | `/skills`, nested `/versions` | [Bundles and versions](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/file-resource-semantics.md) | | Vaults and Credentials | `/vaults`, nested `/credentials` | [Scoped write-only credential storage](/environments-and-files) | SSE is live, not a historical replay service. After a disconnect, query the Session, @@ -58,14 +58,14 @@ native execution. Do not automatically replay uncertain tool or file effects. ## Core extension fields The `/v1` route set is exactly the pinned SDK's operations, listed in -[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/upstream-routes.json); Core adds +[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/upstream-routes.json); Core adds no route. It adds fields only inside `x_agents_core`, because it runs several harnesses and accepts custom model access: | Field | Where | Reference | | --- | --- | --- | -| `x_agents_core.harness` | Saved Agent create, update and read; the inline Session `agent`; the Session's effective `agent` | [Harness selection](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/harness-selection.md) | -| `x_agents_core.model_provider` | Saved Agent create, update and read; Session creation. It takes `protocol`, `base_url`, optional `context_window` and `max_output_tokens`, and a write-only `api_key`; reads return `api_key_configured` instead of the key | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/model-execution.md) | +| `x_agents_core.harness` | Saved Agent create, update and read; the inline Session `agent`; the Session's effective `agent` | [Harness selection](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/harness-selection.md) | +| `x_agents_core.model_provider` | Saved Agent create, update and read; Session creation. It takes `protocol`, `base_url`, optional `context_window` and `max_output_tokens`, and a write-only `api_key`; reads return `api_key_configured` instead of the key | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/model-execution.md) | Any other member of `x_agents_core` is rejected with 400. Deployment, placement, credential issuance and operational reads are not part of `/v1`; they belong to @@ -78,7 +78,7 @@ never to `self_hosted`; `none` Sessions accept only the deployment default. `openai_hosted` and `self_hosted` Sessions must end up with one: otherwise creation returns 400 with code `model_provider_required` and param `x_agents_core.model_provider`, and nothing is written. See -[model execution](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/model-execution.md). +[model execution](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/model-execution.md). An application reads a Session's model from `agent.model` and an explicitly selected harness from `agent.x_agents_core.harness`; a Session on the deployment @@ -88,10 +88,13 @@ read. `openai_hosted` keeps the official wire name and means Core-managed compute here. The deployment chooses E2B or its own Docker/microsandbox nodes. A public `self_hosted` Environment uses caller-owned compute and the same colocated -Runtime. For a `self_hosted` Session, the deployment operator issues the +Runtime on Linux, macOS or Windows; managed Providers remain Linux-only. Native +Runtime execution uses the launching user's permissions without an inner sandbox. +See [native installation and validation status](/self-hosted-native). +For a `self_hosted` Session, the deployment operator issues the Environment's executor credential with the Core key; see -[executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-executor-credentials.md). +[executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-executor-credentials.md). Our daemon transport and the supported native harness differences are explicit; they do not imply interoperability with OpenAI's stock executor transport. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/api/public-agent-api.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/api/public-agent-api.md) diff --git a/apps/docs/content/docs/troubleshooting.mdx b/apps/docs/content/docs/troubleshooting.mdx index b1d4fe576..12c9615e6 100644 --- a/apps/docs/content/docs/troubleshooting.mdx +++ b/apps/docs/content/docs/troubleshooting.mdx @@ -123,7 +123,7 @@ refuses. `rotate-core-key` refuses to run on a Web-only installation. ## Projects and API keys Create Projects and issue their keys in Web, on **Projects and keys**, or with the -[Core API](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md) under `/core/v1/projects`. A key's +[Core API](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md) under `/core/v1/projects`. A key's plaintext is shown once; Core stores only its digest. All keys of a Project share its assets and execution principal, and each write records the key that made it. @@ -225,11 +225,14 @@ sign-in sessions live in Web's memory and last 12 hours. microsandbox uses an explicit network policy: public egress, the Core and DNS ports the Runtime needs, and no inbound or private-network access. Private model or MCP endpoints -need an explicit policy change. A Session's own network policy is separate. +need an explicit policy change. The daemon does not enforce Session-level +`disabled` or `restricted` networking. Combinations without the required outer +network enforcement are unsupported; a requested mode is not an isolation guarantee. -The Docker node uses the nested-sandbox Runtime policy. Its service account needs the -host's Docker daemon, and Core has no Docker socket. Install nodes on trusted hosts and -don't share their Docker access with untrusted users. +Docker isolates the Runtime at the outer container boundary. The daemon does not +add an inner filesystem, permission or network sandbox. The node's service account +needs the host's Docker daemon, and Core has no Docker socket. Install nodes on +trusted hosts and do not share their Docker access with untrusted users. A Core restart leaves node services and resident microVMs running. A host reboot, the end of a user manager or the loss of a running microVM is not a Core restart and is not @@ -241,9 +244,9 @@ Core records which key made each public resource write, for the console. Set `core.write_audit_retention` in `config.json` (Go duration, at least `1h`, default `2160h`) to control how long non-creation history is kept; creation ownership is kept for good. Removing keys or resources does not delete these records. See the -[query contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/write-audit.md); administrator mutations +[query contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/write-audit.md); administrator mutations have a separate -[audit log](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md#monitoring-and-audit). Neither +[audit log](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md#monitoring-and-audit). Neither logs bodies or secrets. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/getting-started/operations.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/getting-started/operations.md) diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index b5aa27c92..8d65bb82b 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -1,8 +1,8 @@ { - "revision": "e35d3c9eda2cdb717fd4c3627404d362cc2e4dca", + "revision": "281f243aa785ea25512a6157daa1d44197b9723e", "sources": { - "docs/getting-started/README.md": "4eb3d21e073b25e2e6ffb8800e13b33032104ba44d8d93ea3e59394549dc7962", - "docs/design-principles.md": "71285ff0b9a7796481b9fe42f7c4b71d0377c767157980ca6386792d9ca8951b", + "docs/getting-started/README.md": "06ee21845be57504ee780eaf063753941cb67ab6bf3fbac69319bb3bf7b64dbb", + "docs/design-principles.md": "564a9b7a79f429e4d4501d87b3f759b3ea7db76a5befc399f37df4d61f6ebbc2", "docs/web/architecture.md": "80bdb8053c06c2b2030b193aae17d5434545f9c72af50e9d3df3ade522fe25ed", "docs/getting-started/install.md": "a4f5591793ba37a7200fa69f462b7bad2a7466eebc3aa5bc2514d1ab0b15238e", "docs/configuration.md": "478b988c06a61f5555f8ec2b5fe4a9379c74e28cea4108734af2e478397905fe", @@ -10,52 +10,55 @@ "docs/getting-started/quickstart.md": "5effeffcfb343f7939910f0c1758e1492d49e7bd66c409b1819cefe67af592d7", "docs/api/README.md": "592fc2aef474e36eb760be0dd1b2269b6766c0d984f826eaf6f47711ea139387", "contracts/agents-api/execution-tools.md": "e0b61f6c0c236c362186c5f6d0ad69a16dd8a8d9afabe1329a1fe22e5f4a71e9", - "docs/api/public-agent-api.md": "18829b366bd2e49fe5beb76dc8a709bc1aa8e35d4fbfd2484a0c52b46cf75417", - "contracts/agents-api/environments.md": "4cbc2efb68e7527798b3d6836593aad5af1130416c861fc6b2b638681aea328a", + "docs/api/public-agent-api.md": "5b04e5ec9311df851376f66fc1584dd48ca123cd8c26ca63277c10b9172e9955", + "contracts/agents-api/environments.md": "bd6cdd76f33d6e2cf6cf1654713be9fb10d0273dc2a9adf0fc88d951ab7aa291", "docs/getting-started/nodes.md": "c1bc854c4798b9757bbb8cea4474a03442ba6f4c10444d5505733e8b94046038", - "docs/getting-started/self-hosted.md": "82f45d07962f49ebffbda9f2c4c4ac029296b31a6cfa048f9fcf7adca32e9de9", + "docs/getting-started/self-hosted.md": "a2eb73cd5d3930cd2851d52e9a60a45f5bd2b0f2f60bfb1da9997ef52dd9a5b5", + "docs/self-hosted-native.md": "f14c3eabf3f1b07284560ccc1e9c7fde90122df43f71fbbd0f42405a0b2b8c03", "apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", "docs/web/README.md": "9fce295f6f6ff04acefe338679c1e54723e123dd0027a778202adfa9ab95b6c4", "docs/api/web-management.md": "dc79df33dade6139749752c9ce53d4e126354cfabccada3e7826de4dd20fae62", "contracts/agents-api/runtime-observability-api.md": "cd46e777fe716a7e76374f4655c96dfbad9d8be1bfca6203ed6118ab87874c2b", - "docs/getting-started/operations.md": "3f0980913fb63bc249e95e7cb41224354c750f5f6f4b4cb15007ce149d188984", - "apps/docs/scripts/guides.json": "1f6036e3cccaf5a71cdd9ea790be6f395acdf871c4552c490b6747957a10b174" + "docs/getting-started/operations.md": "f39d7eaf403ed92c6a1f37c732a19195a389784f78799b3c391654f7d394ac2e", + "apps/docs/scripts/guides.json": "1da7b90e34505a2c149410627f1463103eb638ce0dd02004abd58406fd07a114" }, "outputs": { - "content/docs/index.mdx": "d710539d3e0b5951cbf5b980142c31feda939dda9c39a38915045efd3492ecf6", + "content/docs/index.mdx": "14b35b89630c7836508ccf174217fdc1721c4dfa71370be28e5fc3a164a022d1", "content/docs/index.zh.mdx": "5bdd41893946f1d229eb4cf1599a97f2e38cb9e09722d2a69f62053376ac88a9", - "content/docs/concepts.mdx": "c7eb1d9cbddeabb8b132db37b825eed24456e7729d2784b78f3cd4f88a216fae", + "content/docs/concepts.mdx": "f97413b5c7ba6dcb136980fefa85f1d2defbe12e67fb3e2eeb3bd38335cc0002", "content/docs/concepts.zh.mdx": "c60644f395dbac5ee607cdb053249e05ec8352d28de97ef1c38d6c1e4443bbb1", - "content/docs/execution-model.mdx": "cfc5d4245a7eaf693022717dc34e004a65467d1c30fe0f57de1835a569344365", + "content/docs/execution-model.mdx": "83f44bbba5b2cece1e6f74511bbd10b0db6286782cf2ed8fd402636194477031", "content/docs/execution-model.zh.mdx": "9976a9203c6a83d49c2c753e3a8528a369078e87aeb88850654da948ac8c17e1", - "content/docs/install.mdx": "fbcc7f893e29e5a03a26cdcb5e51b71569a23df5d2a8d268bcba4eb35b836126", + "content/docs/install.mdx": "736f74a503fcb7227aa0ec047c06beba83fedb25d66a3a4f0bc6a53f82286c0a", "content/docs/install.zh.mdx": "f207607f0cfceef2012a4e772fc347ef254486327584289fee1c85414dffc907", - "content/docs/configure.mdx": "d3c6bd0c0c5fe76725a57acc1681f66bf6eb3f642cbaf296ce40d5ea45d24d01", + "content/docs/configure.mdx": "3180e9945c386f95673fc68d05adff1b9f16dc8c1dc258dff9dff1c0ba46c381", "content/docs/configure.zh.mdx": "1c4b149c332ade6f2eeca52319e3e7ccfaac3213db2fd084d66e2da517b6bbd6", - "content/docs/bootstrap-projects-keys.mdx": "da60daed343138acaf5d961b72ca670c18171371d6e791883b170fd233461e41", + "content/docs/bootstrap-projects-keys.mdx": "bc4f999166f48184b92275653e735f6727858b58ec6f78cfef5be3dcb8d750b5", "content/docs/bootstrap-projects-keys.zh.mdx": "509b4dcea4c865d6834bcaef78baa681180d48274931230f958a39e46011c88f", - "content/docs/quickstart.mdx": "0afc29fc97b166b11356343e67c4bb802b14cdc22d29d7c691683fc24f2d8a14", + "content/docs/quickstart.mdx": "6b886d5f000444a692c2cded7e4f965d40c8709414e60a3ff30311cf8d70fb7e", "content/docs/quickstart.zh.mdx": "c5f119b100b102918a45763834d0ed7fd436479fb411e16a62a408411dbcb3e1", - "content/docs/public-api.mdx": "53492062e7647ebbf39f469bb0ade267769cd08eabe73d9f42e0b9870356b4d8", + "content/docs/public-api.mdx": "7e85514c70420cfff66bd02f8c48372967828303434076fc7e000ce05a94e25b", "content/docs/public-api.zh.mdx": "fb0efb9be2fba3f4a3dc71bf053d2c7eddffbfe010f59fa5a1ae60a3f068545b", - "content/docs/agents-and-tools.mdx": "efbfee0d551c3f362f900affde6fe2721910fabc11c85841e981d0a60655a5a5", + "content/docs/agents-and-tools.mdx": "713d5e619c4bd0eec76b66f196842a2507c94b8daaa333458d64a2ca81b023cd", "content/docs/agents-and-tools.zh.mdx": "cc8d4e0ef0ea894f068f3bbd143d5ee70c6346bf98f425b1d5fe07ec3931f128", - "content/docs/sessions.mdx": "57328e8fb2aa645cb8334b6e9a8eb677e4123b5505027d2d6add5c8e6d030b38", + "content/docs/sessions.mdx": "994fa339b30c625ccb9692a226fc5e365b1e430c575ad63c7a08e65b48c39393", "content/docs/sessions.zh.mdx": "b1b1a7f8aacd9a902320f9692173ede24036124f85761cf02f23ef3ef2bcc4c1", - "content/docs/environments-and-files.mdx": "cd5ca9579ef981b9e7c2d37ebec89d9e9d15283964334d74efaaf118bbd5c09c", + "content/docs/environments-and-files.mdx": "be8395bf8568ab19a60debf014c75809d68ebc7d27991d3db0e50937b71a1fa9", "content/docs/environments-and-files.zh.mdx": "ee64577220a798f023b330c65302c14438f25a34a78c4e36144bfdb4304a3ae5", - "content/docs/hosted-providers.mdx": "de6ece49be3db64e5e5b223737673c86107e001dd540b228bb43ee2b02094a32", + "content/docs/hosted-providers.mdx": "a0ccf06394e28a79c8e98503852a8f6a7558b22928ecdaa1804b04b16ecf92b4", "content/docs/hosted-providers.zh.mdx": "d7f6115f052a09dc943f0ed516ff30a27da5847ed24d387d9d1c7e7faa006ab0", - "content/docs/self-hosted-execution.mdx": "b7d8c2424b02229527465b144f62059ca5c09dfe90474869ba57ce576d994e7f", - "content/docs/self-hosted-execution.zh.mdx": "f11d34ce30d48039376256474bc9509a508a2f6b9906f49b5cf6e396707ae5c0", + "content/docs/self-hosted-execution.mdx": "cad6d197ad1c49d06174f304a067f3e5e57b5b23c739cab6b5c6fa5e6dadd5fa", + "content/docs/self-hosted-execution.zh.mdx": "48627ebf94a278b37fb814ec8cf0186447268bb36da9982f354f88e1d6d47500", + "content/docs/self-hosted-native.mdx": "e90ab57097bfa5d654c5216139446ec19381b5325867204c3ae2d70d261d20f3", + "content/docs/self-hosted-native.zh.mdx": "686eb7c22c0bbe59c753d5e8271309d60fad43131129ba5df4fcde44610f2d78", "public/images/source/apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", - "content/docs/console.mdx": "40bc03f4ad3334c7b7d1e141827cd6eaf51144f34532007cfe7925c2d6cb2a82", + "content/docs/console.mdx": "95d3e6f3795f07809e59c88b395a0fa1c5a746e3b878b3fae84a7222f187f4f5", "content/docs/console.zh.mdx": "72f953ba46dcc95933773ac0c4dffdc0d0a499be9d9a1cb9467db2659f1fa10d", - "content/docs/admin-api.mdx": "73c8f57db4e0131e3af1442a84dd61337622188f16d5c685b18b39750a1000b7", + "content/docs/admin-api.mdx": "5807056736f190680f93f73e07f64eb496fdd4ead765914b3ea8066e857f8834", "content/docs/admin-api.zh.mdx": "07006de30df3080e54c3cd79ed0ea5337dce557aa2f11afea84edb7f92884d38", - "content/docs/observability.mdx": "3d9f0891a7efb0a35a5cd7ba9db97d364511ae1e70ceecbdb7f815957909bc4b", + "content/docs/observability.mdx": "b9bd7151b204c36ceaf4d200a4a6420ecc8b8e23eefa44253474f9c947e49b30", "content/docs/observability.zh.mdx": "9574b3412a5754f41ab2d3a16062ac1cad06766be481a8e6bff1b84acbde7e7e", - "content/docs/troubleshooting.mdx": "a87de7639962d7830a2c5905d69439eb17f762f87d7cdd4df1dbedc71278e7a9", + "content/docs/troubleshooting.mdx": "fe388146f73b46f4f749ad399c8d24a96111d084a4f2ed20b52f78e6984fae5c", "content/docs/troubleshooting.zh.mdx": "6d8b4668e8c371120a9275daabbebc0d1ecf21a42ba32beeb92853c14b5a459a" } } diff --git a/apps/docs/openapi/core-api.yaml b/apps/docs/openapi/core-api.yaml index e27a325ae..bf40279d6 100644 --- a/apps/docs/openapi/core-api.yaml +++ b/apps/docs/openapi/core-api.yaml @@ -5889,14 +5889,9 @@ components: items: type: string type: array - system: - items: - type: string - type: array required: - npm - python - - system type: object v1.EnvironmentTemplate: properties: diff --git a/apps/docs/openapi/public-api.yaml b/apps/docs/openapi/public-api.yaml index 090594dd8..8cd200c67 100644 --- a/apps/docs/openapi/public-api.yaml +++ b/apps/docs/openapi/public-api.yaml @@ -575,7 +575,7 @@ paths: tags: - Environment Templates post: - description: Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential env, ordered setup_commands, system/npm/Python packages inline/referenced Skill ZIPs, Plugin ZIPs and workspace-contained capability directories. Omitted/null network defaults to enabled. Restricted network requires 1–100 exact ASCII hostnames; other host forms and populated unsupported installations are rejected before persistence without echoing input. Network policy rejections return invalid_request_error with a null param. No compute is allocated. Exact hosted error/retry semantics remain unverified. + description: Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential env, ordered setup_commands, npm/Python packages inline/referenced Skill ZIPs, Plugin ZIPs and workspace-contained capability directories. Omitted/null network defaults to enabled. Restricted network requires 1–100 exact ASCII hostnames; other host forms and populated unsupported installations are rejected before persistence without echoing input. Network policy rejections return invalid_request_error with a null param. System dependencies must be preinstalled in the sandbox image or template, or on the host machine; packages.system is rejected. No compute is allocated. Exact hosted error/retry semantics remain unverified. parameters: - schema: type: string @@ -864,7 +864,7 @@ paths: tags: - Sessions post: - description: The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. 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, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. 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 native OAuth login remain unsupported. The self_hosted profile uses a qualified native harness, a clean absolute workspace_directory and optional absolute local capability_directories prepared by Runtime, with optional non-deferred function tools and HTTP MCP using 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 boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. 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; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. 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. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. 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; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. 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. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. + description: The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. 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, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. 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 native OAuth login remain unsupported. The self_hosted profile uses a qualified native harness, a clean absolute workspace_directory and optional absolute local capability_directories prepared by Runtime, with optional non-deferred function tools and HTTP MCP using 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 boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. 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; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. 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. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. 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; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. 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. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. System dependencies must be preinstalled in the sandbox image or template, or on the host machine; packages.system is rejected. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. parameters: - schema: type: string @@ -3991,14 +3991,9 @@ components: items: type: string type: array - system: - items: - type: string - type: array required: - npm - python - - system type: object v1.EnvironmentPackagesInput: properties: @@ -4014,12 +4009,6 @@ components: type: string type: array - type: 'null' - system: - anyOf: - - items: - type: string - type: array - - type: 'null' type: object v1.EnvironmentTemplate: properties: diff --git a/apps/docs/openapi/sources.json b/apps/docs/openapi/sources.json index d9d0f0b17..cfb8468c3 100644 --- a/apps/docs/openapi/sources.json +++ b/apps/docs/openapi/sources.json @@ -1,14 +1,14 @@ { "sources": { - "contracts/agents-api/openapi.yaml": "e54687e7b3c8cba023432fc5b8ec56ee4e1312f840b8f355b5ca2ddb41917d1d", - "contracts/agents-api/core.openapi.yaml": "7801fc77259893815fe9cdcb404f045b375711b9b075eab2b2ed8c5dba55e24b", + "contracts/agents-api/openapi.yaml": "1433d6ef0d5c4b3598ae2c906faacce4eeefea37bcc125243ab25d04f61e620b", + "contracts/agents-api/core.openapi.yaml": "3e02dacf7047e1179d334cc6fcb5110e9968410571c8d1732bc9cd0681a68939", "contracts/agents-api/runtime.openapi.yaml": "23852205d3b45892f2de9ecf3d52518c0dd0808962c0c512f219b565aa7ccef2" }, "outputs": { "content/docs/api-reference/agents.mdx": "1f7697959e9c52c9d24fbe70e116e807f49912b9a27ac638b86782e2f326af44", "content/docs/api-reference/environments.mdx": "6a9e08a67479a745983c45b9711137617c13fdf7599a64533997b00a26ac7282", - "content/docs/api-reference/environment-templates.mdx": "c1daeaec159aea40b0aa5ed72b1e77ac1715471431b558d0d89e05bfe9ad8945", - "content/docs/api-reference/sessions.mdx": "9709476af57c645f441bb287c3455ee09dd1fd2d1043558439dc6c32676a3c82", + "content/docs/api-reference/environment-templates.mdx": "68df9276e858e525e15bb88af624fc8adee8bcdcc03a788faaff56b560fd038c", + "content/docs/api-reference/sessions.mdx": "7c12ac70dfdfef86b5d08b9151a7fafdbec6df4848760c81f059b6a331db81a1", "content/docs/api-reference/artifacts.mdx": "8df8d008126b49b1df88f28a676c4b237ccffc45a470b35ec30eab2daf1c0f7f", "content/docs/api-reference/events.mdx": "83d7f56a788d073e0b19f0bc9197f39ec364375b9cc3a7ec69b79b54bc71e3eb", "content/docs/api-reference/items.mdx": "fa4d248ac5623beb3f00a4ebb5a6c4ff477865ebee351e0c543a970a7ec3654d", @@ -18,7 +18,7 @@ "content/docs/api-reference/skills.mdx": "774ea05a7a8250fe5af5164388b98dafca99d56dc2eae686e8f628acb4e7c10b", "content/docs/api-reference/vaults.mdx": "d80eade1c2f8100030866abbb0fb6b7a09c94250a859c97cdf1abce32fdfdf1e", "content/docs/api-reference/credentials.mdx": "f129a51f74a3c7021c016600518e2350023f8d29571666ac5d64b7584cd7c7d0", - "openapi/public-api.yaml": "17ae24606c26be371ffa0c90904fdba3deac5a744ac985034a68aa35873b9a94", + "openapi/public-api.yaml": "7228ef80c91b6b736dbfbf80d078ab3c56538e7ba91640a4dfa3c30fd9127aa3", "content/docs/api-reference/meta.json": "56bf1dc145ccc8adde38c5f956f1a06f18ef18b646122250d01a294c516f8f83", "content/docs/api-reference/index.mdx": "9fd6b1c54149874d2999235067c101326f9e8a2e707a70b6672c81eb3ebfe63f", "content/docs/api-reference/core/core-administration.mdx": "5fc3ada51d2190a141f13738151e4d513390cc3bd66ce6f5013994bda68569b0", @@ -40,7 +40,7 @@ "content/docs/api-reference/core/vaults.mdx": "12d0aac96cd4a3472ef4eaa287f7945f78862017956e79bfc3ed0ef8e772e78a", "content/docs/api-reference/core/credentials.mdx": "27d586b9a8fd738b9947802f32c80458195370252886bc26abedccf2125ee981", "content/docs/api-reference/core/sandbox-manager.mdx": "6be7c52e057365949f0247c18c710561d680847a955f78f1bbe57825d4d5f549", - "openapi/core-api.yaml": "7079694e447c0783f85aeb3b73f23af68ebb066540c67251bbae34e7e4bd8e62", + "openapi/core-api.yaml": "2ae4043550e953945fc184d2f82149ea9fe4d5f693f5c8bd31d7edb87e8a0c4c", "content/docs/api-reference/core/meta.json": "588b163ed2de2100b0f8376688274f80698a6bf6ac037eef56d17c93fa8a5062", "content/docs/api-reference/core/index.mdx": "ddf0a8d87a6c21761beac703a32f2a40486a1c74abb341bd726f40c44a866405", "content/docs/api-reference/machine/sandbox-node.mdx": "5b367a08d43cea82686345d2ba1c75cc90627a7b2970fe42a0df9d77fb3e1e80", diff --git a/apps/docs/scripts/generate-guides.mjs b/apps/docs/scripts/generate-guides.mjs index f403d7a78..b09e09273 100644 --- a/apps/docs/scripts/generate-guides.mjs +++ b/apps/docs/scripts/generate-guides.mjs @@ -8,7 +8,7 @@ const app = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const repo = path.resolve(app, '../..') const guides = JSON.parse(fs.readFileSync(path.join(app, 'scripts/guides.json'), 'utf8')) const routes = new Map(guides.map(g => [g.source, g.slug === 'index' ? '/' : '/' + g.slug])) -const sourceRevision = 'e35d3c9eda2cdb717fd4c3627404d362cc2e4dca' +const sourceRevision = '281f243aa785ea25512a6157daa1d44197b9723e' const sourceURL = relative => `https://github.com/MiniMax-AI/parsar-core/blob/${sourceRevision}/${relative}` const digest = text => crypto.createHash('sha256').update(text).digest('hex') function mdx(text) { diff --git a/apps/docs/scripts/guides.json b/apps/docs/scripts/guides.json index 0384a6a69..6b7b7ebf2 100644 --- a/apps/docs/scripts/guides.json +++ b/apps/docs/scripts/guides.json @@ -88,7 +88,14 @@ "source": "docs/getting-started/self-hosted.md", "title": "Self-hosted executors", "description": "Connect a user-owned Runtime to its Environment with a restricted executor credential.", - "zh": "self_hosted 执行器由用户拥有,Core 不管理它的容器。通过 Web 为对应 Environment 签发 executor 凭据,安装器通过隐藏输入或私有文件读取凭据;不要把凭据放入命令行。" + "zh": "self_hosted 执行器由用户拥有,通过 Web 为对应 Environment 签发凭据。Linux、macOS 和 Windows 使用同一 daemon 协议;daemon 以启动账户权限运行,隔离由外层环境负责。凭据通过私有文件提供,不放入命令行。" + }, + { + "slug": "self-hosted-native", + "source": "docs/self-hosted-native.md", + "title": "Native daemon installation", + "description": "Install and operate a self-hosted daemon on Linux, macOS or Windows.", + "zh": "使用当前平台的 oac-daemon 安装、启动和停止自托管 Runtime。系统依赖需提前安装,运行时不提权。Windows 已通过原生 CI 自动测试,尚未人工实机验收。" }, { "slug": "console", diff --git a/apps/docs/scripts/verify-docs-facts.mjs b/apps/docs/scripts/verify-docs-facts.mjs index 0265012a0..d9173603a 100644 --- a/apps/docs/scripts/verify-docs-facts.mjs +++ b/apps/docs/scripts/verify-docs-facts.mjs @@ -17,7 +17,7 @@ const source = slug => fs.readFileSync(path.join(app, 'content/docs', slug + '.m for (const token of ['/v1', '/core/v1', '/api/v1', 'Project API key', 'Core key', 'executor']) assert.ok(source('public-api').includes(token), 'Credential matrix omits ' + token) for (const token of ['config.json', 'oac apply']) assert.ok(source('configure').includes(token), 'Configuration guide omits ' + token) for (const token of ['oac-node', '~/.oac/nodes']) assert.ok(source('hosted-providers').includes(token), 'Node guide omits ' + token) -for (const token of ['oac-selfhost', '~/.oac/self-hosted']) assert.ok(source('self-hosted-execution').includes(token), 'Executor guide omits ' + token) +for (const token of ['oac-daemon install', 'OAC_RUNTIME_HOME', 'Linux, macOS and Windows']) assert.ok(source('self-hosted-execution').includes(token), 'Executor guide omits ' + token) // Installation policy must agree in both generated guides and reviewed reading notes. for (const [slug, tokens] of [ ['troubleshooting', ['In-place version upgrades, downgrades and historical conversions are not supported.', '.oac.lock']], diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 4c48e7b3b..30d9cf2c7 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -203,7 +203,7 @@ user-managed enrollment remain outside this qualification. | Area | Missing or unverified scope | | --- | --- | | Subagents / multi_agent | Six reads and same-child recovery have three-harness Docker evidence; optional native operations, live child progress, full lifecycle/interactions and tool combinations remain explicit gaps | -| Environment Templates | Unsupported restricted hostname forms and exact hosted errors remain gaps. Referenced null network and capability-list selection follow [qualified inheritance rules](template-null-selection.md). Template-reference env/files/commands/packages composition follows [qualified field rules](environment-templates.md#template-and-inline-configuration-composition). CRUD/list, files, env/setup/system/npm/Python, inline/referenced Skills, Plugins, workspace capability directories and Session references have recorded coverage. Environment Plugin MCP transport and placement limits are [listed separately](environment-templates.md#environment-origin-mcp-plugins) | +| Environment Templates | Unsupported restricted hostname forms and exact hosted errors remain gaps. Referenced null network and capability-list selection follow [qualified inheritance rules](template-null-selection.md). Template-reference env/files/commands/packages composition follows [qualified field rules](environment-templates.md#template-and-inline-configuration-composition). CRUD/list, files, env/setup/npm/Python, inline/referenced Skills, Plugins, workspace capability directories and Session references have recorded coverage. System-package and inner-isolation evidence is historical; current `packages.system` rejects and the daemon adds no sandbox. Environment Plugin MCP transport and placement limits are [listed separately](environment-templates.md#environment-origin-mcp-plugins) | | Input and configuration | Non-text initial input, broader content/configuration unions and reasoning/verbosity combinations; [structured output](structured-output.md) has qualified Claude function profiles on none and Core-managed Docker openai_hosted, with other combinations remaining gaps | | Tools and interactions | [Deferred discovery qualification](tool-search.md), other tool types, effective tool-set enforcement and result/cancel publication ordering; MiniMax public functions and service-origin MCP remain unsupported | | Vault and Credentials | Archive semantics, in-flight token withdrawal and exact hosted selection/error behavior; static/OAuth CRUD, replacement and scoped dispatch-time refresh are implemented (see credential guide for qualification) | @@ -490,15 +490,17 @@ model provider in the request or through a saved Agent; deployment defaults appl to `openai_hosted` and `none`, never to `self_hosted` ([model execution](model-execution.md)). Service-origin HTTP MCP is rejected on `self_hosted` and hosted local placements. This does not remove separately qualified Environment Plugin MCP. The [Docker lifecycle](environments.md#basic-public-docker-hosted-profile) retains -workspace Files/Artifacts, cancellation and recovery with native isolation. +workspace Files/Artifacts, cancellation and recovery. Managed isolation belongs to +the outer Environment; native tools use the starting account's permissions. Configure immutable Runtime images explicitly: [Codex](../../services/agents-api/deploy/codex/README.md), [Claude](../../services/agents-api/deploy/claude/README.md), [MiniMax](../../services/agents-api/deploy/mcode/README.md). [E2B packaging](../../services/agents-api/deploy/e2b/README.md) reuses the Runtime with the official SDK; the user owns provisioning, renewal and destruction. -The shared initialization path supports env/setup and system/npm/Python packages; -see the [evidence and limits](environment-templates.md#verification). Remaining +The shared initialization path supports env/setup and user-directory npm/Python packages; +`packages.system` rejects explicitly and system dependencies must be preinstalled. +See the [evidence and limits](environment-templates.md#verification). Remaining unsupported startup installations, unqualified restricted hostname forms and hosted service-origin HTTP MCP remain outside these accepted profiles. Environment-origin MCP Plugins have a separate [Docker qualification and transport matrix](environment-templates.md#environment-origin-mcp-plugins): diff --git a/contracts/agents-api/environment-executor-credentials.md b/contracts/agents-api/environment-executor-credentials.md index 36cb3de15..887ce6620 100644 --- a/contracts/agents-api/environment-executor-credentials.md +++ b/contracts/agents-api/environment-executor-credentials.md @@ -101,8 +101,10 @@ apply to `self_hosted` Sessions, and creation without a provider fails with 400 `model_provider_required`. Core freezes the bundle in the Session's encrypted snapshot and sends it only over the connection of the executor enrolled for this Environment with a current credential of the Session creator's principal. The -executor keeps it in the Runtime's native harness home, which tools and public -Files cannot read; the executor host's owner can. Revocation does not erase a +executor keeps it in the Runtime's native harness home. Public Files remains +scoped to the authorized workspace, but native tools and the host owner can read +whatever the starting account can access. The daemon provides no same-user +credential isolation. Revocation does not erase a bundle already delivered. A saved Agent's provider key is delivered to the executor of every `self_hosted` Session created with that Agent in the Project, so anyone who can create `self_hosted` Sessions in the Project and run an executor diff --git a/contracts/agents-api/environment-templates.md b/contracts/agents-api/environment-templates.md index f548fc5f5..34a09ad82 100644 --- a/contracts/agents-api/environment-templates.md +++ b/contracts/agents-api/environment-templates.md @@ -14,11 +14,12 @@ and five-operation SandboxProvider path as inline configuration. CRUD/list works without an execution deployment. - Optional nullable name, preserved verbatim, with a local 1–256 Unicode character bound. Network supports `enabled`, `disabled` and exact-host `restricted`; omitted/null create network - defaults to the pinned enabled policy. Update omission preserves; supplied name + defaults to the pinned enabled policy. These are configuration values, not daemon + network enforcement; unsupported execution combinations reject. Update omission preserves; supplied name or network replaces, with null clearing name or resetting network. - Empty/null installation fields retain empty defaults. Responses contain safe metadata and never `env`, `setup_commands` or inline file data. Initial files are - supported as described below, together with inline/referenced Skills, env, ordered setup and system/npm/Python packages; remaining populated installations reject explicitly. + supported as described below, together with inline/referenced Skills, env, ordered setup and npm/Python packages; `packages.system` rejects explicitly and system dependencies must be preinstalled. - Listing uses `after`, `limit` (default 20; 0 is treated as 1 and larger values as 100), and `order` (default `desc`). Creation timestamp plus ID supplies stable local ordering. Missing/foreign IDs @@ -37,7 +38,7 @@ from openai import OpenAI client = OpenAI(base_url="https://your-core.example/v1", api_key="your-project-key") template = client.beta.agents.environments.templates.create( - name="Python workspace", network={"access": "disabled"}, + name="Python workspace", network={"access": "enabled"}, env={"APP_MODE": "analysis"}, packages={"python": ["packaging==26.0"]}, setup_commands=[{"command": "mkdir -p /workspace/outputs"}] ) @@ -57,8 +58,9 @@ an absolute destination inside `/workspace`: `inline` with standard-base64 `data `file_id` referencing a project-owned Files API upload. The guide's limits are 50 initial files, 5 MiB per inline file, 10 MiB total inline content, and 50 MiB per referenced file. Session/Template JSON requests allow 16 MiB for the base64 envelope. -Paths must be canonical, distinct and stay within the workspace; symlinks are not -followed. A failed install never starts native execution. +Paths must be canonical, distinct and stay within the logical workspace. Runtime +anchors the operation to its bound workspace; this is API path scope, not a +restriction on native tools running as the same user. A failed install never starts native execution. Configure `OAC_CREDENTIAL_KEY_FILE` with the existing execution-service base64 32-byte encryption key. Template writes and Session resolution need it; @@ -166,17 +168,18 @@ content after template update/deletion. Core transfers frozen Skill archives over the authenticated Runtime connection. Runtime's shared parser installs them under its operator-bound capability root -(default `/environment/initialization/capabilities/skills/` for each Skill) -before setup and native execution; +(`skills/` below the configured capability directory) +before setup and native execution. The same daemon handles initial files, configuration, packages and setup; Providers -only bootstrap it and manage compute resources. Setup and native tools can read -that tree but cannot write it. Completed recovery loads the protected installation +only bootstrap it and manage compute resources. Setup and native tools run with +the launching user's permissions; snapshot file modes are an integrity hint, not +protection from that user. Completed recovery loads the recorded installation without reinstalling. The common Runtime resolves installed metadata and paths before invoking the native executor factory. Codex registers native extra roots, Claude creates its own explicit Skill plugin envelope, and MiniMax points its native user-global catalog at the shared root. MiniMax retains disabled unrestricted built-in tools and uses its existing -isolated workspace tool worker. No Provider or model/tool loop is added. +workspace tool worker with the launching user's permissions. No Provider or model/tool loop is added. Codex nested `SKILL.md` discovery, `agents/openai.yaml` native dependency configuration and Claude inline/fenced shell preprocessing are not qualified in this batch and explicitly fail adapter @@ -202,7 +205,7 @@ Hosted Template and inline `capability_directories` accept clean absolute paths within `/workspace`; self-hosted local selections have their [separate public input](environments.md#runtime-capability-preparation). Initial files and setup can populate hosted directories. Runtime snapshots them after -setup and writes the shared protected `installed.json`, bound to the Session, +setup and writes the shared `installed.json`, bound to the Session, Environment and source-selection digest. Recovery loads those installed bytes even if a source directory changes or is removed; a new Session captures its own snapshot. Partial or conflicting installation fails rather than silently recapturing sources. @@ -270,13 +273,13 @@ model or daemon variable. Public `env_http_headers` remains unsupported; an adap may privately use native reference fields without changing public literal values. Caller-owned env remains available to caller code under the normal env contract. -Stdio executes through the existing Runtime sandbox and a fixed static helper, -which applies selected env/cwd and directly execs the package command. A small -single-threaded launcher monitors the native parent process with Linux pidfds; -bwrap retains its parent-death protection against that stable launcher. This avoids -killing MCP servers when Codex recycles a spawning thread. Native MCP owns the -transport; the launcher does not parse or forward protocol messages. Dependencies must be installed in the ordinary -Environment initialization flow. The current implemented transport boundaries are: +Stdio executes through the common daemon helper, which resolves the frozen package +and explicitly selected env/cwd, then launches the declared command. Unix replaces +the helper process; Windows forwards stdio within the owned process tree. Native +MCP still owns the protocol. Process groups and Windows Jobs handle cancellation, +not isolation. There is no Python launcher or inner mount/network sandbox. System +dependencies must be preinstalled; npm/Python dependencies may use the ordinary +Runtime initialization flow. The implemented transport boundaries are: | Adapter | Environment MCP implementation | | --- | --- | @@ -341,6 +344,10 @@ complete upstream protocol compatibility. ### Composed initialization acceptance +This section records the historical September 21 fixture and binaries. Its +system-package and inner-isolation checks are not current support requirements; +`packages.system` now rejects and tools run with the starting user's permissions. + `services/agents-api/tests/official_environment_composition.py` combines the existing public fixtures in one enabled-network configuration: inline/referenced files, caller env, system/npm/Python packages, ordered setup, an uploaded Skill @@ -366,7 +373,7 @@ initial Turn alone does not pass this composed workflow. The final Docker composition passed on 2026-09-21 with SDK 3.13.0/raw HTTP, independent Core/PostgreSQL, Kimi K3 (Codex/Claude) and MiniMax-M2.7. Core and the unchanged Codex/MiniMax images are from main `8eb3c089`; the Claude image includes -the native-prefix correction described under System packages. All four public +the historical native-prefix correction recorded by that batch. All four public runs completed with zero cleanup errors: | Profile | Result under `~/.parsar/remediation/20260921/template-composed-acceptance/` | @@ -399,60 +406,41 @@ Templates workflow, not all upstream semantics, transports or Provider combinati ## Packaged Runtime initialization contract Template handlers and stores resolve public configuration without choosing a -harness, native path or compute backend. The common runner depends on neutral +harness, native path or compute backend. The common runner uses neutral Environment/Session identity and an authenticated Runtime peer. It sends initial -files, configure/system/npm/python/setup operations, Skills, Plugins and finalization +files, configure/npm/python/setup operations, Skills, Plugins and finalization through `runtime_prepare`, then uses the same daemon for execution. Providers own -placement, creation, daemon bootstrap, inspection, renewal and reclamation; they do -not run Core initialization commands. Harness-specific behavior stays in adapters. - -The protocol accepts logical `/workspace` file and working-directory addresses and -portable absolute source selections. Physical paths, executable selection and local -access checks belong to Runtime. The current implementation uses these existing -Linux packaging requirements; they are not public Template fields or requirements -for future Runtime implementations on other platforms: - -- `/workspace` is the logical workspace. The packaged Linux default binds it to - `/environment/workspace`; `/environment/staging` is private staging. -- The daemon invokes the fixed trusted, fd-anchored initial-file installer and the - shared atomic writer. These helpers do not select a harness or invoke native - model tools. -- Confidential content travels in bounded protocol frames and private helper stdin. - The daemon verifies native completion before sending a typed Runtime receipt. - Unknown effects use the existing allocation cleanup path rather than replay. -- Runtime adapters own native configuration. Initialization cannot access a - harness's private history, model credentials or native tool protocol. - -New Providers bootstrap the same Runtime contract. New harnesses reuse common -preparation, with native differences confined to their adapters. Neither addition -changes template validation, storage or resolution. This interface separation does -not claim a non-Linux installer, layout or isolation implementation. -The trusted `/usr/local/bin/oac-runtime-initialize` receives a bounded -version-1 JSON operation on stdin from the Linux daemon. This private helper format -is not the Core-to-Runtime protocol. It configures read-only tool env under -`/environment/initialization`, installs packages under `/environment/packages`, -and runs ordered commands through distro bubblewrap. The fixed mount/process map -excludes daemon credentials, native history and staging. User values are applied -inside isolation, never to the launcher. Receipt and process exit must both confirm -completion; child output is discarded because it can contain secrets. A failed step -that ran inside that isolation (a setup command or a package manager) adds only its -integer `exit_code` to the failed receipt; see -[Initialization failure](#initialization-failure--september-23). - -Runtime receives a `tool_environment` execution flag, without template identity or -provider information. Adapters validate the common files and apply them in their -native tool sandbox: Claude uses its native Bash hook, Codex its managed Bash hook, -and MiniMax its isolated native-tool worker. Native transports remain unchanged. -Files reads do not require initialized tool configuration. Packages and setup keep -enabled provisioning network access; requested Session network restrictions apply -to native execution after setup. Docker setup requires -the existing nested-sandbox deployment profile for every harness; E2B supplies -the same Runtime layout and kernel isolation. - -Codex 0.153.4 can execute an original command when a native hook process fails. -The adapter verifies the required trusted managed hook before preparation and -stops the Turn on an observed failed hook. Earlier command effects may already -exist; this is not an atomic hook-failure prevention guarantee. +placement, creation, bootstrap, inspection, renewal and reclamation; they do not +run Core initialization commands. Harness differences stay in adapters. + +One Go preparation implementation serves Linux, macOS and Windows. Initial files +and setup working directories use logical `/workspace` addresses; physical paths +and executable selection belong to Runtime. Initial files use the shared atomic +writer, including replacement, while public Files creation retains its separate +semantics. Transfer input and stored tool configuration are bounded. Process +ownership and I/O settle before a typed receipt; unknown effects are never replayed. + +Initialization and packages default under `OAC_RUNTIME_HOME`. Operators can select +`OAC_RUNTIME_INITIALIZATION_DIRECTORY` and `OAC_RUNTIME_PACKAGE_DIRECTORY`; +packaged Linux images set `/environment/initialization` and `/environment/packages`. +`OAC_RUNTIME_TOOL_ENV_FILE` selects explicit tool-variable JSON. Runtime does not +overwrite an existing configuration. No Python initialization wrapper, system-root +seed or managed shell hook is shipped. + +Setup uses Bash, with Git Bash on Windows; a missing dependency fails explicitly +rather than substituting a different shell. npm uses a local prefix and Python/pip +a local target under the Runtime package directory. Node/npm and Python/pip must +already be installed. No automatic system-package installation, sudo or daemon +privilege increase occurs. + +All commands use the launching user's permissions and host network. The daemon +provides no filesystem, permission or network sandbox, including on Linux. Managed +isolation belongs to the outer Environment. Configured user variables are applied +to the command, not automatically inherited from daemon credentials, but tools may +read any local state the same user can read. Output is discarded rather than +included in failure diagnostics. Only a confirmed command failure can report its +bounded integer exit status; cancellation or unknown effects retain a generic +reason. Files reads retain their separate authorization and readiness requirements. ## Initialization failure — September 23 @@ -476,7 +464,7 @@ The reason names only the failed step and its exit status: | --- | --- | | Setup command `i` | `Failed to provision environment: script "setup_commands[i]" failed with exit code N` (observed) | | Python packages | `... script "Python package installation" failed with exit code N` (observed label; the official reason appends raw pip output, Core never does) | -| npm or system packages | `... script "npm package installation"` / `"System package installation"` `failed with exit code N` (unverified) | +| npm packages | `... script "npm package installation" failed with exit code N` (unverified label; system packages now reject before initialization) | | Initial file write or Runtime Skill preparation with a confirmed failure | `Failed to provision environment: initial file installation failed` / `Skill installation failed` (unverified) | | Anything else | `Failed to provision environment: initialization did not complete` | @@ -484,9 +472,8 @@ The reason names only the failed step and its exit status: missing or malformed receipts, Plugin installation and directory finalization, bootstrap rejection and Core restart during initialization. All initialization operations return typed Runtime `rejected`, `failed` or `unknown` outcomes. The -Linux daemon confirms the trusted helper's process exit and private receipt before -reporting completion or failure. Only `failed` can carry a bounded `exit_code`; -For setup and package steps, Core projects a confirmed nonzero status, never +daemon confirms process exit and I/O settlement before reporting completion or +failure. Only `failed` can carry a bounded `exit_code`. For setup and package steps, Core projects a confirmed nonzero status, never process output; zero or missing status retains the generic reason. Only a confirmed Skill preparation failure gets the Skill label. Plugin/finalization failures and uncertain effects retain the generic reason. The Store composes the reason from a fixed label and integers, so commands, env values, package names, @@ -496,37 +483,19 @@ not retried and later steps do not run. ## System packages -`packages.system` accepts package names for the Runtime's Debian apt repositories, -in both templates and inline hosted configuration. Real apt/dpkg installs packages -and runs package scripts before npm/Python dependencies and setup commands. Template -updates replace the package object; omission preserves it and null clears it. -Referencing Sessions freeze the existing template configuration. - -Each Runtime image supplies a seed built before daemon, harness and credential -installation. The common initializer extracts it into -`/environment/packages/system` under the unprivileged Runtime identity. Matching -package databases and base tools are included; private Runtime files and native -history are absent. Installation uses its own process/filesystem view. Package -output is not exposed in public diagnostics. A failed or uncertain installation -fails the Environment through the existing lifecycle and is not replayed. - -Setup and native shell tools enter this installed root read-only, with the same -workspace and adapter-owned temporary storage. Trusted launchers stay outside the -package-controlled root. Codex uses its managed hook, Claude its full-shell prefix, -and MiniMax Code its existing tool worker; native execution and cancellation retain -their existing owners. Core carries only the required initialized-tool condition. -Files operations retain their existing authorization and initialization boundary. -The Claude image includes an adapter-specific prefix dispatcher: its exact Runtime -MCP entry starts directly, while complete native Bash commands enter the tool root -unchanged. This preserves native sandboxing and cwd receipts without wrapping MCP -inside a second tool root or introducing Core engine branches. - -This is a single-UID tool environment, not a full operating-system service manager. -Packages requiring additional Unix identities, privileged operations or background -system services may fail explicitly. There is no apt mirror, package cache, arbitrary -root installation or package retry mechanism. Existing operation and initialization -time budgets apply. New harnesses implement the same Runtime contract rather than -adding template-specific business logic. +`packages.system` is unsupported in both Templates and inline configuration. +Supplying the member, including null or an empty list, returns an explicit +validation error with param `packages.system`: preinstall system dependencies in +the sandbox image/template or on the host machine. The field is not silently +ignored, inherited into a privileged action or translated to apt. + +The daemon runs as its starting account. It does not install system packages, +request sudo or elevate permissions. Operators build managed images/templates +with required system dependencies; self-hosted users prepare them before execution. +A missing executable or library fails the operation that requires it. npm/Python +packages and setup retain the common user-directory initialization path. +Historical system-package qualification below applies only to its old binaries; +there is no system-root seed, package-root launcher or upgrade compatibility path. ## Restricted network policy @@ -539,14 +508,13 @@ Public reads preserve supplied spelling, order and duplicates. Effective native comparison uses a separate lowercase, deduplicated copy; updates cannot change existing Session snapshots or retry intent. -Provider bootstrap and preparation carry the same frozen policy. Runtime rejects -mismatches and missing hosted execution policy. Read-only workspace access retains -its existing minimal prerequisites. Core owns no native proxy configuration: -Codex uses its managed network ceiling, while Claude and MiniMax use native sandbox -allowlists. Provisioning remains a separate phase before runtime restrictions. -Current qualification evidence must cover real Docker native execution, permitted -and denied hosts, credential isolation, Files/Artifacts, cancellation and retained -policy on recovery; resource tests alone do not establish execution compatibility. +The daemon does not enforce `disabled` or `restricted` network modes. Stored +configuration and successful Template CRUD do not establish execution support. +An execution combination must reject unless its outer Environment provides and +qualifies the requested restriction; it must not silently run unrestricted. +The normal native self-hosted combination uses the host's existing network. +Historical native-proxy or sandbox allowlist results below do not qualify current +outer enforcement. Read-only Files retains its separate minimal prerequisites. ## Explicit gaps and evidence boundaries @@ -567,19 +535,18 @@ Template updates replace each supplied field; omission preserves it and null cle it. Referencing Sessions use the composition rules below; these differ from Template.update replacement rules. -Files and resolved Skills are installed first, followed by system, npm/Python packages and ordered commands; +Files and resolved Skills are installed first, followed by npm/Python packages and ordered commands; the default cwd is `/workspace`. One command or package operation has the existing two-minute local budget, within the thirty-minute initialization budget. No command is retried after unknown effects. Completed setup never runs on reconnect. Package dependencies are available to native tools across working directories. -System packages use the isolated tool root described above. +System dependencies must already be installed; `packages.system` rejects. The [update Reference](https://developers.openai.com/api/reference/python/resources/beta/subresources/agents/subresources/environments/subresources/templates/methods/update) defines runtime network as post-setup and packages as preceding that policy. -Initialization therefore uses its isolated provisioning network; native tools -apply the requested enabled/disabled/restricted policy afterward. Allowing setup internet is -an implementation inference from that phase boundary, not an explicit upstream -guarantee. Env values are intentionally readable by Agent code; they must not +Runtime initialization uses the host's existing network. The daemon does not +provide the reference's later restricted-network enforcement. Unsupported execution +combinations must reject; the phase ordering is not an isolation guarantee. Env values are intentionally readable by Agent code; they must not appear automatically in public metadata or initialization diagnostics. The [current Template reference](https://developers.openai.com/api/reference/python/resources/beta/subresources/agents/subresources/environments/subresources/templates) @@ -592,14 +559,16 @@ unsupported hostname forms still reject. This is not full protocol compatibility ## Template and inline configuration composition The pinned Session description applies the template before inline configuration. -Owned official API probes on 2026-09-23 establish the following narrower behavior: +Owned official API probes on 2026-09-23 established composition behavior. The +current implementation applies it only to supported fields; the table below +excludes the now-unsupported `packages.system` member: | Session field | Omitted or null | Non-null inline value | |---|---|---| | `env` | Inherit template keys | Overlay by key; inline value wins, `{}` preserves all keys | | `setup_commands` | Inherit template sequence | Replace the sequence; `[]` clears it | | `files` | Inherit template file set | Replace the complete set; `[]` clears it | -| `packages` | Inherit all managers | Resolve Python/npm/system independently; `{}` inherits all | +| `packages` | Inherit supported managers | Resolve Python/npm independently; `{}` inherits both; supplying `system` rejects | | Individual package manager | Inherit its template list | Replace that list; `[]` clears it | Core performs this composition once, before the existing encrypted Session snapshot @@ -654,6 +623,10 @@ does not add MiniMax, Claude or E2B native qualification. ### Real Codex Docker composition acceptance (2026-09-23) +This is historical evidence for the recorded images, including their former +system-package and inner-sandbox implementation. It does not qualify current +Runtime permissions or permit `packages.system`. + The exact `8019ac5` production build ran independently with Core, PostgreSQL and Docker Runtime against the real Kimi API. Three completed native model Turns prove: @@ -669,8 +642,7 @@ Docker Runtime against the real Kimi API. Three completed native model Turns pro Five owned Core Sessions were created over the acceptance attempts. The first two failed before any model Turn because the reused private Skill-only runner omitted -Docker `nested_sandbox: true`, already required by the documented initialization -profile. Correcting that operator setting resolved the proc-mount failure without +Docker `nested_sandbox: true`, required by that historical initialization profile. Correcting that operator setting resolved the proc-mount failure without production changes. In the corrected pair, the populated Session completed its native command, but the private runner then required an optional assistant `phase` and unwrapped JSON. The original message instead contained matching fenced JSON @@ -687,6 +659,11 @@ or complete Template/Agents API semantics. ## Verification +The dated results in this section are historical evidence for their exact source, +images and inputs. They do not qualify current bypass execution, cross-platform +preparation, or outer network enforcement. In particular, former system-package +installation and private-file denial results are not current support promises. + ### Restricted-network Docker acceptance (2026-09-21) The fixed SDK 3.13.0 and raw HTTP passed restricted-policy CRUD, tenant isolation, @@ -813,9 +790,10 @@ One MiniMax inline post-restart model request reported an upstream timeout after transport changes; this does not establish or fix the timeout cause. All completed runs confirmed owned resource cleanup. The three-harness-by-two-Provider matrix was not repeated: shared E2B initialization and the changed native adapter paths -were covered separately. System packages were outside that batch; their current -qualification is recorded separately. Unconfirmed reference overrides remain gaps, -and native Codex hook failure retains the limitation stated above. +were covered separately. System packages were outside that batch; a later +historical qualification is recorded below. Unconfirmed reference overrides +remained gaps in that evidence, and the retired native Codex hook's failure +limitation was not resolved by this run. Private sanitized run/check/build evidence is retained under `~/.parsar/remediation/20260920/environment-template-setup/` and the linked board. These results do not establish complete Template or Agents API compatibility. diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index f0bf8ea7d..0143f158a 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -36,7 +36,8 @@ See the [enrollment guide](../../services/agents-api/README.md#user-managed-runt An explicitly configured default managed Docker provider enables `type=openai_hosted` for the qualified Codex, Claude Code and MiniMax Code profiles. Each uses the same -Runtime lifecycle and workspace interfaces with its own native adapter/isolation. +Runtime lifecycle and workspace interfaces with its native adapter. The outer +Environment provides managed isolation; the daemon adds no inner sandbox. See the [engine profile guides](README.md#public-engine-profiles) for setup and limits. The standalone [operator configuration](../../services/agents-api/deploy/codex/README.md#standalone-operator-configuration) selects the qualified immutable Runtime image; advertised capabilities alone do @@ -45,15 +46,17 @@ and retry identity before the existing leased Worker provisions its allocation. A committed creation interrupted before bootstrap is recovered without replaying an existing allocation's Create. -Omitted/null network defaults to enabled. Enabled, disabled and exact-host restricted -policies use the same qualified image with adapter-selected immutable native policy. +Omitted/null network defaults to enabled. The daemon does not enforce disabled +or restricted networking. An execution combination must reject unless its outer +Environment implements and qualifies the requested behavior. Templates and inline configuration share initial files, env, packages, ordered setup and inline or tenant-owned referenced Skills through ordered managed initialization. The same authenticated daemon handles initial files, tool configuration, packages, setup, Skill/Plugin import and directory snapshot finalization through `runtime_prepare`. Providers place, create, bootstrap, inspect, renew and reclaim -compute. Package installation and setup retain enabled provisioning network access; -the requested network policy constrains native execution after setup. +compute. npm/Python installation and setup use the host's existing network and +starting account. System dependencies must be preinstalled; `packages.system` +rejects explicitly and never invokes apt, sudo or elevated execution. Unsupported hostname forms and installation combinations reject explicitly; see the [Template coverage and limits](environment-templates.md). Empty/null installation defaults produce safe empty metadata, not a live workspace inventory. Service-origin @@ -162,11 +165,11 @@ accepts only local `capability_directories` with `workspace_directory`, not mana Skill/Plugin archive fields or a hosted Template. Local directory discovery does not populate the public API-managed `skills` or `plugins` installation arrays. -Both origins use the packaged Linux Runtime's shared parser and protected -`installed.json`. Its default root is `/environment/initialization/capabilities`; -the operator may freeze another root with `OAC_RUNTIME_CAPABILITY_DIRECTORY`, kept -separate from workspace and private Runtime state. Core and public requests cannot -select this installation root. The manifest binds one +Both origins use the common Runtime parser and `installed.json` on Linux, macOS +and Windows. The operator selects the capability root; the native installer +uses `capabilities` under `OAC_RUNTIME_HOME` by default. Core and public requests +cannot select that destination. Read-only snapshot modes are integrity hints, +not protection from the launching user. The manifest binds one Session and Environment to the source-selection digest. The admitted asynchronous preparation owner verifies or creates it before native execution, including for an empty selection. Reconnect and replacement executors reuse installed contents; @@ -186,11 +189,12 @@ Core does not resolve these paths on its own host. The daemon applies its local path and access checks. Initial files and setup working directories use logical `/workspace` paths. Harness differences remain in adapters. -The protocol is platform-neutral, but this implementation retains the existing Linux -helpers, protected initialization storage and isolation requirements. It does not provide -a new native-host installer, another platform layout, or Windows/macOS isolation. -This implementation description does not extend historical real-deployment acceptance; -new managed and self-hosted capability coverage requires its own evidence. See the +The protocol and Go preparation implementation are shared by all three native +platforms. Resource directories are operator settings, not deployment branches. +Managed Providers remain Linux-only. The daemon does not sandbox tools, files or +network access; tools use the starting account's permissions. Native installation +and current validation limits are in the [native guide](../../docs/self-hosted-native.md). +Historical deployment acceptance does not qualify later platform or capability changes. See the [canonical preparation rules](../../CONTRIBUTING.md#shared-runtime-capability-preparation). ## Contract inventory diff --git a/contracts/agents-api/harnesses.md b/contracts/agents-api/harnesses.md index 5c8976f84..1b77f2d65 100644 --- a/contracts/agents-api/harnesses.md +++ b/contracts/agents-api/harnesses.md @@ -58,8 +58,10 @@ not a different contract or authority level. The documented extension remains se work merely because a connection closed. - Resume only the bound Session's native history. Missing, ambiguous or foreign history fails closed. Device identity is not native Session ownership. -- Native tools and public Files operate on the same authorized workspace. - Generated code cannot access daemon/model credentials or foreign history. +- Native tools and public Files use the same bound workspace. Public Files retains + tenant and path authorization. Native tools run with the starting account's + permissions; daemon/model credentials are not isolated from that same user. + Managed outer Environments must exclude other tenants' resources. - Emit verified measurements; absence of native usage detail is not a zero value. Explicit unsupported operations remain implementation gaps in protocol coverage. diff --git a/contracts/agents-api/mcode-workspace-v1.md b/contracts/agents-api/mcode-workspace-v1.md index 45aaf3b99..7bd3067d6 100644 --- a/contracts/agents-api/mcode-workspace-v1.md +++ b/contracts/agents-api/mcode-workspace-v1.md @@ -1,6 +1,10 @@ # MiniMax Code workspace Runtime qualification -Status: implementation, public qualification and independent review passed, +Historical evidence only: the current daemon runs tools with its starting user's +permissions and no inner sandbox. The isolation probes below apply to the recorded +old binaries, not current native or managed support. + +Status at the recorded revision: implementation, public qualification and independent review passed, 2026-09-20. Baseline main `55502c87c3f722bbefc5449348309e3fcc03a7ee`. Board batch: `MCODE-WORKSPACE-V1-001`, under `ENGINE-EXTENSIBILITY-001`. diff --git a/contracts/agents-api/model-execution.md b/contracts/agents-api/model-execution.md index 244d542a5..2b4969999 100644 --- a/contracts/agents-api/model-execution.md +++ b/contracts/agents-api/model-execution.md @@ -129,8 +129,11 @@ options only over the daemon connection bound to the Session. For `self_hosted`, that is the executor enrolled for the Session's own Environment with a current executor credential of the Session creator's principal; rotation or revocation closes the socket before further dispatch. The executor host stores the bundle in -its native harness home, as hosted Runtimes do; tools and public Files cannot reach -that home. Revocation does not erase a bundle already delivered. +its native harness home, as hosted Runtimes do. Public Files remains scoped to the +bound workspace, but native tools use the starting account's permissions and can +access whatever that user can read. The daemon does not isolate its local +credentials from same-user tools. Revocation does not erase an already delivered +bundle. ## Deployment defaults diff --git a/contracts/agents-api/official-semantics-alignment.md b/contracts/agents-api/official-semantics-alignment.md index a581097d2..ae3500ce5 100644 --- a/contracts/agents-api/official-semantics-alignment.md +++ b/contracts/agents-api/official-semantics-alignment.md @@ -751,7 +751,13 @@ package; both were deleted. The batch plan is | H7 | Delete | 200 `agent.session.deleted`, as officially, then 404. Deletion while provisioning is unchanged (HI-05 awaits a decision). | | H8 | Unchanged | `self_hosted` and `none` Environments, successful initialization and its timing, the two-minute step limit (HI-06), expiry and tenant isolation. | -Decisions: +Historical implementation decisions at the September 23 revision follow. The +current daemon uses shared Go preparation and process settlement, with no bwrap, +Python receipt decoder or old-image compatibility. System packages now reject +before initialization. Safe fixed labels and bounded exit statuses remain the +current public failure policy; see the [current initialization contract](environment-templates.md#packaged-runtime-initialization-contract). + +Recorded decisions: - **No output.** The shared Runtime initializer adds only an integer `exit_code` to its failed receipt, and only for a step run inside its bwrap isolation; diff --git a/contracts/agents-api/user-managed-runtime-v1.md b/contracts/agents-api/user-managed-runtime-v1.md index 52335471c..1c48b6a80 100644 --- a/contracts/agents-api/user-managed-runtime-v1.md +++ b/contracts/agents-api/user-managed-runtime-v1.md @@ -17,7 +17,10 @@ file operations continue through Core and daemon, not E2B commands or files. Historical qualification recorded on 2026-09-22 against source candidate `8f0cd2530d7b58cb7fb3ea124a1fc43dacecca36`. These records qualify only those binaries and tested inputs. Later implementation -changes, including shared Runtime capability preparation, require separate acceptance. +changes, including shared Runtime capability preparation and native-platform +support, require separate acceptance. The current daemon has no inner sandbox; +private-file denial and namespace probes below describe the former tested +implementation, not current tool permissions or an installation requirement. | Deployment | Codex | Claude Code | MiniMax Code | | --- | --- | --- | --- | diff --git a/contracts/agents-api/workspace-placement.md b/contracts/agents-api/workspace-placement.md index 2e02d85ea..e44456128 100644 --- a/contracts/agents-api/workspace-placement.md +++ b/contracts/agents-api/workspace-placement.md @@ -8,9 +8,11 @@ provider. Users manage local or E2B Runtime creation, renewal and destruction through the official SDK. All three harnesses reuse typed `LocalEnvironment`, existing preparation/start/ -cancel ownership and protected local Files/Artifacts. Native credentials and -histories remain inaccessible to generated tools. Strict resume requires retained -history; connectivity alone establishes neither readiness nor isolation. Current +cancel ownership and authorized local Files/Artifacts. Native tools use the +starting account's permissions and may access that account's Runtime credentials +and history. The daemon adds no inner sandbox; managed isolation belongs to the +outer Environment. Strict resume requires retained history; connectivity alone +establishes neither readiness nor isolation. Current implementation is recorded in the [Environment profile](environments.md#initial-public-self-hosted-profile); [real qualification](user-managed-runtime-v1.md) identifies accepted deployments and limits. @@ -23,8 +25,9 @@ hosted Template Plugin MCP retain their separate boundaries. ## Historical two-engine assessment The assessment below records the earlier topology and native prerequisites at its -original scope. It is not current installation guidance or proof of the V1 daemon -enrollment chain. Referenced retired package/probe sources remain in Git history. +original scope. Its inner-sandbox prerequisites and denied private-file probes +are historical only and must not be reinstated as current daemon requirements. +It is not current installation guidance or proof of the current daemon chain. Referenced retired package/probe sources remain in Git history. ### Current implementation and missing prerequisites diff --git a/docs/getting-started/self-hosted.md b/docs/getting-started/self-hosted.md index cc22d12c0..d37ecff09 100644 --- a/docs/getting-started/self-hosted.md +++ b/docs/getting-started/self-hosted.md @@ -11,7 +11,7 @@ filesystem, permission or network sandbox. Its tools may access that user's file and Runtime credentials. Managed isolation belongs to the outer Environment; self-hosted operators choose any outer isolation they need. See the [native installation guide](../self-hosted-native.md) for prerequisites, commands -and the current platform validation status. +and supported platforms. An executor credential works for one Environment only. It cannot call the Agent API, Core API or node enrollment. Default model providers never apply to diff --git a/docs/self-hosted-native.md b/docs/self-hosted-native.md index b536e3811..e21570f2c 100644 --- a/docs/self-hosted-native.md +++ b/docs/self-hosted-native.md @@ -13,23 +13,20 @@ Core creates that outer boundary for managed Docker/E2B/microsandbox Environment The daemon does not enforce `disabled` or `restricted` network modes; combinations without corresponding outer enforcement are unsupported. -## Prerequisites and validation status +## Supported platforms -Use a current-version daemon binary for the host's OS and architecture, and install -the native harness selected by the Session. The daemon installer saves local -configuration; it does not install the harness, create a container, or register an -OS service. Native harness dependencies must already be available to its process. -Claude on Windows requires Git Bash. The MiniMax adapter does not currently support -Windows. +| Platform | Codex | Claude Code | MiniMax Code | +| --- | --- | --- | --- | +| Linux | Supported | Supported | Supported | +| macOS | Supported | Supported | Supported | +| Windows | Supported | Supported | Not supported by the current adapter | -| Platform | Current validation status | -| --- | --- | -| Linux | Existing Runtime platform; the removal of inner sandboxing needs the current change's focused checks | -| macOS | Native daemon lifecycle and Files tests passed with the race detector; native harness validation is in progress | -| Windows | Native CI build and automated execution tests are required and have not yet passed for this change; cross-compilation is not acceptance, and no manual Windows acceptance is recorded | +Native CI passing is the platform support criterion. Windows has not yet had +manual machine acceptance. Managed Providers support Linux only. -These are validation results for the current implementation work, not a claim that -every harness/model combination has passed on every platform. +Use a current-version daemon binary for the host's OS and architecture. The daemon +installer saves local configuration; native harnesses and their dependencies must +already be available to its process. Claude on Windows requires Git Bash. Create a `self_hosted` Session with its model provider and an existing absolute `workspace_directory` on the executor host. Obtain its `environment.id` and @@ -78,8 +75,7 @@ oac-daemon.exe start Replace the example URL and UUID with the Session's actual values. On Windows, keep the credential file under the intended user's normal private storage permissions. -The Windows commands describe the installation interface; they do not replace the -pending native CI validation above. + Optional install flags: @@ -152,6 +148,7 @@ home and Session when moving versions. System dependencies must be present before Runtime execution: install them while building the managed image/template, or prepare them as the self-hosted user. Runtime does not run apt, request sudo or elevate the daemon's privileges. -`system_packages` is unsupported; a missing dependency fails the operation that -requires it. npm/Python package installation and setup retain their supported +`packages.system` is explicitly rejected on Session and Template input, including +an empty or null value; it is never ignored. A missing preinstalled dependency +fails the operation that requires it. npm/Python package installation and setup retain their supported initialization flow using the launching user's existing permissions. From 5c2cd42267da6ce3bdea0a82de656b080f75a6be Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:31:33 +0000 Subject: [PATCH 04/11] fix(runtime): align native smoke names with name guard --- scripts/name-allowlist.json | 13 +++++++++---- scripts/native-harness-smoke.mjs | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/scripts/name-allowlist.json b/scripts/name-allowlist.json index 33861441c..8017f17c6 100644 --- a/scripts/name-allowlist.json +++ b/scripts/name-allowlist.json @@ -224,6 +224,11 @@ "regex": "\"agents-api-(?:session)?\"", "reason": "AgentStateKey is a persisted daemon/native-session resume identity carried on the existing machine wire contract; changing it would select another session." }, + { + "path": "apps/parsar-daemon/internal/localworkspace/native_binding.go", + "regex": "\"agents-api-(?:session)?\"", + "reason": "AgentStateKey is a persisted daemon/native-session resume identity carried on the existing machine wire contract; changing it would select another session." + }, { "path": "apps/parsar-daemon/internal/localworkspace/binding_test.go", "regex": "\"agents-api-(?:session)?\"", @@ -562,22 +567,22 @@ { "path": "apps/web/src/features/sessions/items/ItemRenderers.tsx", "regex": "parseParsarApplyPatch|Parsar apply_patch", - "reason": "The parser identifies the existing product-origin apply_patch event format; renaming private parser symbols is deferred by \u00a79." + "reason": "The parser identifies the existing product-origin apply_patch event format; renaming private parser symbols is deferred by §9." }, { "path": "apps/web/src/features/sessions/items/apply-patch.ts", "regex": "parseParsarApplyPatch|Parsar apply_patch", - "reason": "The parser identifies the existing product-origin apply_patch event format; renaming private parser symbols is deferred by \u00a79." + "reason": "The parser identifies the existing product-origin apply_patch event format; renaming private parser symbols is deferred by §9." }, { "path": "apps/web/src/features/sessions/items/apply-patch.test.tsx", "regex": "parseParsarApplyPatch|Parsar apply_patch", - "reason": "The parser identifies the existing product-origin apply_patch event format; renaming private parser symbols is deferred by \u00a79." + "reason": "The parser identifies the existing product-origin apply_patch event format; renaming private parser symbols is deferred by §9." }, { "path": "apps/web/src/features/sessions/trace/TraceView.tsx", "regex": "parseParsarApplyPatch|Parsar apply_patch", - "reason": "The parser identifies the existing product-origin apply_patch event format; renaming private parser symbols is deferred by \u00a79." + "reason": "The parser identifies the existing product-origin apply_patch event format; renaming private parser symbols is deferred by §9." }, { "path": "apps/web/src/features/sandbox/deployment-specification.test.ts", diff --git a/scripts/native-harness-smoke.mjs b/scripts/native-harness-smoke.mjs index 76d48c757..26227b29a 100644 --- a/scripts/native-harness-smoke.mjs +++ b/scripts/native-harness-smoke.mjs @@ -71,7 +71,7 @@ async function codexSmoke(root) { } finally { clearTimeout(timer); pending = undefined; } }; try { - await request('initialize', { clientInfo: { name: 'parsar-native-smoke', version: '1' }, capabilities: { experimentalApi: true } }); + await request('initialize', { clientInfo: { name: 'oac-native-smoke', version: '1' }, capabilities: { experimentalApi: true } }); child.stdin.write(JSON.stringify({ method: 'initialized' }) + '\n'); const result = await request('thread/start', { cwd: workspace, approvalPolicy: 'never', sandbox: 'danger-full-access', persistExtendedHistory: true }); if (typeof result?.thread?.id !== 'string' || !result.thread.id) throw failure('codex_thread_identity'); @@ -101,7 +101,7 @@ async function claudeSmoke() { return { status: 'passed', sdk: info.sdk, native: info.native }; } -const root = await realpath(await mkdtemp(join(tmpdir(), 'parsar-native-smoke-'))); +const root = await realpath(await mkdtemp(join(tmpdir(), 'oac-native-smoke-'))); const report = { platform: process.platform, arch: process.arch, model_requests: 0 }; try { for (const [name, run] of [['codex', () => codexSmoke(root)], ['claude', claudeSmoke]]) { From ae13b0a6c401b71986aea4cf29a0bc7f5870ca5e Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:43:03 +0000 Subject: [PATCH 05/11] fix(runtime): own Codex descendants and remove retired file helpers --- .github/workflows/agents-executor.yml | 49 -- .github/workflows/check.yml | 11 - .github/workflows/native-daemon.yml | 7 + .github/workflows/release.yml | 5 +- CONTRIBUTING.md | 27 +- Makefile | 10 +- .../internal/agent/claudesdk/local.go | 2 +- .../agent/claudesdk/tool_environment_test.go | 34 + .../internal/agent/claudesdk/workspace.go | 8 +- .../internal/agent/codex/executor_test.go | 14 +- .../agent/codex/permission_profile_test.go | 4 +- .../agent/codex/preparation_router_test.go | 9 +- .../parsar-daemon/internal/agent/codex/rpc.go | 46 +- .../internal/agent/codex/rpc_close.go | 12 +- .../internal/agent/codex/rpc_close_test.go | 19 +- .../agent/codex/rpc_descendants_test.go | 148 ++++ .../internal/agent/codex/session_plan.go | 9 +- .../agent/mcode/tool_environment_test.go | 40 ++ .../internal/agent/mcode/workspace.go | 10 +- apps/parsar-daemon/internal/cli/claude_sdk.go | 2 +- .../cli/connect_environment_binding.go | 2 +- .../cli/connect_environment_binding_test.go | 7 +- .../internal/cli/mcode_workspace.go | 2 +- .../internal/cli/retired_configuration.go | 4 - .../internal/dispatch/local_directory_test.go | 8 +- .../internal/dispatch/preparation_test.go | 9 - .../dispatch/runtime_preparation_test.go | 6 +- .../dispatch/workspace_export_test.go | 2 +- .../internal/dispatch/workspace_write_test.go | 2 +- .../internal/localworkspace/binding.go | 48 +- .../internal/localworkspace/binding_test.go | 51 +- .../capability_preparation_test.go | 2 +- .../internal/localworkspace/directory.go | 38 - .../internal/localworkspace/write.go | 46 +- .../internal/localworkspace/write_binding.go | 39 +- .../internal/localworkspace/write_test.go | 57 -- contracts/agents-api/environment-files.md | 20 +- packages/codex-executor/Cargo.lock | 655 ------------------ packages/codex-executor/Cargo.toml | 34 - packages/codex-executor/README.md | 160 ----- packages/codex-executor/rust-toolchain.toml | 3 - packages/codex-executor/src/bin/directory.rs | 266 ------- packages/codex-executor/src/bin/export.rs | 20 - packages/codex-executor/src/bin/write.rs | 103 --- packages/codex-executor/src/directory.rs | 68 -- .../codex-executor/src/directory_tests.rs | 154 ---- packages/codex-executor/src/export.rs | 145 ---- packages/codex-executor/src/export_tests.rs | 263 ------- packages/codex-executor/src/workspace_path.rs | 69 -- packages/codex-executor/src/write_file.rs | 312 --------- .../codex-executor/src/write_file_tests.rs | 494 ------------- provenance/README.md | 1 - scripts/build-agents-executor.sh | 42 -- scripts/build-agents-runtime.sh | 6 +- scripts/build-claude-runtime.sh | 7 +- scripts/build-core-distribution.sh | 4 +- scripts/build-mcode-runtime.sh | 7 +- scripts/check-agents-executor.sh | 21 - scripts/core-distribution-manifest.py | 6 +- scripts/name-allowlist.json | 10 - services/agents-api/deploy/claude/Dockerfile | 8 +- services/agents-api/deploy/codex/Dockerfile | 8 +- services/agents-api/deploy/codex/README.md | 3 +- services/agents-api/deploy/mcode/Dockerfile | 8 +- 64 files changed, 339 insertions(+), 3347 deletions(-) delete mode 100644 .github/workflows/agents-executor.yml create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/tool_environment_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/rpc_descendants_test.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/tool_environment_test.go delete mode 100644 packages/codex-executor/Cargo.lock delete mode 100644 packages/codex-executor/Cargo.toml delete mode 100644 packages/codex-executor/README.md delete mode 100644 packages/codex-executor/rust-toolchain.toml delete mode 100644 packages/codex-executor/src/bin/directory.rs delete mode 100644 packages/codex-executor/src/bin/export.rs delete mode 100644 packages/codex-executor/src/bin/write.rs delete mode 100644 packages/codex-executor/src/directory.rs delete mode 100644 packages/codex-executor/src/directory_tests.rs delete mode 100644 packages/codex-executor/src/export.rs delete mode 100644 packages/codex-executor/src/export_tests.rs delete mode 100644 packages/codex-executor/src/workspace_path.rs delete mode 100644 packages/codex-executor/src/write_file.rs delete mode 100644 packages/codex-executor/src/write_file_tests.rs delete mode 100755 scripts/build-agents-executor.sh delete mode 100755 scripts/check-agents-executor.sh diff --git a/.github/workflows/agents-executor.yml b/.github/workflows/agents-executor.yml deleted file mode 100644 index 8a6db4b26..000000000 --- a/.github/workflows/agents-executor.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: agents-executor - -on: - push: - branches: [main] - paths: - - 'packages/codex-executor/**' - - 'scripts/*agents-executor.sh' - - '.github/workflows/agents-executor.yml' - - 'Makefile' - pull_request: - paths: - - 'packages/codex-executor/**' - - 'scripts/*agents-executor.sh' - - '.github/workflows/agents-executor.yml' - - 'Makefile' - -permissions: - contents: read - -concurrency: - group: agents-executor-${{ github.ref }} - cancel-in-progress: true - -jobs: - native-build: - runs-on: ubuntu-22.04 - timeout-minutes: 30 - env: - CARGO_HOME: /home/runner/.oac/cache/executor-cargo - CARGO_TARGET_DIR: /home/runner/.oac/cache/executor-target - CARGO_BUILD_JOBS: 4 - steps: - - uses: actions/checkout@v7 - - name: Install native build prerequisites - run: | - sudo apt-get update - sudo apt-get install -y build-essential pkg-config libssl-dev - rustup toolchain install 1.95.0 --profile minimal --component rustfmt --component clippy - - uses: actions/cache@v6 - with: - path: | - ~/.oac/cache/executor-cargo - ~/.oac/cache/executor-target - key: agents-executor-${{ runner.os }}-1.95.0-${{ hashFiles('packages/codex-executor/Cargo.lock') }} - - name: Check shared workspace helpers and independent release build - run: | - make check-agents-executor - make build-agents-executor diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 70d0851f2..cf729e58e 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -30,10 +30,6 @@ jobs: --health-interval 5s --health-timeout 5s --health-retries 10 - env: - CARGO_HOME: /home/runner/.oac/cache/executor-cargo - CARGO_TARGET_DIR: /home/runner/.oac/cache/executor-target - CARGO_BUILD_JOBS: 4 steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 @@ -47,13 +43,6 @@ jobs: npm install --global pnpm@10.30.3 sudo apt-get update sudo apt-get install -y build-essential pkg-config libssl-dev - rustup toolchain install 1.95.0 --profile minimal --component rustfmt --component clippy - - uses: actions/cache@v6 - with: - path: | - ~/.oac/cache/executor-cargo - ~/.oac/cache/executor-target - key: core-executor-${{ runner.os }}-${{ hashFiles('packages/codex-executor/Cargo.lock') }} - name: Install Web acceptance browser run: | pnpm install --frozen-lockfile diff --git a/.github/workflows/native-daemon.yml b/.github/workflows/native-daemon.yml index 65c667800..d06ac2592 100644 --- a/.github/workflows/native-daemon.yml +++ b/.github/workflows/native-daemon.yml @@ -33,6 +33,13 @@ jobs: ./apps/parsar-daemon/internal/paths ./apps/parsar-daemon/internal/daemonize ./apps/parsar-daemon/internal/agent/clirunner + - name: Native Harness ownership and environment identity + run: >- + go test -race -count=1 + ./apps/parsar-daemon/internal/agent/codex + ./apps/parsar-daemon/internal/agent/claudesdk + ./apps/parsar-daemon/internal/agent/mcode + -run 'TestJSONRPCClientOwnsToolDescendants|TestSelfHostedToolEnvironment' - name: Native capability snapshots, files and installation run: >- go test -race -count=1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e3b26da7d..9697fb88a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,15 +70,12 @@ jobs: npm install --global pnpm@10.30.3 sudo apt-get update sudo apt-get install -y build-essential pkg-config libssl-dev - rustup toolchain install 1.95.0 --profile minimal - uses: actions/cache@v6 with: path: | ~/.oac/cache/go-build ~/.oac/cache/go-mod - ~/.oac/cache/executor-cargo - ~/.oac/cache/executor-target - key: core-release-${{ runner.os }}-${{ hashFiles('go.sum', 'packages/codex-executor/Cargo.lock') }} + key: core-release-${{ runner.os }}-${{ hashFiles('go.sum') }} - name: Check release metadata and prepare pinned harnesses run: | PYTHONDONTWRITEBYTECODE=1 python3 scripts/core-distribution-manifest.test.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57272fdcf..ef30cdb14 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -309,11 +309,9 @@ of product routes. Core changes must retain the independent build and official-client workflow. Native adapter changes require their applicable build/check targets and live provider acceptance. Real execution checks require real models; do not count omitted -prerequisites or mocked responses as live acceptance. `packages/codex-executor` -retains only the directory, write and workspace-export Rust helpers. Its build and -check targets remain; the separate `packages/codex-harness`, its build/check scripts -and its CI/`make check` gate are retired. Historical remote native probes are not -current validation entrypoints. +prerequisites or mocked responses as live acceptance. Workspace Files run in the +daemon's portable Go implementation and are covered by its tests. Historical +remote native probes are not current validation entrypoints. ## Core operational metrics @@ -1441,21 +1439,18 @@ capability advertisement alone does not qualify an operator's deployment. Exporter component checks do not establish public Artifact compatibility. Local inline file delivery uses the same authenticated daemon connection and exact -Environment/Session binding. The optional startup-owned `OAC_RUNTIME_WRITE_HELPER` -and `OAC_RUNTIME_STAGING` enable only the bounded installer primitive; they do -not grant public feature admission. Require a canonical executable outside the -Environment parent, canonical sibling workspace/staging directories on one mount, -and verified native tool denial of staging and its ancestors. Native credentials -and history remain outside that parent. Mode bits and path checks alone do not -qualify this layout. The read-only deployment needs neither writer setting. +Environment/Session binding. All platforms use the daemon's Go implementation +for bounded file reads, directory listing, file creation and output export. +Files operations require no external helper executable or staging directory. +The Files API keeps workspace-relative paths and no-overwrite creation semantics; +it does not restrict native Harness tools' host permissions. Transfer a complete bounded body in acknowledged 64 KiB frames before invoking -the existing installer, verify the declared digest, and run no model for upload. +the native file writer, verify the declared digest, and run no model for upload. Keep the private 50 MiB transfer bound distinct from the official 5 MiB decoded inline bound, which the API checks before any Runtime work. Files.create uses the -installer's explicit create mode: parents are created without following links and -an existing path is never replaced. Initial Session files and Skills keep the -installer's replace mode; do not change one caller's mode for another. +native writer's no-overwrite operation. Initial Session files retain their separate +atomic replacement behavior; do not change one caller's semantics for another. The dedicated Runtime excludes execution while receiving or applying a write; malformed, incomplete or expired transfers cannot reach the installer. Exact commit/rejection receipts release the mutation owner. Missing or ambiguous diff --git a/Makefile b/Makefile index cf4c9f4cf..23c43bda5 100644 --- a/Makefile +++ b/Makefile @@ -3,12 +3,12 @@ SQLC_VERSION ?= v1.29.0 SQLC ?= go run github.com/sqlc-dev/sqlc/cmd/sqlc@$(SQLC_VERSION) SWAG_VERSION ?= v1.16.4 -.PHONY: help check check-database check-go check-sqlc sqlc-generate node-deps check-claude-sdk check-web check-mcode-harness build-daemon build-agents-api build-agents-api-release check-agents-api docker-build-agents-api check-agents-api-container build-agents-executor check-agents-executor build-agents-runtime build-claude-runtime build-claude-sdk-runtime build-mcode-harness build-mcode-runtime +.PHONY: help check check-database check-go check-sqlc sqlc-generate node-deps check-claude-sdk check-web check-mcode-harness build-daemon build-agents-api build-agents-api-release check-agents-api docker-build-agents-api check-agents-api-container build-agents-runtime build-claude-runtime build-claude-sdk-runtime build-mcode-harness build-mcode-runtime help: @printf '%s\n' 'make build-agents-api Build standalone Core commands' 'make build-daemon Build the execution daemon' 'make check Run Core, persistence and runtime checks' 'See README.md for runtime prerequisites and deployment.' -check: check-docs check-names check-distribution check-database check-sqlc check-go check-microsandbox-provider check-agents-api check-claude-sdk check-web check-mcode-harness check-agents-executor +check: check-docs check-names check-distribution check-database check-sqlc check-go check-microsandbox-provider check-agents-api check-claude-sdk check-web check-mcode-harness @printf 'OpenAgentCore checks passed.\n' .PHONY: check-names @@ -95,12 +95,6 @@ check-mcode-harness: @for script in packages/mcode-harness/*.mjs; do node --check "$$script"; done bash -n scripts/build-mcode-harness.sh scripts/build-mcode-runtime.sh -build-agents-executor: - ./scripts/build-agents-executor.sh - -check-agents-executor: - ./scripts/check-agents-executor.sh - build-agents-runtime: ./scripts/build-agents-runtime.sh diff --git a/apps/parsar-daemon/internal/agent/claudesdk/local.go b/apps/parsar-daemon/internal/agent/claudesdk/local.go index 35a3d6f42..8c4e043f5 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/local.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/local.go @@ -11,7 +11,7 @@ import ( // ConfigureLocal selects the qualified, dedicated Runtime layout. The shared // localworkspace binding still authorizes every request against its Session. -func ConfigureLocal(config Config, root, workspace string, network agentnetwork.Policy, staging string) (Config, error) { +func ConfigureLocal(config Config, root, workspace string, network agentnetwork.Policy) (Config, error) { config.StateDir = filepath.Join(root, "runtime", "claude-sdk", "history") config.Workspace = &WorkspaceConfig{ Directory: workspace, PublicDirectory: workspace, NetworkAccess: network.Access, AllowedDomains: network.Hosts(), diff --git a/apps/parsar-daemon/internal/agent/claudesdk/tool_environment_test.go b/apps/parsar-daemon/internal/agent/claudesdk/tool_environment_test.go new file mode 100644 index 000000000..23db27770 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/tool_environment_test.go @@ -0,0 +1,34 @@ +package claudesdk + +import ( + "os" + "path/filepath" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestSelfHostedToolEnvironment(t *testing.T) { + config := workspaceFixture(t) + config.Workspace.NetworkAccess = "enabled" + file := filepath.Join(t.TempDir(), "tool-env.json") + if err := os.WriteFile(file, []byte(`{"USER_VALUE":"ready"}`), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", file) + req := workspaceRequest() + req.LocalEnvironment = &proto.LocalEnvironment{NetworkAccess: "enabled"} + profile, _, err := prepareWorkspace(config, req) + if err != nil { + t.Fatal(err) + } + if profile.ToolEnv["USER_VALUE"] != "ready" { + t.Fatal("self-hosted tool configuration was not applied") + } + if err := os.WriteFile(file, []byte(`[]`), 0600); err != nil { + t.Fatal(err) + } + if _, _, err := prepareWorkspace(config, req); err == nil { + t.Fatal("invalid explicit tool configuration was ignored") + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go index c6cc95c09..07ebad94a 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go @@ -53,12 +53,8 @@ func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspace if err != nil { return nil, nil, err } - if req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment { - binding, loadErr := localworkspace.Load() - if loadErr != nil || binding == nil { - return nil, nil, fmt.Errorf("claudesdk: native Runtime binding unavailable") - } - profile.ToolEnv, err = binding.ReadToolEnvironment() + if req.LocalEnvironment != nil { + profile.ToolEnv, err = localworkspace.ReadOptionalToolEnvironment() if err != nil { return nil, nil, err } diff --git a/apps/parsar-daemon/internal/agent/codex/executor_test.go b/apps/parsar-daemon/internal/agent/codex/executor_test.go index f6c9d56fa..69ca2b173 100644 --- a/apps/parsar-daemon/internal/agent/codex/executor_test.go +++ b/apps/parsar-daemon/internal/agent/codex/executor_test.go @@ -4,13 +4,13 @@ import ( "context" "errors" "os" - "os/exec" "sync" "sync/atomic" "testing" "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) @@ -165,19 +165,15 @@ func TestExecutorStartErrorsRetainExactOwnership(t *testing.T) { } func TestExecutorCloseRetainsPlanUntilReaped(t *testing.T) { - cmd := exec.Command(os.Args[0], "-test.run=^TestJSONRPCClientFakeCodexProcess$", "--") - cmd.Env = append(os.Environ(), "CODEX_RPC_FAKE_PROCESS=1", "GORACE=atexit_sleep_ms=0") - stdin, err := cmd.StdinPipe() + process, err := clirunner.Start(clirunner.StartOptions{Parent: t.Context(), Binary: os.Args[0], Args: []string{"-test.run=^TestJSONRPCClientFakeCodexProcess$", "--"}, Env: append(os.Environ(), "CODEX_RPC_FAKE_PROCESS=1", "GORACE=atexit_sleep_ms=0"), NeedStdin: true, OwnProcessGroup: true}) if err != nil { t.Fatal(err) } - if err = cmd.Start(); err != nil { - t.Fatal(err) - } rpc := NewJSONRPCClient(JSONRPCConfig{}) - rpc.cmd, rpc.stdin, rpc.alive = cmd, stdin, true + rpc.process, rpc.cmd, rpc.stdin, rpc.alive = process, process.Cmd, process.Stdin, true + var reap sync.Once - t.Cleanup(func() { _ = cmd.Process.Kill(); reap.Do(rpc.waitChild) }) + t.Cleanup(func() { process.Cancel(); reap.Do(rpc.waitChild) }) _, cancel := context.WithCancel(t.Context()) var cleaned atomic.Bool e := &Executor{prepared: &Prepared{session: &Session{rpc: rpc, cancelFn: cancel}, plan: SessionPlan{Cleanup: func() { cleaned.Store(true) }}}} diff --git a/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go b/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go index 3d597d6a8..e8ec49b35 100644 --- a/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go +++ b/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go @@ -34,7 +34,7 @@ func TestRuntimeUsesHostPermissions(t *testing.T) { } } -func TestInitializedEnvironmentCannotRedirectNativeHistory(t *testing.T) { +func TestSelfHostedToolEnvironmentCannotRedirectNativeHistory(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) workspace := filepath.Join(root, "workspace") @@ -48,7 +48,7 @@ func TestInitializedEnvironmentCannotRedirectNativeHistory(t *testing.T) { for key, value := range map[string]string{"OAC_RUNTIME_ENVIRONMENT_ID": "b3d154b8-543b-4248-97b1-665f9f418d52", "OAC_RUNTIME_SESSION_ID": "33e02e0d-6fc8-4904-9d7a-4b61b9094ae0", "OAC_RUNTIME_WORKSPACE": workspace, "OAC_RUNTIME_CAPABILITY_DIRECTORY": filepath.Join(root, "capabilities"), "OAC_RUNTIME_NETWORK_ACCESS": "enabled", "OAC_RUNTIME_TOOL_ENV_FILE": config} { t.Setenv(key, value) } - req := proto.PromptRequestPayload{AgentStateKey: "session", DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{NetworkAccess: "enabled", ToolEnvironment: true}} + req := proto.PromptRequestPayload{AgentStateKey: "session", DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{NetworkAccess: "enabled"}} plan, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{}) if err != nil { t.Fatal(err) diff --git a/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go b/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go index fbf5dcbbe..4078d3b99 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go @@ -41,11 +41,10 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { t.Fatal(err) } for key, value := range map[string]string{ - "OAC_RUNTIME_ENVIRONMENT_ID": environment, - "OAC_RUNTIME_SESSION_ID": session, - "OAC_RUNTIME_WORKSPACE": req.WorkDir, - "OAC_RUNTIME_DIRECTORY_HELPER": cfg.codexBinary, - "OAC_RUNTIME_NETWORK_ACCESS": "enabled", + "OAC_RUNTIME_ENVIRONMENT_ID": environment, + "OAC_RUNTIME_SESSION_ID": session, + "OAC_RUNTIME_WORKSPACE": req.WorkDir, + "OAC_RUNTIME_NETWORK_ACCESS": "enabled", } { t.Setenv(key, value) } diff --git a/apps/parsar-daemon/internal/agent/codex/rpc.go b/apps/parsar-daemon/internal/agent/codex/rpc.go index d078c4588..00a92d5c7 100644 --- a/apps/parsar-daemon/internal/agent/codex/rpc.go +++ b/apps/parsar-daemon/internal/agent/codex/rpc.go @@ -14,6 +14,8 @@ import ( "sync" "time" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" ) @@ -79,10 +81,11 @@ type JSONRPCConfig struct { type JSONRPCClient struct { cfg JSONRPCConfig - cmd *exec.Cmd - stdin io.WriteCloser - stdout io.ReadCloser - stderr io.ReadCloser + process *clirunner.Process + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + stderr io.ReadCloser mu sync.Mutex alive bool @@ -178,33 +181,17 @@ func (c *JSONRPCClient) Start(ctx context.Context, init InitializeParams) (Initi args = append(args, "--disable", f) } - cmd := exec.CommandContext(ctx, c.cfg.Binary, args...) - cmd.Dir = c.cfg.Cwd - if len(c.cfg.Env) > 0 { - cmd.Env = append([]string{}, c.cfg.Env...) - } - - stdin, err := cmd.StdinPipe() - if err != nil { - return InitializeResult{}, fmt.Errorf("codex rpc: stdin pipe: %w", err) - } - stdout, err := cmd.StdoutPipe() - if err != nil { - return InitializeResult{}, fmt.Errorf("codex rpc: stdout pipe: %w", err) - } - stderr, err := cmd.StderrPipe() + process, err := clirunner.Start(clirunner.StartOptions{ + Parent: ctx, Binary: c.cfg.Binary, Args: args, Dir: c.cfg.Cwd, Env: c.cfg.Env, + NeedStdin: true, OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond, + }) if err != nil { - return InitializeResult{}, fmt.Errorf("codex rpc: stderr pipe: %w", err) - } - if err := cmd.Start(); err != nil { return InitializeResult{}, fmt.Errorf("codex rpc: spawn %q: %w", c.cfg.Binary, err) } - - c.cmd = cmd - c.stdin = stdin - c.stdout = stdout - c.stderr = stderr c.mu.Lock() + c.process = process + c.cmd = process.Cmd + c.stdin, c.stdout, c.stderr = process.Stdin, process.Stdout, process.Stderr c.alive = true c.mu.Unlock() @@ -487,7 +474,10 @@ func (c *JSONRPCClient) pumpStderr() { func (c *JSONRPCClient) waitChild() { defer close(c.doneCh) - err := c.cmd.Wait() + // The common owner reaps the leader and terminates its descendants independently. + // Drain complete RPC frames before Wait closes the owned output handles. + c.readers.Wait() + err := c.process.Wait() c.mu.Lock() c.alive = false if c.cmd.ProcessState != nil { diff --git a/apps/parsar-daemon/internal/agent/codex/rpc_close.go b/apps/parsar-daemon/internal/agent/codex/rpc_close.go index 94901ad7e..8d55e0e29 100644 --- a/apps/parsar-daemon/internal/agent/codex/rpc_close.go +++ b/apps/parsar-daemon/internal/agent/codex/rpc_close.go @@ -7,32 +7,32 @@ import ( "time" ) -// Close initiates shutdown once and waits for the owned child to be reaped. +// Close waits for the common owner to settle the native process and descendants. func (c *JSONRPCClient) Close() error { defer func() { _ = c.drainPending(errors.New("codex rpc: client closed")) }() c.closeOnce.Do(func() { c.mu.Lock() - cmd := c.cmd + process := c.process stdin := c.stdin c.alive = false c.mu.Unlock() if stdin != nil { _ = stdin.Close() } - if cmd != nil && cmd.Process != nil { + if process != nil { grace := time.NewTimer(250 * time.Millisecond) defer grace.Stop() select { case <-c.doneCh: case <-grace.C: - _ = cmd.Process.Kill() + process.Cancel() } } }) c.mu.Lock() - cmd := c.cmd + process := c.process c.mu.Unlock() - if cmd == nil || cmd.Process == nil { + if process == nil { return nil } wait := time.NewTimer(rpcKillTimeout) diff --git a/apps/parsar-daemon/internal/agent/codex/rpc_close_test.go b/apps/parsar-daemon/internal/agent/codex/rpc_close_test.go index 9aed5ef85..cc4c18ebb 100644 --- a/apps/parsar-daemon/internal/agent/codex/rpc_close_test.go +++ b/apps/parsar-daemon/internal/agent/codex/rpc_close_test.go @@ -6,33 +6,26 @@ import ( "errors" "io" "os" - "os/exec" "sync" "sync/atomic" "testing" "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" ) func TestJSONRPCClientCloseCanRetryUnreapedChild(t *testing.T) { - cmd := exec.Command(os.Args[0], "-test.run=^TestJSONRPCClientFakeCodexProcess$", "--") - cmd.Env = append(os.Environ(), "CODEX_RPC_FAKE_PROCESS=1", "GORACE=atexit_sleep_ms=0") - stdin, err := cmd.StdinPipe() - if err != nil { - t.Fatal(err) - } - stdout, err := cmd.StdoutPipe() + process, err := clirunner.Start(clirunner.StartOptions{Parent: t.Context(), Binary: os.Args[0], Args: []string{"-test.run=^TestJSONRPCClientFakeCodexProcess$", "--"}, Env: append(os.Environ(), "CODEX_RPC_FAKE_PROCESS=1", "GORACE=atexit_sleep_ms=0"), NeedStdin: true, OwnProcessGroup: true}) if err != nil { t.Fatal(err) } - if err := cmd.Start(); err != nil { - t.Fatal(err) - } + cmd, stdin, stdout := process.Cmd, process.Stdin, process.Stdout client := NewJSONRPCClient(JSONRPCConfig{}) input := &countedCloseWriter{WriteCloser: stdin} - client.cmd, client.stdin, client.stdout, client.alive = cmd, input, stdout, true + client.process, client.cmd, client.stdin, client.stdout, client.alive = process, cmd, input, stdout, true var reap sync.Once t.Cleanup(func() { - _ = cmd.Process.Kill() + process.Cancel() reap.Do(client.waitChild) }) if _, err := io.WriteString(stdin, "{\"id\":\"close-test\"}\n"); err != nil { diff --git a/apps/parsar-daemon/internal/agent/codex/rpc_descendants_test.go b/apps/parsar-daemon/internal/agent/codex/rpc_descendants_test.go new file mode 100644 index 000000000..4e76bbc0e --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/rpc_descendants_test.go @@ -0,0 +1,148 @@ +package codex + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + stdstrconv "strconv" + "testing" + "time" +) + +func TestJSONRPCClientOwnsToolDescendants(t *testing.T) { + for _, mode := range []string{"close", "owner-cancel", "leader-exit", "initialize-error"} { + t.Run(mode, func(t *testing.T) { + dir := t.TempDir() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + client := NewJSONRPCClient(JSONRPCConfig{Binary: os.Args[0], ExtraArgs: []string{"-test.run=^TestJSONRPCDescendantHelper$", "--"}, Env: append(os.Environ(), "OAC_RPC_DESCENDANT=leader", "OAC_RPC_DESCENDANT_DIR="+dir, "OAC_RPC_DESCENDANT_MODE="+mode, "GORACE=atexit_sleep_ms=0")}) + t.Cleanup(func() { _ = client.Close() }) + _, err := client.Start(ctx, InitializeParams{}) + if mode == "initialize-error" { + if err == nil { + t.Fatal("initialization error accepted") + } + } else if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(dir, "child.pid")) + if err != nil { + t.Fatal(err) + } + pid, err := stdstrconv.Atoi(string(raw)) + if err != nil { + t.Fatal(err) + } + child, err := os.FindProcess(pid) + if err == nil { + t.Cleanup(func() { + if t.Failed() { + _ = child.Kill() + } + _ = child.Release() + }) + } + switch mode { + case "close": + if err := client.Close(); err != nil { + t.Fatal(err) + } + case "owner-cancel": + cancel() + case "leader-exit": + if err := client.Notify("exit", nil); err != nil { + t.Fatal(err) + } + } + select { + case <-client.Done(): + case <-time.After(5 * time.Second): + t.Fatal("native ownership did not settle") + } + if err := client.Close(); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(filepath.Join(dir, "effects")) + if err != nil || len(before) == 0 { + t.Fatal("descendant did not begin work", err) + } + time.Sleep(100 * time.Millisecond) + after, err := os.ReadFile(filepath.Join(dir, "effects")) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("tool descendant continued after native cleanup", err) + } + }) + } +} + +func TestJSONRPCDescendantHelper(t *testing.T) { + role := os.Getenv("OAC_RPC_DESCENDANT") + if role == "" { + return + } + dir := os.Getenv("OAC_RPC_DESCENDANT_DIR") + if role == "child" { + f, err := os.OpenFile(filepath.Join(dir, "effects"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + os.Exit(2) + } + defer f.Close() + for i := 0; i < 3000; i++ { + if _, err := f.WriteString("effect\n"); err != nil { + os.Exit(3) + } + time.Sleep(10 * time.Millisecond) + } + os.Exit(0) + } + child := exec.Command(os.Args[0], "-test.run=^TestJSONRPCDescendantHelper$", "--") + child.Env = append(os.Environ(), "OAC_RPC_DESCENDANT=child") + // Retain the RPC pipes as real native tool children can do. + child.Stdout, child.Stderr = os.Stdout, os.Stderr + if child.Start() != nil { + os.Exit(4) + } + if os.WriteFile(filepath.Join(dir, "child.pid"), []byte(stdstrconv.Itoa(child.Process.Pid)), 0600) != nil { + os.Exit(5) + } + deadline := time.Now().Add(3 * time.Second) + for { + if info, err := os.Stat(filepath.Join(dir, "effects")); err == nil && info.Size() > 0 { + break + } + if time.Now().After(deadline) { + os.Exit(6) + } + time.Sleep(time.Millisecond) + } + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + var request struct { + ID string `json:"id"` + Method string `json:"method"` + } + if json.Unmarshal(scanner.Bytes(), &request) != nil { + os.Exit(7) + } + if request.Method == "exit" { + os.Exit(0) + } + if request.ID != "" { + response := map[string]any{"id": request.ID, "result": map[string]any{}} + if os.Getenv("OAC_RPC_DESCENDANT_MODE") == "initialize-error" { + delete(response, "result") + response["error"] = map[string]any{"code": -32603, "message": "fixture initialization failure"} + } + body, _ := json.Marshal(response) + fmt.Println(string(body)) + } + } + // Leave the leader alive after stdin closes to exercise explicit cancellation. + time.Sleep(30 * time.Second) + os.Exit(0) +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_plan.go b/apps/parsar-daemon/internal/agent/codex/session_plan.go index 263aa3bd9..4dad48a69 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_plan.go +++ b/apps/parsar-daemon/internal/agent/codex/session_plan.go @@ -36,13 +36,8 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg plan.ApprovalPolicy = AskForApproval{String: "never"} } - if req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment { - binding, err := localworkspace.Load() - if err != nil || binding == nil { - plan.Cleanup() - return SessionPlan{}, nil, fmt.Errorf("codex: native Runtime binding unavailable") - } - values, err := binding.ReadToolEnvironment() + if req.LocalEnvironment != nil { + values, err := localworkspace.ReadOptionalToolEnvironment() if err != nil { plan.Cleanup() return SessionPlan{}, nil, err diff --git a/apps/parsar-daemon/internal/agent/mcode/tool_environment_test.go b/apps/parsar-daemon/internal/agent/mcode/tool_environment_test.go new file mode 100644 index 000000000..420cdf980 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/tool_environment_test.go @@ -0,0 +1,40 @@ +package mcode + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestSelfHostedToolEnvironment(t *testing.T) { + config, req, _ := workspaceFixture(t) + file := filepath.Join(t.TempDir(), "tool-env.json") + if err := os.WriteFile(file, []byte(`{"USER_VALUE":"ready"}`), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", file) + opts, err := prepareWorkspaceOptions(t.Context(), config, req) + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(opts.DataDir, "workspace-profile.json")) + if err != nil { + t.Fatal(err) + } + var profile struct { + ToolEnv map[string]string `json:"toolEnv"` + } + if err := json.Unmarshal(raw, &profile); err != nil { + t.Fatal(err) + } + if profile.ToolEnv["USER_VALUE"] != "ready" { + t.Fatal("self-hosted tool configuration was not applied") + } + if err := os.WriteFile(file, []byte(`[]`), 0600); err != nil { + t.Fatal(err) + } + if _, err := prepareWorkspaceOptions(t.Context(), config, req); err == nil { + t.Fatal("invalid explicit tool configuration was ignored") + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace.go b/apps/parsar-daemon/internal/agent/mcode/workspace.go index 9af591662..5a5e1e327 100644 --- a/apps/parsar-daemon/internal/agent/mcode/workspace.go +++ b/apps/parsar-daemon/internal/agent/mcode/workspace.go @@ -19,7 +19,7 @@ type WorkspaceConfig struct { AllowedDomains []string } -func ConfigureLocal(binary, node, bridge, root, workspace string, network agentnetwork.Policy, staging string) (WorkspaceConfig, error) { +func ConfigureLocal(binary, node, bridge, root, workspace string, network agentnetwork.Policy) (WorkspaceConfig, error) { c := WorkspaceConfig{Binary: binary, Node: node, Bridge: bridge, Directory: workspace, Network: network.Access, AllowedDomains: network.Hosts(), Scratch: filepath.Join(root, "runtime", "mcode-tools", "scratch")} if runtime.GOOS == "windows" || network.Access != "enabled" || len(network.AllowedDomains) != 0 { @@ -109,12 +109,8 @@ func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.P profile := map[string]any{"capabilityRoot": req.LocalEnvironment.CapabilityRoot, "workspace": "/workspace", "scratch": c.Scratch, "network": c.Network, "allowedDomains": (agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Hosts(), "skills": len(req.LocalEnvironment.Skills) > 0} profile["workspace"] = c.Directory - if req.LocalEnvironment.ToolEnvironment { - binding, err := localworkspace.Load() - if err != nil || binding == nil { - return opts, fmt.Errorf("mcode: Runtime binding unavailable") - } - values, err := binding.ReadToolEnvironment() + { + values, err := localworkspace.ReadOptionalToolEnvironment() if err != nil { return opts, err } diff --git a/apps/parsar-daemon/internal/cli/claude_sdk.go b/apps/parsar-daemon/internal/cli/claude_sdk.go index 88b0e461f..0b552bf2f 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk.go @@ -73,7 +73,7 @@ func discoverClaudeSDK(rc *runContext, profile string, check func(context.Contex if err != nil { return fail(err) } - out.Config, err = claudesdk.ConfigureLocal(out.Config, root, os.Getenv("OAC_RUNTIME_WORKSPACE"), binding.NetworkPolicy(), os.Getenv("OAC_RUNTIME_STAGING")) + out.Config, err = claudesdk.ConfigureLocal(out.Config, root, os.Getenv("OAC_RUNTIME_WORKSPACE"), binding.NetworkPolicy()) if err != nil { return fail(err) } diff --git a/apps/parsar-daemon/internal/cli/connect_environment_binding.go b/apps/parsar-daemon/internal/cli/connect_environment_binding.go index 9d79bed2f..df771a66f 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment_binding.go +++ b/apps/parsar-daemon/internal/cli/connect_environment_binding.go @@ -76,7 +76,7 @@ func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credenti if capabilityDirectory == "" { capabilityDirectory = localworkspace.CapabilityDirectory } - if _, err := localworkspace.NewWithCapabilityDirectory(bound.EnvironmentID, bound.SessionID, workspace, os.Getenv("OAC_RUNTIME_DIRECTORY_HELPER"), capabilityDirectory); err != nil { + if _, err := localworkspace.NewWithCapabilityDirectory(bound.EnvironmentID, bound.SessionID, workspace, capabilityDirectory); err != nil { return errors.New("connect: local Runtime layout unavailable") } want := environmentBinding{RemoteURL: remote, Enrollment: bound, LocalWorkspace: workspace, CapabilityDirectory: capabilityDirectory} diff --git a/apps/parsar-daemon/internal/cli/connect_environment_binding_test.go b/apps/parsar-daemon/internal/cli/connect_environment_binding_test.go index aedd3ce06..0f2b3ac68 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment_binding_test.go +++ b/apps/parsar-daemon/internal/cli/connect_environment_binding_test.go @@ -21,14 +21,9 @@ func environmentRuntimeFixture(t *testing.T) (root, workspace, credential string if err := os.WriteFile(credential, []byte("private-test-credential"), 0600); err != nil { t.Fatal(err) } - helper := filepath.Join(base, "directory-helper") - if err := os.WriteFile(helper, []byte("#!/bin/sh\nexit 0\n"), 0700); err != nil { - t.Fatal(err) - } t.Setenv("OAC_RUNTIME_HOME", root) t.Setenv("OAC_RUNTIME_WORKSPACE", workspace) - t.Setenv("OAC_RUNTIME_DIRECTORY_HELPER", helper) - for _, name := range []string{"OAC_RUNTIME_ENVIRONMENT_ID", "OAC_RUNTIME_SESSION_ID", "OAC_RUNTIME_NETWORK_ACCESS", "OAC_RUNTIME_ALLOWED_DOMAINS", "OAC_RUNTIME_WRITE_HELPER", "OAC_RUNTIME_EXPORT_HELPER", "OAC_RUNTIME_STAGING"} { + for _, name := range []string{"OAC_RUNTIME_ENVIRONMENT_ID", "OAC_RUNTIME_SESSION_ID", "OAC_RUNTIME_NETWORK_ACCESS", "OAC_RUNTIME_ALLOWED_DOMAINS"} { t.Setenv(name, "") } return root, workspace, credential, environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"} diff --git a/apps/parsar-daemon/internal/cli/mcode_workspace.go b/apps/parsar-daemon/internal/cli/mcode_workspace.go index 50b19078e..c3cc98fe4 100644 --- a/apps/parsar-daemon/internal/cli/mcode_workspace.go +++ b/apps/parsar-daemon/internal/cli/mcode_workspace.go @@ -59,7 +59,7 @@ func discoverMCodeWorkspace(rc *runContext, discovery *agentCLIDiscovery) { fail(err) return } - c, err := mcode.ConfigureLocal(binary, node, os.Getenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE"), root, os.Getenv("OAC_RUNTIME_WORKSPACE"), binding.NetworkPolicy(), os.Getenv("OAC_RUNTIME_STAGING")) + c, err := mcode.ConfigureLocal(binary, node, os.Getenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE"), root, os.Getenv("OAC_RUNTIME_WORKSPACE"), binding.NetworkPolicy()) if err == nil { err = mcode.CheckWorkspace(context.Background(), c) } diff --git a/apps/parsar-daemon/internal/cli/retired_configuration.go b/apps/parsar-daemon/internal/cli/retired_configuration.go index 712aea7c8..d5bbf5e97 100644 --- a/apps/parsar-daemon/internal/cli/retired_configuration.go +++ b/apps/parsar-daemon/internal/cli/retired_configuration.go @@ -34,17 +34,13 @@ var renamedRuntimeSettings = map[string]string{ "PARSAR_PI_API_KEY": "OAC_RUNTIME_PI_API_KEY", "PARSAR_PI_BIN": "OAC_RUNTIME_PI_BIN", "PARSAR_RUNTIME_ALLOWED_DOMAINS": "OAC_RUNTIME_ALLOWED_DOMAINS", - "PARSAR_RUNTIME_DIRECTORY_HELPER": "OAC_RUNTIME_DIRECTORY_HELPER", "PARSAR_RUNTIME_ENVIRONMENT_ID": "OAC_RUNTIME_ENVIRONMENT_ID", - "PARSAR_RUNTIME_EXPORT_HELPER": "OAC_RUNTIME_EXPORT_HELPER", "PARSAR_RUNTIME_NETWORK_ACCESS": "OAC_RUNTIME_NETWORK_ACCESS", "PARSAR_RUNTIME_SESSION_ID": "OAC_RUNTIME_SESSION_ID", - "PARSAR_RUNTIME_STAGING": "OAC_RUNTIME_STAGING", "PARSAR_RUNTIME_SYSTEM_PACKAGES": "OAC_RUNTIME_SYSTEM_PACKAGES", "PARSAR_RUNTIME_TOOL_ENV": "OAC_RUNTIME_TOOL_ENV", "PARSAR_RUNTIME_TOOL_SCRATCH": "OAC_RUNTIME_TOOL_SCRATCH", "PARSAR_RUNTIME_WORKSPACE": "OAC_RUNTIME_WORKSPACE", - "PARSAR_RUNTIME_WRITE_HELPER": "OAC_RUNTIME_WRITE_HELPER", "PARSAR_LOG_LEVEL": "OAC_LOG_LEVEL", "PARSAR_LOG_FORMAT": "OAC_LOG_FORMAT", "PARSAR_LOG_ADD_SOURCE": "OAC_LOG_ADD_SOURCE", diff --git a/apps/parsar-daemon/internal/dispatch/local_directory_test.go b/apps/parsar-daemon/internal/dispatch/local_directory_test.go index 3e6d359e8..ca534f751 100644 --- a/apps/parsar-daemon/internal/dispatch/local_directory_test.go +++ b/apps/parsar-daemon/internal/dispatch/local_directory_test.go @@ -17,10 +17,10 @@ import ( ) func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing.T) { - workspace, helper := t.TempDir(), "" + workspace := t.TempDir() environment, session := uuid.NewString(), uuid.NewString() - binding, err := localworkspace.New(environment, session, workspace, helper) + binding, err := localworkspace.New(environment, session, workspace) if err != nil { t.Fatal(err) } @@ -98,12 +98,12 @@ func waitWorkspaceRead(t *testing.T, sender *recSender, id string) proto.Workspa } func TestLocalDirectoryKeepsNotDirectorySeparateFromFailures(t *testing.T) { - workspace, helper := t.TempDir(), "" + workspace := t.TempDir() if err := os.WriteFile(filepath.Join(workspace, "file"), nil, 0600); err != nil { t.Fatal(err) } environment, session := uuid.NewString(), uuid.NewString() - binding, err := localworkspace.New(environment, session, workspace, helper) + binding, err := localworkspace.New(environment, session, workspace) if err != nil { t.Fatal(err) } diff --git a/apps/parsar-daemon/internal/dispatch/preparation_test.go b/apps/parsar-daemon/internal/dispatch/preparation_test.go index 2304ca1a2..243e2b8df 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation_test.go +++ b/apps/parsar-daemon/internal/dispatch/preparation_test.go @@ -4,7 +4,6 @@ import ( "context" "errors" "os" - "path/filepath" "sync" "sync/atomic" "testing" @@ -74,10 +73,6 @@ const preparationSessionID = "22222222-2222-4222-8222-222222222222" func preparationWorkspace(t *testing.T) *localworkspace.Binding { t.Helper() - helper := filepath.Join(t.TempDir(), "directory") - if err := os.WriteFile(helper, []byte("#!/bin/sh\nprintf '%s' '{\"version\":1,\"directory\":{\"entries\":[{\"name\":\"file\",\"kind\":\"file\",\"size_bytes\":3}],\"truncated\":true}}'\n"), 0o700); err != nil { - t.Fatal(err) - } runtimeHome := t.TempDir() if err := os.Chmod(runtimeHome, 0o700); err != nil { t.Fatal(err) @@ -88,12 +83,8 @@ func preparationWorkspace(t *testing.T) *localworkspace.Binding { "OAC_RUNTIME_WORKSPACE": t.TempDir(), "OAC_RUNTIME_CAPABILITY_DIRECTORY": t.TempDir(), "OAC_RUNTIME_HOME": runtimeHome, - "OAC_RUNTIME_DIRECTORY_HELPER": helper, "OAC_RUNTIME_NETWORK_ACCESS": "enabled", "OAC_RUNTIME_ALLOWED_DOMAINS": "", - "OAC_RUNTIME_WRITE_HELPER": "", - "OAC_RUNTIME_EXPORT_HELPER": "", - "OAC_RUNTIME_STAGING": "", } { t.Setenv(name, value) } diff --git a/apps/parsar-daemon/internal/dispatch/runtime_preparation_test.go b/apps/parsar-daemon/internal/dispatch/runtime_preparation_test.go index dd58170e6..bcdbcda11 100644 --- a/apps/parsar-daemon/internal/dispatch/runtime_preparation_test.go +++ b/apps/parsar-daemon/internal/dispatch/runtime_preparation_test.go @@ -33,11 +33,7 @@ func (s *capabilitiesTestSender) Send(ctx context.Context, env proto.Envelope) e func capabilitiesTestRouter(t *testing.T) (*Router, *capabilitiesTestSender, string, string) { t.Helper() environment, session := uuid.NewString(), uuid.NewString() - helper := filepath.Join(t.TempDir(), "helper") - if err := os.WriteFile(helper, []byte("#!/bin/sh\nexit 0\n"), 0700); err != nil { - t.Fatal(err) - } - binding, err := localworkspace.New(environment, session, t.TempDir(), helper) + binding, err := localworkspace.New(environment, session, t.TempDir()) if err != nil { t.Fatal(err) } diff --git a/apps/parsar-daemon/internal/dispatch/workspace_export_test.go b/apps/parsar-daemon/internal/dispatch/workspace_export_test.go index 8f8edbd69..0a3959c9b 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_export_test.go +++ b/apps/parsar-daemon/internal/dispatch/workspace_export_test.go @@ -47,7 +47,7 @@ func exporterRouter(t *testing.T, program string) (*Router, exportSender, proto. f.Close() } environment, session := uuid.NewString(), uuid.NewString() - binding, err := localworkspace.New(environment, session, workspace, "") + binding, err := localworkspace.New(environment, session, workspace) if err != nil { t.Fatal(err) } diff --git a/apps/parsar-daemon/internal/dispatch/workspace_write_test.go b/apps/parsar-daemon/internal/dispatch/workspace_write_test.go index c629d421f..b2530eaf8 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_write_test.go +++ b/apps/parsar-daemon/internal/dispatch/workspace_write_test.go @@ -20,7 +20,7 @@ func localWriterRouter(t *testing.T) (*dispatch.Router, *recSender, proto.Worksp t.Helper() workspace := t.TempDir() environment, session := uuid.NewString(), uuid.NewString() - binding, err := localworkspace.New(environment, session, workspace, "") + binding, err := localworkspace.New(environment, session, workspace) if err != nil { t.Fatal(err) } diff --git a/apps/parsar-daemon/internal/localworkspace/binding.go b/apps/parsar-daemon/internal/localworkspace/binding.go index e8702b689..8bceb4a83 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding.go +++ b/apps/parsar-daemon/internal/localworkspace/binding.go @@ -21,60 +21,44 @@ type Binding struct { allowedDomains []string stateKey string workspace string - helper string - exportHelper string writer *fileWriter capabilityMu sync.Mutex capabilityRoot string } -func New(environment, session, workspace, helper string) (*Binding, error) { +func New(environment, session, workspace string) (*Binding, error) { root, err := paths.Root() if err != nil { return nil, err } - b, err := newNativeBinding(environment, session, workspace, filepath.Join(root, "capabilities")) - if err != nil { - return nil, err - } - b.helper = helper - return b, nil + return newNativeBinding(environment, session, workspace, filepath.Join(root, "capabilities")) } // NewWithCapabilityDirectory freezes paths selected by the Runtime operator. -func NewWithCapabilityDirectory(environment, session, workspace, helper, directory string) (*Binding, error) { - b, err := newNativeBinding(environment, session, workspace, directory) - if err != nil { - return nil, err - } - b.helper = helper - return b, nil +func NewWithCapabilityDirectory(environment, session, workspace, directory string) (*Binding, error) { + return newNativeBinding(environment, session, workspace, directory) } func Load() (*Binding, error) { - values := []string{os.Getenv("OAC_RUNTIME_ENVIRONMENT_ID"), os.Getenv("OAC_RUNTIME_SESSION_ID"), os.Getenv("OAC_RUNTIME_WORKSPACE"), os.Getenv("OAC_RUNTIME_DIRECTORY_HELPER")} + values := []string{os.Getenv("OAC_RUNTIME_ENVIRONMENT_ID"), os.Getenv("OAC_RUNTIME_SESSION_ID"), os.Getenv("OAC_RUNTIME_WORKSPACE")} policy, err := RuntimeNetworkPolicy() if err != nil { return nil, err } network := policy.Access - writeHelper, staging := os.Getenv("OAC_RUNTIME_WRITE_HELPER"), os.Getenv("OAC_RUNTIME_STAGING") - exportHelper := os.Getenv("OAC_RUNTIME_EXPORT_HELPER") capabilityDirectory := os.Getenv("OAC_RUNTIME_CAPABILITY_DIRECTORY") - if strings.Join(values, "") == "" && writeHelper == "" && staging == "" && network == "" && exportHelper == "" && capabilityDirectory == "" { + if strings.Join(values, "") == "" && network == "" && capabilityDirectory == "" { return nil, nil } if capabilityDirectory == "" { capabilityDirectory = CapabilityDirectory } - b, err := NewWithCapabilityDirectory(values[0], values[1], values[2], values[3], capabilityDirectory) + b, err := NewWithCapabilityDirectory(values[0], values[1], values[2], capabilityDirectory) if err != nil { return nil, err } b.networkAccess = network b.allowedDomains = policy.Hosts() - b.exportHelper = exportHelper - b.writer.helper, b.writer.staging = writeHelper, staging return b, nil } @@ -118,21 +102,3 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa func (b *Binding) Matches(environment, session string) bool { return b != nil && b.environment == environment && b.stateKey == "agents-api-"+session } - -// Inspect existing ancestors before creating an operator-owned path. This prevents -// MkdirAll from creating directories through an alias before its final validation. -func canonicalExistingParent(name string) bool { - for { - if _, err := os.Lstat(name); err == nil { - actual, err := filepath.EvalSymlinks(name) - return err == nil && actual == name - } else if !errors.Is(err, os.ErrNotExist) { - return false - } - parent := filepath.Dir(name) - if parent == name { - return false - } - name = parent - } -} diff --git a/apps/parsar-daemon/internal/localworkspace/binding_test.go b/apps/parsar-daemon/internal/localworkspace/binding_test.go index e07b0dbea..c954e6e0c 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding_test.go +++ b/apps/parsar-daemon/internal/localworkspace/binding_test.go @@ -1,13 +1,10 @@ package localworkspace import ( - "errors" - "io/fs" "os" "path/filepath" "testing" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/google/uuid" @@ -21,12 +18,8 @@ func testBinding(t *testing.T) (*Binding, proto.PromptRequestPayload) { } t.Setenv("OAC_RUNTIME_HOME", private) root := t.TempDir() - helper := filepath.Join(t.TempDir(), "helper") - if err := os.WriteFile(helper, []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil { - t.Fatal(err) - } environment, session := uuid.NewString(), uuid.NewString() - b, err := New(environment, session, root, helper) + b, err := New(environment, session, root) if err != nil { t.Fatal(err) } @@ -68,16 +61,11 @@ func TestBindingRejectsScopeAndPathOverrides(t *testing.T) { } } -func TestLocalHelperCannotInheritCredentials(t *testing.T) { +func TestDirectoryValidatesRelativePaths(t *testing.T) { b, _ := testBinding(t) - t.Setenv("OAC_TEST_PRIVATE_CREDENTIAL", "synthetic-secret") - script := "#!/bin/sh\n[ -z \"$OAC_TEST_PRIVATE_CREDENTIAL\" ] || exit 13\nprintf '%s' '{\"version\":1,\"directory\":{\"entries\":[],\"truncated\":false}}'\n" - if err := os.WriteFile(b.helper, []byte(script), 0o700); err != nil { - t.Fatal(err) - } got, err := b.ListWorkspaceDirectory(t.Context(), "", 2) if err != nil || got.Entries == nil || len(got.Entries) != 0 || got.Truncated { - t.Fatalf("read-only helper: %+v %v", got, err) + t.Fatalf("empty directory: %+v %v", got, err) } for _, path := range []string{"/etc", "..", "a/../b", "a//b", ".", "a\\b"} { if _, err := b.ListWorkspaceDirectory(t.Context(), path, 2); err == nil { @@ -86,35 +74,6 @@ func TestLocalHelperCannotInheritCredentials(t *testing.T) { } } -func TestDirectoryClassifiesNativeErrors(t *testing.T) { - for code, want := range map[string]error{ - "not_directory": agent.ErrWorkspaceNotDirectory, - "not_found": fs.ErrNotExist, - "permission_denied": fs.ErrPermission, - "invalid_path": agent.ErrWorkspaceReadInvalid, - "native_error": agent.ErrWorkspaceReadUncertain, - } { - if _, err := decodeDirectory([]byte(`{"version":1,"error":"`+code+`"}`), 2); !errors.Is(err, want) { - t.Fatal("native error classification changed", code, err) - } - } -} - -func TestDirectoryRejectsMalformedOrIncompleteResponses(t *testing.T) { - for _, frame := range []string{ - `{"version":2,"directory":{"entries":[],"truncated":false}}`, - `{"version":1,"directory":{"entries":[]}}`, - `{"version":1,"directory":{"entries":null,"truncated":false}}`, - `{"version":1,"directory":{"entries":[{"name":"../secret","kind":"file","size_bytes":1}],"truncated":false}}`, - `{"version":1,"error":"unknown"}`, - `{"version":1,"directory":{"entries":[],"truncated":false}} {}`, - } { - if _, err := decodeDirectory([]byte(frame), 2); err == nil { - t.Fatal("unconfirmed response accepted", frame) - } - } -} - func TestBindingAllowsRetainedExecutor(t *testing.T) { b, req := testBinding(t) req.ReleaseOnCompletion = false @@ -126,11 +85,11 @@ func TestBindingAllowsRetainedExecutor(t *testing.T) { func TestCapabilityLayoutUsesOperatorDirectories(t *testing.T) { b, _ := testBinding(t) for _, directory := range []string{filepath.Join(b.workspace, "capabilities"), filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "capabilities")} { - if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, "", directory); err != nil { + if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, directory); err != nil { t.Fatal(err) } } - if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, "", "relative"); err == nil { + if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, "relative"); err == nil { t.Fatal("relative installation path accepted") } } diff --git a/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go b/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go index 35870e6a3..696906ad2 100644 --- a/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go +++ b/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go @@ -39,7 +39,7 @@ func TestPreparationFreezesLocalContentsAcrossReconnect(t *testing.T) { t.Fatalf("first preparation: %v", err) } writeSourceSkill(t, source, "second") - reconnect, err := New(b.environment, b.capabilityIdentity().SessionID, b.workspace, b.helper) + reconnect, err := New(b.environment, b.capabilityIdentity().SessionID, b.workspace) if err != nil { t.Fatal(err) } diff --git a/apps/parsar-daemon/internal/localworkspace/directory.go b/apps/parsar-daemon/internal/localworkspace/directory.go index 8ce8ac130..3679a60c4 100644 --- a/apps/parsar-daemon/internal/localworkspace/directory.go +++ b/apps/parsar-daemon/internal/localworkspace/directory.go @@ -1,10 +1,7 @@ package localworkspace import ( - "bytes" "context" - "encoding/json" - "io" "io/fs" "strings" @@ -18,38 +15,3 @@ func (b *Binding) ListWorkspaceDirectory(ctx context.Context, path string, limit } return b.listNativeDirectory(ctx, path, limit) } - -func decodeDirectory(data []byte, limit int) (agent.WorkspaceDirectoryResult, error) { - var wire struct { - Version int `json:"version"` - Error *string `json:"error"` - Directory *proto.WorkspaceDirectoryResult `json:"directory"` - } - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if decoder.Decode(&wire) != nil || decoder.Decode(new(any)) != io.EOF || wire.Version != 1 || (wire.Error == nil) == (wire.Directory == nil) { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUncertain - } - if wire.Error != nil { - err := agent.ErrWorkspaceReadUncertain - switch *wire.Error { - case "not_found": - err = fs.ErrNotExist - case "permission_denied": - err = fs.ErrPermission - case "invalid_path": - err = agent.ErrWorkspaceReadInvalid - case proto.WorkspaceReadNotDirectory: - err = agent.ErrWorkspaceNotDirectory - } - return agent.WorkspaceDirectoryResult{}, err - } - if !proto.ValidWorkspaceDirectory(wire.Directory, limit) { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUncertain - } - result := agent.WorkspaceDirectoryResult{Entries: make([]agent.WorkspaceDirectoryEntry, 0, len(wire.Directory.Entries)), Truncated: wire.Directory.Truncated} - for _, e := range wire.Directory.Entries { - result.Entries = append(result.Entries, agent.WorkspaceDirectoryEntry{Name: e.Name, Kind: e.Kind, SizeBytes: e.SizeBytes}) - } - return result, nil -} diff --git a/apps/parsar-daemon/internal/localworkspace/write.go b/apps/parsar-daemon/internal/localworkspace/write.go index 4ea1b191e..53ce99910 100644 --- a/apps/parsar-daemon/internal/localworkspace/write.go +++ b/apps/parsar-daemon/internal/localworkspace/write.go @@ -1,11 +1,8 @@ package localworkspace import ( - "bytes" "context" - "encoding/json" "errors" - "io" "io/fs" "strings" @@ -13,7 +10,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) -// This private bound matches the existing installer; it is not an upstream limit. +// This private transfer bound is distinct from the public inline-file limit. const WriteMaxBytes = proto.WorkspaceWriteMaxBytes var _ agent.WorkspaceWriter = (*Binding)(nil) @@ -46,44 +43,3 @@ func (b *Binding) WriteWorkspaceFile(ctx context.Context, path string, data []by // Once admitted, finish this synchronous mutation before returning ownership. return b.writeNativeFile(context.WithoutCancel(ctx), path, data) } - -// A malformed helper cannot grow the daemon's output buffer without bound. -type writeOutput struct{ data bytes.Buffer } - -func (b *writeOutput) Bytes() []byte { return b.data.Bytes() } - -func (b *writeOutput) Write(p []byte) (int, error) { - if len(p) > 1024-b.data.Len() { - return 0, errors.New("local write response exceeds limit") - } - return b.data.Write(p) -} - -func decodeWrite(data []byte, expected int) (agent.WorkspaceWriteResult, error) { - var wire struct { - Version int `json:"version"` - Outcome string `json:"outcome"` - SizeBytes *int64 `json:"size_bytes"` - Error *string `json:"error"` - } - invalid := agent.ErrWorkspaceWriteUncertain - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if decoder.Decode(&wire) != nil || decoder.Decode(new(any)) != io.EOF || wire.Version != 1 { - return agent.WorkspaceWriteResult{}, invalid - } - if wire.Outcome == "completed" && wire.Error == nil && wire.SizeBytes != nil && *wire.SizeBytes == int64(expected) { - return agent.WorkspaceWriteResult{SizeBytes: *wire.SizeBytes}, nil - } - if wire.Outcome == "failed" && wire.SizeBytes == nil && wire.Error != nil { - switch *wire.Error { - case "invalid_input", "write_failed": - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteRejected - case "destination_directory": - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteDirectory - case "unsafe_destination": - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsafe - } - } - return agent.WorkspaceWriteResult{}, invalid -} diff --git a/apps/parsar-daemon/internal/localworkspace/write_binding.go b/apps/parsar-daemon/internal/localworkspace/write_binding.go index c656e8cfb..e5d54ab16 100644 --- a/apps/parsar-daemon/internal/localworkspace/write_binding.go +++ b/apps/parsar-daemon/internal/localworkspace/write_binding.go @@ -1,45 +1,8 @@ package localworkspace -import ( - "errors" - "os" - "path/filepath" - "strings" - "sync" -) +import "sync" type fileWriter struct { - helper string - staging string mu sync.Mutex uncertain bool } - -// bindWriter runs only during startup. Path checks do not prove native isolation; -// deployment qualification must deny tools access to staging and its ancestors. -func (b *Binding) bindWriter(helper, staging string) error { - invalid := errors.New("local file writer requires a protected sibling staging directory and external executable") - parent := filepath.Dir(b.workspace) - if parent == "/" || filepath.Dir(staging) != parent || staging == b.workspace { - return invalid - } - for _, name := range []string{b.workspace, staging, helper} { - if !filepath.IsAbs(name) || filepath.Clean(name) != name || strings.ContainsAny(name, "\x00\r\n\\") { - return invalid - } - resolved, err := filepath.EvalSymlinks(name) - if err != nil || resolved != name { - return invalid - } - } - dir, err := os.Stat(staging) - if err != nil || !dir.IsDir() { - return invalid - } - program, err := os.Stat(helper) - if err != nil || !program.Mode().IsRegular() || program.Mode().Perm()&0111 == 0 || helper == parent || strings.HasPrefix(helper, parent+string(filepath.Separator)) { - return invalid - } - b.writer = &fileWriter{helper: helper, staging: staging} - return nil -} diff --git a/apps/parsar-daemon/internal/localworkspace/write_test.go b/apps/parsar-daemon/internal/localworkspace/write_test.go index 0cf675e23..5d953a421 100644 --- a/apps/parsar-daemon/internal/localworkspace/write_test.go +++ b/apps/parsar-daemon/internal/localworkspace/write_test.go @@ -11,63 +11,6 @@ import ( "testing" ) -func writableBinding(t *testing.T, script string) *Binding { - t.Helper() - b, _ := testBinding(t) - // macOS test roots may contain /var aliases. Writer deployment paths must be canonical. - parent, err := filepath.EvalSymlinks(t.TempDir()) - if err != nil { - t.Fatal(err) - } - b.workspace = filepath.Join(parent, "workspace") - staging := filepath.Join(parent, "staging") - for _, p := range []string{b.workspace, staging} { - if err := os.Mkdir(p, 0700); err != nil { - t.Fatal(err) - } - } - helper, err := filepath.EvalSymlinks(b.helper) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(helper, []byte("#!/bin/sh\n"+script), 0700); err != nil { - t.Fatal(err) - } - if err := b.bindWriter(helper, staging); err != nil { - t.Fatal(err) - } - return b -} - -func TestWriteReceiptValidation(t *testing.T) { - for _, response := range []string{ - `{"version":1,"outcome":"completed"}`, - `{"version":1,"outcome":"completed","size_bytes":0,"error":"write_failed"}`, - `{"version":1,"outcome":"failed","size_bytes":0,"error":"write_failed"}`, - `{"version":1,"outcome":"unknown","error":"write_failed"}`, - `{"version":1,"outcome":"failed","error":"other"}`, - `{"version":1,"outcome":"completed","size_bytes":0} {}`, - `{"version":1,"outcome":"completed","size_bytes":0,"extra":true}`, - `{"version":1,"outcome":"unknown","error":"unsafe_destination"}`, - `{"version":1,"outcome":"failed","size_bytes":0,"error":"destination_directory"}`, - } { - if _, err := decodeWrite([]byte(response), 0); !errors.Is(err, agent.ErrWorkspaceWriteUncertain) { - t.Fatalf("unsafe receipt %s: %v", response, err) - } - } - for code, want := range map[string]error{ - "invalid_input": agent.ErrWorkspaceWriteRejected, - "write_failed": agent.ErrWorkspaceWriteRejected, - "destination_directory": agent.ErrWorkspaceWriteDirectory, - "unsafe_destination": agent.ErrWorkspaceWriteUnsafe, - } { - _, err := decodeWrite([]byte(fmt.Sprintf(`{"version":1,"outcome":"failed","error":%q}`, code)), 0) - if err != want || !errors.Is(err, agent.ErrWorkspaceWriteRejected) { - t.Fatal(code, err) - } - } -} - func nativeFileBinding(t *testing.T) *Binding { t.Helper() return &Binding{workspace: t.TempDir(), writer: &fileWriter{}} diff --git a/contracts/agents-api/environment-files.md b/contracts/agents-api/environment-files.md index 671ee4d02..b47212d69 100644 --- a/contracts/agents-api/environment-files.md +++ b/contracts/agents-api/environment-files.md @@ -277,21 +277,15 @@ files guide saved beside them. The batch plan is Decisions: -- The shared `oac-codex-write` helper takes an explicit per-call mode. Only - the daemon's local workspace writer, which serves only Files.create, passes - `create`. Core's initial Session file installer keeps the four-argument replace - mode. At this historical acceptance, the Skill installer also used that helper; - current Skills and Plugins use the +- The daemon's Go workspace writer now serves Files.create on all platforms. + It verifies the complete body before creating parents, writes a temporary file, + and publishes it with a no-overwrite hard link. Initial Session files use a + separate atomic replacement operation. Skills and Plugins use [shared Runtime capability preparation](environments.md#runtime-capability-preparation). - Completed recovery, including cold resume, never reinstalls files. The Files.create - semantics recorded here remain unchanged. + Completed recovery, including cold resume, never reinstalls files. - Parents are created only after the complete body is verified, so incomplete or - corrupt input creates nothing. Each new directory is reopened without following - links. The install itself never replaces: `renameat2(RENAME_NOREPLACE)`, or - `linkat` where the filesystem lacks that flag (best effort; no test forces that - trigger). A normal rejection happens before anything is created and leaves - nothing behind. Only a concurrent change, an I/O error or a device mismatch - after the parents were created can leave them as empty mode-0700 directories. + corrupt input creates nothing. A write failure can leave newly created empty + parent directories, but never replaces an existing destination. - Core cannot tell a file that an earlier Files.create wrote from any other file. Its durable write intent stores a digest of the path, size and content, not a path ledger, and a tool can remove and recreate a file afterwards; no cheap, diff --git a/packages/codex-executor/Cargo.lock b/packages/codex-executor/Cargo.lock deleted file mode 100644 index 5cd47d66b..000000000 --- a/packages/codex-executor/Cargo.lock +++ /dev/null @@ -1,655 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "agents-api-codex-executor" -version = "0.1.0" -dependencies = [ - "rustix", - "serde_json", - "sha2", - "tar", - "tempfile", - "uuid", -] - -[[package]] -name = "anyhow" -version = "1.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bumpalo" -version = "3.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "js-sys" -version = "0.3.85" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "libredox" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" -dependencies = [ - "bitflags", - "libc", - "redox_syscall", -] - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "memchr" -version = "2.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "redox_syscall" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" -dependencies = [ - "bitflags", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tar" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" -dependencies = [ - "filetime", - "libc", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys", -] - -[[package]] -name = "typenum" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" - -[[package]] -name = "unicode-ident" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "uuid" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" -dependencies = [ - "getrandom 0.3.4", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "zmij" -version = "1.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/packages/codex-executor/Cargo.toml b/packages/codex-executor/Cargo.toml deleted file mode 100644 index 810779b6e..000000000 --- a/packages/codex-executor/Cargo.toml +++ /dev/null @@ -1,34 +0,0 @@ -[package] -name = "agents-api-codex-executor" -version = "0.1.0" -edition = "2024" -license = "Apache-2.0" -publish = false - -[[bin]] -name = "oac-codex-directory" -path = "src/bin/directory.rs" - -[[bin]] -name = "oac-codex-write" -path = "src/bin/write.rs" - -[[bin]] -name = "oac-workspace-export" -path = "src/bin/export.rs" - -[dependencies] -rustix = { version = "=1.1.4", features = ["fs"] } -serde_json = "1" -sha2 = "=0.10.9" -tar = { version = "=0.4.46", default-features = false } -uuid = { version = "1", features = ["v4"] } - -[dev-dependencies] -tempfile = "=3.27.0" - -[profile.dev] -debug = 0 - -[profile.release] -strip = "symbols" diff --git a/packages/codex-executor/README.md b/packages/codex-executor/README.md deleted file mode 100644 index 4e62b7726..000000000 --- a/packages/codex-executor/README.md +++ /dev/null @@ -1,160 +0,0 @@ -# Runtime filesystem helpers - -This package provides three bounded local filesystem helpers reused by the V1 -Runtime: directory listing, atomic file installation and workspace output export. -The daemon, native harness, tools and workspace are colocated. The former separate -Codex executor launcher and registry/Noise route are retired; source is retained -in Git history. The package and helper names remain to avoid unrelated renaming. - -## Build and installation - -Install Rust 1.95.0 with rustfmt and Clippy and a C toolchain. Build with the -committed Cargo lock: - -```sh -make check-agents-executor -make build-agents-executor -``` - -Build state stays under `~/.oac/`. `CARGO_HOME`, `CARGO_TARGET_DIR` and -`AGENTS_EXECUTOR_BUILD_DIR` select absolute cache/output locations. The build -copies only this package and produces Linux x86_64 GNU binaries. Install helpers -at operator-controlled paths outside the writable workspace. Runtime packaging -supplies the selected harness and its native isolation independently. - -## Scoped directory helper - -The build emits `oac-codex-directory` for the current directory-listing -adapter gap. Install it at an operator-controlled absolute path on the executor, -outside the writable workspace. Its three argv values are the authorized absolute -workspace root, a relative directory (empty for the root), and a limit of 1–4096. -Runtime invokes it locally with the authorized filesystem policy. No shell or model is involved. -The helper itself is a local program, not an authorization service: the caller -must bind the root to the exact authorized owner and validate the installation. - -It opens every directory component without following symlinks, retains directory -descriptors for enumeration and metadata, and stops after the limit plus one -entry. It returns one version-1 JSON response: `directory.entries` contains -`name`, `kind` (`file`, `directory`, `symlink`, `other`) and nullable `size_bytes`; -`directory.truncated` reports lookahead. Only regular files have sizes. Errors -use `error` with no partial entries. Invalid paths/names and oversized responses -are rejected. Descriptor cleanup precedes output; exit zero alone is not success, -since a settled error also returns JSON. Require valid complete JSON, a successful -exit and native output-close receipt before accepting an observation. Unknown -start/termination/transport results remain uncertain and must not be retried as -settled reads. - -This one-level observation has no order, paging or snapshot guarantee. Renames may -leave an operation reading the directory it already opened; workspace replacement -and cross-tenant placement remain the caller's responsibility. The helper does -not change stock native filesystem methods, create a daemon connection, or enable -public Files. The package tests qualify the bounded helper; public Runtime acceptance separately -checks authorization and Files behavior. - -## Scoped output exporter - -`oac-workspace-export` takes one authorized absolute workspace root and -streams regular files beneath its `outputs` directory as a standard tar archive -on stdout. It reuses the directory helper's descriptor-relative path protection; -it does not invoke a shell, model or provider command. The caller must supply the -exact Environment's frozen root and independently authorize the operation. - -Require both a complete validated archive and successful process exit before -publishing anything. On failure the stdout prefix may still look like a valid -archive. Never extract it into Core's filesystem. Consumers must bound and stream -individual entries into private storage, then publish only after the entire -capture succeeds. A missing `outputs` directory produces an empty archive. - -The exporter rejects symlinks, multiply linked files, special files, device -changes and detected concurrent modifications. It limits each file to 200 MiB -and aggregate bytes to 500 MiB, following the current official Files guide. -Traversal is additionally bounded at 4096 entries, 64 directory levels and -4096-byte relative paths; those are implementation limits, not upstream promises. -This is not a filesystem-wide point-in-time snapshot. Immutable publication, -tenant isolation, Turn ordering and storage cleanup remain Core/Runtime duties; -the helper alone does not enable public Artifacts. - -## Scoped file installer - -The optional `oac-codex-write` helper addresses two pinned native write -limitations: hard-link targets are modified in place, and base64 encoding a -50 MiB file exceeds the native 64 MiB message bound. Install this helper outside -the writable workspace and invoke it locally through the existing Runtime installer, with restricted network -and the required helper/runtime reads. The qualified -installer policy grants write access to one dedicated per-Environment parent -containing only workspace and staging; ordinary native tools can write only the -workspace. Keep credentials, native history and other Environments outside that -parent. Separate writable mount entries can make rename fail with EXDEV even -when their backing filesystem matches; the helper must reject that layout. -It does not authorize callers or enable Files.create. - -Arguments are the authorized absolute root, a nonempty relative file path, -its declared byte count (0–50 MiB), and an existing absolute staging directory, -optionally followed by the mode `create`. The staging directory must be outside -the workspace, not its ancestor, and on the destination filesystem. Symlink -traversal and cross-filesystem replacement are rejected; there is no copy -fallback. The former three-argument private CLI is no longer accepted. Stream those bytes in bounded native stdin -chunks, followed by their 32-byte binary SHA-256 digest. This is one private frame; -there is no second request on that process. The digest terminates the private frame without waiting for EOF. -Extra bytes after the frame are not consumed. A process/write accepted receipt -means queued input, not committed file contents. - -Each caller selects its mode explicitly. The four-argument form is the replace -mode of trusted initialization: Core's initial Session file installer and the -Skill installer use it, and older Runtime images understand only this form. -Only the daemon's public Files.create writer passes `create`. - -The helper reuses held-directory no-follow traversal. It writes a fresh mode-0600 -temporary file in the held staging directory, checks the declared byte count and -digest and syncs the file. In replace mode it requires an existing parent, -rejects existing nonregular targets, then replaces the destination directory -entry with renameat and syncs both directories. Existing hard links retain their -original inode and contents; destination mode/ownership metadata is not preserved. - -Create mode never replaces anything. Before reading input it walks the existing -parent components without following links and checks the destination when the -parent exists. After the body is verified it creates each missing parent with -mkdirat (mode 0700, as the initial-file installer does) relative to the held -parent, syncs that parent and reopens the new directory without following links. -It then installs with renameat2(RENAME_NOREPLACE); where the filesystem lacks that -flag, linkat installs the staging inode without replacement and the staging name -is unlinked as best-effort cleanup. An existing symlink or non-directory component, -or an existing destination that is not a directory, reports `unsafe_destination`; -an existing directory reports `destination_directory`. A destination or link that -appears concurrently is never replaced or followed and reports -`unsafe_destination`. A normal rejection happens before anything is created and -leaves nothing behind; only such a race, an I/O error or a device mismatch after -parent creation can leave empty mode-0700 directories. - -The operator must protect staging and -its ancestors from native tools and background processes. A dedicated staging -directory per Environment, with native tool write access limited to the workspace -and separate installer access, is the qualified mechanism. Directory naming or -mode 0700 alone does not isolate processes running as the same user. The helper -cannot verify other processes' policies; public admission must bind and validate -this condition. Broad read permission may still expose staging bytes; confidentiality -requires its own placement policy. Concurrent workspace changes do not gain access -to protected staging, but later writers can change the installed file. No snapshot -or exactly-once guarantee is implied. - -One version-1 JSON response reports `outcome: completed` with `size_bytes`, -`failed` before installation, or `unknown` if either directory sync fails after installation. -Errors contain only a fixed safe code: `invalid_input` or `write_failed`, and in -create mode also `destination_directory` or `unsafe_destination`. Require a complete response plus observed -native exit/output close; exit zero alone is insufficient. Input errors preserve -the old destination provided staging remains protected; independent workspace -writers can still change that destination themselves. Temporary-file cleanup -is best effort: permission or I/O errors, as well as forced termination, can leave -a `.oac-upload-*` file in the private staging directory. Never interpret it as a -completed upload. -A missing receipt remains unknown and must not trigger automatic replay. This -helper does not fence a replacement owner after remote transport or service loss; -public admission still needs operation ownership and recovery handling. - -## Verification boundaries - -`make check-agents-executor` runs helper tests, formatting and Clippy. The shared -[public acceptance](../../services/agents-api/tests/official_user_runtime.py) -checks execution, Files/Artifacts, cancellation and recovery through an enrolled -Runtime. It requires real model APIs and independently deployed Core/Runtime; -helper unit tests alone do not establish deployment or complete protocol compatibility. diff --git a/packages/codex-executor/rust-toolchain.toml b/packages/codex-executor/rust-toolchain.toml deleted file mode 100644 index 38ab2c6bd..000000000 --- a/packages/codex-executor/rust-toolchain.toml +++ /dev/null @@ -1,3 +0,0 @@ -[toolchain] -channel = "1.95.0" -components = ["clippy", "rustfmt"] diff --git a/packages/codex-executor/src/bin/directory.rs b/packages/codex-executor/src/bin/directory.rs deleted file mode 100644 index c5e9fec1e..000000000 --- a/packages/codex-executor/src/bin/directory.rs +++ /dev/null @@ -1,266 +0,0 @@ -use rustix::fs::{FileType, fstat}; -#[path = "../directory.rs"] -mod directory; -use directory::observe; -#[path = "../workspace_path.rs"] -mod workspace_path; -use serde_json::json; -use std::{io, os::fd::OwnedFd, path::Path}; -use workspace_path::{anchor, directory}; - -fn run() -> io::Result { - let args: Vec<_> = std::env::args().skip(1).collect(); - if args.len() != 3 { - return Err(io::ErrorKind::InvalidInput.into()); - } - list(&args[0], &args[1], &args[2]) -} - -fn list(root: &str, relative: &str, limit: &str) -> io::Result { - let limit = limit - .parse() - .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; - if !(1..=4096).contains(&limit) { - return Err(io::ErrorKind::InvalidInput.into()); - } - // Validate every component first, so that a missing earlier component can - // never hide an invalid request as a listable-directory result. - if !relative.is_empty() - && relative.split('/').any(|part| { - part.is_empty() - || part == "." - || part == ".." - || part.contains(['\\', '\0', '\r', '\n']) - }) - { - return Err(io::ErrorKind::InvalidInput.into()); - } - let root_path = Path::new(root); - let root = anchor(root_path)?; - list_anchored(root_path, &root, relative, limit) -} - -fn list_anchored( - root_path: &Path, - root: &OwnedFd, - relative: &str, - limit: usize, -) -> io::Result { - let Some(selected) = select(root, relative)? else { - root_unchanged(root_path, root)?; - return Ok(json!({"version": 1, "error": "not_directory"})); - }; - let result = observe(&selected, limit)?; - // Reading a removed directory ends like an empty one, so an empty listing - // also confirms that the workspace root is still in place. - if result.entries.is_empty() { - root_unchanged(root_path, root)?; - } - let entries: Vec<_> = result - .entries - .into_iter() - .map(|entry| { - let kind = match entry.kind { - FileType::RegularFile => "file", - FileType::Directory => "directory", - FileType::Symlink => "symlink", - _ => "other", - }; - json!({"name": entry.name, "kind": kind, "size_bytes": entry.size}) - }) - .collect(); - Ok(json!({"version": 1, "directory": {"entries": entries, "truncated": result.truncated}})) -} - -/// Resolves the requested path below the anchored root. `None` means that -/// path does not name a listable directory: a missing component, a regular file -/// or a symbolic link (never followed). Root, permission and observation -/// failures keep their existing codes. -fn select(root: &OwnedFd, relative: &str) -> io::Result> { - match directory(root, relative) { - Ok(selected) => Ok(Some(selected)), - Err(error) - if matches!( - error.kind(), - io::ErrorKind::NotFound | io::ErrorKind::NotADirectory - ) => - { - Ok(None) - } - Err(error) => Err(error), - } -} - -/// Confirms that the root path still names the held root directory. The path -/// is reopened without following links and compared by device and inode, which -/// does not depend on link counts that some filesystems (such as overlayfs) -/// keep for removed directories. A removed or replaced root is a missing -/// workspace, not a missing requested directory. -fn root_unchanged(root_path: &Path, root: &OwnedFd) -> io::Result<()> { - let current = match anchor(root_path) { - Ok(current) => current, - Err(error) - if matches!( - error.kind(), - io::ErrorKind::NotFound | io::ErrorKind::NotADirectory - ) => - { - return Err(io::ErrorKind::NotFound.into()); - } - Err(error) => return Err(error), - }; - let (held, now) = (fstat(root)?, fstat(¤t)?); - if held.st_dev != now.st_dev || held.st_ino != now.st_ino { - return Err(io::ErrorKind::NotFound.into()); - } - Ok(()) -} - -fn main() -> std::process::ExitCode { - let response = match run() { - Ok(value) => value, - Err(error) => json!({"version": 1, "error": match error.kind() { - io::ErrorKind::NotFound => "not_found", - io::ErrorKind::PermissionDenied => "permission_denied", - io::ErrorKind::InvalidInput | io::ErrorKind::NotADirectory => "invalid_path", - _ => "native_error", - }}), - }; - let bytes = match serde_json::to_vec(&response) { - Ok(bytes) if bytes.len() <= 4 * 1024 * 1024 => bytes, - _ => b"{\"version\":1,\"error\":\"too_large\"}".to_vec(), - }; - use io::Write; - match io::stdout().lock().write_all(&bytes) { - Ok(()) => std::process::ExitCode::SUCCESS, - Err(_) => std::process::ExitCode::FAILURE, - } -} - -#[cfg(test)] -mod tests { - use super::{list, list_anchored}; - use crate::directory::observe; - use crate::workspace_path::{anchor, directory}; - use std::{fs, io, os::unix::fs::symlink}; - - // These tests open descriptors, so they share the lock that keeps the - // descriptor-count assertions in the directory tests deterministic. - fn serialized() -> std::sync::MutexGuard<'static, ()> { - crate::directory::TEST_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - } - - fn code(result: io::Result) -> String { - match result { - Ok(value) => value["error"].as_str().unwrap_or("listed").to_string(), - Err(error) => format!("{:?}", error.kind()), - } - } - - #[test] - fn only_the_requested_path_resolution_is_not_a_directory() { - let _guard = serialized(); - let workspace = tempfile::tempdir().unwrap(); - let outside = tempfile::tempdir().unwrap(); - let root = workspace.path().join("workspace"); - fs::create_dir_all(root.join("d")).unwrap(); - fs::write(root.join("d/f.txt"), b"hidden").unwrap(); - fs::write(root.join("file.txt"), b"x").unwrap(); - fs::write(outside.path().join("secret"), b"secret").unwrap(); - symlink(root.join("d"), root.join("linkdir")).unwrap(); - symlink("d", root.join("relative-link")).unwrap(); - symlink(outside.path(), root.join("outside-link")).unwrap(); - symlink("missing", root.join("dangling")).unwrap(); - let root = root.to_str().unwrap(); - for path in [ - "missing", - "missing/deeper", - "file.txt", - "file.txt/deeper", - "linkdir", - "linkdir/f.txt", - "relative-link", - "outside-link", - "dangling", - ] { - let value = list(root, path, "10").unwrap(); - assert_eq!( - value, - serde_json::json!({"version": 1, "error": "not_directory"}), - "{path}" - ); - } - let listed = list(root, "d", "10").unwrap(); - assert_eq!(listed["directory"]["entries"][0]["name"], "f.txt"); - // Invalid requests are rejected before any component is opened. - for path in ["missing/..", "missing//x", "./d", "d/", "missing/a\\b"] { - assert_eq!(code(list(root, path, "10")), "InvalidInput", "{path}"); - } - // Root failures keep their existing classification. - let missing_root = format!("{root}/missing"); - assert_eq!(code(list(&missing_root, "", "10")), "NotFound"); - let linked_root = format!("{root}/linkdir"); - assert_eq!(code(list(&linked_root, "", "10")), "NotADirectory"); - let file_root = format!("{root}/file.txt"); - assert_eq!(code(list(&file_root, "", "10")), "NotADirectory"); - } - - fn not_found(result: io::Result) -> bool { - matches!(result, Err(error) if error.kind() == io::ErrorKind::NotFound) - } - - #[test] - fn a_root_removed_after_opening_is_a_missing_workspace() { - let _guard = serialized(); - let workspace = tempfile::tempdir().unwrap(); - let root = workspace.path().join("workspace"); - fs::create_dir_all(root.join("d")).unwrap(); - let anchored = anchor(&root).unwrap(); - let listed = list_anchored(&root, &anchored, "missing", 10).unwrap(); - assert_eq!(listed["error"], "not_directory"); - let selected = directory(&anchored, "").unwrap(); - fs::remove_dir_all(&root).unwrap(); - // Reading the removed root ends like an empty directory. - assert!(observe(&selected, 10).unwrap().entries.is_empty()); - for path in ["", "d", "missing", "missing/deeper"] { - assert!( - not_found(list_anchored(&root, &anchored, path, 10)), - "{path}" - ); - } - } - - #[test] - fn a_root_replaced_after_opening_is_a_missing_workspace() { - let _guard = serialized(); - let workspace = tempfile::tempdir().unwrap(); - let root = workspace.path().join("workspace"); - let moved = workspace.path().join("moved"); - fs::create_dir_all(root.join("d")).unwrap(); - fs::create_dir_all(root.join("empty")).unwrap(); - fs::write(root.join("d/f.txt"), b"x").unwrap(); - let anchored = anchor(&root).unwrap(); - fs::rename(&root, &moved).unwrap(); - fs::create_dir_all(root.join("d")).unwrap(); - // The held root stays authoritative for entries it still lists. - let listed = list_anchored(&root, &anchored, "d", 10).unwrap(); - assert_eq!(listed["directory"]["entries"][0]["name"], "f.txt"); - for path in ["missing", "empty"] { - assert!( - not_found(list_anchored(&root, &anchored, path, 10)), - "{path}" - ); - } - fs::remove_dir_all(&root).unwrap(); - symlink(&moved, &root).unwrap(); - assert!(not_found(list_anchored(&root, &anchored, "missing", 10))); - // An unchanged root still lists missing paths and empty directories. - let anchored = anchor(&moved).unwrap(); - let listed = list_anchored(&moved, &anchored, "missing", 10).unwrap(); - assert_eq!(listed["error"], "not_directory"); - let listed = list_anchored(&moved, &anchored, "empty", 10).unwrap(); - assert_eq!(listed["directory"]["entries"], serde_json::json!([])); - } -} diff --git a/packages/codex-executor/src/bin/export.rs b/packages/codex-executor/src/bin/export.rs deleted file mode 100644 index d1ca3672e..000000000 --- a/packages/codex-executor/src/bin/export.rs +++ /dev/null @@ -1,20 +0,0 @@ -#[path = "../directory.rs"] -mod directory; -#[path = "../export.rs"] -mod export; -#[path = "../workspace_path.rs"] -mod workspace_path; - -use std::{io, path::Path, process::ExitCode}; - -fn main() -> ExitCode { - let args: Vec<_> = std::env::args_os().skip(1).collect(); - if args.len() != 1 { - return ExitCode::FAILURE; - } - // A valid archive prefix alone is not success: callers must also observe exit 0. - match export::outputs(Path::new(&args[0]), io::stdout().lock()) { - Ok(()) => ExitCode::SUCCESS, - Err(_) => ExitCode::FAILURE, - } -} diff --git a/packages/codex-executor/src/bin/write.rs b/packages/codex-executor/src/bin/write.rs deleted file mode 100644 index ccbbfbfa6..000000000 --- a/packages/codex-executor/src/bin/write.rs +++ /dev/null @@ -1,103 +0,0 @@ -#[path = "../workspace_path.rs"] -mod workspace_path; -#[path = "../write_file.rs"] -mod write_file; - -use serde_json::json; -use std::io::{self, Write}; -use std::path::Path; -use write_file::{InstallMode, Rejection}; - -/// The four-argument form keeps the replace mode of trusted initialization, -/// including Core-issued initial-file installs on older Runtime images. The -/// public Files.create writer appends `create`. -fn mode(args: &[String]) -> Option { - match args { - [_, _, _, _] => Some(InstallMode::Replace), - [_, _, _, _, mode] if mode == "create" => Some(InstallMode::Create), - _ => None, - } -} - -fn run() -> Result { - let args: Vec<_> = std::env::args().skip(1).collect(); - let mode = mode(&args).ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?; - let size = args[2] - .parse::() - .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; - write_file::install( - Path::new(&args[0]), - &args[1], - size, - io::stdin().lock(), - Path::new(&args[3]), - mode, - )?; - Ok(size) -} - -fn error_code(failure: &write_file::Failure) -> &'static str { - match failure.rejection { - Some(Rejection::Directory) => "destination_directory", - Some(Rejection::Unsafe) => "unsafe_destination", - None if failure.error.kind() == io::ErrorKind::InvalidInput => "invalid_input", - None => "write_failed", - } -} - -fn main() -> std::process::ExitCode { - let response = match run() { - Ok(size) => json!({"version": 1, "outcome": "completed", "size_bytes": size}), - Err(failure) => { - json!({"version": 1, "outcome": if failure.committed { "unknown" } else { "failed" }, "error": error_code(&failure)}) - } - }; - if writeln!(io::stdout().lock(), "{response}").is_err() { - return std::process::ExitCode::FAILURE; - } - std::process::ExitCode::SUCCESS -} - -#[cfg(test)] -mod cli_tests { - use super::*; - - #[test] - fn selects_mode_explicitly_and_reports_fixed_codes() { - let args = |values: &[&str]| values.iter().map(|v| v.to_string()).collect::>(); - assert_eq!( - mode(&args(&["r", "p", "0", "s"])), - Some(InstallMode::Replace) - ); - assert_eq!( - mode(&args(&["r", "p", "0", "s", "create"])), - Some(InstallMode::Create) - ); - for invalid in [ - args(&["r", "p", "0"]), - args(&["r", "p", "0", "s", "replace"]), - args(&["r", "p", "0", "s", "create", "x"]), - ] { - assert_eq!(mode(&invalid), None); - } - let failure = |rejection, kind| write_file::Failure { - committed: false, - rejection, - error: io::Error::from(kind), - }; - for (expected, value) in [ - ( - "destination_directory", - failure(Some(Rejection::Directory), io::ErrorKind::AlreadyExists), - ), - ( - "unsafe_destination", - failure(Some(Rejection::Unsafe), io::ErrorKind::AlreadyExists), - ), - ("invalid_input", failure(None, io::ErrorKind::InvalidInput)), - ("write_failed", failure(None, io::ErrorKind::NotFound)), - ] { - assert_eq!(error_code(&value), expected); - } - } -} diff --git a/packages/codex-executor/src/directory.rs b/packages/codex-executor/src/directory.rs deleted file mode 100644 index 0d77f898e..000000000 --- a/packages/codex-executor/src/directory.rs +++ /dev/null @@ -1,68 +0,0 @@ -use rustix::fs::{AtFlags, Dir, FileType, statat}; -use std::io; -use std::os::fd::OwnedFd; - -#[derive(Debug, PartialEq)] -pub(crate) struct Entry { - pub name: String, - pub kind: FileType, - pub size: Option, -} - -#[derive(Debug)] -pub(crate) struct Observation { - pub entries: Vec, - pub truncated: bool, - pub visited: usize, -} - -fn invalid() -> io::Error { - io::ErrorKind::InvalidInput.into() -} - -pub(crate) fn observe(fd: &OwnedFd, limit: usize) -> io::Result { - if !(1..=4096).contains(&limit) { - return Err(invalid()); - } - let mut result = Observation { - entries: Vec::with_capacity(limit), - truncated: false, - visited: 0, - }; - for entry in Dir::read_from(fd)? { - let entry = entry?; - let name = entry.file_name().to_str().map_err(|_| invalid())?; - if name == "." || name == ".." { - continue; - } - result.visited += 1; - if result.entries.len() == limit { - result.truncated = true; - break; - } - if name.len() > 255 || name.contains(['\\', '\0', '\r', '\n']) { - return Err(invalid()); - } - let metadata = statat(fd, entry.file_name(), AtFlags::SYMLINK_NOFOLLOW)?; - let kind = FileType::from_raw_mode(metadata.st_mode); - let size = if kind == FileType::RegularFile { - Some(u64::try_from(metadata.st_size).map_err(|_| invalid())?) - } else { - None - }; - result.entries.push(Entry { - name: name.into(), - kind, - size, - }); - } - Ok(result) -} - -#[cfg(test)] -#[path = "directory_tests.rs"] -mod tests; - -// Tests counting process-wide descriptors must exclude concurrent fixture I/O. -#[cfg(test)] -pub(crate) static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); diff --git a/packages/codex-executor/src/directory_tests.rs b/packages/codex-executor/src/directory_tests.rs deleted file mode 100644 index 3aa0519ff..000000000 --- a/packages/codex-executor/src/directory_tests.rs +++ /dev/null @@ -1,154 +0,0 @@ -use super::*; -use crate::workspace_path::{anchor, directory}; -use std::fs; -use std::os::unix::fs::symlink; -use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; - -struct Fixture { - path: PathBuf, - _guard: std::sync::MutexGuard<'static, ()>, -} -impl Fixture { - fn new() -> Self { - static NEXT: AtomicU64 = AtomicU64::new(0); - let guard = TEST_LOCK.lock().unwrap(); - let root = PathBuf::from(std::env::var_os("HOME").expect("HOME required")) - .join(".oac/tests/scoped-directory"); - fs::create_dir_all(&root).unwrap(); - let path = root.join(format!( - "fixture-{}-{}", - std::process::id(), - NEXT.fetch_add(1, Ordering::Relaxed) - )); - fs::create_dir(&path).unwrap(); - Self { - path, - _guard: guard, - } - } -} -impl Drop for Fixture { - fn drop(&mut self) { - fs::remove_dir_all(&self.path).unwrap(); - } -} - -#[test] -fn ancestor_replacement_after_open_stays_on_authorized_directory() { - let f = Fixture::new(); - fs::create_dir_all(f.path.join("workspace/a/sub")).unwrap(); - fs::create_dir_all(f.path.join("outside/sub")).unwrap(); - fs::write(f.path.join("workspace/a/sub/inside"), b"inside").unwrap(); - fs::write(f.path.join("workspace/a/sub/outside-secret"), b"decoy").unwrap(); - fs::write(f.path.join("outside/sub/outside-secret"), b"outside-secret").unwrap(); - let root = anchor(&f.path.join("workspace")).unwrap(); - let selected = directory(&root, "a/sub").unwrap(); - fs::rename(f.path.join("workspace/a"), f.path.join("workspace/held")).unwrap(); - symlink(f.path.join("outside"), f.path.join("workspace/a")).unwrap(); - let path_names: Vec<_> = fs::read_dir(f.path.join("workspace/a/sub")) - .unwrap() - .map(|entry| entry.unwrap().file_name()) - .collect(); - assert_eq!(path_names, ["outside-secret"]); - let mut safe = observe(&selected, 16).unwrap(); - safe.entries.sort_by(|a, b| a.name.cmp(&b.name)); - assert_eq!( - safe.entries, - [ - Entry { - name: "inside".into(), - kind: FileType::RegularFile, - size: Some(6) - }, - Entry { - name: "outside-secret".into(), - kind: FileType::RegularFile, - size: Some(5) - }, - ] - ); - assert!(!safe.truncated); - assert!(directory(&root, "a/sub").is_err()); - fs::remove_file(f.path.join("workspace/a")).unwrap(); - fs::rename(f.path.join("workspace/held"), f.path.join("workspace/a")).unwrap(); - for name in path_names { - assert!( - fs::symlink_metadata(f.path.join("workspace/a/sub").join(name)) - .unwrap() - .is_file() - ); - } -} - -#[test] -fn ancestor_replacement_between_component_opens_stays_on_original_fd() { - let f = Fixture::new(); - fs::create_dir_all(f.path.join("workspace/a/sub")).unwrap(); - fs::create_dir_all(f.path.join("outside/sub")).unwrap(); - fs::write(f.path.join("workspace/a/sub/inside"), b"inside").unwrap(); - fs::write(f.path.join("outside/sub/outside"), b"outside").unwrap(); - let root = anchor(&f.path.join("workspace")).unwrap(); - let parent = directory(&root, "a").unwrap(); - fs::rename(f.path.join("workspace/a"), f.path.join("workspace/held")).unwrap(); - symlink(f.path.join("outside"), f.path.join("workspace/a")).unwrap(); - let selected = directory(&parent, "sub").unwrap(); - assert_eq!(observe(&selected, 1).unwrap().entries[0].name, "inside"); -} - -#[test] -fn bound_applies_before_collecting_directory_names() { - let f = Fixture::new(); - for i in 0..5000 { - fs::write(f.path.join(format!("entry-{i:05}")), b"x").unwrap(); - } - let root = anchor(&f.path).unwrap(); - let selected = directory(&root, "").unwrap(); - let result = observe(&selected, 3).unwrap(); - assert_eq!(result.entries.len(), 3); - assert_eq!(result.visited, 4); - assert!(result.truncated); -} - -#[test] -fn symlink_metadata_does_not_follow_the_target_and_invalid_paths_fail() { - let f = Fixture::new(); - fs::create_dir(f.path.join("workspace")).unwrap(); - fs::write( - f.path.join("outside"), - b"must not become the returned file size", - ) - .unwrap(); - symlink(f.path.join("outside"), f.path.join("workspace/link")).unwrap(); - let root = anchor(&f.path.join("workspace")).unwrap(); - let selected = directory(&root, "").unwrap(); - let result = observe(&selected, 1).unwrap(); - assert_eq!( - result.entries, - [Entry { - name: "link".into(), - kind: FileType::Symlink, - size: None - }] - ); - assert!(!result.truncated); - for path in ["/outside", "..", "a/../b", ".", "a//b", "a/", "a\\b", "a\0"] { - assert!(directory(&root, path).is_err(), "{path:?}"); - } - assert!(observe(&selected, 0).is_err()); - assert!(observe(&selected, 4097).is_err()); -} - -#[test] -fn repeated_reads_release_directory_descriptors() { - let f = Fixture::new(); - let count = || fs::read_dir("/proc/self/fd").unwrap().count(); - let before = count(); - for _ in 0..200 { - let root = anchor(&f.path).unwrap(); - let selected = directory(&root, "").unwrap(); - let result = observe(&selected, 1).unwrap(); - assert!(result.entries.is_empty() && !result.truncated); - } - assert_eq!(count(), before); -} diff --git a/packages/codex-executor/src/export.rs b/packages/codex-executor/src/export.rs deleted file mode 100644 index 3e5de57b1..000000000 --- a/packages/codex-executor/src/export.rs +++ /dev/null @@ -1,145 +0,0 @@ -use crate::{directory::observe, workspace_path}; -use rustix::fs::{FileType, Mode, OFlags, Stat, fstat, openat}; -use std::fs::File; -use std::io::{self, Read, Write}; -use std::os::fd::OwnedFd; -use std::path::Path; - -const MAX_FILE_BYTES: u64 = 200 * 1024 * 1024; -const MAX_TOTAL_BYTES: u64 = 500 * 1024 * 1024; -// Defensive traversal bounds, not upstream protocol limits. -const MAX_ENTRIES: usize = 4096; -const MAX_DEPTH: usize = 64; - -#[derive(Default)] -struct Budget { - entries: usize, - bytes: u64, -} - -fn invalid() -> io::Error { - io::ErrorKind::InvalidData.into() -} - -pub(crate) fn outputs(root: &Path, output: impl Write) -> io::Result<()> { - let root = workspace_path::anchor(root)?; - let mut archive = tar::Builder::new(output); - let selected = match workspace_path::directory(&root, "outputs") { - Ok(fd) => fd, - Err(error) if error.kind() == io::ErrorKind::NotFound => return archive.finish(), - Err(error) => return Err(error), - }; - let device = fstat(&root)?.st_dev; - walk( - &selected, - "outputs", - device, - 0, - &mut Budget::default(), - &mut archive, - )?; - archive.finish() -} - -fn walk( - parent: &OwnedFd, - path: &str, - device: u64, - depth: usize, - budget: &mut Budget, - archive: &mut tar::Builder, -) -> io::Result<()> { - if depth > MAX_DEPTH || path.len() > 4096 { - return Err(invalid()); - } - let before = fstat(parent)?; - if before.st_dev != device { - return Err(invalid()); - } - let mut listing = observe(parent, MAX_ENTRIES)?; - budget.entries += listing.visited; - if listing.truncated || budget.entries > MAX_ENTRIES { - return Err(invalid()); - } - listing.entries.sort_by(|a, b| a.name.cmp(&b.name)); - for entry in &listing.entries { - let path = format!("{path}/{}", entry.name); - if path.len() > 4096 { - return Err(invalid()); - } - match entry.kind { - FileType::Directory => { - let child = workspace_path::directory(parent, &entry.name)?; - walk(&child, &path, device, depth + 1, budget, archive)?; - } - FileType::RegularFile => { - let file = openat( - parent, - entry.name.as_str(), - OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC, - Mode::empty(), - )?; - append(File::from(file), &path, device, budget, archive)?; - } - // A link is recognized by its lstat type and skipped: it is never - // followed, opened or resolved, and publishes no Artifact. - FileType::Symlink => {} - _ => return Err(invalid()), - } - } - // Directory timestamps may have coarser resolution than successive mutations. - let mut after = observe(parent, MAX_ENTRIES)?; - after.entries.sort_by(|a, b| a.name.cmp(&b.name)); - if after.truncated || after.entries != listing.entries || !unchanged(&before, &fstat(parent)?) { - return Err(invalid()); - } - Ok(()) -} - -fn append( - mut file: File, - path: &str, - device: u64, - budget: &mut Budget, - archive: &mut tar::Builder, -) -> io::Result<()> { - let before = fstat(&file)?; - if FileType::from_raw_mode(before.st_mode) != FileType::RegularFile - || before.st_dev != device - || before.st_nlink != 1 - { - return Err(invalid()); - } - let size = u64::try_from(before.st_size).map_err(|_| invalid())?; - if size > MAX_FILE_BYTES || size > MAX_TOTAL_BYTES - budget.bytes { - return Err(invalid()); - } - budget.bytes += size; - let mut header = tar::Header::new_gnu(); - header.set_entry_type(tar::EntryType::Regular); - header.set_mode(0o600); - header.set_size(size); - // Read through the held descriptor, never reopen an attacker-controlled path. - let mut body = (&mut file).take(size); - archive.append_data(&mut header, path, &mut body)?; - if body.limit() != 0 || !unchanged(&before, &fstat(&file)?) { - return Err(invalid()); - } - Ok(()) -} - -fn unchanged(a: &Stat, b: &Stat) -> bool { - a.st_dev == b.st_dev - && a.st_ino == b.st_ino - && a.st_mode == b.st_mode - && a.st_nlink == b.st_nlink - && a.st_size == b.st_size - && a.st_mtime == b.st_mtime - && a.st_mtime_nsec == b.st_mtime_nsec - && a.st_ctime == b.st_ctime - && a.st_ctime_nsec == b.st_ctime_nsec -} - -#[cfg(test)] -#[path = "export_tests.rs"] -mod tests; diff --git a/packages/codex-executor/src/export_tests.rs b/packages/codex-executor/src/export_tests.rs deleted file mode 100644 index cdbf5f874..000000000 --- a/packages/codex-executor/src/export_tests.rs +++ /dev/null @@ -1,263 +0,0 @@ -use super::*; -use rustix::fs::{inotify, mkfifoat}; -use std::collections::BTreeMap; -use std::ffi::CStr; -use std::fs; -use std::mem::MaybeUninit; -use std::os::unix::{fs::symlink, net::UnixListener}; - -#[test] -fn captures_nested_binary_empty_and_long_names_without_other_workspace_files() { - let _guard = crate::directory::TEST_LOCK.lock().unwrap(); - let root = tempfile::tempdir().unwrap(); - fs::create_dir_all(root.path().join("outputs/nested")).unwrap(); - fs::write(root.path().join("private.txt"), "not an output").unwrap(); - fs::write(root.path().join("outputs/empty"), []).unwrap(); - let body: Vec<_> = (0..=255).cycle().take(131_079).collect(); - let name = format!("outputs/nested/{}", "x".repeat(200)); - fs::write(root.path().join(&name), &body).unwrap(); - let mut bytes = Vec::new(); - outputs(root.path(), &mut bytes).unwrap(); - let mut archive = tar::Archive::new(bytes.as_slice()); - let mut actual = BTreeMap::new(); - for entry in archive.entries().unwrap() { - let mut entry = entry.unwrap(); - assert!(entry.header().entry_type().is_file()); - let name = entry.path().unwrap().to_str().unwrap().to_owned(); - let mut data = Vec::new(); - entry.read_to_end(&mut data).unwrap(); - actual.insert(name, data); - } - assert_eq!( - actual, - BTreeMap::from([("outputs/empty".into(), vec![]), (name, body)]) - ); -} - -#[test] -fn absent_outputs_is_an_empty_archive() { - let _guard = crate::directory::TEST_LOCK.lock().unwrap(); - let root = tempfile::tempdir().unwrap(); - let mut bytes = Vec::new(); - outputs(root.path(), &mut bytes).unwrap(); - assert_eq!( - tar::Archive::new(bytes.as_slice()) - .entries() - .unwrap() - .count(), - 0 - ); -} - -/// Returns pending inotify events as (flags, entry name) without blocking. -fn drain(watcher: &OwnedFd) -> Vec<(inotify::ReadFlags, Option)> { - let mut buffer = [MaybeUninit::::uninit(); 8192]; - let mut reader = inotify::Reader::new(watcher, &mut buffer); - let mut events = Vec::new(); - loop { - match reader.next() { - Ok(event) => events.push(( - event.events(), - event.file_name().map(CStr::to_string_lossy).map(Into::into), - )), - Err(rustix::io::Errno::AGAIN) => return events, - Err(error) => panic!("inotify read: {error}"), - } - } -} - -#[test] -fn skips_symlinks_without_following_or_opening_their_targets() { - let _guard = crate::directory::TEST_LOCK.lock().unwrap(); - let root = tempfile::tempdir().unwrap(); - let other = tempfile::tempdir().unwrap(); - let secret = b"outside workspace secret marker"; - let private = b"workspace file outside outputs marker"; - fs::write(other.path().join("secret"), secret).unwrap(); - fs::create_dir(other.path().join("directory")).unwrap(); - fs::write(other.path().join("directory/secret"), secret).unwrap(); - fs::write(root.path().join("private.txt"), private).unwrap(); - fs::create_dir_all(root.path().join("outputs/sub")).unwrap(); - fs::write(root.path().join("outputs/a.txt"), "alpha").unwrap(); - fs::write(root.path().join("outputs/sub/b.txt"), "bravo").unwrap(); - fs::write(root.path().join("outputs/empty.txt"), []).unwrap(); - let tree = root.path().join("outputs"); - for (link, target) in [ - ("link.txt", Path::new("a.txt").to_path_buf()), - ("sub-link", "sub".into()), - ("sub/parent", "..".into()), - ("self", "self".into()), - ("dangling", "missing".into()), - ("private-link", "../private.txt".into()), - ("outside-file", other.path().join("secret")), - ("outside-directory", other.path().join("directory")), - ("sub/outside-root", other.path().into()), - ] { - symlink(target, tree.join(link)).unwrap(); - } - // Any open or read of a target through a followed link would be observed here. - let watcher = - inotify::init(inotify::CreateFlags::NONBLOCK | inotify::CreateFlags::CLOEXEC).unwrap(); - let flags = inotify::WatchFlags::OPEN | inotify::WatchFlags::ACCESS; - for watched in [ - other.path().to_path_buf(), - other.path().join("directory"), - root.path().join("private.txt"), - ] { - inotify::add_watch(&watcher, &watched, flags).unwrap(); - } - let mut bytes = Vec::new(); - outputs(root.path(), &mut bytes).unwrap(); - assert_eq!(drain(&watcher), [], "a link target was opened or read"); - let mut archive = tar::Archive::new(bytes.as_slice()); - let mut actual = BTreeMap::new(); - for entry in archive.entries().unwrap() { - let mut entry = entry.unwrap(); - assert!(entry.header().entry_type().is_file()); - let name = entry.path().unwrap().to_str().unwrap().to_owned(); - let mut data = Vec::new(); - entry.read_to_end(&mut data).unwrap(); - actual.insert(name, data); - } - assert_eq!( - actual, - BTreeMap::from([ - ("outputs/a.txt".into(), b"alpha".to_vec()), - ("outputs/empty.txt".into(), vec![]), - ("outputs/sub/b.txt".into(), b"bravo".to_vec()), - ]) - ); - for marker in [&secret[..], &private[..]] { - assert!(!bytes.windows(marker.len()).any(|part| part == marker)); - } - // Positive control: the watcher does report an actual target read. - assert_eq!(fs::read(other.path().join("secret")).unwrap(), secret); - assert!( - drain(&watcher) - .iter() - .any(|(flags, name)| flags.contains(inotify::ReadFlags::OPEN) - && name.as_deref() == Some("secret")), - "inotify control was not observed" - ); -} - -#[test] -fn rejects_root_link_hard_links_and_special_files_without_exposing_their_contents() { - let _guard = crate::directory::TEST_LOCK.lock().unwrap(); - for kind in ["root-symlink", "hardlink", "socket", "fifo"] { - let root = tempfile::tempdir().unwrap(); - let other = tempfile::tempdir().unwrap(); - let secret = b"private daemon credential marker"; - fs::write(other.path().join("secret"), secret).unwrap(); - if kind == "root-symlink" { - symlink(other.path(), root.path().join("outputs")).unwrap(); - } else { - fs::create_dir(root.path().join("outputs")).unwrap(); - fs::write(root.path().join("outputs/regular"), "kept only on success").unwrap(); - } - let target = root.path().join("outputs/file"); - let _socket = match kind { - "hardlink" => { - fs::hard_link(other.path().join("secret"), target).unwrap(); - None - } - "socket" => Some(UnixListener::bind(target).unwrap()), - "fifo" => { - mkfifoat(rustix::fs::CWD, &target, Mode::RUSR | Mode::WUSR).unwrap(); - None - } - _ => None, - }; - let mut bytes = Vec::new(); - assert!(outputs(root.path(), &mut bytes).is_err(), "{kind}"); - assert!( - !bytes.windows(secret.len()).any(|part| part == secret), - "{kind}" - ); - } -} - -#[test] -fn enforces_file_and_total_bytes_without_loading_bodies() { - let _guard = crate::directory::TEST_LOCK.lock().unwrap(); - for sizes in [ - vec![MAX_FILE_BYTES + 1], - vec![MAX_FILE_BYTES, MAX_FILE_BYTES, 101 << 20], - ] { - let root = tempfile::tempdir().unwrap(); - fs::create_dir(root.path().join("outputs")).unwrap(); - for (index, size) in sizes.iter().enumerate() { - File::create(root.path().join(format!("outputs/{index}"))) - .unwrap() - .set_len(*size) - .unwrap(); - } - assert!(outputs(root.path(), io::sink()).is_err()); - } -} - -#[test] -fn rejects_a_changed_file_or_directory_during_export() { - let _guard = crate::directory::TEST_LOCK.lock().unwrap(); - struct Mutate<'a> { - root: &'a Path, - change: &'static str, - done: bool, - } - impl Write for Mutate<'_> { - fn write(&mut self, bytes: &[u8]) -> io::Result { - if !self.done { - self.done = true; - match self.change { - "file" => fs::write(self.root.join("outputs/file"), "changed-length")?, - "directory" => fs::write(self.root.join("outputs/new"), "late")?, - // Skipped links still take part in the directory listing check. - _ => symlink("file", self.root.join("outputs/late-link"))?, - } - } - Ok(bytes.len()) - } - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } - for change in ["file", "directory", "link"] { - let root = tempfile::tempdir().unwrap(); - fs::create_dir(root.path().join("outputs")).unwrap(); - fs::write(root.path().join("outputs/file"), "initial").unwrap(); - assert!( - outputs( - root.path(), - Mutate { - root: root.path(), - change, - done: false - } - ) - .is_err(), - "change={change}" - ); - } -} - -#[test] -fn rejects_truncated_traversal_and_broken_destination() { - let _guard = crate::directory::TEST_LOCK.lock().unwrap(); - let root = tempfile::tempdir().unwrap(); - fs::create_dir(root.path().join("outputs")).unwrap(); - for index in 0..=MAX_ENTRIES { - fs::write(root.path().join(format!("outputs/{index}")), []).unwrap(); - } - assert!(outputs(root.path(), io::sink()).is_err()); - struct Broken; - impl Write for Broken { - fn write(&mut self, _: &[u8]) -> io::Result { - Err(io::ErrorKind::BrokenPipe.into()) - } - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } - let empty = tempfile::tempdir().unwrap(); - assert!(outputs(empty.path(), Broken).is_err()); -} diff --git a/packages/codex-executor/src/workspace_path.rs b/packages/codex-executor/src/workspace_path.rs deleted file mode 100644 index c6096989b..000000000 --- a/packages/codex-executor/src/workspace_path.rs +++ /dev/null @@ -1,69 +0,0 @@ -use rustix::fs::{Mode, OFlags, open, openat}; -use std::io; -use std::os::fd::OwnedFd; -use std::path::{Component, Path}; - -fn invalid() -> io::Error { - io::Error::new(io::ErrorKind::InvalidInput, "invalid directory request") -} - -pub(crate) fn anchor(root: &Path) -> io::Result { - if !root.is_absolute() || root.as_os_str().len() > 4096 { - return Err(invalid()); - } - let mut fd = open( - "/", - OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC, - Mode::empty(), - )?; - for part in root.components() { - match part { - Component::RootDir => (), - Component::Normal(name) => { - fd = openat( - &fd, - name, - OFlags::PATH | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, - Mode::empty(), - )?; - } - _ => return Err(invalid()), - } - } - Ok(fd) -} - -pub(crate) fn directory(root: &OwnedFd, relative: &str) -> io::Result { - if relative.len() > 4096 { - return Err(invalid()); - } - let mut fd = openat( - root, - ".", - OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC, - Mode::empty(), - )?; - if !relative.is_empty() { - for part in relative.split('/') { - if part.is_empty() - || part == "." - || part == ".." - || part.contains(['\\', '\0', '\r', '\n']) - { - return Err(invalid()); - } - fd = openat( - &fd, - part, - OFlags::PATH | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, - Mode::empty(), - )?; - } - } - Ok(openat( - &fd, - ".", - OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, - Mode::empty(), - )?) -} diff --git a/packages/codex-executor/src/write_file.rs b/packages/codex-executor/src/write_file.rs deleted file mode 100644 index a6dec3e3a..000000000 --- a/packages/codex-executor/src/write_file.rs +++ /dev/null @@ -1,312 +0,0 @@ -use crate::workspace_path::{anchor, directory}; -use rustix::fs::{ - AtFlags, FileType, Mode, OFlags, RenameFlags, fstat, linkat, mkdirat, openat, renameat, - renameat_with, statat, unlinkat, -}; -use rustix::io::Errno; -use sha2::{Digest, Sha256}; -use std::fs::File; -use std::io::{self, Read, Write}; -use std::os::fd::OwnedFd; -use std::path::Path; - -pub(crate) const MAX_BYTES: u64 = 50 * 1024 * 1024; - -/// Selects the destination policy explicitly for each invocation. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum InstallMode { - /// Trusted initialization (initial Session files and Skills): the parent must - /// exist and an existing regular file is replaced by a fresh inode. - Replace, - /// Public Environment Files.create: missing parents are created without - /// following links and an existing destination is never replaced. - Create, -} - -/// A known create-mode refusal. Nothing was installed and staging is removed. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum Rejection { - /// A directory already occupies the destination. - Directory, - /// The destination exists, or a path component is a symlink or not a directory. - Unsafe, -} - -#[derive(Debug)] -pub(crate) struct Failure { - pub committed: bool, - pub rejection: Option, - pub error: io::Error, -} - -impl From for Failure { - fn from(error: io::Error) -> Self { - Self { - committed: false, - rejection: None, - error, - } - } -} - -impl From for Failure { - fn from(error: Errno) -> Self { - io::Error::from(error).into() - } -} - -impl From for Failure { - fn from(rejection: Rejection) -> Self { - Self { - committed: false, - rejection: Some(rejection), - error: io::ErrorKind::AlreadyExists.into(), - } - } -} - -pub(crate) fn install( - root: &Path, - relative: &str, - size: u64, - mut input: impl Read, - staging_root: &Path, - mode: InstallMode, -) -> Result<(), Failure> { - if size > MAX_BYTES - || staging_root.starts_with(root) - || root.starts_with(staging_root) - || relative.is_empty() - || relative.len() > 4096 - || relative - .split('/') - .any(|p| p.is_empty() || p == "." || p == ".." || p.contains(['\\', '\0', '\r', '\n'])) - { - return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); - } - let (parent, leaf) = relative.rsplit_once('/').unwrap_or(("", relative)); - let root = anchor(root)?; - let (held, missing) = match mode { - InstallMode::Replace => (directory(&root, parent)?, Vec::new()), - InstallMode::Create => existing_prefix(&root, parent)?, - }; - let staging_parent = directory(&anchor(staging_root)?, "")?; - let device = fstat(&staging_parent)?.st_dev; - if fstat(&held)?.st_dev != device { - return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); - } - if missing.is_empty() { - match (mode, statat(&held, leaf, AtFlags::SYMLINK_NOFOLLOW)) { - (_, Err(Errno::NOENT)) => (), - (InstallMode::Replace, Ok(metadata)) - if FileType::from_raw_mode(metadata.st_mode) == FileType::RegularFile => {} - (InstallMode::Replace, Ok(_)) => { - return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); - } - (InstallMode::Create, Ok(metadata)) - if FileType::from_raw_mode(metadata.st_mode) == FileType::Directory => - { - return Err(Rejection::Directory.into()); - } - (InstallMode::Create, Ok(_)) => return Err(Rejection::Unsafe.into()), - (_, Err(error)) => return Err(error.into()), - } - } - let staging = Staging::new(&staging_parent)?; - let mut file = &staging.file; - let mut digest = Sha256::new(); - let mut remaining = size; - let mut buffer = [0u8; 64 * 1024]; - while remaining != 0 { - let count = remaining.min(buffer.len() as u64) as usize; - input.read_exact(&mut buffer[..count])?; - file.write_all(&buffer[..count])?; - digest.update(&buffer[..count]); - remaining -= count as u64; - } - // Native process/write has no stdin-close operation; the digest ends one frame. - let mut trailer = [0u8; 32]; - input.read_exact(&mut trailer)?; - if digest.finalize().as_slice() != trailer { - return Err(io::Error::from(io::ErrorKind::InvalidData).into()); - } - staging.file.sync_all()?; - let parent = match mode { - InstallMode::Replace => { - staging.replace(&held, leaf)?; - held - } - InstallMode::Create => { - // Parents are created only after the complete body is verified. - let parent = create_missing(held, &missing)?; - if fstat(&parent)?.st_dev != device { - return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); - } - staging.install_new(&parent, leaf)?; - parent - } - }; - File::from(parent).sync_all().map_err(|error| Failure { - committed: true, - rejection: None, - error, - })?; - File::from(staging_parent) - .sync_all() - .map_err(|error| Failure { - committed: true, - rejection: None, - error, - })?; - Ok(()) -} - -const HELD: OFlags = OFlags::PATH - .union(OFlags::DIRECTORY) - .union(OFlags::NOFOLLOW) - .union(OFlags::CLOEXEC); - -/// Walks existing components below the held root without following links. It -/// returns the deepest existing directory and the components still missing. An -/// existing symlink or non-directory component rejects; nothing is created here. -fn existing_prefix<'a>( - root: &OwnedFd, - relative: &'a str, -) -> Result<(OwnedFd, Vec<&'a str>), Failure> { - let mut fd = openat( - root, - ".", - OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC, - Mode::empty(), - )?; - if relative.is_empty() { - return Ok((fd, Vec::new())); - } - let parts: Vec<_> = relative.split('/').collect(); - for (index, part) in parts.iter().enumerate() { - match openat(&fd, *part, HELD, Mode::empty()) { - Ok(child) => fd = child, - Err(Errno::NOENT) => return Ok((fd, parts[index..].to_vec())), - Err(Errno::NOTDIR | Errno::LOOP) => return Err(Rejection::Unsafe.into()), - Err(error) => return Err(error.into()), - } - } - Ok((fd, Vec::new())) -} - -/// Creates each missing component with mode 0700 relative to the held parent, -/// then reopens it without following links. A component that appears -/// concurrently is accepted only if it is a real directory. -fn create_missing(mut fd: OwnedFd, missing: &[&str]) -> Result { - for part in missing { - match mkdirat(&fd, *part, Mode::from_raw_mode(0o700)) { - Ok(()) => File::from(openat( - &fd, - ".", - OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, - Mode::empty(), - )?) - .sync_all()?, - Err(Errno::EXIST) => (), - Err(error) => return Err(error.into()), - } - fd = match openat(&fd, *part, HELD, Mode::empty()) { - Ok(child) => child, - Err(Errno::NOTDIR | Errno::LOOP) => return Err(Rejection::Unsafe.into()), - Err(error) => return Err(error.into()), - }; - } - Ok(openat( - &fd, - ".", - OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, - Mode::empty(), - )?) -} - -struct Staging<'a> { - parent: &'a OwnedFd, - name: String, - file: File, - committed: bool, -} - -impl<'a> Staging<'a> { - fn new(parent: &'a OwnedFd) -> io::Result { - let name = format!(".oac-upload-{}", uuid::Uuid::new_v4()); - let file = openat( - parent, - name.as_str(), - OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, - Mode::from_raw_mode(0o600), - )?; - Ok(Self { - parent, - name, - file: File::from(file), - committed: false, - }) - } - - fn replace(mut self, destination: &OwnedFd, leaf: &str) -> io::Result<()> { - renameat(self.parent, self.name.as_str(), destination, leaf)?; - self.committed = true; - Ok(()) - } - - /// Atomically installs the file only if the destination name is free. - fn install_new(self, destination: &OwnedFd, leaf: &str) -> Result<(), Failure> { - match renameat_with( - self.parent, - self.name.as_str(), - destination, - leaf, - RenameFlags::NOREPLACE, - ) { - Ok(()) => { - self.commit(); - Ok(()) - } - Err(Errno::EXIST) => Err(Rejection::Unsafe.into()), - // The filesystem or kernel lacks RENAME_NOREPLACE. - Err(Errno::INVAL | Errno::NOSYS) => self.link_new(destination, leaf), - Err(error) => Err(error.into()), - } - } - - /// linkat never replaces an existing name. Removing the staging name after - /// the link is best-effort cleanup of an already installed file. - fn link_new(self, destination: &OwnedFd, leaf: &str) -> Result<(), Failure> { - match linkat( - self.parent, - self.name.as_str(), - destination, - leaf, - AtFlags::empty(), - ) { - Ok(()) => { - let _ = unlinkat(self.parent, self.name.as_str(), AtFlags::empty()); - self.commit(); - Ok(()) - } - Err(Errno::EXIST) => Err(Rejection::Unsafe.into()), - Err(error) => Err(error.into()), - } - } - - fn commit(mut self) { - self.committed = true; - } -} - -impl Drop for Staging<'_> { - fn drop(&mut self) { - if !self.committed { - let _ = unlinkat(self.parent, self.name.as_str(), AtFlags::empty()); - } - } -} - -#[cfg(test)] -#[path = "write_file_tests.rs"] -mod tests; diff --git a/packages/codex-executor/src/write_file_tests.rs b/packages/codex-executor/src/write_file_tests.rs deleted file mode 100644 index 089687639..000000000 --- a/packages/codex-executor/src/write_file_tests.rs +++ /dev/null @@ -1,494 +0,0 @@ -use super::*; -use std::fs; -use std::io::Cursor; -use std::os::unix::fs::{MetadataExt, PermissionsExt, symlink}; - -fn fixture() -> tempfile::TempDir { - let root = std::path::PathBuf::from(std::env::var_os("HOME").expect("HOME required")) - .join(".oac/tests/scoped-write"); - fs::create_dir_all(&root).unwrap(); - tempfile::tempdir_in(root).unwrap() -} - -fn frame(data: &[u8]) -> impl Read + '_ { - Cursor::new(data).chain(Cursor::new(Sha256::digest(data).to_vec())) -} - -fn install(root: &Path, relative: &str, size: u64, input: impl Read) -> Result<(), Failure> { - let staging = fixture(); - let result = super::install( - root, - relative, - size, - input, - staging.path(), - InstallMode::Replace, - ); - assert_eq!(fs::read_dir(staging.path()).unwrap().count(), 0); - // Replace mode keeps the legacy error codes of trusted initialization. - assert!(result.as_ref().err().is_none_or(|f| f.rejection.is_none())); - result -} - -#[test] -fn requires_existing_disjoint_nofollow_staging_before_consuming_input() { - let f = fixture(); - let root = f.path().join("workspace"); - let staging = f.path().join("private"); - fs::create_dir_all(root.join("nested")).unwrap(); - fs::create_dir(&staging).unwrap(); - fs::write(root.join("file"), b"old").unwrap(); - symlink(&staging, f.path().join("link")).unwrap(); - struct Unread; - impl Read for Unread { - fn read(&mut self, _: &mut [u8]) -> io::Result { - panic!("invalid staging must be rejected before reading input") - } - } - for invalid in [ - root.clone(), - root.join("nested"), - f.path().to_path_buf(), - f.path().join("missing"), - f.path().join("link"), - staging.join("../private"), - Path::new("relative").to_path_buf(), - ] { - for mode in [InstallMode::Replace, InstallMode::Create] { - let failure = super::install(&root, "new/file", 3, Unread, &invalid, mode).unwrap_err(); - assert!(!failure.committed); - assert!(failure.rejection.is_none()); - } - let failure = - super::install(&root, "file", 3, Unread, &invalid, InstallMode::Replace).unwrap_err(); - assert!(!failure.committed); - assert_eq!(fs::read(root.join("file")).unwrap(), b"old"); - assert!(!root.join("new").exists()); - } - assert_eq!(fs::read_dir(&staging).unwrap().count(), 0); -} - -#[test] -fn replaces_only_destination_hard_link_and_keeps_exact_binary_bytes() { - let f = fixture(); - let root = f.path().join("workspace"); - fs::create_dir(&root).unwrap(); - let outside = f.path().join("outside"); - fs::write(&outside, b"outside").unwrap(); - fs::hard_link(&outside, root.join("file")).unwrap(); - let input: Vec<_> = (0..=255).cycle().take(256 * 1024).collect(); - install(&root, "file", input.len() as u64, frame(&input)).unwrap(); - assert_eq!(fs::read(&outside).unwrap(), b"outside"); - assert_eq!(fs::read(root.join("file")).unwrap(), input); - assert_ne!( - fs::metadata(&outside).unwrap().ino(), - fs::metadata(root.join("file")).unwrap().ino() - ); - assert_eq!( - fs::metadata(root.join("file")) - .unwrap() - .permissions() - .mode() - & 0o777, - 0o600 - ); - install(&root, "file", 0, frame(b"")).unwrap(); - assert!(fs::read(root.join("file")).unwrap().is_empty()); -} - -#[test] -fn incomplete_excess_and_failed_input_preserve_old_file_and_remove_staging() { - let f = fixture(); - fs::write(f.path().join("file"), b"old").unwrap(); - for (size, data) in [(4, b"new".as_slice()), (2, b"new".as_slice())] { - assert!( - !install(f.path(), "file", size, Cursor::new(data)) - .unwrap_err() - .committed - ); - assert_eq!(fs::read(f.path().join("file")).unwrap(), b"old"); - assert_eq!(fs::read_dir(f.path()).unwrap().count(), 1); - } - struct Failing; - impl Read for Failing { - fn read(&mut self, _: &mut [u8]) -> io::Result { - Err(io::ErrorKind::BrokenPipe.into()) - } - } - assert!(!install(f.path(), "file", 3, Failing).unwrap_err().committed); - assert_eq!(fs::read(f.path().join("file")).unwrap(), b"old"); - assert_eq!(fs::read_dir(f.path()).unwrap().count(), 1); -} - -#[test] -fn rejects_symlinks_traversal_nonregular_targets_and_oversized_inputs() { - let f = fixture(); - let root = f.path().join("workspace"); - fs::create_dir(&root).unwrap(); - fs::create_dir(f.path().join("outside")).unwrap(); - fs::write(f.path().join("outside/file"), b"canary").unwrap(); - symlink(f.path().join("outside/file"), root.join("link")).unwrap(); - symlink(f.path().join("outside"), root.join("parent")).unwrap(); - for name in [ - "link", - "parent/file", - "../outside/file", - "/file", - "a/../file", - "a//file", - "", - ".", - "a\\b", - "x\n", - "missing/file", - ] { - assert!( - install(&root, name, 3, Cursor::new(b"new")).is_err(), - "{name:?}" - ); - } - assert!(install(&root, "parent", 3, Cursor::new(b"new")).is_err()); - fs::create_dir(root.join("directory")).unwrap(); - assert!(install(&root, "directory", 0, io::empty()).is_err()); - assert!(install(&root, "large", MAX_BYTES + 1, io::empty()).is_err()); - assert_eq!(fs::read(f.path().join("outside/file")).unwrap(), b"canary"); -} - -#[test] -fn supports_large_stream_without_whole_input_allocation() { - let f = fixture(); - let mut digest = Sha256::new(); - for _ in 0..(MAX_BYTES / 8192) { - digest.update([0x91; 8192]); - } - let input = io::repeat(0x91) - .take(MAX_BYTES) - .chain(Cursor::new(digest.finalize().to_vec())); - install(f.path(), "large", MAX_BYTES, input).unwrap(); - let file = fs::File::open(f.path().join("large")).unwrap(); - assert_eq!(file.metadata().unwrap().len(), MAX_BYTES); - let mut content = io::BufReader::new(file); - let mut chunk = [0u8; 8192]; - loop { - let n = content.read(&mut chunk).unwrap(); - if n == 0 { - break; - } - assert!(chunk[..n].iter().all(|x| *x == 0x91)); - } -} - -#[test] -fn ancestor_replacement_during_input_cannot_redirect_commit() { - let f = fixture(); - let root = f.path().join("workspace"); - fs::create_dir_all(root.join("a")).unwrap(); - fs::create_dir(f.path().join("outside")).unwrap(); - fs::write(f.path().join("outside/file"), b"outside").unwrap(); - let mut changed = false; - let mut bytes = Cursor::new(b"inside"); - let input = std::io::Read::by_ref(&mut bytes); - struct Replace<'a> { - read: &'a mut Cursor<&'static [u8; 6]>, - change: Box, - } - impl Read for Replace<'_> { - fn read(&mut self, buf: &mut [u8]) -> io::Result { - (self.change)(); - self.read.read(buf) - } - } - let input = Replace { - read: input, - change: Box::new(|| { - if !changed { - fs::rename(root.join("a"), root.join("held")).unwrap(); - symlink(f.path().join("outside"), root.join("a")).unwrap(); - changed = true; - } - }), - }; - install( - &root, - "a/file", - 6, - input.chain(Cursor::new(Sha256::digest(b"inside").to_vec())), - ) - .unwrap(); - assert_eq!(fs::read(root.join("held/file")).unwrap(), b"inside"); - assert_eq!(fs::read(f.path().join("outside/file")).unwrap(), b"outside"); -} - -#[test] -fn requires_matching_commit_trailer_and_does_not_wait_for_eof() { - let f = fixture(); - fs::write(f.path().join("file"), b"old").unwrap(); - for data in [b"new".to_vec(), [b"new".as_slice(), &[0u8; 32]].concat()] { - assert!(install(f.path(), "file", 3, Cursor::new(data)).is_err()); - assert_eq!(fs::read(f.path().join("file")).unwrap(), b"old"); - } - struct NoMore; - impl Read for NoMore { - fn read(&mut self, _: &mut [u8]) -> io::Result { - panic!("must stop after commit trailer") - } - } - install(f.path(), "file", 3, frame(b"new").chain(NoMore)).unwrap(); - assert_eq!(fs::read(f.path().join("file")).unwrap(), b"new"); -} - -fn create(root: &Path, relative: &str, size: u64, input: impl Read) -> Result<(), Failure> { - let staging = fixture(); - let result = super::install( - root, - relative, - size, - input, - staging.path(), - InstallMode::Create, - ); - assert_eq!(fs::read_dir(staging.path()).unwrap().count(), 0); - result -} - -fn rejection(result: Result<(), Failure>) -> Option { - let failure = result.unwrap_err(); - assert!(!failure.committed); - failure.rejection -} - -fn mode(path: &Path) -> u32 { - fs::symlink_metadata(path).unwrap().permissions().mode() & 0o777 -} - -struct Unread; - -impl Read for Unread { - fn read(&mut self, _: &mut [u8]) -> io::Result { - panic!("a known conflict must be rejected before reading input") - } -} - -#[test] -fn create_mode_makes_missing_parents_after_verified_input() { - let f = fixture(); - let root = f.path().join("workspace"); - fs::create_dir(&root).unwrap(); - create(&root, "top.txt", 3, frame(b"top")).unwrap(); - create(&root, "n1/n2/nested.txt", 6, frame(b"nested")).unwrap(); - assert_eq!(fs::read(root.join("n1/n2/nested.txt")).unwrap(), b"nested"); - assert_eq!(fs::read(root.join("top.txt")).unwrap(), b"top"); - for created in ["n1", "n1/n2"] { - assert!(fs::symlink_metadata(root.join(created)).unwrap().is_dir()); - assert_eq!(mode(&root.join(created)), 0o700); - } - assert_eq!(mode(&root.join("n1/n2/nested.txt")), 0o600); - // An existing prefix is reused; only the missing tail is created. - create(&root, "n1/n3/deeper/empty", 0, frame(b"")).unwrap(); - assert!( - fs::read(root.join("n1/n3/deeper/empty")) - .unwrap() - .is_empty() - ); - // Incomplete or mismatched input creates no parents. - assert!(rejection(create(&root, "p1/p2/file", 3, Cursor::new(b"ab"))).is_none()); - let mismatched = [b"abc".as_slice(), &[0u8; 32]].concat(); - assert!(rejection(create(&root, "p1/p2/file", 3, Cursor::new(mismatched))).is_none()); - assert!(!root.join("p1").exists()); -} - -#[test] -fn create_mode_never_replaces_existing_destinations() { - let f = fixture(); - let root = f.path().join("workspace"); - fs::create_dir_all(root.join("n1/n2")).unwrap(); - let outside = f.path().join("outside"); - fs::write(&outside, b"outside").unwrap(); - fs::hard_link(&outside, root.join("alias")).unwrap(); - fs::write(root.join("n1/tracked.txt"), b"old").unwrap(); - fs::write(root.join("empty"), b"").unwrap(); - rustix::fs::mknodat( - rustix::fs::CWD, - root.join("fifo").as_path(), - FileType::Fifo, - rustix::fs::Mode::from_raw_mode(0o600), - 0, - ) - .unwrap(); - for name in ["n1/tracked.txt", "alias", "empty", "fifo"] { - assert_eq!( - rejection(create(&root, name, 3, Unread)), - Some(Rejection::Unsafe), - "{name}" - ); - } - for name in ["n1", "n1/n2"] { - assert_eq!( - rejection(create(&root, name, 3, Unread)), - Some(Rejection::Directory), - "{name}" - ); - assert!(fs::symlink_metadata(root.join(name)).unwrap().is_dir()); - } - assert_eq!(fs::read(root.join("n1/tracked.txt")).unwrap(), b"old"); - assert_eq!(fs::read(&outside).unwrap(), b"outside"); - assert_eq!( - fs::metadata(&outside).unwrap().ino(), - fs::metadata(root.join("alias")).unwrap().ino() - ); - assert!(fs::read(root.join("empty")).unwrap().is_empty()); - // A second create of the same path is refused after the first commits. - create(&root, "n1/once.txt", 3, frame(b"one")).unwrap(); - assert_eq!( - rejection(create(&root, "n1/once.txt", 3, frame(b"two"))), - Some(Rejection::Unsafe) - ); - assert_eq!(fs::read(root.join("n1/once.txt")).unwrap(), b"one"); -} - -#[test] -fn create_mode_rejects_symlink_and_nondirectory_components() { - let f = fixture(); - let root = f.path().join("workspace"); - fs::create_dir_all(root.join("real")).unwrap(); - let outside = f.path().join("outside"); - fs::create_dir(&outside).unwrap(); - fs::write(outside.join("file"), b"canary").unwrap(); - symlink(outside.join("file"), root.join("leaf-link")).unwrap(); - symlink(&outside, root.join("dir-link")).unwrap(); - symlink(&outside, root.join("real/nested-link")).unwrap(); - symlink(root.join("real"), root.join("inside-link")).unwrap(); - symlink(f.path().join("missing"), root.join("dangling")).unwrap(); - fs::write(root.join("plain"), b"plain").unwrap(); - for name in [ - "leaf-link", - "dir-link", - "dangling", - "inside-link", - "dir-link/file", - "dir-link/new/file", - "real/nested-link/file", - "inside-link/file", - "dangling/file", - "plain/child", - "plain/child/deeper", - ] { - assert_eq!( - rejection(create(&root, name, 3, Unread)), - Some(Rejection::Unsafe), - "{name}" - ); - } - assert_eq!(fs::read(outside.join("file")).unwrap(), b"canary"); - assert_eq!(fs::read_dir(&outside).unwrap().count(), 1); - assert_eq!(fs::read_dir(root.join("real")).unwrap().count(), 1); - assert!(!f.path().join("missing").exists()); - for invalid in [ - "../outside/file", - "/file", - "a/../b", - "a//b", - "a\\b", - "x\n", - ".", - ] { - assert!( - rejection(create(&root, invalid, 3, Unread)).is_none(), - "{invalid:?}" - ); - } -} - -/// Changes the workspace once, after the pre-install checks and before commit. -struct Race<'a, R> { - inner: R, - change: Option>, -} - -impl Read for Race<'_, R> { - fn read(&mut self, buf: &mut [u8]) -> io::Result { - if let Some(change) = self.change.take() { - change(); - } - self.inner.read(buf) - } -} - -fn racing<'a>(data: &'a [u8], change: impl FnOnce() + 'a) -> impl Read + 'a { - Race { - inner: frame(data), - change: Some(Box::new(change)), - } -} - -#[test] -fn create_mode_race_never_replaces_or_follows() { - let f = fixture(); - let root = f.path().join("workspace"); - fs::create_dir(&root).unwrap(); - let outside = f.path().join("outside"); - fs::create_dir(&outside).unwrap(); - // The destination appears after the leaf check. - let target = root.join("raced.txt"); - let input = racing(b"api", || fs::write(&target, b"racer").unwrap()); - assert_eq!( - rejection(create(&root, "raced.txt", 3, input)), - Some(Rejection::Unsafe) - ); - assert_eq!(fs::read(&target).unwrap(), b"racer"); - // A directory appears at the destination. - let input = racing(b"api", || fs::create_dir(root.join("raced-dir")).unwrap()); - assert_eq!( - rejection(create(&root, "raced-dir", 3, input)), - Some(Rejection::Unsafe) - ); - assert_eq!(fs::read_dir(root.join("raced-dir")).unwrap().count(), 0); - // A missing parent becomes a symlink before it is created. - let link = root.join("parent"); - let input = racing(b"api", || symlink(&outside, &link).unwrap()); - assert_eq!( - rejection(create(&root, "parent/child/file", 3, input)), - Some(Rejection::Unsafe) - ); - assert_eq!(fs::read_dir(&outside).unwrap().count(), 0); - // A missing parent is created concurrently as a real directory. - let concurrent = root.join("shared"); - let input = racing(b"api", || fs::create_dir(&concurrent).unwrap()); - create(&root, "shared/file", 3, input).unwrap(); - assert_eq!(fs::read(root.join("shared/file")).unwrap(), b"api"); - // A held existing ancestor that is replaced by a link keeps the commit inside. - fs::create_dir(root.join("a")).unwrap(); - let input = racing(b"api", || { - fs::rename(root.join("a"), root.join("held")).unwrap(); - symlink(&outside, root.join("a")).unwrap(); - }); - create(&root, "a/b/file", 3, input).unwrap(); - assert_eq!(fs::read(root.join("held/b/file")).unwrap(), b"api"); - assert_eq!(fs::read_dir(&outside).unwrap().count(), 0); -} - -#[test] -fn link_fallback_never_replaces_and_removes_staging() { - let f = fixture(); - let staging_root = f.path().join("staging"); - let root = f.path().join("workspace"); - fs::create_dir(&staging_root).unwrap(); - fs::create_dir(&root).unwrap(); - fs::write(root.join("existing"), b"old").unwrap(); - let staging_parent = directory(&anchor(&staging_root).unwrap(), "").unwrap(); - let parent = directory(&anchor(&root).unwrap(), "").unwrap(); - let staging = Staging::new(&staging_parent).unwrap(); - (&staging.file).write_all(b"new").unwrap(); - assert_eq!( - staging.link_new(&parent, "existing").unwrap_err().rejection, - Some(Rejection::Unsafe) - ); - assert_eq!(fs::read(root.join("existing")).unwrap(), b"old"); - assert_eq!(fs::read_dir(&staging_root).unwrap().count(), 0); - let staging = Staging::new(&staging_parent).unwrap(); - (&staging.file).write_all(b"new").unwrap(); - staging.link_new(&parent, "fresh").unwrap(); - assert_eq!(fs::read(root.join("fresh")).unwrap(), b"new"); - assert_eq!(fs::metadata(root.join("fresh")).unwrap().nlink(), 1); - assert_eq!(fs::read_dir(&staging_root).unwrap().count(), 0); -} diff --git a/provenance/README.md b/provenance/README.md index 6f41a21af..8237b7980 100644 --- a/provenance/README.md +++ b/provenance/README.md @@ -15,7 +15,6 @@ The source repository was not modified or stripped of Core. | `apps/parsar-daemon` | Native execution daemon and existing adapters | | `internal/agentdaemon`, `internal/agentskill`, `internal/runtimecrypto`, `internal/obs/log` | Shared execution protocol, placement, assets, crypto and logging | | `packages/agents-client` | Core HTTP client and protocol tests, independent of product business code | -| `packages/codex-executor`, `packages/codex-harness` | Pinned native helpers and Harness artifact builder | | `packages/claude-sdk-adapter`, `packages/mcode-harness`, `packages/tsconfig` | Existing alternative native execution adapters and their build dependencies | | Selected `scripts` and workflows | API/runtime build, packaging, database/protocol and native checks | diff --git a/scripts/build-agents-executor.sh b/scripts/build-agents-executor.sh deleted file mode 100755 index 53f7b43f9..000000000 --- a/scripts/build-agents-executor.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -runtime_root="${OAC_DEV_HOME:-$HOME/.oac}" -output_dir="${AGENTS_EXECUTOR_BUILD_DIR:-$runtime_root/build/agents-executor}" -for directory in "$runtime_root" "$output_dir"; do - if [[ "$directory" != /* ]]; then - printf 'Executor build directories must be absolute\n' >&2 - exit 1 - fi -done -if [[ "$(uname -s)" != Linux || "$(uname -m)" != x86_64 ]]; then - printf 'The executor build currently supports Linux x86_64\n' >&2 - exit 1 -fi - -export CARGO_HOME="${CARGO_HOME:-$runtime_root/cache/executor-cargo}" -export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$runtime_root/cache/executor-target}" -for directory in "$CARGO_HOME" "$CARGO_TARGET_DIR"; do - if [[ "$directory" != /* ]]; then - printf 'Executor Cargo directories must be absolute\n' >&2 - exit 1 - fi -done -mkdir -p "$runtime_root/cache/executor-builds" -build_context="$(mktemp -d "$runtime_root/cache/executor-builds/source.XXXXXX")" -trap 'rm -rf "$build_context"' EXIT -for file in Cargo.toml Cargo.lock rust-toolchain.toml; do - cp "$repo_root/packages/codex-executor/$file" "$build_context/$file" -done -cp -R "$repo_root/packages/codex-executor/src" "$build_context/src" -( - cd "$build_context" - cargo build --locked --release -) -mkdir -p "$output_dir" -for binary in oac-codex-directory oac-codex-write oac-workspace-export; do - cp "$CARGO_TARGET_DIR/release/$binary" "$output_dir/$binary.tmp" - mv -f "$output_dir/$binary.tmp" "$output_dir/$binary" -done -printf 'Standalone workspace helpers: %s\n' "$output_dir" diff --git a/scripts/build-agents-runtime.sh b/scripts/build-agents-runtime.sh index 3f812dafc..464687d31 100755 --- a/scripts/build-agents-runtime.sh +++ b/scripts/build-agents-runtime.sh @@ -6,8 +6,7 @@ runtime_root="${OAC_DEV_HOME:-$HOME/.oac}" output_dir="${AGENTS_RUNTIME_BUILD_DIR:-$runtime_root/build/agents-runtime}" # Extract the official @openai/codex@0.153.4-linux-x64 npm package here. package_dir="${AGENTS_RUNTIME_CODEX_PACKAGE:?Set AGENTS_RUNTIME_CODEX_PACKAGE to the extracted pinned platform package}" -helpers_dir="${AGENTS_EXECUTOR_BUILD_DIR:-$runtime_root/build/agents-executor}" -for directory in "$runtime_root" "$output_dir" "$package_dir" "$helpers_dir"; do +for directory in "$runtime_root" "$output_dir" "$package_dir"; do if [[ "$directory" != /* ]]; then printf 'Runtime build directories must be absolute: %s\n' "$directory" >&2 exit 1 @@ -19,7 +18,7 @@ package = json.load(open(sys.argv[1])) assert package['name'] == '@openai/codex' and package['version'] == '0.153.4-linux-x64', 'Expected pinned official Linux x64 package' PY native_dir="$package_dir/vendor/x86_64-unknown-linux-musl" -for executable in "$native_dir/bin/codex" "$helpers_dir/oac-codex-directory" "$helpers_dir/oac-codex-write" "$helpers_dir/oac-workspace-export"; do +for executable in "$native_dir/bin/codex"; do test -x "$executable" || { printf 'Missing executable: %s\n' "$executable" >&2; exit 1; } done test -d "$native_dir/codex-resources" @@ -31,7 +30,6 @@ trap 'rm -rf "$context"' EXIT CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=readonly -trimpath \ -o "$context/oac-daemon" ./apps/parsar-daemon/cmd/parsar-daemon ) -cp "$helpers_dir/oac-codex-directory" "$helpers_dir/oac-codex-write" "$helpers_dir/oac-workspace-export" "$context/" cp "$native_dir/bin/codex" "$context/codex" cp -R "$native_dir/codex-resources" "$context/codex-resources" cp "$repo_root/services/agents-api/deploy/codex/Dockerfile" "$context/Dockerfile" diff --git a/scripts/build-claude-runtime.sh b/scripts/build-claude-runtime.sh index 025f5b9ee..5030dc55e 100755 --- a/scripts/build-claude-runtime.sh +++ b/scripts/build-claude-runtime.sh @@ -5,8 +5,7 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" runtime_root="${OAC_DEV_HOME:-$HOME/.oac}" 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 +for directory in "$runtime_root" "$output_dir" "$sdk_dir"; do [[ "$directory" == /* ]] || { printf 'Expected absolute build directory: %s\n' "$directory" >&2; exit 1; } done mkdir -p "$runtime_root/cache/oac-runtime-builds" @@ -22,10 +21,6 @@ node "$repo_root/scripts/check-claude-sdk-runtime.mjs" "$context/claude-sdk" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=readonly -trimpath \ -o "$context/oac-daemon" ./apps/parsar-daemon/cmd/parsar-daemon ) -for helper in oac-codex-directory oac-codex-write oac-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/" diff --git a/scripts/build-core-distribution.sh b/scripts/build-core-distribution.sh index 4308ae997..1abe41290 100755 --- a/scripts/build-core-distribution.sh +++ b/scripts/build-core-distribution.sh @@ -154,8 +154,6 @@ cp -R apps/web/dist "$stage/web/dist" cp services/core-console/Dockerfile "$stage/web/Dockerfile" build_image web "$stage/web" -export AGENTS_EXECUTOR_BUILD_DIR="$stage/helpers" -scripts/build-agents-executor.sh CGO_ENABLED=0 go build -mod=readonly -trimpath -o "$stage/oac-daemon" ./apps/parsar-daemon/cmd/parsar-daemon cp "$stage/oac-daemon" "$bundle/native/bin/oac-daemon" CGO_ENABLED=0 go build -mod=readonly -trimpath -o "$bundle/native/bin/oac-selfhost" ./services/agents-api/cmd/runtime @@ -183,7 +181,7 @@ else mcode_image="$(cat "$stage/mcode.id")" fi for image in "$codex_image" "$claude_image" "$mcode_image"; do - python3 scripts/core-distribution-manifest.py verify-runtime "$image" "$stage/oac-daemon" "$stage/helpers" "$source_dir" + python3 scripts/core-distribution-manifest.py verify-runtime "$image" "$stage/oac-daemon" "$source_dir" done tag_suffix="${stage##*.}" for harness in codex claude mcode; do diff --git a/scripts/build-mcode-runtime.sh b/scripts/build-mcode-runtime.sh index 49412c48f..444dfdc54 100644 --- a/scripts/build-mcode-runtime.sh +++ b/scripts/build-mcode-runtime.sh @@ -6,8 +6,7 @@ runtime_root="${OAC_DEV_HOME:-$HOME/.oac}" output="${AGENTS_RUNTIME_BUILD_DIR:-$runtime_root/build/mcode-runtime}" companion="${MCODE_HARNESS_BUILD_DIR:?Set MCODE_HARNESS_BUILD_DIR to the built companion}" native="$companion/native" -helpers="${AGENTS_EXECUTOR_BUILD_DIR:-$runtime_root/build/agents-executor}" -for directory in "$runtime_root" "$output" "$native" "$companion" "$helpers"; do +for directory in "$runtime_root" "$output" "$native" "$companion"; do [[ "$directory" == /* ]] || { printf 'Absolute build directories are required\n' >&2; exit 1; } done test -f "$companion/provenance.json" @@ -23,10 +22,6 @@ cp -RL "$companion/." "$context/mcode-harness/" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=readonly -trimpath \ -o "$context/oac-daemon" ./apps/parsar-daemon/cmd/parsar-daemon ) -for helper in oac-codex-directory oac-codex-write oac-workspace-export; do - test -x "$helpers/$helper" - cp "$helpers/$helper" "$context/" -done cp "$repo_root/services/agents-api/deploy/mcode/Dockerfile" "$context/Dockerfile" mkdir -p "$output" cp -R "$context/." "$output/" diff --git a/scripts/check-agents-executor.sh b/scripts/check-agents-executor.sh deleted file mode 100755 index 7c4ddf43d..000000000 --- a/scripts/check-agents-executor.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -runtime_root="${OAC_DEV_HOME:-$HOME/.oac}" -if [[ "$runtime_root" != /* ]]; then - printf 'Executor runtime directory must be absolute\n' >&2 - exit 1 -fi -export CARGO_HOME="${CARGO_HOME:-$runtime_root/cache/executor-cargo}" -export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$runtime_root/cache/executor-target}" -for directory in "$CARGO_HOME" "$CARGO_TARGET_DIR"; do - if [[ "$directory" != /* ]]; then - printf 'Executor Cargo directories must be absolute\n' >&2 - exit 1 - fi -done -cd "$repo_root/packages/codex-executor" -cargo fmt --all -- --check -cargo test --locked -cargo clippy --locked --all-targets -- -D warnings diff --git a/scripts/core-distribution-manifest.py b/scripts/core-distribution-manifest.py index cd8aa7c29..6dc2f4228 100644 --- a/scripts/core-distribution-manifest.py +++ b/scripts/core-distribution-manifest.py @@ -158,12 +158,10 @@ def blob(descriptor, parse=False): return config_digest, manifest_digest -def verify_runtime(image, daemon, helpers, source): +def verify_runtime(image, daemon, source): details = verify_image(image) - helpers, source = pathlib.Path(helpers), pathlib.Path(source) + source = pathlib.Path(source) files = {"/usr/local/bin/oac-daemon": pathlib.Path(daemon)} - for name in ("oac-codex-directory", "oac-codex-write", "oac-workspace-export"): - files["/usr/local/bin/" + name] = helpers / name environment = dict(value.split("=", 1) for value in details["Config"]["Env"] if "=" in value) if "OAC_RUNTIME_MCODE_BIN" in environment: for name in ("launch.mjs", "bridge.mjs", "check.mjs", "tool-executor.mjs", "subagent-snapshot.mjs", "source.json"): diff --git a/scripts/name-allowlist.json b/scripts/name-allowlist.json index 8017f17c6..2d86c729e 100644 --- a/scripts/name-allowlist.json +++ b/scripts/name-allowlist.json @@ -179,16 +179,6 @@ "regex": "parsar\\.agents-api\\.model-provider-api-key\\.v1", "reason": "This persisted model-provider key fingerprint purpose remains stable across upgrade and request replay." }, - { - "path": "packages/codex-executor/Cargo.toml", - "regex": "\"agents-api-codex-executor\"", - "reason": "The Cargo package identity remains stable; the installed executable is oac-runtime-executor." - }, - { - "path": "packages/codex-executor/Cargo.lock", - "regex": "\"agents-api-codex-executor\"", - "reason": "The Cargo package identity remains stable; the installed executable is oac-runtime-executor." - }, { "path": "package.json", "regex": "\"(?:parsar-core|parsar-mcode-harness)\"", diff --git a/services/agents-api/deploy/claude/Dockerfile b/services/agents-api/deploy/claude/Dockerfile index e33d13ed2..08ffc4eee 100644 --- a/services/agents-api/deploy/claude/Dockerfile +++ b/services/agents-api/deploy/claude/Dockerfile @@ -5,7 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates bash git python3 python3-pip ripgrep \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /environment/workspace /workspace /home/runtime -COPY --chmod=0555 oac-daemon oac-codex-directory oac-codex-write oac-workspace-export /usr/local/bin/ +COPY --chmod=0555 oac-daemon /usr/local/bin/ COPY claude-sdk /opt/claude-sdk ENV HOME=/home/runtime OAC_RUNTIME_HOME=/home/runtime/.oac \ OAC_RUNTIME_CLAUDE_SDK_NODE=/usr/local/bin/node \ @@ -13,11 +13,7 @@ ENV HOME=/home/runtime OAC_RUNTIME_HOME=/home/runtime/.oac \ OAC_RUNTIME_CLAUDE_SDK_WORKSPACE=managed \ OAC_RUNTIME_WORKSPACE=/environment/workspace \ OAC_RUNTIME_INITIALIZATION_DIRECTORY=/environment/initialization \ - OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages \ - OAC_RUNTIME_DIRECTORY_HELPER=/usr/local/bin/oac-codex-directory \ - OAC_RUNTIME_WRITE_HELPER=/usr/local/bin/oac-codex-write \ - OAC_RUNTIME_EXPORT_HELPER=/usr/local/bin/oac-workspace-export \ - OAC_RUNTIME_STAGING=/environment/staging + OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages USER 1000:1000 RUN node /opt/claude-sdk/dist/runtime_check.js /opt/claude-sdk/dist/main.js WORKDIR /environment/workspace diff --git a/services/agents-api/deploy/codex/Dockerfile b/services/agents-api/deploy/codex/Dockerfile index 32b7304c6..79ef9471a 100644 --- a/services/agents-api/deploy/codex/Dockerfile +++ b/services/agents-api/deploy/codex/Dockerfile @@ -7,17 +7,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && mkdir -p /environment/workspace /workspace /home/runtime \ && groupadd --gid 1000 runtime \ && useradd --uid 1000 --gid 1000 --home-dir /home/runtime --no-create-home runtime -COPY --chmod=0555 oac-daemon oac-codex-directory oac-codex-write oac-workspace-export codex /usr/local/bin/ +COPY --chmod=0555 oac-daemon codex /usr/local/bin/ COPY codex-resources /usr/local/codex-resources ENV HOME=/home/runtime OAC_RUNTIME_HOME=/home/runtime/.oac \ OAC_RUNTIME_CODEX_BIN=/usr/local/bin/codex \ OAC_RUNTIME_WORKSPACE=/environment/workspace \ OAC_RUNTIME_INITIALIZATION_DIRECTORY=/environment/initialization \ - OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages \ - OAC_RUNTIME_DIRECTORY_HELPER=/usr/local/bin/oac-codex-directory \ - OAC_RUNTIME_WRITE_HELPER=/usr/local/bin/oac-codex-write \ - OAC_RUNTIME_EXPORT_HELPER=/usr/local/bin/oac-workspace-export \ - OAC_RUNTIME_STAGING=/environment/staging + OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages USER 1000:1000 RUN test "$(codex --version)" = "codex-cli 0.153.4" WORKDIR /environment/workspace diff --git a/services/agents-api/deploy/codex/README.md b/services/agents-api/deploy/codex/README.md index 134caa46c..294df4909 100644 --- a/services/agents-api/deploy/codex/README.md +++ b/services/agents-api/deploy/codex/README.md @@ -41,8 +41,7 @@ is not acceptance. ## Managed Runtime image and Docker adapter -Build the existing Rust filesystem helpers with `make build-agents-executor`, and -extract the official npm package `@openai/codex@0.153.4-linux-x64` beneath +Extract the official npm package `@openai/codex@0.153.4-linux-x64` beneath `~/.oac/`. Set `AGENTS_RUNTIME_CODEX_PACKAGE` to its extracted `package` directory and run `scripts/build-agents-runtime.sh`. It builds the existing daemon and prepares a binary-only Docker context at `~/.oac/build/agents-runtime`; build diff --git a/services/agents-api/deploy/mcode/Dockerfile b/services/agents-api/deploy/mcode/Dockerfile index 178d2ad43..df7c80c9a 100644 --- a/services/agents-api/deploy/mcode/Dockerfile +++ b/services/agents-api/deploy/mcode/Dockerfile @@ -5,7 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates bash git python3 python3-pip ripgrep \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /environment/workspace /workspace /home/runtime -COPY --chmod=0555 oac-daemon oac-codex-directory oac-codex-write oac-workspace-export /usr/local/bin/ +COPY --chmod=0555 oac-daemon /usr/local/bin/ COPY mcode-harness /opt/mcode-harness ENV HOME=/home/runtime OAC_RUNTIME_HOME=/home/runtime/.oac \ OAC_RUNTIME_MCODE_NODE=/usr/local/bin/node \ @@ -15,11 +15,7 @@ ENV HOME=/home/runtime OAC_RUNTIME_HOME=/home/runtime/.oac \ OAC_RUNTIME_MCODE_WORKSPACE=managed \ OAC_RUNTIME_WORKSPACE=/environment/workspace \ OAC_RUNTIME_INITIALIZATION_DIRECTORY=/environment/initialization \ - OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages \ - OAC_RUNTIME_DIRECTORY_HELPER=/usr/local/bin/oac-codex-directory \ - OAC_RUNTIME_WRITE_HELPER=/usr/local/bin/oac-codex-write \ - OAC_RUNTIME_EXPORT_HELPER=/usr/local/bin/oac-workspace-export \ - OAC_RUNTIME_STAGING=/environment/staging + OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages USER 1000:1000 RUN node /opt/mcode-harness/check.mjs && /opt/mcode-harness/native/cli.js --version WORKDIR /environment/workspace From 1bdd1433434e8e3c0fd210f29865cdc9fffe91b8 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:54:33 +0000 Subject: [PATCH 06/11] fix(runtime): validate native package launch and Claude bundle contract --- .github/workflows/native-daemon.yml | 7 ++ CONTRIBUTING.md | 11 +- apps/docs/content/docs/self-hosted-native.mdx | 6 ++ apps/docs/content/guide-sources.json | 4 +- .../claudesdk/preparation_fixture_test.go | 2 +- .../internal/agent/claudesdk/readiness.go | 2 +- .../claudesdk/workspace_structured_test.go | 13 ++- .../internal/cli/runtime_mcp_windows.go | 8 +- .../internal/cli/runtime_mcp_windows_test.go | 102 ++++++++++++++++++ .../localworkspace/package_command.go | 45 ++++++++ .../package_command_windows_test.go | 81 ++++++++++++++ .../runtime_initialization_process.go | 14 +-- .../runtime_initialization_test.go | 12 +++ docs/self-hosted-native.md | 6 ++ .../claude-sdk-adapter/src/runtime_check.ts | 2 +- scripts/check-claude-sdk-runtime.mjs | 2 +- scripts/native-harness-smoke.mjs | 2 +- 17 files changed, 295 insertions(+), 24 deletions(-) create mode 100644 apps/parsar-daemon/internal/cli/runtime_mcp_windows_test.go create mode 100644 apps/parsar-daemon/internal/localworkspace/package_command.go create mode 100644 apps/parsar-daemon/internal/localworkspace/package_command_windows_test.go diff --git a/.github/workflows/native-daemon.yml b/.github/workflows/native-daemon.yml index d06ac2592..f947efae4 100644 --- a/.github/workflows/native-daemon.yml +++ b/.github/workflows/native-daemon.yml @@ -49,6 +49,13 @@ jobs: - uses: actions/setup-node@v6 with: node-version: '22' + - name: Windows npm and npx stdio launchers without network + if: runner.os == 'Windows' + run: >- + go test -race -count=1 -timeout=2m + ./apps/parsar-daemon/internal/localworkspace + ./apps/parsar-daemon/internal/cli + -run 'TestWindowsPackageManager|TestWindowsRuntimeMCP' - name: Install pinned native Harness dependencies run: | npm install --global pnpm@10.30.3 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ef30cdb14..8c273a40f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -284,8 +284,7 @@ Run `make check` before completion. The standalone gate includes all daemon/shar Go tests, Core contract/client/service tests, Core Web and TypeScript client checks (including fixture-only Playwright acceptance), a real dedicated PostgreSQL test database, byte-for-byte sqlc regeneration checks, standalone API builds, Claude SDK -tests and packaging, MiniMax companion checks, and Rust filesystem-helper -tests/format/Clippy. It intentionally has no product Web/server/installer gates. The full gate fails when the database variable is missing. The test database role +tests and packaging, MiniMax companion checks, and native daemon filesystem tests. It intentionally has no product Web/server/installer gates. The full gate fails when the database variable is missing. The test database role needs CREATE DATABASE permission: managed-provider tests create and drop isolated `oac_*_tests` databases because provider identity is deployment-wide. Set `OAC_TEST_DATABASE_URL` to that dedicated database and `OAC_TEST_OFFICIAL_SDK_PYTHON` @@ -293,8 +292,7 @@ to the pinned SDK interpreter. `PARSAR_AGENTS_API_TEST_DATABASE_URL` is retired; `make check-database` reports its replacement when only the old name is set. Tests must not bypass the production provider-switch guard. -Use Go from `go.mod`, Node 22, pnpm 10.30.3, Python 3.9+, Rust 1.95.0 with rustfmt -and Clippy, and Linux OpenSSL development libraries. `make sqlc-generate` owns only +Use Go from `go.mod`, Node 22, pnpm 10.30.3 and Python 3.9+. `make sqlc-generate` owns only `services/agents-api/internal/db/sqlc` (sqlc v1.29.0). Do not rewrite landed migrations. The public protocol schema is `contracts/agents-api/openapi.yaml`; there is no product swaggo contract in this repository. Preserve its pinned types, @@ -3381,7 +3379,10 @@ scratch remain under `OAC_RUNTIME_HOME/runtime/claude-sdk` for session ownership without restricting tools. Authentication remains under `OAC_RUNTIME_HOME/daemon`. The daemon requires neither nested sandbox privileges nor host security changes. -The adapter advertises `local_runtime_v1` after checking the installed bridge and +The `local_runtime_v2` capability identifies the current workspace and direct MCP +launcher contract. Reject earlier workspace bundles; matching SDK versions alone +do not establish adapter compatibility. +The adapter advertises `local_runtime_v2` after checking the installed bridge and native binary. Windows additionally needs Git Bash for its Bash tool. Registration combines that check with the local binding; Core consumes the same readiness contract on every platform. The profile supports Bash/Read/Edit, preparation, diff --git a/apps/docs/content/docs/self-hosted-native.mdx b/apps/docs/content/docs/self-hosted-native.mdx index b0e1c908f..78f323661 100644 --- a/apps/docs/content/docs/self-hosted-native.mdx +++ b/apps/docs/content/docs/self-hosted-native.mdx @@ -156,4 +156,10 @@ an empty or null value; it is never ignored. A missing preinstalled dependency fails the operation that requires it. npm/Python package installation and setup retain their supported initialization flow using the launching user's existing permissions. + +On Windows, stdio MCP commands named `npm` or `npx` (including explicit +`.cmd` paths) use the selected installation's JavaScript entrypoint with Node. +Ordinary executables run directly. Other batch wrappers must configure +`cmd.exe` explicitly, with the wrapper arguments required by that command. + [Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/self-hosted-native.md) diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index 8d65bb82b..43a448ca4 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -14,7 +14,7 @@ "contracts/agents-api/environments.md": "bd6cdd76f33d6e2cf6cf1654713be9fb10d0273dc2a9adf0fc88d951ab7aa291", "docs/getting-started/nodes.md": "c1bc854c4798b9757bbb8cea4474a03442ba6f4c10444d5505733e8b94046038", "docs/getting-started/self-hosted.md": "a2eb73cd5d3930cd2851d52e9a60a45f5bd2b0f2f60bfb1da9997ef52dd9a5b5", - "docs/self-hosted-native.md": "f14c3eabf3f1b07284560ccc1e9c7fde90122df43f71fbbd0f42405a0b2b8c03", + "docs/self-hosted-native.md": "36831e323bd198771bec853f832d2bc08530f194165a11c824b4a5ca905ebc27", "apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", "docs/web/README.md": "9fce295f6f6ff04acefe338679c1e54723e123dd0027a778202adfa9ab95b6c4", "docs/api/web-management.md": "dc79df33dade6139749752c9ce53d4e126354cfabccada3e7826de4dd20fae62", @@ -49,7 +49,7 @@ "content/docs/hosted-providers.zh.mdx": "d7f6115f052a09dc943f0ed516ff30a27da5847ed24d387d9d1c7e7faa006ab0", "content/docs/self-hosted-execution.mdx": "cad6d197ad1c49d06174f304a067f3e5e57b5b23c739cab6b5c6fa5e6dadd5fa", "content/docs/self-hosted-execution.zh.mdx": "48627ebf94a278b37fb814ec8cf0186447268bb36da9982f354f88e1d6d47500", - "content/docs/self-hosted-native.mdx": "e90ab57097bfa5d654c5216139446ec19381b5325867204c3ae2d70d261d20f3", + "content/docs/self-hosted-native.mdx": "a00486af6b408fad6fbea03f7a74a675665647febbfffb50de51712757848f00", "content/docs/self-hosted-native.zh.mdx": "686eb7c22c0bbe59c753d5e8271309d60fad43131129ba5df4fcde44610f2d78", "public/images/source/apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", "content/docs/console.mdx": "95d3e6f3795f07809e59c88b395a0fa1c5a746e3b878b3fae84a7222f187f4f5", diff --git a/apps/parsar-daemon/internal/agent/claudesdk/preparation_fixture_test.go b/apps/parsar-daemon/internal/agent/claudesdk/preparation_fixture_test.go index a9e8e0af6..556c00786 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/preparation_fixture_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/preparation_fixture_test.go @@ -45,7 +45,7 @@ func runPreparationHelper() { features = []string{"workspace_tools", "workspace_prepare"} } if strings.HasPrefix(mode, "structured-") { - features = append(features, "local_runtime_v1", "structured_output") + features = append(features, "local_runtime_v2", "structured_output") if mode == "structured-ready" { features = append(features, "workspace_structured_output") } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/readiness.go b/apps/parsar-daemon/internal/agent/claudesdk/readiness.go index ac9a91908..a04e2c0dc 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/readiness.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/readiness.go @@ -74,7 +74,7 @@ func (info RuntimeInfo) supportsWorkspaceCommands() bool { } func (info RuntimeInfo) SupportsLocalRuntime() bool { - return info.supportsWorkspaceCommands() && slices.Contains(info.Features, "local_runtime_v1") + return info.supportsWorkspaceCommands() && slices.Contains(info.Features, "local_runtime_v2") } func (info RuntimeInfo) SupportsWorkspaceFunctions() bool { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_structured_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_structured_test.go index b8ad20f00..f555fd75b 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_structured_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_structured_test.go @@ -60,7 +60,7 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T } func TestWorkspaceStructuredReadinessRequiresCompleteLocalContract(t *testing.T) { - features := []string{"workspace_tools", "workspace_prepare", "workspace_command_observations", "local_runtime_v1", "structured_output", "workspace_structured_output"} + features := []string{"workspace_tools", "workspace_prepare", "workspace_command_observations", "local_runtime_v2", "structured_output", "workspace_structured_output"} if !(RuntimeInfo{Features: features}).SupportsWorkspaceStructuredOutput() { t.Fatal("qualified Runtime unavailable") } @@ -73,3 +73,14 @@ func TestWorkspaceStructuredReadinessRequiresCompleteLocalContract(t *testing.T) }) } } + +func TestLocalRuntimeRejectsObsoleteWorkspaceBundle(t *testing.T) { + info := RuntimeInfo{Features: []string{"workspace_tools", "workspace_prepare", "workspace_command_observations", "local_runtime_v1", "workspace_functions", "structured_output", "workspace_structured_output"}} + if info.SupportsLocalRuntime() || info.SupportsWorkspaceFunctions() || info.SupportsWorkspaceStructuredOutput() { + t.Fatal("obsolete workspace and MCP launcher contract was accepted") + } + info.Features[3] = "local_runtime_v2" + if !info.SupportsLocalRuntime() || !info.SupportsWorkspaceFunctions() || !info.SupportsWorkspaceStructuredOutput() { + t.Fatal("current workspace contract was rejected") + } +} diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_windows.go b/apps/parsar-daemon/internal/cli/runtime_mcp_windows.go index a1f3f9c49..4095efd4d 100644 --- a/apps/parsar-daemon/internal/cli/runtime_mcp_windows.go +++ b/apps/parsar-daemon/internal/cli/runtime_mcp_windows.go @@ -5,10 +5,16 @@ package cli import ( "os" "os/exec" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" ) func execRuntimeMCP(invocation mcpInvocation) error { - command := exec.Command(invocation.command, invocation.args[1:]...) + binary, args, err := localworkspace.ResolvePackageManagerCommand(invocation.command, invocation.args[1:]) + if err != nil { + return errRuntimeMCP + } + command := exec.Command(binary, args...) command.Dir, command.Env = invocation.cwd, invocation.env command.Stdin, command.Stdout, command.Stderr = os.Stdin, os.Stdout, os.Stderr if err := command.Run(); err != nil { diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_windows_test.go b/apps/parsar-daemon/internal/cli/runtime_mcp_windows_test.go new file mode 100644 index 000000000..c42e2c9bd --- /dev/null +++ b/apps/parsar-daemon/internal/cli/runtime_mcp_windows_test.go @@ -0,0 +1,102 @@ +//go:build windows + +package cli + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "slices" + "testing" +) + +func TestWindowsRuntimeMCPPackageManagers(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Fatal("native test requires installed Node.js", err) + } + workspace := filepath.Join(t.TempDir(), "local stdio project") + bin := filepath.Join(workspace, "node_modules", ".bin") + pkg := filepath.Join(workspace, "node_modules", "oac-stdio-fixture") + if err = os.MkdirAll(bin, 0700); err != nil { + t.Fatal(err) + } + if err = os.MkdirAll(pkg, 0700); err != nil { + t.Fatal(err) + } + fixture := filepath.Join(pkg, "fixture.cjs") + files := map[string]string{ + filepath.Join(workspace, "package.json"): `{"name":"offline-test","version":"1.0.0","private":true}`, + filepath.Join(pkg, "package.json"): `{"name":"oac-stdio-fixture","version":"1.0.0","bin":{"oac-stdio-fixture":"fixture.cjs"}}`, + fixture: `let input="";process.stdin.setEncoding("utf8");process.stdin.on("data",v=>input+=v);process.stdin.on("end",()=>process.stdout.write(JSON.stringify({input,cwd:process.cwd(),value:process.env.OAC_MCP_FIXTURE_VALUE,args:process.argv.slice(2)})))`, + filepath.Join(bin, "oac-stdio-fixture.cmd"): "@\"" + node + "\" \"" + fixture + "\" %*\r\n", + } + for path, body := range files { + if err = os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatal(err) + } + } + // Each invocation uses only this installed local bin, with an empty offline cache. + environment := append(os.Environ(), "npm_config_offline=true", "npm_config_yes=false", "npm_config_cache="+t.TempDir(), "OAC_MCP_FIXTURE_VALUE=selected") + for _, name := range []string{"npm", "npx"} { + absolute, err := exec.LookPath(name + ".cmd") + if err != nil { + t.Fatal("native test requires installed npm/npx", err) + } + for _, command := range []string{name, name + ".cmd", absolute} { + t.Run(command, func(t *testing.T) { + args := []string{command} + if name == "npm" { + args = append(args, "exec") + } + args = append(args, "--offline", "--yes=false", "--", "oac-stdio-fixture", "two words") + checkWindowsMCPStdio(t, mcpInvocation{command: command, args: args, cwd: workspace, env: environment}, []string{"two words"}) + }) + } + } + checkWindowsMCPStdio(t, mcpInvocation{command: node, args: []string{node, fixture, "ordinary exe", "literal & %PATH%"}, cwd: workspace, env: environment}, []string{"ordinary exe", "literal & %PATH%"}) +} + +func checkWindowsMCPStdio(t *testing.T, invocation mcpInvocation, wantArgs []string) { + t.Helper() + input, err := os.CreateTemp(t.TempDir(), "input") + if err != nil { + t.Fatal(err) + } + defer input.Close() + output, err := os.CreateTemp(t.TempDir(), "output") + if err != nil { + t.Fatal(err) + } + defer output.Close() + stderr, err := os.CreateTemp(t.TempDir(), "stderr") + if err != nil { + t.Fatal(err) + } + defer stderr.Close() + if _, err = input.WriteString("local stdio payload\n"); err != nil { + t.Fatal(err) + } + if _, err = input.Seek(0, 0); err != nil { + t.Fatal(err) + } + stdin, stdout, oldErr := os.Stdin, os.Stdout, os.Stderr + os.Stdin, os.Stdout, os.Stderr = input, output, stderr + defer func() { os.Stdin, os.Stdout, os.Stderr = stdin, stdout, oldErr }() + if err = execRuntimeMCP(invocation); err != nil { + detail, _ := os.ReadFile(stderr.Name()) + t.Fatalf("native local MCP failed: %v: %s", err, detail) + } + raw, err := os.ReadFile(output.Name()) + if err != nil { + t.Fatal(err) + } + var got struct { + Input, CWD, Value string + Args []string + } + if json.Unmarshal(raw, &got) != nil || got.Input != "local stdio payload\n" || filepath.Clean(got.CWD) != filepath.Clean(invocation.cwd) || got.Value != "selected" || !slices.Equal(got.Args, wantArgs) { + t.Fatalf("stdio invocation changed: %s", raw) + } +} diff --git a/apps/parsar-daemon/internal/localworkspace/package_command.go b/apps/parsar-daemon/internal/localworkspace/package_command.go new file mode 100644 index 000000000..9ae5f053c --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/package_command.go @@ -0,0 +1,45 @@ +package localworkspace + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// ResolvePackageManagerCommand runs Windows npm/npx shims through their installed +// JavaScript entrypoints. It does not pass arguments through an extra shell. +func ResolvePackageManagerCommand(command string, args []string) (string, []string, error) { + if runtime.GOOS != "windows" { + return command, args, nil + } + name := strings.ToLower(filepath.Base(command)) + switch name { + case "npm", "npm.cmd", "npx", "npx.cmd": + default: + return command, args, nil + } + shim, err := exec.LookPath(command) + if err != nil { + return "", nil, errors.New("npm or npx launcher unavailable") + } + if !strings.EqualFold(filepath.Ext(shim), ".cmd") { + return shim, args, nil + } + name = strings.TrimSuffix(name, ".cmd") + directory := filepath.Dir(shim) + cli := filepath.Join(directory, "node_modules", "npm", "bin", name+"-cli.js") + if info, err := os.Stat(cli); err != nil || !info.Mode().IsRegular() { + return "", nil, errors.New("npm or npx CLI unavailable") + } + node := filepath.Join(directory, "node.exe") + if info, err := os.Stat(node); err != nil || !info.Mode().IsRegular() { + node, err = exec.LookPath("node") + if err != nil { + return "", nil, errors.New("Node.js unavailable") + } + } + return node, append([]string{cli}, args...), nil +} diff --git a/apps/parsar-daemon/internal/localworkspace/package_command_windows_test.go b/apps/parsar-daemon/internal/localworkspace/package_command_windows_test.go new file mode 100644 index 000000000..442398204 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/package_command_windows_test.go @@ -0,0 +1,81 @@ +//go:build windows + +package localworkspace + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "slices" + "testing" +) + +func TestWindowsPackageManagerResolution(t *testing.T) { + installation := filepath.Join(t.TempDir(), "selected node") + if err := os.MkdirAll(filepath.Join(installation, "node_modules", "npm", "bin"), 0700); err != nil { + t.Fatal(err) + } + for _, name := range []string{"node.exe", "npm.cmd", "npx.cmd", "node_modules/npm/bin/npm-cli.js", "node_modules/npm/bin/npx-cli.js"} { + if err := os.WriteFile(filepath.Join(installation, filepath.FromSlash(name)), nil, 0600); err != nil { + t.Fatal(err) + } + } + args := []string{"two words", "literal & | < > ^ %PATH%", "quote\"value"} + for _, name := range []string{"npm", "npx"} { + command := filepath.Join(installation, name+".cmd") + binary, got, err := ResolvePackageManagerCommand(command, args) + want := append([]string{filepath.Join(installation, "node_modules", "npm", "bin", name+"-cli.js")}, args...) + if err != nil || binary != filepath.Join(installation, "node.exe") || !slices.Equal(got, want) { + t.Fatal(binary, got, err) + } + } + for _, name := range []string{"server.exe", "npm.exe", "other.cmd"} { + binary, got, err := ResolvePackageManagerCommand(name, args) + if err != nil || binary != name || !slices.Equal(got, args) { + t.Fatal("ordinary command changed", binary, got, err) + } + } + if err := os.Remove(filepath.Join(installation, "node_modules", "npm", "bin", "npx-cli.js")); err != nil { + t.Fatal(err) + } + if _, _, err := ResolvePackageManagerCommand(filepath.Join(installation, "npx.cmd"), args); err == nil { + t.Fatal("missing selected CLI accepted") + } +} + +func TestWindowsPackageManagerArguments(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Fatal("native test requires installed Node.js", err) + } + installation := filepath.Join(t.TempDir(), "npm installation") + directory := filepath.Join(installation, "node_modules", "npm", "bin") + if err = os.MkdirAll(directory, 0700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", installation+string(os.PathListSeparator)+os.Getenv("PATH")) + args := []string{"two words", "literal & | < > ^ %PATH%", "quote\"value"} + for _, name := range []string{"npm", "npx"} { + if err = os.WriteFile(filepath.Join(installation, name+".cmd"), []byte("@exit /b 99\r\n"), 0600); err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(directory, name+"-cli.js"), []byte("process.stdout.write(JSON.stringify(process.argv.slice(2)))"), 0600); err != nil { + t.Fatal(err) + } + for _, command := range []string{name, name + ".cmd", filepath.Join(installation, name+".cmd")} { + binary, argv, err := ResolvePackageManagerCommand(command, args) + if err != nil || filepath.Clean(binary) != filepath.Clean(node) { + t.Fatal("PATH Node fallback failed", err) + } + out, err := exec.CommandContext(t.Context(), binary, argv...).Output() + if err != nil { + t.Fatal(err) + } + var got []string + if json.Unmarshal(out, &got) != nil || !slices.Equal(got, args) { + t.Fatal("arguments were interpreted", string(out)) + } + } + } +} diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_process.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_process.go index 73ad59d35..b054648e2 100644 --- a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_process.go +++ b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_process.go @@ -52,24 +52,18 @@ func initializationPython() (string, error) { } func initializationNPM() (string, []string, error) { - node, err := exec.LookPath("node") - if err != nil { + if _, err := exec.LookPath("node"); err != nil { return "", nil, initializationDependency("Node.js") } npm, err := exec.LookPath("npm") if err != nil { return "", nil, initializationDependency("npm") } - if runtime.GOOS != "windows" { - return npm, nil, nil - } - // npm's Windows launcher is a .cmd file. Use the installed CLI with Node rather - // than reinterpret package arguments through cmd.exe. - cli := filepath.Join(filepath.Dir(npm), "node_modules", "npm", "bin", "npm-cli.js") - if info, err := os.Stat(cli); err != nil || !info.Mode().IsRegular() { + binary, args, err := ResolvePackageManagerCommand(npm, nil) + if err != nil { return "", nil, initializationDependency("npm CLI") } - return node, []string{cli}, nil + return binary, args, nil } func initializationEnvironment(configured map[string]string) []string { diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_test.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_test.go index 762ec6854..ac8c6246d 100644 --- a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_test.go +++ b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_test.go @@ -48,6 +48,12 @@ func TestRuntimeInitializationPackageTargets(t *testing.T) { suffix = ".exe" } for _, name := range []string{"node", "npm", "python3"} { + if name == "npm" && runtime.GOOS == "windows" { + if err = os.WriteFile(filepath.Join(bin, "npm.cmd"), []byte("@exit /b 99\r\n"), 0600); err != nil { + t.Fatal(err) + } + continue + } if err = os.WriteFile(filepath.Join(bin, name+suffix), source, 0700); err != nil { t.Fatal(err) } @@ -79,6 +85,12 @@ func TestRuntimeInitializationPackageTargets(t *testing.T) { if json.Unmarshal(raw, &args) != nil { t.Fatal("invalid fixture receipt") } + if action == "npm" && runtime.GOOS == "windows" { + cli := filepath.Join(bin, "node_modules", "npm", "bin", "npm-cli.js") + if len(args) == 0 || args[0] != cli { + t.Fatal("initializer bypassed the shared npm shim resolver", args) + } + } flag, target := "--prefix", filepath.Join(packages, "npm") if action == "python" { flag, target = "--target", filepath.Join(packages, "python") diff --git a/docs/self-hosted-native.md b/docs/self-hosted-native.md index e21570f2c..19a05c3dc 100644 --- a/docs/self-hosted-native.md +++ b/docs/self-hosted-native.md @@ -152,3 +152,9 @@ Runtime does not run apt, request sudo or elevate the daemon's privileges. an empty or null value; it is never ignored. A missing preinstalled dependency fails the operation that requires it. npm/Python package installation and setup retain their supported initialization flow using the launching user's existing permissions. + + +On Windows, stdio MCP commands named `npm` or `npx` (including explicit +`.cmd` paths) use the selected installation's JavaScript entrypoint with Node. +Ordinary executables run directly. Other batch wrappers must configure +`cmd.exe` explicitly, with the wrapper arguments required by that command. diff --git a/packages/claude-sdk-adapter/src/runtime_check.ts b/packages/claude-sdk-adapter/src/runtime_check.ts index 7e3ec1560..9a2ad1d94 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_v1", "workspace_functions", "workspace_structured_output"] : []), "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"] : []), "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"); } catch { // Native diagnostics can include operator environment; never forward them. process.stdout.write(JSON.stringify({ type: "runtime_unavailable" }) + "\n"); diff --git a/scripts/check-claude-sdk-runtime.mjs b/scripts/check-claude-sdk-runtime.mjs index 960bb36de..8452b0f60 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, 3); -assert.deepEqual(report.features, ["executor_reuse", ...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions", "workspace_structured_output"] : []), "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"]); +assert.deepEqual(report.features, ["executor_reuse", ...(["linux", "darwin", "win32"].includes(process.platform) ? ["workspace_directory", "local_runtime_v2", "workspace_functions", "workspace_structured_output"] : []), "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"]); 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/scripts/native-harness-smoke.mjs b/scripts/native-harness-smoke.mjs index 26227b29a..f86249b7b 100644 --- a/scripts/native-harness-smoke.mjs +++ b/scripts/native-harness-smoke.mjs @@ -97,7 +97,7 @@ async function claudeSmoke() { if (result.error || result.status !== 0) throw failure('claude_runtime_unavailable'); let info; try { info = JSON.parse(result.stdout); } catch { throw failure('claude_invalid_receipt'); } - if (info.type !== 'runtime_ready' || info.sdk !== '0.3.269' || info.native !== '2.1.269 (Claude Code)' || !info.features?.includes('local_runtime_v1')) throw failure('claude_runtime_identity'); + if (info.type !== 'runtime_ready' || info.sdk !== '0.3.269' || info.native !== '2.1.269 (Claude Code)' || !info.features?.includes('local_runtime_v2')) throw failure('claude_runtime_identity'); return { status: 'passed', sdk: info.sdk, native: info.native }; } From 430740fd6278669938bd4d821ae250dc7ebdf97f Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 19:00:08 +0000 Subject: [PATCH 07/11] fix(runtime): preserve completion clocks and official package responses --- CONTRIBUTING.md | 7 ++- .../content/docs/environments-and-files.mdx | 4 +- apps/docs/content/guide-sources.json | 4 +- apps/docs/openapi/core-api.yaml | 11 +++- apps/docs/openapi/public-api.yaml | 23 +++++++- apps/docs/openapi/sources.json | 8 +-- contracts/agents-api/core.openapi.yaml | 11 +++- contracts/agents-api/environments.md | 4 +- contracts/agents-api/openapi.yaml | 23 +++++++- .../agents-api/v1/environment_templates.go | 22 ++++---- contracts/agents-api/v1/environments.go | 8 +++ .../agents-api/v1/session_environment.go | 20 +++---- .../internal/api/environment_setup.go | 5 +- .../internal/api/hosted_environment.go | 2 +- .../internal/api/hosted_environment_test.go | 2 +- .../internal/store/runtime_idle_clock_test.go | 56 ++++++++++++++++--- .../store/template_composition_public_test.go | 2 +- .../internal/store/turn_completion.go | 11 +--- .../tests/official_template_composition.py | 20 +++++-- 19 files changed, 174 insertions(+), 69 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c273a40f..6d0e70609 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -670,6 +670,8 @@ or any other automatic system-package operation. No daemon privilege increase or managed-only exception is permitted. Missing dependencies fail the consuming operation. npm and Python use local prefix/target directories; setup requires Bash and Windows requires Git Bash, without a substitute shell. +Package responses retain the official required `system: []` field as empty +response metadata; it is not stored as an initialization option. Initialization and package directories default under `OAC_RUNTIME_HOME` and may be selected with `OAC_RUNTIME_INITIALIZATION_DIRECTORY` and @@ -1175,8 +1177,9 @@ root or subagent Turn, pending input/file operation or initialization remains, and real activity has been idle for the configured interval. For node-managed allocations, record the first root or child terminal transition in the same transaction using Core's database clock and the existing compute activity field. -Native completion timestamps remain unchanged in public history but cannot drive -idle admission across hosts; repeated terminal projections never reset that timer. +For every Environment source, positive native completion timestamps remain +unchanged in public history, even when host clock skew places them before Core's +Turn creation time. They cannot drive idle admission across hosts; repeated terminal projections never reset that timer. Read activity together with the database observation time. Candidate filtering and the Session-locked phase recheck compare elapsed database time with the configured idle duration; callers must not supply a Core-wall-clock cutoff. Anchor the initial diff --git a/apps/docs/content/docs/environments-and-files.mdx b/apps/docs/content/docs/environments-and-files.mdx index 7322707ca..ef5251c55 100644 --- a/apps/docs/content/docs/environments-and-files.mdx +++ b/apps/docs/content/docs/environments-and-files.mdx @@ -59,7 +59,9 @@ setup, Skill/Plugin import and directory snapshot finalization through `runtime_prepare`. Providers place, create, bootstrap, inspect, renew and reclaim compute. npm/Python installation and setup use the host's existing network and starting account. System dependencies must be preinstalled; `packages.system` -rejects explicitly and never invokes apt, sudo or elevated execution. +rejects explicitly, including null and empty lists, and never invokes apt, sudo +or elevated execution. Package responses include the official required +`system: []` field as empty metadata; it does not enable installation. Unsupported hostname forms and installation combinations reject explicitly; see the [Template coverage and limits](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-templates.md). Empty/null installation defaults produce safe empty metadata, not a live workspace inventory. Service-origin diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index 43a448ca4..86b98d32b 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -11,7 +11,7 @@ "docs/api/README.md": "592fc2aef474e36eb760be0dd1b2269b6766c0d984f826eaf6f47711ea139387", "contracts/agents-api/execution-tools.md": "e0b61f6c0c236c362186c5f6d0ad69a16dd8a8d9afabe1329a1fe22e5f4a71e9", "docs/api/public-agent-api.md": "5b04e5ec9311df851376f66fc1584dd48ca123cd8c26ca63277c10b9172e9955", - "contracts/agents-api/environments.md": "bd6cdd76f33d6e2cf6cf1654713be9fb10d0273dc2a9adf0fc88d951ab7aa291", + "contracts/agents-api/environments.md": "4c4b9423b22919d6aa6bd98d96422e1a8d610d3442d4cf7731a575bd31fd43f0", "docs/getting-started/nodes.md": "c1bc854c4798b9757bbb8cea4474a03442ba6f4c10444d5505733e8b94046038", "docs/getting-started/self-hosted.md": "a2eb73cd5d3930cd2851d52e9a60a45f5bd2b0f2f60bfb1da9997ef52dd9a5b5", "docs/self-hosted-native.md": "36831e323bd198771bec853f832d2bc08530f194165a11c824b4a5ca905ebc27", @@ -43,7 +43,7 @@ "content/docs/agents-and-tools.zh.mdx": "cc8d4e0ef0ea894f068f3bbd143d5ee70c6346bf98f425b1d5fe07ec3931f128", "content/docs/sessions.mdx": "994fa339b30c625ccb9692a226fc5e365b1e430c575ad63c7a08e65b48c39393", "content/docs/sessions.zh.mdx": "b1b1a7f8aacd9a902320f9692173ede24036124f85761cf02f23ef3ef2bcc4c1", - "content/docs/environments-and-files.mdx": "be8395bf8568ab19a60debf014c75809d68ebc7d27991d3db0e50937b71a1fa9", + "content/docs/environments-and-files.mdx": "6c1ee07b0c55127cc014d6180492dd2848be390897686c9c011618ca3476c3f7", "content/docs/environments-and-files.zh.mdx": "ee64577220a798f023b330c65302c14438f25a34a78c4e36144bfdb4304a3ae5", "content/docs/hosted-providers.mdx": "a0ccf06394e28a79c8e98503852a8f6a7558b22928ecdaa1804b04b16ecf92b4", "content/docs/hosted-providers.zh.mdx": "d7f6115f052a09dc943f0ed516ff30a27da5847ed24d387d9d1c7e7faa006ab0", diff --git a/apps/docs/openapi/core-api.yaml b/apps/docs/openapi/core-api.yaml index bf40279d6..8e3300354 100644 --- a/apps/docs/openapi/core-api.yaml +++ b/apps/docs/openapi/core-api.yaml @@ -5879,7 +5879,7 @@ components: - access - allowed_domains type: object - v1.EnvironmentPackages: + v1.EnvironmentPackagesResponse: properties: npm: items: @@ -5889,9 +5889,14 @@ components: items: type: string type: array + system: + items: + type: string + type: array required: - npm - python + - system type: object v1.EnvironmentTemplate: properties: @@ -5918,7 +5923,7 @@ components: - agent.environment.template type: string packages: - $ref: '#/components/schemas/v1.EnvironmentPackages' + $ref: '#/components/schemas/v1.EnvironmentPackagesResponse' plugins: items: type: object @@ -7045,7 +7050,7 @@ components: network: $ref: '#/components/schemas/v1.EnvironmentNetwork' packages: - $ref: '#/components/schemas/v1.EnvironmentPackages' + $ref: '#/components/schemas/v1.EnvironmentPackagesResponse' plugins: items: type: object diff --git a/apps/docs/openapi/public-api.yaml b/apps/docs/openapi/public-api.yaml index 8cd200c67..2df6a5418 100644 --- a/apps/docs/openapi/public-api.yaml +++ b/apps/docs/openapi/public-api.yaml @@ -4010,6 +4010,25 @@ components: type: array - type: 'null' type: object + v1.EnvironmentPackagesResponse: + properties: + npm: + items: + type: string + type: array + python: + items: + type: string + type: array + system: + items: + type: string + type: array + required: + - npm + - python + - system + type: object v1.EnvironmentTemplate: properties: capability_directories: @@ -4035,7 +4054,7 @@ components: - agent.environment.template type: string packages: - $ref: '#/components/schemas/v1.EnvironmentPackages' + $ref: '#/components/schemas/v1.EnvironmentPackagesResponse' plugins: items: type: object @@ -4868,7 +4887,7 @@ components: network: $ref: '#/components/schemas/v1.EnvironmentNetwork' packages: - $ref: '#/components/schemas/v1.EnvironmentPackages' + $ref: '#/components/schemas/v1.EnvironmentPackagesResponse' plugins: items: type: object diff --git a/apps/docs/openapi/sources.json b/apps/docs/openapi/sources.json index cfb8468c3..f3c75b98f 100644 --- a/apps/docs/openapi/sources.json +++ b/apps/docs/openapi/sources.json @@ -1,7 +1,7 @@ { "sources": { - "contracts/agents-api/openapi.yaml": "1433d6ef0d5c4b3598ae2c906faacce4eeefea37bcc125243ab25d04f61e620b", - "contracts/agents-api/core.openapi.yaml": "3e02dacf7047e1179d334cc6fcb5110e9968410571c8d1732bc9cd0681a68939", + "contracts/agents-api/openapi.yaml": "03eecf82e377cba718898e1fd02bd9ae3564dbf7ee9598242a23b26a806077f9", + "contracts/agents-api/core.openapi.yaml": "8fe4f7adff61005cd17bb693a4ccc11744ca40ab87f60bb4dd886f093ac35ca7", "contracts/agents-api/runtime.openapi.yaml": "23852205d3b45892f2de9ecf3d52518c0dd0808962c0c512f219b565aa7ccef2" }, "outputs": { @@ -18,7 +18,7 @@ "content/docs/api-reference/skills.mdx": "774ea05a7a8250fe5af5164388b98dafca99d56dc2eae686e8f628acb4e7c10b", "content/docs/api-reference/vaults.mdx": "d80eade1c2f8100030866abbb0fb6b7a09c94250a859c97cdf1abce32fdfdf1e", "content/docs/api-reference/credentials.mdx": "f129a51f74a3c7021c016600518e2350023f8d29571666ac5d64b7584cd7c7d0", - "openapi/public-api.yaml": "7228ef80c91b6b736dbfbf80d078ab3c56538e7ba91640a4dfa3c30fd9127aa3", + "openapi/public-api.yaml": "9350970fdd9e97fb3d0908750dbb190beaf40ebc9d4c40b104fec3ff376d9c59", "content/docs/api-reference/meta.json": "56bf1dc145ccc8adde38c5f956f1a06f18ef18b646122250d01a294c516f8f83", "content/docs/api-reference/index.mdx": "9fd6b1c54149874d2999235067c101326f9e8a2e707a70b6672c81eb3ebfe63f", "content/docs/api-reference/core/core-administration.mdx": "5fc3ada51d2190a141f13738151e4d513390cc3bd66ce6f5013994bda68569b0", @@ -40,7 +40,7 @@ "content/docs/api-reference/core/vaults.mdx": "12d0aac96cd4a3472ef4eaa287f7945f78862017956e79bfc3ed0ef8e772e78a", "content/docs/api-reference/core/credentials.mdx": "27d586b9a8fd738b9947802f32c80458195370252886bc26abedccf2125ee981", "content/docs/api-reference/core/sandbox-manager.mdx": "6be7c52e057365949f0247c18c710561d680847a955f78f1bbe57825d4d5f549", - "openapi/core-api.yaml": "2ae4043550e953945fc184d2f82149ea9fe4d5f693f5c8bd31d7edb87e8a0c4c", + "openapi/core-api.yaml": "bbba80991919dafd55411aa7825f004cc1fa0ffaa42c4b7f9475e744dc9e78eb", "content/docs/api-reference/core/meta.json": "588b163ed2de2100b0f8376688274f80698a6bf6ac037eef56d17c93fa8a5062", "content/docs/api-reference/core/index.mdx": "ddf0a8d87a6c21761beac703a32f2a40486a1c74abb341bd726f40c44a866405", "content/docs/api-reference/machine/sandbox-node.mdx": "5b367a08d43cea82686345d2ba1c75cc90627a7b2970fe42a0df9d77fb3e1e80", diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index 57bf5cdda..f9a6a97e5 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -1687,7 +1687,7 @@ definitions: - access - allowed_domains type: object - v1.EnvironmentPackages: + v1.EnvironmentPackagesResponse: properties: npm: items: @@ -1697,9 +1697,14 @@ definitions: items: type: string type: array + system: + items: + type: string + type: array required: - npm - python + - system type: object v1.EnvironmentTemplate: properties: @@ -1725,7 +1730,7 @@ definitions: - agent.environment.template type: string packages: - $ref: '#/definitions/v1.EnvironmentPackages' + $ref: '#/definitions/v1.EnvironmentPackagesResponse' plugins: items: type: object @@ -2801,7 +2806,7 @@ definitions: network: $ref: '#/definitions/v1.EnvironmentNetwork' packages: - $ref: '#/definitions/v1.EnvironmentPackages' + $ref: '#/definitions/v1.EnvironmentPackagesResponse' plugins: items: type: object diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index 0143f158a..a20123c77 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -56,7 +56,9 @@ setup, Skill/Plugin import and directory snapshot finalization through `runtime_prepare`. Providers place, create, bootstrap, inspect, renew and reclaim compute. npm/Python installation and setup use the host's existing network and starting account. System dependencies must be preinstalled; `packages.system` -rejects explicitly and never invokes apt, sudo or elevated execution. +rejects explicitly, including null and empty lists, and never invokes apt, sudo +or elevated execution. Package responses include the official required +`system: []` field as empty metadata; it does not enable installation. Unsupported hostname forms and installation combinations reject explicitly; see the [Template coverage and limits](environment-templates.md). Empty/null installation defaults produce safe empty metadata, not a live workspace inventory. Service-origin diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index 4cd47065e..c9cc11c44 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -549,6 +549,25 @@ definitions: type: array x-nullable: true type: object + v1.EnvironmentPackagesResponse: + properties: + npm: + items: + type: string + type: array + python: + items: + type: string + type: array + system: + items: + type: string + type: array + required: + - npm + - python + - system + type: object v1.EnvironmentTemplate: properties: capability_directories: @@ -573,7 +592,7 @@ definitions: - agent.environment.template type: string packages: - $ref: '#/definitions/v1.EnvironmentPackages' + $ref: '#/definitions/v1.EnvironmentPackagesResponse' plugins: items: type: object @@ -1362,7 +1381,7 @@ definitions: network: $ref: '#/definitions/v1.EnvironmentNetwork' packages: - $ref: '#/definitions/v1.EnvironmentPackages' + $ref: '#/definitions/v1.EnvironmentPackagesResponse' plugins: items: type: object diff --git a/contracts/agents-api/v1/environment_templates.go b/contracts/agents-api/v1/environment_templates.go index b2710750e..d325ef476 100644 --- a/contracts/agents-api/v1/environment_templates.go +++ b/contracts/agents-api/v1/environment_templates.go @@ -24,17 +24,17 @@ type EnvironmentPackagesInput struct { // EnvironmentTemplate returns safe configuration metadata only. type EnvironmentTemplate struct { - ID string `json:"id" binding:"required"` - Object string `json:"object" binding:"required" enums:"agent.environment.template"` - Name *string `json:"name" extensions:"x-nullable"` - CreatedAt int64 `json:"created_at" binding:"required"` - UpdatedAt int64 `json:"updated_at" binding:"required"` - CapabilityDirectories []string `json:"capability_directories" binding:"required"` - Network EnvironmentNetwork `json:"network" binding:"required"` - Packages EnvironmentPackages `json:"packages" binding:"required"` - Files []json.RawMessage `json:"files" binding:"required" swaggertype:"array,object"` - Plugins []json.RawMessage `json:"plugins" binding:"required" swaggertype:"array,object"` - Skills []json.RawMessage `json:"skills" binding:"required" swaggertype:"array,object"` + ID string `json:"id" binding:"required"` + Object string `json:"object" binding:"required" enums:"agent.environment.template"` + Name *string `json:"name" extensions:"x-nullable"` + CreatedAt int64 `json:"created_at" binding:"required"` + UpdatedAt int64 `json:"updated_at" binding:"required"` + CapabilityDirectories []string `json:"capability_directories" binding:"required"` + Network EnvironmentNetwork `json:"network" binding:"required"` + Packages EnvironmentPackagesResponse `json:"packages" binding:"required"` + Files []json.RawMessage `json:"files" binding:"required" swaggertype:"array,object"` + Plugins []json.RawMessage `json:"plugins" binding:"required" swaggertype:"array,object"` + Skills []json.RawMessage `json:"skills" binding:"required" swaggertype:"array,object"` } type EnvironmentTemplateList struct { diff --git a/contracts/agents-api/v1/environments.go b/contracts/agents-api/v1/environments.go index 50ccbada8..fc4e4a1de 100644 --- a/contracts/agents-api/v1/environments.go +++ b/contracts/agents-api/v1/environments.go @@ -29,3 +29,11 @@ type EnvironmentPackages struct { NPM []string `json:"npm" binding:"required"` Python []string `json:"python" binding:"required"` } + +// EnvironmentPackagesResponse preserves the official response schema. System is +// always empty: Runtime system-package requests are unsupported. +type EnvironmentPackagesResponse struct { + NPM []string `json:"npm" binding:"required"` + Python []string `json:"python" binding:"required"` + System []string `json:"system" binding:"required"` +} diff --git a/contracts/agents-api/v1/session_environment.go b/contracts/agents-api/v1/session_environment.go index 21be2a704..be8117520 100644 --- a/contracts/agents-api/v1/session_environment.go +++ b/contracts/agents-api/v1/session_environment.go @@ -4,14 +4,14 @@ import "encoding/json" // SessionEnvironment contains supported output variants, independently of request admission. type SessionEnvironment struct { - Type string `json:"type" enums:"none,self_hosted,openai_hosted" binding:"required"` - ID string `json:"id,omitempty"` - CapabilityDirectories *[]string `json:"capability_directories,omitempty"` - RemoteURL string `json:"remote_url,omitempty"` - WorkspaceDirectory string `json:"workspace_directory,omitempty"` - Network *EnvironmentNetwork `json:"network,omitempty"` - Packages *EnvironmentPackages `json:"packages,omitempty"` - Files *[]json.RawMessage `json:"files,omitempty" swaggertype:"array,object"` - Plugins *[]json.RawMessage `json:"plugins,omitempty" swaggertype:"array,object"` - Skills *[]json.RawMessage `json:"skills,omitempty" swaggertype:"array,object"` + Type string `json:"type" enums:"none,self_hosted,openai_hosted" binding:"required"` + ID string `json:"id,omitempty"` + CapabilityDirectories *[]string `json:"capability_directories,omitempty"` + RemoteURL string `json:"remote_url,omitempty"` + WorkspaceDirectory string `json:"workspace_directory,omitempty"` + Network *EnvironmentNetwork `json:"network,omitempty"` + Packages *EnvironmentPackagesResponse `json:"packages,omitempty"` + Files *[]json.RawMessage `json:"files,omitempty" swaggertype:"array,object"` + Plugins *[]json.RawMessage `json:"plugins,omitempty" swaggertype:"array,object"` + Skills *[]json.RawMessage `json:"skills,omitempty" swaggertype:"array,object"` } diff --git a/services/agents-api/internal/api/environment_setup.go b/services/agents-api/internal/api/environment_setup.go index 367b948ac..646335070 100644 --- a/services/agents-api/internal/api/environment_setup.go +++ b/services/agents-api/internal/api/environment_setup.go @@ -112,10 +112,11 @@ func decodeEnvironmentSetup(fields map[string]json.RawMessage) (store.Environmen return result, result.Validate() } -func packageMetadata(packages *v1.EnvironmentPackages) v1.EnvironmentPackages { +func packageMetadata(packages *v1.EnvironmentPackages) v1.EnvironmentPackagesResponse { value := store.EnvironmentSetup{} if packages != nil { value.Packages = *packages } - return value.PackageMetadata() + normalized := value.PackageMetadata() + return v1.EnvironmentPackagesResponse{NPM: normalized.NPM, Python: normalized.Python, System: []string{}} } diff --git a/services/agents-api/internal/api/hosted_environment.go b/services/agents-api/internal/api/hosted_environment.go index b9318de8b..74c257403 100644 --- a/services/agents-api/internal/api/hosted_environment.go +++ b/services/agents-api/internal/api/hosted_environment.go @@ -77,7 +77,7 @@ func hostedSessionEnvironment(environment store.Environment) (v1.SessionEnvironm directories := append([]string{}, cfg.CapabilityDirectories...) return v1.SessionEnvironment{ID: environment.ID, Type: cfg.Type, CapabilityDirectories: &directories, Network: &v1.EnvironmentNetwork{Access: cfg.Network.Access, AllowedDomains: append([]string{}, cfg.Network.AllowedDomains...)}, - Packages: func() *v1.EnvironmentPackages { value := packageMetadata(cfg.Packages); return &value }(), Files: &files, Plugins: &cfg.Plugins, Skills: &cfg.Skills}, nil + Packages: func() *v1.EnvironmentPackagesResponse { value := packageMetadata(cfg.Packages); return &value }(), Files: &files, Plugins: &cfg.Plugins, Skills: &cfg.Skills}, nil } // WithHostedEnvironments enables admission only for an operator-composed, diff --git a/services/agents-api/internal/api/hosted_environment_test.go b/services/agents-api/internal/api/hosted_environment_test.go index 8146b26cb..4100d4eba 100644 --- a/services/agents-api/internal/api/hosted_environment_test.go +++ b/services/agents-api/internal/api/hosted_environment_test.go @@ -62,7 +62,7 @@ func TestHostedEnvironmentResponseHasPinnedShapeAndNoConnectionAction(t *testing if json.Unmarshal(raw, &got) != nil { t.Fatal("invalid environment JSON") } - expected := map[string]any{"id": "environment", "type": "openai_hosted", "capability_directories": []any{}, "files": []any{}, "plugins": []any{}, "skills": []any{}, "packages": map[string]any{"npm": []any{}, "python": []any{}}, "network": map[string]any{"access": "enabled", "allowed_domains": []any{}}} + expected := map[string]any{"id": "environment", "type": "openai_hosted", "capability_directories": []any{}, "files": []any{}, "plugins": []any{}, "skills": []any{}, "packages": map[string]any{"npm": []any{}, "python": []any{}, "system": []any{}}, "network": map[string]any{"access": "enabled", "allowed_domains": []any{}}} if !reflect.DeepEqual(got, expected) { t.Fatal("hosted response shape changed", string(raw)) } diff --git a/services/agents-api/internal/store/runtime_idle_clock_test.go b/services/agents-api/internal/store/runtime_idle_clock_test.go index a30bf748b..89945fd0f 100644 --- a/services/agents-api/internal/store/runtime_idle_clock_test.go +++ b/services/agents-api/internal/store/runtime_idle_clock_test.go @@ -154,15 +154,53 @@ func TestManagedIdleClockIgnoresChildHostSkewAndReplay(t *testing.T) { }) } } -func TestUnmanagedRootCompletionStillRejectsEarlierClock(t *testing.T) { - s, _ := testStore(t) - tenant, session := newTurnSession(t, s) - input := submitMessage(t, s, tenant, session.ID, "earlier-clock") - current := transition(t, s, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) - source := current.CreatedAt.Add(-269 * time.Second).UnixMilli() - outcome := json.RawMessage(fmt.Sprintf(`{"done":{"source_completed_at_ms":%d}}`, source)) - if _, err := s.CompleteExecution(t.Context(), tenant, session.ID, input.TurnID, TurnCompleted, outcome, "", input.Sequence); !errors.Is(err, ErrInvalidInput) { - t.Fatal("unmanaged timing contract changed", err) +func TestUnmanagedRootCompletionPreservesHostSkew(t *testing.T) { + for _, placement := range []string{"none", "self_hosted"} { + for _, skew := range []time.Duration{-269 * time.Second, 269 * time.Second} { + t.Run(placement+"/"+skew.String(), func(t *testing.T) { + s, _ := testStore(t) + tenant := uuid.NewString() + environment := map[string]any{"type": placement} + if placement == "self_hosted" { + environment["workspace_directory"] = "/workspace" + } + configuration, _ := json.Marshal(map[string]any{"agent": map[string]string{"model": "test"}, "environment": environment}) + session, err := s.CreateSession(t.Context(), tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: configuration}) + if err != nil { + t.Fatal(err) + } + input := submitMessage(t, s, tenant, session.ID, "host-clock") + current := transition(t, s, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) + source := current.CreatedAt.Add(skew).UnixMilli() + outcome := json.RawMessage(fmt.Sprintf(`{"done":{"source_completed_at_ms":%d}}`, source)) + completed, err := s.CompleteExecution(t.Context(), tenant, session.ID, input.TurnID, TurnCompleted, outcome, "", input.Sequence) + if err != nil || completed.CompletedAt.UnixMilli() != source { + t.Fatal("native completion changed or rejected", completed, err) + } + read, err := s.GetTurn(t.Context(), tenant, session.ID, input.TurnID) + if err != nil || read.Status != TurnCompleted || read.CompletedAt.UnixMilli() != source { + t.Fatal("public native timestamp rewritten", read, err) + } + if _, err := s.CompleteExecution(t.Context(), tenant, session.ID, input.TurnID, TurnCompleted, outcome, "", input.Sequence); !errors.Is(err, ErrTurnConflict) { + t.Fatal("terminal replay accepted", err) + } + }) + } + } +} + +func TestRootCompletionRejectsNonpositiveSourceTime(t *testing.T) { + for _, source := range []int64{0, -1} { + t.Run(fmt.Sprint(source), func(t *testing.T) { + s, _ := testStore(t) + tenant, session := newTurnSession(t, s) + input := submitMessage(t, s, tenant, session.ID, "invalid-clock") + transition(t, s, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) + outcome := json.RawMessage(fmt.Sprintf(`{"done":{"source_completed_at_ms":%d}}`, source)) + if _, err := s.CompleteExecution(t.Context(), tenant, session.ID, input.TurnID, TurnCompleted, outcome, "", input.Sequence); !errors.Is(err, ErrInvalidInput) { + t.Fatal("invalid native timestamp accepted", err) + } + }) } } diff --git a/services/agents-api/internal/store/template_composition_public_test.go b/services/agents-api/internal/store/template_composition_public_test.go index 380b18578..a3358d503 100644 --- a/services/agents-api/internal/store/template_composition_public_test.go +++ b/services/agents-api/internal/store/template_composition_public_test.go @@ -72,7 +72,7 @@ func TestTemplateCompositionOfficialClientPostgres(t *testing.T) { if err := json.Unmarshal(output, &receipt); err != nil { t.Fatalf("invalid acceptance receipt: %v %s", err, output) } - if len(receipt.Sessions) != 6 || len(receipt.RejectedKeys) != 7 { + if len(receipt.Sessions) != 6 || len(receipt.RejectedKeys) != 10 { t.Fatalf("incomplete acceptance receipt: %s", output) } t.Cleanup(func() { diff --git a/services/agents-api/internal/store/turn_completion.go b/services/agents-api/internal/store/turn_completion.go index 2ffda864e..eda77d776 100644 --- a/services/agents-api/internal/store/turn_completion.go +++ b/services/agents-api/internal/store/turn_completion.go @@ -61,15 +61,8 @@ func (s *Store) CompleteExecution(ctx context.Context, tenantID, sessionID, turn if ms <= 0 { return ErrInvalidInput } - if ms/1000 < current.CreatedAt.Time.Unix() { - managed, err := q.SessionHasRuntimeNode(ctx, session) - if err != nil { - return err - } - if !managed { - return ErrInvalidInput - } - } + // Native and Core timestamps come from independent host clocks. + // Preserve source time; committed activity uses the database clock. sourceCompleted = pgtype.Timestamptz{Time: time.UnixMilli(ms), Valid: true} } } diff --git a/services/agents-api/tests/official_template_composition.py b/services/agents-api/tests/official_template_composition.py index ec04e88ef..43874d4c0 100644 --- a/services/agents-api/tests/official_template_composition.py +++ b/services/agents-api/tests/official_template_composition.py @@ -20,8 +20,8 @@ def main(): assert distribution.version == pin["sdk_version"] assert json.loads(distribution.read_text("direct_url.json"))["vcs_info"]["commit_id"] == pin["commit"] marker = settings["canary"] - packages = {"python": ["packaging==25.0"], "npm": ["semver@7.7.2"], "system": ["jq"]} - populated_packages = {"python": ["idna==3.10"], "npm": [], "system": ["jq"]} + packages = {"python": ["packaging==25.0"], "npm": ["semver@7.7.2"]} + populated_packages = {"python": ["idna==3.10"], "npm": []} sessions, rejected_keys, templates, files = {}, [], [], [] succeeded = False @@ -54,15 +54,16 @@ def request(body, *, key=None, token=None, base=None): safe(response) return response - def rejected(body, status, *, token=None): + def rejected(body, status, *, token=None, param=None): key = secrets.token_hex(20) rejected_keys.append(key) response = request(body, key=key, token=token) assert response.status_code == status, (status, response.status_code, response.text) - assert response.json()["error"]["param"] is None + assert response.json()["error"]["param"] == param return response.json() def projection(client, session_id, expected_files, expected_packages): + expected_packages = {**expected_packages, "system": []} response = client.beta.agents.sessions.with_raw_response.retrieve(session_id) assert response.status_code == 200 safe(response.http_response) @@ -106,6 +107,7 @@ def projection(client, session_id, expected_files, expected_packages): assert created.status_code == 201 safe(created.http_response) template_snapshot = created.parse().to_dict() + assert template_snapshot["packages"] == {**packages, "system": []} base = {"agent": {"model": "composition-fixture"}, "environment": {"type": "openai_hosted", "environment_template_id": template_id}} default_files = [ @@ -124,7 +126,7 @@ def projection(client, session_id, expected_files, expected_packages): "files": populated_files, "packages": {"python": ["idna==3.10"], "npm": []}}, selected_files, populated_packages), ("empty", {"env": {}, "setup_commands": [], "files": [], "packages": {}}, [], packages), ("null", {"env": None, "setup_commands": None, "files": None, "packages": None}, default_files, packages), - ("nested-null", {"packages": {"python": None, "npm": None, "system": None}}, default_files, packages), + ("nested-null", {"packages": {"python": None, "npm": None}}, default_files, packages), ] for label, overrides, expected_files, expected_packages in rows: body = {**base, "environment": {**base["environment"], **overrides}} @@ -139,6 +141,14 @@ def projection(client, session_id, expected_files, expected_packages): retry_body, retry_key, frozen = body, key, projected assert api.retrieve(template_id).to_dict() == template_snapshot + for system in (None, [], ["jq"]): + invalid = {**base, "environment": {**base["environment"], "packages": {"system": system}}} + rejected(invalid, 400, param="packages.system") + response = raw.post(settings["base"] + "/v1/agents/environments/templates", + json={"packages": {"system": system}}, headers=headers) + safe(response) + assert response.status_code == 400, response.text + assert response.json()["error"]["param"] == "packages.system" denied = rejected(base, 404, token=settings["foreign"]) missing = copy.deepcopy(base) missing["environment"]["environment_template_id"] = "00000000-0000-0000-0000-000000000001" From eae849bbaee814a706d9cca34268266df4310809 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 19:07:50 +0000 Subject: [PATCH 08/11] fix(claude): load prepared MCP tools and resolve Skill aliases --- packages/claude-sdk-adapter/src/mcp.ts | 2 +- packages/claude-sdk-adapter/src/workspace.ts | 20 +++++++---- .../tests/mcp_workspace.test.mjs | 5 +-- .../tests/workspace.test.mjs | 10 +++--- .../tests/workspace_skill_alias.test.mjs | 36 +++++++++++++++++++ 5 files changed, 58 insertions(+), 15 deletions(-) create mode 100644 packages/claude-sdk-adapter/tests/workspace_skill_alias.test.mjs diff --git a/packages/claude-sdk-adapter/src/mcp.ts b/packages/claude-sdk-adapter/src/mcp.ts index f791ccb46..453122181 100644 --- a/packages/claude-sdk-adapter/src/mcp.ts +++ b/packages/claude-sdk-adapter/src/mcp.ts @@ -80,7 +80,7 @@ export class MCPProfile { for (const server of declarations) { const prefix = `mcp__${server.server_label}__`; if ("command" in server) { - this.servers[server.server_label] = { type: "stdio", command: server.command, args: [...server.args], env: {} }; + this.servers[server.server_label] = { type: "stdio", command: server.command, args: [...server.args], env: {}, alwaysLoad: true }; } else { const reference = server.bearer_token_env_var; if (reference && !process.env[reference]) throw new Error("missing MCP credential environment"); diff --git a/packages/claude-sdk-adapter/src/workspace.ts b/packages/claude-sdk-adapter/src/workspace.ts index 5e7cb5078..80320d2c8 100644 --- a/packages/claude-sdk-adapter/src/workspace.ts +++ b/packages/claude-sdk-adapter/src/workspace.ts @@ -119,7 +119,7 @@ export class WorkspaceProfile { readonly canUseTool: CanUseTool = async (name, input, { signal, agentID }) => { if (!signal.aborted && (agentID === undefined || this.subagents?.permitsActor(agentID)) && this.permits(name, input)) { - return { behavior: "allow", updatedInput: this.absoluteInput(name, input) }; + return { behavior: "allow", updatedInput: this.nativeInput(name, input) }; } return { behavior: "deny", message: denial }; }; @@ -133,25 +133,31 @@ export class WorkspaceProfile { } if (!signal.aborted && input.hook_event_name === "PreToolUse" && (input.agent_id === undefined || this.subagents?.permitsActor(input.agent_id)) && (id === undefined || id === input.tool_use_id) && this.permits(input.tool_name, input.tool_input)) { - return input.tool_name !== "Read" && input.tool_name !== "Edit" ? {} : { hookSpecificOutput: { hookEventName: "PreToolUse", - updatedInput: this.absoluteInput(input.tool_name, input.tool_input as Record) } }; + return !["Read", "Edit", "Skill"].includes(input.tool_name) ? {} : { hookSpecificOutput: { hookEventName: "PreToolUse", + updatedInput: this.nativeInput(input.tool_name, input.tool_input as Record) } }; } return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: denial } }; }; - private absoluteInput(name: string, input: Record): Record { + private nativeInput(name: string, input: Record): Record { + if (name === "Skill") return { ...input, skill: this.skillName(input.skill) }; return name !== "Read" && name !== "Edit" ? input : { ...input, file_path: resolve(this.cwd, input.file_path as string) }; } + private skillName(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + // Public names are unique in the installed snapshot; native plugins add a namespace. + return this.skillNames.find(name => name === value || name.endsWith(":" + value)); + } + private permits(name: string, value: unknown): boolean { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const input = value as Record; if (this.structuredOutput && name === "StructuredOutput") return true; if (this.functions.includes(name) || this.mcp?.permits(name)) return true; - if (name === "Skill") return typeof input.skill === "string" && this.skillNames.includes(input.skill); + if (name === "Skill") return this.skillName(input.skill) !== undefined; if (name === "Bash") return typeof input.command === "string" && !!input.command.trim() && - (input.run_in_background === undefined || input.run_in_background === false) && - (input.dangerouslyDisableSandbox === undefined || input.dangerouslyDisableSandbox === false); + (input.run_in_background === undefined || input.run_in_background === false); if ((name !== "Read" && name !== "Edit") || typeof input.file_path !== "string" || !input.file_path || /[\x00-\x1f]/.test(input.file_path)) return false; return true; diff --git a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs index c765b4872..80cc7bd77 100644 --- a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs @@ -28,7 +28,7 @@ function fixture(t, declarations = [stdio]) { return { dirs, config, request }; } -test("installed MCP private projection cannot launch arbitrary unsandboxed commands", t => { +test("installed MCP projection uses the common Runtime launcher", t => { const { request } = fixture(t); assert.deepEqual(parseStart(JSON.stringify(request)), request); assert.throws(() => parseStart(JSON.stringify({ ...request, observe_messages: true, @@ -65,12 +65,13 @@ test("combined inventory admits exact MCP identities with host file authority", assert.equal((await workspace.canUseTool(native, {}, { signal })).behavior, "allow"); assert.equal((await workspace.canUseTool("mcp__installed__undeclared", {}, { signal })).behavior, "deny"); assert.equal((await workspace.canUseTool("Read", { file_path: join(dirs.secrets, "model.key") }, { signal })).behavior, "allow"); - assert.equal((await workspace.canUseTool("Bash", { command: "pwd", dangerouslyDisableSandbox: true }, { signal })).behavior, "deny"); + assert.equal((await workspace.canUseTool("Bash", { command: "pwd", dangerouslyDisableSandbox: true }, { signal })).behavior, "allow"); assert.equal((await workspace.beforeTool({ ...input, tool_name: "Read", tool_input: { file_path: "ok.txt" } }, "call", { signal })).hookSpecificOutput.updatedInput.file_path, join(dirs.work, "ok.txt")); for (const fields of [{ session_id: "other" }, { agent_id: "child" }, { tool_name: "mcp__ambient__echo" }]) { assert.equal((await workspace.beforeTool({ ...input, ...fields }, "call", { signal })).hookSpecificOutput.permissionDecision, "deny"); } assert.deepEqual(workspace.options.allowedTools, ["mcp__installed__*"]); + assert.equal(mcp.servers.installed.alwaysLoad, true); assert.deepEqual(workspace.options.tools, baseline); assert.deepEqual(workspace.options.sandbox, {enabled:false}); mcp.close(); diff --git a/packages/claude-sdk-adapter/tests/workspace.test.mjs b/packages/claude-sdk-adapter/tests/workspace.test.mjs index 53b669d56..58784edc9 100644 --- a/packages/claude-sdk-adapter/tests/workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace.test.mjs @@ -70,7 +70,7 @@ test("workspace environment copies only selected refs and fixed values", t => { assert.throws(() => new WorkspaceProfile(dirs.workspace, config), /invalid_request/); }); -test("workspace native options keep the strict sandbox and exact native inventory", t => { +test("workspace native options bypass isolation and preserve selected tool inventory", t => { const { dirs, config } = fixture(t); const profile = new WorkspaceProfile(dirs.workspace, config); const options = profile.options; @@ -120,7 +120,7 @@ test("structured workspace admits only its configured native terminal tool", asy } }); -test("workspace permissions and pre-tool hook reject outside paths and unsafe Bash flags", async t => { +test("workspace permissions preserve host authority and synchronous command ownership", async t => { const { dirs, config } = fixture(t); writeFileSync(join(dirs.workspace, "file.txt"), "fixture"); symlinkSync(dirs.protected, join(dirs.workspace, "escape")); @@ -130,7 +130,7 @@ test("workspace permissions and pre-tool hook reject outside paths and unsafe Ba const options = { signal: new AbortController().signal, toolUseID: "tool", requestId: "request" }; const hook = async (name, input) => profile.beforeTool({ hook_event_name: "PreToolUse", session_id: "native", cwd: dirs.workspace, transcript_path: join(dirs.state, "session"), tool_name: name, tool_input: input, tool_use_id: "tool" }, "tool", options); - for (const [name, input] of [["Bash", { command: "printf value" }], ["Read", { file_path: "file.txt" }], + for (const [name, input] of [["Bash", { command: "printf value" }], ["Bash", { command: "true", dangerouslyDisableSandbox: true }], ["Read", { file_path: "file.txt" }], ["Read", { file_path: "inside" }], ["Edit", { file_path: "new/file.txt", old_string: "", new_string: "value" }]]) { const permission = await profile.canUseTool(name, input, options); assert.equal(permission.behavior, "allow"); @@ -141,7 +141,7 @@ test("workspace permissions and pre-tool hook reject outside paths and unsafe Ba } } for (const [name, input] of [["Bash", { command: "true", run_in_background: true }], - ["Bash", { command: "true", dangerouslyDisableSandbox: true }], ["Bash", { command: "true", run_in_background: "false" }], + ["Bash", { command: "true", run_in_background: "false" }], ["Write", { file_path: "file.txt" }]]) { assert.equal((await profile.canUseTool(name, input, options)).behavior, "deny"); assert.equal((await hook(name, input)).hookSpecificOutput.permissionDecision, "deny"); @@ -175,7 +175,7 @@ test("workspace functions retain native sandbox and exact tool authority", async const event = { hook_event_name: "PreToolUse", tool_name: "mcp__functions__lookup", tool_input: input, tool_use_id: "call" }; assert.deepEqual(await profile.beforeTool(event, "call", options), {}); assert.equal((await profile.beforeTool({ ...event, tool_name: "mcp__external__lookup" }, "call", options)).hookSpecificOutput.permissionDecision, "deny"); - assert.equal((await profile.canUseTool("Bash", { command: "true", dangerouslyDisableSandbox: true }, options)).behavior, "deny"); + assert.equal((await profile.canUseTool("Bash", { command: "true", dangerouslyDisableSandbox: true }, options)).behavior, "allow"); assert.equal((await profile.canUseTool("Read", { file_path: dirs.state + "/history" }, options)).behavior, "allow"); controller.abort(); assert.equal((await profile.canUseTool("mcp__functions__lookup", input, options)).behavior, "deny"); diff --git a/packages/claude-sdk-adapter/tests/workspace_skill_alias.test.mjs b/packages/claude-sdk-adapter/tests/workspace_skill_alias.test.mjs new file mode 100644 index 000000000..1a0cc640e --- /dev/null +++ b/packages/claude-sdk-adapter/tests/workspace_skill_alias.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { WorkspaceProfile } from "../dist/workspace.js"; + +test("selected public Skill names resolve to their native plugin identities", async t => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "oac-skill-alias-"))); + const previous = process.env; + process.env = { ...previous, HOME: root, CLAUDE_CONFIG_DIR: root }; + delete process.env.CLAUDE_CODE_PROJECT_DIR_NAME; + t.after(() => { process.env = previous; rmSync(root, { recursive: true, force: true }); }); + const skills = ["first", "second"].map((name, index) => { + const package_root = `plugins/${index}`; + const relative_root = `${package_root}/skills/${name}`; + mkdirSync(join(root, relative_root), { recursive: true }); + writeFileSync(join(root, relative_root, "SKILL.md"), `---\nname: ${name}\ndescription: Selected fixture.\n---\nFixture.`); + return { metadata: { type: "inline", name, description: "Selected fixture." }, package_root, relative_root }; + }); + const profile = new WorkspaceProfile(root, { home: root, state: root, scratch: root, env_names: [], skills, capability_root: root }); + const context = { signal: new AbortController().signal }; + for (const [index, name] of ["first", "second"].entries()) { + const qualified = `environment-skills-${index}:${name}`; + for (const skill of [name, qualified]) { + const input = { skill, args: "fixture argument" }; + assert.deepEqual(await profile.canUseTool("Skill", input, context), + { behavior: "allow", updatedInput: { ...input, skill: qualified } }); + const result = await profile.beforeTool({ hook_event_name: "PreToolUse", tool_use_id: "call", tool_name: "Skill", tool_input: input }, "call", context); + assert.deepEqual(result.hookSpecificOutput.updatedInput, { ...input, skill: qualified }); + } + } + for (const skill of ["unselected", "other:first", "environment-skills-0:second"]) { + assert.equal((await profile.canUseTool("Skill", { skill }, context)).behavior, "deny"); + } +}); From 16a03428194705d76e9fa52e70a0ac634ae3e91a Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 19:21:11 +0000 Subject: [PATCH 09/11] fix(mcode): settle native tools before cancellation completes --- CONTRIBUTING.md | 5 + .../internal/agent/mcode/executor.go | 6 +- .../internal/agent/mcode/executor_test.go | 133 +++++++++++------- .../internal/agent/mcode/executor_turn.go | 8 +- .../internal/agent/mcode/session_test.go | 2 + .../agent/mcode/workspace_readiness.go | 11 +- .../agent/mcode/workspace_readiness_test.go | 42 ++++++ packages/mcode-harness/cancellation.test.mjs | 63 +++++++++ packages/mcode-harness/check.mjs | 10 +- packages/mcode-harness/launch.mjs | 2 +- packages/mcode-harness/tool-executor.mjs | 2 +- packages/mcode-harness/worker.ts | 15 +- 12 files changed, 238 insertions(+), 61 deletions(-) create mode 100644 apps/parsar-daemon/internal/agent/mcode/workspace_readiness_test.go create mode 100644 packages/mcode-harness/cancellation.test.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6d0e70609..ae6877f63 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3334,6 +3334,11 @@ process and wait for its exit before settling the Turn as non-reusable. Common Runtime recovery then loads the exact owned native history in a new Executor. Do not infer history IDs or qualify hosted execution from this text profile. See [deployment and acceptance](services/agents-api/deploy/mcode/README.md). +MiniMax companion readiness uses private protocol 2; old companions are rejected +even when the upstream version matches. Cancellation retires the executor and +settles its native workers and detached Bash groups before acknowledgement; +later work recovers the same native history in a new owner without replay. + The MiniMax workspace profile builds one CLI from the fixed upstream source and lockfile through the existing companion packaging path, and connects native workspace tools through its standard MCP client. The process and native Session diff --git a/apps/parsar-daemon/internal/agent/mcode/executor.go b/apps/parsar-daemon/internal/agent/mcode/executor.go index ed1b42c69..cfe83cdc1 100644 --- a/apps/parsar-daemon/internal/agent/mcode/executor.go +++ b/apps/parsar-daemon/internal/agent/mcode/executor.go @@ -54,7 +54,11 @@ func NewExecutorFactory(config *WorkspaceConfig) agent.ExecutorFactory { if err != nil { return nil, err } - return newExecutor(ctx, req, opts, binary) + resource, err := newExecutor(ctx, req, opts, binary) + if resource == nil { + return nil, err + } + return resource, err } } diff --git a/apps/parsar-daemon/internal/agent/mcode/executor_test.go b/apps/parsar-daemon/internal/agent/mcode/executor_test.go index f0e44f72c..2edf8eecd 100644 --- a/apps/parsar-daemon/internal/agent/mcode/executor_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/executor_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "os" + "path/filepath" "strings" "testing" "time" @@ -115,57 +116,75 @@ func TestExecutorReusesNativeOwnerWithFreshTurnsAndIdleDrain(t *testing.T) { } } -func TestExecutorCancellationWithoutRootProofInvalidatesAndLateCancelCannotRetarget(t *testing.T) { - e, record := executorFixture(t, "executor-cancel", true) - first, _ := runExecutorFixtureTurn(t, e, "first", "one") - out := make(chan proto.Envelope, 32) - second, err := e.StartTurn(t.Context(), "second", proto.TextInput("wait"), out) - if err != nil { - t.Fatal(err) - } - select { - case event := <-out: - if event.Type != proto.TypeDelta { - t.Fatal(event.Type) - } - case <-time.After(3 * time.Second): - t.Fatal("second Turn did not start") - } - if err = first.Cancel(t.Context()); err != nil { - t.Fatal(err) - } - raw, _ := os.ReadFile(record) - if strings.Contains(string(raw), "session/cancel") { - t.Fatal("late cancellation targeted the new Turn") - } - ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) - defer cancel() - if err = second.Cancel(ctx); err != nil { - t.Fatal("stopped nonreusable owner reported cancellation failure", err) - } - settlement, err := second.AwaitSettlement(ctx) - if err != nil || settlement.Reusable { - t.Fatal(settlement, err) - } - select { - case <-e.connection.exited: - default: - t.Fatal("nonreusable settlement preceded native process exit") - } - if got := second.CancellationOutcome(); got.Content != "ready" || got.Metadata[proto.DoneMetaAgentSessionID] != "native-1" { - t.Fatal("cancel lost settled outcome", got) - } - for range out { - } - thirdOut := make(chan proto.Envelope, 1) - third, err := e.StartTurn(t.Context(), "third", proto.TextInput("three"), thirdOut) - if third != nil || err == nil { - t.Fatal("unproven native owner admitted a successor") - } - close(thirdOut) - raw, _ = os.ReadFile(record) - if strings.Count(string(raw), "session/cancel\n") != 1 || strings.Count(string(raw), "initialize\n") != 1 { - t.Fatalf("cancellation replaced owner: %s", raw) +func TestExecutorCancellationRetiresOwnerAndLateCancelCannotRetarget(t *testing.T) { + for _, disabled := range []bool{true, false} { + t.Run(map[bool]string{true: "single-agent", false: "subagents"}[disabled], func(t *testing.T) { + e, record := executorFixture(t, "executor-cancel", true) + first, _ := runExecutorFixtureTurn(t, e, "first", "one") + e.req.DisableSubagents = disabled + if !disabled { + // A terminal native history record still cannot prove Bash cleanup. + dir := t.TempDir() + node, bridge := filepath.Join(dir, "node"), filepath.Join(dir, "bridge.mjs") + body := "#!/bin/sh\nprintf '%s\\n' '{\"version\":1,\"complete\":true,\"rootSessionId\":\"native-1\",\"sessions\":[{\"id\":\"native-1\",\"turns\":[{\"id\":\"root-turn\",\"status\":\"aborted\"}]}]}'\n" + for name, data := range map[string]string{node: body, bridge: "", filepath.Join(dir, "subagent-snapshot.mjs"): ""} { + if err := os.WriteFile(name, []byte(data), 0700); err != nil { + t.Fatal(err) + } + } + t.Setenv("OAC_RUNTIME_MCODE_NODE", node) + t.Setenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE", bridge) + } + out := make(chan proto.Envelope, 32) + second, err := e.StartTurn(t.Context(), "second", proto.TextInput("wait"), out) + if err != nil { + t.Fatal(err) + } + select { + case event := <-out: + if event.Type != proto.TypeDelta { + t.Fatal(event.Type) + } + case <-time.After(3 * time.Second): + t.Fatal("second Turn did not start") + } + if err = first.Cancel(t.Context()); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(record) + if strings.Contains(string(raw), "session/cancel") { + t.Fatal("late cancellation targeted the new Turn") + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + if err = second.Cancel(ctx); err != nil { + t.Fatal("stopped nonreusable owner reported cancellation failure", err) + } + settlement, err := second.AwaitSettlement(ctx) + if err != nil || settlement.Reusable { + t.Fatal(settlement, err) + } + select { + case <-e.connection.exited: + default: + t.Fatal("nonreusable settlement preceded native process exit") + } + if got := second.CancellationOutcome(); got.Content != "ready" || got.Metadata[proto.DoneMetaAgentSessionID] != "native-1" { + t.Fatal("cancel lost settled outcome", got) + } + for range out { + } + thirdOut := make(chan proto.Envelope, 1) + third, err := e.StartTurn(t.Context(), "third", proto.TextInput("three"), thirdOut) + if third != nil || err == nil { + t.Fatal("unproven native owner admitted a successor") + } + close(thirdOut) + raw, _ = os.ReadFile(record) + if strings.Count(string(raw), "session/cancel\n") != 1 || strings.Count(string(raw), "initialize\n") != 1 { + t.Fatalf("cancellation replaced owner: %s", raw) + } + }) } } @@ -241,3 +260,15 @@ func TestExecutorCloseRetainsOwnerAfterDeadline(t *testing.T) { t.Fatal("Close lost native cleanup ownership") } } + +func TestExecutorFactoryPreparationFailureHasNoTypedNilOwner(t *testing.T) { + config, req, _ := workspaceFixture(t) + req.ReleaseOnCompletion = false + if err := os.WriteFile(config.Binary, []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil { + t.Fatal(err) + } + value, err := NewExecutorFactory(&config)(t.Context(), req) + if err == nil || value != nil { + t.Fatalf("settled preparation failure returned owner: nil=%t error=%v", value == nil, err) + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/executor_turn.go b/apps/parsar-daemon/internal/agent/mcode/executor_turn.go index 6a43459f5..ed81a3cc6 100644 --- a/apps/parsar-daemon/internal/agent/mcode/executor_turn.go +++ b/apps/parsar-daemon/internal/agent/mcode/executor_turn.go @@ -40,9 +40,9 @@ func (s *Session) runExecutorTurn() { } s.finishEnvironmentMCP() s.mu.Lock() - // ACP cancelled can precede native root Turn settlement in 0.4.12. - // Without the qualified history reader, retain no claim of safe reuse. - if (s.cancelled && s.req.DisableSubagents) || s.inputUncertain || len(s.permissions) != 0 || len(s.questions) != 0 { + // ACP and native history cancellation do not prove detached tool cleanup. + // Retire the owner and settle its workers before acknowledging cancellation. + if s.cancelled || s.inputUncertain || len(s.permissions) != 0 || len(s.questions) != 0 { reusable = false } if s.inputUncertain && err == nil { @@ -126,7 +126,7 @@ func (s *Session) cancelTurn(ctx context.Context) error { } s.mu.Unlock() // Cancellation releases event backpressure but does not cancel native owner - // context. ACP prompt completion and child/tool settlement decide reuse. + // context. After native settlement, cancellation retires and drains the owner. s.outputCancel() e.mu.Unlock() var err error diff --git a/apps/parsar-daemon/internal/agent/mcode/session_test.go b/apps/parsar-daemon/internal/agent/mcode/session_test.go index 8d0fb9537..47f480c28 100644 --- a/apps/parsar-daemon/internal/agent/mcode/session_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/session_test.go @@ -327,6 +327,8 @@ func TestMCodeProcess(t *testing.T) { } update("usage_update", map[string]any{"used": 2000, "size": 64000, "cost": map[string]any{"amount": 2, "currency": "USD"}}) result = map[string]string{"stopReason": "end_turn"} + case "mcode/session/delegation/stop": + result = map[string]any{"receipt": map[string]any{"failedSessionIds": []string{}}} case "session/cancel": raw, _ := json.Marshal(map[string]string{"stopReason": "cancelled"}) send(rpcFrame{JSONRPC: "2.0", ID: promptID, Result: raw}) diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace_readiness.go b/apps/parsar-daemon/internal/agent/mcode/workspace_readiness.go index 84b71f218..85d1b4535 100644 --- a/apps/parsar-daemon/internal/agent/mcode/workspace_readiness.go +++ b/apps/parsar-daemon/internal/agent/mcode/workspace_readiness.go @@ -27,11 +27,20 @@ func CheckWorkspace(ctx context.Context, c WorkspaceConfig) error { p.Cancel() } waitErr := p.Wait() + if err != nil || waitErr != nil { + return fmt.Errorf("mcode: workspace companion check failed") + } + return ValidateWorkspaceReadiness(raw) +} + +// ValidateWorkspaceReadiness checks the installed companion's private contract. +// Neither upstream version alone nor a successful CLI --version proves cleanup. +func ValidateWorkspaceReadiness(raw []byte) error { var info struct { Protocol int `json:"protocol"` Native, Source string } - if err != nil || waitErr != nil || len(raw) > 4096 || json.Unmarshal(raw, &info) != nil || info.Protocol != 1 || info.Native != SupportedVersion || info.Source != "33b259bbbeb1c16433390869938191d09bdb0680" { + if len(raw) > 4096 || json.Unmarshal(raw, &info) != nil || info.Protocol != 2 || info.Native != SupportedVersion || info.Source != "33b259bbbeb1c16433390869938191d09bdb0680" { return fmt.Errorf("mcode: workspace companion check failed") } return nil diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace_readiness_test.go b/apps/parsar-daemon/internal/agent/mcode/workspace_readiness_test.go new file mode 100644 index 000000000..6fca32fe8 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/workspace_readiness_test.go @@ -0,0 +1,42 @@ +package mcode + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestWorkspaceReadinessRejectsOldCleanupContract(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("MiniMax adapter supports Linux and macOS") + } + for _, protocol := range []int{1, 2} { + t.Run(fmt.Sprint(protocol), func(t *testing.T) { + dir := t.TempDir() + node := filepath.Join(dir, "node") + body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' '{\"protocol\":%d,\"native\":\"0.4.12\",\"source\":\"33b259bbbeb1c16433390869938191d09bdb0680\"}'\n", protocol) + if err := os.WriteFile(node, []byte(body), 0700); err != nil { + t.Fatal(err) + } + err := CheckWorkspace(context.Background(), WorkspaceConfig{Node: node, Bridge: filepath.Join(dir, "bridge.mjs")}) + if (err == nil) != (protocol == 2) { + t.Fatalf("protocol %d readiness: %v", protocol, err) + } + }) + } +} + +func TestValidateWorkspaceReadinessRejectsInvalidDescriptors(t *testing.T) { + for _, raw := range []string{"", "null", "{", strings.Repeat(" ", 4097), + `{"protocol":1,"native":"0.4.12","source":"33b259bbbeb1c16433390869938191d09bdb0680"}`, + `{"protocol":2,"native":"0.4.11","source":"33b259bbbeb1c16433390869938191d09bdb0680"}`, + `{"protocol":2,"native":"0.4.12","source":"other"}`} { + if err := ValidateWorkspaceReadiness([]byte(raw)); err == nil || err.Error() != "mcode: workspace companion check failed" { + t.Fatal("invalid descriptor accepted or exposed", err) + } + } +} diff --git a/packages/mcode-harness/cancellation.test.mjs b/packages/mcode-harness/cancellation.test.mjs new file mode 100644 index 000000000..5910df622 --- /dev/null +++ b/packages/mcode-harness/cancellation.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { existsSync } from 'node:fs'; +import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; + +const artifact = process.env.OAC_TEST_MCODE_CLEANUP_ARTIFACT ?? dirname(fileURLToPath(import.meta.url)); +const available = existsSync(join(artifact, 'dist/worker.mjs')); +const quote = value => "'" + value.replaceAll("'", "'\\''") + "'"; + +for (const operation of ['cancel', 'close']) { + test(`native Bash ${operation} settles detached parent and child before returning`, { skip: !available, timeout: 20_000 }, async t => { + const root = await mkdtemp(join(tmpdir(), 'oac-mcode-cancel-')); + const scratch = join(root, 'scratch'); + await mkdir(scratch); + const pids = []; + let cleaned = false; + t.after(async () => { + if (!cleaned) for (const pid of pids) { try { process.kill(pid, 'SIGKILL'); } catch {} } + await rm(root, { recursive: true, force: true }); + }); + await writeFile(join(root, 'ticks.mjs'), ` + import { spawn } from 'node:child_process'; + import { appendFileSync, writeFileSync } from 'node:fs'; + const name=process.argv[2]; + process.on('SIGTERM',()=>{}); + writeFileSync(name+'.pid',String(process.pid)); + if(name==='parent') spawn(process.execPath,['ticks.mjs','child'],{stdio:'inherit'}); + setInterval(()=>appendFileSync(name+'.ticks','tick\\n'),20); + `); + const profile = join(root, 'profile.json'); + await writeFile(profile, JSON.stringify({ workspace: root, scratch, network: 'enabled' })); + const { ToolExecutor } = await import(pathToFileURL(join(artifact, 'tool-executor.mjs'))); + const executor = new ToolExecutor(profile); + t.after(() => executor.close()); + const abort = new AbortController(); + const finished = executor.execute('bash', { command: `${quote(process.execPath)} ticks.mjs parent` }, abort.signal); + const rejected = assert.rejects(finished); + for (const name of ['parent', 'child']) { + let started = false; + for (let i = 0; i < 500; i++) { + try { + if ((await readFile(join(root, name+'.ticks'))).length > 0) { + pids.push(Number(await readFile(join(root, name+'.pid'), 'utf8'))); + started = true; break; + } + } catch {} + await delay(10); + } + assert.equal(started, true, name+' began writing'); + } + if (operation === 'cancel') abort.abort(); else await executor.close(); + await rejected; + const before = await Promise.all(['parent','child'].map(name => readFile(join(root,name+'.ticks'),'utf8'))); + await delay(300); + const after = await Promise.all(['parent','child'].map(name => readFile(join(root,name+'.ticks'),'utf8'))); + assert.deepEqual(after, before, 'cancel receipt preceded native descendant cleanup'); + cleaned = true; + }); +} diff --git a/packages/mcode-harness/check.mjs b/packages/mcode-harness/check.mjs index fb6fede76..e77cb1c92 100644 --- a/packages/mcode-harness/check.mjs +++ b/packages/mcode-harness/check.mjs @@ -2,6 +2,7 @@ import '@modelcontextprotocol/sdk/server/index.js'; import { accessSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; if (!['linux', 'darwin'].includes(process.platform)) throw new Error('MiniMax native execution requires Linux or macOS'); for (const file of ['bridge.mjs', 'launch.mjs', 'tool-executor.mjs', 'dist/worker.mjs', @@ -12,7 +13,14 @@ const pin = JSON.parse(readFileSync(new URL('source.json', import.meta.url))); const patch = JSON.parse(readFileSync(new URL('native-patch.json', import.meta.url))); if (patch.revision !== pin.revision || !/^[a-f0-9]{64}$/.test(patch.files?.['patch-native.mjs'] ?? '') || !/^[a-f0-9]{64}$/.test(patch.files?.['native-subagent-admission.mjs'] ?? '')) throw new Error('Native admission patch provenance mismatch'); +// Load the installed native closure, never a global addon or user database. +const require = createRequire(import.meta.url); +try { + const Database = require(fileURLToPath(new URL('./native/node_modules/better-sqlite3', import.meta.url))); + const database = new Database(':memory:'); + database.close(); +} catch { throw new Error('Native database dependency check failed'); } const tools = JSON.parse(execFileSync(process.execPath, [fileURLToPath(new URL('dist/worker.mjs', import.meta.url)), process.cwd(), '--describe'], { maxBuffer: 1024 * 1024 })); if (tools.map(t => t.name).sort().join(',') !== 'bash,edit,glob,grep,read,write') throw new Error('Native tool inventory mismatch'); -process.stdout.write(JSON.stringify({ protocol: 1, native: pin.version, source: pin.revision }) + '\n'); +process.stdout.write(JSON.stringify({ protocol: 2, native: pin.version, source: pin.revision }) + '\n'); diff --git a/packages/mcode-harness/launch.mjs b/packages/mcode-harness/launch.mjs index d9ad7defa..d07c45649 100644 --- a/packages/mcode-harness/launch.mjs +++ b/packages/mcode-harness/launch.mjs @@ -22,7 +22,7 @@ if (profile.capabilityRoot && (typeof profile.capabilityRoot !== 'string' || !is if (profile.skills && !profile.capabilityRoot) throw new Error('Missing capability root'); let child; let cancelled=false; -const cancel=()=>{cancelled=true;child?.kill('SIGKILL');}; +const cancel=()=>{cancelled=true;child?.kill('SIGTERM');}; process.on('SIGTERM',cancel);process.on('SIGINT',cancel); try { mkdirSync(profile.scratch,{recursive:true}); diff --git a/packages/mcode-harness/tool-executor.mjs b/packages/mcode-harness/tool-executor.mjs index 9e02a9109..16de8ea1f 100644 --- a/packages/mcode-harness/tool-executor.mjs +++ b/packages/mcode-harness/tool-executor.mjs @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'; const limit = 16 * 1024 * 1024; const launcher = fileURLToPath(new URL('./launch.mjs', import.meta.url)); -// One bridge owns every launcher until its stdio and sandbox have settled. +// One bridge owns every launcher until its stdio and native tools have settled. export class ToolExecutor { #calls = new Set(); #closed = false; diff --git a/packages/mcode-harness/worker.ts b/packages/mcode-harness/worker.ts index 612e6418b..f034b1ef6 100644 --- a/packages/mcode-harness/worker.ts +++ b/packages/mcode-harness/worker.ts @@ -3,6 +3,7 @@ import { LocalGrepTool } from '@native/local-grep'; import { LocalGlobTool } from '@native/local-glob'; import { toRuntimeTool, isRuntimeToolInputValid } from '@mavis/agent-core/tools'; import { readFileSync } from 'node:fs'; +import { killTrackedDetachedChildren } from '@pi/utils/shell'; import { isAbsolute } from 'node:path'; const root = process.argv[2]; @@ -15,6 +16,15 @@ if (process.argv[3] === '--describe') { description: t.def.description, inputSchema: t.def.schema }))) + '\n'); process.exit(0); } +const abort = new AbortController(); +const cancel = () => { + abort.abort(); + // Native Bash owns a detached process group outside the daemon's group. + // Keep the worker alive to settle its tool after stopping that owned group. + killTrackedDetachedChildren(); +}; +process.on('SIGTERM', cancel); +process.on('SIGINT', cancel); const request = JSON.parse(readFileSync(0, 'utf8')); const tool = tools.find(value => value.def.name === request.tool); if (!tool || !request.input || typeof request.input !== 'object' || Array.isArray(request.input) || @@ -23,10 +33,13 @@ if (!tool || !request.input || typeof request.input !== 'object' || Array.isArra const context = { sessionId: 'worker', turnId: 'call', allowBashAutoPromotion: false, canConsumeBackgroundBashOutput: false }; try { - const result = await tool.impl.execute(context, request.input); + const result = await tool.impl.execute(context, request.input, abort.signal); process.stdout.write(JSON.stringify(result) + '\n'); } catch (error) { process.stdout.write(JSON.stringify({ tool_name: request.tool, isError: true, text: error instanceof Error ? error.message : String(error), content: [] }) + '\n'); process.exitCode = 1; +} finally { + process.removeListener('SIGTERM', cancel); + process.removeListener('SIGINT', cancel); } From af1e40e38e027c22598865e4e4192c699a4c3dce Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 19:18:47 +0000 Subject: [PATCH 10/11] Pair enrollment expiry checks with confirmed node data --- .../src/features/sandbox/NodeEnrollment.tsx | 20 ++++++++++--------- .../features/sandbox/SandboxManagerView.tsx | 6 +++++- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index c2033bbc9..250d3c991 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -65,8 +65,8 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, open: boolean; fresh: boolean; onClose: () => void; - /** Reads the page's node list again; settles when the read has. */ - onRefresh: () => Promise; + /** Returns the confirmed node list, or null when the read failed. */ + onRefresh: () => Promise; }) { const { t, i18n } = useTranslation("sandbox"); const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; @@ -85,8 +85,8 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, const [copiedMode, setCopiedMode] = useState("sudo"); const queryClient = useQueryClient(); const installation = useQuery(installationQuery); - // When the latest node-list read this dialog asked for began (Date.now()), once it has finished. - const [checkedAt, setCheckedAt] = useState(0); + // Keep the completed read's start time paired with its data: query notifications can render later. + const [checked, setChecked] = useState<{ startedAt: number; nodes: SandboxNode[] } | null>(null); const reading = useRef(false); const generation = useRef(0); const request = useRef(null); @@ -137,7 +137,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, const expiresAt = enrollment ? Date.parse(enrollment.expires_at) : 0; const lapsed = Boolean(enrollment && expiresAt <= now); // Expired only once a read begun after the expiry found no node for the command. - const expired = lapsed && checkedAt >= expiresAt; + const expired = lapsed && fresh && checked !== null && checked.startedAt >= expiresAt && checked.nodes === nodes; const commandFor = (mode: NodeInstallMode) => enrollment && provider && available && publicUrl && (registered || !expired) && !ready ? nodeInstallCommand({ token: enrollment.token, coreUrl: publicUrl, sourceUrl: publicUrl, provider, installationId: deployment.installation_id, scriptDigest: consoleConfig.node_installer_sha256, mode }) : ""; const command = commandFor("sudo"); @@ -147,8 +147,10 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, if (reading.current) return; reading.current = true; const started = Date.now(); - try { await onRefresh(); } finally { reading.current = false; } - setCheckedAt((current) => Math.max(current, started)); + try { + const confirmedNodes = await onRefresh(); + if (confirmedNodes) setChecked({ startedAt: started, nodes: confirmedNodes }); + } finally { reading.current = false; } }, [onRefresh]); useEffect(() => () => { generation.current++; request.current?.abort(); }, []); useEffect(() => { if (open) rememberRequirementsSeen(); }, [open]); @@ -167,8 +169,8 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, useEffect(() => { if (open && enrollment) void check(); }, [open]); // eslint-disable-line react-hooks/exhaustive-deps // At expiry, one more read decides between the command's node and "Command expired". useEffect(() => { - if (open && lapsed && !registered && checkedAt < expiresAt) void check(); - }, [open, lapsed, registered, checkedAt, expiresAt, check]); + if (open && lapsed && !registered && !expired) void check(); + }, [open, lapsed, registered, expired, check]); // The installer's wait for readiness counts from when the node appears. useEffect(() => { if (nodeId) setAppeared((current) => (current?.id === nodeId ? current : { id: nodeId, at: Date.now() })); diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index 003414a27..492dad044 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -67,7 +67,11 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const [cleanup, setCleanup] = useState<{ node: NodeCleanup; open: boolean } | null>(null); // The enrollment dialog's reads: the node list alone, every few seconds while it waits. It settles // when the read does, which the dialog waits for before calling a command expired. - const refreshNodes = useCallback(() => refetch(), [refetch]); + const { refetch: refetchInventory } = query; + const refreshNodes = useCallback(async () => { + const result = await refetchInventory(); + return result.isSuccess && !result.data.nodesError ? result.data.nodes : null; + }, [refetchInventory]); // Where focus goes once a dialog about a node that is gone closes. const heading = useRef(null); const toast = useToast(); From 781a4dcd65ec9bf4e20d6827ee2a4cb50a55d6aa Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 19:28:04 +0000 Subject: [PATCH 11/11] fix(runtime): honor required tool configuration and native MCP environment --- .../parsar-daemon/internal/cli/runtime_mcp.go | 28 ++++++- .../internal/cli/runtime_mcp_unix.go | 13 +--- .../internal/cli/runtime_mcp_windows.go | 3 + .../internal/cli/runtime_mcp_windows_test.go | 77 +++++++++++++++++++ .../internal/localworkspace/capabilities.go | 2 +- .../capability_preparation_test.go | 38 +++++++++ .../internal/localworkspace/initialization.go | 2 +- 7 files changed, 148 insertions(+), 15 deletions(-) diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp.go b/apps/parsar-daemon/internal/cli/runtime_mcp.go index dd78bc0af..bca7cab0d 100644 --- a/apps/parsar-daemon/internal/cli/runtime_mcp.go +++ b/apps/parsar-daemon/internal/cli/runtime_mcp.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "runtime" "sort" "strings" @@ -63,7 +64,7 @@ func resolveMCPInvocation(manifest agentcapabilities.Manifest, installationRoot, for _, entry := range os.Environ() { key, value, ok := strings.Cut(entry, "=") if ok { - env[key] = value + env[mcpEnvironmentKey(key)] = value } } @@ -72,7 +73,7 @@ func resolveMCPInvocation(manifest agentcapabilities.Manifest, installationRoot, if !exists || strings.ContainsRune(value, 0) { return mcpInvocation{}, errRuntimeMCP } - env[key] = value + env[mcpEnvironmentKey(key)] = value } cwd := server.CWD if !filepath.IsAbs(cwd) { @@ -87,3 +88,26 @@ func resolveMCPInvocation(manifest agentcapabilities.Manifest, installationRoot, } return mcpInvocation{}, errRuntimeMCP } + +func mcpEnvironmentKey(key string) string { + if runtime.GOOS == "windows" { + return strings.ToUpper(key) + } + return key +} + +// This command is a dedicated MCP launcher process. Apply its final environment +// before executable lookup so PATH and npm selection agree on every platform. +func configureMCPProcess(invocation mcpInvocation) error { + if os.Chdir(invocation.cwd) != nil { + return errRuntimeMCP + } + os.Clearenv() + for _, entry := range invocation.env { + key, value, _ := strings.Cut(entry, "=") + if os.Setenv(key, value) != nil { + return errRuntimeMCP + } + } + return nil +} diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_unix.go b/apps/parsar-daemon/internal/cli/runtime_mcp_unix.go index 9c7d1df9d..7c74a3b64 100644 --- a/apps/parsar-daemon/internal/cli/runtime_mcp_unix.go +++ b/apps/parsar-daemon/internal/cli/runtime_mcp_unix.go @@ -3,22 +3,13 @@ package cli import ( - "os" "os/exec" - "strings" "syscall" ) func execRuntimeMCP(invocation mcpInvocation) error { - if os.Chdir(invocation.cwd) != nil { - return errRuntimeMCP - } - os.Clearenv() - for _, entry := range invocation.env { - key, value, _ := strings.Cut(entry, "=") - if os.Setenv(key, value) != nil { - return errRuntimeMCP - } + if err := configureMCPProcess(invocation); err != nil { + return err } command, err := exec.LookPath(invocation.command) if err != nil { diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_windows.go b/apps/parsar-daemon/internal/cli/runtime_mcp_windows.go index 4095efd4d..35606bfd7 100644 --- a/apps/parsar-daemon/internal/cli/runtime_mcp_windows.go +++ b/apps/parsar-daemon/internal/cli/runtime_mcp_windows.go @@ -10,6 +10,9 @@ import ( ) func execRuntimeMCP(invocation mcpInvocation) error { + if err := configureMCPProcess(invocation); err != nil { + return err + } binary, args, err := localworkspace.ResolvePackageManagerCommand(invocation.command, invocation.args[1:]) if err != nil { return errRuntimeMCP diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_windows_test.go b/apps/parsar-daemon/internal/cli/runtime_mcp_windows_test.go index c42e2c9bd..0d1179240 100644 --- a/apps/parsar-daemon/internal/cli/runtime_mcp_windows_test.go +++ b/apps/parsar-daemon/internal/cli/runtime_mcp_windows_test.go @@ -4,10 +4,13 @@ package cli import ( "encoding/json" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" "os" "os/exec" "path/filepath" "slices" + "strings" "testing" ) @@ -60,6 +63,21 @@ func TestWindowsRuntimeMCPPackageManagers(t *testing.T) { func checkWindowsMCPStdio(t *testing.T, invocation mcpInvocation, wantArgs []string) { t.Helper() + previousEnv := os.Environ() + previousCWD, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer func() { + os.Clearenv() + for _, entry := range previousEnv { + key, value, ok := strings.Cut(entry, "=") + if ok { + _ = os.Setenv(key, value) + } + } + _ = os.Chdir(previousCWD) + }() input, err := os.CreateTemp(t.TempDir(), "input") if err != nil { t.Fatal(err) @@ -100,3 +118,62 @@ func checkWindowsMCPStdio(t *testing.T, invocation mcpInvocation, wantArgs []str t.Fatalf("stdio invocation changed: %s", raw) } } + +func TestWindowsRuntimeMCPExplicitPath(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Fatal(err) + } + directory := t.TempDir() + body, err := os.ReadFile(node) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(directory, "selected-node.exe"), body, 0700); err != nil { + t.Fatal(err) + } + fixture := filepath.Join(directory, "stdio.cjs") + if err = os.WriteFile(fixture, []byte(`let input="";process.stdin.on("data",v=>input+=v);process.stdin.on("end",()=>process.stdout.write(JSON.stringify({input,cwd:process.cwd(),value:process.env.OAC_MCP_FIXTURE_VALUE,args:process.argv.slice(2)})))`), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("Path", os.Getenv("PATH")) + manifest := agentcapabilities.Manifest{MCP: []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: agentplugin.MCPServer{Name: "test", Type: "stdio", Command: "selected-node", Args: []string{fixture}, CWD: directory, EnvVars: []string{"PATH", "OAC_MCP_FIXTURE_VALUE"}}}}} + invocation, err := resolveMCPInvocation(manifest, directory, "plugins/0", "test", map[string]string{"PATH": directory, "OAC_MCP_FIXTURE_VALUE": "selected"}) + if err != nil { + t.Fatal(err) + } + count := 0 + for _, entry := range invocation.env { + key, value, _ := strings.Cut(entry, "=") + if strings.EqualFold(key, "PATH") { + count++ + if value != directory { + t.Fatal("explicit PATH lost") + } + } + } + if count != 1 { + t.Fatal("duplicate PATH variants", count) + } + checkWindowsMCPStdio(t, invocation, nil) + // npm resolution must also use the selected installation, not ambient npm. + npmBin := filepath.Join(directory, "node_modules", "npm", "bin") + if err = os.MkdirAll(npmBin, 0700); err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(directory, "npm.cmd"), []byte("@exit /b 1"), 0600); err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(directory, "node.exe"), body, 0700); err != nil { + t.Fatal(err) + } + source, err := os.ReadFile(fixture) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(npmBin, "npm-cli.js"), source, 0600); err != nil { + t.Fatal(err) + } + invocation.command, invocation.args = "npm", []string{"npm", "selected installation"} + checkWindowsMCPStdio(t, invocation, []string{"selected installation"}) +} diff --git a/apps/parsar-daemon/internal/localworkspace/capabilities.go b/apps/parsar-daemon/internal/localworkspace/capabilities.go index 9cf8b9ae9..13605e761 100644 --- a/apps/parsar-daemon/internal/localworkspace/capabilities.go +++ b/apps/parsar-daemon/internal/localworkspace/capabilities.go @@ -49,7 +49,7 @@ func (b *Binding) Prepare(ctx context.Context, r proto.PromptRequestPayload) (pr local.Skills[i].InstallationRoot = b.capabilityRoot } if local.ToolEnvironment { - if _, err = b.ReadToolEnvironment(); err != nil { + if _, err = ReadToolEnvironment(); err != nil { return r, err } } diff --git a/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go b/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go index 696906ad2..fbfc83904 100644 --- a/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go +++ b/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go @@ -140,3 +140,41 @@ func TestPreparationEmptySelectionAndCancellation(t *testing.T) { t.Fatal("Files required capability installation", err) } } + +func TestRuntimePreparationRejectsMissingRequiredToolEnvironment(t *testing.T) { + b, req := testBinding(t) + t.Setenv("OAC_RUNTIME_INITIALIZATION_DIRECTORY", t.TempDir()) + t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", "") + req.LocalEnvironment.ToolEnvironment = true + configured, err := b.Configure(req) + if err != nil { + t.Fatal(err) + } + if _, err = b.Prepare(t.Context(), configured); err == nil { + t.Fatal("missing required tool environment admitted") + } + directory, err := InitializationDirectory() + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(directory, "tool-env.json"), []byte(`{"READY":"yes"}`), 0600); err != nil { + t.Fatal(err) + } + if _, err = b.Prepare(t.Context(), configured); err != nil { + t.Fatal("prepared tool environment rejected", err) + } + if err = os.Remove(filepath.Join(directory, "tool-env.json")); err != nil { + t.Fatal(err) + } + configured.LocalEnvironment.ToolEnvironment = false + if _, err = b.Prepare(t.Context(), configured); err != nil { + t.Fatal("optional tool environment became mandatory", err) + } +} + +func TestRuntimePreparationRejectsMissingExplicitToolEnvironment(t *testing.T) { + t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", filepath.Join(t.TempDir(), "missing.json")) + if _, err := ReadOptionalToolEnvironment(); err == nil { + t.Fatal("missing explicitly configured tool environment ignored") + } +} diff --git a/apps/parsar-daemon/internal/localworkspace/initialization.go b/apps/parsar-daemon/internal/localworkspace/initialization.go index 338ad7c78..d147d67b6 100644 --- a/apps/parsar-daemon/internal/localworkspace/initialization.go +++ b/apps/parsar-daemon/internal/localworkspace/initialization.go @@ -70,7 +70,7 @@ func ReadOptionalToolEnvironment() (map[string]string, error) { if err != nil { return nil, err } - if _, err = os.Stat(path); errors.Is(err, os.ErrNotExist) { + if _, err = os.Stat(path); errors.Is(err, os.ErrNotExist) && os.Getenv("OAC_RUNTIME_TOOL_ENV_FILE") == "" { return map[string]string{}, nil } return ReadToolEnvironment()