@openclaw/openshell-sandbox 2026.8.1-beta.3 → 2026.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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;
@@ -582,66 +608,65 @@ function createExcludeMatcher(excludeDirs) {
582
608
  }
583
609
  const runLimitedFs = pLimit(COPY_TREE_FS_CONCURRENCY);
584
610
  async function lstatIfExists(targetPath) {
585
- return await runLimitedFs(async () => await fsPromises.lstat(targetPath)).catch(() => null);
611
+ return await runLimitedFs(fsPromises.lstat, targetPath).catch((error) => {
612
+ if (extractErrorCode(error) === "ENOENT") return null;
613
+ throw error;
614
+ });
586
615
  }
587
- async function copyTreeWithoutSymlinks(params) {
588
- const stats = await runLimitedFs(async () => await fsPromises.lstat(params.sourcePath));
589
- if (stats.isSymbolicLink()) return;
616
+ async function reconcileMirrorPath(params) {
590
617
  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
- }
618
+ if (targetStats && !targetStats.isDirectory() && !targetStats.isFile()) return true;
619
+ const sourceStats = params.sourcePath ? await runLimitedFs(fsPromises.lstat, params.sourcePath) : null;
620
+ const sourceDir = sourceStats?.isDirectory() ? params.sourcePath : void 0;
621
+ const sourceFile = sourceStats?.isFile() ? params.sourcePath : void 0;
622
+ if (!params.replace && !sourceDir && !sourceFile) return false;
623
+ if (sourceDir || targetStats?.isDirectory()) {
624
+ if (targetStats && !targetStats.isDirectory()) await runLimitedFs(fsPromises.rm, params.targetPath, { force: true });
625
+ const preservedEntries = await reconcileMirrorDirectory({
626
+ sourceDir,
627
+ targetDir: params.targetPath,
628
+ replace: params.replace
629
+ });
630
+ if (sourceDir || preservedEntries) return preservedEntries;
631
+ await runLimitedFs(fsPromises.rmdir, params.targetPath);
632
+ } else if (targetStats) await runLimitedFs(fsPromises.rm, params.targetPath, { force: true });
633
+ if (sourceFile) await runLimitedFs(fsPromises.copyFile, sourceFile, params.targetPath);
634
+ return false;
608
635
  }
609
- async function replaceDirectoryContents(params) {
636
+ async function reconcileMirrorDirectory(params) {
637
+ const { sourceDir } = params;
610
638
  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
- });
639
+ await runLimitedFs(fsPromises.mkdir, params.targetDir, { recursive: true });
640
+ const sourceEntries = new Set(sourceDir ? await runLimitedFs(async () => await fsPromises.readdir(sourceDir)) : []);
641
+ const targetEntries = params.replace ? await runLimitedFs(async () => await fsPromises.readdir(params.targetDir)) : [];
642
+ const results = await Promise.allSettled([.../* @__PURE__ */ new Set([...sourceEntries, ...targetEntries])].filter((entry) => !isExcluded(entry)).map((entry) => reconcileMirrorPath({
643
+ sourcePath: sourceDir && sourceEntries.has(entry) ? path.join(sourceDir, entry) : void 0,
644
+ targetPath: path.join(params.targetDir, entry),
645
+ replace: params.replace
646
+ })));
647
+ let preservedEntries = false;
648
+ for (const result of results) {
649
+ if (result.status === "rejected") throw result.reason;
650
+ preservedEntries ||= result.value;
629
651
  }
652
+ return preservedEntries;
653
+ }
654
+ async function replaceDirectoryContents(params) {
655
+ await reconcileMirrorDirectory({
656
+ ...params,
657
+ replace: true
658
+ });
630
659
  }
631
660
  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
- }
661
+ await reconcileMirrorDirectory({
662
+ ...params,
663
+ replace: false
664
+ });
642
665
  }
643
666
  //#endregion
644
667
  //#region extensions/openshell/src/backend.ts
668
+ const openShellWorkspaceOperations = new KeyedAsyncQueue();
669
+ let openShellDetachedCreateSupport;
645
670
  const MATERIALIZED_SKILLS_REMOTE_PARTS = [".openclaw", "sandbox-skills"];
