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
49 changes: 49 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,55 @@ PARSAR_AGENT_DAEMON_WS_URL=ws://parsar-server:8080/agent-daemon/ws
# Bot's own open_id (ou_xxx). Required for group chat @Bot to work.
PARSAR_FEISHU_DEFAULT_BOT_OPEN_ID=

# -----------------------------------------------------------------------------
# Cloud sandbox — e2b.app (optional; powers the "Cloud isolation" agent mode)
# -----------------------------------------------------------------------------
# Both values are required to enable cloud isolation. With either empty the
# server logs a warning at boot and cloud-isolation agents fail fast, while
# local-device agents keep working.
#
# Build the template first (writes only gitignored build artifacts):
# make e2b-template
# then paste the template id it reports here. Rebuilding the same template
# name keeps the id, so this only changes on first creation.
AGENT_DAEMON_SANDBOX_TEMPLATE=
PARSAR_E2B_API_KEY=

# Optional larger tier, selected per agent via agents.config.sandbox_size="xl".
# An agent asking for a size with no template configured degrades to the
# standard one with a warning rather than failing the acquire.
AGENT_DAEMON_SANDBOX_TEMPLATE_XL=

# Optional deployment-wide sandbox lifetime. Uses Go duration syntax (for
# example 30m, 1h, or 24h) and defaults to 1h. An agent may override this with
# agents.config.sandbox_ttl. Parsar does not impose a provider-specific maximum;
# configure a value supported by your sandbox provider and account.
AGENT_DAEMON_SANDBOX_TTL=

# Deployment-wide default for periodic best-effort renewal. An agent may
# override this with agents.config.sandbox_auto_renew. Renewal requires a TTL
# longer than the five-minute scan interval. Enabled by default so a
# continuously-used agent's sandbox is not reaped mid-conversation; set to
# false to opt out (e.g. for a provider that does not support renewal).
AGENT_DAEMON_SANDBOX_AUTO_RENEW=true

# Deprecated compatibility setting. Used only when the duration above is
# empty; prefer AGENT_DAEMON_SANDBOX_TTL for new deployments.
AGENT_DAEMON_SANDBOX_TTL_HOURS=

# Self-hosted / proxied e2b only. Leave empty for e2b.app.
PARSAR_E2B_API_BASE_URL=
PARSAR_E2B_SANDBOX_HOST=
# PEM of a private CA, when the sandbox gateway serves a non-public cert.
PARSAR_E2B_CA_CERT=

# IMPORTANT for local testing: the daemon runs INSIDE the cloud sandbox and
# dials back to PARSAR_PUBLIC_URL, so a loopback URL can never work — the
# sandbox resolves 127.0.0.1 to itself and pairing times out. Expose the dev
# server through a tunnel and set PARSAR_PUBLIC_URL to that hostname, e.g.
# ngrok http 18080
# PARSAR_PUBLIC_URL=https://<subdomain>.ngrok-free.dev

# -----------------------------------------------------------------------------
# Network proxy (optional — only if your host needs a proxy for internet)
# -----------------------------------------------------------------------------
Expand Down
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ go.work.sum

# Reviewer / verifier session artifacts (workspace-only, never commit)
reviewer-report.md
semantic-review/
/.fork-point

# Claude Code IDE state (per-developer scratch: plans, worktree pointers,
Expand All @@ -72,3 +73,12 @@ reviewer-report.md
!/parsar-bootstrap/
/httprunner
!/httprunner/
# `cd server && go build ./cmd/server` drops its output here.
/server/server
!/server/server/

# Cross-compiled binaries staged for the E2B template build
# (`make e2b-template`). E2B has no multi-stage build support, so
# parsar-daemon / parsar are compiled on the host and COPY'd in by
# infra/sandbox/e2b.Dockerfile. Build artefacts, never committed.
infra/sandbox/.build/
41 changes: 41 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,47 @@ description and keep ownership on the side listed here.
- Eager acquisition must be best-effort. Failure to prewarm a sandbox should
surface as runtime health/provisioning state, not crash unrelated startup
paths.
- Cloud sandbox maintenance runs once at server startup and every five minutes.
Automatic renewal requires a TTL longer than that interval; interrupted
renewals remain retryable, while a provider rejection disables the policy.
- A `spawning` sandbox binding holds that agent's only reservation slot
(`uk_sandboxes_active_per_agent` is partial on `killed_at is null`), and the
loser path waits on `spawning` indefinitely. Any code that reserves a slot
must therefore guarantee a terminal transition, and an acquire that finds a
reservation older than the cold-start bound must be able to reclaim it —
otherwise one crashed cold start wedges the agent permanently.

### Sandbox images

