重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,28 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox";
import {
createOpenShellSandboxBackendFactory,
createOpenShellSandboxBackendManager,
} from "./src/backend.js";
import { createOpenShellPluginConfigSchema, resolveOpenShellPluginConfig } from "./src/config.js";
export default definePluginEntry({
id: "openshell",
name: "OpenShell Sandbox",
description: "OpenShell-backed sandbox runtime for agent exec and file tools.",
configSchema: createOpenShellPluginConfigSchema(),
register(api) {
if (api.registrationMode !== "full") {
return;
}
const pluginConfig = resolveOpenShellPluginConfig(api.pluginConfig);
registerSandboxBackend("openshell", {
factory: createOpenShellSandboxBackendFactory({
pluginConfig,
}),
manager: createOpenShellSandboxBackendManager({
pluginConfig,
}),
});
},
});

View file

@ -0,0 +1,115 @@
{
"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": {
"mode": {
"type": "string",
"enum": ["mirror", "remote"]
},
"command": {
"type": "string",
"minLength": 1
},
"gateway": {
"type": "string",
"minLength": 1
},
"gatewayEndpoint": {
"type": "string",
"minLength": 1
},
"from": {
"type": "string",
"minLength": 1
},
"policy": {
"type": "string",
"minLength": 1
},
"providers": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"gpu": {
"type": "boolean"
},
"autoProviders": {
"type": "boolean"
},
"remoteWorkspaceDir": {
"type": "string",
"minLength": 1
},
"remoteAgentWorkspaceDir": {
"type": "string",
"minLength": 1
},
"timeoutSeconds": {
"type": "number",
"minimum": 1
}
}
},
"uiHints": {
"mode": {
"label": "Mode",
"help": "Sandbox mode. Use mirror for the default local-workspace flow or remote for a fully remote workspace."
},
"command": {
"label": "OpenShell Command",
"help": "Path or command name for the openshell CLI."
},
"gateway": {
"label": "Gateway Name",
"help": "Optional OpenShell gateway name passed as --gateway."
},
"gatewayEndpoint": {
"label": "Gateway Endpoint",
"help": "Optional OpenShell gateway endpoint passed as --gateway-endpoint."
},
"from": {
"label": "Sandbox Source",
"help": "OpenShell sandbox source for first-time create. Defaults to openclaw."
},
"policy": {
"label": "Policy File",
"help": "Optional path to a custom OpenShell sandbox policy YAML."
},
"providers": {
"label": "Providers",
"help": "Provider names to attach when a sandbox is created."
},
"gpu": {
"label": "GPU",
"help": "Request GPU resources when creating the sandbox.",
"advanced": true
},
"autoProviders": {
"label": "Auto-create Providers",
"help": "When enabled, pass --auto-providers during sandbox create.",
"advanced": true
},
"remoteWorkspaceDir": {
"label": "Remote Workspace Dir",
"help": "Primary writable workspace inside the OpenShell sandbox.",
"advanced": true
},
"remoteAgentWorkspaceDir": {
"label": "Remote Agent Dir",
"help": "Mirror path for the real agent workspace when workspaceAccess is read-only.",
"advanced": true
},
"timeoutSeconds": {
"label": "Command Timeout Seconds",
"help": "Timeout for openshell CLI operations such as create/upload/download.",
"advanced": true
}
}
}

View file

