Files
synapbus/internal/harness/docker/docker.go
T
Algis DumbrisandClaude Opus 4.6 ddbb1bd329 fix(docker): tmpfs /tmp mounted rw,exec,size=128m
Docker Desktop's default noexec on tmpfs broke the "download a CLI
to /tmp, chmod +x, run it" workflow — exactly what the doc-gardener
inspector needs to verify docs.mcpproxy.app against the real
mcpproxy binary. Previously the agent spent ~10 minutes in a self-
debug loop discovering the noexec, falling back to /home/agent,
running into externally-managed Python, missing python3-venv, etc.

With /tmp exec, the inspector's own install pipeline works on the
first try: curl | tar | chmod | run. First real run produced a
72-claim drift report (21 matched / 1 drifted / 50 missing) against
mcpproxy v0.24.4 in ~8 minutes, no REVISE loop.

The 64m → 128m bump gives breathing room for curl'd tarballs that
need a temp extraction directory alongside the final binary.

Inspector prompt updated to tell the agent about the /tmp install
path explicitly and forbid the previous /home/agent detours. Also
updated the coordinator brief template to match.

Note the image itself is UNCHANGED — we deliberately do NOT bake
mcpproxy (or any other domain-specific tool) into synapbus-agent.
The image stays a blank Linux shell with Node + Python + core tools,
and each example's prompt teaches its agent how to install whatever
it needs. This keeps the gardener universal: swap in any other docs
domain and the inspector figures out what to install on demand.

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

578 lines
16 KiB
Go