- `infra/sandbox/Dockerfile` (local Docker + generic) and
`infra/sandbox/e2b.Dockerfile` (e2b.app) must keep their shared runtime
payload, CLI versions, and hooks aligned; provider-specific bootstrap and
build mechanics may differ.
Agent CLI installs live only in `infra/sandbox/scripts/install-agents.sh`,
which both images run; do not inline per-CLI `npm install -g` / download
steps in either Dockerfile. That script owns the version pins and the Node
force-relink that keeps a base image's bundled Node from shadowing ours.
- The image must ship the hook scripts at the absolute paths
`server/internal/connector/agentdaemon/sandbox_seed.go` seeds into
`settings.json` (`/opt/parsar/hooks/claude/...`). The hooks fail open, so a
missing script degrades spec/memory injection silently instead of erroring —
changing one side means changing the other.
- e2b's template builder is not BuildKit. It rejects multi-stage builds (hence
the prebuilt binaries in `infra/sandbox/.build/`, staged by
`make e2b-template`), it does not persist `/tmp` between layers, and it
lowers `ARG FOO="bar"` keeping the quotes as literal characters — so version
ARGs in `e2b.Dockerfile` must stay unquoted.
- Build templates with `make e2b-template`. It writes only into
`infra/sandbox/.build/` (gitignored), never the repo root.

### Testing cloud isolation locally

- The daemon runs inside the cloud sandbox and dials back to
`PARSAR_PUBLIC_URL`, so a loopback URL cannot work: the sandbox resolves
`127.0.0.1` to itself and pairing times out. Expose the dev server through a
tunnel and set `PARSAR_PUBLIC_URL` to that hostname.
- `AGENT_DAEMON_SANDBOX_TEMPLATE` + `PARSAR_E2B_API_KEY` are the two required
values; see `.env.example` for the full set and their defaults.

### API, DB, and generated surfaces

Expand Down
70 changes: 66 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ endif
PARSAR_IMAGE ?= parsar
PARSAR_IMAGE_TAG ?= dev

.PHONY: help setup node-deps dev dev-db check check-setup check-sqlc check-go check-store check-web check-cli check-hygiene test test-fast test-go test-web typecheck-web lint-web-design lint-web test-cli typecheck reset-dev clean-dev paths migrate-dev sqlc-generate server web cli devgateway http-runner-once http-runner-loop dev-all smoke e2e-http-agent e2e-feishu-gateway dev-server-up dev-server-down dev-server-log bootstrap docker-build docker-build-no-cache openapi
.PHONY: help setup node-deps dev dev-db check check-setup check-sqlc check-go check-store check-web check-cli check-hygiene test test-fast test-go test-web typecheck-web lint-web-design lint-web test-cli typecheck reset-dev clean-dev paths migrate-dev sqlc-generate server web cli devgateway http-runner-once http-runner-loop dev-all smoke e2e-http-agent e2e-feishu-gateway dev-server-up dev-server-down dev-server-log bootstrap docker-build docker-build-no-cache openapi e2b-template e2b-template-binaries

help:
@printf '%s\n' \
Expand All @@ -43,7 +43,11 @@ help:
' make check-go Run sqlc drift check and non-store Go tests' \
' make check-store Run migration and store integration tests' \
' make check-web Run web typecheck and design lint' \
' make check-cli Typecheck CLI/plugin packages'
' make check-cli Typecheck CLI/plugin packages' \
'' \
'Cloud sandbox (e2b):' \
' make e2b-template Cross-compile binaries and build the e2b sandbox template' \
' make e2b-template E2B_TEMPLATE_NAME=my-sandbox'

setup:
./scripts/setup.sh
Expand Down Expand Up @@ -243,12 +247,13 @@ docker-build-no-cache:
# go install github.com/swaggo/swag/cmd/swag@v1.16.4
# The recipe auto-installs on first use.
SWAG_VERSION ?= v1.16.4
SWAG ?= $(shell command -v swag 2>/dev/null || printf '%s/bin/swag' "$$(go env GOPATH)")

openapi:
@command -v swag >/dev/null 2>&1 || \
@command -v $(SWAG) >/dev/null 2>&1 || \
go install github.com/swaggo/swag/cmd/swag@$(SWAG_VERSION)
@mkdir -p docs/openapi
swag init \
$(SWAG) init \
-g server/cmd/server/main.go \
--dir . \
--output docs/openapi/gen \
Expand All @@ -260,3 +265,60 @@ openapi:
@rmdir docs/openapi/gen 2>/dev/null || true
@echo "openapi: wrote docs/openapi/openapi.yaml"
@echo "openapi: paths=$$(grep -c '^ /' docs/openapi/openapi.yaml)"

