Skip to content

Commit 5977e21

Browse files
jkyberneeesclaude
andcommitted
fix(shell): bound command execution with timeout + context cancellation
shell ran cmd.Run() on a plain exec.Command with no timeout and no context, so a stuck command (network read that never returns, interactive prompt, infinite loop) wedged the agent forever — and Ctrl-C could not recover because the loop's drain blocks on the tool goroutine. The sibling parallel_shell already had a timeout; plain shell, the most-used tool, did not. - SetContext ties execution to the agent context (the loop already calls it on context-aware tools), so Ctrl-C / turn timeout kills the command now. - exec.CommandContext + a generous 30m backstop timeout bounds genuinely stuck commands for unattended runs (serve, telegram) with no human to interrupt. WaitDelay guarantees Run() returns even if the killed process leaves children holding the pipes. - Cancellation/timeout surface as clear errors, not opaque "signal: killed". Tests: a sleeping command now returns promptly via both the timeout and context-cancellation paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6a2055c commit 5977e21

2 files changed

Lines changed: 112 additions & 7 deletions

File tree

cmd/odek/shell.go

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,25 @@ package main
22

33
import (
44
"bytes"
5+
"context"
56
"encoding/json"
67
"fmt"
78
"os/exec"
89
"strings"
910
"sync"
11+
"time"
1012

1113
"github.com/BackendStack21/odek/internal/danger"
1214
)
1315

16+
// defaultShellTimeout bounds a single shell command. It is deliberately
17+
// generous — the goal is to stop a genuinely stuck command (a network read
18+
// that never returns, an interactive prompt, an infinite loop) from wedging
19+
// the agent forever, NOT to kill legitimate long builds or test suites. When
20+
// the agent context is cancelled (Ctrl-C, turn timeout) the command is killed
21+
// immediately regardless of this backstop.
22+
const defaultShellTimeout = 30 * time.Minute
23+
1424
// shellTool is odek's built-in tool that lets the agent run shell commands.
1525
//
1626
// This is the only built-in tool — it's enough for reading files, running
@@ -61,8 +71,20 @@ type shellTool struct {
6171
// ttyPath is the path to the terminal device for approval prompts.
6272
// Overridden in tests to mock user input. Only used when approver is nil.
6373
ttyPath string
74+
75+
// ctx, when set via SetContext, ties command execution to the agent's
76+
// lifetime: cancelling it (Ctrl-C, turn timeout) kills the running command.
77+
ctx context.Context
78+
79+
// timeout bounds a single command. Zero falls back to defaultShellTimeout.
80+
timeout time.Duration
6481
}
6582

83+
// SetContext lets the agent loop propagate its context so a running command is
84+
// killed when the turn is cancelled. Implements the loop's optional
85+
// context-aware tool interface.
86+
func (t *shellTool) SetContext(ctx context.Context) { t.ctx = ctx }
87+
6688
func (t *shellTool) Name() string { return "shell" }
6789

