Merge branch 'feature/prefer-bash-shell'
# Conflicts: # README.md
3 files · +143 -7
M README.md +2 -2
| @@ -226,7 +226,7 @@ zot --help | |||
| 226 | 226 | - `read`: read text files, or inline images (PNG, JPEG, GIF, WebP). | |
| 227 | 227 | - `write`: create or overwrite files, making parent directories as needed. | |
| 228 | 228 | - `edit`: one or more exact-match replacements in an existing file. | |
| 229 | - | - `bash`: run a shell command in the session cwd, with merged stdout/stderr and a timeout. | |
| 229 | + | - `bash`: run a command in the session cwd with merged stdout/stderr and a timeout. On Unix, zot uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable. | |
| 230 | 230 | ||
| 231 | 231 | When the sandbox is on (see `/jail`), all four tools refuse paths outside the session cwd. | |
| 232 | 232 | ||
| @@ -290,7 +290,7 @@ Extension-registered commands appear under a divider at the bottom of the popup, | |||
| 290 | 290 | ||
| 291 | 291 | ### Shell escape (`!command`) | |
| 292 | 292 | ||
| 293 | - | Type `!` followed by a command to run it directly without going through the model. Everything after the `!` is passed to the same shell the `bash` tool uses (`/bin/sh -c` on Unix, `cmd /C` on Windows), runs in the session working directory, and honors the `/jail` sandbox. The command, merged output, and exit status are appended to the transcript as user context, so the model can use them on the next turn. Running the command does not itself start a model turn. A running `!command` shares the busy state with the agent: `esc` cancels it, and you cannot start one while a turn (or another shell escape) is in flight. | |
| 293 | + | Type `!` followed by a command to run it directly without going through the model. Everything after the `!` is passed to the same shell the `bash` tool uses (`/bin/bash -c` when available on Unix, then `bash -c` from `PATH`, with POSIX `/bin/sh -c` as a fallback; `cmd /C` on Windows), runs in the session working directory, and honors the `/jail` sandbox. The command, merged output, and exit status are appended to the transcript as user context, so the model can use them on the next turn. Running the command does not itself start a model turn. A running `!command` shares the busy state with the agent: `esc` cancels it, and you cannot start one while a turn (or another shell escape) is in flight. | |
| 294 | 294 | ||
| 295 | 295 | ### `/sessions` | |
| 296 | 296 | ||
| @@ -226,7 +226,7 @@ zot --help | |||||
| 226 | - `read`: read text files, or inline images (PNG, JPEG, GIF, WebP). | 226 | - `read`: read text files, or inline images (PNG, JPEG, GIF, WebP). | ||
| 227 | - `write`: create or overwrite files, making parent directories as needed. | 227 | - `write`: create or overwrite files, making parent directories as needed. | ||
| 228 | - `edit`: one or more exact-match replacements in an existing file. | 228 | - `edit`: one or more exact-match replacements in an existing file. | ||
| 229 | - | - `bash`: run a shell command in the session cwd, with merged stdout/stderr and a timeout. | 229 | + | - `bash`: run a command in the session cwd with merged stdout/stderr and a timeout. On Unix, zot uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable. |
| 230 | 230 | ||||
| 231 | When the sandbox is on (see `/jail`), all four tools refuse paths outside the session cwd. | 231 | When the sandbox is on (see `/jail`), all four tools refuse paths outside the session cwd. | ||
| 232 | 232 | ||||
| @@ -290,7 +290,7 @@ Extension-registered commands appear under a divider at the bottom of the popup, | |||||
| 290 | 290 | ||||
| 291 | ### Shell escape (`!command`) | 291 | ### Shell escape (`!command`) | ||
| 292 | 292 | ||||
| 293 | - | Type `!` followed by a command to run it directly without going through the model. Everything after the `!` is passed to the same shell the `bash` tool uses (`/bin/sh -c` on Unix, `cmd /C` on Windows), runs in the session working directory, and honors the `/jail` sandbox. The command, merged output, and exit status are appended to the transcript as user context, so the model can use them on the next turn. Running the command does not itself start a model turn. A running `!command` shares the busy state with the agent: `esc` cancels it, and you cannot start one while a turn (or another shell escape) is in flight. | 293 | + | Type `!` followed by a command to run it directly without going through the model. Everything after the `!` is passed to the same shell the `bash` tool uses (`/bin/bash -c` when available on Unix, then `bash -c` from `PATH`, with POSIX `/bin/sh -c` as a fallback; `cmd /C` on Windows), runs in the session working directory, and honors the `/jail` sandbox. The command, merged output, and exit status are appended to the transcript as user context, so the model can use them on the next turn. Running the command does not itself start a model turn. A running `!command` shares the busy state with the agent: `esc` cancels it, and you cannot start one while a turn (or another shell escape) is in flight. |
| 294 | 294 | ||||
| 295 | ### `/sessions` | 295 | ### `/sessions` | ||
| 296 | 296 | ||||
M packages/agent/tools/bash.go +41 -5
| @@ -38,7 +38,7 @@ type bashArgs struct { | |||
| 38 | 38 | const bashSchema = `{"type":"object","properties":{"command":{"type":"string"},"timeout":{"type":"integer"}},"required":["command"]}` | |
| 39 | 39 | ||
| 40 | 40 | func (t *BashTool) Name() string { return "bash" } | |
| 41 | - | func (t *BashTool) Description() string { return "Run a shell command. stdout+stderr merged." } | |
| 41 | + | func (t *BashTool) Description() string { return shellDescription(currentShell()) } | |
| 42 | 42 | func (t *BashTool) Schema() json.RawMessage { return json.RawMessage(bashSchema) } | |
| 43 | 43 | ||
| 44 | 44 | func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |
| @@ -231,9 +231,45 @@ func writeFullOutput(s string) string { | |||
| 231 | 231 | return name | |
| 232 | 232 | } | |
| 233 | 233 | ||
| 234 | - | func newShellCmd(ctx context.Context, command string) *exec.Cmd { | |
| 235 | - | if runtime.GOOS == "windows" { | |
| 236 | - | return exec.CommandContext(ctx, "cmd", "/C", command) | |
| 234 | + | type shellCommand struct { | |
| 235 | + | path string | |
| 236 | + | flag string | |
| 237 | + | isBash bool | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func currentShell() shellCommand { | |
| 241 | + | return resolveShell(runtime.GOOS, isExecutableFile, exec.LookPath) | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func resolveShell(goos string, executable func(string) bool, lookPath func(string) (string, error)) shellCommand { | |
| 245 | + | if goos == "windows" { | |
| 246 | + | return shellCommand{path: "cmd", flag: "/C"} | |
| 247 | + | } | |
| 248 | + | if executable("/bin/bash") { | |
| 249 | + | return shellCommand{path: "/bin/bash", flag: "-c", isBash: true} | |
| 250 | + | } | |
| 251 | + | if path, err := lookPath("bash"); err == nil { | |
| 252 | + | return shellCommand{path: path, flag: "-c", isBash: true} | |
| 253 | + | } | |
| 254 | + | return shellCommand{path: "/bin/sh", flag: "-c"} | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func isExecutableFile(path string) bool { | |
| 258 | + | info, err := os.Stat(path) | |
| 259 | + | return err == nil && !info.IsDir() && info.Mode().Perm()&0o111 != 0 | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func shellDescription(shell shellCommand) string { | |
| 263 | + | if shell.flag == "/C" { | |
| 264 | + | return "Run a Windows Command Prompt command via cmd /C. stdout+stderr merged." | |
| 237 | 265 | } | |
| 238 | - | return exec.CommandContext(ctx, "/bin/sh", "-c", command) | |
| 266 | + | if shell.isBash { | |
| 267 | + | return fmt.Sprintf("Run a Bash command via %s -c. stdout+stderr merged.", shell.path) | |
| 268 | + | } | |
| 269 | + | return "Bash is unavailable; run a POSIX sh command via /bin/sh -c. stdout+stderr merged." | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | func newShellCmd(ctx context.Context, command string) *exec.Cmd { | |
| 273 | + | shell := currentShell() | |
| 274 | + | return exec.CommandContext(ctx, shell.path, shell.flag, command) | |
| 239 | 275 | } | |
| @@ -38,7 +38,7 @@ type bashArgs struct { | |||||
| 38 | const bashSchema = `{"type":"object","properties":{"command":{"type":"string"},"timeout":{"type":"integer"}},"required":["command"]}` | 38 | const bashSchema = `{"type":"object","properties":{"command":{"type":"string"},"timeout":{"type":"integer"}},"required":["command"]}` | ||
| 39 | 39 | ||||
| 40 | func (t *BashTool) Name() string { return "bash" } | 40 | func (t *BashTool) Name() string { return "bash" } | ||
| 41 | - | func (t *BashTool) Description() string { return "Run a shell command. stdout+stderr merged." } | 41 | + | func (t *BashTool) Description() string { return shellDescription(currentShell()) } |
| 42 | func (t *BashTool) Schema() json.RawMessage { return json.RawMessage(bashSchema) } | 42 | func (t *BashTool) Schema() json.RawMessage { return json.RawMessage(bashSchema) } | ||
| 43 | 43 | ||||
| 44 | func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | 44 | func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | ||
| @@ -231,9 +231,45 @@ func writeFullOutput(s string) string { | |||||
| 231 | return name | 231 | return name | ||
| 232 | } | 232 | } | ||
| 233 | 233 | ||||
| 234 | - | func newShellCmd(ctx context.Context, command string) *exec.Cmd { | 234 | + | type shellCommand struct { |
| 235 | - | if runtime.GOOS == "windows" { | 235 | + | path string |
| 236 | - | return exec.CommandContext(ctx, "cmd", "/C", command) | 236 | + | flag string |
| 237 | + | isBash bool | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | func currentShell() shellCommand { | |||
| 241 | + | return resolveShell(runtime.GOOS, isExecutableFile, exec.LookPath) | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | func resolveShell(goos string, executable func(string) bool, lookPath func(string) (string, error)) shellCommand { | |||
| 245 | + | if goos == "windows" { | |||
| 246 | + | return shellCommand{path: "cmd", flag: "/C"} | |||
| 247 | + | } | |||
| 248 | + | if executable("/bin/bash") { | |||
| 249 | + | return shellCommand{path: "/bin/bash", flag: "-c", isBash: true} | |||
| 250 | + | } | |||
| 251 | + | if path, err := lookPath("bash"); err == nil { | |||
| 252 | + | return shellCommand{path: path, flag: "-c", isBash: true} | |||
| 253 | + | } | |||
| 254 | + | return shellCommand{path: "/bin/sh", flag: "-c"} | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | func isExecutableFile(path string) bool { | |||
| 258 | + | info, err := os.Stat(path) | |||
| 259 | + | return err == nil && !info.IsDir() && info.Mode().Perm()&0o111 != 0 | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func shellDescription(shell shellCommand) string { | |||
| 263 | + | if shell.flag == "/C" { | |||
| 264 | + | return "Run a Windows Command Prompt command via cmd /C. stdout+stderr merged." | |||
| 237 | } | 265 | } | ||
| 238 | - | return exec.CommandContext(ctx, "/bin/sh", "-c", command) | 266 | + | if shell.isBash { |
| 267 | + | return fmt.Sprintf("Run a Bash command via %s -c. stdout+stderr merged.", shell.path) | |||
| 268 | + | } | |||
| 269 | + | return "Bash is unavailable; run a POSIX sh command via /bin/sh -c. stdout+stderr merged." | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | func newShellCmd(ctx context.Context, command string) *exec.Cmd { | |||
| 273 | + | shell := currentShell() | |||
| 274 | + | return exec.CommandContext(ctx, shell.path, shell.flag, command) | |||
| 239 | } | 275 | } | ||
M packages/agent/tools/tools_test.go +100 -0
| @@ -3,6 +3,7 @@ package tools | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "errors" | |
| 6 | 7 | "os" | |
| 7 | 8 | "path/filepath" | |
| 8 | 9 | "runtime" | |
| @@ -257,6 +258,85 @@ func TestEditPreservesCRLF(t *testing.T) { | |||
| 257 | 258 | } | |
| 258 | 259 | } | |
| 259 | 260 | ||
| 261 | + | func TestResolveShell(t *testing.T) { | |
| 262 | + | notFound := errors.New("not found") | |
| 263 | + | tests := []struct { | |
| 264 | + | name string | |
| 265 | + | goos string | |
| 266 | + | binBash bool | |
| 267 | + | pathBash string | |
| 268 | + | pathErr error | |
| 269 | + | want shellCommand | |
| 270 | + | }{ | |
| 271 | + | { | |
| 272 | + | name: "prefers /bin/bash", | |
| 273 | + | goos: "linux", binBash: true, pathBash: "/usr/local/bin/bash", | |
| 274 | + | want: shellCommand{path: "/bin/bash", flag: "-c", isBash: true}, | |
| 275 | + | }, | |
| 276 | + | { | |
| 277 | + | name: "uses bash from PATH", | |
| 278 | + | goos: "linux", pathBash: "/usr/local/bin/bash", | |
| 279 | + | want: shellCommand{path: "/usr/local/bin/bash", flag: "-c", isBash: true}, | |
| 280 | + | }, | |
| 281 | + | { | |
| 282 | + | name: "falls back to POSIX sh", | |
| 283 | + | goos: "linux", pathErr: notFound, | |
| 284 | + | want: shellCommand{path: "/bin/sh", flag: "-c"}, | |
| 285 | + | }, | |
| 286 | + | { | |
| 287 | + | name: "uses Command Prompt on Windows", | |
| 288 | + | goos: "windows", binBash: true, pathBash: "/usr/local/bin/bash", | |
| 289 | + | want: shellCommand{path: "cmd", flag: "/C"}, | |
| 290 | + | }, | |
| 291 | + | } | |
| 292 | + | for _, tt := range tests { | |
| 293 | + | t.Run(tt.name, func(t *testing.T) { | |
| 294 | + | got := resolveShell(tt.goos, func(path string) bool { | |
| 295 | + | return path == "/bin/bash" && tt.binBash | |
| 296 | + | }, func(name string) (string, error) { | |
| 297 | + | if name != "bash" { | |
| 298 | + | t.Fatalf("unexpected PATH lookup for %q", name) | |
| 299 | + | } | |
| 300 | + | return tt.pathBash, tt.pathErr | |
| 301 | + | }) | |
| 302 | + | if got != tt.want { | |
| 303 | + | t.Fatalf("resolveShell() = %+v, want %+v", got, tt.want) | |
| 304 | + | } | |
| 305 | + | }) | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | func TestShellDescription(t *testing.T) { | |
| 310 | + | tests := []struct { | |
| 311 | + | name string | |
| 312 | + | shell shellCommand | |
| 313 | + | want string | |
| 314 | + | }{ | |
| 315 | + | { | |
| 316 | + | name: "bash", | |
| 317 | + | shell: shellCommand{path: "/opt/bin/bash", flag: "-c", isBash: true}, | |
| 318 | + | want: "Run a Bash command via /opt/bin/bash -c. stdout+stderr merged.", | |
| 319 | + | }, | |
| 320 | + | { | |
| 321 | + | name: "POSIX fallback", | |
| 322 | + | shell: shellCommand{path: "/bin/sh", flag: "-c"}, | |
| 323 | + | want: "Bash is unavailable; run a POSIX sh command via /bin/sh -c. stdout+stderr merged.", | |
| 324 | + | }, | |
| 325 | + | { | |
| 326 | + | name: "Windows", | |
| 327 | + | shell: shellCommand{path: "cmd", flag: "/C"}, | |
| 328 | + | want: "Run a Windows Command Prompt command via cmd /C. stdout+stderr merged.", | |
| 329 | + | }, | |
| 330 | + | } | |
| 331 | + | for _, tt := range tests { | |
| 332 | + | t.Run(tt.name, func(t *testing.T) { | |
| 333 | + | if got := shellDescription(tt.shell); got != tt.want { | |
| 334 | + | t.Fatalf("shellDescription() = %q, want %q", got, tt.want) | |
| 335 | + | } | |
| 336 | + | }) | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | ||
| 260 | 340 | func TestBashSuccess(t *testing.T) { | |
| 261 | 341 | if runtime.GOOS == "windows" { | |
| 262 | 342 | t.Skip("posix shell only") | |
| @@ -275,6 +355,26 @@ func TestBashSuccess(t *testing.T) { | |||
| 275 | 355 | } | |
| 276 | 356 | } | |
| 277 | 357 | ||
| 358 | + | func TestBashSyntax(t *testing.T) { | |
| 359 | + | if runtime.GOOS == "windows" { | |
| 360 | + | t.Skip("bash syntax is not used on Windows") | |
| 361 | + | } | |
| 362 | + | if !currentShell().isBash { | |
| 363 | + | t.Skip("bash is unavailable") | |
| 364 | + | } | |
| 365 | + | tool := &BashTool{CWD: t.TempDir()} | |
| 366 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{ | |
| 367 | + | "command": `items=(one two); [[ ${#items[@]} -eq 2 ]] && printf 'bash syntax works\n'`, | |
| 368 | + | }), nil) | |
| 369 | + | if err != nil { | |
| 370 | + | t.Fatal(err) | |
| 371 | + | } | |
| 372 | + | got := res.Content[0].(provider.TextBlock).Text | |
| 373 | + | if res.IsError || !strings.Contains(got, "bash syntax works") { | |
| 374 | + | t.Fatalf("got %q", got) | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | ||
| 278 | 378 | func TestBashFailure(t *testing.T) { | |
| 279 | 379 | if runtime.GOOS == "windows" { | |
| 280 | 380 | t.Skip("posix shell only") | |
| @@ -3,6 +3,7 @@ package tools | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | + | "errors" | |||
| 6 | "os" | 7 | "os" | ||
| 7 | "path/filepath" | 8 | "path/filepath" | ||
| 8 | "runtime" | 9 | "runtime" | ||
| @@ -257,6 +258,85 @@ func TestEditPreservesCRLF(t *testing.T) { | |||||
| 257 | } | 258 | } | ||
| 258 | } | 259 | } | ||
| 259 | 260 | ||||
| 261 | + | func TestResolveShell(t *testing.T) { | |||
| 262 | + | notFound := errors.New("not found") | |||
| 263 | + | tests := []struct { | |||
| 264 | + | name string | |||
| 265 | + | goos string | |||
| 266 | + | binBash bool | |||
| 267 | + | pathBash string | |||
| 268 | + | pathErr error | |||
| 269 | + | want shellCommand | |||
| 270 | + | }{ | |||
| 271 | + | { | |||
| 272 | + | name: "prefers /bin/bash", | |||
| 273 | + | goos: "linux", binBash: true, pathBash: "/usr/local/bin/bash", | |||
| 274 | + | want: shellCommand{path: "/bin/bash", flag: "-c", isBash: true}, | |||
| 275 | + | }, | |||
| 276 | + | { | |||
| 277 | + | name: "uses bash from PATH", | |||
| 278 | + | goos: "linux", pathBash: "/usr/local/bin/bash", | |||
| 279 | + | want: shellCommand{path: "/usr/local/bin/bash", flag: "-c", isBash: true}, | |||
| 280 | + | }, | |||
| 281 | + | { | |||
| 282 | + | name: "falls back to POSIX sh", | |||
| 283 | + | goos: "linux", pathErr: notFound, | |||
| 284 | + | want: shellCommand{path: "/bin/sh", flag: "-c"}, | |||
| 285 | + | }, | |||
| 286 | + | { | |||
| 287 | + | name: "uses Command Prompt on Windows", | |||
| 288 | + | goos: "windows", binBash: true, pathBash: "/usr/local/bin/bash", | |||
| 289 | + | want: shellCommand{path: "cmd", flag: "/C"}, | |||
| 290 | + | }, | |||
| 291 | + | } | |||
| 292 | + | for _, tt := range tests { | |||
| 293 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 294 | + | got := resolveShell(tt.goos, func(path string) bool { | |||
| 295 | + | return path == "/bin/bash" && tt.binBash | |||
| 296 | + | }, func(name string) (string, error) { | |||
| 297 | + | if name != "bash" { | |||
| 298 | + | t.Fatalf("unexpected PATH lookup for %q", name) | |||
| 299 | + | } | |||
| 300 | + | return tt.pathBash, tt.pathErr | |||
| 301 | + | }) | |||
| 302 | + | if got != tt.want { | |||
| 303 | + | t.Fatalf("resolveShell() = %+v, want %+v", got, tt.want) | |||
| 304 | + | } | |||
| 305 | + | }) | |||
| 306 | + | } | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | func TestShellDescription(t *testing.T) { | |||
| 310 | + | tests := []struct { | |||
| 311 | + | name string | |||
| 312 | + | shell shellCommand | |||
| 313 | + | want string | |||
| 314 | + | }{ | |||
| 315 | + | { | |||
| 316 | + | name: "bash", | |||
| 317 | + | shell: shellCommand{path: "/opt/bin/bash", flag: "-c", isBash: true}, | |||
| 318 | + | want: "Run a Bash command via /opt/bin/bash -c. stdout+stderr merged.", | |||
| 319 | + | }, | |||
| 320 | + | { | |||
| 321 | + | name: "POSIX fallback", | |||
| 322 | + | shell: shellCommand{path: "/bin/sh", flag: "-c"}, | |||
| 323 | + | want: "Bash is unavailable; run a POSIX sh command via /bin/sh -c. stdout+stderr merged.", | |||
| 324 | + | }, | |||
| 325 | + | { | |||
| 326 | + | name: "Windows", | |||
| 327 | + | shell: shellCommand{path: "cmd", flag: "/C"}, | |||
| 328 | + | want: "Run a Windows Command Prompt command via cmd /C. stdout+stderr merged.", | |||
| 329 | + | }, | |||
| 330 | + | } | |||
| 331 | + | for _, tt := range tests { | |||
| 332 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 333 | + | if got := shellDescription(tt.shell); got != tt.want { | |||
| 334 | + | t.Fatalf("shellDescription() = %q, want %q", got, tt.want) | |||
| 335 | + | } | |||
| 336 | + | }) | |||
| 337 | + | } | |||
| 338 | + | } | |||
| 339 | + | ||||
| 260 | func TestBashSuccess(t *testing.T) { | 340 | func TestBashSuccess(t *testing.T) { | ||
| 261 | if runtime.GOOS == "windows" { | 341 | if runtime.GOOS == "windows" { | ||
| 262 | t.Skip("posix shell only") | 342 | t.Skip("posix shell only") | ||
| @@ -275,6 +355,26 @@ func TestBashSuccess(t *testing.T) { | |||||
| 275 | } | 355 | } | ||
| 276 | } | 356 | } | ||
| 277 | 357 | ||||
| 358 | + | func TestBashSyntax(t *testing.T) { | |||
| 359 | + | if runtime.GOOS == "windows" { | |||
| 360 | + | t.Skip("bash syntax is not used on Windows") | |||
| 361 | + | } | |||
| 362 | + | if !currentShell().isBash { | |||
| 363 | + | t.Skip("bash is unavailable") | |||
| 364 | + | } | |||
| 365 | + | tool := &BashTool{CWD: t.TempDir()} | |||
| 366 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{ | |||
| 367 | + | "command": `items=(one two); [[ ${#items[@]} -eq 2 ]] && printf 'bash syntax works\n'`, | |||
| 368 | + | }), nil) | |||
| 369 | + | if err != nil { | |||
| 370 | + | t.Fatal(err) | |||
| 371 | + | } | |||
| 372 | + | got := res.Content[0].(provider.TextBlock).Text | |||
| 373 | + | if res.IsError || !strings.Contains(got, "bash syntax works") { | |||
| 374 | + | t.Fatalf("got %q", got) | |||
| 375 | + | } | |||
| 376 | + | } | |||
| 377 | + | ||||
| 278 | func TestBashFailure(t *testing.T) { | 378 | func TestBashFailure(t *testing.T) { | ||
| 279 | if runtime.GOOS == "windows" { | 379 | if runtime.GOOS == "windows" { | ||
| 280 | t.Skip("posix shell only") | 380 | t.Skip("posix shell only") | ||