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  }