Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
# @openclaw/openshell-sandbox
Official NVIDIA OpenShell sandbox backend for OpenClaw.
This plugin lets OpenClaw use OpenShell-managed sandboxes with mirrored local workspaces and SSH command execution.
## Install
```bash
openclaw plugins install @openclaw/openshell-sandbox
```
Restart the Gateway after installing or updating the plugin.
## Configure
Use the OpenShell docs for credentials, workspace mirroring, runtime selection, and troubleshooting:
- https://docs.openclaw.ai/gateway/openshell
## Package
- Plugin id: `openshell`
- Package: `@openclaw/openshell-sandbox`
- Minimum OpenClaw host: `2026.5.12-beta.1`

View File

@@ -0,0 +1,30 @@
// Openshell plugin entrypoint registers its OpenClaw integration.
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,
}),
resolveWorkdir: () => pluginConfig.remoteWorkspaceDir,
});
},
});

24
extensions/openshell/npm-shrinkwrap.json generated Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "@openclaw/openshell-sandbox",
"version": "2026.6.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/openshell-sandbox",
"version": "2026.6.11",
"dependencies": {
"zod": "4.4.3"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -0,0 +1,119 @@
{
"id": "openshell",
"activation": {
"onStartup": true
},
"name": "OpenShell Sandbox",
"description": "OpenClaw sandbox backend for the NVIDIA OpenShell CLI with mirrored local workspaces and SSH 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,
"maximum": 2147000
}
}
},
"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 NVIDIA 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,37 @@
{
"name": "@openclaw/openshell-sandbox",
"version": "2026.6.11",
"description": "OpenClaw sandbox backend for the NVIDIA OpenShell CLI with mirrored local workspaces and SSH command execution.",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"dependencies": {
"zod": "4.4.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"install": {
"npmSpec": "@openclaw/openshell-sandbox",
"defaultChoice": "npm",
"minHostVersion": ">=2026.5.12-beta.1"
},
"compat": {
"pluginApi": ">=2026.6.11"
},
"build": {
"openclawVersion": "2026.6.11",
"bundledDist": false
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}
}

View File

