Skip to content

fix(wsl): pin each pane's distro, keep guest cwd in splits/tabs/forks, pass guest paths safely - #351

Merged
Kuddev merged 20 commits into
Kuddev:mainfrom
MomentDerek:fix/wsl-zsh-distro-snapshot
Oct 5, 2026
Merged

Kuddev merged 20 commits into
Kuddev:mainfrom
MomentDerek:fix/wsl-zsh-distro-snapshot

Conversation

@MomentDerek

@MomentDerek MomentDerek commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Result / 用户结果

Improves how every WSL pane knows its guest, and how guest paths reach wsl.exe:

  • Every WSL pane knows its guest. Bare wsl, a WSL default shell (including the PTY-default shell=wsl) and imported WSL profiles now get a guest identity: the distribution is snapshotted at spawn (explicit -d or the registry default). The file tree, Git view, prompt-path links, command execution, image paste and path drop all use it, and a later default-distribution change no longer redirects a running pane. The pane's own wsl.exe spawn is pinned to that snapshot (the persisted launch stays as configured).
  • Copies stay in the right guest and directory. Split, duplicate, new tab and AI-session fork keep the guest cwd only when they enter the same distribution as the same user. A guest path never reaches a host directory check (Windows would resolve / against the current drive). Splits made before the first prompt keep the launch's own --cd/~. Relative prompt links resolve in the guest cwd.
  • Guest paths are passed safely. Paths with spaces survive split/duplicate/fork/file-tree "open terminal here". Guest helpers (Git view, file enumeration, merge view) exec directly through wsl.exe --exec instead of --, so a directory or file name containing $(…) is no longer expanded by the guest login shell. A directory name containing " is no longer injected into the wsl.exe command line, and pane.exec in a WSL pane refuses a guest cwd or argument containing " for the same reason.
  • WSL argument handling is consistent. One parser reads WSL's own options (-d, --distribution, --distribution-id, --system, -u, --cd, ~) and stops at the guest command, so a guest command's -d is no longer mistaken for the distribution. One detector decides what is a WSL launch.

Scope change: the WSL zsh startup takeover (guest probe, zsh bootstrap changes, start-up warm-up) was moved out of this PR to keep it one decision under the size limit. It depends on this PR's option parser and spawn pinning, so it follows as the draft PR #409, stacked on this one (review its last commit only; it will be rebased onto main after this PR merges). Until then a WSL guest whose login shell is zsh behaves as on main (no OSC 7); nothing regresses for it.

Design / 设计边界

  • Responsibility and affected modules: shell_detect owns WSL argument parsing (one option-region parser, wsl_options; one program detector, is_wsl_launcher; wsl_spawn_distro; wsl_args_pinned; spawn_shell), the --cd encoding (wsl_raw_arg, wsl_accepts_arg) and the direct-exec builder wsl_exec_command. The pane's PaneExecContext records the pinned options and TerminalView::wsl_distro reads them; workspace/tab_duplication owns the copy rule in one place (PaneOrigin::of, CopyKind, copy_launch).
  • Why this belongs here; interfaces that remain unchanged: wsl.exe must keep launching the guest login shell for the pane itself (the 1.1.0 --exec bash regression); only helpers use --exec. wsl_launch_distro keeps its explicit-only contract; nebula_terminal is unchanged.
  • Dependency, data-format, threading, or lifetime changes: no new dependencies or threads. A copy of a bare wsl pane persists -d <snapshot> in its launch; the restored original keeps following the default. Rationale and rejected alternatives: architecture/notes/nebula_app/shell_detect/2026-09-28-wsl-spawn-distro-snapshot.md.
  • Compatibility and behaviour differences: helpers run through --exec no longer pass through the guest login shell, so a git found only via a PATH set in the user's shell rc (e.g. some nix/linuxbrew setups) may not be found by the Git view. Host cwd for a copy is computed when the user splits/duplicates (at most one is_dir on /mnt/<drive>; UNC paths are never probed). Known limits: --distribution-id/--system panes have no snapshot.

Evidence / 验证依据

  • Commands and actual results (Windows 11, after merging upstream main at 1e569537):
    • cargo test -p nebula --bin pebrel: 1883 passed, 0 failed, 19 ignored.
    • cargo test -p nebula_terminal --lib tty (before the merge; nebula_terminal is not touched by this PR): 54 passed.
    • python3 scripts/check_architecture.py --base 1e569537 (in WSL): exit 0.
    • python3 -m unittest scripts.tests.test_shell_integration (Ubuntu 26.04 WSL guest): 22 ran, OK, 1 skipped.
    • rustfmt --edition 2024 --check on the touched files: clean.
    • wsl.exe --cd encoding checked against a real wsl.exe (spaces, trailing and inner backslashes, %); CRT \" escaping was shown to inject a guest command.
    • -- vs --exec, measured on 2026-09-30: a directory named x$(touch /tmp/pwned) ran the touch through -- and not through --exec.
    • pane.exec quoting: a CRT-quoted --cd '/tmp/i" touch /tmp/x #' runs touch in the guest; the exec path now refuses it (wsl_exec_refuses_what_wsl_cannot_receive).
  • Review follow-ups:
    • Copilot: a guest command's --cd no longer suppresses cwd inheritance (wsl_identity_reads_only_the_option_region). Distribution names cannot contain whitespace (wsl --import "Nebula Probe" is rejected with E_INVALIDARG), so the pinned -d value stays unquoted.
    • Cross-review: pane.exec " refusal; leading ~ yields to an inherited host directory; the command palette no longer stats \\wsl.localhost while rendering; spawn_shell extracted and tested; notes corrected.
    • Size review: the five copy entry points were collapsed into one copy_launch, the duplicate TerminalView.wsl_distro field was replaced by a read of the exec context, overlapping test rows were merged, and the zsh takeover was moved to a follow-up PR (see Scope change).
  • Regression tests: Rust tests cover option-region parsing (-e tool -d x, --distribution=, --system, --distribution-id in both spellings), --cd quoting and refusal of ", snapshot pinning, split/duplicate/new-tab/fork inheritance, the exec-context snapshot and relative prompt-link bases.
  • UI changes: none.
  • Hot-path changes: none; a spawn reads the registry once.
  • PR size: under the 1500 counted-line limit (997 changed lines against main).

