@openclaw/openshell-sandbox 2026.9.1-beta.1 → 2026.9.2

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.
Files changed (2) hide show
  1. package/dist/index.js +354 -334
  2. 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 !== "rw" || !target.writable) throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`);
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
- if (skillsRoot && this.sandbox.workspaceAccess === "rw") {
347
- const protectedSkillTarget = resolveProtectedSkillTarget({
348
- input,
349
- skillsRoot,
350
- skillsContainerRoot
351
- });
352
- if (protectedSkillTarget) return protectedSkillTarget;
353
- }
354
- if (input.startsWith(`${workspaceContainerRoot}/`) || input === workspaceContainerRoot) {
355
- const relative = path.posix.relative(workspaceContainerRoot, input) || "";
356
- const hostPath = relative ? path.resolve(workspaceRoot, ...relative.split("/")) : workspaceRoot;
357
- if (!isPathInside(workspaceRoot, hostPath)) throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${input}`);
358
- return {
359
- hostPath,
360
- relativePath: relative,
361
- containerPath: relative ? path.posix.join(workspaceContainerRoot, relative) : workspaceContainerRoot,
362
- mountHostRoot: workspaceRoot,
363
- writable: this.sandbox.workspaceAccess === "rw",
364
- source: "workspace"
365
- };
366
- }
367
- if (hasAgentMount && (input.startsWith(`${agentContainerRoot}/`) || input === agentContainerRoot)) {
368
- const relative = path.posix.relative(agentContainerRoot, input) || "";
369
- const hostPath = relative ? path.resolve(agentRoot, ...relative.split("/")) : agentRoot;
370
- if (!isPathInside(agentRoot, hostPath)) throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${input}`);
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 ? agentContainerRoot + "/" + relative : agentContainerRoot,
374
- containerPath: relative ? path.posix.join(agentContainerRoot, relative) : agentContainerRoot,
375
- mountHostRoot: agentRoot,
376
- writable: this.sandbox.workspaceAccess === "rw",
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
- if (skillsRoot && this.sandbox.workspaceAccess === "rw") {
383
- const protectedSkillShadowTarget = resolveProtectedSkillShadowTarget({
384
- hostPath,
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
- 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}`);
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(async () => await fsPromises.lstat(targetPath)).catch(() => null);
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 copyTreeWithoutSymlinks(params) {
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 (params.preserveTargetSymlinks && targetStats?.isSymbolicLink()) return;
598
- if (stats.isDirectory()) {
599
- await runLimitedFs(fsPromises.mkdir, params.targetPath, { recursive: true });
600
- const entries = await runLimitedFs(async () => await fsPromises.readdir(params.sourcePath));
601
- await Promise.all(entries.map(async (entry) => {
602
- await copyTreeWithoutSymlinks({
603
- sourcePath: path.join(params.sourcePath, entry),
604
- targetPath: path.join(params.targetPath, entry),
605
- preserveTargetSymlinks: params.preserveTargetSymlinks
606
- });
607
- }));
608
- return;
609
- }
610
- if (stats.isFile()) {
611
- await runLimitedFs(fsPromises.mkdir, path.dirname(params.targetPath), { recursive: true });
612
- await runLimitedFs(async () => await fsPromises.copyFile(params.sourcePath, params.targetPath));
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 replaceDirectoryContents(params) {
589
+ async function reconcileMirrorDirectory(params) {
590
+ const { sourceDir } = params;
616
591
  const isExcluded = createExcludeMatcher(params.excludeDirs);
617
- await fsPromises.mkdir(params.targetDir, { recursive: true });
618
- const existing = await fsPromises.readdir(params.targetDir);
619
- await Promise.all(existing.filter((entry) => !isExcluded(entry)).map(async (entry) => {
620
- const targetPath = path.join(params.targetDir, entry);
621
- if ((await lstatIfExists(targetPath))?.isSymbolicLink()) return;
622
- await runLimitedFs(fsPromises.rm, targetPath, {
623
- recursive: true,
624
- force: true
625
- });
626
- }));
627
- const sourceEntries = await fsPromises.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
- 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
- const isExcluded = createExcludeMatcher(params.excludeDirs);
639
- await fsPromises.mkdir(params.targetDir, { recursive: true });
640
- const sourceEntries = await fsPromises.readdir(params.sourceDir);
641
- for (const entry of sourceEntries) {
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 echo \"unsafe remote directory symlink: $next\" >&2; exit 1; fi",
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
- " if [ ! -d \"$next\" ]; then echo \"unsafe remote directory component: $next\" >&2; exit 1; fi",
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: async (command) => await this.runWorkspaceOperation(async () => await this.runRemoteShellScript(command)),
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: async (command) => await this.runWorkspaceOperation(async () => await this.runRemoteShellScript(command))
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:${path.resolve(this.params.createParams.workspaceDir)}`, `runtime:${JSON.stringify([
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
- try {
966
- for (const key of keys) {
967
- const acquired = createDeferred();
968
- const released = createDeferred();
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 preparedWorkspace = this.consumePreparedRemoteWorkspaceForNextExec(remoteWorkdir);
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 (preparedWorkspace?.promise ?? this.prepareRemoteWorkspaceForExec());
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
- sshSession,
1010
- cleanup: prepared.cleanup,
1011
- workspaceLease
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.release();
992
+ workspaceLease?.release();
1020
993
  throw error;
1021
994
  }
1022
995
  }
1023
996
  async validateWorkdir(workdir) {
1024
- this.discardPreparedRemoteWorkspace();
1025
- const lease = await this.acquireWorkspaceLease();
1026
- const preparedWorkspace = this.prepareRemoteWorkspaceForExec();
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 preparedWorkspace;
1035
- const sshSession = await createOpenShellSshSession({ context: this.params.execContext });
1036
- try {
1037
- const result = await runSshSandboxCommand({
1038
- session: sshSession,
1039
- remoteCommand: buildRemoteWorkdirValidationCommand({
1040
- workdir,
1041
- root: this.resolveWorkdirValidationRoot(workdir)
1042
- }),
1043
- allowFailure: true
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 [normalizeRemotePath(this.params.remoteAgentWorkspaceDir), normalizeRemotePath(this.params.remoteWorkspaceDir)].toSorted((a, b) => b.length - a.length).find((root) => isRemotePathInside(root, normalized)) ?? this.params.remoteWorkspaceDir;
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
- consumePreparedRemoteWorkspaceForNextExec(workdir) {
1073
- const preparedWorkspace = this.preparedRemoteWorkspaceForNextExec;
1074
- if (!preparedWorkspace || preparedWorkspace.workdir !== workdir) {
1075
- this.discardPreparedRemoteWorkspace();
1076
- return null;
1077
- }
1078
- this.preparedRemoteWorkspaceForNextExec = null;
1079
- return preparedWorkspace;
1080
- }
1081
- discardPreparedWorkdir(workdir) {
1082
- if (this.preparedRemoteWorkspaceForNextExec?.workdir === workdir) this.discardPreparedRemoteWorkspace();
1083
- }
1084
- discardPreparedRemoteWorkspace() {
1085
- const preparedWorkspace = this.preparedRemoteWorkspaceForNextExec;
1086
- if (!preparedWorkspace) return;
1087
- this.preparedRemoteWorkspaceForNextExec = null;
1088
- preparedWorkspace.promise.then(() => preparedWorkspace.lease.release(), () => preparedWorkspace.lease.release());
1089
- }
1090
- async prepareRemoteWorkspaceForExec() {
1091
- await this.ensureSandboxExists();
1092
- if (this.params.execContext.config.mode === "mirror") {
1093
- await this.syncWorkspaceToRemote();
1094
- return;
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
- if (!await this.maybeSeedRemoteWorkspace()) await this.syncSkillsWorkspaceToRemote();
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
- if (token?.sshSession) try {
1105
- await token.cleanup();
1106
- } finally {
1107
- await disposeSshSandboxSession(token.sshSession);
1108
- }
1066
+ await token?.cleanup();
1109
1067
  } finally {
1110
- workspaceLease.release();
1068
+ workspaceLease?.release();
1111
1069
  }
1112
1070
  }
1113
1071
  }
1114
1072
  async runRemoteShellScript(params) {
1115
1073
  await this.ensureSandboxExists();
1116
- if (!await this.maybeSeedRemoteWorkspace()) await this.syncSkillsWorkspaceToRemote();
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 roots = [normalizeRemotePath(this.params.remoteWorkspaceDir), normalizeRemotePath(this.params.remoteAgentWorkspaceDir)].toSorted((a, b) => b.length - a.length);
1237
- for (const root of roots) if (isRemotePathInside(root, normalized)) {
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
- this.ensurePromise = this.ensureSandboxExistsInner();
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
- async syncWorkspaceToRemote() {
1373
- await this.runRemoteShellScriptInternal({
1374
- script: "mkdir -p -- \"$1\" && find \"$1\" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +",
1375
- args: [this.params.remoteWorkspaceDir]
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
- await this.uploadPathToRemote(this.params.createParams.workspaceDir, this.params.remoteWorkspaceDir);
1378
- if (this.params.createParams.cfg.workspaceAccess !== "none" && path.resolve(this.params.createParams.agentWorkspaceDir) !== path.resolve(this.params.createParams.workspaceDir)) {
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: "mkdir -p -- \"$1\" && find \"$1\" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +",
1381
- args: [this.params.remoteAgentWorkspaceDir]
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(this.params.createParams.agentWorkspaceDir, this.params.remoteAgentWorkspaceDir);
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
- await withTempWorkspace({
1400
- rootDir: resolveOpenShellTmpRoot(),
1401
- prefix: "openclaw-openshell-sync-"
1402
- }, async ({ dir: tmpDir }) => {
1403
- const result = await runOpenShellCli({
1404
- context: this.params.execContext,
1405
- args: [
1406
- "sandbox",
1407
- "download",
1408
- this.params.execContext.sandboxName,
1409
- this.params.remoteWorkspaceDir,
1410
- tmpDir
1411
- ],
1412
- cwd: this.params.createParams.workspaceDir
1413
- });
1414
- if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox download failed");
1415
- await removeMaterializedSkillsFromDownloadedWorkspace(tmpDir);
1416
- const preservedSandboxSkills = await moveMaterializedSkillsShadowAside({
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 stagedEntries = (await fsPromises.readdir(stagedRoot)).toSorted();
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 removeMaterializedSkillsFromDownloadedWorkspace(tmpDir) {
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 MATERIALIZED_SKILLS_REMOTE_PARTS.entries()) {
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 === MATERIALIZED_SKILLS_REMOTE_PARTS.length - 1) {
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 moveMaterializedSkillsShadowAside(params) {
1568
- const shadowPath = path.join(params.workspaceDir, ...MATERIALIZED_SKILLS_REMOTE_PARTS);
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, "sandbox-skills");
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 restoreMaterializedSkillsShadow(params) {
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, ...MATERIALIZED_SKILLS_REMOTE_PARTS);
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 sandbox skills through symlink parent: ${parentPath}`);
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-beta.1",
3
+ "version": "2026.9.2",
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-beta.1"
24
+ "pluginApi": ">=2026.9.2"
25
25
  },
26
26
  "build": {
27
- "openclawVersion": "2026.9.1-beta.1",
27
+ "openclawVersion": "2026.9.2",
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-beta.1"
44
+ "openclaw": ">=2026.9.2"
45
45
  },
46
46
  "peerDependenciesMeta": {
47
47
  "openclaw": {