diff --git a/docs/research/ISSUE_419_SHELL_PATH_RETROSPECTIVE_2026-09-07.md b/docs/research/ISSUE_419_SHELL_PATH_RETROSPECTIVE_2026-09-07.md new file mode 100644 index 00000000..a0340ce0 --- /dev/null +++ b/docs/research/ISSUE_419_SHELL_PATH_RETROSPECTIVE_2026-09-07.md @@ -0,0 +1,45 @@ +# Issue #419:headless Session 的 shellPath 继承复盘 + +- 状态:在源码与回归测试边界验证 +- 创建 / 验证:2026-09-07 +- 基线:`c8f2c13`(OpenPI 0.6.0,Pi 0.85.1) +- 修复边界:`1bbd243` 与 PR [#423](https://github.com/openpi-dev/openpi/pull/423) +- Issue:[#419](https://github.com/openpi-dev/openpi/issues/419) +- Supersedes:无 + +## 已验证事实 + +Pi 的 `AgentSession` 会从 `SettingsManager.getShellPath()` 构造内置 +`bash` 工具。OpenPI 的 `file-mutation-display` 扩展在 `session_start` 中 +重新注册 `createBashToolDefinition(ctx.cwd)`,但没有传入 `shellPath`。 +这个处理在 TUI 中用于替换展示器,却也在 `print` 模式执行;Direct +Subagent 和 Workflow 子 Session 通过该模式绑定扩展,因此会丢失 Pi 的 +shell 设置。Windows 没有 WSL 时,裸 `bash` 可能被系统目录中的 WSL +launcher stub 截获。 + +在修复前的最小替身中,子 Session 的设置将 `shellPath` 指向不存在的文件, +绑定扩展后调用 `bash` 仍成功执行 `printf`,证明调用的是裸 shell。修复后, +同一调用返回 Pi 的 `Custom shell path not found` 错误,说明配置仍由原生 +工具读取。 + +## 实施与证据 + +修复让 `file-mutation-display` 只在 `ctx.mode === "tui"` 时修改工具注册和 +TUI 展开状态;`print`、RPC、JSON Session 保留 Pi 原生定义。TUI 测试改为 +显式使用 `tui` 模式,新增子 Session 测试覆盖扩展启动后的 shellPath 边界。 + +本地验证结果: + +- `npx --yes bun@1.3.14 run test`:1428 通过、1 跳过、0 失败;Vitest 30 通过; +- `npx --yes bun@1.3.14 run lint`:通过; +- `npx --yes bun@1.3.14 run typecheck`:通过; +- 子 Session 与展示扩展定向测试:20 通过; +- 变更文件格式检查与 `git diff --check`:通过。 + +## 影响与限制 + +该改动不改变 TUI 的可见展示、模型工具 schema、Session 文件或设置持久化。 +Headless Session 重新使用 Pi 的 shell 解析和生命周期。当前环境不是 Windows, +因此没有宣称真实 Windows WSL stub 的手工验收;回归测试使用跨平台的“不存在 +shell 路径”证据验证覆盖边界。Windows 原生命令编码和 Git Bash 安装发现仍由 +Pi 0.85.1 负责。 diff --git a/docs/research/README.md b/docs/research/README.md index 9a19db98..fbbc8f07 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -4,6 +4,8 @@ Research records preserve sourced investigation and distinguish observations, in ## Validated investigations +- [`ISSUE_419_SHELL_PATH_RETROSPECTIVE_2026-09-07.md`](ISSUE_419_SHELL_PATH_RETROSPECTIVE_2026-09-07.md) — headless 子 Session 被展示扩展覆盖 bash 定义、shellPath 丢失的根因与回归证据 ([#419](https://github.com/openpi-dev/openpi/issues/419), [PR #423](https://github.com/openpi-dev/openpi/pull/423))。 + - [`CURSOR_CONTEXT_AND_TRANSPORT_2026-09-07.md`](CURSOR_CONTEXT_AND_TRANSPORT_2026-09-07.md) — Cursor protocol context boundaries, linear frame accumulation and cwd-sensitive transcript caching ([#431](https://github.com/openpi-dev/openpi/issues/431)). - [`CHILD_ACQUISITION_AND_PROGRESS_2026-09-07.md`](CHILD_ACQUISITION_AND_PROGRESS_2026-09-07.md) — cancelled child/worktree acquisitions and stale progress evidence under output pressure ([#428](https://github.com/openpi-dev/openpi/issues/428)). diff --git a/extensions/file-mutation-display/index.ts b/extensions/file-mutation-display/index.ts index 32766051..fd885834 100644 --- a/extensions/file-mutation-display/index.ts +++ b/extensions/file-mutation-display/index.ts @@ -27,15 +27,19 @@ function compact( export default function fileMutationDisplay(pi: ExtensionAPI) { pi.on("session_start", (_event, ctx) => { const display = loadSetupConfig().ui; - if (ctx.mode === "tui") { - // Ctrl+O remains a temporary override. A new/reloaded session starts from - // the persisted defaults instead of inheriting an old expanded toggle. - ctx.ui.setToolsExpanded( - display.subagentResultDisplay === "full" && - display.bashToolDisplay === "full" && - display.fileMutationDisplay === "full", - ); - } + // This extension changes only the interactive TUI projection. Headless + // sessions must keep Pi's native definitions, especially bash: replacing + // it here would drop the SettingsManager-provided shellPath and can make + // Windows resolve the WSL System32 stub instead of the configured shell. + if (ctx.mode !== "tui") return; + + // Ctrl+O remains a temporary override. A new/reloaded session starts from + // the persisted defaults instead of inheriting an old expanded toggle. + ctx.ui.setToolsExpanded( + display.subagentResultDisplay === "full" && + display.bashToolDisplay === "full" && + display.fileMutationDisplay === "full", + ); pi.registerTool( compact( diff --git a/tests/extensions/file-mutation-display/index.test.ts b/tests/extensions/file-mutation-display/index.test.ts index 64bbdf3d..030783fa 100644 --- a/tests/extensions/file-mutation-display/index.test.ts +++ b/tests/extensions/file-mutation-display/index.test.ts @@ -54,7 +54,7 @@ async function withSession( sessionManager: SessionManager.inMemory(cwd), }); try { - await session.bindExtensions({ mode: "print" }); + await session.bindExtensions({ mode: "tui" }); await run(session, cwd); } finally { session.dispose(); diff --git a/tests/extensions/shared/child-session.test.ts b/tests/extensions/shared/child-session.test.ts index 8084efd9..ff49e6c6 100644 --- a/tests/extensions/shared/child-session.test.ts +++ b/tests/extensions/shared/child-session.test.ts @@ -19,6 +19,7 @@ import { DefaultPackageManager, DefaultResourceLoader, defineTool, + type ExtensionContext, ProjectTrustStore, SessionManager, type SessionShutdownEvent, @@ -260,6 +261,60 @@ test("child resources remove only verified parent-only OpenPI extensions", async }); }); +test("headless children preserve Pi shellPath through display extension startup", async () => { + await withTempDir(async (directory) => { + const cwd = path.join(directory, "project"); + const agentDir = path.join(directory, "agent"); + const repoRoot = fileURLToPath(new URL("../../..", import.meta.url)); + const missingShell = path.join(directory, "missing-shell"); + await mkdir(cwd, { recursive: true }); + await mkdir(agentDir, { recursive: true }); + await writeFile( + path.join(agentDir, "settings.json"), + JSON.stringify({ shellPath: missingShell, packages: [repoRoot] }), + ); + + const { loader, settingsManager } = await createChildResources({ + cwd, + agentDir, + projectTrusted: true, + }); + const { session } = await createAgentSession({ + cwd, + agentDir, + resourceLoader: loader, + settingsManager, + sessionManager: SessionManager.inMemory(cwd), + ...childToolPolicy(["bash"]), + }); + + try { + await bindChildSessionExtensions(session, ["bash"]); + const bash = session.getToolDefinition("bash"); + assert.ok(bash); + const context = { + cwd, + sessionManager: { + getSessionId: () => "shell-path", + getSessionFile: () => undefined, + }, + } as unknown as ExtensionContext; + await assert.rejects( + bash.execute( + "shell-path", + { command: "printf should-not-run" }, + undefined, + undefined, + context, + ), + /Custom shell path not found/, + ); + } finally { + await shutdownAndDisposeChildSession(session); + } + }); +}); + test("production child binding skips foreign Workflow artifacts", async () => { await withTempDir(async (directory) => { const cwd = path.join(directory, "project");