- 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
+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.?);
+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 {