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