Skip to content

Commit 3fca38f

Browse files
committed
Make sure to stop the whole process group
Terminating the command executed and all of its children too Signed-off-by: Djordje Lukic <djordje.lukic@docker.com>
1 parent d78f953 commit 3fca38f

5 files changed

Lines changed: 141 additions & 15 deletions

File tree

.gitattributes

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Auto detect text files and normalize to LF
2+
* text=auto eol=lf
3+
4+
# Go source files
5+
*.go text eol=lf
6+
7+
# Shell scripts
8+
*.sh text eol=lf
9+
10+
# Windows specific files
11+
*.bat text eol=crlf
12+
*.cmd text eol=crlf
13+
*.ps1 text eol=crlf
14+
15+
# Binary files
16+
*.exe binary
17+
*.dll binary
18+
*.so binary
19+
*.dylib binary
20+
*.png binary
21+
*.jpg binary
22+
*.jpeg binary
23+
*.gif binary
24+
*.ico binary
25+
*.pdf binary
26+
*.zip binary
27+
*.tar binary
28+
*.gz binary

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ require (
3535
go.opentelemetry.io/otel/sdk v1.38.0
3636
go.opentelemetry.io/otel/trace v1.38.0
3737
golang.org/x/oauth2 v0.32.0
38+
golang.org/x/sys v0.37.0
3839
golang.org/x/term v0.36.0
3940
google.golang.org/genai v1.31.0
4041
modernc.org/sqlite v1.39.1
@@ -116,7 +117,6 @@ require (
116117
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect
117118
golang.org/x/net v0.46.0 // indirect
118119
golang.org/x/sync v0.17.0 // indirect
119-
golang.org/x/sys v0.37.0 // indirect
120120
golang.org/x/text v0.30.0 // indirect
121121
golang.org/x/time v0.14.0 // indirect
122122
google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1 // indirect

pkg/tools/builtin/cmd_unix.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,20 @@ import (
77
"syscall"
88
)
99

10+
type processGroup struct {
11+
// Unix doesn't need to store handles, process group is managed by kernel
12+
}
13+
1014
func platformSpecificSysProcAttr() *syscall.SysProcAttr {
1115
return &syscall.SysProcAttr{
1216
Setpgid: true,
1317
}
1418
}
1519

16-
func kill(proc *os.Process) error {
20+
func createProcessGroup(proc *os.Process) (*processGroup, error) {
21+
return &processGroup{}, nil
22+
}
23+
24+
func kill(proc *os.Process, pg *processGroup) error {
1725
return syscall.Kill(-proc.Pid, syscall.SIGTERM)
1826
}

pkg/tools/builtin/cmd_windows.go

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,70 @@ package builtin
33
import (
44
"os"
55
"syscall"
6+
"unsafe"
7+
8+
"golang.org/x/sys/windows"
69
)
710

11+
type processGroup struct {
12+
jobHandle windows.Handle
13+
processHandle windows.Handle
14+
}
15+
816
func platformSpecificSysProcAttr() *syscall.SysProcAttr {
917
return nil
1018
}
1119

12-
func kill(proc *os.Process) error {
20+
func createProcessGroup(proc *os.Process) (*processGroup, error) {
21+
job, err := windows.CreateJobObject(nil, nil)
22+
if err != nil {
23+
return nil, err
24+
}
25+
26+
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
27+
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
28+
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
29+
},
30+
}
31+
if _, err := windows.SetInformationJobObject(
32+
job,
33+
windows.JobObjectExtendedLimitInformation,
34+
uintptr(unsafe.Pointer(&info)),
35+
uint32(unsafe.Sizeof(info))); err != nil {
36+
_ = windows.CloseHandle(job)
37+
return nil, err
38+
}
39+
40+
handle, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(proc.Pid))
41+
if err != nil {
42+
_ = windows.CloseHandle(job)
43+
return nil, err
44+
}
45+
46+
if err := windows.AssignProcessToJobObject(job, handle); err != nil {
47+
_ = windows.CloseHandle(handle)
48+
_ = windows.CloseHandle(job)
49+
return nil, err
50+
}
51+
52+
return &processGroup{
53+
jobHandle: job,
54+
processHandle: handle,
55+
}, nil
56+
}
57+
58+
func kill(proc *os.Process, pg *processGroup) error {
59+
if pg != nil {
60+
// Close handles to trigger JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
61+
// which will terminate all processes in the job
62+
if pg.processHandle != 0 {
63+
_ = windows.CloseHandle(pg.processHandle)
64+
}
65+
if pg.jobHandle != 0 {
66+
_ = windows.CloseHandle(pg.jobHandle)
67+
}
68+
}
69+
70+
// Also call Kill on the process as a fallback
1371
return proc.Kill()
1472
}

pkg/tools/builtin/shell.go

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package builtin
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"fmt"
78
"os"
89
"os/exec"
910
"runtime"
11+
"strings"
1012

1113
"github.com/docker/cagent/pkg/tools"
1214
)
@@ -36,37 +38,67 @@ func (h *shellHandler) RunShell(ctx context.Context, toolCall tools.ToolCall) (*
3638
return nil, fmt.Errorf("invalid arguments: %w", err)
3739
}
3840

