From 30346f97883c1a5e3f3a901bf487264b9e80fcf3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 29 Jul 2026 16:50:38 +0800 Subject: [PATCH] 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> --- docs/gateway/sandboxing.md | 4 ++ extensions/mxc/src/mxc-backend-factory.ts | 4 ++ extensions/mxc/test/mxc-backend.test.ts | 15 ++++ extensions/openshell/src/backend.ts | 7 ++ .../openshell/src/openshell-core.test.ts | 4 ++ .../sandbox.resolveSandboxContext.test.ts | 69 ++++++++++++++++++- src/agents/sandbox/docker-backend.test.ts | 21 +++++- src/agents/sandbox/docker-backend.ts | 2 +- .../docker.config-hash-recreate.test.ts | 36 ++++++++-- src/agents/sandbox/docker.ts | 16 ++--- src/agents/sandbox/shared.test.ts | 51 +++++++++++++- src/agents/sandbox/shared.ts | 35 ++++++++-- src/agents/sandbox/ssh-backend.test.ts | 10 +++ src/agents/sandbox/ssh-backend.ts | 4 ++ src/commands/sandbox-explain.test.ts | 7 +- 15 files changed, 258 insertions(+), 27 deletions(-) diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 66b8d8ede1e4..7fc25c81bab5 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -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 | diff --git a/extensions/mxc/src/mxc-backend-factory.ts b/extensions/mxc/src/mxc-backend-factory.ts index 0374d1d3980e..cc9ec8841552 100644 --- a/extensions/mxc/src/mxc-backend-factory.ts +++ b/extensions/mxc/src/mxc-backend-factory.ts @@ -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, "-") diff --git a/extensions/mxc/test/mxc-backend.test.ts b/extensions/mxc/test/mxc-backend.test.ts index 1cc6c95ed311..41b5fb08f6fe 100644 --- a/extensions/mxc/test/mxc-backend.test.ts +++ b/extensions/mxc/test/mxc-backend.test.ts @@ -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(); diff --git a/extensions/openshell/src/backend.ts b/extensions/openshell/src/backend.ts index 141ceb198636..ef009b2176a2 100644 --- a/extensions/openshell/src/backend.ts +++ b/extensions/openshell/src/backend.ts @@ -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); diff --git a/extensions/openshell/src/openshell-core.test.ts b/extensions/openshell/src/openshell-core.test.ts index 5c0252f01501..8b4df65a97c5 100644 --- a/extensions/openshell/src/openshell-core.test.ts +++ b/extensions/openshell/src/openshell-core.test.ts @@ -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); diff --git a/src/agents/sandbox.resolveSandboxContext.test.ts b/src/agents/sandbox.resolveSandboxContext.test.ts index 6a084b6aa528..d2fa3c98a539 100644 --- a/src/agents/sandbox.resolveSandboxContext.test.ts +++ b/src/agents/sandbox.resolveSandboxContext.test.ts @@ -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( diff --git a/src/agents/sandbox/docker-backend.test.ts b/src/agents/sandbox/docker-backend.test.ts index 666d7b92273a..35f7e3c067f0 100644 --- a/src/agents/sandbox/docker-backend.test.ts +++ b/src/agents/sandbox/docker-backend.test.ts @@ -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, diff --git a/src/agents/sandbox/docker-backend.ts b/src/agents/sandbox/docker-backend.ts index 237f2d6027ee..797b7c95df90 100644 --- a/src/agents/sandbox/docker-backend.ts +++ b/src/agents/sandbox/docker-backend.ts @@ -36,7 +36,7 @@ export async function createDockerSandboxBackend( params: CreateSandboxBackendParams, ): Promise { const containerName = await ensureSandboxContainer({ - sessionKey: params.sessionKey, + scopeKey: params.scopeKey, workspaceDir: params.workspaceDir, agentWorkspaceDir: params.agentWorkspaceDir, skillsWorkspaceDir: params.skillsWorkspaceDir, diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index 4e3877a482d4..b68836b79118 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -190,11 +190,11 @@ function createSandboxConfig( async function ensureSandboxCreateCallForTest(params: { cfg: SandboxConfig; workspaceDir?: string; - sessionKey?: string; + scopeKey?: string; }): Promise { 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, diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index fb3dce94d036..aae4556eb56e 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -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 { - 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, diff --git a/src/agents/sandbox/shared.test.ts b/src/agents/sandbox/shared.test.ts index 2d47973397e6..cbf151eb4f66 100644 --- a/src/agents/sandbox/shared.test.ts +++ b/src/agents/sandbox/shared.test.ts @@ -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"); + }); }); diff --git a/src/agents/sandbox/shared.ts b/src/agents/sandbox/shared.ts index 619b37877fab..715dd3bf7e80 100644 --- a/src/agents/sandbox/shared.ts +++ b/src/agents/sandbox/shared.ts @@ -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 diff --git a/src/agents/sandbox/ssh-backend.test.ts b/src/agents/sandbox/ssh-backend.test.ts index 1f0e4dc98b60..3878fb421ed1 100644 --- a/src/agents/sandbox/ssh-backend.test.ts +++ b/src/agents/sandbox/ssh-backend.test.ts @@ -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: { diff --git a/src/agents/sandbox/ssh-backend.ts b/src/agents/sandbox/ssh-backend.ts index f3d93b14d141..be1511275a02 100644 --- a/src/agents/sandbox/ssh-backend.ts +++ b/src/agents/sandbox/ssh-backend.ts @@ -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) diff --git a/src/commands/sandbox-explain.test.ts b/src/commands/sandbox-explain.test.ts index 2808e1964370..2797c4e94a09 100644 --- a/src/commands/sandbox-explain.test.ts +++ b/src/commands/sandbox-explain.test.ts @@ -233,8 +233,11 @@ describe("sandbox explain command", () => { } as unknown as Parameters[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([