detached 7792f99

Merge branch 'feature/prefer-bash-shell'

# Conflicts:
#	README.md
commit
7792f998e4a526f5898f39db913e4acdc9314c54
author
patriceckhart · · 2026-07-26 19:35 UTC
merge of
999d53a + 608d44b · diff against the first parent
signature
unsigned
refs
tag: v0.3.13
3 files · +143 -7
M README.md +2 -2
@@ -226,7 +226,7 @@ zot --help
226226 - `read`: read text files, or inline images (PNG, JPEG, GIF, WebP).
227227 - `write`: create or overwrite files, making parent directories as needed.
228228 - `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.
230230
231231 When the sandbox is on (see `/jail`), all four tools refuse paths outside the session cwd.
232232
@@ -290,7 +290,7 @@ Extension-registered commands appear under a divider at the bottom of the popup,
290290
291291 ### Shell escape (`!command`)
292292
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.
294294
295295 ### `/sessions`
296296
@@ -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 {
3838 const bashSchema = `{"type":"object","properties":{"command":{"type":"string"},"timeout":{"type":"integer"}},"required":["command"]}`
3939
4040 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()) }
4242 func (t *BashTool) Schema() json.RawMessage { return json.RawMessage(bashSchema) }
4343
4444 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 {
231231 return name
232232 }
233233
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."
237265 }
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)
239275 }
@@ -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 name231 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
33 import (
44 "context"
55 "encoding/json"
6+ "errors"
67 "os"
78 "path/filepath"
89 "runtime"
@@ -257,6 +258,85 @@ func TestEditPreservesCRLF(t *testing.T) {
257258 }
258259 }
259260
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+
260340 func TestBashSuccess(t *testing.T) {
261341 if runtime.GOOS == "windows" {
262342 t.Skip("posix shell only")
@@ -275,6 +355,26 @@ func TestBashSuccess(t *testing.T) {
275355 }
276356 }
277357
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+
278378 func TestBashFailure(t *testing.T) {
279379 if runtime.GOOS == "windows" {
280380 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")

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close