Commit a27b371

Ian  ·  2026-03-08 23:03:14 -0400 EDT
parent ccdc1b9
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
+37, -7
......@@ -83,7 +83,7 @@ pub fn main() !void {
8383 session_name = arg;
8484 }
8585 }
86- const sesh = try socket.getSeshName(alloc, session_name.?);
86+ const sesh = try socket.getSeshName(alloc, session_name orelse "");
8787 defer alloc.free(sesh);
8888 return history(&cfg, sesh, format);
8989 } else if (std.mem.eql(u8, cmd, "attach") or std.mem.eql(u8, cmd, "a")) {
......@@ -567,6 +567,13 @@ const Daemon = struct {
567567 }
568568
569569 pub fn handleRun(self: *Daemon, client: *Client, pty_fd: i32, payload: []const u8) !void {
570+ // Reset task tracking so the new command's exit marker is detected.
571+ // Without this, a second `zmx run` on the same session is ignored
572+ // because task_exit_code is still set from the first run.
573+ self.task_exit_code = null;
574+ self.task_ended_at = null;
575+ self.is_task_mode = true;
576+
570577 if (payload.len > 0) {
571578 _ = try posix.write(pty_fd, payload);
572579 }
......@@ -641,6 +648,11 @@ fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void {
641648 var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
642649 const stdout = &stdout_writer.interface;
643650
651+ // Highest match count seen so far. Lets us distinguish "sessions haven't
652+ // appeared yet" (keep polling) from "sessions we were tracking
653+ // disappeared" (fail — daemon crashed or was killed).
654+ var max_seen: i32 = 0;
655+
644656 while (true) {
645657 var sessions = try util.get_session_entries(alloc, cfg.socket_dir);
646658 var total: i32 = 0;
......@@ -676,7 +688,18 @@ fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void {
676688 }
677689 sessions.deinit(alloc);
678690
679- if (total == done) {
691+ // Check disappearance BEFORE completion: if one of N sessions
692+ // crashed and the remaining N-1 happen to be done, total==done
693+ // would be a false success.
694+ if (total < max_seen) {
695+ try stdout.print("error: {d} session(s) disappeared before completing\n", .{max_seen - total});
696+ try stdout.flush();
697+ std.process.exit(1);
698+ return;
699+ }
700+ max_seen = total;
701+
702+ if (total > 0 and total == done) {
680703 try stdout.print("tasks completed!\n", .{});
681704 try stdout.flush();
682705 std.process.exit(agg_exit_code);
......@@ -947,7 +970,11 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
947970 }
948971
949972 try cmd_list.appendSlice(alloc, inline_task_marker);
950- try cmd_list.append(alloc, '\n');
973+ // \r, not \n: once the shell is at the readline prompt the PTY is in
974+ // raw mode; readline's accept-line binds to CR. The first-ever run
975+ // works with \n only because it arrives during shell startup while
976+ // the line discipline is still canonical.
977+ try cmd_list.append(alloc, '\r');
951978
952979 cmd_to_send = try cmd_list.toOwnedSlice(alloc);
953980 allocated_cmd = @constCast(cmd_to_send.?);
......@@ -968,13 +995,16 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
968995 }
969996
970997 if (stdin_buf.items.len > 0) {
971- const needs_newline = stdin_buf.items[stdin_buf.items.len - 1] != '\n';
972- if (needs_newline) {
973- try stdin_buf.append(alloc, '\n');
998+ // Normalize any trailing newline to CR so readline (raw mode)
999+ // accepts each line.
1000+ if (stdin_buf.items[stdin_buf.items.len - 1] == '\n') {
1001+ stdin_buf.items[stdin_buf.items.len - 1] = '\r';
1002+ } else {
1003+ try stdin_buf.append(alloc, '\r');
9741004 }
9751005
9761006 try stdin_buf.appendSlice(alloc, stdin_task_marker);
977- try stdin_buf.append(alloc, '\n');
1007+ try stdin_buf.append(alloc, '\r');
9781008
9791009 cmd_to_send = try alloc.dupe(u8, stdin_buf.items);
9801010 allocated_cmd = @constCast(cmd_to_send.?);
+13, -2
......@@ -7,10 +7,21 @@ pub fn seshPrefix() []const u8 {
77
88 pub fn getSeshName(alloc: std.mem.Allocator, sesh: []const u8) ![]const u8 {
99 const prefix = seshPrefix();
10- if (std.mem.eql(u8, prefix, "") and std.mem.eql(u8, sesh, "")) {
10+ if (prefix.len == 0 and sesh.len == 0) {
1111 return error.SessionNameRequired;
1212 }
13- return std.fmt.allocPrint(alloc, "{s}{s}", .{ seshPrefix(), sesh });
13+ const full = try std.fmt.allocPrint(alloc, "{s}{s}", .{ prefix, sesh });
14+ // Session names become filenames under socket_dir. Rejecting path
15+ // separators and dot-dot prevents socket creation and stale-socket
16+ // deletion from operating outside that directory.
17+ if (std.mem.indexOfScalar(u8, full, '/') != null or
18+ std.mem.indexOfScalar(u8, full, 0) != null or
19+ std.mem.eql(u8, full, ".") or std.mem.eql(u8, full, ".."))
20+ {
21+ alloc.free(full);
22+ return error.InvalidSessionName;
23+ }
24+ return full;
1425 }
1526
1627 pub fn sessionConnect(sesh: []const u8) !i32 {