From b6d5acbc861b74d053df72743ccc2d3b4fde5fdb Mon Sep 17 00:00:00 2001 From: sonhyrd Date: Mon, 27 Jul 2026 17:20:35 +0700 Subject: [PATCH] feat(pty): add cwd to PtySpawnOptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pty child always started in the app process's working directory: `PtySpawnOptions` took key/argv/cols/rows/term/on_event, `startRealPty` forwarded five of those to `pty.spawn`, and `pty.SpawnOptions` carried no directory either. For an interactive shell — which is what a pty child nearly always is — that is the one thing worth setting. The workaround it replaces is smuggling the change into the command line: `sh -c "cd '' && exec -i"`. That turns a path into a shell word (a single quote in the path breaks it, so callers end up detecting that case and silently starting somewhere else), burns the shell's own `-c`, and has no Windows spelling worth trusting. Entered in the CHILD with `chdir` immediately before `execve`, not in the parent: the parent's directory is shared with every other spawn on the process, so a parent-side chdir would be a data race with them. That also keeps the failure honest — an unenterable directory reports through the same exec self-pipe a failed exec uses, so the app sees this spawn's `.exit` rather than a child running in the wrong place. `chdir` is async-signal-safe, so the post-fork section keeps the property its module comment states. On Windows the same request is `CreateProcessW`'s `lpCurrentDirectory`, which sets the child's directory without touching the parent's. Admission mirrors argv and TERM: empty, over `max_effect_pty_cwd_bytes`, or NUL-bearing paths refuse with the family's one rejected `.exit` — a path truncated at a NUL would silently enter a different directory than the caller named. `PtyRequest.cwd` exposes the request to the fake executor, where null and "" stay distinguishable. --- docs/src/app/terminal/page.mdx | 4 +- src/runtime/effects.zig | 42 ++++++++++++++++++ src/runtime/effects_pty_tests.zig | 73 +++++++++++++++++++++++++++++++ src/runtime/pty.zig | 32 +++++++++++++- src/runtime/pty_windows.zig | 12 ++++- 5 files changed, 160 insertions(+), 3 deletions(-) diff --git a/docs/src/app/terminal/page.mdx b/docs/src/app/terminal/page.mdx index 04ef65e6e..3df3bbaa6 100644 --- a/docs/src/app/terminal/page.mdx +++ b/docs/src/app/terminal/page.mdx @@ -171,7 +171,7 @@ The full app lives in [`examples/terminal`](https://github.com/native-sdk/native ptySpawn - Open a pty, fork argv onto it as its controlling terminal, and stream output back through on_event. Initial cols/rows and an optional term (default xterm-256color). + Open a pty, fork argv onto it as its controlling terminal, and stream output back through on_event. Initial cols/rows, an optional term (default xterm-256color), and an optional cwd (default: inherit the app's). ptyWrite @@ -188,6 +188,8 @@ The full app lives in [`examples/terminal`](https://github.com/native-sdk/native +`cwd` is entered in the CHILD, immediately before exec — so a directory that cannot be entered fails that spawn through its own `.exit` terminal (`code != 0`), and no other pty's directory moves. `argv[0]` still resolves against PATH from the app's own directory. Without it, per-directory terminals have to smuggle the change into the command line (`sh -c "cd '' && exec -i"`), which turns a path into a shell word and breaks on a quote. + A pty is **a spawn with a different transport**, so it rides the same seams as `Cmd.spawn`: the same `command` permission in [`app.zon`](/app-zon), the same environment policy (the child inherits the host environment the app bound, plus `TERM`), the same argv budgets, and the same one key space as spawns, fetches, and channels — a pty key is occupied from spawn until its exit Msg delivers. ## Output is coalesced, never per-read diff --git a/src/runtime/effects.zig b/src/runtime/effects.zig index 016d6f6ab..eab62f678 100644 --- a/src/runtime/effects.zig +++ b/src/runtime/effects.zig @@ -949,6 +949,9 @@ pub const max_effect_pty_outbound_bytes: usize = 64 * 1024; pub const max_effect_pty_write_records: usize = 256; /// Longest TERM value `ptySpawn` accepts. pub const max_effect_pty_term_bytes: usize = 32; +/// Longest `cwd` path `ptySpawn` accepts. The slot stores the request +/// inline like argv and TERM, so this is a fixed cost per pty slot. +pub const max_effect_pty_cwd_bytes: usize = 1024; /// Bytes of `ptyWrite` input a FAKE pty retains for test assertions /// (`ptyWrittenBytes`) — the scriptable pty's inspection window, not a /// delivery bound: writes beyond it drop oldest-first, exactly what a @@ -2899,6 +2902,16 @@ pub fn Effects(comptime Msg: type) type { /// Initial grid size the child observes via TIOCGWINSZ. cols: u16 = 80, rows: u16 = 24, + /// The child's initial working directory, at most + /// `max_effect_pty_cwd_bytes`. Null (the default) inherits + /// the app process's, which is what a pty child got before + /// this existed. Entered in the CHILD immediately before + /// exec, so a directory that cannot be entered fails the + /// spawn through the same `.exit` terminal a failed exec + /// reports through — never a child silently running + /// somewhere else. `argv[0]` still resolves against PATH + /// from the app's own directory. + cwd: ?[]const u8 = null, /// The TERM the child starts with. The rest of the child's /// environment is the spawn policy verbatim: the bound host /// environ (`bindEnviron`), nothing else — a pty is a spawn @@ -2920,6 +2933,9 @@ pub fn Effects(comptime Msg: type) type { cols: u16, rows: u16, term: []const u8, + /// The requested working directory, or null where the + /// child inherits the app's. + cwd: ?[]const u8 = null, }; /// How one pty table slot advances: `.running` from the @@ -2969,6 +2985,10 @@ pub fn Effects(comptime Msg: type) type { argv_count: usize = 0, term_storage: [max_effect_pty_term_bytes]u8 = undefined, term_len: usize = 0, + cwd_storage: [max_effect_pty_cwd_bytes]u8 = undefined, + /// 0 means "no cwd requested" — an empty path is refused at + /// the seam, so the length carries the optionality. + cwd_len: usize = 0, /// Session replay only: the pending-order reservation this /// parked spawn holds (see `ParkOrderState` — the channel /// park dance, pty-shaped: a fed start-failure terminal @@ -2985,6 +3005,11 @@ pub fn Effects(comptime Msg: type) type { fn requestTerm(slot: *const PtySlot) []const u8 { return slot.term_storage[0..slot.term_len]; } + + fn requestCwd(slot: *const PtySlot) ?[]const u8 { + if (slot.cwd_len == 0) return null; + return slot.cwd_storage[0..slot.cwd_len]; + } }; /// How one channel table slot advances: `.open` accepts posts @@ -6797,6 +6822,7 @@ pub fn Effects(comptime Msg: type) type { .cols = slot.cols, .rows = slot.rows, .term = slot.requestTerm(), + .cwd = slot.requestCwd(), }; } seen += 1; @@ -6864,6 +6890,17 @@ pub fn Effects(comptime Msg: type) type { // truncate it at the C boundary, so a spawn that "succeeded" // would hand the child a different TERM than requested. if (std.mem.indexOfScalar(u8, options.term, 0) != null) return self.rejectPty(options.key, options.on_event, true); + // The cwd rides the same regenerating-validation rule: an + // empty path is not a directory, an over-bound one does not + // fit the slot, and an embedded NUL would enter a DIFFERENT + // directory than the caller named. All three refuse here so + // the fake executor and replay refuse identically. + if (options.cwd) |dir| { + if (dir.len == 0 or dir.len > max_effect_pty_cwd_bytes) { + return self.rejectPty(options.key, options.on_event, true); + } + if (std.mem.indexOfScalar(u8, dir, 0) != null) return self.rejectPty(options.key, options.on_event, true); + } if (self.keyOccupiedUntilDelivery(options.key)) return self.rejectPty(options.key, options.on_event, true); const slot_index = self.findIdlePtySlot() orelse return self.rejectPty(options.key, options.on_event, true); // Table capacity obeys the replay-hold invariant, the @@ -6911,6 +6948,10 @@ pub fn Effects(comptime Msg: type) type { } @memcpy(slot.term_storage[0..options.term.len], options.term); slot.term_len = options.term.len; + if (options.cwd) |dir| { + @memcpy(slot.cwd_storage[0..dir.len], dir); + slot.cwd_len = dir.len; + } else slot.cwd_len = 0; if (slot.fake) { // Session replay: PARK — the fake-slot discipline. The @@ -11064,6 +11105,7 @@ pub fn Effects(comptime Msg: type) type { .argv = slot.requestArgv(), .env = env, .term = slot.requestTerm(), + .cwd = slot.requestCwd(), .cols = slot.cols, .rows = slot.rows, }) catch { diff --git a/src/runtime/effects_pty_tests.zig b/src/runtime/effects_pty_tests.zig index a92c59f67..b82fb3eb4 100644 --- a/src/runtime/effects_pty_tests.zig +++ b/src/runtime/effects_pty_tests.zig @@ -131,6 +131,18 @@ test "pty admission: every refused spawn delivers exactly one rejected exit" { fx.ptySpawn(.{ .key = 5, .argv = &.{"sh"}, .term = long_term, .on_event = DirectFx.ptyMsg(.pty) }); _ = try expectExit(&fx, 5, .rejected); + // A cwd that is empty, over its bound, or carries a NUL — the same + // three refusals argv and TERM take, for the same reason: a path + // truncated at a NUL would enter a DIFFERENT directory than the + // caller named, silently. + fx.ptySpawn(.{ .key = 51, .argv = &.{"sh"}, .cwd = "", .on_event = DirectFx.ptyMsg(.pty) }); + _ = try expectExit(&fx, 51, .rejected); + const long_cwd = "/d" ** effects_mod.max_effect_pty_cwd_bytes; + fx.ptySpawn(.{ .key = 52, .argv = &.{"sh"}, .cwd = long_cwd, .on_event = DirectFx.ptyMsg(.pty) }); + _ = try expectExit(&fx, 52, .rejected); + fx.ptySpawn(.{ .key = 53, .argv = &.{"sh"}, .cwd = "/tmp\x00/etc", .on_event = DirectFx.ptyMsg(.pty) }); + _ = try expectExit(&fx, 53, .rejected); + // A duplicate active key. fx.ptySpawn(.{ .key = 6, .argv = &.{"sh"}, .on_event = DirectFx.ptyMsg(.pty) }); fx.ptySpawn(.{ .key = 6, .argv = &.{"sh"}, .on_event = DirectFx.ptyMsg(.pty) }); @@ -676,6 +688,67 @@ test "live pty end to end: output, coalescing, and the exit code" { try testing.expectEqual(effects_mod.EffectExitReason.exited, result.exit.reason); } +test "live pty cwd: the child starts in the requested directory" { + if (comptime !live_posix) return; + var fx = DirectFx.init(testing.allocator); + defer fx.deinit(); + + // `/` is the one directory every posix host has, and it is not the + // test process's own — so a child that ignored `cwd` prints + // something else and the assertion fails rather than passing by + // coincidence. + fx.ptySpawn(.{ + .key = 61, + .argv = &.{ "/bin/sh", "-c", "pwd" }, + .cwd = "/", + .on_event = DirectFx.ptyMsg(.pty), + }); + var result = try drainUntilExit(&fx, 5_000); + defer result.output.deinit(testing.allocator); + try testing.expectEqual(@as(i32, 0), result.exit.code); + try testing.expect(std.mem.startsWith(u8, result.output.items, "/\r\n")); +} + +test "live pty cwd: an unenterable directory fails the spawn, never a child elsewhere" { + if (comptime !live_posix) return; + var fx = DirectFx.init(testing.allocator); + defer fx.deinit(); + + // The whole point of entering the directory in the CHILD: the + // failure has to reach the app as this spawn's terminal. A parent + // that could not chdir would either move every other spawn's + // directory too or silently start the child in the wrong place. + fx.ptySpawn(.{ + .key = 62, + .argv = &.{ "/bin/sh", "-c", "pwd" }, + .cwd = "/no/such/directory/native-sdk-cwd-test", + .on_event = DirectFx.ptyMsg(.pty), + }); + var result = try drainUntilExit(&fx, 5_000); + defer result.output.deinit(testing.allocator); + try testing.expectEqual(@as(usize, 0), result.output.items.len); + try testing.expect(result.exit.code != 0); +} + +test "a fake pty mirrors the requested cwd, and null means inherit" { + var fx = DirectFx.init(testing.allocator); + defer fx.deinit(); + fx.executor = .fake; + + fx.ptySpawn(.{ .key = 71, .argv = &.{"sh"}, .cwd = "/tmp", .on_event = DirectFx.ptyMsg(.pty) }); + fx.ptySpawn(.{ .key = 72, .argv = &.{"sh"}, .on_event = DirectFx.ptyMsg(.pty) }); + + const with_cwd = fx.pendingPtyAt(0) orelse return error.TestExpectedRequest; + try testing.expectEqual(@as(u64, 71), with_cwd.key); + try testing.expectEqualStrings("/tmp", with_cwd.cwd orelse return error.TestExpectedRequest); + // Null is not the empty string: "inherit the app's directory" and + // "start in the filesystem root of a relative path" are different + // requests, and a test that pinned "" could not tell them apart. + const without_cwd = fx.pendingPtyAt(1) orelse return error.TestExpectedRequest; + try testing.expectEqual(@as(u64, 72), without_cwd.key); + try testing.expect(without_cwd.cwd == null); +} + test "live pty back-pressure is lossless past the staging ring" { if (comptime !live_posix) return; var fx = DirectFx.init(testing.allocator); diff --git a/src/runtime/pty.zig b/src/runtime/pty.zig index cec0b6b0d..0c46d9b04 100644 --- a/src/runtime/pty.zig +++ b/src/runtime/pty.zig @@ -391,6 +391,15 @@ pub const Exit = struct { /// child does nothing but exec. pub const SpawnOptions = struct { argv: []const []const u8, + /// The child's initial working directory. Null inherits the + /// parent's, which is what a pty child got before this existed. + /// Applied in the child with `chdir` immediately before `execve` + /// (async-signal-safe, like every other post-fork call here), so a + /// directory that cannot be entered fails the spawn through the + /// exec self-pipe rather than starting the child somewhere else. + /// Note that `argv[0]` still resolves against PATH in the PARENT — + /// a relative command is resolved before this `chdir`. + cwd: ?[]const u8 = null, /// The child's environment. When null the child inherits nothing but /// `TERM` (a clean environment, like the fallback spawn environ). env: ?[]const EnvVar = null, @@ -435,6 +444,12 @@ pub fn spawn(gpa: std.mem.Allocator, options: SpawnOptions) Error!Pty { for (options.argv) |arg| { if (std.mem.indexOfScalar(u8, arg, 0) != null) return error.PtyArgvInvalid; } + // Same rule for the cwd, and for the same reason: `chdir` reads to + // the first NUL, so an embedded one would silently enter a + // DIFFERENT directory rather than fail. + if (options.cwd) |dir| { + if (dir.len == 0 or std.mem.indexOfScalar(u8, dir, 0) != null) return error.PtyArgvInvalid; + } // Resolve argv[0] to an absolute path in the PARENT so the child's // only post-fork calls are login_tty/execve/_exit — execve needs a @@ -451,6 +466,10 @@ pub fn spawn(gpa: std.mem.Allocator, options: SpawnOptions) Error!Pty { const resolved_z = arena.dupeZ(u8, resolved) catch return error.PtyEnvironTooLarge; const argv_z = buildArgvZ(arena, options.argv) catch return error.PtyEnvironTooLarge; const envp_z = buildEnvpZ(arena, options.env, options.term) catch return error.PtyEnvironTooLarge; + const cwd_z: ?[:0]const u8 = if (options.cwd) |dir| + arena.dupeZ(u8, dir) catch return error.PtyEnvironTooLarge + else + null; const ws: Winsize = .{ .row = if (options.rows == 0) 24 else options.rows, @@ -458,7 +477,7 @@ pub fn spawn(gpa: std.mem.Allocator, options: SpawnOptions) Error!Pty { .xpixel = 0, .ypixel = 0, }; - const pair = try spawnPair(resolved_z, argv_z, envp_z, ws); + const pair = try spawnPair(resolved_z, argv_z, envp_z, cwd_z, ws); // A failure byte, EOF, or the timeout resolves the probe. A real // exec failure writes its byte into the pipe buffer INSTANTLY (the // child's write lands before its _exit), so a byte always means @@ -513,6 +532,7 @@ fn spawnPair( resolved_z: [:0]const u8, argv_z: [:null]const ?[*:0]const u8, envp_z: [:null]const ?[*:0]const u8, + cwd_z: ?[:0]const u8, ws: Winsize, ) Error!SpawnedPair { pty_spawn_mutex.lock(); @@ -651,6 +671,15 @@ fn spawnPair( _ = c.close(parent); _ = c.close(exec_pipe[0]); if (c.login_tty(child_fd) != 0) reportExecFailure(exec_pipe[1]); + // The requested cwd, entered here and not in the parent: the + // parent's directory is shared by every other spawn on the + // process, and a `chdir` there would be a data race with them. + // A directory that cannot be entered reports through the SAME + // self-pipe an exec failure uses — "the pty forked but the + // program could not start" is exactly this case. + if (cwd_z) |dir| { + if (c.chdir(dir.ptr) != 0) reportExecFailure(exec_pipe[1]); + } _ = c.execve(resolved_z.ptr, argv_z.ptr, envp_z.ptr); reportExecFailure(exec_pipe[1]); } @@ -949,6 +978,7 @@ const c = struct { extern "c" fn write(fd: c_int, buf: [*]const u8, len: usize) isize; extern "c" fn close(fd: c_int) c_int; extern "c" fn access(path: [*:0]const u8, mode: c_int) c_int; + extern "c" fn chdir(path: [*:0]const u8) c_int; extern "c" fn _exit(code: c_int) noreturn; extern "c" fn kill(pid: c_int, sig: c_int) c_int; extern "c" fn waitpid(pid: c_int, status: *c_int, options: c_int) c_int; diff --git a/src/runtime/pty_windows.zig b/src/runtime/pty_windows.zig index 1eb26fcdf..e8a315ff1 100644 --- a/src/runtime/pty_windows.zig +++ b/src/runtime/pty_windows.zig @@ -728,6 +728,16 @@ pub fn spawn(gpa: std.mem.Allocator, options: SpawnOptions) Error!Pty { error.InvalidWtf8 => return error.PtyEnvironTooLarge, error.OutOfMemory => return error.PtyEnvironTooLarge, }; + // `lpCurrentDirectory` is the whole posix `chdir` on this side — + // CreateProcess sets the child's directory itself, so nothing here + // touches the parent's. Null keeps the inherit-the-parent default. + const cwd_w: ?[*:0]const u16 = if (options.cwd) |dir| cwd: { + if (dir.len == 0 or std.mem.indexOfScalar(u8, dir, 0) != null) return error.PtyArgvInvalid; + const len = std.unicode.calcWtf16LeLen(dir) catch return error.PtyArgvInvalid; + const wide = arena.allocSentinel(u16, len, 0) catch return error.PtyEnvironTooLarge; + _ = std.unicode.wtf8ToWtf16Le(wide, dir) catch return error.PtyArgvInvalid; + break :cwd wide.ptr; + } else null; // The pipe pair. Parent ends are overlapped named-pipe servers (the // one Windows pipe flavor that can join an event wait); the conhost @@ -811,7 +821,7 @@ pub fn spawn(gpa: std.mem.Allocator, options: SpawnOptions) Error!Pty { 0, win.EXTENDED_STARTUPINFO_PRESENT | win.CREATE_UNICODE_ENVIRONMENT, env_block_w.ptr, - null, + cwd_w, &siex.StartupInfo, &pi, ) == 0) {