@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 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
+ }