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
| ... | ... | @@ -1140,17 +1140,20 @@ const Daemon = struct { | |
| 1140 | 1140 | ||
| 1141 | 1141 | const cmd = payload; | |
| 1142 | 1142 | ||
| 1143 | - | // Redirect stdin from /dev/null to prevent interactive programs | |
| 1144 | - | // (pagers, editors, prompts) from blocking the task. Programs that | |
| 1145 | - | // detect a TTY and open a pager (e.g. git, man) or read stdin | |
| 1146 | - | // (e.g. cat, head) will receive EOF instead of blocking. This | |
| 1147 | - | // matches the behavior of CI runners and `tmux send-keys`. | |
| 1143 | + | // Prefix the command with environment variables to prevent it from | |
| 1144 | + | // blocking. Commands run in a PTY where stdout looks like a TTY, | |
| 1145 | + | // so programs like git/man/less will open a pager and hang. We set | |
| 1146 | + | // pager-related env vars to force non-interactive behavior: | |
| 1147 | + | // PAGER=cat — default pager fallback | |
| 1148 | + | // GIT_PAGER=cat — git ignores PAGER, uses its own | |
| 1149 | + | // LESS=-F — auto-exit if content fits (catches edge cases) | |
| 1150 | + | // MANPAGER=cat — man overrides PAGER with MANPAGER | |
| 1151 | + | // COLORTERM= — disable color (avoid ANSI in output) | |
| 1152 | + | // Plus < /dev/null to prevent programs that read stdin from blocking. | |
| 1148 | 1153 | // | |
| 1149 | 1154 | // Commands that legitimately need stdin should use `zmx send` | |
| 1150 | 1155 | // instead, or pipe data directly: `echo data | zmx run dev cat`. | |
| 1151 | - | // Here-documents still work because the shell processes the | |
| 1152 | - | // here-document before applying the /dev/null redirection. | |
| 1153 | - | const stdin_redirect = "< /dev/null "; | |
| 1156 | + | const cmd_prefix = "PAGER=cat GIT_PAGER=cat LESS=-F MANPAGER=cat COLORTERM= < /dev/null "; | |
| 1154 | 1157 | ||
| 1155 | 1158 | // Chain the exit marker with `;` on the same line. `$?` captures the | |
| 1156 | 1159 | // exit code of the command (not the `;`). The sole exception is when |
| ... | ... | @@ -1160,7 +1163,7 @@ const Daemon = struct { | |
| 1160 | 1163 | const heredoc_marker = "\r\necho ZMX_TASK_COMPLETED:$?\r"; | |
| 1161 | 1164 | const uses_heredoc = std.mem.indexOf(u8, cmd, "<<") != null; | |
| 1162 | 1165 | ||
| 1163 | - | self.queuePtyInput(stdin_redirect); | |
| 1166 | + | self.queuePtyInput(cmd_prefix); | |
| 1164 | 1167 | if (cmd.len > 0 and cmd[cmd.len - 1] == '\r') { | |
| 1165 | 1168 | self.queuePtyInput(cmd[0 .. cmd.len - 1]); | |
| 1166 | 1169 | } else { |
| ... | ... | @@ -1451,17 +1454,30 @@ fn tail(client_socket_fds: std.ArrayList(i32), detached: bool, is_run_cmd: bool) | |
| 1451 | 1454 | }, | |
| 1452 | 1455 | .Output => { | |
| 1453 | 1456 | if (msg.payload.len > 0) { | |
| 1454 | - | // strip the first line since it is an echo of | |
| 1455 | - | // the command. | |
| 1457 | + | // Strip the first line (command echo) for run mode. | |
| 1458 | + | var payload = msg.payload; | |
| 1456 | 1459 | if (!detached and is_run_cmd and is_first_line) { | |
| 1457 | - | if (std.mem.indexOfScalar(u8, msg.payload, '\n')) |nl| { | |
| 1460 | + | if (std.mem.indexOfScalar(u8, payload, '\n')) |nl| { | |
| 1458 | 1461 | is_first_line = false; | |
| 1459 | - | if (nl + 1 < msg.payload.len) { | |
| 1460 | - | try stdout_buf.appendSlice(alloc, msg.payload[nl + 1 ..]); | |
| 1461 | - | } | |
| 1462 | + | payload = payload[nl + 1 ..]; | |
| 1463 | + | } else { | |
| 1464 | + | is_first_line = false; | |
| 1465 | + | payload = payload[payload.len..]; // consume entire echo line | |
| 1466 | + | } | |
| 1467 | + | } | |
| 1468 | + | ||
| 1469 | + | if (payload.len > 0) { | |
| 1470 | + | // Strip ANSI escape sequences to produce plain text. | |
| 1471 | + | // This prevents shell prompts, colors, cursor movements, | |
| 1472 | + | // and other VT sequences from corrupting the caller's terminal. | |
| 1473 | + | const plain = util.stripAnsi(alloc, payload) catch |err| { | |
| 1474 | + | std.log.warn("stripAnsi failed: {s}", .{@errorName(err)}); | |
| 1475 | + | continue; | |
| 1476 | + | }; | |
| 1477 | + | defer alloc.free(plain); | |
| 1478 | + | if (plain.len > 0) { | |
| 1479 | + | try stdout_buf.appendSlice(alloc, plain); | |
| 1462 | 1480 | } | |
| 1463 | - | } else { | |
| 1464 | - | try stdout_buf.appendSlice(alloc, msg.payload); | |
| 1465 | 1481 | } | |
| 1466 | 1482 | } | |
| 1467 | 1483 | }, |
+106,
-1
| ... | ... | @@ -350,7 +350,41 @@ pub fn findTaskExitMarker(output: []const u8) ?u8 { | |
| 350 | 350 | return null; | |
| 351 | 351 | } | |
| 352 | 352 | ||
| 353 | - | /// Detects Kitty keyboard protocol escape sequence for Ctrl+\. | |
| 353 | + | /// Strip ANSI escape sequences from data, returning only printable characters | |
| 354 | + | /// and essential whitespace (CR, LF, tab, backspace). Uses the ghostty VT | |
| 355 | + | /// parser to correctly handle multi-byte sequences (CSI, OSC, DCS, etc.).n/// The returned slice is owned by the caller and must be freed. | |
| 356 | + | pub fn stripAnsi(alloc: std.mem.Allocator, data: []const u8) ![]const u8 { | |
| 357 | + | var result = std.ArrayList(u8).initCapacity(alloc, data.len) catch unreachable; | |
| 358 | + | defer result.deinit(alloc); | |
| 359 | + | ||
| 360 | + | var parser = ghostty_vt.Parser.init(); | |
| 361 | + | for (data) |c| { | |
| 362 | + | const actions = parser.next(c); | |
| 363 | + | for (actions) |action_opt| { | |
| 364 | + | const action = action_opt orelse continue; | |
| 365 | + | switch (action) { | |
| 366 | + | .print => { | |
| 367 | + | result.append(alloc, c) catch unreachable; | |
| 368 | + | }, | |
| 369 | + | .execute => |code| { | |
| 370 | + | // Pass through essential whitespace/control chars | |
| 371 | + | switch (code) { | |
| 372 | + | '\r', '\n', '\t', 0x08 => { // CR, LF, TAB, BS | |
| 373 | + | result.append(alloc, @as(u8, @intCast(code))) catch unreachable; | |
| 374 | + | }, | |
| 375 | + | else => {}, | |
| 376 | + | } | |
| 377 | + | }, | |
| 378 | + | // All other actions (CSI, OSC, DCS, etc.) are silently dropped | |
| 379 | + | else => {}, | |
| 380 | + | } | |
| 381 | + | } | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | return result.toOwnedSlice(alloc); | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | /// Detects Kitty keyboard protocol escape sequence for Ctrl+\ | |
| 354 | 388 | pub fn isCtrlBackslash(buf: []const u8) bool { | |
| 355 | 389 | if (buf.len == 0) return false; | |
| 356 | 390 | return buf[0] == 0x1C or isKeyPressed(buf, 0x5c, 0b100); |
| ... | ... | @@ -1417,3 +1451,74 @@ test "isUserInput: bracketed paste included" { | |
| 1417 | 1451 | // Content between start/end is also user input | |
| 1418 | 1452 | try testing.expect(isUserInput("\x1b[200~hello\x1b[201~")); | |
| 1419 | 1453 | } | |
| 1454 | + | ||
| 1455 | + | test "stripAnsi: plain text passes through" { | |
| 1456 | + | const alloc = testing.allocator; | |
| 1457 | + | const result = try stripAnsi(alloc, "hello world\n"); | |
| 1458 | + | defer alloc.free(result); | |
| 1459 | + | try testing.expectEqualStrings("hello world\n", result); | |
| 1460 | + | } | |
| 1461 | + | ||
| 1462 | + | test "stripAnsi: removes SGR color codes" { | |
| 1463 | + | const alloc = testing.allocator; | |
| 1464 | + | // \e[31m = red, \e[0m = reset | |
| 1465 | + | const result = try stripAnsi(alloc, "\x1b[31mred\x1b[0m"); | |
| 1466 | + | defer alloc.free(result); | |
| 1467 | + | try testing.expectEqualStrings("red", result); | |
| 1468 | + | } | |
| 1469 | + | ||
| 1470 | + | test "stripAnsi: removes cursor movement" { | |
| 1471 | + | const alloc = testing.allocator; | |
| 1472 | + | // \e[2J = clear screen, \e[H = home cursor | |
| 1473 | + | const result = try stripAnsi(alloc, "\x1b[2J\x1b[Hhello"); | |
| 1474 | + | defer alloc.free(result); | |
| 1475 | + | try testing.expectEqualStrings("hello", result); | |
| 1476 | + | } | |
| 1477 | + | ||
| 1478 | + | test "stripAnsi: preserves newlines and tabs" { | |
| 1479 | + | const alloc = testing.allocator; | |
| 1480 | + | const result = try stripAnsi(alloc, "line1\nline2\ttab\r"); | |
| 1481 | + | defer alloc.free(result); | |
| 1482 | + | try testing.expectEqualStrings("line1\nline2\ttab\r", result); | |
| 1483 | + | } | |
| 1484 | + | ||
| 1485 | + | test "stripAnsi: removes OSC sequences" { | |
| 1486 | + | const alloc = testing.allocator; | |
| 1487 | + | // OSC 0;title BEL = set window title | |
| 1488 | + | const result = try stripAnsi(alloc, "\x1b]0;My Title\x07hello"); | |
| 1489 | + | defer alloc.free(result); | |
| 1490 | + | try testing.expectEqualStrings("hello", result); | |
| 1491 | + | } | |
| 1492 | + | ||
| 1493 | + | test "stripAnsi: removes DA query and response" { | |
| 1494 | + | const alloc = testing.allocator; | |
| 1495 | + | // DA1 query: \e[c, DA1 response: \e[?62;22c | |
| 1496 | + | const result = try stripAnsi(alloc, "\x1b[c\x1b[?62;22chello"); | |
| 1497 | + | defer alloc.free(result); | |
| 1498 | + | try testing.expectEqualStrings("hello", result); | |
| 1499 | + | } | |
| 1500 | + | ||
| 1501 | + | test "stripAnsi: complex mixed content" { | |
| 1502 | + | const alloc = testing.allocator; | |
| 1503 | + | // Shell prompt with colors + command echo + output | |
| 1504 | + | const input = "\x1b[0;32m[user@host ~]$\x1b[0m git log\n" ++ | |
| 1505 | + | "abc1234 commit message\n" ++ | |
| 1506 | + | "\x1b[0;32m[user@host ~]$\x1b[0m"; | |
| 1507 | + | const result = try stripAnsi(alloc, input); | |
| 1508 | + | defer alloc.free(result); | |
| 1509 | + | try testing.expectEqualStrings("[user@host ~]$ git log\nabc1234 commit message\n[user@host ~]$", result); | |
| 1510 | + | } | |
| 1511 | + | ||
| 1512 | + | test "stripAnsi: empty input" { | |
| 1513 | + | const alloc = testing.allocator; | |
| 1514 | + | const result = try stripAnsi(alloc, ""); | |
| 1515 | + | defer alloc.free(result); | |
| 1516 | + | try testing.expectEqualStrings("", result); | |
| 1517 | + | } | |
| 1518 | + | ||
| 1519 | + | test "stripAnsi: only escape sequences" { | |
| 1520 | + | const alloc = testing.allocator; | |
| 1521 | + | const result = try stripAnsi(alloc, "\x1b[31m\x1b[1m\x1b[0m"); | |
| 1522 | + | defer alloc.free(result); | |
| 1523 | + | try testing.expectEqualStrings("", result); | |
| 1524 | + | } |