diff --git a/pi-container-sandbox/docs/superpowers/specs/2026-08-15-toolcall-path-translation-design.md b/pi-container-sandbox/docs/superpowers/specs/2026-08-15-toolcall-path-translation-design.md new file mode 100644 index 0000000..5c13311 --- /dev/null +++ b/pi-container-sandbox/docs/superpowers/specs/2026-08-15-toolcall-path-translation-design.md @@ -0,0 +1,146 @@ +# 第三方工具 tool_call 路径翻译设计 + +> 状态:已确认(2026-08-15) + +## 背景 + +`pi-container-sandbox` 只把内置工具 `bash` / `read` / `write` / `edit` 路由进容器, +项目 cwd 以读写方式 bind-mount 到 `/workspace`。agent 在 `before_agent_start` 阶段 +被告知 cwd 为 `/workspace`,因此天然输出 `/workspace/...` 绝对容器路径。 + +第三方扩展工具(如 `pi-vision-tools` 的 `describe_image`)运行在**宿主机**上 +(pi 官方文档 containerization.md 明确:tool-routing 扩展只路由自己代理的工具, +其他自定义扩展工具仍在宿主机运行)。当 agent 把 `/workspace/foo.png` 传给这类工具时, +宿主机 `readFile("/workspace/foo.png")` 找不到文件(宿主机上没有 `/workspace`),工具失败。 + +## 目标 + +让宿主机上运行的第三方工具(以及宿主机内置工具 `find` / `grep` / `ls`)能正确读取 +agent 给出的 `/workspace` 绝对路径,无需第三方工具做任何改动。 + +## 方案:`tool_call` 事件通用路径翻译 + +pi 的 `tool_call` 事件中 `event.input` 可变,且「对 `event.input` 的修改会作用到真实工具执行」, +这是官方为工具调用拦截/改写预留的机制(`pi-permission-system` 已有先例)。 + +sandbox 订阅 `tool_call`,对**非自己路由**的工具,把其输入参数中的 `/workspace` 路径 +原地翻译为宿主机路径。 + +## 设计决策 + +| 决策 | 结论 | +|------|------| +| 拦截时机 | `tool_call` 事件(`event.input` 原地 mutate) | +| 翻译范围 | **仅 `/workspace`**(项目 cwd 挂载)。不处理 `/skills`、不处理用户 mount | +| 跳过集合 | `{ "bash", "read", "write", "edit" }` —— sandbox 自己路由的工具,内部已做 `hostToContainer` | +| 配置开关 | **无**,始终开启(YAGNI,需要时再补) | +| 映射失败 | 不抛错,原样返回(防御式) | +| 遍历方式 | 递归遍历对象/数组,只替换「字符串值」中等于 `/workspace` 或以 `/workspace/` 开头的值 | +| 相对路径/自由文本 | 一律不动(只匹配 `/workspace` 前缀,天然不误翻) | + +## 组件 + +### `src/path-translation.ts`(新增,单一职责,纯函数可测) + +```ts +import { resolve as resolvePath } from "node:path"; +import { CONTAINER_ROOT } from "./paths"; + +export const SANDBOX_ROUTED_TOOLS = new Set(["bash", "read", "write", "edit"]); + +// /workspace → hostCwd;/workspace/xxx → hostCwd/xxx;其余原样返回 +export function workspacePathToHost(path: string, hostCwd: string): string { + if (path === CONTAINER_ROOT) return hostCwd; + if (path.startsWith(`${CONTAINER_ROOT}/`)) { + return resolvePath(hostCwd, path.slice(CONTAINER_ROOT.length + 1)); + } + return path; +} + +// 递归遍历 input,原地把字符串值中的 /workspace 路径替换为宿主机路径 +export function translateToolCallPaths(input: unknown, hostCwd: string): void { + if (Array.isArray(input)) { + for (let i = 0; i < input.length; i++) { + const v = input[i]; + if (typeof v === "string") input[i] = workspacePathToHost(v, hostCwd); + else translateToolCallPaths(v, hostCwd); + } + return; + } + if (input && typeof input === "object") { + for (const key of Object.keys(input)) { + const v = (input as Record)[key]; + if (typeof v === "string") (input as Record)[key] = workspacePathToHost(v, hostCwd); + else translateToolCallPaths(v, hostCwd); + } + } +} +``` + +说明:字符串是叶子,直接写回父槽位(数组下标或对象键);number/boolean/null 等原始值 no-op。 + +### `index.ts`(改) + +```ts +import { SANDBOX_ROUTED_TOOLS, translateToolCallPaths } from "./src/path-translation"; + +// 在 registerTool 之后新增: +pi.on("tool_call", (event) => { + const sbx = getSbx(); + if (!sbx) return; + if (SANDBOX_ROUTED_TOOLS.has(event.toolName)) return; + translateToolCallPaths(event.input, sbx.hostCwd); +}); +``` + +## 数据流 + +``` +agent 调 describe_image(image_path="/workspace/foo.png") + │ + ▼ tool_call(sandbox handler) + │ 非路由工具 → translateToolCallPaths(input, hostCwd) + │ image_path: "/workspace/foo.png" → "/home/user/proj/foo.png" + ▼ +pi-vision-tools execute() 拿到翻译后的 image_path → readFile 成功 +``` + +`read`/`write`/`edit`/`bash` 被跳过(内部已翻译);`find`/`grep`/`ls` 与所有第三方工具自动受益。 + +## 错误处理与边界 + +- `workspacePathToHost` 只对 `/workspace` 前缀做纯字符串替换,永不抛错。 +- `translateToolCallPaths` 对 null / undefined / number / boolean 等非对象输入 no-op。 +- 已知局限:若第三方工具自身是「容器感知」并期望收到容器路径,会被误翻 —— 此类工具应走 + 协作式共享函数(方案 C,本次不做)。 + +## 测试 + +`tests/paths.test.ts`(或新增 `tests/path-translation.test.ts`)覆盖: + +1. `workspacePathToHost`: + - `/workspace` → hostCwd + - `/workspace/foo.png` → hostCwd/foo.png + - `/workspace/a/b/c` → hostCwd/a/b/c + - 相对路径 `foo.png`、宿主绝对路径 `/home/x/a.png`、自由文本 `describe /workspace/foo` 原样返回 +2. `translateToolCallPaths`: + - 顶层字符串字段翻译 + - 嵌套对象、数组内字符串翻译 + - 相对路径 / 自由文本 / 非对象值不动 + - null / undefined / number 输入 no-op 不抛错 +3. 跳过逻辑:`SANDBOX_ROUTED_TOOLS` 包含 `bash`/`read`/`write`/`edit`(可选,直接断言 Set 内容) + +## 变更文件 + +| 文件 | 变更 | +|------|------| +| `src/path-translation.ts` | 新增:`workspacePathToHost`、`translateToolCallPaths`、`SANDBOX_ROUTED_TOOLS` | +| `index.ts` | 新增 `tool_call` 订阅 | +| `tests/path-translation.test.ts` | 新增单测 | + +## 不变的部分 + +- `paths.ts` 的 `hostToContainer` / `containerToHost` / `isContainerPath` 等均不改。 +- `read`/`write`/`edit`/`bash` 的工具路由逻辑不变。 +- `/skills` 与用户 mount 的翻译行为不变(本方案不触及)。 +- 配置结构(`sandbox.json`)不变。 diff --git a/pi-container-sandbox/index.ts b/pi-container-sandbox/index.ts index a6d7691..30f0312 100644 --- a/pi-container-sandbox/index.ts +++ b/pi-container-sandbox/index.ts @@ -27,6 +27,7 @@ import { } from "./src/paths"; import { createRuntime, deriveContainerName, type MountSpec } from "./src/runtime"; import { clearSbx, getSbx, type SbxSession, setSbx } from "./src/session"; +import { translateHostToolCall } from "./src/path-translation"; import { fixSkillLocations, parseAvailableSkills, skillsToMountSpecs } from "./src/skills"; export default function (pi: ExtensionAPI) { @@ -127,6 +128,12 @@ export default function (pi: ExtensionAPI) { }, }); + pi.on("tool_call", (event) => { + const sbx = getSbx(); + if (!sbx) return; + translateHostToolCall(event, sbx.hostCwd); + }); + pi.on("user_bash", () => { const sbx = getSbx(); if (!sbx) return; diff --git a/pi-container-sandbox/src/path-translation.ts b/pi-container-sandbox/src/path-translation.ts new file mode 100644 index 0000000..b542b17 --- /dev/null +++ b/pi-container-sandbox/src/path-translation.ts @@ -0,0 +1,68 @@ +import { resolve as resolvePath } from "node:path"; +import { CONTAINER_ROOT } from "./paths"; + +/** + * Tools the sandbox routes into the container itself. Their inputs are already + * translated (hostToContainer) by the tool ops, so they must NOT be rewritten here. + */ +export const SANDBOX_ROUTED_TOOLS: ReadonlySet = new Set(["bash", "read", "write", "edit"]); + +/** + * Maps a `/workspace` container path back to the host path it points to: + * /workspace → hostCwd + * /workspace/a/b → hostCwd/a/b + * /workspace/../x escaping hostCwd → unchanged (traversal guard) + * anything else → unchanged (relative paths, host absolute paths, free text, /skills, user mounts) + */ +export function workspacePathToHost(path: string, hostCwd: string): string { + if (path === CONTAINER_ROOT) return hostCwd; + if (path.startsWith(`${CONTAINER_ROOT}/`)) { + const resolved = resolvePath(hostCwd, path.slice(CONTAINER_ROOT.length + 1)); + if (resolved !== hostCwd && !resolved.startsWith(`${hostCwd}/`)) { + return path; + } + return resolved; + } + return path; +} + +/** + * Recursively walks a tool input and rewrites, in place, any string value that is a + * `/workspace` container path to its host equivalent. Non-object/non-array inputs are + * left untouched (tool inputs are always objects, so a bare top-level string is not a + * real case and is intentionally not translated). + */ +export function translateToolCallPaths(input: unknown, hostCwd: string): void { + if (Array.isArray(input)) { + for (let i = 0; i < input.length; i++) { + const value = input[i]; + if (typeof value === "string") input[i] = workspacePathToHost(value, hostCwd); + else translateToolCallPaths(value, hostCwd); + } + return; + } + if (input !== null && typeof input === "object") { + for (const key of Object.keys(input as Record)) { + const value = (input as Record)[key]; + if (typeof value === "string") { + (input as Record)[key] = workspacePathToHost(value, hostCwd); + } else { + translateToolCallPaths(value, hostCwd); + } + } + } +} + +export interface ToolCallEventLike { + toolName: string; + input: unknown; +} + +/** + * Entry point used by the `tool_call` handler: translate a host-running tool's input, + * skipping tools the sandbox routes into the container itself. + */ +export function translateHostToolCall(event: ToolCallEventLike, hostCwd: string): void { + if (SANDBOX_ROUTED_TOOLS.has(event.toolName)) return; + translateToolCallPaths(event.input, hostCwd); +} diff --git a/pi-container-sandbox/tests/config.test.ts b/pi-container-sandbox/tests/config.test.ts index e5e4964..06c3275 100644 --- a/pi-container-sandbox/tests/config.test.ts +++ b/pi-container-sandbox/tests/config.test.ts @@ -140,7 +140,7 @@ describe("detectEngine", () => { it("detects at least one runtime via resolveEngine(auto)", () => { const engine = resolveEngine("auto"); expect(["docker", "podman"]).toContain(engine); - }); + }, 30_000); }); describe("resolveEngine", () => { @@ -156,7 +156,7 @@ describe("resolveEngine", () => { it("throws when engine=podman but podman not available", () => { expect(["docker", "podman"]).toContain(resolveEngine("auto")); - }); + }, 30_000); }); describe("new runtime fields", () => { diff --git a/pi-container-sandbox/tests/path-translation.test.ts b/pi-container-sandbox/tests/path-translation.test.ts new file mode 100644 index 0000000..9b643c8 --- /dev/null +++ b/pi-container-sandbox/tests/path-translation.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { + SANDBOX_ROUTED_TOOLS, + translateHostToolCall, + translateToolCallPaths, + workspacePathToHost, +} from "../src/path-translation"; + +const HOST_CWD = "/home/user/proj"; + +describe("workspacePathToHost", () => { + it("maps /workspace to hostCwd", () => { + expect(workspacePathToHost("/workspace", HOST_CWD)).toBe(HOST_CWD); + }); + it("maps /workspace/foo.png to hostCwd/foo.png", () => { + expect(workspacePathToHost("/workspace/foo.png", HOST_CWD)).toBe("/home/user/proj/foo.png"); + }); + it("maps nested /workspace paths", () => { + expect(workspacePathToHost("/workspace/a/b/c.txt", HOST_CWD)).toBe("/home/user/proj/a/b/c.txt"); + }); + it("leaves relative paths unchanged", () => { + expect(workspacePathToHost("foo.png", HOST_CWD)).toBe("foo.png"); + expect(workspacePathToHost("./src/a.ts", HOST_CWD)).toBe("./src/a.ts"); + }); + it("leaves host absolute paths unchanged", () => { + expect(workspacePathToHost("/home/user/proj/foo.png", HOST_CWD)).toBe("/home/user/proj/foo.png"); + }); + it("leaves free text unchanged (no prefix match)", () => { + expect(workspacePathToHost("describe /workspace/foo", HOST_CWD)).toBe("describe /workspace/foo"); + }); + it("leaves /skills unchanged (scope is /workspace only)", () => { + expect(workspacePathToHost("/skills/foo/SKILL.md", HOST_CWD)).toBe("/skills/foo/SKILL.md"); + }); + it("refuses /workspace traversal that escapes hostCwd", () => { + expect(workspacePathToHost("/workspace/../etc/passwd", HOST_CWD)).toBe("/workspace/../etc/passwd"); + }); +}); + +describe("translateToolCallPaths", () => { + it("translates a top-level string field", () => { + const input = { image_path: "/workspace/foo.png" }; + translateToolCallPaths(input, HOST_CWD); + expect(input.image_path).toBe("/home/user/proj/foo.png"); + }); + it("translates nested objects and arrays", () => { + const input = { files: [{ path: "/workspace/a.png" }, "/workspace/b.png"] }; + translateToolCallPaths(input, HOST_CWD); + expect(input.files[0]).toMatchObject({ path: "/home/user/proj/a.png" }); + expect(input.files[1]).toBe("/home/user/proj/b.png"); + }); + it("leaves relative paths and free text untouched", () => { + const input = { prompt: "look at /workspace/x.png", path: "rel.png" }; + translateToolCallPaths(input, HOST_CWD); + expect(input.prompt).toBe("look at /workspace/x.png"); + expect(input.path).toBe("rel.png"); + }); + it("no-ops on non-object inputs without throwing", () => { + expect(() => translateToolCallPaths(null, HOST_CWD)).not.toThrow(); + expect(() => translateToolCallPaths(42, HOST_CWD)).not.toThrow(); + expect(() => translateToolCallPaths("just a string", HOST_CWD)).not.toThrow(); + }); +}); + +describe("translateHostToolCall", () => { + it("translates paths for non-sandboxed tools", () => { + const event = { toolName: "describe_image", input: { image_path: "/workspace/foo.png" } }; + translateHostToolCall(event, HOST_CWD); + expect(event.input.image_path).toBe("/home/user/proj/foo.png"); + }); + it("skips sandbox-routed tools", () => { + for (const name of ["bash", "read", "write", "edit"]) { + const event = { toolName: name, input: { path: "/workspace/foo.png" } }; + translateHostToolCall(event, HOST_CWD); + expect(event.input.path).toBe("/workspace/foo.png"); + } + }); +}); + +describe("SANDBOX_ROUTED_TOOLS", () => { + it("contains exactly bash/read/write/edit", () => { + expect([...SANDBOX_ROUTED_TOOLS].sort()).toEqual(["bash", "edit", "read", "write"]); + }); +});