Required Review / 必须确认

  • I followed CONTRIBUTING.md, docs/architecture.md, and docs/project-constraints.md.
  • I split responsibilities, not arbitrary line ranges; no duplicate behavior authority was added.
  • python3 scripts/check_architecture.py --base <PR-base-commit> passes; budgets were not inflated to fit the change.
  • Tests cover success and failure; platform/feature coverage limitations are stated.
  • New messages use typed i18n IDs and matching placeholders; untranslated content has an explicit fallback. (No new messages.)
  • Governance changes include a counterexample, corrected contract, tests, and a maintainer-reviewed decision. (No governance changes.)

Checkboxes explain the review; they do not replace CI or maintainer approval.


中文版本

用户结果

让每个 WSL pane 都知道自己在哪个来宾里,并让来宾路径安全地传给 wsl.exe:

  • 每个 WSL pane 都知道自己的来宾。 裸 wsl、WSL 默认 shell(包括 PTY 默认的 shell=wsl)和导入的 WSL profile 现在都有来宾身份:发行版在 spawn 时记录下来(显式的 -d,否则取注册表里的默认发行版)。文件树、Git 视图、提示符路径链接、命令执行、图片粘贴和路径拖放都用这个身份;之后改默认发行版,也不会让已经在运行的 pane 跑到别的发行版去。pane 自己的 wsl.exe 启动参数固定为这个快照,持久化的 launch 仍保持用户原来的配置。
  • 复制出来的 pane 留在正确的来宾和目录里。 分屏、复制、新标签和 AI 会话 fork,只有进入同一个发行版、同一个用户时才继承来宾 cwd。来宾路径不会拿去做宿主目录检查(Windows 会把 / 解析到当前盘符)。第一个提示符出现之前的分屏,沿用 launch 自己的 --cd/~。提示符里的相对链接按来宾 cwd 解析。
  • 来宾路径安全传递。 含空格的路径在分屏、复制、fork 和文件树的「在此打开终端」里都不会被截断。来宾 helper(Git 视图、文件枚举、合并视图)改用 wsl.exe --exec 直接执行,不再用 --,所以目录名或文件名里的 $(…) 不会再被来宾登录 shell 展开。含 " 的目录名不会再注入 wsl.exe 命令行;出于同样的原因,WSL pane 里的 pane.exec 会拒绝含 " 的来宾 cwd 或参数。
  • WSL 参数处理保持一致。 只用一个解析器读取 WSL 自己的选项(-d、--distribution、--distribution-id、--system、-u、--cd、~),遇到来宾命令就停下,所以来宾命令里的 -d 不会再被误当成发行版。是不是 WSL 启动,也只由一个检测函数判断。

范围调整: WSL zsh 启动接管(来宾探测、zsh bootstrap 改动、启动预热)已移出本 PR,让本 PR 只包含一个决策,并控制在行数上限以内。它依赖本 PR 的选项解析器和 spawn 固定,所以作为堆叠在本 PR 之上的 Draft PR #409 跟进(只需看它的最后一个 commit;本 PR 合入后它会 rebase 到 main 上)。在那之前,登录 shell 是 zsh 的 WSL 来宾与 main 上的行为相同(不上报 OSC 7),不会出现回退。

设计边界

  • 职责与涉及模块:shell_detect 负责 WSL 参数解析(一个选项区解析器 wsl_options、一个程序检测函数 is_wsl_launcher,以及 wsl_spawn_distro、wsl_args_pinned、spawn_shell)、--cd 编码(wsl_raw_arg、wsl_accepts_arg)和直接执行的构造函数 wsl_exec_command。pane 的 PaneExecContext 记录固定后的选项,TerminalView::wsl_distro 从中读取;workspace/tab_duplication 在一处统一负责复制规则(PaneOrigin::of、CopyKind、copy_launch)。
  • 为什么放在这里、哪些接口不变:pane 本身的 wsl.exe 必须继续启动来宾登录 shell(1.1.0 的 --exec bash 回退),只有 helper 使用 --exec。wsl_launch_distro 保持「只认显式指定」的约定;nebula_terminal 没有改动。
  • 依赖、数据格式、线程与生命周期:没有新依赖,也没有新线程。复制裸 wsl pane 时,副本的 launch 会持久化 -d <快照>;恢复出来的原 pane 仍跟随默认发行版。决策依据和被否决的方案见 architecture/notes/nebula_app/shell_detect/2026-09-28-wsl-spawn-distro-snapshot.md。
  • 兼容性与行为差异:经 --exec 运行的 helper 不再经过来宾登录 shell,所以如果 git 只能通过用户 shell rc 里设置的 PATH 找到(例如某些 nix/linuxbrew 配置),Git 视图可能找不到它。复制时的宿主 cwd 改为在用户分屏或复制的当下计算(最多对 /mnt/<盘符> 做一次 is_dir,UNC 路径从不探测)。已知限制:--distribution-id/--system 启动的 pane 没有快照。

