Files
synapbus/internal/harness/docker/docker_test.go
T
Algis DumbrisandClaude Opus 4.6 560d9d4125 feat(harness): docker isolation backend + canonical synapbus-agent image
New `internal/harness/docker` package: per-run ephemeral container
backend that runs each agent in `docker run --rm`, bind-mounts the
materialized workdir at /workspace, and captures stdout/stderr/exit
code/result.json the same way the subprocess backend does.

Inspired by scion's pkg/runtime/docker.go: shell out to the docker
CLI (zero new Go deps, zero CGO), per-task ephemeral containers with
no warm pool, host-side scratch dir bind-mounted in.

Default security posture (overridable per agent):
  --rm
  --cap-drop=ALL
  --security-opt=no-new-privileges
  --read-only with tmpfs /tmp
  --pids-limit=512
  --user=<host uid:gid>
  --network=bridge (configurable; --network=none for air-gap)
  --memory / --cpus from agent config
  --add-host host.docker.internal:host-gateway on Linux

The backend reuses subprocess.AgentConfig for gemini_md/claude_md/
mcp_servers/skills materialization so existing example configs work
unchanged. Per-agent docker tunables go under a new `docker` block
in harness_config_json: image, memory, cpus, network, extra_mounts,
cap_add, read_only_root, user, entrypoint, command, extra_args.

MCP host rewrite: `.gemini/settings.json` URLs of the form
http://127.0.0.1:<port>/mcp are rewritten to
http://host.docker.internal:<port>/mcp at materialization time so the
in-container Gemini CLI can reach the SynapBus MCP server on the host
without code changes in the example wrappers.

Wired into the reactor and Registry resolver:
- Registry.Resolve picks "docker" when harness_config_json contains a
  `"docker"` block, taking precedence over local_command so explicit
  isolation never silently downgrades.
- reactor.agentBackendKind() returns backendDocker for the same case.
- evaluateTrigger's harness-backend gate accepts backendDocker
  alongside subprocess + webhook.
- main.go registers docker.Harness with the harness registry, passing
  the SynapBus listen port so the URL rewrite uses the correct host
  port.

Smoke tests in docker_test.go (skipped when no docker daemon):
- TestExecute_Hello: env injection + bind-mount writeback + message.json
  + result.json + stdout capture using alpine:3.20
- TestExecute_NoImage: rejects agents missing docker.image
- TestExecute_TimeoutCancel: wall-clock budget kills the container

New canonical agent image at image-build/synapbus-agent/:
- Debian bookworm-slim base
- Node 22 + @google/gemini-cli + @anthropic-ai/claude-code
- jq, sqlite3, curl, git, python3, tini (PID 1 for signal forwarding)
- Non-root agent user uid/gid 1000
- ENTRYPOINT tini, CMD /workspace/wrapper.sh

No SynapBus binary inside the image — agents reach the host MCP server
over the network at host.docker.internal:<port>.

Pre-existing reactor test failures (TestReactorNoK8sImage,
TestReactorDepthExceeded, TestReactorBudgetExhausted,
TestReactorCooldownSkipped, TestReactorSequentialExecution) verified
to exist on f319290 unchanged — not introduced by this commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:59:37 +03:00

220 lines
6.4 KiB
Go

