Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 58 additions & 12 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,10 +381,10 @@ release and recovery semantics for Codex and Claude. Retain each harness's nativ
implementation behind its adapter. Core acts on verified capabilities and runtime
conditions; a capability declaration alone never grants public feature admission.
Extend existing interfaces during related functional work without introducing a
second framework or a broad rewrite. The current MVP accepts Codex only, with
Docker-hosted delivery first and E2B afterward. Preserve existing Claude code and
evidence; further Claude integration is deferred and hosted remote-executor
separation is outside the current roadmap.
second framework or a broad rewrite. The completed MVP remains Codex/Docker.
The next accepted engine profile is Claude SDK on the same dedicated Docker
Runtime; qualify each image with the common full-loop acceptance before deploying.
E2B, other engines and hosted remote-executor separation remain outside this batch.
Later engines must satisfy the same applicable acceptance contract while keeping
their suitable native deployment layout.

Expand Down Expand Up @@ -1577,7 +1577,8 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti
The operator-selected
`AGENTS_API_ENGINE` is separate from the requested model.
Public execution supports Codex and Claude SDK with environment `none`, plus
the Codex self-hosted text/function profile defined above; reject unsupported
the Codex self-hosted text/function profile and the qualified Codex/Claude dedicated
Docker hosted profiles; reject unsupported
input/environment/agent options explicitly.
- `packages/agents-client/v1` configures the pinned official `openai-go` Session
service. Use SDK request/response types, pagination and errors directly rather
Expand Down Expand Up @@ -1927,6 +1928,47 @@ must close their query, await their native child and drain observations before
publishing completion. Process groups are lifecycle supervision, not OS isolation
or containment of descendants that deliberately leave the group.

### Claude dedicated Docker Runtime

Build the pinned SDK bundle with `scripts/build-claude-sdk-runtime.sh`, then use
`scripts/build-claude-runtime.sh` with the existing shared workspace helper build.
See [deployment and engine onboarding](services/agents-api/deploy/claude/README.md).
The helper executables retain their historical Codex names; their local directory,
write and export operations are shared and do not launch an engine.

`PARSAR_CLAUDE_SDK_WORKSPACE=managed` requires the shared dedicated local binding,
canonical workspace and its same-inode `/workspace` mount, and explicit immutable
network policy. Native history, home and scratch live separately under
`PARSAR_HOME/runtime/claude-sdk`; daemon authentication stays under
`PARSAR_HOME/parsar-daemon`. The trusted image and protected staging directory
remain outside writable workspace roots. No product state or native user profile
is imported. The separate unbound `environment:none` profile keeps its behavior.

The Docker operator option `nested_sandbox` is false by default. The qualified
Claude image requires it: Docker supplies an init process and permits nested procfs
mounting by removing its outer `/proc` masks/read-only submounts. `/sys/firmware`
and powercap remain masked; the root and sysfs mounts remain read-only, capabilities
remain dropped, and the existing seccomp/no-new-privileges policy remains enabled.
Do not enable privileged mode, weaken the native sandbox, mount host process state,
or apply host-global policy changes. This is an image deployment prerequisite,
not a public API option or an engine-name branch in the Provider.

The SDK adapter advertises `local_runtime_v1` only for its Linux bridge contract.
Registration combines that contract with the verified operator binding. Core uses
an explicit accepted engine profile independently of advertisements. This profile
supports native Bash/Read/Edit, preparation, shared Files/Artifacts, cancellation
and same-history continuation. Functions and MCP in this workspace profile remain
unqualified and reject before persistence; their existing `none` support is retained.
Native Bash network access uses the harness's HTTP proxy; no alternate networking
or tool loop is implemented by Core.

Recovery uses the SDK's history APIs. An explicitly supplied native identity must
exist. If Core requires existing history without having recorded an identity, the
adapter accepts only one nonempty native history for the exact bound cwd. Missing,
foreign, ambiguous or metadata-only history rejects before model input. The Runtime
volume and shared Environment/Session binding establish ownership; this lookup
cannot select another Session's home or infer ownership from a model response.

### Claude SDK adapter foundation