@ -0,0 +1,18 @@
{
"name": "@openclaw/openshell-sandbox",
"version": "2026.4.20",
"private": true,
"description": "OpenClaw OpenShell sandbox backend",
"type": "module",
"dependencies": {
"openshell": "0.1.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View file

@ -0,0 +1,589 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createSandboxTestContext } from "../../../src/agents/sandbox/test-fixtures.js";
import {
createSandboxBrowserConfig,
createSandboxPruneConfig,
createSandboxSshConfig,
} from "../../../test/helpers/sandbox-fixtures.js";
import { createOpenShellSandboxBackendFactory } from "./backend.js";
import { resolveOpenShellPluginConfig } from "./config.js";
const OPENCLAW_OPENSHELL_E2E = process.env.OPENCLAW_E2E_OPENSHELL === "1";
const OPENCLAW_OPENSHELL_E2E_TIMEOUT_MS = 12 * 60_000;
const OPENCLAW_OPENSHELL_COMMAND =
process.env.OPENCLAW_E2E_OPENSHELL_COMMAND?.trim() || "openshell";
const CUSTOM_IMAGE_DOCKERFILE = `FROM python:3.13-slim
RUN apt-get update && apt-get install -y --no-install-recommends \\
coreutils \\
curl \\
findutils \\
iproute2 \\
&& rm -rf /var/lib/apt/lists/*
RUN groupadd -g 1000 sandbox && \\
useradd -m -u 1000 -g sandbox sandbox
RUN echo "openclaw-openshell-e2e" > /opt/openshell-e2e-marker.txt
WORKDIR /sandbox
CMD ["sleep", "infinity"]
`;
type ExecResult = {
code: number;
stdout: string;
stderr: string;
};
type HostPolicyServer = {
port: number;
close(): Promise<void>;
};
async function runCommand(params: {
command: string;
args: string[];
cwd?: string;
env?: NodeJS.ProcessEnv;
stdin?: string | Uint8Array;
allowFailure?: boolean;
timeoutMs?: number;
}): Promise<ExecResult> {
return await new Promise((resolve, reject) => {
const child = spawn(params.command, params.args, {
cwd: params.cwd,
env: params.env,
stdio: ["pipe", "pipe", "pipe"],
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let timedOut = false;
const timeout =
params.timeoutMs && params.timeoutMs > 0
? setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, params.timeoutMs)
: null;
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) => {
if (timeout) {
clearTimeout(timeout);
}
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
const stderr = Buffer.concat(stderrChunks).toString("utf8");
if (timedOut) {
reject(new Error(`command timed out: ${params.command} ${params.args.join(" ")}`));
return;
}
const exitCode = code ?? 0;
if (exitCode !== 0 && !params.allowFailure) {
reject(
new Error(
[
`command failed: ${params.command} ${params.args.join(" ")}`,
`exit: ${exitCode}`,
stdout.trim() ? `stdout:\n${stdout}` : "",
stderr.trim() ? `stderr:\n${stderr}` : "",
]
.filter(Boolean)
.join("\n"),
),
);
return;
}
resolve({ code: exitCode, stdout, stderr });
});
child.stdin.end(params.stdin);
});
}
async function commandAvailable(command: string): Promise<boolean> {
try {
const result = await runCommand({
command,
args: ["--help"],
allowFailure: true,
timeoutMs: 20_000,
});
return result.code === 0;
} catch {
return false;
}
}
async function openshellGatewayAvailable(command: string): Promise<boolean> {
try {
const result = await runCommand({
command,
args: ["gateway", "start", "--help"],
allowFailure: true,
timeoutMs: 20_000,
});
return result.code === 0 && `${result.stdout}\n${result.stderr}`.includes("--name");
} catch {
return false;
}
}
async function dockerReady(): Promise<boolean> {
try {
const result = await runCommand({
command: "docker",
args: ["version"],
allowFailure: true,
timeoutMs: 20_000,
});
return result.code === 0;
} catch {
return false;
}
}
async function allocatePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("failed to allocate local port")));
return;
}
const { port } = address;
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(port);
});
});
});
}
function openshellEnv(rootDir: string): NodeJS.ProcessEnv {
const homeDir = path.join(rootDir, "home");
const xdgDir = path.join(rootDir, "xdg");
const cacheDir = path.join(rootDir, "xdg-cache");
return {
...process.env,
HOME: homeDir,
XDG_CONFIG_HOME: xdgDir,
XDG_CACHE_HOME: cacheDir,
};
}
function trimTrailingNewline(value: string): string {
return value.replace(/\r?\n$/, "");
}
async function startHostPolicyServer(): Promise<HostPolicyServer> {
const port = await allocatePort();
const responseBody = JSON.stringify({ ok: true, message: "hello-from-host" });
const serverScript = `from http.server import BaseHTTPRequestHandler, HTTPServer
import os
BODY = os.environ["RESPONSE_BODY"].encode()
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(BODY)))
self.end_headers()
self.wfile.write(BODY)
def do_POST(self):
length = int(self.headers.get("Content-Length", "0"))
if length:
self.rfile.read(length)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(BODY)))
self.end_headers()
self.wfile.write(BODY)
def log_message(self, _format, *_args):
pass
HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
`;
const startResult = await runCommand({
command: "docker",
args: [
"run",
"--detach",
"--rm",
"-e",
`RESPONSE_BODY=${responseBody}`,
"-p",
`${port}:8000`,
"python:3.13-alpine",
"python3",
"-c",
serverScript,
],
timeoutMs: 60_000,
});
const containerId = trimTrailingNewline(startResult.stdout.trim());
if (!containerId) {
throw new Error("failed to start docker-backed host policy server");
}
const startedAt = Date.now();
while (Date.now() - startedAt < 30_000) {
const readyResult = await runCommand({
command: "docker",
args: [
"exec",
containerId,
"python3",
"-c",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000', timeout=1).read()",
],
allowFailure: true,
timeoutMs: 15_000,
});
if (readyResult.code === 0) {
return {
port,
async close() {
await runCommand({
command: "docker",
args: ["rm", "-f", containerId],
allowFailure: true,
timeoutMs: 30_000,
});
},
};
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
await runCommand({
command: "docker",
args: ["rm", "-f", containerId],
allowFailure: true,
timeoutMs: 30_000,
});
throw new Error("docker-backed host policy server did not become ready");
}
function buildOpenShellPolicyYaml(params: { port: number; binaryPath: string }): string {
const networkPolicies = ` host_echo:
name: host-echo
endpoints:
- host: host.openshell.internal
port: ${params.port}
allowed_ips:
- "0.0.0.0/0"
binaries:
- path: ${params.binaryPath}`;
return `version: 1
filesystem_policy:
include_workdir: true
read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log]
read_write: [/sandbox, /tmp, /dev/null]
landlock:
compatibility: best_effort
process:
run_as_user: sandbox
run_as_group: sandbox
network_policies:
${networkPolicies}
`;
}
async function runBackendExec(params: {
backend: Awaited<ReturnType<ReturnType<typeof createOpenShellSandboxBackendFactory>>>;
command: string;
allowFailure?: boolean;
timeoutMs?: number;
}): Promise<ExecResult> {
const execSpec = await params.backend.buildExecSpec({
command: params.command,
env: {},
usePty: false,
});
let result: ExecResult | null = null;
try {
result = await runCommand({
command: execSpec.argv[0] ?? "ssh",
args: execSpec.argv.slice(1),
env: execSpec.env,
allowFailure: params.allowFailure,
timeoutMs: params.timeoutMs,
});
return result;
} finally {
await params.backend.finalizeExec?.({
status: result?.code === 0 ? "completed" : "failed",
exitCode: result?.code ?? 1,
timedOut: false,
token: execSpec.finalizeToken,
});
}
}
describe("openshell sandbox backend e2e", () => {
it.runIf(process.platform !== "win32" && OPENCLAW_OPENSHELL_E2E)(
"creates a remote-canonical sandbox through OpenShell and executes over SSH",
{ timeout: OPENCLAW_OPENSHELL_E2E_TIMEOUT_MS },
async () => {
if (!(await dockerReady())) {
return;
}
if (!(await commandAvailable(OPENCLAW_OPENSHELL_COMMAND))) {
return;
}
if (!(await openshellGatewayAvailable(OPENCLAW_OPENSHELL_COMMAND))) {
return;
}
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-openshell-e2e-"));
const env = openshellEnv(rootDir);
const previousHome = process.env.HOME;
const previousXdgConfigHome = process.env.XDG_CONFIG_HOME;
const previousXdgCacheHome = process.env.XDG_CACHE_HOME;
const workspaceDir = path.join(rootDir, "workspace");
const dockerfileDir = path.join(rootDir, "custom-image");
const dockerfilePath = path.join(dockerfileDir, "Dockerfile");
const denyPolicyPath = path.join(rootDir, "deny-policy.yaml");
const allowPolicyPath = path.join(rootDir, "allow-policy.yaml");
const scopeSuffix = `${process.pid}-${Date.now()}`;
const gatewayName = `openclaw-e2e-${scopeSuffix}`;
const scopeKey = `session:openshell-e2e-deny:${scopeSuffix}`;
const allowSandboxName = `openclaw-policy-allow-${scopeSuffix}`;
const gatewayPort = await allocatePort();
let hostPolicyServer: HostPolicyServer | null = null;
const sandboxCfg = {
mode: "all" as const,
backend: "openshell" as const,
scope: "session" as const,
workspaceAccess: "rw" as const,
workspaceRoot: path.join(rootDir, "sandboxes"),
docker: {
image: "openclaw-sandbox:bookworm-slim",
containerPrefix: "openclaw-sbx-",
workdir: "/workspace",
readOnlyRoot: true,
tmpfs: ["/tmp"],
network: "none",
capDrop: ["ALL"],
env: {},
},
ssh: createSandboxSshConfig("/tmp/openclaw-sandboxes"),
browser: createSandboxBrowserConfig(),
tools: { allow: [], deny: [] },
prune: createSandboxPruneConfig(),
};
const pluginConfig = resolveOpenShellPluginConfig({
command: OPENCLAW_OPENSHELL_COMMAND,
gateway: gatewayName,
from: dockerfilePath,
mode: "remote",
autoProviders: false,
policy: denyPolicyPath,
});
const backendFactory = createOpenShellSandboxBackendFactory({ pluginConfig });
const backend = await backendFactory({
sessionKey: scopeKey,
scopeKey,
workspaceDir,
agentWorkspaceDir: workspaceDir,
cfg: sandboxCfg,
});
try {
process.env.HOME = env.HOME;
process.env.XDG_CONFIG_HOME = env.XDG_CONFIG_HOME;
process.env.XDG_CACHE_HOME = env.XDG_CACHE_HOME;
hostPolicyServer = await startHostPolicyServer();
if (!hostPolicyServer) {
throw new Error("failed to start host policy server");
}
await fs.mkdir(workspaceDir, { recursive: true });
await fs.mkdir(dockerfileDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "seed.txt"), "seed-from-local\n", "utf8");
await fs.writeFile(dockerfilePath, CUSTOM_IMAGE_DOCKERFILE, "utf8");
await fs.writeFile(
denyPolicyPath,
buildOpenShellPolicyYaml({
port: hostPolicyServer.port,
binaryPath: "/usr/bin/false",
}),
"utf8",
);
await fs.writeFile(
allowPolicyPath,
buildOpenShellPolicyYaml({
port: hostPolicyServer.port,
binaryPath: "/**",
}),
"utf8",
);
await runCommand({
command: OPENCLAW_OPENSHELL_COMMAND,
args: [
"gateway",
"start",
"--name",
gatewayName,
"--port",
String(gatewayPort),
"--recreate",
],
env,
timeoutMs: 8 * 60_000,
});
const execResult = await runBackendExec({
backend,
command: "pwd && cat /opt/openshell-e2e-marker.txt && cat seed.txt",
timeoutMs: 2 * 60_000,
});
expect(execResult.code).toBe(0);
const stdout = execResult.stdout.trim();
expect(stdout).toContain("/sandbox");
expect(stdout).toContain("openclaw-openshell-e2e");
expect(stdout).toContain("seed-from-local");
const curlPathResult = await runBackendExec({
backend,
command: "command -v curl",
timeoutMs: 60_000,
});
expect(trimTrailingNewline(curlPathResult.stdout.trim())).toMatch(/^\/.+\/curl$/);
const sandbox = createSandboxTestContext({
overrides: {
backendId: "openshell",
workspaceDir,
agentWorkspaceDir: workspaceDir,
runtimeId: backend.runtimeId,
runtimeLabel: backend.runtimeLabel,
containerName: backend.runtimeId,
containerWorkdir: backend.workdir,
backend,
},
});
const bridge = backend.createFsBridge?.({ sandbox });
if (!bridge) {
throw new Error("openshell backend did not create a filesystem bridge");
}
await bridge.writeFile({ filePath: "nested/remote-only.txt", data: "hello-remote\n" });
await expect(
fs.readFile(path.join(workspaceDir, "nested", "remote-only.txt"), "utf8"),
).rejects.toThrow();
await expect(bridge.readFile({ filePath: "nested/remote-only.txt" })).resolves.toEqual(
Buffer.from("hello-remote\n"),
);
const verifyResult = await runCommand({
command: OPENCLAW_OPENSHELL_COMMAND,
args: ["sandbox", "ssh-config", backend.runtimeId],
env,
timeoutMs: 60_000,
});
expect(verifyResult.code).toBe(0);
expect(trimTrailingNewline(verifyResult.stdout)).toContain("Host ");
const blockedGetResult = await runBackendExec({
backend,
command: `curl --fail --silent --show-error --max-time 15 "http://host.openshell.internal:${hostPolicyServer.port}/policy-test"`,
allowFailure: true,
timeoutMs: 60_000,
});
expect(blockedGetResult.code).not.toBe(0);
expect(`${blockedGetResult.stdout}\n${blockedGetResult.stderr}`).toMatch(/403|deny/i);
const allowedGetResult = await runCommand({
command: OPENCLAW_OPENSHELL_COMMAND,
args: [
"sandbox",
"create",
"--name",
allowSandboxName,
"--from",
dockerfilePath,
"--policy",
allowPolicyPath,
"--no-auto-providers",
"--no-keep",
"--",
"curl",
"--fail",
"--silent",
"--show-error",
"--max-time",
"15",
`http://host.openshell.internal:${hostPolicyServer.port}/policy-test`,
],
env,
timeoutMs: 60_000,
});
expect(allowedGetResult.code).toBe(0);
expect(allowedGetResult.stdout).toContain('"message":"hello-from-host"');
} finally {
await runCommand({
command: OPENCLAW_OPENSHELL_COMMAND,
args: ["sandbox", "delete", backend.runtimeId],
env,
allowFailure: true,
timeoutMs: 2 * 60_000,
});
await runCommand({
command: OPENCLAW_OPENSHELL_COMMAND,
args: ["sandbox", "delete", allowSandboxName],
env,
allowFailure: true,
timeoutMs: 2 * 60_000,
});
await runCommand({
command: OPENCLAW_OPENSHELL_COMMAND,
args: ["gateway", "destroy", "--name", gatewayName],
env,
allowFailure: true,
timeoutMs: 3 * 60_000,
});
await hostPolicyServer?.close().catch(() => {});
await fs.rm(rootDir, { recursive: true, force: true });
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
if (previousXdgConfigHome === undefined) {
delete process.env.XDG_CONFIG_HOME;
} else {
process.env.XDG_CONFIG_HOME = previousXdgConfigHome;
}
if (previousXdgCacheHome === undefined) {
delete process.env.XDG_CACHE_HOME;
} else {
process.env.XDG_CACHE_HOME = previousXdgCacheHome;
}
}
},
);
});

View file

@ -0,0 +1,29 @@
import { afterEach, describe, expect, it } from "vitest";
import { buildOpenShellSshExecEnv } from "./backend.js";
describe("openshell backend env", () => {
const originalEnv = { ...process.env };
afterEach(() => {
for (const key of Object.keys(process.env)) {
if (!(key in originalEnv)) {
delete process.env[key];
}
}
Object.assign(process.env, originalEnv);
});
it("filters blocked secrets from ssh exec env", () => {
process.env.OPENAI_API_KEY = "sk-test-secret";
process.env.ANTHROPIC_API_KEY = "sk-ant-test-secret";
process.env.LANG = "en_US.UTF-8";
process.env.NODE_ENV = "test";
const env = buildOpenShellSshExecEnv();
expect(env.OPENAI_API_KEY).toBeUndefined();
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
expect(env.LANG).toBe("en_US.UTF-8");
expect(env.NODE_ENV).toBe("test");
});
});

View file

@ -0,0 +1,515 @@
import fs from "node:fs/promises";
import path from "node:path";
import type {
CreateSandboxBackendParams,
OpenClawConfig,
SandboxBackendCommandParams,
SandboxBackendCommandResult,
SandboxBackendFactory,
SandboxBackendManager,
SshSandboxSession,
} from "openclaw/plugin-sdk/sandbox";
import {
createRemoteShellSandboxFsBridge,
disposeSshSandboxSession,
resolvePreferredOpenClawTmpDir,
runSshSandboxCommand,
sanitizeEnvVars,
} from "openclaw/plugin-sdk/sandbox";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type { OpenShellSandboxBackend } from "./backend.types.js";
import {
buildExecRemoteCommand,
buildRemoteCommand,
createOpenShellSshSession,
runOpenShellCli,
type OpenShellExecContext,
} from "./cli.js";
import { resolveOpenShellPluginConfig, type ResolvedOpenShellPluginConfig } from "./config.js";
import { createOpenShellFsBridge } from "./fs-bridge.js";
import {
DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS,
replaceDirectoryContents,
stageDirectoryContents,
} from "./mirror.js";
type CreateOpenShellSandboxBackendFactoryParams = {
pluginConfig: ResolvedOpenShellPluginConfig;
};
type PendingExec = {
sshSession: SshSandboxSession;
};
export function buildOpenShellSshExecEnv(): NodeJS.ProcessEnv {
return sanitizeEnvVars(process.env).allowed;
}
export type { OpenShellFsBridgeContext, OpenShellSandboxBackend } from "./backend.types.js";
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,
mode: params.pluginConfig.mode,
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: buildOpenShellSshExecEnv(),
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 }) =>
params.pluginConfig.mode === "remote"
? createRemoteShellSandboxFsBridge({
sandbox,
runtime: impl.asHandle(),
})
: 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;
private remoteSeedPending = false;
constructor(
private readonly params: {
createParams: CreateSandboxBackendParams;
execContext: OpenShellExecContext;
remoteWorkspaceDir: string;
remoteAgentWorkspaceDir: string;
},
) {}
asHandle(): OpenShellSandboxBackend {
return {
id: "openshell",
runtimeId: this.params.execContext.sandboxName,
runtimeLabel: this.params.execContext.sandboxName,
workdir: this.params.remoteWorkspaceDir,
env: this.params.createParams.cfg.docker.env,
mode: this.params.execContext.config.mode,
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 this.prepareExec({ command, workdir, env, usePty });
return {
argv: pending.argv,
env: buildOpenShellSshExecEnv(),
stdinMode: "pipe-open",
finalizeToken: pending.token,
};
},
finalizeExec: async ({ token }) => {
await this.finalizeExec(token as PendingExec | undefined);
},
runShellCommand: async (command) => await this.runRemoteShellScript(command),
createFsBridge: ({ sandbox }) =>
this.params.execContext.config.mode === "remote"
? createRemoteShellSandboxFsBridge({
sandbox,
runtime: this.asHandle(),
})
: createOpenShellFsBridge({
sandbox,
backend: this.asHandle(),
}),
runRemoteShellScript: async (command) => await this.runRemoteShellScript(command),
syncLocalPathToRemote: async (localPath, remotePath) =>
await this.syncLocalPathToRemote(localPath, remotePath),
};
}
async prepareExec(params: {
command: string;
workdir?: string;
env: Record<string, string>;
usePty: boolean;
}): Promise<{ argv: string[]; token: PendingExec }> {
await this.ensureSandboxExists();
if (this.params.execContext.config.mode === "mirror") {
await this.syncWorkspaceToRemote();
} else {
await this.maybeSeedRemoteWorkspace();
}
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 {
if (this.params.execContext.config.mode === "mirror") {
await this.syncWorkspaceFromRemote();
}
} finally {
if (token?.sshSession) {
await disposeSshSandboxSession(token.sshSession);
}
}
}
async runRemoteShellScript(
params: SandboxBackendCommandParams,
): Promise<SandboxBackendCommandResult> {
await this.ensureSandboxExists();
await this.maybeSeedRemoteWorkspace();
return await this.runRemoteShellScriptInternal(params);
}
private async runRemoteShellScriptInternal(
params: SandboxBackendCommandParams,
): Promise<SandboxBackendCommandResult> {
const session = await createOpenShellSshSession({
context: this.params.execContext,
});
try {
return await runSshSandboxCommand({
session,
remoteCommand: buildRemoteCommand([
"/bin/sh",
"-c",
params.script,
"openclaw-openshell-fs",
...(params.args ?? []),
]),
stdin: params.stdin,
allowFailure: params.allowFailure,
signal: params.signal,
});
} finally {
await disposeSshSandboxSession(session);
}
}
async syncLocalPathToRemote(localPath: string, remotePath: string): Promise<void> {
await this.ensureSandboxExists();
await this.maybeSeedRemoteWorkspace();
const stats = await fs.lstat(localPath).catch(() => null);
if (!stats) {
await this.runRemoteShellScript({
script: 'rm -rf -- "$1"',
args: [remotePath],
allowFailure: true,
});
return;
}
if (stats.isSymbolicLink()) {
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");
}
this.remoteSeedPending = true;
}
private async syncWorkspaceToRemote(): Promise<void> {
await this.runRemoteShellScriptInternal({
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.runRemoteShellScriptInternal({
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,
// Never sync trusted host hook directories or repository metadata from
// the remote sandbox.
excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS,
});
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
}
private async uploadPathToRemote(localPath: string, remotePath: string): Promise<void> {
const tmpDir = await fs.mkdtemp(
path.join(resolveOpenShellTmpRoot(), "openclaw-openshell-upload-"),
);
try {
// Stage a symlink-free snapshot so upload never dereferences host paths
// outside the mirrored workspace tree.
await stageDirectoryContents({
sourceDir: localPath,
targetDir: tmpDir,
});
const result = await runOpenShellCli({
context: this.params.execContext,
args: [
"sandbox",
"upload",
"--no-git-ignore",
this.params.execContext.sandboxName,
tmpDir,
remotePath,
],
cwd: this.params.createParams.workspaceDir,
});
if (result.code !== 0) {
throw new Error(result.stderr.trim() || "openshell sandbox upload failed");
}
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
}
private async maybeSeedRemoteWorkspace(): Promise<void> {
if (!this.remoteSeedPending) {
return;
}
this.remoteSeedPending = false;
try {
await this.syncWorkspaceToRemote();
} catch (error) {
this.remoteSeedPending = true;
throw error;
}
}
}
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 = normalizeLowercaseStringOrEmpty(trimmed)
.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());
}