package docker_test
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/harness"
"github.com/synapbus/synapbus/internal/harness/docker"
"github.com/synapbus/synapbus/internal/messaging"
)
// TestExecute_Hello is the smoke test for the docker backend. Skipped
// when the docker daemon isn't reachable so CI without docker won't
// fail. Builds nothing — uses `alpine:3.20` which is small and
// universally available.
func TestExecute_Hello(t *testing.T) {
if err := exec.Command("docker", "version", "--format", "{{.Server.Version}}").Run(); err != nil {
t.Skip("docker daemon not available, skipping")
}
base := t.TempDir()
h := docker.New(docker.Config{
BaseDir: base,
KeepWorkdirOnSuccess: true,
HostMCPPort: 0, // skip URL rewrite for this test
}, nil)
// Minimal agent with a docker block. wrapper.sh writes a marker
// file to /workspace and prints a known string so we can assert
// both bind-mount writeback and stdout capture.
cfgJSON, err := json.Marshal(map[string]any{
"env": map[string]string{
"GREETING": "hello-from-container",
},
"docker": map[string]any{
"image": "alpine:3.20",
"command": []string{"sh", "/workspace/wrapper.sh"},
"network": "none", // air-gapped; we don't need MCP for this test
"memory": "128m",
"cpus": "0.5",
"pids_limit": 32,
},
})
if err != nil {
t.Fatal(err)
}
agent := &agents.Agent{
ID: 1,
Name: "smoke-test-agent",
HarnessConfigJSON: string(cfgJSON),
}
req := &harness.ExecRequest{
RunID: "smoke-test-1",
AgentName: agent.Name,
Agent: agent,
Message: &messaging.Message{
ID: 42,
FromAgent: "tester",
ToAgent: agent.Name,
Body: "hello",
},
Budget: harness.Budget{
MaxWallClock: 60 * time.Second,
},
}
// We need a wrapper.sh staged BEFORE Execute creates the container.
// In production the harness materializes one via the gemini_md /
// claude_md fields, but for the test we drop a tiny shell script
// directly into the run workdir.
runDir := filepath.Join(base, "smoke-test-1")
if err := os.MkdirAll(runDir, 0o755); err != nil {
t.Fatal(err)
}
wrapper := `#!/bin/sh
set -eu
echo "wrapper running as uid=$(id -u) gid=$(id -g) cwd=$(pwd)"
echo "GREETING=$GREETING"
echo "SYNAPBUS_RUN_ID=$SYNAPBUS_RUN_ID"
echo "SYNAPBUS_FROM_AGENT=$SYNAPBUS_FROM_AGENT"
[ -f /workspace/message.json ] && echo "message.json present"
echo '{"ok":true,"phase":"smoke"}' > /workspace/result.json
echo "wrapper done"
`
if err := os.WriteFile(filepath.Join(runDir, "wrapper.sh"), []byte(wrapper), 0o755); err != nil {
t.Fatal(err)
}
res, err := h.Execute(context.Background(), req)
if err != nil {
t.Fatalf("Execute returned error: %v\nlogs:\n%s", err, func() string {
if res != nil {
return res.Logs
}
return ""
}())
}
if res == nil {
t.Fatal("nil result")
}
if res.ExitCode != 0 {
t.Fatalf("expected exit 0, got %d\nlogs:\n%s", res.ExitCode, res.Logs)
}
// Check stdout capture.
if !strings.Contains(res.Logs, "wrapper done") {
t.Errorf("stdout missing 'wrapper done':\n%s", res.Logs)
}
if !strings.Contains(res.Logs, "GREETING=hello-from-container") {
t.Errorf("env injection missing GREETING:\n%s", res.Logs)
}
if !strings.Contains(res.Logs, "SYNAPBUS_RUN_ID=smoke-test-1") {
t.Errorf("SYNAPBUS_RUN_ID not propagated:\n%s", res.Logs)
}
if !strings.Contains(res.Logs, "message.json present") {
t.Errorf("message.json not bind-mounted:\n%s", res.Logs)
}
// Check result.json bind-mount writeback.
if len(res.ResultJSON) == 0 {
t.Error("result.json was not captured from bind-mount")
} else {
var parsed map[string]any
if err := json.Unmarshal(res.ResultJSON, &parsed); err != nil {
t.Errorf("result.json invalid: %v", err)
} else if parsed["ok"] != true {
t.Errorf("result.json content unexpected: %v", parsed)
}
}
// Workdir should still exist (KeepWorkdirOnSuccess=true). Verify
// the result.json the container wrote actually landed on the host.
hostResult, err := os.ReadFile(filepath.Join(runDir, "result.json"))
if err != nil {
t.Errorf("result.json not on host post-run: %v", err)
} else if !strings.Contains(string(hostResult), "smoke") {
t.Errorf("host result.json content unexpected: %s", hostResult)
}
}
// TestExecute_NoImage verifies the backend rejects agents whose
// harness_config_json lacks docker.image rather than silently picking
// some default.
func TestExecute_NoImage(t *testing.T) {
h := docker.New(docker.Config{}, nil)
agent := &agents.Agent{
Name: "no-image",
HarnessConfigJSON: `{"docker":{}}`,
}
req := &harness.ExecRequest{
RunID: "noimg",
AgentName: agent.Name,
Agent: agent,
}
_, err := h.Execute(context.Background(), req)
if err == nil {
t.Fatal("expected error for missing image, got nil")
}
if !strings.Contains(err.Error(), "image is required") {
t.Errorf("unexpected error: %v", err)
}
}
// TestExecute_TimeoutCancel verifies the wall-clock budget kills a
// long-running container.
func TestExecute_TimeoutCancel(t *testing.T) {
if err := exec.Command("docker", "version", "--format", "{{.Server.Version}}").Run(); err != nil {
t.Skip("docker daemon not available, skipping")
}
base := t.TempDir()
h := docker.New(docker.Config{BaseDir: base, KeepWorkdirOnSuccess: true}, nil)
cfgJSON, _ := json.Marshal(map[string]any{
"docker": map[string]any{
"image": "alpine:3.20",
"command": []string{"sh", "/workspace/wrapper.sh"},
"network": "none",
"memory": "64m",
},
})
agent := &agents.Agent{
Name: "slow",
HarnessConfigJSON: string(cfgJSON),
}
runDir := filepath.Join(base, "slow")
_ = os.MkdirAll(runDir, 0o755)
_ = os.WriteFile(filepath.Join(runDir, "wrapper.sh"),
[]byte("#!/bin/sh\nsleep 30\n"), 0o755)
req := &harness.ExecRequest{
RunID: "slow",
AgentName: agent.Name,
Agent: agent,
Budget: harness.Budget{MaxWallClock: 2 * time.Second},
}
start := time.Now()
res, err := h.Execute(context.Background(), req)
elapsed := time.Since(start)
if err == nil {
t.Fatalf("expected timeout error, got nil (exit=%d)", res.ExitCode)
}
if !strings.Contains(err.Error(), "budget") {
t.Errorf("unexpected error: %v", err)
}
if elapsed > 10*time.Second {
t.Errorf("timeout took too long: %s", elapsed)
}
}