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
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)[key];
if (typeof v === "string") (input as Record<string, unknown>)[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`)不变。
7 changes: 7 additions & 0 deletions pi-container-sandbox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
68 changes: 68 additions & 0 deletions pi-container-sandbox/src/path-translation.ts
Original file line number Diff line number Diff line change
@@ -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<string> = 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<string, unknown>)) {
const value = (input as Record<string, unknown>)[key];
if (typeof value === "string") {
(input as Record<string, unknown>)[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);
}
4 changes: 2 additions & 2 deletions pi-container-sandbox/tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand Down
83 changes: 83 additions & 0 deletions pi-container-sandbox/tests/path-translation.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});