mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 02:50:24 +00:00
feat: add openshell sandbox backend
This commit is contained in:
445
extensions/openshell/src/backend.ts
Normal file
445
extensions/openshell/src/backend.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
CreateSandboxBackendParams,
|
||||
OpenClawConfig,
|
||||
SandboxBackendCommandParams,
|
||||
SandboxBackendCommandResult,
|
||||
SandboxBackendFactory,
|
||||
SandboxBackendHandle,
|
||||
SandboxBackendManager,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
buildExecRemoteCommand,
|
||||
buildRemoteCommand,
|
||||
createOpenShellSshSession,
|
||||
disposeOpenShellSshSession,
|
||||
runOpenShellCli,
|
||||
runOpenShellSshCommand,
|
||||
type OpenShellExecContext,
|
||||
type OpenShellSshSession,
|
||||
} from "./cli.js";
|
||||
import { resolveOpenShellPluginConfig, type ResolvedOpenShellPluginConfig } from "./config.js";
|
||||
import { createOpenShellFsBridge } from "./fs-bridge.js";
|
||||
import { replaceDirectoryContents } from "./mirror.js";
|
||||
|
||||
type CreateOpenShellSandboxBackendFactoryParams = {
|
||||
pluginConfig: ResolvedOpenShellPluginConfig;
|
||||
};
|
||||
|
||||
type PendingExec = {
|
||||
sshSession: OpenShellSshSession;
|
||||
};
|
||||
|
||||
export type OpenShellSandboxBackend = SandboxBackendHandle & {
|
||||
remoteWorkspaceDir: string;
|
||||
remoteAgentWorkspaceDir: string;
|
||||
runRemoteShellScript(params: SandboxBackendCommandParams): Promise<SandboxBackendCommandResult>;
|
||||
syncLocalPathToRemote(localPath: string, remotePath: string): Promise<void>;
|
||||
};
|
||||
|
||||
export function createOpenShellSandboxBackendFactory(
|
||||
params: CreateOpenShellSandboxBackendFactoryParams,
|
||||
): SandboxBackendFactory {
|
||||
return async (createParams) =>
|
||||
await createOpenShellSandboxBackend({
|
||||
...params,
|
||||
createParams,
|
||||
});
|
||||
}
|
||||
|
||||
export function createOpenShellSandboxBackendManager(params: {
|
||||
pluginConfig: ResolvedOpenShellPluginConfig;
|
||||
}): SandboxBackendManager {
|
||||
return {
|
||||
async describeRuntime({ entry, config }) {
|
||||
const execContext: OpenShellExecContext = {
|
||||
config: resolveOpenShellPluginConfigFromConfig(config, params.pluginConfig),
|
||||
sandboxName: entry.containerName,
|
||||
};
|
||||
const result = await runOpenShellCli({
|
||||
context: execContext,
|
||||
args: ["sandbox", "get", entry.containerName],
|
||||
});
|
||||
const configuredSource = execContext.config.from;
|
||||
return {
|
||||
running: result.code === 0,
|
||||
actualConfigLabel: entry.image,
|
||||
configLabelMatch: entry.image === configuredSource,
|
||||
};
|
||||
},
|
||||
async removeRuntime({ entry }) {
|
||||
const execContext: OpenShellExecContext = {
|
||||
config: params.pluginConfig,
|
||||
sandboxName: entry.containerName,
|
||||
};
|
||||
await runOpenShellCli({
|
||||
context: execContext,
|
||||
args: ["sandbox", "delete", entry.containerName],
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createOpenShellSandboxBackend(params: {
|
||||
pluginConfig: ResolvedOpenShellPluginConfig;
|
||||
createParams: CreateSandboxBackendParams;
|
||||
}): Promise<OpenShellSandboxBackend> {
|
||||
if ((params.createParams.cfg.docker.binds?.length ?? 0) > 0) {
|
||||
throw new Error("OpenShell sandbox backend does not support sandbox.docker.binds.");
|
||||
}
|
||||
|
||||
const sandboxName = buildOpenShellSandboxName(params.createParams.scopeKey);
|
||||
const execContext: OpenShellExecContext = {
|
||||
config: params.pluginConfig,
|
||||
sandboxName,
|
||||
};
|
||||
const impl = new OpenShellSandboxBackendImpl({
|
||||
createParams: params.createParams,
|
||||
execContext,
|
||||
remoteWorkspaceDir: params.pluginConfig.remoteWorkspaceDir,
|
||||
remoteAgentWorkspaceDir: params.pluginConfig.remoteAgentWorkspaceDir,
|
||||
});
|
||||
|
||||
return {
|
||||
id: "openshell",
|
||||
runtimeId: sandboxName,
|
||||
runtimeLabel: sandboxName,
|
||||
workdir: params.pluginConfig.remoteWorkspaceDir,
|
||||
env: params.createParams.cfg.docker.env,
|
||||
configLabel: params.pluginConfig.from,
|
||||
configLabelKind: "Source",
|
||||
buildExecSpec: async ({ command, workdir, env, usePty }) => {
|
||||
const pending = await impl.prepareExec({ command, workdir, env, usePty });
|
||||
return {
|
||||
argv: pending.argv,
|
||||
env: process.env,
|
||||
stdinMode: "pipe-open",
|
||||
finalizeToken: pending.token,
|
||||
};
|
||||
},
|
||||
finalizeExec: async ({ token }) => {
|
||||
await impl.finalizeExec(token as PendingExec | undefined);
|
||||
},
|
||||
runShellCommand: async (command) => await impl.runRemoteShellScript(command),
|
||||
createFsBridge: ({ sandbox }) =>
|
||||
createOpenShellFsBridge({
|
||||
sandbox,
|
||||
backend: impl.asHandle(),
|
||||
}),
|
||||
remoteWorkspaceDir: params.pluginConfig.remoteWorkspaceDir,
|
||||
remoteAgentWorkspaceDir: params.pluginConfig.remoteAgentWorkspaceDir,
|
||||
runRemoteShellScript: async (command) => await impl.runRemoteShellScript(command),
|
||||
syncLocalPathToRemote: async (localPath, remotePath) =>
|
||||
await impl.syncLocalPathToRemote(localPath, remotePath),
|
||||
};
|
||||
}
|
||||
|
||||
class OpenShellSandboxBackendImpl {
|
||||
private ensurePromise: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly params: {
|
||||
createParams: CreateSandboxBackendParams;
|
||||
execContext: OpenShellExecContext;
|
||||
remoteWorkspaceDir: string;
|
||||
remoteAgentWorkspaceDir: string;
|
||||
},
|
||||
) {}
|
||||
|
||||
asHandle(): OpenShellSandboxBackend {
|
||||
const self = this;
|
||||
return {
|
||||
id: "openshell",
|
||||
runtimeId: this.params.execContext.sandboxName,
|
||||
runtimeLabel: this.params.execContext.sandboxName,
|
||||
workdir: this.params.remoteWorkspaceDir,
|
||||
env: this.params.createParams.cfg.docker.env,
|
||||
configLabel: this.params.execContext.config.from,
|
||||
configLabelKind: "Source",
|
||||
remoteWorkspaceDir: this.params.remoteWorkspaceDir,
|
||||
remoteAgentWorkspaceDir: this.params.remoteAgentWorkspaceDir,
|
||||
buildExecSpec: async ({ command, workdir, env, usePty }) => {
|
||||
const pending = await self.prepareExec({ command, workdir, env, usePty });
|
||||
return {
|
||||
argv: pending.argv,
|
||||
env: process.env,
|
||||
stdinMode: "pipe-open",
|
||||
finalizeToken: pending.token,
|
||||
};
|
||||
},
|
||||
finalizeExec: async ({ token }) => {
|
||||
await self.finalizeExec(token as PendingExec | undefined);
|
||||
},
|
||||
runShellCommand: async (command) => await self.runRemoteShellScript(command),
|
||||
createFsBridge: ({ sandbox }) =>
|
||||
createOpenShellFsBridge({
|
||||
sandbox,
|
||||
backend: self.asHandle(),
|
||||
}),
|
||||
runRemoteShellScript: async (command) => await self.runRemoteShellScript(command),
|
||||
syncLocalPathToRemote: async (localPath, remotePath) =>
|
||||
await self.syncLocalPathToRemote(localPath, remotePath),
|
||||
};
|
||||
}
|
||||
|
||||
async prepareExec(params: {
|
||||
command: string;
|
||||
workdir?: string;
|
||||
env: Record<string, string>;
|
||||
usePty: boolean;
|
||||
}): Promise<{ argv: string[]; token: PendingExec }> {
|
||||
await this.ensureSandboxExists();
|
||||
await this.syncWorkspaceToRemote();
|
||||
const sshSession = await createOpenShellSshSession({
|
||||
context: this.params.execContext,
|
||||
});
|
||||
const remoteCommand = buildExecRemoteCommand({
|
||||
command: params.command,
|
||||
workdir: params.workdir ?? this.params.remoteWorkspaceDir,
|
||||
env: params.env,
|
||||
});
|
||||
return {
|
||||
argv: [
|
||||
"ssh",
|
||||
"-F",
|
||||
sshSession.configPath,
|
||||
...(params.usePty
|
||||
? ["-tt", "-o", "RequestTTY=force", "-o", "SetEnv=TERM=xterm-256color"]
|
||||
: ["-T", "-o", "RequestTTY=no"]),
|
||||
sshSession.host,
|
||||
remoteCommand,
|
||||
],
|
||||
token: { sshSession },
|
||||
};
|
||||
}
|
||||
|
||||
async finalizeExec(token?: PendingExec): Promise<void> {
|
||||
try {
|
||||
await this.syncWorkspaceFromRemote();
|
||||
} finally {
|
||||
if (token?.sshSession) {
|
||||
await disposeOpenShellSshSession(token.sshSession);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async runRemoteShellScript(
|
||||
params: SandboxBackendCommandParams,
|
||||
): Promise<SandboxBackendCommandResult> {
|
||||
await this.ensureSandboxExists();
|
||||
const session = await createOpenShellSshSession({
|
||||
context: this.params.execContext,
|
||||
});
|
||||
try {
|
||||
return await runOpenShellSshCommand({
|
||||
session,
|
||||
remoteCommand: buildRemoteCommand([
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
params.script,
|
||||
"openclaw-openshell-fs",
|
||||
...(params.args ?? []),
|
||||
]),
|
||||
stdin: params.stdin,
|
||||
allowFailure: params.allowFailure,
|
||||
signal: params.signal,
|
||||
});
|
||||
} finally {
|
||||
await disposeOpenShellSshSession(session);
|
||||
}
|
||||
}
|
||||
|
||||
async syncLocalPathToRemote(localPath: string, remotePath: string): Promise<void> {
|
||||
await this.ensureSandboxExists();
|
||||
const stats = await fs.lstat(localPath).catch(() => null);
|
||||
if (!stats) {
|
||||
await this.runRemoteShellScript({
|
||||
script: 'rm -rf -- "$1"',
|
||||
args: [remotePath],
|
||||
allowFailure: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (stats.isDirectory()) {
|
||||
await this.runRemoteShellScript({
|
||||
script: 'mkdir -p -- "$1"',
|
||||
args: [remotePath],
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.runRemoteShellScript({
|
||||
script: 'mkdir -p -- "$(dirname -- "$1")"',
|
||||
args: [remotePath],
|
||||
});
|
||||
const result = await runOpenShellCli({
|
||||
context: this.params.execContext,
|
||||
args: [
|
||||
"sandbox",
|
||||
"upload",
|
||||
"--no-git-ignore",
|
||||
this.params.execContext.sandboxName,
|
||||
localPath,
|
||||
path.posix.dirname(remotePath),
|
||||
],
|
||||
cwd: this.params.createParams.workspaceDir,
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr.trim() || "openshell sandbox upload failed");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureSandboxExists(): Promise<void> {
|
||||
if (this.ensurePromise) {
|
||||
return await this.ensurePromise;
|
||||
}
|
||||
this.ensurePromise = this.ensureSandboxExistsInner();
|
||||
try {
|
||||
await this.ensurePromise;
|
||||
} catch (error) {
|
||||
this.ensurePromise = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureSandboxExistsInner(): Promise<void> {
|
||||
const getResult = await runOpenShellCli({
|
||||
context: this.params.execContext,
|
||||
args: ["sandbox", "get", this.params.execContext.sandboxName],
|
||||
cwd: this.params.createParams.workspaceDir,
|
||||
});
|
||||
if (getResult.code === 0) {
|
||||
return;
|
||||
}
|
||||
const createArgs = [
|
||||
"sandbox",
|
||||
"create",
|
||||
"--name",
|
||||
this.params.execContext.sandboxName,
|
||||
"--from",
|
||||
this.params.execContext.config.from,
|
||||
...(this.params.execContext.config.policy
|
||||
? ["--policy", this.params.execContext.config.policy]
|
||||
: []),
|
||||
...(this.params.execContext.config.gpu ? ["--gpu"] : []),
|
||||
...(this.params.execContext.config.autoProviders
|
||||
? ["--auto-providers"]
|
||||
: ["--no-auto-providers"]),
|
||||
...this.params.execContext.config.providers.flatMap((provider) => ["--provider", provider]),
|
||||
"--",
|
||||
"true",
|
||||
];
|
||||
const createResult = await runOpenShellCli({
|
||||
context: this.params.execContext,
|
||||
args: createArgs,
|
||||
cwd: this.params.createParams.workspaceDir,
|
||||
timeoutMs: Math.max(this.params.execContext.config.timeoutMs, 300_000),
|
||||
});
|
||||
if (createResult.code !== 0) {
|
||||
throw new Error(createResult.stderr.trim() || "openshell sandbox create failed");
|
||||
}
|
||||
}
|
||||
|
||||
private async syncWorkspaceToRemote(): Promise<void> {
|
||||
await this.runRemoteShellScript({
|
||||
script: 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +',
|
||||
args: [this.params.remoteWorkspaceDir],
|
||||
});
|
||||
await this.uploadPathToRemote(
|
||||
this.params.createParams.workspaceDir,
|
||||
this.params.remoteWorkspaceDir,
|
||||
);
|
||||
|
||||
if (
|
||||
this.params.createParams.cfg.workspaceAccess !== "none" &&
|
||||
path.resolve(this.params.createParams.agentWorkspaceDir) !==
|
||||
path.resolve(this.params.createParams.workspaceDir)
|
||||
) {
|
||||
await this.runRemoteShellScript({
|
||||
script: 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +',
|
||||
args: [this.params.remoteAgentWorkspaceDir],
|
||||
});
|
||||
await this.uploadPathToRemote(
|
||||
this.params.createParams.agentWorkspaceDir,
|
||||
this.params.remoteAgentWorkspaceDir,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async syncWorkspaceFromRemote(): Promise<void> {
|
||||
const tmpDir = await fs.mkdtemp(
|
||||
path.join(resolveOpenShellTmpRoot(), "openclaw-openshell-sync-"),
|
||||
);
|
||||
try {
|
||||
const result = await runOpenShellCli({
|
||||
context: this.params.execContext,
|
||||
args: [
|
||||
"sandbox",
|
||||
"download",
|
||||
this.params.execContext.sandboxName,
|
||||
this.params.remoteWorkspaceDir,
|
||||
tmpDir,
|
||||
],
|
||||
cwd: this.params.createParams.workspaceDir,
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr.trim() || "openshell sandbox download failed");
|
||||
}
|
||||
await replaceDirectoryContents({
|
||||
sourceDir: tmpDir,
|
||||
targetDir: this.params.createParams.workspaceDir,
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadPathToRemote(localPath: string, remotePath: string): Promise<void> {
|
||||
const result = await runOpenShellCli({
|
||||
context: this.params.execContext,
|
||||
args: [
|
||||
"sandbox",
|
||||
"upload",
|
||||
"--no-git-ignore",
|
||||
this.params.execContext.sandboxName,
|
||||
localPath,
|
||||
remotePath,
|
||||
],
|
||||
cwd: this.params.createParams.workspaceDir,
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr.trim() || "openshell sandbox upload failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOpenShellPluginConfigFromConfig(
|
||||
config: OpenClawConfig,
|
||||
fallback: ResolvedOpenShellPluginConfig,
|
||||
): ResolvedOpenShellPluginConfig {
|
||||
const pluginConfig = config.plugins?.entries?.openshell?.config;
|
||||
if (!pluginConfig) {
|
||||
return fallback;
|
||||
}
|
||||
return resolveOpenShellPluginConfig(pluginConfig);
|
||||
}
|
||||
|
||||
function buildOpenShellSandboxName(scopeKey: string): string {
|
||||
const trimmed = scopeKey.trim() || "session";
|
||||
const safe = trimmed
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 32);
|
||||
const hash = Array.from(trimmed).reduce(
|
||||
(acc, char) => ((acc * 33) ^ char.charCodeAt(0)) >>> 0,
|
||||
5381,
|
||||
);
|
||||
return `openclaw-${safe || "session"}-${hash.toString(16).slice(0, 8)}`;
|
||||
}
|
||||
|
||||
function resolveOpenShellTmpRoot(): string {
|
||||
return path.resolve(resolvePreferredOpenClawTmpDir() ?? os.tmpdir());
|
||||
}
|
||||
Reference in New Issue
Block a user