`packages/claude-sdk-adapter` privately owns the pinned official TypeScript SDK
Expand All @@ -1948,8 +1990,8 @@ SDK/native child release and output draining precede daemon completion.
qualified placement. It enables only native Bash/Read/Edit in the existing SDK
loop. The entire factory must already run inside an outer mount/process boundary
that excludes application, daemon and other-tenant credentials and host policy.
The factory does not create that boundary. Public discovery/admission remains
unchanged; cwd and request options cannot select the workspace policy.
The factory does not create that boundary. The dedicated Docker profile below
selects this binding at startup; cwd and request options cannot select its policy.
The workspace, managed history, runtime home, scratch and protected secret roots
must be pre-existing canonical, separate directories. Runtime code and dependency
search paths must remain outside those roots and be read-only in the placement.
Expand All @@ -1972,8 +2014,8 @@ The deployment must retain these controls, including the SDK-owned hook.
Functions, MCP and remote-environment combinations are rejected in this private
profile until separately qualified. The existing `none` profile retains its
behavior. Packaged `workspace_tools` establishes bridge support only, not host
isolation or a public capability. Public command/file Item delivery, public
preparation, shared placement quotas and Files ownership remain separate work.
isolation or a public capability. The dedicated Runtime integration composes
public preparation, shared placement quotas, command Items and Files ownership.
`TestLiveClaudeWorkspaceFactory` is explicit real-provider acceptance inside a
qualified placement, including effects, cancellation and same-history continuation.

Expand Down Expand Up @@ -2051,7 +2093,8 @@ are returned only from distinguishable filesystem outcomes; unknown results stop
owner. Each operation closes its directory and intermediate descriptors before a
successful receipt; the root descriptor remains owned until bridge release.
Caller cancellation, Start transfer and owner shutdown retain the existing workspace
read settlement rules. This adapter gap fill does not enable public Claude Files.
read settlement rules. This adapter gap fill alone does not enable public Claude Files; the dedicated
Runtime integration supplies public placement and ownership.

With `ObserveToolObservations`, private workspace execution requires the packaged
`workspace_command_observations` feature and emits the existing neutral command
Expand Down Expand Up @@ -2165,8 +2208,11 @@ schemas. Function-result batches normalize through the existing shared parser an
reject non-text content before any batch write, preserving pending calls and retry
identity. These are implementation limits, not changes to the upstream contract.
Do not bypass them by dropping fields, changing model identity or fabricating usage.
The server owns no provider credential: operators configure the daemon's native SDK
provider environment. Product `claude_code` and product execution are unchanged.
Operators may configure the daemon provider environment or the existing transient
`AGENTS_API_EXECUTION_OPTIONS_FILE` with adapter-owned `claude_provider`
(`base_url` HTTPS and `bearer_token`). Core forwards these opaque options without
persisting them in Session configuration. The adapter exclusively selects the
provider environment and removes credentials from native tool environments. Product `claude_code` and product execution are unchanged.
The public profile accepts only
text, explicit model/system instructions, managed state, exact native resume and
declared functions with ordered text results, and the HTTP MCP subset
Expand Down
38 changes: 38 additions & 0 deletions apps/parsar-daemon/internal/agent/claudesdk/local.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package claudesdk

import (
"fmt"
"os"
"path/filepath"
"strings"
)

// ConfigureLocal selects the qualified, dedicated Runtime layout. The shared
// localworkspace binding still authorizes every request against its Session.
func ConfigureLocal(config Config, root, workspace, network, staging string) (Config, error) {
config.StateDir = filepath.Join(root, "runtime", "claude-sdk", "history")
config.Workspace = &WorkspaceConfig{
Directory: workspace, PublicDirectory: "/workspace", NetworkAccess: network,
HomeDir: filepath.Join(root, "runtime", "claude-sdk", "home"),
ScratchDir: filepath.Join(root, "runtime", "claude-sdk", "scratch"),
ProtectedDirs: []string{filepath.Join(root, "parsar-daemon"), staging},
DependencyPath: "/usr/local/bin:/usr/bin:/bin",
}
if network != "enabled" && network != "disabled" {
return Config{}, fmt.Errorf("claudesdk: dedicated Runtime requires an explicit network policy")
}
for _, dir := range []string{config.StateDir, config.Workspace.HomeDir, config.Workspace.ScratchDir} {
if err := os.MkdirAll(dir, 0700); err != nil {
return Config{}, err
}
}
config.Env = nil
for _, entry := range os.Environ() {
name, _, _ := strings.Cut(entry, "=")
if workspaceEnvName(name) {
config.Env = append(config.Env, entry)
}
}
_, _, err := workspaceEnvironment(config)
return config, err
}
66 changes: 66 additions & 0 deletions apps/parsar-daemon/internal/agent/claudesdk/local_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package claudesdk

