Commit adb11d6

Eric Bower  ·  2026-05-21 10:06:07 -0400 EDT
parent a25e2fe
refactor: strip ansi escape codes for tail cmd

refactor: pass program env vars to help prevent
2 files changed,  +139, -18
+33, -17
 1@@ -1140,17 +1140,20 @@ const Daemon = struct {
 2 
 3         const cmd = payload;
 4 
 5-        // Redirect stdin from /dev/null to prevent interactive programs
 6-        // (pagers, editors, prompts) from blocking the task. Programs that
 7-        // detect a TTY and open a pager (e.g. git, man) or read stdin
 8-        // (e.g. cat, head) will receive EOF instead of blocking. This
 9-        // matches the behavior of CI runners and `tmux send-keys`.
10+        // Prefix the command with environment variables to prevent it from
11+        // blocking. Commands run in a PTY where stdout looks like a TTY,
12+        // so programs like git/man/less will open a pager and hang. We set
13+        // pager-related env vars to force non-interactive behavior:
14+        //   PAGER=cat       — default pager fallback
15+        //   GIT_PAGER=cat   — git ignores PAGER, uses its own
16+        //   LESS=-F         — auto-exit if content fits (catches edge cases)
17+        //   MANPAGER=cat    — man overrides PAGER with MANPAGER
18+        //   COLORTERM=      — disable color (avoid ANSI in output)
19+        // Plus < /dev/null to prevent programs that read stdin from blocking.
20         //
21         // Commands that legitimately need stdin should use `zmx send`
22         // instead, or pipe data directly: `echo data | zmx run dev cat`.
23-        // Here-documents still work because the shell processes the
24-        // here-document before applying the /dev/null redirection.
25-        const stdin_redirect = "< /dev/null ";
26+        const cmd_prefix = "PAGER=cat GIT_PAGER=cat LESS=-F MANPAGER=cat COLORTERM= < /dev/null ";
27 
28         // Chain the exit marker with `;` on the same line. `$?` captures the
29         // exit code of the command (not the `;`). The sole exception is when
30@@ -1160,7 +1163,7 @@ const Daemon = struct {
31         const heredoc_marker = "\r\necho ZMX_TASK_COMPLETED:$?\r";
32         const uses_heredoc = std.mem.indexOf(u8, cmd, "<<") != null;
33 
34-        self.queuePtyInput(stdin_redirect);
35+        self.queuePtyInput(cmd_prefix);
36         if (cmd.len > 0 and cmd[cmd.len - 1] == '\r') {
37             self.queuePtyInput(cmd[0 .. cmd.len - 1]);
38         } else {
39@@ -1451,17 +1454,30 @@ fn tail(client_socket_fds: std.ArrayList(i32), detached: bool, is_run_cmd: bool)
40                         },
41                         .Output => {
42                             if (msg.payload.len > 0) {
43-                                // strip the first line since it is an echo of
44-                                // the command.
45+                                // Strip the first line (command echo) for run mode.
46+                                var payload = msg.payload;
47                                 if (!detached and is_run_cmd and is_first_line) {
48-                                    if (std.mem.indexOfScalar(u8, msg.payload, '\n')) |nl| {
49+                                    if (std.mem.indexOfScalar(u8, payload, '\n')) |nl| {
50                                         is_first_line = false;
51-                                        if (nl + 1 < msg.payload.len) {
52-                                            try stdout_buf.appendSlice(alloc, msg.payload[nl + 1 ..]);
53-                                        }
54+                                        payload = payload[nl + 1 ..];
55+                                    } else {
56+                                        is_first_line = false;
57+                                        payload = payload[payload.len..]; // consume entire echo line
58+                                    }
59+                                }
60+
61+                                if (payload.len > 0) {
62+                                    // Strip ANSI escape sequences to produce plain text.
63+                                    // This prevents shell prompts, colors, cursor movements,
64+                                    // and other VT sequences from corrupting the caller's terminal.
65+                                    const plain = util.stripAnsi(alloc, payload) catch |err| {
66+                                        std.log.warn("stripAnsi failed: {s}", .{@errorName(err)});
67+                                        continue;
68+                                    };
69+                                    defer alloc.free(plain);
70+                                    if (plain.len > 0) {
71+                                        try stdout_buf.appendSlice(alloc, plain);
72                                     }
73-                                } else {
74-                                    try stdout_buf.appendSlice(alloc, msg.payload);
75                                 }
76                             }
77                         },
+106, -1
  1@@ -350,7 +350,41 @@ pub fn findTaskExitMarker(output: []const u8) ?u8 {
  2     return null;
  3 }
  4 
  5-/// Detects Kitty keyboard protocol escape sequence for Ctrl+\.
  6+/// Strip ANSI escape sequences from data, returning only printable characters
  7+/// and essential whitespace (CR, LF, tab, backspace). Uses the ghostty VT
  8+/// parser to correctly handle multi-byte sequences (CSI, OSC, DCS, etc.).n/// The returned slice is owned by the caller and must be freed.
  9+pub fn stripAnsi(alloc: std.mem.Allocator, data: []const u8) ![]const u8 {
 10+    var result = std.ArrayList(u8).initCapacity(alloc, data.len) catch unreachable;
 11+    defer result.deinit(alloc);
 12+
 13+    var parser = ghostty_vt.Parser.init();
 14+    for (data) |c| {
 15+        const actions = parser.next(c);
 16+        for (actions) |action_opt| {
 17+            const action = action_opt orelse continue;
 18+            switch (action) {
 19+                .print => {
 20+                    result.append(alloc, c) catch unreachable;
 21+                },
 22+                .execute => |code| {
 23+                    // Pass through essential whitespace/control chars
 24+                    switch (code) {
 25+                        '\r', '\n', '\t', 0x08 => { // CR, LF, TAB, BS
 26+                            result.append(alloc, @as(u8, @intCast(code))) catch unreachable;
 27+                        },
 28+                        else => {},
 29+                    }
 30+                },
 31+                // All other actions (CSI, OSC, DCS, etc.) are silently dropped
 32+                else => {},
 33+            }
 34+        }
 35+    }
 36+
 37+    return result.toOwnedSlice(alloc);
 38+}
 39+
 40+/// Detects Kitty keyboard protocol escape sequence for Ctrl+\
 41 pub fn isCtrlBackslash(buf: []const u8) bool {
 42     if (buf.len == 0) return false;
 43     return buf[0] == 0x1C or isKeyPressed(buf, 0x5c, 0b100);
 44@@ -1417,3 +1451,74 @@ test "isUserInput: bracketed paste included" {
 45     // Content between start/end is also user input
 46     try testing.expect(isUserInput("\x1b[200~hello\x1b[201~"));
 47 }
 48+
 49+test "stripAnsi: plain text passes through" {
 50+    const alloc = testing.allocator;
 51+    const result = try stripAnsi(alloc, "hello world\n");
 52+    defer alloc.free(result);
 53+    try testing.expectEqualStrings("hello world\n", result);
 54+}
 55+
 56+test "stripAnsi: removes SGR color codes" {
 57+    const alloc = testing.allocator;
 58+    // \e[31m = red, \e[0m = reset
 59+    const result = try stripAnsi(alloc, "\x1b[31mred\x1b[0m");
 60+    defer alloc.free(result);
 61+    try testing.expectEqualStrings("red", result);
 62+}
 63+
 64+test "stripAnsi: removes cursor movement" {
 65+    const alloc = testing.allocator;
 66+    // \e[2J = clear screen, \e[H = home cursor
 67+    const result = try stripAnsi(alloc, "\x1b[2J\x1b[Hhello");
 68+    defer alloc.free(result);
 69+    try testing.expectEqualStrings("hello", result);
 70+}
 71+
 72+test "stripAnsi: preserves newlines and tabs" {
 73+    const alloc = testing.allocator;
 74+    const result = try stripAnsi(alloc, "line1\nline2\ttab\r");
 75+    defer alloc.free(result);
 76+    try testing.expectEqualStrings("line1\nline2\ttab\r", result);
 77+}
 78+
 79+test "stripAnsi: removes OSC sequences" {
 80+    const alloc = testing.allocator;
 81+    // OSC 0;title BEL = set window title
 82+    const result = try stripAnsi(alloc, "\x1b]0;My Title\x07hello");
 83+    defer alloc.free(result);
 84+    try testing.expectEqualStrings("hello", result);
 85+}
 86+
 87+test "stripAnsi: removes DA query and response" {
 88+    const alloc = testing.allocator;
 89+    // DA1 query: \e[c, DA1 response: \e[?62;22c
 90+    const result = try stripAnsi(alloc, "\x1b[c\x1b[?62;22chello");
 91+    defer alloc.free(result);
 92+    try testing.expectEqualStrings("hello", result);
 93+}
 94+
 95+test "stripAnsi: complex mixed content" {
 96+    const alloc = testing.allocator;
 97+    // Shell prompt with colors + command echo + output
 98+    const input = "\x1b[0;32m[user@host ~]$\x1b[0m git log\n" ++
 99+        "abc1234 commit message\n" ++
100+        "\x1b[0;32m[user@host ~]$\x1b[0m";
101+    const result = try stripAnsi(alloc, input);
102+    defer alloc.free(result);
103+    try testing.expectEqualStrings("[user@host ~]$ git log\nabc1234 commit message\n[user@host ~]$", result);
104+}
105+
106+test "stripAnsi: empty input" {
107+    const alloc = testing.allocator;
108+    const result = try stripAnsi(alloc, "");
109+    defer alloc.free(result);
110+    try testing.expectEqualStrings("", result);
111+}
112+
113+test "stripAnsi: only escape sequences" {
114+    const alloc = testing.allocator;
115+    const result = try stripAnsi(alloc, "\x1b[31m\x1b[1m\x1b[0m");
116+    defer alloc.free(result);
117+    try testing.expectEqualStrings("", result);
118+}