View file

@ -0,0 +1,11 @@
import type { RemoteShellSandboxHandle, SandboxBackendHandle } from "openclaw/plugin-sdk/sandbox";
export type OpenShellFsBridgeContext = Parameters<
NonNullable<SandboxBackendHandle["createFsBridge"]>
>[0]["sandbox"];
export type OpenShellSandboxBackend = SandboxBackendHandle &
RemoteShellSandboxHandle & {
mode: "mirror" | "remote";
syncLocalPathToRemote(localPath: string, remotePath: string): Promise<void>;
};

View file

@ -0,0 +1,103 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import {
buildExecRemoteCommand,
createSshSandboxSessionFromConfigText,
runPluginCommandWithTimeout,
shellEscape,
type SshSandboxSession,
} from "openclaw/plugin-sdk/sandbox";
import type { ResolvedOpenShellPluginConfig } from "./config.js";
export { buildExecRemoteCommand, shellEscape } from "openclaw/plugin-sdk/sandbox";
const require = createRequire(import.meta.url);
let cachedBundledOpenShellCommand: string | null | undefined;
let bundledCommandResolverForTest: (() => string | null) | undefined;
export type OpenShellExecContext = {
config: ResolvedOpenShellPluginConfig;
sandboxName: string;
timeoutMs?: number;
};
export function setBundledOpenShellCommandResolverForTest(resolver?: () => string | null): void {
bundledCommandResolverForTest = resolver;
cachedBundledOpenShellCommand = undefined;
}
function resolveBundledOpenShellCommand(): string | null {
if (bundledCommandResolverForTest) {
return bundledCommandResolverForTest();
}
if (cachedBundledOpenShellCommand !== undefined) {
return cachedBundledOpenShellCommand;
}
try {
const packageJsonPath = require.resolve("openshell/package.json");
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
bin?: string | Record<string, string>;
};
const relativeBin =
typeof packageJson.bin === "string" ? packageJson.bin : packageJson.bin?.openshell;
cachedBundledOpenShellCommand = relativeBin
? path.resolve(path.dirname(packageJsonPath), relativeBin)
: null;
} catch {
cachedBundledOpenShellCommand = null;
}
return cachedBundledOpenShellCommand;
}
export function resolveOpenShellCommand(command: string): string {
if (command !== "openshell") {
return command;
}
return resolveBundledOpenShellCommand() ?? command;
}
export function buildOpenShellBaseArgv(config: ResolvedOpenShellPluginConfig): string[] {
const argv = [resolveOpenShellCommand(config.command)];
if (config.gateway) {
argv.push("--gateway", config.gateway);
}
if (config.gatewayEndpoint) {
argv.push("--gateway-endpoint", config.gatewayEndpoint);
}
return argv;
}
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<SshSandboxSession> {
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");
}
return await createSshSandboxSessionFromConfigText({
configText: result.stdout,
});
}

