@openclaw/openshell-sandbox 2026.8.1-beta.3 → 2026.8.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.
package/README.md CHANGED
@@ -2,7 +2,13 @@
2
2
 
3
3
  Official NVIDIA OpenShell sandbox backend for OpenClaw.
4
4
 
5
- This plugin lets OpenClaw use OpenShell-managed sandboxes with mirrored local workspaces and SSH command execution.
5
+ This plugin lets OpenClaw use OpenShell-managed local or remote sandboxes with
6
+ SSH command execution. Choose `mirror` mode for a synchronized local workspace
7
+ or `remote` mode for a remote-canonical workspace.
8
+
9
+ Mirror operations sharing a workspace run sequentially so concurrent agent
10
+ turns cannot overwrite one another. Outbound attachments resolve against the
11
+ configured remote workspace, which defaults to `/sandbox`.
6
12
 
7
13
  Configuring an OpenShell workspace requires OpenShell `v0.0.88` or newer. The
8
14
  plugin supports OpenShell control-plane workspaces through
@@ -22,6 +28,23 @@ Restart the Gateway after installing or updating the plugin.
22
28
 
23
29
  ## Configure
24
30
 
31
+ Install and configure the OpenShell CLI before enabling the backend. As the same
32
+ operating system user that runs the OpenClaw Gateway, verify:
33
+
34
+ ```bash
35
+ openshell --version
36
+ openshell gateway list
37
+ openshell sandbox list
38
+ ```
39
+
40
+ Set `agents.defaults.sandbox.backend` to `"openshell"`, enable
41
+ `plugins.entries.openshell`, and restart the OpenClaw Gateway. OpenShell
42
+ settings belong under `plugins.entries.openshell.config`.
43
+
44
+ The optional `policy` setting must be the path to a readable OpenShell policy
45
+ YAML file on the Gateway host; it is not a policy name or ID. Use an absolute
46
+ path to avoid resolving it relative to an agent workspace.
47
+
25
48
  Use the OpenShell docs for credentials, workspace mirroring, runtime selection, and troubleshooting:
26
49
 
27
50
  - https://docs.openclaw.ai/gateway/openshell
package/dist/index.js CHANGED
@@ -1,15 +1,16 @@
1
1
  import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
