Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8e99add
fix(wsl): report zsh guest cwd and follow each pane's spawn-time distro
MomentDerek Sep 29, 2026
5a8ca69
fix(wsl): harden guest cwd injection and zsh bootstrap after review
MomentDerek Sep 29, 2026
f7c6df2
test(shell): drop compaudit-rejected fpath entries before the global …
MomentDerek Sep 29, 2026
a3b0b91
test: make the new WSL/zsh cases independent of the CI host
MomentDerek Sep 29, 2026
099fefe
fix(wsl): read a launch's own start directory from WSL's option region
MomentDerek Sep 29, 2026
fe912d3
fix(wsl): let the guest confirm zsh and a readable bootstrap before t…
MomentDerek Sep 30, 2026
3c0bb35
fix(wsl): read /etc/passwd when the guest has no getent, and test the…
MomentDerek Sep 30, 2026
40f1e97
fix(wsl): never wait for the guest probe on the UI thread; share the …
MomentDerek Sep 30, 2026
c423b42
fix(wsl): close the review round's findings on exec quoting, nested g…
MomentDerek Sep 30, 2026
8be6cd1
fix(wsl): exec guest helpers directly and keep the zsh takeover out o…
MomentDerek Sep 30, 2026
fde4b4a
refactor(wsl): collapse duplicated rules in the WSL snapshot and zsh …
MomentDerek Sep 30, 2026
2c8fad9
docs(notes): state what a bootstrap deleted mid-process costs
MomentDerek Sep 30, 2026
f3fa246
refactor(wsl): reuse the shared bounded runner and one guest parse fo…
MomentDerek Sep 30, 2026
d9d3596
refactor(wsl): read the pane's guest from exec_context and route ever…
MomentDerek Sep 30, 2026
c223df9
refactor(wsl): tighten the guest probe, zsh bootstrap comments and zs…
MomentDerek Sep 30, 2026
fb75386
docs(notes): align the WSL notes with the slimmed code and keep them …
MomentDerek Sep 30, 2026
c7089aa
refactor(wsl): move the zsh startup takeover to a follow-up PR
MomentDerek Sep 30, 2026
78b20d2
Integrate current split layout restoration with WSL launch identity
Kuddev Oct 5, 2026
f14aecc
fix(checks): decode added source independently of diff metadata
Kuddev Oct 5, 2026
d384003
test(checks): reuse existing naming rule samples
Kuddev Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# WSL pane identity is the distribution resolved at spawn

## Status

Proposed for review with implementation.

## Context

`wsl_launch_distro` recognizes only an explicit `-d`/`--distribution`; its test
states that guessing the default distribution for a bare `wsl` could point at the
wrong guest. As a consequence, bare `wsl`, legacy `shell=wsl` and panes whose
default shell is WSL had no cwd mapping: the file tree, Git view and prompt-path
links ignored them. The workspace also read WSL identity from the tab-level
launch, which is `Default` for a default-shell tab and `Profile` for imported
profiles, so those panes were ignored even with an explicit distribution.

## Evidence

`wsl.exe` itself reads `HKCU\...\Lxss\DefaultDistribution` when it starts. The
completion context already resolved that value at spawn for history scoping
(`completion_context::launch_environment`). Each view keeps its own spawn-time
`session_launch`; split panes can differ from the first pane of a tab.

Review of the first cut found that the helpers disagreed on where WSL's options
end. `wsl_launch_distro` scanned the whole argv, so `wsl -e tool -d x` named the
guest command's `x`. `--distribution=Debian` and `--system` fell through to the
registry default. Explicit shell arguments are joined raw
(`tty::Options::escape_args` is `false`), so an injected `--cd /home/a b` split
into a directory plus a guest command; a directory name from a cloned repository
could thus run a guest command on split, duplicate or fork. `runtime_exec`, the WSL hook setup and a
PTY-default `shell=wsl` pane still read launch arguments without the snapshot.

`wsl.exe -- …` hands the joined line to the guest's login shell: measured on
2026-09-30, a directory named `x$(touch /tmp/pwned)` ran the `touch` through
`--` and not through `--exec`. Direct exec also keeps `find -printf`'s single
backslash.

## Decision

