diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index 7582428548e1..ee85164bdbdf 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -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 diff --git a/docs/docs_map.md b/docs/docs_map.md index 78dfa61cbfb9..57d307a556a4 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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 diff --git a/docs/gateway/openshell.md b/docs/gateway/openshell.md index e1015ffffcc1..c4c6c7ec8a23 100644 --- a/docs/gateway/openshell.md +++ b/docs/gateway/openshell.md @@ -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 diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index f939f0dd3df7..7c6c2d6812a7 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -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. @@ -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`. + +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`. + + The bundled sandbox browser image applies conservative Chromium startup flags for containerized workloads: diff --git a/docs/reference/test.md b/docs/reference/test.md index 8452c4ddbf65..4d4c6fdb0e73 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -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: diff --git a/extensions/openshell/src/backend.e2e.test.ts b/extensions/openshell/src/backend.e2e.test.ts index 9d97c13371e1..16c3f22d0e9d 100644 --- a/extensions/openshell/src/backend.e2e.test.ts +++ b/extensions/openshell/src/backend.e2e.test.ts @@ -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 { } } +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, diff --git a/extensions/openshell/src/backend.exec-workdir.test.ts b/extensions/openshell/src/backend.exec-workdir.test.ts index 9a048ff887a2..6c2065a49c61 100644 --- a/extensions/openshell/src/backend.exec-workdir.test.ts +++ b/extensions/openshell/src/backend.exec-workdir.test.ts @@ -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"); diff --git a/extensions/openshell/src/backend.ts b/extensions/openshell/src/backend.ts index f9a4f195659e..141ceb198636 100644 --- a/extensions/openshell/src/backend.ts +++ b/extensions/openshell/src/backend.ts @@ -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 { + 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 { 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; + 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); diff --git a/extensions/openshell/src/openshell-core.test.ts b/extensions/openshell/src/openshell-core.test.ts index 799a2b7bbe5c..f6a2a5a68a4c 100644 --- a/extensions/openshell/src/openshell-core.test.ts +++ b/extensions/openshell/src/openshell-core.test.ts @@ -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 () => { diff --git a/src/agents/sandbox.resolveSandboxContext.test.ts b/src/agents/sandbox.resolveSandboxContext.test.ts index 324f15b7d9ce..8447d22239eb 100644 --- a/src/agents/sandbox.resolveSandboxContext.test.ts +++ b/src/agents/sandbox.resolveSandboxContext.test.ts @@ -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>(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); diff --git a/src/agents/sandbox/backend.types.ts b/src/agents/sandbox/backend.types.ts index f79c73ff90a8..06334709c636 100644 --- a/src/agents/sandbox/backend.types.ts +++ b/src/agents/sandbox/backend.types.ts @@ -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; diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts index e515c8197f4f..158ffb4a7722 100644 --- a/src/agents/sandbox/context.ts +++ b/src/agents/sandbox/context.ts @@ -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, diff --git a/src/agents/sandbox/registry.test.ts b/src/agents/sandbox/registry.test.ts index 924f7b9616f8..74a0e2ee1b8d 100644 --- a/src/agents/sandbox/registry.test.ts +++ b/src/agents/sandbox/registry.test.ts @@ -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" })), diff --git a/src/agents/sandbox/registry.ts b/src/agents/sandbox/registry.ts index 6624ea8f7493..2d1371cb365c 100644 --- a/src/agents/sandbox/registry.ts +++ b/src/agents/sandbox/registry.ts @@ -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 { + 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 }) => {