39-
cmd := exec.CommandContext(ctx, h.shell, append(h.shellArgsPrefix, params.Cmd)...)
41+
cmd := exec.Command(h.shell, append(h.shellArgsPrefix, params.Cmd)...)
4042
cmd.Env = h.env
4143
if params.Cwd != "" {
4244
cmd.Dir = params.Cwd
4345
} else {
44-
// Use the current working directory; avoid PWD on Windows (may be MSYS-style like /c/...)
4546
if wd, err := os.Getwd(); err == nil {
4647
cmd.Dir = wd
4748
}
4849
}
4950

50-
// Set up process group for proper cleanup
51-
// On Unix: create new process group so we can kill the entire tree
5251
cmd.SysProcAttr = platformSpecificSysProcAttr()
5352

54-
output, err := cmd.CombinedOutput()
55-
if err != nil {
53+
var outBuf bytes.Buffer
54+
cmd.Stdout = &outBuf
55+
cmd.Stderr = &outBuf
56+
57+
if err := cmd.Start(); err != nil {
5658
return &tools.ToolCallResult{
57-
Output: fmt.Sprintf("Error executing command: %s\nOutput: %s", err, string(output)),
59+
Output: fmt.Sprintf("Error starting command: %s", err),
5860
}, nil
5961
}
6062

61-
if len(output) == 0 {
63+
pg, err := createProcessGroup(cmd.Process)
64+
if err != nil {
6265
return &tools.ToolCallResult{
63-
Output: "<no output>",
66+
Output: fmt.Sprintf("Error creating process group: %s", err),
6467
}, nil
6568
}
6669

67-
return &tools.ToolCallResult{
68-
Output: fmt.Sprintf("Output: %s", string(output)),
69-
}, nil
70+
done := make(chan error, 1)
71+
go func() {
72+
done <- cmd.Wait()
73+
}()
74+
75+
select {
76+
case <-ctx.Done():
77+
if cmd.Process != nil {
78+
_ = kill(cmd.Process, pg)
79+
}
80+
return &tools.ToolCallResult{
81+
Output: "Command cancelled",
82+
}, nil
83+
case err := <-done:
84+
output := outBuf.String()
85+
86+
if err != nil {
87+
return &tools.ToolCallResult{
88+
Output: fmt.Sprintf("Error executing command: %s\nOutput: %s", err, output),
89+
}, nil
90+
}
91+
92+
if strings.TrimSpace(output) == "" {
93+
return &tools.ToolCallResult{
94+
Output: "<no output>",
95+
}, nil
96+
}
97+
98+
return &tools.ToolCallResult{
99+
Output: fmt.Sprintf("Output: %s", output),
100+
}, nil
101+
}
70102
}
71103

72104
func NewShellTool(env []string) *ShellTool {

0 commit comments

Comments
 (0)