@openclaw/openshell-sandbox 2026.5.12-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +884 -0
- package/openclaw.plugin.json +118 -0
- package/package.json +52 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
3
|
+
import { buildExecRemoteCommand, createRemoteShellSandboxFsBridge, createSshSandboxSessionFromConfigText, createWritableRenameTargetResolver, disposeSshSandboxSession, registerSandboxBackend, resolvePreferredOpenClawTmpDir, runPluginCommandWithTimeout, runSshSandboxCommand, sanitizeEnvVars, shellEscape, withTempWorkspace } from "openclaw/plugin-sdk/sandbox";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
7
|
+
import { loadJsonFile } from "openclaw/plugin-sdk/json-store";
|
|
8
|
+
import { buildPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
|
9
|
+
import { formatPluginConfigIssue, mapPluginConfigIssues } from "openclaw/plugin-sdk/extension-shared";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { root } from "openclaw/plugin-sdk/file-access-runtime";
|
|
12
|
+
import { isPathInside, movePathWithCopyFallback } from "openclaw/plugin-sdk/security-runtime";
|
|
13
|
+
//#region extensions/openshell/src/cli.ts
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
let cachedBundledOpenShellCommand;
|
|
16
|
+
function resolveBundledOpenShellCommand() {
|
|
17
|
+
if (cachedBundledOpenShellCommand !== void 0) return cachedBundledOpenShellCommand;
|
|
18
|
+
try {
|
|
19
|
+
const packageJsonPath = require.resolve("openshell/package.json");
|
|
20
|
+
const packageJson = loadJsonFile(packageJsonPath);
|
|
21
|
+
const relativeBin = typeof packageJson?.bin === "string" ? packageJson.bin : packageJson?.bin?.openshell;
|
|
22
|
+
cachedBundledOpenShellCommand = relativeBin ? path.resolve(path.dirname(packageJsonPath), relativeBin) : null;
|
|
23
|
+
} catch {
|
|
24
|
+
cachedBundledOpenShellCommand = null;
|
|
25
|
+
}
|
|
26
|
+
return cachedBundledOpenShellCommand;
|
|
27
|
+
}
|
|
28
|
+
function resolveOpenShellCommand(command) {
|
|
29
|
+
if (command !== "openshell") return command;
|
|
30
|
+
return resolveBundledOpenShellCommand() ?? command;
|
|
31
|
+
}
|
|
32
|
+
function buildOpenShellBaseArgv(config) {
|
|
33
|
+
const argv = [resolveOpenShellCommand(config.command)];
|
|
34
|
+
if (config.gateway) argv.push("--gateway", config.gateway);
|
|
35
|
+
if (config.gatewayEndpoint) argv.push("--gateway-endpoint", config.gatewayEndpoint);
|
|
36
|
+
return argv;
|
|
37
|
+
}
|
|
38
|
+
function buildRemoteCommand(argv) {
|
|
39
|
+
return argv.map((entry) => shellEscape(entry)).join(" ");
|
|
40
|
+
}
|
|
41
|
+
async function runOpenShellCli(params) {
|
|
42
|
+
return await runPluginCommandWithTimeout({
|
|
43
|
+
argv: [...buildOpenShellBaseArgv(params.context.config), ...params.args],
|
|
44
|
+
cwd: params.cwd,
|
|
45
|
+
timeoutMs: params.timeoutMs ?? params.context.timeoutMs ?? params.context.config.timeoutMs,
|
|
46
|
+
env: process.env
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
async function createOpenShellSshSession(params) {
|
|
50
|
+
const result = await runOpenShellCli({
|
|
51
|
+
context: params.context,
|
|
52
|
+
args: [
|
|
53
|
+
"sandbox",
|
|
54
|
+
"ssh-config",
|
|
55
|
+
params.context.sandboxName
|
|
56
|
+
]
|
|
57
|
+
});
|
|
58
|
+
if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox ssh-config failed");
|
|
59
|
+
return await createSshSandboxSessionFromConfigText({ configText: result.stdout });
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region extensions/openshell/src/config.ts
|
|
63
|
+
const DEFAULT_COMMAND = "openshell";
|
|
64
|
+
const DEFAULT_MODE = "mirror";
|
|
65
|
+
const DEFAULT_SOURCE = "openclaw";
|
|
66
|
+
const DEFAULT_REMOTE_WORKSPACE_DIR = "/sandbox";
|
|
67
|
+
const DEFAULT_REMOTE_AGENT_WORKSPACE_DIR = "/agent";
|
|
68
|
+
const DEFAULT_TIMEOUT_MS = 12e4;
|
|
69
|
+
const OPEN_SHELL_MANAGED_REMOTE_ROOTS = [DEFAULT_REMOTE_WORKSPACE_DIR, DEFAULT_REMOTE_AGENT_WORKSPACE_DIR];
|
|
70
|
+
function normalizeProviders(value) {
|
|
71
|
+
const seen = /* @__PURE__ */ new Set();
|
|
72
|
+
const providers = [];
|
|
73
|
+
for (const entry of value ?? []) {
|
|
74
|
+
const normalized = entry.trim();
|
|
75
|
+
if (seen.has(normalized)) continue;
|
|
76
|
+
seen.add(normalized);
|
|
77
|
+
providers.push(normalized);
|
|
78
|
+
}
|
|
79
|
+
return providers;
|
|
80
|
+
}
|
|
81
|
+
const nonEmptyTrimmedString = (message) => z.string({ error: message }).trim().min(1, { error: message });
|
|
82
|
+
const OpenShellPluginConfigSchema = z.strictObject({
|
|
83
|
+
mode: z.enum(["mirror", "remote"], { error: "mode must be one of mirror, remote" }).optional(),
|
|
84
|
+
command: nonEmptyTrimmedString("command must be a non-empty string").optional(),
|
|
85
|
+
gateway: nonEmptyTrimmedString("gateway must be a non-empty string").optional(),
|
|
86
|
+
gatewayEndpoint: nonEmptyTrimmedString("gatewayEndpoint must be a non-empty string").optional(),
|
|
87
|
+
from: nonEmptyTrimmedString("from must be a non-empty string").optional(),
|
|
88
|
+
policy: nonEmptyTrimmedString("policy must be a non-empty string").optional(),
|
|
89
|
+
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(),
|
|
90
|
+
gpu: z.boolean({ error: "gpu must be a boolean" }).optional(),
|
|
91
|
+
autoProviders: z.boolean({ error: "autoProviders must be a boolean" }).optional(),
|
|
92
|
+
remoteWorkspaceDir: nonEmptyTrimmedString("remoteWorkspaceDir must be a non-empty string").optional(),
|
|
93
|
+
remoteAgentWorkspaceDir: nonEmptyTrimmedString("remoteAgentWorkspaceDir must be a non-empty string").optional(),
|
|
94
|
+
timeoutSeconds: z.number({ error: "timeoutSeconds must be a number >= 1" }).min(1, { error: "timeoutSeconds must be a number >= 1" }).optional()
|
|
95
|
+
});
|
|
96
|
+
function isManagedOpenShellRemotePath(value) {
|
|
97
|
+
return OPEN_SHELL_MANAGED_REMOTE_ROOTS.some((root) => value === root || value.startsWith(`${root}/`));
|
|
98
|
+
}
|
|
99
|
+
function normalizeOpenShellRemotePath(value, fallback, fieldName = "remote path") {
|
|
100
|
+
const candidate = value ?? fallback;
|
|
101
|
+
const normalized = path.posix.normalize(candidate.trim() || fallback);
|
|
102
|
+
if (!normalized.startsWith("/")) throw new Error(`OpenShell ${fieldName} must be absolute: ${candidate}`);
|
|
103
|
+
if (!isManagedOpenShellRemotePath(normalized)) throw new Error(`OpenShell ${fieldName} must stay under ${OPEN_SHELL_MANAGED_REMOTE_ROOTS.join(" or ")}: ${candidate}`);
|
|
104
|
+
return normalized;
|
|
105
|
+
}
|
|
106
|
+
function createOpenShellPluginConfigSchema() {
|
|
107
|
+
return buildPluginConfigSchema(OpenShellPluginConfigSchema, { safeParse(value) {
|
|
108
|
+
if (value === void 0) return {
|
|
109
|
+
success: true,
|
|
110
|
+
data: void 0
|
|
111
|
+
};
|
|
112
|
+
const parsed = OpenShellPluginConfigSchema.safeParse(value);
|
|
113
|
+
if (parsed.success) return {
|
|
114
|
+
success: true,
|
|
115
|
+
data: parsed.data
|
|
116
|
+
};
|
|
117
|
+
return {
|
|
118
|
+
success: false,
|
|
119
|
+
error: { issues: mapPluginConfigIssues(parsed.error.issues) }
|
|
120
|
+
};
|
|
121
|
+
} });
|
|
122
|
+
}
|
|
123
|
+
function resolveOpenShellPluginConfig(value) {
|
|
124
|
+
if (value === void 0) return {
|
|
125
|
+
mode: DEFAULT_MODE,
|
|
126
|
+
command: DEFAULT_COMMAND,
|
|
127
|
+
gateway: void 0,
|
|
128
|
+
gatewayEndpoint: void 0,
|
|
129
|
+
from: DEFAULT_SOURCE,
|
|
130
|
+
policy: void 0,
|
|
131
|
+
providers: [],
|
|
132
|
+
gpu: false,
|
|
133
|
+
autoProviders: true,
|
|
134
|
+
remoteWorkspaceDir: DEFAULT_REMOTE_WORKSPACE_DIR,
|
|
135
|
+
remoteAgentWorkspaceDir: DEFAULT_REMOTE_AGENT_WORKSPACE_DIR,
|
|
136
|
+
timeoutMs: DEFAULT_TIMEOUT_MS
|
|
137
|
+
};
|
|
138
|
+
const parsed = OpenShellPluginConfigSchema.safeParse(value);
|
|
139
|
+
if (!parsed.success) {
|
|
140
|
+
const message = formatPluginConfigIssue(parsed.error.issues[0]);
|
|
141
|
+
throw new Error(`Invalid openshell plugin config: ${message}`);
|
|
142
|
+
}
|
|
143
|
+
const cfg = parsed.data;
|
|
144
|
+
return {
|
|
145
|
+
mode: cfg.mode ?? DEFAULT_MODE,
|
|
146
|
+
command: cfg.command ?? DEFAULT_COMMAND,
|
|
147
|
+
gateway: cfg.gateway,
|
|
148
|
+
gatewayEndpoint: cfg.gatewayEndpoint,
|
|
149
|
+
from: cfg.from ?? DEFAULT_SOURCE,
|
|
150
|
+
policy: cfg.policy,
|
|
151
|
+
providers: normalizeProviders(cfg.providers),
|
|
152
|
+
gpu: cfg.gpu ?? false,
|
|
153
|
+
autoProviders: cfg.autoProviders ?? true,
|
|
154
|
+
remoteWorkspaceDir: normalizeOpenShellRemotePath(cfg.remoteWorkspaceDir, DEFAULT_REMOTE_WORKSPACE_DIR, "remoteWorkspaceDir"),
|
|
155
|
+
remoteAgentWorkspaceDir: normalizeOpenShellRemotePath(cfg.remoteAgentWorkspaceDir, DEFAULT_REMOTE_AGENT_WORKSPACE_DIR, "remoteAgentWorkspaceDir"),
|
|
156
|
+
timeoutMs: typeof cfg.timeoutSeconds === "number" ? Math.floor(cfg.timeoutSeconds * 1e3) : DEFAULT_TIMEOUT_MS
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
//#endregion
|
|
160
|
+
//#region extensions/openshell/src/mirror.ts
|
|
161
|
+
const DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS = [
|
|
162
|
+
"hooks",
|
|
163
|
+
"git-hooks",
|
|
164
|
+
".git"
|
|
165
|
+
];
|
|
166
|
+
const COPY_TREE_FS_CONCURRENCY = 16;
|
|
167
|
+
function createExcludeMatcher(excludeDirs) {
|
|
168
|
+
const excluded = new Set((excludeDirs ?? []).map((d) => normalizeLowercaseStringOrEmpty(d)));
|
|
169
|
+
return (name) => excluded.has(normalizeLowercaseStringOrEmpty(name));
|
|
170
|
+
}
|
|
171
|
+
function createConcurrencyLimiter(limit) {
|
|
172
|
+
let active = 0;
|
|
173
|
+
const queue = [];
|
|
174
|
+
const release = () => {
|
|
175
|
+
active -= 1;
|
|
176
|
+
queue.shift()?.();
|
|
177
|
+
};
|
|
178
|
+
return async (task) => {
|
|
179
|
+
if (active >= limit) await new Promise((resolve) => {
|
|
180
|
+
queue.push(resolve);
|
|
181
|
+
});
|
|
182
|
+
active += 1;
|
|
183
|
+
try {
|
|
184
|
+
return await task();
|
|
185
|
+
} finally {
|
|
186
|
+
release();
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const runLimitedFs = createConcurrencyLimiter(COPY_TREE_FS_CONCURRENCY);
|
|
191
|
+
async function lstatIfExists(targetPath) {
|
|
192
|
+
return await runLimitedFs(async () => await fs.lstat(targetPath)).catch(() => null);
|
|
193
|
+
}
|
|
194
|
+
async function copyTreeWithoutSymlinks(params) {
|
|
195
|
+
const stats = await runLimitedFs(async () => await fs.lstat(params.sourcePath));
|
|
196
|
+
if (stats.isSymbolicLink()) return;
|
|
197
|
+
const targetStats = await lstatIfExists(params.targetPath);
|
|
198
|
+
if (params.preserveTargetSymlinks && targetStats?.isSymbolicLink()) return;
|
|
199
|
+
if (stats.isDirectory()) {
|
|
200
|
+
await runLimitedFs(async () => await fs.mkdir(params.targetPath, { recursive: true }));
|
|
201
|
+
const entries = await runLimitedFs(async () => await fs.readdir(params.sourcePath));
|
|
202
|
+
await Promise.all(entries.map(async (entry) => {
|
|
203
|
+
await copyTreeWithoutSymlinks({
|
|
204
|
+
sourcePath: path.join(params.sourcePath, entry),
|
|
205
|
+
targetPath: path.join(params.targetPath, entry),
|
|
206
|
+
preserveTargetSymlinks: params.preserveTargetSymlinks
|
|
207
|
+
});
|
|
208
|
+
}));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (stats.isFile()) {
|
|
212
|
+
await runLimitedFs(async () => await fs.mkdir(path.dirname(params.targetPath), { recursive: true }));
|
|
213
|
+
await runLimitedFs(async () => await fs.copyFile(params.sourcePath, params.targetPath));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async function replaceDirectoryContents(params) {
|
|
217
|
+
const isExcluded = createExcludeMatcher(params.excludeDirs);
|
|
218
|
+
await fs.mkdir(params.targetDir, { recursive: true });
|
|
219
|
+
const existing = await fs.readdir(params.targetDir);
|
|
220
|
+
await Promise.all(existing.filter((entry) => !isExcluded(entry)).map(async (entry) => {
|
|
221
|
+
const targetPath = path.join(params.targetDir, entry);
|
|
222
|
+
if ((await lstatIfExists(targetPath))?.isSymbolicLink()) return;
|
|
223
|
+
await runLimitedFs(async () => await fs.rm(targetPath, {
|
|
224
|
+
recursive: true,
|
|
225
|
+
force: true
|
|
226
|
+
}));
|
|
227
|
+
}));
|
|
228
|
+
const sourceEntries = await fs.readdir(params.sourceDir);
|
|
229
|
+
for (const entry of sourceEntries) {
|
|
230
|
+
if (isExcluded(entry)) continue;
|
|
231
|
+
await copyTreeWithoutSymlinks({
|
|
232
|
+
sourcePath: path.join(params.sourceDir, entry),
|
|
233
|
+
targetPath: path.join(params.targetDir, entry),
|
|
234
|
+
preserveTargetSymlinks: true
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
async function stageDirectoryContents(params) {
|
|
239
|
+
const isExcluded = createExcludeMatcher(params.excludeDirs);
|
|
240
|
+
await fs.mkdir(params.targetDir, { recursive: true });
|
|
241
|
+
const sourceEntries = await fs.readdir(params.sourceDir);
|
|
242
|
+
for (const entry of sourceEntries) {
|
|
243
|
+
if (isExcluded(entry)) continue;
|
|
244
|
+
await copyTreeWithoutSymlinks({
|
|
245
|
+
sourcePath: path.join(params.sourceDir, entry),
|
|
246
|
+
targetPath: path.join(params.targetDir, entry)
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
//#endregion
|
|
251
|
+
//#region extensions/openshell/src/fs-bridge.ts
|
|
252
|
+
function createOpenShellFsBridge(params) {
|
|
253
|
+
return new OpenShellFsBridge(params.sandbox, params.backend);
|
|
254
|
+
}
|
|
255
|
+
var OpenShellFsBridge = class {
|
|
256
|
+
constructor(sandbox, backend) {
|
|
257
|
+
this.sandbox = sandbox;
|
|
258
|
+
this.backend = backend;
|
|
259
|
+
this.resolveRenameTargets = createWritableRenameTargetResolver((target) => this.resolveTarget(target), (target, action) => this.ensureWritable(target, action));
|
|
260
|
+
}
|
|
261
|
+
resolvePath(params) {
|
|
262
|
+
const target = this.resolveTarget(params);
|
|
263
|
+
return {
|
|
264
|
+
hostPath: target.hostPath,
|
|
265
|
+
relativePath: target.relativePath,
|
|
266
|
+
containerPath: target.containerPath
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
async readFile(params) {
|
|
270
|
+
const target = this.resolveTarget(params);
|
|
271
|
+
const hostPath = this.requireHostPath(target);
|
|
272
|
+
let opened;
|
|
273
|
+
try {
|
|
274
|
+
await assertLocalPathSafety({
|
|
275
|
+
target,
|
|
276
|
+
root: target.mountHostRoot,
|
|
277
|
+
allowMissingLeaf: false,
|
|
278
|
+
allowFinalSymlinkForUnlink: false
|
|
279
|
+
});
|
|
280
|
+
opened = await (await root(target.mountHostRoot)).open(path.relative(target.mountHostRoot, hostPath), { hardlinks: "reject" });
|
|
281
|
+
try {
|
|
282
|
+
return await opened.handle.readFile();
|
|
283
|
+
} finally {
|
|
284
|
+
await opened.handle.close();
|
|
285
|
+
}
|
|
286
|
+
} catch (err) {
|
|
287
|
+
throw new Error(`Sandbox boundary checks failed; cannot read files: ${target.containerPath}`, { cause: err });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async writeFile(params) {
|
|
291
|
+
const target = this.resolveTarget(params);
|
|
292
|
+
const hostPath = this.requireHostPath(target);
|
|
293
|
+
this.ensureWritable(target, "write files");
|
|
294
|
+
await assertLocalPathSafety({
|
|
295
|
+
target,
|
|
296
|
+
root: target.mountHostRoot,
|
|
297
|
+
allowMissingLeaf: true,
|
|
298
|
+
allowFinalSymlinkForUnlink: false
|
|
299
|
+
});
|
|
300
|
+
const buffer = Buffer.isBuffer(params.data) ? params.data : Buffer.from(params.data, params.encoding ?? "utf8");
|
|
301
|
+
await (await root(target.mountHostRoot)).write(path.relative(target.mountHostRoot, hostPath), buffer, { mkdir: params.mkdir });
|
|
302
|
+
await this.backend.syncLocalPathToRemote(hostPath, target.containerPath);
|
|
303
|
+
}
|
|
304
|
+
async mkdirp(params) {
|
|
305
|
+
const target = this.resolveTarget(params);
|
|
306
|
+
const hostPath = this.requireHostPath(target);
|
|
307
|
+
this.ensureWritable(target, "create directories");
|
|
308
|
+
await assertLocalPathSafety({
|
|
309
|
+
target,
|
|
310
|
+
root: target.mountHostRoot,
|
|
311
|
+
allowMissingLeaf: true,
|
|
312
|
+
allowFinalSymlinkForUnlink: false
|
|
313
|
+
});
|
|
314
|
+
await fs.mkdir(hostPath, { recursive: true });
|
|
315
|
+
await this.backend.runRemoteShellScript({
|
|
316
|
+
script: "mkdir -p -- \"$1\"",
|
|
317
|
+
args: [target.containerPath],
|
|
318
|
+
signal: params.signal
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
async remove(params) {
|
|
322
|
+
const target = this.resolveTarget(params);
|
|
323
|
+
const hostPath = this.requireHostPath(target);
|
|
324
|
+
this.ensureWritable(target, "remove files");
|
|
325
|
+
await assertLocalPathSafety({
|
|
326
|
+
target,
|
|
327
|
+
root: target.mountHostRoot,
|
|
328
|
+
allowMissingLeaf: params.force !== false,
|
|
329
|
+
allowFinalSymlinkForUnlink: true
|
|
330
|
+
});
|
|
331
|
+
await fs.rm(hostPath, {
|
|
332
|
+
recursive: params.recursive ?? false,
|
|
333
|
+
force: params.force !== false
|
|
334
|
+
});
|
|
335
|
+
await this.backend.runRemoteShellScript({
|
|
336
|
+
script: params.recursive ? "rm -rf -- \"$1\"" : "if [ -d \"$1\" ] && [ ! -L \"$1\" ]; then rmdir -- \"$1\"; elif [ -e \"$1\" ] || [ -L \"$1\" ]; then rm -f -- \"$1\"; fi",
|
|
337
|
+
args: [target.containerPath],
|
|
338
|
+
signal: params.signal,
|
|
339
|
+
allowFailure: params.force !== false
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
async rename(params) {
|
|
343
|
+
const { from, to } = this.resolveRenameTargets(params);
|
|
344
|
+
const fromHostPath = this.requireHostPath(from);
|
|
345
|
+
const toHostPath = this.requireHostPath(to);
|
|
346
|
+
await assertLocalPathSafety({
|
|
347
|
+
target: from,
|
|
348
|
+
root: from.mountHostRoot,
|
|
349
|
+
allowMissingLeaf: false,
|
|
350
|
+
allowFinalSymlinkForUnlink: true
|
|
351
|
+
});
|
|
352
|
+
await assertLocalPathSafety({
|
|
353
|
+
target: to,
|
|
354
|
+
root: to.mountHostRoot,
|
|
355
|
+
allowMissingLeaf: true,
|
|
356
|
+
allowFinalSymlinkForUnlink: false
|
|
357
|
+
});
|
|
358
|
+
await fs.mkdir(path.dirname(toHostPath), { recursive: true });
|
|
359
|
+
await movePathWithCopyFallback({
|
|
360
|
+
from: fromHostPath,
|
|
361
|
+
to: toHostPath
|
|
362
|
+
});
|
|
363
|
+
await this.backend.runRemoteShellScript({
|
|
364
|
+
script: "mkdir -p -- \"$(dirname -- \"$2\")\" && mv -- \"$1\" \"$2\"",
|
|
365
|
+
args: [from.containerPath, to.containerPath],
|
|
366
|
+
signal: params.signal
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
async stat(params) {
|
|
370
|
+
const target = this.resolveTarget(params);
|
|
371
|
+
const hostPath = this.requireHostPath(target);
|
|
372
|
+
const stats = await fs.lstat(hostPath).catch(() => null);
|
|
373
|
+
if (!stats) return null;
|
|
374
|
+
await assertLocalPathSafety({
|
|
375
|
+
target,
|
|
376
|
+
root: target.mountHostRoot,
|
|
377
|
+
allowMissingLeaf: false,
|
|
378
|
+
allowFinalSymlinkForUnlink: false
|
|
379
|
+
});
|
|
380
|
+
return {
|
|
381
|
+
type: stats.isDirectory() ? "directory" : stats.isFile() ? "file" : "other",
|
|
382
|
+
size: stats.size,
|
|
383
|
+
mtimeMs: stats.mtimeMs
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
ensureWritable(target, action) {
|
|
387
|
+
if (this.sandbox.workspaceAccess !== "rw" || !target.writable) throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`);
|
|
388
|
+
}
|
|
389
|
+
requireHostPath(target) {
|
|
390
|
+
if (!target.hostPath) throw new Error(`OpenShell mirror bridge requires a local host path: ${target.containerPath}`);
|
|
391
|
+
return target.hostPath;
|
|
392
|
+
}
|
|
393
|
+
resolveTarget(params) {
|
|
394
|
+
const workspaceRoot = path.resolve(this.sandbox.workspaceDir);
|
|
395
|
+
const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir);
|
|
396
|
+
const hasAgentMount = this.sandbox.workspaceAccess !== "none" && workspaceRoot !== agentRoot;
|
|
397
|
+
const agentContainerRoot = (this.backend.remoteAgentWorkspaceDir || "/agent").replace(/\\/g, "/");
|
|
398
|
+
const workspaceContainerRoot = this.sandbox.containerWorkdir.replace(/\\/g, "/");
|
|
399
|
+
const input = params.filePath.trim();
|
|
400
|
+
if (input.startsWith(`${workspaceContainerRoot}/`) || input === workspaceContainerRoot) {
|
|
401
|
+
const relative = path.posix.relative(workspaceContainerRoot, input) || "";
|
|
402
|
+
return {
|
|
403
|
+
hostPath: relative ? path.resolve(workspaceRoot, ...relative.split("/")) : workspaceRoot,
|
|
404
|
+
relativePath: relative,
|
|
405
|
+
containerPath: relative ? path.posix.join(workspaceContainerRoot, relative) : workspaceContainerRoot,
|
|
406
|
+
mountHostRoot: workspaceRoot,
|
|
407
|
+
writable: this.sandbox.workspaceAccess === "rw",
|
|
408
|
+
source: "workspace"
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
if (hasAgentMount && (input.startsWith(`${agentContainerRoot}/`) || input === agentContainerRoot)) {
|
|
412
|
+
const relative = path.posix.relative(agentContainerRoot, input) || "";
|
|
413
|
+
return {
|
|
414
|
+
hostPath: relative ? path.resolve(agentRoot, ...relative.split("/")) : agentRoot,
|
|
415
|
+
relativePath: relative ? agentContainerRoot + "/" + relative : agentContainerRoot,
|
|
416
|
+
containerPath: relative ? path.posix.join(agentContainerRoot, relative) : agentContainerRoot,
|
|
417
|
+
mountHostRoot: agentRoot,
|
|
418
|
+
writable: this.sandbox.workspaceAccess === "rw",
|
|
419
|
+
source: "agent"
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
const cwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot;
|
|
423
|
+
const hostPath = path.isAbsolute(input) ? path.resolve(input) : path.resolve(cwd, input);
|
|
424
|
+
if (isPathInside(workspaceRoot, hostPath)) {
|
|
425
|
+
const relative = path.relative(workspaceRoot, hostPath).split(path.sep).join(path.posix.sep);
|
|
426
|
+
return {
|
|
427
|
+
hostPath,
|
|
428
|
+
relativePath: relative,
|
|
429
|
+
containerPath: relative ? path.posix.join(workspaceContainerRoot, relative) : workspaceContainerRoot,
|
|
430
|
+
mountHostRoot: workspaceRoot,
|
|
431
|
+
writable: this.sandbox.workspaceAccess === "rw",
|
|
432
|
+
source: "workspace"
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
if (hasAgentMount && isPathInside(agentRoot, hostPath)) {
|
|
436
|
+
const relative = path.relative(agentRoot, hostPath).split(path.sep).join(path.posix.sep);
|
|
437
|
+
return {
|
|
438
|
+
hostPath,
|
|
439
|
+
relativePath: relative ? `${agentContainerRoot}/${relative}` : agentContainerRoot,
|
|
440
|
+
containerPath: relative ? path.posix.join(agentContainerRoot, relative) : agentContainerRoot,
|
|
441
|
+
mountHostRoot: agentRoot,
|
|
442
|
+
writable: this.sandbox.workspaceAccess === "rw",
|
|
443
|
+
source: "agent"
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
throw new Error(`Path escapes sandbox root (${workspaceRoot}): ${params.filePath}`);
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
async function assertLocalPathSafety(params) {
|
|
450
|
+
if (!params.target.hostPath) throw new Error(`Missing local host path for ${params.target.containerPath}`);
|
|
451
|
+
if (!isPathInside(await fs.realpath(params.root).catch(() => path.resolve(params.root)), await resolveCanonicalCandidate(params.target.hostPath))) throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${params.target.containerPath}`);
|
|
452
|
+
const relative = path.relative(params.root, params.target.hostPath);
|
|
453
|
+
const segments = relative.split(path.sep).filter(Boolean).slice(0, Math.max(0, relative.split(path.sep).filter(Boolean).length));
|
|
454
|
+
let cursor = params.root;
|
|
455
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
456
|
+
cursor = path.join(cursor, segments[index]);
|
|
457
|
+
const stats = await fs.lstat(cursor).catch(() => null);
|
|
458
|
+
if (!stats) {
|
|
459
|
+
if (index === segments.length - 1 && params.allowMissingLeaf) return;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
const isFinal = index === segments.length - 1;
|
|
463
|
+
if (stats.isSymbolicLink() && (!isFinal || !params.allowFinalSymlinkForUnlink)) throw new Error(`Sandbox boundary checks failed: ${params.target.containerPath}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
async function resolveCanonicalCandidate(targetPath) {
|
|
467
|
+
const missing = [];
|
|
468
|
+
let cursor = path.resolve(targetPath);
|
|
469
|
+
while (true) {
|
|
470
|
+
if (await fs.lstat(cursor).then(() => true).catch(() => false)) {
|
|
471
|
+
const canonical = await fs.realpath(cursor).catch(() => cursor);
|
|
472
|
+
return path.resolve(canonical, ...missing);
|
|
473
|
+
}
|
|
474
|
+
const parent = path.dirname(cursor);
|
|
475
|
+
if (parent === cursor) return path.resolve(cursor, ...missing);
|
|
476
|
+
missing.unshift(path.basename(cursor));
|
|
477
|
+
cursor = parent;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
//#endregion
|
|
481
|
+
//#region extensions/openshell/src/backend.ts
|
|
482
|
+
function buildOpenShellSshExecEnv() {
|
|
483
|
+
return sanitizeEnvVars(process.env).allowed;
|
|
484
|
+
}
|
|
485
|
+
function createOpenShellSandboxBackendFactory(params) {
|
|
486
|
+
return async (createParams) => await createOpenShellSandboxBackend({
|
|
487
|
+
...params,
|
|
488
|
+
createParams
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
function createOpenShellSandboxBackendManager(params) {
|
|
492
|
+
return {
|
|
493
|
+
async describeRuntime({ entry, config }) {
|
|
494
|
+
const execContext = {
|
|
495
|
+
config: resolveOpenShellPluginConfigFromConfig(config, params.pluginConfig),
|
|
496
|
+
sandboxName: entry.containerName
|
|
497
|
+
};
|
|
498
|
+
const result = await runOpenShellCli({
|
|
499
|
+
context: execContext,
|
|
500
|
+
args: [
|
|
501
|
+
"sandbox",
|
|
502
|
+
"get",
|
|
503
|
+
entry.containerName
|
|
504
|
+
]
|
|
505
|
+
});
|
|
506
|
+
const configuredSource = execContext.config.from;
|
|
507
|
+
return {
|
|
508
|
+
running: result.code === 0,
|
|
509
|
+
actualConfigLabel: entry.image,
|
|
510
|
+
configLabelMatch: entry.image === configuredSource
|
|
511
|
+
};
|
|
512
|
+
},
|
|
513
|
+
async removeRuntime({ entry }) {
|
|
514
|
+
await runOpenShellCli({
|
|
515
|
+
context: {
|
|
516
|
+
config: params.pluginConfig,
|
|
517
|
+
sandboxName: entry.containerName
|
|
518
|
+
},
|
|
519
|
+
args: [
|
|
520
|
+
"sandbox",
|
|
521
|
+
"delete",
|
|
522
|
+
entry.containerName
|
|
523
|
+
]
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
async function createOpenShellSandboxBackend(params) {
|
|
529
|
+
if ((params.createParams.cfg.docker.binds?.length ?? 0) > 0) throw new Error("OpenShell sandbox backend does not support sandbox.docker.binds.");
|
|
530
|
+
const sandboxName = buildOpenShellSandboxName(params.createParams.scopeKey);
|
|
531
|
+
const execContext = {
|
|
532
|
+
config: params.pluginConfig,
|
|
533
|
+
sandboxName
|
|
534
|
+
};
|
|
535
|
+
const impl = new OpenShellSandboxBackendImpl({
|
|
536
|
+
createParams: params.createParams,
|
|
537
|
+
execContext,
|
|
538
|
+
remoteWorkspaceDir: params.pluginConfig.remoteWorkspaceDir,
|
|
539
|
+
remoteAgentWorkspaceDir: params.pluginConfig.remoteAgentWorkspaceDir
|
|
540
|
+
});
|
|
541
|
+
return {
|
|
542
|
+
id: "openshell",
|
|
543
|
+
runtimeId: sandboxName,
|
|
544
|
+
runtimeLabel: sandboxName,
|
|
545
|
+
workdir: params.pluginConfig.remoteWorkspaceDir,
|
|
546
|
+
env: params.createParams.cfg.docker.env,
|
|
547
|
+
mode: params.pluginConfig.mode,
|
|
548
|
+
configLabel: params.pluginConfig.from,
|
|
549
|
+
configLabelKind: "Source",
|
|
550
|
+
buildExecSpec: async ({ command, workdir, env, usePty }) => {
|
|
551
|
+
const pending = await impl.prepareExec({
|
|
552
|
+
command,
|
|
553
|
+
workdir,
|
|
554
|
+
env,
|
|
555
|
+
usePty
|
|
556
|
+
});
|
|
557
|
+
return {
|
|
558
|
+
argv: pending.argv,
|
|
559
|
+
env: buildOpenShellSshExecEnv(),
|
|
560
|
+
stdinMode: "pipe-open",
|
|
561
|
+
finalizeToken: pending.token
|
|
562
|
+
};
|
|
563
|
+
},
|
|
564
|
+
finalizeExec: async ({ token }) => {
|
|
565
|
+
await impl.finalizeExec(token);
|
|
566
|
+
},
|
|
567
|
+
runShellCommand: async (command) => await impl.runRemoteShellScript(command),
|
|
568
|
+
createFsBridge: ({ sandbox }) => params.pluginConfig.mode === "remote" ? createRemoteShellSandboxFsBridge({
|
|
569
|
+
sandbox,
|
|
570
|
+
runtime: impl.asHandle()
|
|
571
|
+
}) : createOpenShellFsBridge({
|
|
572
|
+
sandbox,
|
|
573
|
+
backend: impl.asHandle()
|
|
574
|
+
}),
|
|
575
|
+
remoteWorkspaceDir: params.pluginConfig.remoteWorkspaceDir,
|
|
576
|
+
remoteAgentWorkspaceDir: params.pluginConfig.remoteAgentWorkspaceDir,
|
|
577
|
+
runRemoteShellScript: async (command) => await impl.runRemoteShellScript(command),
|
|
578
|
+
syncLocalPathToRemote: async (localPath, remotePath) => await impl.syncLocalPathToRemote(localPath, remotePath)
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
var OpenShellSandboxBackendImpl = class {
|
|
582
|
+
constructor(params) {
|
|
583
|
+
this.params = params;
|
|
584
|
+
this.ensurePromise = null;
|
|
585
|
+
this.remoteSeedPending = false;
|
|
586
|
+
}
|
|
587
|
+
asHandle() {
|
|
588
|
+
return {
|
|
589
|
+
id: "openshell",
|
|
590
|
+
runtimeId: this.params.execContext.sandboxName,
|
|
591
|
+
runtimeLabel: this.params.execContext.sandboxName,
|
|
592
|
+
workdir: this.params.remoteWorkspaceDir,
|
|
593
|
+
env: this.params.createParams.cfg.docker.env,
|
|
594
|
+
mode: this.params.execContext.config.mode,
|
|
595
|
+
configLabel: this.params.execContext.config.from,
|
|
596
|
+
configLabelKind: "Source",
|
|
597
|
+
remoteWorkspaceDir: this.params.remoteWorkspaceDir,
|
|
598
|
+
remoteAgentWorkspaceDir: this.params.remoteAgentWorkspaceDir,
|
|
599
|
+
buildExecSpec: async ({ command, workdir, env, usePty }) => {
|
|
600
|
+
const pending = await this.prepareExec({
|
|
601
|
+
command,
|
|
602
|
+
workdir,
|
|
603
|
+
env,
|
|
604
|
+
usePty
|
|
605
|
+
});
|
|
606
|
+
return {
|
|
607
|
+
argv: pending.argv,
|
|
608
|
+
env: buildOpenShellSshExecEnv(),
|
|
609
|
+
stdinMode: "pipe-open",
|
|
610
|
+
finalizeToken: pending.token
|
|
611
|
+
};
|
|
612
|
+
},
|
|
613
|
+
finalizeExec: async ({ token }) => {
|
|
614
|
+
await this.finalizeExec(token);
|
|
615
|
+
},
|
|
616
|
+
runShellCommand: async (command) => await this.runRemoteShellScript(command),
|
|
617
|
+
createFsBridge: ({ sandbox }) => this.params.execContext.config.mode === "remote" ? createRemoteShellSandboxFsBridge({
|
|
618
|
+
sandbox,
|
|
619
|
+
runtime: this.asHandle()
|
|
620
|
+
}) : createOpenShellFsBridge({
|
|
621
|
+
sandbox,
|
|
622
|
+
backend: this.asHandle()
|
|
623
|
+
}),
|
|
624
|
+
runRemoteShellScript: async (command) => await this.runRemoteShellScript(command),
|
|
625
|
+
syncLocalPathToRemote: async (localPath, remotePath) => await this.syncLocalPathToRemote(localPath, remotePath)
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
async prepareExec(params) {
|
|
629
|
+
await this.ensureSandboxExists();
|
|
630
|
+
if (this.params.execContext.config.mode === "mirror") await this.syncWorkspaceToRemote();
|
|
631
|
+
else await this.maybeSeedRemoteWorkspace();
|
|
632
|
+
const sshSession = await createOpenShellSshSession({ context: this.params.execContext });
|
|
633
|
+
const remoteCommand = buildExecRemoteCommand({
|
|
634
|
+
command: params.command,
|
|
635
|
+
workdir: params.workdir ?? this.params.remoteWorkspaceDir,
|
|
636
|
+
env: params.env
|
|
637
|
+
});
|
|
638
|
+
return {
|
|
639
|
+
argv: [
|
|
640
|
+
"ssh",
|
|
641
|
+
"-F",
|
|
642
|
+
sshSession.configPath,
|
|
643
|
+
...params.usePty ? [
|
|
644
|
+
"-tt",
|
|
645
|
+
"-o",
|
|
646
|
+
"RequestTTY=force",
|
|
647
|
+
"-o",
|
|
648
|
+
"SetEnv=TERM=xterm-256color"
|
|
649
|
+
] : [
|
|
650
|
+
"-T",
|
|
651
|
+
"-o",
|
|
652
|
+
"RequestTTY=no"
|
|
653
|
+
],
|
|
654
|
+
sshSession.host,
|
|
655
|
+
remoteCommand
|
|
656
|
+
],
|
|
657
|
+
token: { sshSession }
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
async finalizeExec(token) {
|
|
661
|
+
try {
|
|
662
|
+
if (this.params.execContext.config.mode === "mirror") await this.syncWorkspaceFromRemote();
|
|
663
|
+
} finally {
|
|
664
|
+
if (token?.sshSession) await disposeSshSandboxSession(token.sshSession);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
async runRemoteShellScript(params) {
|
|
668
|
+
await this.ensureSandboxExists();
|
|
669
|
+
await this.maybeSeedRemoteWorkspace();
|
|
670
|
+
return await this.runRemoteShellScriptInternal(params);
|
|
671
|
+
}
|
|
672
|
+
async runRemoteShellScriptInternal(params) {
|
|
673
|
+
const session = await createOpenShellSshSession({ context: this.params.execContext });
|
|
674
|
+
try {
|
|
675
|
+
return await runSshSandboxCommand({
|
|
676
|
+
session,
|
|
677
|
+
remoteCommand: buildRemoteCommand([
|
|
678
|
+
"/bin/sh",
|
|
679
|
+
"-c",
|
|
680
|
+
params.script,
|
|
681
|
+
"openclaw-openshell-fs",
|
|
682
|
+
...params.args ?? []
|
|
683
|
+
]),
|
|
684
|
+
stdin: params.stdin,
|
|
685
|
+
allowFailure: params.allowFailure,
|
|
686
|
+
signal: params.signal
|
|
687
|
+
});
|
|
688
|
+
} finally {
|
|
689
|
+
await disposeSshSandboxSession(session);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
async syncLocalPathToRemote(localPath, remotePath) {
|
|
693
|
+
await this.ensureSandboxExists();
|
|
694
|
+
await this.maybeSeedRemoteWorkspace();
|
|
695
|
+
const stats = await fs.lstat(localPath).catch(() => null);
|
|
696
|
+
if (!stats) {
|
|
697
|
+
await this.runRemoteShellScript({
|
|
698
|
+
script: "rm -rf -- \"$1\"",
|
|
699
|
+
args: [remotePath],
|
|
700
|
+
allowFailure: true
|
|
701
|
+
});
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (stats.isSymbolicLink()) {
|
|
705
|
+
await this.runRemoteShellScript({
|
|
706
|
+
script: "rm -rf -- \"$1\"",
|
|
707
|
+
args: [remotePath],
|
|
708
|
+
allowFailure: true
|
|
709
|
+
});
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (stats.isDirectory()) {
|
|
713
|
+
await this.runRemoteShellScript({
|
|
714
|
+
script: "mkdir -p -- \"$1\"",
|
|
715
|
+
args: [remotePath]
|
|
716
|
+
});
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
await this.runRemoteShellScript({
|
|
720
|
+
script: "mkdir -p -- \"$(dirname -- \"$1\")\"",
|
|
721
|
+
args: [remotePath]
|
|
722
|
+
});
|
|
723
|
+
const result = await runOpenShellCli({
|
|
724
|
+
context: this.params.execContext,
|
|
725
|
+
args: [
|
|
726
|
+
"sandbox",
|
|
727
|
+
"upload",
|
|
728
|
+
"--no-git-ignore",
|
|
729
|
+
this.params.execContext.sandboxName,
|
|
730
|
+
localPath,
|
|
731
|
+
path.posix.dirname(remotePath)
|
|
732
|
+
],
|
|
733
|
+
cwd: this.params.createParams.workspaceDir
|
|
734
|
+
});
|
|
735
|
+
if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox upload failed");
|
|
736
|
+
}
|
|
737
|
+
async ensureSandboxExists() {
|
|
738
|
+
if (this.ensurePromise) return await this.ensurePromise;
|
|
739
|
+
this.ensurePromise = this.ensureSandboxExistsInner();
|
|
740
|
+
try {
|
|
741
|
+
await this.ensurePromise;
|
|
742
|
+
} catch (error) {
|
|
743
|
+
this.ensurePromise = null;
|
|
744
|
+
throw error;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
async ensureSandboxExistsInner() {
|
|
748
|
+
if ((await runOpenShellCli({
|
|
749
|
+
context: this.params.execContext,
|
|
750
|
+
args: [
|
|
751
|
+
"sandbox",
|
|
752
|
+
"get",
|
|
753
|
+
this.params.execContext.sandboxName
|
|
754
|
+
],
|
|
755
|
+
cwd: this.params.createParams.workspaceDir
|
|
756
|
+
})).code === 0) return;
|
|
757
|
+
const createArgs = [
|
|
758
|
+
"sandbox",
|
|
759
|
+
"create",
|
|
760
|
+
"--name",
|
|
761
|
+
this.params.execContext.sandboxName,
|
|
762
|
+
"--from",
|
|
763
|
+
this.params.execContext.config.from,
|
|
764
|
+
...this.params.execContext.config.policy ? ["--policy", this.params.execContext.config.policy] : [],
|
|
765
|
+
...this.params.execContext.config.gpu ? ["--gpu"] : [],
|
|
766
|
+
...this.params.execContext.config.autoProviders ? ["--auto-providers"] : ["--no-auto-providers"],
|
|
767
|
+
...this.params.execContext.config.providers.flatMap((provider) => ["--provider", provider]),
|
|
768
|
+
"--",
|
|
769
|
+
"true"
|
|
770
|
+
];
|
|
771
|
+
const createResult = await runOpenShellCli({
|
|
772
|
+
context: this.params.execContext,
|
|
773
|
+
args: createArgs,
|
|
774
|
+
cwd: this.params.createParams.workspaceDir,
|
|
775
|
+
timeoutMs: Math.max(this.params.execContext.config.timeoutMs, 3e5)
|
|
776
|
+
});
|
|
777
|
+
if (createResult.code !== 0) throw new Error(createResult.stderr.trim() || "openshell sandbox create failed");
|
|
778
|
+
this.remoteSeedPending = true;
|
|
779
|
+
}
|
|
780
|
+
async syncWorkspaceToRemote() {
|
|
781
|
+
await this.runRemoteShellScriptInternal({
|
|
782
|
+
script: "mkdir -p -- \"$1\" && find \"$1\" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +",
|
|
783
|
+
args: [this.params.remoteWorkspaceDir]
|
|
784
|
+
});
|
|
785
|
+
await this.uploadPathToRemote(this.params.createParams.workspaceDir, this.params.remoteWorkspaceDir);
|
|
786
|
+
if (this.params.createParams.cfg.workspaceAccess !== "none" && path.resolve(this.params.createParams.agentWorkspaceDir) !== path.resolve(this.params.createParams.workspaceDir)) {
|
|
787
|
+
await this.runRemoteShellScriptInternal({
|
|
788
|
+
script: "mkdir -p -- \"$1\" && find \"$1\" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +",
|
|
789
|
+
args: [this.params.remoteAgentWorkspaceDir]
|
|
790
|
+
});
|
|
791
|
+
await this.uploadPathToRemote(this.params.createParams.agentWorkspaceDir, this.params.remoteAgentWorkspaceDir);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
async syncWorkspaceFromRemote() {
|
|
795
|
+
await withTempWorkspace({
|
|
796
|
+
rootDir: resolveOpenShellTmpRoot(),
|
|
797
|
+
prefix: "openclaw-openshell-sync-"
|
|
798
|
+
}, async ({ dir: tmpDir }) => {
|
|
799
|
+
const result = await runOpenShellCli({
|
|
800
|
+
context: this.params.execContext,
|
|
801
|
+
args: [
|
|
802
|
+
"sandbox",
|
|
803
|
+
"download",
|
|
804
|
+
this.params.execContext.sandboxName,
|
|
805
|
+
this.params.remoteWorkspaceDir,
|
|
806
|
+
tmpDir
|
|
807
|
+
],
|
|
808
|
+
cwd: this.params.createParams.workspaceDir
|
|
809
|
+
});
|
|
810
|
+
if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox download failed");
|
|
811
|
+
await replaceDirectoryContents({
|
|
812
|
+
sourceDir: tmpDir,
|
|
813
|
+
targetDir: this.params.createParams.workspaceDir,
|
|
814
|
+
excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
async uploadPathToRemote(localPath, remotePath) {
|
|
819
|
+
await withTempWorkspace({
|
|
820
|
+
rootDir: resolveOpenShellTmpRoot(),
|
|
821
|
+
prefix: "openclaw-openshell-upload-"
|
|
822
|
+
}, async ({ dir: tmpDir }) => {
|
|
823
|
+
await stageDirectoryContents({
|
|
824
|
+
sourceDir: localPath,
|
|
825
|
+
targetDir: tmpDir
|
|
826
|
+
});
|
|
827
|
+
const result = await runOpenShellCli({
|
|
828
|
+
context: this.params.execContext,
|
|
829
|
+
args: [
|
|
830
|
+
"sandbox",
|
|
831
|
+
"upload",
|
|
832
|
+
"--no-git-ignore",
|
|
833
|
+
this.params.execContext.sandboxName,
|
|
834
|
+
tmpDir,
|
|
835
|
+
remotePath
|
|
836
|
+
],
|
|
837
|
+
cwd: this.params.createParams.workspaceDir
|
|
838
|
+
});
|
|
839
|
+
if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox upload failed");
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
async maybeSeedRemoteWorkspace() {
|
|
843
|
+
if (!this.remoteSeedPending) return;
|
|
844
|
+
this.remoteSeedPending = false;
|
|
845
|
+
try {
|
|
846
|
+
await this.syncWorkspaceToRemote();
|
|
847
|
+
} catch (error) {
|
|
848
|
+
this.remoteSeedPending = true;
|
|
849
|
+
throw error;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
function resolveOpenShellPluginConfigFromConfig(config, fallback) {
|
|
854
|
+
const pluginConfig = config.plugins?.entries?.openshell?.config;
|
|
855
|
+
if (!pluginConfig) return fallback;
|
|
856
|
+
return resolveOpenShellPluginConfig(pluginConfig);
|
|
857
|
+
}
|
|
858
|
+
function buildOpenShellSandboxName(scopeKey) {
|
|
859
|
+
const trimmed = scopeKey.trim() || "session";
|
|
860
|
+
const safe = normalizeLowercaseStringOrEmpty(trimmed).replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
|
|
861
|
+
const hash = Array.from(trimmed).reduce((acc, char) => (acc * 33 ^ char.charCodeAt(0)) >>> 0, 5381);
|
|
862
|
+
return `openclaw-${safe || "session"}-${hash.toString(16).slice(0, 8)}`;
|
|
863
|
+
}
|
|
864
|
+
function resolveOpenShellTmpRoot() {
|
|
865
|
+
return path.resolve(resolvePreferredOpenClawTmpDir());
|
|
866
|
+
}
|
|
867
|
+
//#endregion
|
|
868
|
+
//#region extensions/openshell/index.ts
|
|
869
|
+
var openshell_default = definePluginEntry({
|
|
870
|
+
id: "openshell",
|
|
871
|
+
name: "OpenShell Sandbox",
|
|
872
|
+
description: "OpenShell-backed sandbox runtime for agent exec and file tools.",
|
|
873
|
+
configSchema: createOpenShellPluginConfigSchema(),
|
|
874
|
+
register(api) {
|
|
875
|
+
if (api.registrationMode !== "full") return;
|
|
876
|
+
const pluginConfig = resolveOpenShellPluginConfig(api.pluginConfig);
|
|
877
|
+
registerSandboxBackend("openshell", {
|
|
878
|
+
factory: createOpenShellSandboxBackendFactory({ pluginConfig }),
|
|
879
|
+
manager: createOpenShellSandboxBackendManager({ pluginConfig })
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
//#endregion
|
|
884
|
+
export { openshell_default as default };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "openshell",
|
|
3
|
+
"activation": {
|
|
4
|
+
"onStartup": true
|
|
5
|
+
},
|
|
6
|
+
"name": "OpenShell Sandbox",
|
|
7
|
+
"description": "Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution.",
|
|
8
|
+
"configSchema": {
|
|
9
|
+
"type": "object",
|
|
10
|
+
"additionalProperties": false,
|
|
11
|
+
"properties": {
|
|
12
|
+
"mode": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"enum": ["mirror", "remote"]
|
|
15
|
+
},
|
|
16
|
+
"command": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"minLength": 1
|
|
19
|
+
},
|
|
20
|
+
"gateway": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"minLength": 1
|
|
23
|
+
},
|
|
24
|
+
"gatewayEndpoint": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"minLength": 1
|
|
27
|
+
},
|
|
28
|
+
"from": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"minLength": 1
|
|
31
|
+
},
|
|
32
|
+
"policy": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"minLength": 1
|
|
35
|
+
},
|
|
36
|
+
"providers": {
|
|
37
|
+
"type": "array",
|
|
38
|
+
"items": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"minLength": 1
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"gpu": {
|
|
44
|
+
"type": "boolean"
|
|
45
|
+
},
|
|
46
|
+
"autoProviders": {
|
|
47
|
+
"type": "boolean"
|
|
48
|
+
},
|
|
49
|
+
"remoteWorkspaceDir": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"minLength": 1
|
|
52
|
+
},
|
|
53
|
+
"remoteAgentWorkspaceDir": {
|
|
54
|
+
"type": "string",
|
|
55
|
+
"minLength": 1
|
|
56
|
+
},
|
|
57
|
+
"timeoutSeconds": {
|
|
58
|
+
"type": "number",
|
|
59
|
+
"minimum": 1
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"uiHints": {
|
|
64
|
+
"mode": {
|
|
65
|
+
"label": "Mode",
|
|
66
|
+
"help": "Sandbox mode. Use mirror for the default local-workspace flow or remote for a fully remote workspace."
|
|
67
|
+
},
|
|
68
|
+
"command": {
|
|
69
|
+
"label": "OpenShell Command",
|
|
70
|
+
"help": "Path or command name for the openshell CLI."
|
|
71
|
+
},
|
|
72
|
+
"gateway": {
|
|
73
|
+
"label": "Gateway Name",
|
|
74
|
+
"help": "Optional OpenShell gateway name passed as --gateway."
|
|
75
|
+
},
|
|
76
|
+
"gatewayEndpoint": {
|
|
77
|
+
"label": "Gateway Endpoint",
|
|
78
|
+
"help": "Optional OpenShell gateway endpoint passed as --gateway-endpoint."
|
|
79
|
+
},
|
|
80
|
+
"from": {
|
|
81
|
+
"label": "Sandbox Source",
|
|
82
|
+
"help": "OpenShell sandbox source for first-time create. Defaults to openclaw."
|
|
83
|
+
},
|
|
84
|
+
"policy": {
|
|
85
|
+
"label": "Policy File",
|
|
86
|
+
"help": "Optional path to a custom OpenShell sandbox policy YAML."
|
|
87
|
+
},
|
|
88
|
+
"providers": {
|
|
89
|
+
"label": "Providers",
|
|
90
|
+
"help": "Provider names to attach when a sandbox is created."
|
|
91
|
+
},
|
|
92
|
+
"gpu": {
|
|
93
|
+
"label": "GPU",
|
|
94
|
+
"help": "Request GPU resources when creating the sandbox.",
|
|
95
|
+
"advanced": true
|
|
96
|
+
},
|
|
97
|
+
"autoProviders": {
|
|
98
|
+
"label": "Auto-create Providers",
|
|
99
|
+
"help": "When enabled, pass --auto-providers during sandbox create.",
|
|
100
|
+
"advanced": true
|
|
101
|
+
},
|
|
102
|
+
"remoteWorkspaceDir": {
|
|
103
|
+
"label": "Remote Workspace Dir",
|
|
104
|
+
"help": "Primary writable workspace inside the OpenShell sandbox.",
|
|
105
|
+
"advanced": true
|
|
106
|
+
},
|
|
107
|
+
"remoteAgentWorkspaceDir": {
|
|
108
|
+
"label": "Remote Agent Dir",
|
|
109
|
+
"help": "Mirror path for the real agent workspace when workspaceAccess is read-only.",
|
|
110
|
+
"advanced": true
|
|
111
|
+
},
|
|
112
|
+
"timeoutSeconds": {
|
|
113
|
+
"label": "Command Timeout Seconds",
|
|
114
|
+
"help": "Timeout for openshell CLI operations such as create/upload/download.",
|
|
115
|
+
"advanced": true
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openclaw/openshell-sandbox",
|
|
3
|
+
"version": "2026.5.12-beta.7",
|
|
4
|
+
"description": "OpenClaw OpenShell sandbox backend",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/openclaw/openclaw"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"openshell": "0.1.0",
|
|
12
|
+
"zod": "4.4.3"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@openclaw/plugin-sdk": "workspace:*"
|
|
16
|
+
},
|
|
17
|
+
"openclaw": {
|
|
18
|
+
"extensions": [
|
|
19
|
+
"./index.ts"
|
|
20
|
+
],
|
|
21
|
+
"install": {
|
|
22
|
+
"npmSpec": "@openclaw/openshell-sandbox",
|
|
23
|
+
"defaultChoice": "npm",
|
|
24
|
+
"minHostVersion": ">=2026.5.12-beta.6"
|
|
25
|
+
},
|
|
26
|
+
"compat": {
|
|
27
|
+
"pluginApi": ">=2026.5.12-beta.7"
|
|
28
|
+
},
|
|
29
|
+
"build": {
|
|
30
|
+
"openclawVersion": "2026.5.12-beta.7"
|
|
31
|
+
},
|
|
32
|
+
"release": {
|
|
33
|
+
"publishToClawHub": true,
|
|
34
|
+
"publishToNpm": true
|
|
35
|
+
},
|
|
36
|
+
"runtimeExtensions": [
|
|
37
|
+
"./dist/index.js"
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"dist/**",
|
|
42
|
+
"openclaw.plugin.json"
|
|
43
|
+
],
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"openclaw": ">=2026.5.12-beta.7"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"openclaw": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|