Skip to content

feat(wsl): report zsh guest cwd through a guest-verified ZDOTDIR takeover - #409

Open
MomentDerek wants to merge 1 commit into
Kuddev:mainfrom
MomentDerek:feat/wsl-zsh-startup
Open

MomentDerek wants to merge 1 commit into
Kuddev:mainfrom
MomentDerek:feat/wsl-zsh-startup

Conversation

@MomentDerek

@MomentDerek MomentDerek commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Result / 用户结果

  • zsh guests report their cwd, and only zsh guests are touched. A WSL guest whose login shell is zsh now reports OSC 7 and command boundaries, as bash guests already did, so the file tree, Git view, split/duplicate directory inheritance and prompt-path links follow it. The user's own zsh setup is kept (global compinit, newuser wizard, XDG ZDOTDIR).
  • The takeover happens only after the guest itself confirmed that its login shell is zsh and that it can read the host bootstrap; fish, nushell and bash logins, and guests where automount/translation/permissions hide the bootstrap, keep their startup environment untouched. A wsl.exe started inside a taken-over guest receives none of the bootstrap variables.

Relation to #351 (merged)

The takeover needs #351's WSL option-region parser (to tell a zsh guest command from -e htop or an installer) and its spawn-time distribution pinning (a bare wsl or PTY-default shell=wsl pane is otherwise not probed, and the probe must read the same guest the pane enters). Both are now on main, so this PR reuses them instead of keeping a second copy of the option parser, which the snapshot note records as the cause of an earlier incident.

Design / 设计边界

  • Responsibility and affected modules: platform/shell_integration writes the zsh bootstrap shared with local zsh (res/shell/zshenv, zprofile, zshrc). New: platform/wsl_guest_shell asks the guest, once per (distribution, user) per process, which login shell its passwd entry names and whether that user can read the bootstrap at the WSLENV-translated path (res/shell/wsl-guest-probe.sh over wsl.exe -d <d> [-u <u>] --exec sh -s, built by shell_detect::wsl_exec_command); takes_zsh_bootstrap is the single takeover decision. The probe and the WSL hook installer share the existing bounded runner platform::process_output (new stdin entry point read_with_input) instead of a second runner.
  • Why this belongs here; interfaces that remain unchanged: wsl.exe must keep launching the guest login shell (the 1.1.0 --exec bash regression), so zsh integration travels only through WSLENV (ZDOTDIR/pu). The host cannot see the guest login shell or the guest's view of a drvfs path from the launch arguments, so the guest answers. nebula_terminal only makes tty::shell_line_endings public for reuse.
  • Dependency, data-format, threading, or lifetime changes: no new dependencies. The guest probe runs on its own thread (10 s budget, failures retried after 5 min, at most 32 finished verdicts cached, running probes never evicted), and that worker also writes the bootstrap files. The spawn never waits: it reads only the cached verdict and an already written bootstrap. Panes of a guest spawned before its first verdict have no zsh reports; later panes use the cached verdict. main warms the first pane's guest at process start, off the main thread (shell_launch::startup_shell), and skips it when an explicit command was given. With a WSL default shell the warm-up starts a stopped distribution. Rationale and rejected alternatives: architecture/notes/nebula_app/platform/2026-09-28-wsl-zsh-startup-integration.md.
  • Compatibility and fallback: the bootstrap only takes over interactive zsh; zsh -c restores the user's ZDOTDIR. It preserves Ubuntu's global compinit (deferred until the user's ZDOTDIR is back, respecting GLOBAL_RCS and a .zprofile skip_global_compinit) and zsh's newuser wizard. Unknown (probe not yet answered or failed, --distribution-id/--system) leaves the guest untouched. Forwarding ZDOTDIR in the host WSLENV opts out before any probe. The bootstrap removes its own entries from the guest's WSLENV and keeps the export attribute the user gave ZDOTDIR (both also apply to local zsh, fixing NEBULA_* leaking into programs exec'd from rc files and a missing newuser wizard). Probe and hook output is decoded leniently (lossy UTF-8). Known limits: panes spawned before their guest's first verdict have no zsh reports; Windows programs started from the guest still see the host ZDOTDIR; a chsh or mount change in the guest is seen by the next Pebrel process; a zsh started from a non-zsh login shell is not integrated.