@@ -0,0 +1,680 @@
// Openshell tests cover backend plugin behavior.
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 { createSandboxTestContext } from "openclaw/plugin-sdk/test-fixtures";
import {
createSandboxBrowserConfig,
createSandboxPruneConfig,
createSandboxSshConfig,
} from "openclaw/plugin-sdk/test-fixtures";
import { describe, expect, it } from "vitest";
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 OPENCLAW_OPENSHELL_CONFIG_HOME =
process.env.OPENCLAW_E2E_OPENSHELL_CONFIG_HOME?.trim() || null;
const OPENCLAW_OPENSHELL_HOST_IP = process.env.OPENCLAW_E2E_OPENSHELL_HOST_IP?.trim() || null;
const ANSI_ESCAPE_RE = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-?]*[ -/]*[@-~]`, "gu");
const CUSTOM_IMAGE_DOCKERFILE = `FROM python:3.13-slim
RUN apt-get update && apt-get install -y --no-install-recommends \\
coreutils curl findutils iproute2 nftables \\
&& rm -rf /var/lib/apt/lists/*
RUN groupadd -g 1000660000 sandbox && \\
useradd -m -u 1000660000 -g sandbox sandbox && \\
install -d -o sandbox -g sandbox /sandbox
RUN echo "openclaw-openshell-e2e" > /opt/openshell-e2e-marker.txt
USER sandbox
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) {
const message = [
`command failed: ${params.command} ${params.args.join(" ")}`,
`exit: ${exitCode}`,
];
const trimmedStdout = stdout.trim();
if (trimmedStdout.length > 0) {
message.push(`stdout:\n${stdout}`);
}
const trimmedStderr = stderr.trim();
if (trimmedStderr.length > 0) {
message.push(`stderr:\n${stderr}`);
}
reject(new Error(message.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 activeOpenShellGateway(
command: string,
env: NodeJS.ProcessEnv = process.env,
): Promise<string | null> {
try {
const result = await runCommand({
command,
args: ["gateway", "list"],
env,
allowFailure: true,
timeoutMs: 20_000,
});
if (result.code !== 0) {
return null;
}
const output = `${result.stdout}\n${result.stderr}`.replace(ANSI_ESCAPE_RE, "");
for (const line of output.split(/\r?\n/u)) {
const match = line.match(/\*\s+(\S+)/u);
if (match) {
const info = await runCommand({
command,
args: ["gateway", "info", "--gateway", match[1]],
env,
allowFailure: true,
timeoutMs: 20_000,
});
const endpoint = `${info.stdout}\n${info.stderr}`
.replace(ANSI_ESCAPE_RE, "")
.match(/Gateway endpoint:\s+(\S+)/u)?.[1];
if (
info.code === 0 &&
endpoint &&
/^(?:https?:\/\/)?(?:127\.0\.0\.1|localhost)(?::\d+)?(?:\/|$)/u.test(endpoint)
) {
const status = await runCommand({
command,
args: ["--gateway", match[1], "sandbox", "list"],
env,
allowFailure: true,
timeoutMs: 20_000,
});
return status.code === 0 ? match[1] : null;
}
return null;
}
}
return null;
} catch {
return null;
}
}
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 resolveOpenShellHostIp(): Promise<string> {
if (OPENCLAW_OPENSHELL_HOST_IP) {
return OPENCLAW_OPENSHELL_HOST_IP;
}
const networks = await runCommand({
command: "docker",
args: ["network", "ls", "--format", "{{.Name}}"],
timeoutMs: 20_000,
});
for (const network of networks.stdout.split(/\r?\n/u).map((value) => value.trim())) {
if (!network.startsWith("openshell")) {
continue;
}
const gateway = await runCommand({
command: "docker",
args: [
"network",
"inspect",
network,
"--format",
"{{range .IPAM.Config}}{{.Gateway}}{{end}}",
],
allowFailure: true,
timeoutMs: 20_000,
});
const hostIp = gateway.stdout.trim();
if (gateway.code === 0 && hostIp) {
return hostIp;
}
}
throw new Error(
"OpenShell E2E could not resolve the OpenShell Docker network gateway; set OPENCLAW_E2E_OPENSHELL_HOST_IP",
);
}
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;
hostIp: string;
}): string {
const networkPolicies = ` host_echo:
name: host-echo
endpoints:
- host: host.openshell.internal
port: ${params.port}
protocol: rest
enforcement: enforce
access: full
allowed_ips:
- "${params.hostIp}/32"
binaries:
- path: ${params.binaryPath}`;
return `version: 1
filesystem_policy:
include_workdir: true
read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log, /opt]
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 | undefined;
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())) {
throw new Error("OpenShell E2E requires a working Docker daemon");
}
if (!(await commandAvailable(OPENCLAW_OPENSHELL_COMMAND))) {
throw new Error(`OpenShell CLI is unavailable: ${OPENCLAW_OPENSHELL_COMMAND}`);
}
if (!OPENCLAW_OPENSHELL_CONFIG_HOME) {
throw new Error(
"OpenShell E2E requires OPENCLAW_E2E_OPENSHELL_CONFIG_HOME because tests isolate HOME and XDG_CONFIG_HOME",
);
}
const openshellConfigHome = OPENCLAW_OPENSHELL_CONFIG_HOME;
const hostIp = await resolveOpenShellHostIp();
const gatewayName = await activeOpenShellGateway(OPENCLAW_OPENSHELL_COMMAND, {
...process.env,
XDG_CONFIG_HOME: openshellConfigHome,
});
if (!gatewayName) {
throw new Error("OpenShell E2E requires an active local registered gateway");
}
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 scopeKey = `session:openshell-e2e-deny:${scopeSuffix}`;
const allowSandboxName = `openclaw-policy-allow-${scopeSuffix}`;
let hostPolicyServer: HostPolicyServer | null | undefined;
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 });
const isolatedConfigHome = env.XDG_CONFIG_HOME;
if (!isolatedConfigHome) {
throw new Error("OpenShell E2E could not create an isolated XDG config home");
}
await fs.mkdir(isolatedConfigHome, { recursive: true });
await fs.cp(
path.join(openshellConfigHome, "openshell"),
path.join(isolatedConfigHome, "openshell"),
{ 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",
hostIp,
}),
"utf8",
);
await fs.writeFile(
allowPolicyPath,
buildOpenShellPolicyYaml({
port: hostPolicyServer.port,
binaryPath: "/usr/bin/curl",
hostIp,
}),
"utf8",
);
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" });
const hostReadError = await fs
.readFile(path.join(workspaceDir, "nested", "remote-only.txt"), "utf8")
.then(
() => undefined,
(error: unknown) => error,
);
expect(hostReadError).toBeInstanceOf(Error);
expect((hostReadError as NodeJS.ErrnoException).code).toBe("ENOENT");
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 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,167 @@
// Openshell tests cover backend-owned exec workdir validation behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { CreateSandboxBackendParams } from "openclaw/plugin-sdk/sandbox";
import {
createSandboxBrowserConfig,
createSandboxPruneConfig,
createSandboxSshConfig,
} from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createOpenShellSandboxBackendFactory } from "./backend.js";
import { resolveOpenShellPluginConfig } from "./config.js";
const sdkMocks = vi.hoisted(() => ({
runSshSandboxCommand: vi.fn(),
disposeSshSandboxSession: vi.fn(),
}));
const cliMocks = vi.hoisted(() => ({
runOpenShellCli: vi.fn(),
createOpenShellSshSession: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/sandbox", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/sandbox")>();
return {
...actual,
runSshSandboxCommand: sdkMocks.runSshSandboxCommand,
disposeSshSandboxSession: sdkMocks.disposeSshSandboxSession,
};
});
vi.mock("./cli.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./cli.js")>();
return {
...actual,
runOpenShellCli: cliMocks.runOpenShellCli,
createOpenShellSshSession: cliMocks.createOpenShellSshSession,
};
});
const tempDirs: string[] = [];
function createOpenShellBackendSandboxConfig(): CreateSandboxBackendParams["cfg"] {
return {
mode: "all",
backend: "openshell",
scope: "session",
workspaceAccess: "rw",
workspaceRoot: "/tmp/openclaw-sandboxes",
docker: {
image: "openclaw-sandbox:bookworm-slim",
containerPrefix: "openclaw-sbx-",
workdir: "/workspace",
readOnlyRoot: false,
tmpfs: [],
network: "none",
capDrop: [],
binds: [],
env: {},
},
ssh: createSandboxSshConfig("/tmp/openclaw-sandboxes"),
browser: createSandboxBrowserConfig(),
tools: { allow: ["*"], deny: [] },
prune: createSandboxPruneConfig(),
};
}
async function makeTempDir(prefix: string) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
describe("openshell backend exec workdir validation", () => {
beforeEach(() => {
vi.clearAllMocks();
cliMocks.createOpenShellSshSession.mockResolvedValue({
command: "ssh",
configPath: "/tmp/openclaw-openshell-test-ssh-config",
host: "openshell-test",
});
cliMocks.runOpenShellCli.mockResolvedValue({
code: 0,
stdout: "",
stderr: "",
});
sdkMocks.runSshSandboxCommand.mockImplementation(async ({ remoteCommand }) => ({
stdout: String(remoteCommand).includes("openclaw-validate-workdir")
? Buffer.from("/workspace\n")
: Buffer.alloc(0),
stderr: Buffer.alloc(0),
code: 0,
}));
});
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
it("reuses validation-time workspace preparation for the following exec", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-workspace-");
await fs.writeFile(path.join(workspaceDir, "seed.txt"), "seed", "utf8");
const backendFactory = createOpenShellSandboxBackendFactory({
pluginConfig: resolveOpenShellPluginConfig({
command: "openshell",
mode: "mirror",
}),
});
const backend = await backendFactory({
sessionKey: "agent:main:turn",
scopeKey: "agent:main",
workspaceDir,
agentWorkspaceDir: workspaceDir,
cfg: createOpenShellBackendSandboxConfig(),
});
await expect(backend.validateWorkdir?.("/workspace")).resolves.toBe("/workspace");
const execSpec = await backend.buildExecSpec({
command: "pwd",
workdir: "/workspace",
env: {},
usePty: false,
});
const uploadCalls = cliMocks.runOpenShellCli.mock.calls.filter(
([params]) => params.args[0] === "sandbox" && params.args[1] === "upload",
);
expect(uploadCalls).toHaveLength(1);
expect(execSpec.argv).toContain("openshell-test");
});
it("does not reuse validation-time workspace preparation after discard", async () => {
const workspaceDir = await makeTempDir("openclaw-openshell-workspace-");
await fs.writeFile(path.join(workspaceDir, "seed.txt"), "seed", "utf8");
const backendFactory = createOpenShellSandboxBackendFactory({
pluginConfig: resolveOpenShellPluginConfig({
command: "openshell",
mode: "mirror",
}),
});
const backend = await backendFactory({
sessionKey: "agent:main:turn",
scopeKey: "agent:main",
workspaceDir,
agentWorkspaceDir: workspaceDir,
cfg: createOpenShellBackendSandboxConfig(),
});
await expect(backend.validateWorkdir?.("/workspace")).resolves.toBe("/workspace");
backend.discardPreparedWorkdir?.("/workspace");
await backend.buildExecSpec({
command: "pwd",
workdir: "/workspace",
env: {},
usePty: false,
});
const uploadCalls = cliMocks.runOpenShellCli.mock.calls.filter(
([params]) => params.args[0] === "sandbox" && params.args[1] === "upload",
);
expect(uploadCalls).toHaveLength(2);
});
});

View File

@@ -0,0 +1,41 @@
// Openshell tests cover backend plugin behavior.
import { afterEach, describe, expect, it } from "vitest";
import { buildOpenShellSandboxName, 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");
});
});
describe("openshell sandbox names", () => {
it("generates Kubernetes-safe names from OpenClaw session scope keys", () => {
const name = buildOpenShellSandboxName("agent:somalley_alice:dashboard-8");
expect(name).toMatch(/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/);
expect(name).toContain("somalley-alice");
expect(name).not.toContain("_");
expect(name.length).toBeLessThanOrEqual(63);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
// Openshell type declarations define plugin contracts.
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";
mkdirpRemotePath(remotePath: string, signal?: AbortSignal): Promise<void>;
removeRemotePath(
remotePath: string,
params?: {
recursive?: boolean;
signal?: AbortSignal;
ignoreMissing?: boolean;
},
): Promise<void>;
renameRemotePath(
fromRemotePath: string,
toRemotePath: string,
signal?: AbortSignal,
): Promise<void>;
syncLocalPathToRemote(localPath: string, remotePath: string): Promise<void>;
};

View File

@@ -0,0 +1,91 @@
// Openshell plugin module implements cli behavior.
import {
createSshSandboxSessionFromConfigText,
runPluginCommandWithTimeout,
shellEscape,
type SshSandboxSession,
} from "openclaw/plugin-sdk/sandbox";
import type { ResolvedOpenShellPluginConfig } from "./config.js";
export {
buildExecRemoteCommand,
buildRemoteWorkdirValidationCommand,
buildValidatedExecRemoteCommand,
shellEscape,
} from "openclaw/plugin-sdk/sandbox";
export type OpenShellExecContext = {
config: ResolvedOpenShellPluginConfig;
sandboxName: string;
timeoutMs?: number;
};
export function resolveOpenShellCommand(command: string): string {
return 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 function applyGatewayEndpointToSshConfig(params: {
configText: string;
gatewayEndpoint?: string;
}): string {
const endpoint = params.gatewayEndpoint?.trim();
if (!endpoint) {
return params.configText;
}
return params.configText.replace(/^(\s*ProxyCommand\s+)(.*)$/m, (line, prefix, command) => {
if (!command.includes("ssh-proxy")) {
return line;
}
if (/(^|\s)--server(\s|=)|(^|\s)--gateway-endpoint(\s|=)/.test(command)) {
return line;
}
return `${prefix}${command} --server ${shellEscape(endpoint)}`;
});
}
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: applyGatewayEndpointToSshConfig({
configText: result.stdout,
gatewayEndpoint: params.context.config.gatewayEndpoint,
}),
});
}

View File

@@ -0,0 +1,89 @@
// Openshell tests cover config plugin behavior.
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({
mode: "mirror",
command: "openshell",
gateway: undefined,
gatewayEndpoint: undefined,
from: "openclaw",
policy: undefined,
providers: [],
gpu: false,
autoProviders: true,
remoteWorkspaceDir: "/sandbox/project",
remoteAgentWorkspaceDir: "/agent/session",
timeoutMs: 120_000,
});
});
it("rejects unknown mode", () => {
expect(() =>
resolveOpenShellPluginConfig({
mode: "bogus",
}),
).toThrow("mode must be one of mirror, remote");
});
it("rejects timeouts beyond Node's safe timer range", () => {
expect(() =>
resolveOpenShellPluginConfig({
timeoutSeconds: 2_147_001,
}),
).toThrow("timeoutSeconds must be a number <= 2147000");
});
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,201 @@
// Openshell helper module supports config behavior.
import path from "node:path";
import { buildPluginConfigSchema, type OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/core";
import {
formatPluginConfigIssue,
mapPluginConfigIssues,
} from "openclaw/plugin-sdk/extension-shared";
import { MAX_TIMER_TIMEOUT_SECONDS } from "openclaw/plugin-sdk/number-runtime";
import { z } from "zod";
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 between 1 and ${MAX_TIMER_TIMEOUT_SECONDS}`,
})
.min(1, { error: "timeoutSeconds must be a number >= 1" })
.max(MAX_TIMER_TIMEOUT_SECONDS, {
error: `timeoutSeconds must be a number <= ${MAX_TIMER_TIMEOUT_SECONDS}`,
})
.optional(),
});
function isManagedOpenShellRemotePath(value: string): boolean {
return OPEN_SHELL_MANAGED_REMOTE_ROOTS.some(
(root) => value === root || value.startsWith(`${root}/`),
);
}
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: mapPluginConfigIssues(parsed.error.issues),
},
};
},
});
}
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 = formatPluginConfigIssue(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,644 @@
// Openshell plugin module implements fs bridge behavior.
import fsPromises from "node:fs/promises";
import path from "node:path";
import { root as fsRoot } from "openclaw/plugin-sdk/file-access-runtime";
import type {
SandboxFsBridge,
SandboxFsStat,
SandboxResolvedPath,
} from "openclaw/plugin-sdk/sandbox";
import { createWritableRenameTargetResolver } from "openclaw/plugin-sdk/sandbox";
import { FsSafeError, isPathInside } from "openclaw/plugin-sdk/security-runtime";
import type { OpenShellFsBridgeContext, OpenShellSandboxBackend } from "./backend.types.js";
type ResolvedMountPath = SandboxResolvedPath & {
mountHostRoot: string;
writable: boolean;
source: "workspace" | "agent" | "protectedSkill";
};
type FsSafeRoot = Awaited<ReturnType<typeof fsRoot>>;
type FsSafeStat = Awaited<ReturnType<FsSafeRoot["stat"]>>;
const MATERIALIZED_SKILLS_CONTAINER_PARTS = [".openclaw", "sandbox-skills", "skills"] as const;
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);
let opened: Awaited<ReturnType<Awaited<ReturnType<typeof fsRoot>>["open"]>>;
try {
await assertLocalPathSafety({
target,
root: target.mountHostRoot,
allowMissingLeaf: false,
allowFinalSymlinkForUnlink: false,
});
const root = await fsRoot(target.mountHostRoot);
opened = await root.open(path.relative(target.mountHostRoot, hostPath), {
hardlinks: "reject",
});
try {
return (await opened.handle.readFile()) as Buffer;
} finally {
await opened.handle.close();
}
} catch (err) {
throw new Error(
`Sandbox boundary checks failed; cannot read files: ${target.containerPath}`,
{ cause: err },
);
}
}
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 root = await fsRoot(target.mountHostRoot);
await root.write(path.relative(target.mountHostRoot, hostPath), buffer, {
mkdir: params.mkdir,
});
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 this.backend.mkdirpRemotePath(target.containerPath, params.signal);
await mkdirLocalRootPath({ hostPath, target });
}
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 this.backend.removeRemotePath(target.containerPath, {
recursive: params.recursive ?? false,
signal: params.signal,
ignoreMissing: params.force !== false,
});
await removeLocalRootPath({
force: params.force,
hostPath,
recursive: params.recursive,
target,
});
}
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 assertRenameSourceSupported(fromHostPath);
if (from.mountHostRoot !== to.mountHostRoot) {
throw new Error("OpenShell cross-root mirror renames require pinned fs-safe support");
}
await assertSameDeviceRenameSupported({
fromHostPath,
root: from.mountHostRoot,
toHostPath,
});
await this.backend.renameRemotePath(from.containerPath, to.containerPath, params.signal);
await moveLocalRootPath({ from, fromHostPath, to, toHostPath });
}
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 skillsRoot = this.sandbox.skillsWorkspaceDir
? path.resolve(this.sandbox.skillsWorkspaceDir, "skills")
: undefined;
const skillsContainerRoot = path.posix.join(
workspaceContainerRoot,
...MATERIALIZED_SKILLS_CONTAINER_PARTS,
);
const workspaceSkillsShadowRoot = path.resolve(
workspaceRoot,
...MATERIALIZED_SKILLS_CONTAINER_PARTS,
);
const input = params.filePath.trim();
if (skillsRoot && this.sandbox.workspaceAccess === "rw") {
const protectedSkillTarget = resolveProtectedSkillTarget({
input,
skillsRoot,
skillsContainerRoot,
});
if (protectedSkillTarget) {
return protectedSkillTarget;
}
}
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 (skillsRoot && this.sandbox.workspaceAccess === "rw") {
const protectedSkillShadowTarget = resolveProtectedSkillShadowTarget({
hostPath,
workspaceSkillsShadowRoot,
skillsRoot,
skillsContainerRoot,
});
if (protectedSkillShadowTarget) {
return protectedSkillShadowTarget;
}
}
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 (skillsRoot && this.sandbox.workspaceAccess === "rw" && isPathInside(skillsRoot, hostPath)) {
const relative = path.relative(skillsRoot, hostPath).split(path.sep).join(path.posix.sep);
return {
hostPath,
relativePath: relative
? path.posix.join(...MATERIALIZED_SKILLS_CONTAINER_PARTS, relative)
: path.posix.join(...MATERIALIZED_SKILLS_CONTAINER_PARTS),
containerPath: relative
? path.posix.join(skillsContainerRoot, relative)
: skillsContainerRoot,
mountHostRoot: skillsRoot,
writable: false,
source: "protectedSkill",
};
}
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}`);
}
}
async function mkdirLocalRootPath(params: {
target: ResolvedMountPath;
hostPath: string;
}): Promise<void> {
const relativePath = relativeToRoot(params.target, params.hostPath);
if (!relativePath) {
return;
}
const root = await fsRoot(params.target.mountHostRoot);
await root.mkdir(relativePath);
}
async function removeLocalRootPath(params: {
target: ResolvedMountPath;
hostPath: string;
recursive?: boolean;
force?: boolean;
}): Promise<void> {
const root = await fsRoot(params.target.mountHostRoot);
const relativePath = relativeToRoot(params.target, params.hostPath);
try {
if (params.force === false) {
await fsPromises.lstat(params.hostPath);
}
if (params.recursive) {
const stats = await fsPromises.lstat(params.hostPath).catch((err: unknown) => {
if (isNotFoundError(err)) {
return null;
}
throw err;
});
if (stats?.isSymbolicLink()) {
await root.remove(relativePath);
return;
}
await removeRootTree(root, relativePath);
return;
}
await root.remove(relativePath);
} catch (err) {
if (params.force !== false && isNotFoundError(err)) {
return;
}
throw err;
}
}
async function removeRootTree(
root: FsSafeRoot,
relativePath: string,
knownStats?: FsSafeStat,
): Promise<void> {
const stats = knownStats ?? (await root.stat(relativePath));
if (stats.isDirectory && !stats.isSymbolicLink) {
const entries = await root.list(relativePath, { withFileTypes: true });
for (const entry of entries) {
await removeRootTree(root, path.join(relativePath, entry.name), entry);
}
if (!relativePath) {
return;
}
}
await root.remove(relativePath);
}
async function moveLocalRootPath(params: {
from: ResolvedMountPath;
fromHostPath: string;
to: ResolvedMountPath;
toHostPath: string;
}): Promise<void> {
const root = await fsRoot(params.from.mountHostRoot);
const fromRelativePath = relativeToRoot(params.from, params.fromHostPath);
const toRelativePath = relativeToRoot(params.to, params.toHostPath);
await mkdirParentPath(root, toRelativePath);
await root.move(fromRelativePath, toRelativePath, { overwrite: true });
}
async function mkdirParentPath(root: FsSafeRoot, relativePath: string): Promise<void> {
const parentPath = path.dirname(relativePath);
if (parentPath === "." || parentPath === "") {
return;
}
await root.mkdir(parentPath);
}
function relativeToRoot(target: ResolvedMountPath, hostPath: string): string {
const relativePath = path.relative(target.mountHostRoot, hostPath);
return relativePath === "." ? "" : relativePath;
}
async function assertRenameSourceSupported(fromHostPath: string): Promise<void> {
const stats = await fsPromises.lstat(fromHostPath);
if (stats.isSymbolicLink()) {
throw new Error("Sandbox symlink rename sources are not supported by the local mirror bridge");
}
if (stats.isFile() && stats.nlink > 1) {
throw new Error(
"Sandbox hardlinked rename sources are not supported by the local mirror bridge",
);
}
}
async function assertSameDeviceRenameSupported(params: {
fromHostPath: string;
root: string;
toHostPath: string;
}): Promise<void> {
const sourceStats = await fsPromises.lstat(params.fromHostPath);
const destinationParentStats = await nearestExistingDirectoryStats({
root: params.root,
targetPath: path.dirname(params.toHostPath),
});
if (sourceStats.dev !== destinationParentStats.dev) {
throw new Error("OpenShell cross-device mirror renames require pinned fs-safe support");
}
}
async function nearestExistingDirectoryStats(params: {
root: string;
targetPath: string;
}): Promise<Awaited<ReturnType<typeof fsPromises.lstat>>> {
const rootPath = path.resolve(params.root);
let cursor = path.resolve(params.targetPath);
while (isPathInside(rootPath, cursor)) {
const stats = await fsPromises.lstat(cursor).catch((err: unknown) => {
if (isNotFoundError(err)) {
return null;
}
throw err;
});
if (stats) {
if (!stats.isDirectory()) {
throw new Error(`Sandbox rename destination parent is not a directory: ${cursor}`);
}
return stats;
}
const next = path.dirname(cursor);
if (next === cursor) {
break;
}
cursor = next;
}
return await fsPromises.lstat(rootPath);
}
function isNotFoundError(err: unknown): boolean {
return (
(err instanceof FsSafeError && err.code === "not-found") ||
(typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code?: unknown }).code === "ENOENT")
);
}
function resolveProtectedSkillTarget(params: {
input: string;
skillsRoot: string;
skillsContainerRoot: string;
}): ResolvedMountPath | null {
const relativeRoot = path.posix.join(...MATERIALIZED_SKILLS_CONTAINER_PARTS);
const normalizedInput = path.posix.normalize(params.input.replace(/\\/g, "/"));
const isAbsoluteContainer =
normalizedInput === params.skillsContainerRoot ||
normalizedInput.startsWith(`${params.skillsContainerRoot}/`);
const isRelativeContainer =
normalizedInput === relativeRoot || normalizedInput.startsWith(`${relativeRoot}/`);
if (!isAbsoluteContainer && !isRelativeContainer) {
return null;
}
const relative = isAbsoluteContainer
? path.posix.relative(params.skillsContainerRoot, normalizedInput)
: path.posix.relative(relativeRoot, normalizedInput);
const safeRelative = relative === "." ? "" : relative;
const hostPath = safeRelative
? path.resolve(params.skillsRoot, ...safeRelative.split("/"))
: params.skillsRoot;
return {
hostPath,
relativePath: safeRelative ? path.posix.join(relativeRoot, safeRelative) : relativeRoot,
containerPath: safeRelative
? path.posix.join(params.skillsContainerRoot, safeRelative)
: params.skillsContainerRoot,
mountHostRoot: params.skillsRoot,
writable: false,
source: "protectedSkill",
};
}
function resolveProtectedSkillShadowTarget(params: {
hostPath: string;
workspaceSkillsShadowRoot: string;
skillsRoot: string;
skillsContainerRoot: string;
}): ResolvedMountPath | null {
if (!isPathInside(params.workspaceSkillsShadowRoot, params.hostPath)) {
return null;
}
const relative = path
.relative(params.workspaceSkillsShadowRoot, params.hostPath)
.split(path.sep)
.join(path.posix.sep);
const safeRelative = relative === "." ? "" : relative;
const hostPath = safeRelative
? path.resolve(params.skillsRoot, ...safeRelative.split("/"))
: params.skillsRoot;
const relativeRoot = path.posix.join(...MATERIALIZED_SKILLS_CONTAINER_PARTS);
return {
hostPath,
relativePath: safeRelative ? path.posix.join(relativeRoot, safeRelative) : relativeRoot,
containerPath: safeRelative
? path.posix.join(params.skillsContainerRoot, safeRelative)
: params.skillsContainerRoot,
mountHostRoot: params.skillsRoot,
writable: false,
source: "protectedSkill",
};
}
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 targetStats = await fsPromises.lstat(params.target.hostPath).catch(() => null);
const candidate =
params.allowFinalSymlinkForUnlink && targetStats?.isSymbolicLink()
? path.resolve(canonicalRoot, path.relative(params.root, params.target.hostPath))
: 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,195 @@
// Openshell tests cover mirror plugin behavior.
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;
}
async function expectPathMissing(targetPath: string): Promise<void> {
let error: unknown;
try {
await fs.access(targetPath);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
}
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 expectPathMissing(path.join(target, "old.txt"));
});
// 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 expectPathMissing(path.join(target, "existing.txt"));
// 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 expectPathMissing(path.join(target, "hooks", "evil"));
});
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 expectPathMissing(path.join(target, "Hooks"));
});
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 expectPathMissing(path.join(target, ".git", "hooks", "post-checkout"));
});
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 expectPathMissing(path.join(target, "escaped-link"));
await expectPathMissing(path.join(target, "nested", "escaped-dir"));
});
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");
const trustedTarget = path.resolve("/tmp/trusted-host-target");
await fs.symlink(trustedTarget, 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(trustedTarget);
});
});
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 expectPathMissing(path.join(staged, "escaped-link"));
await expectPathMissing(path.join(staged, "nested", "escaped-dir"));
});
});

View File

@@ -0,0 +1,142 @@
// Openshell plugin module implements mirror behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { movePathWithCopyFallback } from "openclaw/plugin-sdk/security-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-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 { movePathWithCopyFallback };

File diff suppressed because it is too large Load Diff

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