6890
func (t *shellTool) Description() string {
@@ -113,13 +135,40 @@ func (t *shellTool) Call(args string) (string, error) {
113135
return "", err
114136
}
115137

116-
cmd := t.buildCmd(input.Command)
138+
// Bound execution: cancel with the agent context (Ctrl-C / turn timeout)
139+
// and a generous backstop timeout so a stuck command can never wedge the
140+
// agent forever.
141+
base := t.ctx
142+
if base == nil {
143+
base = context.Background()
144+
}
145+
timeout := t.timeout
146+
if timeout <= 0 {
147+
timeout = defaultShellTimeout
148+
}
149+
ctx, cancel := context.WithTimeout(base, timeout)
150+
defer cancel()
151+
152+
cmd := t.buildCmd(ctx, input.Command)
153+
// WaitDelay guarantees Run() returns even if the killed process leaves
154+
// children holding the output pipes open after the context fires.
155+
cmd.WaitDelay = 5 * time.Second
117156

118157
var outBuf, errBuf bytes.Buffer
119158
cmd.Stdout = &outBuf
120159
cmd.Stderr = &errBuf
121160

122161
err := cmd.Run()
162+
163+
// Surface cancellation/timeout as a clear, actionable error rather than an
164+
// opaque "signal: killed".
165+
if ctxErr := ctx.Err(); ctxErr != nil {
166+
if ctxErr == context.DeadlineExceeded {
167+
return "", fmt.Errorf("shell: command timed out after %s (still running? it was killed): %s", timeout, input.Command)
168+
}
169+
return "", fmt.Errorf("shell: command cancelled: %s", input.Command)
170+
}
171+
123172
output := strings.TrimSpace(outBuf.String())
124173
stderrStr := strings.TrimSpace(errBuf.String())
125174
if stderrStr != "" {
@@ -200,9 +249,9 @@ func (t *shellTool) promptUser(cmd, description string) error {
200249
//
201250
// When running on the host (default), the command executes via "sh -c"
202251
// in odek's current working directory.
203-
func (t *shellTool) buildCmd(command string) *exec.Cmd {
252+
func (t *shellTool) buildCmd(ctx context.Context, command string) *exec.Cmd {
204253
if t.containerName != "" {
205-
return exec.Command("docker", "exec", "-w", "/workspace", t.containerName, "sh", "-c", command)
254+
return exec.CommandContext(ctx, "docker", "exec", "-w", "/workspace", t.containerName, "sh", "-c", command)
206255
}
207-
return exec.Command("sh", "-c", command)
256+
return exec.CommandContext(ctx, "sh", "-c", command)
208257
}

cmd/odek/shell_test.go

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package main
22

33
import (
4+
"context"
45
"encoding/json"
56
"os"
67
"os/exec"
78
"strings"
89
"testing"
10+
"time"
911
)
1012

1113
func TestShellTool_Name(t *testing.T) {
@@ -15,6 +17,60 @@ func TestShellTool_Name(t *testing.T) {
1517
}
1618
}
1719

20+
// TestShellTool_Timeout verifies a stuck command can no longer wedge the agent:
21+
// a tiny per-tool timeout kills the command and Call returns promptly with a
22+
// clear timeout error instead of blocking forever.
23+
func TestShellTool_Timeout(t *testing.T) {
24+
st := &shellTool{timeout: 200 * time.Millisecond}
25+
done := make(chan struct{})
26+
var out string
27+
var err error
28+
go func() {
29+
out, err = st.Call(`{"command":"sleep 30"}`)
30+
close(done)
31+
}()
32+
select {
33+
case <-done:
34+
case <-time.After(5 * time.Second):
35+
t.Fatal("Call did not return after the command timeout — agent would hang")
36+
}
37+
if err == nil {
38+
t.Fatalf("expected a timeout error, got output %q", out)
39+
}
40+
if !strings.Contains(err.Error(), "timed out") {
41+
t.Errorf("error should mention the timeout, got: %v", err)
42+
}
43+
}
44+
45+
// TestShellTool_ContextCancellation verifies Ctrl-C / turn cancellation kills a
46+
// running command immediately via the agent context.
47+
func TestShellTool_ContextCancellation(t *testing.T) {
48+
st := &shellTool{}
49+
ctx, cancel := context.WithCancel(context.Background())
50+
st.SetContext(ctx)
51+
52+
done := make(chan struct{})
53+
var err error
54+
go func() {
55+
_, err = st.Call(`{"command":"sleep 30"}`)
56+
close(done)
57+
}()
58+
time.Sleep(100 * time.Millisecond)
59+
cancel()
60+
61+
select {
62+
case <-done:
63+
case <-time.After(5 * time.Second):
64+
t.Fatal("Call did not return after context cancellation — Ctrl-C would not work")
65+
}
66+
if err == nil {
67+
t.Fatal("expected a cancellation error")
68+
}
69+
if !strings.Contains(err.Error(), "cancelled") {
70+
t.Errorf("error should mention cancellation, got: %v", err)
71+
}
72+
}
73+
1874
func TestShellTool_Description(t *testing.T) {
1975
st := &shellTool{}
2076
desc := st.Description()
@@ -130,7 +186,7 @@ func TestShellTool_Call_StdoutAndStderr(t *testing.T) {
130186

131187
func TestShellTool_BuildCmd_Local(t *testing.T) {
132188
st := &shellTool{}
133-
cmd := st.buildCmd("echo test")
189+
cmd := st.buildCmd(context.Background(), "echo test")
134190
args := cmd.Args
135191
if args[0] != "sh" || args[1] != "-c" || args[2] != "echo test" {
136192
t.Errorf("local cmd args = %v, want [sh -c 'echo test']", args)
@@ -139,7 +195,7 @@ func TestShellTool_BuildCmd_Local(t *testing.T) {
139195

140196
func TestShellTool_BuildCmd_Docker(t *testing.T) {
141197
st := &shellTool{containerName: "odek-12345"}
142-
cmd := st.buildCmd("echo test")
198+
cmd := st.buildCmd(context.Background(), "echo test")
143199
args := cmd.Args
144200
expected := []string{"docker", "exec", "-w", "/workspace", "odek-12345", "sh", "-c", "echo test"}
145201
if !stringSlicesEqual(args, expected) {
@@ -303,7 +359,7 @@ func TestShellTool_CheckApproval(t *testing.T) {
303359

304360
func TestShellTool_BuildCmd_Default(t *testing.T) {
305361
st := &shellTool{}
306-
cmd := st.buildCmd("echo hello")
362+
cmd := st.buildCmd(context.Background(), "echo hello")
307363
if cmd.Args[0] != "sh" {
308364
t.Errorf("expected sh, got %s", cmd.Args[0])
309365
}

0 commit comments

Comments
 (0)