Skip to content

Commit 0e8b27b

Browse files
committed
test(sandbox): cover ro spawn workspace handoff
1 parent 26a6a96 commit 0e8b27b

File tree

4 files changed

+378
-4
lines changed

4 files changed

+378
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai
5353
- Telegram/network env-proxy: apply configured transport policy to proxied HTTPS dispatchers as well as direct `NO_PROXY` bypasses, so resolver-scoped IPv4 fallback and network settings work consistently for env-proxied Telegram traffic. (#40740) Thanks @sircrumpet.
5454
- Agents/memory flush: forward `memoryFlushWritePath` through `runEmbeddedPiAgent` so memory-triggered flush turns keep the append-only write guard without aborting before tool setup. Follows up on #38574. (#41761) Thanks @frankekn.
5555
- CI/CodeQL Swift toolchain: select Xcode 26.1 before installing Swift build tools so the CodeQL Swift job uses Swift tools 6.2 on `macos-latest`. (#41787) thanks @BunsDev.
56+
- Sandbox/subagents: pass the real configured workspace through `sessions_spawn` inheritance when a parent agent runs in a copied-workspace sandbox, so child `/agent` mounts point at the configured workspace instead of the parent sandbox copy. (#40757) Thanks @dsantoreis.
5657

5758
## 2026.3.8
5859

Lines changed: 373 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,373 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import type { Api, Model } from "@mariozechner/pi-ai";
5+
import type {
6+
AuthStorage,
7+
ExtensionContext,
8+
ModelRegistry,
9+
ToolDefinition,
10+
} from "@mariozechner/pi-coding-agent";
11+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12+
import { createHostSandboxFsBridge } from "../../test-helpers/host-sandbox-fs-bridge.js";
13+
import { createPiToolsSandboxContext } from "../../test-helpers/pi-tools-sandbox-context.js";
14+
15+
const hoisted = vi.hoisted(() => {
16+
const spawnSubagentDirectMock = vi.fn();
17+
const createAgentSessionMock = vi.fn();
18+
const sessionManagerOpenMock = vi.fn();
19+
const resolveSandboxContextMock = vi.fn();
20+
const subscribeEmbeddedPiSessionMock = vi.fn();
21+
const acquireSessionWriteLockMock = vi.fn();
22+
const sessionManager = {
23+
getLeafEntry: vi.fn(() => null),
24+
branch: vi.fn(),
25+
resetLeaf: vi.fn(),
26+
buildSessionContext: vi.fn(() => ({ messages: [] })),
27+
appendCustomEntry: vi.fn(),
28+
};
29+
return {
30+
spawnSubagentDirectMock,
31+
createAgentSessionMock,
32+
sessionManagerOpenMock,
33+
resolveSandboxContextMock,
34+
subscribeEmbeddedPiSessionMock,
35+
acquireSessionWriteLockMock,
36+
sessionManager,
37+
};
38+
});
39+
40+
vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
41+
const actual = await importOriginal<typeof import("@mariozechner/pi-coding-agent")>();
42+
43+
return {
44+
...actual,
45+
createAgentSession: (...args: unknown[]) => hoisted.createAgentSessionMock(...args),
46+
DefaultResourceLoader: class {
47+
async reload() {}
48+
},
49+
SessionManager: {
50+
open: (...args: unknown[]) => hoisted.sessionManagerOpenMock(...args),
51+
} as unknown as typeof actual.SessionManager,
52+
};
53+
});
54+
55+
vi.mock("../../subagent-spawn.js", () => ({
56+
SUBAGENT_SPAWN_MODES: ["run", "session"],
57+
spawnSubagentDirect: (...args: unknown[]) => hoisted.spawnSubagentDirectMock(...args),
58+
}));
59+
60+
vi.mock("../../sandbox.js", () => ({
61+
resolveSandboxContext: (...args: unknown[]) => hoisted.resolveSandboxContextMock(...args),
62+
}));
63+
64+
vi.mock("../../session-tool-result-guard-wrapper.js", () => ({
65+
guardSessionManager: () => hoisted.sessionManager,
66+
}));
67+
68+
vi.mock("../../pi-embedded-subscribe.js", () => ({
69+
subscribeEmbeddedPiSession: (...args: unknown[]) =>
70+
hoisted.subscribeEmbeddedPiSessionMock(...args),
71+
}));
72+
73+
vi.mock("../../../plugins/hook-runner-global.js", () => ({
74+
getGlobalHookRunner: () => undefined,
75+
}));
76+
77+
vi.mock("../../../infra/machine-name.js", () => ({
78+
getMachineDisplayName: async () => "test-host",
79+
}));
80+
81+
vi.mock("../../../infra/net/undici-global-dispatcher.js", () => ({
82+
ensureGlobalUndiciStreamTimeouts: () => {},
83+
}));
84+
85+
vi.mock("../../bootstrap-files.js", () => ({
86+
makeBootstrapWarn: () => () => {},
87+
resolveBootstrapContextForRun: async () => ({ bootstrapFiles: [], contextFiles: [] }),
88+
}));
89+
90+
vi.mock("../../skills.js", () => ({
91+
applySkillEnvOverrides: () => () => {},
92+
applySkillEnvOverridesFromSnapshot: () => () => {},
93+
resolveSkillsPromptForRun: () => "",
94+
}));
95+
96+
vi.mock("../skills-runtime.js", () => ({
97+
resolveEmbeddedRunSkillEntries: () => ({
98+
shouldLoadSkillEntries: false,
99+
skillEntries: undefined,
100+
}),
101+
}));
102+
103+
vi.mock("../../docs-path.js", () => ({
104+
resolveOpenClawDocsPath: async () => undefined,
105+
}));
106+
107+
vi.mock("../../pi-project-settings.js", () => ({
108+
createPreparedEmbeddedPiSettingsManager: () => ({}),
109+
}));
110+
111+
vi.mock("../../pi-settings.js", () => ({
112+
applyPiAutoCompactionGuard: () => {},
113+
}));
114+
115+
vi.mock("../extensions.js", () => ({
116+
buildEmbeddedExtensionFactories: () => [],
117+
}));
118+
119+
vi.mock("../google.js", () => ({
120+
logToolSchemasForGoogle: () => {},
121+
sanitizeSessionHistory: async ({ messages }: { messages: unknown[] }) => messages,
122+
sanitizeToolsForGoogle: ({ tools }: { tools: unknown[] }) => tools,
123+
}));
124+
125+
vi.mock("../../session-file-repair.js", () => ({
126+
repairSessionFileIfNeeded: async () => {},
127+
}));
128+
129+
vi.mock("../session-manager-cache.js", () => ({
130+
prewarmSessionFile: async () => {},
131+
trackSessionManagerAccess: () => {},
132+
}));
133+
134+
vi.mock("../session-manager-init.js", () => ({
135+
prepareSessionManagerForRun: async () => {},
136+
}));
137+
138+
vi.mock("../../session-write-lock.js", () => ({
139+
acquireSessionWriteLock: (...args: unknown[]) => hoisted.acquireSessionWriteLockMock(...args),
140+
resolveSessionLockMaxHoldFromTimeout: () => 1,
141+
}));
142+
143+
vi.mock("../tool-result-context-guard.js", () => ({
144+
installToolResultContextGuard: () => () => {},
145+
}));
146+
147+
vi.mock("../wait-for-idle-before-flush.js", () => ({
148+
flushPendingToolResultsAfterIdle: async () => {},
149+
}));
150+
151+
vi.mock("../runs.js", () => ({
152+
setActiveEmbeddedRun: () => {},
153+
clearActiveEmbeddedRun: () => {},
154+
}));
155+
156+
vi.mock("./images.js", () => ({
157+
detectAndLoadPromptImages: async () => ({ images: [] }),
158+
}));
159+
160+
vi.mock("../../system-prompt-params.js", () => ({
161+
buildSystemPromptParams: () => ({
162+
runtimeInfo: {},
163+
userTimezone: "UTC",
164+
userTime: "00:00",
165+
userTimeFormat: "24h",
166+
}),
167+
}));
168+
169+
vi.mock("../../system-prompt-report.js", () => ({
170+
buildSystemPromptReport: () => undefined,
171+
}));
172+
173+
vi.mock("../system-prompt.js", () => ({
174+
applySystemPromptOverrideToSession: () => {},
175+
buildEmbeddedSystemPrompt: () => "system prompt",
176+
createSystemPromptOverride: (prompt: string) => () => prompt,
177+
}));
178+
179+
vi.mock("../extra-params.js", () => ({
180+
applyExtraParamsToAgent: () => {},
181+
}));
182+
183+
vi.mock("../../openai-ws-stream.js", () => ({
184+
createOpenAIWebSocketStreamFn: vi.fn(),
185+
releaseWsSession: () => {},
186+
}));
187+
188+
vi.mock("../../anthropic-payload-log.js", () => ({
189+
createAnthropicPayloadLogger: () => undefined,
190+
}));
191+
192+
vi.mock("../../cache-trace.js", () => ({
193+
createCacheTrace: () => undefined,
194+
}));
195+
196+
vi.mock("../../model-selection.js", async (importOriginal) => {
197+
const actual = await importOriginal<typeof import("../../model-selection.js")>();
198+
199+
return {
200+
...actual,
201+
normalizeProviderId: (providerId?: string) => providerId?.trim().toLowerCase() ?? "",
202+
resolveDefaultModelForAgent: () => ({ provider: "openai", model: "gpt-test" }),
203+
};
204+
});
205+
206+
const { runEmbeddedAttempt } = await import("./attempt.js");
207+
208+
type MutableSession = {
209+
sessionId: string;
210+
messages: unknown[];
211+
isCompacting: boolean;
212+
isStreaming: boolean;
213+
agent: {
214+
streamFn?: unknown;
215+
replaceMessages: (messages: unknown[]) => void;
216+
};
217+
prompt: (prompt: string, options?: { images?: unknown[] }) => Promise<void>;
218+
abort: () => Promise<void>;
219+
dispose: () => void;
220+
steer: (text: string) => Promise<void>;
221+
};
222+
223+
function createSubscriptionMock() {
224+
return {
225+
assistantTexts: [] as string[],
226+
toolMetas: [] as Array<{ toolName: string; meta?: string }>,
227+
unsubscribe: () => {},
228+
waitForCompactionRetry: async () => {},
229+
getMessagingToolSentTexts: () => [] as string[],
230+
getMessagingToolSentMediaUrls: () => [] as string[],
231+
getMessagingToolSentTargets: () => [] as unknown[],
232+
getSuccessfulCronAdds: () => 0,
233+
didSendViaMessagingTool: () => false,
234+
didSendDeterministicApprovalPrompt: () => false,
235+
getLastToolError: () => undefined,
236+
getUsageTotals: () => undefined,
237+
getCompactionCount: () => 0,
238+
isCompacting: () => false,
239+
};
240+
}
241+
242+
describe("runEmbeddedAttempt sessions_spawn workspace inheritance", () => {
243+
const tempPaths: string[] = [];
244+
245+
beforeEach(() => {
246+
hoisted.spawnSubagentDirectMock.mockReset().mockResolvedValue({
247+
status: "accepted",
248+
childSessionKey: "agent:main:subagent:child",
249+
runId: "run-child",
250+
});
251+
hoisted.createAgentSessionMock.mockReset();
252+
hoisted.sessionManagerOpenMock.mockReset().mockReturnValue(hoisted.sessionManager);
253+
hoisted.resolveSandboxContextMock.mockReset();
254+
hoisted.subscribeEmbeddedPiSessionMock.mockReset().mockImplementation(createSubscriptionMock);
255+
hoisted.acquireSessionWriteLockMock.mockReset().mockResolvedValue({
256+
release: async () => {},
257+
});
258+
hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null);
259+
hoisted.sessionManager.branch.mockReset();
260+
hoisted.sessionManager.resetLeaf.mockReset();
261+
hoisted.sessionManager.buildSessionContext.mockReset().mockReturnValue({ messages: [] });
262+
hoisted.sessionManager.appendCustomEntry.mockReset();
263+
});
264+
265+
afterEach(async () => {
266+
while (tempPaths.length > 0) {
267+
const target = tempPaths.pop();
268+
if (target) {
269+
await fs.rm(target, { recursive: true, force: true });
270+
}
271+
}
272+
});
273+
274+
it("passes the real workspace to sessions_spawn when workspaceAccess is ro", async () => {
275+
const realWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-real-workspace-"));
276+
const sandboxWorkspace = await fs.mkdtemp(
277+
path.join(os.tmpdir(), "openclaw-sandbox-workspace-"),
278+
);
279+
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-dir-"));
280+
tempPaths.push(realWorkspace, sandboxWorkspace, agentDir);
281+
282+
hoisted.resolveSandboxContextMock.mockResolvedValue(
283+
createPiToolsSandboxContext({
284+
workspaceDir: sandboxWorkspace,
285+
agentWorkspaceDir: realWorkspace,
286+
workspaceAccess: "ro",
287+
fsBridge: createHostSandboxFsBridge(sandboxWorkspace),
288+
tools: { allow: ["sessions_spawn"], deny: [] },
289+
sessionKey: "agent:main:main",
290+
}),
291+
);
292+
293+
hoisted.createAgentSessionMock.mockImplementation(
294+
async (params: { customTools: ToolDefinition[] }) => {
295+
const session: MutableSession = {
296+
sessionId: "embedded-session",
297+
messages: [],
298+
isCompacting: false,
299+
isStreaming: false,
300+
agent: {
301+
replaceMessages: (messages: unknown[]) => {
302+
session.messages = [...messages];
303+
},
304+
},
305+
prompt: async () => {
306+
const spawnTool = params.customTools.find((tool) => tool.name === "sessions_spawn");
307+
expect(spawnTool).toBeDefined();
308+
if (!spawnTool) {
309+
throw new Error("missing sessions_spawn tool");
310+
}
311+
await spawnTool.execute(
312+
"call-sessions-spawn",
313+
{ task: "inspect workspace" },
314+
undefined,
315+
undefined,
316+
{} as unknown as ExtensionContext,
317+
);
318+
},
319+
abort: async () => {},
320+
dispose: () => {},
321+
steer: async () => {},
322+
};
323+
324+
return { session };
325+
},
326+
);
327+
328+
const model = {
329+
api: "openai-completions",
330+
provider: "openai",
331+
compat: {},
332+
contextWindow: 8192,
333+
input: ["text"],
334+
} as unknown as Model<Api>;
335+
336+
const result = await runEmbeddedAttempt({
337+
sessionId: "embedded-session",
338+
sessionKey: "agent:main:main",
339+
sessionFile: path.join(realWorkspace, "session.jsonl"),
340+
workspaceDir: realWorkspace,
341+
agentDir,
342+
config: {},
343+
prompt: "spawn a child session",
344+
timeoutMs: 10_000,
345+
runId: "run-1",
346+
provider: "openai",
347+
modelId: "gpt-test",
348+
model,
349+
authStorage: {} as AuthStorage,
350+
modelRegistry: {} as ModelRegistry,
351+
thinkLevel: "off",
352+
senderIsOwner: true,
353+
disableMessageTool: true,
354+
});
355+
356+
expect(result.promptError).toBeNull();
357+
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledTimes(1);
358+
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith(
359+
expect.objectContaining({
360+
task: "inspect workspace",
361+
}),
362+
expect.objectContaining({
363+
workspaceDir: realWorkspace,
364+
}),
365+
);
366+
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalledWith(
367+
expect.anything(),
368+
expect.objectContaining({
369+
workspaceDir: sandboxWorkspace,
370+
}),
371+
);
372+
});
373+
});

src/agents/pi-embedded-runner/run/attempt.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -869,8 +869,8 @@ export async function runEmbeddedAttempt(
869869
runId: params.runId,
870870
agentDir,
871871
workspaceDir: effectiveWorkspace,
872-
// When running inside a read-only sandbox, effectiveWorkspace is the sandbox copy.
873-
// Spawned subagents should inherit the real workspace, not the temporary sandbox dir.
872+
// When sandboxing uses a copied workspace (`ro` or `none`), effectiveWorkspace points
873+
// at the sandbox copy. Spawned subagents should inherit the real workspace instead.
874874
spawnWorkspaceDir:
875875
sandbox?.enabled && sandbox.workspaceAccess !== "rw" ? resolvedWorkspace : undefined,
876876
config: params.config,

0 commit comments

Comments
 (0)