Skip to content

Commit f4f062b

Browse files
committed
feat: add codex agent shortcut command
Provide a first-class entrypoint so common agent workflows do not require manually composing sandbox and console commands. This keeps the implementation backend-neutral by reusing existing console execution semantics while adding parser and integration coverage for command passthrough and persistent sandbox behavior.
1 parent 7fe067e commit f4f062b

3 files changed

Lines changed: 237 additions & 0 deletions

File tree

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"os"
9+
"path/filepath"
10+
"reflect"
11+
"testing"
12+
13+
"github.com/buildkite/cleanroom/internal/backend"
14+
"github.com/buildkite/cleanroom/internal/controlclient"
15+
"github.com/buildkite/cleanroom/internal/endpoint"
16+
cleanroomv1 "github.com/buildkite/cleanroom/internal/gen/cleanroom/v1"
17+
)
18+
19+
func runAgentCodexWithCapture(cmd AgentCodexCommand, stdinData string, ctx runtimeContext) execOutcome {
20+
tmpDir, err := os.MkdirTemp("", "cleanroom-agent-codex-test-*")
21+
if err != nil {
22+
return execOutcome{cause: fmt.Errorf("create temp dir: %w", err)}
23+
}
24+
defer os.RemoveAll(tmpDir)
25+
26+
stdoutPath := filepath.Join(tmpDir, "stdout.log")
27+
stderrPath := filepath.Join(tmpDir, "stderr.log")
28+
29+
stdoutFile, err := os.Create(stdoutPath)
30+
if err != nil {
31+
return execOutcome{cause: fmt.Errorf("create stdout capture file: %w", err)}
32+
}
33+
defer stdoutFile.Close()
34+
35+
stderrFile, err := os.Create(stderrPath)
36+
if err != nil {
37+
return execOutcome{cause: fmt.Errorf("create stderr capture file: %w", err)}
38+
}
39+
defer stderrFile.Close()
40+
41+
stdinReader, stdinWriter, err := os.Pipe()
42+
if err != nil {
43+
return execOutcome{cause: fmt.Errorf("create stdin pipe: %w", err)}
44+
}
45+
if stdinData != "" {
46+
if _, err := io.WriteString(stdinWriter, stdinData); err != nil {
47+
return execOutcome{cause: fmt.Errorf("write stdin payload: %w", err)}
48+
}
49+
}
50+
_ = stdinWriter.Close()
51+
defer stdinReader.Close()
52+
53+
oldStdin := os.Stdin
54+
oldStderr := os.Stderr
55+
os.Stdin = stdinReader
56+
os.Stderr = stderrFile
57+
defer func() {
58+
os.Stdin = oldStdin
59+
os.Stderr = oldStderr
60+
}()
61+
62+
ctx.Stdout = stdoutFile
63+
runErr := cmd.Run(&ctx)
64+
65+
if err := stdoutFile.Sync(); err != nil {
66+
return execOutcome{cause: fmt.Errorf("sync stdout capture: %w", err)}
67+
}
68+
if err := stderrFile.Sync(); err != nil {
69+
return execOutcome{cause: fmt.Errorf("sync stderr capture: %w", err)}
70+
}
71+
72+
stdoutBytes, err := os.ReadFile(stdoutPath)
73+
if err != nil {
74+
return execOutcome{cause: fmt.Errorf("read stdout capture: %w", err)}
75+
}
76+
stderrBytes, err := os.ReadFile(stderrPath)
77+
if err != nil {
78+
return execOutcome{cause: fmt.Errorf("read stderr capture: %w", err)}
79+
}
80+
81+
return execOutcome{
82+
err: runErr,
83+
stdout: string(stdoutBytes),
84+
stderr: string(stderrBytes),
85+
}
86+
}
87+
88+
func TestAgentCodexIntegrationStartsPersistentSandbox(t *testing.T) {
89+
var gotCommand []string
90+
adapter := &integrationAdapter{
91+
runStreamFn: func(_ context.Context, req backend.RunRequest, stream backend.OutputStream) (*backend.RunResult, error) {
92+
if !req.TTY {
93+
return nil, errors.New("expected tty execution")
94+
}
95+
gotCommand = append([]string(nil), req.Command...)
96+
if stream.OnStdout != nil {
97+
stream.OnStdout([]byte("codex-ready\n"))
98+
}
99+
return &backend.RunResult{
100+
RunID: req.RunID,
101+
ExitCode: 0,
102+
Stdout: "codex-ready\n",
103+
Message: "ok",
104+
}, nil
105+
},
106+
}
107+
108+
host, _ := startIntegrationServer(t, adapter)
109+
cwd := t.TempDir()
110+
outcome := runAgentCodexWithCapture(AgentCodexCommand{
111+
clientFlags: clientFlags{Host: host},
112+
Chdir: cwd,
113+
}, "", runtimeContext{
114+
CWD: cwd,
115+
Loader: integrationLoader{},
116+
})
117+
118+
if outcome.cause != nil {
119+
t.Fatalf("capture failure: %v", outcome.cause)
120+
}
121+
if outcome.err != nil {
122+
t.Fatalf("AgentCodexCommand.Run returned error: %v", outcome.err)
123+
}
124+
if got, want := gotCommand, []string{"codex"}; !reflect.DeepEqual(got, want) {
125+
t.Fatalf("unexpected command: got %v want %v", got, want)
126+
}
127+
128+
ep, err := endpoint.Resolve(host)
129+
if err != nil {
130+
t.Fatalf("resolve endpoint: %v", err)
131+
}
132+
client, err := controlclient.New(ep)
133+
if err != nil {
134+
t.Fatalf("create control client: %v", err)
135+
}
136+
listResp, err := client.ListSandboxes(context.Background(), &cleanroomv1.ListSandboxesRequest{})
137+
if err != nil {
138+
t.Fatalf("ListSandboxes returned error: %v", err)
139+
}
140+
if got, want := len(listResp.GetSandboxes()), 1; got != want {
141+
t.Fatalf("unexpected sandbox count: got %d want %d", got, want)
142+
}
143+
if got, want := listResp.GetSandboxes()[0].GetStatus(), cleanroomv1.SandboxStatus_SANDBOX_STATUS_READY; got != want {
144+
t.Fatalf("unexpected sandbox status: got %v want %v", got, want)
145+
}
146+
}
147+
148+
func TestAgentCodexIntegrationPassesArgsToCodex(t *testing.T) {
149+
var gotCommand []string
150+
adapter := &integrationAdapter{
151+
runStreamFn: func(_ context.Context, req backend.RunRequest, stream backend.OutputStream) (*backend.RunResult, error) {
152+
gotCommand = append([]string(nil), req.Command...)
153+
return &backend.RunResult{
154+
RunID: req.RunID,
155+
ExitCode: 0,
156+
Message: "ok",
157+
}, nil
158+
},
159+
}
160+
161+
host, _ := startIntegrationServer(t, adapter)
162+
cwd := t.TempDir()
163+
outcome := runAgentCodexWithCapture(AgentCodexCommand{
164+
clientFlags: clientFlags{Host: host},
165+
Chdir: cwd,
166+
Args: []string{"exec", "--yolo", "fix lint failures"},
167+
}, "", runtimeContext{
168+
CWD: cwd,
169+
Loader: integrationLoader{},
170+
})
171+
172+
if outcome.cause != nil {
173+
t.Fatalf("capture failure: %v", outcome.cause)
174+
}
175+
if outcome.err != nil {
176+
t.Fatalf("AgentCodexCommand.Run returned error: %v", outcome.err)
177+
}
178+
if got, want := gotCommand, []string{"codex", "exec", "--yolo", "fix lint failures"}; !reflect.DeepEqual(got, want) {
179+
t.Fatalf("unexpected command: got %v want %v", got, want)
180+
}
181+
}