2
- import { buildRemoteCommand, buildRemoteWorkdirValidationCommand, buildValidatedExecRemoteCommand, createRemoteShellSandboxFsBridge, createSshSandboxSessionFromConfigText, createWritableRenameTargetResolver, disposeSshSandboxSession, registerSandboxBackend, resolvePreferredOpenClawTmpDir, runPluginCommandWithTimeout, runSshSandboxCommand, sanitizeEnvVars, shellEscape, withTempWorkspace } from "openclaw/plugin-sdk/sandbox";
2
+ import { buildRemoteCommand, buildRemoteWorkdirValidationCommand, buildValidatedExecRemoteCommand, createRemoteShellSandboxFsBridge, createSshSandboxSessionFromConfigText, createWritableRenameTargetResolver, disposeSshSandboxSession, prepareSshSandboxExec, registerSandboxBackend, resolvePreferredOpenClawTmpDir, 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
+ import { createDeferred, formatPluginConfigIssue, mapPluginConfigIssues } from "openclaw/plugin-sdk/extension-shared";
7
+ import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
8
+ import { FsSafeError, canonicalPathFromExistingAncestor, extractErrorCode, movePathWithCopyFallback } from "openclaw/plugin-sdk/security-runtime";
6
9
  import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
7
10
  import { buildPluginConfigSchema } from "openclaw/plugin-sdk/core";
8
- import { formatPluginConfigIssue, mapPluginConfigIssues } from "openclaw/plugin-sdk/extension-shared";
9
11
  import { MAX_TIMER_TIMEOUT_SECONDS } from "openclaw/plugin-sdk/number-runtime";
10
12
  import { z } from "zod";
11
13
  import { isPathInside, root } from "openclaw/plugin-sdk/file-access-runtime";
12
- import { FsSafeError, movePathWithCopyFallback } from "openclaw/plugin-sdk/security-runtime";
13
14
  import pLimit from "p-limit";
14
15
  //#region extensions/openshell/src/cli.ts
15
16
  function buildOpenShellBaseArgv(config) {
@@ -72,6 +73,7 @@ function normalizeProviders(value) {
72
73
  return providers;
73
74
  }
74
75
  const nonEmptyTrimmedString = (message) => z.string({ error: message }).trim().min(1, { error: message });
76
+ const openShellManagedRemotePath = (fieldName) => nonEmptyTrimmedString(`${fieldName} must be a non-empty string`).regex(/^\/(?:sandbox|agent)(?:\/|$)/, { error: (issue) => String(issue.input).startsWith("/") ? `OpenShell ${fieldName} must stay under /sandbox or /agent` : `OpenShell ${fieldName} must be absolute` }).refine((value) => isManagedOpenShellRemotePath(path.posix.normalize(value)), { error: `OpenShell ${fieldName} must stay under /sandbox or /agent` });
75
77
  const openShellWorkspaceName = z.string({ error: "workspace must be a valid OpenShell workspace name" }).trim().min(1, { error: "workspace must be a valid OpenShell workspace name" }).max(19, { error: "workspace must be at most 19 characters" }).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { error: "workspace must contain lowercase alphanumeric characters or single hyphens and must not start or end with a hyphen" });
76
78
  const OpenShellPluginConfigSchema = z.strictObject({
77
79
  mode: z.enum(["mirror", "remote"], { error: "mode must be one of mirror, remote" }).optional(),
@@ -84,8 +86,8 @@ const OpenShellPluginConfigSchema = z.strictObject({
84
86
  providers: z.array(z.string({ error: "providers must be an array of strings" }).trim().min(1, { error: "providers must be an array of strings" }), { error: "providers must be an array of strings" }).optional(),
85
87
  gpu: z.boolean({ error: "gpu must be a boolean" }).optional(),
86
88
  autoProviders: z.boolean({ error: "autoProviders must be a boolean" }).optional(),
87
- remoteWorkspaceDir: nonEmptyTrimmedString("remoteWorkspaceDir must be a non-empty string").optional(),
88
- remoteAgentWorkspaceDir: nonEmptyTrimmedString("remoteAgentWorkspaceDir must be a non-empty string").optional(),
89
+ remoteWorkspaceDir: openShellManagedRemotePath("remoteWorkspaceDir").optional(),
90
+ remoteAgentWorkspaceDir: openShellManagedRemotePath("remoteAgentWorkspaceDir").optional(),
89
91
  timeoutSeconds: z.number({ error: `timeoutSeconds must be a number between 1 and ${MAX_TIMER_TIMEOUT_SECONDS}` }).min(1, { error: "timeoutSeconds must be a number >= 1" }).max(MAX_TIMER_TIMEOUT_SECONDS, { error: `timeoutSeconds must be a number <= ${MAX_TIMER_TIMEOUT_SECONDS}` }).optional()
90
92
  });
91
93
  function isManagedOpenShellRemotePath(value) {
@@ -154,6 +156,24 @@ function resolveOpenShellPluginConfig(value) {
154
156
  };
155
157
  }
156
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
157
177
  //#region extensions/openshell/src/fs-bridge.ts
158
178
  const MATERIALIZED_SKILLS_CONTAINER_PARTS = [
159
179
  ".openclaw",
@@ -349,27 +369,43 @@ var OpenShellFsBridge = class {
349
369
  });
350
370
  if (protectedSkillTarget) return protectedSkillTarget;
351
371
  }
352
- if (input.startsWith(`${workspaceContainerRoot}/`) || input === workspaceContainerRoot) {
353
- const relative = path.posix.relative(workspaceContainerRoot, input) || "";
354
- return {
355
- hostPath: relative ? path.resolve(workspaceRoot, ...relative.split("/")) : workspaceRoot,
356
- relativePath: relative,
357
- containerPath: relative ? path.posix.join(workspaceContainerRoot, relative) : workspaceContainerRoot,
358
- mountHostRoot: workspaceRoot,
359
- writable: this.sandbox.workspaceAccess === "rw",
360
- source: "workspace"
361
- };
362
- }
363
- if (hasAgentMount && (input.startsWith(`${agentContainerRoot}/`) || input === agentContainerRoot)) {
364
- const relative = path.posix.relative(agentContainerRoot, input) || "";
372
+ const containerMounts = [{
373
+ remote: workspaceContainerRoot,
374
+ owner: "workspace",
375
+ value: {
376
+ hostRoot: workspaceRoot,
377
+ writable: this.sandbox.workspaceAccess === "rw"
378
+ }
379
+ }, ...hasAgentMount ? [{
380
+ remote: agentContainerRoot,
381
+ owner: "agent",
382
+ value: {
383
+ hostRoot: agentRoot,
384
+ writable: this.sandbox.workspaceAccess === "rw"
385
+ }
386
+ }] : []];
387
+ const resolveContainerTarget = (containerPath) => {
388
+ const containerMount = resolveOpenShellWorkspaceRoot(containerMounts, containerPath);
389
+ if (!containerMount) return;
390
+ const relative = path.posix.relative(containerMount.remote, containerPath) || "";
391
+ const hostPath = relative ? path.resolve(containerMount.value.hostRoot, ...relative.split("/")) : containerMount.value.hostRoot;
392
+ if (!isPathInside(containerMount.value.hostRoot, hostPath)) throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${input}`);
365
393
  return {
366
- hostPath: relative ? path.resolve(agentRoot, ...relative.split("/")) : agentRoot,
367
- relativePath: relative ? agentContainerRoot + "/" + relative : agentContainerRoot,
368
- containerPath: relative ? path.posix.join(agentContainerRoot, relative) : agentContainerRoot,
369
- mountHostRoot: agentRoot,
370
- writable: this.sandbox.workspaceAccess === "rw",
371
- source: "agent"
394
+ hostPath,
395
+ relativePath: containerMount.owner === "agent" ? relative ? containerMount.remote + "/" + relative : containerMount.remote : relative,
396
+ containerPath: relative ? path.posix.join(containerMount.remote, relative) : containerMount.remote,
397
+ mountHostRoot: containerMount.value.hostRoot,
398
+ writable: containerMount.value.writable,
399
+ source: containerMount.owner
372
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}`);
373
409
  }
374
410
  const cwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot;
375
411
  const hostPath = path.isAbsolute(input) ? path.resolve(input) : path.resolve(cwd, input);
@@ -384,14 +420,7 @@ var OpenShellFsBridge = class {
384
420
  }
385
421
  if (isPathInside(workspaceRoot, hostPath)) {
386
422
  const relative = path.relative(workspaceRoot, hostPath).split(path.sep).join(path.posix.sep);
387
- return {
388
- hostPath,
389
- relativePath: relative,
390
- containerPath: relative ? path.posix.join(workspaceContainerRoot, relative) : workspaceContainerRoot,
391
- mountHostRoot: workspaceRoot,
392
- writable: this.sandbox.workspaceAccess === "rw",
393
- source: "workspace"
394
- };
423
+ return expectResolvedContainerTarget(resolveContainerTarget(path.posix.join(workspaceContainerRoot, relative)), input);
395
424
  }
396
425
  if (skillsRoot && this.sandbox.workspaceAccess === "rw" && isPathInside(skillsRoot, hostPath)) {
397
426
  const relative = path.relative(skillsRoot, hostPath).split(path.sep).join(path.posix.sep);
@@ -406,18 +435,15 @@ var OpenShellFsBridge = class {
406
435
  }
407
436
  if (hasAgentMount && isPathInside(agentRoot, hostPath)) {
408
437
  const relative = path.relative(agentRoot, hostPath).split(path.sep).join(path.posix.sep);
409
- return {
410
- hostPath,
411
- relativePath: relative ? `${agentContainerRoot}/${relative}` : agentContainerRoot,
412
- containerPath: relative ? path.posix.join(agentContainerRoot, relative) : agentContainerRoot,
413
- mountHostRoot: agentRoot,
414
- writable: this.sandbox.workspaceAccess === "rw",
415
- source: "agent"
416
- };
438
+ return expectResolvedContainerTarget(resolveContainerTarget(path.posix.join(agentContainerRoot, relative)), input);
417
439
  }
418
440
  throw new Error(`Path escapes sandbox root (${workspaceRoot}): ${params.filePath}`);
419
441
  }
420
442
  };
443
+ function expectResolvedContainerTarget(target, input) {
444
+ if (!target) throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${input}`);
445
+ return target;
446
+ }
421
447
  async function mkdirLocalRootPath(params) {
422
448
  const relativePath = relativeToRoot(params.target, params.hostPath);
423
449
  if (!relativePath) return;
@@ -540,7 +566,8 @@ async function assertLocalPathSafety(params) {
540
566
  if (!params.target.hostPath) throw new Error(`Missing local host path for ${params.target.containerPath}`);
541
567
  const canonicalRoot = await fsPromises.realpath(params.root).catch(() => path.resolve(params.root));
542
568
  const targetStats = await fsPromises.lstat(params.target.hostPath).catch(() => null);
543
- 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}`);
569
+ const candidate = params.allowFinalSymlinkForUnlink && targetStats?.isSymbolicLink() ? path.resolve(canonicalRoot, path.relative(params.root, params.target.hostPath)) : await resolveCanonicalCandidate(params.target.hostPath);
570
+ if (!isPathInside(canonicalRoot, candidate)) throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${params.target.containerPath}`);
544
571
  const segments = path.relative(params.root, params.target.hostPath).split(path.sep).filter(Boolean);
545
572
  let cursor = params.root;
546
573
  for (const [index, segment] of segments.entries()) {
@@ -582,66 +609,65 @@ function createExcludeMatcher(excludeDirs) {
582
609
  }
583
610
  const runLimitedFs = pLimit(COPY_TREE_FS_CONCURRENCY);
584
611
  async function lstatIfExists(targetPath) {
585
- return await runLimitedFs(async () => await fsPromises.lstat(targetPath)).catch(() => null);
612
+ return await runLimitedFs(fsPromises.lstat, targetPath).catch((error) => {
613
+ if (extractErrorCode(error) === "ENOENT") return null;
614
+ throw error;
615
+ });
586
616
  }
587
- async function copyTreeWithoutSymlinks(params) {
588
- const stats = await runLimitedFs(async () => await fsPromises.lstat(params.sourcePath));
589
- if (stats.isSymbolicLink()) return;
617
+ async function reconcileMirrorPath(params) {
590
618
  const targetStats = await lstatIfExists(params.targetPath);
591
- if (params.preserveTargetSymlinks && targetStats?.isSymbolicLink()) return;
592
- if (stats.isDirectory()) {
593
- await runLimitedFs(fsPromises.mkdir, params.targetPath, { recursive: true });
594
- const entries = await runLimitedFs(async () => await fsPromises.readdir(params.sourcePath));
595
- await Promise.all(entries.map(async (entry) => {
596
- await copyTreeWithoutSymlinks({
597
- sourcePath: path.join(params.sourcePath, entry),
598
- targetPath: path.join(params.targetPath, entry),
599
- preserveTargetSymlinks: params.preserveTargetSymlinks
600
- });
601
- }));
602
- return;
603
- }
604
- if (stats.isFile()) {
605
- await runLimitedFs(fsPromises.mkdir, path.dirname(params.targetPath), { recursive: true });
606
- await runLimitedFs(async () => await fsPromises.copyFile(params.sourcePath, params.targetPath));
607
- }
619
+ if (targetStats && !targetStats.isDirectory() && !targetStats.isFile()) return true;
620
+ const sourceStats = params.sourcePath ? await runLimitedFs(fsPromises.lstat, params.sourcePath) : null;
621
+ const sourceDir = sourceStats?.isDirectory() ? params.sourcePath : void 0;
622
+ const sourceFile = sourceStats?.isFile() ? params.sourcePath : void 0;
623
+ if (!params.replace && !sourceDir && !sourceFile) return false;
624
+ if (sourceDir || targetStats?.isDirectory()) {
625
+ if (targetStats && !targetStats.isDirectory()) await runLimitedFs(fsPromises.rm, params.targetPath, { force: true });
626
+ const preservedEntries = await reconcileMirrorDirectory({
627
+ sourceDir,
628
+ targetDir: params.targetPath,
629
+ replace: params.replace
630
+ });
631
+ if (sourceDir || preservedEntries) return preservedEntries;
632
+ await runLimitedFs(fsPromises.rmdir, params.targetPath);
633
+ } else if (targetStats) await runLimitedFs(fsPromises.rm, params.targetPath, { force: true });
634
+ if (sourceFile) await runLimitedFs(fsPromises.copyFile, sourceFile, params.targetPath);
635
+ return false;
608
636
  }
609
- async function replaceDirectoryContents(params) {
637
+ async function reconcileMirrorDirectory(params) {
638
+ const { sourceDir } = params;
610
639
  const isExcluded = createExcludeMatcher(params.excludeDirs);
611
- await fsPromises.mkdir(params.targetDir, { recursive: true });
612
- const existing = await fsPromises.readdir(params.targetDir);
613
- await Promise.all(existing.filter((entry) => !isExcluded(entry)).map(async (entry) => {
614
- const targetPath = path.join(params.targetDir, entry);
615
- if ((await lstatIfExists(targetPath))?.isSymbolicLink()) return;
616
- await runLimitedFs(fsPromises.rm, targetPath, {
617
- recursive: true,
618
- force: true
619
- });
620
- }));
621
- const sourceEntries = await fsPromises.readdir(params.sourceDir);
622
- for (const entry of sourceEntries) {
623
- if (isExcluded(entry)) continue;
624
- await copyTreeWithoutSymlinks({
625
- sourcePath: path.join(params.sourceDir, entry),
626
- targetPath: path.join(params.targetDir, entry),
627
- preserveTargetSymlinks: true
628
- });
640
+ await runLimitedFs(fsPromises.mkdir, params.targetDir, { recursive: true });
641
+ const sourceEntries = new Set(sourceDir ? await runLimitedFs(async () => await fsPromises.readdir(sourceDir)) : []);
642
+ const targetEntries = params.replace ? await runLimitedFs(async () => await fsPromises.readdir(params.targetDir)) : [];
643
+ const results = await Promise.allSettled([.../* @__PURE__ */ new Set([...sourceEntries, ...targetEntries])].filter((entry) => !isExcluded(entry)).map((entry) => reconcileMirrorPath({
644
+ sourcePath: sourceDir && sourceEntries.has(entry) ? path.join(sourceDir, entry) : void 0,
645
+ targetPath: path.join(params.targetDir, entry),
646
+ replace: params.replace
647
+ })));
648
+ let preservedEntries = false;
649
+ for (const result of results) {
650
+ if (result.status === "rejected") throw result.reason;
651
+ preservedEntries ||= result.value;
629
652
  }
653
+ return preservedEntries;
654
+ }
655
+ async function replaceDirectoryContents(params) {
656
+ await reconcileMirrorDirectory({
657
+ ...params,
658
+ replace: true
659
+ });
630
660
  }
631
661
  async function stageDirectoryContents(params) {
632
- const isExcluded = createExcludeMatcher(params.excludeDirs);
633
- await fsPromises.mkdir(params.targetDir, { recursive: true });
634
- const sourceEntries = await fsPromises.readdir(params.sourceDir);
635
- for (const entry of sourceEntries) {
636
- if (isExcluded(entry)) continue;
637
- await copyTreeWithoutSymlinks({
638
- sourcePath: path.join(params.sourceDir, entry),
639
- targetPath: path.join(params.targetDir, entry)
640
- });
641
- }
662
+ await reconcileMirrorDirectory({
663
+ ...params,
664
+ replace: false
665
+ });
642
666
  }
643
667
  //#endregion
644
668
  //#region extensions/openshell/src/backend.ts
669
+ const openShellWorkspaceOperations = new KeyedAsyncQueue();
670
+ let openShellDetachedCreateSupport;
645
671
  const MATERIALIZED_SKILLS_REMOTE_PARTS = [".openclaw", "sandbox-skills"];
646
672
  function buildOpenShellDirectoryUploadArgs(params) {
647
673
  return [
@@ -650,9 +676,10 @@ function buildOpenShellDirectoryUploadArgs(params) {
650
676
  "--no-git-ignore",
651
677
  params.sandboxName,
652
678
  params.localPath,
653
- normalizeRemotePath(params.remotePath)
679
+ `${normalizeRemotePath(params.remotePath)}/`
654
680
  ];
655
681
  }
682
+ const REMOTE_MANAGED_ROOTS_EMPTY_SCRIPT = "for root in \"$@\"; do if [ -d \"$root\" ] && [ -n \"$(ls -A \"$root\")\" ]; then printf \"1\\n\"; exit 0; fi; done; printf \"0\\n\"";
656
683
  const PINNED_REMOTE_PATH_MUTATION_SCRIPT = [
657
684
  "set -eu",
658
685
  "die() { echo \"$1\" >&2; exit 1; }",
@@ -758,6 +785,7 @@ const ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT = [
758
785
  "set -e",
759
786
  "target=\"$1\"",
760
787
  "root=\"${2:-$1}\"",
788
+ "replace_blocking=\"${3:-0}\"",
761
789
  "case \"$target\" in /*) ;; *) echo \"remote directory must be absolute: $target\" >&2; exit 1 ;; esac",
762
790
  "case \"$root\" in /*) ;; *) echo \"remote root must be absolute: $root\" >&2; exit 1 ;; esac",
763
791
  "target=\"${target%/}\"",
@@ -785,9 +813,15 @@ const ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT = [
785
813
  " if [ \"$part\" = \"$relative\" ]; then relative=\"\"; else relative=\"${relative#*/}\"; fi",
786
814
  " [ -n \"$part\" ] || continue",
787
815
  " if [ \"$current\" = \"/\" ]; then next=\"/$part\"; else next=\"$current/$part\"; fi",
788
- " if [ -L \"$next\" ]; then echo \"unsafe remote directory symlink: $next\" >&2; exit 1; fi",
816
+ " if [ -L \"$next\" ]; then",
817
+ " if [ \"$replace_blocking\" != \"1\" ]; then echo \"unsafe remote directory symlink: $next\" >&2; exit 1; fi",
818
+ " rm -rf -- \"$next\"",
819
+ " elif [ -e \"$next\" ] && [ ! -d \"$next\" ]; then",
820
+ " if [ \"$replace_blocking\" != \"1\" ]; then echo \"unsafe remote directory component: $next\" >&2; exit 1; fi",
821
+ " rm -rf -- \"$next\"",
822
+ " fi",
789
823
  " if [ -e \"$next\" ]; then",
790
- " if [ ! -d \"$next\" ]; then echo \"unsafe remote directory component: $next\" >&2; exit 1; fi",
824
+ " [ -d \"$next\" ] || { echo \"unsafe remote directory component: $next\" >&2; exit 1; }",
791
825
  " else",
792
826
  " mkdir -- \"$next\"",
793
827
  " fi",
@@ -815,20 +849,22 @@ function createOpenShellSandboxBackendManager(params) {
815
849
  args: [
816
850
  "sandbox",
817
851
  "get",
818
- entry.containerName
852
+ entry.containerName,
853
+ "--output",
854
+ "json"
819
855
  ]
820
856
  });
821
857
  const configuredSource = execContext.config.from;
822
858
  return {
823
- running: result.code === 0,
859
+ running: result.code === 0 && parseOpenShellSandboxPhase(result.stdout) === "Ready",
824
860
  actualConfigLabel: entry.image,
825
861
  configLabelMatch: entry.image === configuredSource
826
862
  };
827
863
  },
828
- async removeRuntime({ entry }) {
829
- await runOpenShellCli({
864
+ async removeRuntime({ entry, config }) {
865
+ const result = await runOpenShellCli({
830
866
  context: {
831
- config: params.pluginConfig,
867
+ config: resolveOpenShellPluginConfigFromConfig(config, params.pluginConfig),
832
868
  sandboxName: entry.containerName
833
869
  },
834
870
  args: [
@@ -837,6 +873,7 @@ function createOpenShellSandboxBackendManager(params) {
837
873
  entry.containerName
838
874
  ]
839
875
  });
876
+ if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox delete failed");
840
877
  }
841
878
  };
842
879
  }
@@ -864,11 +901,11 @@ var OpenShellSandboxBackendImpl = class {
864
901
  this.params = params;
865
902
  this.handle = null;
866
903
  this.ensurePromise = null;
867
- this.preparedRemoteWorkspaceForNextExec = null;
868
904
  this.remoteSeedPending = false;
869
905
  }
870
906
  asHandle() {
871
907
  if (this.handle) return this.handle;
908
+ const runRemoteShellScript = (command) => this.params.execContext.config.mode === "mirror" ? this.runWorkspaceOperation(() => this.runRemoteShellScript(command), command.signal) : this.runRemoteShellScript(command);
872
909
  const handle = {
873
910
  id: "openshell",
874
911
  runtimeId: this.params.execContext.sandboxName,
@@ -880,7 +917,6 @@ var OpenShellSandboxBackendImpl = class {
880
917
  configLabelKind: "Source",
881
918
  workdirValidation: "backend",
882
919
  validateWorkdir: async (workdir) => await this.validateWorkdir(workdir),
883
- discardPreparedWorkdir: (workdir) => this.discardPreparedWorkdir(workdir),
884
920
  workdirRoots: [this.params.remoteWorkspaceDir, this.params.remoteAgentWorkspaceDir],
885
921
  remoteWorkspaceDir: this.params.remoteWorkspaceDir,
886
922
  remoteAgentWorkspaceDir: this.params.remoteAgentWorkspaceDir,
@@ -901,126 +937,189 @@ var OpenShellSandboxBackendImpl = class {
901
937
  finalizeExec: async ({ token }) => {
902
938
  await this.finalizeExec(token);
903
939
  },
904
- runShellCommand: async (command) => await this.runRemoteShellScript(command),
940
+ runShellCommand: runRemoteShellScript,
905
941
  createFsBridge: ({ sandbox }) => this.params.execContext.config.mode === "remote" ? createRemoteShellSandboxFsBridge({
906
942
  sandbox,
907
943
  runtime: handle
908
- }) : createOpenShellFsBridge({
909
- sandbox,
910
- backend: handle
911
- }),
912
- runRemoteShellScript: async (command) => await this.runRemoteShellScript(command),
913
- mkdirpRemotePath: async (remotePath, signal) => await this.mkdirpRemotePath(remotePath, signal),
914
- removeRemotePath: async (remotePath, removeParams) => await this.removeRemotePath(remotePath, removeParams),
915
- renameRemotePath: async (fromRemotePath, toRemotePath, signal) => await this.renameRemotePath(fromRemotePath, toRemotePath, signal),
916
- syncLocalPathToRemote: async (localPath, remotePath) => await this.syncLocalPathToRemote(localPath, remotePath)
944
+ }) : this.createMirrorFsBridge(sandbox),
945
+ runRemoteShellScript
917
946
  };
918
947
  this.handle = handle;
919
948
  return handle;
920
949
  }
950
+ createMirrorFsBridge(sandbox) {
951
+ const bridge = createOpenShellFsBridge({
952
+ sandbox,
953
+ backend: {
954
+ remoteAgentWorkspaceDir: this.params.remoteAgentWorkspaceDir,
955
+ mkdirpRemotePath: (remotePath, signal) => this.mkdirpRemotePath(remotePath, signal),
956
+ removeRemotePath: (remotePath, params) => this.removeRemotePath(remotePath, params),
957
+ renameRemotePath: (from, to, signal) => this.renameRemotePath(from, to, signal),
958
+ syncLocalPathToRemote: (localPath, remotePath) => this.syncLocalPathToRemote(localPath, remotePath)
959
+ }
960
+ });
961
+ return {
962
+ resolvePath: (params) => bridge.resolvePath(params),
963
+ readFile: (params) => this.runWorkspaceOperation(() => bridge.readFile(params), params.signal),
964
+ writeFile: (params) => this.runWorkspaceOperation(() => bridge.writeFile(params), params.signal),
965
+ createFileExclusive: (params) => this.runWorkspaceOperation(() => bridge.createFileExclusive(params), params.signal),
966
+ mkdirp: (params) => this.runWorkspaceOperation(() => bridge.mkdirp(params), params.signal),
967
+ remove: (params) => this.runWorkspaceOperation(() => bridge.remove(params), params.signal),
968
+ rename: (params) => this.runWorkspaceOperation(() => bridge.rename(params), params.signal),
969
+ stat: (params) => this.runWorkspaceOperation(() => bridge.stat(params), params.signal)
970
+ };
971
+ }
972
+ async runWorkspaceOperation(operation, signal) {
973
+ signal?.throwIfAborted();
974
+ const lease = await this.acquireWorkspaceLease();
975
+ try {
976
+ signal?.throwIfAborted();
977
+ return await operation();
978
+ } finally {
979
+ lease.release();
980
+ }
981
+ }
982
+ async acquireWorkspaceLease() {
983
+ const { config, sandboxName } = this.params.execContext;
984
+ const keys = [`host:${await canonicalPathFromExistingAncestor(this.params.createParams.workspaceDir)}`, `runtime:${JSON.stringify([
985
+ config.gatewayEndpoint ?? "",
986
+ config.gateway ?? "",
987
+ config.workspace ?? process.env.OPENSHELL_WORKSPACE ?? "",
988
+ sandboxName
989
+ ])}`].toSorted();
990
+ const releases = [];
991
+ for (const key of keys) {
992
+ const acquired = createDeferred();
993
+ openShellWorkspaceOperations.enqueue(key, () => new Promise(acquired.resolve));
994
+ releases.push(await acquired.promise);
995
+ }
996
+ return { release: () => {
997
+ for (const release of releases.toReversed()) release();
998
+ } };
999
+ }
921
1000
  async prepareExec(params) {
922
1001
  const remoteWorkdir = params.workdir ?? this.params.remoteWorkspaceDir;
923
- const preparedWorkspace = this.consumePreparedRemoteWorkspaceForNextExec(remoteWorkdir);
924
1002
  const remoteCommand = buildValidatedExecRemoteCommand({
925
1003
  command: params.command,
926
1004
  workdir: remoteWorkdir,
927
- env: params.env
1005
+ env: {}
928
1006
  });
929
- await (preparedWorkspace ?? this.prepareRemoteWorkspaceForExec());
930
- const sshSession = await createOpenShellSshSession({ context: this.params.execContext });
931
- return {
932
- argv: [
933
- "ssh",
934
- "-F",
935
- sshSession.configPath,
936
- ...params.usePty ? [
937
- "-tt",
938
- "-o",
939
- "RequestTTY=force",
940
- "-o",
941
- "SetEnv=TERM=xterm-256color"
942
- ] : [
943
- "-T",
944
- "-o",
945
- "RequestTTY=no"
946
- ],
947
- sshSession.host,
948
- remoteCommand
949
- ],
950
- token: { sshSession }
951
- };
952
- }
953
- async validateWorkdir(workdir) {
954
- const preparedWorkspace = this.prepareRemoteWorkspaceForExec();
955
- const reusablePreparation = {
956
- workdir,
957
- promise: preparedWorkspace
958
- };
959
- this.preparedRemoteWorkspaceForNextExec = reusablePreparation;
1007
+ const workspaceLease = this.params.execContext.config.mode === "mirror" ? await this.acquireWorkspaceLease() : void 0;
960
1008
  try {
961
- await preparedWorkspace;
1009
+ await this.ensureSandboxExists();
1010
+ if (workspaceLease) {
1011
+ await this.syncWorkspaceToRemote();
1012
+ this.remoteSeedPending = false;
1013
+ }
962
1014
  const sshSession = await createOpenShellSshSession({ context: this.params.execContext });
963
1015
  try {
964
- const result = await runSshSandboxCommand({
1016
+ const prepared = await prepareSshSandboxExec({
965
1017
  session: sshSession,
966
- remoteCommand: buildRemoteWorkdirValidationCommand({
967
- workdir,
968
- root: this.resolveWorkdirValidationRoot(workdir)
969
- }),
970
- allowFailure: true
1018
+ remoteCommand,
1019
+ env: params.env,
1020
+ tty: params.usePty
971
1021
  });
972
- const resolvedWorkdir = result.code === 0 ? result.stdout.toString("utf8").trim() : "";
973
- if (this.preparedRemoteWorkspaceForNextExec === reusablePreparation) this.preparedRemoteWorkspaceForNextExec = resolvedWorkdir ? {
974
- workdir: resolvedWorkdir,
975
- promise: preparedWorkspace
976
- } : null;
977
- return resolvedWorkdir || null;
978
- } finally {
1022
+ return {
1023
+ argv: prepared.argv,
1024
+ token: {
1025
+ workspaceLease,
1026
+ cleanup: async () => {
1027
+ try {
1028
+ await prepared.cleanup();
1029
+ } finally {
1030
+ await disposeSshSandboxSession(sshSession);
1031
+ }
1032
+ }
1033
+ }
1034
+ };
1035
+ } catch (error) {
979
1036
  await disposeSshSandboxSession(sshSession);
1037
+ throw error;
980
1038
  }
981
1039
  } catch (error) {
982
- if (this.preparedRemoteWorkspaceForNextExec === reusablePreparation) this.preparedRemoteWorkspaceForNextExec = null;
1040
+ workspaceLease?.release();
983
1041
  throw error;
984
1042
  }
985
1043
  }
1044
+ async validateWorkdir(workdir) {
1045
+ if (this.params.execContext.config.mode === "mirror") return await this.runWorkspaceOperation(() => this.validateMirrorWorkdir(workdir));
1046
+ await this.ensureSandboxExists();
1047
+ const sshSession = await createOpenShellSshSession({ context: this.params.execContext });
1048
+ try {
1049
+ const result = await runSshSandboxCommand({
1050
+ session: sshSession,
1051
+ remoteCommand: buildRemoteWorkdirValidationCommand({
1052
+ workdir,
1053
+ root: this.resolveWorkdirValidationRoot(workdir)
1054
+ }),
1055
+ allowFailure: true
1056
+ });
1057
+ return result.code === 0 ? result.stdout.toString("utf8").trim() || null : null;
1058
+ } finally {
1059
+ await disposeSshSandboxSession(sshSession);
1060
+ }
1061
+ }
986
1062
  resolveWorkdirValidationRoot(workdir) {
987
1063
  try {
988
1064
  const normalized = normalizeRemotePath(workdir);
989
- return [normalizeRemotePath(this.params.remoteAgentWorkspaceDir), normalizeRemotePath(this.params.remoteWorkspaceDir)].toSorted((a, b) => b.length - a.length).find((root) => isRemotePathInside(root, normalized)) ?? this.params.remoteWorkspaceDir;
1065
+ return resolveOpenShellWorkspaceRoot([{
1066
+ remote: normalizeRemotePath(this.params.remoteWorkspaceDir),
1067
+ owner: "workspace",
1068
+ value: void 0
1069
+ }, {
1070
+ remote: normalizeRemotePath(this.params.remoteAgentWorkspaceDir),
1071
+ owner: "agent",
1072
+ value: void 0
1073
+ }], normalized)?.remote ?? this.params.remoteWorkspaceDir;
990
1074
  } catch {
991
1075
  return this.params.remoteWorkspaceDir;
992
1076
  }
993
1077
  }
994
- consumePreparedRemoteWorkspaceForNextExec(workdir) {
995
- const preparedWorkspace = this.preparedRemoteWorkspaceForNextExec;
996
- if (!preparedWorkspace || preparedWorkspace.workdir !== workdir) {
997
- this.preparedRemoteWorkspaceForNextExec = null;
998
- return null;
999
- }
1000
- this.preparedRemoteWorkspaceForNextExec = null;
1001
- return preparedWorkspace.promise;
1002
- }
1003
- discardPreparedWorkdir(workdir) {
1004
- if (this.preparedRemoteWorkspaceForNextExec?.workdir === workdir) this.preparedRemoteWorkspaceForNextExec = null;
1005
- }
1006
- async prepareRemoteWorkspaceForExec() {
1007
- await this.ensureSandboxExists();
1008
- if (this.params.execContext.config.mode === "mirror") {
1009
- await this.syncWorkspaceToRemote();
1010
- return;
1078
+ async validateMirrorWorkdir(workdir) {
1079
+ const normalized = normalizeRemotePath(workdir);
1080
+ const roots = this.workspaceUploadRoots();
1081
+ if (!roots.some((root) => isOpenShellRemotePathInside(root.remote, normalized))) return null;
1082
+ const { cfg, skillsWorkspaceDir } = this.params.createParams;
1083
+ if (cfg.workspaceAccess === "rw" && skillsWorkspaceDir) roots.push({
1084
+ remote: resolveRemoteMaterializedSkillsWorkspaceDir(this.params.remoteWorkspaceDir),
1085
+ local: skillsWorkspaceDir,
1086
+ owner: "workspace"
1087
+ });
1088
+ let createdAncestor = false;
1089
+ for (const root of roots.toReversed()) {
1090
+ if (!isOpenShellRemotePathInside(root.remote, normalized)) {
1091
+ createdAncestor ||= isOpenShellRemotePathInside(normalized, root.remote);
1092
+ continue;
1093
+ }
1094
+ if (createdAncestor) return normalized;
1095
+ const relative = path.posix.relative(root.remote, normalized);
1096
+ if (!relative) return normalized;
1097
+ const parts = relative.split("/");
1098
+ if (DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS.some((excluded) => excluded === normalizeLowercaseStringOrEmpty(parts[0]))) return null;
1099
+ let local = root.local;
1100
+ for (const part of root.local === skillsWorkspaceDir ? ["", ...parts] : parts) {
1101
+ local = path.join(local, part);
1102
+ if (!(await fsPromises.lstat(local).catch(() => null))?.isDirectory()) return null;
1103
+ }
1104
+ return normalized;
1011
1105
  }
1012
- if (!await this.maybeSeedRemoteWorkspace()) await this.syncSkillsWorkspaceToRemote();
1106
+ return createdAncestor ? normalized : null;
1013
1107
  }
1014
1108
  async finalizeExec(token) {
1109
+ const workspaceLease = token?.workspaceLease ?? (this.params.execContext.config.mode === "mirror" ? await this.acquireWorkspaceLease() : void 0);
1015
1110
  try {
1016
1111
  if (this.params.execContext.config.mode === "mirror") await this.syncWorkspaceFromRemote();
1017
1112
  } finally {
1018
- if (token?.sshSession) await disposeSshSandboxSession(token.sshSession);
1113
+ try {
1114
+ await token?.cleanup();
1115
+ } finally {
1116
+ workspaceLease?.release();
1117
+ }
1019
1118
  }
1020
1119
  }
1021
1120
  async runRemoteShellScript(params) {
1022
1121
  await this.ensureSandboxExists();
1023
- if (!await this.maybeSeedRemoteWorkspace()) await this.syncSkillsWorkspaceToRemote();
1122
+ if (this.params.execContext.config.mode === "mirror") await this.maybeSeedRemoteWorkspace();
1024
1123
  return await this.runRemoteShellScriptInternal(params);
1025
1124
  }
1026
1125
  async mkdirpRemotePath(remotePath, signal) {
@@ -1125,7 +1224,7 @@ var OpenShellSandboxBackendImpl = class {
1125
1224
  "--no-git-ignore",
1126
1225
  this.params.execContext.sandboxName,
1127
1226
  localPath,
1128
- path.posix.dirname(remotePath)
1227
+ remotePath
1129
1228
  ],
1130
1229
  cwd: this.params.createParams.workspaceDir
1131
1230
  });
@@ -1140,8 +1239,16 @@ var OpenShellSandboxBackendImpl = class {
1140
1239
  }
1141
1240
  resolveRemoteTarget(remotePath) {
1142
1241
  const normalized = normalizeRemotePath(remotePath);
1143
- const roots = [normalizeRemotePath(this.params.remoteWorkspaceDir), normalizeRemotePath(this.params.remoteAgentWorkspaceDir)].toSorted((a, b) => b.length - a.length);
1144
- for (const root of roots) if (isRemotePathInside(root, normalized)) {
1242
+ const root = resolveOpenShellWorkspaceRoot([{
1243
+ remote: normalizeRemotePath(this.params.remoteWorkspaceDir),
1244
+ owner: "workspace",
1245
+ value: void 0
1246
+ }, {
1247
+ remote: normalizeRemotePath(this.params.remoteAgentWorkspaceDir),
1248
+ owner: "agent",
1249
+ value: void 0
1250
+ }], normalized)?.remote;
1251
+ if (root) {
1145
1252
  const relativePath = path.posix.relative(root, normalized);
1146
1253
  return {
1147
1254
  root,
@@ -1152,7 +1259,11 @@ var OpenShellSandboxBackendImpl = class {
1152
1259
  }
1153
1260
  async ensureSandboxExists() {
1154
1261
  if (this.ensurePromise) return await this.ensurePromise;
1155
- this.ensurePromise = this.ensureSandboxExistsInner();
1262
+ const initialize = async () => {
1263
+ await this.ensureSandboxExistsInner();
1264
+ if (this.params.execContext.config.mode === "remote" && !await this.maybeSeedRemoteWorkspace()) await this.syncSkillsWorkspaceToRemote();
1265
+ };
1266
+ this.ensurePromise = this.params.execContext.config.mode === "remote" ? this.runWorkspaceOperation(initialize) : initialize();
1156
1267
  try {
1157
1268
  await this.ensurePromise;
1158
1269
  } catch (error) {
@@ -1176,9 +1287,12 @@ var OpenShellSandboxBackendImpl = class {
1176
1287
  if (!phase) throw this.buildLegacyRuntimeUnavailableError("OpenShell did not report a lifecycle phase for this sandbox.");
1177
1288
  if (phase !== "Ready") throw this.buildLegacyRuntimeUnavailableError(`OpenShell reports phase "${phase}".`);
1178
1289
  }
1290
+ if (this.params.execContext.config.mode === "remote" && await this.remoteManagedRootsEmpty()) this.remoteSeedPending = true;
1179
1291
  return;
1180
1292
  }
1181
1293
  if (this.params.legacyRuntimeAdopted) throw this.buildLegacyRuntimeUnavailableError(getResult.stderr.trim());
1294
+ if (!/\bsandbox not found\b/iu.test(getResult.stderr)) throw new Error(getResult.stderr.trim() || "openshell sandbox get failed");
1295
+ const detachedCreateSupported = await this.supportsDetachedSandboxCreation();
1182
1296
  const createArgs = [
1183
1297
  "sandbox",
1184
1298
  "create",
@@ -1190,8 +1304,12 @@ var OpenShellSandboxBackendImpl = class {
1190
1304
  ...this.params.execContext.config.gpu ? ["--gpu"] : [],
1191
1305
  ...this.params.execContext.config.autoProviders ? ["--auto-providers"] : ["--no-auto-providers"],
1192
1306
  ...this.params.execContext.config.providers.flatMap((provider) => ["--provider", provider]),
1193
- "--",
1194
- "true"
1307
+ ...detachedCreateSupported ? [
1308
+ "--detach",
1309
+ "--",
1310
+ "sleep",
1311
+ "infinity"
1312
+ ] : ["--", "true"]
1195
1313
  ];
1196
1314
  const createResult = await runOpenShellCli({
1197
1315
  context: this.params.execContext,
@@ -1202,6 +1320,41 @@ var OpenShellSandboxBackendImpl = class {
1202
1320
  if (createResult.code !== 0) throw new Error(createResult.stderr.trim() || "openshell sandbox create failed");
1203
1321
  this.remoteSeedPending = true;
1204
1322
  }
1323
+ async supportsDetachedSandboxCreation() {
1324
+ const { config } = this.params.execContext;
1325
+ const cliIdentity = JSON.stringify([
1326
+ config.command,
1327
+ config.gatewayEndpoint ?? "",
1328
+ config.gateway ?? "",
1329
+ config.workspace ?? process.env.OPENSHELL_WORKSPACE ?? ""
1330
+ ]);
1331
+ let support = openShellDetachedCreateSupport?.key === cliIdentity ? openShellDetachedCreateSupport.promise : void 0;
1332
+ if (!support) {
1333
+ support = (async () => {
1334
+ const result = await runOpenShellCli({
1335
+ context: this.params.execContext,
1336
+ args: [
1337
+ "sandbox",
1338
+ "create",
1339
+ "--help"
1340
+ ],
1341
+ cwd: this.params.createParams.workspaceDir
1342
+ });
1343
+ if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox create capability check failed");
1344
+ return /^\s*--detach(?:\s|$)/mu.test(result.stdout);
1345
+ })();
1346
+ openShellDetachedCreateSupport = {
1347
+ key: cliIdentity,
1348
+ promise: support
1349
+ };
1350
+ }
1351
+ try {
1352
+ return await support;
1353
+ } catch (error) {
1354
+ if (openShellDetachedCreateSupport?.promise === support) openShellDetachedCreateSupport = void 0;
1355
+ throw error;
1356
+ }
1357
+ }
1205
1358
  async resolveLegacyRuntimePhase() {
1206
1359
  const pageSize = 100;
1207
1360
  for (let offset = 0;; offset += pageSize) {
@@ -1234,18 +1387,37 @@ var OpenShellSandboxBackendImpl = class {
1234
1387
  `OpenClaw will not recreate this retired runtime name. Run \`${recreateCommand}\` to migrate this scope to the current naming format.`
1235
1388
  ].filter(Boolean).join(" "));