package docker
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/harness"
"github.com/synapbus/synapbus/internal/harness/subprocess"
"github.com/synapbus/synapbus/internal/messaging"
)
// Harness runs agents inside ephemeral Docker containers. One container
// per Execute call, bind-mounted workdir, --rm cleanup, no warm pool.
type Harness struct {
cfg Config
logger *slog.Logger
}
// New builds a docker harness with sensible defaults.
func New(cfg Config, logger *slog.Logger) *Harness {
if cfg.LogsCap <= 0 {
cfg.LogsCap = 64 * 1024
}
if cfg.HostGatewayName == "" {
cfg.HostGatewayName = "host.docker.internal"
}
if cfg.DockerBin == "" {
cfg.DockerBin = "docker"
}
if logger == nil {
logger = slog.Default()
}
return &Harness{
cfg: cfg,
logger: logger.With("harness", "docker"),
}
}
// Name is the registered backend identifier. Match it in agent rows via
// harness_name = "docker".
func (h *Harness) Name() string { return "docker" }
// Capabilities mirrors subprocess: same workdir convention, same MCP
// support, same OTel env-var injection. Skills aren't materialized
// today (subprocess doesn't either).
func (h *Harness) Capabilities() harness.Capabilities {
return harness.Capabilities{
SystemPrompt: true,
SessionResume: true,
Skills: false,
OTelNative: true,
MaxConcurrency: 4,
}
}
// TestEnvironment runs `docker version --format {{.Server.Version}}`
// and fails fast if the daemon isn't reachable.
func (h *Harness) TestEnvironment(ctx context.Context) error {
cmd := exec.CommandContext(ctx, h.cfg.DockerBin, "version", "--format", "{{.Server.Version}}")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker: daemon unreachable: %w (output: %s)", err, strings.TrimSpace(string(out)))
}
return nil
}
// Provision is a no-op. Image pulls happen lazily on the first Execute
// (docker run will pull missing images automatically).
func (h *Harness) Provision(ctx context.Context, agent *agents.Agent) error { return nil }
// Cancel asks the docker daemon to kill the container for runID.
// Best-effort: returns nil even if no container exists.
func (h *Harness) Cancel(ctx context.Context, runID string) error {
name := containerName(runID)
_ = exec.CommandContext(ctx, h.cfg.DockerBin, "kill", name).Run()
return nil
}
// Execute is the hot path. Materializes the workdir, runs `docker run
// --rm` synchronously, captures exit + stdout/stderr.
func (h *Harness) Execute(ctx context.Context, req *harness.ExecRequest) (*harness.ExecResult, error) {
if req == nil {
return nil, errors.New("docker: nil ExecRequest")
}
if req.Agent == nil {
return nil, errors.New("docker: ExecRequest.Agent is required")
}
dockerCfg, err := ParseDockerConfig(req.Agent.HarnessConfigJSON)
if err != nil {
return nil, err
}
if dockerCfg.Image == "" {
return nil, errors.New("docker: agent's harness_config_json.docker.image is required")
}
subCfg, err := subprocess.ParseAgentConfig(req.Agent.HarnessConfigJSON)
if err != nil {
return nil, err
}
workdir, err := h.makeWorkdir(req.RunID)
if err != nil {
return nil, err
}
if req.Message != nil {
if err := writeMessageFile(workdir, req.Message); err != nil {
return nil, err
}
}
if err := subprocess.MaterialiseAgentConfig(workdir, subCfg); err != nil {
return nil, err
}
// Rewrite MCP host in .gemini/settings.json so the agent inside the
// container can reach SynapBus on the host. The host writes
// 127.0.0.1:<port> by default; the container sees that loopback as
// itself, not the host.
if h.cfg.HostMCPPort > 0 {
if err := rewriteGeminiMCPHost(workdir, h.cfg.HostGatewayName, h.cfg.HostMCPPort); err != nil {
h.logger.Warn("rewrite gemini MCP host failed",
"workdir", workdir, "error", err)
}
}
runCtx := ctx
if req.Budget.MaxWallClock > 0 {
var cancel context.CancelFunc
runCtx, cancel = context.WithTimeout(ctx, req.Budget.MaxWallClock)
defer cancel()
}
args, err := h.buildRunArgs(req, dockerCfg, subCfg, workdir)
if err != nil {
return nil, err
}
h.logger.Info("docker launching",
"run_id", req.RunID,
"agent", req.AgentName,
"image", dockerCfg.Image,
"workdir", workdir,
"network", argOr(dockerCfg.Network, "bridge"),
)
cmd := exec.CommandContext(runCtx, h.cfg.DockerBin, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = io.MultiWriter(&stdout, fileWriter(workdir, "stdout.log"))
cmd.Stderr = io.MultiWriter(&stderr, fileWriter(workdir, "stderr.log"))
startedAt := time.Now()
runErr := cmd.Run()
duration := time.Since(startedAt)
exitCode := 0
if runErr != nil {
var exitErr *exec.ExitError
if errors.As(runErr, &exitErr) {
exitCode = exitErr.ExitCode()
} else {
exitCode = 1
}
}
var resultJSON json.RawMessage
if raw, readErr := os.ReadFile(filepath.Join(workdir, "result.json")); readErr == nil && len(raw) > 0 {
if json.Valid(raw) {
resultJSON = raw
}
}
promptText := readFileSafe(filepath.Join(workdir, "prompt.txt"))
responseText := readFileSafe(filepath.Join(workdir, "response.txt"))
logs := mergeLogs(&stdout, &stderr, h.cfg.LogsCap)
if exitCode == 0 && !h.cfg.KeepWorkdirOnSuccess {
_ = os.RemoveAll(workdir)
}
h.logger.Info("docker finished",
"run_id", req.RunID,
"agent", req.AgentName,
"exit", exitCode,
"duration_ms", duration.Milliseconds(),
)
result := &harness.ExecResult{
ExitCode: exitCode,
Logs: logs,
ResultJSON: resultJSON,
Prompt: promptText,
Response: responseText,
}
if runErr != nil && errors.Is(runCtx.Err(), context.DeadlineExceeded) {
_ = h.Cancel(context.Background(), req.RunID)
return result, fmt.Errorf("docker: wall-clock budget %s exceeded", req.Budget.MaxWallClock)
}
if runErr != nil && errors.Is(runCtx.Err(), context.Canceled) {
_ = h.Cancel(context.Background(), req.RunID)
return result, runCtx.Err()
}
return result, nil
}
// buildRunArgs constructs the full `docker run` argv. Defaults are
// security-conservative: --rm, --cap-drop=ALL, no-new-privileges, pids
// limit, read-only root with tmpfs /tmp, no privilege escalation, no
// host networking. Per-agent config layers on top.
func (h *Harness) buildRunArgs(
req *harness.ExecRequest,
dockerCfg AgentConfig,
subCfg subprocess.AgentConfig,
workdir string,
) ([]string, error) {
name := containerName(req.RunID)
args := []string{
"run",
"--rm",
"--name", name,
"--workdir", "/workspace",
"--mount", fmt.Sprintf("type=bind,source=%s,target=/workspace", workdir),
"--security-opt", "no-new-privileges",
"--cap-drop", "ALL",
"--pids-limit", fmt.Sprintf("%d", pidsLimit(dockerCfg.PIDsLimit)),
}
// Read-only root + tmpfs scratch unless explicitly disabled.
// The tmpfs mount is `exec` so agents can download and run small
// binaries there (e.g. a CLI the verifier needs to invoke). Without
// `exec` Docker Desktop's default "noexec" on tmpfs breaks any
// `chmod +x && ./binary` workflow inside the sandbox.
if dockerCfg.ReadOnlyRoot == nil || *dockerCfg.ReadOnlyRoot {
args = append(args, "--read-only", "--tmpfs", "/tmp:rw,exec,size=128m")
}
if dockerCfg.Memory != "" {
args = append(args, "--memory", dockerCfg.Memory)
// Match memory-swap to memory so swap doesn't silently double
// the effective limit. -1 would mean unlimited; equal disables.
args = append(args, "--memory-swap", dockerCfg.Memory)
}
if dockerCfg.CPUs != "" {
args = append(args, "--cpus", dockerCfg.CPUs)
}
network := dockerCfg.Network
if network == "" {
network = "bridge"
}
args = append(args, "--network", network)
// Add host.docker.internal pointer on Linux so the agent can reach
// the SynapBus MCP server at the same hostname as on Docker Desktop.
// Skipped for --network=host (not needed) and --network=none
// (would fail the gateway lookup).
if network != "host" && network != "none" && runtime.GOOS == "linux" {
args = append(args, "--add-host", h.cfg.HostGatewayName+":host-gateway")
}
// User namespacing — host UID/GID injection so files written into
// the bind-mounted workdir end up owned by the SynapBus user. If
// the agent overrides User explicitly use that.
if dockerCfg.User != "" {
args = append(args, "--user", dockerCfg.User)
} else {
args = append(args, "--user", currentUserSpec())
}
for _, c := range dockerCfg.CapAdd {
if c == "" {
continue
}
args = append(args, "--cap-add", c)
}
for _, m := range dockerCfg.ExtraMounts {
if m.Source == "" || m.Target == "" {
continue
}
spec := fmt.Sprintf("type=bind,source=%s,target=%s", m.Source, m.Target)
if m.ReadOnly {
spec += ",readonly"
}
args = append(args, "--mount", spec)
}
// Environment variables — caller-provided + harness-injected. Pass
// through as -e KEY=VALUE; sort for deterministic output.
envMap := buildEnvMap(req, subCfg)
keys := make([]string, 0, len(envMap))
for k := range envMap {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
args = append(args, "--env", k+"="+envMap[k])
}
args = append(args, dockerCfg.ExtraArgs...)
if len(dockerCfg.Entrypoint) > 0 {
args = append(args, "--entrypoint", dockerCfg.Entrypoint[0])
}
args = append(args, dockerCfg.Image)
// Args after the image become the container's CMD. If Entrypoint is
// set we still need to forward its tail args. If neither Entrypoint
// nor Command is configured we deliberately pass nothing so the
// image's baked CMD is used (e.g. the synapbus-agent image's
// /usr/local/bin/synapbus-agent-wrapper.sh).
if len(dockerCfg.Entrypoint) > 1 {
args = append(args, dockerCfg.Entrypoint[1:]...)
}
if len(dockerCfg.Command) > 0 {
args = append(args, dockerCfg.Command...)
}
return args, nil
}
// makeWorkdir creates BaseDir/<runID-sanitised>/ with mode 0755. The
// directory is removed on successful completion unless KeepWorkdirOn
// Success is set.
func (h *Harness) makeWorkdir(runID string) (string, error) {
base := h.cfg.BaseDir
if base == "" {
base = filepath.Join(os.TempDir(), "synapbus-docker")
}
if err := os.MkdirAll(base, 0o755); err != nil {
return "", fmt.Errorf("docker: mkdir base: %w", err)
}
name := sanitizeRunDir(runID)
if name == "" {
name = fmt.Sprintf("run-%d", time.Now().UnixNano())
}
wd := filepath.Join(base, name)
if err := os.MkdirAll(wd, 0o755); err != nil {
return "", fmt.Errorf("docker: mkdir workdir: %w", err)
}
return wd, nil
}
// containerName turns a run id into a docker-safe container name.
func containerName(runID string) string {
clean := sanitizeRunDir(runID)
if clean == "" {
clean = fmt.Sprintf("run%d", time.Now().UnixNano())
}
return "synapbus-" + clean
}
func sanitizeRunDir(runID string) string {
if runID == "" {
return ""
}
var b strings.Builder
for _, r := range runID {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-' || r == '_':
b.WriteRune(r)
default:
b.WriteByte('-')
}
}
out := b.String()
if len(out) > 64 {
out = out[:64]
}
return out
}
func writeMessageFile(workdir string, msg *messaging.Message) error {
raw, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("docker: marshal message: %w", err)
}
return os.WriteFile(filepath.Join(workdir, "message.json"), raw, 0o644)
}
// rewriteGeminiMCPHost reads .gemini/settings.json (written by the
// subprocess MaterialiseAgentConfig step) and rewrites every mcpServers
// URL whose host is 127.0.0.1 / localhost / 0.0.0.0 to the host
// gateway. The container can't reach the host on loopback.
func rewriteGeminiMCPHost(workdir, gateway string, port int) error {
path := filepath.Join(workdir, ".gemini", "settings.json")
raw, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
var settings struct {
MCPServers map[string]map[string]json.RawMessage `json:"mcpServers"`
}
if err := json.Unmarshal(raw, &settings); err != nil {
return fmt.Errorf("docker: parse gemini settings: %w", err)
}
if len(settings.MCPServers) == 0 {
return nil
}
dirty := false
for name, server := range settings.MCPServers {
urlRaw, ok := server["url"]
if !ok {
continue
}
var url string
if err := json.Unmarshal(urlRaw, &url); err != nil {
continue
}
newURL := rewriteLoopback(url, gateway, port)
if newURL == url {
continue
}
fixed, _ := json.Marshal(newURL)
settings.MCPServers[name]["url"] = fixed
dirty = true
}
if !dirty {
return nil
}
out, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, out, 0o644)
}
func rewriteLoopback(url, gateway string, port int) string {
for _, host := range []string{"127.0.0.1", "localhost", "0.0.0.0"} {
needle := "//" + host
if i := strings.Index(url, needle); i >= 0 {
rest := url[i+len(needle):]
// Replace the port on the URL with the harness-known host
// port so a misconfigured agent cant accidentally point at
// a different listener.
if strings.HasPrefix(rest, ":") {
if slash := strings.IndexByte(rest, '/'); slash >= 0 {
rest = rest[slash:]
} else {
rest = ""
}
}
return url[:i] + "//" + gateway + ":" + fmt.Sprintf("%d", port) + rest
}
}
return url
}
// buildEnvMap mirrors subprocess.buildEnv but does NOT inherit the
// parent process's environment. Containers start clean — only what we
// explicitly forward gets in. Order: agent k8s_env_json → harness
// config env → caller overrides → SYNAPBUS_* run context.
func buildEnvMap(req *harness.ExecRequest, cfg subprocess.AgentConfig) map[string]string {
env := map[string]string{}
if req.Agent != nil && req.Agent.K8sEnvJSON != "" {
var m map[string]json.RawMessage
if err := json.Unmarshal([]byte(req.Agent.K8sEnvJSON), &m); err == nil {
for k, v := range m {
var s string
if err := json.Unmarshal(v, &s); err == nil {
env[k] = s
continue
}
env[k] = strings.Trim(string(v), "\"")
}
}
}
for k, v := range cfg.Env {
env[k] = v
}
for k, v := range req.Env {
env[k] = v
}
env["SYNAPBUS_RUN_ID"] = req.RunID
env["SYNAPBUS_AGENT"] = req.AgentName
env["SYNAPBUS_WORKDIR"] = "/workspace"
if req.Message != nil {
env["SYNAPBUS_MESSAGE_ID"] = fmt.Sprintf("%d", req.Message.ID)
env["SYNAPBUS_FROM_AGENT"] = req.Message.FromAgent
}
return env
}
// currentUserSpec returns "uid:gid" for the host user so files written
// inside the bind-mount land with sane ownership instead of root. On
// Linux this is a defence-in-depth measure (and also enables sane
// ownership on host bind-mounts). On macOS Docker Desktop the
// virtio-fs/gRPC FUSE layer handles ownership translation regardless
// so we leave it empty and let the image's USER directive apply —
// which keeps /etc/passwd in agreement with the runtime user and
// avoids gemini-cli's keychain init failing on uv_os_get_passwd
// ENOENT for an unknown uid.
func currentUserSpec() string {
if runtime.GOOS != "linux" {
return ""
}
uid := os.Getuid()
gid := os.Getgid()
if uid <= 0 {
return ""
}
return fmt.Sprintf("%d:%d", uid, gid)
}
func argOr(s, fallback string) string {
if s == "" {
return fallback
}
return s
}
func pidsLimit(cfg int) int {
if cfg <= 0 {
return 512
}
return cfg
}
func fileWriter(workdir, name string) io.Writer {
f, err := os.OpenFile(filepath.Join(workdir, name), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return io.Discard
}
return f
}
func readFileSafe(path string) string {
raw, err := os.ReadFile(path)
if err != nil {
return ""
}
return string(raw)
}
func mergeLogs(out, errb *bytes.Buffer, cap int) string {
var b strings.Builder
if out.Len() > 0 {
b.WriteString(out.String())
}
if errb.Len() > 0 {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString("-- stderr --\n")
b.WriteString(errb.String())
}
s := b.String()
if cap > 0 && len(s) > cap {
s = "... [truncated " + fmt.Sprintf("%d", len(s)-cap) + " bytes] ...\n" + s[len(s)-cap:]
}
return s
}