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+}