验证依据

  • 命令与实际结果(Windows 11,已合并上游 main 的 1e569537):
    • cargo test -p nebula --bin pebrel:1883 个通过,0 个失败,19 个忽略。
    • cargo test -p nebula_terminal --lib tty(合并前运行;本 PR 不改 nebula_terminal):54 个通过。
    • python3 scripts/check_architecture.py --base 1e569537(在 WSL 中运行):退出码 0。
    • python3 -m unittest scripts.tests.test_shell_integration(Ubuntu 26.04 WSL 来宾):运行 22 个,全部通过,跳过 1 个。
    • 对改动文件运行 rustfmt --edition 2024 --check:无差异。
    • wsl.exe --cd 编码用真实的 wsl.exe 验证过(空格、末尾和中间的反斜杠、%);实测 CRT 的 \" 转义可以注入来宾命令。
    • -- 与 --exec 对比(2026-09-30 实测):名为 x$(touch /tmp/pwned) 的目录,经 -- 时执行了 touch,经 --exec 时没有。
    • pane.exec 引号:CRT 引号包裹的 --cd '/tmp/i" touch /tmp/x #' 会在来宾里执行 touch;现在执行路径会拒绝它(wsl_exec_refuses_what_wsl_cannot_receive)。
  • 审阅跟进:
    • Copilot:来宾命令里的 --cd 不再阻止 cwd 继承(wsl_identity_reads_only_the_option_region)。发行版名称不能含空白(wsl --import "Nebula Probe" 会以 E_INVALIDARG 被拒绝),所以固定的 -d 值不加引号。
    • 交叉审阅:pane.exec 拒绝 ";开头的 ~ 让位于继承来的宿主目录;命令面板渲染时不再 stat \\wsl.localhost;抽出并测试了 spawn_shell;修正了 notes。
    • 行数审阅:五个复制入口合并为一个 copy_launch;重复的 TerminalView.wsl_distro 字段改为从 exec context 读取;合并了重叠的测试行;zsh 接管移到后续 PR(见「范围调整」)。
  • 回归测试:Rust 测试覆盖选项区解析(-e tool -d x、--distribution=、--system、两种写法的 --distribution-id)、--cd 引号处理与对 " 的拒绝、快照固定、分屏/复制/新标签/fork 的继承、exec context 快照,以及相对提示符链接的基准目录。
  • UI 改动:无。
  • 热路径改动:无;每次 spawn 只读一次注册表。
  • PR 行数:997 行(相对 main),在 1500 行上限以内。

必须确认

以上英文版的勾选项同样适用于本中文说明。

🤖 Generated with Claude Code

@MomentDerek
MomentDerek requested a review from Kuddev as a code owner September 29, 2026 05:36
@MomentDerek MomentDerek changed the title fix(wsl): report zsh guest cwd and follow each pane's spawn-time distro fix(wsl): improve the WSL experience Sep 29, 2026
@MomentDerek
MomentDerek force-pushed the fix/wsl-zsh-distro-snapshot branch from b2ff3f8 to d3f49d3 Compare September 29, 2026 05:49
@MomentDerek MomentDerek changed the title fix(wsl): improve the WSL experience fix(wsl): pin each pane's distro, keep guest cwd in splits/tabs/forks, pass paths safely, report zsh cwd Sep 29, 2026
@Kuddev
Kuddev requested a balanced review from Copilot September 29, 2026 06:57

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Raw distribution names with spaces break pinned launches, and guest-command options can incorrectly suppress cwd inheritance.

Review effort: Balanced
Findings: 2 Medium severity

Open (2)
What changed in this PR

Improves WSL pane identity, cwd inheritance, path safety, and zsh shell integration.

Changes:

  • Snapshots WSL distributions and preserves guest cwd across pane operations.
  • Centralizes WSL parsing and safe path handling.
  • Adds shared zsh bootstrap support and regression tests.
File Description
scripts/​tests/​test_shell_integration.py Expands zsh integration coverage.
nebula_app/​src/​shell_detect.rs Adds WSL parsing, snapshots, quoting, and environment handling.
nebula_app/​src/​runtime_exec.rs Applies spawn-time WSL identity to execution.
nebula_app/​src/​platform/​wsl_hooks/​windows.rs Reuses centralized WSL detection.
nebula_app/​src/​platform/​shell_integration.rs Creates shared local/WSL zsh bootstrap files.
nebula_app/​src/​gpui_shell/​workspace/​tests.rs Tests safe file-tree WSL launches.
nebula_app/​src/​gpui_shell/​workspace/​tab_duplication.rs Implements WSL-aware split, tab, duplicate, and cwd inheritance.
nebula_app/​src/​gpui_shell/​workspace/​file_tree.rs Safely constructs WSL terminal launches.
nebula_app/​src/​gpui_shell/​workspace/​agents.rs Preserves WSL context in AI forks.
nebula_app/​src/​gpui_shell/​workspace.rs Routes tab and split creation through WSL-aware logic.
nebula_app/​src/​gpui_shell/​terminal/​view/​startup.rs Captures distribution identity at spawn.
nebula_app/​src/​gpui_shell/​terminal/​view/​pointer.rs Resolves links using guest cwd.
nebula_app/​src/​gpui_shell/​terminal/​view.rs Stores pane-level WSL identity.
nebula_app/​src/​gpui_shell/​terminal/​session.rs Injects WSL zsh startup environment.
nebula_app/​src/​gpui_shell/​terminal/​osc_links.rs Maps WSL prompt links through the snapshot.
nebula_app/​src/​completion_context.rs Uses centralized WSL distribution resolution.
nebula_app/​src/​agent_env.rs Shares WSLENV merging logic.
nebula_app/​res/​shell/​zshrc Restores compinit and new-user behavior.
nebula_app/​res/​shell/​zshenv Limits bootstrap state to interactive zsh.
nebula_app/​AGENTS.md Links WSL architecture notes.
docs/​architecture-decisions.md Documents WSL zsh lifecycle signals.
architecture/​notes/​nebula_app/​shell_detect/​2026-09-28-wsl-spawn-distro-snapshot.md Records WSL snapshot design.
architecture/​notes/​nebula_app/​platform/​2026-09-28-wsl-zsh-startup-integration.md Records zsh integration design.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread nebula_app/src/gpui_shell/workspace/tab_duplication.rs Outdated
Comment thread nebula_app/src/shell_detect.rs

@Kuddev Kuddev left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed head 4eb7069c1207702409333e16795fa2470ba53311.

