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
1@@ -14,6 +14,8 @@ Use spec: https://common-changelog.org/
2
3 - Clear screen when switching sessions to prevent term state corruption
4 - Stray NUL byte in the OSC 7 sequence replayed on attach
5+- The OSC 7 cwd is now decoded before the chdir, so a new session can start in
6+ a directory whose name needed percent-encoding
7
8 ### Changed
9
+70,
-32
1@@ -560,7 +560,18 @@ pub const Daemon = struct {
2 running: bool = true,
3 pid: i32 = undefined,
4 command: ?[]const []const u8 = null,
5+ /// The session's working directory in OSC 7 form, `file://<host><path>`.
6+ /// Kept as a URI rather than a path so `zmx list` shows the host, which is
7+ /// what tells you a session is inside SSH. Points into `cwd_buf` once set,
8+ /// so a Daemon must not be copied by value after that.
9 cwd: []const u8 = "",
10+ /// The same directory as a path that can be opened: percent-decoding
11+ /// applied, scheme and host stripped. Empty when the cwd is on another
12+ /// host, since then it names no directory here and nothing should chdir
13+ /// into it. Points into `cwd_path_buf`.
14+ cwd_path: []const u8 = "",
15+ cwd_buf: [std.fs.max_path_bytes]u8 = undefined,
16+ cwd_path_buf: [std.fs.max_path_bytes]u8 = undefined,
17 has_pty_output: bool = false,
18 has_had_client: bool = false,
19 has_terminal_client: bool = false, // true only after a real attach (.Init received)
20@@ -691,31 +702,19 @@ pub const Daemon = struct {
21 var keep_fds_open = [_]i32{ server_sock_fd, dir.handle, log_fd };
22 const cmd = try daemonize.createCmdZ(self.shell, self.is_task_mode, self.command);
23
24- // format will look like file://{host}{path}
25- std.log.info("checking pwd={s}", .{self.cwd});
26- const uri_opt = std.Uri.parse(self.cwd) catch |err| blk: {
27- std.log.warn("uri parse failed err={s}", .{@errorName(err)});
28- break :blk null;
29- };
30- if (uri_opt) |uri| {
31- var host_buf: [255]u8 = undefined;
32- const pwd_host = if (uri.getHost(&host_buf) catch null) |host| host.bytes else "unknown";
33- var buf: [std.posix.HOST_NAME_MAX]u8 = undefined;
34- const hostname = try std.posix.gethostname(&buf);
35- std.log.info("pwd_host={s} hostname={s}", .{ pwd_host, hostname });
36- if (std.mem.eql(u8, pwd_host, hostname)) {
37- const path_str = switch (uri.path) {
38- .raw, .percent_encoded => |s| s,
39- };
40- const pwd_dir = std.Io.Dir.openDirAbsolute(io, path_str, .{}) catch |err| blk: {
41- std.log.warn("failed to open dir={s} err={s}", .{ path_str, @errorName(err) });
42- break :blk null;
43- };
44- if (pwd_dir) |pdir| {
45- defer std.Io.Dir.close(pdir, io);
46- std.log.info("set directory dir={s}", .{path_str});
47- try std.process.setCurrentDir(io, pdir);
48- }
49+ // `cwd_path` is the decoded path, and is empty when the cwd is on
50+ // another host: OSC 7 crosses SSH boundaries, so a session that ssh'd
51+ // elsewhere reports a directory that does not exist on this machine.
52+ std.log.info("checking pwd={s} path={s}", .{ self.cwd, self.cwd_path });
53+ if (self.cwd_path.len > 0) {
54+ const pwd_dir = std.Io.Dir.openDirAbsolute(io, self.cwd_path, .{}) catch |err| blk: {
55+ std.log.warn("failed to open dir={s} err={s}", .{ self.cwd_path, @errorName(err) });
56+ break :blk null;
57+ };
58+ if (pwd_dir) |pdir| {
59+ defer std.Io.Dir.close(pdir, io);
60+ std.log.info("set directory dir={s}", .{self.cwd_path});
61+ try std.process.setCurrentDir(io, pdir);
62 }
63 }
64
65@@ -866,8 +865,11 @@ pub const Daemon = struct {
66 pub fn handleSwitch(self: *Daemon, gpa: std.mem.Allocator, session_name: []const u8) !void {
67 for (self.clients.items) |client| {
68 if (self.leader_client_fd == client.socket_fd) {
69- // Include the daemon's current cwd so the new session can start in the right directory
70- if (self.cwd.len > 0) {
71+ // Include the daemon's current cwd so the new session can start
72+ // in the right directory. A remote cwd is left out: it names no
73+ // directory here, so the new session is better off with the
74+ // attaching client's own cwd than with a path it cannot enter.
75+ if (self.cwd.len > 0 and self.cwd_path.len > 0) {
76 var payload = gpa.alloc(u8, session_name.len + 1 + self.cwd.len) catch return;
77 defer gpa.free(payload);
78 @memcpy(payload[0..session_name.len], session_name);
79@@ -1141,12 +1143,48 @@ pub const Daemon = struct {
80 std.log.debug("run command len={d}", .{payload.len});
81 }
82
83- fn setPwd(self: *Daemon, term: *ghostty_vt.Terminal) void {
84- const pwd_opt = term.getPwd();
85- if (pwd_opt) |pwd| {
86- std.log.info("setting pwd to ghostty term pwd={s}", .{pwd});
87- self.cwd = pwd;
88+ /// Store the session's working directory as a plain path.
89+ ///
90+ /// Accepts either an OSC 7 value (`file://<host><path>`, percent-encoded)
91+ /// or a path. Decoding here rather than at each use keeps `zmx list`
92+ /// printing a path and lets the chdir on session create find directories
93+ /// whose names needed escaping.
94+ ///
95+ /// The value is copied, so callers may pass a temporary.
96+ pub fn setCwd(self: *Daemon, value: []const u8) void {
97+ var buf: [std.fs.max_path_bytes]u8 = undefined;
98+ var host_buf: [std.posix.HOST_NAME_MAX]u8 = undefined;
99+ const hostname = std.posix.gethostname(&host_buf) catch "";
100+ const cwd = util.parseOsc7Cwd(&buf, value, hostname) orelse {
101+ std.log.warn("ignoring unusable cwd={s}", .{value});
102+ return;
103+ };
104+
105+ // Store the URI form. A caller that handed us a plain path gets one
106+ // built here, so `cwd` has the same shape no matter the source. A value
107+ // that already was a URI is kept verbatim, so `list` shows what the
108+ // shell actually reported.
109+ self.cwd = if (std.fs.path.isAbsolute(value))
110+ util.toOsc7Cwd(&self.cwd_buf, value, hostname) orelse return
111+ else blk: {
112+ if (value.len > self.cwd_buf.len) return;
113+ @memcpy(self.cwd_buf[0..value.len], value);
114+ break :blk self.cwd_buf[0..value.len];
115+ };
116+
117+ // Only keep an openable path when it names a directory on this host.
118+ if (cwd.is_local and cwd.path.len <= self.cwd_path_buf.len) {
119+ @memcpy(self.cwd_path_buf[0..cwd.path.len], cwd.path);
120+ self.cwd_path = self.cwd_path_buf[0..cwd.path.len];
121+ } else {
122+ self.cwd_path = "";
123 }
124+ std.log.info("set cwd={s} path={s}", .{ self.cwd, self.cwd_path });
125+ }
126+
127+ fn setPwd(self: *Daemon, term: *ghostty_vt.Terminal) void {
128+ const pwd = term.getPwd() orelse return;
129+ self.setCwd(pwd);
130 }
131
132 pub fn handleOutput(self: *Daemon, gpa: std.mem.Allocator, payload: []const u8, term: *ghostty_vt.Terminal, vt_stream: anytype) !void {
+4,
-4
1@@ -147,7 +147,7 @@ pub fn main(init: std.process.Init) !void {
2 };
3 var daemon = Daemon.init(io, &cfg, sesh, socket_path);
4 daemon.command = command;
5- daemon.cwd = cwd;
6+ daemon.setCwd(cwd);
7 daemon.shell = shell_env;
8 std.log.info("socket path={s}", .{daemon.socket_path});
9 return attach(gpa, io, &daemon);
10@@ -180,7 +180,7 @@ pub fn main(init: std.process.Init) !void {
11 };
12 defer gpa.free(socket_path);
13 var daemon = Daemon.init(io, &cfg, sesh, socket_path);
14- daemon.cwd = cwd;
15+ daemon.setCwd(cwd);
16 daemon.is_task_mode = true;
17 daemon.shell = shell_env;
18 std.log.info("socket path={s}", .{daemon.socket_path});
19@@ -395,7 +395,7 @@ pub fn main(init: std.process.Init) !void {
20 };
21 var daemon = Daemon.init(io, &cfg, sesh, socket_path);
22 daemon.is_task_mode = true;
23- daemon.cwd = cwd;
24+ daemon.setCwd(cwd);
25 daemon.shell = shell_env;
26 std.log.info("socket path={s}", .{daemon.socket_path});
27 try writeFile(gpa, io, &daemon, file_path);
28@@ -1371,7 +1371,7 @@ fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon) !void {
29 // otherwise fall back to the client's original cwd
30 const switch_cwd = looper.cwd orelse daemon.cwd;
31 std.log.info("switching to new session cwd={s}", .{switch_cwd});
32- target_daemon.cwd = switch_cwd;
33+ target_daemon.setCwd(switch_cwd);
34 target_daemon.shell = daemon.shell;
35 return attach(gpa, io, &target_daemon);
36 }
+292,
-0
1@@ -115,6 +115,96 @@ pub fn get_session_entries(
2 return sessions;
3 }
4
5+pub const Cwd = struct {
6+ /// A filesystem path, percent-decoded, with no scheme or host.
7+ path: []const u8,
8+ /// True when the OSC 7 host is this machine, so `path` names a directory we
9+ /// can actually chdir into. OSC 7 crosses SSH boundaries, so a session that
10+ /// ssh'd elsewhere reports a path that does not exist locally.
11+ is_local: bool,
12+};
13+
14+/// parseOsc7Cwd turns an OSC 7 value into a path that can be opened.
15+///
16+/// The value looks like `file://<host><path>` with the path percent-encoded, so
17+/// it cannot be handed to `openDirAbsolute` as-is: the escaping is never
18+/// decoded and any directory whose name needed it fails to open.
19+///
20+/// A plain absolute path is accepted and passed through, since a caller may
21+/// hand us one directly before the session has reported an OSC 7.
22+///
23+/// `buf` holds the decoded path, so the result stays valid after the source
24+/// value changes. Returns null when the value is not a path we can use.
25+pub fn parseOsc7Cwd(buf: []u8, value: []const u8, hostname: []const u8) ?Cwd {
26+ if (value.len == 0) return null;
27+
28+ if (std.fs.path.isAbsolute(value)) {
29+ if (value.len > buf.len) return null;
30+ @memcpy(buf[0..value.len], value);
31+ return .{ .path = buf[0..value.len], .is_local = true };
32+ }
33+
34+ const uri = std.Uri.parse(value) catch return null;
35+ // kitty emits kitty-shell-cwd:// from its own shell integration, and
36+ // accepts it alongside file:// on the way back in.
37+ if (!std.mem.eql(u8, uri.scheme, "file") and
38+ !std.mem.eql(u8, uri.scheme, "kitty-shell-cwd")) return null;
39+
40+ const decoded = uri.path.toRaw(buf) catch return null;
41+ if (!std.fs.path.isAbsolute(decoded)) return null;
42+ // toRaw returns the input slice when there was nothing to decode, and that
43+ // slice is owned by the caller of this fn, so copy it into buf either way.
44+ const path = if (decoded.ptr == buf.ptr) decoded else blk: {
45+ if (decoded.len > buf.len) return null;
46+ std.mem.copyForwards(u8, buf[0..decoded.len], decoded);
47+ break :blk buf[0..decoded.len];
48+ };
49+
50+ return .{ .path = path, .is_local = isLocalHost(uri.host, hostname) };
51+}
52+
53+fn isLocalHost(host: ?std.Uri.Component, hostname: []const u8) bool {
54+ // file:///path omits the host, which conventionally means the local machine.
55+ const component = host orelse return true;
56+ var host_buf: [std.posix.HOST_NAME_MAX]u8 = undefined;
57+ const value = component.toRaw(&host_buf) catch return false;
58+ if (value.len == 0) return true;
59+ if (std.ascii.eqlIgnoreCase(value, "localhost")) return true;
60+ if (std.ascii.eqlIgnoreCase(value, hostname)) return true;
61+ // gethostname often reports a short name while OSC 7 carries the FQDN
62+ // (or the reverse), so fall back to comparing the first label.
63+ const value_label = value[0 .. std.mem.indexOfScalar(u8, value, '.') orelse value.len];
64+ const host_label = hostname[0 .. std.mem.indexOfScalar(u8, hostname, '.') orelse hostname.len];
65+ return host_label.len > 0 and std.ascii.eqlIgnoreCase(value_label, host_label);
66+}
67+
68+/// toOsc7Cwd renders a plain path as the OSC 7 form, `file://<host><path>`.
69+///
70+/// The daemon stores its cwd in this form so `zmx list` shows the host, which
71+/// is how you can tell at a glance that a session is inside SSH. Callers that
72+/// only have a local path (`zmx run`, `zmx attach`) go through this so the
73+/// stored value has one shape regardless of where it came from.
74+///
75+/// Returns null when the result would not fit in `buf`.
76+pub fn toOsc7Cwd(buf: []u8, path: []const u8, hostname: []const u8) ?[]const u8 {
77+ var w: std.Io.Writer = .fixed(buf);
78+ w.print("file://{s}", .{hostname}) catch return null;
79+ // Percent-encode so a path with a space or a `%` in it round-trips back
80+ // through parseOsc7Cwd unchanged.
81+ std.Uri.Component.percentEncode(&w, path, isPathChar) catch return null;
82+ return w.buffered();
83+}
84+
85+/// Characters that need no escaping in a URI path. RFC 3986 pchar, minus the
86+/// sub-delims that a shell would find surprising to see left raw in `zmx list`.
87+fn isPathChar(c: u8) bool {
88+ return switch (c) {
89+ 'A'...'Z', 'a'...'z', '0'...'9' => true,
90+ '-', '.', '_', '~', '/', ':', '@' => true,
91+ else => false,
92+ };
93+}
94+
95 /// getCwd get the current working directory in a std.Uri format.
96 /// Caller is responsible for releasing memory.
97 pub fn getCwd(gpa: std.mem.Allocator, io: std.Io) ![]u8 {
98@@ -1234,6 +1324,208 @@ test "isDetachKeyDisabled" {
99 try testing.expect(isDetachKeyDisabled());
100 }
101
102+test "parseOsc7Cwd" {
103+ const Case = struct {
104+ name: []const u8,
105+ value: []const u8,
106+ hostname: []const u8,
107+ expected: ?Cwd,
108+ };
109+
110+ const cases = [_]Case{
111+ .{
112+ .name = "local file uri",
113+ .value = "file://myhost/private/tmp",
114+ .hostname = "myhost",
115+ .expected = .{ .path = "/private/tmp", .is_local = true },
116+ },
117+ .{
118+ .name = "percent-encoded path is decoded",
119+ .value = "file://myhost/tmp/zmx%20spaced%20dir",
120+ .hostname = "myhost",
121+ .expected = .{ .path = "/tmp/zmx spaced dir", .is_local = true },
122+ },
123+ .{
124+ .name = "kitty scheme",
125+ .value = "kitty-shell-cwd://myhost/private/tmp",
126+ .hostname = "myhost",
127+ .expected = .{ .path = "/private/tmp", .is_local = true },
128+ },
129+ .{
130+ .name = "remote host keeps the path but is not local",
131+ .value = "file://otherhost/home/me",
132+ .hostname = "myhost",
133+ .expected = .{ .path = "/home/me", .is_local = false },
134+ },
135+ .{
136+ .name = "empty host means local",
137+ .value = "file:///private/tmp",
138+ .hostname = "myhost",
139+ .expected = .{ .path = "/private/tmp", .is_local = true },
140+ },
141+ .{
142+ .name = "localhost means local",
143+ .value = "file://localhost/private/tmp",
144+ .hostname = "myhost",
145+ .expected = .{ .path = "/private/tmp", .is_local = true },
146+ },
147+ .{
148+ .name = "fqdn matches a short hostname",
149+ .value = "file://myhost.local/private/tmp",
150+ .hostname = "myhost",
151+ .expected = .{ .path = "/private/tmp", .is_local = true },
152+ },
153+ .{
154+ .name = "short host matches an fqdn hostname",
155+ .value = "file://myhost/private/tmp",
156+ .hostname = "myhost.lan",
157+ .expected = .{ .path = "/private/tmp", .is_local = true },
158+ },
159+ .{
160+ .name = "host comparison ignores case",
161+ .value = "file://MyHost/private/tmp",
162+ .hostname = "myhost",
163+ .expected = .{ .path = "/private/tmp", .is_local = true },
164+ },
165+ .{
166+ .name = "plain absolute path passes through",
167+ .value = "/private/tmp",
168+ .hostname = "myhost",
169+ .expected = .{ .path = "/private/tmp", .is_local = true },
170+ },
171+ .{
172+ .name = "empty value",
173+ .value = "",
174+ .hostname = "myhost",
175+ .expected = null,
176+ },
177+ .{
178+ .name = "relative path",
179+ .value = "some/dir",
180+ .hostname = "myhost",
181+ .expected = null,
182+ },
183+ .{
184+ .name = "unsupported scheme",
185+ .value = "http://myhost/private/tmp",
186+ .hostname = "myhost",
187+ .expected = null,
188+ },
189+ .{
190+ .name = "uri without a path",
191+ .value = "file://myhost",
192+ .hostname = "myhost",
193+ .expected = null,
194+ },
195+ };
196+
197+ for (cases) |c| {
198+ var buf: [std.fs.max_path_bytes]u8 = undefined;
199+ const actual = parseOsc7Cwd(&buf, c.value, c.hostname);
200+ testing.expectEqualDeep(c.expected, actual) catch |err| {
201+ std.debug.print("case: {s}\n", .{c.name});
202+ return err;
203+ };
204+ }
205+}
206+
207+test "parseOsc7Cwd result survives the source value changing" {
208+ var buf: [std.fs.max_path_bytes]u8 = undefined;
209+ var value: [32]u8 = undefined;
210+ const src = "file://myhost/private/tmp";
211+ @memcpy(value[0..src.len], src);
212+
213+ const cwd = parseOsc7Cwd(&buf, value[0..src.len], "myhost") orelse
214+ return error.TestUnexpectedNull;
215+
216+ @memset(&value, 'x');
217+ try testing.expectEqualDeep(Cwd{ .path = "/private/tmp", .is_local = true }, cwd);
218+}
219+
220+test "parseOsc7Cwd rejects a path longer than the buffer" {
221+ var buf: [8]u8 = undefined;
222+ try testing.expectEqual(
223+ @as(?Cwd, null),
224+ parseOsc7Cwd(&buf, "file://myhost/a/very/long/path", "myhost"),
225+ );
226+ try testing.expectEqual(
227+ @as(?Cwd, null),
228+ parseOsc7Cwd(&buf, "/a/very/long/path", "myhost"),
229+ );
230+}
231+
232+test "toOsc7Cwd" {
233+ const Case = struct {
234+ name: []const u8,
235+ path: []const u8,
236+ expected: ?[]const u8,
237+ };
238+
239+ const cases = [_]Case{
240+ .{
241+ .name = "plain path",
242+ .path = "/private/tmp",
243+ .expected = "file://myhost/private/tmp",
244+ },
245+ .{
246+ .name = "space is encoded",
247+ .path = "/tmp/zmx spaced dir",
248+ .expected = "file://myhost/tmp/zmx%20spaced%20dir",
249+ },
250+ .{
251+ .name = "percent is encoded so it round-trips",
252+ .path = "/tmp/100%",
253+ .expected = "file://myhost/tmp/100%25",
254+ },
255+ .{
256+ .name = "unreserved characters are left alone",
257+ .path = "/tmp/a-b_c.d~e",
258+ .expected = "file://myhost/tmp/a-b_c.d~e",
259+ },
260+ };
261+
262+ for (cases) |c| {
263+ var buf: [std.fs.max_path_bytes]u8 = undefined;
264+ testing.expectEqualDeep(c.expected, toOsc7Cwd(&buf, c.path, "myhost")) catch |err| {
265+ std.debug.print("case: {s}\n", .{c.name});
266+ return err;
267+ };
268+ }
269+}
270+
271+test "toOsc7Cwd returns null when the result would not fit" {
272+ var buf: [8]u8 = undefined;
273+ try testing.expectEqual(
274+ @as(?[]const u8, null),
275+ toOsc7Cwd(&buf, "/a/very/long/path", "myhost"),
276+ );
277+}
278+
279+test "toOsc7Cwd round-trips through parseOsc7Cwd" {
280+ const paths = [_][]const u8{
281+ "/private/tmp",
282+ "/tmp/zmx spaced dir",
283+ "/tmp/100%",
284+ "/tmp/a-b_c.d~e",
285+ "/tmp/quote'and\"dquote",
286+ };
287+
288+ for (paths) |path| {
289+ var enc_buf: [std.fs.max_path_bytes]u8 = undefined;
290+ const uri = toOsc7Cwd(&enc_buf, path, "myhost") orelse
291+ return error.TestUnexpectedNull;
292+
293+ var dec_buf: [std.fs.max_path_bytes]u8 = undefined;
294+ testing.expectEqualDeep(
295+ Cwd{ .path = path, .is_local = true },
296+ parseOsc7Cwd(&dec_buf, uri, "myhost"),
297+ ) catch |err| {
298+ std.debug.print("path: {s} uri: {s}\n", .{ path, uri });
299+ return err;
300+ };
301+ }
302+}
303+
304 test "serializeTerminalState excludes synchronized output replay" {
305 const alloc = testing.allocator;
306 const io = testing.io;
+74,
-0
1@@ -0,0 +1,74 @@
2+#!/usr/bin/env bats
3+# Working directory tracking tests for zmx.
4+#
5+# zmx learns a session's cwd from the OSC 7 the shell emits, which arrives as a
6+# percent-encoded file://<host><path> URI. That URI is what `list` reports, so
7+# the host stays visible and you can tell an SSH session apart from a local one.
8+# These tests pin that output plus the thing it depends on: the URI is decoded
9+# for the chdir, so a new session lands in a directory whose name needed
10+# escaping.
11+
12+load test_helper
13+
14+# Emit an OSC 7 for $2 from inside session $1, as a shell integration would.
15+osc7_session() {
16+ local name="$1" path="$2" encoded
17+ # Percent-encode spaces, the character that actually broke the chdir. The %
18+ # is doubled because this goes through printf, which would otherwise read
19+ # "%20s" as a width specifier.
20+ encoded="${path// /%%20}"
21+ "$ZMX" run "$name" -d sh -c \
22+ "printf '\033]7;file://$(hostname)$encoded\007marker-$name\n'; sleep 30"
23+}
24+
25+@test "list: reports the cwd in OSC 7 form, host included" {
26+ local dir="$BATS_TEST_TMPDIR/zmx spaced dir"
27+ mkdir -p "$dir"
28+
29+ osc7_session test-cwd-uri "$dir"
30+ wait_for_session test-cwd-uri
31+ wait_for_output test-cwd-uri marker-test-cwd-uri
32+
33+ run "$ZMX" list
34+ [ "$status" -eq 0 ]
35+ [[ "$output" == *"cwd=file://$(hostname)${dir// /%20}"* ]]
36+}
37+
38+@test "list: shows a remote cwd's host, so SSH is visible" {
39+ "$ZMX" run test-cwd-remote -d sh -c \
40+ "printf '\033]7;file://some-remote-box/home/me\007marker-remote\n'; sleep 30"
41+ wait_for_session test-cwd-remote
42+ wait_for_output test-cwd-remote marker-remote
43+
44+ run "$ZMX" list
45+ [ "$status" -eq 0 ]
46+ [[ "$output" == *"cwd=file://some-remote-box/home/me"* ]]
47+}
48+
49+@test "list: reports an OSC 7 URI even when given a plain path" {
50+ # `zmx run` hands the daemon the client's cwd as a path, so this covers the
51+ # encode direction rather than the decode one.
52+ cd "$BATS_TEST_TMPDIR"
53+ "$ZMX" run test-cwd-encode -d sleep 30
54+ wait_for_session test-cwd-encode
55+
56+ run "$ZMX" list
57+ [ "$status" -eq 0 ]
58+ [[ "$output" == *"cwd=file://$(hostname)/"* ]]
59+}
60+
61+@test "new session starts in a cwd whose name needed escaping" {
62+ local dir="$BATS_TEST_TMPDIR/zmx spaced dir"
63+ mkdir -p "$dir"
64+
65+ cd "$dir"
66+ "$ZMX" run test-cwd-chdir -d sh -c 'pwd; sleep 30'
67+ wait_for_session test-cwd-chdir
68+ wait_for_output test-cwd-chdir "zmx spaced dir"
69+
70+ # `pwd` inside the session is what the daemon actually chdir'd into. Compare
71+ # basenames because macOS resolves /tmp to /private/tmp.
72+ run "$ZMX" history test-cwd-chdir
73+ [ "$status" -eq 0 ]
74+ [[ "$output" == *"/zmx spaced dir"* ]]
75+}