feat: add openshell sandbox backend
This commit is contained in:
parent
bc6ca4940b
commit
d8b927ee6a
@ -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
|
||||
|
||||
|
||||
30
extensions/openshell/index.ts
Normal file
30
extensions/openshell/index.ts
Normal 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;
|
||||
99
extensions/openshell/openclaw.plugin.json
Normal file
99
extensions/openshell/openclaw.plugin.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
12
extensions/openshell/package.json
Normal file
12
extensions/openshell/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
117
extensions/openshell/src/backend.test.ts
Normal file
117
extensions/openshell/src/backend.test.ts
Normal 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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
445
extensions/openshell/src/backend.ts
Normal file
445
extensions/openshell/src/backend.ts
Normal 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());
|
||||
}
|
||||
37
extensions/openshell/src/cli.test.ts
Normal file
37
extensions/openshell/src/cli.test.ts
Normal 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'`);
|
||||
});
|
||||
});
|
||||
166
extensions/openshell/src/cli.ts
Normal file
166
extensions/openshell/src/cli.ts
Normal 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);
|
||||
}
|
||||
28
extensions/openshell/src/config.test.ts
Normal file
28
extensions/openshell/src/config.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
225
extensions/openshell/src/config.ts
Normal file
225
extensions/openshell/src/config.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
88
extensions/openshell/src/fs-bridge.test.ts
Normal file
88
extensions/openshell/src/fs-bridge.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
336
extensions/openshell/src/fs-bridge.ts
Normal file
336
extensions/openshell/src/fs-bridge.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
47
extensions/openshell/src/mirror.ts
Normal file
47
extensions/openshell/src/mirror.ts
Normal 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 });
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
39
src/agents/sandbox/backend.test.ts
Normal file
39
src/agents/sandbox/backend.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
148
src/agents/sandbox/backend.ts
Normal file
148
src/agents/sandbox/backend.ts
Normal 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,
|
||||
});
|
||||
@ -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",
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
130
src/agents/sandbox/docker-backend.ts
Normal file
130
src/agents/sandbox/docker-backend.ts
Normal 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
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -91,6 +91,7 @@ function createSandboxConfig(
|
||||
): SandboxConfig {
|
||||
return {
|
||||
mode: "all",
|
||||
backend: "docker",
|
||||
scope: "shared",
|
||||
workspaceAccess,
|
||||
workspaceRoot: "~/.openclaw/sandboxes",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" })),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: [] },
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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";
|
||||
/**
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user