import (
"encoding/json"
"os"
"path/filepath"
"slices"
"strings"
"testing"

"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto"
)

func TestLocalWorkspaceBindingNetworkAndRequiredHistory(t *testing.T) {
config := workspaceFixture(t)
config.Workspace.PublicDirectory = config.Workspace.Directory
config.Workspace.NetworkAccess = "enabled"
req := workspaceRequest()
req.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "enabled"}
req.RequireExistingNativeSession = true
start, _, err := prepare(config, req)
if err != nil || !start.RequireHistory || start.Workspace.NetworkAccess != "enabled" {
t.Fatal(start, err)
}
req.LocalEnvironment.NetworkAccess = "disabled"
if _, _, err := prepare(config, req); err == nil {
t.Fatal("accepted different Runtime network policy")
}
req.LocalEnvironment.NetworkAccess = "enabled"
config.Workspace.PublicDirectory = config.Workspace.HomeDir
if _, _, err := prepare(config, req); err == nil {
t.Fatal("accepted a different public workspace")
}
alias := filepath.Join(filepath.Dir(config.Workspace.Directory), "alias")
if err := os.Symlink(config.Workspace.Directory, alias); err != nil {
t.Fatal(err)
}
config.Workspace.PublicDirectory = alias
if _, _, err := prepare(config, req); err == nil {
t.Fatal("accepted mutable workspace alias")
}
}

func TestWorkspaceProviderCredentialsReplaceAmbientSelection(t *testing.T) {
config := workspaceFixture(t)
original := slices.Clone(config.Env)
req := workspaceRequest()
req.AgentOptions["claude_provider"] = map[string]any{"base_url": "https://provider.example/anthropic", "bearer_token": "selected-secret"}
start, env, err := prepare(config, req)
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(start)
if strings.Contains(string(raw), "selected-secret") || !slices.Equal(original, config.Env) {
t.Fatal("provider leaked or mutated shared configuration")
}
if !slices.Contains(env, "ANTHROPIC_AUTH_TOKEN=selected-secret") || slices.Contains(env, "ANTHROPIC_AUTH_TOKEN=selected-provider-fixture") {
t.Fatal("provider selection was not exclusive")
}
for _, value := range []any{nil, "secret", map[string]any{"base_url": "http://provider.example", "bearer_token": "secret"}, map[string]any{"base_url": "https://user:pass@provider.example", "bearer_token": "secret"}} {
req.AgentOptions["claude_provider"] = value
if _, _, err := prepare(config, req); err == nil || strings.Contains(err.Error(), "secret") {
t.Fatal("unsafe provider accepted or disclosed")
}
}
}
20 changes: 17 additions & 3 deletions apps/parsar-daemon/internal/agent/claudesdk/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type startRequest struct {
Functions []proto.FunctionTool `json:"functions,omitempty"`
MCPHTTPServers *[]mcpHTTPServer `json:"mcp_http_servers,omitempty"`
Workspace *workspaceProfile `json:"workspace,omitempty"`
RequireHistory bool `json:"require_history,omitempty"`
observeFunctions bool
}

Expand All @@ -45,7 +46,7 @@ func prepare(config Config, req proto.PromptRequestPayload) (startRequest, []str
}