View file

@ -0,0 +1,72 @@
import fsSync from "node:fs";
import { describe, expect, it } from "vitest";
import { createOpenShellPluginConfigSchema, resolveOpenShellPluginConfig } from "./config.js";
describe("openshell plugin config", () => {
it("applies defaults", () => {
expect(resolveOpenShellPluginConfig(undefined)).toEqual({
mode: "mirror",
command: "openshell",
gateway: undefined,
gatewayEndpoint: undefined,
from: "openclaw",
policy: undefined,
providers: [],
gpu: false,
autoProviders: true,
remoteWorkspaceDir: "/sandbox",
remoteAgentWorkspaceDir: "/agent",
timeoutMs: 120_000,
});
});
it("accepts remote mode", () => {
expect(resolveOpenShellPluginConfig({ mode: "remote" }).mode).toBe("remote");
});
it("rejects relative remote paths", () => {
expect(() =>
resolveOpenShellPluginConfig({
remoteWorkspaceDir: "sandbox",
}),
).toThrow("OpenShell remoteWorkspaceDir must be absolute");
});
it("rejects remote paths outside managed sandbox roots", () => {
expect(() =>
resolveOpenShellPluginConfig({
remoteWorkspaceDir: "/tmp/victim",
}),
).toThrow("OpenShell remoteWorkspaceDir must stay under /sandbox or /agent");
});
it("normalizes managed sandbox subpaths", () => {
expect(
resolveOpenShellPluginConfig({
remoteWorkspaceDir: "/sandbox/../sandbox/project",
remoteAgentWorkspaceDir: "/agent/./session",
}),
).toEqual(
expect.objectContaining({
remoteWorkspaceDir: "/sandbox/project",
remoteAgentWorkspaceDir: "/agent/session",
}),
);
});
it("rejects unknown mode", () => {
expect(() =>
resolveOpenShellPluginConfig({
mode: "bogus",
}),
).toThrow("mode must be one of mirror, remote");
});
it("keeps the runtime json schema in sync with the manifest config schema", () => {
const manifest = JSON.parse(
fsSync.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
) as { configSchema?: unknown };
expect(createOpenShellPluginConfigSchema().jsonSchema).toEqual(manifest.configSchema);
});
});

