|
1 | | -import test, { after, before } from "node:test"; |
2 | | -import fs from "node:fs"; |
| 1 | +import test from "node:test"; |
| 2 | +import assert from "node:assert/strict"; |
3 | 3 | import os from "node:os"; |
4 | 4 | import path from "node:path"; |
5 | | -import assert from "node:assert/strict"; |
6 | | -import { createEditTool, createWriteTool } from "@earendil-works/pi-coding-agent"; |
7 | | -import { createState, resetState } from "../../state.js"; |
| 5 | +import { createState } from "../../state.js"; |
8 | 6 | import { registerSpawnTool } from "../../spawn/index.js"; |
9 | | -import { createTestHarness, type TestHarness } from "../test-utils.js"; |
10 | | -import { canUseOsSandbox } from "../../os-sandbox.js"; |
11 | | - |
12 | | -type Handler = (args: any, ctx: any) => any; |
13 | | - |
14 | | -class MockPi { |
15 | | - commands = new Map<string, { description?: string; handler: Handler }>(); |
16 | | - tools = new Map<string, any>(); |
17 | | - handlers = new Map<string, Handler[]>(); |
18 | | - activeTools: string[] = []; |
19 | | - allToolNames: string[] | undefined; |
20 | | - toolSources = new Map<string, string>(); |
21 | | - |
22 | | - registerCommand(name: string, definition: { description?: string; handler: Handler }) { |
23 | | - this.commands.set(name, definition); |
24 | | - } |
25 | | - |
26 | | - registerTool(definition: any) { |
27 | | - this.tools.set(definition.name, definition); |
28 | | - } |
29 | | - |
30 | | - on(event: string, handler: Handler) { |
31 | | - const handlers = this.handlers.get(event) ?? []; |
32 | | - handlers.push(handler); |
33 | | - this.handlers.set(event, handlers); |
34 | | - } |
35 | | - |
36 | | - getActiveTools() { |
37 | | - return [...this.activeTools]; |
38 | | - } |
39 | | - |
40 | | - setActiveTools(tools: string[]) { |
41 | | - this.activeTools = [...tools]; |
42 | | - for (const tool of tools) { |
43 | | - if (!this.toolSources.has(tool)) { |
44 | | - this.toolSources.set(tool, "builtin"); |
45 | | - } |
46 | | - } |
47 | | - } |
48 | | - |
49 | | - setToolSource(name: string, source: string) { |
50 | | - this.toolSources.set(name, source); |
51 | | - } |
52 | | - |
53 | | - setAllTools(tools: string[]) { |
54 | | - this.allToolNames = [...tools]; |
55 | | - for (const tool of tools) { |
56 | | - if (!this.toolSources.has(tool)) { |
57 | | - this.toolSources.set(tool, "builtin"); |
58 | | - } |
59 | | - } |
60 | | - } |
61 | | - |
62 | | - getAllTools() { |
63 | | - return (this.allToolNames ?? this.activeTools).map((name) => ({ |
64 | | - name, |
65 | | - description: "", |
66 | | - parameters: {}, |
67 | | - sourceInfo: { |
68 | | - path: `<${this.toolSources.get(name) ?? "builtin"}:${name}>`, |
69 | | - source: this.toolSources.get(name) ?? "builtin", |
70 | | - scope: "temporary", |
71 | | - origin: "top-level", |
72 | | - }, |
73 | | - })); |
74 | | - } |
75 | | - |
76 | | - getThinkingLevel() { |
77 | | - return "medium"; |
78 | | - } |
79 | | -} |
80 | | - |
81 | | -let h: TestHarness; |
82 | | - |
83 | | -before(() => { |
84 | | - h = createTestHarness(); |
85 | | -}); |
86 | | - |
87 | | -after(() => { |
88 | | - h.teardown(); |
89 | | -}); |
90 | | - |
91 | | -// ── Readonly spawn propagation tests ────────────────────────────── |
92 | | - |
93 | | -test("spawn filters write and edit from child tools when readonly is on", async () => { |
94 | | - const pi = new MockPi(); |
95 | | - pi.setActiveTools(["read", "bash", "write", "edit", "spawn"]); |
| 7 | +import { createTestPI } from "./helpers.js"; |
| 8 | + |
| 9 | +async function spawnWithCapture( |
| 10 | + readonlyEnabled: boolean, |
| 11 | + inspect: (config: any, prompt: string) => Promise<void> | void, |
| 12 | + activeTools?: string[], |
| 13 | +) { |
| 14 | + const pi = createTestPI(); |
| 15 | + const tools = activeTools ?? ["read", "bash", "write", "edit", "spawn", "handoff"]; |
| 16 | + pi.setActiveTools(tools); |
| 17 | + pi.setAllTools(tools); |
96 | 18 | const state = createState(); |
97 | | - state.readonlyEnabled = true; |
| 19 | + state.readonlyEnabled = readonlyEnabled; |
98 | 20 |
|
99 | | - let seenTools: string[] = []; |
100 | | - const mockFactory = async (config: any) => { |
101 | | - seenTools = config.tools; |
| 21 | + const sessionFactory = async (config: any) => { |
102 | 22 | const session = { |
103 | 23 | messages: [] as any[], |
104 | 24 | prompt: async (prompt: string) => { |
105 | | - session.messages = [{ role: "assistant", content: [{ type: "text", text: "done" }] }]; |
106 | | - }, |
107 | | - abort: async () => {}, |
108 | | - getSessionStats: () => undefined, |
109 | | - }; |
110 | | - return { session: session as any }; |
111 | | - }; |
112 | | - |
113 | | - registerSpawnTool(pi as any, state, mockFactory as any); |
114 | | - await pi.tools.get("spawn").execute( |
115 | | - "spawn-1", |
116 | | - { prompt: "Do the task" }, |
117 | | - undefined, |
118 | | - undefined, |
119 | | - { model: { id: "mock-model" }, cwd: "/tmp" }, |
120 | | - ); |
121 | | - |
122 | | - assert.equal(seenTools.includes("write"), false, "write should be filtered"); |
123 | | - assert.equal(seenTools.includes("edit"), false, "edit should be filtered"); |
124 | | - assert.equal(seenTools.includes("read"), true, "read should be inherited"); |
125 | | - assert.equal(seenTools.includes("bash"), true, "bash should be inherited"); |
126 | | -}); |
127 | | - |
128 | | -test("spawn adds a readonly bash override that mirrors parent readonly bash policy", async () => { |
129 | | - const pi = new MockPi(); |
130 | | - pi.setActiveTools(["read", "bash", "spawn"]); |
131 | | - const state = createState(); |
132 | | - state.readonlyEnabled = true; |
133 | | - |
134 | | - let seenTools: string[] = []; |
135 | | - let seenCustomTools: any[] = []; |
136 | | - const mockFactory = async (config: any) => { |
137 | | - seenTools = config.tools; |
138 | | - seenCustomTools = config.customTools; |
139 | | - const session = { |
140 | | - messages: [] as any[], |
141 | | - prompt: async () => { |
142 | | - session.messages = [{ role: "assistant", content: [{ type: "text", text: "done" }] }]; |
| 25 | + await inspect(config, prompt); |
| 26 | + session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; |
143 | 27 | }, |
144 | 28 | abort: async () => {}, |
145 | 29 | getSessionStats: () => undefined, |
146 | 30 | }; |
147 | 31 | return { session: session as any }; |
148 | 32 | }; |
149 | 33 |
|
150 | | - registerSpawnTool(pi as any, state, mockFactory as any); |
| 34 | + registerSpawnTool(pi as any, state, sessionFactory as any); |
151 | 35 | await pi.tools.get("spawn").execute( |
152 | | - "spawn-1", |
153 | | - { prompt: "Do the task" }, |
| 36 | + "spawn-readonly", |
| 37 | + { prompt: "test" }, |
154 | 38 | undefined, |
155 | 39 | undefined, |
156 | | - { model: { id: "mock-model" }, cwd: "/tmp" }, |
157 | | - ); |
158 | | - |
159 | | - assert.equal(seenTools.includes("bash"), true, "bash should still be available"); |
160 | | - const bashTool = seenCustomTools.find((tool) => tool.name === "bash"); |
161 | | - assert.ok(bashTool, "readonly child should override bash"); |
162 | | - if (canUseOsSandbox()) { |
163 | | - // OS-level sandbox is available, but classifyBashCommand pre-blocks |
164 | | - // known dangerous commands at the spawnHook before the sandbox wraps. |
165 | | - await assert.rejects( |
166 | | - bashTool.execute("bash-1", { command: "sudo rm -rf /" }, undefined, undefined, {}), |
167 | | - /Readonly mode: command blocked/, |
168 | | - ); |
169 | | - } else { |
170 | | - // Fallback: classifyBashCommand blocks at the spawnHook |
171 | | - await assert.rejects( |
172 | | - bashTool.execute("bash-1", { command: "sudo rm -rf /" }, undefined, undefined, {}), |
173 | | - /Readonly mode: command blocked/, |
174 | | - ); |
175 | | - } |
176 | | - |
177 | | - // Also verify that a safe command is ALLOWED through the child bash tool |
178 | | - await assert.doesNotReject( |
179 | | - bashTool.execute("bash-2", { command: "ls -la" }, undefined, undefined, {}), |
180 | | - /Readonly mode: command blocked/, |
181 | | - ); |
182 | | - await assert.doesNotReject( |
183 | | - bashTool.execute("bash-3", { command: " " }, undefined, undefined, {}), |
184 | | - /Readonly mode: command blocked/, |
| 40 | + { model: { id: "mock-model" }, cwd: process.cwd() }, |
185 | 41 | ); |
186 | | -}); |
187 | | - |
188 | | -test("spawn non-readonly child can use inherited builtin write/edit", async () => { |
189 | | - const pi = new MockPi(); |
190 | | - pi.setActiveTools(["read", "bash", "write", "edit", "spawn"]); |
191 | | - const state = createState(); |
192 | | - state.readonlyEnabled = false; |
193 | | - |
194 | | - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-spawn-write-edit-")); |
195 | | - const childFile = path.join(tmpDir, "child.txt"); |
196 | | - |
197 | | - const mockFactory = async (config: any) => { |
198 | | - const session = { |
199 | | - messages: [] as any[], |
200 | | - prompt: async () => { |
201 | | - assert.equal(config.tools.includes("write"), true, "child should inherit builtin write"); |
202 | | - assert.equal(config.tools.includes("edit"), true, "child should inherit builtin edit"); |
203 | | - assert.equal(config.customTools.some((t: any) => t.name === "write"), false, "write should stay builtin"); |
204 | | - assert.equal(config.customTools.some((t: any) => t.name === "edit"), false, "edit should stay builtin"); |
| 42 | +} |
205 | 43 |
|
206 | | - const childWrite = createWriteTool(config.cwd); |
207 | | - const childEdit = createEditTool(config.cwd); |
208 | | - await childWrite.execute("child-write", { path: childFile, content: "alpha\nbeta\n" }, undefined, undefined, {}); |
209 | | - await childEdit.execute( |
210 | | - "child-edit", |
211 | | - { path: childFile, edits: [{ oldText: "beta", newText: "gamma" }] }, |
212 | | - undefined, |
213 | | - undefined, |
214 | | - {}, |
215 | | - ); |
216 | | - session.messages = [{ role: "assistant", content: [{ type: "text", text: fs.readFileSync(childFile, "utf8") }] }]; |
217 | | - }, |
218 | | - abort: async () => {}, |
219 | | - getSessionStats: () => undefined, |
220 | | - }; |
221 | | - return { session: session as any }; |
222 | | - }; |
| 44 | +test("readonly spawn child prompt tells the child it inherits readonly authority", async () => { |
| 45 | + let prompt = ""; |
223 | 46 |
|
224 | | - registerSpawnTool(pi as any, state, mockFactory as any); |
225 | | - try { |
226 | | - const result = await pi.tools.get("spawn").execute( |
227 | | - "spawn-1", |
228 | | - { prompt: "Write then edit the file" }, |
229 | | - undefined, |
230 | | - undefined, |
231 | | - { model: { id: "mock-model" }, cwd: tmpDir }, |
232 | | - ); |
| 47 | + await spawnWithCapture(true, (_config, childPrompt) => { |
| 48 | + prompt = childPrompt; |
| 49 | + }); |
233 | 50 |
|
234 | | - assert.equal(fs.readFileSync(childFile, "utf8"), "alpha\ngamma\n"); |
235 | | - assert.equal(result.content[0].text, "alpha\ngamma"); |
236 | | - } finally { |
237 | | - fs.rmSync(tmpDir, { recursive: true, force: true }); |
238 | | - } |
| 51 | + assert.match(prompt, /inherit readonly authority/i); |
| 52 | + assert.match(prompt, /readonly restrictions apply/i); |
239 | 53 | }); |
240 | 54 |
|
241 | | -test("spawn prompt includes readonly notice when enabled", async () => { |
242 | | - const pi = new MockPi(); |
243 | | - pi.setActiveTools(["read", "bash", "spawn"]); |
244 | | - const state = createState(); |
245 | | - state.readonlyEnabled = true; |
246 | | - |
247 | | - let seenPrompt = ""; |
248 | | - const mockFactory = async () => { |
249 | | - const session = { |
250 | | - messages: [] as any[], |
251 | | - prompt: async (prompt: string) => { |
252 | | - seenPrompt = prompt; |
253 | | - session.messages = [{ role: "assistant", content: [{ type: "text", text: "done" }] }]; |
254 | | - }, |
255 | | - abort: async () => {}, |
256 | | - getSessionStats: () => undefined, |
257 | | - }; |
258 | | - return { session: session as any }; |
259 | | - }; |
| 55 | +test("non-readonly spawn child prompt keeps normal authority", async () => { |
| 56 | + let prompt = ""; |
260 | 57 |
|
261 | | - registerSpawnTool(pi as any, state, mockFactory as any); |
262 | | - await pi.tools.get("spawn").execute( |
263 | | - "spawn-1", |
264 | | - { prompt: "Do the task" }, |
265 | | - undefined, |
266 | | - undefined, |
267 | | - { model: { id: "mock-model" }, cwd: "/tmp" }, |
268 | | - ); |
| 58 | + await spawnWithCapture(false, (_config, childPrompt) => { |
| 59 | + prompt = childPrompt; |
| 60 | + }); |
269 | 61 |
|
270 | | - assert.match(seenPrompt, /readonly authority/); |
271 | | - assert.match(seenPrompt, /Readonly restrictions apply/); |
272 | | - assert.doesNotMatch(seenPrompt, /same authority as the parent/); |
| 62 | + assert.match(prompt, /same authority as the parent/i); |
| 63 | + assert.doesNotMatch(prompt, /readonly restrictions apply/i); |
273 | 64 | }); |
274 | 65 |
|
275 | | -test("spawn prompt uses standard authority wording when readonly is off", async () => { |
276 | | - const pi = new MockPi(); |
277 | | - pi.setActiveTools(["read", "bash", "spawn"]); |
278 | | - const state = createState(); |
279 | | - state.readonlyEnabled = false; |
280 | | - |
281 | | - let seenPrompt = ""; |
282 | | - const mockFactory = async () => { |
283 | | - const session = { |
284 | | - messages: [] as any[], |
285 | | - prompt: async (prompt: string) => { |
286 | | - seenPrompt = prompt; |
287 | | - session.messages = [{ role: "assistant", content: [{ type: "text", text: "done" }] }]; |
288 | | - }, |
289 | | - abort: async () => {}, |
290 | | - getSessionStats: () => undefined, |
291 | | - }; |
292 | | - return { session: session as any }; |
293 | | - }; |
| 66 | +test("readonly spawn child bash tool blocks non-temp writes and allows temp writes", async () => { |
| 67 | + const outsideTemp = path.join(os.homedir(), "readonly-child-test"); |
| 68 | + const insideTemp = path.join(os.tmpdir(), `readonly-child-test-${Date.now()}`); |
294 | 69 |
|
295 | | - registerSpawnTool(pi as any, state, mockFactory as any); |
296 | | - await pi.tools.get("spawn").execute( |
297 | | - "spawn-1", |
298 | | - { prompt: "Do the task" }, |
299 | | - undefined, |
300 | | - undefined, |
301 | | - { model: { id: "mock-model" }, cwd: "/tmp" }, |
302 | | - ); |
| 70 | + await spawnWithCapture(true, async (config) => { |
| 71 | + const bashTool = config.customTools.find((tool: any) => tool.name === "bash"); |
303 | 72 |
|
304 | | - assert.match(seenPrompt, /same authority as the parent/); |
305 | | - assert.doesNotMatch(seenPrompt, /read-only authority/); |
306 | | - assert.doesNotMatch(seenPrompt, /Readonly restrictions apply/); |
| 73 | + assert.ok(bashTool, "readonly child should receive a bash tool"); |
| 74 | + await assert.rejects( |
| 75 | + () => bashTool.execute("bash-1", { command: `touch ${outsideTemp}` }), |
| 76 | + /Readonly mode:/, |
| 77 | + ); |
| 78 | + await assert.doesNotReject( |
| 79 | + () => bashTool.execute("bash-2", { command: `touch ${insideTemp} && rm ${insideTemp}` }), |
| 80 | + ); |
| 81 | + }); |
307 | 82 | }); |
0 commit comments