Skip to content

Commit ab04dbf

Browse files
committed
refactor(sandbox): extract Runner interface and move Docker runner to pkg/sandbox
Introduce a pluggable sandbox.Runner interface to decouple shell tool execution from the Docker sandbox implementation. This enables adding alternative sandbox backends without modifying the shell tool itself. Changes: - Add sandbox.Runner interface in pkg/sandbox/runner.go - Move and refactor Docker sandbox from pkg/tools/builtin/sandbox.go to pkg/sandbox/docker.go, implementing the Runner interface - Update ShellTool to accept sandbox.Runner instead of the concrete *sandboxRunner type - Update registry to create sandbox.DockerRunner and pass it to ShellTool Signed-off-by: Tomas Dabašinskas <tomas.dabasinskas@cogvel.com>
1 parent 099265b commit ab04dbf

File tree

6 files changed

+228
-155
lines changed

6 files changed

+228
-155
lines changed
Lines changed: 87 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package builtin
1+
package sandbox
22

33
import (
44
"bytes"
@@ -28,20 +28,24 @@ const (
2828
sandboxLabelPID = "com.docker.cagent.sandbox.pid"
2929
)
3030

31-
// sandboxRunner handles command execution in a Docker container sandbox.
32-
type sandboxRunner struct {
31+
// DockerRunner handles command execution in a Docker container sandbox.
32+
type DockerRunner struct {
3333
config *latest.SandboxConfig
3434
workingDir string
3535
env []string
3636
containerID string
3737
mu sync.Mutex
3838
}
3939

40-
func newSandboxRunner(config *latest.SandboxConfig, workingDir string, env []string) *sandboxRunner {
41-
// Clean up any orphaned containers from previous cagent runs
40+
// Verify interface compliance.
41+
var _ Runner = (*DockerRunner)(nil)
42+
43+
// NewDockerRunner creates a new Docker sandbox runner.
44+
// It cleans up any orphaned containers from previous cagent runs.
45+
func NewDockerRunner(config *latest.SandboxConfig, workingDir string, env []string) *DockerRunner {
4246
cleanupOrphanedSandboxContainers()
4347

44-
return &sandboxRunner{
48+
return &DockerRunner{
4549
config: config,
4650
workingDir: workingDir,
4751
env: env,
@@ -96,15 +100,15 @@ func isProcessRunning(pid int) bool {
96100
return err == nil
97101
}
98102

99-
// runCommand executes a command inside the sandbox container.
100-
func (s *sandboxRunner) runCommand(timeoutCtx, ctx context.Context, command, cwd string, timeout time.Duration) *tools.ToolCallResult {
101-
containerID, err := s.ensureContainer(ctx)
103+
// RunCommand executes a command inside the Docker sandbox container.
104+
func (d *DockerRunner) RunCommand(timeoutCtx, ctx context.Context, command, cwd string, timeout time.Duration) *tools.ToolCallResult {
105+
containerID, err := d.ensureContainer(ctx)
102106
if err != nil {
103107
return tools.ResultError(fmt.Sprintf("Failed to start sandbox container: %s", err))
104108
}
105109

106110
args := []string{"exec", "-w", cwd}
107-
args = append(args, s.buildEnvVars()...)
111+
args = append(args, d.buildEnvVars()...)
108112
args = append(args, containerID, "/bin/sh", "-c", command)
109113

110114
cmd := exec.CommandContext(timeoutCtx, "docker", args...)
@@ -114,47 +118,53 @@ func (s *sandboxRunner) runCommand(timeoutCtx, ctx context.Context, command, cwd
114118

115119
err = cmd.Run()
116120

117-
output := formatCommandOutput(timeoutCtx, ctx, err, outBuf.String(), timeout)
118-
return tools.ResultSuccess(limitOutput(output))
121+
output := FormatCommandOutput(timeoutCtx, ctx, err, outBuf.String(), timeout)
122+
return tools.ResultSuccess(LimitOutput(output))
123+
}
124+
125+
// Start is a no-op for Docker runner; containers are lazily started.
126+
func (d *DockerRunner) Start(context.Context) error {
127+
return nil
119128
}
120129

121-
// stop stops and removes the sandbox container.
122-
func (s *sandboxRunner) stop() {
123-
s.mu.Lock()
124-
defer s.mu.Unlock()
130+
// Stop stops and removes the sandbox container.
131+
func (d *DockerRunner) Stop(context.Context) error {
132+
d.mu.Lock()
133+
defer d.mu.Unlock()
125134

126-
if s.containerID == "" {
127-
return
135+
if d.containerID == "" {
136+
return nil
128137
}
129138

130-
stopCmd := exec.Command("docker", "stop", "-t", "1", s.containerID)
139+
stopCmd := exec.Command("docker", "stop", "-t", "1", d.containerID)
131140
_ = stopCmd.Run()
132141

133-
s.containerID = ""
142+
d.containerID = ""
143+
return nil
134144
}
135145

136146
// ensureContainer ensures the sandbox container is running, starting it if necessary.
137-
func (s *sandboxRunner) ensureContainer(ctx context.Context) (string, error) {
138-
s.mu.Lock()
139-
defer s.mu.Unlock()
147+
func (d *DockerRunner) ensureContainer(ctx context.Context) (string, error) {
148+
d.mu.Lock()
149+
defer d.mu.Unlock()
140150

141-
if s.containerID != "" && s.isContainerRunning(ctx) {
142-
return s.containerID, nil
151+
if d.containerID != "" && d.isContainerRunning(ctx) {
152+
return d.containerID, nil
143153
}
144-
s.containerID = ""
154+
d.containerID = ""
145155

146-
return s.startContainer(ctx)
156+
return d.startContainer(ctx)
147157
}
148158

149-
func (s *sandboxRunner) isContainerRunning(ctx context.Context) bool {
150-
cmd := exec.CommandContext(ctx, "docker", "container", "inspect", "-f", "{{.State.Running}}", s.containerID)
159+
func (d *DockerRunner) isContainerRunning(ctx context.Context) bool {
160+
cmd := exec.CommandContext(ctx, "docker", "container", "inspect", "-f", "{{.State.Running}}", d.containerID)
151161
output, err := cmd.Output()
152162
return err == nil && strings.TrimSpace(string(output)) == "true"
153163
}
154164

155-
func (s *sandboxRunner) startContainer(ctx context.Context) (string, error) {
156-
containerName := s.generateContainerName()
157-
image := cmp.Or(s.config.Image, "alpine:latest")
165+
func (d *DockerRunner) startContainer(ctx context.Context) (string, error) {
166+
containerName := d.generateContainerName()
167+
image := cmp.Or(d.config.Image, "alpine:latest")
158168

159169
args := []string{
160170
"run", "-d",
@@ -163,9 +173,9 @@ func (s *sandboxRunner) startContainer(ctx context.Context) (string, error) {
163173
"--label", sandboxLabelKey + "=true",
164174
"--label", fmt.Sprintf("%s=%d", sandboxLabelPID, os.Getpid()),
165175
}
166-
args = append(args, s.buildVolumeMounts()...)
167-
args = append(args, s.buildEnvVars()...)
168-
args = append(args, "-w", s.workingDir, image, "tail", "-f", "/dev/null")
176+
args = append(args, d.buildVolumeMounts()...)
177+
args = append(args, d.buildEnvVars()...)
178+
args = append(args, "-w", d.workingDir, image, "tail", "-f", "/dev/null")
169179

170180
cmd := exec.CommandContext(ctx, "docker", args...)
171181
var stderr bytes.Buffer
@@ -176,25 +186,25 @@ func (s *sandboxRunner) startContainer(ctx context.Context) (string, error) {
176186
return "", fmt.Errorf("failed to start sandbox container: %w\nstderr: %s", err, stderr.String())
177187
}
178188

179-
s.containerID = strings.TrimSpace(string(output))
180-
return s.containerID, nil
189+
d.containerID = strings.TrimSpace(string(output))
190+
return d.containerID, nil
181191
}
182192

183-
func (s *sandboxRunner) generateContainerName() string {
193+
func (d *DockerRunner) generateContainerName() string {
184194
randomBytes := make([]byte, 4)
185195
_, _ = rand.Read(randomBytes)
186196
return fmt.Sprintf("cagent-sandbox-%s", hex.EncodeToString(randomBytes))
187197
}
188198

189-
func (s *sandboxRunner) buildVolumeMounts() []string {
199+
func (d *DockerRunner) buildVolumeMounts() []string {
190200
var args []string
191-
for _, pathSpec := range s.config.Paths {
192-
hostPath, mode := parseSandboxPath(pathSpec)
201+
for _, pathSpec := range d.config.Paths {
202+
hostPath, mode := ParseSandboxPath(pathSpec)
193203

194204
// Resolve to absolute path
195205
if !filepath.IsAbs(hostPath) {
196-
if s.workingDir != "" {
197-
hostPath = filepath.Join(s.workingDir, hostPath)
206+
if d.workingDir != "" {
207+
hostPath = filepath.Join(d.workingDir, hostPath)
198208
} else {
199209
// If workingDir is empty, resolve relative to current directory
200210
var err error
@@ -215,26 +225,23 @@ func (s *sandboxRunner) buildVolumeMounts() []string {
215225
}
216226

217227
// buildEnvVars creates Docker -e flags for environment variables.
218-
// This forwards the host environment to the sandbox container.
219228
// Only variables with valid POSIX names are forwarded.
220-
func (s *sandboxRunner) buildEnvVars() []string {
229+
func (d *DockerRunner) buildEnvVars() []string {
221230
var args []string
222-
for _, envVar := range s.env {
223-
// Each env var is in KEY=VALUE format
224-
// Only forward variables with valid names to avoid Docker issues
231+
for _, envVar := range d.env {
225232
if idx := strings.Index(envVar, "="); idx > 0 {
226233
key := envVar[:idx]
227-
if isValidEnvVarName(key) {
234+
if IsValidEnvVarName(key) {
228235
args = append(args, "-e", envVar)
229236
}
230237
}
231238
}
232239
return args
233240
}
234241

235-
// isValidEnvVarName checks if an environment variable name is valid for POSIX.
242+
// IsValidEnvVarName checks if an environment variable name is valid for POSIX.
236243
// Valid names start with a letter or underscore and contain only alphanumerics and underscores.
237-
func isValidEnvVarName(name string) bool {
244+
func IsValidEnvVarName(name string) bool {
238245
if name == "" {
239246
return false
240247
}
@@ -247,8 +254,8 @@ func isValidEnvVarName(name string) bool {
247254
return true
248255
}
249256

250-
// parseSandboxPath parses a path specification like "./path" or "/path:ro" into path and mode.
251-
func parseSandboxPath(pathSpec string) (path, mode string) {
257+
// ParseSandboxPath parses a path specification like "./path" or "/path:ro" into path and mode.
258+
func ParseSandboxPath(pathSpec string) (path, mode string) {
252259
mode = "rw" // Default to read-write
253260

254261
switch {
@@ -264,3 +271,30 @@ func parseSandboxPath(pathSpec string) (path, mode string) {
264271

265272
return path, mode
266273
}
274+
275+
// FormatCommandOutput formats command output handling timeout, cancellation, and errors.
276+
func FormatCommandOutput(timeoutCtx, ctx context.Context, err error, rawOutput string, timeout time.Duration) string {
277+
var output string
278+
if timeoutCtx.Err() != nil {
279+
if ctx.Err() != nil {
280+
output = "Command cancelled"
281+
} else {
282+
output = fmt.Sprintf("Command timed out after %v\nOutput: %s", timeout, rawOutput)
283+
}
284+
} else {
285+
output = rawOutput
286+
if err != nil {
287+
output = fmt.Sprintf("Error executing command: %s\nOutput: %s", err, output)
288+
}
289+
}
290+
return cmp.Or(strings.TrimSpace(output), "<no output>")
291+
}
292+
293+
// LimitOutput truncates output to a maximum size.
294+
func LimitOutput(output string) string {
295+
const maxOutputSize = 30000
296+
if len(output) > maxOutputSize {
297+
return output[:maxOutputSize] + "\n\n[Output truncated: exceeded 30,000 character limit]"
298+
}
299+
return output
300+
}

pkg/sandbox/docker_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package sandbox
2+
3+
import (
4+
"os"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestParseSandboxPath(t *testing.T) {
11+
t.Parallel()
12+
13+
tests := []struct {
14+
input string
15+
wantPath string
16+
wantMode string
17+
}{
18+
{input: ".", wantPath: ".", wantMode: "rw"},
19+
{input: "/tmp", wantPath: "/tmp", wantMode: "rw"},
20+
{input: "./src", wantPath: "./src", wantMode: "rw"},
21+
{input: "/tmp:ro", wantPath: "/tmp", wantMode: "ro"},
22+
{input: "./config:ro", wantPath: "./config", wantMode: "ro"},
23+
{input: "/data:rw", wantPath: "/data", wantMode: "rw"},
24+
{input: "./secrets:ro", wantPath: "./secrets", wantMode: "ro"},
25+
}
26+
27+
for _, tt := range tests {
28+
t.Run(tt.input, func(t *testing.T) {
29+
t.Parallel()
30+
path, mode := ParseSandboxPath(tt.input)
31+
assert.Equal(t, tt.wantPath, path)
32+
assert.Equal(t, tt.wantMode, mode)
33+
})
34+
}
35+
}
36+
37+
func TestIsValidEnvVarName(t *testing.T) {
38+
t.Parallel()
39+
40+
tests := []struct {
41+
name string
42+
valid bool
43+
}{
44+
{"HOME", true},
45+
{"USER", true},
46+
{"PATH", true},
47+
{"_private", true},
48+
{"MY_VAR_123", true},
49+
{"a", true},
50+
{"A", true},
51+
{"_", true},
52+
{"", false},
53+
{"123", false},
54+
{"1VAR", false},
55+
{"VAR-NAME", false},
56+
{"VAR.NAME", false},
57+
{"VAR NAME", false},
58+
}
59+
60+
for _, tt := range tests {
61+
t.Run(tt.name, func(t *testing.T) {
62+
t.Parallel()
63+
result := IsValidEnvVarName(tt.name)
64+
assert.Equal(t, tt.valid, result, "IsValidEnvVarName(%q)", tt.name)
65+
})
66+
}
67+
}
68+
69+
func TestIsProcessRunning(t *testing.T) {
70+
t.Parallel()
71+
72+
// Current process should be running
73+
assert.True(t, isProcessRunning(os.Getpid()), "Current process should be running")
74+
75+
// Non-existent PID should not be running (using a very high PID unlikely to exist)
76+
assert.False(t, isProcessRunning(999999999), "Very high PID should not be running")
77+
}

pkg/sandbox/runner.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package sandbox
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
"github.com/docker/cagent/pkg/tools"
8+
)
9+
10+
// Runner is a pluggable interface for sandbox execution backends.
11+
// Implementations handle command execution in isolated environments
12+
// (Docker containers, Kubernetes pods, etc.).
13+
type Runner interface {
14+
// RunCommand executes a command synchronously and returns the result.
15+
// The timeoutCtx carries the command timeout; ctx is the parent context.
16+
// cwd is the working directory inside the sandbox.
17+
// timeout is the original duration for formatting timeout messages.
18+
RunCommand(timeoutCtx, ctx context.Context, command, cwd string, timeout time.Duration) *tools.ToolCallResult
19+
20+
// Start initializes the runner (e.g., discover pod, start container).
21+
Start(ctx context.Context) error
22+
// Stop cleans up the runner (e.g., stop container).
23+
Stop(ctx context.Context) error
24+
}

0 commit comments

Comments
 (0)