Skip to content

feat(pty): add cwd to PtySpawnOptions - #220

Open
sonhyrd wants to merge 1 commit into
vercel-labs:mainfrom
sonhyrd:pty-spawn-cwd
Open

feat(pty): add cwd to PtySpawnOptions#220
sonhyrd wants to merge 1 commit into
vercel-labs:mainfrom
sonhyrd:pty-spawn-cwd

Conversation

@sonhyrd

@sonhyrd sonhyrd commented Jul 27, 2026

Copy link
Copy Markdown

Summary

Adds cwd to PtySpawnOptions, forwarded through pty.SpawnOptions to a chdir in the child (and to CreateProcessW's lpCurrentDirectory on Windows).

The gap

A pty child always started in the app process's working directory. PtySpawnOptions (src/runtime/effects.zig:2890) took key, argv, cols, rows, term, on_event; startRealPty (:11025) forwarded five of those to pty.spawn; and pty.SpawnOptions (src/runtime/pty.zig:392) carried no directory either. For an interactive shell — which is what a pty child nearly always is — that is the one thing worth setting.

Why this matters

An app with several terminals open on different directories cannot express that at all. The workaround:

const script = std.fmt.bufPrint(&buf, "cd '{s}' && exec {s} -i", .{ path, login_shell }) catch ...;
argv = &.{ login_shell, "-c", script };

Three problems:

  1. It turns a path into a shell word. A path containing a single quote breaks the script, so the caller has to detect that case and fall back to a shell in the wrong directory rather than run a mis-quoted command.
  2. It burns the shell's own -c, so the child is a -c shell that execs an -i shell.
  3. It is posix-only by construction — there is no cmd/powershell spelling worth trusting, so a Windows branch drops the directory entirely.

Where the chdir happens, and why

In the child, immediately before execve — not in the parent. The parent's working directory is shared with every other spawn on the process, so a parent-side chdir would be a data race with them. Entering it in the child also keeps the failure honest: an unenterable directory reports through the same exec self-pipe (pty.zig:601) a failed exec uses, so the app sees this spawn's .exit rather than a child running somewhere unexpected.

chdir is async-signal-safe, so the post-fork section keeps the property pty.zig:21 states — "login_tty, dup2, close, execve, and _exit — no allocator, no setenv". The path is NUL-terminated in the parent alongside the argv/envp arrays that are already built there.

On Windows this 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 (1024), or NUL-bearing paths refuse with the family's one rejected .exit. The NUL rule matters most — chdir reads to the first NUL, so a truncated path would silently enter a different directory than the caller named.

PtyRequest.cwd exposes the request to the fake executor, where null ("inherit") and "" stay distinguishable.

Scope

  • fx.spawn's SpawnOptions has no cwd either and this PR does not add one. It would follow the same shape if you want the symmetry; the evidence here is all on the pty side.
  • argv[0] still resolves against PATH in the parent, before the child's chdir — documented on the field rather than changed, since resolving after the chdir would mean a post-fork PATH walk.

Verification

  • zig build test: no new failures (this checkout has 6 pre-existing failed command lanes locally — codesign identity, packaging fixtures — identical before and after).
  • desktop-runtime-core-tests run directly: 531 passed, 11 skipped, 0 failed, including both new live-pty tests.
  • zig fmt --check clean.

Tests

  • live pty cwd: the child starts in the requested directory — spawns sh -c pwd with .cwd = "/" against the real transport and asserts the output. / is not the test process's own directory, so a child that ignored cwd fails rather than passing by coincidence.
  • live pty cwd: an unenterable directory fails the spawn, never a child elsewhere — a nonexistent path produces zero output and a nonzero exit code, proving the failure reaches the app as this spawn's terminal.
  • a fake pty mirrors the requested cwd, and null means inheritpendingPtyAt reports the path, and a spawn without one reports null rather than "".
  • The three admission cases (empty / over-bound / NUL-bearing) join the existing pty admission: every refused spawn delivers exactly one rejected exit.

@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

@sondh0127 is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

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 '<path>' && exec <shell> -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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants