fix(sandbox): pass real workspace to sessions_spawn when workspaceAccess is ro (#40757)

Merged via squash.

Prepared head SHA: 0e8b27bf80
Co-authored-by: dsantoreis <66363641+dsantoreis@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
This commit is contained in:
Daniel Reis
2026-03-10 08:12:50 +01:00
committed by GitHub
parent 9d403fd415
commit 3495563cfe
5 changed files with 399 additions and 1 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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<typeof import("@mariozechner/pi-coding-agent")>();
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<typeof import("../../model-selection.js")>();
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<void>;
abort: () => Promise<void>;
dispose: () => void;
steer: (text: string) => Promise<void>;
};
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<Api>;
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,
}),
);
});
});

View File

@@ -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,

View File

@@ -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([