A pane's WSL identity is the distribution resolved at spawn
(`shell_detect::wsl_spawn_distro`): the explicit distribution, else the registry
default; `--distribution-id` and `--system` resolve to `None` rather than to the
default. `shell_detect::spawn_shell` pins every WSL spawn to it
(`wsl_args_pinned`) while the persisted launch stays as the user configured it,
and a PTY-default `shell=wsl` pane now spawns the snapshotted `wsl.exe`
explicitly. The pane's `PaneExecContext` records the pinned options, so the
workspace WSL location and prompt-path links read the focused pane's snapshot
(`TerminalView::wsl_distro`) instead of the tab launch. Completion scoping
reuses the spawn's value; a new-tab decision reads it on its own.
`wsl_launch_distro` keeps its explicit-only semantics.

One parser, `shell_detect::wsl_options`, reads WSL's option region for every
reader, and `is_wsl_launcher` is the one WSL program detector for launches. The
parser tolerates the `=` forms that `wsl_args_with_directory` already preserved,
although `wsl.exe` itself rejects them. Completion classifies a typed command
word separately.

An injected guest cwd is encoded for `wsl.exe`'s own command-line splitting,
not the CRT's (`shell_detect::wsl_raw_arg`). Measured on WSL 2 on 2026-09-29:
`wsl.exe` pairs `"` and keeps every backslash literal, so a CRT `\"` ends the
quote and the rest of the path runs as a guest command. A path with whitespace
is wrapped in quotes; a path containing `"` has no encoding and is not
injected: a split, duplicate or fork keeps the launch's own `--cd` or `~`, and
only a file-tree terminal starts without `--cd`. `pane.exec` keeps the same
restriction for startup `--cd` through `wsl_accepts_startup_arg`. The argv after
`--exec` uses native `Command` quoting, not that startup option restriction.
A maintainer's native Rust round-trip on WSL 2.7.13.0 / Windows 22631 preserved
double quotes, whitespace and a trailing backslash in direct-exec arguments.
Persisted WSL launch arguments follow the raw convention too:
a spaced `--cd` value is stored quoted, which is what a restored raw spawn needs.

Host-side guest helpers (the side panel's git and `find`, the merge tab's
`cat`/`tee`/git) start through `shell_detect::wsl_exec_command`
(`wsl.exe -d <distro> --exec`), because the snapshot made every WSL pane, not
only an explicit `-d` one, feed its reported cwd to them. Helper paths remain
separate argv after `--exec`, including literal quotes; they are never shell text.

Copies of a pane follow its snapshot through one rule
(`tab_duplication::copy_launch`). Split, duplicate and AI-session fork insert
`-d <snapshot>` into a bare launch, after a leading `~`, so a later default
change cannot move the copy; the pin is persisted only in the copy, and the
restored original follows the default again. Full-layout duplication pins every
pane's launch and reuses shared reconstruction for directories and layout; it
does not return to single-pane duplication. The guest cwd travels through
`--cd` only into the same distribution as the same user; otherwise the copy
gets the host-visible cwd. A guest path maps to a host directory only from
`/mnt/<drive>`: Windows would resolve `/` against the current drive, and a UNC
probe would block the UI thread. The `tab_duplication` and prompt-path
(`osc_links`) tests hold the cases.

## Rejected alternatives