View file

@ -0,0 +1,209 @@
import path from "node:path";
import { buildPluginConfigSchema, type OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/core";
import { z } from "openclaw/plugin-sdk/zod";
export type OpenShellPluginConfig = {
mode?: "mirror" | "remote";
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 = {
mode: "mirror" | "remote";
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_MODE = "mirror";
const DEFAULT_SOURCE = "openclaw";
const DEFAULT_REMOTE_WORKSPACE_DIR = "/sandbox";
const DEFAULT_REMOTE_AGENT_WORKSPACE_DIR = "/agent";
const DEFAULT_TIMEOUT_MS = 120_000;
const OPEN_SHELL_MANAGED_REMOTE_ROOTS = [
DEFAULT_REMOTE_WORKSPACE_DIR,
DEFAULT_REMOTE_AGENT_WORKSPACE_DIR,
] as const;
function normalizeProviders(value: string[] | undefined): string[] {
const seen = new Set<string>();
const providers: string[] = [];
for (const entry of value ?? []) {
const normalized = entry.trim();
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
providers.push(normalized);
}
return providers;
}
const nonEmptyTrimmedString = (message: string) =>
z.string({ error: message }).trim().min(1, { error: message });
const OpenShellPluginConfigSchema = z.strictObject({
mode: z.enum(["mirror", "remote"], { error: "mode must be one of mirror, remote" }).optional(),
command: nonEmptyTrimmedString("command must be a non-empty string").optional(),
gateway: nonEmptyTrimmedString("gateway must be a non-empty string").optional(),
gatewayEndpoint: nonEmptyTrimmedString("gatewayEndpoint must be a non-empty string").optional(),
from: nonEmptyTrimmedString("from must be a non-empty string").optional(),
policy: nonEmptyTrimmedString("policy must be a non-empty string").optional(),
providers: z
.array(
z.string({ error: "providers must be an array of strings" }).trim().min(1, {
error: "providers must be an array of strings",
}),
{
error: "providers must be an array of strings",
},
)
.optional(),
gpu: z.boolean({ error: "gpu must be a boolean" }).optional(),
autoProviders: z.boolean({ error: "autoProviders must be a boolean" }).optional(),
remoteWorkspaceDir: nonEmptyTrimmedString(
"remoteWorkspaceDir must be a non-empty string",
).optional(),
remoteAgentWorkspaceDir: nonEmptyTrimmedString(
"remoteAgentWorkspaceDir must be a non-empty string",
).optional(),
timeoutSeconds: z
.number({ error: "timeoutSeconds must be a number >= 1" })
.min(1, { error: "timeoutSeconds must be a number >= 1" })
.optional(),
});
function formatOpenShellConfigIssue(issue: z.ZodIssue | undefined): string {
if (!issue) {
return "invalid config";
}
if (issue.code === "unrecognized_keys" && issue.keys.length > 0) {
return `unknown config key: ${issue.keys[0]}`;
}
if (issue.code === "invalid_type" && issue.path.length === 0) {
return "expected config object";
}
return issue.message;
}
function isManagedOpenShellRemotePath(value: string): boolean {
return OPEN_SHELL_MANAGED_REMOTE_ROOTS.some(
(root) => value === root || value.startsWith(`${root}/`),
);
}
export function normalizeOpenShellRemotePath(
value: string | undefined,
fallback: string,
fieldName = "remote path",
): string {
const candidate = value ?? fallback;
const normalized = path.posix.normalize(candidate.trim() || fallback);
if (!normalized.startsWith("/")) {
throw new Error(`OpenShell ${fieldName} must be absolute: ${candidate}`);
}
if (!isManagedOpenShellRemotePath(normalized)) {
throw new Error(
`OpenShell ${fieldName} must stay under ${OPEN_SHELL_MANAGED_REMOTE_ROOTS.join(" or ")}: ${candidate}`,
);
}
return normalized;
}
export function createOpenShellPluginConfigSchema(): OpenClawPluginConfigSchema {
return buildPluginConfigSchema(OpenShellPluginConfigSchema, {
safeParse(value) {
if (value === undefined) {
return { success: true, data: undefined };
}
const parsed = OpenShellPluginConfigSchema.safeParse(value);
if (parsed.success) {
return { success: true, data: parsed.data };
}
return {
success: false,
error: {
issues: parsed.error.issues.map((issue) => ({
path: issue.path.filter((segment): segment is string | number => {
const kind = typeof segment;
return kind === "string" || kind === "number";
}),
message: formatOpenShellConfigIssue(issue),
})),
},
};
},
});
}
export function resolveOpenShellPluginConfig(value: unknown): ResolvedOpenShellPluginConfig {
if (value === undefined) {
// The built-in defaults are managed OpenShell roots, so they do not need to
// flow back through normalizeOpenShellRemotePath.
return {
mode: DEFAULT_MODE,
command: DEFAULT_COMMAND,
gateway: undefined,
gatewayEndpoint: undefined,
from: DEFAULT_SOURCE,
policy: undefined,
providers: [],
gpu: false,
autoProviders: true,
remoteWorkspaceDir: DEFAULT_REMOTE_WORKSPACE_DIR,
remoteAgentWorkspaceDir: DEFAULT_REMOTE_AGENT_WORKSPACE_DIR,
timeoutMs: DEFAULT_TIMEOUT_MS,
};
}
const parsed = OpenShellPluginConfigSchema.safeParse(value);
if (!parsed.success) {
const message = formatOpenShellConfigIssue(parsed.error.issues[0]);
throw new Error(`Invalid openshell plugin config: ${message}`);
}
const cfg = parsed.data as OpenShellPluginConfig;
const mode = cfg.mode ?? DEFAULT_MODE;
return {
mode,
command: cfg.command ?? DEFAULT_COMMAND,
gateway: cfg.gateway,
gatewayEndpoint: cfg.gatewayEndpoint,
from: cfg.from ?? DEFAULT_SOURCE,
policy: cfg.policy,
providers: normalizeProviders(cfg.providers),
gpu: cfg.gpu ?? false,
autoProviders: cfg.autoProviders ?? true,
remoteWorkspaceDir: normalizeOpenShellRemotePath(
cfg.remoteWorkspaceDir,
DEFAULT_REMOTE_WORKSPACE_DIR,
"remoteWorkspaceDir",
),
remoteAgentWorkspaceDir: normalizeOpenShellRemotePath(
cfg.remoteAgentWorkspaceDir,
DEFAULT_REMOTE_AGENT_WORKSPACE_DIR,
"remoteAgentWorkspaceDir",
),
timeoutMs:
typeof cfg.timeoutSeconds === "number"
? Math.floor(cfg.timeoutSeconds * 1000)
: DEFAULT_TIMEOUT_MS,
};
}

View file

@ -0,0 +1,357 @@
import fsPromises from "node:fs/promises";
import path from "node:path";
import type {
SandboxFsBridge,
SandboxFsStat,
SandboxResolvedPath,
} from "openclaw/plugin-sdk/sandbox";
import { createWritableRenameTargetResolver } from "openclaw/plugin-sdk/sandbox";
import type { OpenShellFsBridgeContext, OpenShellSandboxBackend } from "./backend.types.js";
import { movePathWithCopyFallback } from "./mirror.js";
type ResolvedMountPath = SandboxResolvedPath & {
mountHostRoot: string;
writable: boolean;
source: "workspace" | "agent";
};
export function createOpenShellFsBridge(params: {
sandbox: OpenShellFsBridgeContext;
backend: OpenShellSandboxBackend;
}): SandboxFsBridge {
return new OpenShellFsBridge(params.sandbox, params.backend);
}
class OpenShellFsBridge implements SandboxFsBridge {
private readonly resolveRenameTargets = createWritableRenameTargetResolver(
(target) => this.resolveTarget(target),
(target, action) => this.ensureWritable(target, action),
);
constructor(
private readonly sandbox: OpenShellFsBridgeContext,
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);
const hostPath = this.requireHostPath(target);
await assertLocalPathSafety({
target,
root: target.mountHostRoot,
allowMissingLeaf: false,
allowFinalSymlinkForUnlink: false,
});
return await fsPromises.readFile(hostPath);
}
async writeFile(params: {
filePath: string;
cwd?: string;
data: Buffer | string;
encoding?: BufferEncoding;
mkdir?: boolean;
signal?: AbortSignal;
}): Promise<void> {
const target = this.resolveTarget(params);
const hostPath = this.requireHostPath(target);
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(hostPath);
if (params.mkdir !== false) {
await fsPromises.mkdir(parentDir, { recursive: true });
}
const tempPath = path.join(
parentDir,
`.openclaw-openshell-write-${path.basename(hostPath)}-${process.pid}-${Date.now()}`,
);
await fsPromises.writeFile(tempPath, buffer);
await fsPromises.rename(tempPath, hostPath);
await this.backend.syncLocalPathToRemote(hostPath, target.containerPath);
}
async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise<void> {
const target = this.resolveTarget(params);
const hostPath = this.requireHostPath(target);
this.ensureWritable(target, "create directories");
await assertLocalPathSafety({
target,
root: target.mountHostRoot,
allowMissingLeaf: true,
allowFinalSymlinkForUnlink: false,
});
await fsPromises.mkdir(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);
const hostPath = this.requireHostPath(target);
this.ensureWritable(target, "remove files");
await assertLocalPathSafety({
target,
root: target.mountHostRoot,
allowMissingLeaf: params.force !== false,
allowFinalSymlinkForUnlink: true,
});
await fsPromises.rm(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, to } = this.resolveRenameTargets(params);
const fromHostPath = this.requireHostPath(from);
const toHostPath = this.requireHostPath(to);
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(toHostPath), { recursive: true });
await movePathWithCopyFallback({ from: fromHostPath, to: toHostPath });
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 hostPath = this.requireHostPath(target);
const stats = await fsPromises.lstat(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 requireHostPath(target: ResolvedMountPath): string {
if (!target.hostPath) {
throw new Error(
`OpenShell mirror bridge requires a local host path: ${target.containerPath}`,
);
}
return target.hostPath;
}
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> {
if (!params.target.hostPath) {
throw new Error(`Missing local host path for ${params.target.containerPath}`);
}
const canonicalRoot = await fsPromises
.realpath(params.root)
.catch(() => path.resolve(params.root));
const candidate = await resolveCanonicalCandidate(params.target.hostPath);
if (!isPathInside(canonicalRoot, candidate)) {
throw new Error(
`Sandbox path escapes allowed mounts; cannot access: ${params.target.containerPath}`,
);
}
const relative = path.relative(params.root, params.target.hostPath);
const segments = relative
.split(path.sep)
.filter(Boolean)
.slice(0, Math.max(0, relative.split(path.sep).filter(Boolean).length));
let cursor = params.root;
for (let index = 0; index < segments.length; index += 1) {
cursor = path.join(cursor, segments[index]);
const stats = await fsPromises.lstat(cursor).catch(() => null);
if (!stats) {
if (index === segments.length - 1 && params.allowMissingLeaf) {
return;
}
continue;
}
const isFinal = index === segments.length - 1;
if (stats.isSymbolicLink() && (!isFinal || !params.allowFinalSymlinkForUnlink)) {
throw new Error(`Sandbox boundary checks failed: ${params.target.containerPath}`);
}
}
}
async function resolveCanonicalCandidate(targetPath: string): Promise<string> {
const missing: string[] = [];
let cursor = path.resolve(targetPath);
while (true) {
const exists = await fsPromises
.lstat(cursor)
.then(() => true)
.catch(() => false);
if (exists) {
const canonical = await fsPromises.realpath(cursor).catch(() => cursor);
return path.resolve(canonical, ...missing);
}
const parent = path.dirname(cursor);
if (parent === cursor) {
return path.resolve(cursor, ...missing);
}
missing.unshift(path.basename(cursor));
cursor = parent;
}
}

View file

@ -0,0 +1,182 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS,
replaceDirectoryContents,
stageDirectoryContents,
} from "./mirror.js";
const dirs: string[] = [];
async function makeTmpDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mirror-test-"));
dirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(dirs.map((d) => fs.rm(d, { recursive: true, force: true })));
dirs.length = 0;
});
describe("replaceDirectoryContents", () => {
it("copies source entries to target", async () => {
const source = await makeTmpDir();
const target = await makeTmpDir();
await fs.writeFile(path.join(source, "a.txt"), "hello");
await fs.writeFile(path.join(target, "old.txt"), "stale");
await replaceDirectoryContents({ sourceDir: source, targetDir: target });
expect(await fs.readFile(path.join(target, "a.txt"), "utf8")).toBe("hello");
await expect(fs.access(path.join(target, "old.txt"))).rejects.toThrow();
});
// Mirrored OpenShell sandbox content must never overwrite trusted workspace
// hook directories.
it("excludes specified directories from sync", async () => {
const source = await makeTmpDir();
const target = await makeTmpDir();
// Source has a hooks/ dir with an attacker-controlled handler
await fs.mkdir(path.join(source, "hooks", "evil"), { recursive: true });
await fs.writeFile(
path.join(source, "hooks", "evil", "handler.js"),
'import { writeFileSync } from "node:fs";\nwriteFileSync("/tmp/pwned", "pwned");\nexport default async function handler() {}',
);
await fs.writeFile(path.join(source, "code.txt"), "legit");
// Target has existing trusted hooks
await fs.mkdir(path.join(target, "hooks", "trusted"), { recursive: true });
await fs.writeFile(path.join(target, "hooks", "trusted", "handler.js"), "// trusted code");
await fs.writeFile(path.join(target, "existing.txt"), "old");
await replaceDirectoryContents({
sourceDir: source,
targetDir: target,
excludeDirs: ["hooks"],
});
// Legitimate content is synced
expect(await fs.readFile(path.join(target, "code.txt"), "utf8")).toBe("legit");
// Old non-excluded content is removed
await expect(fs.access(path.join(target, "existing.txt"))).rejects.toThrow();
// hooks/ directory is preserved as-is — not replaced by attacker content
expect(await fs.readFile(path.join(target, "hooks", "trusted", "handler.js"), "utf8")).toBe(
"// trusted code",
);
await expect(fs.access(path.join(target, "hooks", "evil"))).rejects.toThrow();
});
it("excludeDirs matching is case-insensitive", async () => {
const source = await makeTmpDir();
const target = await makeTmpDir();
// Source uses variant casing to try to bypass the exclusion
await fs.mkdir(path.join(source, "Hooks", "evil"), { recursive: true });
await fs.writeFile(path.join(source, "Hooks", "evil", "handler.js"), "// malicious");
await fs.writeFile(path.join(source, "data.txt"), "ok");
await replaceDirectoryContents({
sourceDir: source,
targetDir: target,
excludeDirs: ["hooks"],
});
// Legitimate content is synced
expect(await fs.readFile(path.join(target, "data.txt"), "utf8")).toBe("ok");
// "Hooks" (variant case) must still be excluded
await expect(fs.access(path.join(target, "Hooks"))).rejects.toThrow();
});
it("preserves default excluded directories and repository metadata", async () => {
const source = await makeTmpDir();
const target = await makeTmpDir();
await fs.mkdir(path.join(source, "hooks"), { recursive: true });
await fs.writeFile(path.join(source, "hooks", "pre-commit"), "malicious");
await fs.mkdir(path.join(source, "git-hooks"), { recursive: true });
await fs.writeFile(path.join(source, "git-hooks", "pre-commit"), "malicious");
await fs.mkdir(path.join(source, ".git", "hooks"), { recursive: true });
await fs.writeFile(path.join(source, ".git", "hooks", "post-checkout"), "malicious");
await fs.writeFile(path.join(source, "safe.txt"), "ok");
await fs.mkdir(path.join(target, "hooks"), { recursive: true });
await fs.writeFile(path.join(target, "hooks", "trusted"), "trusted");
await fs.mkdir(path.join(target, "git-hooks"), { recursive: true });
await fs.writeFile(path.join(target, "git-hooks", "trusted"), "trusted");
await fs.mkdir(path.join(target, ".git"), { recursive: true });
await fs.writeFile(path.join(target, ".git", "HEAD"), "ref: refs/heads/main\n");
await replaceDirectoryContents({
sourceDir: source,
targetDir: target,
excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS,
});
expect(await fs.readFile(path.join(target, "safe.txt"), "utf8")).toBe("ok");
expect(await fs.readFile(path.join(target, "hooks", "trusted"), "utf8")).toBe("trusted");
expect(await fs.readFile(path.join(target, "git-hooks", "trusted"), "utf8")).toBe("trusted");
expect(await fs.readFile(path.join(target, ".git", "HEAD"), "utf8")).toBe(
"ref: refs/heads/main\n",
);
await expect(fs.access(path.join(target, ".git", "hooks", "post-checkout"))).rejects.toThrow();
});
it("skips symbolic links when copying into the host workspace", async () => {
const source = await makeTmpDir();
const target = await makeTmpDir();
await fs.writeFile(path.join(source, "safe.txt"), "ok");
await fs.mkdir(path.join(source, "nested"), { recursive: true });
await fs.writeFile(path.join(source, "nested", "file.txt"), "nested");
await fs.symlink("/tmp/host-secret", path.join(source, "escaped-link"));
await fs.symlink("/tmp/host-secret-dir", path.join(source, "nested", "escaped-dir"));
await replaceDirectoryContents({ sourceDir: source, targetDir: target });
expect(await fs.readFile(path.join(target, "safe.txt"), "utf8")).toBe("ok");
expect(await fs.readFile(path.join(target, "nested", "file.txt"), "utf8")).toBe("nested");
await expect(fs.lstat(path.join(target, "escaped-link"))).rejects.toThrow();
await expect(fs.lstat(path.join(target, "nested", "escaped-dir"))).rejects.toThrow();
});
it("preserves existing trusted host symlinks", async () => {
const source = await makeTmpDir();
const target = await makeTmpDir();
await fs.writeFile(path.join(source, "safe.txt"), "ok");
await fs.writeFile(path.join(source, "linked-entry"), "remote-plain-file");
await fs.symlink("/tmp/trusted-host-target", path.join(target, "linked-entry"));
await replaceDirectoryContents({ sourceDir: source, targetDir: target });
expect(await fs.readFile(path.join(target, "safe.txt"), "utf8")).toBe("ok");
expect(await fs.readlink(path.join(target, "linked-entry"))).toBe("/tmp/trusted-host-target");
});
});
describe("stageDirectoryContents", () => {
it("stages upload content without symbolic links", async () => {
const source = await makeTmpDir();
const staged = await makeTmpDir();
await fs.writeFile(path.join(source, "safe.txt"), "ok");
await fs.mkdir(path.join(source, "nested"), { recursive: true });
await fs.writeFile(path.join(source, "nested", "file.txt"), "nested");
await fs.symlink("/tmp/host-secret", path.join(source, "escaped-link"));
await fs.symlink("/tmp/host-secret-dir", path.join(source, "nested", "escaped-dir"));
await stageDirectoryContents({ sourceDir: source, targetDir: staged });
expect(await fs.readFile(path.join(staged, "safe.txt"), "utf8")).toBe("ok");
expect(await fs.readFile(path.join(staged, "nested", "file.txt"), "utf8")).toBe("nested");
await expect(fs.lstat(path.join(staged, "escaped-link"))).rejects.toThrow();
await expect(fs.lstat(path.join(staged, "nested", "escaped-dir"))).rejects.toThrow();
});
});

View file

@ -0,0 +1,159 @@
import fs from "node:fs/promises";
import path from "node:path";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
export const DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS = ["hooks", "git-hooks", ".git"] as const;
const COPY_TREE_FS_CONCURRENCY = 16;
function createExcludeMatcher(excludeDirs?: readonly string[]) {
const excluded = new Set((excludeDirs ?? []).map((d) => normalizeLowercaseStringOrEmpty(d)));
return (name: string) => excluded.has(normalizeLowercaseStringOrEmpty(name));
}
function createConcurrencyLimiter(limit: number) {
let active = 0;
const queue: Array<() => void> = [];
const release = () => {
active -= 1;
queue.shift()?.();
};
return async <T>(task: () => Promise<T>): Promise<T> => {
if (active >= limit) {
await new Promise<void>((resolve) => {
queue.push(resolve);
});
}
active += 1;
try {
return await task();
} finally {
release();
}
};
}
const runLimitedFs = createConcurrencyLimiter(COPY_TREE_FS_CONCURRENCY);
async function lstatIfExists(targetPath: string) {
return await runLimitedFs(async () => await fs.lstat(targetPath)).catch(() => null);
}
async function copyTreeWithoutSymlinks(params: {
sourcePath: string;
targetPath: string;
preserveTargetSymlinks?: boolean;
}): Promise<void> {
const stats = await runLimitedFs(async () => await fs.lstat(params.sourcePath));
// Mirror sync only carries regular files and directories across the
// host/sandbox boundary. Symlinks and special files are dropped.
if (stats.isSymbolicLink()) {
return;
}
const targetStats = await lstatIfExists(params.targetPath);
if (params.preserveTargetSymlinks && targetStats?.isSymbolicLink()) {
return;
}
if (stats.isDirectory()) {
await runLimitedFs(async () => await fs.mkdir(params.targetPath, { recursive: true }));
const entries = await runLimitedFs(async () => await fs.readdir(params.sourcePath));
await Promise.all(
entries.map(async (entry) => {
await copyTreeWithoutSymlinks({
sourcePath: path.join(params.sourcePath, entry),
targetPath: path.join(params.targetPath, entry),
preserveTargetSymlinks: params.preserveTargetSymlinks,
});
}),
);
return;
}
if (stats.isFile()) {
await runLimitedFs(
async () => await fs.mkdir(path.dirname(params.targetPath), { recursive: true }),
);
await runLimitedFs(async () => await fs.copyFile(params.sourcePath, params.targetPath));
}
}
export async function replaceDirectoryContents(params: {
sourceDir: string;
targetDir: string;
/** Top-level directory names to exclude from sync (preserved in target, skipped from source). */
excludeDirs?: readonly string[];
}): Promise<void> {
const isExcluded = createExcludeMatcher(params.excludeDirs);
await fs.mkdir(params.targetDir, { recursive: true });
const existing = await fs.readdir(params.targetDir);
await Promise.all(
existing
.filter((entry) => !isExcluded(entry))
.map(async (entry) => {
const targetPath = path.join(params.targetDir, entry);
const stats = await lstatIfExists(targetPath);
if (stats?.isSymbolicLink()) {
return;
}
await runLimitedFs(
async () =>
await fs.rm(targetPath, {
recursive: true,
force: true,
}),
);
}),
);
const sourceEntries = await fs.readdir(params.sourceDir);
for (const entry of sourceEntries) {
if (isExcluded(entry)) {
continue;
}
await copyTreeWithoutSymlinks({
sourcePath: path.join(params.sourceDir, entry),
targetPath: path.join(params.targetDir, entry),
preserveTargetSymlinks: true,
});
}
}
export async function stageDirectoryContents(params: {
sourceDir: string;
targetDir: string;
/** Top-level directory names to exclude from the staged upload. */
excludeDirs?: readonly string[];
}): Promise<void> {
const isExcluded = createExcludeMatcher(params.excludeDirs);
await fs.mkdir(params.targetDir, { recursive: true });
const sourceEntries = await fs.readdir(params.sourceDir);
for (const entry of sourceEntries) {
if (isExcluded(entry)) {
continue;
}
await copyTreeWithoutSymlinks({
sourcePath: path.join(params.sourceDir, entry),
targetPath: path.join(params.targetDir, entry),
});
}
}
export async function movePathWithCopyFallback(params: {
from: string;
to: string;
}): Promise<void> {
try {
await fs.rename(params.from, params.to);
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException | null)?.code;
if (code !== "EXDEV") {
throw error;
}
}
await fs.cp(params.from, params.to, {
recursive: true,
force: true,
dereference: false,
});
await fs.rm(params.from, { recursive: true, force: true });
}

