mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 13:53:18 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
28
openclaw/extensions/openshell/index.ts
Normal file
28
openclaw/extensions/openshell/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox";
|
||||
import {
|
||||
createOpenShellSandboxBackendFactory,
|
||||
createOpenShellSandboxBackendManager,
|
||||
} from "./src/backend.js";
|
||||
import { createOpenShellPluginConfigSchema, resolveOpenShellPluginConfig } from "./src/config.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "openshell",
|
||||
name: "OpenShell Sandbox",
|
||||
description: "OpenShell-backed sandbox runtime for agent exec and file tools.",
|
||||
configSchema: createOpenShellPluginConfigSchema(),
|
||||
register(api) {
|
||||
if (api.registrationMode !== "full") {
|
||||
return;
|
||||
}
|
||||
const pluginConfig = resolveOpenShellPluginConfig(api.pluginConfig);
|
||||
registerSandboxBackend("openshell", {
|
||||
factory: createOpenShellSandboxBackendFactory({
|
||||
pluginConfig,
|
||||
}),
|
||||
manager: createOpenShellSandboxBackendManager({
|
||||
pluginConfig,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
115
openclaw/extensions/openshell/openclaw.plugin.json
Normal file
115
openclaw/extensions/openshell/openclaw.plugin.json
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
{
|
||||
"id": "openshell",
|
||||
"name": "OpenShell Sandbox",
|
||||
"description": "Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution.",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["mirror", "remote"]
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"gateway": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"gatewayEndpoint": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"from": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"policy": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"providers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"gpu": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"autoProviders": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"remoteWorkspaceDir": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"remoteAgentWorkspaceDir": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"timeoutSeconds": {
|
||||
"type": "number",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"mode": {
|
||||
"label": "Mode",
|
||||
"help": "Sandbox mode. Use mirror for the default local-workspace flow or remote for a fully remote workspace."
|
||||
},
|
||||
"command": {
|
||||
"label": "OpenShell Command",
|
||||
"help": "Path or command name for the openshell CLI."
|
||||
},
|
||||
"gateway": {
|
||||
"label": "Gateway Name",
|
||||
"help": "Optional OpenShell gateway name passed as --gateway."
|
||||
},
|
||||
"gatewayEndpoint": {
|
||||
"label": "Gateway Endpoint",
|
||||
"help": "Optional OpenShell gateway endpoint passed as --gateway-endpoint."
|
||||
},
|
||||
"from": {
|
||||
"label": "Sandbox Source",
|
||||
"help": "OpenShell sandbox source for first-time create. Defaults to openclaw."
|
||||
},
|
||||
"policy": {
|
||||
"label": "Policy File",
|
||||
"help": "Optional path to a custom OpenShell sandbox policy YAML."
|
||||
},
|
||||
"providers": {
|
||||
"label": "Providers",
|
||||
"help": "Provider names to attach when a sandbox is created."
|
||||
},
|
||||
"gpu": {
|
||||
"label": "GPU",
|
||||
"help": "Request GPU resources when creating the sandbox.",
|
||||
"advanced": true
|
||||
},
|
||||
"autoProviders": {
|
||||
"label": "Auto-create Providers",
|
||||
"help": "When enabled, pass --auto-providers during sandbox create.",
|
||||
"advanced": true
|
||||
},
|
||||
"remoteWorkspaceDir": {
|
||||
"label": "Remote Workspace Dir",
|
||||
"help": "Primary writable workspace inside the OpenShell sandbox.",
|
||||
"advanced": true
|
||||
},
|
||||
"remoteAgentWorkspaceDir": {
|
||||
"label": "Remote Agent Dir",
|
||||
"help": "Mirror path for the real agent workspace when workspaceAccess is read-only.",
|
||||
"advanced": true
|
||||
},
|
||||
"timeoutSeconds": {
|
||||
"label": "Command Timeout Seconds",
|
||||
"help": "Timeout for openshell CLI operations such as create/upload/download.",
|
||||
"advanced": true
|
||||
}
|
||||
}
|
||||
}
|
||||
18
openclaw/extensions/openshell/package.json
Normal file
18
openclaw/extensions/openshell/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "@openclaw/openshell-sandbox",
|
||||
"version": "2026.4.20",
|
||||
"private": true,
|
||||
"description": "OpenClaw OpenShell sandbox backend",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"openshell": "0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
589
openclaw/extensions/openshell/src/backend.e2e.test.ts
Normal file
589
openclaw/extensions/openshell/src/backend.e2e.test.ts
Normal file
|
|
@ -0,0 +1,589 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createSandboxTestContext } from "../../../src/agents/sandbox/test-fixtures.js";
|
||||
import {
|
||||
createSandboxBrowserConfig,
|
||||
createSandboxPruneConfig,
|
||||
createSandboxSshConfig,
|
||||
} from "../../../test/helpers/sandbox-fixtures.js";
|
||||
import { createOpenShellSandboxBackendFactory } from "./backend.js";
|
||||
import { resolveOpenShellPluginConfig } from "./config.js";
|
||||
|
||||
const OPENCLAW_OPENSHELL_E2E = process.env.OPENCLAW_E2E_OPENSHELL === "1";
|
||||
const OPENCLAW_OPENSHELL_E2E_TIMEOUT_MS = 12 * 60_000;
|
||||
const OPENCLAW_OPENSHELL_COMMAND =
|
||||
process.env.OPENCLAW_E2E_OPENSHELL_COMMAND?.trim() || "openshell";
|
||||
|
||||
const CUSTOM_IMAGE_DOCKERFILE = `FROM python:3.13-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \\
|
||||
coreutils \\
|
||||
curl \\
|
||||
findutils \\
|
||||
iproute2 \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd -g 1000 sandbox && \\
|
||||
useradd -m -u 1000 -g sandbox sandbox
|
||||
|
||||
RUN echo "openclaw-openshell-e2e" > /opt/openshell-e2e-marker.txt
|
||||
|
||||
WORKDIR /sandbox
|
||||
CMD ["sleep", "infinity"]
|
||||
`;
|
||||
|
||||
type ExecResult = {
|
||||
code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
type HostPolicyServer = {
|
||||
port: number;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
async function runCommand(params: {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stdin?: string | Uint8Array;
|
||||
allowFailure?: boolean;
|
||||
timeoutMs?: number;
|
||||
}): Promise<ExecResult> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(params.command, params.args, {
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
let timedOut = false;
|
||||
const timeout =
|
||||
params.timeoutMs && params.timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGKILL");
|
||||
}, params.timeoutMs)
|
||||
: null;
|
||||
|
||||
child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk)));
|
||||
child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk)));
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
||||
const stderr = Buffer.concat(stderrChunks).toString("utf8");
|
||||
if (timedOut) {
|
||||
reject(new Error(`command timed out: ${params.command} ${params.args.join(" ")}`));
|
||||
return;
|
||||
}
|
||||
const exitCode = code ?? 0;
|
||||
if (exitCode !== 0 && !params.allowFailure) {
|
||||
reject(
|
||||
new Error(
|
||||
[
|
||||
`command failed: ${params.command} ${params.args.join(" ")}`,
|
||||
`exit: ${exitCode}`,
|
||||
stdout.trim() ? `stdout:\n${stdout}` : "",
|
||||
stderr.trim() ? `stderr:\n${stderr}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve({ code: exitCode, stdout, stderr });
|
||||
});
|
||||
|
||||
child.stdin.end(params.stdin);
|
||||
});
|
||||
}
|
||||
|
||||
async function commandAvailable(command: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await runCommand({
|
||||
command,
|
||||
args: ["--help"],
|
||||
allowFailure: true,
|
||||
timeoutMs: 20_000,
|
||||
});
|
||||
return result.code === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openshellGatewayAvailable(command: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await runCommand({
|
||||
command,
|
||||
args: ["gateway", "start", "--help"],
|
||||
allowFailure: true,
|
||||
timeoutMs: 20_000,
|
||||
});
|
||||
return result.code === 0 && `${result.stdout}\n${result.stderr}`.includes("--name");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function dockerReady(): Promise<boolean> {
|
||||
try {
|
||||
const result = await runCommand({
|
||||
command: "docker",
|
||||
args: ["version"],
|
||||
allowFailure: true,
|
||||
timeoutMs: 20_000,
|
||||
});
|
||||
return result.code === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function allocatePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("failed to allocate local port")));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openshellEnv(rootDir: string): NodeJS.ProcessEnv {
|
||||
const homeDir = path.join(rootDir, "home");
|
||||
const xdgDir = path.join(rootDir, "xdg");
|
||||
const cacheDir = path.join(rootDir, "xdg-cache");
|
||||
return {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
XDG_CONFIG_HOME: xdgDir,
|
||||
XDG_CACHE_HOME: cacheDir,
|
||||
};
|
||||
}
|
||||
|
||||
function trimTrailingNewline(value: string): string {
|
||||
return value.replace(/\r?\n$/, "");
|
||||
}
|
||||
|
||||
async function startHostPolicyServer(): Promise<HostPolicyServer> {
|
||||
const port = await allocatePort();
|
||||
const responseBody = JSON.stringify({ ok: true, message: "hello-from-host" });
|
||||
const serverScript = `from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
import os
|
||||
|
||||
BODY = os.environ["RESPONSE_BODY"].encode()
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(BODY)))
|
||||
self.end_headers()
|
||||
self.wfile.write(BODY)
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if length:
|
||||
self.rfile.read(length)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(BODY)))
|
||||
self.end_headers()
|
||||
self.wfile.write(BODY)
|
||||
|
||||
def log_message(self, _format, *_args):
|
||||
pass
|
||||
|
||||
HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
|
||||
`;
|
||||
const startResult = await runCommand({
|
||||
command: "docker",
|
||||
args: [
|
||||
"run",
|
||||
"--detach",
|
||||
"--rm",
|
||||
"-e",
|
||||
`RESPONSE_BODY=${responseBody}`,
|
||||
"-p",
|
||||
`${port}:8000`,
|
||||
"python:3.13-alpine",
|
||||
"python3",
|
||||
"-c",
|
||||
serverScript,
|
||||
],
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
const containerId = trimTrailingNewline(startResult.stdout.trim());
|
||||
if (!containerId) {
|
||||
throw new Error("failed to start docker-backed host policy server");
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < 30_000) {
|
||||
const readyResult = await runCommand({
|
||||
command: "docker",
|
||||
args: [
|
||||
"exec",
|
||||
containerId,
|
||||
"python3",
|
||||
"-c",
|
||||
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000', timeout=1).read()",
|
||||
],
|
||||
allowFailure: true,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
if (readyResult.code === 0) {
|
||||
return {
|
||||
port,
|
||||
async close() {
|
||||
await runCommand({
|
||||
command: "docker",
|
||||
args: ["rm", "-f", containerId],
|
||||
allowFailure: true,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
await runCommand({
|
||||
command: "docker",
|
||||
args: ["rm", "-f", containerId],
|
||||
allowFailure: true,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
throw new Error("docker-backed host policy server did not become ready");
|
||||
}
|
||||
|
||||
function buildOpenShellPolicyYaml(params: { port: number; binaryPath: string }): string {
|
||||
const networkPolicies = ` host_echo:
|
||||
name: host-echo
|
||||
endpoints:
|
||||
- host: host.openshell.internal
|
||||
port: ${params.port}
|
||||
allowed_ips:
|
||||
- "0.0.0.0/0"
|
||||
binaries:
|
||||
- path: ${params.binaryPath}`;
|
||||
return `version: 1
|
||||
|
||||
filesystem_policy:
|
||||
include_workdir: true
|
||||
read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log]
|
||||
read_write: [/sandbox, /tmp, /dev/null]
|
||||
|
||||
landlock:
|
||||
compatibility: best_effort
|
||||
|
||||
process:
|
||||
run_as_user: sandbox
|
||||
run_as_group: sandbox
|
||||
|
||||
network_policies:
|
||||
${networkPolicies}
|
||||
`;
|
||||
}
|
||||
|
||||
async function runBackendExec(params: {
|
||||
backend: Awaited<ReturnType<ReturnType<typeof createOpenShellSandboxBackendFactory>>>;
|
||||
command: string;
|
||||
allowFailure?: boolean;
|
||||
timeoutMs?: number;
|
||||
}): Promise<ExecResult> {
|
||||
const execSpec = await params.backend.buildExecSpec({
|
||||
command: params.command,
|
||||
env: {},
|
||||
usePty: false,
|
||||
});
|
||||
let result: ExecResult | null = null;
|
||||
try {
|
||||
result = await runCommand({
|
||||
command: execSpec.argv[0] ?? "ssh",
|
||||
args: execSpec.argv.slice(1),
|
||||
env: execSpec.env,
|
||||
allowFailure: params.allowFailure,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
return result;
|
||||
} finally {
|
||||
await params.backend.finalizeExec?.({
|
||||
status: result?.code === 0 ? "completed" : "failed",
|
||||
exitCode: result?.code ?? 1,
|
||||
timedOut: false,
|
||||
token: execSpec.finalizeToken,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("openshell sandbox backend e2e", () => {
|
||||
it.runIf(process.platform !== "win32" && OPENCLAW_OPENSHELL_E2E)(
|
||||
"creates a remote-canonical sandbox through OpenShell and executes over SSH",
|
||||
{ timeout: OPENCLAW_OPENSHELL_E2E_TIMEOUT_MS },
|
||||
async () => {
|
||||
if (!(await dockerReady())) {
|
||||
return;
|
||||
}
|
||||
if (!(await commandAvailable(OPENCLAW_OPENSHELL_COMMAND))) {
|
||||
return;
|
||||
}
|
||||
if (!(await openshellGatewayAvailable(OPENCLAW_OPENSHELL_COMMAND))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-openshell-e2e-"));
|
||||
const env = openshellEnv(rootDir);
|
||||
const previousHome = process.env.HOME;
|
||||
const previousXdgConfigHome = process.env.XDG_CONFIG_HOME;
|
||||
const previousXdgCacheHome = process.env.XDG_CACHE_HOME;
|
||||
const workspaceDir = path.join(rootDir, "workspace");
|
||||
const dockerfileDir = path.join(rootDir, "custom-image");
|
||||
const dockerfilePath = path.join(dockerfileDir, "Dockerfile");
|
||||
const denyPolicyPath = path.join(rootDir, "deny-policy.yaml");
|
||||
const allowPolicyPath = path.join(rootDir, "allow-policy.yaml");
|
||||
const scopeSuffix = `${process.pid}-${Date.now()}`;
|
||||
const gatewayName = `openclaw-e2e-${scopeSuffix}`;
|
||||
const scopeKey = `session:openshell-e2e-deny:${scopeSuffix}`;
|
||||
const allowSandboxName = `openclaw-policy-allow-${scopeSuffix}`;
|
||||
const gatewayPort = await allocatePort();
|
||||
let hostPolicyServer: HostPolicyServer | null = null;
|
||||
const sandboxCfg = {
|
||||
mode: "all" as const,
|
||||
backend: "openshell" as const,
|
||||
scope: "session" as const,
|
||||
workspaceAccess: "rw" as const,
|
||||
workspaceRoot: path.join(rootDir, "sandboxes"),
|
||||
docker: {
|
||||
image: "openclaw-sandbox:bookworm-slim",
|
||||
containerPrefix: "openclaw-sbx-",
|
||||
workdir: "/workspace",
|
||||
readOnlyRoot: true,
|
||||
tmpfs: ["/tmp"],
|
||||
network: "none",
|
||||
capDrop: ["ALL"],
|
||||
env: {},
|
||||
},
|
||||
ssh: createSandboxSshConfig("/tmp/openclaw-sandboxes"),
|
||||
browser: createSandboxBrowserConfig(),
|
||||
tools: { allow: [], deny: [] },
|
||||
prune: createSandboxPruneConfig(),
|
||||
};
|
||||
|
||||
const pluginConfig = resolveOpenShellPluginConfig({
|
||||
command: OPENCLAW_OPENSHELL_COMMAND,
|
||||
gateway: gatewayName,
|
||||
from: dockerfilePath,
|
||||
mode: "remote",
|
||||
autoProviders: false,
|
||||
policy: denyPolicyPath,
|
||||
});
|
||||
const backendFactory = createOpenShellSandboxBackendFactory({ pluginConfig });
|
||||
const backend = await backendFactory({
|
||||
sessionKey: scopeKey,
|
||||
scopeKey,
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
cfg: sandboxCfg,
|
||||
});
|
||||
|
||||
try {
|
||||
process.env.HOME = env.HOME;
|
||||
process.env.XDG_CONFIG_HOME = env.XDG_CONFIG_HOME;
|
||||
process.env.XDG_CACHE_HOME = env.XDG_CACHE_HOME;
|
||||
hostPolicyServer = await startHostPolicyServer();
|
||||
if (!hostPolicyServer) {
|
||||
throw new Error("failed to start host policy server");
|
||||
}
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.mkdir(dockerfileDir, { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "seed.txt"), "seed-from-local\n", "utf8");
|
||||
await fs.writeFile(dockerfilePath, CUSTOM_IMAGE_DOCKERFILE, "utf8");
|
||||
await fs.writeFile(
|
||||
denyPolicyPath,
|
||||
buildOpenShellPolicyYaml({
|
||||
port: hostPolicyServer.port,
|
||||
binaryPath: "/usr/bin/false",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
allowPolicyPath,
|
||||
buildOpenShellPolicyYaml({
|
||||
port: hostPolicyServer.port,
|
||||
binaryPath: "/**",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await runCommand({
|
||||
command: OPENCLAW_OPENSHELL_COMMAND,
|
||||
args: [
|
||||
"gateway",
|
||||
"start",
|
||||
"--name",
|
||||
gatewayName,
|
||||
"--port",
|
||||
String(gatewayPort),
|
||||
"--recreate",
|
||||
],
|
||||
env,
|
||||
timeoutMs: 8 * 60_000,
|
||||
});
|
||||
|
||||
const execResult = await runBackendExec({
|
||||
backend,
|
||||
command: "pwd && cat /opt/openshell-e2e-marker.txt && cat seed.txt",
|
||||
timeoutMs: 2 * 60_000,
|
||||
});
|
||||
|
||||
expect(execResult.code).toBe(0);
|
||||
const stdout = execResult.stdout.trim();
|
||||
expect(stdout).toContain("/sandbox");
|
||||
expect(stdout).toContain("openclaw-openshell-e2e");
|
||||
expect(stdout).toContain("seed-from-local");
|
||||
|
||||
const curlPathResult = await runBackendExec({
|
||||
backend,
|
||||
command: "command -v curl",
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
expect(trimTrailingNewline(curlPathResult.stdout.trim())).toMatch(/^\/.+\/curl$/);
|
||||
|
||||
const sandbox = createSandboxTestContext({
|
||||
overrides: {
|
||||
backendId: "openshell",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
runtimeId: backend.runtimeId,
|
||||
runtimeLabel: backend.runtimeLabel,
|
||||
containerName: backend.runtimeId,
|
||||
containerWorkdir: backend.workdir,
|
||||
backend,
|
||||
},
|
||||
});
|
||||
const bridge = backend.createFsBridge?.({ sandbox });
|
||||
if (!bridge) {
|
||||
throw new Error("openshell backend did not create a filesystem bridge");
|
||||
}
|
||||
|
||||
await bridge.writeFile({ filePath: "nested/remote-only.txt", data: "hello-remote\n" });
|
||||
await expect(
|
||||
fs.readFile(path.join(workspaceDir, "nested", "remote-only.txt"), "utf8"),
|
||||
).rejects.toThrow();
|
||||
await expect(bridge.readFile({ filePath: "nested/remote-only.txt" })).resolves.toEqual(
|
||||
Buffer.from("hello-remote\n"),
|
||||
);
|
||||
|
||||
const verifyResult = await runCommand({
|
||||
command: OPENCLAW_OPENSHELL_COMMAND,
|
||||
args: ["sandbox", "ssh-config", backend.runtimeId],
|
||||
env,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
expect(verifyResult.code).toBe(0);
|
||||
expect(trimTrailingNewline(verifyResult.stdout)).toContain("Host ");
|
||||
|
||||
const blockedGetResult = await runBackendExec({
|
||||
backend,
|
||||
command: `curl --fail --silent --show-error --max-time 15 "http://host.openshell.internal:${hostPolicyServer.port}/policy-test"`,
|
||||
allowFailure: true,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
expect(blockedGetResult.code).not.toBe(0);
|
||||
expect(`${blockedGetResult.stdout}\n${blockedGetResult.stderr}`).toMatch(/403|deny/i);
|
||||
|
||||
const allowedGetResult = await runCommand({
|
||||
command: OPENCLAW_OPENSHELL_COMMAND,
|
||||
args: [
|
||||
"sandbox",
|
||||
"create",
|
||||
"--name",
|
||||
allowSandboxName,
|
||||
"--from",
|
||||
dockerfilePath,
|
||||
"--policy",
|
||||
allowPolicyPath,
|
||||
"--no-auto-providers",
|
||||
"--no-keep",
|
||||
"--",
|
||||
"curl",
|
||||
"--fail",
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--max-time",
|
||||
"15",
|
||||
`http://host.openshell.internal:${hostPolicyServer.port}/policy-test`,
|
||||
],
|
||||
env,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
expect(allowedGetResult.code).toBe(0);
|
||||
expect(allowedGetResult.stdout).toContain('"message":"hello-from-host"');
|
||||
} finally {
|
||||
await runCommand({
|
||||
command: OPENCLAW_OPENSHELL_COMMAND,
|
||||
args: ["sandbox", "delete", backend.runtimeId],
|
||||
env,
|
||||
allowFailure: true,
|
||||
timeoutMs: 2 * 60_000,
|
||||
});
|
||||
await runCommand({
|
||||
command: OPENCLAW_OPENSHELL_COMMAND,
|
||||
args: ["sandbox", "delete", allowSandboxName],
|
||||
env,
|
||||
allowFailure: true,
|
||||
timeoutMs: 2 * 60_000,
|
||||
});
|
||||
await runCommand({
|
||||
command: OPENCLAW_OPENSHELL_COMMAND,
|
||||
args: ["gateway", "destroy", "--name", gatewayName],
|
||||
env,
|
||||
allowFailure: true,
|
||||
timeoutMs: 3 * 60_000,
|
||||
});
|
||||
await hostPolicyServer?.close().catch(() => {});
|
||||
await fs.rm(rootDir, { recursive: true, force: true });
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
if (previousXdgConfigHome === undefined) {
|
||||
delete process.env.XDG_CONFIG_HOME;
|
||||
} else {
|
||||
process.env.XDG_CONFIG_HOME = previousXdgConfigHome;
|
||||
}
|
||||
if (previousXdgCacheHome === undefined) {
|
||||
delete process.env.XDG_CACHE_HOME;
|
||||
} else {
|
||||
process.env.XDG_CACHE_HOME = previousXdgCacheHome;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
29
openclaw/extensions/openshell/src/backend.test.ts
Normal file
29
openclaw/extensions/openshell/src/backend.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildOpenShellSshExecEnv } from "./backend.js";
|
||||
|
||||
describe("openshell backend env", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in originalEnv)) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
Object.assign(process.env, originalEnv);
|
||||
});
|
||||
|
||||
it("filters blocked secrets from ssh exec env", () => {
|
||||
process.env.OPENAI_API_KEY = "sk-test-secret";
|
||||
process.env.ANTHROPIC_API_KEY = "sk-ant-test-secret";
|
||||
process.env.LANG = "en_US.UTF-8";
|
||||
process.env.NODE_ENV = "test";
|
||||
|
||||
const env = buildOpenShellSshExecEnv();
|
||||
|
||||
expect(env.OPENAI_API_KEY).toBeUndefined();
|
||||
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
|
||||
expect(env.LANG).toBe("en_US.UTF-8");
|
||||
expect(env.NODE_ENV).toBe("test");
|
||||
});
|
||||
});
|
||||
515
openclaw/extensions/openshell/src/backend.ts
Normal file
515
openclaw/extensions/openshell/src/backend.ts
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
CreateSandboxBackendParams,
|
||||
OpenClawConfig,
|
||||
SandboxBackendCommandParams,
|
||||
SandboxBackendCommandResult,
|
||||
SandboxBackendFactory,
|
||||
SandboxBackendManager,
|
||||
SshSandboxSession,
|
||||
} from "openclaw/plugin-sdk/sandbox";
|
||||
import {
|
||||
createRemoteShellSandboxFsBridge,
|
||||
disposeSshSandboxSession,
|
||||
resolvePreferredOpenClawTmpDir,
|
||||
runSshSandboxCommand,
|
||||
sanitizeEnvVars,
|
||||
} from "openclaw/plugin-sdk/sandbox";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { OpenShellSandboxBackend } from "./backend.types.js";
|
||||
import {
|
||||
buildExecRemoteCommand,
|
||||
buildRemoteCommand,
|
||||
createOpenShellSshSession,
|
||||
runOpenShellCli,
|
||||
type OpenShellExecContext,
|
||||
} from "./cli.js";
|
||||
import { resolveOpenShellPluginConfig, type ResolvedOpenShellPluginConfig } from "./config.js";
|
||||
import { createOpenShellFsBridge } from "./fs-bridge.js";
|
||||
import {
|
||||
DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS,
|
||||
replaceDirectoryContents,
|
||||
stageDirectoryContents,
|
||||
} from "./mirror.js";
|
||||
|
||||
type CreateOpenShellSandboxBackendFactoryParams = {
|
||||
pluginConfig: ResolvedOpenShellPluginConfig;
|
||||
};
|
||||
|
||||
type PendingExec = {
|
||||
sshSession: SshSandboxSession;
|
||||
};
|
||||
|
||||
export function buildOpenShellSshExecEnv(): NodeJS.ProcessEnv {
|
||||
return sanitizeEnvVars(process.env).allowed;
|
||||
}
|
||||
|
||||
export type { OpenShellFsBridgeContext, OpenShellSandboxBackend } from "./backend.types.js";
|
||||
|
||||
export function createOpenShellSandboxBackendFactory(
|
||||
params: CreateOpenShellSandboxBackendFactoryParams,
|
||||
): SandboxBackendFactory {
|
||||
return async (createParams) =>
|
||||
await createOpenShellSandboxBackend({
|
||||
...params,
|
||||
createParams,
|
||||
});
|
||||
}
|
||||
|
||||
export function createOpenShellSandboxBackendManager(params: {
|
||||
pluginConfig: ResolvedOpenShellPluginConfig;
|
||||
}): SandboxBackendManager {
|
||||
return {
|
||||
async describeRuntime({ entry, config }) {
|
||||
const execContext: OpenShellExecContext = {
|
||||
config: resolveOpenShellPluginConfigFromConfig(config, params.pluginConfig),
|
||||
sandboxName: entry.containerName,
|
||||
};
|
||||
const result = await runOpenShellCli({
|
||||
context: execContext,
|
||||
args: ["sandbox", "get", entry.containerName],
|
||||
});
|
||||
const configuredSource = execContext.config.from;
|
||||
return {
|
||||
running: result.code === 0,
|
||||
actualConfigLabel: entry.image,
|
||||
configLabelMatch: entry.image === configuredSource,
|
||||
};
|
||||
},
|
||||
async removeRuntime({ entry }) {
|
||||
const execContext: OpenShellExecContext = {
|
||||
config: params.pluginConfig,
|
||||
sandboxName: entry.containerName,
|
||||
};
|
||||
await runOpenShellCli({
|
||||
context: execContext,
|
||||
args: ["sandbox", "delete", entry.containerName],
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createOpenShellSandboxBackend(params: {
|
||||
pluginConfig: ResolvedOpenShellPluginConfig;
|
||||
createParams: CreateSandboxBackendParams;
|
||||
}): Promise<OpenShellSandboxBackend> {
|
||||
if ((params.createParams.cfg.docker.binds?.length ?? 0) > 0) {
|
||||
throw new Error("OpenShell sandbox backend does not support sandbox.docker.binds.");
|
||||
}
|
||||
|
||||
const sandboxName = buildOpenShellSandboxName(params.createParams.scopeKey);
|
||||
const execContext: OpenShellExecContext = {
|
||||
config: params.pluginConfig,
|
||||
sandboxName,
|
||||
};
|
||||
const impl = new OpenShellSandboxBackendImpl({
|
||||
createParams: params.createParams,
|
||||
execContext,
|
||||
remoteWorkspaceDir: params.pluginConfig.remoteWorkspaceDir,
|
||||
remoteAgentWorkspaceDir: params.pluginConfig.remoteAgentWorkspaceDir,
|
||||
});
|
||||
|
||||
return {
|
||||
id: "openshell",
|
||||
runtimeId: sandboxName,
|
||||
runtimeLabel: sandboxName,
|
||||
workdir: params.pluginConfig.remoteWorkspaceDir,
|
||||
env: params.createParams.cfg.docker.env,
|
||||
mode: params.pluginConfig.mode,
|
||||
configLabel: params.pluginConfig.from,
|
||||
configLabelKind: "Source",
|
||||
buildExecSpec: async ({ command, workdir, env, usePty }) => {
|
||||
const pending = await impl.prepareExec({ command, workdir, env, usePty });
|
||||
return {
|
||||
argv: pending.argv,
|
||||
env: buildOpenShellSshExecEnv(),
|
||||
stdinMode: "pipe-open",
|
||||
finalizeToken: pending.token,
|
||||
};
|
||||
},
|
||||
finalizeExec: async ({ token }) => {
|
||||
await impl.finalizeExec(token as PendingExec | undefined);
|
||||
},
|
||||
runShellCommand: async (command) => await impl.runRemoteShellScript(command),
|
||||
createFsBridge: ({ sandbox }) =>
|
||||
params.pluginConfig.mode === "remote"
|
||||
? createRemoteShellSandboxFsBridge({
|
||||
sandbox,
|
||||
runtime: impl.asHandle(),
|
||||
})
|
||||
: createOpenShellFsBridge({
|
||||
sandbox,
|
||||
backend: impl.asHandle(),
|
||||
}),
|
||||
remoteWorkspaceDir: params.pluginConfig.remoteWorkspaceDir,
|
||||
remoteAgentWorkspaceDir: params.pluginConfig.remoteAgentWorkspaceDir,
|
||||
runRemoteShellScript: async (command) => await impl.runRemoteShellScript(command),
|
||||
syncLocalPathToRemote: async (localPath, remotePath) =>
|
||||
await impl.syncLocalPathToRemote(localPath, remotePath),
|
||||
};
|
||||
}
|
||||
|
||||
class OpenShellSandboxBackendImpl {
|
||||
private ensurePromise: Promise<void> | null = null;
|
||||
private remoteSeedPending = false;
|
||||
|
||||
constructor(
|
||||
private readonly params: {
|
||||
createParams: CreateSandboxBackendParams;
|
||||
execContext: OpenShellExecContext;
|
||||
remoteWorkspaceDir: string;
|
||||
remoteAgentWorkspaceDir: string;
|
||||
},
|
||||
) {}
|
||||
|
||||
asHandle(): OpenShellSandboxBackend {
|
||||
return {
|
||||
id: "openshell",
|
||||
runtimeId: this.params.execContext.sandboxName,
|
||||
runtimeLabel: this.params.execContext.sandboxName,
|
||||
workdir: this.params.remoteWorkspaceDir,
|
||||
env: this.params.createParams.cfg.docker.env,
|
||||
mode: this.params.execContext.config.mode,
|
||||
configLabel: this.params.execContext.config.from,
|
||||
configLabelKind: "Source",
|
||||
remoteWorkspaceDir: this.params.remoteWorkspaceDir,
|
||||
remoteAgentWorkspaceDir: this.params.remoteAgentWorkspaceDir,
|
||||
buildExecSpec: async ({ command, workdir, env, usePty }) => {
|
||||
const pending = await this.prepareExec({ command, workdir, env, usePty });
|
||||
return {
|
||||
argv: pending.argv,
|
||||
env: buildOpenShellSshExecEnv(),
|
||||
stdinMode: "pipe-open",
|
||||
finalizeToken: pending.token,
|
||||
};
|
||||
},
|
||||
finalizeExec: async ({ token }) => {
|
||||
await this.finalizeExec(token as PendingExec | undefined);
|
||||
},
|
||||
runShellCommand: async (command) => await this.runRemoteShellScript(command),
|
||||
createFsBridge: ({ sandbox }) =>
|
||||
this.params.execContext.config.mode === "remote"
|
||||
? createRemoteShellSandboxFsBridge({
|
||||
sandbox,
|
||||
runtime: this.asHandle(),
|
||||
})
|
||||
: createOpenShellFsBridge({
|
||||
sandbox,
|
||||
backend: this.asHandle(),
|
||||
}),
|
||||
runRemoteShellScript: async (command) => await this.runRemoteShellScript(command),
|
||||
syncLocalPathToRemote: async (localPath, remotePath) =>
|
||||
await this.syncLocalPathToRemote(localPath, remotePath),
|
||||
};
|
||||
}
|
||||
|
||||
async prepareExec(params: {
|
||||
command: string;
|
||||
workdir?: string;
|
||||
env: Record<string, string>;
|
||||
usePty: boolean;
|
||||
}): Promise<{ argv: string[]; token: PendingExec }> {
|
||||
await this.ensureSandboxExists();
|
||||
if (this.params.execContext.config.mode === "mirror") {
|
||||
await this.syncWorkspaceToRemote();
|
||||
} else {
|
||||
await this.maybeSeedRemoteWorkspace();
|
||||
}
|
||||
const sshSession = await createOpenShellSshSession({
|
||||
context: this.params.execContext,
|
||||
});
|
||||
const remoteCommand = buildExecRemoteCommand({
|
||||
command: params.command,
|
||||
workdir: params.workdir ?? this.params.remoteWorkspaceDir,
|
||||
env: params.env,
|
||||
});
|
||||
return {
|
||||
argv: [
|
||||
"ssh",
|
||||
"-F",
|
||||
sshSession.configPath,
|
||||
...(params.usePty
|
||||
? ["-tt", "-o", "RequestTTY=force", "-o", "SetEnv=TERM=xterm-256color"]
|
||||
: ["-T", "-o", "RequestTTY=no"]),
|
||||
sshSession.host,
|
||||
remoteCommand,
|
||||
],
|
||||
token: { sshSession },
|
||||
};
|
||||
}
|
||||
|
||||
async finalizeExec(token?: PendingExec): Promise<void> {
|
||||
try {
|
||||
if (this.params.execContext.config.mode === "mirror") {
|
||||
await this.syncWorkspaceFromRemote();
|
||||
}
|
||||
} finally {
|
||||
if (token?.sshSession) {
|
||||
await disposeSshSandboxSession(token.sshSession);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async runRemoteShellScript(
|
||||
params: SandboxBackendCommandParams,
|
||||
): Promise<SandboxBackendCommandResult> {
|
||||
await this.ensureSandboxExists();
|
||||
await this.maybeSeedRemoteWorkspace();
|
||||
return await this.runRemoteShellScriptInternal(params);
|
||||
}
|
||||
|
||||
private async runRemoteShellScriptInternal(
|
||||
params: SandboxBackendCommandParams,
|
||||
): Promise<SandboxBackendCommandResult> {
|
||||
const session = await createOpenShellSshSession({
|
||||
context: this.params.execContext,
|
||||
});
|
||||
try {
|
||||
return await runSshSandboxCommand({
|
||||
session,
|
||||
remoteCommand: buildRemoteCommand([
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
params.script,
|
||||
"openclaw-openshell-fs",
|
||||
...(params.args ?? []),
|
||||
]),
|
||||
stdin: params.stdin,
|
||||
allowFailure: params.allowFailure,
|
||||
signal: params.signal,
|
||||
});
|
||||
} finally {
|
||||
await disposeSshSandboxSession(session);
|
||||
}
|
||||
}
|
||||
|
||||
async syncLocalPathToRemote(localPath: string, remotePath: string): Promise<void> {
|
||||
await this.ensureSandboxExists();
|
||||
await this.maybeSeedRemoteWorkspace();
|
||||
const stats = await fs.lstat(localPath).catch(() => null);
|
||||
if (!stats) {
|
||||
await this.runRemoteShellScript({
|
||||
script: 'rm -rf -- "$1"',
|
||||
args: [remotePath],
|
||||
allowFailure: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (stats.isSymbolicLink()) {
|
||||
await this.runRemoteShellScript({
|
||||
script: 'rm -rf -- "$1"',
|
||||
args: [remotePath],
|
||||
allowFailure: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (stats.isDirectory()) {
|
||||
await this.runRemoteShellScript({
|
||||
script: 'mkdir -p -- "$1"',
|
||||
args: [remotePath],
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.runRemoteShellScript({
|
||||
script: 'mkdir -p -- "$(dirname -- "$1")"',
|
||||
args: [remotePath],
|
||||
});
|
||||
const result = await runOpenShellCli({
|
||||
context: this.params.execContext,
|
||||
args: [
|
||||
"sandbox",
|
||||
"upload",
|
||||
"--no-git-ignore",
|
||||
this.params.execContext.sandboxName,
|
||||
localPath,
|
||||
path.posix.dirname(remotePath),
|
||||
],
|
||||
cwd: this.params.createParams.workspaceDir,
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr.trim() || "openshell sandbox upload failed");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureSandboxExists(): Promise<void> {
|
||||
if (this.ensurePromise) {
|
||||
return await this.ensurePromise;
|
||||
}
|
||||
this.ensurePromise = this.ensureSandboxExistsInner();
|
||||
try {
|
||||
await this.ensurePromise;
|
||||
} catch (error) {
|
||||
this.ensurePromise = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureSandboxExistsInner(): Promise<void> {
|
||||
const getResult = await runOpenShellCli({
|
||||
context: this.params.execContext,
|
||||
args: ["sandbox", "get", this.params.execContext.sandboxName],
|
||||
cwd: this.params.createParams.workspaceDir,
|
||||
});
|
||||
if (getResult.code === 0) {
|
||||
return;
|
||||
}
|
||||
const createArgs = [
|
||||
"sandbox",
|
||||
"create",
|
||||
"--name",
|
||||
this.params.execContext.sandboxName,
|
||||
"--from",
|
||||
this.params.execContext.config.from,
|
||||
...(this.params.execContext.config.policy
|
||||
? ["--policy", this.params.execContext.config.policy]
|
||||
: []),
|
||||
...(this.params.execContext.config.gpu ? ["--gpu"] : []),
|
||||
...(this.params.execContext.config.autoProviders
|
||||
? ["--auto-providers"]
|
||||
: ["--no-auto-providers"]),
|
||||
...this.params.execContext.config.providers.flatMap((provider) => ["--provider", provider]),
|
||||
"--",
|
||||
"true",
|
||||
];
|
||||
const createResult = await runOpenShellCli({
|
||||
context: this.params.execContext,
|
||||
args: createArgs,
|
||||
cwd: this.params.createParams.workspaceDir,
|
||||
timeoutMs: Math.max(this.params.execContext.config.timeoutMs, 300_000),
|
||||
});
|
||||
if (createResult.code !== 0) {
|
||||
throw new Error(createResult.stderr.trim() || "openshell sandbox create failed");
|
||||
}
|
||||
this.remoteSeedPending = true;
|
||||
}
|
||||
|
||||
private async syncWorkspaceToRemote(): Promise<void> {
|
||||
await this.runRemoteShellScriptInternal({
|
||||
script: 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +',
|
||||
args: [this.params.remoteWorkspaceDir],
|
||||
});
|
||||
await this.uploadPathToRemote(
|
||||
this.params.createParams.workspaceDir,
|
||||
this.params.remoteWorkspaceDir,
|
||||
);
|
||||
|
||||
if (
|
||||
this.params.createParams.cfg.workspaceAccess !== "none" &&
|
||||
path.resolve(this.params.createParams.agentWorkspaceDir) !==
|
||||
path.resolve(this.params.createParams.workspaceDir)
|
||||
) {
|
||||
await this.runRemoteShellScriptInternal({
|
||||
script: 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +',
|
||||
args: [this.params.remoteAgentWorkspaceDir],
|
||||
});
|
||||
await this.uploadPathToRemote(
|
||||
this.params.createParams.agentWorkspaceDir,
|
||||
this.params.remoteAgentWorkspaceDir,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async syncWorkspaceFromRemote(): Promise<void> {
|
||||
const tmpDir = await fs.mkdtemp(
|
||||
path.join(resolveOpenShellTmpRoot(), "openclaw-openshell-sync-"),
|
||||
);
|
||||
try {
|
||||
const result = await runOpenShellCli({
|
||||
context: this.params.execContext,
|
||||
args: [
|
||||
"sandbox",
|
||||
"download",
|
||||
this.params.execContext.sandboxName,
|
||||
this.params.remoteWorkspaceDir,
|
||||
tmpDir,
|
||||
],
|
||||
cwd: this.params.createParams.workspaceDir,
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr.trim() || "openshell sandbox download failed");
|
||||
}
|
||||
await replaceDirectoryContents({
|
||||
sourceDir: tmpDir,
|
||||
targetDir: this.params.createParams.workspaceDir,
|
||||
// Never sync trusted host hook directories or repository metadata from
|
||||
// the remote sandbox.
|
||||
excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS,
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadPathToRemote(localPath: string, remotePath: string): Promise<void> {
|
||||
const tmpDir = await fs.mkdtemp(
|
||||
path.join(resolveOpenShellTmpRoot(), "openclaw-openshell-upload-"),
|
||||
);
|
||||
try {
|
||||
// Stage a symlink-free snapshot so upload never dereferences host paths
|
||||
// outside the mirrored workspace tree.
|
||||
await stageDirectoryContents({
|
||||
sourceDir: localPath,
|
||||
targetDir: tmpDir,
|
||||
});
|
||||
const result = await runOpenShellCli({
|
||||
context: this.params.execContext,
|
||||
args: [
|
||||
"sandbox",
|
||||
"upload",
|
||||
"--no-git-ignore",
|
||||
this.params.execContext.sandboxName,
|
||||
tmpDir,
|
||||
remotePath,
|
||||
],
|
||||
cwd: this.params.createParams.workspaceDir,
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr.trim() || "openshell sandbox upload failed");
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
private async maybeSeedRemoteWorkspace(): Promise<void> {
|
||||
if (!this.remoteSeedPending) {
|
||||
return;
|
||||
}
|
||||
this.remoteSeedPending = false;
|
||||
try {
|
||||
await this.syncWorkspaceToRemote();
|
||||
} catch (error) {
|
||||
this.remoteSeedPending = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOpenShellPluginConfigFromConfig(
|
||||
config: OpenClawConfig,
|
||||
fallback: ResolvedOpenShellPluginConfig,
|
||||
): ResolvedOpenShellPluginConfig {
|
||||
const pluginConfig = config.plugins?.entries?.openshell?.config;
|
||||
if (!pluginConfig) {
|
||||
return fallback;
|
||||
}
|
||||
return resolveOpenShellPluginConfig(pluginConfig);
|
||||
}
|
||||
|
||||
function buildOpenShellSandboxName(scopeKey: string): string {
|
||||
const trimmed = scopeKey.trim() || "session";
|
||||
const safe = normalizeLowercaseStringOrEmpty(trimmed)
|
||||
.replace(/[^a-z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 32);
|
||||
const hash = Array.from(trimmed).reduce(
|
||||
(acc, char) => ((acc * 33) ^ char.charCodeAt(0)) >>> 0,
|
||||
5381,
|
||||
);
|
||||
return `openclaw-${safe || "session"}-${hash.toString(16).slice(0, 8)}`;
|
||||
}
|
||||
|
||||
function resolveOpenShellTmpRoot(): string {
|
||||
return path.resolve(resolvePreferredOpenClawTmpDir());
|
||||
}
|
||||
11
openclaw/extensions/openshell/src/backend.types.ts
Normal file
11
openclaw/extensions/openshell/src/backend.types.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { RemoteShellSandboxHandle, SandboxBackendHandle } from "openclaw/plugin-sdk/sandbox";
|
||||
|
||||
export type OpenShellFsBridgeContext = Parameters<
|
||||
NonNullable<SandboxBackendHandle["createFsBridge"]>
|
||||
>[0]["sandbox"];
|
||||
|
||||
export type OpenShellSandboxBackend = SandboxBackendHandle &
|
||||
RemoteShellSandboxHandle & {
|
||||
mode: "mirror" | "remote";
|
||||
syncLocalPathToRemote(localPath: string, remotePath: string): Promise<void>;
|
||||
};
|
||||
103
openclaw/extensions/openshell/src/cli.ts
Normal file
103
openclaw/extensions/openshell/src/cli.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildExecRemoteCommand,
|
||||
createSshSandboxSessionFromConfigText,
|
||||
runPluginCommandWithTimeout,
|
||||
shellEscape,
|
||||
type SshSandboxSession,
|
||||
} from "openclaw/plugin-sdk/sandbox";
|
||||
import type { ResolvedOpenShellPluginConfig } from "./config.js";
|
||||
|
||||
export { buildExecRemoteCommand, shellEscape } from "openclaw/plugin-sdk/sandbox";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
let cachedBundledOpenShellCommand: string | null | undefined;
|
||||
let bundledCommandResolverForTest: (() => string | null) | undefined;
|
||||
|
||||
export type OpenShellExecContext = {
|
||||
config: ResolvedOpenShellPluginConfig;
|
||||
sandboxName: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export function setBundledOpenShellCommandResolverForTest(resolver?: () => string | null): void {
|
||||
bundledCommandResolverForTest = resolver;
|
||||
cachedBundledOpenShellCommand = undefined;
|
||||
}
|
||||
|
||||
function resolveBundledOpenShellCommand(): string | null {
|
||||
if (bundledCommandResolverForTest) {
|
||||
return bundledCommandResolverForTest();
|
||||
}
|
||||
if (cachedBundledOpenShellCommand !== undefined) {
|
||||
return cachedBundledOpenShellCommand;
|
||||
}
|
||||
try {
|
||||
const packageJsonPath = require.resolve("openshell/package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
|
||||
bin?: string | Record<string, string>;
|
||||
};
|
||||
const relativeBin =
|
||||
typeof packageJson.bin === "string" ? packageJson.bin : packageJson.bin?.openshell;
|
||||
cachedBundledOpenShellCommand = relativeBin
|
||||
? path.resolve(path.dirname(packageJsonPath), relativeBin)
|
||||
: null;
|
||||
} catch {
|
||||
cachedBundledOpenShellCommand = null;
|
||||
}
|
||||
return cachedBundledOpenShellCommand;
|
||||
}
|
||||
|
||||
export function resolveOpenShellCommand(command: string): string {
|
||||
if (command !== "openshell") {
|
||||
return command;
|
||||
}
|
||||
return resolveBundledOpenShellCommand() ?? command;
|
||||
}
|
||||
|
||||
export function buildOpenShellBaseArgv(config: ResolvedOpenShellPluginConfig): string[] {
|
||||
const argv = [resolveOpenShellCommand(config.command)];
|
||||
if (config.gateway) {
|
||||
argv.push("--gateway", config.gateway);
|
||||
}
|
||||
if (config.gatewayEndpoint) {
|
||||
argv.push("--gateway-endpoint", config.gatewayEndpoint);
|
||||
}
|
||||
return argv;
|
||||
}
|
||||
|
||||
export function buildRemoteCommand(argv: string[]): string {
|
||||
return argv.map((entry) => shellEscape(entry)).join(" ");
|
||||
}
|
||||
|
||||
export async function runOpenShellCli(params: {
|
||||
context: OpenShellExecContext;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
return await runPluginCommandWithTimeout({
|
||||
argv: [...buildOpenShellBaseArgv(params.context.config), ...params.args],
|
||||
cwd: params.cwd,
|
||||
timeoutMs: params.timeoutMs ?? params.context.timeoutMs ?? params.context.config.timeoutMs,
|
||||
env: process.env,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createOpenShellSshSession(params: {
|
||||
context: OpenShellExecContext;
|
||||
}): Promise<SshSandboxSession> {
|
||||
const result = await runOpenShellCli({
|
||||
context: params.context,
|
||||
args: ["sandbox", "ssh-config", params.context.sandboxName],
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr.trim() || "openshell sandbox ssh-config failed");
|
||||
}
|
||||
return await createSshSandboxSessionFromConfigText({
|
||||
configText: result.stdout,
|
||||
});
|
||||
}
|
||||
72
openclaw/extensions/openshell/src/config.test.ts
Normal file
72
openclaw/extensions/openshell/src/config.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import fsSync from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createOpenShellPluginConfigSchema, resolveOpenShellPluginConfig } from "./config.js";
|
||||
|
||||
describe("openshell plugin config", () => {
|
||||
it("applies defaults", () => {
|
||||
expect(resolveOpenShellPluginConfig(undefined)).toEqual({
|
||||
mode: "mirror",
|
||||
command: "openshell",
|
||||
gateway: undefined,
|
||||
gatewayEndpoint: undefined,
|
||||
from: "openclaw",
|
||||
policy: undefined,
|
||||
providers: [],
|
||||
gpu: false,
|
||||
autoProviders: true,
|
||||
remoteWorkspaceDir: "/sandbox",
|
||||
remoteAgentWorkspaceDir: "/agent",
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts remote mode", () => {
|
||||
expect(resolveOpenShellPluginConfig({ mode: "remote" }).mode).toBe("remote");
|
||||
});
|
||||
|
||||
it("rejects relative remote paths", () => {
|
||||
expect(() =>
|
||||
resolveOpenShellPluginConfig({
|
||||
remoteWorkspaceDir: "sandbox",
|
||||
}),
|
||||
).toThrow("OpenShell remoteWorkspaceDir must be absolute");
|
||||
});
|
||||
|
||||
it("rejects remote paths outside managed sandbox roots", () => {
|
||||
expect(() =>
|
||||
resolveOpenShellPluginConfig({
|
||||
remoteWorkspaceDir: "/tmp/victim",
|
||||
}),
|
||||
).toThrow("OpenShell remoteWorkspaceDir must stay under /sandbox or /agent");
|
||||
});
|
||||
|
||||
it("normalizes managed sandbox subpaths", () => {
|
||||
expect(
|
||||
resolveOpenShellPluginConfig({
|
||||
remoteWorkspaceDir: "/sandbox/../sandbox/project",
|
||||
remoteAgentWorkspaceDir: "/agent/./session",
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
remoteWorkspaceDir: "/sandbox/project",
|
||||
remoteAgentWorkspaceDir: "/agent/session",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown mode", () => {
|
||||
expect(() =>
|
||||
resolveOpenShellPluginConfig({
|
||||
mode: "bogus",
|
||||
}),
|
||||
).toThrow("mode must be one of mirror, remote");
|
||||
});
|
||||
|
||||
it("keeps the runtime json schema in sync with the manifest config schema", () => {
|
||||
const manifest = JSON.parse(
|
||||
fsSync.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
|
||||
) as { configSchema?: unknown };
|
||||
|
||||
expect(createOpenShellPluginConfigSchema().jsonSchema).toEqual(manifest.configSchema);
|
||||
});
|
||||
});
|
||||
209
openclaw/extensions/openshell/src/config.ts
Normal file
209
openclaw/extensions/openshell/src/config.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import path from "node:path";
|
||||
import { buildPluginConfigSchema, type OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { z } from "openclaw/plugin-sdk/zod";
|
||||
|
||||
export type OpenShellPluginConfig = {
|
||||
mode?: "mirror" | "remote";
|
||||
command?: string;
|
||||
gateway?: string;
|
||||
gatewayEndpoint?: string;
|
||||
from?: string;
|
||||
policy?: string;
|
||||
providers?: string[];
|
||||
gpu?: boolean;
|
||||
autoProviders?: boolean;
|
||||
remoteWorkspaceDir?: string;
|
||||
remoteAgentWorkspaceDir?: string;
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
|
||||
export type ResolvedOpenShellPluginConfig = {
|
||||
mode: "mirror" | "remote";
|
||||
command: string;
|
||||
gateway?: string;
|
||||
gatewayEndpoint?: string;
|
||||
from: string;
|
||||
policy?: string;
|
||||
providers: string[];
|
||||
gpu: boolean;
|
||||
autoProviders: boolean;
|
||||
remoteWorkspaceDir: string;
|
||||
remoteAgentWorkspaceDir: string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
const DEFAULT_COMMAND = "openshell";
|
||||
const DEFAULT_MODE = "mirror";
|
||||
const DEFAULT_SOURCE = "openclaw";
|
||||
const DEFAULT_REMOTE_WORKSPACE_DIR = "/sandbox";
|
||||
const DEFAULT_REMOTE_AGENT_WORKSPACE_DIR = "/agent";
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
const OPEN_SHELL_MANAGED_REMOTE_ROOTS = [
|
||||
DEFAULT_REMOTE_WORKSPACE_DIR,
|
||||
DEFAULT_REMOTE_AGENT_WORKSPACE_DIR,
|
||||
] as const;
|
||||
|
||||
function normalizeProviders(value: string[] | undefined): string[] {
|
||||
const seen = new Set<string>();
|
||||
const providers: string[] = [];
|
||||
for (const entry of value ?? []) {
|
||||
const normalized = entry.trim();
|
||||
if (seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
providers.push(normalized);
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
const nonEmptyTrimmedString = (message: string) =>
|
||||
z.string({ error: message }).trim().min(1, { error: message });
|
||||
|
||||
const OpenShellPluginConfigSchema = z.strictObject({
|
||||
mode: z.enum(["mirror", "remote"], { error: "mode must be one of mirror, remote" }).optional(),
|
||||
command: nonEmptyTrimmedString("command must be a non-empty string").optional(),
|
||||
gateway: nonEmptyTrimmedString("gateway must be a non-empty string").optional(),
|
||||
gatewayEndpoint: nonEmptyTrimmedString("gatewayEndpoint must be a non-empty string").optional(),
|
||||
from: nonEmptyTrimmedString("from must be a non-empty string").optional(),
|
||||
policy: nonEmptyTrimmedString("policy must be a non-empty string").optional(),
|
||||
providers: z
|
||||
.array(
|
||||
z.string({ error: "providers must be an array of strings" }).trim().min(1, {
|
||||
error: "providers must be an array of strings",
|
||||
}),
|
||||
{
|
||||
error: "providers must be an array of strings",
|
||||
},
|
||||
)
|
||||
.optional(),
|
||||
gpu: z.boolean({ error: "gpu must be a boolean" }).optional(),
|
||||
autoProviders: z.boolean({ error: "autoProviders must be a boolean" }).optional(),
|
||||
remoteWorkspaceDir: nonEmptyTrimmedString(
|
||||
"remoteWorkspaceDir must be a non-empty string",
|
||||
).optional(),
|
||||
remoteAgentWorkspaceDir: nonEmptyTrimmedString(
|
||||
"remoteAgentWorkspaceDir must be a non-empty string",
|
||||
).optional(),
|
||||
timeoutSeconds: z
|
||||
.number({ error: "timeoutSeconds must be a number >= 1" })
|
||||
.min(1, { error: "timeoutSeconds must be a number >= 1" })
|
||||
.optional(),
|
||||
});
|
||||
|
||||
function formatOpenShellConfigIssue(issue: z.ZodIssue | undefined): string {
|
||||
if (!issue) {
|
||||
return "invalid config";
|
||||
}
|
||||
if (issue.code === "unrecognized_keys" && issue.keys.length > 0) {
|
||||
return `unknown config key: ${issue.keys[0]}`;
|
||||
}
|
||||
if (issue.code === "invalid_type" && issue.path.length === 0) {
|
||||
return "expected config object";
|
||||
}
|
||||
return issue.message;
|
||||
}
|
||||
|
||||
function isManagedOpenShellRemotePath(value: string): boolean {
|
||||
return OPEN_SHELL_MANAGED_REMOTE_ROOTS.some(
|
||||
(root) => value === root || value.startsWith(`${root}/`),
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeOpenShellRemotePath(
|
||||
value: string | undefined,
|
||||
fallback: string,
|
||||
fieldName = "remote path",
|
||||
): string {
|
||||
const candidate = value ?? fallback;
|
||||
const normalized = path.posix.normalize(candidate.trim() || fallback);
|
||||
if (!normalized.startsWith("/")) {
|
||||
throw new Error(`OpenShell ${fieldName} must be absolute: ${candidate}`);
|
||||
}
|
||||
if (!isManagedOpenShellRemotePath(normalized)) {
|
||||
throw new Error(
|
||||
`OpenShell ${fieldName} must stay under ${OPEN_SHELL_MANAGED_REMOTE_ROOTS.join(" or ")}: ${candidate}`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function createOpenShellPluginConfigSchema(): OpenClawPluginConfigSchema {
|
||||
return buildPluginConfigSchema(OpenShellPluginConfigSchema, {
|
||||
safeParse(value) {
|
||||
if (value === undefined) {
|
||||
return { success: true, data: undefined };
|
||||
}
|
||||
const parsed = OpenShellPluginConfigSchema.safeParse(value);
|
||||
if (parsed.success) {
|
||||
return { success: true, data: parsed.data };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
issues: parsed.error.issues.map((issue) => ({
|
||||
path: issue.path.filter((segment): segment is string | number => {
|
||||
const kind = typeof segment;
|
||||
return kind === "string" || kind === "number";
|
||||
}),
|
||||
message: formatOpenShellConfigIssue(issue),
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveOpenShellPluginConfig(value: unknown): ResolvedOpenShellPluginConfig {
|
||||
if (value === undefined) {
|
||||
// The built-in defaults are managed OpenShell roots, so they do not need to
|
||||
// flow back through normalizeOpenShellRemotePath.
|
||||
return {
|
||||
mode: DEFAULT_MODE,
|
||||
command: DEFAULT_COMMAND,
|
||||
gateway: undefined,
|
||||
gatewayEndpoint: undefined,
|
||||
from: DEFAULT_SOURCE,
|
||||
policy: undefined,
|
||||
providers: [],
|
||||
gpu: false,
|
||||
autoProviders: true,
|
||||
remoteWorkspaceDir: DEFAULT_REMOTE_WORKSPACE_DIR,
|
||||
remoteAgentWorkspaceDir: DEFAULT_REMOTE_AGENT_WORKSPACE_DIR,
|
||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = OpenShellPluginConfigSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
const message = formatOpenShellConfigIssue(parsed.error.issues[0]);
|
||||
throw new Error(`Invalid openshell plugin config: ${message}`);
|
||||
}
|
||||
const cfg = parsed.data as OpenShellPluginConfig;
|
||||
const mode = cfg.mode ?? DEFAULT_MODE;
|
||||
return {
|
||||
mode,
|
||||
command: cfg.command ?? DEFAULT_COMMAND,
|
||||
gateway: cfg.gateway,
|
||||
gatewayEndpoint: cfg.gatewayEndpoint,
|
||||
from: cfg.from ?? DEFAULT_SOURCE,
|
||||
policy: cfg.policy,
|
||||
providers: normalizeProviders(cfg.providers),
|
||||
gpu: cfg.gpu ?? false,
|
||||
autoProviders: cfg.autoProviders ?? true,
|
||||
remoteWorkspaceDir: normalizeOpenShellRemotePath(
|
||||
cfg.remoteWorkspaceDir,
|
||||
DEFAULT_REMOTE_WORKSPACE_DIR,
|
||||
"remoteWorkspaceDir",
|
||||
),
|
||||
remoteAgentWorkspaceDir: normalizeOpenShellRemotePath(
|
||||
cfg.remoteAgentWorkspaceDir,
|
||||
DEFAULT_REMOTE_AGENT_WORKSPACE_DIR,
|
||||
"remoteAgentWorkspaceDir",
|
||||
),
|
||||
timeoutMs:
|
||||
typeof cfg.timeoutSeconds === "number"
|
||||
? Math.floor(cfg.timeoutSeconds * 1000)
|
||||
: DEFAULT_TIMEOUT_MS,
|
||||
};
|
||||
}
|
||||
357
openclaw/extensions/openshell/src/fs-bridge.ts
Normal file
357
openclaw/extensions/openshell/src/fs-bridge.ts
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
import fsPromises from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
SandboxFsBridge,
|
||||
SandboxFsStat,
|
||||
SandboxResolvedPath,
|
||||
} from "openclaw/plugin-sdk/sandbox";
|
||||
import { createWritableRenameTargetResolver } from "openclaw/plugin-sdk/sandbox";
|
||||
import type { OpenShellFsBridgeContext, OpenShellSandboxBackend } from "./backend.types.js";
|
||||
import { movePathWithCopyFallback } from "./mirror.js";
|
||||
|
||||
type ResolvedMountPath = SandboxResolvedPath & {
|
||||
mountHostRoot: string;
|
||||
writable: boolean;
|
||||
source: "workspace" | "agent";
|
||||
};
|
||||
|
||||
export function createOpenShellFsBridge(params: {
|
||||
sandbox: OpenShellFsBridgeContext;
|
||||
backend: OpenShellSandboxBackend;
|
||||
}): SandboxFsBridge {
|
||||
return new OpenShellFsBridge(params.sandbox, params.backend);
|
||||
}
|
||||
|
||||
class OpenShellFsBridge implements SandboxFsBridge {
|
||||
private readonly resolveRenameTargets = createWritableRenameTargetResolver(
|
||||
(target) => this.resolveTarget(target),
|
||||
(target, action) => this.ensureWritable(target, action),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private readonly sandbox: OpenShellFsBridgeContext,
|
||||
private readonly backend: OpenShellSandboxBackend,
|
||||
) {}
|
||||
|
||||
resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath {
|
||||
const target = this.resolveTarget(params);
|
||||
return {
|
||||
hostPath: target.hostPath,
|
||||
relativePath: target.relativePath,
|
||||
containerPath: target.containerPath,
|
||||
};
|
||||
}
|
||||
|
||||
async readFile(params: {
|
||||
filePath: string;
|
||||
cwd?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<Buffer> {
|
||||
const target = this.resolveTarget(params);
|
||||
const hostPath = this.requireHostPath(target);
|
||||
await assertLocalPathSafety({
|
||||
target,
|
||||
root: target.mountHostRoot,
|
||||
allowMissingLeaf: false,
|
||||
allowFinalSymlinkForUnlink: false,
|
||||
});
|
||||
return await fsPromises.readFile(hostPath);
|
||||
}
|
||||
|
||||
async writeFile(params: {
|
||||
filePath: string;
|
||||
cwd?: string;
|
||||
data: Buffer | string;
|
||||
encoding?: BufferEncoding;
|
||||
mkdir?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const target = this.resolveTarget(params);
|
||||
const hostPath = this.requireHostPath(target);
|
||||
this.ensureWritable(target, "write files");
|
||||
await assertLocalPathSafety({
|
||||
target,
|
||||
root: target.mountHostRoot,
|
||||
allowMissingLeaf: true,
|
||||
allowFinalSymlinkForUnlink: false,
|
||||
});
|
||||
const buffer = Buffer.isBuffer(params.data)
|
||||
? params.data
|
||||
: Buffer.from(params.data, params.encoding ?? "utf8");
|
||||
const parentDir = path.dirname(hostPath);
|
||||
if (params.mkdir !== false) {
|
||||
await fsPromises.mkdir(parentDir, { recursive: true });
|
||||
}
|
||||
const tempPath = path.join(
|
||||
parentDir,
|
||||
`.openclaw-openshell-write-${path.basename(hostPath)}-${process.pid}-${Date.now()}`,
|
||||
);
|
||||
await fsPromises.writeFile(tempPath, buffer);
|
||||
await fsPromises.rename(tempPath, hostPath);
|
||||
await this.backend.syncLocalPathToRemote(hostPath, target.containerPath);
|
||||
}
|
||||
|
||||
async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise<void> {
|
||||
const target = this.resolveTarget(params);
|
||||
const hostPath = this.requireHostPath(target);
|
||||
this.ensureWritable(target, "create directories");
|
||||
await assertLocalPathSafety({
|
||||
target,
|
||||
root: target.mountHostRoot,
|
||||
allowMissingLeaf: true,
|
||||
allowFinalSymlinkForUnlink: false,
|
||||
});
|
||||
await fsPromises.mkdir(hostPath, { recursive: true });
|
||||
await this.backend.runRemoteShellScript({
|
||||
script: 'mkdir -p -- "$1"',
|
||||
args: [target.containerPath],
|
||||
signal: params.signal,
|
||||
});
|
||||
}
|
||||
|
||||
async remove(params: {
|
||||
filePath: string;
|
||||
cwd?: string;
|
||||
recursive?: boolean;
|
||||
force?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const target = this.resolveTarget(params);
|
||||
const hostPath = this.requireHostPath(target);
|
||||
this.ensureWritable(target, "remove files");
|
||||
await assertLocalPathSafety({
|
||||
target,
|
||||
root: target.mountHostRoot,
|
||||
allowMissingLeaf: params.force !== false,
|
||||
allowFinalSymlinkForUnlink: true,
|
||||
});
|
||||
await fsPromises.rm(hostPath, {
|
||||
recursive: params.recursive ?? false,
|
||||
force: params.force !== false,
|
||||
});
|
||||
await this.backend.runRemoteShellScript({
|
||||
script: params.recursive
|
||||
? 'rm -rf -- "$1"'
|
||||
: 'if [ -d "$1" ] && [ ! -L "$1" ]; then rmdir -- "$1"; elif [ -e "$1" ] || [ -L "$1" ]; then rm -f -- "$1"; fi',
|
||||
args: [target.containerPath],
|
||||
signal: params.signal,
|
||||
allowFailure: params.force !== false,
|
||||
});
|
||||
}
|
||||
|
||||
async rename(params: {
|
||||
from: string;
|
||||
to: string;
|
||||
cwd?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const { from, to } = this.resolveRenameTargets(params);
|
||||
const fromHostPath = this.requireHostPath(from);
|
||||
const toHostPath = this.requireHostPath(to);
|
||||
await assertLocalPathSafety({
|
||||
target: from,
|
||||
root: from.mountHostRoot,
|
||||
allowMissingLeaf: false,
|
||||
allowFinalSymlinkForUnlink: true,
|
||||
});
|
||||
await assertLocalPathSafety({
|
||||
target: to,
|
||||
root: to.mountHostRoot,
|
||||
allowMissingLeaf: true,
|
||||
allowFinalSymlinkForUnlink: false,
|
||||
});
|
||||
await fsPromises.mkdir(path.dirname(toHostPath), { recursive: true });
|
||||
await movePathWithCopyFallback({ from: fromHostPath, to: toHostPath });
|
||||
await this.backend.runRemoteShellScript({
|
||||
script: 'mkdir -p -- "$(dirname -- "$2")" && mv -- "$1" "$2"',
|
||||
args: [from.containerPath, to.containerPath],
|
||||
signal: params.signal,
|
||||
});
|
||||
}
|
||||
|
||||
async stat(params: {
|
||||
filePath: string;
|
||||
cwd?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<SandboxFsStat | null> {
|
||||
const target = this.resolveTarget(params);
|
||||
const hostPath = this.requireHostPath(target);
|
||||
const stats = await fsPromises.lstat(hostPath).catch(() => null);
|
||||
if (!stats) {
|
||||
return null;
|
||||
}
|
||||
await assertLocalPathSafety({
|
||||
target,
|
||||
root: target.mountHostRoot,
|
||||
allowMissingLeaf: false,
|
||||
allowFinalSymlinkForUnlink: false,
|
||||
});
|
||||
return {
|
||||
type: stats.isDirectory() ? "directory" : stats.isFile() ? "file" : "other",
|
||||
size: stats.size,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
};
|
||||
}
|
||||
|
||||
private ensureWritable(target: ResolvedMountPath, action: string) {
|
||||
if (this.sandbox.workspaceAccess !== "rw" || !target.writable) {
|
||||
throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
private requireHostPath(target: ResolvedMountPath): string {
|
||||
if (!target.hostPath) {
|
||||
throw new Error(
|
||||
`OpenShell mirror bridge requires a local host path: ${target.containerPath}`,
|
||||
);
|
||||
}
|
||||
return target.hostPath;
|
||||
}
|
||||
|
||||
private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedMountPath {
|
||||
const workspaceRoot = path.resolve(this.sandbox.workspaceDir);
|
||||
const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir);
|
||||
const hasAgentMount = this.sandbox.workspaceAccess !== "none" && workspaceRoot !== agentRoot;
|
||||
const agentContainerRoot = (this.backend.remoteAgentWorkspaceDir || "/agent").replace(
|
||||
/\\/g,
|
||||
"/",
|
||||
);
|
||||
const workspaceContainerRoot = this.sandbox.containerWorkdir.replace(/\\/g, "/");
|
||||
const input = params.filePath.trim();
|
||||
|
||||
if (input.startsWith(`${workspaceContainerRoot}/`) || input === workspaceContainerRoot) {
|
||||
const relative = path.posix.relative(workspaceContainerRoot, input) || "";
|
||||
const hostPath = relative
|
||||
? path.resolve(workspaceRoot, ...relative.split("/"))
|
||||
: workspaceRoot;
|
||||
return {
|
||||
hostPath,
|
||||
relativePath: relative,
|
||||
containerPath: relative
|
||||
? path.posix.join(workspaceContainerRoot, relative)
|
||||
: workspaceContainerRoot,
|
||||
mountHostRoot: workspaceRoot,
|
||||
writable: this.sandbox.workspaceAccess === "rw",
|
||||
source: "workspace",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
hasAgentMount &&
|
||||
(input.startsWith(`${agentContainerRoot}/`) || input === agentContainerRoot)
|
||||
) {
|
||||
const relative = path.posix.relative(agentContainerRoot, input) || "";
|
||||
const hostPath = relative ? path.resolve(agentRoot, ...relative.split("/")) : agentRoot;
|
||||
return {
|
||||
hostPath,
|
||||
relativePath: relative ? agentContainerRoot + "/" + relative : agentContainerRoot,
|
||||
containerPath: relative
|
||||
? path.posix.join(agentContainerRoot, relative)
|
||||
: agentContainerRoot,
|
||||
mountHostRoot: agentRoot,
|
||||
writable: this.sandbox.workspaceAccess === "rw",
|
||||
source: "agent",
|
||||
};
|
||||
}
|
||||
|
||||
const cwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot;
|
||||
const hostPath = path.isAbsolute(input) ? path.resolve(input) : path.resolve(cwd, input);
|
||||
|
||||
if (isPathInside(workspaceRoot, hostPath)) {
|
||||
const relative = path.relative(workspaceRoot, hostPath).split(path.sep).join(path.posix.sep);
|
||||
return {
|
||||
hostPath,
|
||||
relativePath: relative,
|
||||
containerPath: relative
|
||||
? path.posix.join(workspaceContainerRoot, relative)
|
||||
: workspaceContainerRoot,
|
||||
mountHostRoot: workspaceRoot,
|
||||
writable: this.sandbox.workspaceAccess === "rw",
|
||||
source: "workspace",
|
||||
};
|
||||
}
|
||||
|
||||
if (hasAgentMount && isPathInside(agentRoot, hostPath)) {
|
||||
const relative = path.relative(agentRoot, hostPath).split(path.sep).join(path.posix.sep);
|
||||
return {
|
||||
hostPath,
|
||||
relativePath: relative ? `${agentContainerRoot}/${relative}` : agentContainerRoot,
|
||||
containerPath: relative
|
||||
? path.posix.join(agentContainerRoot, relative)
|
||||
: agentContainerRoot,
|
||||
mountHostRoot: agentRoot,
|
||||
writable: this.sandbox.workspaceAccess === "rw",
|
||||
source: "agent",
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Path escapes sandbox root (${workspaceRoot}): ${params.filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isPathInside(root: string, target: string): boolean {
|
||||
const relative = path.relative(root, target);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
async function assertLocalPathSafety(params: {
|
||||
target: ResolvedMountPath;
|
||||
root: string;
|
||||
allowMissingLeaf: boolean;
|
||||
allowFinalSymlinkForUnlink: boolean;
|
||||
}): Promise<void> {
|
||||
if (!params.target.hostPath) {
|
||||
throw new Error(`Missing local host path for ${params.target.containerPath}`);
|
||||
}
|
||||
const canonicalRoot = await fsPromises
|
||||
.realpath(params.root)
|
||||
.catch(() => path.resolve(params.root));
|
||||
const candidate = await resolveCanonicalCandidate(params.target.hostPath);
|
||||
if (!isPathInside(canonicalRoot, candidate)) {
|
||||
throw new Error(
|
||||
`Sandbox path escapes allowed mounts; cannot access: ${params.target.containerPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
const relative = path.relative(params.root, params.target.hostPath);
|
||||
const segments = relative
|
||||
.split(path.sep)
|
||||
.filter(Boolean)
|
||||
.slice(0, Math.max(0, relative.split(path.sep).filter(Boolean).length));
|
||||
let cursor = params.root;
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
cursor = path.join(cursor, segments[index]);
|
||||
const stats = await fsPromises.lstat(cursor).catch(() => null);
|
||||
if (!stats) {
|
||||
if (index === segments.length - 1 && params.allowMissingLeaf) {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const isFinal = index === segments.length - 1;
|
||||
if (stats.isSymbolicLink() && (!isFinal || !params.allowFinalSymlinkForUnlink)) {
|
||||
throw new Error(`Sandbox boundary checks failed: ${params.target.containerPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCanonicalCandidate(targetPath: string): Promise<string> {
|
||||
const missing: string[] = [];
|
||||
let cursor = path.resolve(targetPath);
|
||||
while (true) {
|
||||
const exists = await fsPromises
|
||||
.lstat(cursor)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (exists) {
|
||||
const canonical = await fsPromises.realpath(cursor).catch(() => cursor);
|
||||
return path.resolve(canonical, ...missing);
|
||||
}
|
||||
const parent = path.dirname(cursor);
|
||||
if (parent === cursor) {
|
||||
return path.resolve(cursor, ...missing);
|
||||
}
|
||||
missing.unshift(path.basename(cursor));
|
||||
cursor = parent;
|
||||
}
|
||||
}
|
||||
182
openclaw/extensions/openshell/src/mirror.test.ts
Normal file
182
openclaw/extensions/openshell/src/mirror.test.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS,
|
||||
replaceDirectoryContents,
|
||||
stageDirectoryContents,
|
||||
} from "./mirror.js";
|
||||
|
||||
const dirs: string[] = [];
|
||||
|
||||
async function makeTmpDir(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mirror-test-"));
|
||||
dirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(dirs.map((d) => fs.rm(d, { recursive: true, force: true })));
|
||||
dirs.length = 0;
|
||||
});
|
||||
|
||||
describe("replaceDirectoryContents", () => {
|
||||
it("copies source entries to target", async () => {
|
||||
const source = await makeTmpDir();
|
||||
const target = await makeTmpDir();
|
||||
await fs.writeFile(path.join(source, "a.txt"), "hello");
|
||||
await fs.writeFile(path.join(target, "old.txt"), "stale");
|
||||
|
||||
await replaceDirectoryContents({ sourceDir: source, targetDir: target });
|
||||
|
||||
expect(await fs.readFile(path.join(target, "a.txt"), "utf8")).toBe("hello");
|
||||
await expect(fs.access(path.join(target, "old.txt"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
// Mirrored OpenShell sandbox content must never overwrite trusted workspace
|
||||
// hook directories.
|
||||
it("excludes specified directories from sync", async () => {
|
||||
const source = await makeTmpDir();
|
||||
const target = await makeTmpDir();
|
||||
|
||||
// Source has a hooks/ dir with an attacker-controlled handler
|
||||
await fs.mkdir(path.join(source, "hooks", "evil"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(source, "hooks", "evil", "handler.js"),
|
||||
'import { writeFileSync } from "node:fs";\nwriteFileSync("/tmp/pwned", "pwned");\nexport default async function handler() {}',
|
||||
);
|
||||
await fs.writeFile(path.join(source, "code.txt"), "legit");
|
||||
|
||||
// Target has existing trusted hooks
|
||||
await fs.mkdir(path.join(target, "hooks", "trusted"), { recursive: true });
|
||||
await fs.writeFile(path.join(target, "hooks", "trusted", "handler.js"), "// trusted code");
|
||||
await fs.writeFile(path.join(target, "existing.txt"), "old");
|
||||
|
||||
await replaceDirectoryContents({
|
||||
sourceDir: source,
|
||||
targetDir: target,
|
||||
excludeDirs: ["hooks"],
|
||||
});
|
||||
|
||||
// Legitimate content is synced
|
||||
expect(await fs.readFile(path.join(target, "code.txt"), "utf8")).toBe("legit");
|
||||
|
||||
// Old non-excluded content is removed
|
||||
await expect(fs.access(path.join(target, "existing.txt"))).rejects.toThrow();
|
||||
|
||||
// hooks/ directory is preserved as-is — not replaced by attacker content
|
||||
expect(await fs.readFile(path.join(target, "hooks", "trusted", "handler.js"), "utf8")).toBe(
|
||||
"// trusted code",
|
||||
);
|
||||
await expect(fs.access(path.join(target, "hooks", "evil"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("excludeDirs matching is case-insensitive", async () => {
|
||||
const source = await makeTmpDir();
|
||||
const target = await makeTmpDir();
|
||||
|
||||
// Source uses variant casing to try to bypass the exclusion
|
||||
await fs.mkdir(path.join(source, "Hooks", "evil"), { recursive: true });
|
||||
await fs.writeFile(path.join(source, "Hooks", "evil", "handler.js"), "// malicious");
|
||||
await fs.writeFile(path.join(source, "data.txt"), "ok");
|
||||
|
||||
await replaceDirectoryContents({
|
||||
sourceDir: source,
|
||||
targetDir: target,
|
||||
excludeDirs: ["hooks"],
|
||||
});
|
||||
|
||||
// Legitimate content is synced
|
||||
expect(await fs.readFile(path.join(target, "data.txt"), "utf8")).toBe("ok");
|
||||
|
||||
// "Hooks" (variant case) must still be excluded
|
||||
await expect(fs.access(path.join(target, "Hooks"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("preserves default excluded directories and repository metadata", async () => {
|
||||
const source = await makeTmpDir();
|
||||
const target = await makeTmpDir();
|
||||
|
||||
await fs.mkdir(path.join(source, "hooks"), { recursive: true });
|
||||
await fs.writeFile(path.join(source, "hooks", "pre-commit"), "malicious");
|
||||
await fs.mkdir(path.join(source, "git-hooks"), { recursive: true });
|
||||
await fs.writeFile(path.join(source, "git-hooks", "pre-commit"), "malicious");
|
||||
await fs.mkdir(path.join(source, ".git", "hooks"), { recursive: true });
|
||||
await fs.writeFile(path.join(source, ".git", "hooks", "post-checkout"), "malicious");
|
||||
await fs.writeFile(path.join(source, "safe.txt"), "ok");
|
||||
|
||||
await fs.mkdir(path.join(target, "hooks"), { recursive: true });
|
||||
await fs.writeFile(path.join(target, "hooks", "trusted"), "trusted");
|
||||
await fs.mkdir(path.join(target, "git-hooks"), { recursive: true });
|
||||
await fs.writeFile(path.join(target, "git-hooks", "trusted"), "trusted");
|
||||
await fs.mkdir(path.join(target, ".git"), { recursive: true });
|
||||
await fs.writeFile(path.join(target, ".git", "HEAD"), "ref: refs/heads/main\n");
|
||||
|
||||
await replaceDirectoryContents({
|
||||
sourceDir: source,
|
||||
targetDir: target,
|
||||
excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS,
|
||||
});
|
||||
|
||||
expect(await fs.readFile(path.join(target, "safe.txt"), "utf8")).toBe("ok");
|
||||
expect(await fs.readFile(path.join(target, "hooks", "trusted"), "utf8")).toBe("trusted");
|
||||
expect(await fs.readFile(path.join(target, "git-hooks", "trusted"), "utf8")).toBe("trusted");
|
||||
expect(await fs.readFile(path.join(target, ".git", "HEAD"), "utf8")).toBe(
|
||||
"ref: refs/heads/main\n",
|
||||
);
|
||||
await expect(fs.access(path.join(target, ".git", "hooks", "post-checkout"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("skips symbolic links when copying into the host workspace", async () => {
|
||||
const source = await makeTmpDir();
|
||||
const target = await makeTmpDir();
|
||||
|
||||
await fs.writeFile(path.join(source, "safe.txt"), "ok");
|
||||
await fs.mkdir(path.join(source, "nested"), { recursive: true });
|
||||
await fs.writeFile(path.join(source, "nested", "file.txt"), "nested");
|
||||
await fs.symlink("/tmp/host-secret", path.join(source, "escaped-link"));
|
||||
await fs.symlink("/tmp/host-secret-dir", path.join(source, "nested", "escaped-dir"));
|
||||
|
||||
await replaceDirectoryContents({ sourceDir: source, targetDir: target });
|
||||
|
||||
expect(await fs.readFile(path.join(target, "safe.txt"), "utf8")).toBe("ok");
|
||||
expect(await fs.readFile(path.join(target, "nested", "file.txt"), "utf8")).toBe("nested");
|
||||
await expect(fs.lstat(path.join(target, "escaped-link"))).rejects.toThrow();
|
||||
await expect(fs.lstat(path.join(target, "nested", "escaped-dir"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("preserves existing trusted host symlinks", async () => {
|
||||
const source = await makeTmpDir();
|
||||
const target = await makeTmpDir();
|
||||
|
||||
await fs.writeFile(path.join(source, "safe.txt"), "ok");
|
||||
await fs.writeFile(path.join(source, "linked-entry"), "remote-plain-file");
|
||||
await fs.symlink("/tmp/trusted-host-target", path.join(target, "linked-entry"));
|
||||
|
||||
await replaceDirectoryContents({ sourceDir: source, targetDir: target });
|
||||
|
||||
expect(await fs.readFile(path.join(target, "safe.txt"), "utf8")).toBe("ok");
|
||||
expect(await fs.readlink(path.join(target, "linked-entry"))).toBe("/tmp/trusted-host-target");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stageDirectoryContents", () => {
|
||||
it("stages upload content without symbolic links", async () => {
|
||||
const source = await makeTmpDir();
|
||||
const staged = await makeTmpDir();
|
||||
|
||||
await fs.writeFile(path.join(source, "safe.txt"), "ok");
|
||||
await fs.mkdir(path.join(source, "nested"), { recursive: true });
|
||||
await fs.writeFile(path.join(source, "nested", "file.txt"), "nested");
|
||||
await fs.symlink("/tmp/host-secret", path.join(source, "escaped-link"));
|
||||
await fs.symlink("/tmp/host-secret-dir", path.join(source, "nested", "escaped-dir"));
|
||||
|
||||
await stageDirectoryContents({ sourceDir: source, targetDir: staged });
|
||||
|
||||
expect(await fs.readFile(path.join(staged, "safe.txt"), "utf8")).toBe("ok");
|
||||
expect(await fs.readFile(path.join(staged, "nested", "file.txt"), "utf8")).toBe("nested");
|
||||
await expect(fs.lstat(path.join(staged, "escaped-link"))).rejects.toThrow();
|
||||
await expect(fs.lstat(path.join(staged, "nested", "escaped-dir"))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
159
openclaw/extensions/openshell/src/mirror.ts
Normal file
159
openclaw/extensions/openshell/src/mirror.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
export const DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS = ["hooks", "git-hooks", ".git"] as const;
|
||||
const COPY_TREE_FS_CONCURRENCY = 16;
|
||||
|
||||
function createExcludeMatcher(excludeDirs?: readonly string[]) {
|
||||
const excluded = new Set((excludeDirs ?? []).map((d) => normalizeLowercaseStringOrEmpty(d)));
|
||||
return (name: string) => excluded.has(normalizeLowercaseStringOrEmpty(name));
|
||||
}
|
||||
|
||||
function createConcurrencyLimiter(limit: number) {
|
||||
let active = 0;
|
||||
const queue: Array<() => void> = [];
|
||||
|
||||
const release = () => {
|
||||
active -= 1;
|
||||
queue.shift()?.();
|
||||
};
|
||||
|
||||
return async <T>(task: () => Promise<T>): Promise<T> => {
|
||||
if (active >= limit) {
|
||||
await new Promise<void>((resolve) => {
|
||||
queue.push(resolve);
|
||||
});
|
||||
}
|
||||
active += 1;
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const runLimitedFs = createConcurrencyLimiter(COPY_TREE_FS_CONCURRENCY);
|
||||
|
||||
async function lstatIfExists(targetPath: string) {
|
||||
return await runLimitedFs(async () => await fs.lstat(targetPath)).catch(() => null);
|
||||
}
|
||||
|
||||
async function copyTreeWithoutSymlinks(params: {
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
preserveTargetSymlinks?: boolean;
|
||||
}): Promise<void> {
|
||||
const stats = await runLimitedFs(async () => await fs.lstat(params.sourcePath));
|
||||
// Mirror sync only carries regular files and directories across the
|
||||
// host/sandbox boundary. Symlinks and special files are dropped.
|
||||
if (stats.isSymbolicLink()) {
|
||||
return;
|
||||
}
|
||||
const targetStats = await lstatIfExists(params.targetPath);
|
||||
if (params.preserveTargetSymlinks && targetStats?.isSymbolicLink()) {
|
||||
return;
|
||||
}
|
||||
if (stats.isDirectory()) {
|
||||
await runLimitedFs(async () => await fs.mkdir(params.targetPath, { recursive: true }));
|
||||
const entries = await runLimitedFs(async () => await fs.readdir(params.sourcePath));
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
await copyTreeWithoutSymlinks({
|
||||
sourcePath: path.join(params.sourcePath, entry),
|
||||
targetPath: path.join(params.targetPath, entry),
|
||||
preserveTargetSymlinks: params.preserveTargetSymlinks,
|
||||
});
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (stats.isFile()) {
|
||||
await runLimitedFs(
|
||||
async () => await fs.mkdir(path.dirname(params.targetPath), { recursive: true }),
|
||||
);
|
||||
await runLimitedFs(async () => await fs.copyFile(params.sourcePath, params.targetPath));
|
||||
}
|
||||
}
|
||||
|
||||
export async function replaceDirectoryContents(params: {
|
||||
sourceDir: string;
|
||||
targetDir: string;
|
||||
/** Top-level directory names to exclude from sync (preserved in target, skipped from source). */
|
||||
excludeDirs?: readonly string[];
|
||||
}): Promise<void> {
|
||||
const isExcluded = createExcludeMatcher(params.excludeDirs);
|
||||
await fs.mkdir(params.targetDir, { recursive: true });
|
||||
const existing = await fs.readdir(params.targetDir);
|
||||
await Promise.all(
|
||||
existing
|
||||
.filter((entry) => !isExcluded(entry))
|
||||
.map(async (entry) => {
|
||||
const targetPath = path.join(params.targetDir, entry);
|
||||
const stats = await lstatIfExists(targetPath);
|
||||
if (stats?.isSymbolicLink()) {
|
||||
return;
|
||||
}
|
||||
await runLimitedFs(
|
||||
async () =>
|
||||
await fs.rm(targetPath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
const sourceEntries = await fs.readdir(params.sourceDir);
|
||||
for (const entry of sourceEntries) {
|
||||
if (isExcluded(entry)) {
|
||||
continue;
|
||||
}
|
||||
await copyTreeWithoutSymlinks({
|
||||
sourcePath: path.join(params.sourceDir, entry),
|
||||
targetPath: path.join(params.targetDir, entry),
|
||||
preserveTargetSymlinks: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function stageDirectoryContents(params: {
|
||||
sourceDir: string;
|
||||
targetDir: string;
|
||||
/** Top-level directory names to exclude from the staged upload. */
|
||||
excludeDirs?: readonly string[];
|
||||
}): Promise<void> {
|
||||
const isExcluded = createExcludeMatcher(params.excludeDirs);
|
||||
await fs.mkdir(params.targetDir, { recursive: true });
|
||||
const sourceEntries = await fs.readdir(params.sourceDir);
|
||||
for (const entry of sourceEntries) {
|
||||
if (isExcluded(entry)) {
|
||||
continue;
|
||||
}
|
||||
await copyTreeWithoutSymlinks({
|
||||
sourcePath: path.join(params.sourceDir, entry),
|
||||
targetPath: path.join(params.targetDir, entry),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function movePathWithCopyFallback(params: {
|
||||
from: string;
|
||||
to: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await fs.rename(params.from, params.to);
|
||||
return;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code;
|
||||
if (code !== "EXDEV") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await fs.cp(params.from, params.to, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
dereference: false,
|
||||
});
|
||||
await fs.rm(params.from, { recursive: true, force: true });
|
||||
}
|
||||
272
openclaw/extensions/openshell/src/openshell-core.test.ts
Normal file
272
openclaw/extensions/openshell/src/openshell-core.test.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSandboxTestContext } from "../../../src/agents/sandbox/test-fixtures.js";
|
||||
import type { OpenShellSandboxBackend } from "./backend.js";
|
||||
import {
|
||||
buildExecRemoteCommand,
|
||||
buildOpenShellBaseArgv,
|
||||
resolveOpenShellCommand,
|
||||
setBundledOpenShellCommandResolverForTest,
|
||||
shellEscape,
|
||||
} from "./cli.js";
|
||||
import { resolveOpenShellPluginConfig } from "./config.js";
|
||||
|
||||
const cliMocks = vi.hoisted(() => ({
|
||||
runOpenShellCli: vi.fn(),
|
||||
}));
|
||||
|
||||
let createOpenShellSandboxBackendManager: typeof import("./backend.js").createOpenShellSandboxBackendManager;
|
||||
|
||||
describe("openshell cli helpers", () => {
|
||||
afterEach(() => {
|
||||
setBundledOpenShellCommandResolverForTest();
|
||||
});
|
||||
|
||||
it("builds base argv with gateway overrides", () => {
|
||||
const config = resolveOpenShellPluginConfig({
|
||||
command: "/usr/local/bin/openshell",
|
||||
gateway: "lab",
|
||||
gatewayEndpoint: "https://lab.example",
|
||||
});
|
||||
expect(buildOpenShellBaseArgv(config)).toEqual([
|
||||
"/usr/local/bin/openshell",
|
||||
"--gateway",
|
||||
"lab",
|
||||
"--gateway-endpoint",
|
||||
"https://lab.example",
|
||||
]);
|
||||
});
|
||||
|
||||
it("prefers the bundled openshell command when available", () => {
|
||||
setBundledOpenShellCommandResolverForTest(() => "/tmp/node_modules/.bin/openshell");
|
||||
const config = resolveOpenShellPluginConfig(undefined);
|
||||
|
||||
expect(resolveOpenShellCommand("openshell")).toBe("/tmp/node_modules/.bin/openshell");
|
||||
expect(buildOpenShellBaseArgv(config)).toEqual(["/tmp/node_modules/.bin/openshell"]);
|
||||
});
|
||||
|
||||
it("falls back to the PATH command when no bundled openshell is present", () => {
|
||||
setBundledOpenShellCommandResolverForTest(() => null);
|
||||
|
||||
expect(resolveOpenShellCommand("openshell")).toBe("openshell");
|
||||
});
|
||||
|
||||
it("shell escapes single quotes", () => {
|
||||
expect(shellEscape(`a'b`)).toBe(`'a'"'"'b'`);
|
||||
});
|
||||
|
||||
it("wraps exec commands with env and workdir", () => {
|
||||
const command = buildExecRemoteCommand({
|
||||
command: "pwd && printenv TOKEN",
|
||||
workdir: "/sandbox/project",
|
||||
env: {
|
||||
TOKEN: "abc 123",
|
||||
},
|
||||
});
|
||||
expect(command).toContain(`'env'`);
|
||||
expect(command).toContain(`'TOKEN=abc 123'`);
|
||||
expect(command).toContain(`'cd '"'"'/sandbox/project'"'"' && pwd && printenv TOKEN'`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openshell backend manager", () => {
|
||||
beforeAll(async () => {
|
||||
vi.doMock("./cli.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./cli.js")>("./cli.js");
|
||||
return {
|
||||
...actual,
|
||||
runOpenShellCli: cliMocks.runOpenShellCli,
|
||||
};
|
||||
});
|
||||
({ createOpenShellSandboxBackendManager } = await import("./backend.js"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("./cli.js");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("checks runtime status with config override from OpenClaw config", async () => {
|
||||
cliMocks.runOpenShellCli.mockResolvedValue({
|
||||
code: 0,
|
||||
stdout: "{}",
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
const manager = createOpenShellSandboxBackendManager({
|
||||
pluginConfig: resolveOpenShellPluginConfig({
|
||||
command: "openshell",
|
||||
from: "openclaw",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await manager.describeRuntime({
|
||||
entry: {
|
||||
containerName: "openclaw-session-1234",
|
||||
backendId: "openshell",
|
||||
runtimeLabel: "openclaw-session-1234",
|
||||
sessionKey: "agent:main",
|
||||
createdAtMs: 1,
|
||||
lastUsedAtMs: 1,
|
||||
image: "custom-source",
|
||||
configLabelKind: "Source",
|
||||
},
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
openshell: {
|
||||
enabled: true,
|
||||
config: {
|
||||
command: "openshell",
|
||||
from: "custom-source",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
running: true,
|
||||
actualConfigLabel: "custom-source",
|
||||
configLabelMatch: true,
|
||||
});
|
||||
expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({
|
||||
context: expect.objectContaining({
|
||||
sandboxName: "openclaw-session-1234",
|
||||
config: expect.objectContaining({
|
||||
from: "custom-source",
|
||||
}),
|
||||
}),
|
||||
args: ["sandbox", "get", "openclaw-session-1234"],
|
||||
});
|
||||
});
|
||||
|
||||
it("removes runtimes via openshell sandbox delete", async () => {
|
||||
cliMocks.runOpenShellCli.mockResolvedValue({
|
||||
code: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
const manager = createOpenShellSandboxBackendManager({
|
||||
pluginConfig: resolveOpenShellPluginConfig({
|
||||
command: "/usr/local/bin/openshell",
|
||||
gateway: "lab",
|
||||
}),
|
||||
});
|
||||
|
||||
await manager.removeRuntime({
|
||||
entry: {
|
||||
containerName: "openclaw-session-5678",
|
||||
backendId: "openshell",
|
||||
runtimeLabel: "openclaw-session-5678",
|
||||
sessionKey: "agent:main",
|
||||
createdAtMs: 1,
|
||||
lastUsedAtMs: 1,
|
||||
image: "openclaw",
|
||||
configLabelKind: "Source",
|
||||
},
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({
|
||||
context: expect.objectContaining({
|
||||
sandboxName: "openclaw-session-5678",
|
||||
config: expect.objectContaining({
|
||||
command: "/usr/local/bin/openshell",
|
||||
gateway: "lab",
|
||||
}),
|
||||
}),
|
||||
args: ["sandbox", "delete", "openclaw-session-5678"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function makeTempDir(prefix: string) {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
function createMirrorBackendMock(): OpenShellSandboxBackend {
|
||||
return {
|
||||
id: "openshell",
|
||||
runtimeId: "openshell-test",
|
||||
runtimeLabel: "openshell-test",
|
||||
workdir: "/sandbox",
|
||||
env: {},
|
||||
remoteWorkspaceDir: "/sandbox",
|
||||
remoteAgentWorkspaceDir: "/agent",
|
||||
buildExecSpec: vi.fn(),
|
||||
runShellCommand: vi.fn(),
|
||||
runRemoteShellScript: vi.fn().mockResolvedValue({
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.alloc(0),
|
||||
code: 0,
|
||||
}),
|
||||
syncLocalPathToRemote: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as OpenShellSandboxBackend;
|
||||
}
|
||||
|
||||
describe("openshell fs bridges", () => {
|
||||
it("writes locally and syncs the file to the remote workspace", async () => {
|
||||
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
|
||||
const backend = createMirrorBackendMock();
|
||||
const sandbox = createSandboxTestContext({
|
||||
overrides: {
|
||||
backendId: "openshell",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
containerWorkdir: "/sandbox",
|
||||
},
|
||||
});
|
||||
|
||||
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
|
||||
const bridge = createOpenShellFsBridge({ sandbox, backend });
|
||||
await bridge.writeFile({
|
||||
filePath: "nested/file.txt",
|
||||
data: "hello",
|
||||
mkdir: true,
|
||||
});
|
||||
|
||||
expect(await fs.readFile(path.join(workspaceDir, "nested", "file.txt"), "utf8")).toBe("hello");
|
||||
expect(backend.syncLocalPathToRemote).toHaveBeenCalledWith(
|
||||
path.join(workspaceDir, "nested", "file.txt"),
|
||||
"/sandbox/nested/file.txt",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps agent mount paths when the sandbox workspace is read-only", async () => {
|
||||
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
|
||||
const agentWorkspaceDir = await makeTempDir("openclaw-openshell-agent-");
|
||||
await fs.writeFile(path.join(agentWorkspaceDir, "note.txt"), "agent", "utf8");
|
||||
const backend = createMirrorBackendMock();
|
||||
const sandbox = createSandboxTestContext({
|
||||
overrides: {
|
||||
backendId: "openshell",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir,
|
||||
workspaceAccess: "ro",
|
||||
containerWorkdir: "/sandbox",
|
||||
},
|
||||
});
|
||||
|
||||
const { createOpenShellFsBridge } = await import("./fs-bridge.js");
|
||||
const bridge = createOpenShellFsBridge({ sandbox, backend });
|
||||
const resolved = bridge.resolvePath({ filePath: "/agent/note.txt" });
|
||||
expect(resolved.hostPath).toBe(path.join(agentWorkspaceDir, "note.txt"));
|
||||
expect(await bridge.readFile({ filePath: "/agent/note.txt" })).toEqual(Buffer.from("agent"));
|
||||
});
|
||||
});
|
||||
16
openclaw/extensions/openshell/tsconfig.json
Normal file
16
openclaw/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"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue