Skip to content

Commit 2bf4843

Browse files
committed
Simplify readonly-spawn test with shared spawnWithCapture helper
1 parent b764a44 commit 2bf4843

1 file changed

Lines changed: 51 additions & 276 deletions

File tree

tests/unit/readonly-spawn.test.ts

Lines changed: 51 additions & 276 deletions
Original file line numberDiff line numberDiff line change
@@ -1,307 +1,82 @@
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";
33
import os from "node:os";
44
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";
86
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);
9618
const state = createState();
97-
state.readonlyEnabled = true;
19+
state.readonlyEnabled = readonlyEnabled;
9820

99-
let seenTools: string[] = [];
100-
const mockFactory = async (config: any) => {
101-
seenTools = config.tools;
21+
const sessionFactory = async (config: any) => {
10222
const session = {
10323
messages: [] as any[],
10424
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" }] }];
14327
},
14428
abort: async () => {},
14529
getSessionStats: () => undefined,
14630
};
14731
return { session: session as any };
14832
};
14933

150-
registerSpawnTool(pi as any, state, mockFactory as any);
34+
registerSpawnTool(pi as any, state, sessionFactory as any);
15135
await pi.tools.get("spawn").execute(
152-
"spawn-1",
153-
{ prompt: "Do the task" },
36+
"spawn-readonly",
37+
{ prompt: "test" },
15438
undefined,
15539
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() },
18541
);
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+
}
20543

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 = "";
22346

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+
});
23350

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);
23953
});
24054

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 = "";
26057

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+
});
26961

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);
27364
});
27465

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()}`);
29469

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");
30372

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+
});
30782
});

0 commit comments

Comments
 (0)