# --- E2B sandbox template ----------------------------------------------
#
# Builds the cloud-isolation sandbox template on e2b.app. Collapses the
# manual sequence (cross-compile two Go binaries, then invoke the e2b
# CLI from the right directory) into one command.
#
# Why the binaries are cross-compiled here rather than in the image:
# e2b's builder rejects multi-stage Dockerfiles, so infra/sandbox/
# e2b.Dockerfile cannot compile Go itself and instead COPYs prebuilt
# binaries out of infra/sandbox/.build/ (gitignored). linux/amd64 is
# hardcoded because e2b templates are amd64-only.
#
# The build context is infra/sandbox/ (not the repo root) so the upload
# stays small; every COPY source in e2b.Dockerfile lives under it.
#
# Requires the e2b CLI and an API key:
# npm install -g @e2b/cli
# export E2B_API_KEY=e2b_...
# PARSAR_E2B_API_KEY is accepted as a fallback so the same value already
# in .env (used by the server) works without being re-exported, and .env
# is sourced when present.
#
# After a successful build, point the server at the template:
# AGENT_DAEMON_SANDBOX_TEMPLATE=<template id>
# Rebuilding an existing template name keeps the same id, so the id only
# has to be copied into .env on first creation.
E2B_TEMPLATE_NAME ?= parsar-sandbox
E2B_TEMPLATE_MEMORY_MB ?= 4096
E2B_BUILD_DIR := infra/sandbox/.build

e2b-template: e2b-template-binaries
@set -euo pipefail; \
if [[ -f .env ]]; then set -a; source .env; set +a; fi; \
key="$${E2B_API_KEY:-$${PARSAR_E2B_API_KEY:-}}"; \
if [[ -z "$$key" ]]; then \
echo "e2b-template: E2B_API_KEY (or PARSAR_E2B_API_KEY) is required" >&2; \
echo " get one at https://e2b.dev/dashboard" >&2; \
exit 1; \
fi; \
command -v e2b >/dev/null 2>&1 || { \
echo "e2b-template: e2b CLI not found; install with 'npm install -g @e2b/cli'" >&2; \
exit 1; \
}; \
cd infra/sandbox && E2B_API_KEY="$$key" e2b template create $(E2B_TEMPLATE_NAME) \
--dockerfile e2b.Dockerfile \
--memory-mb $(E2B_TEMPLATE_MEMORY_MB)

# Cross-compile the two binaries the template image needs. Split out so
# it can be run on its own when iterating on daemon code.
e2b-template-binaries:
@mkdir -p $(E2B_BUILD_DIR)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" \
-o $(E2B_BUILD_DIR)/parsar-daemon ./apps/parsar-daemon/cmd/parsar-daemon
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" \
-o $(E2B_BUILD_DIR)/parsar ./apps/parsar/cmd/parsar
@echo "e2b-template: staged linux/amd64 binaries in $(E2B_BUILD_DIR)"
61 changes: 61 additions & 0 deletions apps/parsar-daemon/internal/agent/binpath/binpath.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Package binpath resolves which executable each agent adapter should
// probe and spawn.
//
// By default every engine is looked up by bare name on PATH ("claude",
// "codex", ...). That breaks in images where PATH is not under our
// control: e2b's base image, for instance, ships its own
// /usr/local/bin entries that can shadow the ones we install, and a
// bare-name lookup then resolves to the wrong (or no) binary. The
// symptom is the worst kind — `parsar-daemon connect` reports
// "no supported agent CLI available" and the device never dials in,
// with no indication of which lookup failed.
//
// The env overrides below let an image or operator pin an absolute path
// instead. They are read in ONE place so the version probe
// (CheckCLIAvailable) and the run-time spawn (sessionConfig) can never
// disagree: a probe that succeeds against /custom/claude while the run
// spawns PATH's `claude` would advertise a capability the daemon cannot
// actually honour.
package binpath

import (
"os"
"strings"
)

// Env var names for the per-engine executable overrides. Empty or unset
// means "look up the default name on PATH".
const (
EnvClaudeCode = "PARSAR_CLAUDE_BIN"
EnvCodex = "PARSAR_CODEX_BIN"
EnvPi = "PARSAR_PI_BIN"
EnvOpenCode = "PARSAR_OPENCODE_BIN"
)

// Default executable names, used when the matching env var is unset.
const (
DefaultClaudeCode = "claude"
DefaultCodex = "codex"
DefaultPi = "pi"
DefaultOpenCode = "opencode"
)

// resolve returns the trimmed env override when set, else fallback.
func resolve(envVar, fallback string) string {
if v := strings.TrimSpace(os.Getenv(envVar)); v != "" {
return v
}
return fallback
}

// ClaudeCode returns the claude executable to probe and spawn.
func ClaudeCode() string { return resolve(EnvClaudeCode, DefaultClaudeCode) }

