Skip to content

Commit 41bbe50

Browse files
committed
✨ 实现 OPFS 工作区文件系统工具及 CAT.agent.opfs 用户脚本 API
- 新增 opfs_write/opfs_read/opfs_list/opfs_delete 四个 Agent 内置工具 - 所有操作限制在 agents/workspace/ 下,sanitizePath 禁止路径穿越 - 新增 CAT.agent.opfs 用户脚本 API(Content + SW 双层),写操作需持久化授权 - 新增 CATAgentOPFS 类型声明及 ESLint grant 兼容 - 新增 27 个单元测试覆盖工具核心、API 注入、Service 路由三层
1 parent 426f742 commit 41bbe50

12 files changed

Lines changed: 905 additions & 0 deletions

File tree

packages/eslint/compat-grant.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const compatMap = {
1111
"CAT.agent.skills": [{ type: "scriptcat", versionConstraint: ">=1.4.0-beta" }],
1212
"CAT.agent.dom": [{ type: "scriptcat", versionConstraint: ">=1.4.0-beta" }],
1313
"CAT.agent.task": [{ type: "scriptcat", versionConstraint: ">=1.4.0-beta" }],
14+
"CAT.agent.opfs": [{ type: "scriptcat", versionConstraint: ">=1.4.0-beta" }],
1415
...compat_grant.compatMap,
1516
};
1617

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import { describe, it, expect, beforeEach, vi } from "vitest";
2+
import { createOPFSTools, sanitizePath } from "./opfs_tools";
3+
4+
// ---- In-memory OPFS mock ----
5+
6+
type FSNode = { kind: "file"; content: string } | { kind: "directory"; children: Map<string, FSNode> };
7+
8+
function createMockFS() {
9+
const root: FSNode = { kind: "directory", children: new Map() };
10+
11+
function navigate(path: string[]): FSNode {
12+
let node = root;
13+
for (const seg of path) {
14+
if (node.kind !== "directory") throw new DOMException("Not a directory", "TypeMismatchError");
15+
const child = node.children.get(seg);
16+
if (!child) throw new DOMException(`"${seg}" not found`, "NotFoundError");
17+
node = child;
18+
}
19+
return node;
20+
}
21+
22+
function makeDirectoryHandle(node: FSNode & { kind: "directory" }, name = ""): FileSystemDirectoryHandle {
23+
const handle: any = {
24+
kind: "directory",
25+
name,
26+
getDirectoryHandle(childName: string, opts?: { create?: boolean }) {
27+
let child = node.children.get(childName);
28+
if (!child) {
29+
if (opts?.create) {
30+
child = { kind: "directory", children: new Map() };
31+
node.children.set(childName, child);
32+
} else {
33+
throw new DOMException(`"${childName}" not found`, "NotFoundError");
34+
}
35+
}
36+
if (child.kind !== "directory") throw new DOMException("Not a directory", "TypeMismatchError");
37+
return makeDirectoryHandle(child, childName);
38+
},
39+
getFileHandle(childName: string, opts?: { create?: boolean }) {
40+
let child = node.children.get(childName);
41+
if (!child) {
42+
if (opts?.create) {
43+
child = { kind: "file", content: "" };
44+
node.children.set(childName, child);
45+
} else {
46+
throw new DOMException(`"${childName}" not found`, "NotFoundError");
47+
}
48+
}
49+
if (child.kind !== "file") throw new DOMException("Not a file", "TypeMismatchError");
50+
return makeFileHandle(child, childName);
51+
},
52+
removeEntry(childName: string) {
53+
if (!node.children.has(childName)) {
54+
throw new DOMException(`"${childName}" not found`, "NotFoundError");
55+
}
56+
node.children.delete(childName);
57+
},
58+
async *[Symbol.asyncIterator]() {
59+
for (const [n, c] of node.children) {
60+
if (c.kind === "file") {
61+
yield [n, makeFileHandle(c, n)];
62+
} else {
63+
yield [n, makeDirectoryHandle(c, n)];
64+
}
65+
}
66+
},
67+
};
68+
return handle;
69+
}
70+
71+
function makeFileHandle(node: FSNode & { kind: "file" }, name: string): FileSystemFileHandle {
72+
const handle: any = {
73+
kind: "file",
74+
name,
75+
async getFile() {
76+
return new Blob([node.content], { type: "text/plain" });
77+
},
78+
async createWritable() {
79+
let buffer = "";
80+
return {
81+
async write(data: string) {
82+
buffer += data;
83+
},
84+
async close() {
85+
node.content = buffer;
86+
},
87+
};
88+
},
89+
};
90+
return handle;
91+
}
92+
93+
return {
94+
root,
95+
navigate,
96+
rootHandle: makeDirectoryHandle(root, ""),
97+
};
98+
}
99+
100+
// Extend Blob with text() for vitest (jsdom may not have it)
101+
if (!Blob.prototype.text) {
102+
Blob.prototype.text = async function () {
103+
return new Promise((resolve) => {
104+
const reader = new FileReader();
105+
reader.onload = () => resolve(reader.result as string);
106+
reader.readAsText(this);
107+
});
108+
};
109+
}
110+
111+
describe("sanitizePath", () => {
112+
it("should strip leading slashes", () => {
113+
expect(sanitizePath("/foo/bar.txt")).toBe("foo/bar.txt");
114+
expect(sanitizePath("///a/b")).toBe("a/b");
115+
});
116+
117+
it("should reject .. segments", () => {
118+
expect(() => sanitizePath("../etc/passwd")).toThrow('".." is not allowed');
119+
expect(() => sanitizePath("foo/../../bar")).toThrow('".." is not allowed');
120+
});
121+
122+
it("should handle normal paths", () => {
123+
expect(sanitizePath("notes/todo.txt")).toBe("notes/todo.txt");
124+
expect(sanitizePath("file.txt")).toBe("file.txt");
125+
});
126+
127+
it("should collapse empty segments", () => {
128+
expect(sanitizePath("a//b///c")).toBe("a/b/c");
129+
});
130+
});
131+
132+
describe("opfs_tools", () => {
133+
let mockFS: ReturnType<typeof createMockFS>;
134+
135+
beforeEach(() => {
136+
mockFS = createMockFS();
137+
vi.stubGlobal("navigator", {
138+
storage: {
139+
getDirectory: vi.fn().mockResolvedValue(mockFS.rootHandle),
140+
},
141+
});
142+
});
143+
144+
function getTool(name: string) {
145+
const { tools } = createOPFSTools();
146+
return tools.find((t) => t.definition.name === name)!;
147+
}
148+
149+
it("should create 4 tools", () => {
150+
const { tools } = createOPFSTools();
151+
expect(tools).toHaveLength(4);
152+
expect(tools.map((t) => t.definition.name)).toEqual(["opfs_write", "opfs_read", "opfs_list", "opfs_delete"]);
153+
});
154+
155+
describe("opfs_write + opfs_read", () => {
156+
it("should write and read a file", async () => {
157+
const write = getTool("opfs_write");
158+
const read = getTool("opfs_read");
159+
160+
const writeResult = JSON.parse(
161+
(await write.executor.execute({ path: "hello.txt", content: "Hello!" })) as string
162+
);
163+
expect(writeResult.path).toBe("hello.txt");
164+
expect(writeResult.size).toBe(6);
165+
166+
const readResult = JSON.parse((await read.executor.execute({ path: "hello.txt" })) as string);
167+
expect(readResult.path).toBe("hello.txt");
168+
expect(readResult.content).toBe("Hello!");
169+
expect(readResult.size).toBe(6);
170+
});
171+
172+
it("should create nested directories automatically", async () => {
173+
const write = getTool("opfs_write");
174+
const read = getTool("opfs_read");
175+
176+
await write.executor.execute({ path: "a/b/c.txt", content: "deep" });
177+
const result = JSON.parse((await read.executor.execute({ path: "a/b/c.txt" })) as string);
178+
expect(result.content).toBe("deep");
179+
});
180+
181+
it("should overwrite existing file", async () => {
182+
const write = getTool("opfs_write");
183+
const read = getTool("opfs_read");
184+
185+
await write.executor.execute({ path: "f.txt", content: "v1" });
186+
await write.executor.execute({ path: "f.txt", content: "v2" });
187+
const result = JSON.parse((await read.executor.execute({ path: "f.txt" })) as string);
188+
expect(result.content).toBe("v2");
189+
});
190+
191+
it("should strip leading slashes from path", async () => {
192+
const write = getTool("opfs_write");
193+
const read = getTool("opfs_read");
194+
195+
await write.executor.execute({ path: "/leading.txt", content: "ok" });
196+
const result = JSON.parse((await read.executor.execute({ path: "leading.txt" })) as string);
197+
expect(result.content).toBe("ok");
198+
});
199+
200+
it("should reject .. in path", async () => {
201+
const write = getTool("opfs_write");
202+
await expect(write.executor.execute({ path: "../escape.txt", content: "bad" })).rejects.toThrow(
203+
'".." is not allowed'
204+
);
205+
});
206+
});
207+
208+
describe("opfs_read errors", () => {
209+
it("should throw for non-existent file", async () => {
210+
const read = getTool("opfs_read");
211+
await expect(read.executor.execute({ path: "nope.txt" })).rejects.toThrow();
212+
});
213+
});
214+
215+
describe("opfs_list", () => {
216+
it("should list files and directories", async () => {
217+
const write = getTool("opfs_write");
218+
const list = getTool("opfs_list");
219+
220+
await write.executor.execute({ path: "file1.txt", content: "a" });
221+
await write.executor.execute({ path: "sub/file2.txt", content: "bb" });
222+
223+
const result = JSON.parse((await list.executor.execute({})) as string);
224+
expect(result).toHaveLength(2);
225+
226+
const fileEntry = result.find((e: any) => e.name === "file1.txt");
227+
expect(fileEntry).toEqual({ name: "file1.txt", type: "file", size: 1 });
228+
229+
const dirEntry = result.find((e: any) => e.name === "sub");
230+
expect(dirEntry).toEqual({ name: "sub", type: "directory" });
231+
});
232+
233+
it("should list subdirectory contents", async () => {
234+
const write = getTool("opfs_write");
235+
const list = getTool("opfs_list");
236+
237+
await write.executor.execute({ path: "dir/a.txt", content: "aaa" });
238+
await write.executor.execute({ path: "dir/b.txt", content: "bb" });
239+
240+
const result = JSON.parse((await list.executor.execute({ path: "dir" })) as string);
241+
expect(result).toHaveLength(2);
242+
});
243+
244+
it("should return empty array for empty directory", async () => {
245+
const list = getTool("opfs_list");
246+
const result = JSON.parse((await list.executor.execute({})) as string);
247+
expect(result).toEqual([]);
248+
});
249+
});
250+
251+
describe("opfs_delete", () => {
252+
it("should delete a file", async () => {
253+
const write = getTool("opfs_write");
254+
const del = getTool("opfs_delete");
255+
const read = getTool("opfs_read");
256+
257+
await write.executor.execute({ path: "temp.txt", content: "bye" });
258+
const result = JSON.parse((await del.executor.execute({ path: "temp.txt" })) as string);
259+
expect(result).toEqual({ success: true });
260+
261+
await expect(read.executor.execute({ path: "temp.txt" })).rejects.toThrow();
262+
});
263+
264+
it("should throw for non-existent path", async () => {
265+
const del = getTool("opfs_delete");
266+
await expect(del.executor.execute({ path: "ghost.txt" })).rejects.toThrow();
267+
});
268+
269+
it("should reject .. in path", async () => {
270+
const del = getTool("opfs_delete");
271+
await expect(del.executor.execute({ path: "a/../../b" })).rejects.toThrow('".." is not allowed');
272+
});
273+
});
274+
});

0 commit comments

Comments
 (0)