Skip to content

Commit fedf12d

Browse files
committed
feat: copy agent credentials into sandboxes
1 parent ea6d2b2 commit fedf12d

9 files changed

Lines changed: 407 additions & 7 deletions

File tree

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,16 +230,36 @@ agents:
230230
command: mise exec -- codex
231231
test: mise exec -- codex --version >/dev/null 2>&1
232232
install: mise use -g npm:@openai/codex
233+
credentials:
234+
- source: ~/.codex/auth.json
235+
target: ~/.codex/auth.json
236+
- source: ~/.codex/config.toml
237+
target: ~/.codex/config.toml
233238
claude:
234239
command: mise exec -- claude
235240
test: mise exec -- claude --version >/dev/null 2>&1
236241
install: mise use -g npm:@anthropic-ai/claude-code
242+
credentials:
243+
- source: ~/.claude
244+
target: ~/.claude
237245
gemini:
238246
command: mise exec -- gemini
239247
test: mise exec -- gemini --version >/dev/null 2>&1
240248
install: mise use -g npm:@google/gemini-cli
249+
credentials:
250+
- source: ~/.gemini
251+
target: ~/.gemini
252+
opencode:
253+
command: mise exec -- opencode
254+
test: mise exec -- opencode --version >/dev/null 2>&1
255+
install: mise use -g npm:opencode-ai
256+
credentials:
257+
- source: ~/.config/opencode
258+
target: ~/.config/opencode
241259
```
242260

261+
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.
262+
243263
The Debian agents image uses the same package sources with pinned versions for reproducible image builds, then symlinks the resulting mise shims into `/usr/local/bin`.
244264

245265
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.
@@ -464,14 +484,32 @@ agents:
464484
command: mise exec -- codex
465485
test: mise exec -- codex --version >/dev/null 2>&1
466486
install: mise use -g npm:@openai/codex
487+
credentials:
488+
- source: ~/.codex/auth.json
489+
target: ~/.codex/auth.json
490+
- source: ~/.codex/config.toml
491+
target: ~/.codex/config.toml
467492
claude:
468493
command: mise exec -- claude
469494
test: mise exec -- claude --version >/dev/null 2>&1
470495
install: mise use -g npm:@anthropic-ai/claude-code
496+
credentials:
497+
- source: ~/.claude
498+
target: ~/.claude
471499
gemini:
472500
command: mise exec -- gemini
473501
test: mise exec -- gemini --version >/dev/null 2>&1
474502
install: mise use -g npm:@google/gemini-cli
503+
credentials:
504+
- source: ~/.gemini
505+
target: ~/.gemini
506+
opencode:
507+
command: mise exec -- opencode
508+
test: mise exec -- opencode --version >/dev/null 2>&1
509+
install: mise use -g npm:opencode-ai
510+
credentials:
511+
- source: ~/.config/opencode
512+
target: ~/.config/opencode
475513
backends:
476514
firecracker:
477515
binary_path: firecracker

internal/cli/agent.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
package cli
22

33
import (
4+
"archive/tar"
5+
"bytes"
6+
"context"
47
"fmt"
8+
"io"
9+
"io/fs"
10+
"os"
11+
"path/filepath"
512
"strings"
613

14+
"github.com/buildkite/cleanroom/internal/controlclient"
715
"github.com/buildkite/cleanroom/internal/runtimeconfig"
816
)
917

@@ -25,6 +33,10 @@ func (a *AgentCommand) Run(ctx *runtimeContext) error {
2533
if err != nil {
2634
return err
2735
}
36+
credentials, err := agentCredentialArchive(a.Command, ctx.Config.Agents)
37+
if err != nil {
38+
return err
39+
}
2840

2941
console := ConsoleCommand{
3042
clientFlags: a.clientFlags,
@@ -35,6 +47,11 @@ func (a *AgentCommand) Run(ctx *runtimeContext) error {
3547
LaunchSeconds: a.LaunchSeconds,
3648
Command: []string{"sh", "-lc", command},
3749
}
50+
if len(credentials) > 0 {
51+
console.preAttach = func(callCtx context.Context, client *controlclient.Client, sandboxID string) error {
52+
return extractAgentCredentialArchive(callCtx, client, sandboxID, credentials)
53+
}
54+
}
3855
return console.Run(ctx)
3956
}
4057

@@ -85,6 +102,156 @@ func agentShellCommand(name string, rawArgs []string, agents map[string]runtimec
85102
return script.String(), nil
86103
}
87104

105+
func agentCredentialArchive(name string, agents map[string]runtimeconfig.Agent) ([]byte, error) {
106+
name = strings.TrimSpace(name)
107+
if name == "" {
108+
return nil, nil
109+
}
110+
111+
credentials := agentCredentials(name, agents)
112+
if len(credentials) == 0 {
113+
return nil, nil
114+
}
115+
116+
var buf bytes.Buffer
117+
tw := tar.NewWriter(&buf)
118+
wrote := false
119+
for _, credential := range credentials {
120+
source, err := expandHomePath(credential.Source)
121+
if err != nil {
122+
return nil, err
123+
}
124+
info, err := os.Lstat(source)
125+
if err != nil {
126+
if os.IsNotExist(err) {
127+
continue
128+
}
129+
return nil, fmt.Errorf("read agent credential %q: %w", credential.Source, err)
130+
}
131+
target, err := guestCredentialPath(credential.Target)
132+
if err != nil {
133+
return nil, err
134+
}
135+
if err := addCredentialToArchive(tw, source, target, info); err != nil {
136+
return nil, err
137+
}
138+
wrote = true
139+
}
140+
if err := tw.Close(); err != nil {
141+
return nil, fmt.Errorf("write agent credential archive: %w", err)
142+
}
143+
if !wrote {
144+
return nil, nil
145+
}
146+
return buf.Bytes(), nil
147+
}
148+
149+
func agentCredentials(name string, agents map[string]runtimeconfig.Agent) []runtimeconfig.AgentCredential {
150+
if agent, ok := agents[name]; ok && len(agent.Credentials) > 0 {
151+
return append([]runtimeconfig.AgentCredential(nil), agent.Credentials...)
152+
}
153+
return nil
154+
}
155+
156+
func extractAgentCredentialArchive(ctx context.Context, client *controlclient.Client, sandboxID string, archive []byte) error {
157+
if err := extractSandboxArchive(ctx, client, sandboxID, "/", bytes.NewReader(archive)); err != nil {
158+
return fmt.Errorf("copy agent credentials: %w", err)
159+
}
160+
return nil
161+
}
162+
163+
func addCredentialToArchive(tw *tar.Writer, source, target string, info fs.FileInfo) error {
164+
if info.IsDir() {
165+
return filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error {
166+
if err != nil {
167+
return fmt.Errorf("walk agent credential %q: %w", source, err)
168+
}
169+
entryInfo, err := entry.Info()
170+
if err != nil {
171+
return fmt.Errorf("stat agent credential %q: %w", path, err)
172+
}
173+
rel, err := filepath.Rel(source, path)
174+
if err != nil {
175+
return err
176+
}
177+
entryTarget := target
178+
if rel != "." {
179+
entryTarget = filepath.ToSlash(filepath.Join(target, rel))
180+
}
181+
return writeCredentialArchiveEntry(tw, path, entryTarget, entryInfo)
182+
})
183+
}
184+
return writeCredentialArchiveEntry(tw, source, target, info)
185+
}
186+
187+
func writeCredentialArchiveEntry(tw *tar.Writer, source, target string, info fs.FileInfo) error {
188+
link := ""
189+
if info.Mode()&os.ModeSymlink != 0 {
190+
var err error
191+
link, err = os.Readlink(source)
192+
if err != nil {
193+
return fmt.Errorf("read agent credential symlink %q: %w", source, err)
194+
}
195+
}
196+
header, err := tar.FileInfoHeader(info, link)
197+
if err != nil {
198+
return fmt.Errorf("create agent credential archive header %q: %w", source, err)
199+
}
200+
header.Name = target
201+
if err := tw.WriteHeader(header); err != nil {
202+
return fmt.Errorf("write agent credential archive header %q: %w", source, err)
203+
}
204+
if !info.Mode().IsRegular() {
205+
return nil
206+
}
207+
file, err := os.Open(source)
208+
if err != nil {
209+
return fmt.Errorf("open agent credential %q: %w", source, err)
210+
}
211+
defer file.Close()
212+
if _, err := io.Copy(tw, file); err != nil {
213+
return fmt.Errorf("write agent credential %q: %w", source, err)
214+
}
215+
return nil
216+
}
217+
218+
func expandHomePath(path string) (string, error) {
219+
path = strings.TrimSpace(path)
220+
if path == "" {
221+
return "", fmt.Errorf("agent credential source is required")
222+
}
223+
if path == "~" || strings.HasPrefix(path, "~/") {
224+
home, err := os.UserHomeDir()
225+
if err != nil {
226+
return "", fmt.Errorf("resolve home directory: %w", err)
227+
}
228+
if path == "~" {
229+
return home, nil
230+
}
231+
return filepath.Join(home, path[2:]), nil
232+
}
233+
return path, nil
234+
}
235+
236+
func guestCredentialPath(path string) (string, error) {
237+
path = strings.TrimSpace(path)
238+
if path == "" {
239+
return "", fmt.Errorf("agent credential target is required")
240+
}
241+
switch {
242+
case path == "~":
243+
path = "/root"
244+
case strings.HasPrefix(path, "~/"):
245+
path = "/root/" + path[2:]
246+
}
247+
path = filepath.ToSlash(filepath.Clean(path))
248+
path = strings.TrimPrefix(path, "/")
249+
if path == "." || path == "" || strings.HasPrefix(path, "../") || path == ".." {
250+
return "", fmt.Errorf("invalid agent credential target %q", path)
251+
}
252+
return path, nil
253+
}
254+
88255
func shellQuote(s string) string {
89256
if s == "" {
90257
return "''"

internal/cli/agent_integration_test.go

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package cli
22

33
import (
4+
"archive/tar"
5+
"bytes"
46
"context"
57
"errors"
68
"fmt"
@@ -15,6 +17,7 @@ import (
1517
"github.com/buildkite/cleanroom/internal/controlclient"
1618
"github.com/buildkite/cleanroom/internal/endpoint"
1719
cleanroomv1 "github.com/buildkite/cleanroom/internal/gen/cleanroom/v1"
20+
"github.com/buildkite/cleanroom/internal/runtimeconfig"
1821
)
1922

2023
func runAgentWithCapture(cmd AgentCommand, stdinData string, ctx runtimeContext) execOutcome {
@@ -100,7 +103,6 @@ func TestAgentIntegrationStartsPersistentSandbox(t *testing.T) {
100103
return &backend.ExecutionResult{
101104
ExecutionID: req.ExecutionID,
102105
ExitCode: 0,
103-
Stdout: "codex-ready\n",
104106
Message: "ok",
105107
}, nil
106108
},
@@ -222,6 +224,92 @@ func TestAgentIntegrationPassesArgsToCommand(t *testing.T) {
222224
assertAgentShellCommand(t, gotCommand, "codex", "exec codex 'exec' '--yolo' 'fix lint failures'")
223225
}
224226

227+
func TestAgentIntegrationCopiesConfiguredCredentialsBeforeAttach(t *testing.T) {
228+
hostHome := t.TempDir()
229+
sourcePath := filepath.Join(hostHome, ".codex", "auth.json")
230+
if err := os.MkdirAll(filepath.Dir(sourcePath), 0o700); err != nil {
231+
t.Fatalf("mkdir credentials: %v", err)
232+
}
233+
if err := os.WriteFile(sourcePath, []byte(`{"token":"redacted"}`), 0o600); err != nil {
234+
t.Fatalf("write credential: %v", err)
235+
}
236+
237+
adapter := &agentCredentialCopyAdapter{
238+
integrationAdapter: &integrationAdapter{
239+
runStreamFn: func(_ context.Context, req backend.ExecutionRequest, _ backend.OutputStream) (*backend.ExecutionResult, error) {
240+
if !req.TTY {
241+
return nil, errors.New("expected agent execution to use tty")
242+
}
243+
return &backend.ExecutionResult{
244+
ExecutionID: req.ExecutionID,
245+
ExitCode: 0,
246+
Message: "ok",
247+
}, nil
248+
},
249+
},
250+
}
251+
252+
host, _ := startIntegrationServer(t, adapter)
253+
cwd := t.TempDir()
254+
outcome := runAgentWithCapture(AgentCommand{
255+
clientFlags: clientFlags{Host: host},
256+
Chdir: cwd,
257+
Command: "codex",
258+
}, "", runtimeContext{
259+
CWD: cwd,
260+
Loader: integrationLoader{},
261+
Config: runtimeconfig.Config{
262+
Agents: map[string]runtimeconfig.Agent{
263+
"codex": {
264+
Command: "codex",
265+
Credentials: []runtimeconfig.AgentCredential{
266+
{Source: sourcePath, Target: "~/.codex/auth.json"},
267+
},
268+
},
269+
},
270+
},
271+
})
272+
273+
if outcome.cause != nil {
274+
t.Fatalf("capture failure: %v", outcome.cause)
275+
}
276+
if outcome.err != nil {
277+
t.Fatalf("AgentCommand.Run returned error: %v", outcome.err)
278+
}
279+
if got, want := adapter.archiveDestination, "/"; got != want {
280+
t.Fatalf("unexpected credential archive destination: got %q want %q", got, want)
281+
}
282+
assertAgentShellCommand(t, adapter.runReq.Command, "codex", "exec codex")
283+
284+
tr := tar.NewReader(bytes.NewReader(adapter.archive.Bytes()))
285+
header, err := tr.Next()
286+
if err != nil {
287+
t.Fatalf("read credential archive header: %v", err)
288+
}
289+
if got, want := header.Name, "root/.codex/auth.json"; got != want {
290+
t.Fatalf("unexpected credential archive path: got %q want %q", got, want)
291+
}
292+
body, err := io.ReadAll(tr)
293+
if err != nil {
294+
t.Fatalf("read credential archive body: %v", err)
295+
}
296+
if got, want := string(body), `{"token":"redacted"}`; got != want {
297+
t.Fatalf("unexpected credential archive body: got %q want %q", got, want)
298+
}
299+
}
300+
301+
type agentCredentialCopyAdapter struct {
302+
*integrationAdapter
303+
304+
archiveDestination string
305+
archive bytes.Buffer
306+
}
307+
308+
func (a *agentCredentialCopyAdapter) ExtractSandboxArchive(_ context.Context, _ string, destination string, r io.Reader) (int64, error) {
309+
a.archiveDestination = destination
310+
return io.Copy(&a.archive, r)
311+
}
312+
225313
func TestAgentIntegrationReusesProvidedSandboxWithoutLoadingPolicy(t *testing.T) {
226314
var gotCommand []string
227315
host, _ := startIntegrationServer(t, &integrationAdapter{

internal/cli/config_init_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ func TestConfigInitWritesRuntimeConfig(t *testing.T) {
7474
if got, want := cfg.Agents["gemini"].Install, "mise use -g npm:@google/gemini-cli"; got != want {
7575
t.Fatalf("expected generated config to include gemini install command %q, got %q", want, got)
7676
}
77+
if got, want := cfg.Agents["opencode"].Install, "mise use -g npm:opencode-ai"; got != want {
78+
t.Fatalf("expected generated config to include opencode install command %q, got %q", want, got)
79+
}
80+
if got := cfg.Agents["codex"].Credentials; len(got) == 0 {
81+
t.Fatal("expected generated config to include codex credential paths")
82+
}
7783
if strings.Contains(string(raw), "default_backend:") {
7884
t.Fatalf("expected generated config to omit default_backend when only one backend is defined, got:\n%s", raw)
7985
}

0 commit comments

Comments
 (0)