diff --git a/README.md b/README.md index b94540cf..c7d2445e 100644 --- a/README.md +++ b/README.md @@ -218,8 +218,56 @@ Temporarily disable egress filtering for a newly created repository sandbox: ```bash cleanroom console --dangerously-allow-all -- bash cleanroom exec --dangerously-allow-all -- npm test +cleanroom agent --dangerously-allow-all codex -- exec "summarize the repo" ``` +Agent command: + +```bash +cleanroom agent codex -- --device-auth --yolo +cleanroom agent claude +``` + +`cleanroom agent` creates a new sandbox from the current policy and runs the requested agent command inside it. It does not switch images: agent sessions use the same `sandbox.image.ref` and network policy as the rest of the repo. + +By default Cleanroom checks that the requested command exists in the sandbox before starting it. Runtime config can provide string-based command, test, and install snippets for host-local preferences: + +```yaml +agents: + codex: + command: sh -lc 'if command -v codex >/dev/null 2>&1; then exec codex "$@"; fi; exec env MISE_YES=1 MISE_TRUSTED_CONFIG_PATHS=/workspace mise --no-config exec -y nodejs@lts -- npm exec --yes --package @openai/codex@latest -- codex "$@"' sh + test: command -v codex >/dev/null 2>&1 || command -v mise >/dev/null 2>&1 + credentials: + - source: ~/.codex/auth.json + target: ~/.codex/auth.json + - source: ~/.codex/config.toml + target: ~/.codex/config.toml + claude: + command: sh -lc 'if command -v claude >/dev/null 2>&1; then exec claude "$@"; fi; exec env MISE_YES=1 MISE_TRUSTED_CONFIG_PATHS=/workspace mise --no-config exec -y nodejs@lts -- npm exec --yes --package @anthropic-ai/claude-code@latest -- claude "$@"' sh + test: command -v claude >/dev/null 2>&1 || command -v mise >/dev/null 2>&1 + credentials: + - source: ~/.claude + target: ~/.claude + gemini: + command: sh -lc 'if command -v gemini >/dev/null 2>&1; then exec gemini "$@"; fi; exec env MISE_YES=1 MISE_TRUSTED_CONFIG_PATHS=/workspace mise --no-config exec -y nodejs@lts -- npm exec --yes --package @google/gemini-cli@latest -- gemini "$@"' sh + test: command -v gemini >/dev/null 2>&1 || command -v mise >/dev/null 2>&1 + credentials: + - source: ~/.gemini + target: ~/.gemini + opencode: + command: sh -lc 'if command -v opencode >/dev/null 2>&1; then exec opencode "$@"; fi; exec env MISE_YES=1 MISE_TRUSTED_CONFIG_PATHS=/workspace mise --no-config exec -y nodejs@lts -- npm exec --yes --package opencode-ai@latest -- opencode "$@"' sh + test: command -v opencode >/dev/null 2>&1 || command -v mise >/dev/null 2>&1 + credentials: + - source: ~/.config/opencode + target: ~/.config/opencode +``` + +Credential paths are copied into the sandbox before the agent starts. Missing credential files are skipped, and copied files remain in a kept sandbox until that sandbox is terminated. + +The default fallback uses mise-managed Node.js plus the agent npm package so plain Debian images with the required runtime libraries can start agents without preinstalled agent binaries. + +For Codex inside cleanroom, prefer device-code auth or API-key auth. Browser/ChatGPT sign-in is not supported in the sandbox yet because it expects a localhost OAuth callback. + ## Policy file A `cleanroom.yaml` in your repo defines the sandbox policy. Cleanroom also checks `.buildkite/cleanroom.yaml` as a fallback. @@ -435,6 +483,33 @@ Optional endpoint override precedence is `--host`, then `CLEANROOM_HOST`, then ` ```yaml default_backend: firecracker control_host: "" # optional override for client endpoint resolution +agents: + codex: + command: sh -lc 'if command -v codex >/dev/null 2>&1; then exec codex "$@"; fi; exec env MISE_YES=1 MISE_TRUSTED_CONFIG_PATHS=/workspace mise --no-config exec -y nodejs@lts -- npm exec --yes --package @openai/codex@latest -- codex "$@"' sh + test: command -v codex >/dev/null 2>&1 || command -v mise >/dev/null 2>&1 + credentials: + - source: ~/.codex/auth.json + target: ~/.codex/auth.json + - source: ~/.codex/config.toml + target: ~/.codex/config.toml + claude: + command: sh -lc 'if command -v claude >/dev/null 2>&1; then exec claude "$@"; fi; exec env MISE_YES=1 MISE_TRUSTED_CONFIG_PATHS=/workspace mise --no-config exec -y nodejs@lts -- npm exec --yes --package @anthropic-ai/claude-code@latest -- claude "$@"' sh + test: command -v claude >/dev/null 2>&1 || command -v mise >/dev/null 2>&1 + credentials: + - source: ~/.claude + target: ~/.claude + gemini: + command: sh -lc 'if command -v gemini >/dev/null 2>&1; then exec gemini "$@"; fi; exec env MISE_YES=1 MISE_TRUSTED_CONFIG_PATHS=/workspace mise --no-config exec -y nodejs@lts -- npm exec --yes --package @google/gemini-cli@latest -- gemini "$@"' sh + test: command -v gemini >/dev/null 2>&1 || command -v mise >/dev/null 2>&1 + credentials: + - source: ~/.gemini + target: ~/.gemini + opencode: + command: sh -lc 'if command -v opencode >/dev/null 2>&1; then exec opencode "$@"; fi; exec env MISE_YES=1 MISE_TRUSTED_CONFIG_PATHS=/workspace mise --no-config exec -y nodejs@lts -- npm exec --yes --package opencode-ai@latest -- opencode "$@"' sh + test: command -v opencode >/dev/null 2>&1 || command -v mise >/dev/null 2>&1 + credentials: + - source: ~/.config/opencode + target: ~/.config/opencode backends: firecracker: binary_path: firecracker diff --git a/cleanroom.yaml b/cleanroom.yaml index 81194728..1f715161 100644 --- a/cleanroom.yaml +++ b/cleanroom.yaml @@ -1,7 +1,7 @@ version: 1 sandbox: image: - ref: ghcr.io/buildkite/cleanroom-base/alpine@sha256:91a63856cdf97b2e5659660b41d1a131d3b57bfa4cad254018e391ffef6fa4b9 + ref: ghcr.io/buildkite/cleanroom-base/debian@sha256:66d2bafc1cd64e594b32d9c091f1bc79bc1fb3811686ea920c58f6e4275f6663 network: default: deny allow: diff --git a/internal/cli/agent.go b/internal/cli/agent.go new file mode 100644 index 00000000..b4f5becc --- /dev/null +++ b/internal/cli/agent.go @@ -0,0 +1,316 @@ +package cli + +import ( + "archive/tar" + "bytes" + "context" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/buildkite/cleanroom/internal/controlclient" + "github.com/buildkite/cleanroom/internal/runtimeconfig" +) + +type AgentCommand struct { + clientFlags + Chdir string `short:"c" help:"Change to this directory before running commands"` + Backend string `help:"Execution backend (defaults to runtime config or firecracker)"` + SandboxID string `help:"Reuse an existing sandbox instead of creating a new one"` + + DangerouslyAllowAll bool `name:"dangerously-allow-all" help:"Disable network egress filtering for a newly created sandbox"` + LaunchSeconds int64 `help:"VM boot/guest-agent readiness timeout in seconds"` + + Agent string `arg:"" required:"" enum:"${agent_names}" help:"Agent name to run inside the sandbox (one of ${enum})"` + Args []string `arg:"" passthrough:"" optional:"" help:"Arguments to pass to the agent (prefix with '--' to separate cleanroom and agent flags)"` +} + +func (a *AgentCommand) Run(ctx *runtimeContext) error { + keepSandbox := strings.TrimSpace(a.SandboxID) == "" + command, err := agentShellCommand(a.Agent, a.Args, ctx.Config.Agents) + if err != nil { + return err + } + credentials, err := agentCredentialArchive(a.Agent, ctx.Config.Agents) + if err != nil { + return err + } + + console := ConsoleCommand{ + clientFlags: a.clientFlags, + Chdir: a.Chdir, + Backend: a.Backend, + In: a.SandboxID, + Keep: keepSandbox, + DangerouslyAllowAll: a.DangerouslyAllowAll, + LaunchSeconds: a.LaunchSeconds, + Command: []string{"sh", "-lc", command}, + } + if len(credentials) > 0 { + console.preAttach = func(callCtx context.Context, client *controlclient.Client, sandboxID string) error { + return extractAgentCredentialArchive(callCtx, client, sandboxID, credentials) + } + } + return console.Run(ctx) +} + +func agentShellCommand(name string, rawArgs []string, agents map[string]runtimeconfig.Agent) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("agent command is required") + } + + args := append([]string(nil), rawArgs...) + if len(args) > 0 && args[0] == "--" { + args = args[1:] + } + + spec := resolveAgentSpec(name, agents) + command := strings.TrimSpace(spec.Command) + if command == "" { + command = name + } + test := strings.TrimSpace(spec.Test) + if test == "" { + test = "command -v " + shellQuote(name) + " >/dev/null 2>&1" + } + + var script strings.Builder + script.WriteString("set -e\n") + if install := strings.TrimSpace(spec.Install); install != "" { + script.WriteString("if ! (") + script.WriteString(test) + script.WriteString("); then\n") + script.WriteString(install) + script.WriteString("\nfi\n") + } + script.WriteString("if ! (") + script.WriteString(test) + script.WriteString("); then\n") + script.WriteString("printf '%s\\n' ") + script.WriteString(shellQuote("cleanroom: agent command not found: " + name)) + script.WriteString(" >&2\n") + script.WriteString("exit 127\n") + script.WriteString("fi\n") + script.WriteString("exec ") + script.WriteString(command) + for _, arg := range args { + script.WriteByte(' ') + script.WriteString(shellQuote(arg)) + } + return script.String(), nil +} + +func agentCredentialArchive(name string, agents map[string]runtimeconfig.Agent) ([]byte, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, nil + } + + credentials := agentCredentials(name, agents) + if len(credentials) == 0 { + return nil, nil + } + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + wrote := false + for _, credential := range credentials { + source, err := expandHomePath(credential.Source) + if err != nil { + return nil, err + } + info, err := os.Lstat(source) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, fmt.Errorf("read agent credential %q: %w", credential.Source, err) + } + target, err := guestCredentialPath(credential.Target) + if err != nil { + return nil, err + } + if err := addCredentialToArchive(tw, source, target, info); err != nil { + return nil, err + } + wrote = true + } + if err := tw.Close(); err != nil { + return nil, fmt.Errorf("write agent credential archive: %w", err) + } + if !wrote { + return nil, nil + } + return buf.Bytes(), nil +} + +func agentCredentials(name string, agents map[string]runtimeconfig.Agent) []runtimeconfig.AgentCredential { + if agent := resolveAgentSpec(name, agents); len(agent.Credentials) > 0 { + return append([]runtimeconfig.AgentCredential(nil), agent.Credentials...) + } + return nil +} + +func resolveAgentSpec(name string, agents map[string]runtimeconfig.Agent) runtimeconfig.Agent { + name = strings.TrimSpace(name) + if name == "" { + return runtimeconfig.Agent{} + } + spec := defaultRuntimeAgentConfig()[name] + if configured, ok := agents[name]; ok { + if configured.Command != "" { + spec.Command = configured.Command + } + if configured.Test != "" { + spec.Test = configured.Test + } + if configured.Install != "" { + spec.Install = configured.Install + } + if len(configured.Credentials) > 0 { + spec.Credentials = configured.Credentials + } + } + return spec +} + +func extractAgentCredentialArchive(ctx context.Context, client *controlclient.Client, sandboxID string, archive []byte) error { + if err := extractSandboxArchive(ctx, client, sandboxID, "/", bytes.NewReader(archive)); err != nil { + return fmt.Errorf("copy agent credentials: %w", err) + } + return nil +} + +func addCredentialToArchive(tw *tar.Writer, source, target string, info fs.FileInfo) error { + if info.IsDir() { + return filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return fmt.Errorf("walk agent credential %q: %w", source, err) + } + entryInfo, err := entry.Info() + if err != nil { + return fmt.Errorf("stat agent credential %q: %w", path, err) + } + rel, err := filepath.Rel(source, path) + if err != nil { + return err + } + entryTarget := target + if rel != "." { + entryTarget = filepath.ToSlash(filepath.Join(target, rel)) + } + return writeCredentialArchiveEntry(tw, path, entryTarget, entryInfo) + }) + } + return writeCredentialArchiveEntry(tw, source, target, info) +} + +func writeCredentialArchiveEntry(tw *tar.Writer, source, target string, info fs.FileInfo) error { + link := "" + if info.Mode()&os.ModeSymlink != 0 { + var err error + link, err = os.Readlink(source) + if err != nil { + return fmt.Errorf("read agent credential symlink %q: %w", source, err) + } + } + header, err := tar.FileInfoHeader(info, link) + if err != nil { + return fmt.Errorf("create agent credential archive header %q: %w", source, err) + } + header.Name = target + if isCodexConfigTarget(target) { + raw, err := os.ReadFile(source) + if err != nil { + return fmt.Errorf("read agent credential %q: %w", source, err) + } + data := codexConfigWithWorkspaceTrust(raw) + header.Size = int64(len(data)) + if err := tw.WriteHeader(header); err != nil { + return fmt.Errorf("write agent credential archive header %q: %w", source, err) + } + if _, err := tw.Write(data); err != nil { + return fmt.Errorf("write agent credential %q: %w", source, err) + } + return nil + } + if err := tw.WriteHeader(header); err != nil { + return fmt.Errorf("write agent credential archive header %q: %w", source, err) + } + if !info.Mode().IsRegular() { + return nil + } + file, err := os.Open(source) + if err != nil { + return fmt.Errorf("open agent credential %q: %w", source, err) + } + defer file.Close() + if _, err := io.Copy(tw, file); err != nil { + return fmt.Errorf("write agent credential %q: %w", source, err) + } + return nil +} + +func isCodexConfigTarget(target string) bool { + return filepath.ToSlash(filepath.Clean(target)) == "root/.codex/config.toml" +} + +func codexConfigWithWorkspaceTrust(raw []byte) []byte { + if bytes.Contains(raw, []byte(`[projects."/workspace"]`)) || bytes.Contains(raw, []byte(`[projects.'/workspace']`)) { + return raw + } + out := append([]byte(nil), raw...) + if len(out) > 0 && out[len(out)-1] != '\n' { + out = append(out, '\n') + } + out = append(out, []byte("\n[projects.\"/workspace\"]\ntrust_level = \"trusted\"\n")...) + return out +} + +func expandHomePath(path string) (string, error) { + path = strings.TrimSpace(path) + if path == "" { + return "", fmt.Errorf("agent credential source is required") + } + if path == "~" || strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + if path == "~" { + return home, nil + } + return filepath.Join(home, path[2:]), nil + } + return path, nil +} + +func guestCredentialPath(path string) (string, error) { + path = strings.TrimSpace(path) + if path == "" { + return "", fmt.Errorf("agent credential target is required") + } + switch { + case path == "~": + path = "/root" + case strings.HasPrefix(path, "~/"): + path = "/root/" + path[2:] + } + path = filepath.ToSlash(filepath.Clean(path)) + path = strings.TrimPrefix(path, "/") + if path == "." || path == "" || strings.HasPrefix(path, "../") || path == ".." { + return "", fmt.Errorf("invalid agent credential target %q", path) + } + return path, nil +} + +func shellQuote(s string) string { + if s == "" { + return "''" + } + return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" +} diff --git a/internal/cli/agent_integration_test.go b/internal/cli/agent_integration_test.go new file mode 100644 index 00000000..3d73ea73 --- /dev/null +++ b/internal/cli/agent_integration_test.go @@ -0,0 +1,397 @@ +package cli + +import ( + "archive/tar" + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/buildkite/cleanroom/internal/backend" + "github.com/buildkite/cleanroom/internal/controlclient" + "github.com/buildkite/cleanroom/internal/endpoint" + cleanroomv1 "github.com/buildkite/cleanroom/internal/gen/cleanroom/v1" + "github.com/buildkite/cleanroom/internal/runtimeconfig" +) + +func runAgentWithCapture(cmd AgentCommand, stdinData string, ctx runtimeContext) execOutcome { + tmpDir, err := os.MkdirTemp("", "cleanroom-agent-codex-test-*") + if err != nil { + return execOutcome{cause: fmt.Errorf("create temp dir: %w", err)} + } + defer os.RemoveAll(tmpDir) + + stdoutPath := filepath.Join(tmpDir, "stdout.log") + stderrPath := filepath.Join(tmpDir, "stderr.log") + + stdoutFile, err := os.Create(stdoutPath) + if err != nil { + return execOutcome{cause: fmt.Errorf("create stdout capture file: %w", err)} + } + defer stdoutFile.Close() + + stderrFile, err := os.Create(stderrPath) + if err != nil { + return execOutcome{cause: fmt.Errorf("create stderr capture file: %w", err)} + } + defer stderrFile.Close() + + stdinReader, stdinWriter, err := os.Pipe() + if err != nil { + return execOutcome{cause: fmt.Errorf("create stdin pipe: %w", err)} + } + if stdinData != "" { + if _, err := io.WriteString(stdinWriter, stdinData); err != nil { + return execOutcome{cause: fmt.Errorf("write stdin payload: %w", err)} + } + } + _ = stdinWriter.Close() + defer stdinReader.Close() + + oldStdin := os.Stdin + oldStderr := os.Stderr + os.Stdin = stdinReader + os.Stderr = stderrFile + defer func() { + os.Stdin = oldStdin + os.Stderr = oldStderr + }() + + ctx.Stdout = stdoutFile + runErr := cmd.Run(&ctx) + + if err := stdoutFile.Sync(); err != nil { + return execOutcome{cause: fmt.Errorf("sync stdout capture: %w", err)} + } + if err := stderrFile.Sync(); err != nil { + return execOutcome{cause: fmt.Errorf("sync stderr capture: %w", err)} + } + + stdoutBytes, err := os.ReadFile(stdoutPath) + if err != nil { + return execOutcome{cause: fmt.Errorf("read stdout capture: %w", err)} + } + stderrBytes, err := os.ReadFile(stderrPath) + if err != nil { + return execOutcome{cause: fmt.Errorf("read stderr capture: %w", err)} + } + + return execOutcome{ + err: runErr, + stdout: string(stdoutBytes), + stderr: string(stderrBytes), + } +} + +func TestAgentIntegrationStartsPersistentSandbox(t *testing.T) { + var gotCommand []string + adapter := &integrationAdapter{ + runStreamFn: func(_ context.Context, req backend.ExecutionRequest, stream backend.OutputStream) (*backend.ExecutionResult, error) { + if !req.TTY { + return nil, errors.New("expected tty execution") + } + gotCommand = append([]string(nil), req.Command...) + if stream.OnStdout != nil { + stream.OnStdout([]byte("codex-ready\n")) + } + return &backend.ExecutionResult{ + ExecutionID: req.ExecutionID, + ExitCode: 0, + Message: "ok", + }, nil + }, + } + + host, _ := startIntegrationServer(t, adapter) + cwd := t.TempDir() + outcome := runAgentWithCapture(AgentCommand{ + clientFlags: clientFlags{Host: host}, + Chdir: cwd, + Agent: "amp", + }, "", runtimeContext{ + CWD: cwd, + Loader: integrationLoader{}, + }) + + if outcome.cause != nil { + t.Fatalf("capture failure: %v", outcome.cause) + } + if outcome.err != nil { + t.Fatalf("AgentCommand.Run returned error: %v", outcome.err) + } + assertAgentShellCommand(t, gotCommand, "amp", "exec amp") + + ep, err := endpoint.Resolve(host) + if err != nil { + t.Fatalf("resolve endpoint: %v", err) + } + client, err := controlclient.New(ep) + if err != nil { + t.Fatalf("create control client: %v", err) + } + listResp, err := client.ListSandboxes(context.Background(), &cleanroomv1.ListSandboxesRequest{}) + if err != nil { + t.Fatalf("ListSandboxes returned error: %v", err) + } + if got, want := len(listResp.GetSandboxes()), 1; got != want { + t.Fatalf("unexpected sandbox count: got %d want %d", got, want) + } + if got, want := listResp.GetSandboxes()[0].GetStatus(), cleanroomv1.SandboxStatus_SANDBOX_STATUS_READY; got != want { + t.Fatalf("unexpected sandbox status: got %v want %v", got, want) + } +} + +func TestAgentIntegrationUsesPolicyImageForCreatedSandbox(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + imageRefCh := make(chan string, 1) + var gotCommand []string + adapter := &integrationAdapter{ + runStreamFn: func(_ context.Context, req backend.ExecutionRequest, _ backend.OutputStream) (*backend.ExecutionResult, error) { + if req.Policy == nil { + return nil, errors.New("expected policy on run request") + } + imageRefCh <- req.Policy.ImageRef + gotCommand = append([]string(nil), req.Command...) + return &backend.ExecutionResult{ + ExecutionID: req.ExecutionID, + ExitCode: 0, + Message: "ok", + }, nil + }, + } + + host, _ := startIntegrationServer(t, adapter) + cwd := t.TempDir() + outcome := runAgentWithCapture(AgentCommand{ + clientFlags: clientFlags{Host: host}, + Chdir: cwd, + Agent: "codex", + }, "", runtimeContext{ + CWD: cwd, + Loader: integrationLoader{}, + }) + + if outcome.cause != nil { + t.Fatalf("capture failure: %v", outcome.cause) + } + if outcome.err != nil { + t.Fatalf("AgentCommand.Run returned error: %v", outcome.err) + } + assertAgentShellCommand(t, gotCommand, "codex", "exec sh -lc 'if command -v codex >/dev/null 2>&1; then exec codex \"$@\"; fi; exec env MISE_YES=1 MISE_TRUSTED_CONFIG_PATHS=/workspace mise --no-config exec -y nodejs@lts -- npm exec --yes --package @openai/codex@latest -- codex \"$@\"' sh") + + gotImageRef := mustReceiveWithin(t, imageRefCh, 2*time.Second, "timed out waiting for run request policy") + if got, want := gotImageRef, integrationPolicyImageRef; got != want { + t.Fatalf("unexpected image ref: got %q want %q", got, want) + } +} + +func TestAgentIntegrationDangerouslyAllowAllSetsAllowNetworkDefault(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + adapter := &snapshotIntegrationAdapter{} + host, _ := startIntegrationServer(t, adapter) + cwd := t.TempDir() + + outcome := runAgentWithCapture(AgentCommand{ + clientFlags: clientFlags{Host: host}, + Chdir: cwd, + DangerouslyAllowAll: true, + Agent: "codex", + }, "", runtimeContext{ + CWD: cwd, + Loader: integrationLoader{}, + }) + + if outcome.cause != nil { + t.Fatalf("capture failure: %v", outcome.cause) + } + if outcome.err != nil { + t.Fatalf("AgentCommand.Run returned error: %v", outcome.err) + } + if adapter.provisionReq.Policy == nil { + t.Fatal("expected provisioned policy") + } + if got, want := adapter.provisionReq.Policy.NetworkDefault, "allow"; got != want { + t.Fatalf("unexpected provisioned network default: got %q want %q", got, want) + } +} + +func TestAgentIntegrationPassesArgsToCommand(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + var gotCommand []string + adapter := &integrationAdapter{ + runStreamFn: func(_ context.Context, req backend.ExecutionRequest, stream backend.OutputStream) (*backend.ExecutionResult, error) { + gotCommand = append([]string(nil), req.Command...) + return &backend.ExecutionResult{ + ExecutionID: req.ExecutionID, + ExitCode: 0, + Message: "ok", + }, nil + }, + } + + host, _ := startIntegrationServer(t, adapter) + cwd := t.TempDir() + outcome := runAgentWithCapture(AgentCommand{ + clientFlags: clientFlags{Host: host}, + Chdir: cwd, + Agent: "codex", + Args: []string{"--", "exec", "--yolo", "fix lint failures"}, + }, "", runtimeContext{ + CWD: cwd, + Loader: integrationLoader{}, + }) + + if outcome.cause != nil { + t.Fatalf("capture failure: %v", outcome.cause) + } + if outcome.err != nil { + t.Fatalf("AgentCommand.Run returned error: %v", outcome.err) + } + assertAgentShellCommand(t, gotCommand, "codex", "exec sh -lc 'if command -v codex >/dev/null 2>&1; then exec codex \"$@\"; fi; exec env MISE_YES=1 MISE_TRUSTED_CONFIG_PATHS=/workspace mise --no-config exec -y nodejs@lts -- npm exec --yes --package @openai/codex@latest -- codex \"$@\"' sh 'exec' '--yolo' 'fix lint failures'") +} + +func TestAgentIntegrationCopiesConfiguredCredentialsBeforeAttach(t *testing.T) { + hostHome := t.TempDir() + sourcePath := filepath.Join(hostHome, ".codex", "auth.json") + if err := os.MkdirAll(filepath.Dir(sourcePath), 0o700); err != nil { + t.Fatalf("mkdir credentials: %v", err) + } + if err := os.WriteFile(sourcePath, []byte(`{"token":"redacted"}`), 0o600); err != nil { + t.Fatalf("write credential: %v", err) + } + + adapter := &agentCredentialCopyAdapter{ + integrationAdapter: &integrationAdapter{ + runStreamFn: func(_ context.Context, req backend.ExecutionRequest, _ backend.OutputStream) (*backend.ExecutionResult, error) { + if !req.TTY { + return nil, errors.New("expected agent execution to use tty") + } + return &backend.ExecutionResult{ + ExecutionID: req.ExecutionID, + ExitCode: 0, + Message: "ok", + }, nil + }, + }, + } + + host, _ := startIntegrationServer(t, adapter) + cwd := t.TempDir() + outcome := runAgentWithCapture(AgentCommand{ + clientFlags: clientFlags{Host: host}, + Chdir: cwd, + Agent: "codex", + }, "", runtimeContext{ + CWD: cwd, + Loader: integrationLoader{}, + Config: runtimeconfig.Config{ + Agents: map[string]runtimeconfig.Agent{ + "codex": { + Command: "codex", + Test: "command -v codex >/dev/null 2>&1", + Credentials: []runtimeconfig.AgentCredential{ + {Source: sourcePath, Target: "~/.codex/auth.json"}, + }, + }, + }, + }, + }) + + if outcome.cause != nil { + t.Fatalf("capture failure: %v", outcome.cause) + } + if outcome.err != nil { + t.Fatalf("AgentCommand.Run returned error: %v", outcome.err) + } + if got, want := adapter.archiveDestination, "/"; got != want { + t.Fatalf("unexpected credential archive destination: got %q want %q", got, want) + } + assertAgentShellCommand(t, adapter.runReq.Command, "codex", "exec codex") + + tr := tar.NewReader(bytes.NewReader(adapter.archive.Bytes())) + header, err := tr.Next() + if err != nil { + t.Fatalf("read credential archive header: %v", err) + } + if got, want := header.Name, "root/.codex/auth.json"; got != want { + t.Fatalf("unexpected credential archive path: got %q want %q", got, want) + } + body, err := io.ReadAll(tr) + if err != nil { + t.Fatalf("read credential archive body: %v", err) + } + if got, want := string(body), `{"token":"redacted"}`; got != want { + t.Fatalf("unexpected credential archive body: got %q want %q", got, want) + } +} + +type agentCredentialCopyAdapter struct { + *integrationAdapter + + archiveDestination string + archive bytes.Buffer +} + +func (a *agentCredentialCopyAdapter) ExtractSandboxArchive(_ context.Context, _ string, destination string, r io.Reader) (int64, error) { + a.archiveDestination = destination + return io.Copy(&a.archive, r) +} + +func TestAgentIntegrationReusesProvidedSandboxWithoutLoadingPolicy(t *testing.T) { + var gotCommand []string + host, _ := startIntegrationServer(t, &integrationAdapter{ + runStreamFn: func(_ context.Context, req backend.ExecutionRequest, _ backend.OutputStream) (*backend.ExecutionResult, error) { + gotCommand = append([]string(nil), req.Command...) + return &backend.ExecutionResult{ + ExecutionID: req.ExecutionID, + ExitCode: 0, + Message: "ok", + }, nil + }, + }) + client := mustNewControlClient(t, host) + sandboxID := mustCreateSandbox(t, client) + + outcome := runAgentWithCapture(AgentCommand{ + clientFlags: clientFlags{Host: host}, + SandboxID: sandboxID, + Agent: "amp", + }, "", runtimeContext{ + CWD: t.TempDir(), + Loader: failingLoader{}, + }) + + if outcome.cause != nil { + t.Fatalf("capture failure: %v", outcome.cause) + } + if outcome.err != nil { + t.Fatalf("AgentCommand.Run returned error: %v", outcome.err) + } + assertAgentShellCommand(t, gotCommand, "amp", "exec amp") +} + +func assertAgentShellCommand(t *testing.T, got []string, testName, execLine string) { + t.Helper() + if len(got) != 3 || got[0] != "sh" || got[1] != "-lc" { + t.Fatalf("unexpected command wrapper: got %v", got) + } + script := got[2] + if !strings.Contains(script, "command -v '"+testName+"' >/dev/null 2>&1") && + !strings.Contains(script, "command -v "+testName+" >/dev/null 2>&1") && + !strings.Contains(script, "mise exec -- "+testName+" --version >/dev/null 2>&1") && + !strings.Contains(script, "mise --no-config exec -y nodejs@lts -- npm exec --yes --package ") { + t.Fatalf("expected agent test for %q in script:\n%s", testName, script) + } + if !strings.Contains(script, execLine) { + t.Fatalf("expected exec line %q in script:\n%s", execLine, script) + } +} diff --git a/internal/cli/agent_test.go b/internal/cli/agent_test.go new file mode 100644 index 00000000..074856df --- /dev/null +++ b/internal/cli/agent_test.go @@ -0,0 +1,153 @@ +package cli + +import ( + "archive/tar" + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/buildkite/cleanroom/internal/runtimeconfig" +) + +func TestAgentShellCommandUsesStringConfig(t *testing.T) { + script, err := agentShellCommand("codex", []string{"--", "exec", "fix $PATH"}, map[string]runtimeconfig.Agent{ + "codex": { + Command: "mise exec -- codex", + Test: "mise exec -- codex --version >/dev/null 2>&1", + Install: "mise use -g npm:@openai/codex", + }, + }) + if err != nil { + t.Fatalf("agentShellCommand returned error: %v", err) + } + for _, want := range []string{ + "if ! (mise exec -- codex --version >/dev/null 2>&1); then", + "mise use -g npm:@openai/codex", + "exec mise exec -- codex 'exec' 'fix $PATH'", + } { + if !strings.Contains(script, want) { + t.Fatalf("expected script to contain %q:\n%s", want, script) + } + } +} + +func TestAgentShellCommandUsesDefaultAgentConfig(t *testing.T) { + script, err := agentShellCommand("codex", []string{"exec", "summarize"}, nil) + if err != nil { + t.Fatalf("agentShellCommand returned error: %v", err) + } + for _, want := range []string{ + "command -v codex >/dev/null 2>&1 || command -v mise >/dev/null 2>&1", + "exec sh -lc 'if command -v codex >/dev/null 2>&1; then exec codex \"$@\"; fi; exec env MISE_YES=1 MISE_TRUSTED_CONFIG_PATHS=/workspace mise --no-config exec -y nodejs@lts -- npm exec --yes --package @openai/codex@latest -- codex \"$@\"' sh 'exec' 'summarize'", + } { + if !strings.Contains(script, want) { + t.Fatalf("expected script to contain %q:\n%s", want, script) + } + } + if strings.Contains(script, "mise use -g") { + t.Fatalf("did not expect default agent install command:\n%s", script) + } +} + +func TestAgentShellCommandAllowsConfigToOverrideDefaultAgentCommand(t *testing.T) { + script, err := agentShellCommand("codex", []string{"exec"}, map[string]runtimeconfig.Agent{ + "codex": { + Command: "codex-nightly", + Test: "command -v codex-nightly >/dev/null 2>&1", + }, + }) + if err != nil { + t.Fatalf("agentShellCommand returned error: %v", err) + } + if want := "exec codex-nightly 'exec'"; !strings.Contains(script, want) { + t.Fatalf("expected override command %q in script:\n%s", want, script) + } + if strings.Contains(script, "mise exec -- codex") { + t.Fatalf("did not expect default command after override:\n%s", script) + } +} + +func TestAgentCredentialsUseDefaultAgentConfig(t *testing.T) { + credentials := agentCredentials("codex", nil) + if got, want := len(credentials), 2; got != want { + t.Fatalf("unexpected credential count: got %d want %d", got, want) + } + if got, want := credentials[0], (runtimeconfig.AgentCredential{Source: "~/.codex/auth.json", Target: "~/.codex/auth.json"}); got != want { + t.Fatalf("unexpected first credential: got %#v want %#v", got, want) + } + if got, want := credentials[1], (runtimeconfig.AgentCredential{Source: "~/.codex/config.toml", Target: "~/.codex/config.toml"}); got != want { + t.Fatalf("unexpected second credential: got %#v want %#v", got, want) + } +} + +func TestAgentCredentialArchiveTrustsWorkspaceForCodexConfig(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(configPath, []byte("model = \"gpt-5.5\"\n"), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + archive, err := agentCredentialArchive("codex", map[string]runtimeconfig.Agent{ + "codex": { + Credentials: []runtimeconfig.AgentCredential{ + {Source: configPath, Target: "~/.codex/config.toml"}, + }, + }, + }) + if err != nil { + t.Fatalf("agentCredentialArchive returned error: %v", err) + } + + entries := readTarEntries(t, archive) + got := entries["root/.codex/config.toml"] + for _, want := range []string{ + `model = "gpt-5.5"`, + `[projects."/workspace"]`, + `trust_level = "trusted"`, + } { + if !strings.Contains(got, want) { + t.Fatalf("expected copied codex config to contain %q, got:\n%s", want, got) + } + } +} + +func TestCodexConfigWithWorkspaceTrustDoesNotDuplicateExistingTrust(t *testing.T) { + raw := []byte("model = \"gpt-5.5\"\n\n[projects.\"/workspace\"]\ntrust_level = \"trusted\"\n") + got := codexConfigWithWorkspaceTrust(raw) + if !bytes.Equal(got, raw) { + t.Fatalf("expected existing workspace trust to be preserved without changes, got:\n%s", string(got)) + } +} + +func TestAgentShellCommandQuotesSingleQuotes(t *testing.T) { + script, err := agentShellCommand("custom", []string{"it's broken"}, nil) + if err != nil { + t.Fatalf("agentShellCommand returned error: %v", err) + } + if want := "exec custom 'it'\"'\"'s broken'"; !strings.Contains(script, want) { + t.Fatalf("expected shell-quoted argument %q in script:\n%s", want, script) + } +} + +func readTarEntries(t *testing.T, archive []byte) map[string]string { + t.Helper() + tr := tar.NewReader(bytes.NewReader(archive)) + entries := map[string]string{} + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("read tar header: %v", err) + } + body, err := io.ReadAll(tr) + if err != nil { + t.Fatalf("read tar body: %v", err) + } + entries[header.Name] = string(body) + } + return entries +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index bdfe4d9d..0e82a556 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "sort" "strings" "sync" "time" @@ -55,6 +56,7 @@ type CLI struct { Config ConfigCommand `cmd:"" help:"Runtime config commands"` Image ImageCommand `cmd:"" help:"Manage OCI image cache artifacts"` Inspect InspectCommand `cmd:"" help:"Inspect a sandbox, execution, or snapshot by ID"` + Agent AgentCommand `cmd:"" help:"Run long-lived agent workflows"` Snapshot SnapshotCommand `cmd:"" help:"Manage snapshots"` Create CreateCommand `cmd:"" help:"Create a sandbox using repo policy"` Exec ExecCommand `cmd:"" help:"Execute a command in a sandbox"` @@ -137,6 +139,9 @@ func Run(args []string, version string) (runErr error) { &cli, kong.Name("cleanroom"), kong.Description("Cleanroom CLI"), + kong.Vars{ + "agent_names": agentNamesForParser(), + }, ) if err != nil { return err @@ -188,6 +193,32 @@ func Run(args []string, version string) (runErr error) { return runErr } +func agentNamesForParser() string { + cfg, _, err := runtimeconfig.Load() + if err != nil { + cfg = runtimeconfig.Config{} + } + names := map[string]struct{}{} + for name := range defaultRuntimeAgentConfig() { + name = strings.TrimSpace(name) + if name != "" { + names[name] = struct{}{} + } + } + for name := range cfg.Agents { + name = strings.TrimSpace(name) + if name != "" { + names[name] = struct{}{} + } + } + out := make([]string, 0, len(names)) + for name := range names { + out = append(out, name) + } + sort.Strings(out) + return strings.Join(out, ",") +} + func commandBypassesStartupRuntimeConfig(ctx *kong.Context) bool { if ctx == nil { return false diff --git a/internal/cli/cli_parse_test.go b/internal/cli/cli_parse_test.go index a2071661..0c767771 100644 --- a/internal/cli/cli_parse_test.go +++ b/internal/cli/cli_parse_test.go @@ -3,7 +3,10 @@ package cli import ( "bytes" "errors" + "os" + "path/filepath" "reflect" + "slices" "strings" "testing" @@ -17,6 +20,9 @@ func newParserForTest(t *testing.T, c *CLI) *kong.Kong { c, kong.Name("cleanroom"), kong.Description("Cleanroom CLI (MVP)"), + kong.Vars{ + "agent_names": "amp,claude,codex,gemini,opencode", + }, ) if err != nil { t.Fatalf("create parser: %v", err) @@ -141,6 +147,97 @@ func TestConfigValidateParses(t *testing.T) { } } +func TestAgentParsesCommandWithoutArgs(t *testing.T) { + c := &CLI{} + parser := newParserForTest(t, c) + + if _, err := parser.Parse([]string{"agent", "amp"}); err != nil { + t.Fatalf("parse agent returned error: %v", err) + } + if got, want := c.Agent.Agent, "amp"; got != want { + t.Fatalf("unexpected agent name: got %q want %q", got, want) + } + if got := len(c.Agent.Args); got != 0 { + t.Fatalf("expected no agent args, got %v", c.Agent.Args) + } +} + +func TestAgentParsesCommandAfterSeparator(t *testing.T) { + c := &CLI{} + parser := newParserForTest(t, c) + + if _, err := parser.Parse([]string{"agent", "--", "amp"}); err != nil { + t.Fatalf("parse agent command after separator returned error: %v", err) + } + if got, want := c.Agent.Agent, "amp"; got != want { + t.Fatalf("unexpected agent name: got %q want %q", got, want) + } +} + +func TestAgentPassesThroughArgs(t *testing.T) { + c := &CLI{} + parser := newParserForTest(t, c) + + if _, err := parser.Parse([]string{"agent", "codex", "--yolo", "--model", "gpt-5.3-codex"}); err != nil { + t.Fatalf("parse agent args returned error: %v", err) + } + if got, want := c.Agent.Agent, "codex"; got != want { + t.Fatalf("unexpected agent name: got %q want %q", got, want) + } + if got, want := strings.Join(c.Agent.Args, " "), "--yolo --model gpt-5.3-codex"; got != want { + t.Fatalf("unexpected agent args: got %q want %q", got, want) + } +} + +func TestAgentParsesDangerouslyAllowAll(t *testing.T) { + c := &CLI{} + parser := newParserForTest(t, c) + + if _, err := parser.Parse([]string{"agent", "--dangerously-allow-all", "codex"}); err != nil { + t.Fatalf("parse agent --dangerously-allow-all returned error: %v", err) + } + if !c.Agent.DangerouslyAllowAll { + t.Fatal("expected agent dangerously-allow-all flag to be set") + } +} + +func TestAgentRejectsUnknownAgentName(t *testing.T) { + c := &CLI{} + parser := newParserForTest(t, c) + + _, err := parser.Parse([]string{"agent", "unknown"}) + if err == nil { + t.Fatal("expected parse error for unknown agent") + } + if !strings.Contains(err.Error(), "must be one of") || !strings.Contains(err.Error(), "unknown") { + t.Fatalf("expected enum parse error, got %v", err) + } +} + +func TestAgentNamesForParserIncludesConfiguredAgents(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + configPath := filepath.Join(tmp, "cleanroom", "config.yaml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatalf("mkdir config dir: %v", err) + } + if err := os.WriteFile(configPath, []byte(`default_backend: darwin-vz +agents: + amp: + command: amp +backends: + darwin-vz: + rootfs: /tmp/rootfs +`), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + names := strings.Split(agentNamesForParser(), ",") + if !slices.Contains(names, "amp") || !slices.Contains(names, "codex") { + t.Fatalf("expected configured and default agent names, got %v", names) + } +} + func TestSnapshotInspectParses(t *testing.T) { c := &CLI{} parser := newParserForTest(t, c) diff --git a/internal/cli/config_init_test.go b/internal/cli/config_init_test.go index 4c9398f8..73132425 100644 --- a/internal/cli/config_init_test.go +++ b/internal/cli/config_init_test.go @@ -65,6 +65,21 @@ func TestConfigInitWritesRuntimeConfig(t *testing.T) { if got := strings.TrimSpace(cfg.DefaultBackend); got == "" { t.Fatal("expected default_backend to be populated") } + if got, want := cfg.Agents["codex"].Command, defaultAgentCommand("codex", "@openai/codex"); got != want { + t.Fatalf("expected generated config to include codex command %q, got %q", want, got) + } + if got, want := cfg.Agents["claude"].Command, defaultAgentCommand("claude", "@anthropic-ai/claude-code"); got != want { + t.Fatalf("expected generated config to include claude command %q, got %q", want, got) + } + if got, want := cfg.Agents["gemini"].Command, defaultAgentCommand("gemini", "@google/gemini-cli"); got != want { + t.Fatalf("expected generated config to include gemini command %q, got %q", want, got) + } + if got, want := cfg.Agents["opencode"].Command, defaultAgentCommand("opencode", "opencode-ai"); got != want { + t.Fatalf("expected generated config to include opencode command %q, got %q", want, got) + } + if got := cfg.Agents["codex"].Credentials; len(got) == 0 { + t.Fatal("expected generated config to include codex credential paths") + } if strings.Contains(string(raw), "default_backend:") { t.Fatalf("expected generated config to omit default_backend when only one backend is defined, got:\n%s", raw) } diff --git a/internal/cli/copy.go b/internal/cli/copy.go index b1f5e10c..409d443a 100644 --- a/internal/cli/copy.go +++ b/internal/cli/copy.go @@ -253,6 +253,40 @@ func applyLocalCopyMetadata(path string, info *cleanroomv1.SandboxPathInfo) erro return nil } +func extractSandboxArchive(ctx context.Context, client *controlclient.Client, sandboxID, destination string, r io.Reader) error { + stream := client.ExtractSandboxArchive(ctx) + if err := stream.Send(&cleanroomv1.ExtractSandboxArchiveRequest{ + Payload: &cleanroomv1.ExtractSandboxArchiveRequest_Init{Init: &cleanroomv1.ExtractSandboxArchiveInit{ + SandboxId: sandboxID, + Destination: destination, + }}, + }); err != nil { + return fmt.Errorf("start sandbox archive extract: %w", err) + } + + buf := make([]byte, 32*1024) + for { + n, readErr := r.Read(buf) + if n > 0 { + if err := stream.Send(&cleanroomv1.ExtractSandboxArchiveRequest{ + Payload: &cleanroomv1.ExtractSandboxArchiveRequest_Data{Data: append([]byte(nil), buf[:n]...)}, + }); err != nil { + return fmt.Errorf("extract sandbox archive: %w", err) + } + } + if readErr != nil { + if !errors.Is(readErr, io.EOF) { + return fmt.Errorf("read sandbox archive payload: %w", readErr) + } + break + } + } + if _, err := stream.CloseAndReceive(); err != nil { + return fmt.Errorf("extract sandbox archive: %w", err) + } + return nil +} + func parseCopyOperand(spec string) (copyOperand, error) { if strings.Contains(spec, "\x00") { return copyOperand{}, errors.New("path contains NUL") diff --git a/internal/cli/policy_config.go b/internal/cli/policy_config.go index a21be317..eaf7278a 100644 --- a/internal/cli/policy_config.go +++ b/internal/cli/policy_config.go @@ -224,8 +224,9 @@ func defaultDarwinVZSnapshotConfig(emitRuntimeWarnings bool) (runtimeconfig.Snap } type runtimeConfigTemplate struct { - DefaultBackend string `yaml:"default_backend,omitempty"` - Backends runtimeConfigTemplateNodes `yaml:"backends"` + DefaultBackend string `yaml:"default_backend,omitempty"` + Agents map[string]runtimeconfig.Agent `yaml:"agents,omitempty"` + Backends runtimeConfigTemplateNodes `yaml:"backends"` } type runtimeConfigTemplateNodes struct { @@ -279,6 +280,7 @@ type runtimeConfigSnapshot struct { func defaultRuntimeConfig(defaultBackend string, firecrackerSnapshots, darwinVZSnapshots runtimeconfig.SnapshotConfig) runtimeConfigTemplate { tpl := runtimeConfigTemplate{ + Agents: defaultRuntimeAgentConfig(), Backends: runtimeConfigTemplateNodes{}, } @@ -326,6 +328,48 @@ func defaultRuntimeConfig(defaultBackend string, firecrackerSnapshots, darwinVZS return tpl } +func defaultRuntimeAgentConfig() map[string]runtimeconfig.Agent { + return map[string]runtimeconfig.Agent{ + "codex": { + Command: defaultAgentCommand("codex", "@openai/codex"), + Test: defaultAgentTest("codex"), + Credentials: []runtimeconfig.AgentCredential{ + {Source: "~/.codex/auth.json", Target: "~/.codex/auth.json"}, + {Source: "~/.codex/config.toml", Target: "~/.codex/config.toml"}, + }, + }, + "claude": { + Command: defaultAgentCommand("claude", "@anthropic-ai/claude-code"), + Test: defaultAgentTest("claude"), + Credentials: []runtimeconfig.AgentCredential{ + {Source: "~/.claude", Target: "~/.claude"}, + }, + }, + "gemini": { + Command: defaultAgentCommand("gemini", "@google/gemini-cli"), + Test: defaultAgentTest("gemini"), + Credentials: []runtimeconfig.AgentCredential{ + {Source: "~/.gemini", Target: "~/.gemini"}, + }, + }, + "opencode": { + Command: defaultAgentCommand("opencode", "opencode-ai"), + Test: defaultAgentTest("opencode"), + Credentials: []runtimeconfig.AgentCredential{ + {Source: "~/.config/opencode", Target: "~/.config/opencode"}, + }, + }, + } +} + +func defaultAgentCommand(binary, npmPackage string) string { + return fmt.Sprintf(`sh -lc 'if command -v %[1]s >/dev/null 2>&1; then exec %[1]s "$@"; fi; exec env MISE_YES=1 MISE_TRUSTED_CONFIG_PATHS=/workspace mise --no-config exec -y nodejs@lts -- npm exec --yes --package %[2]s@latest -- %[1]s "$@"' sh`, binary, npmPackage) +} + +func defaultAgentTest(binary string) string { + return fmt.Sprintf("command -v %s >/dev/null 2>&1 || command -v mise >/dev/null 2>&1", binary) +} + func marshalRuntimeConfigTemplate(cfg runtimeConfigTemplate) ([]byte, error) { var buf bytes.Buffer enc := yaml.NewEncoder(&buf) diff --git a/internal/runtimeconfig/config.go b/internal/runtimeconfig/config.go index 4f2efe4d..0fd865c9 100644 --- a/internal/runtimeconfig/config.go +++ b/internal/runtimeconfig/config.go @@ -22,10 +22,23 @@ type Config struct { DefaultBackend string `yaml:"default_backend"` ControlHost string `yaml:"control_host,omitempty"` Gateway GatewayConfig `yaml:"gateway,omitempty"` + Agents map[string]Agent `yaml:"agents,omitempty"` Observability ObservabilityConfig `yaml:"observability,omitempty"` Backends Backends `yaml:"backends"` } +type Agent struct { + Command string `yaml:"command,omitempty"` + Test string `yaml:"test,omitempty"` + Install string `yaml:"install,omitempty"` + Credentials []AgentCredential `yaml:"credentials,omitempty"` +} + +type AgentCredential struct { + Source string `yaml:"source,omitempty"` + Target string `yaml:"target,omitempty"` +} + type GatewayConfig struct { Git GatewayGitConfig `yaml:"git,omitempty"` OCI GatewayOCIConfig `yaml:"oci,omitempty"` @@ -73,6 +86,7 @@ type configFile struct { DefaultBackend string `yaml:"default_backend"` ControlHost string `yaml:"control_host,omitempty"` Gateway GatewayConfig `yaml:"gateway,omitempty"` + Agents map[string]Agent `yaml:"agents,omitempty"` Observability ObservabilityConfig `yaml:"observability,omitempty"` Backends backendsFile `yaml:"backends"` } @@ -88,6 +102,7 @@ func (f configFile) config() Config { DefaultBackend: f.DefaultBackend, ControlHost: f.ControlHost, Gateway: f.Gateway, + Agents: f.Agents, Observability: f.Observability, Backends: Backends{ Firecracker: f.Backends.Firecracker, @@ -379,6 +394,7 @@ func normalizeConfig(cfg Config, inferredDefaultBackend string) Config { cfg.ControlHost = strings.TrimSpace(cfg.ControlHost) cfg.Gateway.Git.CacheHosts = trimStringSlice(cfg.Gateway.Git.CacheHosts) cfg.Gateway.OCI.Registries = trimStringMap(cfg.Gateway.OCI.Registries) + cfg.Agents = normalizeAgents(cfg.Agents) cfg.Observability.DeploymentEnvironment = strings.TrimSpace(cfg.Observability.DeploymentEnvironment) cfg.Observability.Logs.Format = strings.ToLower(strings.TrimSpace(cfg.Observability.Logs.Format)) if cfg.Observability.Logs.Format == "" { @@ -393,6 +409,50 @@ func normalizeConfig(cfg Config, inferredDefaultBackend string) Config { return cfg } +func normalizeAgents(in map[string]Agent) map[string]Agent { + if len(in) == 0 { + return nil + } + out := make(map[string]Agent, len(in)) + for name, agent := range in { + trimmedName := strings.TrimSpace(name) + if trimmedName == "" { + continue + } + agent.Command = strings.TrimSpace(agent.Command) + agent.Test = strings.TrimSpace(agent.Test) + agent.Install = strings.TrimSpace(agent.Install) + agent.Credentials = normalizeAgentCredentials(agent.Credentials) + out[trimmedName] = agent + } + if len(out) == 0 { + return nil + } + return out +} + +func normalizeAgentCredentials(in []AgentCredential) []AgentCredential { + if len(in) == 0 { + return nil + } + out := make([]AgentCredential, 0, len(in)) + for _, credential := range in { + credential.Source = strings.TrimSpace(credential.Source) + credential.Target = strings.TrimSpace(credential.Target) + if credential.Source == "" { + continue + } + if credential.Target == "" { + credential.Target = credential.Source + } + out = append(out, credential) + } + if len(out) == 0 { + return nil + } + return out +} + func inferredDefaultBackend(hasFirecracker, hasDarwinVZ bool) string { if hasFirecracker == hasDarwinVZ { return DefaultBackendForHost() diff --git a/internal/runtimeconfig/config_test.go b/internal/runtimeconfig/config_test.go index 5730fbc9..1ebcaec8 100644 --- a/internal/runtimeconfig/config_test.go +++ b/internal/runtimeconfig/config_test.go @@ -64,6 +64,56 @@ backends: } } +func TestLoadSupportsAgentStringConfig(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + configPath := filepath.Join(tmp, "cleanroom", "config.yaml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatalf("mkdir config dir: %v", err) + } + + content := `default_backend: darwin-vz +agents: + codex: + command: mise exec -- codex + test: mise exec -- codex --version >/dev/null 2>&1 + install: mise use -g npm:@openai/codex + credentials: + - source: ~/.codex/auth.json + target: ~/.codex/auth.json +backends: + darwin-vz: + rootfs: /tmp/rootfs +` + if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, _, err := Load() + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + agent := cfg.Agents["codex"] + if got, want := agent.Command, "mise exec -- codex"; got != want { + t.Fatalf("unexpected agent command: got %q want %q", got, want) + } + if got, want := agent.Test, "mise exec -- codex --version >/dev/null 2>&1"; got != want { + t.Fatalf("unexpected agent test: got %q want %q", got, want) + } + if got, want := agent.Install, "mise use -g npm:@openai/codex"; got != want { + t.Fatalf("unexpected agent install: got %q want %q", got, want) + } + if got, want := len(agent.Credentials), 1; got != want { + t.Fatalf("unexpected credential count: got %d want %d", got, want) + } + if got, want := agent.Credentials[0].Source, "~/.codex/auth.json"; got != want { + t.Fatalf("unexpected credential source: got %q want %q", got, want) + } + if got, want := agent.Credentials[0].Target, "~/.codex/auth.json"; got != want { + t.Fatalf("unexpected credential target: got %q want %q", got, want) + } +} + func TestLoadSupportsDarwinVZMinimumRootFSBytes(t *testing.T) { tmp := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tmp)