mirror of https://github.com/openclaw/openclaw.git
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:
parent
9d403fd415
commit
3495563cfe
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
Loading…
Reference in New Issue