fix(openshell): restore sandbox creation on current OpenShell (#115058)

* test(openshell): use structured gateway discovery

* fix(openshell): bound sandbox runtime names

* test(openshell): bound policy sandbox fixture

* docs(sandbox): document compatibility matrix

* fix(openshell): preserve registered runtime identities

* fix(openshell): quote recreate scope safely

* fix(openshell): match shipped legacy names

* docs(sandbox): refresh generated map

* test(sandbox): mock registered runtime lookup

* fix(openshell): reject non-ready legacy runtimes
This commit is contained in:
Vincent Koc
2026-07-28 11:13:04 +02:00
committed by GitHub
parent dd73d4b0da
commit ac157b3af0
14 changed files with 556 additions and 65 deletions
@@ -974,12 +974,12 @@ jobs:
shell: bash
run: |
set -euo pipefail
export OPENSHELL_VERSION=v0.0.68
export OPENSHELL_VERSION=v0.0.92
installer_path="$(mktemp "${RUNNER_TEMP}/openshell-install.XXXXXX")"
trap 'rm -f "$installer_path"' EXIT
curl -LsSf --connect-timeout 10 --max-time 120 \
-o "$installer_path" \
https://raw.githubusercontent.com/NVIDIA/OpenShell/d64542f69d06694cbd203b64929d286dd0533bbb/install.sh
https://raw.githubusercontent.com/NVIDIA/OpenShell/2d108818f84be568e63232d5d0aba53775cea4f7/install.sh
sh "$installer_path"
openshell --version
+3
View File
@@ -3794,6 +3794,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H3: Per-agent OpenShell with custom gateway
- H2: Lifecycle management
- H2: Security hardening
- H2: Custom image contract
- H2: Current limitations
- H2: How it works
- H2: Related
@@ -3968,6 +3969,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- Headings:
- H2: What gets sandboxed
- H2: Modes, scope, and backend
- H2: Supported capability matrix
- H2: Docker backend
- H3: Sandboxed browser
- H2: SSH backend
@@ -9204,6 +9206,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Gateway and E2E
- H2: Full Docker suite (pnpm test:docker:all)
- H3: Notable Docker lanes
- H3: Sandbox compatibility lanes
- H2: Local PR gate
- H2: Test performance tooling
- H2: Benchmarks
+36
View File
@@ -21,6 +21,7 @@ workspace sync mode.
- OpenShell plugin installed (`openclaw plugins install @openclaw/openshell-sandbox`)
- `openshell` CLI on `PATH` (or a custom path via
`plugins.entries.openshell.config.command`)
- OpenSSH client available on the Gateway host
- An OpenShell account with sandbox access
- OpenClaw Gateway running on the host
@@ -253,6 +254,18 @@ remote workspace for that scope, and the next use seeds a fresh one from
local. For `mirror` mode, recreate mainly resets the remote execution
environment since local stays canonical.
OpenClaw keeps a registered sandbox's shipped legacy runtime name after an
upgrade so its remote workspace remains addressable. Recreating that scope
deletes the legacy runtime; the next use creates the current 19-character
runtime name.
OpenShell v0.0.92 can still locate a sandbox record created by v0.0.68, but a
Docker-backed sandbox may remain in a non-Ready phase after the gateway
upgrade. OpenClaw preserves the registered runtime identity, refuses to create
a replacement implicitly, and reports the scoped `openclaw sandbox recreate`
command. Treat that recreation as destructive in `remote` mode because the
remote workspace is canonical.
Recreate after changing any of:
- `agents.defaults.sandbox.backend`
@@ -267,6 +280,26 @@ canonical paths (via realpath) before every read, write, mkdir, remove, and
rename, rejecting mid-path symlinks. A symlink swap or remounted workspace
cannot redirect file access outside the mirrored tree.
## Custom image contract
The OpenShell source image owns the remote operating system and package set.
OpenClaw does not apply Docker image, root-filesystem, network, user, or package
settings to this backend.
Custom images used with the OpenClaw filesystem bridge must provide:
- `/bin/sh`
- `python3` or `python` for pinned write, edit, rename, and remove operations
- GNU-compatible `stat` and `find`
- standard `mkdir`, `mv`, `rm`, and `rmdir` utilities
Package installation and private certificate roots must be included in the
source image or installed from inside the sandbox. The selected OpenShell
policy must permit the required network destinations, and the sandbox user and
filesystem must permit the writes. `sandbox.docker.network`,
`sandbox.docker.readOnlyRoot`, `sandbox.docker.user`, and
`sandbox.docker.setupCommand` do not configure OpenShell.
## Current limitations
- Sandbox browser is not supported on the OpenShell backend.
@@ -274,6 +307,9 @@ cannot redirect file access outside the mirrored tree.
if binds are configured.
- Docker-specific runtime knobs under `sandbox.docker.*` (other than `env`)
apply only to the Docker backend.
- Native plugin code and Gateway RPC stay on the Gateway host. Plugin-owned and
MCP tools are available to sandboxed sessions only when sandbox tool policy
allows them.
## How it works
+63
View File
@@ -56,12 +56,65 @@ Three independent settings control sandbox behavior:
| **Bind mounts** | `docker.binds` | N/A | N/A |
| **Best for** | Local dev, full isolation | Offloading to a remote machine | Managed remote sandboxes with optional two-way sync |
## Supported capability matrix
Sandbox backends isolate tool execution. They do not move the Gateway, native
plugins, or control-plane RPC into the sandbox.
| Capability | Docker | SSH | OpenShell |
| -------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------- |
| Shell and child processes | Supported inside the container | Supported on the remote host | Supported inside the managed sandbox |
| File tools | Supported through the container filesystem bridge | Supported through the SSH filesystem bridge | Supported through the SSH bridge in `mirror` or `remote` mode |
| Workspace access | `none`, `ro`, and `rw` | `none`, `ro`, and `rw` | `none`, `ro`, and `rw` |
| Network restriction | `docker.network`; defaults to `"none"` | Controlled by the remote host | Controlled by the selected OpenShell policy |
| Sandboxed browser | Supported in a separate browser container | Not supported | Not supported |
| Additional host folders | `docker.binds` with explicit `:ro` or `:rw` | Not supported as mounts; seed or copy files instead | Not supported as mounts; use workspace sync or remote files |
| Packages and runtimes | Bake a custom image, or use `setupCommand` with the required privileges | Provision them on the remote host | Include them in the source image or install when policy permits |
| Private certificate roots | Bake or mount them into the image and configure the consuming runtime | Configure the remote host trust store | Include them in the source image or configure them inside sandbox |
| Plugin and MCP tool access | Gateway-side execution, additionally gated by sandbox tool policy | Gateway-side execution, additionally gated by policy | Gateway-side execution, additionally gated by sandbox tool policy |
Native plugins remain in-process with the Gateway and share its trust boundary.
Sandboxed sessions can use plugin-owned and MCP tools only when normal tool
policy and `tools.sandbox.tools` both allow them. See
[MCP and plugin tools inside sandbox tool policy](/gateway/config-tools#mcp-and-plugin-tools-inside-sandbox-tool-policy)
and [Plugin execution model](/plugins/architecture#execution-model).
## Docker backend
Docker is the default backend once sandboxing is enabled. It runs tools and sandbox browsers locally through the Docker daemon socket (`/var/run/docker.sock`); isolation comes from Docker namespaces.
Defaults: `network: "none"` (no egress), `readOnlyRoot: true`, `capDrop: ["ALL"]`, image `openclaw-sandbox:bookworm-slim`.
This explicit configuration keeps the agent workspace read-only and preserves
the default restricted runtime posture:
```json5
{
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "docker",
scope: "session",
workspaceAccess: "ro",
docker: {
image: "openclaw-sandbox:bookworm-slim",
readOnlyRoot: true,
tmpfs: ["/tmp", "/var/tmp", "/run"],
network: "none",
capDrop: ["ALL"],
},
},
},
},
}
```
OpenClaw also creates Docker sandbox containers with an init process and
`no-new-privileges`. With `workspaceAccess: "ro"`, the agent workspace is
mounted read-only at `/agent`; write operations to the agent workspace are
rejected, while the configured tmpfs paths remain writable.
To expose host GPUs, set `agents.defaults.sandbox.docker.gpus` (or the per-agent override) to a value like `"all"` or `"device=GPU-uuid"`. This is passed to Docker's `--gpus` flag and requires a compatible host runtime such as NVIDIA Container Toolkit.
<Warning>
@@ -351,6 +404,16 @@ If you installed OpenClaw via `npm install -g openclaw`, use the inline `docker
By default, Docker sandbox containers run with **no network**. Override with `agents.defaults.sandbox.docker.network`.
<Note>
Package installation and certificate-store changes are image provisioning, not
normal sandbox-turn behavior. The defaults deliberately combine no network,
a read-only root filesystem, and a non-root image user, so an in-turn package
install should fail. Prefer a custom image that already contains packages and
private certificate roots. If a Node process needs a private CA, also configure
the CA path for Node, for example with `NODE_EXTRA_CA_CERTS`, through the custom
image or `sandbox.docker.env`.
</Note>
<AccordionGroup>
<Accordion title="Sandbox browser Chromium defaults">
The bundled sandbox browser image applies conservative Chromium startup flags for containerized workloads:
+11
View File
@@ -164,6 +164,17 @@ Other behavior: the runner preflights Docker by default, cleans stale OpenClaw E
| `pnpm test:docker:update-migration` | Published-upgrade survivor harness in the `plugin-deps-cleanup` scenario, starting at `openclaw@2026.4.23` by default. The `Update Migration` workflow expands this with `baselines=all-since-2026.4.23` to prove configured-plugin dependency cleanup outside Full Release CI. |
| `pnpm test:docker:plugins` | Install/update smoke for local path, `file:`, npm registry packages with hoisted dependencies, git moving refs, ClawHub fixtures, marketplace updates, and Claude-bundle enable/inspect. |
### Sandbox compatibility lanes
| Command | Verifies |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `pnpm test:e2e:openshell` | Real OpenShell gateway, custom image build, managed sandbox lifecycle, SSH execution, remote filesystem bridge, seeded workspace, and deny/allow network policies. |
| `pnpm test:docker:package-install` | Packed OpenClaw npm artifact installation into a clean global prefix, then CLI version and help startup from the installed package. |
| `pnpm test:docker:openai-web-search-minimal` | Mocked TLS endpoint with a private test CA, isolated Gateway startup, and web-search request handling through the configured certificate trust path. |
| `pnpm test:docker:browser-cdp-snapshot` | Chromium startup, raw CDP connectivity, isolated Gateway browser commands, doctor output, and accessibility snapshot roles. |
| `pnpm test:docker:kitchen-sink-rpc` | Installed plugin commands and catalog tools, read-only Gateway RPC traversal, authentication boundaries, channel lifecycle, and resource ceilings. |
| `pnpm test:docker:kitchen-sink-plugin` | Packaged and registry plugin install flows, plugin execution, expected unsupported-version failures, ClawHub fallback, and npm-to-ClawHub migration. |
## Local PR gate
For local PR land/gate checks, run:
+80 -36
View File
@@ -4,7 +4,6 @@ import fs from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { createSandboxTestContext } from "openclaw/plugin-sdk/test-fixtures";
import {
createSandboxBrowserConfig,
@@ -22,7 +21,6 @@ const OPENCLAW_OPENSHELL_COMMAND =
const OPENCLAW_OPENSHELL_CONFIG_HOME =
process.env.OPENCLAW_E2E_OPENSHELL_CONFIG_HOME?.trim() || null;
const OPENCLAW_OPENSHELL_HOST_IP = process.env.OPENCLAW_E2E_OPENSHELL_HOST_IP?.trim() || null;
const ANSI_ESCAPE_RE = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-?]*[ -/]*[@-~]`, "gu");
const CUSTOM_IMAGE_DOCKERFILE = `FROM python:3.13-slim
@@ -129,6 +127,35 @@ async function commandAvailable(command: string): Promise<boolean> {
}
}
function parseActiveLocalOpenShellGateway(stdout: string): string | null {
let gateways: unknown;
try {
gateways = JSON.parse(stdout);
} catch {
return null;
}
if (!Array.isArray(gateways)) {
return null;
}
for (const gateway of gateways) {
if (
typeof gateway !== "object" ||
gateway === null ||
gateway.active !== true ||
typeof gateway.name !== "string" ||
typeof gateway.endpoint !== "string"
) {
continue;
}
if (
/^(?:https?:\/\/)?(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?(?:\/|$)/u.test(gateway.endpoint)
) {
return gateway.name;
}
}
return null;
}
async function activeOpenShellGateway(
command: string,
env: NodeJS.ProcessEnv = process.env,
@@ -136,7 +163,7 @@ async function activeOpenShellGateway(
try {
const result = await runCommand({
command,
args: ["gateway", "list"],
args: ["gateway", "list", "--output", "json"],
env,
allowFailure: true,
timeoutMs: 20_000,
@@ -144,39 +171,18 @@ async function activeOpenShellGateway(
if (result.code !== 0) {
return null;
}
const output = `${result.stdout}\n${result.stderr}`.replace(ANSI_ESCAPE_RE, "");
for (const line of output.split(/\r?\n/u)) {
const match = line.match(/\*\s+(\S+)/u);
if (match) {
const gateway = expectDefined(match[1], "OpenShell gateway name");
const info = await runCommand({
command,
args: ["gateway", "info", "--gateway", gateway],
env,
allowFailure: true,
timeoutMs: 20_000,
});
const endpoint = `${info.stdout}\n${info.stderr}`
.replace(ANSI_ESCAPE_RE, "")
.match(/Gateway endpoint:\s+(\S+)/u)?.[1];
if (
info.code === 0 &&
endpoint &&
/^(?:https?:\/\/)?(?:127\.0\.0\.1|localhost)(?::\d+)?(?:\/|$)/u.test(endpoint)
) {
const status = await runCommand({
command,
args: ["--gateway", gateway, "sandbox", "list"],
env,
allowFailure: true,
timeoutMs: 20_000,
});
return status.code === 0 ? gateway : null;
}
return null;
}
const gateway = parseActiveLocalOpenShellGateway(result.stdout);
if (!gateway) {
return null;
}
return null;
const status = await runCommand({
command,
args: ["--gateway", gateway, "sandbox", "list"],
env,
allowFailure: true,
timeoutMs: 20_000,
});
return status.code === 0 ? gateway : null;
} catch {
return null;
}
@@ -430,6 +436,43 @@ async function runBackendExec(params: {
}
}
describe("OpenShell gateway discovery", () => {
it("selects the active local gateway from structured output", () => {
expect(
parseActiveLocalOpenShellGateway(
JSON.stringify([
{
name: "remote",
endpoint: "https://gateway.example.com",
active: false,
},
{
name: "openshell",
endpoint: "https://127.0.0.1:17670",
active: true,
},
]),
),
).toBe("openshell");
});
it.each([
["malformed output", "not json"],
[
"active remote gateway",
JSON.stringify([
{
name: "remote",
endpoint: "https://gateway.example.com",
active: true,
},
]),
],
])("rejects %s", (_name, output) => {
expect(parseActiveLocalOpenShellGateway(output)).toBeNull();
});
});
describe("openshell sandbox backend e2e", () => {
it.runIf(process.platform !== "win32" && OPENCLAW_OPENSHELL_E2E)(
"creates a remote-canonical sandbox through OpenShell and executes over SSH",
@@ -468,7 +511,8 @@ describe("openshell sandbox backend e2e", () => {
const allowPolicyPath = path.join(rootDir, "allow-policy.yaml");
const scopeSuffix = `${process.pid}-${Date.now()}`;
const scopeKey = `session:openshell-e2e-deny:${scopeSuffix}`;
const allowSandboxName = `openclaw-policy-allow-${scopeSuffix}`;
const testRunId = `${process.pid.toString(36)}${Date.now().toString(36)}`;
const allowSandboxName = `oc-a-${testRunId.slice(-14)}`;
let hostPolicyServer: HostPolicyServer | null | undefined;
const sandboxCfg = {
mode: "all" as const,
@@ -147,9 +147,8 @@ describe("openshell backend exec workdir validation", () => {
cwd: workspaceDir,
});
expect(backend.runtimeId).toMatch(/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/);
expect(backend.runtimeId).toContain("somalley-alice");
expect(backend.runtimeId).not.toContain("_");
expect(backend.runtimeId.length).toBeLessThanOrEqual(63);
expect(backend.runtimeId).toMatch(/^oc-[a-f0-9]{16}$/u);
expect(backend.runtimeId).toHaveLength(19);
expect(execSpec.env.OPENAI_API_KEY).toBeUndefined();
expect(execSpec.env.ANTHROPIC_API_KEY).toBeUndefined();
expect(execSpec.env.LANG).toBe("en_US.UTF-8");
+123 -1
View File
@@ -1,4 +1,5 @@
// Openshell plugin module implements backend behavior.
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type {
@@ -16,6 +17,7 @@ import {
resolvePreferredOpenClawTmpDir,
runSshSandboxCommand,
sanitizeEnvVars,
shellEscape,
withTempWorkspace,
} from "openclaw/plugin-sdk/sandbox";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -258,7 +260,11 @@ async function createOpenShellSandboxBackend(params: {
throw new Error("OpenShell sandbox backend does not support sandbox.docker.binds.");
}
const sandboxName = buildOpenShellSandboxName(params.createParams.scopeKey);
const resolvedSandboxName = resolveOpenShellSandboxName({
scopeKey: params.createParams.scopeKey,
registeredRuntimeIds: params.createParams.registeredRuntimeIds,
});
const sandboxName = resolvedSandboxName.sandboxName;
const execContext: OpenShellExecContext = {
config: params.pluginConfig,
sandboxName,
@@ -266,6 +272,7 @@ async function createOpenShellSandboxBackend(params: {
const impl = new OpenShellSandboxBackendImpl({
createParams: params.createParams,
execContext,
legacyRuntimeAdopted: resolvedSandboxName.legacyRuntimeAdopted,
remoteWorkspaceDir: params.pluginConfig.remoteWorkspaceDir,
remoteAgentWorkspaceDir: params.pluginConfig.remoteAgentWorkspaceDir,
});
@@ -334,6 +341,7 @@ class OpenShellSandboxBackendImpl {
private readonly params: {
createParams: CreateSandboxBackendParams;
execContext: OpenShellExecContext;
legacyRuntimeAdopted: boolean;
remoteWorkspaceDir: string;
remoteAgentWorkspaceDir: string;
},
@@ -710,8 +718,22 @@ class OpenShellSandboxBackendImpl {
cwd: this.params.createParams.workspaceDir,
});
if (getResult.code === 0) {
if (this.params.legacyRuntimeAdopted) {
const phase = await this.resolveLegacyRuntimePhase();
if (!phase) {
throw this.buildLegacyRuntimeUnavailableError(
"OpenShell did not report a lifecycle phase for this sandbox.",
);
}
if (phase !== "Ready") {
throw this.buildLegacyRuntimeUnavailableError(`OpenShell reports phase "${phase}".`);
}
}
return;
}
if (this.params.legacyRuntimeAdopted) {
throw this.buildLegacyRuntimeUnavailableError(getResult.stderr.trim());
}
const createArgs = [
"sandbox",
"create",
@@ -742,6 +764,57 @@ class OpenShellSandboxBackendImpl {
this.remoteSeedPending = true;
}
private async resolveLegacyRuntimePhase(): Promise<string | undefined> {
const pageSize = 100;
for (let offset = 0; ; offset += pageSize) {
const listResult = await runOpenShellCli({
context: this.params.execContext,
args: [
"sandbox",
"list",
"--limit",
String(pageSize),
"--offset",
String(offset),
"--output",
"json",
],
cwd: this.params.createParams.workspaceDir,
});
if (listResult.code !== 0) {
throw this.buildLegacyRuntimeUnavailableError(listResult.stderr.trim());
}
const page = parseOpenShellSandboxPhasePage(
listResult.stdout,
this.params.execContext.sandboxName,
);
if (!page) {
throw this.buildLegacyRuntimeUnavailableError(
"OpenShell returned malformed sandbox lifecycle data.",
);
}
if (page.phase) {
return page.phase;
}
if (page.count < pageSize) {
return undefined;
}
}
}
private buildLegacyRuntimeUnavailableError(detail: string): Error {
const recreateCommand = `openclaw sandbox recreate --session ${shellEscape(this.params.createParams.scopeKey)}`;
return new Error(
[
`Registered legacy OpenShell sandbox "${this.params.execContext.sandboxName}" is not usable.`,
detail,
`OpenClaw will not recreate this retired runtime name. Run \`${recreateCommand}\` to migrate this scope to the current naming format.`,
]
.filter(Boolean)
.join(" "),
);
}
private async syncWorkspaceToRemote(): Promise<void> {
await this.runRemoteShellScriptInternal({
script: 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +',
@@ -893,6 +966,16 @@ function resolveOpenShellPluginConfigFromConfig(
function buildOpenShellSandboxName(scopeKey: string): string {
const trimmed = scopeKey.trim() || "session";
// 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);
return `oc-${hash}`;
}
function buildLegacyOpenShellSandboxName(scopeKey: string): string {
const trimmed = scopeKey.trim() || "session";
// Keep this byte-for-byte compatible with the naming contract shipped before
// the 19-character OpenShell limit; registered remote workspaces depend on it.
const safe = normalizeLowercaseStringOrEmpty(trimmed)
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "")
@@ -904,6 +987,45 @@ function buildOpenShellSandboxName(scopeKey: string): string {
return `openclaw-${safe || "session"}-${hash.toString(16).slice(0, 8)}`;
}
function resolveOpenShellSandboxName(params: {
scopeKey: string;
registeredRuntimeIds?: readonly string[];
}): { sandboxName: string; legacyRuntimeAdopted: boolean } {
const sandboxName = buildOpenShellSandboxName(params.scopeKey);
if (params.registeredRuntimeIds?.includes(sandboxName)) {
return { sandboxName, legacyRuntimeAdopted: false };
}
const legacySandboxName = buildLegacyOpenShellSandboxName(params.scopeKey);
if (params.registeredRuntimeIds?.includes(legacySandboxName)) {
return { sandboxName: legacySandboxName, legacyRuntimeAdopted: true };
}
return { sandboxName, legacyRuntimeAdopted: false };
}
function parseOpenShellSandboxPhasePage(
stdout: string,
sandboxName: string,
): { count: number; phase?: string } | undefined {
try {
const parsed: unknown = JSON.parse(stdout);
if (!Array.isArray(parsed)) {
return undefined;
}
for (const entry of parsed) {
if (!entry || typeof entry !== "object") {
continue;
}
const record = entry as Record<string, unknown>;
if (record.name === sandboxName && typeof record.phase === "string") {
return { count: parsed.length, phase: record.phase };
}
}
return { count: parsed.length };
} catch {
return undefined;
}
}
function resolveRemoteMaterializedSkillsWorkspaceDir(remoteWorkspaceDir: string): string {
const root = remoteWorkspaceDir.replace(/\\/g, "/").replace(/\/+$/, "") || "/";
return path.posix.join(root, ...MATERIALIZED_SKILLS_REMOTE_PARTS);
@@ -222,6 +222,138 @@ describe("openshell backend manager", () => {
afterAll(uninstallOpenShellBackendMocks);
beforeEach(resetOpenShellBackendMocks);
it("builds deterministic OpenShell-compatible sandbox names", async () => {
const factory = createOpenShellSandboxBackendFactory({
pluginConfig: resolveOpenShellPluginConfig({ command: "openshell" }),
});
const createBackend = async (scopeKey: string, registeredRuntimeIds?: readonly string[]) =>
await factory({
sessionKey: `${scopeKey}:turn`,
scopeKey,
...(registeredRuntimeIds ? { registeredRuntimeIds } : {}),
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
cfg: createOpenShellBackendSandboxConfig(),
});
const first = await createBackend("agent:main");
const repeated = await createBackend("agent:main");
const other = await createBackend("agent:other");
const legacyRuntimeId = "openclaw-agent-main-25bffc4d";
const adoptedLegacy = await createBackend("agent:main", [legacyRuntimeId]);
const punctuationLegacyRuntimeId = "openclaw-agent-foo-bar-baz-ab401a99";
const adoptedPunctuationLegacy = await createBackend("agent:foo_bar.baz", [
punctuationLegacyRuntimeId,
]);
const ignoresUnknown = await createBackend("agent:main", ["unrelated-runtime"]);
const prefersCurrent = await createBackend("agent:main", [legacyRuntimeId, first.runtimeId]);
expect(first.runtimeId).toMatch(/^oc-[a-f0-9]{16}$/u);
expect(first.runtimeId).toHaveLength(19);
expect(repeated.runtimeId).toBe(first.runtimeId);
expect(other.runtimeId).not.toBe(first.runtimeId);
expect(adoptedLegacy.runtimeId).toBe(legacyRuntimeId);
expect(adoptedPunctuationLegacy.runtimeId).toBe(punctuationLegacyRuntimeId);
expect(ignoresUnknown.runtimeId).toBe(first.runtimeId);
expect(prefersCurrent.runtimeId).toBe(first.runtimeId);
});
it("does not recreate an unreachable registered legacy sandbox name", async () => {
const scopeKey = "agent:main'$(touch /tmp/pwn)";
const legacyRuntimeId = "openclaw-agent-main-touch-tmp-pwn-87608e6a";
cliMocks.runOpenShellCli.mockResolvedValue({
code: 1,
stdout: "",
stderr: "sandbox not found",
});
const factory = createOpenShellSandboxBackendFactory({
pluginConfig: resolveOpenShellPluginConfig({ command: "openshell", mode: "remote" }),
});
const backend = await factory({
sessionKey: `${scopeKey}:turn`,
scopeKey,
registeredRuntimeIds: [legacyRuntimeId],
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
cfg: createOpenShellBackendSandboxConfig(),
});
await expect(
backend.runShellCommand({
script: "true",
}),
).rejects.toThrow(
`Run \`openclaw sandbox recreate --session ${shellEscape(scopeKey)}\` to migrate this scope`,
);
expect(cliMocks.runOpenShellCli).toHaveBeenCalledTimes(1);
expect(cliMocks.runOpenShellCli).not.toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining(["create"]),
}),
);
});
it("does not execute a registered legacy sandbox that is no longer ready", async () => {
const scopeKey = "agent:main";
const legacyRuntimeId = "openclaw-agent-main-25bffc4d";
cliMocks.runOpenShellCli
.mockResolvedValueOnce({
code: 0,
stdout: "sandbox detail",
stderr: "",
})
.mockResolvedValueOnce({
code: 0,
stdout: JSON.stringify(
Array.from({ length: 100 }, (_, index) => ({
name: `other-${index}`,
phase: "Ready",
})),
),
stderr: "",
})
.mockResolvedValueOnce({
code: 0,
stdout: JSON.stringify([{ name: legacyRuntimeId, phase: "Error" }]),
stderr: "",
});
const factory = createOpenShellSandboxBackendFactory({
pluginConfig: resolveOpenShellPluginConfig({ command: "openshell", mode: "remote" }),
});
const backend = await factory({
sessionKey: `${scopeKey}:turn`,
scopeKey,
registeredRuntimeIds: [legacyRuntimeId],
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
cfg: createOpenShellBackendSandboxConfig(),
});
await expect(backend.runShellCommand({ script: "true" })).rejects.toThrow(
'OpenShell reports phase "Error".',
);
expect(cliMocks.runOpenShellCli).toHaveBeenNthCalledWith(2, {
context: expect.objectContaining({
sandboxName: legacyRuntimeId,
}),
args: ["sandbox", "list", "--limit", "100", "--offset", "0", "--output", "json"],
cwd: "/tmp/workspace",
});
expect(cliMocks.runOpenShellCli).toHaveBeenNthCalledWith(3, {
context: expect.objectContaining({
sandboxName: legacyRuntimeId,
}),
args: ["sandbox", "list", "--limit", "100", "--offset", "100", "--output", "json"],
cwd: "/tmp/workspace",
});
expect(cliMocks.runOpenShellCli).not.toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining(["create"]),
}),
);
expect(cliMocks.createOpenShellSshSession).not.toHaveBeenCalled();
});
it.runIf(process.platform !== "win32")(
"clears the materialized skills directory through the remote backend boundary",
async () => {
@@ -9,6 +9,7 @@ import { registerSandboxBackend } from "./sandbox/backend.js";
import { ensureSandboxWorkspaceForSession, resolveSandboxContext } from "./sandbox/context.js";
const updateRegistryMock = vi.hoisted(() => vi.fn());
const readRegisteredSandboxRuntimeIdsMock = vi.hoisted(() => vi.fn(async () => [] as string[]));
const syncSkillsToWorkspaceMock = vi.hoisted(() =>
vi.fn<() => Promise<SkillUsagePath[]>>(async () => []),
);
@@ -27,6 +28,7 @@ const browserProfilesMock = vi.hoisted(() => ({
}));
vi.mock("./sandbox/registry.js", () => ({
readRegisteredSandboxRuntimeIds: readRegisteredSandboxRuntimeIdsMock,
updateRegistry: updateRegistryMock,
}));
@@ -206,23 +208,25 @@ describe("resolveSandboxContext", () => {
it("resolves a registered non-docker backend", async () => {
resolveNodeExecEligibilityMock.mockClear();
const restore = registerSandboxBackend("test-backend", {
factory: async () => ({
id: "test-backend",
runtimeId: "test-runtime",
runtimeLabel: "Test Runtime",
workdir: "/runtime/workspace",
buildExecSpec: async () => ({
argv: ["test-backend", "exec"],
env: process.env,
stdinMode: "pipe-closed",
}),
runShellCommand: async () => ({
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
code: 0,
}),
readRegisteredSandboxRuntimeIdsMock.mockResolvedValue(["registered-runtime"]);
const backendFactory = vi.fn(async () => ({
id: "test-backend",
runtimeId: "test-runtime",
runtimeLabel: "Test Runtime",
workdir: "/runtime/workspace",
buildExecSpec: async () => ({
argv: ["test-backend", "exec"],
env: process.env,
stdinMode: "pipe-closed" as const,
}),
runShellCommand: async () => ({
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
code: 0,
}),
}));
const restore = registerSandboxBackend("test-backend", {
factory: backendFactory,
resolveWorkdir: () => "/runtime/workspace",
});
try {
@@ -251,6 +255,11 @@ describe("resolveSandboxContext", () => {
expect(result?.runtimeId).toBe("test-runtime");
expect(result?.containerName).toBe("test-runtime");
expect(result?.backend?.id).toBe("test-backend");
expect(backendFactory).toHaveBeenCalledWith(
expect.objectContaining({
registeredRuntimeIds: ["registered-runtime"],
}),
);
expect(resolveNodeExecEligibilityMock).toHaveBeenCalledWith(
expect.objectContaining({
execOverrides: { host: "node", node: "build-node", security: "allowlist" },
@@ -264,6 +273,7 @@ describe("resolveSandboxContext", () => {
});
expect(workspace?.containerWorkdir).toBe("/runtime/workspace");
} finally {
readRegisteredSandboxRuntimeIdsMock.mockResolvedValue([]);
restore();
}
}, 15_000);
+2
View File
@@ -33,6 +33,8 @@ export type SandboxBackendManager = {
export type CreateSandboxBackendParams = {
sessionKey: string;
scopeKey: string;
/** Runtime IDs already registered for this backend and scope, newest first. */
registeredRuntimeIds?: readonly string[];
workspaceDir: string;
agentWorkspaceDir: string;
skillsWorkspaceDir?: string;
+6 -1
View File
@@ -21,7 +21,7 @@ import { ensureSandboxBrowser } from "./browser.js";
import { resolveSandboxConfigForAgent } from "./config.js";
import { resolveSandboxDockerUser } from "./docker-user.js";
import { createSandboxFsBridge } from "./fs-bridge.js";
import { updateRegistry } from "./registry.js";
import { readRegisteredSandboxRuntimeIds, updateRegistry } from "./registry.js";
import { resolveSandboxRuntimeStatus } from "./runtime-status.js";
import { assertSshSandboxSecretOwnerAvailable } from "./secret-owner.js";
import { resolveSandboxWorkspaceLayoutPaths } from "./shared.js";
@@ -227,9 +227,14 @@ export async function resolveSandboxContext(params: {
const resolvedCfg = docker === cfg.docker ? cfg : { ...cfg, docker };
const backendFactory = requireSandboxBackendFactory(resolvedCfg.backend);
const registeredRuntimeIds = await readRegisteredSandboxRuntimeIds({
backendId: resolvedCfg.backend,
scopeKey,
});
const backend = await backendFactory({
sessionKey: rawSessionKey,
scopeKey,
...(registeredRuntimeIds.length > 0 ? { registeredRuntimeIds } : {}),
workspaceDir,
agentWorkspaceDir,
skillsWorkspaceDir,
+43
View File
@@ -43,6 +43,7 @@ import { hashTextSha256 } from "./hash.js";
import {
migrateLegacySandboxRegistryFiles,
readBrowserRegistry,
readRegisteredSandboxRuntimeIds,
readRegistry,
readRegistryEntry,
removeBrowserRegistryEntry,
@@ -340,6 +341,48 @@ describe("registry race safety", () => {
await expect(readRegistryEntry("missing-container")).resolves.toBeNull();
});
it("reads registered runtime IDs for one backend and scope newest first", async () => {
await updateRegistry(
containerEntry({
containerName: "openshell-older",
backendId: "openshell",
sessionKey: "agent:main",
lastUsedAtMs: 10,
}),
);
await updateRegistry(
containerEntry({
containerName: "openshell-newer",
backendId: "openshell",
sessionKey: "agent:main",
lastUsedAtMs: 20,
}),
);
await updateRegistry(
containerEntry({
containerName: "docker-same-scope",
backendId: "docker",
sessionKey: "agent:main",
lastUsedAtMs: 30,
}),
);
await updateRegistry(
containerEntry({
containerName: "openshell-other-scope",
backendId: "openshell",
sessionKey: "agent:other",
lastUsedAtMs: 40,
}),
);
await expect(
readRegisteredSandboxRuntimeIds({
backendId: "openshell",
scopeKey: "agent:main",
}),
).resolves.toEqual(["openshell-newer", "openshell-older"]);
});
it("keeps both container updates under concurrent writes", async () => {
await Promise.all([
updateRegistry(containerEntry({ containerName: "container-a" })),
+27 -6
View File
@@ -231,7 +231,10 @@ function rowToUpdate(row: SandboxRegistryInsert): SandboxRegistryUpdate {
return update;
}
function readRegistryRows(kind: SandboxRegistryKind): SandboxRegistryRow[] {
function readRegistryRows(
kind: SandboxRegistryKind,
filter?: { backendId: string; scopeKey: string },
): SandboxRegistryRow[] {
if (!fsSync.existsSync(resolveOpenClawStateSqlitePath(process.env))) {
return [];
}
@@ -241,13 +244,20 @@ function readRegistryRows(kind: SandboxRegistryKind): SandboxRegistryRow[] {
return [];
}
const stateDb = getSandboxRegistryKysely(db);
let query = stateDb
.selectFrom("sandbox_registry_entries")
.selectAll()
.where("registry_kind", "=", kind);
if (filter) {
query = query
.where("session_key", "=", filter.scopeKey)
.where("backend_id", "=", filter.backendId);
}
return executeSqliteQuerySync(
db,
stateDb
.selectFrom("sandbox_registry_entries")
.selectAll()
.where("registry_kind", "=", kind)
.orderBy("container_name", "asc"),
filter
? query.orderBy("last_used_at_ms", "desc").orderBy("container_name", "asc")
: query.orderBy("container_name", "asc"),
).rows;
});
}
@@ -704,6 +714,17 @@ export async function readRegistryEntry(
return entry ? normalizeSandboxRegistryEntry(entry) : null;
}
/** Reads registered runtime IDs for one backend-owned sandbox scope, newest first. */
export async function readRegisteredSandboxRuntimeIds(params: {
backendId: string;
scopeKey: string;
}): Promise<string[]> {
return readRegistryRows("container", params)
.map((row) => rowToContainerEntry(row))
.filter((entry): entry is SandboxRegistryEntry => entry != null)
.map((entry) => entry.containerName);
}
/** Creates or updates one sandbox runtime registry entry, preserving immutable creation fields. */
export async function updateRegistry(entry: SandboxRegistryEntry) {
runOpenClawStateWriteTransaction(({ db }) => {