@openclaw/openshell-sandbox 2026.6.11-beta.2 → 2026.7.1-beta.1
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 +303 -120
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
2
|
-
import { buildValidatedExecRemoteCommand, createRemoteShellSandboxFsBridge, createSshSandboxSessionFromConfigText, createWritableRenameTargetResolver, disposeSshSandboxSession, registerSandboxBackend, resolvePreferredOpenClawTmpDir, runPluginCommandWithTimeout, runSshSandboxCommand, sanitizeEnvVars, shellEscape, withTempWorkspace } from "openclaw/plugin-sdk/sandbox";
|
|
2
|
+
import { buildRemoteWorkdirValidationCommand, buildValidatedExecRemoteCommand, createRemoteShellSandboxFsBridge, createSshSandboxSessionFromConfigText, createWritableRenameTargetResolver, disposeSshSandboxSession, registerSandboxBackend, resolvePreferredOpenClawTmpDir, runPluginCommandWithTimeout, runSshSandboxCommand, sanitizeEnvVars, shellEscape, withTempWorkspace } from "openclaw/plugin-sdk/sandbox";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
@@ -8,7 +8,7 @@ import { formatPluginConfigIssue, mapPluginConfigIssues } from "openclaw/plugin-
|
|
|
8
8
|
import { MAX_TIMER_TIMEOUT_SECONDS } from "openclaw/plugin-sdk/number-runtime";
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
import { root } from "openclaw/plugin-sdk/file-access-runtime";
|
|
11
|
-
import { isPathInside, movePathWithCopyFallback } from "openclaw/plugin-sdk/security-runtime";
|
|
11
|
+
import { FsSafeError, isPathInside, movePathWithCopyFallback } from "openclaw/plugin-sdk/security-runtime";
|
|
12
12
|
//#region extensions/openshell/src/cli.ts
|
|
13
13
|
function resolveOpenShellCommand(command) {
|
|
14
14
|
return command;
|
|
@@ -153,97 +153,6 @@ function resolveOpenShellPluginConfig(value) {
|
|
|
153
153
|
};
|
|
154
154
|
}
|
|
155
155
|
//#endregion
|
|
156
|
-
//#region extensions/openshell/src/mirror.ts
|
|
157
|
-
const DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS = [
|
|
158
|
-
"hooks",
|
|
159
|
-
"git-hooks",
|
|
160
|
-
".git"
|
|
161
|
-
];
|
|
162
|
-
const COPY_TREE_FS_CONCURRENCY = 16;
|
|
163
|
-
function createExcludeMatcher(excludeDirs) {
|
|
164
|
-
const excluded = new Set((excludeDirs ?? []).map((d) => normalizeLowercaseStringOrEmpty(d)));
|
|
165
|
-
return (name) => excluded.has(normalizeLowercaseStringOrEmpty(name));
|
|
166
|
-
}
|
|
167
|
-
function createConcurrencyLimiter(limit) {
|
|
168
|
-
let active = 0;
|
|
169
|
-
const queue = [];
|
|
170
|
-
const release = () => {
|
|
171
|
-
active -= 1;
|
|
172
|
-
queue.shift()?.();
|
|
173
|
-
};
|
|
174
|
-
return async (task) => {
|
|
175
|
-
if (active >= limit) await new Promise((resolve) => {
|
|
176
|
-
queue.push(resolve);
|
|
177
|
-
});
|
|
178
|
-
active += 1;
|
|
179
|
-
try {
|
|
180
|
-
return await task();
|
|
181
|
-
} finally {
|
|
182
|
-
release();
|
|
183
|
-
}
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
|
-
const runLimitedFs = createConcurrencyLimiter(COPY_TREE_FS_CONCURRENCY);
|
|
187
|
-
async function lstatIfExists(targetPath) {
|
|
188
|
-
return await runLimitedFs(async () => await fs.lstat(targetPath)).catch(() => null);
|
|
189
|
-
}
|
|
190
|
-
async function copyTreeWithoutSymlinks(params) {
|
|
191
|
-
const stats = await runLimitedFs(async () => await fs.lstat(params.sourcePath));
|
|
192
|
-
if (stats.isSymbolicLink()) return;
|
|
193
|
-
const targetStats = await lstatIfExists(params.targetPath);
|
|
194
|
-
if (params.preserveTargetSymlinks && targetStats?.isSymbolicLink()) return;
|
|
195
|
-
if (stats.isDirectory()) {
|
|
196
|
-
await runLimitedFs(async () => await fs.mkdir(params.targetPath, { recursive: true }));
|
|
197
|
-
const entries = await runLimitedFs(async () => await fs.readdir(params.sourcePath));
|
|
198
|
-
await Promise.all(entries.map(async (entry) => {
|
|
199
|
-
await copyTreeWithoutSymlinks({
|
|
200
|
-
sourcePath: path.join(params.sourcePath, entry),
|
|
201
|
-
targetPath: path.join(params.targetPath, entry),
|
|
202
|
-
preserveTargetSymlinks: params.preserveTargetSymlinks
|
|
203
|
-
});
|
|
204
|
-
}));
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
if (stats.isFile()) {
|
|
208
|
-
await runLimitedFs(async () => await fs.mkdir(path.dirname(params.targetPath), { recursive: true }));
|
|
209
|
-
await runLimitedFs(async () => await fs.copyFile(params.sourcePath, params.targetPath));
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
async function replaceDirectoryContents(params) {
|
|
213
|
-
const isExcluded = createExcludeMatcher(params.excludeDirs);
|
|
214
|
-
await fs.mkdir(params.targetDir, { recursive: true });
|
|
215
|
-
const existing = await fs.readdir(params.targetDir);
|
|
216
|
-
await Promise.all(existing.filter((entry) => !isExcluded(entry)).map(async (entry) => {
|
|
217
|
-
const targetPath = path.join(params.targetDir, entry);
|
|
218
|
-
if ((await lstatIfExists(targetPath))?.isSymbolicLink()) return;
|
|
219
|
-
await runLimitedFs(async () => await fs.rm(targetPath, {
|
|
220
|
-
recursive: true,
|
|
221
|
-
force: true
|
|
222
|
-
}));
|
|
223
|
-
}));
|
|
224
|
-
const sourceEntries = await fs.readdir(params.sourceDir);
|
|
225
|
-
for (const entry of sourceEntries) {
|
|
226
|
-
if (isExcluded(entry)) continue;
|
|
227
|
-
await copyTreeWithoutSymlinks({
|
|
228
|
-
sourcePath: path.join(params.sourceDir, entry),
|
|
229
|
-
targetPath: path.join(params.targetDir, entry),
|
|
230
|
-
preserveTargetSymlinks: true
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
async function stageDirectoryContents(params) {
|
|
235
|
-
const isExcluded = createExcludeMatcher(params.excludeDirs);
|
|
236
|
-
await fs.mkdir(params.targetDir, { recursive: true });
|
|
237
|
-
const sourceEntries = await fs.readdir(params.sourceDir);
|
|
238
|
-
for (const entry of sourceEntries) {
|
|
239
|
-
if (isExcluded(entry)) continue;
|
|
240
|
-
await copyTreeWithoutSymlinks({
|
|
241
|
-
sourcePath: path.join(params.sourceDir, entry),
|
|
242
|
-
targetPath: path.join(params.targetDir, entry)
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
//#endregion
|
|
247
156
|
//#region extensions/openshell/src/fs-bridge.ts
|
|
248
157
|
const MATERIALIZED_SKILLS_CONTAINER_PARTS = [
|
|
249
158
|
".openclaw",
|
|
@@ -313,7 +222,10 @@ var OpenShellFsBridge = class {
|
|
|
313
222
|
allowFinalSymlinkForUnlink: false
|
|
314
223
|
});
|
|
315
224
|
await this.backend.mkdirpRemotePath(target.containerPath, params.signal);
|
|
316
|
-
await
|
|
225
|
+
await mkdirLocalRootPath({
|
|
226
|
+
hostPath,
|
|
227
|
+
target
|
|
228
|
+
});
|
|
317
229
|
}
|
|
318
230
|
async remove(params) {
|
|
319
231
|
const target = this.resolveTarget(params);
|
|
@@ -330,9 +242,11 @@ var OpenShellFsBridge = class {
|
|
|
330
242
|
signal: params.signal,
|
|
331
243
|
ignoreMissing: params.force !== false
|
|
332
244
|
});
|
|
333
|
-
await
|
|
334
|
-
|
|
335
|
-
|
|
245
|
+
await removeLocalRootPath({
|
|
246
|
+
force: params.force,
|
|
247
|
+
hostPath,
|
|
248
|
+
recursive: params.recursive,
|
|
249
|
+
target
|
|
336
250
|
});
|
|
337
251
|
}
|
|
338
252
|
async rename(params) {
|
|
@@ -351,11 +265,19 @@ var OpenShellFsBridge = class {
|
|
|
351
265
|
allowMissingLeaf: true,
|
|
352
266
|
allowFinalSymlinkForUnlink: false
|
|
353
267
|
});
|
|
268
|
+
await assertRenameSourceSupported(fromHostPath);
|
|
269
|
+
if (from.mountHostRoot !== to.mountHostRoot) throw new Error("OpenShell cross-root mirror renames require pinned fs-safe support");
|
|
270
|
+
await assertSameDeviceRenameSupported({
|
|
271
|
+
fromHostPath,
|
|
272
|
+
root: from.mountHostRoot,
|
|
273
|
+
toHostPath
|
|
274
|
+
});
|
|
354
275
|
await this.backend.renameRemotePath(from.containerPath, to.containerPath, params.signal);
|
|
355
|
-
await
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
to
|
|
276
|
+
await moveLocalRootPath({
|
|
277
|
+
from,
|
|
278
|
+
fromHostPath,
|
|
279
|
+
to,
|
|
280
|
+
toHostPath
|
|
359
281
|
});
|
|
360
282
|
}
|
|
361
283
|
async stat(params) {
|
|
@@ -469,6 +391,92 @@ var OpenShellFsBridge = class {
|
|
|
469
391
|
throw new Error(`Path escapes sandbox root (${workspaceRoot}): ${params.filePath}`);
|
|
470
392
|
}
|
|
471
393
|
};
|
|
394
|
+
async function mkdirLocalRootPath(params) {
|
|
395
|
+
const relativePath = relativeToRoot(params.target, params.hostPath);
|
|
396
|
+
if (!relativePath) return;
|
|
397
|
+
await (await root(params.target.mountHostRoot)).mkdir(relativePath);
|
|
398
|
+
}
|
|
399
|
+
async function removeLocalRootPath(params) {
|
|
400
|
+
const root$1 = await root(params.target.mountHostRoot);
|
|
401
|
+
const relativePath = relativeToRoot(params.target, params.hostPath);
|
|
402
|
+
try {
|
|
403
|
+
if (params.force === false) await fs.lstat(params.hostPath);
|
|
404
|
+
if (params.recursive) {
|
|
405
|
+
if ((await fs.lstat(params.hostPath).catch((err) => {
|
|
406
|
+
if (isNotFoundError(err)) return null;
|
|
407
|
+
throw err;
|
|
408
|
+
}))?.isSymbolicLink()) {
|
|
409
|
+
await root$1.remove(relativePath);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
await removeRootTree(root$1, relativePath);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
await root$1.remove(relativePath);
|
|
416
|
+
} catch (err) {
|
|
417
|
+
if (params.force !== false && isNotFoundError(err)) return;
|
|
418
|
+
throw err;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
async function removeRootTree(root, relativePath, knownStats) {
|
|
422
|
+
const stats = knownStats ?? await root.stat(relativePath);
|
|
423
|
+
if (stats.isDirectory && !stats.isSymbolicLink) {
|
|
424
|
+
const entries = await root.list(relativePath, { withFileTypes: true });
|
|
425
|
+
for (const entry of entries) await removeRootTree(root, path.join(relativePath, entry.name), entry);
|
|
426
|
+
if (!relativePath) return;
|
|
427
|
+
}
|
|
428
|
+
await root.remove(relativePath);
|
|
429
|
+
}
|
|
430
|
+
async function moveLocalRootPath(params) {
|
|
431
|
+
const root$2 = await root(params.from.mountHostRoot);
|
|
432
|
+
const fromRelativePath = relativeToRoot(params.from, params.fromHostPath);
|
|
433
|
+
const toRelativePath = relativeToRoot(params.to, params.toHostPath);
|
|
434
|
+
await mkdirParentPath(root$2, toRelativePath);
|
|
435
|
+
await root$2.move(fromRelativePath, toRelativePath, { overwrite: true });
|
|
436
|
+
}
|
|
437
|
+
async function mkdirParentPath(root, relativePath) {
|
|
438
|
+
const parentPath = path.dirname(relativePath);
|
|
439
|
+
if (parentPath === "." || parentPath === "") return;
|
|
440
|
+
await root.mkdir(parentPath);
|
|
441
|
+
}
|
|
442
|
+
function relativeToRoot(target, hostPath) {
|
|
443
|
+
const relativePath = path.relative(target.mountHostRoot, hostPath);
|
|
444
|
+
return relativePath === "." ? "" : relativePath;
|
|
445
|
+
}
|
|
446
|
+
async function assertRenameSourceSupported(fromHostPath) {
|
|
447
|
+
const stats = await fs.lstat(fromHostPath);
|
|
448
|
+
if (stats.isSymbolicLink()) throw new Error("Sandbox symlink rename sources are not supported by the local mirror bridge");
|
|
449
|
+
if (stats.isFile() && stats.nlink > 1) throw new Error("Sandbox hardlinked rename sources are not supported by the local mirror bridge");
|
|
450
|
+
}
|
|
451
|
+
async function assertSameDeviceRenameSupported(params) {
|
|
452
|
+
const sourceStats = await fs.lstat(params.fromHostPath);
|
|
453
|
+
const destinationParentStats = await nearestExistingDirectoryStats({
|
|
454
|
+
root: params.root,
|
|
455
|
+
targetPath: path.dirname(params.toHostPath)
|
|
456
|
+
});
|
|
457
|
+
if (sourceStats.dev !== destinationParentStats.dev) throw new Error("OpenShell cross-device mirror renames require pinned fs-safe support");
|
|
458
|
+
}
|
|
459
|
+
async function nearestExistingDirectoryStats(params) {
|
|
460
|
+
const rootPath = path.resolve(params.root);
|
|
461
|
+
let cursor = path.resolve(params.targetPath);
|
|
462
|
+
while (isPathInside(rootPath, cursor)) {
|
|
463
|
+
const stats = await fs.lstat(cursor).catch((err) => {
|
|
464
|
+
if (isNotFoundError(err)) return null;
|
|
465
|
+
throw err;
|
|
466
|
+
});
|
|
467
|
+
if (stats) {
|
|
468
|
+
if (!stats.isDirectory()) throw new Error(`Sandbox rename destination parent is not a directory: ${cursor}`);
|
|
469
|
+
return stats;
|
|
470
|
+
}
|
|
471
|
+
const next = path.dirname(cursor);
|
|
472
|
+
if (next === cursor) break;
|
|
473
|
+
cursor = next;
|
|
474
|
+
}
|
|
475
|
+
return await fs.lstat(rootPath);
|
|
476
|
+
}
|
|
477
|
+
function isNotFoundError(err) {
|
|
478
|
+
return err instanceof FsSafeError && err.code === "not-found" || typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
|
|
479
|
+
}
|
|
472
480
|
function resolveProtectedSkillTarget(params) {
|
|
473
481
|
const relativeRoot = path.posix.join(...MATERIALIZED_SKILLS_CONTAINER_PARTS);
|
|
474
482
|
const normalizedInput = path.posix.normalize(params.input.replace(/\\/g, "/"));
|
|
@@ -503,7 +511,9 @@ function resolveProtectedSkillShadowTarget(params) {
|
|
|
503
511
|
}
|
|
504
512
|
async function assertLocalPathSafety(params) {
|
|
505
513
|
if (!params.target.hostPath) throw new Error(`Missing local host path for ${params.target.containerPath}`);
|
|
506
|
-
|
|
514
|
+
const canonicalRoot = await fs.realpath(params.root).catch(() => path.resolve(params.root));
|
|
515
|
+
const targetStats = await fs.lstat(params.target.hostPath).catch(() => null);
|
|
516
|
+
if (!isPathInside(canonicalRoot, params.allowFinalSymlinkForUnlink && targetStats?.isSymbolicLink() ? path.resolve(canonicalRoot, path.relative(params.root, params.target.hostPath)) : await resolveCanonicalCandidate(params.target.hostPath))) throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${params.target.containerPath}`);
|
|
507
517
|
const relative = path.relative(params.root, params.target.hostPath);
|
|
508
518
|
const segments = relative.split(path.sep).filter(Boolean).slice(0, Math.max(0, relative.split(path.sep).filter(Boolean).length));
|
|
509
519
|
let cursor = params.root;
|
|
@@ -533,8 +543,109 @@ async function resolveCanonicalCandidate(targetPath) {
|
|
|
533
543
|
}
|
|
534
544
|
}
|
|
535
545
|
//#endregion
|
|
546
|
+
//#region extensions/openshell/src/mirror.ts
|
|
547
|
+
const DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS = [
|
|
548
|
+
"hooks",
|
|
549
|
+
"git-hooks",
|
|
550
|
+
".git"
|
|
551
|
+
];
|
|
552
|
+
const COPY_TREE_FS_CONCURRENCY = 16;
|
|
553
|
+
function createExcludeMatcher(excludeDirs) {
|
|
554
|
+
const excluded = new Set((excludeDirs ?? []).map((d) => normalizeLowercaseStringOrEmpty(d)));
|
|
555
|
+
return (name) => excluded.has(normalizeLowercaseStringOrEmpty(name));
|
|
556
|
+
}
|
|
557
|
+
function createConcurrencyLimiter(limit) {
|
|
558
|
+
let active = 0;
|
|
559
|
+
const queue = [];
|
|
560
|
+
const release = () => {
|
|
561
|
+
active -= 1;
|
|
562
|
+
queue.shift()?.();
|
|
563
|
+
};
|
|
564
|
+
return async (task) => {
|
|
565
|
+
if (active >= limit) await new Promise((resolve) => {
|
|
566
|
+
queue.push(resolve);
|
|
567
|
+
});
|
|
568
|
+
active += 1;
|
|
569
|
+
try {
|
|
570
|
+
return await task();
|
|
571
|
+
} finally {
|
|
572
|
+
release();
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
const runLimitedFs = createConcurrencyLimiter(COPY_TREE_FS_CONCURRENCY);
|
|
577
|
+
async function lstatIfExists(targetPath) {
|
|
578
|
+
return await runLimitedFs(async () => await fs.lstat(targetPath)).catch(() => null);
|
|
579
|
+
}
|
|
580
|
+
async function copyTreeWithoutSymlinks(params) {
|
|
581
|
+
const stats = await runLimitedFs(async () => await fs.lstat(params.sourcePath));
|
|
582
|
+
if (stats.isSymbolicLink()) return;
|
|
583
|
+
const targetStats = await lstatIfExists(params.targetPath);
|
|
584
|
+
if (params.preserveTargetSymlinks && targetStats?.isSymbolicLink()) return;
|
|
585
|
+
if (stats.isDirectory()) {
|
|
586
|
+
await runLimitedFs(async () => await fs.mkdir(params.targetPath, { recursive: true }));
|
|
587
|
+
const entries = await runLimitedFs(async () => await fs.readdir(params.sourcePath));
|
|
588
|
+
await Promise.all(entries.map(async (entry) => {
|
|
589
|
+
await copyTreeWithoutSymlinks({
|
|
590
|
+
sourcePath: path.join(params.sourcePath, entry),
|
|
591
|
+
targetPath: path.join(params.targetPath, entry),
|
|
592
|
+
preserveTargetSymlinks: params.preserveTargetSymlinks
|
|
593
|
+
});
|
|
594
|
+
}));
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
if (stats.isFile()) {
|
|
598
|
+
await runLimitedFs(async () => await fs.mkdir(path.dirname(params.targetPath), { recursive: true }));
|
|
599
|
+
await runLimitedFs(async () => await fs.copyFile(params.sourcePath, params.targetPath));
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
async function replaceDirectoryContents(params) {
|
|
603
|
+
const isExcluded = createExcludeMatcher(params.excludeDirs);
|
|
604
|
+
await fs.mkdir(params.targetDir, { recursive: true });
|
|
605
|
+
const existing = await fs.readdir(params.targetDir);
|
|
606
|
+
await Promise.all(existing.filter((entry) => !isExcluded(entry)).map(async (entry) => {
|
|
607
|
+
const targetPath = path.join(params.targetDir, entry);
|
|
608
|
+
if ((await lstatIfExists(targetPath))?.isSymbolicLink()) return;
|
|
609
|
+
await runLimitedFs(async () => await fs.rm(targetPath, {
|
|
610
|
+
recursive: true,
|
|
611
|
+
force: true
|
|
612
|
+
}));
|
|
613
|
+
}));
|
|
614
|
+
const sourceEntries = await fs.readdir(params.sourceDir);
|
|
615
|
+
for (const entry of sourceEntries) {
|
|
616
|
+
if (isExcluded(entry)) continue;
|
|
617
|
+
await copyTreeWithoutSymlinks({
|
|
618
|
+
sourcePath: path.join(params.sourceDir, entry),
|
|
619
|
+
targetPath: path.join(params.targetDir, entry),
|
|
620
|
+
preserveTargetSymlinks: true
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
async function stageDirectoryContents(params) {
|
|
625
|
+
const isExcluded = createExcludeMatcher(params.excludeDirs);
|
|
626
|
+
await fs.mkdir(params.targetDir, { recursive: true });
|
|
627
|
+
const sourceEntries = await fs.readdir(params.sourceDir);
|
|
628
|
+
for (const entry of sourceEntries) {
|
|
629
|
+
if (isExcluded(entry)) continue;
|
|
630
|
+
await copyTreeWithoutSymlinks({
|
|
631
|
+
sourcePath: path.join(params.sourceDir, entry),
|
|
632
|
+
targetPath: path.join(params.targetDir, entry)
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
//#endregion
|
|
536
637
|
//#region extensions/openshell/src/backend.ts
|
|
537
638
|
const MATERIALIZED_SKILLS_REMOTE_PARTS = [".openclaw", "sandbox-skills"];
|
|
639
|
+
function buildOpenShellDirectoryUploadArgs(params) {
|
|
640
|
+
return [
|
|
641
|
+
"sandbox",
|
|
642
|
+
"upload",
|
|
643
|
+
"--no-git-ignore",
|
|
644
|
+
params.sandboxName,
|
|
645
|
+
params.localPath,
|
|
646
|
+
normalizeRemotePath(params.remotePath)
|
|
647
|
+
];
|
|
648
|
+
}
|
|
538
649
|
const PINNED_REMOTE_PATH_MUTATION_SCRIPT = [
|
|
539
650
|
"set -eu",
|
|
540
651
|
"die() { echo \"$1\" >&2; exit 1; }",
|
|
@@ -744,6 +855,10 @@ async function createOpenShellSandboxBackend(params) {
|
|
|
744
855
|
mode: params.pluginConfig.mode,
|
|
745
856
|
configLabel: params.pluginConfig.from,
|
|
746
857
|
configLabelKind: "Source",
|
|
858
|
+
workdirValidation: "backend",
|
|
859
|
+
validateWorkdir: async (workdir) => await impl.validateWorkdir(workdir),
|
|
860
|
+
discardPreparedWorkdir: (workdir) => impl.discardPreparedWorkdir(workdir),
|
|
861
|
+
workdirRoots: [params.pluginConfig.remoteWorkspaceDir, params.pluginConfig.remoteAgentWorkspaceDir],
|
|
747
862
|
buildExecSpec: async ({ command, workdir, env, usePty }) => {
|
|
748
863
|
const pending = await impl.prepareExec({
|
|
749
864
|
command,
|
|
@@ -782,6 +897,7 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
782
897
|
constructor(params) {
|
|
783
898
|
this.params = params;
|
|
784
899
|
this.ensurePromise = null;
|
|
900
|
+
this.preparedRemoteWorkspaceForNextExec = null;
|
|
785
901
|
this.remoteSeedPending = false;
|
|
786
902
|
}
|
|
787
903
|
asHandle() {
|
|
@@ -794,6 +910,10 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
794
910
|
mode: this.params.execContext.config.mode,
|
|
795
911
|
configLabel: this.params.execContext.config.from,
|
|
796
912
|
configLabelKind: "Source",
|
|
913
|
+
workdirValidation: "backend",
|
|
914
|
+
validateWorkdir: async (workdir) => await this.validateWorkdir(workdir),
|
|
915
|
+
discardPreparedWorkdir: (workdir) => this.discardPreparedWorkdir(workdir),
|
|
916
|
+
workdirRoots: [this.params.remoteWorkspaceDir, this.params.remoteAgentWorkspaceDir],
|
|
797
917
|
remoteWorkspaceDir: this.params.remoteWorkspaceDir,
|
|
798
918
|
remoteAgentWorkspaceDir: this.params.remoteAgentWorkspaceDir,
|
|
799
919
|
buildExecSpec: async ({ command, workdir, env, usePty }) => {
|
|
@@ -829,14 +949,14 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
829
949
|
};
|
|
830
950
|
}
|
|
831
951
|
async prepareExec(params) {
|
|
952
|
+
const remoteWorkdir = params.workdir ?? this.params.remoteWorkspaceDir;
|
|
953
|
+
const preparedWorkspace = this.consumePreparedRemoteWorkspaceForNextExec(remoteWorkdir);
|
|
832
954
|
const remoteCommand = buildValidatedExecRemoteCommand({
|
|
833
955
|
command: params.command,
|
|
834
|
-
workdir:
|
|
956
|
+
workdir: remoteWorkdir,
|
|
835
957
|
env: params.env
|
|
836
958
|
});
|
|
837
|
-
await this.
|
|
838
|
-
if (this.params.execContext.config.mode === "mirror") await this.syncWorkspaceToRemote();
|
|
839
|
-
else if (!await this.maybeSeedRemoteWorkspace()) await this.syncSkillsWorkspaceToRemote();
|
|
959
|
+
await (preparedWorkspace ?? this.prepareRemoteWorkspaceForExec());
|
|
840
960
|
const sshSession = await createOpenShellSshSession({ context: this.params.execContext });
|
|
841
961
|
return {
|
|
842
962
|
argv: [
|
|
@@ -860,6 +980,67 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
860
980
|
token: { sshSession }
|
|
861
981
|
};
|
|
862
982
|
}
|
|
983
|
+
async validateWorkdir(workdir) {
|
|
984
|
+
const preparedWorkspace = this.prepareRemoteWorkspaceForExec();
|
|
985
|
+
const reusablePreparation = {
|
|
986
|
+
workdir,
|
|
987
|
+
promise: preparedWorkspace
|
|
988
|
+
};
|
|
989
|
+
this.preparedRemoteWorkspaceForNextExec = reusablePreparation;
|
|
990
|
+
try {
|
|
991
|
+
await preparedWorkspace;
|
|
992
|
+
const sshSession = await createOpenShellSshSession({ context: this.params.execContext });
|
|
993
|
+
try {
|
|
994
|
+
const result = await runSshSandboxCommand({
|
|
995
|
+
session: sshSession,
|
|
996
|
+
remoteCommand: buildRemoteWorkdirValidationCommand({
|
|
997
|
+
workdir,
|
|
998
|
+
root: this.resolveWorkdirValidationRoot(workdir)
|
|
999
|
+
}),
|
|
1000
|
+
allowFailure: true
|
|
1001
|
+
});
|
|
1002
|
+
const resolvedWorkdir = result.code === 0 ? result.stdout.toString("utf8").trim() : "";
|
|
1003
|
+
if (this.preparedRemoteWorkspaceForNextExec === reusablePreparation) this.preparedRemoteWorkspaceForNextExec = resolvedWorkdir ? {
|
|
1004
|
+
workdir: resolvedWorkdir,
|
|
1005
|
+
promise: preparedWorkspace
|
|
1006
|
+
} : null;
|
|
1007
|
+
return resolvedWorkdir || null;
|
|
1008
|
+
} finally {
|
|
1009
|
+
await disposeSshSandboxSession(sshSession);
|
|
1010
|
+
}
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
if (this.preparedRemoteWorkspaceForNextExec === reusablePreparation) this.preparedRemoteWorkspaceForNextExec = null;
|
|
1013
|
+
throw error;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
resolveWorkdirValidationRoot(workdir) {
|
|
1017
|
+
try {
|
|
1018
|
+
const normalized = normalizeRemotePath(workdir);
|
|
1019
|
+
return [normalizeRemotePath(this.params.remoteAgentWorkspaceDir), normalizeRemotePath(this.params.remoteWorkspaceDir)].toSorted((a, b) => b.length - a.length).find((root) => isRemotePathInside(root, normalized)) ?? this.params.remoteWorkspaceDir;
|
|
1020
|
+
} catch {
|
|
1021
|
+
return this.params.remoteWorkspaceDir;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
consumePreparedRemoteWorkspaceForNextExec(workdir) {
|
|
1025
|
+
const preparedWorkspace = this.preparedRemoteWorkspaceForNextExec;
|
|
1026
|
+
if (!preparedWorkspace || preparedWorkspace.workdir !== workdir) {
|
|
1027
|
+
this.preparedRemoteWorkspaceForNextExec = null;
|
|
1028
|
+
return null;
|
|
1029
|
+
}
|
|
1030
|
+
this.preparedRemoteWorkspaceForNextExec = null;
|
|
1031
|
+
return preparedWorkspace.promise;
|
|
1032
|
+
}
|
|
1033
|
+
discardPreparedWorkdir(workdir) {
|
|
1034
|
+
if (this.preparedRemoteWorkspaceForNextExec?.workdir === workdir) this.preparedRemoteWorkspaceForNextExec = null;
|
|
1035
|
+
}
|
|
1036
|
+
async prepareRemoteWorkspaceForExec() {
|
|
1037
|
+
await this.ensureSandboxExists();
|
|
1038
|
+
if (this.params.execContext.config.mode === "mirror") {
|
|
1039
|
+
await this.syncWorkspaceToRemote();
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
if (!await this.maybeSeedRemoteWorkspace()) await this.syncSkillsWorkspaceToRemote();
|
|
1043
|
+
}
|
|
863
1044
|
async finalizeExec(token) {
|
|
864
1045
|
try {
|
|
865
1046
|
if (this.params.execContext.config.mode === "mirror") await this.syncWorkspaceFromRemote();
|
|
@@ -1109,23 +1290,25 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
1109
1290
|
rootDir: resolveOpenShellTmpRoot(),
|
|
1110
1291
|
prefix: "openclaw-openshell-upload-"
|
|
1111
1292
|
}, async ({ dir: tmpDir }) => {
|
|
1293
|
+
const remoteRootName = path.posix.basename(normalizeRemotePath(remotePath));
|
|
1294
|
+
const stagedRoot = path.join(tmpDir, remoteRootName);
|
|
1112
1295
|
await stageDirectoryContents({
|
|
1113
1296
|
sourceDir: localPath,
|
|
1114
|
-
targetDir:
|
|
1297
|
+
targetDir: stagedRoot
|
|
1115
1298
|
});
|
|
1116
|
-
const
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1299
|
+
const stagedEntries = (await fs.readdir(stagedRoot)).toSorted();
|
|
1300
|
+
for (const entry of stagedEntries) {
|
|
1301
|
+
const result = await runOpenShellCli({
|
|
1302
|
+
context: this.params.execContext,
|
|
1303
|
+
args: buildOpenShellDirectoryUploadArgs({
|
|
1304
|
+
sandboxName: this.params.execContext.sandboxName,
|
|
1305
|
+
localPath: path.join(stagedRoot, entry),
|
|
1306
|
+
remotePath
|
|
1307
|
+
}),
|
|
1308
|
+
cwd: this.params.createParams.workspaceDir
|
|
1309
|
+
});
|
|
1310
|
+
if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox upload failed");
|
|
1311
|
+
}
|
|
1129
1312
|
});
|
|
1130
1313
|
}
|
|
1131
1314
|
async maybeSeedRemoteWorkspace() {
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/openshell-sandbox",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.7.1-beta.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@openclaw/openshell-sandbox",
|
|
9
|
-
"version": "2026.
|
|
9
|
+
"version": "2026.7.1-beta.1",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"zod": "4.4.3"
|
|
12
12
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/openshell-sandbox",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.7.1-beta.1",
|
|
4
4
|
"description": "OpenClaw sandbox backend for the NVIDIA OpenShell CLI with mirrored local workspaces and SSH command execution.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -20,10 +20,10 @@
|
|
|
20
20
|
"minHostVersion": ">=2026.5.12-beta.1"
|
|
21
21
|
},
|
|
22
22
|
"compat": {
|
|
23
|
-
"pluginApi": ">=2026.
|
|
23
|
+
"pluginApi": ">=2026.7.1-beta.1"
|
|
24
24
|
},
|
|
25
25
|
"build": {
|
|
26
|
-
"openclawVersion": "2026.
|
|
26
|
+
"openclawVersion": "2026.7.1-beta.1",
|
|
27
27
|
"bundledDist": false
|
|
28
28
|
},
|
|
29
29
|
"release": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"README.md"
|
|
42
42
|
],
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"openclaw": ">=2026.
|
|
44
|
+
"openclaw": ">=2026.7.1-beta.1"
|
|
45
45
|
},
|
|
46
46
|
"peerDependenciesMeta": {
|
|
47
47
|
"openclaw": {
|