Commit d588268

Eric Bower  ·  2026-05-20 10:41:34 -0400 EDT
parent bcf57a1
refactor(run): when creating session will always run `/bin/bash`

BREAKING CHANGE: `zmx run` now requires the target shell to support `$?` exit status tracking
5 files changed,  +94, -75
+7, -0
 1@@ -4,6 +4,13 @@ Use spec: https://common-changelog.org/
 2 
 3 ## Staged
 4 
 5+### Changed
 6+
 7+- *BREAKING* `zmx run` when creating session it runs `/bin/bash` instead of `$SHELL`
 8+  - There are just too many edge cases with tracking exit status in other shells which makes
 9+    `zmx run` much less useful for task management.
10+  - This means when using `zmx run` the target shell must have support for `$?` exit code tracking
11+
12 ## v0.6.0 - 2026-05-16
13 
14 ### Added
+1, -1
1@@ -78,7 +78,7 @@ Usage: zmx <command> [args...]
2 
3 Commands:
4   [a]ttach <name> [command...]             Attach to session, creating if needed
5-  [r]un <name> [-d] [--fish] [command...]  Send command without attaching
6+  [r]un <name> [-d] [command...]           Send command without attaching
7   [s]end <name> <text...>                  Send raw input to session PTY
8   [p]rint <name> <text...>                 Inject text into session display
9   [wr]ite <name> <file_path>               Write stdin to file_path through the session
+0, -57
 1@@ -5,7 +5,6 @@ const posix = std.posix;
 2 pub const c = switch (builtin.os.tag) {
 3     .macos => @cImport({
 4         @cInclude("sys/ioctl.h"); // ioctl and constants
 5-        @cInclude("sys/sysctl.h"); // sysctl for process name lookup
 6         @cInclude("termios.h");
 7         @cInclude("stdlib.h");
 8         @cInclude("unistd.h");
 9@@ -31,59 +30,3 @@ pub const forkpty = if (builtin.os.tag == .macos)
10     }.forkpty
11 else
12     c.forkpty;
13-
14-/// Returns the basename of the foreground process running on the given PTY fd.
15-/// Writes into `buf` and returns a slice of it, or null on failure.
16-pub fn getForegroundProcessName(pty_fd: i32, buf: []u8) ?[]const u8 {
17-    const pgid = c.tcgetpgrp(pty_fd);
18-    if (pgid <= 0) return null;
19-
20-    switch (builtin.os.tag) {
21-        .macos => {
22-            // Use KERN_PROC_PGRP to find the process in the foreground group.
23-            // We walk the process list and find the first process whose pgid matches.
24-            var mib = [_]c_int{ c.CTL_KERN, c.KERN_PROC, c.KERN_PROC_PGRP, @intCast(pgid) };
25-            var size: usize = 0;
26-            if (c.sysctl(&mib, mib.len, null, &size, null, 0) != 0) return null;
27-            if (size == 0) return null;
28-
29-            // kinfo_proc is large; allocate on heap to avoid blowing the stack
30-            const kinfo_size = @sizeOf(c.struct_kinfo_proc);
31-            const count = size / kinfo_size;
32-            if (count == 0) return null;
33-
34-            // Use a stack buffer for small lists (usually 1-3 procs), heap otherwise.
35-            var stack_buf: [8 * @sizeOf(c.struct_kinfo_proc)]u8 align(@alignOf(c.struct_kinfo_proc)) = undefined;
36-            const heap_needed = size > stack_buf.len;
37-            const proc_buf: []u8 = if (heap_needed)
38-                std.heap.c_allocator.alloc(u8, size) catch return null
39-            else
40-                stack_buf[0..size];
41-            defer if (heap_needed) std.heap.c_allocator.free(proc_buf);
42-
43-            if (c.sysctl(&mib, mib.len, proc_buf.ptr, &size, null, 0) != 0) return null;
44-
45-            const procs: []c.struct_kinfo_proc = @alignCast(std.mem.bytesAsSlice(c.struct_kinfo_proc, proc_buf[0..size]));
46-            if (procs.len == 0) return null;
47-
48-            // p_comm is a null-terminated fixed-length field
49-            const comm: [*:0]const u8 = @ptrCast(&procs[0].kp_proc.p_comm);
50-            const name = std.mem.sliceTo(comm, 0);
51-            const copy_len = @min(name.len, buf.len);
52-            @memcpy(buf[0..copy_len], name[0..copy_len]);
53-            return buf[0..copy_len];
54-        },
55-        .linux => {
56-            // /proc/<pid>/comm contains just the process name + newline
57-            var path_buf: [64]u8 = undefined;
58-            const path = std.fmt.bufPrint(&path_buf, "/proc/{d}/comm", .{pgid}) catch return null;
59-            const file = std.fs.openFileAbsolute(path, .{}) catch return null;
60-            defer file.close();
61-            const n = file.read(buf) catch return null;
62-            // strip trailing newline
63-            const end = if (n > 0 and buf[n - 1] == '\n') n - 1 else n;
64-            return buf[0..end];
65-        },
66-        else => return null,
67-    }
68-}
+5, -17
 1@@ -594,7 +594,6 @@ const Daemon = struct {
 2     is_task_mode: bool = false, // flag for when session is run as a task
 3     task_exit_code: ?u8 = null, // null = running or n/a, set when task completes
 4     task_ended_at: ?u64 = null, // timestamp when task exited
 5-    is_fish: bool = false, // true if session shell is fish (affects exit code variable)
 6     pty_fd: i32 = -1, // set by daemonLoop so handleRun can probe the foreground process
 7     pty_write_buf: std.ArrayList(u8) = .empty,
 8 
 9@@ -683,7 +682,7 @@ const Daemon = struct {
10             std.posix.exit(1);
11         }
12 
13-        const shell = util.detectShell();
14+        const shell: [:0]const u8 = if (self.is_task_mode) "/bin/bash" else util.detectShell();
15         // Use "-shellname" as argv[0] to signal login shell (traditional method)
16         const login_shell = try std.fmt.allocPrintSentinel(
17             alloc,
18@@ -1139,30 +1138,19 @@ const Daemon = struct {
19 
20         if (payload.len == 0) return;
21 
22-        // Auto-detect the foreground process on the PTY to determine shell type.
23-        if (self.pty_fd >= 0) {
24-            var name_buf: [64]u8 = undefined;
25-            if (cross.getForegroundProcessName(self.pty_fd, &name_buf)) |name| {
26-                self.is_fish = std.mem.eql(u8, name, "fish");
27-                std.log.debug("foreground process={s} is_fish={}", .{ name, self.is_fish });
28-            }
29-        }
30         const cmd = payload;
31 
32-        // Daemon appends the task marker so the client never injects
33-        // shell-specific syntax, keeping Ctrl-C recovery clean.
34-        const marker = if (self.is_fish)
35-            "; echo ZMX_TASK_COMPLETED:$status"
36-        else
37-            "; echo ZMX_TASK_COMPLETED:$?";
38+        // Daemon appends the task marker so we know when a task is done with
39+        // exit status
40+        const marker = "\necho ZMX_TASK_COMPLETED:$?\r";
41 
42         if (cmd.len > 0 and cmd[cmd.len - 1] == '\r') {
43             self.queuePtyInput(cmd[0 .. cmd.len - 1]);
44         } else {
45             self.queuePtyInput(cmd);
46         }
47-        self.queuePtyInput(marker);
48         self.queuePtyInput("\r");
49+        self.queuePtyInput(marker);
50 
51         try ipc.appendMessage(self.alloc, &client.write_buf, .Ack, "");
52         client.has_pending_output = true;
+81, -0
 1@@ -0,0 +1,81 @@
 2+#!/usr/bin/env bats
 3+# Tests for stdin piped to `zmx run` sessions.
 4+#
 5+# Verifies that `zmx run` sessions always use bash (not the user's $SHELL),
 6+# so piping commands via stdin works without quoting issues regardless of
 7+# the user's default shell.
 8+
 9+load test_helper
10+
11+# ============================================================================
12+# Session shell is always bash
13+# ============================================================================
14+
15+@test "run: session uses bash regardless of SHELL env" {
16+  run timeout 10 env SHELL=/usr/bin/fish "$ZMX" run test-shell-check echo 'hello'
17+  [ "$status" -eq 0 ]
18+
19+  sleep 0.3
20+  run "$ZMX" history test-shell-check
21+  # Task marker uses $? (bash syntax), not $status (fish syntax)
22+  [[ "$output" == *'ZMX_TASK_COMPLETED:'* ]]
23+  [[ "$output" == *'$?'* ]]
24+}
25+
26+# ============================================================================
27+# Stdin piped to run
28+# ============================================================================
29+
30+@test "run: stdin pipe executes command" {
31+  run bash -c 'printf "echo stdin-marker-abc123\n" | timeout 10 "$0" run test-stdin-basic' "$ZMX"
32+  [ "$status" -eq 0 ]
33+
34+  sleep 0.3
35+  run "$ZMX" history test-stdin-basic
36+  [[ "$output" == *"stdin-marker-abc123"* ]]
37+}
38+
39+@test "run: stdin with special characters passes through unmangled" {
40+  run bash -c 'printf "echo '\''hello \$USER \$(whoami) \\\"double\\\" ; # comment'\''\n" | timeout 10 "$0" run test-stdin-special' "$ZMX"
41+  [ "$status" -eq 0 ]
42+
43+  sleep 0.3
44+  run "$ZMX" history test-stdin-special
45+  [[ "$output" == *'$USER'* ]]
46+  [[ "$output" == *'$(whoami)'* ]]
47+}
48+
49+@test "run: multiline stdin script executes all lines" {
50+  local script
51+  script=$(printf 'echo line-one-aaa\necho line-two-bbb\necho line-three-ccc\n')
52+  run bash -c 'printf "%s" "$1" | timeout 10 "$0" run test-stdin-multi' "$ZMX" "$script"
53+  [ "$status" -eq 0 ]
54+
55+  sleep 0.5
56+  run "$ZMX" history test-stdin-multi
57+  [[ "$output" == *"line-one-aaa"* ]]
58+  [[ "$output" == *"line-two-bbb"* ]]
59+  [[ "$output" == *"line-three-ccc"* ]]
60+}
61+
62+@test "run: stdin with heredoc in script" {
63+  # Heredoc delimiter as the last line of stdin should work now that
64+  # the task marker is sent on its own line.
65+  local script
66+  script=$(printf "cat <<'EOF'\nThis has \"double\" and 'single' quotes\nand \$variables that should not expand\nEOF\n")
67+  run bash -c 'printf "%s" "$1" | timeout 10 "$0" run test-stdin-heredoc' "$ZMX" "$script"
68+  [ "$status" -eq 0 ]
69+
70+  sleep 0.5
71+  run "$ZMX" history test-stdin-heredoc
72+  [[ "$output" == *'$variables that should not expand'* ]]
73+}
74+
75+@test "run: args-only still works" {
76+  run timeout 10 env SHELL=/bin/bash "$ZMX" run test-args-only echo args-only-marker-999
77+  [ "$status" -eq 0 ]
78+
79+  sleep 0.3
80+  run "$ZMX" history test-args-only
81+  [[ "$output" == *"args-only-marker-999"* ]]
82+}