From 90fea3d714428d92c2fc01fc43c5f5b40830298a Mon Sep 17 00:00:00 2001 From: Bryan Tegomoh Date: Fri, 20 Mar 2026 21:24:08 -0500 Subject: [PATCH 1/2] fix(sandbox): include workspace dir in container name to prevent cross-instance collisions When multiple OpenClaw instances run on the same host with different HOME dirs, they all derived the same container name from the agent/scope key alone (e.g. 'agent:main'). This caused every instance to share a single Docker container, leaking file reads/writes across instances. Mix workspaceDir (derived from HOME) into the slug hash so each instance gets a distinct container name. Fixes #51363 --- src/agents/sandbox/browser.ts | 8 +++++++- .../sandbox/docker.config-hash-recreate.test.ts | 17 +++++++++++------ src/agents/sandbox/docker.ts | 8 +++++++- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index a0fdae3babe..2a9d2370d1a 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -141,7 +141,13 @@ export async function ensureSandboxBrowser(params: { return null; } - const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(params.scopeKey); + // Include workspaceDir in the slug so co-hosted instances with different HOME + // dirs produce distinct container names (fixes #51363). + const slug = slugifySessionKey( + params.cfg.scope === "shared" + ? `shared:${params.workspaceDir}` + : `${params.scopeKey}:${params.workspaceDir}`, + ); const name = `${params.cfg.browser.containerPrefix}${slug}`; const containerName = name.slice(0, 63); const state = await dockerContainerState(containerName); diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index 46d37f9fd61..f0709a04e6e 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -3,6 +3,7 @@ import { Readable } from "node:stream"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { computeSandboxConfigHash } from "./config-hash.js"; import { ensureSandboxContainer } from "./docker.js"; +import { slugifySessionKey } from "./shared.js"; import { collectDockerFlagValues } from "./test-args.js"; import type { SandboxConfig } from "./types.js"; @@ -146,6 +147,7 @@ describe("ensureSandboxContainer config-hash recreation", () => { it("recreates shared container when array-order change alters hash", async () => { const workspaceDir = "/tmp/workspace"; + const expectedName = `oc-test-${slugifySessionKey(`shared:${workspaceDir}`)}`.slice(0, 63); const oldCfg = createSandboxConfig(["1.1.1.1", "8.8.8.8"]); const newCfg = createSandboxConfig(["8.8.8.8", "1.1.1.1"]); @@ -167,7 +169,7 @@ describe("ensureSandboxContainer config-hash recreation", () => { registryMocks.readRegistry.mockResolvedValue({ entries: [ { - containerName: "oc-test-shared", + containerName: expectedName, sessionKey: "shared", createdAtMs: 1, lastUsedAtMs: 0, @@ -184,12 +186,11 @@ describe("ensureSandboxContainer config-hash recreation", () => { cfg: newCfg, }); - expect(containerName).toBe("oc-test-shared"); + expect(containerName).toBe(expectedName); const dockerCalls = spawnState.calls.filter((call) => call.command === "docker"); expect( dockerCalls.some( - (call) => - call.args[0] === "rm" && call.args[1] === "-f" && call.args[2] === "oc-test-shared", + (call) => call.args[0] === "rm" && call.args[1] === "-f" && call.args[2] === expectedName, ), ).toBe(true); const createCall = dockerCalls.find((call) => call.args[0] === "create"); @@ -197,7 +198,7 @@ describe("ensureSandboxContainer config-hash recreation", () => { expect(createCall?.args).toContain(`openclaw.configHash=${newHash}`); expect(registryMocks.updateRegistry).toHaveBeenCalledWith( expect.objectContaining({ - containerName: "oc-test-shared", + containerName: expectedName, configHash: newHash, }), ); @@ -205,6 +206,10 @@ describe("ensureSandboxContainer config-hash recreation", () => { it("applies custom binds after workspace mounts so overlapping binds can override", async () => { const workspaceDir = "/tmp/workspace"; + const expectedContainerName = `oc-test-${slugifySessionKey(`shared:${workspaceDir}`)}`.slice( + 0, + 63, + ); const cfg = createSandboxConfig( ["1.1.1.1"], ["/tmp/workspace-shared/USER.md:/workspace/USER.md:ro"], @@ -222,7 +227,7 @@ describe("ensureSandboxContainer config-hash recreation", () => { registryMocks.readRegistry.mockResolvedValue({ entries: [ { - containerName: "oc-test-shared", + containerName: expectedContainerName, sessionKey: "shared", createdAtMs: 1, lastUsedAtMs: 0, diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index 80a2921cb6b..77d585b9aa1 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -496,7 +496,13 @@ export async function ensureSandboxContainer(params: { cfg: SandboxConfig; }) { const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey); - const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey); + // Include workspaceDir in the slug so co-hosted instances with different HOME + // dirs produce distinct container names (fixes #51363). + const slug = slugifySessionKey( + params.cfg.scope === "shared" + ? `shared:${params.workspaceDir}` + : `${scopeKey}:${params.workspaceDir}`, + ); const name = `${params.cfg.docker.containerPrefix}${slug}`; const containerName = name.slice(0, 63); const expectedHash = computeSandboxConfigHash({ From c96192bb024987d1a45711f9322d4f6e9f251c5f Mon Sep 17 00:00:00 2001 From: Bryan Tegomoh Date: Fri, 20 Mar 2026 23:05:50 -0500 Subject: [PATCH 2/2] narrow sandbox slug fix to shared/agent scopes only --- src/agents/sandbox/browser.ts | 11 +++++++---- src/agents/sandbox/docker.ts | 11 +++++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index 2a9d2370d1a..5ee35d088d7 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -141,11 +141,14 @@ export async function ensureSandboxBrowser(params: { return null; } - // Include workspaceDir in the slug so co-hosted instances with different HOME - // dirs produce distinct container names (fixes #51363). + // Include workspaceDir in the slug for shared/agent scopes so co-hosted + // instances with different HOME dirs produce distinct container names + // (fixes #51363). Session-scope containers are already collision-free via + // their per-session unique key, so we leave those names unchanged to avoid + // orphaning existing containers on upgrade. const slug = slugifySessionKey( - params.cfg.scope === "shared" - ? `shared:${params.workspaceDir}` + params.cfg.scope === "session" + ? params.scopeKey : `${params.scopeKey}:${params.workspaceDir}`, ); const name = `${params.cfg.browser.containerPrefix}${slug}`; diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index 77d585b9aa1..2aafcca03cd 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -496,11 +496,14 @@ export async function ensureSandboxContainer(params: { cfg: SandboxConfig; }) { const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey); - // Include workspaceDir in the slug so co-hosted instances with different HOME - // dirs produce distinct container names (fixes #51363). + // Include workspaceDir in the slug for shared/agent scopes so co-hosted + // instances with different HOME dirs produce distinct container names + // (fixes #51363). Session-scope containers are already collision-free via + // their per-session unique key, so we leave those names unchanged to avoid + // orphaning existing containers on upgrade. const slug = slugifySessionKey( - params.cfg.scope === "shared" - ? `shared:${params.workspaceDir}` + params.cfg.scope === "session" + ? scopeKey : `${scopeKey}:${params.workspaceDir}`, ); const name = `${params.cfg.docker.containerPrefix}${slug}`;