diff --git a/CHANGELOG.md b/CHANGELOG.md index 232cbb167a1..98208595e0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc. - Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus. - secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant. +- Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend in mirror mode, and make sandbox list/recreate/prune backend-aware instead of Docker-only. ### Fixes diff --git a/extensions/openshell/index.ts b/extensions/openshell/index.ts new file mode 100644 index 00000000000..910abe31b44 --- /dev/null +++ b/extensions/openshell/index.ts @@ -0,0 +1,30 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { registerSandboxBackend } from "openclaw/plugin-sdk/core"; +import { + createOpenShellSandboxBackendFactory, + createOpenShellSandboxBackendManager, +} from "./src/backend.js"; +import { createOpenShellPluginConfigSchema, resolveOpenShellPluginConfig } from "./src/config.js"; + +const plugin = { + id: "openshell", + name: "OpenShell Sandbox", + description: "OpenShell-backed sandbox runtime for agent exec and file tools.", + configSchema: createOpenShellPluginConfigSchema(), + register(api: OpenClawPluginApi) { + if (api.registrationMode !== "full") { + return; + } + const pluginConfig = resolveOpenShellPluginConfig(api.pluginConfig); + registerSandboxBackend("openshell", { + factory: createOpenShellSandboxBackendFactory({ + pluginConfig, + }), + manager: createOpenShellSandboxBackendManager({ + pluginConfig, + }), + }); + }, +}; + +export default plugin; diff --git a/extensions/openshell/openclaw.plugin.json b/extensions/openshell/openclaw.plugin.json new file mode 100644 index 00000000000..cf3f9ad5579 --- /dev/null +++ b/extensions/openshell/openclaw.plugin.json @@ -0,0 +1,99 @@ +{ + "id": "openshell", + "name": "OpenShell Sandbox", + "description": "Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution.", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "command": { + "type": "string" + }, + "gateway": { + "type": "string" + }, + "gatewayEndpoint": { + "type": "string" + }, + "from": { + "type": "string" + }, + "policy": { + "type": "string" + }, + "providers": { + "type": "array", + "items": { + "type": "string" + } + }, + "gpu": { + "type": "boolean" + }, + "autoProviders": { + "type": "boolean" + }, + "remoteWorkspaceDir": { + "type": "string" + }, + "remoteAgentWorkspaceDir": { + "type": "string" + }, + "timeoutSeconds": { + "type": "number", + "minimum": 1 + } + } + }, + "uiHints": { + "command": { + "label": "OpenShell Command", + "help": "Path or command name for the openshell CLI." + }, + "gateway": { + "label": "Gateway Name", + "help": "Optional OpenShell gateway name passed as --gateway." + }, + "gatewayEndpoint": { + "label": "Gateway Endpoint", + "help": "Optional OpenShell gateway endpoint passed as --gateway-endpoint." + }, + "from": { + "label": "Sandbox Source", + "help": "OpenShell sandbox source for first-time create. Defaults to openclaw." + }, + "policy": { + "label": "Policy File", + "help": "Optional path to a custom OpenShell sandbox policy YAML." + }, + "providers": { + "label": "Providers", + "help": "Provider names to attach when a sandbox is created." + }, + "gpu": { + "label": "GPU", + "help": "Request GPU resources when creating the sandbox.", + "advanced": true + }, + "autoProviders": { + "label": "Auto-create Providers", + "help": "When enabled, pass --auto-providers during sandbox create.", + "advanced": true + }, + "remoteWorkspaceDir": { + "label": "Remote Workspace Dir", + "help": "Primary writable workspace inside the OpenShell sandbox.", + "advanced": true + }, + "remoteAgentWorkspaceDir": { + "label": "Remote Agent Dir", + "help": "Mirror path for the real agent workspace when workspaceAccess is read-only.", + "advanced": true + }, + "timeoutSeconds": { + "label": "Command Timeout Seconds", + "help": "Timeout for openshell CLI operations such as create/upload/download.", + "advanced": true + } + } +} diff --git a/extensions/openshell/package.json b/extensions/openshell/package.json new file mode 100644 index 00000000000..464c749ea34 --- /dev/null +++ b/extensions/openshell/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/openshell-sandbox", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw OpenShell sandbox backend", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/openshell/src/backend.test.ts b/extensions/openshell/src/backend.test.ts new file mode 100644 index 00000000000..2999599c648 --- /dev/null +++ b/extensions/openshell/src/backend.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const cliMocks = vi.hoisted(() => ({ + runOpenShellCli: vi.fn(), +})); + +vi.mock("./cli.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runOpenShellCli: cliMocks.runOpenShellCli, + }; +}); + +import { createOpenShellSandboxBackendManager } from "./backend.js"; +import { resolveOpenShellPluginConfig } from "./config.js"; + +describe("openshell backend manager", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("checks runtime status with config override from OpenClaw config", async () => { + cliMocks.runOpenShellCli.mockResolvedValue({ + code: 0, + stdout: "{}", + stderr: "", + }); + + const manager = createOpenShellSandboxBackendManager({ + pluginConfig: resolveOpenShellPluginConfig({ + command: "openshell", + from: "openclaw", + }), + }); + + const result = await manager.describeRuntime({ + entry: { + containerName: "openclaw-session-1234", + backendId: "openshell", + runtimeLabel: "openclaw-session-1234", + sessionKey: "agent:main", + createdAtMs: 1, + lastUsedAtMs: 1, + image: "custom-source", + configLabelKind: "Source", + }, + config: { + plugins: { + entries: { + openshell: { + enabled: true, + config: { + command: "openshell", + from: "custom-source", + }, + }, + }, + }, + }, + }); + + expect(result).toEqual({ + running: true, + actualConfigLabel: "custom-source", + configLabelMatch: true, + }); + expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({ + context: expect.objectContaining({ + sandboxName: "openclaw-session-1234", + config: expect.objectContaining({ + from: "custom-source", + }), + }), + args: ["sandbox", "get", "openclaw-session-1234"], + }); + }); + + it("removes runtimes via openshell sandbox delete", async () => { + cliMocks.runOpenShellCli.mockResolvedValue({ + code: 0, + stdout: "", + stderr: "", + }); + + const manager = createOpenShellSandboxBackendManager({ + pluginConfig: resolveOpenShellPluginConfig({ + command: "/usr/local/bin/openshell", + gateway: "lab", + }), + }); + + await manager.removeRuntime({ + entry: { + containerName: "openclaw-session-5678", + backendId: "openshell", + runtimeLabel: "openclaw-session-5678", + sessionKey: "agent:main", + createdAtMs: 1, + lastUsedAtMs: 1, + image: "openclaw", + configLabelKind: "Source", + }, + }); + + expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({ + context: expect.objectContaining({ + sandboxName: "openclaw-session-5678", + config: expect.objectContaining({ + command: "/usr/local/bin/openshell", + gateway: "lab", + }), + }), + args: ["sandbox", "delete", "openclaw-session-5678"], + }); + }); +}); diff --git a/extensions/openshell/src/backend.ts b/extensions/openshell/src/backend.ts new file mode 100644 index 00000000000..48f730946d4 --- /dev/null +++ b/extensions/openshell/src/backend.ts @@ -0,0 +1,445 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { + CreateSandboxBackendParams, + OpenClawConfig, + SandboxBackendCommandParams, + SandboxBackendCommandResult, + SandboxBackendFactory, + SandboxBackendHandle, + SandboxBackendManager, +} from "openclaw/plugin-sdk/core"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/core"; +import { + buildExecRemoteCommand, + buildRemoteCommand, + createOpenShellSshSession, + disposeOpenShellSshSession, + runOpenShellCli, + runOpenShellSshCommand, + type OpenShellExecContext, + type OpenShellSshSession, +} from "./cli.js"; +import { resolveOpenShellPluginConfig, type ResolvedOpenShellPluginConfig } from "./config.js"; +import { createOpenShellFsBridge } from "./fs-bridge.js"; +import { replaceDirectoryContents } from "./mirror.js"; + +type CreateOpenShellSandboxBackendFactoryParams = { + pluginConfig: ResolvedOpenShellPluginConfig; +}; + +type PendingExec = { + sshSession: OpenShellSshSession; +}; + +export type OpenShellSandboxBackend = SandboxBackendHandle & { + remoteWorkspaceDir: string; + remoteAgentWorkspaceDir: string; + runRemoteShellScript(params: SandboxBackendCommandParams): Promise; + syncLocalPathToRemote(localPath: string, remotePath: string): Promise; +}; + +export function createOpenShellSandboxBackendFactory( + params: CreateOpenShellSandboxBackendFactoryParams, +): SandboxBackendFactory { + return async (createParams) => + await createOpenShellSandboxBackend({ + ...params, + createParams, + }); +} + +export function createOpenShellSandboxBackendManager(params: { + pluginConfig: ResolvedOpenShellPluginConfig; +}): SandboxBackendManager { + return { + async describeRuntime({ entry, config }) { + const execContext: OpenShellExecContext = { + config: resolveOpenShellPluginConfigFromConfig(config, params.pluginConfig), + sandboxName: entry.containerName, + }; + const result = await runOpenShellCli({ + context: execContext, + args: ["sandbox", "get", entry.containerName], + }); + const configuredSource = execContext.config.from; + return { + running: result.code === 0, + actualConfigLabel: entry.image, + configLabelMatch: entry.image === configuredSource, + }; + }, + async removeRuntime({ entry }) { + const execContext: OpenShellExecContext = { + config: params.pluginConfig, + sandboxName: entry.containerName, + }; + await runOpenShellCli({ + context: execContext, + args: ["sandbox", "delete", entry.containerName], + }); + }, + }; +} + +async function createOpenShellSandboxBackend(params: { + pluginConfig: ResolvedOpenShellPluginConfig; + createParams: CreateSandboxBackendParams; +}): Promise { + if ((params.createParams.cfg.docker.binds?.length ?? 0) > 0) { + throw new Error("OpenShell sandbox backend does not support sandbox.docker.binds."); + } + + const sandboxName = buildOpenShellSandboxName(params.createParams.scopeKey); + const execContext: OpenShellExecContext = { + config: params.pluginConfig, + sandboxName, + }; + const impl = new OpenShellSandboxBackendImpl({ + createParams: params.createParams, + execContext, + remoteWorkspaceDir: params.pluginConfig.remoteWorkspaceDir, + remoteAgentWorkspaceDir: params.pluginConfig.remoteAgentWorkspaceDir, + }); + + return { + id: "openshell", + runtimeId: sandboxName, + runtimeLabel: sandboxName, + workdir: params.pluginConfig.remoteWorkspaceDir, + env: params.createParams.cfg.docker.env, + configLabel: params.pluginConfig.from, + configLabelKind: "Source", + buildExecSpec: async ({ command, workdir, env, usePty }) => { + const pending = await impl.prepareExec({ command, workdir, env, usePty }); + return { + argv: pending.argv, + env: process.env, + stdinMode: "pipe-open", + finalizeToken: pending.token, + }; + }, + finalizeExec: async ({ token }) => { + await impl.finalizeExec(token as PendingExec | undefined); + }, + runShellCommand: async (command) => await impl.runRemoteShellScript(command), + createFsBridge: ({ sandbox }) => + createOpenShellFsBridge({ + sandbox, + backend: impl.asHandle(), + }), + remoteWorkspaceDir: params.pluginConfig.remoteWorkspaceDir, + remoteAgentWorkspaceDir: params.pluginConfig.remoteAgentWorkspaceDir, + runRemoteShellScript: async (command) => await impl.runRemoteShellScript(command), + syncLocalPathToRemote: async (localPath, remotePath) => + await impl.syncLocalPathToRemote(localPath, remotePath), + }; +} + +class OpenShellSandboxBackendImpl { + private ensurePromise: Promise | null = null; + + constructor( + private readonly params: { + createParams: CreateSandboxBackendParams; + execContext: OpenShellExecContext; + remoteWorkspaceDir: string; + remoteAgentWorkspaceDir: string; + }, + ) {} + + asHandle(): OpenShellSandboxBackend { + const self = this; + return { + id: "openshell", + runtimeId: this.params.execContext.sandboxName, + runtimeLabel: this.params.execContext.sandboxName, + workdir: this.params.remoteWorkspaceDir, + env: this.params.createParams.cfg.docker.env, + configLabel: this.params.execContext.config.from, + configLabelKind: "Source", + remoteWorkspaceDir: this.params.remoteWorkspaceDir, + remoteAgentWorkspaceDir: this.params.remoteAgentWorkspaceDir, + buildExecSpec: async ({ command, workdir, env, usePty }) => { + const pending = await self.prepareExec({ command, workdir, env, usePty }); + return { + argv: pending.argv, + env: process.env, + stdinMode: "pipe-open", + finalizeToken: pending.token, + }; + }, + finalizeExec: async ({ token }) => { + await self.finalizeExec(token as PendingExec | undefined); + }, + runShellCommand: async (command) => await self.runRemoteShellScript(command), + createFsBridge: ({ sandbox }) => + createOpenShellFsBridge({ + sandbox, + backend: self.asHandle(), + }), + runRemoteShellScript: async (command) => await self.runRemoteShellScript(command), + syncLocalPathToRemote: async (localPath, remotePath) => + await self.syncLocalPathToRemote(localPath, remotePath), + }; + } + + async prepareExec(params: { + command: string; + workdir?: string; + env: Record; + usePty: boolean; + }): Promise<{ argv: string[]; token: PendingExec }> { + await this.ensureSandboxExists(); + await this.syncWorkspaceToRemote(); + const sshSession = await createOpenShellSshSession({ + context: this.params.execContext, + }); + const remoteCommand = buildExecRemoteCommand({ + command: params.command, + workdir: params.workdir ?? this.params.remoteWorkspaceDir, + env: params.env, + }); + return { + argv: [ + "ssh", + "-F", + sshSession.configPath, + ...(params.usePty + ? ["-tt", "-o", "RequestTTY=force", "-o", "SetEnv=TERM=xterm-256color"] + : ["-T", "-o", "RequestTTY=no"]), + sshSession.host, + remoteCommand, + ], + token: { sshSession }, + }; + } + + async finalizeExec(token?: PendingExec): Promise { + try { + await this.syncWorkspaceFromRemote(); + } finally { + if (token?.sshSession) { + await disposeOpenShellSshSession(token.sshSession); + } + } + } + + async runRemoteShellScript( + params: SandboxBackendCommandParams, + ): Promise { + await this.ensureSandboxExists(); + const session = await createOpenShellSshSession({ + context: this.params.execContext, + }); + try { + return await runOpenShellSshCommand({ + session, + remoteCommand: buildRemoteCommand([ + "/bin/sh", + "-c", + params.script, + "openclaw-openshell-fs", + ...(params.args ?? []), + ]), + stdin: params.stdin, + allowFailure: params.allowFailure, + signal: params.signal, + }); + } finally { + await disposeOpenShellSshSession(session); + } + } + + async syncLocalPathToRemote(localPath: string, remotePath: string): Promise { + await this.ensureSandboxExists(); + const stats = await fs.lstat(localPath).catch(() => null); + if (!stats) { + await this.runRemoteShellScript({ + script: 'rm -rf -- "$1"', + args: [remotePath], + allowFailure: true, + }); + return; + } + if (stats.isDirectory()) { + await this.runRemoteShellScript({ + script: 'mkdir -p -- "$1"', + args: [remotePath], + }); + return; + } + await this.runRemoteShellScript({ + script: 'mkdir -p -- "$(dirname -- "$1")"', + args: [remotePath], + }); + const result = await runOpenShellCli({ + context: this.params.execContext, + args: [ + "sandbox", + "upload", + "--no-git-ignore", + this.params.execContext.sandboxName, + localPath, + path.posix.dirname(remotePath), + ], + cwd: this.params.createParams.workspaceDir, + }); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "openshell sandbox upload failed"); + } + } + + private async ensureSandboxExists(): Promise { + if (this.ensurePromise) { + return await this.ensurePromise; + } + this.ensurePromise = this.ensureSandboxExistsInner(); + try { + await this.ensurePromise; + } catch (error) { + this.ensurePromise = null; + throw error; + } + } + + private async ensureSandboxExistsInner(): Promise { + const getResult = await runOpenShellCli({ + context: this.params.execContext, + args: ["sandbox", "get", this.params.execContext.sandboxName], + cwd: this.params.createParams.workspaceDir, + }); + if (getResult.code === 0) { + return; + } + const createArgs = [ + "sandbox", + "create", + "--name", + this.params.execContext.sandboxName, + "--from", + this.params.execContext.config.from, + ...(this.params.execContext.config.policy + ? ["--policy", this.params.execContext.config.policy] + : []), + ...(this.params.execContext.config.gpu ? ["--gpu"] : []), + ...(this.params.execContext.config.autoProviders + ? ["--auto-providers"] + : ["--no-auto-providers"]), + ...this.params.execContext.config.providers.flatMap((provider) => ["--provider", provider]), + "--", + "true", + ]; + const createResult = await runOpenShellCli({ + context: this.params.execContext, + args: createArgs, + cwd: this.params.createParams.workspaceDir, + timeoutMs: Math.max(this.params.execContext.config.timeoutMs, 300_000), + }); + if (createResult.code !== 0) { + throw new Error(createResult.stderr.trim() || "openshell sandbox create failed"); + } + } + + private async syncWorkspaceToRemote(): Promise { + await this.runRemoteShellScript({ + script: 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +', + args: [this.params.remoteWorkspaceDir], + }); + await this.uploadPathToRemote( + this.params.createParams.workspaceDir, + this.params.remoteWorkspaceDir, + ); + + if ( + this.params.createParams.cfg.workspaceAccess !== "none" && + path.resolve(this.params.createParams.agentWorkspaceDir) !== + path.resolve(this.params.createParams.workspaceDir) + ) { + await this.runRemoteShellScript({ + script: 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +', + args: [this.params.remoteAgentWorkspaceDir], + }); + await this.uploadPathToRemote( + this.params.createParams.agentWorkspaceDir, + this.params.remoteAgentWorkspaceDir, + ); + } + } + + private async syncWorkspaceFromRemote(): Promise { + const tmpDir = await fs.mkdtemp( + path.join(resolveOpenShellTmpRoot(), "openclaw-openshell-sync-"), + ); + try { + const result = await runOpenShellCli({ + context: this.params.execContext, + args: [ + "sandbox", + "download", + this.params.execContext.sandboxName, + this.params.remoteWorkspaceDir, + tmpDir, + ], + cwd: this.params.createParams.workspaceDir, + }); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "openshell sandbox download failed"); + } + await replaceDirectoryContents({ + sourceDir: tmpDir, + targetDir: this.params.createParams.workspaceDir, + }); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + } + + private async uploadPathToRemote(localPath: string, remotePath: string): Promise { + const result = await runOpenShellCli({ + context: this.params.execContext, + args: [ + "sandbox", + "upload", + "--no-git-ignore", + this.params.execContext.sandboxName, + localPath, + remotePath, + ], + cwd: this.params.createParams.workspaceDir, + }); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "openshell sandbox upload failed"); + } + } +} + +function resolveOpenShellPluginConfigFromConfig( + config: OpenClawConfig, + fallback: ResolvedOpenShellPluginConfig, +): ResolvedOpenShellPluginConfig { + const pluginConfig = config.plugins?.entries?.openshell?.config; + if (!pluginConfig) { + return fallback; + } + return resolveOpenShellPluginConfig(pluginConfig); +} + +function buildOpenShellSandboxName(scopeKey: string): string { + const trimmed = scopeKey.trim() || "session"; + const safe = trimmed + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 32); + const hash = Array.from(trimmed).reduce( + (acc, char) => ((acc * 33) ^ char.charCodeAt(0)) >>> 0, + 5381, + ); + return `openclaw-${safe || "session"}-${hash.toString(16).slice(0, 8)}`; +} + +function resolveOpenShellTmpRoot(): string { + return path.resolve(resolvePreferredOpenClawTmpDir() ?? os.tmpdir()); +} diff --git a/extensions/openshell/src/cli.test.ts b/extensions/openshell/src/cli.test.ts new file mode 100644 index 00000000000..d039a571ebc --- /dev/null +++ b/extensions/openshell/src/cli.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { buildExecRemoteCommand, buildOpenShellBaseArgv, shellEscape } from "./cli.js"; +import { resolveOpenShellPluginConfig } from "./config.js"; + +describe("openshell cli helpers", () => { + it("builds base argv with gateway overrides", () => { + const config = resolveOpenShellPluginConfig({ + command: "/usr/local/bin/openshell", + gateway: "lab", + gatewayEndpoint: "https://lab.example", + }); + expect(buildOpenShellBaseArgv(config)).toEqual([ + "/usr/local/bin/openshell", + "--gateway", + "lab", + "--gateway-endpoint", + "https://lab.example", + ]); + }); + + it("shell escapes single quotes", () => { + expect(shellEscape(`a'b`)).toBe(`'a'"'"'b'`); + }); + + it("wraps exec commands with env and workdir", () => { + const command = buildExecRemoteCommand({ + command: "pwd && printenv TOKEN", + workdir: "/sandbox/project", + env: { + TOKEN: "abc 123", + }, + }); + expect(command).toContain(`'env'`); + expect(command).toContain(`'TOKEN=abc 123'`); + expect(command).toContain(`'cd '"'"'/sandbox/project'"'"' && pwd && printenv TOKEN'`); + }); +}); diff --git a/extensions/openshell/src/cli.ts b/extensions/openshell/src/cli.ts new file mode 100644 index 00000000000..8f9808b5164 --- /dev/null +++ b/extensions/openshell/src/cli.ts @@ -0,0 +1,166 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + resolvePreferredOpenClawTmpDir, + runPluginCommandWithTimeout, +} from "openclaw/plugin-sdk/core"; +import type { SandboxBackendCommandResult } from "openclaw/plugin-sdk/core"; +import type { ResolvedOpenShellPluginConfig } from "./config.js"; + +export type OpenShellExecContext = { + config: ResolvedOpenShellPluginConfig; + sandboxName: string; + timeoutMs?: number; +}; + +export type OpenShellSshSession = { + configPath: string; + host: string; +}; + +export type OpenShellRunSshCommandParams = { + session: OpenShellSshSession; + remoteCommand: string; + stdin?: Buffer | string; + allowFailure?: boolean; + signal?: AbortSignal; + tty?: boolean; +}; + +export function buildOpenShellBaseArgv(config: ResolvedOpenShellPluginConfig): string[] { + const argv = [config.command]; + if (config.gateway) { + argv.push("--gateway", config.gateway); + } + if (config.gatewayEndpoint) { + argv.push("--gateway-endpoint", config.gatewayEndpoint); + } + return argv; +} + +export function shellEscape(value: string): string { + return `'${value.replaceAll("'", `'\"'\"'`)}'`; +} + +export function buildRemoteCommand(argv: string[]): string { + return argv.map((entry) => shellEscape(entry)).join(" "); +} + +export async function runOpenShellCli(params: { + context: OpenShellExecContext; + args: string[]; + cwd?: string; + timeoutMs?: number; +}): Promise<{ code: number; stdout: string; stderr: string }> { + return await runPluginCommandWithTimeout({ + argv: [...buildOpenShellBaseArgv(params.context.config), ...params.args], + cwd: params.cwd, + timeoutMs: params.timeoutMs ?? params.context.timeoutMs ?? params.context.config.timeoutMs, + env: process.env, + }); +} + +export async function createOpenShellSshSession(params: { + context: OpenShellExecContext; +}): Promise { + const result = await runOpenShellCli({ + context: params.context, + args: ["sandbox", "ssh-config", params.context.sandboxName], + }); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "openshell sandbox ssh-config failed"); + } + const hostMatch = result.stdout.match(/^\s*Host\s+(\S+)/m); + const host = hostMatch?.[1]?.trim(); + if (!host) { + throw new Error("Failed to parse openshell ssh-config output."); + } + const tmpRoot = resolvePreferredOpenClawTmpDir() || os.tmpdir(); + await fs.mkdir(tmpRoot, { recursive: true }); + const configDir = await fs.mkdtemp(path.join(tmpRoot, "openclaw-openshell-ssh-")); + const configPath = path.join(configDir, "config"); + await fs.writeFile(configPath, result.stdout, "utf8"); + return { configPath, host }; +} + +export async function disposeOpenShellSshSession(session: OpenShellSshSession): Promise { + await fs.rm(path.dirname(session.configPath), { recursive: true, force: true }); +} + +export async function runOpenShellSshCommand( + params: OpenShellRunSshCommandParams, +): Promise { + const argv = [ + "ssh", + "-F", + params.session.configPath, + ...(params.tty + ? ["-tt", "-o", "RequestTTY=force", "-o", "SetEnv=TERM=xterm-256color"] + : ["-T", "-o", "RequestTTY=no"]), + params.session.host, + params.remoteCommand, + ]; + + const result = await new Promise((resolve, reject) => { + const child = spawn(argv[0]!, argv.slice(1), { + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + signal: params.signal, + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); + child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); + child.on("error", reject); + child.on("close", (code) => { + const stdout = Buffer.concat(stdoutChunks); + const stderr = Buffer.concat(stderrChunks); + const exitCode = code ?? 0; + if (exitCode !== 0 && !params.allowFailure) { + const error = Object.assign( + new Error(stderr.toString("utf8").trim() || `ssh exited with code ${exitCode}`), + { + code: exitCode, + stdout, + stderr, + }, + ); + reject(error); + return; + } + resolve({ stdout, stderr, code: exitCode }); + }); + + if (params.stdin !== undefined) { + child.stdin.end(params.stdin); + return; + } + child.stdin.end(); + }); + + return result; +} + +export function buildExecRemoteCommand(params: { + command: string; + workdir?: string; + env: Record; +}): string { + const body = params.workdir + ? `cd ${shellEscape(params.workdir)} && ${params.command}` + : params.command; + const argv = + Object.keys(params.env).length > 0 + ? [ + "env", + ...Object.entries(params.env).map(([key, value]) => `${key}=${value}`), + "/bin/sh", + "-c", + body, + ] + : ["/bin/sh", "-c", body]; + return buildRemoteCommand(argv); +} diff --git a/extensions/openshell/src/config.test.ts b/extensions/openshell/src/config.test.ts new file mode 100644 index 00000000000..66734ca43e0 --- /dev/null +++ b/extensions/openshell/src/config.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { resolveOpenShellPluginConfig } from "./config.js"; + +describe("openshell plugin config", () => { + it("applies defaults", () => { + expect(resolveOpenShellPluginConfig(undefined)).toEqual({ + command: "openshell", + gateway: undefined, + gatewayEndpoint: undefined, + from: "openclaw", + policy: undefined, + providers: [], + gpu: false, + autoProviders: true, + remoteWorkspaceDir: "/sandbox", + remoteAgentWorkspaceDir: "/agent", + timeoutMs: 120_000, + }); + }); + + it("rejects relative remote paths", () => { + expect(() => + resolveOpenShellPluginConfig({ + remoteWorkspaceDir: "sandbox", + }), + ).toThrow("OpenShell remote path must be absolute"); + }); +}); diff --git a/extensions/openshell/src/config.ts b/extensions/openshell/src/config.ts new file mode 100644 index 00000000000..53e5f06584b --- /dev/null +++ b/extensions/openshell/src/config.ts @@ -0,0 +1,225 @@ +import path from "node:path"; +import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/core"; + +export type OpenShellPluginConfig = { + command?: string; + gateway?: string; + gatewayEndpoint?: string; + from?: string; + policy?: string; + providers?: string[]; + gpu?: boolean; + autoProviders?: boolean; + remoteWorkspaceDir?: string; + remoteAgentWorkspaceDir?: string; + timeoutSeconds?: number; +}; + +export type ResolvedOpenShellPluginConfig = { + command: string; + gateway?: string; + gatewayEndpoint?: string; + from: string; + policy?: string; + providers: string[]; + gpu: boolean; + autoProviders: boolean; + remoteWorkspaceDir: string; + remoteAgentWorkspaceDir: string; + timeoutMs: number; +}; + +const DEFAULT_COMMAND = "openshell"; +const DEFAULT_SOURCE = "openclaw"; +const DEFAULT_REMOTE_WORKSPACE_DIR = "/sandbox"; +const DEFAULT_REMOTE_AGENT_WORKSPACE_DIR = "/agent"; +const DEFAULT_TIMEOUT_MS = 120_000; + +type ParseSuccess = { success: true; data?: OpenShellPluginConfig }; +type ParseFailure = { + success: false; + error: { + issues: Array<{ path: Array; message: string }>; + }; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function trimString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +function normalizeProviders(value: unknown): string[] | null { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + return null; + } + const seen = new Set(); + const providers: string[] = []; + for (const entry of value) { + if (typeof entry !== "string" || !entry.trim()) { + return null; + } + const normalized = entry.trim(); + if (seen.has(normalized)) { + continue; + } + seen.add(normalized); + providers.push(normalized); + } + return providers; +} + +function normalizeRemotePath(value: string | undefined, fallback: string): string { + const candidate = value ?? fallback; + const normalized = path.posix.normalize(candidate.trim() || fallback); + if (!normalized.startsWith("/")) { + throw new Error(`OpenShell remote path must be absolute: ${candidate}`); + } + return normalized; +} + +export function createOpenShellPluginConfigSchema(): OpenClawPluginConfigSchema { + const safeParse = (value: unknown): ParseSuccess | ParseFailure => { + if (value === undefined) { + return { success: true, data: undefined }; + } + if (!isRecord(value)) { + return { + success: false, + error: { issues: [{ path: [], message: "expected config object" }] }, + }; + } + const allowedKeys = new Set([ + "command", + "gateway", + "gatewayEndpoint", + "from", + "policy", + "providers", + "gpu", + "autoProviders", + "remoteWorkspaceDir", + "remoteAgentWorkspaceDir", + "timeoutSeconds", + ]); + for (const key of Object.keys(value)) { + if (!allowedKeys.has(key)) { + return { + success: false, + error: { issues: [{ path: [key], message: `unknown config key: ${key}` }] }, + }; + } + } + + const providers = normalizeProviders(value.providers); + if (providers === null) { + return { + success: false, + error: { + issues: [{ path: ["providers"], message: "providers must be an array of strings" }], + }, + }; + } + + const timeoutSeconds = value.timeoutSeconds; + if ( + timeoutSeconds !== undefined && + (typeof timeoutSeconds !== "number" || !Number.isFinite(timeoutSeconds) || timeoutSeconds < 1) + ) { + return { + success: false, + error: { + issues: [{ path: ["timeoutSeconds"], message: "timeoutSeconds must be a number >= 1" }], + }, + }; + } + + for (const key of ["gpu", "autoProviders"] as const) { + const candidate = value[key]; + if (candidate !== undefined && typeof candidate !== "boolean") { + return { + success: false, + error: { issues: [{ path: [key], message: `${key} must be a boolean` }] }, + }; + } + } + + return { + success: true, + data: { + command: trimString(value.command), + gateway: trimString(value.gateway), + gatewayEndpoint: trimString(value.gatewayEndpoint), + from: trimString(value.from), + policy: trimString(value.policy), + providers, + gpu: value.gpu as boolean | undefined, + autoProviders: value.autoProviders as boolean | undefined, + remoteWorkspaceDir: trimString(value.remoteWorkspaceDir), + remoteAgentWorkspaceDir: trimString(value.remoteAgentWorkspaceDir), + timeoutSeconds: timeoutSeconds as number | undefined, + }, + }; + }; + + return { + safeParse, + jsonSchema: { + type: "object", + additionalProperties: false, + properties: { + command: { type: "string" }, + gateway: { type: "string" }, + gatewayEndpoint: { type: "string" }, + from: { type: "string" }, + policy: { type: "string" }, + providers: { type: "array", items: { type: "string" } }, + gpu: { type: "boolean" }, + autoProviders: { type: "boolean" }, + remoteWorkspaceDir: { type: "string" }, + remoteAgentWorkspaceDir: { type: "string" }, + timeoutSeconds: { type: "number", minimum: 1 }, + }, + }, + }; +} + +export function resolveOpenShellPluginConfig(value: unknown): ResolvedOpenShellPluginConfig { + const parsed = createOpenShellPluginConfigSchema().safeParse?.(value); + if (!parsed || !parsed.success) { + const issues = parsed && !parsed.success ? parsed.error?.issues : undefined; + const message = + issues?.map((issue: { message: string }) => issue.message).join(", ") || "invalid config"; + throw new Error(`Invalid openshell plugin config: ${message}`); + } + const raw = parsed.data ?? {}; + const cfg = (raw ?? {}) as OpenShellPluginConfig; + return { + command: cfg.command ?? DEFAULT_COMMAND, + gateway: cfg.gateway, + gatewayEndpoint: cfg.gatewayEndpoint, + from: cfg.from ?? DEFAULT_SOURCE, + policy: cfg.policy, + providers: cfg.providers ?? [], + gpu: cfg.gpu ?? false, + autoProviders: cfg.autoProviders ?? true, + remoteWorkspaceDir: normalizeRemotePath(cfg.remoteWorkspaceDir, DEFAULT_REMOTE_WORKSPACE_DIR), + remoteAgentWorkspaceDir: normalizeRemotePath( + cfg.remoteAgentWorkspaceDir, + DEFAULT_REMOTE_AGENT_WORKSPACE_DIR, + ), + timeoutMs: + typeof cfg.timeoutSeconds === "number" + ? Math.floor(cfg.timeoutSeconds * 1000) + : DEFAULT_TIMEOUT_MS, + }; +} diff --git a/extensions/openshell/src/fs-bridge.test.ts b/extensions/openshell/src/fs-bridge.test.ts new file mode 100644 index 00000000000..67a3edc5bcc --- /dev/null +++ b/extensions/openshell/src/fs-bridge.test.ts @@ -0,0 +1,88 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createSandboxTestContext } from "../../../src/agents/sandbox/test-fixtures.js"; +import type { OpenShellSandboxBackend } from "./backend.js"; +import { createOpenShellFsBridge } from "./fs-bridge.js"; + +const tempDirs: string[] = []; + +async function makeTempDir() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-openshell-fs-")); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +function createBackendMock(): OpenShellSandboxBackend { + return { + id: "openshell", + runtimeId: "openshell-test", + runtimeLabel: "openshell-test", + workdir: "/sandbox", + env: {}, + remoteWorkspaceDir: "/sandbox", + remoteAgentWorkspaceDir: "/agent", + buildExecSpec: vi.fn(), + runShellCommand: vi.fn(), + runRemoteShellScript: vi.fn().mockResolvedValue({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + }), + syncLocalPathToRemote: vi.fn().mockResolvedValue(undefined), + } as unknown as OpenShellSandboxBackend; +} + +describe("openshell fs bridge", () => { + it("writes locally and syncs the file to the remote workspace", async () => { + const workspaceDir = await makeTempDir(); + const backend = createBackendMock(); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir: workspaceDir, + containerWorkdir: "/sandbox", + }, + }); + + const bridge = createOpenShellFsBridge({ sandbox, backend }); + await bridge.writeFile({ + filePath: "nested/file.txt", + data: "hello", + mkdir: true, + }); + + expect(await fs.readFile(path.join(workspaceDir, "nested", "file.txt"), "utf8")).toBe("hello"); + expect(backend.syncLocalPathToRemote).toHaveBeenCalledWith( + path.join(workspaceDir, "nested", "file.txt"), + "/sandbox/nested/file.txt", + ); + }); + + it("maps agent mount paths when the sandbox workspace is read-only", async () => { + const workspaceDir = await makeTempDir(); + const agentWorkspaceDir = await makeTempDir(); + await fs.writeFile(path.join(agentWorkspaceDir, "note.txt"), "agent", "utf8"); + const backend = createBackendMock(); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir, + workspaceAccess: "ro", + containerWorkdir: "/sandbox", + }, + }); + + const bridge = createOpenShellFsBridge({ sandbox, backend }); + const resolved = bridge.resolvePath({ filePath: "/agent/note.txt" }); + expect(resolved.hostPath).toBe(path.join(agentWorkspaceDir, "note.txt")); + expect(await bridge.readFile({ filePath: "/agent/note.txt" })).toEqual(Buffer.from("agent")); + }); +}); diff --git a/extensions/openshell/src/fs-bridge.ts b/extensions/openshell/src/fs-bridge.ts new file mode 100644 index 00000000000..b9ab9b01549 --- /dev/null +++ b/extensions/openshell/src/fs-bridge.ts @@ -0,0 +1,336 @@ +import fsPromises from "node:fs/promises"; +import path from "node:path"; +import type { + SandboxContext, + SandboxFsBridge, + SandboxFsStat, + SandboxResolvedPath, +} from "openclaw/plugin-sdk/core"; +import type { OpenShellSandboxBackend } from "./backend.js"; +import { movePathWithCopyFallback } from "./mirror.js"; + +type ResolvedMountPath = SandboxResolvedPath & { + mountHostRoot: string; + writable: boolean; + source: "workspace" | "agent"; +}; + +export function createOpenShellFsBridge(params: { + sandbox: SandboxContext; + backend: OpenShellSandboxBackend; +}): SandboxFsBridge { + return new OpenShellFsBridge(params.sandbox, params.backend); +} + +class OpenShellFsBridge implements SandboxFsBridge { + constructor( + private readonly sandbox: SandboxContext, + private readonly backend: OpenShellSandboxBackend, + ) {} + + resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath { + const target = this.resolveTarget(params); + return { + hostPath: target.hostPath, + relativePath: target.relativePath, + containerPath: target.containerPath, + }; + } + + async readFile(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + await assertLocalPathSafety({ + target, + root: target.mountHostRoot, + allowMissingLeaf: false, + allowFinalSymlinkForUnlink: false, + }); + return await fsPromises.readFile(target.hostPath); + } + + async writeFile(params: { + filePath: string; + cwd?: string; + data: Buffer | string; + encoding?: BufferEncoding; + mkdir?: boolean; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "write files"); + await assertLocalPathSafety({ + target, + root: target.mountHostRoot, + allowMissingLeaf: true, + allowFinalSymlinkForUnlink: false, + }); + const buffer = Buffer.isBuffer(params.data) + ? params.data + : Buffer.from(params.data, params.encoding ?? "utf8"); + const parentDir = path.dirname(target.hostPath); + if (params.mkdir !== false) { + await fsPromises.mkdir(parentDir, { recursive: true }); + } + const tempPath = path.join( + parentDir, + `.openclaw-openshell-write-${path.basename(target.hostPath)}-${process.pid}-${Date.now()}`, + ); + await fsPromises.writeFile(tempPath, buffer); + await fsPromises.rename(tempPath, target.hostPath); + await this.backend.syncLocalPathToRemote(target.hostPath, target.containerPath); + } + + async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "create directories"); + await assertLocalPathSafety({ + target, + root: target.mountHostRoot, + allowMissingLeaf: true, + allowFinalSymlinkForUnlink: false, + }); + await fsPromises.mkdir(target.hostPath, { recursive: true }); + await this.backend.runRemoteShellScript({ + script: 'mkdir -p -- "$1"', + args: [target.containerPath], + signal: params.signal, + }); + } + + async remove(params: { + filePath: string; + cwd?: string; + recursive?: boolean; + force?: boolean; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "remove files"); + await assertLocalPathSafety({ + target, + root: target.mountHostRoot, + allowMissingLeaf: params.force !== false, + allowFinalSymlinkForUnlink: true, + }); + await fsPromises.rm(target.hostPath, { + recursive: params.recursive ?? false, + force: params.force !== false, + }); + await this.backend.runRemoteShellScript({ + script: params.recursive + ? 'rm -rf -- "$1"' + : 'if [ -d "$1" ] && [ ! -L "$1" ]; then rmdir -- "$1"; elif [ -e "$1" ] || [ -L "$1" ]; then rm -f -- "$1"; fi', + args: [target.containerPath], + signal: params.signal, + allowFailure: params.force !== false, + }); + } + + async rename(params: { + from: string; + to: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const from = this.resolveTarget({ filePath: params.from, cwd: params.cwd }); + const to = this.resolveTarget({ filePath: params.to, cwd: params.cwd }); + this.ensureWritable(from, "rename files"); + this.ensureWritable(to, "rename files"); + await assertLocalPathSafety({ + target: from, + root: from.mountHostRoot, + allowMissingLeaf: false, + allowFinalSymlinkForUnlink: true, + }); + await assertLocalPathSafety({ + target: to, + root: to.mountHostRoot, + allowMissingLeaf: true, + allowFinalSymlinkForUnlink: false, + }); + await fsPromises.mkdir(path.dirname(to.hostPath), { recursive: true }); + await movePathWithCopyFallback({ from: from.hostPath, to: to.hostPath }); + await this.backend.runRemoteShellScript({ + script: 'mkdir -p -- "$(dirname -- "$2")" && mv -- "$1" "$2"', + args: [from.containerPath, to.containerPath], + signal: params.signal, + }); + } + + async stat(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + const stats = await fsPromises.lstat(target.hostPath).catch(() => null); + if (!stats) { + return null; + } + await assertLocalPathSafety({ + target, + root: target.mountHostRoot, + allowMissingLeaf: false, + allowFinalSymlinkForUnlink: false, + }); + return { + type: stats.isDirectory() ? "directory" : stats.isFile() ? "file" : "other", + size: stats.size, + mtimeMs: stats.mtimeMs, + }; + } + + private ensureWritable(target: ResolvedMountPath, action: string) { + if (this.sandbox.workspaceAccess !== "rw" || !target.writable) { + throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`); + } + } + + private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedMountPath { + const workspaceRoot = path.resolve(this.sandbox.workspaceDir); + const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir); + const hasAgentMount = this.sandbox.workspaceAccess !== "none" && workspaceRoot !== agentRoot; + const agentContainerRoot = (this.backend.remoteAgentWorkspaceDir || "/agent").replace( + /\\/g, + "/", + ); + const workspaceContainerRoot = this.sandbox.containerWorkdir.replace(/\\/g, "/"); + const input = params.filePath.trim(); + + if (input.startsWith(`${workspaceContainerRoot}/`) || input === workspaceContainerRoot) { + const relative = path.posix.relative(workspaceContainerRoot, input) || ""; + const hostPath = relative + ? path.resolve(workspaceRoot, ...relative.split("/")) + : workspaceRoot; + return { + hostPath, + relativePath: relative, + containerPath: relative + ? path.posix.join(workspaceContainerRoot, relative) + : workspaceContainerRoot, + mountHostRoot: workspaceRoot, + writable: this.sandbox.workspaceAccess === "rw", + source: "workspace", + }; + } + + if ( + hasAgentMount && + (input.startsWith(`${agentContainerRoot}/`) || input === agentContainerRoot) + ) { + const relative = path.posix.relative(agentContainerRoot, input) || ""; + const hostPath = relative ? path.resolve(agentRoot, ...relative.split("/")) : agentRoot; + return { + hostPath, + relativePath: relative ? agentContainerRoot + "/" + relative : agentContainerRoot, + containerPath: relative + ? path.posix.join(agentContainerRoot, relative) + : agentContainerRoot, + mountHostRoot: agentRoot, + writable: this.sandbox.workspaceAccess === "rw", + source: "agent", + }; + } + + const cwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot; + const hostPath = path.isAbsolute(input) ? path.resolve(input) : path.resolve(cwd, input); + + if (isPathInside(workspaceRoot, hostPath)) { + const relative = path.relative(workspaceRoot, hostPath).split(path.sep).join(path.posix.sep); + return { + hostPath, + relativePath: relative, + containerPath: relative + ? path.posix.join(workspaceContainerRoot, relative) + : workspaceContainerRoot, + mountHostRoot: workspaceRoot, + writable: this.sandbox.workspaceAccess === "rw", + source: "workspace", + }; + } + + if (hasAgentMount && isPathInside(agentRoot, hostPath)) { + const relative = path.relative(agentRoot, hostPath).split(path.sep).join(path.posix.sep); + return { + hostPath, + relativePath: relative ? `${agentContainerRoot}/${relative}` : agentContainerRoot, + containerPath: relative + ? path.posix.join(agentContainerRoot, relative) + : agentContainerRoot, + mountHostRoot: agentRoot, + writable: this.sandbox.workspaceAccess === "rw", + source: "agent", + }; + } + + throw new Error(`Path escapes sandbox root (${workspaceRoot}): ${params.filePath}`); + } +} + +function isPathInside(root: string, target: string): boolean { + const relative = path.relative(root, target); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +async function assertLocalPathSafety(params: { + target: ResolvedMountPath; + root: string; + allowMissingLeaf: boolean; + allowFinalSymlinkForUnlink: boolean; +}): Promise { + const canonicalRoot = await fsPromises + .realpath(params.root) + .catch(() => path.resolve(params.root)); + const candidate = await resolveCanonicalCandidate(params.target.hostPath); + if (!isPathInside(canonicalRoot, candidate)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot access: ${params.target.containerPath}`, + ); + } + + const relative = path.relative(params.root, params.target.hostPath); + const segments = relative + .split(path.sep) + .filter(Boolean) + .slice(0, Math.max(0, relative.split(path.sep).filter(Boolean).length)); + let cursor = params.root; + for (let index = 0; index < segments.length; index += 1) { + cursor = path.join(cursor, segments[index]!); + const stats = await fsPromises.lstat(cursor).catch(() => null); + if (!stats) { + if (index === segments.length - 1 && params.allowMissingLeaf) { + return; + } + continue; + } + const isFinal = index === segments.length - 1; + if (stats.isSymbolicLink() && (!isFinal || !params.allowFinalSymlinkForUnlink)) { + throw new Error(`Sandbox boundary checks failed: ${params.target.containerPath}`); + } + } +} + +async function resolveCanonicalCandidate(targetPath: string): Promise { + const missing: string[] = []; + let cursor = path.resolve(targetPath); + while (true) { + const exists = await fsPromises + .lstat(cursor) + .then(() => true) + .catch(() => false); + if (exists) { + const canonical = await fsPromises.realpath(cursor).catch(() => cursor); + return path.resolve(canonical, ...missing); + } + const parent = path.dirname(cursor); + if (parent === cursor) { + return path.resolve(cursor, ...missing); + } + missing.unshift(path.basename(cursor)); + cursor = parent; + } +} diff --git a/extensions/openshell/src/mirror.ts b/extensions/openshell/src/mirror.ts new file mode 100644 index 00000000000..ee5024850d6 --- /dev/null +++ b/extensions/openshell/src/mirror.ts @@ -0,0 +1,47 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export async function replaceDirectoryContents(params: { + sourceDir: string; + targetDir: string; +}): Promise { + await fs.mkdir(params.targetDir, { recursive: true }); + const existing = await fs.readdir(params.targetDir); + await Promise.all( + existing.map((entry) => + fs.rm(path.join(params.targetDir, entry), { + recursive: true, + force: true, + }), + ), + ); + const sourceEntries = await fs.readdir(params.sourceDir); + for (const entry of sourceEntries) { + await fs.cp(path.join(params.sourceDir, entry), path.join(params.targetDir, entry), { + recursive: true, + force: true, + dereference: false, + }); + } +} + +export async function movePathWithCopyFallback(params: { + from: string; + to: string; +}): Promise { + try { + await fs.rename(params.from, params.to); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException | null)?.code; + if (code !== "EXDEV") { + throw error; + } + } + await fs.cp(params.from, params.to, { + recursive: true, + force: true, + dereference: false, + }); + await fs.rm(params.from, { recursive: true, force: true }); +} diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 5c3301414b9..72367deb33d 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -384,6 +384,7 @@ export async function runExecProcess(opts: { typeof opts.timeoutSec === "number" && opts.timeoutSec > 0 ? Math.floor(opts.timeoutSec * 1000) : undefined; + let sandboxFinalizeToken: unknown; const spawnSpec: | { @@ -398,11 +399,18 @@ export async function runExecProcess(opts: { childFallbackArgv: string[]; env: NodeJS.ProcessEnv; stdinMode: "pipe-open"; - } = (() => { + } = await (async () => { if (opts.sandbox) { + const backendExecSpec = await opts.sandbox.buildExecSpec?.({ + command: execCommand, + workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir, + env: shellRuntimeEnv, + usePty: opts.usePty, + }); + sandboxFinalizeToken = backendExecSpec?.finalizeToken; return { mode: "child" as const, - argv: [ + argv: backendExecSpec?.argv ?? [ "docker", ...buildDockerExecArgs({ containerName: opts.sandbox.containerName, @@ -412,8 +420,10 @@ export async function runExecProcess(opts: { tty: opts.usePty, }), ], - env: process.env, - stdinMode: opts.usePty ? ("pipe-open" as const) : ("pipe-closed" as const), + env: backendExecSpec?.env ?? process.env, + stdinMode: + backendExecSpec?.stdinMode ?? + (opts.usePty ? ("pipe-open" as const) : ("pipe-closed" as const)), }; } const { shell, args: shellArgs } = getShellConfig(); @@ -519,7 +529,7 @@ export async function runExecProcess(opts: { const promise = managedRun .wait() - .then((exit): ExecProcessOutcome => { + .then(async (exit): Promise => { const durationMs = Date.now() - startedAt; const isNormalExit = exit.reason === "exit"; const exitCode = exit.exitCode ?? 0; @@ -536,6 +546,14 @@ export async function runExecProcess(opts: { session.stdin.destroyed = true; } const aggregated = session.aggregated.trim(); + if (opts.sandbox?.finalizeExec) { + await opts.sandbox.finalizeExec({ + status, + exitCode: exit.exitCode ?? null, + timedOut: exit.timedOut, + token: sandboxFinalizeToken, + }); + } if (status === "completed") { const exitMsg = exitCode !== 0 ? `\n\n(Command exited with code ${exitCode})` : ""; return { diff --git a/src/agents/bash-tools.shared.ts b/src/agents/bash-tools.shared.ts index 3cfb92655e2..25f1fb5bd8d 100644 --- a/src/agents/bash-tools.shared.ts +++ b/src/agents/bash-tools.shared.ts @@ -4,6 +4,7 @@ import { homedir } from "node:os"; import path from "node:path"; import { sliceUtf16Safe } from "../utils.js"; import { assertSandboxPath } from "./sandbox-paths.js"; +import type { SandboxBackendExecSpec } from "./sandbox/backend.js"; const CHUNK_LIMIT = 8 * 1024; @@ -12,6 +13,18 @@ export type BashSandboxConfig = { workspaceDir: string; containerWorkdir: string; env?: Record; + buildExecSpec?: (params: { + command: string; + workdir?: string; + env: Record; + usePty: boolean; + }) => Promise; + finalizeExec?: (params: { + status: "completed" | "failed"; + exitCode: number | null; + timedOut: boolean; + token?: unknown; + }) => Promise; }; export function buildSandboxEnv(params: { diff --git a/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts b/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts index 8b225ff89cb..52289130690 100644 --- a/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts +++ b/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts @@ -5,10 +5,13 @@ import type { SandboxContext } from "./sandbox.js"; function createSandboxContext(overrides?: Partial): SandboxContext { const base = { enabled: true, + backendId: "docker", sessionKey: "session:test", workspaceDir: "/tmp/openclaw-sandbox", agentWorkspaceDir: "/tmp/openclaw-workspace", workspaceAccess: "none", + runtimeId: "openclaw-sbx-test", + runtimeLabel: "openclaw-sbx-test", containerName: "openclaw-sbx-test", containerWorkdir: "/workspace", docker: { diff --git a/src/agents/pi-tools-agent-config.test.ts b/src/agents/pi-tools-agent-config.test.ts index e24186e0b30..353b0333759 100644 --- a/src/agents/pi-tools-agent-config.test.ts +++ b/src/agents/pi-tools-agent-config.test.ts @@ -574,10 +574,13 @@ describe("Agent-specific tool filtering", () => { agentDir: "/tmp/agent-restricted", sandbox: { enabled: true, + backendId: "docker", sessionKey: "agent:restricted:main", workspaceDir: "/tmp/sandbox", agentWorkspaceDir: "/tmp/test-restricted", workspaceAccess: "none", + runtimeId: "test-container", + runtimeLabel: "test-container", containerName: "test-container", containerWorkdir: "/workspace", docker: { diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 6536e9dfbb5..9c7aafbd56e 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -438,7 +438,9 @@ export function createOpenClawCodingTools(options?: { containerName: sandbox.containerName, workspaceDir: sandbox.workspaceDir, containerWorkdir: sandbox.containerWorkdir, - env: sandbox.docker.env, + env: sandbox.backend?.env ?? sandbox.docker.env, + buildExecSpec: sandbox.backend?.buildExecSpec.bind(sandbox.backend), + finalizeExec: sandbox.backend?.finalizeExec?.bind(sandbox.backend), } : undefined, }); diff --git a/src/agents/sandbox-merge.test.ts b/src/agents/sandbox-merge.test.ts index 0635703b8bb..d120ac84820 100644 --- a/src/agents/sandbox-merge.test.ts +++ b/src/agents/sandbox-merge.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { resolveSandboxBrowserConfig, + resolveSandboxConfigForAgent, resolveSandboxDockerConfig, resolveSandboxPruneConfig, resolveSandboxScope, @@ -128,4 +129,8 @@ describe("sandbox config merges", () => { }); expect(pruneShared).toEqual({ idleHours: 24, maxAgeDays: 7 }); }); + + it("defaults sandbox backend to docker", () => { + expect(resolveSandboxConfigForAgent().backend).toBe("docker"); + }); }); diff --git a/src/agents/sandbox.resolveSandboxContext.test.ts b/src/agents/sandbox.resolveSandboxContext.test.ts index 2ecec621a70..0fa62a364e2 100644 --- a/src/agents/sandbox.resolveSandboxContext.test.ts +++ b/src/agents/sandbox.resolveSandboxContext.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { registerSandboxBackend } from "./sandbox/backend.js"; import { ensureSandboxWorkspaceForSession, resolveSandboxContext } from "./sandbox/context.js"; describe("resolveSandboxContext", () => { @@ -84,4 +85,45 @@ describe("resolveSandboxContext", () => { }), ).toBeNull(); }, 15_000); + + it("resolves a registered non-docker backend", async () => { + const restore = registerSandboxBackend("test-backend", async () => ({ + id: "test-backend", + runtimeId: "test-runtime", + runtimeLabel: "Test Runtime", + workdir: "/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, + }), + })); + try { + const cfg: OpenClawConfig = { + agents: { + defaults: { + sandbox: { mode: "all", backend: "test-backend", scope: "session" }, + }, + }, + }; + + const result = await resolveSandboxContext({ + config: cfg, + sessionKey: "agent:worker:task", + workspaceDir: "/tmp/openclaw-test", + }); + + expect(result?.backendId).toBe("test-backend"); + expect(result?.runtimeId).toBe("test-runtime"); + expect(result?.containerName).toBe("test-runtime"); + expect(result?.backend?.id).toBe("test-backend"); + } finally { + restore(); + } + }, 15_000); }); diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 8ac65795d0f..b52cb5ab050 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -11,6 +11,12 @@ export { DEFAULT_SANDBOX_IMAGE, } from "./sandbox/constants.js"; export { ensureSandboxWorkspaceForSession, resolveSandboxContext } from "./sandbox/context.js"; +export { + getSandboxBackendFactory, + getSandboxBackendManager, + registerSandboxBackend, + requireSandboxBackendFactory, +} from "./sandbox/backend.js"; export { buildSandboxCreateArgs } from "./sandbox/docker.js"; export { @@ -27,6 +33,20 @@ export { } from "./sandbox/runtime-status.js"; export { resolveSandboxToolPolicyForAgent } from "./sandbox/tool-policy.js"; +export type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "./sandbox/fs-bridge.js"; + +export type { + CreateSandboxBackendParams, + SandboxBackendCommandParams, + SandboxBackendCommandResult, + SandboxBackendExecSpec, + SandboxBackendFactory, + SandboxBackendHandle, + SandboxBackendId, + SandboxBackendManager, + SandboxBackendRegistration, + SandboxBackendRuntimeInfo, +} from "./sandbox/backend.js"; export type { SandboxBrowserConfig, diff --git a/src/agents/sandbox/backend.test.ts b/src/agents/sandbox/backend.test.ts new file mode 100644 index 00000000000..6878e768945 --- /dev/null +++ b/src/agents/sandbox/backend.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { + getSandboxBackendFactory, + getSandboxBackendManager, + registerSandboxBackend, +} from "./backend.js"; + +describe("sandbox backend registry", () => { + it("registers and restores backend factories", () => { + const factory = async () => { + throw new Error("not used"); + }; + const restore = registerSandboxBackend("test-backend", factory); + expect(getSandboxBackendFactory("test-backend")).toBe(factory); + restore(); + expect(getSandboxBackendFactory("test-backend")).toBeNull(); + }); + + it("registers backend managers alongside factories", () => { + const factory = async () => { + throw new Error("not used"); + }; + const manager = { + describeRuntime: async () => ({ + running: true, + configLabelMatch: true, + }), + removeRuntime: async () => {}, + }; + const restore = registerSandboxBackend("test-managed", { + factory, + manager, + }); + expect(getSandboxBackendFactory("test-managed")).toBe(factory); + expect(getSandboxBackendManager("test-managed")).toBe(manager); + restore(); + expect(getSandboxBackendManager("test-managed")).toBeNull(); + }); +}); diff --git a/src/agents/sandbox/backend.ts b/src/agents/sandbox/backend.ts new file mode 100644 index 00000000000..c186b0fe4cc --- /dev/null +++ b/src/agents/sandbox/backend.ts @@ -0,0 +1,148 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { SandboxFsBridge } from "./fs-bridge.js"; +import type { SandboxRegistryEntry } from "./registry.js"; +import type { SandboxConfig, SandboxContext } from "./types.js"; + +export type SandboxBackendId = string; + +export type SandboxBackendExecSpec = { + argv: string[]; + env: NodeJS.ProcessEnv; + stdinMode: "pipe-open" | "pipe-closed"; + finalizeToken?: unknown; +}; + +export type SandboxBackendCommandParams = { + script: string; + args?: string[]; + stdin?: Buffer | string; + allowFailure?: boolean; + signal?: AbortSignal; +}; + +export type SandboxBackendCommandResult = { + stdout: Buffer; + stderr: Buffer; + code: number; +}; + +export type SandboxBackendHandle = { + id: SandboxBackendId; + runtimeId: string; + runtimeLabel: string; + workdir: string; + env?: Record; + configLabel?: string; + configLabelKind?: string; + capabilities?: { + browser?: boolean; + }; + buildExecSpec(params: { + command: string; + workdir?: string; + env: Record; + usePty: boolean; + }): Promise; + finalizeExec?: (params: { + status: "completed" | "failed"; + exitCode: number | null; + timedOut: boolean; + token?: unknown; + }) => Promise; + runShellCommand(params: SandboxBackendCommandParams): Promise; + createFsBridge?: (params: { sandbox: SandboxContext }) => SandboxFsBridge; +}; + +export type SandboxBackendRuntimeInfo = { + running: boolean; + actualConfigLabel?: string; + configLabelMatch: boolean; +}; + +export type SandboxBackendManager = { + describeRuntime(params: { + entry: SandboxRegistryEntry; + config: OpenClawConfig; + agentId?: string; + }): Promise; + removeRuntime(params: { entry: SandboxRegistryEntry }): Promise; +}; + +export type CreateSandboxBackendParams = { + sessionKey: string; + scopeKey: string; + workspaceDir: string; + agentWorkspaceDir: string; + cfg: SandboxConfig; +}; + +export type SandboxBackendFactory = ( + params: CreateSandboxBackendParams, +) => Promise; + +export type SandboxBackendRegistration = + | SandboxBackendFactory + | { + factory: SandboxBackendFactory; + manager?: SandboxBackendManager; + }; + +type RegisteredSandboxBackend = { + factory: SandboxBackendFactory; + manager?: SandboxBackendManager; +}; + +const SANDBOX_BACKEND_FACTORIES = new Map(); + +function normalizeSandboxBackendId(id: string): SandboxBackendId { + const normalized = id.trim().toLowerCase(); + if (!normalized) { + throw new Error("Sandbox backend id must not be empty."); + } + return normalized; +} + +export function registerSandboxBackend( + id: string, + registration: SandboxBackendRegistration, +): () => void { + const normalizedId = normalizeSandboxBackendId(id); + const resolved = typeof registration === "function" ? { factory: registration } : registration; + const previous = SANDBOX_BACKEND_FACTORIES.get(normalizedId); + SANDBOX_BACKEND_FACTORIES.set(normalizedId, resolved); + return () => { + if (previous) { + SANDBOX_BACKEND_FACTORIES.set(normalizedId, previous); + return; + } + SANDBOX_BACKEND_FACTORIES.delete(normalizedId); + }; +} + +export function getSandboxBackendFactory(id: string): SandboxBackendFactory | null { + return SANDBOX_BACKEND_FACTORIES.get(normalizeSandboxBackendId(id))?.factory ?? null; +} + +export function getSandboxBackendManager(id: string): SandboxBackendManager | null { + return SANDBOX_BACKEND_FACTORIES.get(normalizeSandboxBackendId(id))?.manager ?? null; +} + +export function requireSandboxBackendFactory(id: string): SandboxBackendFactory { + const factory = getSandboxBackendFactory(id); + if (factory) { + return factory; + } + throw new Error( + [ + `Sandbox backend "${id}" is not registered.`, + "Load the plugin that provides it, or set agents.defaults.sandbox.backend=docker.", + ].join("\n"), + ); +} + +import { createDockerSandboxBackend, dockerSandboxBackendManager } from "./docker-backend.js"; + +registerSandboxBackend("docker", { + factory: createDockerSandboxBackend, + manager: dockerSandboxBackendManager, +}); diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index 077db23c53b..c62276c6b87 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -48,6 +48,7 @@ vi.mock("../../browser/bridge-server.js", () => ({ function buildConfig(enableNoVnc: boolean): SandboxConfig { return { mode: "all", + backend: "docker", scope: "session", workspaceAccess: "none", workspaceRoot: "/tmp/openclaw-sandboxes", diff --git a/src/agents/sandbox/config.ts b/src/agents/sandbox/config.ts index b7595ae8c4b..dda3e048ea7 100644 --- a/src/agents/sandbox/config.ts +++ b/src/agents/sandbox/config.ts @@ -189,6 +189,7 @@ export function resolveSandboxConfigForAgent( return { mode: agentSandbox?.mode ?? agent?.mode ?? "off", + backend: agentSandbox?.backend?.trim() || agent?.backend?.trim() || "docker", scope, workspaceAccess: agentSandbox?.workspaceAccess ?? agent?.workspaceAccess ?? "none", workspaceRoot: diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts index 8468dd2c556..031b7c45998 100644 --- a/src/agents/sandbox/context.ts +++ b/src/agents/sandbox/context.ts @@ -7,11 +7,12 @@ import { defaultRuntime } from "../../runtime.js"; import { resolveUserPath } from "../../utils.js"; import { syncSkillsToWorkspace } from "../skills.js"; import { DEFAULT_AGENT_WORKSPACE_DIR } from "../workspace.js"; +import { requireSandboxBackendFactory } from "./backend.js"; import { ensureSandboxBrowser } from "./browser.js"; import { resolveSandboxConfigForAgent } from "./config.js"; -import { ensureSandboxContainer } from "./docker.js"; import { createSandboxFsBridge } from "./fs-bridge.js"; import { maybePruneSandboxes } from "./prune.js"; +import { updateRegistry } from "./registry.js"; import { resolveSandboxRuntimeStatus } from "./runtime-status.js"; import { resolveSandboxScopeKey, resolveSandboxWorkspaceDir } from "./shared.js"; import type { SandboxContext, SandboxDockerConfig, SandboxWorkspaceInfo } from "./types.js"; @@ -131,12 +132,24 @@ export async function resolveSandboxContext(params: { }); const resolvedCfg = docker === cfg.docker ? cfg : { ...cfg, docker }; - const containerName = await ensureSandboxContainer({ + const backendFactory = requireSandboxBackendFactory(resolvedCfg.backend); + const backend = await backendFactory({ sessionKey: rawSessionKey, + scopeKey, workspaceDir, agentWorkspaceDir, cfg: resolvedCfg, }); + await updateRegistry({ + containerName: backend.runtimeId, + backendId: backend.id, + runtimeLabel: backend.runtimeLabel, + sessionKey: scopeKey, + createdAtMs: Date.now(), + lastUsedAtMs: Date.now(), + image: backend.configLabel ?? resolvedCfg.docker.image, + configLabelKind: backend.configLabelKind ?? "Image", + }); const evaluateEnabled = params.config?.browser?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED; @@ -157,30 +170,44 @@ export async function resolveSandboxContext(params: { return browserAuth; })() : undefined; - const browser = await ensureSandboxBrowser({ - scopeKey, - workspaceDir, - agentWorkspaceDir, - cfg: resolvedCfg, - evaluateEnabled, - bridgeAuth, - }); + if (resolvedCfg.browser.enabled && backend.capabilities?.browser !== true) { + throw new Error( + `Sandbox backend "${resolvedCfg.backend}" does not support browser sandboxes yet.`, + ); + } + const browser = + resolvedCfg.browser.enabled && backend.capabilities?.browser === true + ? await ensureSandboxBrowser({ + scopeKey, + workspaceDir, + agentWorkspaceDir, + cfg: resolvedCfg, + evaluateEnabled, + bridgeAuth, + }) + : null; const sandboxContext: SandboxContext = { enabled: true, + backendId: backend.id, sessionKey: rawSessionKey, workspaceDir, agentWorkspaceDir, workspaceAccess: resolvedCfg.workspaceAccess, - containerName, - containerWorkdir: resolvedCfg.docker.workdir, + runtimeId: backend.runtimeId, + runtimeLabel: backend.runtimeLabel, + containerName: backend.runtimeId, + containerWorkdir: backend.workdir, docker: resolvedCfg.docker, tools: resolvedCfg.tools, browserAllowHostControl: resolvedCfg.browser.allowHostControl, browser: browser ?? undefined, + backend, }; - sandboxContext.fsBridge = createSandboxFsBridge({ sandbox: sandboxContext }); + sandboxContext.fsBridge = + backend.createFsBridge?.({ sandbox: sandboxContext }) ?? + createSandboxFsBridge({ sandbox: sandboxContext }); return sandboxContext; } diff --git a/src/agents/sandbox/docker-backend.ts b/src/agents/sandbox/docker-backend.ts new file mode 100644 index 00000000000..9686dc4b612 --- /dev/null +++ b/src/agents/sandbox/docker-backend.ts @@ -0,0 +1,130 @@ +import { buildDockerExecArgs } from "../bash-tools.shared.js"; +import type { + CreateSandboxBackendParams, + SandboxBackendManager, + SandboxBackendCommandParams, + SandboxBackendHandle, +} from "./backend.js"; +import { resolveSandboxConfigForAgent } from "./config.js"; +import { + dockerContainerState, + ensureSandboxContainer, + execDocker, + execDockerRaw, +} from "./docker.js"; + +export async function createDockerSandboxBackend( + params: CreateSandboxBackendParams, +): Promise { + const containerName = await ensureSandboxContainer({ + sessionKey: params.sessionKey, + workspaceDir: params.workspaceDir, + agentWorkspaceDir: params.agentWorkspaceDir, + cfg: params.cfg, + }); + return createDockerSandboxBackendHandle({ + containerName, + workdir: params.cfg.docker.workdir, + env: params.cfg.docker.env, + image: params.cfg.docker.image, + }); +} + +export function createDockerSandboxBackendHandle(params: { + containerName: string; + workdir: string; + env?: Record; + image: string; +}): SandboxBackendHandle { + return { + id: "docker", + runtimeId: params.containerName, + runtimeLabel: params.containerName, + workdir: params.workdir, + env: params.env, + configLabel: params.image, + configLabelKind: "Image", + capabilities: { + browser: true, + }, + async buildExecSpec({ command, workdir, env, usePty }) { + return { + argv: [ + "docker", + ...buildDockerExecArgs({ + containerName: params.containerName, + command, + workdir: workdir ?? params.workdir, + env, + tty: usePty, + }), + ], + env: process.env, + stdinMode: usePty ? "pipe-open" : "pipe-closed", + }; + }, + runShellCommand(command) { + return runDockerSandboxShellCommand({ + containerName: params.containerName, + ...command, + }); + }, + }; +} + +export function runDockerSandboxShellCommand( + params: { + containerName: string; + } & SandboxBackendCommandParams, +) { + const dockerArgs = [ + "exec", + "-i", + params.containerName, + "sh", + "-c", + params.script, + "moltbot-sandbox-fs", + ]; + if (params.args?.length) { + dockerArgs.push(...params.args); + } + return execDockerRaw(dockerArgs, { + input: params.stdin, + allowFailure: params.allowFailure, + signal: params.signal, + }); +} + +export const dockerSandboxBackendManager: SandboxBackendManager = { + async describeRuntime({ entry, config, agentId }) { + const state = await dockerContainerState(entry.containerName); + let actualConfigLabel = entry.image; + if (state.exists) { + try { + const result = await execDocker( + ["inspect", "-f", "{{.Config.Image}}", entry.containerName], + { allowFailure: true }, + ); + if (result.code === 0) { + actualConfigLabel = result.stdout.trim() || actualConfigLabel; + } + } catch { + // ignore inspect failures + } + } + const configuredImage = resolveSandboxConfigForAgent(config, agentId).docker.image; + return { + running: state.running, + actualConfigLabel, + configLabelMatch: actualConfigLabel === configuredImage, + }; + }, + async removeRuntime({ entry }) { + try { + await execDocker(["rm", "-f", entry.containerName], { allowFailure: true }); + } catch { + // ignore removal failures + } + }, +}; diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index b2cd24c6630..54941ba04d1 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -91,6 +91,7 @@ function createSandboxConfig( ): SandboxConfig { return { mode: "all", + backend: "docker", scope: "shared", workspaceAccess, workspaceRoot: "~/.openclaw/sandboxes", diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index aefceb08495..80a2921cb6b 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -557,10 +557,13 @@ export async function ensureSandboxContainer(params: { } await updateRegistry({ containerName, + backendId: "docker", + runtimeLabel: containerName, sessionKey: scopeKey, createdAtMs: now, lastUsedAtMs: now, image: params.cfg.docker.image, + configLabelKind: "Image", configHash: hashMismatch && running ? (currentHash ?? undefined) : expectedHash, }); return containerName; diff --git a/src/agents/sandbox/fs-bridge.ts b/src/agents/sandbox/fs-bridge.ts index 7a9a22d4459..16c307e053c 100644 --- a/src/agents/sandbox/fs-bridge.ts +++ b/src/agents/sandbox/fs-bridge.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; -import { execDockerRaw, type ExecDockerRawResult } from "./docker.js"; +import type { SandboxBackendCommandResult } from "./backend.js"; +import { runDockerSandboxShellCommand } from "./docker-backend.js"; import { buildPinnedMkdirpPlan, buildPinnedRemovePlan, @@ -248,21 +249,22 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { private async runCommand( script: string, options: RunCommandOptions = {}, - ): Promise { - const dockerArgs = [ - "exec", - "-i", - this.sandbox.containerName, - "sh", - "-c", - script, - "moltbot-sandbox-fs", - ]; - if (options.args?.length) { - dockerArgs.push(...options.args); + ): Promise { + const backend = this.sandbox.backend; + if (backend) { + return await backend.runShellCommand({ + script, + args: options.args, + stdin: options.stdin, + allowFailure: options.allowFailure, + signal: options.signal, + }); } - return execDockerRaw(dockerArgs, { - input: options.stdin, + return await runDockerSandboxShellCommand({ + containerName: this.sandbox.containerName, + script, + args: options.args, + stdin: options.stdin, allowFailure: options.allowFailure, signal: options.signal, }); @@ -279,7 +281,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { private async runCheckedCommand( plan: SandboxFsCommandPlan & { stdin?: Buffer | string; signal?: AbortSignal }, - ): Promise { + ): Promise { await this.pathGuard.assertPathChecks(plan.checks); if (plan.recheckBeforeCommand) { await this.pathGuard.assertPathChecks(plan.checks); @@ -295,7 +297,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { private async runPlannedCommand( plan: SandboxFsCommandPlan, signal?: AbortSignal, - ): Promise { + ): Promise { return await this.runCheckedCommand({ ...plan, signal }); } diff --git a/src/agents/sandbox/manage.ts b/src/agents/sandbox/manage.ts index f6988146e90..0b5ba578d7d 100644 --- a/src/agents/sandbox/manage.ts +++ b/src/agents/sandbox/manage.ts @@ -1,8 +1,8 @@ import { stopBrowserBridgeServer } from "../../browser/bridge-server.js"; import { loadConfig } from "../../config/config.js"; +import { getSandboxBackendManager } from "./backend.js"; import { BROWSER_BRIDGES } from "./browser-bridges.js"; -import { resolveSandboxConfigForAgent } from "./config.js"; -import { dockerContainerState, execDocker } from "./docker.js"; +import { dockerSandboxBackendManager } from "./docker-backend.js"; import { readBrowserRegistry, readRegistry, @@ -23,80 +23,92 @@ export type SandboxBrowserInfo = SandboxBrowserRegistryEntry & { imageMatch: boolean; }; -async function listSandboxRegistryItems< - TEntry extends { containerName: string; image: string; sessionKey: string }, ->(params: { - read: () => Promise<{ entries: TEntry[] }>; - resolveConfiguredImage: (agentId?: string) => string; -}): Promise> { - const registry = await params.read(); - const results: Array = []; +export async function listSandboxContainers(): Promise { + const config = loadConfig(); + const registry = await readRegistry(); + const results: SandboxContainerInfo[] = []; for (const entry of registry.entries) { - const state = await dockerContainerState(entry.containerName); - // Get actual image from container. - let actualImage = entry.image; - if (state.exists) { - try { - const result = await execDocker( - ["inspect", "-f", "{{.Config.Image}}", entry.containerName], - { allowFailure: true }, - ); - if (result.code === 0) { - actualImage = result.stdout.trim(); - } - } catch { - // ignore - } + const backendId = entry.backendId ?? "docker"; + const manager = getSandboxBackendManager(backendId); + if (!manager) { + results.push({ + ...entry, + running: false, + imageMatch: true, + }); + continue; } const agentId = resolveSandboxAgentId(entry.sessionKey); - const configuredImage = params.resolveConfiguredImage(agentId); + const runtime = await manager.describeRuntime({ + entry, + config, + agentId, + }); results.push({ ...entry, - image: actualImage, - running: state.running, - imageMatch: actualImage === configuredImage, + image: runtime.actualConfigLabel ?? entry.image, + running: runtime.running, + imageMatch: runtime.configLabelMatch, }); } return results; } -export async function listSandboxContainers(): Promise { - const config = loadConfig(); - return listSandboxRegistryItems({ - read: readRegistry, - resolveConfiguredImage: (agentId) => resolveSandboxConfigForAgent(config, agentId).docker.image, - }); -} - export async function listSandboxBrowsers(): Promise { const config = loadConfig(); - return listSandboxRegistryItems({ - read: readBrowserRegistry, - resolveConfiguredImage: (agentId) => - resolveSandboxConfigForAgent(config, agentId).browser.image, - }); + const registry = await readBrowserRegistry(); + const results: SandboxBrowserInfo[] = []; + + for (const entry of registry.entries) { + const agentId = resolveSandboxAgentId(entry.sessionKey); + const runtime = await dockerSandboxBackendManager.describeRuntime({ + entry: { + ...entry, + backendId: "docker", + runtimeLabel: entry.containerName, + configLabelKind: "Image", + }, + config, + agentId, + }); + results.push({ + ...entry, + image: runtime.actualConfigLabel ?? entry.image, + running: runtime.running, + imageMatch: runtime.configLabelMatch, + }); + } + + return results; } export async function removeSandboxContainer(containerName: string): Promise { - try { - await execDocker(["rm", "-f", containerName], { allowFailure: true }); - } catch { - // ignore removal failures + const registry = await readRegistry(); + const entry = registry.entries.find((item) => item.containerName === containerName); + if (entry) { + const manager = getSandboxBackendManager(entry.backendId ?? "docker"); + await manager?.removeRuntime({ entry }); } await removeRegistryEntry(containerName); } export async function removeSandboxBrowserContainer(containerName: string): Promise { - try { - await execDocker(["rm", "-f", containerName], { allowFailure: true }); - } catch { - // ignore removal failures + const registry = await readBrowserRegistry(); + const entry = registry.entries.find((item) => item.containerName === containerName); + if (entry) { + await dockerSandboxBackendManager.removeRuntime({ + entry: { + ...entry, + backendId: "docker", + runtimeLabel: entry.containerName, + configLabelKind: "Image", + }, + }); } await removeBrowserRegistryEntry(containerName); - // Stop browser bridge if active for (const [sessionKey, bridge] of BROWSER_BRIDGES.entries()) { if (bridge.containerName === containerName) { await stopBrowserBridgeServer(bridge.bridge.server).catch(() => undefined); diff --git a/src/agents/sandbox/prune.ts b/src/agents/sandbox/prune.ts index 45e7fda6308..6ccfd8ac238 100644 --- a/src/agents/sandbox/prune.ts +++ b/src/agents/sandbox/prune.ts @@ -1,7 +1,8 @@ import { stopBrowserBridgeServer } from "../../browser/bridge-server.js"; import { defaultRuntime } from "../../runtime.js"; +import { getSandboxBackendManager } from "./backend.js"; import { BROWSER_BRIDGES } from "./browser-bridges.js"; -import { dockerContainerState, execDocker } from "./docker.js"; +import { dockerSandboxBackendManager } from "./docker-backend.js"; import { readBrowserRegistry, readRegistry, @@ -16,7 +17,7 @@ let lastPruneAtMs = 0; type PruneableRegistryEntry = Pick< SandboxRegistryEntry, - "containerName" | "createdAtMs" | "lastUsedAtMs" + "containerName" | "backendId" | "createdAtMs" | "lastUsedAtMs" >; function shouldPruneSandboxEntry(cfg: SandboxConfig, now: number, entry: PruneableRegistryEntry) { @@ -33,10 +34,11 @@ function shouldPruneSandboxEntry(cfg: SandboxConfig, now: number, entry: Pruneab ); } -async function pruneSandboxRegistryEntries(params: { +async function pruneSandboxRegistryEntries(params: { cfg: SandboxConfig; read: () => Promise<{ entries: TEntry[] }>; remove: (containerName: string) => Promise; + removeRuntime: (entry: TEntry) => Promise; onRemoved?: (entry: TEntry) => Promise; }) { const now = Date.now(); @@ -49,9 +51,7 @@ async function pruneSandboxRegistryEntries { + const manager = getSandboxBackendManager(entry.backendId ?? "docker"); + await manager?.removeRuntime({ entry }); + }, }); } async function pruneSandboxBrowsers(cfg: SandboxConfig) { - await pruneSandboxRegistryEntries({ + await pruneSandboxRegistryEntries< + SandboxBrowserRegistryEntry & { + backendId?: string; + runtimeLabel?: string; + configLabelKind?: string; + } + >({ cfg, read: readBrowserRegistry, remove: removeBrowserRegistryEntry, + removeRuntime: async (entry) => { + await dockerSandboxBackendManager.removeRuntime({ + entry: { + ...entry, + backendId: "docker", + runtimeLabel: entry.containerName, + configLabelKind: "Image", + }, + }); + }, onRemoved: async (entry) => { const bridge = BROWSER_BRIDGES.get(entry.sessionKey); if (bridge?.containerName === entry.containerName) { @@ -103,10 +123,3 @@ export async function maybePruneSandboxes(cfg: SandboxConfig) { defaultRuntime.error?.(`Sandbox prune failed: ${message ?? "unknown error"}`); } } - -export async function ensureDockerContainerIsRunning(containerName: string) { - const state = await dockerContainerState(containerName); - if (state.exists && !state.running) { - await execDocker(["start", containerName]); - } -} diff --git a/src/agents/sandbox/registry.test.ts b/src/agents/sandbox/registry.test.ts index 2de75190bf8..059e6f77c88 100644 --- a/src/agents/sandbox/registry.test.ts +++ b/src/agents/sandbox/registry.test.ts @@ -172,6 +172,28 @@ async function seedBrowserRegistry(entries: SandboxBrowserRegistryEntry[]) { } describe("registry race safety", () => { + it("normalizes legacy registry entries on read", async () => { + await seedContainerRegistry([ + { + containerName: "legacy-container", + sessionKey: "agent:main", + createdAtMs: 1, + lastUsedAtMs: 1, + image: "openclaw-sandbox:test", + }, + ]); + + const registry = await readRegistry(); + expect(registry.entries).toEqual([ + expect.objectContaining({ + containerName: "legacy-container", + backendId: "docker", + runtimeLabel: "legacy-container", + configLabelKind: "Image", + }), + ]); + }); + 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 54bb361934b..f8efebbf32b 100644 --- a/src/agents/sandbox/registry.ts +++ b/src/agents/sandbox/registry.ts @@ -5,10 +5,13 @@ import { SANDBOX_BROWSER_REGISTRY_PATH, SANDBOX_REGISTRY_PATH } from "./constant export type SandboxRegistryEntry = { containerName: string; + backendId?: string; + runtimeLabel?: string; sessionKey: string; createdAtMs: number; lastUsedAtMs: number; image: string; + configLabelKind?: string; configHash?: string; }; @@ -42,8 +45,11 @@ type RegistryFile = { }; type UpsertEntry = RegistryEntry & { + backendId?: string; + runtimeLabel?: string; createdAtMs: number; image: string; + configLabelKind?: string; configHash?: string; }; @@ -55,6 +61,15 @@ function isRegistryEntry(value: unknown): value is RegistryEntry { return isRecord(value) && typeof value.containerName === "string"; } +function normalizeSandboxRegistryEntry(entry: SandboxRegistryEntry): SandboxRegistryEntry { + return { + ...entry, + backendId: entry.backendId?.trim() || "docker", + runtimeLabel: entry.runtimeLabel?.trim() || entry.containerName, + configLabelKind: entry.configLabelKind?.trim() || "Image", + }; +} + function isRegistryFile(value: unknown): value is RegistryFile { if (!isRecord(value)) { return false; @@ -110,7 +125,13 @@ async function writeRegistryFile( } export async function readRegistry(): Promise { - return await readRegistryFromFile(SANDBOX_REGISTRY_PATH, "fallback"); + const registry = await readRegistryFromFile( + SANDBOX_REGISTRY_PATH, + "fallback", + ); + return { + entries: registry.entries.map((entry) => normalizeSandboxRegistryEntry(entry)), + }; } function upsertEntry(entries: T[], entry: T): T[] { @@ -118,8 +139,11 @@ function upsertEntry(entries: T[], entry: T): T[] { const next = entries.filter((item) => item.containerName !== entry.containerName); next.push({ ...entry, + backendId: entry.backendId ?? existing?.backendId, + runtimeLabel: entry.runtimeLabel ?? existing?.runtimeLabel, createdAtMs: existing?.createdAtMs ?? entry.createdAtMs, image: existing?.image ?? entry.image, + configLabelKind: entry.configLabelKind ?? existing?.configLabelKind, configHash: entry.configHash ?? existing?.configHash, }); return next; diff --git a/src/agents/sandbox/test-fixtures.ts b/src/agents/sandbox/test-fixtures.ts index db3835dcba5..b20b5b452f7 100644 --- a/src/agents/sandbox/test-fixtures.ts +++ b/src/agents/sandbox/test-fixtures.ts @@ -28,10 +28,13 @@ export function createSandboxTestContext(params?: { return { enabled: true, + backendId: "docker", sessionKey: "sandbox:test", workspaceDir: "/tmp/workspace", agentWorkspaceDir: "/tmp/workspace", workspaceAccess: "rw", + runtimeId: "openclaw-sbx-test", + runtimeLabel: "openclaw-sbx-test", containerName: "openclaw-sbx-test", containerWorkdir: "/workspace", tools: { allow: ["*"], deny: [] }, diff --git a/src/agents/sandbox/types.ts b/src/agents/sandbox/types.ts index 4ccfd691cfb..8244583ea0c 100644 --- a/src/agents/sandbox/types.ts +++ b/src/agents/sandbox/types.ts @@ -1,3 +1,4 @@ +import type { SandboxBackendHandle, SandboxBackendId } from "./backend.js"; import type { SandboxFsBridge } from "./fs-bridge.js"; import type { SandboxDockerConfig } from "./types.docker.js"; @@ -54,6 +55,7 @@ export type SandboxScope = "session" | "agent" | "shared"; export type SandboxConfig = { mode: "off" | "non-main" | "all"; + backend: SandboxBackendId; scope: SandboxScope; workspaceAccess: SandboxWorkspaceAccess; workspaceRoot: string; @@ -71,10 +73,13 @@ export type SandboxBrowserContext = { export type SandboxContext = { enabled: boolean; + backendId: SandboxBackendId; sessionKey: string; workspaceDir: string; agentWorkspaceDir: string; workspaceAccess: SandboxWorkspaceAccess; + runtimeId: string; + runtimeLabel: string; containerName: string; containerWorkdir: string; docker: SandboxDockerConfig; @@ -82,6 +87,7 @@ export type SandboxContext = { browserAllowHostControl: boolean; browser?: SandboxBrowserContext; fsBridge?: SandboxFsBridge; + backend?: SandboxBackendHandle; }; export type SandboxWorkspaceInfo = { diff --git a/src/agents/test-helpers/pi-tools-sandbox-context.ts b/src/agents/test-helpers/pi-tools-sandbox-context.ts index 286c5eed685..abf712c2c0b 100644 --- a/src/agents/test-helpers/pi-tools-sandbox-context.ts +++ b/src/agents/test-helpers/pi-tools-sandbox-context.ts @@ -18,10 +18,13 @@ export function createPiToolsSandboxContext(params: PiToolsSandboxContextParams) const workspaceDir = params.workspaceDir; return { enabled: true, + backendId: "docker", sessionKey: params.sessionKey ?? "sandbox:test", workspaceDir, agentWorkspaceDir: params.agentWorkspaceDir ?? workspaceDir, workspaceAccess: params.workspaceAccess ?? "rw", + runtimeId: params.containerName ?? "openclaw-sbx-test", + runtimeLabel: params.containerName ?? "openclaw-sbx-test", containerName: params.containerName ?? "openclaw-sbx-test", containerWorkdir: params.containerWorkdir ?? "/workspace", fsBridge: params.fsBridge, diff --git a/src/commands/doctor-sandbox.ts b/src/commands/doctor-sandbox.ts index 90790e90737..2138c422fe2 100644 --- a/src/commands/doctor-sandbox.ts +++ b/src/commands/doctor-sandbox.ts @@ -94,6 +94,11 @@ function resolveSandboxDockerImage(cfg: OpenClawConfig): string { return image ? image : DEFAULT_SANDBOX_IMAGE; } +function resolveSandboxBackend(cfg: OpenClawConfig): string { + const backend = cfg.agents?.defaults?.sandbox?.backend?.trim(); + return backend || "docker"; +} + function resolveSandboxBrowserImage(cfg: OpenClawConfig): string { const image = cfg.agents?.defaults?.sandbox?.browser?.image?.trim(); return image ? image : DEFAULT_SANDBOX_BROWSER_IMAGE; @@ -185,6 +190,16 @@ export async function maybeRepairSandboxImages( if (!sandbox || mode === "off") { return cfg; } + const backend = resolveSandboxBackend(cfg); + if (backend !== "docker") { + if (sandbox.browser?.enabled) { + note( + `Sandbox backend "${backend}" selected. Docker browser health checks are skipped; browser sandbox currently requires the docker backend.`, + "Sandbox", + ); + } + return cfg; + } const dockerAvailable = await isDockerAvailable(); if (!dockerAvailable) { diff --git a/src/commands/sandbox-display.ts b/src/commands/sandbox-display.ts index 181af6bcc1f..8eaf245c5bf 100644 --- a/src/commands/sandbox-display.ts +++ b/src/commands/sandbox-display.ts @@ -30,12 +30,15 @@ export function displayContainers(containers: SandboxContainerInfo[], runtime: R displayItems( containers, { - emptyMessage: "No sandbox containers found.", - title: "šŸ“¦ Sandbox Containers:", + emptyMessage: "No sandbox runtimes found.", + title: "šŸ“¦ Sandbox Runtimes:", renderItem: (container, rt) => { - rt.log(` ${container.containerName}`); + rt.log(` ${container.runtimeLabel ?? container.containerName}`); rt.log(` Status: ${formatStatus(container.running)}`); - rt.log(` Image: ${container.image} ${formatImageMatch(container.imageMatch)}`); + rt.log( + ` ${container.configLabelKind ?? "Image"}: ${container.image} ${formatImageMatch(container.imageMatch)}`, + ); + rt.log(` Backend: ${container.backendId ?? "docker"}`); rt.log( ` Age: ${formatDurationCompact(Date.now() - container.createdAtMs, { spaced: true }) ?? "0s"}`, ); @@ -92,9 +95,9 @@ export function displaySummary( runtime.log(`Total: ${totalCount} (${runningCount} running)`); if (mismatchCount > 0) { - runtime.log(`\nāš ļø ${mismatchCount} container(s) with image mismatch detected.`); + runtime.log(`\nāš ļø ${mismatchCount} runtime(s) with config mismatch detected.`); runtime.log( - ` Run '${formatCliCommand("openclaw sandbox recreate --all")}' to update all containers.`, + ` Run '${formatCliCommand("openclaw sandbox recreate --all")}' to update all runtimes.`, ); } } @@ -104,12 +107,14 @@ export function displayRecreatePreview( browsers: SandboxBrowserInfo[], runtime: RuntimeEnv, ): void { - runtime.log("\nContainers to be recreated:\n"); + runtime.log("\nSandbox runtimes to be recreated:\n"); if (containers.length > 0) { - runtime.log("šŸ“¦ Sandbox Containers:"); + runtime.log("šŸ“¦ Sandbox Runtimes:"); for (const container of containers) { - runtime.log(` - ${container.containerName} (${formatSimpleStatus(container.running)})`); + runtime.log( + ` - ${container.runtimeLabel ?? container.containerName} [${container.backendId ?? "docker"}] (${formatSimpleStatus(container.running)})`, + ); } } @@ -121,7 +126,7 @@ export function displayRecreatePreview( } const total = containers.length + browsers.length; - runtime.log(`\nTotal: ${total} container(s)`); + runtime.log(`\nTotal: ${total} runtime(s)`); } export function displayRecreateResult( @@ -131,6 +136,6 @@ export function displayRecreateResult( runtime.log(`\nDone: ${result.successCount} removed, ${result.failCount} failed`); if (result.successCount > 0) { - runtime.log("\nContainers will be automatically recreated when the agent is next used."); + runtime.log("\nRuntimes will be automatically recreated when the agent is next used."); } } diff --git a/src/commands/sandbox.test.ts b/src/commands/sandbox.test.ts index 384dc2eef41..7425e712c6f 100644 --- a/src/commands/sandbox.test.ts +++ b/src/commands/sandbox.test.ts @@ -29,10 +29,14 @@ import { sandboxListCommand, sandboxRecreateCommand } from "./sandbox.js"; const NOW = Date.now(); function createContainer(overrides: Partial = {}): SandboxContainerInfo { + const containerName = overrides.containerName ?? "openclaw-sandbox-test"; return { - containerName: "openclaw-sandbox-test", + containerName, + backendId: "docker", + runtimeLabel: containerName, sessionKey: "test-session", image: "openclaw/sandbox:latest", + configLabelKind: "Image", imageMatch: true, running: true, createdAtMs: NOW - 3600000, @@ -104,7 +108,7 @@ describe("sandboxListCommand", () => { await sandboxListCommand({ browser: false, json: false }, runtime as never); - expectLogContains(runtime, "šŸ“¦ Sandbox Containers"); + expectLogContains(runtime, "šŸ“¦ Sandbox Runtimes"); expectLogContains(runtime, container1.containerName); expectLogContains(runtime, container2.containerName); expectLogContains(runtime, "Total"); @@ -128,14 +132,14 @@ describe("sandboxListCommand", () => { await sandboxListCommand({ browser: false, json: false }, runtime as never); expectLogContains(runtime, "āš ļø"); - expectLogContains(runtime, "image mismatch"); + expectLogContains(runtime, "config mismatch"); expectLogContains(runtime, "sandbox recreate --all"); }); it("should display message when no containers found", async () => { await sandboxListCommand({ browser: false, json: false }, runtime as never); - expect(runtime.log).toHaveBeenCalledWith("No sandbox containers found."); + expect(runtime.log).toHaveBeenCalledWith("No sandbox runtimes found."); }); }); @@ -161,7 +165,7 @@ describe("sandboxListCommand", () => { await sandboxListCommand({ browser: false, json: false }, runtime as never); - expect(runtime.log).toHaveBeenCalledWith("No sandbox containers found."); + expect(runtime.log).toHaveBeenCalledWith("No sandbox runtimes found."); }); }); }); @@ -295,7 +299,7 @@ describe("sandboxRecreateCommand", () => { it("should show message when no containers match", async () => { await sandboxRecreateCommand({ all: true, browser: false, force: true }, runtime as never); - expect(runtime.log).toHaveBeenCalledWith("No containers found matching the criteria."); + expect(runtime.log).toHaveBeenCalledWith("No sandbox runtimes found matching the criteria."); expect(mocks.removeSandboxContainer).not.toHaveBeenCalled(); }); diff --git a/src/commands/sandbox.ts b/src/commands/sandbox.ts index e9071ce7810..d6b494fc5aa 100644 --- a/src/commands/sandbox.ts +++ b/src/commands/sandbox.ts @@ -74,7 +74,7 @@ export async function sandboxRecreateCommand( const filtered = await fetchAndFilterContainers(opts); if (filtered.containers.length + filtered.browsers.length === 0) { - runtime.log("No containers found matching the criteria."); + runtime.log("No sandbox runtimes found matching the criteria."); return; } @@ -154,7 +154,7 @@ async function removeContainers( filtered: FilteredContainers, runtime: RuntimeEnv, ): Promise<{ successCount: number; failCount: number }> { - runtime.log("\nRemoving containers...\n"); + runtime.log("\nRemoving sandbox runtimes...\n"); let successCount = 0; let failCount = 0; diff --git a/src/config/types.agents-shared.ts b/src/config/types.agents-shared.ts index 152c8973c11..1e398cc1c70 100644 --- a/src/config/types.agents-shared.ts +++ b/src/config/types.agents-shared.ts @@ -15,6 +15,8 @@ export type AgentModelConfig = export type AgentSandboxConfig = { mode?: "off" | "non-main" | "all"; + /** Sandbox runtime backend id. Default: "docker". */ + backend?: string; /** Agent workspace access inside the sandbox. */ workspaceAccess?: "none" | "ro" | "rw"; /** diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index d7b1dd393e7..2ee70e58ef6 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -496,6 +496,7 @@ const ToolLoopDetectionSchema = z export const AgentSandboxSchema = z .object({ mode: z.union([z.literal("off"), z.literal("non-main"), z.literal("all")]).optional(), + backend: z.string().min(1).optional(), workspaceAccess: z.union([z.literal("none"), z.literal("ro"), z.literal("rw")]).optional(), sessionToolsVisibility: z.union([z.literal("spawned"), z.literal("all")]).optional(), scope: z.union([z.literal("session"), z.literal("agent"), z.literal("shared")]).optional(), diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 4f403343b34..a792af23816 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -1,6 +1,7 @@ export type { AnyAgentTool, OpenClawPluginApi, + OpenClawPluginConfigSchema, ProviderDiscoveryContext, ProviderCatalogContext, ProviderCatalogResult, @@ -25,6 +26,22 @@ export type { ProviderAuthMethodNonInteractiveContext, ProviderAuthResult, } from "../plugins/types.js"; +export type { + CreateSandboxBackendParams, + SandboxBackendCommandParams, + SandboxBackendCommandResult, + SandboxBackendExecSpec, + SandboxBackendFactory, + SandboxFsBridge, + SandboxFsStat, + SandboxBackendHandle, + SandboxBackendId, + SandboxBackendManager, + SandboxBackendRegistration, + SandboxBackendRuntimeInfo, + SandboxContext, + SandboxResolvedPath, +} from "../agents/sandbox.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawConfig } from "../config/config.js"; @@ -36,6 +53,12 @@ export type { } from "../infra/provider-usage.types.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { + getSandboxBackendFactory, + getSandboxBackendManager, + registerSandboxBackend, + requireSandboxBackendFactory, +} from "../agents/sandbox.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export { applyProviderDefaultModel,