Evidence / 验证依据

  • Rebased onto main c1c499d3 (fix(wsl): pin each pane's distro, keep guest cwd in splits/tabs/forks, pass guest paths safely #351 merged); re-run after the rebase on Linux (WSL 2, Ubuntu 26.04):
    • cargo test -p nebula --bin pebrel: 1855 passed, 0 failed, 14 ignored.
    • python3 -m unittest scripts.tests.test_shell_integration: 31 ran, OK, 1 skipped.
    • python3 scripts/check_architecture.py --base c1c499d3: exit 0.
    • Windows 11 host (cargo.exe): cargo check -p nebula --all-targets clean; cargo test -p nebula --bin pebrel: 1935 passed, 0 failed, 22 ignored.
  • Before the rebase (Windows 11, on top of fix(wsl): pin each pane's distro, keep guest cwd in splits/tabs/forks, pass guest paths safely #351 merged with main at 1e569537):
    • cargo test -p nebula --bin pebrel: 1891 passed, 0 failed, 19 ignored.
    • python3 -m unittest scripts.tests.test_shell_integration in an Ubuntu 26.04 WSL guest with real zsh 5.9 and sh: 31 ran, OK, 1 skipped (BSD ls only). Covers the probe script (including a guest without getent) and test_zsh_bootstrap_forwards_nothing_to_nested_guests_and_keeps_zdotdir_unexported, which fails against the previous scripts.
    • python3 scripts/check_architecture.py --base 1e569537 (in WSL): exit 0.
    • Probe run by hand against a real guest (Ubuntu-26.04, WSL 2), exactly as the app runs it: default user → shell=/usr/bin/fish, bootstrap=readable in 0.23 s; a missing directory → bootstrap=unreadable; --user root → shell=/bin/bash, bootstrap=readable.
    • Nested guest, by hand: under a taken-over zsh, wsl.exe --exec sh -c 'echo ${ZDOTDIR-unset}' prints unset.
    • Not run: an end-to-end pane spawned through wsl.exe with a zsh login shell, and a cold-boot probe since wsl.exe now runs inside the bounded runner's Windows job object.
  • Review history: this was part of fix(wsl): pin each pane's distro, keep guest cwd in splits/tabs/forks, pass guest paths safely #351; maintainer P1 (unreadable bootstrap) and P2 (login shell not proven to be zsh) are resolved by the guest probe (launches_that_cannot_take_the_bootstrap_are_never_probed, the_guest_answer_decides_the_bootstrap, plus the real-sh probe case). It was split out to keep each PR one decision under the size limit.
  • Regression tests: the Python cases for the bootstrap ZDOTDIR leak from zsh -c, the global compinit dump, the newuser wizard and exported NEBULA_* fail against the earlier scripts.
  • UI changes: none.
  • Hot-path changes: none; the bootstrap is written once per process on the probe worker, and the probe runs once per guest per process on a worker thread.
  • PR size: one commit against main, 1149 changed lines, under the 1500 limit.

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.


中文版本

用户结果

  • zsh 来宾上报 cwd,并且只动 zsh 来宾。 登录 shell 是 zsh 的 WSL 来宾,现在会像 bash 来宾一样上报 OSC 7 和命令边界,文件树、Git 视图、分屏/复制的目录继承和提示符路径链接都会跟随它。用户自己的 zsh 配置保持不变(全局 compinit、newuser 向导、XDG ZDOTDIR)。
  • 只有来宾自己确认了登录 shell 是 zsh、并且能读到宿主 bootstrap 之后才会接管。fish、nushell、bash 登录,以及因 automount、路径转换或权限读不到 bootstrap 的来宾,启动环境都不受影响。在已接管的来宾里再启动的 wsl.exe,不会收到任何 bootstrap 变量。

与 #351(已合并)的关系

接管需要 #351 的 WSL 选项区解析器(用来区分 zsh 来宾命令和 -e htop、安装程序等),也需要它在 spawn 时固定发行版(否则裸 wsl 或 PTY 默认 shell=wsl 的 pane 不会被探测,而且探测必须读的是 pane 实际进入的那个来宾)。两者现已在 main 上,本 PR 直接复用,不再保留第二份选项解析器;快照 note 记录过的一次事故,正是由这种重复造成的。

设计边界

  • 职责与涉及模块:platform/shell_integration 写入与本地 zsh 共用的 zsh bootstrap(res/shell/zshenv、zprofile、zshrc)。新增 platform/wsl_guest_shell:每个进程对每个(发行版,用户)问一次来宾,passwd 记录里的登录 shell 是什么,以及该用户能否在经 WSLENV 转换后的路径上读到 bootstrap(通过 wsl.exe -d <d> [-u <u>] --exec sh -s 执行 res/shell/wsl-guest-probe.sh,命令由 shell_detect::wsl_exec_command 构造)。takes_zsh_bootstrap 是唯一的接管判断。探测和 WSL hook 安装器共用已有的有界执行器 platform::process_output(新增带 stdin 的入口 read_with_input),不再另写一个执行器。
  • 为什么放在这里、哪些接口不变:wsl.exe 必须继续启动来宾登录 shell(1.1.0 的 --exec bash 回退),所以 zsh 集成只能经 WSLENV 传递(ZDOTDIR/pu)。宿主从启动参数里看不到来宾的登录 shell,也看不到来宾眼里的 drvfs 路径,所以由来宾来回答。nebula_terminal 只是把 tty::shell_line_endings 改为 public 以便复用。
  • 依赖、数据格式、线程与生命周期:没有新依赖。来宾探测在独立线程上运行(10 秒时限,失败后 5 分钟重试,最多缓存 32 个已完成的结论,正在运行的探测不会被淘汰),bootstrap 文件也由这个 worker 写入。spawn 从不等待:它只读缓存的结论和已写好的 bootstrap。某个来宾在第一次结论出来之前 spawn 的 pane 没有 zsh 上报,之后的 pane 使用缓存结论。main 在进程启动时于后台线程预热第一个 pane 的来宾(shell_launch::startup_shell),显式给了命令时跳过。默认 shell 是 WSL 时,预热会启动已停止的发行版。决策依据和被否决的方案见 architecture/notes/nebula_app/platform/2026-09-28-wsl-zsh-startup-integration.md。
  • 兼容性与回退:bootstrap 只接管交互式 zsh;zsh -c 会恢复用户的 ZDOTDIR。它保留 Ubuntu 的全局 compinit(推迟到用户的 ZDOTDIR 恢复之后,并遵守 GLOBAL_RCS 和 .zprofile 里的 skip_global_compinit),也保留 zsh 的 newuser 向导。结论未知时(探测还没回答或失败,或 --distribution-id/--system)不动来宾。宿主 WSLENV 里如果已经转发 ZDOTDIR,在任何探测之前就视为退出。bootstrap 会从来宾的 WSLENV 中删掉自己的条目,并保留用户给 ZDOTDIR 设的导出属性(这两点对本地 zsh 同样生效,顺带修复了 NEBULA_* 泄漏到 rc 里 exec 的程序、以及 newuser 向导不出现的问题)。探测和 hook 的输出按宽松 UTF-8 解码。已知限制:来宾第一次结论之前 spawn 的 pane 没有 zsh 上报;从来宾启动的 Windows 程序仍能看到宿主的 ZDOTDIR;来宾里的 chsh 或挂载变化要到下一个 Pebrel 进程才生效;从非 zsh 登录 shell 里再启动的 zsh 不会被集成。

验证依据

  • 已 rebase 到 main c1c499d3(fix(wsl): pin each pane's distro, keep guest cwd in splits/tabs/forks, pass guest paths safely #351 已合并),rebase 后在 Linux(WSL 2,Ubuntu 26.04)重新运行:
    • cargo test -p nebula --bin pebrel:1855 个通过,0 个失败,14 个忽略。
    • python3 -m unittest scripts.tests.test_shell_integration:运行 31 个,全部通过,跳过 1 个。
    • python3 scripts/check_architecture.py --base c1c499d3:退出码 0。
    • Windows 11 宿主(cargo.exe):cargo check -p nebula --all-targets 无错误;cargo test -p nebula --bin pebrel:1935 个通过,0 个失败,22 个忽略。
  • rebase 之前(Windows 11,基于已合并 main 1e569537 的 fix(wsl): pin each pane's distro, keep guest cwd in splits/tabs/forks, pass guest paths safely #351):
    • cargo test -p nebula --bin pebrel:1891 个通过,0 个失败,19 个忽略。
    • python3 -m unittest scripts.tests.test_shell_integration(Ubuntu 26.04 WSL 来宾,真实 zsh 5.9 和 sh):运行 31 个,全部通过,跳过 1 个(仅适用于 BSD ls)。覆盖探测脚本(包括没有 getent 的来宾)和 test_zsh_bootstrap_forwards_nothing_to_nested_guests_and_keeps_zdotdir_unexported,后者在旧脚本上会失败。
    • python3 scripts/check_architecture.py --base 1e569537(在 WSL 中运行):退出码 0。
    • 按应用的方式在真实来宾(Ubuntu-26.04,WSL 2)上手动运行探测:默认用户 → shell=/usr/bin/fish、bootstrap=readable,耗时 0.23 秒;目录不存在 → bootstrap=unreadable;--user root → shell=/bin/bash、bootstrap=readable。
    • 嵌套来宾(手动):在已接管的 zsh 里执行 wsl.exe --exec sh -c 'echo ${ZDOTDIR-unset}',输出 unset。
    • 未运行:通过 wsl.exe 端到端启动一个登录 shell 为 zsh 的 pane;以及冷启动时的探测(wsl.exe 现在运行在有界执行器的 Windows job 里)。
  • 审阅历史:这部分原属于 fix(wsl): pin each pane's distro, keep guest cwd in splits/tabs/forks, pass guest paths safely #351;维护者提出的 P1(bootstrap 读不到)和 P2(没有证明登录 shell 是 zsh)由来宾探测解决(launches_that_cannot_take_the_bootstrap_are_never_probed、the_guest_answer_decides_the_bootstrap,以及真实 sh 下的探测用例)。拆出来是为了让每个 PR 只含一个决策,并控制在行数上限以内。
  • 回归测试:针对 zsh -c 泄漏 bootstrap ZDOTDIR、全局 compinit dump、newuser 向导和导出 NEBULA_* 的 Python 用例,在旧脚本上都会失败。
  • UI 改动:无。
  • 热路径改动:无;bootstrap 每个进程只在探测 worker 上写一次,探测每个进程对每个来宾在 worker 线程上只跑一次。
  • PR 行数:相对 main 一个 commit,1149 行,在 1500 行上限以内。

必须确认

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

🤖 Generated with Claude Code

@MomentDerek
MomentDerek force-pushed the feat/wsl-zsh-startup branch from 81c37f9 to fcc1f45 Compare October 5, 2026 16:43
@MomentDerek MomentDerek changed the title feat(wsl): report zsh guest cwd through a guest-verified ZDOTDIR takeover (stacked on #351) feat(wsl): report zsh guest cwd through a guest-verified ZDOTDIR takeover Oct 5, 2026
@MomentDerek
MomentDerek force-pushed the feat/wsl-zsh-startup branch from fcc1f45 to 4744c76 Compare October 5, 2026 20:14
@MomentDerek
MomentDerek marked this pull request as ready for review October 5, 2026 20:29
@MomentDerek
MomentDerek requested a review from Kuddev as a code owner October 5, 2026 20:29
@MomentDerek MomentDerek closed this Oct 5, 2026
@MomentDerek MomentDerek reopened this Oct 5, 2026
@MomentDerek
MomentDerek force-pushed the feat/wsl-zsh-startup branch 2 times, most recently from 3f89c13 to 24dd4de Compare October 7, 2026 11:04
…over

Restores the WSL zsh startup takeover on top of the spawn-time distro
snapshot: the guest probe confirms a zsh login shell and a readable
bootstrap before ZDOTDIR is forwarded through WSLENV, the bootstrap keeps
the user's global compinit, newuser wizard and XDG ZDOTDIR, nested guests
receive none of it, and the first pane's guest is warmed at start-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MomentDerek
MomentDerek force-pushed the feat/wsl-zsh-startup branch from 24dd4de to a43d160 Compare October 8, 2026 03:45
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.

1 participant