1- package builtin
1+ package sandbox
22
33import (
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\n stderr: %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\n Output: %s" , timeout , rawOutput )
283+ }
284+ } else {
285+ output = rawOutput
286+ if err != nil {
287+ output = fmt .Sprintf ("Error executing command: %s\n Output: %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+ }
0 commit comments