Files
Algis DumbrisandClaude Opus 4.6 121d05f875 feat(docker): auto-stage host OAuth credentials into agent containers
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>
2026-04-16 09:17:54 +03:00

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)
}
}