View file

@ -0,0 +1,272 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createSandboxTestContext } from "../../../src/agents/sandbox/test-fixtures.js";
import type { OpenShellSandboxBackend } from "./backend.js";
import {
buildExecRemoteCommand,
buildOpenShellBaseArgv,
resolveOpenShellCommand,
setBundledOpenShellCommandResolverForTest,
shellEscape,
} from "./cli.js";
import { resolveOpenShellPluginConfig } from "./config.js";
const cliMocks = vi.hoisted(() => ({
runOpenShellCli: vi.fn(),
}));
let createOpenShellSandboxBackendManager: typeof import("./backend.js").createOpenShellSandboxBackendManager;
describe("openshell cli helpers", () => {
afterEach(() => {
setBundledOpenShellCommandResolverForTest();
});
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("prefers the bundled openshell command when available", () => {
setBundledOpenShellCommandResolverForTest(() => "/tmp/node_modules/.bin/openshell");
const config = resolveOpenShellPluginConfig(undefined);
expect(resolveOpenShellCommand("openshell")).toBe("/tmp/node_modules/.bin/openshell");
expect(buildOpenShellBaseArgv(config)).toEqual(["/tmp/node_modules/.bin/openshell"]);
});
it("falls back to the PATH command when no bundled openshell is present", () => {
setBundledOpenShellCommandResolverForTest(() => null);
expect(resolveOpenShellCommand("openshell")).toBe("openshell");
});
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'`);
});
});
describe("openshell backend manager", () => {
beforeAll(async () => {
vi.doMock("./cli.js", async () => {
const actual = await vi.importActual<typeof import("./cli.js")>("./cli.js");
return {
...actual,
runOpenShellCli: cliMocks.runOpenShellCli,
};
});
({ createOpenShellSandboxBackendManager } = await import("./backend.js"));
});
afterAll(() => {
vi.doUnmock("./cli.js");
});
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",
},
config: {},
});
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"],
});
});
});
const tempDirs: string[] = [];
async function makeTempDir(prefix: string) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
function createMirrorBackendMock(): 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 bridges", () => {
it("writes locally and syncs the file to the remote workspace", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
const backend = createMirrorBackendMock();
const sandbox = createSandboxTestContext({
overrides: {
backendId: "openshell",
workspaceDir,
agentWorkspaceDir: workspaceDir,
containerWorkdir: "/sandbox",
},
});
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
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("openclaw-openshell-fs-");
const agentWorkspaceDir = await makeTempDir("openclaw-openshell-agent-");
await fs.writeFile(path.join(agentWorkspaceDir, "note.txt"), "agent", "utf8");
const backend = createMirrorBackendMock();
const sandbox = createSandboxTestContext({
overrides: {
backendId: "openshell",
workspaceDir,
agentWorkspaceDir,
workspaceAccess: "ro",
containerWorkdir: "/sandbox",
},
});
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
const bridge = createOpenShellFsBridge({ sandbox, backend });
const resolved = bridge.resolvePath({ filePath: "/agent/note.txt" });
expect(resolved.hostPath).toBe(path.join(agentWorkspaceDir, "note.txt"));
expect(await bridge.readFile({ filePath: "/agent/note.txt" })).toEqual(Buffer.from("agent"));
});
});

View file

@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}