From 3495563cfe89ab36e72d022ed4e3678c453c0b1e Mon Sep 17 00:00:00 2001 From: Daniel Reis Date: Tue, 10 Mar 2026 08:12:50 +0100 Subject: [PATCH] fix(sandbox): pass real workspace to sessions_spawn when workspaceAccess is ro (#40757) Merged via squash. Prepared head SHA: 0e8b27bf80e41fcce77db8298ac74205c7b3b2c3 Co-authored-by: dsantoreis <66363641+dsantoreis@users.noreply.github.com> Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com> Reviewed-by: @mcaxtr --- CHANGELOG.md | 1 + src/agents/openclaw-tools.ts | 12 +- .../run/attempt.spawn-workspace.test.ts | 373 ++++++++++++++++++ src/agents/pi-embedded-runner/run/attempt.ts | 4 + src/agents/pi-tools.ts | 10 + 5 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f7574f71eb6..2799f0d8c7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai - 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. - 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. - 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. +- 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. ## 2026.3.8 diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 56d0801d13c..8473e4a06e8 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -70,9 +70,19 @@ export function createOpenClawTools( senderIsOwner?: boolean; /** Ephemeral session UUID — regenerated on /new and /reset. */ sessionId?: string; + /** + * Workspace directory to pass to spawned subagents for inheritance. + * Defaults to workspaceDir. Use this to pass the actual agent workspace when the + * session itself is running in a copied-workspace sandbox (`ro` or `none`) so + * subagents inherit the real workspace path instead of the sandbox copy. + */ + spawnWorkspaceDir?: string; } & SpawnedToolContext, ): AnyAgentTool[] { const workspaceDir = resolveWorkspaceRoot(options?.workspaceDir); + const spawnWorkspaceDir = resolveWorkspaceRoot( + options?.spawnWorkspaceDir ?? options?.workspaceDir, + ); const runtimeWebTools = getActiveRuntimeWebToolsMetadata(); const imageTool = options?.agentDir?.trim() ? createImageTool({ @@ -182,7 +192,7 @@ export function createOpenClawTools( agentGroupSpace: options?.agentGroupSpace, sandboxed: options?.sandboxed, requesterAgentIdOverride: options?.requesterAgentIdOverride, - workspaceDir, + workspaceDir: spawnWorkspaceDir, }), createSubagentsTool({ agentSessionKey: options?.agentSessionKey, diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts new file mode 100644 index 00000000000..0341ee97587 --- /dev/null +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts @@ -0,0 +1,373 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { Api, Model } from "@mariozechner/pi-ai"; +import type { + AuthStorage, + ExtensionContext, + ModelRegistry, + ToolDefinition, +} from "@mariozechner/pi-coding-agent"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createHostSandboxFsBridge } from "../../test-helpers/host-sandbox-fs-bridge.js"; +import { createPiToolsSandboxContext } from "../../test-helpers/pi-tools-sandbox-context.js"; + +const hoisted = vi.hoisted(() => { + const spawnSubagentDirectMock = vi.fn(); + const createAgentSessionMock = vi.fn(); + const sessionManagerOpenMock = vi.fn(); + const resolveSandboxContextMock = vi.fn(); + const subscribeEmbeddedPiSessionMock = vi.fn(); + const acquireSessionWriteLockMock = vi.fn(); + const sessionManager = { + getLeafEntry: vi.fn(() => null), + branch: vi.fn(), + resetLeaf: vi.fn(), + buildSessionContext: vi.fn(() => ({ messages: [] })), + appendCustomEntry: vi.fn(), + }; + return { + spawnSubagentDirectMock, + createAgentSessionMock, + sessionManagerOpenMock, + resolveSandboxContextMock, + subscribeEmbeddedPiSessionMock, + acquireSessionWriteLockMock, + sessionManager, + }; +}); + +vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + createAgentSession: (...args: unknown[]) => hoisted.createAgentSessionMock(...args), + DefaultResourceLoader: class { + async reload() {} + }, + SessionManager: { + open: (...args: unknown[]) => hoisted.sessionManagerOpenMock(...args), + } as unknown as typeof actual.SessionManager, + }; +}); + +vi.mock("../../subagent-spawn.js", () => ({ + SUBAGENT_SPAWN_MODES: ["run", "session"], + spawnSubagentDirect: (...args: unknown[]) => hoisted.spawnSubagentDirectMock(...args), +})); + +vi.mock("../../sandbox.js", () => ({ + resolveSandboxContext: (...args: unknown[]) => hoisted.resolveSandboxContextMock(...args), +})); + +vi.mock("../../session-tool-result-guard-wrapper.js", () => ({ + guardSessionManager: () => hoisted.sessionManager, +})); + +vi.mock("../../pi-embedded-subscribe.js", () => ({ + subscribeEmbeddedPiSession: (...args: unknown[]) => + hoisted.subscribeEmbeddedPiSessionMock(...args), +})); + +vi.mock("../../../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => undefined, +})); + +vi.mock("../../../infra/machine-name.js", () => ({ + getMachineDisplayName: async () => "test-host", +})); + +vi.mock("../../../infra/net/undici-global-dispatcher.js", () => ({ + ensureGlobalUndiciStreamTimeouts: () => {}, +})); + +vi.mock("../../bootstrap-files.js", () => ({ + makeBootstrapWarn: () => () => {}, + resolveBootstrapContextForRun: async () => ({ bootstrapFiles: [], contextFiles: [] }), +})); + +vi.mock("../../skills.js", () => ({ + applySkillEnvOverrides: () => () => {}, + applySkillEnvOverridesFromSnapshot: () => () => {}, + resolveSkillsPromptForRun: () => "", +})); + +vi.mock("../skills-runtime.js", () => ({ + resolveEmbeddedRunSkillEntries: () => ({ + shouldLoadSkillEntries: false, + skillEntries: undefined, + }), +})); + +vi.mock("../../docs-path.js", () => ({ + resolveOpenClawDocsPath: async () => undefined, +})); + +vi.mock("../../pi-project-settings.js", () => ({ + createPreparedEmbeddedPiSettingsManager: () => ({}), +})); + +vi.mock("../../pi-settings.js", () => ({ + applyPiAutoCompactionGuard: () => {}, +})); + +vi.mock("../extensions.js", () => ({ + buildEmbeddedExtensionFactories: () => [], +})); + +vi.mock("../google.js", () => ({ + logToolSchemasForGoogle: () => {}, + sanitizeSessionHistory: async ({ messages }: { messages: unknown[] }) => messages, + sanitizeToolsForGoogle: ({ tools }: { tools: unknown[] }) => tools, +})); + +vi.mock("../../session-file-repair.js", () => ({ + repairSessionFileIfNeeded: async () => {}, +})); + +vi.mock("../session-manager-cache.js", () => ({ + prewarmSessionFile: async () => {}, + trackSessionManagerAccess: () => {}, +})); + +vi.mock("../session-manager-init.js", () => ({ + prepareSessionManagerForRun: async () => {}, +})); + +vi.mock("../../session-write-lock.js", () => ({ + acquireSessionWriteLock: (...args: unknown[]) => hoisted.acquireSessionWriteLockMock(...args), + resolveSessionLockMaxHoldFromTimeout: () => 1, +})); + +vi.mock("../tool-result-context-guard.js", () => ({ + installToolResultContextGuard: () => () => {}, +})); + +vi.mock("../wait-for-idle-before-flush.js", () => ({ + flushPendingToolResultsAfterIdle: async () => {}, +})); + +vi.mock("../runs.js", () => ({ + setActiveEmbeddedRun: () => {}, + clearActiveEmbeddedRun: () => {}, +})); + +vi.mock("./images.js", () => ({ + detectAndLoadPromptImages: async () => ({ images: [] }), +})); + +vi.mock("../../system-prompt-params.js", () => ({ + buildSystemPromptParams: () => ({ + runtimeInfo: {}, + userTimezone: "UTC", + userTime: "00:00", + userTimeFormat: "24h", + }), +})); + +vi.mock("../../system-prompt-report.js", () => ({ + buildSystemPromptReport: () => undefined, +})); + +vi.mock("../system-prompt.js", () => ({ + applySystemPromptOverrideToSession: () => {}, + buildEmbeddedSystemPrompt: () => "system prompt", + createSystemPromptOverride: (prompt: string) => () => prompt, +})); + +vi.mock("../extra-params.js", () => ({ + applyExtraParamsToAgent: () => {}, +})); + +vi.mock("../../openai-ws-stream.js", () => ({ + createOpenAIWebSocketStreamFn: vi.fn(), + releaseWsSession: () => {}, +})); + +vi.mock("../../anthropic-payload-log.js", () => ({ + createAnthropicPayloadLogger: () => undefined, +})); + +vi.mock("../../cache-trace.js", () => ({ + createCacheTrace: () => undefined, +})); + +vi.mock("../../model-selection.js", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + normalizeProviderId: (providerId?: string) => providerId?.trim().toLowerCase() ?? "", + resolveDefaultModelForAgent: () => ({ provider: "openai", model: "gpt-test" }), + }; +}); + +const { runEmbeddedAttempt } = await import("./attempt.js"); + +type MutableSession = { + sessionId: string; + messages: unknown[]; + isCompacting: boolean; + isStreaming: boolean; + agent: { + streamFn?: unknown; + replaceMessages: (messages: unknown[]) => void; + }; + prompt: (prompt: string, options?: { images?: unknown[] }) => Promise; + abort: () => Promise; + dispose: () => void; + steer: (text: string) => Promise; +}; + +function createSubscriptionMock() { + return { + assistantTexts: [] as string[], + toolMetas: [] as Array<{ toolName: string; meta?: string }>, + unsubscribe: () => {}, + waitForCompactionRetry: async () => {}, + getMessagingToolSentTexts: () => [] as string[], + getMessagingToolSentMediaUrls: () => [] as string[], + getMessagingToolSentTargets: () => [] as unknown[], + getSuccessfulCronAdds: () => 0, + didSendViaMessagingTool: () => false, + didSendDeterministicApprovalPrompt: () => false, + getLastToolError: () => undefined, + getUsageTotals: () => undefined, + getCompactionCount: () => 0, + isCompacting: () => false, + }; +} + +describe("runEmbeddedAttempt sessions_spawn workspace inheritance", () => { + const tempPaths: string[] = []; + + beforeEach(() => { + hoisted.spawnSubagentDirectMock.mockReset().mockResolvedValue({ + status: "accepted", + childSessionKey: "agent:main:subagent:child", + runId: "run-child", + }); + hoisted.createAgentSessionMock.mockReset(); + hoisted.sessionManagerOpenMock.mockReset().mockReturnValue(hoisted.sessionManager); + hoisted.resolveSandboxContextMock.mockReset(); + hoisted.subscribeEmbeddedPiSessionMock.mockReset().mockImplementation(createSubscriptionMock); + hoisted.acquireSessionWriteLockMock.mockReset().mockResolvedValue({ + release: async () => {}, + }); + hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null); + hoisted.sessionManager.branch.mockReset(); + hoisted.sessionManager.resetLeaf.mockReset(); + hoisted.sessionManager.buildSessionContext.mockReset().mockReturnValue({ messages: [] }); + hoisted.sessionManager.appendCustomEntry.mockReset(); + }); + + afterEach(async () => { + while (tempPaths.length > 0) { + const target = tempPaths.pop(); + if (target) { + await fs.rm(target, { recursive: true, force: true }); + } + } + }); + + it("passes the real workspace to sessions_spawn when workspaceAccess is ro", async () => { + const realWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-real-workspace-")); + const sandboxWorkspace = await fs.mkdtemp( + path.join(os.tmpdir(), "openclaw-sandbox-workspace-"), + ); + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-dir-")); + tempPaths.push(realWorkspace, sandboxWorkspace, agentDir); + + hoisted.resolveSandboxContextMock.mockResolvedValue( + createPiToolsSandboxContext({ + workspaceDir: sandboxWorkspace, + agentWorkspaceDir: realWorkspace, + workspaceAccess: "ro", + fsBridge: createHostSandboxFsBridge(sandboxWorkspace), + tools: { allow: ["sessions_spawn"], deny: [] }, + sessionKey: "agent:main:main", + }), + ); + + hoisted.createAgentSessionMock.mockImplementation( + async (params: { customTools: ToolDefinition[] }) => { + const session: MutableSession = { + sessionId: "embedded-session", + messages: [], + isCompacting: false, + isStreaming: false, + agent: { + replaceMessages: (messages: unknown[]) => { + session.messages = [...messages]; + }, + }, + prompt: async () => { + const spawnTool = params.customTools.find((tool) => tool.name === "sessions_spawn"); + expect(spawnTool).toBeDefined(); + if (!spawnTool) { + throw new Error("missing sessions_spawn tool"); + } + await spawnTool.execute( + "call-sessions-spawn", + { task: "inspect workspace" }, + undefined, + undefined, + {} as unknown as ExtensionContext, + ); + }, + abort: async () => {}, + dispose: () => {}, + steer: async () => {}, + }; + + return { session }; + }, + ); + + const model = { + api: "openai-completions", + provider: "openai", + compat: {}, + contextWindow: 8192, + input: ["text"], + } as unknown as Model; + + const result = await runEmbeddedAttempt({ + sessionId: "embedded-session", + sessionKey: "agent:main:main", + sessionFile: path.join(realWorkspace, "session.jsonl"), + workspaceDir: realWorkspace, + agentDir, + config: {}, + prompt: "spawn a child session", + timeoutMs: 10_000, + runId: "run-1", + provider: "openai", + modelId: "gpt-test", + model, + authStorage: {} as AuthStorage, + modelRegistry: {} as ModelRegistry, + thinkLevel: "off", + senderIsOwner: true, + disableMessageTool: true, + }); + + expect(result.promptError).toBeNull(); + expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledTimes(1); + expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith( + expect.objectContaining({ + task: "inspect workspace", + }), + expect.objectContaining({ + workspaceDir: realWorkspace, + }), + ); + expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + workspaceDir: sandboxWorkspace, + }), + ); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index f6f18801497..081d12c9abf 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -869,6 +869,10 @@ export async function runEmbeddedAttempt( runId: params.runId, agentDir, workspaceDir: effectiveWorkspace, + // When sandboxing uses a copied workspace (`ro` or `none`), effectiveWorkspace points + // at the sandbox copy. Spawned subagents should inherit the real workspace instead. + spawnWorkspaceDir: + sandbox?.enabled && sandbox.workspaceAccess !== "rw" ? resolvedWorkspace : undefined, config: params.config, trigger: params.trigger, memoryFlushWritePath: params.memoryFlushWritePath, diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 14418bbd362..ff71b53baf4 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -215,6 +215,13 @@ export function createOpenClawCodingTools(options?: { memoryFlushWritePath?: string; agentDir?: string; workspaceDir?: string; + /** + * Workspace directory that spawned subagents should inherit. + * When sandboxing uses a copied workspace (`ro` or `none`), workspaceDir is the + * sandbox copy but subagents should inherit the real agent workspace instead. + * Defaults to workspaceDir when not set. + */ + spawnWorkspaceDir?: string; config?: OpenClawConfig; abortSignal?: AbortSignal; /** @@ -499,6 +506,9 @@ export function createOpenClawCodingTools(options?: { sandboxFsBridge, fsPolicy, workspaceDir: workspaceRoot, + spawnWorkspaceDir: options?.spawnWorkspaceDir + ? resolveWorkspaceRoot(options.spawnWorkspaceDir) + : undefined, sandboxed: !!sandbox, config: options?.config, pluginToolAllowlist: collectExplicitAllowlist([