Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
45 changes: 45 additions & 0 deletions docs/research/ISSUE_419_SHELL_PATH_RETROSPECTIVE_2026-09-07.md
Original file line number Diff line number Diff line change
@@ -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 负责。
2 changes: 2 additions & 0 deletions docs/research/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
22 changes: 13 additions & 9 deletions extensions/file-mutation-display/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,19 @@ function compact<TParams extends TSchema, TDetails, TState>(
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(
Expand Down
2 changes: 1 addition & 1 deletion tests/extensions/file-mutation-display/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
55 changes: 55 additions & 0 deletions tests/extensions/shared/child-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
DefaultPackageManager,
DefaultResourceLoader,
defineTool,
type ExtensionContext,
ProjectTrustStore,
SessionManager,
type SessionShutdownEvent,
Expand Down Expand Up @@ -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");
Expand Down
Loading