1236
1389
  }
1237
- async syncWorkspaceToRemote() {
1238
- await this.runRemoteShellScriptInternal({
1239
- script: "mkdir -p -- \"$1\" && find \"$1\" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +",
1240
- args: [this.params.remoteWorkspaceDir]
1390
+ workspaceUploadRoots() {
1391
+ const { createParams, remoteWorkspaceDir, remoteAgentWorkspaceDir } = this.params;
1392
+ const roots = [{
1393
+ remote: remoteWorkspaceDir,
1394
+ owner: "workspace",
1395
+ value: createParams.workspaceDir
1396
+ }];
1397
+ if (createParams.cfg.workspaceAccess !== "none" && path.resolve(createParams.agentWorkspaceDir) !== path.resolve(createParams.workspaceDir)) roots.push({
1398
+ remote: remoteAgentWorkspaceDir,
1399
+ owner: "agent",
1400
+ value: createParams.agentWorkspaceDir
1241
1401
  });
1242
- await this.uploadPathToRemote(this.params.createParams.workspaceDir, this.params.remoteWorkspaceDir);
1243
- if (this.params.createParams.cfg.workspaceAccess !== "none" && path.resolve(this.params.createParams.agentWorkspaceDir) !== path.resolve(this.params.createParams.workspaceDir)) {
1402
+ return orderOpenShellWorkspaceRoots(roots).map(({ remote, owner, value: local }) => ({
1403
+ remote,
1404
+ local,
1405
+ owner
1406
+ }));
1407
+ }
1408
+ async syncWorkspaceToRemote() {
1409
+ const roots = this.workspaceUploadRoots();
1410
+ for (const [index, root] of roots.entries()) {
1411
+ const containingRoot = roots.slice(0, index).toReversed().find((candidate) => isOpenShellRemotePathInside(candidate.remote, root.remote));
1244
1412
  await this.runRemoteShellScriptInternal({
1245
- script: "mkdir -p -- \"$1\" && find \"$1\" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +",
1246
- args: [this.params.remoteAgentWorkspaceDir]
1413
+ script: `${ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT}\nfind "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +`,
1414
+ args: [
1415
+ root.remote,
1416
+ containingRoot?.remote ?? root.remote,
1417
+ containingRoot ? "1" : "0"
1418
+ ]
1247
1419
  });
1248
- await this.uploadPathToRemote(this.params.createParams.agentWorkspaceDir, this.params.remoteAgentWorkspaceDir);
1420
+ await this.uploadPathToRemote(root.local, root.remote);
1249
1421
  }
1250
1422
  await this.syncSkillsWorkspaceToRemote();
1251
1423
  }
@@ -1261,40 +1433,62 @@ var OpenShellSandboxBackendImpl = class {
1261
1433
  await this.uploadPathToRemote(this.params.createParams.skillsWorkspaceDir, remoteSkillsWorkspaceDir);
1262
1434
  }
1263
1435
  async syncWorkspaceFromRemote() {
1264
- await withTempWorkspace({
1265
- rootDir: resolveOpenShellTmpRoot(),
1266
- prefix: "openclaw-openshell-sync-"
1267
- }, async ({ dir: tmpDir }) => {
1268
- const result = await runOpenShellCli({
1269
- context: this.params.execContext,
1270
- args: [
1271
- "sandbox",
1272
- "download",
1273
- this.params.execContext.sandboxName,
1274
- this.params.remoteWorkspaceDir,
1275
- tmpDir
1276
- ],
1277
- cwd: this.params.createParams.workspaceDir
1278
- });
1279
- if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox download failed");
1280
- await removeMaterializedSkillsFromDownloadedWorkspace(tmpDir);
1281
- const preservedSandboxSkills = await moveMaterializedSkillsShadowAside({
1282
- workspaceDir: this.params.createParams.workspaceDir,
1283
- tmpDir
1284
- });
1285
- try {
1286
- await replaceDirectoryContents({
1287
- sourceDir: tmpDir,
1288
- targetDir: this.params.createParams.workspaceDir,
1289
- excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS
1290
- });
1291
- } finally {
1292
- await restoreMaterializedSkillsShadow({
1293
- workspaceDir: this.params.createParams.workspaceDir,
1294
- preserved: preservedSandboxSkills
1436
+ const remoteSkillsWorkspaceDir = resolveRemoteMaterializedSkillsWorkspaceDir(this.params.remoteWorkspaceDir);
1437
+ const roots = this.workspaceUploadRoots();
1438
+ for (const [index, root] of roots.entries()) {
1439
+ if (root.owner === "agent") continue;
1440
+ await withTempWorkspace({
1441
+ rootDir: resolveOpenShellTmpRoot(),
1442
+ prefix: "openclaw-openshell-sync-"
1443
+ }, async ({ dir: tmpDir }) => {
1444
+ const result = await runOpenShellCli({
1445
+ context: this.params.execContext,
1446
+ args: [
1447
+ "sandbox",
1448
+ "download",
1449
+ this.params.execContext.sandboxName,
1450
+ root.remote,
1451
+ tmpDir
1452
+ ],
1453
+ cwd: this.params.createParams.workspaceDir
1295
1454
  });
1296
- }
1297
- });
1455
+ if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox download failed");
1456
+ const preservedShadows = [];
1457
+ try {
1458
+ for (const shadowedRoot of roots.slice(index + 1)) {
1459
+ if (root.remote === shadowedRoot.remote || !isOpenShellRemotePathInside(root.remote, shadowedRoot.remote)) continue;
1460
+ const relativeParts = path.posix.relative(root.remote, shadowedRoot.remote).split("/").filter(Boolean);
1461
+ await removeDownloadedWorkspacePath(tmpDir, relativeParts);
1462
+ const preserved = await moveLocalShadowAside({
1463
+ workspaceDir: root.local,
1464
+ tmpDir,
1465
+ relativeParts
1466
+ });
1467
+ if (preserved) preservedShadows.push(preserved);
1468
+ }
1469
+ const relativeSkillsPath = path.posix.relative(root.remote, remoteSkillsWorkspaceDir);
1470
+ if (relativeSkillsPath === "" || !relativeSkillsPath.startsWith("../") && !path.posix.isAbsolute(relativeSkillsPath)) await removeDownloadedWorkspacePath(tmpDir, relativeSkillsPath.split("/").filter(Boolean));
1471
+ if (root.owner === "workspace") {
1472
+ const preserved = await moveLocalShadowAside({
1473
+ workspaceDir: root.local,
1474
+ tmpDir,
1475
+ relativeParts: MATERIALIZED_SKILLS_REMOTE_PARTS
1476
+ });
1477
+ if (preserved) preservedShadows.push(preserved);
1478
+ }
1479
+ await replaceDirectoryContents({
1480
+ sourceDir: tmpDir,
1481
+ targetDir: root.local,
1482
+ excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS
1483
+ });
1484
+ } finally {
1485
+ for (const preserved of preservedShadows.toReversed()) await restoreLocalShadow({
1486
+ workspaceDir: root.local,
1487
+ preserved
1488
+ });
1489
+ }
1490
+ });
1491
+ }
1298
1492
  }
1299
1493
  async uploadPathToRemote(localPath, remotePath) {
1300
1494
  await withTempWorkspace({
@@ -1305,10 +1499,10 @@ var OpenShellSandboxBackendImpl = class {
1305
1499
  const stagedRoot = path.join(tmpDir, remoteRootName);
1306
1500
  await stageDirectoryContents({
1307
1501
  sourceDir: localPath,
1308
- targetDir: stagedRoot
1502
+ targetDir: stagedRoot,
1503
+ excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS
1309
1504
  });
1310
- const stagedEntries = (await fsPromises.readdir(stagedRoot)).toSorted();
1311
- for (const entry of stagedEntries) {
1505
+ for (const entry of (await fsPromises.readdir(stagedRoot)).toSorted()) {
1312
1506
  const result = await runOpenShellCli({
1313
1507
  context: this.params.execContext,
1314
1508
  args: buildOpenShellDirectoryUploadArgs({
@@ -1322,6 +1516,12 @@ var OpenShellSandboxBackendImpl = class {
1322
1516
  }
1323
1517
  });
1324
1518
  }
1519
+ async remoteManagedRootsEmpty() {
1520
+ return (await this.runRemoteShellScriptInternal({
1521
+ script: REMOTE_MANAGED_ROOTS_EMPTY_SCRIPT,
1522
+ args: [this.params.remoteWorkspaceDir, this.params.remoteAgentWorkspaceDir]
1523
+ })).stdout.toString("utf8").trim() === "0";
1524
+ }
1325
1525
  async maybeSeedRemoteWorkspace() {
1326
1526
  if (!this.remoteSeedPending) return false;
1327
1527
  this.remoteSeedPending = false;
@@ -1386,17 +1586,33 @@ function parseOpenShellSandboxPhasePage(stdout, sandboxName) {
1386
1586
  return;
1387
1587
  }
1388
1588
  }
1589
+ function parseOpenShellSandboxPhase(stdout) {
1590
+ try {
1591
+ const parsed = JSON.parse(stdout);
1592
+ if (typeof parsed !== "object" || parsed === null || !("phase" in parsed)) return;
1593
+ return typeof parsed.phase === "string" ? parsed.phase : void 0;
1594
+ } catch {
1595
+ return;
1596
+ }
1597
+ }
1389
1598
  function resolveRemoteMaterializedSkillsWorkspaceDir(remoteWorkspaceDir) {
1390
1599
  const root = remoteWorkspaceDir.replace(/\\/g, "/").replace(/\/+$/, "") || "/";
1391
1600
  return path.posix.join(root, ...MATERIALIZED_SKILLS_REMOTE_PARTS);
1392
1601
  }
1393
- async function removeMaterializedSkillsFromDownloadedWorkspace(tmpDir) {
1602
+ async function removeDownloadedWorkspacePath(tmpDir, parts) {
1603
+ if (parts.length === 0) {
1604
+ for (const entry of await fsPromises.readdir(tmpDir)) await fsPromises.rm(path.join(tmpDir, entry), {
1605
+ recursive: true,
1606
+ force: true
1607
+ });
1608
+ return;
1609
+ }
1394
1610
  let cursor = tmpDir;
1395
- for (const [index, part] of MATERIALIZED_SKILLS_REMOTE_PARTS.entries()) {
1611
+ for (const [index, part] of parts.entries()) {
1396
1612
  const next = path.join(cursor, part);
1397
1613
  const stats = await fsPromises.lstat(next).catch(() => null);
1398
1614
  if (!stats) return;
1399
- if (index === MATERIALIZED_SKILLS_REMOTE_PARTS.length - 1) {
1615
+ if (index === parts.length - 1) {
1400
1616
  await fsPromises.rm(next, {
1401
1617
  recursive: true,
1402
1618
  force: true
@@ -1413,31 +1629,31 @@ async function removeMaterializedSkillsFromDownloadedWorkspace(tmpDir) {
1413
1629
  cursor = next;
1414
1630
  }
1415
1631
  }
1416
- async function moveMaterializedSkillsShadowAside(params) {
1417
- const shadowPath = path.join(params.workspaceDir, ...MATERIALIZED_SKILLS_REMOTE_PARTS);
1632
+ async function moveLocalShadowAside(params) {
1633
+ const shadowPath = path.join(params.workspaceDir, ...params.relativeParts);
1418
1634
  const parentStats = await fsPromises.lstat(path.dirname(shadowPath)).catch(() => null);
1419
1635
  if (!parentStats?.isDirectory() || parentStats.isSymbolicLink()) return;
1420
1636
  const shadowStats = await fsPromises.lstat(shadowPath).catch(() => null);
1421
1637
  if (!shadowStats || shadowStats.isSymbolicLink()) return;
1422
1638
  const preserveRoot = await fsPromises.mkdtemp(path.join(path.dirname(params.tmpDir), "openclaw-openshell-preserve-"));
1423
- const preservedPath = path.join(preserveRoot, "sandbox-skills");
1639
+ const preservedPath = path.join(preserveRoot, "shadow");
1424
1640
  await movePathWithCopyFallback({
1425
1641
  from: shadowPath,
1426
1642
  to: preservedPath
1427
1643
  });
1428
1644
  return {
1429
1645
  preservedPath,
1430
- preserveRoot
1646
+ preserveRoot,
1647
+ relativeParts: params.relativeParts
1431
1648
  };
1432
1649
  }
1433
- async function restoreMaterializedSkillsShadow(params) {
1434
- if (!params.preserved) return;
1650
+ async function restoreLocalShadow(params) {
1435
1651
  let restored = false;
1436
1652
  try {
1437
- const shadowPath = path.join(params.workspaceDir, ...MATERIALIZED_SKILLS_REMOTE_PARTS);
1653
+ const shadowPath = path.join(params.workspaceDir, ...params.preserved.relativeParts);
1438
1654
  const parentPath = path.dirname(shadowPath);
1439
1655
  const parentStats = await fsPromises.lstat(parentPath).catch(() => null);
1440
- if (parentStats?.isSymbolicLink()) throw new Error(`Refusing to restore sandbox skills through symlink parent: ${parentPath}`);
1656
+ if (parentStats?.isSymbolicLink()) throw new Error(`Refusing to restore workspace shadow through symlink parent: ${parentPath}`);
1441
1657
  if (parentStats && !parentStats.isDirectory()) await fsPromises.rm(parentPath, {
1442
1658
  recursive: true,
1443
1659
  force: true
@@ -1467,10 +1683,6 @@ function normalizeRemotePath(remotePath) {
1467
1683
  if (!path.posix.isAbsolute(normalized)) throw new Error(`OpenShell remote path must be absolute: ${remotePath}`);
1468
1684
  return normalized;
1469
1685
  }
1470
- function isRemotePathInside(root, candidate) {
1471
- const relative = path.posix.relative(root, candidate);
1472
- return relative === "" || relative !== ".." && !relative.startsWith("../") && !path.posix.isAbsolute(relative);
1473
- }
1474
1686
  //#endregion
1475
1687
  //#region extensions/openshell/index.ts
1476
1688
  var openshell_default = definePluginEntry({
@@ -1481,11 +1693,18 @@ var openshell_default = definePluginEntry({
1481
1693
  register(api) {
1482
1694
  if (api.registrationMode !== "full") return;
1483
1695
  const pluginConfig = resolveOpenShellPluginConfig(api.pluginConfig);
1484
- registerSandboxBackend("openshell", {
1696
+ const unregister = registerSandboxBackend("openshell", {
1485
1697
  factory: createOpenShellSandboxBackendFactory({ pluginConfig }),
1486
1698
  manager: createOpenShellSandboxBackendManager({ pluginConfig }),
1487
1699
  resolveWorkdir: () => pluginConfig.remoteWorkspaceDir
1488
1700
  });
1701
+ api.lifecycle.registerRuntimeLifecycle({
1702
+ id: "openshell-sandbox-cleanup",
1703
+ cleanup: ({ reason, sessionKey, runId }) => {
1704
+ if (sessionKey !== void 0 || runId !== void 0) return;
1705
+ if (reason === "disable" || reason === "restart") unregister();
1706
+ }
1707
+ });
1489
1708
  }
1490
1709
  });
1491
1710
  //#endregion
@@ -54,11 +54,13 @@
54
54
  },
55
55
  "remoteWorkspaceDir": {
56
56
  "type": "string",
57
- "minLength": 1
57
+ "minLength": 1,
58
+ "pattern": "^\\/(?:sandbox|agent)(?:\\/|$)"
58
59
  },
59
60
  "remoteAgentWorkspaceDir": {
60
61
  "type": "string",
61
- "minLength": 1
62
+ "minLength": 1,
63
+ "pattern": "^\\/(?:sandbox|agent)(?:\\/|$)"
62
64
  },
63
65
  "timeoutSeconds": {
64
66
  "type": "number",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/openshell-sandbox",
3
- "version": "2026.8.1-beta.3",
3
+ "version": "2026.8.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.8.1-beta.3"
24
+ "pluginApi": ">=2026.8.2"
25
25
  },
26
26
  "build": {
27
- "openclawVersion": "2026.8.1-beta.3",
27
+ "openclawVersion": "2026.8.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.8.1-beta.3"
44
+ "openclaw": ">=2026.8.2"
45
45
  },
46
46
  "peerDependenciesMeta": {
47
47
  "openclaw": {