Commit e4e7ad7

Chance Zibolski  ·  2026-08-05 00:41:09 -0400 EDT
parent a0fc894
fix(cwd): decode the OSC 7 cwd before the chdir

`Daemon.cwd` holds the OSC 7 value, which is percent-encoded, and nothing
decoded it before opening the directory. The chdir on session create
failed on any escaped character:

    [warning] failed to open dir=/tmp/zmx%20spaced%20dir err=FileNotFound

The new session then silently landed in the daemon's inherited cwd.

`util.parseOsc7Cwd` decodes the value to an openable path and reports
whether its host is this machine. `Daemon.setCwd` keeps both: `cwd` stays
in `file://<host><path>` form so `zmx list` still shows the host, which is
what tells you a session is inside SSH, and `cwd_path` holds the decoded
path used for the chdir. A caller that supplies a plain path (`zmx run`,
`zmx attach`) gets a URI built by `util.toOsc7Cwd`, so the stored value
has one shape regardless of source.

`cwd_path` is left empty when the cwd is on another host, since OSC 7
crosses SSH boundaries and the directory does not exist here. That check
previously used `mem.eql` against gethostname(), so a missing host,
`localhost`, and short-name/FQDN mismatches all read as remote and
disabled the chdir even locally.

Refs #222
5 files changed,  +442, -36
+2, -0
......@@ -14,6 +14,8 @@ Use spec: https://common-changelog.org/
1414
1515 - Clear screen when switching sessions to prevent term state corruption
1616 - Stray NUL byte in the OSC 7 sequence replayed on attach
17+- The OSC 7 cwd is now decoded before the chdir, so a new session can start in
18+ a directory whose name needed percent-encoding
1719
1820 ### Changed
1921
+70, -32
......@@ -560,7 +560,18 @@ pub const Daemon = struct {
560560 running: bool = true,
561561 pid: i32 = undefined,
562562 command: ?[]const []const u8 = null,
563+ /// The session's working directory in OSC 7 form, `file://<host><path>`.
564+ /// Kept as a URI rather than a path so `zmx list` shows the host, which is
565+ /// what tells you a session is inside SSH. Points into `cwd_buf` once set,
566+ /// so a Daemon must not be copied by value after that.
563567 cwd: []const u8 = "",
568+ /// The same directory as a path that can be opened: percent-decoding
569+ /// applied, scheme and host stripped. Empty when the cwd is on another
570+ /// host, since then it names no directory here and nothing should chdir
571+ /// into it. Points into `cwd_path_buf`.
572+ cwd_path: []const u8 = "",
573+ cwd_buf: [std.fs.max_path_bytes]u8 = undefined,
574+ cwd_path_buf: [std.fs.max_path_bytes]u8 = undefined,
564575 has_pty_output: bool = false,
565576 has_had_client: bool = false,
566577 has_terminal_client: bool = false, // true only after a real attach (.Init received)
......@@ -691,31 +702,19 @@ pub const Daemon = struct {
691702 var keep_fds_open = [_]i32{ server_sock_fd, dir.handle, log_fd };
692703 const cmd = try daemonize.createCmdZ(self.shell, self.is_task_mode, self.command);
693704
694- // format will look like file://{host}{path}
695- std.log.info("checking pwd={s}", .{self.cwd});
696- const uri_opt = std.Uri.parse(self.cwd) catch |err| blk: {
697- std.log.warn("uri parse failed err={s}", .{@errorName(err)});
698- break :blk null;
699- };
700- if (uri_opt) |uri| {
701- var host_buf: [255]u8 = undefined;
702- const pwd_host = if (uri.getHost(&host_buf) catch null) |host| host.bytes else "unknown";
703- var buf: [std.posix.HOST_NAME_MAX]u8 = undefined;
704- const hostname = try std.posix.gethostname(&buf);
705- std.log.info("pwd_host={s} hostname={s}", .{ pwd_host, hostname });
706- if (std.mem.eql(u8, pwd_host, hostname)) {
707- const path_str = switch (uri.path) {
708- .raw, .percent_encoded => |s| s,
709- };
710- const pwd_dir = std.Io.Dir.openDirAbsolute(io, path_str, .{}) catch |err| blk: {
711- std.log.warn("failed to open dir={s} err={s}", .{ path_str, @errorName(err) });
712- break :blk null;
713- };
714- if (pwd_dir) |pdir| {
715- defer std.Io.Dir.close(pdir, io);
716- std.log.info("set directory dir={s}", .{path_str});
717- try std.process.setCurrentDir(io, pdir);
718- }
705+ // `cwd_path` is the decoded path, and is empty when the cwd is on
706+ // another host: OSC 7 crosses SSH boundaries, so a session that ssh'd
707+ // elsewhere reports a directory that does not exist on this machine.
708+ std.log.info("checking pwd={s} path={s}", .{ self.cwd, self.cwd_path });
709+ if (self.cwd_path.len > 0) {
710+ const pwd_dir = std.Io.Dir.openDirAbsolute(io, self.cwd_path, .{}) catch |err| blk: {
711+ std.log.warn("failed to open dir={s} err={s}", .{ self.cwd_path, @errorName(err) });
712+ break :blk null;
713+ };
714+ if (pwd_dir) |pdir| {
715+ defer std.Io.Dir.close(pdir, io);
716+ std.log.info("set directory dir={s}", .{self.cwd_path});
717+ try std.process.setCurrentDir(io, pdir);
719718 }
720719 }
721720
......@@ -866,8 +865,11 @@ pub const Daemon = struct {
866865 pub fn handleSwitch(self: *Daemon, gpa: std.mem.Allocator, session_name: []const u8) !void {
867866 for (self.clients.items) |client| {
868867 if (self.leader_client_fd == client.socket_fd) {
869- // Include the daemon's current cwd so the new session can start in the right directory
870- if (self.cwd.len > 0) {
868+ // Include the daemon's current cwd so the new session can start
869+ // in the right directory. A remote cwd is left out: it names no
870+ // directory here, so the new session is better off with the
871+ // attaching client's own cwd than with a path it cannot enter.
872+ if (self.cwd.len > 0 and self.cwd_path.len > 0) {
871873 var payload = gpa.alloc(u8, session_name.len + 1 + self.cwd.len) catch return;
872874 defer gpa.free(payload);
873875 @memcpy(payload[0..session_name.len], session_name);
......@@ -1141,12 +1143,48 @@ pub const Daemon = struct {
11411143 std.log.debug("run command len={d}", .{payload.len});
11421144 }
11431145
1144- fn setPwd(self: *Daemon, term: *ghostty_vt.Terminal) void {
1145- const pwd_opt = term.getPwd();
1146- if (pwd_opt) |pwd| {
1147- std.log.info("setting pwd to ghostty term pwd={s}", .{pwd});
1148- self.cwd = pwd;
1146+ /// Store the session's working directory as a plain path.
1147+ ///
1148+ /// Accepts either an OSC 7 value (`file://<host><path>`, percent-encoded)
1149+ /// or a path. Decoding here rather than at each use keeps `zmx list`
1150+ /// printing a path and lets the chdir on session create find directories
1151+ /// whose names needed escaping.
1152+ ///
1153+ /// The value is copied, so callers may pass a temporary.
1154+ pub fn setCwd(self: *Daemon, value: []const u8) void {
1155+ var buf: [std.fs.max_path_bytes]u8 = undefined;
1156+ var host_buf: [std.posix.HOST_NAME_MAX]u8 = undefined;
1157+ const hostname = std.posix.gethostname(&host_buf) catch "";
1158+ const cwd = util.parseOsc7Cwd(&buf, value, hostname) orelse {
1159+ std.log.warn("ignoring unusable cwd={s}", .{value});
1160+ return;
1161+ };
1162+
1163+ // Store the URI form. A caller that handed us a plain path gets one
1164+ // built here, so `cwd` has the same shape no matter the source. A value
1165+ // that already was a URI is kept verbatim, so `list` shows what the
1166+ // shell actually reported.
1167+ self.cwd = if (std.fs.path.isAbsolute(value))
1168+ util.toOsc7Cwd(&self.cwd_buf, value, hostname) orelse return
1169+ else blk: {
1170+ if (value.len > self.cwd_buf.len) return;
1171+ @memcpy(self.cwd_buf[0..value.len], value);
1172+ break :blk self.cwd_buf[0..value.len];
1173+ };
1174+
1175+ // Only keep an openable path when it names a directory on this host.
1176+ if (cwd.is_local and cwd.path.len <= self.cwd_path_buf.len) {
1177+ @memcpy(self.cwd_path_buf[0..cwd.path.len], cwd.path);
1178+ self.cwd_path = self.cwd_path_buf[0..cwd.path.len];
1179+ } else {
1180+ self.cwd_path = "";
11491181 }
1182+ std.log.info("set cwd={s} path={s}", .{ self.cwd, self.cwd_path });
1183+ }
1184+
1185+ fn setPwd(self: *Daemon, term: *ghostty_vt.Terminal) void {
1186+ const pwd = term.getPwd() orelse return;
1187+ self.setCwd(pwd);
11501188 }
11511189
11521190 pub fn handleOutput(self: *Daemon, gpa: std.mem.Allocator, payload: []const u8, term: *ghostty_vt.Terminal, vt_stream: anytype) !void {
+4, -4
......@@ -147,7 +147,7 @@ pub fn main(init: std.process.Init) !void {
147147 };
148148 var daemon = Daemon.init(io, &cfg, sesh, socket_path);
149149 daemon.command = command;
150- daemon.cwd = cwd;
150+ daemon.setCwd(cwd);
151151 daemon.shell = shell_env;
152152 std.log.info("socket path={s}", .{daemon.socket_path});
153153 return attach(gpa, io, &daemon);
......@@ -180,7 +180,7 @@ pub fn main(init: std.process.Init) !void {
180180 };
181181 defer gpa.free(socket_path);
182182 var daemon = Daemon.init(io, &cfg, sesh, socket_path);
183- daemon.cwd = cwd;
183+ daemon.setCwd(cwd);
184184 daemon.is_task_mode = true;
185185 daemon.shell = shell_env;
186186 std.log.info("socket path={s}", .{daemon.socket_path});
......@@ -395,7 +395,7 @@ pub fn main(init: std.process.Init) !void {
395395 };
396396 var daemon = Daemon.init(io, &cfg, sesh, socket_path);
397397 daemon.is_task_mode = true;
398- daemon.cwd = cwd;
398+ daemon.setCwd(cwd);
399399 daemon.shell = shell_env;
400400 std.log.info("socket path={s}", .{daemon.socket_path});
401401 try writeFile(gpa, io, &daemon, file_path);
......@@ -1371,7 +1371,7 @@ fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon) !void {
13711371 // otherwise fall back to the client's original cwd
13721372 const switch_cwd = looper.cwd orelse daemon.cwd;
13731373 std.log.info("switching to new session cwd={s}", .{switch_cwd});
1374- target_daemon.cwd = switch_cwd;
1374+ target_daemon.setCwd(switch_cwd);
13751375 target_daemon.shell = daemon.shell;
13761376 return attach(gpa, io, &target_daemon);
13771377 }
+292, -0
......@@ -115,6 +115,96 @@ pub fn get_session_entries(
115115 return sessions;
116116 }
117117
118+pub const Cwd = struct {
119+ /// A filesystem path, percent-decoded, with no scheme or host.
120+ path: []const u8,
121+ /// True when the OSC 7 host is this machine, so `path` names a directory we
122+ /// can actually chdir into. OSC 7 crosses SSH boundaries, so a session that
123+ /// ssh'd elsewhere reports a path that does not exist locally.
124+ is_local: bool,
125+};
126+
127+/// parseOsc7Cwd turns an OSC 7 value into a path that can be opened.
128+///
129+/// The value looks like `file://<host><path>` with the path percent-encoded, so
130+/// it cannot be handed to `openDirAbsolute` as-is: the escaping is never
131+/// decoded and any directory whose name needed it fails to open.
132+///
133+/// A plain absolute path is accepted and passed through, since a caller may
134+/// hand us one directly before the session has reported an OSC 7.
135+///
136+/// `buf` holds the decoded path, so the result stays valid after the source
137+/// value changes. Returns null when the value is not a path we can use.
138+pub fn parseOsc7Cwd(buf: []u8, value: []const u8, hostname: []const u8) ?Cwd {
139+ if (value.len == 0) return null;
140+
141+ if (std.fs.path.isAbsolute(value)) {
142+ if (value.len > buf.len) return null;
143+ @memcpy(buf[0..value.len], value);
144+ return .{ .path = buf[0..value.len], .is_local = true };
145+ }
146+
147+ const uri = std.Uri.parse(value) catch return null;
148+ // kitty emits kitty-shell-cwd:// from its own shell integration, and
149+ // accepts it alongside file:// on the way back in.
150+ if (!std.mem.eql(u8, uri.scheme, "file") and
151+ !std.mem.eql(u8, uri.scheme, "kitty-shell-cwd")) return null;
152+
153+ const decoded = uri.path.toRaw(buf) catch return null;
154+ if (!std.fs.path.isAbsolute(decoded)) return null;
155+ // toRaw returns the input slice when there was nothing to decode, and that
156+ // slice is owned by the caller of this fn, so copy it into buf either way.
157+ const path = if (decoded.ptr == buf.ptr) decoded else blk: {
158+ if (decoded.len > buf.len) return null;
159+ std.mem.copyForwards(u8, buf[0..decoded.len], decoded);
160+ break :blk buf[0..decoded.len];
161+ };
162+
163+ return .{ .path = path, .is_local = isLocalHost(uri.host, hostname) };
164+}
165+
166+fn isLocalHost(host: ?std.Uri.Component, hostname: []const u8) bool {
167+ // file:///path omits the host, which conventionally means the local machine.
168+ const component = host orelse return true;
169+ var host_buf: [std.posix.HOST_NAME_MAX]u8 = undefined;
170+ const value = component.toRaw(&host_buf) catch return false;
171+ if (value.len == 0) return true;
172+ if (std.ascii.eqlIgnoreCase(value, "localhost")) return true;
173+ if (std.ascii.eqlIgnoreCase(value, hostname)) return true;
174+ // gethostname often reports a short name while OSC 7 carries the FQDN
175+ // (or the reverse), so fall back to comparing the first label.
176+ const value_label = value[0 .. std.mem.indexOfScalar(u8, value, '.') orelse value.len];
177+ const host_label = hostname[0 .. std.mem.indexOfScalar(u8, hostname, '.') orelse hostname.len];
178+ return host_label.len > 0 and std.ascii.eqlIgnoreCase(value_label, host_label);
179+}
180+
181+/// toOsc7Cwd renders a plain path as the OSC 7 form, `file://<host><path>`.
182+///
183+/// The daemon stores its cwd in this form so `zmx list` shows the host, which
184+/// is how you can tell at a glance that a session is inside SSH. Callers that
185+/// only have a local path (`zmx run`, `zmx attach`) go through this so the
186+/// stored value has one shape regardless of where it came from.
187+///
188+/// Returns null when the result would not fit in `buf`.
189+pub fn toOsc7Cwd(buf: []u8, path: []const u8, hostname: []const u8) ?[]const u8 {
190+ var w: std.Io.Writer = .fixed(buf);
191+ w.print("file://{s}", .{hostname}) catch return null;
192+ // Percent-encode so a path with a space or a `%` in it round-trips back
193+ // through parseOsc7Cwd unchanged.
194+ std.Uri.Component.percentEncode(&w, path, isPathChar) catch return null;
195+ return w.buffered();
196+}
197+
198+/// Characters that need no escaping in a URI path. RFC 3986 pchar, minus the
199+/// sub-delims that a shell would find surprising to see left raw in `zmx list`.
200+fn isPathChar(c: u8) bool {
201+ return switch (c) {
202+ 'A'...'Z', 'a'...'z', '0'...'9' => true,
203+ '-', '.', '_', '~', '/', ':', '@' => true,
204+ else => false,
205+ };
206+}
207+
118208 /// getCwd get the current working directory in a std.Uri format.
119209 /// Caller is responsible for releasing memory.
120210 pub fn getCwd(gpa: std.mem.Allocator, io: std.Io) ![]u8 {
......@@ -1234,6 +1324,208 @@ test "isDetachKeyDisabled" {
12341324 try testing.expect(isDetachKeyDisabled());
12351325 }
12361326
1327+test "parseOsc7Cwd" {
1328+ const Case = struct {
1329+ name: []const u8,
1330+ value: []const u8,
1331+ hostname: []const u8,
1332+ expected: ?Cwd,
1333+ };
1334+
1335+ const cases = [_]Case{
1336+ .{
1337+ .name = "local file uri",
1338+ .value = "file://myhost/private/tmp",
1339+ .hostname = "myhost",
1340+ .expected = .{ .path = "/private/tmp", .is_local = true },
1341+ },
1342+ .{
1343+ .name = "percent-encoded path is decoded",
1344+ .value = "file://myhost/tmp/zmx%20spaced%20dir",
1345+ .hostname = "myhost",
1346+ .expected = .{ .path = "/tmp/zmx spaced dir", .is_local = true },
1347+ },
1348+ .{
1349+ .name = "kitty scheme",
1350+ .value = "kitty-shell-cwd://myhost/private/tmp",
1351+ .hostname = "myhost",
1352+ .expected = .{ .path = "/private/tmp", .is_local = true },
1353+ },
1354+ .{
1355+ .name = "remote host keeps the path but is not local",
1356+ .value = "file://otherhost/home/me",
1357+ .hostname = "myhost",
1358+ .expected = .{ .path = "/home/me", .is_local = false },
1359+ },
1360+ .{
1361+ .name = "empty host means local",
1362+ .value = "file:///private/tmp",
1363+ .hostname = "myhost",
1364+ .expected = .{ .path = "/private/tmp", .is_local = true },
1365+ },
1366+ .{
1367+ .name = "localhost means local",
1368+ .value = "file://localhost/private/tmp",
1369+ .hostname = "myhost",
1370+ .expected = .{ .path = "/private/tmp", .is_local = true },
1371+ },
1372+ .{
1373+ .name = "fqdn matches a short hostname",
1374+ .value = "file://myhost.local/private/tmp",
1375+ .hostname = "myhost",
1376+ .expected = .{ .path = "/private/tmp", .is_local = true },
1377+ },
1378+ .{
1379+ .name = "short host matches an fqdn hostname",
1380+ .value = "file://myhost/private/tmp",
1381+ .hostname = "myhost.lan",
1382+ .expected = .{ .path = "/private/tmp", .is_local = true },
1383+ },
1384+ .{
1385+ .name = "host comparison ignores case",
1386+ .value = "file://MyHost/private/tmp",
1387+ .hostname = "myhost",
1388+ .expected = .{ .path = "/private/tmp", .is_local = true },
1389+ },
1390+ .{
1391+ .name = "plain absolute path passes through",
1392+ .value = "/private/tmp",
1393+ .hostname = "myhost",
1394+ .expected = .{ .path = "/private/tmp", .is_local = true },
1395+ },
1396+ .{
1397+ .name = "empty value",
1398+ .value = "",
1399+ .hostname = "myhost",
1400+ .expected = null,
1401+ },
1402+ .{
1403+ .name = "relative path",
1404+ .value = "some/dir",
1405+ .hostname = "myhost",
1406+ .expected = null,
1407+ },
1408+ .{
1409+ .name = "unsupported scheme",
1410+ .value = "http://myhost/private/tmp",
1411+ .hostname = "myhost",
1412+ .expected = null,
1413+ },
1414+ .{
1415+ .name = "uri without a path",
1416+ .value = "file://myhost",
1417+ .hostname = "myhost",
1418+ .expected = null,
1419+ },
1420+ };
1421+
1422+ for (cases) |c| {
1423+ var buf: [std.fs.max_path_bytes]u8 = undefined;
1424+ const actual = parseOsc7Cwd(&buf, c.value, c.hostname);
1425+ testing.expectEqualDeep(c.expected, actual) catch |err| {
1426+ std.debug.print("case: {s}\n", .{c.name});
1427+ return err;
1428+ };
1429+ }
1430+}
1431+
1432+test "parseOsc7Cwd result survives the source value changing" {
1433+ var buf: [std.fs.max_path_bytes]u8 = undefined;
1434+ var value: [32]u8 = undefined;
1435+ const src = "file://myhost/private/tmp";
1436+ @memcpy(value[0..src.len], src);
1437+
1438+ const cwd = parseOsc7Cwd(&buf, value[0..src.len], "myhost") orelse
1439+ return error.TestUnexpectedNull;
1440+
1441+ @memset(&value, 'x');
1442+ try testing.expectEqualDeep(Cwd{ .path = "/private/tmp", .is_local = true }, cwd);
1443+}
1444+
1445+test "parseOsc7Cwd rejects a path longer than the buffer" {
1446+ var buf: [8]u8 = undefined;
1447+ try testing.expectEqual(
1448+ @as(?Cwd, null),
1449+ parseOsc7Cwd(&buf, "file://myhost/a/very/long/path", "myhost"),
1450+ );
1451+ try testing.expectEqual(
1452+ @as(?Cwd, null),
1453+ parseOsc7Cwd(&buf, "/a/very/long/path", "myhost"),
1454+ );
1455+}
1456+
1457+test "toOsc7Cwd" {
1458+ const Case = struct {
1459+ name: []const u8,
1460+ path: []const u8,
1461+ expected: ?[]const u8,
1462+ };
1463+
1464+ const cases = [_]Case{
1465+ .{
1466+ .name = "plain path",
1467+ .path = "/private/tmp",
1468+ .expected = "file://myhost/private/tmp",
1469+ },
1470+ .{
1471+ .name = "space is encoded",
1472+ .path = "/tmp/zmx spaced dir",
1473+ .expected = "file://myhost/tmp/zmx%20spaced%20dir",
1474+ },
1475+ .{
1476+ .name = "percent is encoded so it round-trips",
1477+ .path = "/tmp/100%",
1478+ .expected = "file://myhost/tmp/100%25",
1479+ },
1480+ .{
1481+ .name = "unreserved characters are left alone",
1482+ .path = "/tmp/a-b_c.d~e",
1483+ .expected = "file://myhost/tmp/a-b_c.d~e",
1484+ },
1485+ };
1486+
1487+ for (cases) |c| {
1488+ var buf: [std.fs.max_path_bytes]u8 = undefined;
1489+ testing.expectEqualDeep(c.expected, toOsc7Cwd(&buf, c.path, "myhost")) catch |err| {
1490+ std.debug.print("case: {s}\n", .{c.name});
1491+ return err;
1492+ };
1493+ }
1494+}
1495+
1496+test "toOsc7Cwd returns null when the result would not fit" {
1497+ var buf: [8]u8 = undefined;
1498+ try testing.expectEqual(
1499+ @as(?[]const u8, null),
1500+ toOsc7Cwd(&buf, "/a/very/long/path", "myhost"),
1501+ );
1502+}
1503+
1504+test "toOsc7Cwd round-trips through parseOsc7Cwd" {
1505+ const paths = [_][]const u8{
1506+ "/private/tmp",
1507+ "/tmp/zmx spaced dir",
1508+ "/tmp/100%",
1509+ "/tmp/a-b_c.d~e",
1510+ "/tmp/quote'and\"dquote",
1511+ };
1512+
1513+ for (paths) |path| {
1514+ var enc_buf: [std.fs.max_path_bytes]u8 = undefined;
1515+ const uri = toOsc7Cwd(&enc_buf, path, "myhost") orelse
1516+ return error.TestUnexpectedNull;
1517+
1518+ var dec_buf: [std.fs.max_path_bytes]u8 = undefined;
1519+ testing.expectEqualDeep(
1520+ Cwd{ .path = path, .is_local = true },
1521+ parseOsc7Cwd(&dec_buf, uri, "myhost"),
1522+ ) catch |err| {
1523+ std.debug.print("path: {s} uri: {s}\n", .{ path, uri });
1524+ return err;
1525+ };
1526+ }
1527+}
1528+
12371529 test "serializeTerminalState excludes synchronized output replay" {
12381530 const alloc = testing.allocator;
12391531 const io = testing.io;
+74, -0
......@@ -0,0 +1,74 @@
1+#!/usr/bin/env bats
2+# Working directory tracking tests for zmx.
3+#
4+# zmx learns a session's cwd from the OSC 7 the shell emits, which arrives as a
5+# percent-encoded file://<host><path> URI. That URI is what `list` reports, so
6+# the host stays visible and you can tell an SSH session apart from a local one.
7+# These tests pin that output plus the thing it depends on: the URI is decoded
8+# for the chdir, so a new session lands in a directory whose name needed
9+# escaping.
10+
11+load test_helper
12+
13+# Emit an OSC 7 for $2 from inside session $1, as a shell integration would.
14+osc7_session() {
15+ local name="$1" path="$2" encoded
16+ # Percent-encode spaces, the character that actually broke the chdir. The %
17+ # is doubled because this goes through printf, which would otherwise read
18+ # "%20s" as a width specifier.
19+ encoded="${path// /%%20}"
20+ "$ZMX" run "$name" -d sh -c \
21+ "printf '\033]7;file://$(hostname)$encoded\007marker-$name\n'; sleep 30"
22+}
23+
24+@test "list: reports the cwd in OSC 7 form, host included" {
25+ local dir="$BATS_TEST_TMPDIR/zmx spaced dir"
26+ mkdir -p "$dir"
27+
28+ osc7_session test-cwd-uri "$dir"
29+ wait_for_session test-cwd-uri
30+ wait_for_output test-cwd-uri marker-test-cwd-uri
31+
32+ run "$ZMX" list
33+ [ "$status" -eq 0 ]
34+ [[ "$output" == *"cwd=file://$(hostname)${dir// /%20}"* ]]
35+}
36+
37+@test "list: shows a remote cwd's host, so SSH is visible" {
38+ "$ZMX" run test-cwd-remote -d sh -c \
39+ "printf '\033]7;file://some-remote-box/home/me\007marker-remote\n'; sleep 30"
40+ wait_for_session test-cwd-remote
41+ wait_for_output test-cwd-remote marker-remote
42+
43+ run "$ZMX" list
44+ [ "$status" -eq 0 ]
45+ [[ "$output" == *"cwd=file://some-remote-box/home/me"* ]]
46+}
47+
48+@test "list: reports an OSC 7 URI even when given a plain path" {
49+ # `zmx run` hands the daemon the client's cwd as a path, so this covers the
50+ # encode direction rather than the decode one.
51+ cd "$BATS_TEST_TMPDIR"
52+ "$ZMX" run test-cwd-encode -d sleep 30
53+ wait_for_session test-cwd-encode
54+
55+ run "$ZMX" list
56+ [ "$status" -eq 0 ]
57+ [[ "$output" == *"cwd=file://$(hostname)/"* ]]
58+}
59+
60+@test "new session starts in a cwd whose name needed escaping" {
61+ local dir="$BATS_TEST_TMPDIR/zmx spaced dir"
62+ mkdir -p "$dir"
63+
64+ cd "$dir"
65+ "$ZMX" run test-cwd-chdir -d sh -c 'pwd; sleep 30'
66+ wait_for_session test-cwd-chdir
67+ wait_for_output test-cwd-chdir "zmx spaced dir"
68+
69+ # `pwd` inside the session is what the daemon actually chdir'd into. Compare
70+ # basenames because macOS resolves /tmp to /private/tmp.
71+ run "$ZMX" history test-cwd-chdir
72+ [ "$status" -eq 0 ]
73+ [[ "$output" == *"/zmx spaced dir"* ]]
74+}