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
6 changes: 3 additions & 3 deletions .github/workflows/sandbox-image-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# - parsar-sandbox local docker-backed sandbox provider
# (AGENT_DAEMON_SANDBOX_BACKEND=docker),
# multi-arch (amd64 + arm64)
# - parsar-daemon-claudecode e2b.app SaaS template, amd64 only
# - parsar-sandbox-e2b e2b.app SaaS template, amd64 only
#
# Both compile parsar-daemon + the parsar CLI from source in the shared
# Dockerfile's builder stage — neither depends on a published
Expand All @@ -20,7 +20,7 @@
#
# Output:
# ghcr.io/<owner-lowercase>/parsar-sandbox:<tag>
# ghcr.io/<owner-lowercase>/parsar-daemon-claudecode:<tag>
# ghcr.io/<owner-lowercase>/parsar-sandbox-e2b:<tag>
# - latest on default-branch pushes
# - <short-sha> on every build
# - <release-tag> on tag pushes (e.g. sandbox-v0.1.0)
Expand Down Expand Up @@ -74,7 +74,7 @@ jobs:
platforms_pr: linux/amd64
platforms_publish: linux/amd64,linux/arm64
- variant: e2b
image_name: parsar-daemon-claudecode
image_name: parsar-sandbox-e2b
base_image: e2bdev/base:latest
# e2b.app sandboxes are amd64 only — no point publishing arm64.
platforms_pr: linux/amd64
Expand Down
8 changes: 4 additions & 4 deletions INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@ user is the administrator.

- Docker Engine with Docker Compose v2.
- Linux host with access to `/var/run/docker.sock`. The local compose stack
enables Docker-managed agent sandboxes and mounts the Docker socket.
- To actually run Agents in sandbox mode, build the sandbox image first —
there is no published default:
enables Docker-managed agent sandboxes and mounts the Docker socket. The
sandbox image (`ghcr.io/minimax-ai-dev/parsar-sandbox:latest`) is pulled
automatically — build your own instead with:
```bash
docker build -f infra/sandbox/Dockerfile -t parsar-sandbox:local .
PARSAR_SANDBOX_IMAGE=parsar-sandbox:local ./install.sh
```
Everything else in the stack works without it.

## What The Installer Does

Expand Down
11 changes: 6 additions & 5 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,16 +156,17 @@ services:
# Docker sandbox — Agent runs inside a sandboxed container with
# Claude Code + Codex + Pi + parsar-daemon. The server calls docker
# to start / exec / stop sandbox containers via the mounted
# /var/run/docker.sock below. There is no published image for this —
# build one locally first (README's 5.2 section):
# /var/run/docker.sock below. Defaults to the GHCR-published image
# (infra/sandbox/Dockerfile, built + pushed by
# .github/workflows/sandbox-image-release.yml). Build your own
# instead with:
# docker build -f infra/sandbox/Dockerfile -t parsar-sandbox:local .
# Until you do, sandbox-mode Agent runs fail with "no such image";
# everything else in this stack works fine without it.
# PARSAR_SANDBOX_IMAGE=parsar-sandbox:local
# The network below is pinned to `parsar_default` — the top-level
# `name: parsar` + `networks:` block at the head of this file
# guarantees that alias regardless of the clone directory.
AGENT_DAEMON_SANDBOX_BACKEND: "docker"
AGENT_DAEMON_SANDBOX_DOCKER_IMAGE: "${PARSAR_SANDBOX_IMAGE:-parsar-sandbox:local}"
AGENT_DAEMON_SANDBOX_DOCKER_IMAGE: "${PARSAR_SANDBOX_IMAGE:-ghcr.io/minimax-ai-dev/parsar-sandbox:latest}"
AGENT_DAEMON_SANDBOX_DOCKER_NETWORK: "${PARSAR_PROJECT_NAME:-parsar}_default"
AGENT_DAEMON_SANDBOX_SERVER_URL: "http://parsar-server:8080"
group_add:
Expand Down
4 changes: 2 additions & 2 deletions infra/sandbox/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
# shell. Multi-arch: add --platform linux/arm64 to cross-build.
#
# docker build -f infra/sandbox/Dockerfile \
# --build-arg BASE_IMAGE=e2bdev/base:latest -t parsar-daemon-claudecode .
# --build-arg BASE_IMAGE=e2bdev/base:latest -t parsar-sandbox-e2b .
# e2b.app SaaS template. amd64 only. After build, the SandboxProvider
# only needs to: Sandbox.Create("parsar-daemon-claudecode", {timeout})
# only needs to: Sandbox.Create("parsar-sandbox-e2b", {timeout})
# → RunCommand: parsar-daemon connect --device-name <deviceId> -b
# (URL/token via env) → wait for the gateway to see deviceId dial in.
#
Expand Down
9 changes: 5 additions & 4 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ set -euo pipefail