646
671
  function buildOpenShellDirectoryUploadArgs(params) {
647
672
  return [
@@ -650,9 +675,10 @@ function buildOpenShellDirectoryUploadArgs(params) {
650
675
  "--no-git-ignore",
651
676
  params.sandboxName,
652
677
  params.localPath,
653
- normalizeRemotePath(params.remotePath)
678
+ `${normalizeRemotePath(params.remotePath)}/`
654
679
  ];
655
680
  }
681
+ 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
682
  const PINNED_REMOTE_PATH_MUTATION_SCRIPT = [
657
683
  "set -eu",
658
684
  "die() { echo \"$1\" >&2; exit 1; }",
@@ -758,6 +784,7 @@ const ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT = [
758
784
  "set -e",
759
785
  "target=\"$1\"",
760
786
  "root=\"${2:-$1}\"",
787
+ "replace_blocking=\"${3:-0}\"",
761
788
  "case \"$target\" in /*) ;; *) echo \"remote directory must be absolute: $target\" >&2; exit 1 ;; esac",
762
789
  "case \"$root\" in /*) ;; *) echo \"remote root must be absolute: $root\" >&2; exit 1 ;; esac",
763
790
  "target=\"${target%/}\"",
@@ -785,9 +812,15 @@ const ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT = [
785
812
  " if [ \"$part\" = \"$relative\" ]; then relative=\"\"; else relative=\"${relative#*/}\"; fi",
786
813
  " [ -n \"$part\" ] || continue",
787
814
  " 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",
815
+ " if [ -L \"$next\" ]; then",
816
+ " if [ \"$replace_blocking\" != \"1\" ]; then echo \"unsafe remote directory symlink: $next\" >&2; exit 1; fi",
817
+ " rm -rf -- \"$next\"",
818
+ " elif [ -e \"$next\" ] && [ ! -d \"$next\" ]; then",
819
+ " if [ \"$replace_blocking\" != \"1\" ]; then echo \"unsafe remote directory component: $next\" >&2; exit 1; fi",
820
+ " rm -rf -- \"$next\"",
821
+ " fi",
789
822
  " if [ -e \"$next\" ]; then",
790
- " if [ ! -d \"$next\" ]; then echo \"unsafe remote directory component: $next\" >&2; exit 1; fi",
823
+ " [ -d \"$next\" ] || { echo \"unsafe remote directory component: $next\" >&2; exit 1; }",
791
824
  " else",
792
825
  " mkdir -- \"$next\"",
793
826
  " fi",
@@ -815,20 +848,22 @@ function createOpenShellSandboxBackendManager(params) {
815
848
  args: [
816
849
  "sandbox",
817
850
  "get",
818
- entry.containerName
851
+ entry.containerName,
852
+ "--output",
853
+ "json"
819
854
  ]
820
855
  });
821
856
  const configuredSource = execContext.config.from;
822
857
  return {
823
- running: result.code === 0,
858
+ running: result.code === 0 && parseOpenShellSandboxPhase(result.stdout) === "Ready",
824
859
  actualConfigLabel: entry.image,
825
860
  configLabelMatch: entry.image === configuredSource
826
861
  };
827
862
  },
828
- async removeRuntime({ entry }) {
829
- await runOpenShellCli({
863
+ async removeRuntime({ entry, config }) {
864
+ const result = await runOpenShellCli({
830
865
  context: {
831
- config: params.pluginConfig,
866
+ config: resolveOpenShellPluginConfigFromConfig(config, params.pluginConfig),
832
867
  sandboxName: entry.containerName
833
868
  },
834
869
  args: [
@@ -837,6 +872,7 @@ function createOpenShellSandboxBackendManager(params) {
837
872
  entry.containerName
838
873
  ]
839
874
  });
875
+ if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox delete failed");
840
876
  }
841
877
  };
842
878
  }
@@ -864,11 +900,11 @@ var OpenShellSandboxBackendImpl = class {
864
900
  this.params = params;
865
901
  this.handle = null;
866
902
  this.ensurePromise = null;
867
- this.preparedRemoteWorkspaceForNextExec = null;
868
903
  this.remoteSeedPending = false;
869
904
  }
870
905
  asHandle() {
871
906
  if (this.handle) return this.handle;
907
+ const runRemoteShellScript = (command) => this.params.execContext.config.mode === "mirror" ? this.runWorkspaceOperation(() => this.runRemoteShellScript(command), command.signal) : this.runRemoteShellScript(command);
872
908
  const handle = {
873
909
  id: "openshell",
874
910
  runtimeId: this.params.execContext.sandboxName,
@@ -880,7 +916,6 @@ var OpenShellSandboxBackendImpl = class {
880
916
  configLabelKind: "Source",
881
917
  workdirValidation: "backend",
882
918
  validateWorkdir: async (workdir) => await this.validateWorkdir(workdir),
883
- discardPreparedWorkdir: (workdir) => this.discardPreparedWorkdir(workdir),
884
919
  workdirRoots: [this.params.remoteWorkspaceDir, this.params.remoteAgentWorkspaceDir],
885
920
  remoteWorkspaceDir: this.params.remoteWorkspaceDir,
886
921
  remoteAgentWorkspaceDir: this.params.remoteAgentWorkspaceDir,
@@ -901,126 +936,189 @@ var OpenShellSandboxBackendImpl = class {
901
936
  finalizeExec: async ({ token }) => {
902
937
  await this.finalizeExec(token);
903
938
  },
904
- runShellCommand: async (command) => await this.runRemoteShellScript(command),
939
+ runShellCommand: runRemoteShellScript,
905
940
  createFsBridge: ({ sandbox }) => this.params.execContext.config.mode === "remote" ? createRemoteShellSandboxFsBridge({
906
941
  sandbox,
907
942
  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)
943
+ }) : this.createMirrorFsBridge(sandbox),
944
+ runRemoteShellScript
917
945
  };
918
946
  this.handle = handle;
919
947
  return handle;
920
948
  }
949
+ createMirrorFsBridge(sandbox) {
950
+ const bridge = createOpenShellFsBridge({
951
+ sandbox,
952
+ backend: {
953
+ remoteAgentWorkspaceDir: this.params.remoteAgentWorkspaceDir,
954
+ mkdirpRemotePath: (remotePath, signal) => this.mkdirpRemotePath(remotePath, signal),
955
+ removeRemotePath: (remotePath, params) => this.removeRemotePath(remotePath, params),
956
+ renameRemotePath: (from, to, signal) => this.renameRemotePath(from, to, signal),
957
+ syncLocalPathToRemote: (localPath, remotePath) => this.syncLocalPathToRemote(localPath, remotePath)
958
+ }
959
+ });
960
+ return {
961
+ resolvePath: (params) => bridge.resolvePath(params),
962
+ readFile: (params) => this.runWorkspaceOperation(() => bridge.readFile(params), params.signal),
963
+ writeFile: (params) => this.runWorkspaceOperation(() => bridge.writeFile(params), params.signal),
964
+ createFileExclusive: (params) => this.runWorkspaceOperation(() => bridge.createFileExclusive(params), params.signal),
965
+ mkdirp: (params) => this.runWorkspaceOperation(() => bridge.mkdirp(params), params.signal),
966
+ remove: (params) => this.runWorkspaceOperation(() => bridge.remove(params), params.signal),
967
+ rename: (params) => this.runWorkspaceOperation(() => bridge.rename(params), params.signal),
968
+ stat: (params) => this.runWorkspaceOperation(() => bridge.stat(params), params.signal)
969
+ };
970
+ }
971
+ async runWorkspaceOperation(operation, signal) {
972
+ signal?.throwIfAborted();
973
+ const lease = await this.acquireWorkspaceLease();
974
+ try {
975
+ signal?.throwIfAborted();
976
+ return await operation();
977
+ } finally {
978
+ lease.release();
979
+ }
980
+ }
981
+ async acquireWorkspaceLease() {
982
+ const { config, sandboxName } = this.params.execContext;
983
+ const keys = [`host:${await canonicalPathFromExistingAncestor(this.params.createParams.workspaceDir)}`, `runtime:${JSON.stringify([
984
+ config.gatewayEndpoint ?? "",
985
+ config.gateway ?? "",
986
+ config.workspace ?? process.env.OPENSHELL_WORKSPACE ?? "",
987
+ sandboxName
988
+ ])}`].toSorted();
989
+ const releases = [];
990
+ for (const key of keys) {
991
+ const acquired = createDeferred();
992
+ openShellWorkspaceOperations.enqueue(key, () => new Promise(acquired.resolve));
993
+ releases.push(await acquired.promise);
994
+ }
995
+ return { release: () => {
996
+ for (const release of releases.toReversed()) release();
997
+ } };
998
+ }
921
999
  async prepareExec(params) {
922
1000
  const remoteWorkdir = params.workdir ?? this.params.remoteWorkspaceDir;
923
- const preparedWorkspace = this.consumePreparedRemoteWorkspaceForNextExec(remoteWorkdir);
924
1001
  const remoteCommand = buildValidatedExecRemoteCommand({
925
1002
  command: params.command,
926
1003
  workdir: remoteWorkdir,
927
- env: params.env
1004
+ env: {}
928
1005
  });
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;
1006
+ const workspaceLease = this.params.execContext.config.mode === "mirror" ? await this.acquireWorkspaceLease() : void 0;
960
1007
  try {
961
- await preparedWorkspace;
1008
+ await this.ensureSandboxExists();
1009
+ if (workspaceLease) {
1010
+ await this.syncWorkspaceToRemote();
1011
+ this.remoteSeedPending = false;
1012
+ }
962
1013
  const sshSession = await createOpenShellSshSession({ context: this.params.execContext });
963
1014
  try {
964
- const result = await runSshSandboxCommand({
1015
+ const prepared = await prepareSshSandboxExec({
965
1016
  session: sshSession,
966
- remoteCommand: buildRemoteWorkdirValidationCommand({
967
- workdir,
968
- root: this.resolveWorkdirValidationRoot(workdir)
969
- }),
970
- allowFailure: true
1017
+ remoteCommand,
1018
+ env: params.env,
1019
+ tty: params.usePty
971
1020
  });
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 {
1021
+ return {
1022
+ argv: prepared.argv,
1023
+ token: {
1024
+ workspaceLease,
1025
+ cleanup: async () => {
1026
+ try {
1027
+ await prepared.cleanup();
1028
+ } finally {
1029
+ await disposeSshSandboxSession(sshSession);
1030
+ }
1031
+ }
1032
+ }
1033
+ };
1034
+ } catch (error) {
979
1035
  await disposeSshSandboxSession(sshSession);
1036
+ throw error;
980
1037
  }
981
1038
  } catch (error) {
982
- if (this.preparedRemoteWorkspaceForNextExec === reusablePreparation) this.preparedRemoteWorkspaceForNextExec = null;
1039
+ workspaceLease?.release();
983
1040
  throw error;
984
1041
  }
985
1042
  }
1043
+ async validateWorkdir(workdir) {
1044
+ if (this.params.execContext.config.mode === "mirror") return await this.runWorkspaceOperation(() => this.validateMirrorWorkdir(workdir));
1045
+ await this.ensureSandboxExists();
1046
+ const sshSession = await createOpenShellSshSession({ context: this.params.execContext });
1047
+ try {
1048
+ const result = await runSshSandboxCommand({
1049
+ session: sshSession,
1050
+ remoteCommand: buildRemoteWorkdirValidationCommand({
1051
+ workdir,
1052
+ root: this.resolveWorkdirValidationRoot(workdir)
1053
+ }),
1054
+ allowFailure: true
1055
+ });
1056
+ return result.code === 0 ? result.stdout.toString("utf8").trim() || null : null;
1057
+ } finally {
1058
+ await disposeSshSandboxSession(sshSession);
1059
+ }
1060
+ }
986
1061
  resolveWorkdirValidationRoot(workdir) {
987
1062
  try {
988
1063
  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;
1064
+ return resolveOpenShellWorkspaceRoot([{
1065
+ remote: normalizeRemotePath(this.params.remoteWorkspaceDir),
1066
+ owner: "workspace",
1067
+ value: void 0
1068
+ }, {
1069
+ remote: normalizeRemotePath(this.params.remoteAgentWorkspaceDir),
1070
+ owner: "agent",
1071
+ value: void 0
1072
+ }], normalized)?.remote ?? this.params.remoteWorkspaceDir;
990
1073
  } catch {
991
1074
  return this.params.remoteWorkspaceDir;
992
1075
  }
993
1076
  }
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;
1077
+ async validateMirrorWorkdir(workdir) {
1078
+ const normalized = normalizeRemotePath(workdir);
1079
+ const roots = this.workspaceUploadRoots();
1080
+ if (!roots.some((root) => isOpenShellRemotePathInside(root.remote, normalized))) return null;
1081
+ const { cfg, skillsWorkspaceDir } = this.params.createParams;
1082
+ if (cfg.workspaceAccess === "rw" && skillsWorkspaceDir) roots.push({
1083
+ remote: resolveRemoteMaterializedSkillsWorkspaceDir(this.params.remoteWorkspaceDir),
1084
+ local: skillsWorkspaceDir,
1085
+ owner: "workspace"
1086
+ });
1087
+ let createdAncestor = false;
1088
+ for (const root of roots.toReversed()) {
1089
+ if (!isOpenShellRemotePathInside(root.remote, normalized)) {
1090
+ createdAncestor ||= isOpenShellRemotePathInside(normalized, root.remote);
1091
+ continue;
1092
+ }
1093
+ if (createdAncestor) return normalized;
1094
+ const relative = path.posix.relative(root.remote, normalized);
1095
+ if (!relative) return normalized;
1096
+ const parts = relative.split("/");
1097
+ if (DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS.some((excluded) => excluded === normalizeLowercaseStringOrEmpty(parts[0]))) return null;
1098
+ let local = root.local;
1099
+ for (const part of root.local === skillsWorkspaceDir ? ["", ...parts] : parts) {
1100
+ local = path.join(local, part);
1101
+ if (!(await fsPromises.lstat(local).catch(() => null))?.isDirectory()) return null;
1102
+ }
1103
+ return normalized;
1011
1104
  }
1012
- if (!await this.maybeSeedRemoteWorkspace()) await this.syncSkillsWorkspaceToRemote();
1105
+ return createdAncestor ? normalized : null;
1013
1106
  }
1014
1107
  async finalizeExec(token) {
1108
+ const workspaceLease = token?.workspaceLease ?? (this.params.execContext.config.mode === "mirror" ? await this.acquireWorkspaceLease() : void 0);
1015
1109
  try {
1016
1110
  if (this.params.execContext.config.mode === "mirror") await this.syncWorkspaceFromRemote();
1017
1111
  } finally {
1018
- if (token?.sshSession) await disposeSshSandboxSession(token.sshSession);
1112
+ try {
1113
+ await token?.cleanup();
1114
+ } finally {
1115
+ workspaceLease?.release();
1116
+ }
1019
1117
  }
1020
1118
  }
1021
1119
  async runRemoteShellScript(params) {
1022
1120
  await this.ensureSandboxExists();
1023
- if (!await this.maybeSeedRemoteWorkspace()) await this.syncSkillsWorkspaceToRemote();
1121
+ if (this.params.execContext.config.mode === "mirror") await this.maybeSeedRemoteWorkspace();
1024
1122
  return await this.runRemoteShellScriptInternal(params);
1025
1123
  }
1026
1124
  async mkdirpRemotePath(remotePath, signal) {
@@ -1125,7 +1223,7 @@ var OpenShellSandboxBackendImpl = class {
1125
1223
  "--no-git-ignore",
1126
1224
  this.params.execContext.sandboxName,
1127
1225
  localPath,
1128
- path.posix.dirname(remotePath)
1226
+ remotePath
1129
1227
  ],
1130
1228
  cwd: this.params.createParams.workspaceDir
1131
1229
  });
@@ -1140,8 +1238,16 @@ var OpenShellSandboxBackendImpl = class {
1140
1238
  }
1141
1239
  resolveRemoteTarget(remotePath) {
1142
1240
  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)) {
1241
+ const root = resolveOpenShellWorkspaceRoot([{
1242
+ remote: normalizeRemotePath(this.params.remoteWorkspaceDir),
1243
+ owner: "workspace",
1244
+ value: void 0
1245
+ }, {
1246
+ remote: normalizeRemotePath(this.params.remoteAgentWorkspaceDir),
1247
+ owner: "agent",
1248
+ value: void 0
1249
+ }], normalized)?.remote;
1250
+ if (root) {
1145
1251
  const relativePath = path.posix.relative(root, normalized);
1146
1252
  return {
1147
1253
  root,
@@ -1152,7 +1258,11 @@ var OpenShellSandboxBackendImpl = class {
1152
1258
  }
1153
1259
  async ensureSandboxExists() {
1154
1260
  if (this.ensurePromise) return await this.ensurePromise;
1155
- this.ensurePromise = this.ensureSandboxExistsInner();
1261
+ const initialize = async () => {
1262
+ await this.ensureSandboxExistsInner();
1263
+ if (this.params.execContext.config.mode === "remote" && !await this.maybeSeedRemoteWorkspace()) await this.syncSkillsWorkspaceToRemote();
1264
+ };
1265
+ this.ensurePromise = this.params.execContext.config.mode === "remote" ? this.runWorkspaceOperation(initialize) : initialize();
1156
1266
  try {
1157
1267
  await this.ensurePromise;
1158
1268
  } catch (error) {
@@ -1176,9 +1286,12 @@ var OpenShellSandboxBackendImpl = class {
1176
1286
  if (!phase) throw this.buildLegacyRuntimeUnavailableError("OpenShell did not report a lifecycle phase for this sandbox.");
1177
1287
  if (phase !== "Ready") throw this.buildLegacyRuntimeUnavailableError(`OpenShell reports phase "${phase}".`);
1178
1288
  }
1289
+ if (this.params.execContext.config.mode === "remote" && await this.remoteManagedRootsEmpty()) this.remoteSeedPending = true;
1179
1290
  return;
1180
1291
  }
1181
1292
  if (this.params.legacyRuntimeAdopted) throw this.buildLegacyRuntimeUnavailableError(getResult.stderr.trim());
1293
+ if (!/\bsandbox not found\b/iu.test(getResult.stderr)) throw new Error(getResult.stderr.trim() || "openshell sandbox get failed");
1294
+ const detachedCreateSupported = await this.supportsDetachedSandboxCreation();
1182
1295
  const createArgs = [
1183
1296
  "sandbox",
1184
1297
  "create",
@@ -1190,8 +1303,12 @@ var OpenShellSandboxBackendImpl = class {
1190
1303
  ...this.params.execContext.config.gpu ? ["--gpu"] : [],
1191
1304
  ...this.params.execContext.config.autoProviders ? ["--auto-providers"] : ["--no-auto-providers"],
1192
1305
  ...this.params.execContext.config.providers.flatMap((provider) => ["--provider", provider]),
1193
- "--",
1194
- "true"
1306
+ ...detachedCreateSupported ? [
1307
+ "--detach",
1308
+ "--",
1309
+ "sleep",
1310
+ "infinity"
1311
+ ] : ["--", "true"]
1195
1312
  ];
1196
1313
  const createResult = await runOpenShellCli({
1197
1314
  context: this.params.execContext,
@@ -1202,6 +1319,41 @@ var OpenShellSandboxBackendImpl = class {
1202
1319
  if (createResult.code !== 0) throw new Error(createResult.stderr.trim() || "openshell sandbox create failed");
1203
1320
  this.remoteSeedPending = true;
1204
1321
  }
1322
+ async supportsDetachedSandboxCreation() {
1323
+ const { config } = this.params.execContext;
1324
+ const cliIdentity = JSON.stringify([
1325
+ config.command,
1326
+ config.gatewayEndpoint ?? "",
1327
+ config.gateway ?? "",
1328
+ config.workspace ?? process.env.OPENSHELL_WORKSPACE ?? ""
1329
+ ]);
1330
+ let support = openShellDetachedCreateSupport?.key === cliIdentity ? openShellDetachedCreateSupport.promise : void 0;
1331
+ if (!support) {
1332
+ support = (async () => {
1333
+ const result = await runOpenShellCli({
1334
+ context: this.params.execContext,
1335
+ args: [
1336
+ "sandbox",
1337
+ "create",
1338
+ "--help"
1339
+ ],
1340
+ cwd: this.params.createParams.workspaceDir
1341
+ });
1342
+ if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox create capability check failed");
1343
+ return /^\s*--detach(?:\s|$)/mu.test(result.stdout);
1344
+ })();
1345
+ openShellDetachedCreateSupport = {
1346
+ key: cliIdentity,
1347
+ promise: support
1348
+ };
1349
+ }
1350
+ try {
1351
+ return await support;
1352
+ } catch (error) {
1353
+ if (openShellDetachedCreateSupport?.promise === support) openShellDetachedCreateSupport = void 0;
1354
+ throw error;
1355
+ }
1356
+ }
1205
1357
  async resolveLegacyRuntimePhase() {
1206
1358
  const pageSize = 100;
1207
1359
  for (let offset = 0;; offset += pageSize) {
@@ -1234,18 +1386,37 @@ var OpenShellSandboxBackendImpl = class {
1234
1386
  `OpenClaw will not recreate this retired runtime name. Run \`${recreateCommand}\` to migrate this scope to the current naming format.`
1235
1387
  ].filter(Boolean).join(" "));
1236
1388
  }
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]
1389
+ workspaceUploadRoots() {
1390
+ const { createParams, remoteWorkspaceDir, remoteAgentWorkspaceDir } = this.params;
1391
+ const roots = [{
1392
+ remote: remoteWorkspaceDir,
1393
+ owner: "workspace",
1394
+ value: createParams.workspaceDir
1395
+ }];
1396
+ if (createParams.cfg.workspaceAccess !== "none" && path.resolve(createParams.agentWorkspaceDir) !== path.resolve(createParams.workspaceDir)) roots.push({
1397
+ remote: remoteAgentWorkspaceDir,
1398
+ owner: "agent",
1399
+ value: createParams.agentWorkspaceDir
1241
1400
  });
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)) {
1401
+ return orderOpenShellWorkspaceRoots(roots).map(({ remote, owner, value: local }) => ({
1402
+ remote,
1403
+ local,
1404
+ owner
1405
+ }));
1406
+ }
1407
+ async syncWorkspaceToRemote() {
1408
+ const roots = this.workspaceUploadRoots();
1409
+ for (const [index, root] of roots.entries()) {
1410
+ const containingRoot = roots.slice(0, index).toReversed().find((candidate) => isOpenShellRemotePathInside(candidate.remote, root.remote));
1244
1411
  await this.runRemoteShellScriptInternal({
1245
- script: "mkdir -p -- \"$1\" && find \"$1\" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +",
1246
- args: [this.params.remoteAgentWorkspaceDir]
1412
+ script: `${ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT}\nfind "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +`,
1413
+ args: [
1414
+ root.remote,
1415
+ containingRoot?.remote ?? root.remote,
1416
+ containingRoot ? "1" : "0"
1417
+ ]
1247
1418
  });
1248
- await this.uploadPathToRemote(this.params.createParams.agentWorkspaceDir, this.params.remoteAgentWorkspaceDir);
1419
+ await this.uploadPathToRemote(root.local, root.remote);
1249
1420
  }
1250
1421
  await this.syncSkillsWorkspaceToRemote();
1251
1422
  }
@@ -1261,40 +1432,62 @@ var OpenShellSandboxBackendImpl = class {
1261
1432
  await this.uploadPathToRemote(this.params.createParams.skillsWorkspaceDir, remoteSkillsWorkspaceDir);
1262
1433
  }
1263
1434
  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
1435
+ const remoteSkillsWorkspaceDir = resolveRemoteMaterializedSkillsWorkspaceDir(this.params.remoteWorkspaceDir);
1436
+ const roots = this.workspaceUploadRoots();
1437
+ for (const [index, root] of roots.entries()) {
1438
+ if (root.owner === "agent") continue;
1439
+ await withTempWorkspace({
1440
+ rootDir: resolveOpenShellTmpRoot(),
1441
+ prefix: "openclaw-openshell-sync-"
1442
+ }, async ({ dir: tmpDir }) => {
1443
+ const result = await runOpenShellCli({
1444
+ context: this.params.execContext,
1445
+ args: [
1446
+ "sandbox",
1447
+ "download",
1448
+ this.params.execContext.sandboxName,
1449
+ root.remote,
1450
+ tmpDir
1451
+ ],
1452
+ cwd: this.params.createParams.workspaceDir
1295
1453
  });
1296
- }
1297
- });
1454
+ if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox download failed");
1455
+ const preservedShadows = [];
1456
+ try {
1457
+ for (const shadowedRoot of roots.slice(index + 1)) {
1458
+ if (root.remote === shadowedRoot.remote || !isOpenShellRemotePathInside(root.remote, shadowedRoot.remote)) continue;
1459
+ const relativeParts = path.posix.relative(root.remote, shadowedRoot.remote).split("/").filter(Boolean);
1460
+ await removeDownloadedWorkspacePath(tmpDir, relativeParts);
1461
+ const preserved = await moveLocalShadowAside({
1462
+ workspaceDir: root.local,
1463
+ tmpDir,
1464
+ relativeParts
1465
+ });
1466
+ if (preserved) preservedShadows.push(preserved);
1467
+ }
1468
+ const relativeSkillsPath = path.posix.relative(root.remote, remoteSkillsWorkspaceDir);
1469
+ if (relativeSkillsPath === "" || !relativeSkillsPath.startsWith("../") && !path.posix.isAbsolute(relativeSkillsPath)) await removeDownloadedWorkspacePath(tmpDir, relativeSkillsPath.split("/").filter(Boolean));
1470
+ if (root.owner === "workspace") {
1471
+ const preserved = await moveLocalShadowAside({
1472
+ workspaceDir: root.local,
1473
+ tmpDir,
1474
+ relativeParts: MATERIALIZED_SKILLS_REMOTE_PARTS
1475
+ });
1476
+ if (preserved) preservedShadows.push(preserved);
1477
+ }
1478
+ await replaceDirectoryContents({
1479
+ sourceDir: tmpDir,
1480
+ targetDir: root.local,
1481
+ excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS
1482
+ });
1483
+ } finally {
1484
+ for (const preserved of preservedShadows.toReversed()) await restoreLocalShadow({
1485
+ workspaceDir: root.local,
1486
+ preserved
1487
+ });
1488
+ }
1489
+ });
1490
+ }
1298
1491
  }
1299
1492
  async uploadPathToRemote(localPath, remotePath) {
1300
1493
  await withTempWorkspace({
@@ -1305,10 +1498,10 @@ var OpenShellSandboxBackendImpl = class {
1305
1498
  const stagedRoot = path.join(tmpDir, remoteRootName);
1306
1499
  await stageDirectoryContents({
1307
1500
  sourceDir: localPath,
1308
- targetDir: stagedRoot
1501
+ targetDir: stagedRoot,
1502
+ excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS
1309
1503
  });
1310
- const stagedEntries = (await fsPromises.readdir(stagedRoot)).toSorted();
1311
- for (const entry of stagedEntries) {
1504
+ for (const entry of (await fsPromises.readdir(stagedRoot)).toSorted()) {
1312
1505
  const result = await runOpenShellCli({
1313
1506
  context: this.params.execContext,
1314
1507
  args: buildOpenShellDirectoryUploadArgs({
@@ -1322,6 +1515,12 @@ var OpenShellSandboxBackendImpl = class {
1322
1515
  }
1323
1516
  });
1324
1517
  }
1518
+ async remoteManagedRootsEmpty() {
1519
+ return (await this.runRemoteShellScriptInternal({
1520
+ script: REMOTE_MANAGED_ROOTS_EMPTY_SCRIPT,
1521
+ args: [this.params.remoteWorkspaceDir, this.params.remoteAgentWorkspaceDir]
1522
+ })).stdout.toString("utf8").trim() === "0";
1523
+ }
1325
1524
  async maybeSeedRemoteWorkspace() {
1326
1525
  if (!this.remoteSeedPending) return false;
1327
1526
  this.remoteSeedPending = false;
@@ -1386,17 +1585,33 @@ function parseOpenShellSandboxPhasePage(stdout, sandboxName) {
1386
1585
  return;
1387
1586
  }
1388
1587
  }
1588
+ function parseOpenShellSandboxPhase(stdout) {
1589
+ try {
1590
+ const parsed = JSON.parse(stdout);
1591
+ if (typeof parsed !== "object" || parsed === null || !("phase" in parsed)) return;
1592
+ return typeof parsed.phase === "string" ? parsed.phase : void 0;
1593
+ } catch {
1594
+ return;
1595
+ }
1596
+ }
1389
1597
  function resolveRemoteMaterializedSkillsWorkspaceDir(remoteWorkspaceDir) {
1390
1598
  const root = remoteWorkspaceDir.replace(/\\/g, "/").replace(/\/+$/, "") || "/";
1391
1599
  return path.posix.join(root, ...MATERIALIZED_SKILLS_REMOTE_PARTS);
1392
1600
  }
1393
- async function removeMaterializedSkillsFromDownloadedWorkspace(tmpDir) {
1601
+ async function removeDownloadedWorkspacePath(tmpDir, parts) {
1602
+ if (parts.length === 0) {
1603
+ for (const entry of await fsPromises.readdir(tmpDir)) await fsPromises.rm(path.join(tmpDir, entry), {
1604
+ recursive: true,
1605
+ force: true
1606
+ });
1607
+ return;
1608
+ }
1394
1609
  let cursor = tmpDir;
1395
- for (const [index, part] of MATERIALIZED_SKILLS_REMOTE_PARTS.entries()) {
1610
+ for (const [index, part] of parts.entries()) {
1396
1611
  const next = path.join(cursor, part);
1397
1612
  const stats = await fsPromises.lstat(next).catch(() => null);
1398
1613
  if (!stats) return;
1399
- if (index === MATERIALIZED_SKILLS_REMOTE_PARTS.length - 1) {
1614
+ if (index === parts.length - 1) {
1400
1615
  await fsPromises.rm(next, {
1401
1616
  recursive: true,
1402
1617
  force: true
@@ -1413,31 +1628,31 @@ async function removeMaterializedSkillsFromDownloadedWorkspace(tmpDir) {
1413
1628
  cursor = next;
1414
1629
  }
1415
1630
  }
1416
- async function moveMaterializedSkillsShadowAside(params) {
1417
- const shadowPath = path.join(params.workspaceDir, ...MATERIALIZED_SKILLS_REMOTE_PARTS);
1631
+ async function moveLocalShadowAside(params) {
1632
+ const shadowPath = path.join(params.workspaceDir, ...params.relativeParts);
1418
1633
  const parentStats = await fsPromises.lstat(path.dirname(shadowPath)).catch(() => null);
1419
1634
  if (!parentStats?.isDirectory() || parentStats.isSymbolicLink()) return;
1420
1635
  const shadowStats = await fsPromises.lstat(shadowPath).catch(() => null);
1421
1636
  if (!shadowStats || shadowStats.isSymbolicLink()) return;
1422
1637
  const preserveRoot = await fsPromises.mkdtemp(path.join(path.dirname(params.tmpDir), "openclaw-openshell-preserve-"));
1423
- const preservedPath = path.join(preserveRoot, "sandbox-skills");
1638
+ const preservedPath = path.join(preserveRoot, "shadow");
1424
1639
  await movePathWithCopyFallback({
1425
1640
  from: shadowPath,
1426
1641
  to: preservedPath
1427
1642
  });
1428
1643
  return {
1429
1644
  preservedPath,
1430
- preserveRoot
1645
+ preserveRoot,
1646
+ relativeParts: params.relativeParts
1431
1647
  };
1432
1648
  }
1433
- async function restoreMaterializedSkillsShadow(params) {
1434
- if (!params.preserved) return;
1649
+ async function restoreLocalShadow(params) {
1435
1650
  let restored = false;
1436
1651
  try {
1437
- const shadowPath = path.join(params.workspaceDir, ...MATERIALIZED_SKILLS_REMOTE_PARTS);
1652
+ const shadowPath = path.join(params.workspaceDir, ...params.preserved.relativeParts);
1438
1653
  const parentPath = path.dirname(shadowPath);
1439
1654
  const parentStats = await fsPromises.lstat(parentPath).catch(() => null);
1440
- if (parentStats?.isSymbolicLink()) throw new Error(`Refusing to restore sandbox skills through symlink parent: ${parentPath}`);
1655
+ if (parentStats?.isSymbolicLink()) throw new Error(`Refusing to restore workspace shadow through symlink parent: ${parentPath}`);
1441
1656
  if (parentStats && !parentStats.isDirectory()) await fsPromises.rm(parentPath, {
1442
1657
  recursive: true,
1443
1658
  force: true
@@ -1467,10 +1682,6 @@ function normalizeRemotePath(remotePath) {
1467
1682
  if (!path.posix.isAbsolute(normalized)) throw new Error(`OpenShell remote path must be absolute: ${remotePath}`);
1468
1683
  return normalized;
1469
1684
  }
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
1685
  //#endregion
1475
1686
  //#region extensions/openshell/index.ts
1476
1687
  var openshell_default = definePluginEntry({
@@ -1481,11 +1692,18 @@ var openshell_default = definePluginEntry({
1481
1692
  register(api) {
1482
1693
  if (api.registrationMode !== "full") return;
1483
1694
  const pluginConfig = resolveOpenShellPluginConfig(api.pluginConfig);
1484
- registerSandboxBackend("openshell", {
1695
+ const unregister = registerSandboxBackend("openshell", {
1485
1696
  factory: createOpenShellSandboxBackendFactory({ pluginConfig }),
1486
1697
  manager: createOpenShellSandboxBackendManager({ pluginConfig }),
1487
1698
  resolveWorkdir: () => pluginConfig.remoteWorkspaceDir
1488
1699
  });
1700
+ api.lifecycle.registerRuntimeLifecycle({
1701
+ id: "openshell-sandbox-cleanup",
1702
+ cleanup: ({ reason, sessionKey, runId }) => {
1703
+ if (sessionKey !== void 0 || runId !== void 0) return;
1704
+ if (reason === "disable" || reason === "restart") unregister();
1705
+ }
1706
+ });
1489
1707
  }
1490
1708
  });
1491
1709
  //#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.1",
4
4
  "description": "OpenClaw sandbox backend for the NVIDIA OpenShell CLI with mirrored local workspaces and SSH command execution.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,10 +21,10 @@
21
21
  "minHostVersion": ">=2026.5.12-beta.1"
22
22
  },
23
23
  "compat": {
24
- "pluginApi": ">=2026.8.1-beta.3"
24
+ "pluginApi": ">=2026.8.1"
25
25
  },
26
26
  "build": {
27
- "openclawVersion": "2026.8.1-beta.3",
27
+ "openclawVersion": "2026.8.1",
28
28
  "bundledDist": false
29
29
  },
30
30
  "release": {
@@ -41,7 +41,7 @@
41
41
  "README.md"
42
42
  ],
43
43
  "peerDependencies": {
44
- "openclaw": ">=2026.8.1-beta.3"
44
+ "openclaw": ">=2026.8.1"
45
45
  },
46
46
  "peerDependenciesMeta": {
47
47
  "openclaw": {