[P1] Preserve guest startup files when the WSL bootstrap cannot be read. In nebula_app/src/shell_detect.rs:836-846, ZDOTDIR is replaced with a host-drive bootstrap before the guest can verify that it is reachable. With disabled automount, failed path translation, or restrictive drvfs permissions, zsh cannot load that bootstrap and consequently never restores the user's startup directory. The PR's own architecture note documents this at lines 104-108. The zsh startup contract reads user files from $ZDOTDIR and falls back to $HOME only when ZDOTDIR is unset: https://zsh.sourceforge.io/Doc/Release/Files.html . A cwd integration must preserve startup configuration in these supported WSL configurations; requiring a manual WSLENV opt-out after shell initialization breaks is not a sufficient fallback.

[P2] An unspecified guest command does not prove that the login shell is zsh. command.is_none_or(is_zsh_program) also forwards the host ZDOTDIR to default fish/nushell sessions. Those shells never run the zsh restoration or bash PROMPT_COMMAND cleanup. Installers using ${ZDOTDIR:-$HOME} then write user configuration into the shared bootstrap directory; the next application process overwrites it. This is explicitly acknowledged at lines 112-116 of the same note. Preserve the guest environment for unknown/non-zsh shells, including processes started before the first prompt.

Please keep the WSL distribution/cwd and zsh integration functionality, but resolve these startup/environment regressions and cover non-zsh default shells and an unreachable bootstrap before merging. These findings are grounded in the submitted control flow, its documented consequences and the official startup contract; I have not claimed an end-to-end WSL reproduction or completed review of every remaining change.

@MomentDerek

Copy link
Copy Markdown
Contributor Author

Addressed both findings in 5498683.

P1 / P2 share one root cause: the host decided the takeover from the launch arguments, which cannot show the guest's login shell or the guest's view of a drvfs path. The guest now answers before the first takeover of each (distribution, user), through platform::wsl_guest_shell: wsl.exe --distribution <d> [--user <u>] --exec sh -s runs res/shell/wsl-guest-probe.sh (stdin, no quoting through wsl.exe), with the bootstrap sent through the same WSLENV /p translation the pane will use. It reports the passwd login shell and whether that user can read all three bootstrap files at the translated path.

  • P1: a login shell or -e zsh receives ZDOTDIR only when the guest reported the bootstrap readable. Automount off, failed translation and drvfs permissions for another -u user all come back unreadable, and the pane starts with its own startup environment (no zsh reports, no lost startup files). No opt-out is needed.
  • P2: a login shell is taken over only when the guest's passwd entry names zsh. fish, nushell and bash logins receive no zsh variables at all, so nothing reaches processes started before the first prompt either. Guest commands other than zsh are never probed. Unknown (probe failed, timed out, --distribution-id/--system) leaves the guest untouched. wsl_cwd_report_env no longer guesses from the guest command; the decision is takes_zsh_bootstrap.

Cost and threading: one probe per guest per process on its own thread (10 s budget, failures retried after 5 min, cache bounded to 32); the spawn waits at most 2 s for a guest's first verdict, so a guest still booting starts that pane without zsh reports and later panes use the cached verdict. Rationale and rejected alternatives (host-side \wsl.localhost\...\etc\passwd, unbounded wait, probe without waiting) are in the updated platform note.

Evidence: cargo test -p nebula --bin pebrel 1852 passed; the Python suite in the Ubuntu 26.04 guest 21 passed (new real-sh probe case: readable, one file missing, missing directory, untranslated Windows path, empty variable; shell compared with the passwd entry); check_architecture.py --base c66dc79 exit 0. The probe run by hand against this machine's guest returns shell=/usr/bin/fish bootstrap=readable for the default user (0.23 s with the distribution running) and shell=/bin/bash for --user root; the previous cut would have handed that fish login a host ZDOTDIR. Not run: an end-to-end pane with a zsh login shell.

MomentDerek added a commit to MomentDerek/pebrel that referenced this pull request Sep 30, 2026
…f them

Cross-review of Kuddev#351 found the spawn snapshot widened which panes feed their
OSC 7 cwd to host-side guest helpers, while those helpers still went through
`wsl.exe -- …`, i.e. the guest login shell:

- Side panel git/find and the merge tab's cat/git now start through
  `shell_detect::wsl_exec_command` (`--exec`). Measured: a directory named
  `x$(touch /tmp/pwned)` ran the touch through `--`, not through `--exec`.
  find's -printf format drops the doubled backslash `--` needed; the merge
  tab writes with `tee` instead of a quoted `sh -c`. Paths `wsl.exe` cannot
  carry (`"`) are not handed to the helpers.
- pane.exec / Runtime git strip ZDOTDIR, NEBULA_ZSH_INTEGRATION,
  NEBULA_ZDOTDIR_WAS_SET and their WSLENV entries: a direct exec never runs
  the bootstrap .zshenv that removes them.
- zshenv/zprofile only take over when NEBULA_ZSH_INTEGRATION is set, so a zsh
  started while the parent still exports the bootstrap ZDOTDIR (tmux from a
  global rc) loads the user's rc instead of `/.zshrc`.
- run_bounded kills and reaps the child when stat of its output fails.
- Document `exec_argument_unsupported`; correct the note on `"` paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MomentDerek added a commit to MomentDerek/pebrel that referenced this pull request Sep 30, 2026
…f them

Cross-review of Kuddev#351 found the spawn snapshot widened which panes feed their
OSC 7 cwd to host-side guest helpers, while those helpers still went through
`wsl.exe -- …`, i.e. the guest login shell:

- Side panel git/find and the merge tab's cat/git now start through
  `shell_detect::wsl_exec_command` (`--exec`). Measured: a directory named
  `x$(touch /tmp/pwned)` ran the touch through `--`, not through `--exec`.
  find's -printf format drops the doubled backslash `--` needed; the merge
  tab writes with `tee` instead of a quoted `sh -c`. Paths `wsl.exe` cannot
  carry (`"`) are not handed to the helpers.