DEFAULT_COMPOSE_URL="https://raw.githubusercontent.com/MiniMax-AI-Dev/parsar/main/docker-compose.yml"
DEFAULT_SERVER_IMAGE="ghcr.io/minimax-ai-dev/parsar-server:latest"
DEFAULT_SANDBOX_IMAGE="parsar-sandbox:local"
DEFAULT_SANDBOX_IMAGE="ghcr.io/minimax-ai-dev/parsar-sandbox:latest"

usage() {
cat <<'EOF'
Expand All @@ -20,10 +20,11 @@ Options:
when present, otherwise download the published template.
--image IMAGE parsar-server image.
Default: ghcr.io/minimax-ai-dev/parsar-server:latest
--sandbox-image IMAGE Docker sandbox image. There is no published
default — build one first:
--sandbox-image IMAGE Docker sandbox image.
Default: ghcr.io/minimax-ai-dev/parsar-sandbox:latest
To use your own build instead:
docker build -f infra/sandbox/Dockerfile -t parsar-sandbox:local .
Default: parsar-sandbox:local
--sandbox-image parsar-sandbox:local
--port PORT Web UI host port. Default: 18080
--pg-port PORT Postgres host port. Default: 15432
--bind ADDR Web UI bind address. Default: 127.0.0.1
Expand Down
54 changes: 27 additions & 27 deletions server/internal/connector/agentdaemon/sandbox_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
// first prompt -> Connector hits ErrNotBound -> sees daemon_mode=sandbox
// -> SandboxProvider.Acquire(ctx, in):
// 1. CreateRuntimePairing(type=agent_daemon) -> token + runtimeID(=deviceID)
// 2. e2b.Create("parsar-daemon-claudecode") -> sandbox handle
// 2. e2b.Create("parsar-sandbox-e2b") -> sandbox handle
// 3. RunCommand(parsar-daemon connect -b + env token)
// -> daemon pairs, then dials WS in background
// 4. Registry.WaitForDevice(deviceID, 45s) -> blocks until WS upgrade lands
Expand Down Expand Up @@ -197,7 +197,7 @@ type RuntimeMinter interface {
// - Store: runtime pairing minter.
// - Registry: the gateway registry to WaitForDevice against.
// - Binder: persisted conversation->device bindings.
// - Template: the e2b template id (e.g. "parsar-daemon-claudecode"). The
// - Template: the e2b template id (e.g. "parsar-sandbox-e2b"). The
// deployment must publish this template before sandbox mode works.
// - ServerURL: the public URL the daemon inside the sandbox dials
// back to. Must be reachable from inside the sandbox network.
Expand All @@ -215,12 +215,12 @@ type DeviceOwnerChecker interface {
}

type E2BProviderConfig struct {
Client E2BClient
Store RuntimeMinter
Registry *gateway.Registry
Binder binding.Binder
Bindings SandboxBindingPersister // nil = memory-only (local dev)
Template string
Client E2BClient
Store RuntimeMinter
Registry *gateway.Registry
Binder binding.Binder
Bindings SandboxBindingPersister // nil = memory-only (local dev)
Template string
// Templates maps a sandbox_size label (e.g. "standard", "xl") to the
// e2b template id for that size. The agent's agents.config
// `sandbox_size` field selects which template gets used on cold start.
Expand Down Expand Up @@ -300,7 +300,7 @@ func NewE2BSandboxProvider(cfg E2BProviderConfig) (*E2BSandboxProvider, error) {
return nil, errors.New("E2BSandboxProvider: Binder required")
}
if cfg.Template == "" {
return nil, errors.New("E2BSandboxProvider: Template required (e.g. parsar-daemon-claudecode)")
return nil, errors.New("E2BSandboxProvider: Template required (e.g. parsar-sandbox-e2b)")
}
if cfg.ServerURL == "" {
return nil, errors.New("E2BSandboxProvider: ServerURL required (the URL the daemon inside the sandbox should dial back to)")
Expand Down Expand Up @@ -509,10 +509,10 @@ func (p *E2BSandboxProvider) acquireCrossPod(ctx context.Context, in connector.P
cacheKey := "agent_daemon:" + in.AgentID
_, templateID := p.resolveTemplate(in)
row, won, err := p.cfg.Bindings.ReserveSandboxBindingSlot(ctx, store.ReserveSandboxBindingSlotInput{
WorkspaceID: in.WorkspaceID,
AgentID: in.AgentID,
CacheKey: cacheKey,
TemplateID: templateID,
WorkspaceID: in.WorkspaceID,
AgentID: in.AgentID,
CacheKey: cacheKey,
TemplateID: templateID,
Metadata: map[string]any{
"sandbox_kind": "agent_daemon",
"connector": string(p.cfg.Connector),
Expand Down Expand Up @@ -610,9 +610,9 @@ func (p *E2BSandboxProvider) coldStart(ctx context.Context, in connector.PromptI
// owned by the agent, not a human user.
TokenTTL: SandboxAcquireTimeout + 30*time.Second,
Config: map[string]any{
"created_by": "sandbox_provider",
"agent_id": in.AgentID,
"sandbox_kind": "agent_daemon_claude_code",
"created_by": "sandbox_provider",
"agent_id": in.AgentID,
"sandbox_kind": "agent_daemon_claude_code",
},
})
if err != nil {
Expand All @@ -626,10 +626,10 @@ func (p *E2BSandboxProvider) coldStart(ctx context.Context, in connector.PromptI
TemplateID: templateID,
TimeoutSeconds: int(SandboxDefaultTTL.Seconds()),
Metadata: map[string]string{
"parsar.workspace_id": in.WorkspaceID,
"parsar.agent_id": in.AgentID,
"parsar.device_id": deviceID,
"parsar.sandbox_kind": "agent_daemon_claude_code",
"parsar.workspace_id": in.WorkspaceID,
"parsar.agent_id": in.AgentID,
"parsar.device_id": deviceID,
"parsar.sandbox_kind": "agent_daemon_claude_code",
},
})
if err != nil {
Expand Down Expand Up @@ -839,12 +839,12 @@ func (p *E2BSandboxProvider) coldStart(ctx context.Context, in connector.PromptI
}
} else {
binding, bindErr := p.cfg.Bindings.CreateSandboxBinding(bootCtx, store.CreateSandboxBindingInput{
WorkspaceID: in.WorkspaceID,
AgentID: in.AgentID,
CacheKey: "agent_daemon:" + in.AgentID,
SandboxID: sandbox.SandboxID,
TemplateID: templateID,
Status: store.SandboxBindingStatusActive,
WorkspaceID: in.WorkspaceID,
AgentID: in.AgentID,
CacheKey: "agent_daemon:" + in.AgentID,
SandboxID: sandbox.SandboxID,
TemplateID: templateID,
Status: store.SandboxBindingStatusActive,
Metadata: map[string]any{
"sandbox_kind": "agent_daemon",
"device_id": deviceID,
Expand Down Expand Up @@ -1025,7 +1025,7 @@ func (p *E2BSandboxProvider) Reap(ctx context.Context) (int, error) {
cutoff := time.Now().UTC().Add(-SandboxIdleReapThreshold)
type victim struct {
agentID string
entry *sandboxEntry
entry *sandboxEntry
}
var victims []victim
p.cacheMu.Lock()
Expand Down
32 changes: 16 additions & 16 deletions server/internal/connector/agentdaemon/sandbox_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ func (f *fakeE2BClient) Create(_ context.Context, _ e2b.CreateInput) (e2b.Sandbo
if err != nil {
return e2b.Sandbox{}, err
}
sb := e2b.Sandbox{SandboxID: pad("sbx-", id), TemplateID: "parsar-daemon-claudecode"}
sb := e2b.Sandbox{SandboxID: pad("sbx-", id), TemplateID: "parsar-sandbox-e2b"}
if f.onCreate != nil {
f.onCreate(sb)
}
Expand Down Expand Up @@ -221,7 +221,7 @@ func TestNewE2BSandboxProvider_RequiredFields(t *testing.T) {
Store: &fakeMinter{},
Registry: gateway.NewRegistry(),
Binder: binding.NewInMemoryBinder(),
Template: "parsar-daemon-claudecode",
Template: "parsar-sandbox-e2b",
ServerURL: "https://parsar.example.com",
}
good, err := NewE2BSandboxProvider(base)
Expand Down Expand Up @@ -279,7 +279,7 @@ func TestE2BSandboxProvider_AcquireColdStart(t *testing.T) {
Store: minter,
Registry: reg,
Binder: binding.NewInMemoryBinder(),
Template: "parsar-daemon-claudecode",
Template: "parsar-sandbox-e2b",
ServerURL: "https://parsar.example.com",
})
if err != nil {
Expand Down Expand Up @@ -348,7 +348,7 @@ func TestE2BSandboxProvider_AcquireWarmCacheHit(t *testing.T) {
Store: &fakeMinter{},
Registry: reg,
Binder: binding.NewInMemoryBinder(),
Template: "parsar-daemon-claudecode",
Template: "parsar-sandbox-e2b",
ServerURL: "https://parsar.example.com",
})
// Pre-seed cache as if a prior cold start had populated it.
Expand Down Expand Up @@ -393,7 +393,7 @@ func TestE2BSandboxProvider_AcquireRecoversFromDeadDevice(t *testing.T) {
p, _ := NewE2BSandboxProvider(E2BProviderConfig{
Client: e2bClient, Store: minter, Registry: reg,
Binder: binding.NewInMemoryBinder(),
Template: "parsar-daemon-claudecode", ServerURL: "https://parsar.example.com",
Template: "parsar-sandbox-e2b", ServerURL: "https://parsar.example.com",
})
// Pre-seed with a stale entry whose device is not in the registry.
p.cache["pa-1"] = &sandboxEntry{
Expand Down Expand Up @@ -437,7 +437,7 @@ func TestE2BSandboxProvider_AcquireSerialisesConcurrent(t *testing.T) {
p, _ := NewE2BSandboxProvider(E2BProviderConfig{
Client: e2bClient, Store: minter, Registry: reg,
Binder: binding.NewInMemoryBinder(),
Template: "parsar-daemon-claudecode", ServerURL: "https://parsar.example.com",
Template: "parsar-sandbox-e2b", ServerURL: "https://parsar.example.com",
})

var wg sync.WaitGroup
Expand Down Expand Up @@ -478,7 +478,7 @@ func TestE2BSandboxProvider_AcquireConnectFailureKillsSandbox(t *testing.T) {
p, _ := NewE2BSandboxProvider(E2BProviderConfig{
Client: e2bClient, Store: minter, Registry: reg,
Binder: binding.NewInMemoryBinder(),
Template: "parsar-daemon-claudecode", ServerURL: "https://parsar.example.com",
Template: "parsar-sandbox-e2b", ServerURL: "https://parsar.example.com",
})
_, err := p.Acquire(context.Background(), connector.PromptInput{
AgentID: "pa-1", WorkspaceID: "wks-1",
Expand Down Expand Up @@ -516,7 +516,7 @@ func TestE2BSandboxProvider_AcquireWaitForDeviceTimeout(t *testing.T) {
p, _ := NewE2BSandboxProvider(E2BProviderConfig{
Client: e2bClient, Store: minter, Registry: reg,
Binder: binding.NewInMemoryBinder(),
Template: "parsar-daemon-claudecode", ServerURL: "https://parsar.example.com",
Template: "parsar-sandbox-e2b", ServerURL: "https://parsar.example.com",
})
_, err := p.Acquire(context.Background(), connector.PromptInput{
AgentID: "pa-1", WorkspaceID: "wks-1",
Expand Down Expand Up @@ -544,7 +544,7 @@ func TestE2BSandboxProvider_Release(t *testing.T) {
})
p, _ := NewE2BSandboxProvider(E2BProviderConfig{
Client: e2bClient, Store: &fakeMinter{}, Registry: reg, Binder: binder,
Template: "parsar-daemon-claudecode", ServerURL: "https://parsar.example.com",
Template: "parsar-sandbox-e2b", ServerURL: "https://parsar.example.com",
})
p.cache["pa-1"] = &sandboxEntry{
deviceID: "dev-runtime-01",
Expand Down Expand Up @@ -574,7 +574,7 @@ func TestE2BSandboxProvider_Reap(t *testing.T) {
p, _ := NewE2BSandboxProvider(E2BProviderConfig{
Client: e2bClient, Store: &fakeMinter{}, Registry: reg,
Binder: binding.NewInMemoryBinder(),
Template: "parsar-daemon-claudecode", ServerURL: "https://parsar.example.com",
Template: "parsar-sandbox-e2b", ServerURL: "https://parsar.example.com",
})
prev := SandboxIdleReapThreshold
SandboxIdleReapThreshold = 1 * time.Minute
Expand Down Expand Up @@ -648,7 +648,7 @@ func TestE2BSandboxProvider_SeedFailureKillsSandbox(t *testing.T) {
p, _ := NewE2BSandboxProvider(E2BProviderConfig{
Client: e2bClient, Store: minter, Registry: reg,
Binder: binding.NewInMemoryBinder(),
Template: "parsar-daemon-claudecode", ServerURL: "https://parsar.example.com",
Template: "parsar-sandbox-e2b", ServerURL: "https://parsar.example.com",
})
_, err := p.Acquire(context.Background(), connector.PromptInput{
AgentID: "pa-1", WorkspaceID: "wks-1",
Expand Down Expand Up @@ -699,7 +699,7 @@ func TestE2BSandboxProvider_ColdStartPropagatesTGEnv(t *testing.T) {
p, _ := NewE2BSandboxProvider(E2BProviderConfig{
Client: e2bClient, Store: minter, Registry: reg,
Binder: binding.NewInMemoryBinder(),
Template: "parsar-daemon-claudecode",
Template: "parsar-sandbox-e2b",
ServerURL: "https://parsar.example.com",
// Leave Connector zero-value: documented to default to claude
// because the only published template is Claude-based. Test
Expand Down Expand Up @@ -768,7 +768,7 @@ func TestE2BSandboxProvider_ColdStartOmitsEmptyTGEnv(t *testing.T) {
p, _ := NewE2BSandboxProvider(E2BProviderConfig{
Client: e2bClient, Store: minter, Registry: reg,
Binder: binding.NewInMemoryBinder(),
Template: "parsar-daemon-claudecode",
Template: "parsar-sandbox-e2b",
ServerURL: "https://parsar.example.com",
Connector: SandboxConnectorClaude, // explicit, not zero-value
})
Expand Down Expand Up @@ -908,7 +908,7 @@ func TestE2BSandboxProvider_AcquireWinnerFinalizesReservation(t *testing.T) {
Registry: reg,
Binder: binding.NewInMemoryBinder(),
Bindings: bindings,
Template: "parsar-daemon-claudecode",
Template: "parsar-sandbox-e2b",
ServerURL: "https://parsar.example.com",
})
if err != nil {
Expand Down Expand Up @@ -982,7 +982,7 @@ func TestE2BSandboxProvider_AcquireLoserReusesWinnerDevice(t *testing.T) {
Registry: reg,
Binder: binding.NewInMemoryBinder(),
Bindings: bindings,
Template: "parsar-daemon-claudecode",
Template: "parsar-sandbox-e2b",
ServerURL: "https://parsar.example.com",
})
if err != nil {
Expand Down Expand Up @@ -1038,7 +1038,7 @@ func TestE2BSandboxProvider_AcquireWinnerFailureReleasesReservation(t *testing.T
Registry: reg,
Binder: binding.NewInMemoryBinder(),
Bindings: bindings,
Template: "parsar-daemon-claudecode",
Template: "parsar-sandbox-e2b",
ServerURL: "https://parsar.example.com",
})
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion server/internal/connector/agentdaemon/sandbox_seed.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ func seedPlatformConfig(ctx context.Context, client E2BClient, sb e2b.Sandbox, c
switch conn {
case SandboxConnectorClaude, "":
// Empty defaults to Claude because the only template currently
// boots Claude (parsar-daemon-claudecode).
// boots Claude (parsar-sandbox-e2b).
return seedClaudeConfig(ctx, client, sb, envdURL)
case SandboxConnectorOpenCode:
// TODO: wire spec/memory injection via OpenCode hook scripts
Expand Down
Loading