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
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:
25
extensions/openshell/README.md
Normal file
25
extensions/openshell/README.md
Normal 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`
|
||||
30
extensions/openshell/index.ts
Normal file
30
extensions/openshell/index.ts
Normal 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
24
extensions/openshell/npm-shrinkwrap.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
119
extensions/openshell/openclaw.plugin.json
Normal file
119
extensions/openshell/openclaw.plugin.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
37
extensions/openshell/package.json
Normal file
37
extensions/openshell/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
680
extensions/openshell/src/backend.e2e.test.ts
Normal file
680
extensions/openshell/src/backend.e2e.test.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
167
extensions/openshell/src/backend.exec-workdir.test.ts
Normal file
167
extensions/openshell/src/backend.exec-workdir.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
41
extensions/openshell/src/backend.test.ts
Normal file
41
extensions/openshell/src/backend.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
1005
extensions/openshell/src/backend.ts
Normal file
1005
extensions/openshell/src/backend.ts
Normal file
File diff suppressed because it is too large
Load Diff
26
extensions/openshell/src/backend.types.ts
Normal file
26
extensions/openshell/src/backend.types.ts
Normal 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>;
|
||||
};
|
||||
91
extensions/openshell/src/cli.ts
Normal file
91
extensions/openshell/src/cli.ts
Normal 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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
89
extensions/openshell/src/config.test.ts
Normal file
89
extensions/openshell/src/config.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
201
extensions/openshell/src/config.ts
Normal file
201
extensions/openshell/src/config.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
644
extensions/openshell/src/fs-bridge.ts
Normal file
644
extensions/openshell/src/fs-bridge.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
195
extensions/openshell/src/mirror.test.ts
Normal file
195
extensions/openshell/src/mirror.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
142
extensions/openshell/src/mirror.ts
Normal file
142
extensions/openshell/src/mirror.ts
Normal 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 };
|
||||
1337
extensions/openshell/src/openshell-core.test.ts
Normal file
1337
extensions/openshell/src/openshell-core.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
16
extensions/openshell/tsconfig.json
Normal file
16
extensions/openshell/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user