internal/cli/cli.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ type CLI struct {
7272
Policy PolicyCommand `cmd:"" help:"Policy commands"`
7373
Config ConfigCommand `cmd:"" help:"Runtime config commands"`
7474
Image ImageCommand `cmd:"" help:"Manage OCI image cache artifacts"`
75+
Agent AgentCommand `cmd:"" help:"Run long-lived agent workflows"`
7576
Create CreateCommand `cmd:"" help:"Create a sandbox"`
7677
Exec ExecCommand `cmd:"" help:"Execute a command in a cleanroom backend"`
7778
Console ConsoleCommand `cmd:"" help:"Attach an interactive console to a cleanroom execution"`
@@ -122,6 +123,21 @@ type ImageBumpRefCommand struct {
122123
PolicyPath string `help:"Policy file path (default: cleanroom.yaml, or .buildkite/cleanroom.yaml when primary is missing)"`
123124
}
124125

126+
type AgentCommand struct {
127+
Codex AgentCodexCommand `cmd:"" help:"Create and run a long-running Codex agent session in a sandbox"`
128+
}
129+
130+
type AgentCodexCommand struct {
131+
clientFlags
132+
Chdir string `short:"c" help:"Change to this directory before running commands"`
133+
Backend string `help:"Execution backend (defaults to runtime config or firecracker)"`
134+
SandboxID string `help:"Reuse an existing sandbox instead of creating a new one"`
135+
136+
LaunchSeconds int64 `help:"VM boot/guest-agent readiness timeout in seconds"`
137+
138+
Args []string `arg:"" passthrough:"" optional:"" help:"Arguments to pass to codex (prefix with '--' to separate cleanroom and codex flags)"`
139+
}
140+
125141
type PolicyCommand struct {
126142
Validate PolicyValidateCommand `cmd:"" help:"Validate policy configuration"`
127143
}
@@ -674,6 +690,22 @@ func (c *SandboxTerminateCommand) Run(ctx *runtimeContext) error {
674690
return err
675691
}
676692

693+
func (a *AgentCodexCommand) Run(ctx *runtimeContext) error {
694+
command := make([]string, 0, len(a.Args)+1)
695+
command = append(command, "codex")
696+
command = append(command, a.Args...)
697+
698+
console := ConsoleCommand{
699+
clientFlags: a.clientFlags,
700+
Chdir: a.Chdir,
701+
Backend: a.Backend,
702+
SandboxID: a.SandboxID,
703+
LaunchSeconds: a.LaunchSeconds,
704+
Command: command,
705+
}
706+
return console.Run(ctx)
707+
}
708+
677709
func runSandboxCreate(ctx *runtimeContext, connectFlags clientFlags, chdir, backend string, launchSeconds int64, outputJSON bool) error {
678710
client, err := connectFlags.connect()
679711
if err != nil {

internal/cli/cli_parse_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,30 @@ func TestConfigInitParses(t *testing.T) {
9595
}
9696
}
9797

98+
func TestAgentCodexParsesWithoutArgs(t *testing.T) {
99+
c := &CLI{}
100+
parser := newParserForTest(t, c)
101+
102+
if _, err := parser.Parse([]string{"agent", "codex"}); err != nil {
103+
t.Fatalf("parse agent codex returned error: %v", err)
104+
}
105+
if got := len(c.Agent.Codex.Args); got != 0 {
106+
t.Fatalf("expected no codex args, got %v", c.Agent.Codex.Args)
107+
}
108+
}
109+
110+
func TestAgentCodexPassesThroughArgs(t *testing.T) {
111+
c := &CLI{}
112+
parser := newParserForTest(t, c)
113+
114+
if _, err := parser.Parse([]string{"agent", "codex", "--yolo", "--model", "gpt-5.3-codex"}); err != nil {
115+
t.Fatalf("parse agent codex args returned error: %v", err)
116+
}
117+
if got, want := strings.Join(c.Agent.Codex.Args, " "), "--yolo --model gpt-5.3-codex"; got != want {
118+
t.Fatalf("unexpected codex args: got %q want %q", got, want)
119+
}
120+
}
121+
98122
func TestSandboxCreateParses(t *testing.T) {
99123
c := &CLI{}
100124
parser := newParserForTest(t, c)

0 commit comments

Comments
 (0)