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+}