- Read the registry default whenever a location is needed: a later default
change would silently retarget a running pane — the guess the old rule forbade.
- Rewrite bare launches to `-d <default>` in the persisted launch: changes
launch identity and restore semantics for users who intentionally follow the
default. Only the spawn options are pinned.
- Take the identity from the guest's `WSL_DISTRO_NAME`, which the bash report
carries in the `pebrel_shell` token and completion uses to fill an empty
distribution: it arrives only at the first prompt, after an early split, and
never from shells without the integration, so it supplements the snapshot.
- CRT quoting (`escape_args`, or the PTY's escaper on the injected value):
`wsl.exe` does not parse `\"`, so a directory name containing `"` would still
inject a guest command, as the first cut of this fix did. `escape_args` would
also quote profile and persisted arguments that follow the raw convention.
- Drop the guest command (`-e htop`) when splitting: it would also drop
shell-selecting commands such as `--exec zsh -l`, so splits keep the pane's
command as duplicates do.

## Consequences

A default change between the registry read and `wsl.exe` start could still
mismatch; the window is the same spawn call. Restored panes resolve again at
their own spawn. SSH and host panes have no snapshot. A `--distribution-id` or `--system`
pane has no WSL location until the id is mapped to a name, and its commands run
without a distribution argument. Splitting a pane whose launch runs a guest
command runs that command again. A distribution renamed or re-imported under
another name after spawn leaves pinned copies pointing at the old name.

## Validation

- Registry-free tests in `shell_detect` (resolution, option region, launch
rewriting, spawn composition), `tab_duplication` (copies), `osc_links`
(prompt paths) and `runtime_exec` (startup cwd validation versus preserved
direct-exec argv). Full-layout coverage is retained; a bare-WSL duplicate
regression checks its pane's frozen distribution and user.
- By hand, not automated: the `"`/backslash encoding against a real `wsl.exe`,
and interactive file-tree following.

## Supersedes

None. Narrows the explicit-only rule documented at `wsl_launch_distro`.

## Revisit when

WSL exposes the running distribution of a process, or panes gain an OSC-reported
guest identity.
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Diff metadata and source decoding boundaries

## Status

Proposed maintainer repair for the reproduced PR #351 check failure.

## Context

The naming checker must scan added source and pending commit messages, including
text added and later removed. It decoded an entire Git diff as UTF-8 before
finding source lines.

## Evidence

Git 2.55.0 emitted a combined hunk title ending in bytes `e8 b0`, cutting the final
codepoint of a Chinese context label. Both document revisions are valid UTF-8.
The failing input is reproducible with `git show --format= --cc --no-ext-diff
--unified=0 78b20d2c -- docs/runtime-control-api.md`. The title is metadata, not an
added source line; decoding it rejected a legitimate edit before scanning it.

## Decision

Read physical diff lines and ASCII hunk/addition prefixes as bytes. Decode only
the added source payload, still using strict UTF-8. Staged and commit scans share
that implementation. File headers are recognized by their pre-hunk position,
so added source beginning with multiple plus signs remains subject to checking.

## Rejected alternatives

- Replacement decoding would hide invalid added source bytes.
- Ignoring files, commits or the failing check would weaken the actual policy.
- Editing legitimate document text merely to change Git's context truncation
would conceal the parser defect.

## Consequences

Patterns, exemptions, commit-message/path decoding and whole-history scope are
unchanged. Malformed added UTF-8 still fails. There is no product runtime change.

## Validation

Focused regressions cover regular and combined truncated headers, prohibited
source after those headers, plus-prefixed source and invalid added bytes. Run the
existing naming-check suite and the original failing range before integration.

## Supersedes

None; repairs metadata parsing without changing the naming policy.

## Revisit when

Git's diff output format or the checker's source selection changes.
6 changes: 4 additions & 2 deletions docs/runtime-control-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,8 +297,10 @@ managed generation 改变时,旧回调直接丢弃,不会寻找替代 pane

`pane.exec` 与 `pane.run` 是两种刻意分开的执行语义:它直接接收 argv,不经过 shell 展开,
在 Pane 当前上报的本地 cwd 中启动独立 non-TTY child,不写入 Grid、history 或交互 shell
环境。WSL Pane 通过对应 distribution 和 guest cwd 执行;SSH Pane 返回
`remote_exec_unsupported`。stdout/stderr 始终并行排水,每条默认最多保留 1 MiB、可配置上限
环境。WSL Pane 通过对应 distribution 和 guest cwd 执行;argv 通过 `--exec` 保留原生参数边界,
包括参数内部的双引号。当前启动目录模型不接收含 `"` 的 guest cwd,此时返回
`exec_argument_unsupported`(`details.argument` 为该目录),不会改写后执行。
SSH Pane 返回 `remote_exec_unsupported`。stdout/stderr 始终并行排水,每条默认最多保留 1 MiB、可配置上限
16 MiB;响应的 `stdout`/`stderr` 是直接字符串,`capture` 分别报告 encoding、总字节数、保留
字节数和截断状态。超时会回收整个子进程树,并保留已捕获输出与 `timed_out: true`。

Expand Down
1 change: 1 addition & 0 deletions nebula_app/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@
- 补齐数据源选择和请求快照归 `completion.rs`,界面仅拥有任务与交互;边界依据见 [`completion`](../architecture/notes/nebula_app/completion/)。
- 旧渲染入口的窗口动画状态归 `display/animations.rs`;迁移及显式 legacy 验证依据见 [`display`](../architecture/notes/nebula_app/display/)。
- 新终端代理保持选定协议,不把 SOCKS 改写成 HTTP。见 [`terminal proxy scheme`](../architecture/notes/nebula_app/ssh_proxy/2026-09-29-terminal-proxy-scheme.md)。
- WSL pane 的发行版快照与分屏/新标签继承见 [`shell_detect`](../architecture/notes/nebula_app/shell_detect/)。
- Windows Acrylic 的运行库回退与窗口生命周期见 [`platform`](../architecture/notes/nebula_app/platform/2026-09-22-acrylic-controller.md)。
- Windows Acrylic 的动画期间透明回退与计时边界见 [`窗口状态切换`](../architecture/notes/nebula_app/platform/2026-09-22-acrylic-window-transitions.md)。
11 changes: 4 additions & 7 deletions nebula_app/src/completion_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,15 +209,12 @@ fn typed_environment(parent: &SuggestEnv, words: &[String], wsl: bool) -> Sugges
}