- pane.exec / Runtime git strip ZDOTDIR, NEBULA_ZSH_INTEGRATION,
  NEBULA_ZDOTDIR_WAS_SET and their WSLENV entries: a direct exec never runs
  the bootstrap .zshenv that removes them.
- zshenv/zprofile only take over when NEBULA_ZSH_INTEGRATION is set, so a zsh
  started while the parent still exports the bootstrap ZDOTDIR (tmux from a
  global rc) loads the user's rc instead of `/.zshrc`.
- run_bounded kills and reaps the child when stat of its output fails.
- Document `exec_argument_unsupported`; correct the note on `"` paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MomentDerek
MomentDerek force-pushed the fix/wsl-zsh-distro-snapshot branch from d2abe1b to e9c9562 Compare September 30, 2026 07:45
MomentDerek added a commit to MomentDerek/pebrel that referenced this pull request Sep 30, 2026
…f them

Cross-review of Kuddev#351 found the spawn snapshot widened which panes feed their
OSC 7 cwd to host-side guest helpers, while those helpers still went through
`wsl.exe -- …`, i.e. the guest login shell:

- Side panel git/find and the merge tab's cat/git now start through
  `shell_detect::wsl_exec_command` (`--exec`). Measured: a directory named
  `x$(touch /tmp/pwned)` ran the touch through `--`, not through `--exec`.
  find's -printf format drops the doubled backslash `--` needed; the merge
  tab writes with `tee` instead of a quoted `sh -c`. Paths `wsl.exe` cannot
  carry (`"`) are not handed to the helpers.
- pane.exec / Runtime git strip ZDOTDIR, NEBULA_ZSH_INTEGRATION,
  NEBULA_ZDOTDIR_WAS_SET and their WSLENV entries: a direct exec never runs
  the bootstrap .zshenv that removes them.
- zshenv/zprofile only take over when NEBULA_ZSH_INTEGRATION is set, so a zsh
  started while the parent still exports the bootstrap ZDOTDIR (a multiplexer from a
  global rc) loads the user's rc instead of `/.zshrc`.
- run_bounded kills and reaps the child when stat of its output fails.
- Document `exec_argument_unsupported`; correct the note on `"` paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MomentDerek
MomentDerek force-pushed the fix/wsl-zsh-distro-snapshot branch from e9c9562 to af3e659 Compare September 30, 2026 07:48
MomentDerek added a commit to MomentDerek/pebrel that referenced this pull request Sep 30, 2026
…f them

Cross-review of Kuddev#351 found the spawn snapshot widened which panes feed their
OSC 7 cwd to host-side guest helpers, while those helpers still went through
`wsl.exe -- …`, i.e. the guest login shell:

- Side panel git/find and the merge tab's cat/git now start through
  `shell_detect::wsl_exec_command` (`--exec`). Measured: a directory named
  `x$(touch /tmp/pwned)` ran the touch through `--`, not through `--exec`.
  find's -printf format drops the doubled backslash `--` needed; the merge
  tab writes with `tee` instead of a quoted `sh -c`. Paths `wsl.exe` cannot
  carry (`"`) are not handed to the helpers.
- pane.exec / Runtime git strip ZDOTDIR, NEBULA_ZSH_INTEGRATION,
  NEBULA_ZDOTDIR_WAS_SET and their WSLENV entries: a direct exec never runs
  the bootstrap .zshenv that removes them.
- zshenv/zprofile only take over when NEBULA_ZSH_INTEGRATION is set, so a zsh
  started while the parent still exports the bootstrap ZDOTDIR (a multiplexer from a
  global rc) loads the user's rc instead of `/.zshrc`.
- run_bounded kills and reaps the child when stat of its output fails.
- Document `exec_argument_unsupported`; correct the note on `"` paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MomentDerek
MomentDerek force-pushed the fix/wsl-zsh-distro-snapshot branch from 80a081c to d58f2f1 Compare September 30, 2026 08:36
@MomentDerek MomentDerek changed the title fix(wsl): pin each pane's distro, keep guest cwd in splits/tabs/forks, pass paths safely, report zsh cwd fix(wsl): pin each pane's distro, keep guest cwd in splits/tabs/forks, pass guest paths safely Sep 30, 2026
MomentDerek and others added 8 commits September 30, 2026 22:39
WSL guests whose login shell is zsh never reported OSC 7, and bare `wsl`,
default-shell and profile WSL panes had no guest identity, so the file tree,
Git view, split/new-tab directory inheritance and prompt-path links ignored
them.

- Forward the shared zsh bootstrap as ZDOTDIR through WSLENV (/pu), written
  once per process and only from a fixed local drive. The bootstrap takes
  over interactive zsh only; `zsh -c` restores the user's ZDOTDIR and drops
  the NEBULA_* variables, so terminal multiplexers and nested shells keep XDG-style configs.
  bash guests drop the variables at their first prompt.
- Snapshot each pane's distribution at spawn (explicit -d or the registry
  default, resolved once and shared with completion scoping). Workspace WSL
  location, prompt links, split, duplicate and new tab read that snapshot.
- Splits of WSL panes stay in the guest even before a cwd report; bare
  launches pin the snapshotted distro; a leading `~` yields to --cd. New tabs
  inherit the guest cwd only for the same distro and user, keep a profile's
  own directory, and avoid UNC probes on the UI thread.
- Use a single WSL program detector; move the platform cfg into platform/.

Rationale: architecture/notes/nebula_app/platform/2026-09-28-wsl-zsh-startup-integration.md
and architecture/notes/nebula_app/shell_detect/2026-09-28-wsl-spawn-distro-snapshot.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of b5f8473 found injected guest paths broken or unsafe, helpers that
disagreed on where wsl.exe options end, and bootstrap side effects in guests.

- Encode `--cd` for wsl.exe's own splitting (`shell_detect::wsl_raw_arg`):
  quote paths with whitespace, keep backslashes literal, and refuse paths
  containing `"`. wsl.exe does not parse CRT `\"`, so such a directory name
  could run a guest command on split, duplicate, fork or file-tree open.
