fix(sandbox): scope runtimes by workspace (#115766)

Qualify non-shared sandbox identities by resolved workspace while preserving shared runtime names. Existing non-shared runtimes reset once under the new identity.

Related: #51363

Co-authored-by: Tayoun <39609208+tayoun@users.noreply.github.com>
This commit is contained in:
Vincent Koc
2026-07-29 16:50:38 +08:00
committed by GitHub
parent cc6b766079
commit 30346f9788
15 changed files with 258 additions and 27 deletions
+4
View File
@@ -44,6 +44,10 @@ Three independent settings control sandbox behavior:
- `session`: one container per session.
- `shared`: one container shared by all sandboxed sessions (per-agent `docker`/`ssh`/`browser` overrides are ignored under this scope).
Non-shared runtime identity also includes the resolved agent workspace path. This prevents co-hosted workspaces that reuse the same agent or session keys from sharing Docker, browser, SSH, OpenShell, or plugin-provided sandbox state. `shared` scope intentionally remains workspace-independent.
The first use after upgrading from an older release creates non-shared runtimes and sandbox workspaces under the workspace-qualified identity. Existing non-shared runtimes are not adopted; this is an intentional one-time reset. They can age out through configured prune settings or be removed with `openclaw sandbox recreate`; the next use provisions the current identity.
**Backend** controls which runtime executes sandboxed tools. SSH-specific config lives under `agents.defaults.sandbox.ssh`; OpenShell-specific config lives under `plugins.entries.openshell.config`.
| | Docker | SSH | OpenShell |
@@ -4,6 +4,10 @@ import type { MxcConfig } from "./config.js";
import { createMxcSandboxBackendHandle } from "./mxc-backend.js";
function sanitizeRuntimeId(value: string): string {
if (/:workspace:[a-f0-9]{32}$/i.test(value.trim())) {
const hash = createHash("sha256").update(value).digest("hex").slice(0, 32);
return `openclaw-mxc-workspace-${hash}`;
}
const slug = value
.toLowerCase()
.replace(/[^a-z0-9_.-]+/g, "-")
+15
View File
@@ -204,6 +204,21 @@ async function withProcessEnv(
}
}
describe("createMxcSandboxBackendFactory", () => {
test("hashes workspace-qualified scopes without truncating their identity", async () => {
const createBackend = createMxcSandboxBackendFactory(baseConfig);
const handle = await createBackend({
sessionKey: "agent:main:main",
scopeKey: `agent:main:workspace:${"a".repeat(32)}`,
workspaceDir: baseParams.workdir,
agentWorkspaceDir: baseParams.workdir,
cfg: createSandboxBackendTestConfig({ workspaceAccess: "rw" }),
});
expect(handle.runtimeId).toMatch(/^openclaw-mxc-workspace-[a-f0-9]{32}$/u);
});
});
describeOnWindows("createMxcSandboxBackendHandle (Windows-only MXC backend tests)", () => {
beforeEach(() => {
spawnCommandMock.mockReset();
+7
View File
@@ -966,6 +966,13 @@ function resolveOpenShellPluginConfigFromConfig(
function buildOpenShellSandboxName(scopeKey: string): string {
const trimmed = scopeKey.trim() || "session";
if (/:workspace:[a-f0-9]{32}$/i.test(trimmed)) {
// OpenShell's 19-character DNS-label cap leaves 16 payload characters.
// Base36 retains 80 hash bits within that cap.
const hash = createHash("sha256").update(trimmed).digest("hex").slice(0, 20);
const encoded = BigInt(`0x${hash}`).toString(36).padStart(16, "0");
return `oc-${encoded}`;
}
// OpenShell reserves 19 characters so workspace--sandbox--service remains
// a valid DNS label. Keep 64 hash bits to make opaque scope names collision-resistant.
const hash = createHash("sha256").update(trimmed).digest("hex").slice(0, 16);
@@ -265,6 +265,7 @@ describe("openshell backend manager", () => {
const first = await createBackend("agent:main");
const repeated = await createBackend("agent:main");
const other = await createBackend("agent:other");
const workspaceScoped = await createBackend(`agent:main:workspace:${"a".repeat(32)}`);
const legacyRuntimeId = "openclaw-agent-main-25bffc4d";
const adoptedLegacy = await createBackend("agent:main", [legacyRuntimeId]);
const punctuationLegacyRuntimeId = "openclaw-agent-foo-bar-baz-ab401a99";
@@ -278,6 +279,9 @@ describe("openshell backend manager", () => {
expect(first.runtimeId).toHaveLength(19);
expect(repeated.runtimeId).toBe(first.runtimeId);
expect(other.runtimeId).not.toBe(first.runtimeId);
expect(workspaceScoped.runtimeId).toMatch(/^oc-[a-z0-9]{16}$/u);
expect(workspaceScoped.runtimeId).toHaveLength(19);
expect(workspaceScoped.runtimeId).not.toBe(first.runtimeId);
expect(adoptedLegacy.runtimeId).toBe(legacyRuntimeId);
expect(adoptedPunctuationLegacy.runtimeId).toBe(punctuationLegacyRuntimeId);
expect(ignoresUnknown.runtimeId).toBe(first.runtimeId);
@@ -279,6 +279,71 @@ describe("resolveSandboxContext", () => {
}
}, 15_000);
it("passes one workspace-qualified scope key through backend and browser setup", async () => {
ensureSandboxBrowserMock.mockClear();
const scopeKeys: string[] = [];
const restore = registerSandboxBackend("workspace-scope-backend", async (params) => {
scopeKeys.push(params.scopeKey);
return {
id: "workspace-scope-backend",
runtimeId: `runtime-${params.scopeKey}`,
runtimeLabel: "Workspace Scope Runtime",
workdir: "/workspace",
capabilities: { browser: true },
buildExecSpec: async () => ({
argv: ["workspace-scope-backend", "exec"],
env: process.env,
stdinMode: "pipe-closed",
}),
runShellCommand: async () => ({
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
code: 0,
}),
};
});
try {
const cfg: OpenClawConfig = {
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "workspace-scope-backend",
scope: "agent",
workspaceAccess: "rw",
prune: { idleHours: 0, maxAgeDays: 0 },
browser: { enabled: true },
},
},
},
};
const firstWorkspace = await createSandboxFixtureDir("workspace-scope-a");
const secondWorkspace = await createSandboxFixtureDir("workspace-scope-b");
await resolveSandboxContext({
config: cfg,
sessionKey: "agent:poly:msteams:channel-1",
workspaceDir: firstWorkspace,
});
await resolveSandboxContext({
config: cfg,
sessionKey: "agent:poly:msteams:channel-1",
workspaceDir: secondWorkspace,
});
expect(scopeKeys).toHaveLength(2);
expect(scopeKeys[0]).toMatch(/^agent:poly:workspace:[a-f0-9]{32}$/);
expect(scopeKeys[1]).toMatch(/^agent:poly:workspace:[a-f0-9]{32}$/);
expect(scopeKeys[0]).not.toBe(scopeKeys[1]);
const browserCalls = ensureSandboxBrowserMock.mock.calls as unknown as Array<
[{ scopeKey: string }]
>;
expect(browserCalls.map(([params]) => params.scopeKey)).toEqual(scopeKeys);
} finally {
restore();
}
}, 15_000);
it("types backend creation failures as sandbox provisioning errors", async () => {
const backendFailure = new Error("Sandbox image not found: missing:test");
const restore = registerSandboxBackend("broken-backend", async () => {
@@ -639,7 +704,7 @@ describe("resolveSandboxContext", () => {
path.join(".openclaw", "sandbox", "skills-workspaces"),
);
expect(syncOptions?.targetWorkspaceDir).toMatch(
/[\\/]agent-main-main-[a-f0-9]{8}[\\/]\.openclaw[\\/]sandbox-skills$/,
/[\\/]workspace-[a-f0-9]{32}[\\/]\.openclaw[\\/]sandbox-skills$/,
);
expect(syncOptions?.targetWorkspaceDir).not.toBe(
path.join(workspaceDir, ".openclaw", "sandbox-skills"),
@@ -692,7 +757,7 @@ describe("resolveSandboxContext", () => {
expect(result?.workspaceDir).toBe(workspaceDir);
expect(result?.containerWorkdir).toMatch(
/^\/remote\/openclaw\/openclaw-ssh-agent-main-main-[a-f0-9]{8}\/workspace$/,
/^\/remote\/openclaw\/openclaw-ssh-workspace-[a-f0-9]{32}\/workspace$/,
);
expect(result?.containerWorkdir).not.toBe("/workspace");
expect(result?.skillsWorkspaceDir).toContain(
+20 -1
View File
@@ -2,6 +2,7 @@
// handling for sandbox and browser containers.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { resolveSandboxConfigForAgent } from "./config.js";
const dockerMocks = vi.hoisted(() => ({
dockerContainerState: vi.fn(),
@@ -21,7 +22,8 @@ vi.mock("./docker.js", async () => {
};
});
const { dockerSandboxBackendManager } = await import("./docker-backend.js");
const { createDockerSandboxBackend, dockerSandboxBackendManager } =
await import("./docker-backend.js");
function createConfig(): OpenClawConfig {
return {
@@ -59,6 +61,23 @@ describe("docker sandbox backend manager", () => {
});
});
it("forwards the canonical scope key to container provisioning", async () => {
dockerMocks.ensureSandboxContainer.mockResolvedValueOnce("sandbox-container");
const scopeKey = `agent:poly:workspace:${"a".repeat(32)}`;
await createDockerSandboxBackend({
sessionKey: "agent:poly:msteams:channel-1",
scopeKey,
workspaceDir: "/tmp/customer/workspace",
agentWorkspaceDir: "/tmp/customer/workspace",
cfg: resolveSandboxConfigForAgent(createConfig(), "poly"),
});
expect(dockerMocks.ensureSandboxContainer).toHaveBeenCalledWith(
expect.objectContaining({ scopeKey }),
);
});
it("matches ordinary sandbox runtimes against sandbox.docker.image", async () => {
dockerMocks.execDocker.mockResolvedValueOnce({
code: 0,
+1 -1
View File
@@ -36,7 +36,7 @@ export async function createDockerSandboxBackend(
params: CreateSandboxBackendParams,
): Promise<SandboxBackendHandle> {
const containerName = await ensureSandboxContainer({
sessionKey: params.sessionKey,
scopeKey: params.scopeKey,
workspaceDir: params.workspaceDir,
agentWorkspaceDir: params.agentWorkspaceDir,
skillsWorkspaceDir: params.skillsWorkspaceDir,
@@ -190,11 +190,11 @@ function createSandboxConfig(
async function ensureSandboxCreateCallForTest(params: {
cfg: SandboxConfig;
workspaceDir?: string;
sessionKey?: string;
scopeKey?: string;
}): Promise<SpawnCall> {
const workspaceDir = params.workspaceDir ?? "/tmp/workspace";
await ensureSandboxContainer({
sessionKey: params.sessionKey ?? "agent:main:session-1",
scopeKey: params.scopeKey ?? "shared",
workspaceDir,
agentWorkspaceDir: workspaceDir,
cfg: params.cfg,
@@ -236,7 +236,7 @@ describe("ensureSandboxContainer config-hash recreation", () => {
registryMocks.readRegistryEntry.mockResolvedValue(null);
const params = {
sessionKey: "agent:main:session-1",
scopeKey: "shared",
workspaceDir,
agentWorkspaceDir: workspaceDir,
cfg,
@@ -253,6 +253,30 @@ describe("ensureSandboxContainer config-hash recreation", () => {
expect(registryMocks.updateRegistry).toHaveBeenCalledTimes(2);
});
it("uses the canonical non-shared scope for Docker names, labels, and registry identity", async () => {
const workspaceDir = makeTempDir();
const cfg = createSandboxConfig([], [`${workspaceDir}:/workspace:rw`]);
cfg.scope = "agent";
spawnState.containerExists = false;
spawnState.inspectRunning = false;
registryMocks.readRegistryEntry.mockResolvedValue(null);
const scopeKey = `agent:poly:workspace:${"a".repeat(32)}`;
const createCall = await ensureSandboxCreateCallForTest({
cfg,
workspaceDir,
scopeKey,
});
const containerName = createCall.args[createCall.args.indexOf("--name") + 1];
expect(containerName).toMatch(/^oc-test-workspace-[a-f0-9]{32}$/);
expect(createCall.args).toContain(`openclaw.sessionKey=${scopeKey}`);
expect(registryMocks.updateRegistry.mock.calls.at(-1)?.[0]).toMatchObject({
containerName,
sessionKey: scopeKey,
});
});
it("recreates shared container when array-order change alters hash", async () => {
// Docker flag order is part of the runtime contract, so order-sensitive
// config changes must invalidate a shared container.
@@ -291,7 +315,7 @@ describe("ensureSandboxContainer config-hash recreation", () => {
});
const containerName = await ensureSandboxContainer({
sessionKey: "agent:main:session-1",
scopeKey: "shared",
workspaceDir,
agentWorkspaceDir: workspaceDir,
cfg: newCfg,
@@ -380,7 +404,7 @@ describe("ensureSandboxContainer config-hash recreation", () => {
});
await ensureSandboxContainer({
sessionKey: "agent:main:session-1",
scopeKey: "shared",
workspaceDir,
agentWorkspaceDir: workspaceDir,
cfg,
@@ -409,7 +433,7 @@ describe("ensureSandboxContainer config-hash recreation", () => {
await expect(
ensureSandboxContainer({
sessionKey: "agent:main:session-1",
scopeKey: "shared",
workspaceDir,
agentWorkspaceDir: workspaceDir,
cfg,
+7 -9
View File
@@ -96,7 +96,7 @@ import {
} from "./constants.js";
import { handleHotSandboxConfigMismatch } from "./current-config.js";
import { readRegistryEntry, updateRegistry } from "./registry.js";
import { buildSandboxContainerName, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js";
import { buildSandboxContainerName, slugifySessionKey } from "./shared.js";
import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js";
import { validateSandboxSecurity } from "./validate-sandbox-security.js";
import {
@@ -496,7 +496,7 @@ async function readContainerConfigHash(containerName: string): Promise<string |
}
type EnsureSandboxContainerParams = {
sessionKey: string;
scopeKey: string;
workspaceDir: string;
agentWorkspaceDir: string;
skillsWorkspaceDir?: string;
@@ -505,20 +505,18 @@ type EnsureSandboxContainerParams = {
};
export async function ensureSandboxContainer(params: EnsureSandboxContainerParams) {
const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey);
const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey);
const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(params.scopeKey);
const containerName = buildSandboxContainerName(params.cfg.docker.containerPrefix, slug);
// Independent agent runs can converge on one Docker resource. Serialize the
// full lifecycle so followers re-read state after create, start, or replace.
return await sandboxContainerLifecycleQueue.enqueue(containerName, async () => {
return await ensureSandboxContainerLifecycle(params, scopeKey, containerName);
return await ensureSandboxContainerLifecycle(params, containerName);
});
}
async function ensureSandboxContainerLifecycle(
params: EnsureSandboxContainerParams,
scopeKey: string,
containerName: string,
) {
const readOnlyWorkspaceSkillMounts = resolveReadOnlyWorkspaceSkillMounts({
@@ -568,7 +566,7 @@ async function ensureSandboxContainerLifecycle(
handleHotSandboxConfigMismatch({
containerName,
scope: params.cfg.scope,
sessionKey: scopeKey,
sessionKey: params.scopeKey,
...(params.requireCurrentConfig !== undefined
? { requireCurrentConfig: params.requireCurrentConfig }
: {}),
@@ -588,7 +586,7 @@ async function ensureSandboxContainerLifecycle(
workspaceAccess: params.cfg.workspaceAccess,
agentWorkspaceDir: params.agentWorkspaceDir,
skillsWorkspaceDir: params.skillsWorkspaceDir,
scopeKey,
scopeKey: params.scopeKey,
configHash: expectedHash,
readOnlyWorkspaceSkillMounts,
});
@@ -599,7 +597,7 @@ async function ensureSandboxContainerLifecycle(
containerName,
backendId: "docker",
runtimeLabel: containerName,
sessionKey: scopeKey,
sessionKey: params.scopeKey,
createdAtMs: now,
lastUsedAtMs: now,
image: params.cfg.docker.image,
+50 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { buildSandboxContainerName, slugifySessionKey } from "./shared.js";
import {
buildSandboxContainerName,
resolveSandboxWorkspaceLayoutPaths,
slugifySessionKey,
} from "./shared.js";
describe("buildSandboxContainerName", () => {
it("preserves scope identity when a custom prefix exceeds the Docker name limit", () => {
@@ -30,4 +34,49 @@ describe("buildSandboxContainerName", () => {
expect(second).toMatch(/-[0-9a-f]{12}$/);
expect(oversizedSlug).toMatch(/-[0-9a-f]{12}$/);
});
it("preserves workspace identity when a custom prefix exceeds the Docker name limit", () => {
const slug = slugifySessionKey(`agent:main:workspace:${"a".repeat(32)}`);
const first = buildSandboxContainerName("custom-prefix-one-that-is-far-too-long-", slug);
const second = buildSandboxContainerName("custom-prefix-two-that-is-far-too-long-", slug);
expect(slug).toMatch(/^workspace-[a-f0-9]{32}$/);
expect(first).toHaveLength(63);
expect(second).toHaveLength(63);
expect(first).not.toBe(second);
expect(first).toContain(slug);
expect(second).toContain(slug);
expect(first).toMatch(/-[a-f0-9]{12}$/);
expect(second).toMatch(/-[a-f0-9]{12}$/);
});
});
describe("resolveSandboxWorkspaceLayoutPaths", () => {
const sessionKey = "agent:poly:msteams:channel-1";
const workspaceA = "/tmp/openclaw-customers/atica/agents/poly/workspace";
const workspaceB = "/tmp/openclaw-customers/polytopic/agents/poly/workspace";
const createLayout = (scope: "session" | "agent" | "shared", workspaceDir: string) =>
resolveSandboxWorkspaceLayoutPaths({
cfg: {
scope,
workspaceAccess: "rw",
workspaceRoot: "/tmp/openclaw-sandboxes",
},
rawSessionKey: sessionKey,
workspaceDir,
});
it.each(["session", "agent"] as const)("qualifies %s scope by resolved workspace", (scope) => {
const first = createLayout(scope, workspaceA).scopeKey;
const second = createLayout(scope, workspaceB).scopeKey;
expect(first).toMatch(/:workspace:[a-f0-9]{32}$/);
expect(second).toMatch(/:workspace:[a-f0-9]{32}$/);
expect(first).not.toBe(second);
});
it("keeps shared scope independent of workspace", () => {
expect(createLayout("shared", workspaceA).scopeKey).toBe("shared");
expect(createLayout("shared", workspaceB).scopeKey).toBe("shared");
});
});
+30 -5
View File
@@ -14,9 +14,15 @@ import { hashTextSha256 } from "./hash.js";
import type { SandboxConfig } from "./types.js";
import { resolveMaterializedSandboxSkillsWorkspaceDir } from "./workspace-mounts.js";
const WORKSPACE_SCOPE_SUFFIX_RE = /:workspace:[a-f0-9]{32}$/i;
const WORKSPACE_RUNTIME_SLUG_RE = /^workspace-[a-f0-9]{32}$/i;
/** Converts an arbitrary session key into a bounded filesystem/container-safe slug. */
export function slugifySessionKey(value: string) {
const trimmed = value.trim() || "session";
if (WORKSPACE_SCOPE_SUFFIX_RE.test(trimmed)) {
return `workspace-${hashTextSha256(trimmed).slice(0, 32)}`;
}
const hash = hashTextSha256(trimmed).slice(0, 8);
const safe = normalizeLowercaseStringOrEmpty(trimmed)
.replace(/[^a-z0-9._-]+/g, "-")
@@ -32,6 +38,14 @@ export function buildSandboxContainerName(prefix: string, slug: string): string
if (fullName.length <= maxLength) {
return fullName;
}
if (WORKSPACE_RUNTIME_SLUG_RE.test(slug)) {
// Preserve all 128 scope bits. Only the prefix is shortened, while the
// trailing hash keeps custom prefixes distinct when Docker's limit applies.
const identitySuffix = `-${slug}-${hashTextSha256(fullName).slice(0, 12)}`;
const prefixBudget = maxLength - identitySuffix.length;
const boundedPrefix = prefix.slice(0, prefixBudget);
return `${boundedPrefix}${identitySuffix}`;
}
const identitySuffix = `-${hashTextSha256(fullName).slice(0, 12)}`;
return `${fullName.slice(0, maxLength - identitySuffix.length)}${identitySuffix}`;
}
@@ -43,17 +57,24 @@ function resolveSandboxWorkspaceDir(root: string, sessionKey: string) {
return path.join(resolvedRoot, slug);
}
/** Resolves the registry scope key for session-, agent-, or shared-scope sandbox lifetimes. */
export function resolveSandboxScopeKey(scope: "session" | "agent" | "shared", sessionKey: string) {
/** Resolves workspace-qualified registry identity for non-shared sandbox lifetimes. */
function resolveSandboxScopeKey(
scope: "session" | "agent" | "shared",
sessionKey: string,
workspaceDir: string,
) {
const trimmed = sessionKey.trim() || "main";
if (scope === "shared") {
return "shared";
}
// Co-hosted workspaces may reuse agent and session keys, but must never
// converge on one runtime, registry entry, or materialized skills workspace.
const workspaceSuffix = `:workspace:${hashTextSha256(resolveUserPath(workspaceDir)).slice(0, 32)}`;
if (scope === "session") {
return trimmed;
return `${trimmed}${workspaceSuffix}`;
}
const agentId = resolveAgentIdFromSessionKey(trimmed);
return `agent:${agentId}`;
return `agent:${agentId}${workspaceSuffix}`;
}
/** Extracts the agent id represented by a sandbox scope key, when one exists. */
@@ -79,7 +100,11 @@ export function resolveSandboxWorkspaceLayoutPaths(params: {
params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR,
);
const workspaceRoot = resolveUserPath(params.cfg.workspaceRoot);
const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.rawSessionKey);
const scopeKey = resolveSandboxScopeKey(
params.cfg.scope,
params.rawSessionKey,
agentWorkspaceDir,
);
const sandboxWorkspaceDir =
params.cfg.scope === "shared"
? workspaceRoot
+10
View File
@@ -185,6 +185,16 @@ describe("ssh sandbox backend", () => {
vi.restoreAllMocks();
});
it("preserves shared runtime identity and hashes workspace-qualified scopes", () => {
expect(resolveSshRuntimePaths("/remote/openclaw", "shared").runtimeId).toBe(
"openclaw-ssh-shared-8198076c",
);
expect(
resolveSshRuntimePaths("/remote/openclaw", `agent:main:workspace:${"a".repeat(32)}`)
.runtimeId,
).toMatch(/^openclaw-ssh-workspace-[a-f0-9]{32}$/);
});
it("describes runtimes via the configured ssh target", async () => {
const result = await sshSandboxBackendManager.describeRuntime({
entry: {
+4
View File
@@ -16,6 +16,7 @@ import type {
SandboxBackendManager,
} from "./backend.types.js";
import { resolveSandboxConfigForAgent } from "./config.js";
import { hashTextSha256 } from "./hash.js";
import {
createRemoteShellSandboxFsBridge,
type RemoteShellSandboxHandle,
@@ -459,6 +460,9 @@ export function resolveSshRuntimePaths(
function buildSshSandboxRuntimeId(scopeKey: string): string {
const trimmed = scopeKey.trim() || "session";
if (/:workspace:[a-f0-9]{32}$/i.test(trimmed)) {
return `openclaw-ssh-workspace-${hashTextSha256(trimmed).slice(0, 32)}`;
}
// Keep the path human-readable while hashing the original scope to avoid
// collisions after normalization and truncation.
const safe = normalizeLowercaseStringOrEmpty(trimmed)
+5 -2
View File
@@ -233,8 +233,11 @@ describe("sandbox explain command", () => {
} as unknown as Parameters<typeof sandboxExplainCommand>[1]);
const parsed = JSON.parse(logs.join(""));
expect(parsed.sandbox.effectiveHostWorkspaceRoot).toMatch(
/^\/tmp\/openclaw-sandboxes\/agent-builder-/,
expect(path.dirname(parsed.sandbox.effectiveHostWorkspaceRoot)).toBe(
path.resolve("/tmp/openclaw-sandboxes"),
);
expect(path.basename(parsed.sandbox.effectiveHostWorkspaceRoot)).toMatch(
/^workspace-[a-f0-9]{32}$/,
);
expect(parsed.sandbox.workspaceSource).toBe("sandbox");
expect(parsed.sandbox.workspaceMounts).toEqual([