pub(crate) fn launch_environment(program: &str, args: &[String]) -> SuggestEnv {
if crate::shell_detect::is_wsl_launcher(program) {
let distro = crate::shell_detect::wsl_spawn_distro(program, args).unwrap_or_default();
return SuggestEnv::Wsl { distro };
}
let program_name = program.rsplit(['/', '\\']).next().unwrap_or(program);
match crate::display::extract_program(program_name).as_deref() {
Some("wsl") => {
let distro = crate::shell_detect::wsl_launch_distro(program, args)
.map(str::to_owned)
.or_else(crate::platform::shell::default_wsl_distro)
.unwrap_or_default();
SuggestEnv::Wsl { distro }
},
Some("ssh") => {
let words: Vec<_> =
std::iter::once(program.to_owned()).chain(args.iter().cloned()).collect();
Expand Down
27 changes: 8 additions & 19 deletions nebula_app/src/display/side_panel/enumerate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ pub(crate) fn run_wsl_find_lenient(
distro: &str,
args: impl IntoIterator<Item = OsString>,
) -> Option<(Vec<u8>, bool)> {
let mut command = std::process::Command::new("wsl.exe");
command.args(["-d", distro, "--", "find"]).args(args);
let mut command = crate::shell_detect::wsl_exec_command(distro);
command.arg("find").args(args);
crate::platform::process::hidden_command(&mut command);
let output = match command_output_with_timeout(command, Some(WSL_COMMAND_TIMEOUT)) {
Ok(output) => output,
Expand All @@ -143,24 +143,13 @@ pub(crate) fn run_wsl_find(
exit_ok.then_some(stdout)
}

/// `find -printf` 的格式串:类型 + NUL + 全路径 + NUL。**反斜杠必须写两遍**。
/// `find -printf` 的格式串:类型 + NUL + 全路径 + NUL。
///
/// 2026-08-21 实测:`wsl.exe -d <发行版> -- <命令>` 在把参数转发给来宾时会吞掉
/// 一层反斜杠。同一条 find、同一个目录,三种写法的输出对照:
///
/// | 传入 | NUL 个数 | 输出开头 |
/// |---|---|---|
/// | `%y\0%f\0` | **0** | `l0lib0d0opt0…` |
/// | `%y\\0%f\\0` | 54 | `l\0lib\0d\0opt\0…` |
/// | `sh -c` 包一层 | 54 | `l\0lib\0d\0opt\0…` |
///
/// 也就是说单反斜杠版本让 find 收到的是 `%y0%f0`,输出用字面字符 `'0'` 分隔、
/// 一个 NUL 都没有,[`parse_wsl_find_pairs`] 因此永远配不出记录、返回空列表——
/// UI 再把空列表显示成"此目录为空"。这就是 WSL 文件树空白的根因。
///
/// 用双反斜杠而不是 `sh -c` 包装:后者要为含空格/引号的来宾路径再做一层 shell
/// 引用,而这里只需要把转义层数补对。
pub(crate) const WSL_FIND_PATH_FORMAT: &str = r"%y\\0%p\\0";
/// 单反斜杠:[`crate::shell_detect::wsl_exec_command`] 直接 exec `find`,不经
/// 来宾 shell(`search/walk.rs` 同理)。旧的 `wsl.exe -d <发行版> -- find` 会让
/// 来宾 shell 解释路径里的 `$(…)`,还会吞掉一层反斜杠(2026-08-21 实测单反斜杠
/// 版本一个 NUL 都输出不了,文件树因此全空),所以当时写两遍;那条路已弃用。
pub(crate) const WSL_FIND_PATH_FORMAT: &str = r"%y\0%p\0";

/// 一趟 `find` 的结果,按父目录分桶。
pub(crate) struct WslDirListing {
Expand Down
8 changes: 3 additions & 5 deletions nebula_app/src/display/side_panel/vcs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ impl SidePanel {
let mut guest_args: Vec<OsString> = [
"-d",
located.distro.as_str(),
"--",
"--exec",
"git",
"-C",
located.guest.as_str(),
Expand Down Expand Up @@ -824,12 +824,10 @@ pub(crate) fn read_git(root: &Path) -> Option<GitInfo> {
/// 代价是每次快照多一次 `wsl.exe` 进程往返(发行版没运行时还会把它拉起
/// 来)。快照本来就在后台线程上、且有节流,不进渲染路径。
pub(crate) fn read_git_wsl(located: &crate::shell_detect::WslCwd) -> Option<GitInfo> {
use std::process::Command;
let location = format!("{}:{}", located.distro, located.guest);
collect_git_info(|args| {
let mut cmd = Command::new("wsl.exe");
cmd.args(["-d", &located.distro, "--", "git", "-C", &located.guest, "--no-optional-locks"])
.args(args);
let mut cmd = crate::shell_detect::wsl_exec_command(&located.distro);
cmd.args(["git", "-C", &located.guest, "--no-optional-locks"]).args(args);
run_git(cmd, args, &location, Some(WSL_COMMAND_TIMEOUT))
})
}
Expand Down
12 changes: 6 additions & 6 deletions nebula_app/src/gpui_shell/code_tab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -654,9 +654,9 @@ fn read_worktree_file(key: &MergeKey) -> Result<Vec<u8>, String> {
},
GitLocation::Wsl { distro, root } => {
let path = join_guest_path(root, &key.relative_path);
let mut command = Command::new("wsl.exe");
let mut command = crate::shell_detect::wsl_exec_command(distro);
let output = crate::platform::process::hidden_command(&mut command)
.args(["-d", distro, "--", "cat", "--", path.as_str()])
.args(["cat", "--", path.as_str()])
.output()
.map_err(|error| format!("无法从 WSL 读取冲突文件: {error}"))?;
if output.status.success() { Ok(output.stdout) } else { Ok(Vec::new()) }
Expand All @@ -676,9 +676,9 @@ fn write_conflict_result(key: &MergeKey, result: String) -> Result<(), String> {
},
GitLocation::Wsl { distro, root } => {
let path = join_guest_path(root, &key.relative_path);
let mut command = Command::new("wsl.exe");
let mut command = crate::shell_detect::wsl_exec_command(distro);
let mut child = crate::platform::process::hidden_command(&mut command)
.args(["-d", distro, "--", "sh", "-c", "cat > \"$1\"", "nebula", path.as_str()])
.args(["tee", "--", path.as_str()])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
Expand Down Expand Up @@ -720,8 +720,8 @@ fn git_command(location: &GitLocation, args: &[&str]) -> Result<std::process::Ou
command
},
GitLocation::Wsl { distro, root } => {
let mut command = Command::new("wsl.exe");
command.args(["-d", distro, "--", "git", "-C", root, "--no-optional-locks"]);
let mut command = crate::shell_detect::wsl_exec_command(distro);
command.args(["git", "-C", root, "--no-optional-locks"]);
command
},
};
Expand Down
Loading
Loading