feat: add openshell sandbox backend

This commit is contained in:
Peter Steinberger 2026-03-15 20:02:05 -07:00
parent bc6ca4940b
commit d8b927ee6a
No known key found for this signature in database
44 changed files with 2343 additions and 121 deletions

View File

@ -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

View File

@ -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;

View File

@ -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
}
}
}

View File

@ -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"
]
}
}

View File

@ -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<typeof import("./cli.js")>();
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"],
});
});
});

View File

@ -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<SandboxBackendCommandResult>;
syncLocalPathToRemote(localPath: string, remotePath: string): Promise<void>;
};
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<OpenShellSandboxBackend> {
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<void> | 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<string, string>;
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<void> {
try {
await this.syncWorkspaceFromRemote();
} finally {
if (token?.sshSession) {
await disposeOpenShellSshSession(token.sshSession);
}
}
}
async runRemoteShellScript(
params: SandboxBackendCommandParams,
): Promise<SandboxBackendCommandResult> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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());
}

View File

@ -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'`);
});
});

View File

@ -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<OpenShellSshSession> {
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<void> {
await fs.rm(path.dirname(session.configPath), { recursive: true, force: true });
}
export async function runOpenShellSshCommand(
params: OpenShellRunSshCommandParams,
): Promise<SandboxBackendCommandResult> {
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<SandboxBackendCommandResult>((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, string>;
}): 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);
}

View File

@ -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");
});
});

View File

@ -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<string | number>; message: string }>;
};
};
function isRecord(value: unknown): value is Record<string, unknown> {
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<string>();
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,
};
}

View File

@ -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"));
});
});

View File

@ -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<Buffer> {
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<void> {
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<void> {
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<void> {
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<void> {
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<SandboxFsStat | null> {
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<void> {
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<string> {
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;
}
}

View File

@ -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<void> {
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<void> {
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 });
}

View File

@ -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<ExecProcessOutcome> => {
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 {

View File

@ -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<string, string>;
buildExecSpec?: (params: {
command: string;
workdir?: string;
env: Record<string, string>;
usePty: boolean;
}) => Promise<SandboxBackendExecSpec>;
finalizeExec?: (params: {
status: "completed" | "failed";
exitCode: number | null;
timedOut: boolean;
token?: unknown;
}) => Promise<void>;
};
export function buildSandboxEnv(params: {

View File

@ -5,10 +5,13 @@ import type { SandboxContext } from "./sandbox.js";
function createSandboxContext(overrides?: Partial<SandboxContext>): 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: {

View File

@ -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: {

View File

@ -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,
});

View File

@ -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");
});
});

View File

@ -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);
});

View File

@ -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,

View File

@ -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();
});
});

View File

@ -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<string, string>;
configLabel?: string;
configLabelKind?: string;
capabilities?: {
browser?: boolean;
};
buildExecSpec(params: {
command: string;
workdir?: string;
env: Record<string, string>;
usePty: boolean;
}): Promise<SandboxBackendExecSpec>;
finalizeExec?: (params: {
status: "completed" | "failed";
exitCode: number | null;
timedOut: boolean;
token?: unknown;
}) => Promise<void>;
runShellCommand(params: SandboxBackendCommandParams): Promise<SandboxBackendCommandResult>;
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<SandboxBackendRuntimeInfo>;
removeRuntime(params: { entry: SandboxRegistryEntry }): Promise<void>;
};
export type CreateSandboxBackendParams = {
sessionKey: string;
scopeKey: string;
workspaceDir: string;
agentWorkspaceDir: string;
cfg: SandboxConfig;
};
export type SandboxBackendFactory = (
params: CreateSandboxBackendParams,
) => Promise<SandboxBackendHandle>;
export type SandboxBackendRegistration =
| SandboxBackendFactory
| {
factory: SandboxBackendFactory;
manager?: SandboxBackendManager;
};
type RegisteredSandboxBackend = {
factory: SandboxBackendFactory;
manager?: SandboxBackendManager;
};
const SANDBOX_BACKEND_FACTORIES = new Map<SandboxBackendId, RegisteredSandboxBackend>();
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,
});

View File

@ -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",

View File

@ -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:

View File

@ -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;
}

View File

@ -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<SandboxBackendHandle> {
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<string, string>;
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
}
},
};

View File

