Prefer Bash for shell command execution
- commit
- 608d44baa2650f3620ffcb72592103f265ede6a4
- author
- patriceckhart · · 2026-07-26 10:42 UTC
- parent
- 966d045
- signature
- unsigned
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 output is appended below the transcript as a terminal-log block (command echo, output, exit code), styled by success or failure. It stays on screen until you send your next prompt (or run `/clear`), so it doesn't bleed into the model conversation. 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 output is appended below the transcript as a terminal-log block (command echo, output, exit code), styled by success or failure. It stays on screen until you send your next prompt (or run `/clear`), so it doesn't bleed into the model conversation. 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 output is appended below the transcript as a terminal-log block (command echo, output, exit code), styled by success or failure. It stays on screen until you send your next prompt (or run `/clear`), so it doesn't bleed into the model conversation. 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 output is appended below the transcript as a terminal-log block (command echo, output, exit code), styled by success or failure. It stays on screen until you send your next prompt (or run `/clear`), so it doesn't bleed into the model conversation. 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") | ||