// Codex returns the codex executable to probe and spawn.
func Codex() string { return resolve(EnvCodex, DefaultCodex) }

// Pi returns the pi executable to probe and spawn.
func Pi() string { return resolve(EnvPi, DefaultPi) }

// OpenCode returns the opencode executable to probe and spawn.
func OpenCode() string { return resolve(EnvOpenCode, DefaultOpenCode) }
38 changes: 38 additions & 0 deletions apps/parsar-daemon/internal/agent/binpath/binpath_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package binpath

import "testing"

func TestResolvers(t *testing.T) {
tests := []struct {
name string
envVar string
fallback string
resolve func() string
override string
wantPinned string
}{
{name: "claude", envVar: EnvClaudeCode, fallback: DefaultClaudeCode, resolve: ClaudeCode, override: " /opt/agents/claude ", wantPinned: "/opt/agents/claude"},
{name: "codex", envVar: EnvCodex, fallback: DefaultCodex, resolve: Codex, override: " /opt/agents/codex ", wantPinned: "/opt/agents/codex"},
{name: "pi", envVar: EnvPi, fallback: DefaultPi, resolve: Pi, override: " /opt/agents/pi ", wantPinned: "/opt/agents/pi"},
{name: "opencode", envVar: EnvOpenCode, fallback: DefaultOpenCode, resolve: OpenCode, override: " /opt/agents/opencode ", wantPinned: "/opt/agents/opencode"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv(tt.envVar, "")
if got := tt.resolve(); got != tt.fallback {
t.Fatalf("unset override: got %q, want %q", got, tt.fallback)
}

t.Setenv(tt.envVar, tt.override)
if got := tt.resolve(); got != tt.wantPinned {
t.Fatalf("explicit override: got %q, want %q", got, tt.wantPinned)
}

t.Setenv(tt.envVar, " \t ")
if got := tt.resolve(); got != tt.fallback {
t.Fatalf("blank override: got %q, want %q", got, tt.fallback)
}
})
}
}
8 changes: 5 additions & 3 deletions apps/parsar-daemon/internal/agent/claudecode/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"time"

"github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent"
"github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/binpath"
"github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner"
"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto"
obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log"
Expand All @@ -23,7 +24,8 @@ import (
// sessionConfig customises Factory for tests (alternative binary path,
// alternative logger, shorter SIGTERM→SIGKILL escalation).
type sessionConfig struct {
// claudeBinary defaults to "claude" so os/exec resolves via PATH.
// claudeBinary defaults to binpath.ClaudeCode(): the bare name
// "claude" for a PATH lookup, or the PARSAR_CLAUDE_BIN override.
claudeBinary string

// extraArgs are appended after BuildArgs' output. Tests use this
Expand All @@ -48,7 +50,7 @@ const defaultAskTimeout = 10 * time.Minute

func defaultConfig() sessionConfig {
return sessionConfig{
claudeBinary: "claude",
claudeBinary: binpath.ClaudeCode(),
killTimeout: 3 * time.Second,
askTimeout: defaultAskTimeout,
logger: obslog.Bg(),
Expand Down Expand Up @@ -122,7 +124,7 @@ func newSession(parent context.Context, req proto.PromptRequestPayload, out chan
cfg.logger = obslog.Bg()
}
if cfg.claudeBinary == "" {
cfg.claudeBinary = "claude"
cfg.claudeBinary = binpath.ClaudeCode()
}
if cfg.killTimeout <= 0 {
cfg.killTimeout = 3 * time.Second
Expand Down
10 changes: 6 additions & 4 deletions apps/parsar-daemon/internal/agent/claudecode/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"

"github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/binpath"
"github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/versionprobe"
)

Expand All @@ -18,13 +19,14 @@ const InstallURL = "https://docs.anthropic.com/claude/docs/claude-code"
var ErrCLINotFound = errors.New("claude CLI not found")

// CheckCLIAvailable runs `<binary> --version` and returns the trimmed
// first line. Empty binary defaults to "claude". On missing binary the
// error wraps ErrCLINotFound; on other failures the wrapped error
// keeps the raw stderr.
// first line. Empty binary defaults to binpath.ClaudeCode() — the same
// resolver the session spawn uses, so probe and spawn always agree. On
// missing binary the error wraps ErrCLINotFound; on other failures the
// wrapped error keeps the raw stderr.
func CheckCLIAvailable(ctx context.Context, binary string) (string, error) {
return versionprobe.Check(ctx, binary, versionprobe.Config{
Name: "claude",
DefaultBinary: "claude",
DefaultBinary: binpath.ClaudeCode(),
MissingError: ErrCLINotFound,
})
}
Loading
Loading