github.com/anthropics/anthropic-sdk-go@v1.70.1/tools/agenttoolset/bash_test.go (about) 1 //go:build !windows 2 3 package agenttoolset 4 5 import ( 6 "context" 7 "io" 8 "strings" 9 "testing" 10 "time" 11 12 anthropic "github.com/anthropics/anthropic-sdk-go" 13 "github.com/stretchr/testify/require" 14 ) 15 16 func newBashTool(t *testing.T, dir string) anthropic.BetaTool { 17 t.Helper() 18 tool := BetaBashTool(&AgentToolContext{Workdir: dir}) 19 t.Cleanup(func() { 20 if c, ok := tool.(io.Closer); ok { 21 _ = c.Close() 22 } 23 }) 24 return tool 25 } 26 27 func TestBashSessionPersistence(t *testing.T) { 28 tool := newBashTool(t, t.TempDir()) 29 30 tests := []struct { 31 description string 32 command string 33 want string 34 }{ 35 { 36 description: "first command exports a variable into the persistent shell environment", 37 command: "export FOO=bar; echo set", 38 want: "set", 39 }, 40 { 41 description: "second command in the same session can read the variable exported by the first", 42 command: "echo $FOO", 43 want: "bar", 44 }, 45 { 46 description: "changing directory persists so a later pwd reflects the new location", 47 command: "cd /tmp && pwd", 48 want: "/tmp", 49 }, 50 { 51 description: "subsequent pwd without cd still reports /tmp confirming cwd survived across calls", 52 command: "pwd", 53 want: "/tmp", 54 }, 55 } 56 57 for _, tc := range tests { 58 t.Run(tc.description, func(t *testing.T) { 59 out, isErr := runTool(t, tool, mustJSON(t, map[string]any{"command": tc.command})) 60 require.False(t, isErr, "unexpected error output: %q", out) 61 require.Equal(t, tc.want, strings.TrimSpace(out)) 62 }) 63 } 64 } 65 66 func TestBashRestart(t *testing.T) { 67 tool := newBashTool(t, t.TempDir()) 68 69 _, isErr := runTool(t, tool, mustJSON(t, map[string]any{"command": "export GONE=1"})) 70 require.False(t, isErr) 71 72 out, isErr := runTool(t, tool, mustJSON(t, map[string]any{"restart": true, "command": "echo [$GONE]"})) 73 require.False(t, isErr, "output=%q", out) 74 require.Equal(t, "[]", strings.TrimSpace(out), "variable should be unset after restart") 75 } 76 77 func TestBashNonZeroExit(t *testing.T) { 78 tool := newBashTool(t, t.TempDir()) 79 80 // Use a subshell so the non-zero status is observed without terminating 81 // the persistent session itself. 82 out, isErr := runTool(t, tool, mustJSON(t, map[string]any{"command": "(exit 7)"})) 83 require.True(t, isErr, "non-zero exit must set is_error so the model knows the command failed") 84 require.Contains(t, out, "exit code: 7") 85 } 86 87 func TestBashTimeout(t *testing.T) { 88 work := t.TempDir() 89 sess, err := NewBashSession(work, nil) 90 require.NoError(t, err) 91 t.Cleanup(func() { _ = sess.Close() }) 92 93 start := time.Now() 94 out, code, err := sess.Exec(context.Background(), "sleep 5", 200*time.Millisecond) 95 require.ErrorIs(t, err, ErrTimedOut, "Exec returns ErrTimedOut so the caller knows to restart the session") 96 require.Equal(t, -1, code, "timed-out command reports sentinel exit code") 97 require.Contains(t, out, "[timed out]") 98 require.Less(t, time.Since(start), 2*time.Second, "Exec must return promptly after the timeout rather than blocking on sleep") 99 } 100 101 // TestBashToolRestartsAfterCtxCancel pins the guarantee that the 102 // persistent shell is restarted when the per-tool ctx fires before 103 // bashDefaultTO does — Exec returns ctx.Err() rather than ErrTimedOut, 104 // and the still-running command's stdout plus the per-call exit-code 105 // sentinel would otherwise land in the next Exec's buffer (the next 106 // call then sees stale output or, worse, false-matches the prior 107 // sentinel and reports the wrong exit code). 108 // 109 // This test uses a tight ctx (200 ms) with a long bash-side timeout 110 // (30 s, much greater than bashDefaultTO would matter) so ctx fires 111 // first; the wrapped command writes "before" then sleeps. With the fix, 112 // the second call sees only "after". Without the fix, the second call's 113 // output either contains "before" or the prior sentinel. 114 func TestBashToolRestartsAfterCtxCancel(t *testing.T) { 115 tool := newBashTool(t, t.TempDir()) 116 117 // First call: ctx fires before the bash-side timeout would. The shell 118 // is left mid-command on the other side of the PTY with `sleep 5` 119 // still running and "before\n" buffered (plus the per-call sentinel 120 // that will land once `sleep` returns). 121 tightCtx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) 122 defer cancel() 123 _, err := tool.Execute(tightCtx, mustJSON(t, map[string]any{ 124 "command": "echo before; sleep 5", 125 "timeout_ms": 30000, 126 })) 127 require.Error(t, err, "ctx cancel must surface as a tool error") 128 129 // Second call must run in a fresh session — neither the previous 130 // command's stdout nor its sentinel should be visible. 131 out2, isErr2 := runTool(t, tool, mustJSON(t, map[string]any{"command": "echo after"})) 132 require.False(t, isErr2) 133 require.Contains(t, out2, "after") 134 require.NotContains(t, out2, "before", "second call must not see leftover output from the ctx-cancelled command") 135 require.NotContains(t, out2, "__ANT_CMD_", "second call must not see the ctx-cancelled command's sentinel") 136 } 137 138 func TestBashToolRestartsAfterTimeout(t *testing.T) { 139 tool := newBashTool(t, t.TempDir()) 140 141 // First call hangs past its timeout; the tool must restart so the still-running 142 // `sleep` cannot leak its sentinel into the next call's buffer. 143 out, isErr := runTool(t, tool, mustJSON(t, map[string]any{ 144 "command": "echo before; sleep 5", 145 "timeout_ms": 200, 146 })) 147 require.True(t, isErr) 148 require.Contains(t, out, "session restarted after timeout") 149 150 // Second call must execute in a fresh session and see only its own output. 151 out2, isErr2 := runTool(t, tool, mustJSON(t, map[string]any{"command": "echo after"})) 152 require.False(t, isErr2) 153 require.Contains(t, out2, "after") 154 require.NotContains(t, out2, "before", "second call must not see leftover output from the timed-out group") 155 require.NotContains(t, out2, "__ANT_CMD_", "second call must not see the timed-out group's sentinel") 156 } 157 158 func TestBashMissingCommand(t *testing.T) { 159 tool := newBashTool(t, t.TempDir()) 160 _, isErr := runTool(t, tool, mustJSON(t, map[string]any{})) 161 require.True(t, isErr, "empty command without restart must be rejected") 162 } 163 164 // TestBashScrubsAnthropicCredentials verifies the spawned shell does not 165 // inherit the runner's ANTHROPIC_* credentials (API key, auth/session tokens), 166 // while ordinary variables like PATH still pass through. 167 func TestBashScrubsAnthropicCredentials(t *testing.T) { 168 t.Setenv("ANTHROPIC_API_KEY", "sk-ant-should-not-leak") 169 t.Setenv("ANTHROPIC_AUTH_TOKEN", "tok-should-not-leak") 170 tool := newBashTool(t, t.TempDir()) 171 172 out, isErr := runTool(t, tool, mustJSON(t, map[string]any{ 173 "command": `echo "key=[$ANTHROPIC_API_KEY] token=[$ANTHROPIC_AUTH_TOKEN] path=[${PATH:+set}]"`, 174 })) 175 require.False(t, isErr, "output=%q", out) 176 require.Contains(t, out, "key=[]", "ANTHROPIC_API_KEY must not be visible to the spawned shell") 177 require.Contains(t, out, "token=[]", "ANTHROPIC_AUTH_TOKEN must not be visible to the spawned shell") 178 require.Contains(t, out, "path=[set]", "PATH must survive the credential scrub") 179 } 180 181 // TestBashEnvFullyReplaces verifies that a non-nil AgentToolContext.Env FULLY 182 // REPLACES the default scrubbed process environment rather than being merged 183 // into it: only the provided keys are visible, and an inherited variable like 184 // PATH is gone. 185 func TestBashEnvFullyReplaces(t *testing.T) { 186 t.Setenv("ANTHROPIC_API_KEY", "sk-ant-should-not-leak") 187 t.Setenv("BASH_TEST_INHERITED", "from-process") 188 189 tool := BetaBashTool(&AgentToolContext{ 190 Workdir: t.TempDir(), 191 Env: map[string]string{"CUSTOM_ONLY": "yes"}, 192 }) 193 t.Cleanup(func() { 194 if c, ok := tool.(io.Closer); ok { 195 _ = c.Close() 196 } 197 }) 198 199 // BASH_TEST_INHERITED is a non-ANTHROPIC process var: it would survive the 200 // credential scrub and so would be visible if Env were merged into the 201 // default instead of replacing it. Its absence proves full replacement. 202 // (PATH is not a reliable probe — bash synthesizes a compiled-in default 203 // PATH even when started with an empty environment.) 204 out, isErr := runTool(t, tool, mustJSON(t, map[string]any{ 205 "command": `echo "custom=[$CUSTOM_ONLY] inherited=[$BASH_TEST_INHERITED] key=[$ANTHROPIC_API_KEY]"`, 206 })) 207 require.False(t, isErr, "output=%q", out) 208 require.Contains(t, out, "custom=[yes]", "the provided Env mapping must be used verbatim") 209 require.Contains(t, out, "inherited=[]", 210 "a non-nil Env FULLY REPLACES the default; inherited process vars must not leak in") 211 require.Contains(t, out, "key=[]", "ANTHROPIC_* credentials must never be visible") 212 } 213 214 func TestBashSentinelNotSpoofable(t *testing.T) { 215 sess, err := NewBashSession(t.TempDir(), nil) 216 require.NoError(t, err) 217 t.Cleanup(func() { _ = sess.Close() }) 218 219 // A command that prints a hardcoded sentinel-like marker must not truncate 220 // its own output or spoof the exit code: the per-call random nonce makes 221 // the real marker unguessable. 222 out, code, err := sess.Exec(context.Background(), "printf '__ANT_CMD_DONE__7\\nafter\\n'; (exit 3)", 0) 223 require.NoError(t, err) 224 require.Contains(t, out, "__ANT_CMD_DONE__7") 225 require.Contains(t, out, "after") 226 require.Equal(t, 3, code) 227 } 228 229 func TestBashStdinRedirect(t *testing.T) { 230 sess, err := NewBashSession(t.TempDir(), nil) 231 require.NoError(t, err) 232 t.Cleanup(func() { _ = sess.Close() }) 233 234 // `cat` with an open stdin would block until the timeout; the </dev/null 235 // redirect on the wrapper lets it exit cleanly with EOF. 236 out, code, err := sess.Exec(context.Background(), "cat; echo done", 2*time.Second) 237 require.NoError(t, err) 238 require.Equal(t, "done", strings.TrimSpace(out)) 239 require.Equal(t, 0, code) 240 } 241 242 func TestBashOutputBufferBounded(t *testing.T) { 243 sess, err := NewBashSession(t.TempDir(), nil) 244 require.NoError(t, err) 245 t.Cleanup(func() { _ = sess.Close() }) 246 247 // A command that streams more than the cap must be truncated rather than 248 // buffered unbounded; the tail (and the sentinel) must survive. 249 out, code, err := sess.Exec(context.Background(), "head -c 300000 /dev/zero | tr '\\0' a; echo END", 0) 250 require.NoError(t, err) 251 require.True(t, strings.HasPrefix(out, "[output truncated]\n"), "truncated output is annotated") 252 require.True(t, strings.HasSuffix(strings.TrimSpace(out), "END"), "the tail of the output is kept") 253 require.LessOrEqual(t, len(out), bashOutputLimit+len("[output truncated]\n")) 254 require.Equal(t, 0, code) 255 } 256 257 // A shell dying mid-command returns ErrBashTerminated, distinct from the 258 // errBashClosed of an Exec after Close. 259 func TestBashSessionTerminatedIsMatchable(t *testing.T) { 260 sess, err := NewBashSession(t.TempDir(), nil) 261 require.NoError(t, err) 262 t.Cleanup(func() { _ = sess.Close() }) 263 264 _, _, err = sess.Exec(context.Background(), "exit 0", 5*time.Second) 265 require.ErrorIs(t, err, ErrBashTerminated, "a shell that exits mid-command is reported by name") 266 require.NotErrorIs(t, err, errBashClosed, "a dead shell is not the same failure as an Exec after Close") 267 268 _ = sess.Close() 269 _, _, err = sess.Exec(context.Background(), "echo after", 5*time.Second) 270 require.ErrorIs(t, err, errBashClosed, "Exec on a closed session is reported as closed") 271 }