mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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, "-")
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user