- One option-region parser (`wsl_options`) for distro, user, distribution
  selection and guest command; it stops at `--`, `-e`/`--exec` or the first
  unknown argument. `--system` no longer snapshots the default distro, and
  `--distribution-id` keeps its place when rewriting `--cd`.
- Exec context, WSL hooks, completion and a PTY-default `shell=wsl` pane use
  the spawn snapshot and the single `is_wsl_launcher` detector.
- Duplicate and AI fork carry the guest cwd only into the same guest and user
  (or the pane's own identity without a snapshot); guest paths never reach a
  host `is_dir`. Splits before the first report replay the spawn, keeping the
  launch's own `--cd`. Relative prompt links resolve in the guest cwd.
- zsh bootstrap: no `ZDOTDIR` for non-zsh guest commands; `NEBULA_*` stay
  unexported; Ubuntu's global compinit is deferred until the user's ZDOTDIR is
  back (respecting GLOBAL_RCS and a `.zprofile` skip); the newuser wizard runs
  where zsh itself would check. Bootstrap preparation caches failures and
  rewrites deleted files.
- Share the WSLENV merge helpers with agent_env.

Rationale and measured wsl.exe behavior are in the two 2026-09-28 notes under
architecture/notes/nebula_app/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…compinit case

Hosted Ubuntu runners ship group/world-writable fpath directories, so the
login shell's compinit stopped at compaudit's confirmation prompt and the
test timed out. The production bootstrap still runs the same plain compinit
as Ubuntu's /etc/zsh/zshrc; only the fixture now filters fpath.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The Ubuntu global-compinit case stopped at compaudit's "insecure
  directories" prompt because hosted runners ship group/world-writable
  fpath entries. The fixture now drops what compaudit rejects before the
  login shell's compinit; the bootstrap still runs the same plain compinit
  as Ubuntu's /etc/zsh/zshrc.
- split_of_wsl_pane_without_guest_cwd_stays_in_the_guest used temp_dir()
  as the host cwd; on macOS/Linux that is a POSIX path and reads as a guest
  cwd. Use a Windows host path, as WSL panes report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
new_tab_launch scanned the whole argv for `--cd`, so a guest command's
option (`wsl -d Ubuntu -e tool --cd /tool-dir`) suppressed guest cwd
inheritance. wsl_options now records a leading `~`, `--cd` or `--cd=`
before the guest command, and new_tab_launch asks it through
wsl_launch_chooses_directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aking over ZDOTDIR

Review of the zsh integration found two startup regressions that no guest-side
script can repair: ZDOTDIR was replaced before the guest could verify that the
host bootstrap is reachable (automount off, failed /p translation, drvfs
permissions for another -u user), so zsh skipped the user's own startup files;
and an unspecified guest command was taken as proof that the login shell is
zsh, so fish/nushell logins kept a ZDOTDIR nothing restores.

`platform::wsl_guest_shell` now asks the guest once per (distribution, user)
per process: `wsl.exe --exec sh -s` runs `res/shell/wsl-guest-probe.sh`, which
reports the passwd login shell and whether that user can read all three
bootstrap files at the WSLENV-translated path. A login shell is taken over only
when the guest said zsh and readable, an explicit `-e zsh` only when readable;
other guest commands are never probed, and unknown (failed, timed out,
--distribution-id/--system) leaves the guest untouched. The spawn waits at most
2 s for a guest's first verdict; a guest still booting starts that pane without
zsh reports and later panes use the cached verdict. `wsl_cwd_report_env` no
longer guesses from the guest command.

Tests: probe command/env, answer parsing and the takeover decision with a guest
double; `wsl_launch_command` option-region reads; the probe script under real
`sh` (readable, one file missing, missing directory, untranslated Windows path,
empty variable; shell compared with the passwd entry).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… fallback

The probe script fell back to $SHELL wherever getent is missing, which the
macOS CI runners exposed (Directory Services, no getent). A musl or busybox
guest would have hit the same path. The script now reads /etc/passwd before
falling back to $SHELL; the Python case covers a guest without getent and
expects the $SHELL fallback only where the account is not in /etc/passwd.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…bounded runner

Self-review of the probe cut:

- The spawn waited up to 2 s per pane for the guest's first verdict, on the UI
  thread and serially across restored panes. `verified` now answers only from
  the cache and starts the probe otherwise; `main` warms the default shell's
  guest at process start on a worker thread, so a running distribution has
  usually answered before the first pane.
- The spawn options of every WSL pane are pinned to the distribution snapshot
  (`wsl_args_pinned`) while the persisted launch stays as configured, so the
  pane, its probe and its hook installer name the same guest by construction.
- `platform::process::run_bounded` is the one bounded child runner; the probe
  and the WSL hook installer both use it, and the hook installer appends its
  WSLENV entry through `append_wslenv`.
- A failed bootstrap write is retried after five minutes instead of disabling
  the integration for the rest of the process.
- Non-zsh guest commands no longer materialise the bootstrap on spawn; running
  probes are never evicted from the verdict cache.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MomentDerek and others added 9 commits September 30, 2026 22:39
…uests and warm-up

Cross-review (three reviewers, three verifying judges, majority verdicts):

- pane.exec in a WSL pane refuses a guest cwd or argument containing `"`:
  `Command`'s CRT `\"` ends wsl.exe's quote and the rest runs as a guest
  command (reproduced). `shell_detect::wsl_accepts_arg` is the one rule for
  launches and exec. git exec appends its WSLENV entries with `append_wslenv`.
- The bootstrap `.zshenv` and the bash first prompt remove the zsh entries
  from the guest's WSLENV: WSL forwards them to a nested `wsl.exe` whatever
  the `/u` flag, so another distribution, user or installer received an
  unverified ZDOTDIR (reproduced; now `unset` in the nested guest).
- Each ZDOTDIR restore keeps the user's export attribute, so an XDG
  `~/.zshenv` that sets ZDOTDIR without export still lets child zsh read
  `~/.zshenv`. Shared with local zsh.
- Warm-up resolves the first pane's shell with the pane's own authority
  (`shell_launch::startup_shell`): `resolve_id` missed bare `wsl` whenever
  distributions are registered. It is skipped for an explicit command, and
  the user-forwarded ZDOTDIR opt-out is checked before any probe.
- The bootstrap is written only on the probe worker; the spawn reads a cached
  verdict and `wsl_zsh_directory_ready` (try_lock, no write).
- `shell_detect::spawn_shell` composes the pinned spawn and is unit-tested;
  the dead `PaneExecContext::with_spawn_distro` is gone.
- A leading `~` gives way to an inherited host directory like `--cd` does.
- The command palette no longer stats a `\wsl.localhost` path while rendering.
- `run_bounded` has host tests; notes corrected to match the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…f them

Cross-review of Kuddev#351 found the spawn snapshot widened which panes feed their
OSC 7 cwd to host-side guest helpers, while those helpers still went through
`wsl.exe -- …`, i.e. the guest login shell:

- Side panel git/find and the merge tab's cat/git now start through
  `shell_detect::wsl_exec_command` (`--exec`). Measured: a directory named
  `x$(touch /tmp/pwned)` ran the touch through `--`, not through `--exec`.
  find's -printf format drops the doubled backslash `--` needed; the merge
  tab writes with `tee` instead of a quoted `sh -c`. Paths `wsl.exe` cannot
  carry (`"`) are not handed to the helpers.
- pane.exec / Runtime git strip ZDOTDIR, NEBULA_ZSH_INTEGRATION,
  NEBULA_ZDOTDIR_WAS_SET and their WSLENV entries: a direct exec never runs
  the bootstrap .zshenv that removes them.
- zshenv/zprofile only take over when NEBULA_ZSH_INTEGRATION is set, so a zsh
  started while the parent still exports the bootstrap ZDOTDIR (a multiplexer from a
  global rc) loads the user's rc instead of `/.zshrc`.
- run_bounded kills and reaps the child when stat of its output fails.
- Document `exec_argument_unsupported`; correct the note on `"` paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…takeover

Cross-review found several locally reasonable pieces that repeated a rule
already owned elsewhere. Net counted change against main drops from about
2560 to about 1970 lines without changing covered behavior.

- The guest probe owns the bootstrap: its verdict carries the path, so the
  host-side state machine, the fixed-drive check (the probe already proves the
  guest can read it) and the UI-thread ready lookup are gone. The verdict
  cache is a OnceLock slot per guest.
- One WSL option-region parse: callers read `wsl_launch` fields and
  `WslOptions::spawn_distro`; the command/directory/injected-default wrappers
  are removed.
- Split, duplicate, fork and new tab share `follow_guest`; a split reuses the
  duplicate rule. A pane without a snapshot no longer passes its guest cwd on.
- Completion scoping reads the pinned spawn shell instead of a second branch.
- The zsh takeover is two variables in one constant; NEBULA_ZDOTDIR_WAS_SET is
  no longer forwarded (the host always sent 0, the scripts default to it).
- Behaviour-neutral refactors of pre-existing code (WSLENV append helpers in
  agent_env, runtime_exec and the hook prepare step; the exec detector swap)
  are reverted to keep this change focused.
- Tests are table-driven; the zsh note is condensed to failure facts and
  current decisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r the zsh probe

- Delete process::run_bounded and its tests; platform::process_output gains
  read_with_input (stdin from a temp file), with read/read_cancellable as thin
  wrappers. The zsh probe and the WSL hook exchange use it, so both now run in
  the process group/job object like the other short-lived probes. Its bounded
  test now also runs on Windows and covers stdin.
- takes_zsh_bootstrap parses the launch once and builds the Target from it;
  Target::from_launch is gone and Target::command reuses
  shell_detect::wsl_exec_command, which gains an optional user (-d/-u).
- GuestShell is { login_zsh, readable }; takes_zsh_bootstrap returns bool and
  the spawn path takes the path from the pure shell_integration::wsl_zsh_path.
- wsl_cwd_report_env no longer re-checks a forwarded ZDOTDIR; its argument
  must be takes_zsh_bootstrap's answer, which already refuses that case.
- CR normalization of the zsh bootstrap and probe script goes through
  nebula_terminal::tty::shell_line_endings (now pub) instead of copies.
- Tests: spawn_distro is asserted beside the pin rule for every selector
  spelling (including --distribution-id=), the identity table drops that
  column, and rows that repeated another test's branch are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y copy through one rule

- Drop `TerminalView.wsl_distro`: the pinned spawn options already record the
  snapshot in `exec_context`, so `TerminalView::wsl_distro()` reads it from
  there (pointer links, `active_wsl_cwd`) and startup no longer threads a
  second copy through its tuple.
- Replace `split_launch`, `pane_split`, `new_tab_launch`, `duplicate_launch`,
  `follow_guest`, `focused_guest` and `host_visible_cwd` with
  `PaneOrigin::of(view)` + `CopyKind { Split, Duplicate, NewTab }` +
  `copy_launch`. A split of a non-WSL pane still opens the default shell in
  the host cwd; a WSL pane still pins then follows even without a guest cwd
  (fish, or a split before the first prompt). The guest user now comes from
  `exec_context` too, which the pinned spawn keeps equal to the launch's `-u`.
- Behaviour note: the host-visible cwd is now computed eagerly on split,
  duplicate, new tab and fork, including when same-guest `--cd` inheritance
  wins and it is discarded. That costs at most one `is_dir` on a
  `/mnt/<drive>` path (or the pane's host cwd), only on an explicit user
  action; UNC paths are still never probed.
- Inline the single-use `at` closure in the file-tree WSL test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…h test scaffolding

- wsl-guest-probe.sh: one loop for the three readability checks and one awk
  filter over getent-or-/etc/passwd; the output lines are unchanged.
- zshenv: set the compinit marker with a quoted := default; one-line reasons.
- zshrc: one-line reason for the Ubuntu compinit condition.
- tests: one run_bootstrapped_zsh helper for the three WSL-shaped zsh runs;
  the non-interactive WSLENV strip check moves into the leak test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…separable

Point the notes at process_output::read_with_input, copy_launch and the
exec_context accessor, move zsh-takeover text out of the snapshot note, and
record that the takeover builds on the spawn-time snapshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This PR now carries one decision: the spawn-time WSL distribution
snapshot, with guest cwd plumbing, `wsl.exe --exec` hardening and the
copy rules. The WSL zsh startup takeover (guest shell probe, ZDOTDIR
bootstrap through WSLENV, startup warm-up and its tests and note) is a
separate decision that builds on this snapshot, and together they
exceeded the PR size limit. It returns in a follow-up PR on top of this
one.

Removed: platform/wsl_guest_shell.rs, res/shell/wsl-guest-probe.sh and
the zsh startup note. Reverted to base where the change only served the
takeover: the zsh bootstrap scripts, shell_integration.rs, the pane
session env, the startup warm-up, process_output's stdin runner (the WSL
hook setup keeps its own loop), tty::shell_line_endings visibility, the
shell integration Python tests and the WSL bash/zsh hooks line.
shell_detect drops the zsh parameter of wsl_cwd_report_env, the
takeover cleanup in the bash report, the WSLENV helpers, the guest
command field of WslOptions and the user argument of wsl_exec_command;
runtime_exec no longer strips the takeover from pane.exec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MomentDerek
MomentDerek force-pushed the fix/wsl-zsh-distro-snapshot branch from 1a98d71 to c7089aa Compare September 30, 2026 14:39
@MomentDerek
MomentDerek requested a review from Kuddev September 30, 2026 16:58
@MomentDerek

Copy link
Copy Markdown
Contributor Author

@Kuddev 这个 PR 已按行数审阅拆分,麻烦再看一下 / Split after the size review, ready for another look:

This PR now carries only the spawn-time distro snapshot, guest cwd plumbing and the wsl.exe argument hardening (997 lines). The zsh takeover moved to draft #409, which is stacked on this one; review only its last commit. Details are in both PR descriptions.

@Kuddev Kuddev left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 区分 WSL 启动选项与 --exec 后的真实 argv,保留已能正确传递的双引号参数。

nebula_app/src/runtime_exec.rs:231-244 新增检查将 guest cwd 和 argv 合并,任何参数包含双引号都会返回 exec_argument_unsupported。例如 python -c 的普通字符串、git commit -m 中的引号都被这条新规则阻断,文档还将其声明为永久限制。

本机用 Rust 标准库 std::process::Command(与产品 build_command 的实际启动 API 相同)做了只读参数回显:wsl.exe --exec /usr/bin/printf <格式参数> <单个值>。WSL 2.7.13.0、Windows 10.0.22631.5472 下,普通路径、带空格路径、包含双引号的 /tmp/a" OTHER,以及带空格且尾随反斜杠的值,都返回完全相同的单个 argv;四个断言均通过。此前 Python subprocess 的独立回显也一致。这里只打印参数,没有写入 guest 文件或运行参数中的内容。

这证明 --cd/raw 启动命令行的测量结论不适合直接扩大为所有 --exec argv 的永久限制。请保留确有依据的 cwd/启动参数防护,分别处理并测试元选项与执行 argv,恢复合法参数的精确传递;如需覆盖特定旧 WSL 行为,请给出版本化复现证据,而不是让全部版本退化为受限 argv。

旧审阅针对的 ZDOTDIR takeover 已移至 #409,本轮不再用那两条已移出当前 diff 的路径作为阻塞理由。当前合并仍等待上述新回归修正以及未完成 review threads 的处理。

@Kuddev

Kuddev commented Oct 5, 2026

Copy link
Copy Markdown
Owner

维护者已直接修正并整合当前主线:保留 #455 的完整分屏复制,对每个 pane 固定其实际 WSL 发行版;只限制启动 --cd 的双引号,恢复 --exec argv 及 Git/文件 helper 参数的原样传递。新增 bare-WSL 复制回归,保留原嵌套混合布局测试。架构、格式以及包含 GPUI 测试代码的本地 cargo check 已通过。

另一个独立提交修复了此次 CI 触发的检查器问题:Git 的 combined-diff hunk 标题截断 UTF-8,而实际文档有效。仅新增源码做严格解码,元数据按字节解析;21 项名称检查测试和原失败 range 通过,未更改模式表、豁免或历史扫描范围。

两条旧线程的依据已核对:目录选择现在只读 WSL 自身选项区;发行版名称包含空白的担忧不符合当前 WSL 注册约束,官方 s_ValidateDistroName 使用 ^[a-zA-Z0-9._-]{1,N}$,见 https://github.com/microsoft/WSL/blob/449a7eb5c98638bb35cd4d9b4717d090b15ba271/src/windows/service/exe/LxssUserSession.cpp#L4310-L4320 。因此关闭这两条已解决/不适用的讨论。新 head 仍等待完整 CI,尚未合并。

@Kuddev Kuddev left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

源码复审完成。已有两条 review thread 均已解决;逐窗格 WSL 发行版与完整分屏恢复接线、启动 --cd 与 --exec 参数边界保持一致。维护者另行修正了 Git diff 标题截断 UTF-8 的检查器误报,新增源码仍严格解码,扫描范围及名称规则未放宽。最终测试样本引用现有规则数据,不新增产品名称。

本地产品 tests 编译检查及架构/格式检查通过;名称检查 21 项通过。此批准是对精确 head 的代码审阅结论,不代表原生 CI 已结束;必须等新 head 的全部必需检查和所选原生作业成功后再合并。

@Kuddev
Kuddev merged commit c1c499d into Kuddev:main Oct 5, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants