repos / zmx

session persistence for terminal processes
git clone https://github.com/neurosnap/zmx.git

commit
a27b371
parent
ccdc1b9
author
Ian
date
2026-03-08 23:03:14 -0400 EDT
Fix verified bugs (#83)

* fix: validate session names to prevent path traversal

Session names become filenames under socket_dir. Without validation,
`zmx attach ../foo` would create a socket outside that directory, and
(worse) stale-socket cleanup via unlinkat(dirfd, "../foo") would delete
files outside it. The ZMX_SESSION_PREFIX env var was similarly unsanitized.

Also fixes a minor inefficiency: seshPrefix() was called twice.

* fix(history): don't panic when only flags are provided

`zmx history --vt` panicked on the .? unwrap when no positional session
name was given. Pass empty string instead so getSeshName handles it
(returns error.SessionNameRequired if no ZMX_SESSION_PREFIX is set either).

* fix(wait): don't report vacuous success when no sessions match

total == done was true when both were 0, so `zmx wait foo` returned
exit 0 immediately if no session named foo existed — either because of a
typo, or because of a race where the preceding `zmx run foo` hadn't yet
created its socket. Now wait keeps polling until at least one matching
session is seen.

Also tracks the high-water mark of matched sessions: if the count drops,
a session disappeared (daemon crashed or was killed), so wait errors out
instead of looping forever.

* fix(run): reset task state when receiving a Run command

The daemon's exit-marker scan is gated on task_exit_code == null, but that
field was never reset after the first task completed. A second `zmx run`
on the same session would have its ZMX_TASK_COMPLETED marker ignored,
causing `zmx wait` to immediately return the *first* task's exit code.

Also explicitly set is_task_mode so `zmx run` works against sessions that
were originally created by `zmx attach` (non-task mode).

* fix(run): use CR not LF to submit commands to an already-prompting shell

When `zmx run` sends a command to an existing session, the shell is at
the readline prompt with the PTY in raw mode. readline binds accept-line
to CR (\r), not LF (\n), so a trailing \n just moves the cursor — the
command sits typed but unexecuted.

The first-ever `zmx run` on a fresh session happened to work because
it's sent during shell startup, before readline takes over: the line
discipline is still canonical and ICRNL translates \n. Any subsequent
run silently did nothing, which was masked by the stale-task-state bug
(wait returned the first run's exit code, looking like success).

\r works in both modes: canonical mode translates it via ICRNL, and
raw-mode readline accepts it directly.
2 files changed,  +50, -9
M src/main.zig
+37, -7
 1@@ -83,7 +83,7 @@ pub fn main() !void {
 2                 session_name = arg;
 3             }
 4         }
 5-        const sesh = try socket.getSeshName(alloc, session_name.?);
 6+        const sesh = try socket.getSeshName(alloc, session_name orelse "");
 7         defer alloc.free(sesh);
 8         return history(&cfg, sesh, format);
 9     } else if (std.mem.eql(u8, cmd, "attach") or std.mem.eql(u8, cmd, "a")) {
10@@ -567,6 +567,13 @@ const Daemon = struct {
11     }
12 
13     pub fn handleRun(self: *Daemon, client: *Client, pty_fd: i32, payload: []const u8) !void {
14+        // Reset task tracking so the new command's exit marker is detected.
15+        // Without this, a second `zmx run` on the same session is ignored
16+        // because task_exit_code is still set from the first run.
17+        self.task_exit_code = null;
18+        self.task_ended_at = null;
19+        self.is_task_mode = true;
20+
21         if (payload.len > 0) {
22             _ = try posix.write(pty_fd, payload);
23         }
24@@ -641,6 +648,11 @@ fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void {
25     var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
26     const stdout = &stdout_writer.interface;
27 
28+    // Highest match count seen so far. Lets us distinguish "sessions haven't
29+    // appeared yet" (keep polling) from "sessions we were tracking
30+    // disappeared" (fail — daemon crashed or was killed).
31+    var max_seen: i32 = 0;
32+
33     while (true) {
34         var sessions = try util.get_session_entries(alloc, cfg.socket_dir);
35         var total: i32 = 0;
36@@ -676,7 +688,18 @@ fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void {
37         }
38         sessions.deinit(alloc);
39 
40-        if (total == done) {
41+        // Check disappearance BEFORE completion: if one of N sessions
42+        // crashed and the remaining N-1 happen to be done, total==done
43+        // would be a false success.
44+        if (total < max_seen) {
45+            try stdout.print("error: {d} session(s) disappeared before completing\n", .{max_seen - total});
46+            try stdout.flush();
47+            std.process.exit(1);
48+            return;
49+        }
50+        max_seen = total;
51+
52+        if (total > 0 and total == done) {
53             try stdout.print("tasks completed!\n", .{});
54             try stdout.flush();
55             std.process.exit(agg_exit_code);
56@@ -947,7 +970,11 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
57         }
58 
59         try cmd_list.appendSlice(alloc, inline_task_marker);
60-        try cmd_list.append(alloc, '\n');
61+        // \r, not \n: once the shell is at the readline prompt the PTY is in
62+        // raw mode; readline's accept-line binds to CR. The first-ever run
63+        // works with \n only because it arrives during shell startup while
64+        // the line discipline is still canonical.
65+        try cmd_list.append(alloc, '\r');
66 
67         cmd_to_send = try cmd_list.toOwnedSlice(alloc);
68         allocated_cmd = @constCast(cmd_to_send.?);
69@@ -968,13 +995,16 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
70             }
71 
72             if (stdin_buf.items.len > 0) {
73-                const needs_newline = stdin_buf.items[stdin_buf.items.len - 1] != '\n';
74-                if (needs_newline) {
75-                    try stdin_buf.append(alloc, '\n');
76+                // Normalize any trailing newline to CR so readline (raw mode)
77+                // accepts each line.
78+                if (stdin_buf.items[stdin_buf.items.len - 1] == '\n') {
79+                    stdin_buf.items[stdin_buf.items.len - 1] = '\r';
80+                } else {
81+                    try stdin_buf.append(alloc, '\r');
82                 }
83 
84                 try stdin_buf.appendSlice(alloc, stdin_task_marker);
85-                try stdin_buf.append(alloc, '\n');
86+                try stdin_buf.append(alloc, '\r');
87 
88                 cmd_to_send = try alloc.dupe(u8, stdin_buf.items);
89                 allocated_cmd = @constCast(cmd_to_send.?);
M src/socket.zig
+13, -2
 1@@ -7,10 +7,21 @@ pub fn seshPrefix() []const u8 {
 2 
 3 pub fn getSeshName(alloc: std.mem.Allocator, sesh: []const u8) ![]const u8 {
 4     const prefix = seshPrefix();
 5-    if (std.mem.eql(u8, prefix, "") and std.mem.eql(u8, sesh, "")) {
 6+    if (prefix.len == 0 and sesh.len == 0) {
 7         return error.SessionNameRequired;
 8     }
 9-    return std.fmt.allocPrint(alloc, "{s}{s}", .{ seshPrefix(), sesh });
10+    const full = try std.fmt.allocPrint(alloc, "{s}{s}", .{ prefix, sesh });
11+    // Session names become filenames under socket_dir. Rejecting path
12+    // separators and dot-dot prevents socket creation and stale-socket
13+    // deletion from operating outside that directory.
14+    if (std.mem.indexOfScalar(u8, full, '/') != null or
15+        std.mem.indexOfScalar(u8, full, 0) != null or
16+        std.mem.eql(u8, full, ".") or std.mem.eql(u8, full, ".."))
17+    {
18+        alloc.free(full);
19+        return error.InvalidSessionName;
20+    }
21+    return full;
22 }
23 
24 pub fn sessionConnect(sesh: []const u8) !i32 {