forked from bai/curriculum-project-hub
a2c8fa8eaf
将 provider/runner/tools 三件手搓轮子替换为 AI SDK v7 的 generateText + tool:
- 删 openrouter-provider.ts(翻译层消失)、provider.ts 自定义消息类型
- runner.ts 循环体改为 generateText({stopWhen: stepCountIs}),保留 RunRequest/RunResult 公共签名
- tools.ts ToolRegistry 改为 ToolFactory + build(ctx, names?) 按 role 白名单构建 tools record
- workspace/cph/feishu 工具改写为 tool() 定义,execute 闭包捕获 per-run ToolContext
- transcript.ts 改为 ModelMessage[] JSONL(0.0.0 cutover,无迁移)
- server.ts/trigger.ts 接 modelFactory + toolWhitelist
- 测试:helpers 写 MockLanguageModel implements LanguageModelV4;5 个测试改写 + 新增 runner.test.ts
- 移除未使用的 openai 依赖
- ADR-0017 Consequences 同步:loop 委托给 AI SDK,provider seam 是 LanguageModel
tsc 干净,54 tests 全过。
89 lines
3.8 KiB
TypeScript
89 lines
3.8 KiB
TypeScript
import { describe, it, expect, beforeEach } from "vitest";
|
|
import { mkdtemp, writeFile, mkdir } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { ToolRegistry, type ToolContext } from "../../src/agent/tools.js";
|
|
import { readFileTool, writeFileTool, listFilesTool, PathEscape } from "../../src/agent/workspace.js";
|
|
|
|
describe("workspace path confinement", () => {
|
|
let ws: string;
|
|
let tools: ToolRegistry;
|
|
|
|
beforeEach(async () => {
|
|
ws = await mkdtemp(join(tmpdir(), "hub-unit-"));
|
|
tools = new ToolRegistry();
|
|
tools.register("read_file", readFileTool);
|
|
tools.register("write_file", writeFileTool);
|
|
tools.register("list_files", listFilesTool);
|
|
await mkdir(join(ws, "sub"));
|
|
await writeFile(join(ws, "a.txt"), "hello");
|
|
await writeFile(join(ws, "sub", "b.txt"), "world");
|
|
});
|
|
|
|
const ctx = () => ({ runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws });
|
|
|
|
it("reads a file inside the workspace", async () => {
|
|
expect(await executeTool(tools, "read_file", { path: "a.txt" }, ctx())).toBe("hello");
|
|
});
|
|
|
|
it("reads nested files", async () => {
|
|
expect(await executeTool(tools, "read_file", { path: "sub/b.txt" }, ctx())).toBe("world");
|
|
});
|
|
|
|
it("rejects .. escapes as error JSON, not a throw", async () => {
|
|
const out = await executeTool(tools, "read_file", { path: "../../etc/passwd" }, ctx());
|
|
expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace");
|
|
});
|
|
|
|
it("rejects absolute paths outside the workspace", async () => {
|
|
const out = await executeTool(tools, "read_file", { path: "/etc/passwd" }, ctx());
|
|
expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace");
|
|
});
|
|
|
|
it("writes a file and reads it back", async () => {
|
|
await executeTool(tools, "write_file", { path: "sub/c.txt", content: "new" }, ctx());
|
|
expect(await executeTool(tools, "read_file", { path: "sub/c.txt" }, ctx())).toBe("new");
|
|
});
|
|
|
|
it("creates parent dirs on write", async () => {
|
|
await executeTool(tools, "write_file", { path: "deep/nested/d.txt", content: "x" }, ctx());
|
|
expect(await executeTool(tools, "read_file", { path: "deep/nested/d.txt" }, ctx())).toBe("x");
|
|
});
|
|
|
|
it("lists the workspace root", async () => {
|
|
const out = await executeTool(tools, "list_files", {}, ctx());
|
|
const entries = JSON.parse(out) as Array<{ name: string; kind: string }>;
|
|
expect(entries).toContainEqual({ name: "a.txt", kind: "file" });
|
|
expect(entries).toContainEqual({ name: "sub", kind: "dir" });
|
|
});
|
|
|
|
it("lists a subdirectory", async () => {
|
|
const out = await executeTool(tools, "list_files", { path: "sub" }, ctx());
|
|
const entries = JSON.parse(out) as Array<{ name: string; kind: string }>;
|
|
expect(entries).toContainEqual({ name: "b.txt", kind: "file" });
|
|
});
|
|
|
|
it("PathEscape is an Error subclass", () => {
|
|
expect(new PathEscape("../x", "/ws")).toBeInstanceOf(Error);
|
|
expect(new PathEscape("../x", "/ws").name).toBe("PathEscape");
|
|
});
|
|
|
|
it("read of nonexistent file returns error JSON", async () => {
|
|
const out = await executeTool(tools, "read_file", { path: "nope.txt" }, ctx());
|
|
expect((JSON.parse(out) as { error: string }).error).toBeTruthy();
|
|
});
|
|
|
|
it("write with .. escape returns error JSON", async () => {
|
|
const out = await executeTool(tools, "write_file", { path: "../escape.txt", content: "x" }, ctx());
|
|
expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace");
|
|
});
|
|
});
|
|
|
|
async function executeTool(tools: ToolRegistry, name: string, args: unknown, ctx: ToolContext): Promise<string> {
|
|
const def = tools.build(ctx)[name];
|
|
if (def?.execute === undefined) {
|
|
throw new Error(`missing executable tool: ${name}`);
|
|
}
|
|
return def.execute(args, { toolCallId: "call", messages: [], context: undefined }) as Promise<string>;
|
|
}
|