@openclaw/openshell-sandbox 2026.9.1-beta.1 → 2026.9.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 +354 -334
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
2
|
-
import { buildRemoteCommand, buildRemoteWorkdirValidationCommand, buildValidatedExecRemoteCommand, createRemoteShellSandboxFsBridge, createSshSandboxSessionFromConfigText, createWritableRenameTargetResolver, disposeSshSandboxSession, prepareSshSandboxExec, registerSandboxBackend, resolvePreferredOpenClawTmpDir, runPluginCommandWithTimeout, runSshSandboxCommand, sanitizeEnvVars, shellEscape, withTempWorkspace } from "openclaw/plugin-sdk/sandbox";
|
|
2
|
+
import { buildRemoteCommand, buildRemoteWorkdirValidationCommand, buildValidatedExecRemoteCommand, createRemoteShellSandboxFsBridge, createSshSandboxSessionFromConfigText, createWritableRenameTargetResolver, disposeSshSandboxSession, prepareSshSandboxExec, registerSandboxBackend, resolvePreferredOpenClawTmpDir, resolveReadOnlyWorkspaceSkillMounts, runPluginCommandWithTimeout, runSshSandboxCommand, sanitizeEnvVars, shellEscape, withTempWorkspace } from "openclaw/plugin-sdk/sandbox";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import fsPromises from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { createDeferred, formatPluginConfigIssue, mapPluginConfigIssues } from "openclaw/plugin-sdk/extension-shared";
|
|
7
7
|
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
|
|
8
|
+
import { FsSafeError, canonicalPathFromExistingAncestor, extractErrorCode, movePathWithCopyFallback } from "openclaw/plugin-sdk/security-runtime";
|
|
8
9
|
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
9
10
|
import { buildPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
|
10
11
|
import { MAX_TIMER_TIMEOUT_SECONDS } from "openclaw/plugin-sdk/number-runtime";
|
|
11
12
|
import { z } from "zod";
|
|
12
13
|
import { isPathInside, root } from "openclaw/plugin-sdk/file-access-runtime";
|
|
13
|
-
import { FsSafeError, movePathWithCopyFallback } from "openclaw/plugin-sdk/security-runtime";
|
|
14
14
|
import pLimit from "p-limit";
|
|
15
15
|
//#region extensions/openshell/src/cli.ts
|
|
16
16
|
function buildOpenShellBaseArgv(config) {
|
|
@@ -156,12 +156,25 @@ function resolveOpenShellPluginConfig(value) {
|
|
|
156
156
|
};
|
|
157
157
|
}
|
|
158
158
|
//#endregion
|
|
159
|
+
//#region extensions/openshell/src/workspace-roots.ts
|
|
160
|
+
function remotePathDepth(remote) {
|
|
161
|
+
return remote.split("/").filter(Boolean).length;
|
|
162
|
+
}
|
|
163
|
+
function workspaceRootOwnerOrder(owner) {
|
|
164
|
+
return owner === "workspace" ? 0 : 1;
|
|
165
|
+
}
|
|
166
|
+
function isOpenShellRemotePathInside(root, candidate) {
|
|
167
|
+
const relative = path.posix.relative(root, candidate);
|
|
168
|
+
return relative === "" || relative !== ".." && !relative.startsWith("../") && !path.posix.isAbsolute(relative);
|
|
169
|
+
}
|
|
170
|
+
function orderOpenShellWorkspaceRoots(roots) {
|
|
171
|
+
return roots.toSorted((a, b) => remotePathDepth(a.remote) - remotePathDepth(b.remote) || workspaceRootOwnerOrder(a.owner) - workspaceRootOwnerOrder(b.owner));
|
|
172
|
+
}
|
|
173
|
+
function resolveOpenShellWorkspaceRoot(roots, candidate) {
|
|
174
|
+
return roots.toSorted((a, b) => remotePathDepth(b.remote) - remotePathDepth(a.remote) || workspaceRootOwnerOrder(a.owner) - workspaceRootOwnerOrder(b.owner)).find((root) => isOpenShellRemotePathInside(root.remote, candidate));
|
|
175
|
+
}
|
|
176
|
+
//#endregion
|
|
159
177
|
//#region extensions/openshell/src/fs-bridge.ts
|
|
160
|
-
const MATERIALIZED_SKILLS_CONTAINER_PARTS = [
|
|
161
|
-
".openclaw",
|
|
162
|
-
"sandbox-skills",
|
|
163
|
-
"skills"
|
|
164
|
-
];
|
|
165
178
|
function createOpenShellFsBridge(params) {
|
|
166
179
|
return new OpenShellFsBridge(params.sandbox, params.backend);
|
|
167
180
|
}
|
|
@@ -169,7 +182,7 @@ var OpenShellFsBridge = class {
|
|
|
169
182
|
constructor(sandbox, backend) {
|
|
170
183
|
this.sandbox = sandbox;
|
|
171
184
|
this.backend = backend;
|
|
172
|
-
this.resolveRenameTargets = createWritableRenameTargetResolver((target) => this.resolveTarget(target), (target, action) => this.ensureWritable(target, action));
|
|
185
|
+
this.resolveRenameTargets = createWritableRenameTargetResolver((target) => this.resolveTarget(target), (target, action) => this.ensureWritable(target, action, true));
|
|
173
186
|
}
|
|
174
187
|
resolvePath(params) {
|
|
175
188
|
const target = this.resolveTarget(params);
|
|
@@ -259,7 +272,7 @@ var OpenShellFsBridge = class {
|
|
|
259
272
|
async remove(params) {
|
|
260
273
|
const target = this.resolveTarget(params);
|
|
261
274
|
const hostPath = this.requireHostPath(target);
|
|
262
|
-
this.ensureWritable(target, "remove files");
|
|
275
|
+
this.ensureWritable(target, "remove files", params.recursive);
|
|
263
276
|
await assertLocalPathSafety({
|
|
264
277
|
target,
|
|
265
278
|
root: target.mountHostRoot,
|
|
@@ -326,8 +339,16 @@ var OpenShellFsBridge = class {
|
|
|
326
339
|
mtimeMs: stats.mtimeMs
|
|
327
340
|
};
|
|
328
341
|
}
|
|
329
|
-
ensureWritable(target, action) {
|
|
330
|
-
if (this.sandbox.workspaceAccess
|
|
342
|
+
ensureWritable(target, action, includeDescendants = false) {
|
|
343
|
+
if (this.sandbox.workspaceAccess === "ro" || !target.writable || includeDescendants && this.readOnlyMounts().some((mount) => isOpenShellRemotePathInside(target.containerPath, mount.containerPath))) throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`);
|
|
344
|
+
}
|
|
345
|
+
readOnlyMounts() {
|
|
346
|
+
const workdirs = [this.sandbox.containerWorkdir];
|
|
347
|
+
if (this.sandbox.workspaceAccess !== "none" && path.resolve(this.sandbox.workspaceDir) !== path.resolve(this.sandbox.agentWorkspaceDir)) workdirs.push(this.backend.remoteAgentWorkspaceDir || "/agent");
|
|
348
|
+
return [...workdirs.flatMap((workdir) => resolveReadOnlyWorkspaceSkillMounts({
|
|
349
|
+
...this.sandbox,
|
|
350
|
+
workdir
|
|
351
|
+
})), ...this.sandbox.readOnlyResourceMounts ?? []];
|
|
331
352
|
}
|
|
332
353
|
requireHostPath(target) {
|
|
333
354
|
if (!target.hostPath) throw new Error(`OpenShell mirror bridge requires a local host path: ${target.containerPath}`);
|
|
@@ -339,91 +360,74 @@ var OpenShellFsBridge = class {
|
|
|
339
360
|
const hasAgentMount = this.sandbox.workspaceAccess !== "none" && workspaceRoot !== agentRoot;
|
|
340
361
|
const agentContainerRoot = (this.backend.remoteAgentWorkspaceDir || "/agent").replace(/\\/g, "/");
|
|
341
362
|
const workspaceContainerRoot = this.sandbox.containerWorkdir.replace(/\\/g, "/");
|
|
342
|
-
const skillsRoot = this.sandbox.skillsWorkspaceDir ? path.resolve(this.sandbox.skillsWorkspaceDir, "skills") : void 0;
|
|
343
|
-
const skillsContainerRoot = path.posix.join(workspaceContainerRoot, ...MATERIALIZED_SKILLS_CONTAINER_PARTS);
|
|
344
|
-
const workspaceSkillsShadowRoot = path.resolve(workspaceRoot, ...MATERIALIZED_SKILLS_CONTAINER_PARTS);
|
|
345
363
|
const input = params.filePath.trim();
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
364
|
+
const readOnlyMounts = this.readOnlyMounts();
|
|
365
|
+
const containerMounts = [{
|
|
366
|
+
remote: workspaceContainerRoot,
|
|
367
|
+
owner: "workspace",
|
|
368
|
+
value: {
|
|
369
|
+
hostRoot: workspaceRoot,
|
|
370
|
+
writable: this.sandbox.workspaceAccess !== "ro"
|
|
371
|
+
}
|
|
372
|
+
}, ...hasAgentMount ? [{
|
|
373
|
+
remote: agentContainerRoot,
|
|
374
|
+
owner: "agent",
|
|
375
|
+
value: {
|
|
376
|
+
hostRoot: agentRoot,
|
|
377
|
+
writable: this.sandbox.workspaceAccess === "rw"
|
|
378
|
+
}
|
|
379
|
+
}] : []];
|
|
380
|
+
containerMounts.unshift(...readOnlyMounts.map((mount) => ({
|
|
381
|
+
remote: mount.containerPath,
|
|
382
|
+
owner: resolveOpenShellWorkspaceRoot(containerMounts, mount.containerPath)?.owner ?? "workspace",
|
|
383
|
+
value: {
|
|
384
|
+
hostRoot: path.resolve(mount.hostPath),
|
|
385
|
+
writable: false
|
|
386
|
+
}
|
|
387
|
+
})));
|
|
388
|
+
const resolveContainerTarget = (containerPath) => {
|
|
389
|
+
const containerMount = resolveOpenShellWorkspaceRoot(containerMounts, containerPath);
|
|
390
|
+
if (!containerMount) return;
|
|
391
|
+
const relative = path.posix.relative(containerMount.remote, containerPath) || "";
|
|
392
|
+
const hostPath = relative ? path.resolve(containerMount.value.hostRoot, ...relative.split("/")) : containerMount.value.hostRoot;
|
|
393
|
+
if (!isPathInside(containerMount.value.hostRoot, hostPath)) throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${input}`);
|
|
371
394
|
return {
|
|
372
395
|
hostPath,
|
|
373
|
-
relativePath: relative ?
|
|
374
|
-
containerPath: relative ? path.posix.join(
|
|
375
|
-
mountHostRoot:
|
|
376
|
-
writable:
|
|
377
|
-
source: "agent"
|
|
396
|
+
relativePath: containerMount.owner === "agent" ? relative ? containerMount.remote + "/" + relative : containerMount.remote : path.posix.relative(workspaceContainerRoot, containerPath),
|
|
397
|
+
containerPath: relative ? path.posix.join(containerMount.remote, relative) : containerMount.remote,
|
|
398
|
+
mountHostRoot: containerMount.value.hostRoot,
|
|
399
|
+
writable: containerMount.value.writable
|
|
378
400
|
};
|
|
401
|
+
};
|
|
402
|
+
const containerCwd = params.cwd?.replace(/\\/g, "/");
|
|
403
|
+
const cwdMount = containerCwd ? resolveOpenShellWorkspaceRoot(containerMounts, containerCwd) : void 0;
|
|
404
|
+
const containerInput = path.posix.isAbsolute(input) ? input : cwdMount || !params.cwd ? path.posix.resolve(containerCwd ?? workspaceContainerRoot, input) : void 0;
|
|
405
|
+
if (containerInput) {
|
|
406
|
+
const target = resolveContainerTarget(containerInput);
|
|
407
|
+
if (target) return target;
|
|
408
|
+
if (cwdMount || containerMounts.some((mount) => input === mount.remote || input.startsWith(`${mount.remote}/`))) throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${input}`);
|
|
379
409
|
}
|
|
380
410
|
const cwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot;
|
|
381
411
|
const hostPath = path.isAbsolute(input) ? path.resolve(input) : path.resolve(cwd, input);
|
|
382
|
-
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
workspaceSkillsShadowRoot,
|
|
386
|
-
skillsRoot,
|
|
387
|
-
skillsContainerRoot
|
|
388
|
-
});
|
|
389
|
-
if (protectedSkillShadowTarget) return protectedSkillShadowTarget;
|
|
412
|
+
for (const mount of readOnlyMounts) if (isPathInside(mount.hostPath, hostPath)) {
|
|
413
|
+
const relative = path.relative(mount.hostPath, hostPath).split(path.sep).join(path.posix.sep);
|
|
414
|
+
return expectResolvedContainerTarget(resolveContainerTarget(path.posix.join(mount.containerPath, relative)), input);
|
|
390
415
|
}
|
|
391
416
|
if (isPathInside(workspaceRoot, hostPath)) {
|
|
392
417
|
const relative = path.relative(workspaceRoot, hostPath).split(path.sep).join(path.posix.sep);
|
|
393
|
-
return
|
|
394
|
-
hostPath,
|
|
395
|
-
relativePath: relative,
|
|
396
|
-
containerPath: relative ? path.posix.join(workspaceContainerRoot, relative) : workspaceContainerRoot,
|
|
397
|
-
mountHostRoot: workspaceRoot,
|
|
398
|
-
writable: this.sandbox.workspaceAccess === "rw",
|
|
399
|
-
source: "workspace"
|
|
400
|
-
};
|
|
401
|
-
}
|
|
402
|
-
if (skillsRoot && this.sandbox.workspaceAccess === "rw" && isPathInside(skillsRoot, hostPath)) {
|
|
403
|
-
const relative = path.relative(skillsRoot, hostPath).split(path.sep).join(path.posix.sep);
|
|
404
|
-
return {
|
|
405
|
-
hostPath,
|
|
406
|
-
relativePath: relative ? path.posix.join(...MATERIALIZED_SKILLS_CONTAINER_PARTS, relative) : path.posix.join(...MATERIALIZED_SKILLS_CONTAINER_PARTS),
|
|
407
|
-
containerPath: relative ? path.posix.join(skillsContainerRoot, relative) : skillsContainerRoot,
|
|
408
|
-
mountHostRoot: skillsRoot,
|
|
409
|
-
writable: false,
|
|
410
|
-
source: "protectedSkill"
|
|
411
|
-
};
|
|
418
|
+
return expectResolvedContainerTarget(resolveContainerTarget(path.posix.join(workspaceContainerRoot, relative)), input);
|
|
412
419
|
}
|
|
413
420
|
if (hasAgentMount && isPathInside(agentRoot, hostPath)) {
|
|
414
421
|
const relative = path.relative(agentRoot, hostPath).split(path.sep).join(path.posix.sep);
|
|
415
|
-
return
|
|
416
|
-
hostPath,
|
|
417
|
-
relativePath: relative ? `${agentContainerRoot}/${relative}` : agentContainerRoot,
|
|
418
|
-
containerPath: relative ? path.posix.join(agentContainerRoot, relative) : agentContainerRoot,
|
|
419
|
-
mountHostRoot: agentRoot,
|
|
420
|
-
writable: this.sandbox.workspaceAccess === "rw",
|
|
421
|
-
source: "agent"
|
|
422
|
-
};
|
|
422
|
+
return expectResolvedContainerTarget(resolveContainerTarget(path.posix.join(agentContainerRoot, relative)), input);
|
|
423
423
|
}
|
|
424
424
|
throw new Error(`Path escapes sandbox root (${workspaceRoot}): ${params.filePath}`);
|
|
425
425
|
}
|
|
426
426
|
};
|
|
427
|
+
function expectResolvedContainerTarget(target, input) {
|
|
428
|
+
if (!target) throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${input}`);
|
|
429
|
+
return target;
|
|
430
|
+
}
|
|
427
431
|
async function mkdirLocalRootPath(params) {
|
|
428
432
|
const relativePath = relativeToRoot(params.target, params.hostPath);
|
|
429
433
|
if (!relativePath) return;
|
|
@@ -510,43 +514,12 @@ async function nearestExistingDirectoryStats(params) {
|
|
|
510
514
|
function isNotFoundError(err) {
|
|
511
515
|
return err instanceof FsSafeError && err.code === "not-found" || typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
|
|
512
516
|
}
|
|
513
|
-
function resolveProtectedSkillTarget(params) {
|
|
514
|
-
const relativeRoot = path.posix.join(...MATERIALIZED_SKILLS_CONTAINER_PARTS);
|
|
515
|
-
const normalizedInput = path.posix.normalize(params.input.replace(/\\/g, "/"));
|
|
516
|
-
const isAbsoluteContainer = normalizedInput === params.skillsContainerRoot || normalizedInput.startsWith(`${params.skillsContainerRoot}/`);
|
|
517
|
-
const isRelativeContainer = normalizedInput === relativeRoot || normalizedInput.startsWith(`${relativeRoot}/`);
|
|
518
|
-
if (!isAbsoluteContainer && !isRelativeContainer) return null;
|
|
519
|
-
const relative = isAbsoluteContainer ? path.posix.relative(params.skillsContainerRoot, normalizedInput) : path.posix.relative(relativeRoot, normalizedInput);
|
|
520
|
-
const safeRelative = relative === "." ? "" : relative;
|
|
521
|
-
return {
|
|
522
|
-
hostPath: safeRelative ? path.resolve(params.skillsRoot, ...safeRelative.split("/")) : params.skillsRoot,
|
|
523
|
-
relativePath: safeRelative ? path.posix.join(relativeRoot, safeRelative) : relativeRoot,
|
|
524
|
-
containerPath: safeRelative ? path.posix.join(params.skillsContainerRoot, safeRelative) : params.skillsContainerRoot,
|
|
525
|
-
mountHostRoot: params.skillsRoot,
|
|
526
|
-
writable: false,
|
|
527
|
-
source: "protectedSkill"
|
|
528
|
-
};
|
|
529
|
-
}
|
|
530
|
-
function resolveProtectedSkillShadowTarget(params) {
|
|
531
|
-
if (!isPathInside(params.workspaceSkillsShadowRoot, params.hostPath)) return null;
|
|
532
|
-
const relative = path.relative(params.workspaceSkillsShadowRoot, params.hostPath).split(path.sep).join(path.posix.sep);
|
|
533
|
-
const safeRelative = relative === "." ? "" : relative;
|
|
534
|
-
const hostPath = safeRelative ? path.resolve(params.skillsRoot, ...safeRelative.split("/")) : params.skillsRoot;
|
|
535
|
-
const relativeRoot = path.posix.join(...MATERIALIZED_SKILLS_CONTAINER_PARTS);
|
|
536
|
-
return {
|
|
537
|
-
hostPath,
|
|
538
|
-
relativePath: safeRelative ? path.posix.join(relativeRoot, safeRelative) : relativeRoot,
|
|
539
|
-
containerPath: safeRelative ? path.posix.join(params.skillsContainerRoot, safeRelative) : params.skillsContainerRoot,
|
|
540
|
-
mountHostRoot: params.skillsRoot,
|
|
541
|
-
writable: false,
|
|
542
|
-
source: "protectedSkill"
|
|
543
|
-
};
|
|
544
|
-
}
|
|
545
517
|
async function assertLocalPathSafety(params) {
|
|
546
518
|
if (!params.target.hostPath) throw new Error(`Missing local host path for ${params.target.containerPath}`);
|
|
547
519
|
const canonicalRoot = await fsPromises.realpath(params.root).catch(() => path.resolve(params.root));
|
|
548
520
|
const targetStats = await fsPromises.lstat(params.target.hostPath).catch(() => null);
|
|
549
|
-
|
|
521
|
+
const candidate = params.allowFinalSymlinkForUnlink && targetStats?.isSymbolicLink() ? path.resolve(canonicalRoot, path.relative(params.root, params.target.hostPath)) : await resolveCanonicalCandidate(params.target.hostPath);
|
|
522
|
+
if (!isPathInside(canonicalRoot, candidate)) throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${params.target.containerPath}`);
|
|
550
523
|
const segments = path.relative(params.root, params.target.hostPath).split(path.sep).filter(Boolean);
|
|
551
524
|
let cursor = params.root;
|
|
552
525
|
for (const [index, segment] of segments.entries()) {
|
|
@@ -588,63 +561,60 @@ function createExcludeMatcher(excludeDirs) {
|
|
|
588
561
|
}
|
|
589
562
|
const runLimitedFs = pLimit(COPY_TREE_FS_CONCURRENCY);
|
|
590
563
|
async function lstatIfExists(targetPath) {
|
|
591
|
-
return await runLimitedFs(
|
|
564
|
+
return await runLimitedFs(fsPromises.lstat, targetPath).catch((error) => {
|
|
565
|
+
if (extractErrorCode(error) === "ENOENT") return null;
|
|
566
|
+
throw error;
|
|
567
|
+
});
|
|
592
568
|
}
|
|
593
|
-
async function
|
|
594
|
-
const stats = await runLimitedFs(async () => await fsPromises.lstat(params.sourcePath));
|
|
595
|
-
if (stats.isSymbolicLink()) return;
|
|
569
|
+
async function reconcileMirrorPath(params) {
|
|
596
570
|
const targetStats = await lstatIfExists(params.targetPath);
|
|
597
|
-
if (
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
571
|
+
if (targetStats && !targetStats.isDirectory() && !targetStats.isFile()) return true;
|
|
572
|
+
const sourceStats = params.sourcePath ? await runLimitedFs(fsPromises.lstat, params.sourcePath) : null;
|
|
573
|
+
const sourceDir = sourceStats?.isDirectory() ? params.sourcePath : void 0;
|
|
574
|
+
const sourceFile = sourceStats?.isFile() ? params.sourcePath : void 0;
|
|
575
|
+
if (!params.replace && !sourceDir && !sourceFile) return false;
|
|
576
|
+
if (sourceDir || targetStats?.isDirectory()) {
|
|
577
|
+
if (targetStats && !targetStats.isDirectory()) await runLimitedFs(fsPromises.rm, params.targetPath, { force: true });
|
|
578
|
+
const preservedEntries = await reconcileMirrorDirectory({
|
|
579
|
+
sourceDir,
|
|
580
|
+
targetDir: params.targetPath,
|
|
581
|
+
replace: params.replace
|
|
582
|
+
});
|
|
583
|
+
if (sourceDir || preservedEntries) return preservedEntries;
|
|
584
|
+
await runLimitedFs(fsPromises.rmdir, params.targetPath);
|
|
585
|
+
} else if (targetStats) await runLimitedFs(fsPromises.rm, params.targetPath, { force: true });
|
|
586
|
+
if (sourceFile) await runLimitedFs(fsPromises.copyFile, sourceFile, params.targetPath);
|
|
587
|
+
return false;
|
|
614
588
|
}
|
|
615
|
-
async function
|
|
589
|
+
async function reconcileMirrorDirectory(params) {
|
|
590
|
+
const { sourceDir } = params;
|
|
616
591
|
const isExcluded = createExcludeMatcher(params.excludeDirs);
|
|
617
|
-
await fsPromises.mkdir
|
|
618
|
-
const
|
|
619
|
-
await
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
if (isExcluded(entry)) continue;
|
|
630
|
-
await copyTreeWithoutSymlinks({
|
|
631
|
-
sourcePath: path.join(params.sourceDir, entry),
|
|
632
|
-
targetPath: path.join(params.targetDir, entry),
|
|
633
|
-
preserveTargetSymlinks: true
|
|
634
|
-
});
|
|
592
|
+
await runLimitedFs(fsPromises.mkdir, params.targetDir, { recursive: true });
|
|
593
|
+
const sourceEntries = new Set(sourceDir ? await runLimitedFs(async () => await fsPromises.readdir(sourceDir)) : []);
|
|
594
|
+
const targetEntries = params.replace ? await runLimitedFs(async () => await fsPromises.readdir(params.targetDir)) : [];
|
|
595
|
+
const results = await Promise.allSettled([.../* @__PURE__ */ new Set([...sourceEntries, ...targetEntries])].filter((entry) => !isExcluded(entry)).map((entry) => reconcileMirrorPath({
|
|
596
|
+
sourcePath: sourceDir && sourceEntries.has(entry) ? path.join(sourceDir, entry) : void 0,
|
|
597
|
+
targetPath: path.join(params.targetDir, entry),
|
|
598
|
+
replace: params.replace
|
|
599
|
+
})));
|
|
600
|
+
let preservedEntries = false;
|
|
601
|
+
for (const result of results) {
|
|
602
|
+
if (result.status === "rejected") throw result.reason;
|
|
603
|
+
preservedEntries ||= result.value;
|
|
635
604
|
}
|
|
605
|
+
return preservedEntries;
|
|
606
|
+
}
|
|
607
|
+
async function replaceDirectoryContents(params) {
|
|
608
|
+
await reconcileMirrorDirectory({
|
|
609
|
+
...params,
|
|
610
|
+
replace: true
|
|
611
|
+
});
|
|
636
612
|
}
|
|
637
613
|
async function stageDirectoryContents(params) {
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
if (isExcluded(entry)) continue;
|
|
643
|
-
await copyTreeWithoutSymlinks({
|
|
644
|
-
sourcePath: path.join(params.sourceDir, entry),
|
|
645
|
-
targetPath: path.join(params.targetDir, entry)
|
|
646
|
-
});
|
|
647
|
-
}
|
|
614
|
+
await reconcileMirrorDirectory({
|
|
615
|
+
...params,
|
|
616
|
+
replace: false
|
|
617
|
+
});
|
|
648
618
|
}
|
|
649
619
|
//#endregion
|
|
650
620
|
//#region extensions/openshell/src/backend.ts
|
|
@@ -767,6 +737,7 @@ const ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT = [
|
|
|
767
737
|
"set -e",
|
|
768
738
|
"target=\"$1\"",
|
|
769
739
|
"root=\"${2:-$1}\"",
|
|
740
|
+
"replace_blocking=\"${3:-0}\"",
|
|
770
741
|
"case \"$target\" in /*) ;; *) echo \"remote directory must be absolute: $target\" >&2; exit 1 ;; esac",
|
|
771
742
|
"case \"$root\" in /*) ;; *) echo \"remote root must be absolute: $root\" >&2; exit 1 ;; esac",
|
|
772
743
|
"target=\"${target%/}\"",
|
|
@@ -794,9 +765,15 @@ const ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT = [
|
|
|
794
765
|
" if [ \"$part\" = \"$relative\" ]; then relative=\"\"; else relative=\"${relative#*/}\"; fi",
|
|
795
766
|
" [ -n \"$part\" ] || continue",
|
|
796
767
|
" if [ \"$current\" = \"/\" ]; then next=\"/$part\"; else next=\"$current/$part\"; fi",
|
|
797
|
-
" if [ -L \"$next\" ]; then
|
|
768
|
+
" if [ -L \"$next\" ]; then",
|
|
769
|
+
" if [ \"$replace_blocking\" != \"1\" ]; then echo \"unsafe remote directory symlink: $next\" >&2; exit 1; fi",
|
|
770
|
+
" rm -rf -- \"$next\"",
|
|
771
|
+
" elif [ -e \"$next\" ] && [ ! -d \"$next\" ]; then",
|
|
772
|
+
" if [ \"$replace_blocking\" != \"1\" ]; then echo \"unsafe remote directory component: $next\" >&2; exit 1; fi",
|
|
773
|
+
" rm -rf -- \"$next\"",
|
|
774
|
+
" fi",
|
|
798
775
|
" if [ -e \"$next\" ]; then",
|
|
799
|
-
"
|
|
776
|
+
" [ -d \"$next\" ] || { echo \"unsafe remote directory component: $next\" >&2; exit 1; }",
|
|
800
777
|
" else",
|
|
801
778
|
" mkdir -- \"$next\"",
|
|
802
779
|
" fi",
|
|
@@ -876,11 +853,11 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
876
853
|
this.params = params;
|
|
877
854
|
this.handle = null;
|
|
878
855
|
this.ensurePromise = null;
|
|
879
|
-
this.preparedRemoteWorkspaceForNextExec = null;
|
|
880
856
|
this.remoteSeedPending = false;
|
|
881
857
|
}
|
|
882
858
|
asHandle() {
|
|
883
859
|
if (this.handle) return this.handle;
|
|
860
|
+
const runRemoteShellScript = (command) => this.params.execContext.config.mode === "mirror" ? this.runWorkspaceOperation(() => this.runRemoteShellScript(command), command.signal) : this.runRemoteShellScript(command);
|
|
884
861
|
const handle = {
|
|
885
862
|
id: "openshell",
|
|
886
863
|
runtimeId: this.params.execContext.sandboxName,
|
|
@@ -892,7 +869,6 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
892
869
|
configLabelKind: "Source",
|
|
893
870
|
workdirValidation: "backend",
|
|
894
871
|
validateWorkdir: async (workdir) => await this.validateWorkdir(workdir),
|
|
895
|
-
discardPreparedWorkdir: (workdir) => this.discardPreparedWorkdir(workdir),
|
|
896
872
|
workdirRoots: [this.params.remoteWorkspaceDir, this.params.remoteAgentWorkspaceDir],
|
|
897
873
|
remoteWorkspaceDir: this.params.remoteWorkspaceDir,
|
|
898
874
|
remoteAgentWorkspaceDir: this.params.remoteAgentWorkspaceDir,
|
|
@@ -913,12 +889,12 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
913
889
|
finalizeExec: async ({ token }) => {
|
|
914
890
|
await this.finalizeExec(token);
|
|
915
891
|
},
|
|
916
|
-
runShellCommand:
|
|
892
|
+
runShellCommand: runRemoteShellScript,
|
|
917
893
|
createFsBridge: ({ sandbox }) => this.params.execContext.config.mode === "remote" ? createRemoteShellSandboxFsBridge({
|
|
918
894
|
sandbox,
|
|
919
895
|
runtime: handle
|
|
920
896
|
}) : this.createMirrorFsBridge(sandbox),
|
|
921
|
-
runRemoteShellScript
|
|
897
|
+
runRemoteShellScript
|
|
922
898
|
};
|
|
923
899
|
this.handle = handle;
|
|
924
900
|
return handle;
|
|
@@ -936,18 +912,20 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
936
912
|
});
|
|
937
913
|
return {
|
|
938
914
|
resolvePath: (params) => bridge.resolvePath(params),
|
|
939
|
-
readFile: (params) => this.runWorkspaceOperation(() => bridge.readFile(params)),
|
|
940
|
-
writeFile: (params) => this.runWorkspaceOperation(() => bridge.writeFile(params)),
|
|
941
|
-
createFileExclusive: (params) => this.runWorkspaceOperation(() => bridge.createFileExclusive(params)),
|
|
942
|
-
mkdirp: (params) => this.runWorkspaceOperation(() => bridge.mkdirp(params)),
|
|
943
|
-
remove: (params) => this.runWorkspaceOperation(() => bridge.remove(params)),
|
|
944
|
-
rename: (params) => this.runWorkspaceOperation(() => bridge.rename(params)),
|
|
945
|
-
stat: (params) => this.runWorkspaceOperation(() => bridge.stat(params))
|
|
915
|
+
readFile: (params) => this.runWorkspaceOperation(() => bridge.readFile(params), params.signal),
|
|
916
|
+
writeFile: (params) => this.runWorkspaceOperation(() => bridge.writeFile(params), params.signal),
|
|
917
|
+
createFileExclusive: (params) => this.runWorkspaceOperation(() => bridge.createFileExclusive(params), params.signal),
|
|
918
|
+
mkdirp: (params) => this.runWorkspaceOperation(() => bridge.mkdirp(params), params.signal),
|
|
919
|
+
remove: (params) => this.runWorkspaceOperation(() => bridge.remove(params), params.signal),
|
|
920
|
+
rename: (params) => this.runWorkspaceOperation(() => bridge.rename(params), params.signal),
|
|
921
|
+
stat: (params) => this.runWorkspaceOperation(() => bridge.stat(params), params.signal)
|
|
946
922
|
};
|
|
947
923
|
}
|
|
948
|
-
async runWorkspaceOperation(operation) {
|
|
924
|
+
async runWorkspaceOperation(operation, signal) {
|
|
925
|
+
signal?.throwIfAborted();
|
|
949
926
|
const lease = await this.acquireWorkspaceLease();
|
|
950
927
|
try {
|
|
928
|
+
signal?.throwIfAborted();
|
|
951
929
|
return await operation();
|
|
952
930
|
} finally {
|
|
953
931
|
lease.release();
|
|
@@ -955,32 +933,19 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
955
933
|
}
|
|
956
934
|
async acquireWorkspaceLease() {
|
|
957
935
|
const { config, sandboxName } = this.params.execContext;
|
|
958
|
-
const keys = [`host:${
|
|
936
|
+
const keys = [`host:${await canonicalPathFromExistingAncestor(this.params.createParams.workspaceDir)}`, `runtime:${JSON.stringify([
|
|
959
937
|
config.gatewayEndpoint ?? "",
|
|
960
938
|
config.gateway ?? "",
|
|
961
939
|
config.workspace ?? process.env.OPENSHELL_WORKSPACE ?? "",
|
|
962
940
|
sandboxName
|
|
963
941
|
])}`].toSorted();
|
|
964
942
|
const releases = [];
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
openShellWorkspaceOperations.enqueue(key, async () => {
|
|
970
|
-
acquired.resolve();
|
|
971
|
-
await released.promise;
|
|
972
|
-
});
|
|
973
|
-
await acquired.promise;
|
|
974
|
-
releases.push(released.resolve);
|
|
975
|
-
}
|
|
976
|
-
} catch (error) {
|
|
977
|
-
for (const release of releases.toReversed()) release();
|
|
978
|
-
throw error;
|
|
943
|
+
for (const key of keys) {
|
|
944
|
+
const acquired = createDeferred();
|
|
945
|
+
openShellWorkspaceOperations.enqueue(key, () => new Promise(acquired.resolve));
|
|
946
|
+
releases.push(await acquired.promise);
|
|
979
947
|
}
|
|
980
|
-
let active = true;
|
|
981
948
|
return { release: () => {
|
|
982
|
-
if (!active) return;
|
|
983
|
-
active = false;
|
|
984
949
|
for (const release of releases.toReversed()) release();
|
|
985
950
|
} };
|
|
986
951
|
}
|
|
@@ -991,10 +956,13 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
991
956
|
workdir: remoteWorkdir,
|
|
992
957
|
env: {}
|
|
993
958
|
});
|
|
994
|
-
const
|
|
995
|
-
const workspaceLease = preparedWorkspace?.lease ?? await this.acquireWorkspaceLease();
|
|
959
|
+
const workspaceLease = this.params.execContext.config.mode === "mirror" ? await this.acquireWorkspaceLease() : void 0;
|
|
996
960
|
try {
|
|
997
|
-
await
|
|
961
|
+
await this.ensureSandboxExists();
|
|
962
|
+
if (workspaceLease) {
|
|
963
|
+
await this.syncWorkspaceToRemote();
|
|
964
|
+
this.remoteSeedPending = false;
|
|
965
|
+
}
|
|
998
966
|
const sshSession = await createOpenShellSshSession({ context: this.params.execContext });
|
|
999
967
|
try {
|
|
1000
968
|
const prepared = await prepareSshSandboxExec({
|
|
@@ -1006,9 +974,14 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
1006
974
|
return {
|
|
1007
975
|
argv: prepared.argv,
|
|
1008
976
|
token: {
|
|
1009
|
-
|
|
1010
|
-
cleanup:
|
|
1011
|
-
|
|
977
|
+
workspaceLease,
|
|
978
|
+
cleanup: async () => {
|
|
979
|
+
try {
|
|
980
|
+
await prepared.cleanup();
|
|
981
|
+
} finally {
|
|
982
|
+
await disposeSshSandboxSession(sshSession);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
1012
985
|
}
|
|
1013
986
|
};
|
|
1014
987
|
} catch (error) {
|
|
@@ -1016,104 +989,89 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
1016
989
|
throw error;
|
|
1017
990
|
}
|
|
1018
991
|
} catch (error) {
|
|
1019
|
-
workspaceLease
|
|
992
|
+
workspaceLease?.release();
|
|
1020
993
|
throw error;
|
|
1021
994
|
}
|
|
1022
995
|
}
|
|
1023
996
|
async validateWorkdir(workdir) {
|
|
1024
|
-
this.
|
|
1025
|
-
|
|
1026
|
-
const
|
|
1027
|
-
const reusablePreparation = {
|
|
1028
|
-
workdir,
|
|
1029
|
-
promise: preparedWorkspace,
|
|
1030
|
-
lease
|
|
1031
|
-
};
|
|
1032
|
-
this.preparedRemoteWorkspaceForNextExec = reusablePreparation;
|
|
997
|
+
if (this.params.execContext.config.mode === "mirror") return await this.runWorkspaceOperation(() => this.validateMirrorWorkdir(workdir));
|
|
998
|
+
await this.ensureSandboxExists();
|
|
999
|
+
const sshSession = await createOpenShellSshSession({ context: this.params.execContext });
|
|
1033
1000
|
try {
|
|
1034
|
-
await
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
const resolvedWorkdir = result.code === 0 ? result.stdout.toString("utf8").trim() : "";
|
|
1046
|
-
if (this.preparedRemoteWorkspaceForNextExec === reusablePreparation) {
|
|
1047
|
-
this.preparedRemoteWorkspaceForNextExec = resolvedWorkdir ? {
|
|
1048
|
-
workdir: resolvedWorkdir,
|
|
1049
|
-
promise: preparedWorkspace,
|
|
1050
|
-
lease
|
|
1051
|
-
} : null;
|
|
1052
|
-
if (!resolvedWorkdir) lease.release();
|
|
1053
|
-
} else lease.release();
|
|
1054
|
-
return resolvedWorkdir || null;
|
|
1055
|
-
} finally {
|
|
1056
|
-
await disposeSshSandboxSession(sshSession);
|
|
1057
|
-
}
|
|
1058
|
-
} catch (error) {
|
|
1059
|
-
if (this.preparedRemoteWorkspaceForNextExec?.lease === lease) this.preparedRemoteWorkspaceForNextExec = null;
|
|
1060
|
-
lease.release();
|
|
1061
|
-
throw error;
|
|
1001
|
+
const result = await runSshSandboxCommand({
|
|
1002
|
+
session: sshSession,
|
|
1003
|
+
remoteCommand: buildRemoteWorkdirValidationCommand({
|
|
1004
|
+
workdir,
|
|
1005
|
+
root: this.resolveWorkdirValidationRoot(workdir)
|
|
1006
|
+
}),
|
|
1007
|
+
allowFailure: true
|
|
1008
|
+
});
|
|
1009
|
+
return result.code === 0 ? result.stdout.toString("utf8").trim() || null : null;
|
|
1010
|
+
} finally {
|
|
1011
|
+
await disposeSshSandboxSession(sshSession);
|
|
1062
1012
|
}
|
|
1063
1013
|
}
|
|
1064
1014
|
resolveWorkdirValidationRoot(workdir) {
|
|
1065
1015
|
try {
|
|
1066
1016
|
const normalized = normalizeRemotePath(workdir);
|
|
1067
|
-
return [
|
|
1017
|
+
return resolveOpenShellWorkspaceRoot([{
|
|
1018
|
+
remote: normalizeRemotePath(this.params.remoteWorkspaceDir),
|
|
1019
|
+
owner: "workspace",
|
|
1020
|
+
value: void 0
|
|
1021
|
+
}, {
|
|
1022
|
+
remote: normalizeRemotePath(this.params.remoteAgentWorkspaceDir),
|
|
1023
|
+
owner: "agent",
|
|
1024
|
+
value: void 0
|
|
1025
|
+
}], normalized)?.remote ?? this.params.remoteWorkspaceDir;
|
|
1068
1026
|
} catch {
|
|
1069
1027
|
return this.params.remoteWorkspaceDir;
|
|
1070
1028
|
}
|
|
1071
1029
|
}
|
|
1072
|
-
|
|
1073
|
-
const
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1030
|
+
async validateMirrorWorkdir(workdir) {
|
|
1031
|
+
const normalized = normalizeRemotePath(workdir);
|
|
1032
|
+
const roots = this.workspaceUploadRoots();
|
|
1033
|
+
if (!roots.some((root) => isOpenShellRemotePathInside(root.remote, normalized))) return null;
|
|
1034
|
+
const { cfg, skillsWorkspaceDir } = this.params.createParams;
|
|
1035
|
+
if (cfg.workspaceAccess === "rw" && skillsWorkspaceDir) roots.push({
|
|
1036
|
+
remote: resolveRemoteMaterializedSkillsWorkspaceDir(this.params.remoteWorkspaceDir),
|
|
1037
|
+
local: skillsWorkspaceDir,
|
|
1038
|
+
owner: "workspace"
|
|
1039
|
+
});
|
|
1040
|
+
let createdAncestor = false;
|
|
1041
|
+
for (const root of roots.toReversed()) {
|
|
1042
|
+
if (!isOpenShellRemotePathInside(root.remote, normalized)) {
|
|
1043
|
+
createdAncestor ||= isOpenShellRemotePathInside(normalized, root.remote);
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
if (createdAncestor) return normalized;
|
|
1047
|
+
const relative = path.posix.relative(root.remote, normalized);
|
|
1048
|
+
if (!relative) return normalized;
|
|
1049
|
+
const parts = relative.split("/");
|
|
1050
|
+
if (DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS.some((excluded) => excluded === normalizeLowercaseStringOrEmpty(parts[0]))) return null;
|
|
1051
|
+
let local = root.local;
|
|
1052
|
+
for (const part of root.local === skillsWorkspaceDir ? ["", ...parts] : parts) {
|
|
1053
|
+
local = path.join(local, part);
|
|
1054
|
+
if (!(await fsPromises.lstat(local).catch(() => null))?.isDirectory()) return null;
|
|
1055
|
+
}
|
|
1056
|
+
return normalized;
|
|
1095
1057
|
}
|
|
1096
|
-
|
|
1058
|
+
return createdAncestor ? normalized : null;
|
|
1097
1059
|
}
|
|
1098
1060
|
async finalizeExec(token) {
|
|
1099
|
-
const workspaceLease = token?.workspaceLease ?? await this.acquireWorkspaceLease();
|
|
1061
|
+
const workspaceLease = token?.workspaceLease ?? (this.params.execContext.config.mode === "mirror" ? await this.acquireWorkspaceLease() : void 0);
|
|
1100
1062
|
try {
|
|
1101
1063
|
if (this.params.execContext.config.mode === "mirror") await this.syncWorkspaceFromRemote();
|
|
1102
1064
|
} finally {
|
|
1103
1065
|
try {
|
|
1104
|
-
|
|
1105
|
-
await token.cleanup();
|
|
1106
|
-
} finally {
|
|
1107
|
-
await disposeSshSandboxSession(token.sshSession);
|
|
1108
|
-
}
|
|
1066
|
+
await token?.cleanup();
|
|
1109
1067
|
} finally {
|
|
1110
|
-
workspaceLease
|
|
1068
|
+
workspaceLease?.release();
|
|
1111
1069
|
}
|
|
1112
1070
|
}
|
|
1113
1071
|
}
|
|
1114
1072
|
async runRemoteShellScript(params) {
|
|
1115
1073
|
await this.ensureSandboxExists();
|
|
1116
|
-
if (
|
|
1074
|
+
if (this.params.execContext.config.mode === "mirror") await this.maybeSeedRemoteWorkspace();
|
|
1117
1075
|
return await this.runRemoteShellScriptInternal(params);
|
|
1118
1076
|
}
|
|
1119
1077
|
async mkdirpRemotePath(remotePath, signal) {
|
|
@@ -1233,8 +1191,16 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
1233
1191
|
}
|
|
1234
1192
|
resolveRemoteTarget(remotePath) {
|
|
1235
1193
|
const normalized = normalizeRemotePath(remotePath);
|
|
1236
|
-
const
|
|
1237
|
-
|
|
1194
|
+
const root = resolveOpenShellWorkspaceRoot([{
|
|
1195
|
+
remote: normalizeRemotePath(this.params.remoteWorkspaceDir),
|
|
1196
|
+
owner: "workspace",
|
|
1197
|
+
value: void 0
|
|
1198
|
+
}, {
|
|
1199
|
+
remote: normalizeRemotePath(this.params.remoteAgentWorkspaceDir),
|
|
1200
|
+
owner: "agent",
|
|
1201
|
+
value: void 0
|
|
1202
|
+
}], normalized)?.remote;
|
|
1203
|
+
if (root) {
|
|
1238
1204
|
const relativePath = path.posix.relative(root, normalized);
|
|
1239
1205
|
return {
|
|
1240
1206
|
root,
|
|
@@ -1245,7 +1211,11 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
1245
1211
|
}
|
|
1246
1212
|
async ensureSandboxExists() {
|
|
1247
1213
|
if (this.ensurePromise) return await this.ensurePromise;
|
|
1248
|
-
|
|
1214
|
+
const initialize = async () => {
|
|
1215
|
+
await this.ensureSandboxExistsInner();
|
|
1216
|
+
if (this.params.execContext.config.mode === "remote" && !await this.maybeSeedRemoteWorkspace()) await this.syncSkillsWorkspaceToRemote();
|
|
1217
|
+
};
|
|
1218
|
+
this.ensurePromise = this.params.execContext.config.mode === "remote" ? this.runWorkspaceOperation(initialize) : initialize();
|
|
1249
1219
|
try {
|
|
1250
1220
|
await this.ensurePromise;
|
|
1251
1221
|
} catch (error) {
|
|
@@ -1369,18 +1339,37 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
1369
1339
|
`OpenClaw will not recreate this retired runtime name. Run \`${recreateCommand}\` to migrate this scope to the current naming format.`
|
|
1370
1340
|
].filter(Boolean).join(" "));
|
|
1371
1341
|
}
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1342
|
+
workspaceUploadRoots() {
|
|
1343
|
+
const { createParams, remoteWorkspaceDir, remoteAgentWorkspaceDir } = this.params;
|
|
1344
|
+
const roots = [{
|
|
1345
|
+
remote: remoteWorkspaceDir,
|
|
1346
|
+
owner: "workspace",
|
|
1347
|
+
value: createParams.workspaceDir
|
|
1348
|
+
}];
|
|
1349
|
+
if (createParams.cfg.workspaceAccess !== "none" && path.resolve(createParams.agentWorkspaceDir) !== path.resolve(createParams.workspaceDir)) roots.push({
|
|
1350
|
+
remote: remoteAgentWorkspaceDir,
|
|
1351
|
+
owner: "agent",
|
|
1352
|
+
value: createParams.agentWorkspaceDir
|
|
1376
1353
|
});
|
|
1377
|
-
|
|
1378
|
-
|
|
1354
|
+
return orderOpenShellWorkspaceRoots(roots).map(({ remote, owner, value: local }) => ({
|
|
1355
|
+
remote,
|
|
1356
|
+
local,
|
|
1357
|
+
owner
|
|
1358
|
+
}));
|
|
1359
|
+
}
|
|
1360
|
+
async syncWorkspaceToRemote() {
|
|
1361
|
+
const roots = this.workspaceUploadRoots();
|
|
1362
|
+
for (const [index, root] of roots.entries()) {
|
|
1363
|
+
const containingRoot = roots.slice(0, index).toReversed().find((candidate) => isOpenShellRemotePathInside(candidate.remote, root.remote));
|
|
1379
1364
|
await this.runRemoteShellScriptInternal({
|
|
1380
|
-
script:
|
|
1381
|
-
args: [
|
|
1365
|
+
script: `${ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT}\nfind "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +`,
|
|
1366
|
+
args: [
|
|
1367
|
+
root.remote,
|
|
1368
|
+
containingRoot?.remote ?? root.remote,
|
|
1369
|
+
containingRoot ? "1" : "0"
|
|
1370
|
+
]
|
|
1382
1371
|
});
|
|
1383
|
-
await this.uploadPathToRemote(
|
|
1372
|
+
await this.uploadPathToRemote(root.local, root.remote);
|
|
1384
1373
|
}
|
|
1385
1374
|
await this.syncSkillsWorkspaceToRemote();
|
|
1386
1375
|
}
|
|
@@ -1396,40 +1385,62 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
1396
1385
|
await this.uploadPathToRemote(this.params.createParams.skillsWorkspaceDir, remoteSkillsWorkspaceDir);
|
|
1397
1386
|
}
|
|
1398
1387
|
async syncWorkspaceFromRemote() {
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
this.params.execContext
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
workspaceDir: this.params.createParams.workspaceDir,
|
|
1418
|
-
tmpDir
|
|
1419
|
-
});
|
|
1420
|
-
try {
|
|
1421
|
-
await replaceDirectoryContents({
|
|
1422
|
-
sourceDir: tmpDir,
|
|
1423
|
-
targetDir: this.params.createParams.workspaceDir,
|
|
1424
|
-
excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS
|
|
1425
|
-
});
|
|
1426
|
-
} finally {
|
|
1427
|
-
await restoreMaterializedSkillsShadow({
|
|
1428
|
-
workspaceDir: this.params.createParams.workspaceDir,
|
|
1429
|
-
preserved: preservedSandboxSkills
|
|
1388
|
+
const remoteSkillsWorkspaceDir = resolveRemoteMaterializedSkillsWorkspaceDir(this.params.remoteWorkspaceDir);
|
|
1389
|
+
const roots = this.workspaceUploadRoots();
|
|
1390
|
+
for (const [index, root] of roots.entries()) {
|
|
1391
|
+
if (root.owner === "agent") continue;
|
|
1392
|
+
await withTempWorkspace({
|
|
1393
|
+
rootDir: resolveOpenShellTmpRoot(),
|
|
1394
|
+
prefix: "openclaw-openshell-sync-"
|
|
1395
|
+
}, async ({ dir: tmpDir }) => {
|
|
1396
|
+
const result = await runOpenShellCli({
|
|
1397
|
+
context: this.params.execContext,
|
|
1398
|
+
args: [
|
|
1399
|
+
"sandbox",
|
|
1400
|
+
"download",
|
|
1401
|
+
this.params.execContext.sandboxName,
|
|
1402
|
+
root.remote,
|
|
1403
|
+
tmpDir
|
|
1404
|
+
],
|
|
1405
|
+
cwd: this.params.createParams.workspaceDir
|
|
1430
1406
|
});
|
|
1431
|
-
|
|
1432
|
-
|
|
1407
|
+
if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox download failed");
|
|
1408
|
+
const preservedShadows = [];
|
|
1409
|
+
try {
|
|
1410
|
+
for (const shadowedRoot of roots.slice(index + 1)) {
|
|
1411
|
+
if (root.remote === shadowedRoot.remote || !isOpenShellRemotePathInside(root.remote, shadowedRoot.remote)) continue;
|
|
1412
|
+
const relativeParts = path.posix.relative(root.remote, shadowedRoot.remote).split("/").filter(Boolean);
|
|
1413
|
+
await removeDownloadedWorkspacePath(tmpDir, relativeParts);
|
|
1414
|
+
const preserved = await moveLocalShadowAside({
|
|
1415
|
+
workspaceDir: root.local,
|
|
1416
|
+
tmpDir,
|
|
1417
|
+
relativeParts
|
|
1418
|
+
});
|
|
1419
|
+
if (preserved) preservedShadows.push(preserved);
|
|
1420
|
+
}
|
|
1421
|
+
const relativeSkillsPath = path.posix.relative(root.remote, remoteSkillsWorkspaceDir);
|
|
1422
|
+
if (relativeSkillsPath === "" || !relativeSkillsPath.startsWith("../") && !path.posix.isAbsolute(relativeSkillsPath)) await removeDownloadedWorkspacePath(tmpDir, relativeSkillsPath.split("/").filter(Boolean));
|
|
1423
|
+
if (root.owner === "workspace") {
|
|
1424
|
+
const preserved = await moveLocalShadowAside({
|
|
1425
|
+
workspaceDir: root.local,
|
|
1426
|
+
tmpDir,
|
|
1427
|
+
relativeParts: MATERIALIZED_SKILLS_REMOTE_PARTS
|
|
1428
|
+
});
|
|
1429
|
+
if (preserved) preservedShadows.push(preserved);
|
|
1430
|
+
}
|
|
1431
|
+
await replaceDirectoryContents({
|
|
1432
|
+
sourceDir: tmpDir,
|
|
1433
|
+
targetDir: root.local,
|
|
1434
|
+
excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS
|
|
1435
|
+
});
|
|
1436
|
+
} finally {
|
|
1437
|
+
for (const preserved of preservedShadows.toReversed()) await restoreLocalShadow({
|
|
1438
|
+
workspaceDir: root.local,
|
|
1439
|
+
preserved
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1433
1444
|
}
|
|
1434
1445
|
async uploadPathToRemote(localPath, remotePath) {
|
|
1435
1446
|
await withTempWorkspace({
|
|
@@ -1443,8 +1454,7 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
1443
1454
|
targetDir: stagedRoot,
|
|
1444
1455
|
excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS
|
|
1445
1456
|
});
|
|
1446
|
-
const
|
|
1447
|
-
for (const entry of stagedEntries) {
|
|
1457
|
+
for (const entry of (await fsPromises.readdir(stagedRoot)).toSorted()) {
|
|
1448
1458
|
const result = await runOpenShellCli({
|
|
1449
1459
|
context: this.params.execContext,
|
|
1450
1460
|
args: buildOpenShellDirectoryUploadArgs({
|
|
@@ -1541,13 +1551,20 @@ function resolveRemoteMaterializedSkillsWorkspaceDir(remoteWorkspaceDir) {
|
|
|
1541
1551
|
const root = remoteWorkspaceDir.replace(/\\/g, "/").replace(/\/+$/, "") || "/";
|
|
1542
1552
|
return path.posix.join(root, ...MATERIALIZED_SKILLS_REMOTE_PARTS);
|
|
1543
1553
|
}
|
|
1544
|
-
async function
|
|
1554
|
+
async function removeDownloadedWorkspacePath(tmpDir, parts) {
|
|
1555
|
+
if (parts.length === 0) {
|
|
1556
|
+
for (const entry of await fsPromises.readdir(tmpDir)) await fsPromises.rm(path.join(tmpDir, entry), {
|
|
1557
|
+
recursive: true,
|
|
1558
|
+
force: true
|
|
1559
|
+
});
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1545
1562
|
let cursor = tmpDir;
|
|
1546
|
-
for (const [index, part] of
|
|
1563
|
+
for (const [index, part] of parts.entries()) {
|
|
1547
1564
|
const next = path.join(cursor, part);
|
|
1548
1565
|
const stats = await fsPromises.lstat(next).catch(() => null);
|
|
1549
1566
|
if (!stats) return;
|
|
1550
|
-
if (index ===
|
|
1567
|
+
if (index === parts.length - 1) {
|
|
1551
1568
|
await fsPromises.rm(next, {
|
|
1552
1569
|
recursive: true,
|
|
1553
1570
|
force: true
|
|
@@ -1564,31 +1581,31 @@ async function removeMaterializedSkillsFromDownloadedWorkspace(tmpDir) {
|
|
|
1564
1581
|
cursor = next;
|
|
1565
1582
|
}
|
|
1566
1583
|
}
|
|
1567
|
-
async function
|
|
1568
|
-
const shadowPath = path.join(params.workspaceDir, ...
|
|
1584
|
+
async function moveLocalShadowAside(params) {
|
|
1585
|
+
const shadowPath = path.join(params.workspaceDir, ...params.relativeParts);
|
|
1569
1586
|
const parentStats = await fsPromises.lstat(path.dirname(shadowPath)).catch(() => null);
|
|
1570
1587
|
if (!parentStats?.isDirectory() || parentStats.isSymbolicLink()) return;
|
|
1571
1588
|
const shadowStats = await fsPromises.lstat(shadowPath).catch(() => null);
|
|
1572
1589
|
if (!shadowStats || shadowStats.isSymbolicLink()) return;
|
|
1573
1590
|
const preserveRoot = await fsPromises.mkdtemp(path.join(path.dirname(params.tmpDir), "openclaw-openshell-preserve-"));
|
|
1574
|
-
const preservedPath = path.join(preserveRoot, "
|
|
1591
|
+
const preservedPath = path.join(preserveRoot, "shadow");
|
|
1575
1592
|
await movePathWithCopyFallback({
|
|
1576
1593
|
from: shadowPath,
|
|
1577
1594
|
to: preservedPath
|
|
1578
1595
|
});
|
|
1579
1596
|
return {
|
|
1580
1597
|
preservedPath,
|
|
1581
|
-
preserveRoot
|
|
1598
|
+
preserveRoot,
|
|
1599
|
+
relativeParts: params.relativeParts
|
|
1582
1600
|
};
|
|
1583
1601
|
}
|
|
1584
|
-
async function
|
|
1585
|
-
if (!params.preserved) return;
|
|
1602
|
+
async function restoreLocalShadow(params) {
|
|
1586
1603
|
let restored = false;
|
|
1587
1604
|
try {
|
|
1588
|
-
const shadowPath = path.join(params.workspaceDir, ...
|
|
1605
|
+
const shadowPath = path.join(params.workspaceDir, ...params.preserved.relativeParts);
|
|
1589
1606
|
const parentPath = path.dirname(shadowPath);
|
|
1590
1607
|
const parentStats = await fsPromises.lstat(parentPath).catch(() => null);
|
|
1591
|
-
if (parentStats?.isSymbolicLink()) throw new Error(`Refusing to restore
|
|
1608
|
+
if (parentStats?.isSymbolicLink()) throw new Error(`Refusing to restore workspace shadow through symlink parent: ${parentPath}`);
|
|
1592
1609
|
if (parentStats && !parentStats.isDirectory()) await fsPromises.rm(parentPath, {
|
|
1593
1610
|
recursive: true,
|
|
1594
1611
|
force: true
|
|
@@ -1618,10 +1635,6 @@ function normalizeRemotePath(remotePath) {
|
|
|
1618
1635
|
if (!path.posix.isAbsolute(normalized)) throw new Error(`OpenShell remote path must be absolute: ${remotePath}`);
|
|
1619
1636
|
return normalized;
|
|
1620
1637
|
}
|
|
1621
|
-
function isRemotePathInside(root, candidate) {
|
|
1622
|
-
const relative = path.posix.relative(root, candidate);
|
|
1623
|
-
return relative === "" || relative !== ".." && !relative.startsWith("../") && !path.posix.isAbsolute(relative);
|
|
1624
|
-
}
|
|
1625
1638
|
//#endregion
|
|
1626
1639
|
//#region extensions/openshell/index.ts
|
|
1627
1640
|
var openshell_default = definePluginEntry({
|
|
@@ -1632,11 +1645,18 @@ var openshell_default = definePluginEntry({
|
|
|
1632
1645
|
register(api) {
|
|
1633
1646
|
if (api.registrationMode !== "full") return;
|
|
1634
1647
|
const pluginConfig = resolveOpenShellPluginConfig(api.pluginConfig);
|
|
1635
|
-
registerSandboxBackend("openshell", {
|
|
1648
|
+
const unregister = registerSandboxBackend("openshell", {
|
|
1636
1649
|
factory: createOpenShellSandboxBackendFactory({ pluginConfig }),
|
|
1637
1650
|
manager: createOpenShellSandboxBackendManager({ pluginConfig }),
|
|
1638
1651
|
resolveWorkdir: () => pluginConfig.remoteWorkspaceDir
|
|
1639
1652
|
});
|
|
1653
|
+
api.lifecycle.registerRuntimeLifecycle({
|
|
1654
|
+
id: "openshell-sandbox-cleanup",
|
|
1655
|
+
cleanup: ({ reason, sessionKey, runId }) => {
|
|
1656
|
+
if (sessionKey !== void 0 || runId !== void 0) return;
|
|
1657
|
+
if (reason === "disable" || reason === "restart") unregister();
|
|
1658
|
+
}
|
|
1659
|
+
});
|
|
1640
1660
|
}
|
|
1641
1661
|
});
|
|
1642
1662
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/openshell-sandbox",
|
|
3
|
-
"version": "2026.9.1
|
|
3
|
+
"version": "2026.9.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",
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"minHostVersion": ">=2026.5.12-beta.1"
|
|
22
22
|
},
|
|
23
23
|
"compat": {
|
|
24
|
-
"pluginApi": ">=2026.9.1
|
|
24
|
+
"pluginApi": ">=2026.9.1"
|
|
25
25
|
},
|
|
26
26
|
"build": {
|
|
27
|
-
"openclawVersion": "2026.9.1
|
|
27
|
+
"openclawVersion": "2026.9.1",
|
|
28
28
|
"bundledDist": false
|
|
29
29
|
},
|
|
30
30
|
"release": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"README.md"
|
|
42
42
|
],
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"openclaw": ">=2026.9.1
|
|
44
|
+
"openclaw": ">=2026.9.1"
|
|
45
45
|
},
|
|
46
46
|
"peerDependenciesMeta": {
|
|
47
47
|
"openclaw": {
|