@ -91,6 +91,7 @@ function createSandboxConfig(
): SandboxConfig {
return {
mode: "all",
backend: "docker",
scope: "shared",
workspaceAccess,
workspaceRoot: "~/.openclaw/sandboxes",

View File

@ -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;

View File

@ -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<ExecDockerRawResult> {
const dockerArgs = [
"exec",
"-i",
this.sandbox.containerName,
"sh",
"-c",
script,
"moltbot-sandbox-fs",
];
if (options.args?.length) {
dockerArgs.push(...options.args);
): Promise<SandboxBackendCommandResult> {
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<ExecDockerRawResult> {
): Promise<SandboxBackendCommandResult> {
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<ExecDockerRawResult> {
): Promise<SandboxBackendCommandResult> {
return await this.runCheckedCommand({ ...plan, signal });
}

View File

@ -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<Array<TEntry & { running: boolean; imageMatch: boolean }>> {
const registry = await params.read();
const results: Array<TEntry & { running: boolean; imageMatch: boolean }> = [];
export async function listSandboxContainers(): Promise<SandboxContainerInfo[]> {
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<SandboxContainerInfo[]> {
const config = loadConfig();
return listSandboxRegistryItems<SandboxRegistryEntry>({
read: readRegistry,
resolveConfiguredImage: (agentId) => resolveSandboxConfigForAgent(config, agentId).docker.image,
});
}
export async function listSandboxBrowsers(): Promise<SandboxBrowserInfo[]> {
const config = loadConfig();
return listSandboxRegistryItems<SandboxBrowserRegistryEntry>({
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<void> {
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<void> {
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);

View File

@ -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<TEntry extends PruneableRegistryEntry>(params: {
async function pruneSandboxRegistryEntries<TEntry extends SandboxRegistryEntry>(params: {
cfg: SandboxConfig;
read: () => Promise<{ entries: TEntry[] }>;
remove: (containerName: string) => Promise<void>;
removeRuntime: (entry: TEntry) => Promise<void>;
onRemoved?: (entry: TEntry) => Promise<void>;
}) {
const now = Date.now();
@ -49,9 +51,7 @@ async function pruneSandboxRegistryEntries<TEntry extends PruneableRegistryEntry
continue;
}
try {
await execDocker(["rm", "-f", entry.containerName], {
allowFailure: true,
});
await params.removeRuntime(entry);
} catch {
// ignore prune failures
} finally {
@ -66,14 +66,34 @@ async function pruneSandboxContainers(cfg: SandboxConfig) {
cfg,
read: readRegistry,
remove: removeRegistryEntry,
removeRuntime: async (entry) => {
const manager = getSandboxBackendManager(entry.backendId ?? "docker");
await manager?.removeRuntime({ entry });
},
});
}
async function pruneSandboxBrowsers(cfg: SandboxConfig) {
await pruneSandboxRegistryEntries<SandboxBrowserRegistryEntry>({
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]);
}
}

View File

@ -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" })),

View File

@ -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<T extends RegistryEntry> = {
};
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<T extends RegistryEntry>(value: unknown): value is RegistryFile<T> {
if (!isRecord(value)) {
return false;
@ -110,7 +125,13 @@ async function writeRegistryFile<T extends RegistryEntry>(
}
export async function readRegistry(): Promise<SandboxRegistry> {
return await readRegistryFromFile<SandboxRegistryEntry>(SANDBOX_REGISTRY_PATH, "fallback");
const registry = await readRegistryFromFile<SandboxRegistryEntry>(
SANDBOX_REGISTRY_PATH,
"fallback",
);
return {
entries: registry.entries.map((entry) => normalizeSandboxRegistryEntry(entry)),
};
}
function upsertEntry<T extends UpsertEntry>(entries: T[], entry: T): T[] {
@ -118,8 +139,11 @@ function upsertEntry<T extends 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;

View File

@ -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: [] },

View File

@ -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 = {

View File

@ -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,

View File

@ -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) {

View File

@ -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.");
}
}

View File

@ -29,10 +29,14 @@ import { sandboxListCommand, sandboxRecreateCommand } from "./sandbox.js";
const NOW = Date.now();
function createContainer(overrides: Partial<SandboxContainerInfo> = {}): 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();
});

View File

@ -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;

View File

@ -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";
/**

View File

@ -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(),

View File

@ -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,