The docker harness now detects and stages host CLI auth files (~/.gemini/oauth_creds.json, ~/.claude/.credentials.json) into a writable agent-home directory mounted at /home/agent. This lets containerized agents reuse the host's Gemini Pro / Claude Pro OAuth sessions without manual secret management or API keys. Only auth files are copied — not the host's settings.json or MCP configs (which contain stale localhost URLs that would hang Gemini CLI inside containers). The staged dir is writable so CLIs can create projects.json, history, etc. alongside the auth files. Also sets GEMINI_DEFAULT_AUTH_TYPE=oauth-personal and GEMINI_CLI_NO_RELAUNCH=true when OAuth creds are detected, writes Claude's hasCompletedOnboarding flag, and simplifies the doc-gardener example to use the harness-level credential staging instead of manual HOME directory seeding. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
379 lines
11 KiB
Go
379 lines
11 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_CredentialMounts verifies host credential directories are
|
|
// bind-mounted read-only into the container and HOME is set correctly.
|
|
func TestExecute_CredentialMounts(t *testing.T) {
|
|
if err := exec.Command("docker", "version", "--format", "{{.Server.Version}}").Run(); err != nil {
|
|
t.Skip("docker daemon not available, skipping")
|
|
}
|
|
|
|
fakeHome := t.TempDir()
|
|
geminiDir := filepath.Join(fakeHome, ".gemini")
|
|
claudeDir := filepath.Join(fakeHome, ".claude")
|
|
if err := os.MkdirAll(geminiDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(claudeDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(geminiDir, "oauth_creds.json"), []byte(`{"test":"gemini"}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(claudeDir, ".credentials.json"), []byte(`{"test":"claude"}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
base := t.TempDir()
|
|
h := docker.New(docker.Config{
|
|
BaseDir: base,
|
|
KeepWorkdirOnSuccess: true,
|
|
MountHostCredentials: true,
|
|
HostHomeDir: fakeHome,
|
|
}, nil)
|
|
|
|
readOnlyFalse := false
|
|
cfgJSON, _ := json.Marshal(map[string]any{
|
|
"docker": map[string]any{
|
|
"image": "alpine:3.20",
|
|
"command": []string{"sh", "/workspace/wrapper.sh"},
|
|
"network": "none",
|
|
"read_only_root": readOnlyFalse,
|
|
},
|
|
})
|
|
agent := &agents.Agent{
|
|
Name: "cred-test",
|
|
HarnessConfigJSON: string(cfgJSON),
|
|
}
|
|
|
|
runDir := filepath.Join(base, "cred-test")
|
|
if err := os.MkdirAll(runDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wrapper := `#!/bin/sh
|
|
set -eu
|
|
echo "HOME=$HOME"
|
|
cat /home/agent/.gemini/oauth_creds.json 2>&1 || echo "GEMINI_MISSING"
|
|
cat /home/agent/.claude/.credentials.json 2>&1 || echo "CLAUDE_MISSING"
|
|
echo "GEMINI_DEFAULT_AUTH_TYPE=${GEMINI_DEFAULT_AUTH_TYPE:-unset}"
|
|
echo "GEMINI_CLI_NO_RELAUNCH=${GEMINI_CLI_NO_RELAUNCH:-unset}"
|
|
# Home dir should be writable (staged copy, not RO mount).
|
|
if touch /home/agent/.gemini/projects.json 2>/dev/null; then
|
|
echo "HOME_WRITABLE"
|
|
else
|
|
echo "HOME_READ_ONLY"
|
|
fi
|
|
# settings.json and .claude.json should be auto-generated.
|
|
cat /home/agent/.gemini/settings.json 2>&1 || echo "SETTINGS_MISSING"
|
|
cat /home/agent/.claude.json 2>&1 || echo "ONBOARDING_MISSING"
|
|
`
|
|
if err := os.WriteFile(filepath.Join(runDir, "wrapper.sh"), []byte(wrapper), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
res, err := h.Execute(context.Background(), &harness.ExecRequest{
|
|
RunID: "cred-test",
|
|
AgentName: agent.Name,
|
|
Agent: agent,
|
|
Budget: harness.Budget{MaxWallClock: 30 * time.Second},
|
|
})
|
|
if err != nil {
|
|
logs := ""
|
|
if res != nil {
|
|
logs = res.Logs
|
|
}
|
|
t.Fatalf("Execute: %v\nlogs: %s", err, logs)
|
|
}
|
|
if res.ExitCode != 0 {
|
|
t.Fatalf("exit %d\nlogs: %s", res.ExitCode, res.Logs)
|
|
}
|
|
if !strings.Contains(res.Logs, `"test":"gemini"`) {
|
|
t.Errorf("gemini oauth_creds.json not visible:\n%s", res.Logs)
|
|
}
|
|
if !strings.Contains(res.Logs, `"test":"claude"`) {
|
|
t.Errorf("claude .credentials.json not visible:\n%s", res.Logs)
|
|
}
|
|
if !strings.Contains(res.Logs, "HOME_WRITABLE") {
|
|
t.Errorf("agent home should be writable:\n%s", res.Logs)
|
|
}
|
|
if !strings.Contains(res.Logs, "oauth-personal") {
|
|
t.Errorf("gemini settings.json missing auth type:\n%s", res.Logs)
|
|
}
|
|
if !strings.Contains(res.Logs, "hasCompletedOnboarding") {
|
|
t.Errorf(".claude.json onboarding flag missing:\n%s", res.Logs)
|
|
}
|
|
if !strings.Contains(res.Logs, "HOME=/home/agent") {
|
|
t.Errorf("HOME not set correctly:\n%s", res.Logs)
|
|
}
|
|
if !strings.Contains(res.Logs, "GEMINI_DEFAULT_AUTH_TYPE=oauth-personal") {
|
|
t.Errorf("GEMINI_DEFAULT_AUTH_TYPE not set:\n%s", res.Logs)
|
|
}
|
|
if !strings.Contains(res.Logs, "GEMINI_CLI_NO_RELAUNCH=true") {
|
|
t.Errorf("GEMINI_CLI_NO_RELAUNCH not set:\n%s", res.Logs)
|
|
}
|
|
}
|
|
|
|
// TestExecute_CredentialMounts_MissingDirs verifies that missing
|
|
// credential directories on the host are silently skipped.
|
|
func TestExecute_CredentialMounts_MissingDirs(t *testing.T) {
|
|
if err := exec.Command("docker", "version", "--format", "{{.Server.Version}}").Run(); err != nil {
|
|
t.Skip("docker daemon not available, skipping")
|
|
}
|
|
|
|
fakeHome := t.TempDir() // empty — no .gemini or .claude
|
|
|
|
base := t.TempDir()
|
|
h := docker.New(docker.Config{
|
|
BaseDir: base,
|
|
KeepWorkdirOnSuccess: true,
|
|
MountHostCredentials: true,
|
|
HostHomeDir: fakeHome,
|
|
}, nil)
|
|
|
|
cfgJSON, _ := json.Marshal(map[string]any{
|
|
"docker": map[string]any{
|
|
"image": "alpine:3.20",
|
|
"command": []string{"echo", "ok"},
|
|
"network": "none",
|
|
},
|
|
})
|
|
agent := &agents.Agent{
|
|
Name: "no-creds",
|
|
HarnessConfigJSON: string(cfgJSON),
|
|
}
|
|
|
|
res, err := h.Execute(context.Background(), &harness.ExecRequest{
|
|
RunID: "no-creds",
|
|
AgentName: agent.Name,
|
|
Agent: agent,
|
|
Budget: harness.Budget{MaxWallClock: 30 * time.Second},
|
|
})
|
|
if err != nil {
|
|
logs := ""
|
|
if res != nil {
|
|
logs = res.Logs
|
|
}
|
|
t.Fatalf("Execute: %v\nlogs: %s", err, logs)
|
|
}
|
|
if res.ExitCode != 0 {
|
|
t.Fatalf("exit %d — should succeed even without credential dirs\nlogs: %s", res.ExitCode, res.Logs)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|