func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startRequest, []string, error) {
start := startRequest{Type: "start", Resume: req.AgentSessionID, ObserveMessages: req.ObserveMessages, Functions: req.FunctionTools, observeFunctions: req.ObserveToolObservations}
start := startRequest{Type: "start", Resume: req.AgentSessionID, RequireHistory: req.RequireExistingNativeSession, ObserveMessages: req.ObserveMessages, Functions: req.FunctionTools, observeFunctions: req.ObserveToolObservations}
fail := func(reason string) (startRequest, []string, error) {
return startRequest{}, nil, fmt.Errorf("claudesdk: %s", reason)
}
Expand All @@ -65,7 +66,16 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR
if err := validateFunctions(req.FunctionTools); err != nil {
return startRequest{}, nil, err
}
var provider []string
for name, raw := range req.AgentOptions {
if name == "claude_provider" {
var err error
provider, err = providerEnvironment(raw)
if err != nil {
return startRequest{}, nil, err
}
continue
}
if name == "system_prompt" && raw == nil {
continue
}
Expand All @@ -88,15 +98,19 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR
if !filepath.IsAbs(config.Entrypoint) {
return fail("SDK entrypoint must be absolute")
}
config.Env = withProvider(config.Env, provider)
if config.Workspace != nil {
profile, env, err := prepareWorkspace(config, req)
if err != nil {
return startRequest{}, nil, err
}
start.Workspace = profile
start.Cwd = config.Workspace.Directory
start.Cwd = workspaceCwd(config.Workspace)
return start, env, nil
}
if req.LocalEnvironment != nil || req.RequireExistingNativeSession {
return fail("local execution and history recovery require a dedicated workspace")
}
root, err := paths.Root()
if err != nil {
return startRequest{}, nil, err
Expand Down Expand Up @@ -124,7 +138,7 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR
return startRequest{}, nil, err
}
}
env := append(append([]string{}, os.Environ()...), config.Env...)
env := withProvider(append(append([]string{}, os.Environ()...), config.Env...), provider)
env = append(env, "CLAUDE_CONFIG_DIR="+config.StateDir, "TMPDIR="+filepath.Join(config.StateDir, "tmp"), "DISABLE_TELEMETRY=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1")
var mcpEnv []string
start.MCPHTTPServers, mcpEnv = prepareMCPHTTP(req.MCPHTTPServers)
Expand Down
44 changes: 44 additions & 0 deletions apps/parsar-daemon/internal/agent/claudesdk/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package claudesdk

import (
"fmt"
"net/url"
"strings"
)

// Provider options are transient operator input, never public Agent fields or
// native tool environment. The workspace sandbox removes these variables.
func providerEnvironment(value any) ([]string, error) {
fail := func() ([]string, error) { return nil, fmt.Errorf("claudesdk: invalid provider configuration") }
options, ok := value.(map[string]any)
if !ok || len(options) != 2 {
return fail()
}
base, ok := options["base_url"].(string)
if !ok {
return fail()
}
token, ok := options["bearer_token"].(string)
if !ok || strings.TrimSpace(token) == "" || strings.ContainsAny(token, "\x00\r\n") {
return fail()
}
u, err := url.Parse(base)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return fail()
}
return []string{"ANTHROPIC_BASE_URL=" + base, "ANTHROPIC_AUTH_TOKEN=" + token}, nil
}

func withProvider(env, provider []string) []string {
if provider == nil {
return env
}
out := make([]string, 0, len(env)+len(provider))
for _, entry := range env {
key, _, _ := strings.Cut(entry, "=")
if key != "ANTHROPIC_API_KEY" && key != "ANTHROPIC_AUTH_TOKEN" && key != "ANTHROPIC_BASE_URL" {
out = append(out, entry)
}
}
return append(out, provider...)
}
4 changes: 4 additions & 0 deletions apps/parsar-daemon/internal/agent/claudesdk/readiness.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ func (info RuntimeInfo) supportsWorkspaceCommands() bool {
return info.supportsWorkspacePreparation() && slices.Contains(info.Features, "workspace_command_observations")
}

func (info RuntimeInfo) SupportsLocalRuntime() bool {
return info.supportsWorkspaceCommands() && slices.Contains(info.Features, "local_runtime_v1")
}

// CheckRuntime checks the packaged companion and exact execution entrypoint.
// It does not create Session state, register an engine or make a model request.
func CheckRuntime(ctx context.Context, config Config) (RuntimeInfo, error) {
Expand Down
Loading
Loading