@openclaw/openshell-sandbox 2026.8.1-beta.2 → 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 { 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
- import { root } from "openclaw/plugin-sdk/file-access-runtime";
12
- import { FsSafeError, isPathInside, movePathWithCopyFallback } from "openclaw/plugin-sdk/security-runtime";
13
+ import { isPathInside, root } from "openclaw/plugin-sdk/file-access-runtime";
13
14
  import pLimit from "p-limit";
14
15
  //#region extensions/openshell/src/cli.ts
15
16
  function buildOpenShellBaseArgv(config) {
@@ -19,9 +20,6 @@ function buildOpenShellBaseArgv(config) {
19
20
  if (config.workspace) argv.push("--workspace", config.workspace);
20
21
  return argv;
21
22
  }
22
- function buildRemoteCommand(argv) {
23
- return argv.map((entry) => shellEscape(entry)).join(" ");
24
- }
25
23
  function applyGatewayEndpointToSshConfig(params) {
26
24
  const endpoint = params.gatewayEndpoint?.trim();
27
25
  if (!endpoint) return params.configText;
@@ -75,6 +73,7 @@ function normalizeProviders(value) {
75
73
  return providers;
76
74
  }
77
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` });
78
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" });
79
78
  const OpenShellPluginConfigSchema = z.strictObject({
80
79
  mode: z.enum(["mirror", "remote"], { error: "mode must be one of mirror, remote" }).optional(),
@@ -87,8 +86,8 @@ const OpenShellPluginConfigSchema = z.strictObject({
87
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(),
88
87
  gpu: z.boolean({ error: "gpu must be a boolean" }).optional(),
89
88
  autoProviders: z.boolean({ error: "autoProviders must be a boolean" }).optional(),
90
- remoteWorkspaceDir: nonEmptyTrimmedString("remoteWorkspaceDir must be a non-empty string").optional(),
91
- remoteAgentWorkspaceDir: nonEmptyTrimmedString("remoteAgentWorkspaceDir must be a non-empty string").optional(),
89
+ remoteWorkspaceDir: openShellManagedRemotePath("remoteWorkspaceDir").optional(),
90
+ remoteAgentWorkspaceDir: openShellManagedRemotePath("remoteAgentWorkspaceDir").optional(),
92
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()
93
92
  });
94
93
  function isManagedOpenShellRemotePath(value) {
@@ -157,6 +156,24 @@ function resolveOpenShellPluginConfig(value) {
157
156
  };
158
157
  }
159
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
160
177
  //#region extensions/openshell/src/fs-bridge.ts
161
178
  const MATERIALIZED_SKILLS_CONTAINER_PARTS = [
162
179
  ".openclaw",
@@ -352,27 +369,43 @@ var OpenShellFsBridge = class {
352
369
  });
353
370
  if (protectedSkillTarget) return protectedSkillTarget;
354
371
  }
355
- if (input.startsWith(`${workspaceContainerRoot}/`) || input === workspaceContainerRoot) {
356
- const relative = path.posix.relative(workspaceContainerRoot, input) || "";
357
- return {
358
- hostPath: relative ? path.resolve(workspaceRoot, ...relative.split("/")) : workspaceRoot,
359
- relativePath: relative,
360
- containerPath: relative ? path.posix.join(workspaceContainerRoot, relative) : workspaceContainerRoot,
361
- mountHostRoot: workspaceRoot,
362
- writable: this.sandbox.workspaceAccess === "rw",
363
- source: "workspace"
364
- };
365
- }
366
- if (hasAgentMount && (input.startsWith(`${agentContainerRoot}/`) || input === agentContainerRoot)) {
367
- 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}`);
368
393
  return {
369
- hostPath: relative ? path.resolve(agentRoot, ...relative.split("/")) : agentRoot,
370
- relativePath: relative ? agentContainerRoot + "/" + relative : agentContainerRoot,
371
- containerPath: relative ? path.posix.join(agentContainerRoot, relative) : agentContainerRoot,
372
- mountHostRoot: agentRoot,
373
- writable: this.sandbox.workspaceAccess === "rw",
374
- 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
375
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}`);
376
409
  }
377
410
  const cwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot;
378
411
  const hostPath = path.isAbsolute(input) ? path.resolve(input) : path.resolve(cwd, input);
@@ -387,14 +420,7 @@ var OpenShellFsBridge = class {
387
420
  }
388
421
  if (isPathInside(workspaceRoot, hostPath)) {
389
422
  const relative = path.relative(workspaceRoot, hostPath).split(path.sep).join(path.posix.sep);
390
- return {
391
- hostPath,
392
- relativePath: relative,
393
- containerPath: relative ? path.posix.join(workspaceContainerRoot, relative) : workspaceContainerRoot,
394
- mountHostRoot: workspaceRoot,
395
- writable: this.sandbox.workspaceAccess === "rw",
396
- source: "workspace"
397
- };
423
+ return expectResolvedContainerTarget(resolveContainerTarget(path.posix.join(workspaceContainerRoot, relative)), input);
398
424
  }
399
425
  if (skillsRoot && this.sandbox.workspaceAccess === "rw" && isPathInside(skillsRoot, hostPath)) {
400
426
  const relative = path.relative(skillsRoot, hostPath).split(path.sep).join(path.posix.sep);
@@ -409,18 +435,15 @@ var OpenShellFsBridge = class {
409
435
  }
410
436
  if (hasAgentMount && isPathInside(agentRoot, hostPath)) {
411
437
  const relative = path.relative(agentRoot, hostPath).split(path.sep).join(path.posix.sep);
412
- return {
413
- hostPath,
414
- relativePath: relative ? `${agentContainerRoot}/${relative}` : agentContainerRoot,
415
- containerPath: relative ? path.posix.join(agentContainerRoot, relative) : agentContainerRoot,
416
- mountHostRoot: agentRoot,
417
- writable: this.sandbox.workspaceAccess === "rw",
418
- source: "agent"
419
- };
438
+ return expectResolvedContainerTarget(resolveContainerTarget(path.posix.join(agentContainerRoot, relative)), input);
420
439
  }
421
440
  throw new Error(`Path escapes sandbox root (${workspaceRoot}): ${params.filePath}`);
422
441
  }
423
442
  };
443
+ function expectResolvedContainerTarget(target, input) {
444
+ if (!target) throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${input}`);
445
+ return target;
446
+ }
424
447
  async function mkdirLocalRootPath(params) {
425
448
  const relativePath = relativeToRoot(params.target, params.hostPath);
426
449
  if (!relativePath) return;
@@ -585,66 +608,65 @@ function createExcludeMatcher(excludeDirs) {
585
608
  }
586
609
  const runLimitedFs = pLimit(COPY_TREE_FS_CONCURRENCY);
587
610
  async function lstatIfExists(targetPath) {
588
- 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
+ });
589
615
  }
590
- async function copyTreeWithoutSymlinks(params) {
591
- const stats = await runLimitedFs(async () => await fsPromises.lstat(params.sourcePath));
592
- if (stats.isSymbolicLink()) return;
616
+ async function reconcileMirrorPath(params) {
593
617
  const targetStats = await lstatIfExists(params.targetPath);
594
- if (params.preserveTargetSymlinks && targetStats?.isSymbolicLink()) return;
595
- if (stats.isDirectory()) {
596
- await runLimitedFs(fsPromises.mkdir, params.targetPath, { recursive: true });
597
- const entries = await runLimitedFs(async () => await fsPromises.readdir(params.sourcePath));
598
- await Promise.all(entries.map(async (entry) => {
599
- await copyTreeWithoutSymlinks({
600
- sourcePath: path.join(params.sourcePath, entry),
601
- targetPath: path.join(params.targetPath, entry),
602
- preserveTargetSymlinks: params.preserveTargetSymlinks
603
- });
604
- }));
605
- return;
606
- }
607
- if (stats.isFile()) {
608
- await runLimitedFs(fsPromises.mkdir, path.dirname(params.targetPath), { recursive: true });
609
- await runLimitedFs(async () => await fsPromises.copyFile(params.sourcePath, params.targetPath));
610
- }
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;
611
635
  }
612
- async function replaceDirectoryContents(params) {
636
+ async function reconcileMirrorDirectory(params) {
637
+ const { sourceDir } = params;
613
638
  const isExcluded = createExcludeMatcher(params.excludeDirs);
614
- await fsPromises.mkdir(params.targetDir, { recursive: true });
615
- const existing = await fsPromises.readdir(params.targetDir);
616
- await Promise.all(existing.filter((entry) => !isExcluded(entry)).map(async (entry) => {
617
- const targetPath = path.join(params.targetDir, entry);
618
- if ((await lstatIfExists(targetPath))?.isSymbolicLink()) return;
619
- await runLimitedFs(fsPromises.rm, targetPath, {
620
- recursive: true,
621
- force: true
622
- });
623
- }));
624
- const sourceEntries = await fsPromises.readdir(params.sourceDir);
625
- for (const entry of sourceEntries) {
626
- if (isExcluded(entry)) continue;
627
- await copyTreeWithoutSymlinks({
628
- sourcePath: path.join(params.sourceDir, entry),
629
- targetPath: path.join(params.targetDir, entry),
630
- preserveTargetSymlinks: true
631
- });
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;
632
651
  }
652
+ return preservedEntries;
653
+ }
654
+ async function replaceDirectoryContents(params) {
655
+ await reconcileMirrorDirectory({
656
+ ...params,
657
+ replace: true
658
+ });
633
659
  }
634
660
  async function stageDirectoryContents(params) {
635
- const isExcluded = createExcludeMatcher(params.excludeDirs);
636
- await fsPromises.mkdir(params.targetDir, { recursive: true });
637
- const sourceEntries = await fsPromises.readdir(params.sourceDir);
638
- for (const entry of sourceEntries) {
639
- if (isExcluded(entry)) continue;
640
- await copyTreeWithoutSymlinks({
641
- sourcePath: path.join(params.sourceDir, entry),
642
- targetPath: path.join(params.targetDir, entry)
643
- });
644
- }
661
+ await reconcileMirrorDirectory({
662
+ ...params,
663
+ replace: false
664
+ });
645
665
  }
646
666
  //#endregion
647
667
  //#region extensions/openshell/src/backend.ts
668
+ const openShellWorkspaceOperations = new KeyedAsyncQueue();
669
+ let openShellDetachedCreateSupport;
648
670
  const MATERIALIZED_SKILLS_REMOTE_PARTS = [".openclaw", "sandbox-skills"];
649
671
  function buildOpenShellDirectoryUploadArgs(params) {
650
672
  return [
@@ -653,9 +675,10 @@ function buildOpenShellDirectoryUploadArgs(params) {
653
675
  "--no-git-ignore",
654
676
  params.sandboxName,
655
677
  params.localPath,
656
- normalizeRemotePath(params.remotePath)
678
+ `${normalizeRemotePath(params.remotePath)}/`
657
679
  ];
658
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\"";
659
682
  const PINNED_REMOTE_PATH_MUTATION_SCRIPT = [
660
683
  "set -eu",
661
684
  "die() { echo \"$1\" >&2; exit 1; }",
@@ -761,6 +784,7 @@ const ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT = [
761
784
  "set -e",
762
785
  "target=\"$1\"",
763
786
  "root=\"${2:-$1}\"",
787
+ "replace_blocking=\"${3:-0}\"",
764
788
  "case \"$target\" in /*) ;; *) echo \"remote directory must be absolute: $target\" >&2; exit 1 ;; esac",
765
789
  "case \"$root\" in /*) ;; *) echo \"remote root must be absolute: $root\" >&2; exit 1 ;; esac",
766
790
  "target=\"${target%/}\"",
@@ -788,9 +812,15 @@ const ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT = [
788
812
  " if [ \"$part\" = \"$relative\" ]; then relative=\"\"; else relative=\"${relative#*/}\"; fi",
789
813
  " [ -n \"$part\" ] || continue",
790
814
  " if [ \"$current\" = \"/\" ]; then next=\"/$part\"; else next=\"$current/$part\"; fi",
791
- " 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",
792
822
  " if [ -e \"$next\" ]; then",
793
- " 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; }",
794
824
  " else",
795
825
  " mkdir -- \"$next\"",
796
826
  " fi",
@@ -818,20 +848,22 @@ function createOpenShellSandboxBackendManager(params) {
818
848
  args: [
819
849
  "sandbox",
820
850
  "get",
821
- entry.containerName
851
+ entry.containerName,
852
+ "--output",
853
+ "json"
822
854
  ]
823
855
  });
824
856
  const configuredSource = execContext.config.from;
825
857
  return {
826
- running: result.code === 0,
858
+ running: result.code === 0 && parseOpenShellSandboxPhase(result.stdout) === "Ready",
827
859
  actualConfigLabel: entry.image,
828
860
  configLabelMatch: entry.image === configuredSource
829
861
  };
830
862
  },
831
- async removeRuntime({ entry }) {
832
- await runOpenShellCli({
863
+ async removeRuntime({ entry, config }) {
864
+ const result = await runOpenShellCli({
833
865
  context: {
834
- config: params.pluginConfig,
866
+ config: resolveOpenShellPluginConfigFromConfig(config, params.pluginConfig),
835
867
  sandboxName: entry.containerName
836
868
  },
837
869
  args: [
@@ -840,6 +872,7 @@ function createOpenShellSandboxBackendManager(params) {
840
872
  entry.containerName
841
873
  ]
842
874
  });
875
+ if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox delete failed");
843
876
  }
844
877
  };
845
878
  }
@@ -854,69 +887,25 @@ async function createOpenShellSandboxBackend(params) {
854
887
  config: params.pluginConfig,
855
888
  sandboxName
856
889
  };
857
- const impl = new OpenShellSandboxBackendImpl({
890
+ return new OpenShellSandboxBackendImpl({
858
891
  createParams: params.createParams,
859
892
  execContext,
860
893
  legacyRuntimeAdopted: resolvedSandboxName.legacyRuntimeAdopted,
861
894
  remoteWorkspaceDir: params.pluginConfig.remoteWorkspaceDir,
862
895
  remoteAgentWorkspaceDir: params.pluginConfig.remoteAgentWorkspaceDir
863
- });
864
- return {
865
- id: "openshell",
866
- runtimeId: sandboxName,
867
- runtimeLabel: sandboxName,
868
- workdir: params.pluginConfig.remoteWorkspaceDir,
869
- env: params.createParams.cfg.docker.env,
870
- mode: params.pluginConfig.mode,
871
- configLabel: params.pluginConfig.from,
872
- configLabelKind: "Source",
873
- workdirValidation: "backend",
874
- validateWorkdir: async (workdir) => await impl.validateWorkdir(workdir),
875
- discardPreparedWorkdir: (workdir) => impl.discardPreparedWorkdir(workdir),
876
- workdirRoots: [params.pluginConfig.remoteWorkspaceDir, params.pluginConfig.remoteAgentWorkspaceDir],
877
- buildExecSpec: async ({ command, workdir, env, usePty }) => {
878
- const pending = await impl.prepareExec({
879
- command,
880
- workdir,
881
- env,
882
- usePty
883
- });
884
- return {
885
- argv: pending.argv,
886
- env: buildOpenShellSshExecEnv(),
887
- stdinMode: "pipe-open",
888
- finalizeToken: pending.token
889
- };
890
- },
891
- finalizeExec: async ({ token }) => {
892
- await impl.finalizeExec(token);
893
- },
894
- runShellCommand: async (command) => await impl.runRemoteShellScript(command),
895
- createFsBridge: ({ sandbox }) => params.pluginConfig.mode === "remote" ? createRemoteShellSandboxFsBridge({
896
- sandbox,
897
- runtime: impl.asHandle()
898
- }) : createOpenShellFsBridge({
899
- sandbox,
900
- backend: impl.asHandle()
901
- }),
902
- remoteWorkspaceDir: params.pluginConfig.remoteWorkspaceDir,
903
- remoteAgentWorkspaceDir: params.pluginConfig.remoteAgentWorkspaceDir,
904
- runRemoteShellScript: async (command) => await impl.runRemoteShellScript(command),
905
- mkdirpRemotePath: async (remotePath, signal) => await impl.mkdirpRemotePath(remotePath, signal),
906
- removeRemotePath: async (remotePath, removeParams) => await impl.removeRemotePath(remotePath, removeParams),
907
- renameRemotePath: async (fromRemotePath, toRemotePath, signal) => await impl.renameRemotePath(fromRemotePath, toRemotePath, signal),
908
- syncLocalPathToRemote: async (localPath, remotePath) => await impl.syncLocalPathToRemote(localPath, remotePath)
909
- };
896
+ }).asHandle();
910
897
  }
911
898
  var OpenShellSandboxBackendImpl = class {
912
899
  constructor(params) {
913
900
  this.params = params;
901
+ this.handle = null;
914
902
  this.ensurePromise = null;
915
- this.preparedRemoteWorkspaceForNextExec = null;
916
903
  this.remoteSeedPending = false;
917
904
  }
918
905
  asHandle() {
919
- return {
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);
908
+ const handle = {
920
909
  id: "openshell",
921
910
  runtimeId: this.params.execContext.sandboxName,
922
911
  runtimeLabel: this.params.execContext.sandboxName,
@@ -927,7 +916,6 @@ var OpenShellSandboxBackendImpl = class {
927
916
  configLabelKind: "Source",
928
917
  workdirValidation: "backend",
929
918
  validateWorkdir: async (workdir) => await this.validateWorkdir(workdir),
930
- discardPreparedWorkdir: (workdir) => this.discardPreparedWorkdir(workdir),
931
919
  workdirRoots: [this.params.remoteWorkspaceDir, this.params.remoteAgentWorkspaceDir],
932
920
  remoteWorkspaceDir: this.params.remoteWorkspaceDir,
933
921
  remoteAgentWorkspaceDir: this.params.remoteAgentWorkspaceDir,
@@ -948,124 +936,189 @@ var OpenShellSandboxBackendImpl = class {
948
936
  finalizeExec: async ({ token }) => {
949
937
  await this.finalizeExec(token);
950
938
  },
951
- runShellCommand: async (command) => await this.runRemoteShellScript(command),
939
+ runShellCommand: runRemoteShellScript,
952
940
  createFsBridge: ({ sandbox }) => this.params.execContext.config.mode === "remote" ? createRemoteShellSandboxFsBridge({
953
941
  sandbox,
954
- runtime: this.asHandle()
955
- }) : createOpenShellFsBridge({
956
- sandbox,
957
- backend: this.asHandle()
958
- }),
959
- runRemoteShellScript: async (command) => await this.runRemoteShellScript(command),
960
- mkdirpRemotePath: async (remotePath, signal) => await this.mkdirpRemotePath(remotePath, signal),
961
- removeRemotePath: async (remotePath, removeParams) => await this.removeRemotePath(remotePath, removeParams),
962
- renameRemotePath: async (fromRemotePath, toRemotePath, signal) => await this.renameRemotePath(fromRemotePath, toRemotePath, signal),
963
- syncLocalPathToRemote: async (localPath, remotePath) => await this.syncLocalPathToRemote(localPath, remotePath)
942
+ runtime: handle
943
+ }) : this.createMirrorFsBridge(sandbox),
944
+ runRemoteShellScript
964
945
  };
946
+ this.handle = handle;
947
+ return handle;
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
+ } };
965
998
  }
966
999
  async prepareExec(params) {
967
1000
  const remoteWorkdir = params.workdir ?? this.params.remoteWorkspaceDir;
968
- const preparedWorkspace = this.consumePreparedRemoteWorkspaceForNextExec(remoteWorkdir);
969
1001
  const remoteCommand = buildValidatedExecRemoteCommand({
970
1002
  command: params.command,
971
1003
  workdir: remoteWorkdir,
972
- env: params.env
1004
+ env: {}
973
1005
  });
974
- await (preparedWorkspace ?? this.prepareRemoteWorkspaceForExec());
975
- const sshSession = await createOpenShellSshSession({ context: this.params.execContext });
976
- return {
977
- argv: [
978
- "ssh",
979
- "-F",
980
- sshSession.configPath,
981
- ...params.usePty ? [
982
- "-tt",
983
- "-o",
984
- "RequestTTY=force",
985
- "-o",
986
- "SetEnv=TERM=xterm-256color"
987
- ] : [
988
- "-T",
989
- "-o",
990
- "RequestTTY=no"
991
- ],
992
- sshSession.host,
993
- remoteCommand
994
- ],
995
- token: { sshSession }
996
- };
997
- }
998
- async validateWorkdir(workdir) {
999
- const preparedWorkspace = this.prepareRemoteWorkspaceForExec();
1000
- const reusablePreparation = {
1001
- workdir,
1002
- promise: preparedWorkspace
1003
- };
1004
- this.preparedRemoteWorkspaceForNextExec = reusablePreparation;
1006
+ const workspaceLease = this.params.execContext.config.mode === "mirror" ? await this.acquireWorkspaceLease() : void 0;
1005
1007
  try {
1006
- await preparedWorkspace;
1008
+ await this.ensureSandboxExists();
1009
+ if (workspaceLease) {
1010
+ await this.syncWorkspaceToRemote();
1011
+ this.remoteSeedPending = false;
1012
+ }
1007
1013
  const sshSession = await createOpenShellSshSession({ context: this.params.execContext });
1008
1014
  try {
1009
- const result = await runSshSandboxCommand({
1015
+ const prepared = await prepareSshSandboxExec({
1010
1016
  session: sshSession,
1011
- remoteCommand: buildRemoteWorkdirValidationCommand({
1012
- workdir,
1013
- root: this.resolveWorkdirValidationRoot(workdir)
1014
- }),
1015
- allowFailure: true
1017
+ remoteCommand,
1018
+ env: params.env,
1019
+ tty: params.usePty
1016
1020
  });
1017
- const resolvedWorkdir = result.code === 0 ? result.stdout.toString("utf8").trim() : "";
1018
- if (this.preparedRemoteWorkspaceForNextExec === reusablePreparation) this.preparedRemoteWorkspaceForNextExec = resolvedWorkdir ? {
1019
- workdir: resolvedWorkdir,
1020
- promise: preparedWorkspace
1021
- } : null;
1022
- return resolvedWorkdir || null;
1023
- } 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) {
1024
1035
  await disposeSshSandboxSession(sshSession);
1036
+ throw error;
1025
1037
  }
1026
1038
  } catch (error) {
1027
- if (this.preparedRemoteWorkspaceForNextExec === reusablePreparation) this.preparedRemoteWorkspaceForNextExec = null;
1039
+ workspaceLease?.release();
1028
1040
  throw error;
1029
1041
  }
1030
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
+ }
1031
1061
  resolveWorkdirValidationRoot(workdir) {
1032
1062
  try {
1033
1063
  const normalized = normalizeRemotePath(workdir);
1034
- 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;
1035
1073
  } catch {
1036
1074
  return this.params.remoteWorkspaceDir;
1037
1075
  }
1038
1076
  }
1039
- consumePreparedRemoteWorkspaceForNextExec(workdir) {
1040
- const preparedWorkspace = this.preparedRemoteWorkspaceForNextExec;
1041
- if (!preparedWorkspace || preparedWorkspace.workdir !== workdir) {
1042
- this.preparedRemoteWorkspaceForNextExec = null;
1043
- return null;
1044
- }
1045
- this.preparedRemoteWorkspaceForNextExec = null;
1046
- return preparedWorkspace.promise;
1047
- }
1048
- discardPreparedWorkdir(workdir) {
1049
- if (this.preparedRemoteWorkspaceForNextExec?.workdir === workdir) this.preparedRemoteWorkspaceForNextExec = null;
1050
- }
1051
- async prepareRemoteWorkspaceForExec() {
1052
- await this.ensureSandboxExists();
1053
- if (this.params.execContext.config.mode === "mirror") {
1054
- await this.syncWorkspaceToRemote();
1055
- 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;
1056
1104
  }
1057
- if (!await this.maybeSeedRemoteWorkspace()) await this.syncSkillsWorkspaceToRemote();
1105
+ return createdAncestor ? normalized : null;
1058
1106
  }
1059
1107
  async finalizeExec(token) {
1108
+ const workspaceLease = token?.workspaceLease ?? (this.params.execContext.config.mode === "mirror" ? await this.acquireWorkspaceLease() : void 0);
1060
1109
  try {
1061
1110
  if (this.params.execContext.config.mode === "mirror") await this.syncWorkspaceFromRemote();
1062
1111
  } finally {
1063
- if (token?.sshSession) await disposeSshSandboxSession(token.sshSession);
1112
+ try {
1113
+ await token?.cleanup();
1114
+ } finally {
1115
+ workspaceLease?.release();
1116
+ }
1064
1117
  }
1065
1118
  }
1066
1119
  async runRemoteShellScript(params) {
1067
1120
  await this.ensureSandboxExists();
1068
- if (!await this.maybeSeedRemoteWorkspace()) await this.syncSkillsWorkspaceToRemote();
1121
+ if (this.params.execContext.config.mode === "mirror") await this.maybeSeedRemoteWorkspace();
1069
1122
  return await this.runRemoteShellScriptInternal(params);
1070
1123
  }
1071
1124
  async mkdirpRemotePath(remotePath, signal) {
@@ -1170,7 +1223,7 @@ var OpenShellSandboxBackendImpl = class {
1170
1223
  "--no-git-ignore",
1171
1224
  this.params.execContext.sandboxName,
1172
1225
  localPath,
1173
- path.posix.dirname(remotePath)
1226
+ remotePath
1174
1227
  ],
1175
1228
  cwd: this.params.createParams.workspaceDir
1176
1229
  });
@@ -1185,8 +1238,16 @@ var OpenShellSandboxBackendImpl = class {
1185
1238
  }
1186
1239
  resolveRemoteTarget(remotePath) {
1187
1240
  const normalized = normalizeRemotePath(remotePath);
1188
- const roots = [normalizeRemotePath(this.params.remoteWorkspaceDir), normalizeRemotePath(this.params.remoteAgentWorkspaceDir)].toSorted((a, b) => b.length - a.length);
1189
- 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) {
1190
1251
  const relativePath = path.posix.relative(root, normalized);
1191
1252
  return {
1192
1253
  root,
@@ -1197,7 +1258,11 @@ var OpenShellSandboxBackendImpl = class {
1197
1258
  }
1198
1259
  async ensureSandboxExists() {
1199
1260
  if (this.ensurePromise) return await this.ensurePromise;
1200
- 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();
1201
1266
  try {
1202
1267
  await this.ensurePromise;
1203
1268
  } catch (error) {
@@ -1221,9 +1286,12 @@ var OpenShellSandboxBackendImpl = class {
1221
1286
  if (!phase) throw this.buildLegacyRuntimeUnavailableError("OpenShell did not report a lifecycle phase for this sandbox.");
1222
1287
  if (phase !== "Ready") throw this.buildLegacyRuntimeUnavailableError(`OpenShell reports phase "${phase}".`);
1223
1288
  }
1289
+ if (this.params.execContext.config.mode === "remote" && await this.remoteManagedRootsEmpty()) this.remoteSeedPending = true;
1224
1290
  return;
1225
1291
  }
1226
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();
1227
1295
  const createArgs = [
1228
1296
  "sandbox",
1229
1297
  "create",
@@ -1235,8 +1303,12 @@ var OpenShellSandboxBackendImpl = class {
1235
1303
  ...this.params.execContext.config.gpu ? ["--gpu"] : [],
1236
1304
  ...this.params.execContext.config.autoProviders ? ["--auto-providers"] : ["--no-auto-providers"],
1237
1305
  ...this.params.execContext.config.providers.flatMap((provider) => ["--provider", provider]),
1238
- "--",
1239
- "true"
1306
+ ...detachedCreateSupported ? [
1307
+ "--detach",
1308
+ "--",
1309
+ "sleep",
1310
+ "infinity"
1311
+ ] : ["--", "true"]
1240
1312
  ];
1241
1313
  const createResult = await runOpenShellCli({
1242
1314
  context: this.params.execContext,
@@ -1247,6 +1319,41 @@ var OpenShellSandboxBackendImpl = class {
1247
1319
  if (createResult.code !== 0) throw new Error(createResult.stderr.trim() || "openshell sandbox create failed");
1248
1320
  this.remoteSeedPending = true;
1249
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
+ }
1250
1357
  async resolveLegacyRuntimePhase() {
1251
1358
  const pageSize = 100;
1252
1359
  for (let offset = 0;; offset += pageSize) {
@@ -1279,18 +1386,37 @@ var OpenShellSandboxBackendImpl = class {
1279
1386
  `OpenClaw will not recreate this retired runtime name. Run \`${recreateCommand}\` to migrate this scope to the current naming format.`
1280
1387
  ].filter(Boolean).join(" "));
1281
1388
  }
1282
- async syncWorkspaceToRemote() {
1283
- await this.runRemoteShellScriptInternal({
1284
- script: "mkdir -p -- \"$1\" && find \"$1\" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +",
1285
- 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
1286
1400
  });
1287
- await this.uploadPathToRemote(this.params.createParams.workspaceDir, this.params.remoteWorkspaceDir);
1288
- 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));
1289
1411
  await this.runRemoteShellScriptInternal({
1290
- script: "mkdir -p -- \"$1\" && find \"$1\" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +",
1291
- 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
+ ]
1292
1418
  });
1293
- await this.uploadPathToRemote(this.params.createParams.agentWorkspaceDir, this.params.remoteAgentWorkspaceDir);
1419
+ await this.uploadPathToRemote(root.local, root.remote);
1294
1420
  }
1295
1421
  await this.syncSkillsWorkspaceToRemote();
1296
1422
  }
@@ -1306,40 +1432,62 @@ var OpenShellSandboxBackendImpl = class {
1306
1432
  await this.uploadPathToRemote(this.params.createParams.skillsWorkspaceDir, remoteSkillsWorkspaceDir);
1307
1433
  }
1308
1434
  async syncWorkspaceFromRemote() {
1309
- await withTempWorkspace({
1310
- rootDir: resolveOpenShellTmpRoot(),
1311
- prefix: "openclaw-openshell-sync-"
1312
- }, async ({ dir: tmpDir }) => {
1313
- const result = await runOpenShellCli({
1314
- context: this.params.execContext,
1315
- args: [
1316
- "sandbox",
1317
- "download",
1318
- this.params.execContext.sandboxName,
1319
- this.params.remoteWorkspaceDir,
1320
- tmpDir
1321
- ],
1322
- cwd: this.params.createParams.workspaceDir
1323
- });
1324
- if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox download failed");
1325
- await removeMaterializedSkillsFromDownloadedWorkspace(tmpDir);
1326
- const preservedSandboxSkills = await moveMaterializedSkillsShadowAside({
1327
- workspaceDir: this.params.createParams.workspaceDir,
1328
- tmpDir
1329
- });
1330
- try {
1331
- await replaceDirectoryContents({
1332
- sourceDir: tmpDir,
1333
- targetDir: this.params.createParams.workspaceDir,
1334
- excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS
1335
- });
1336
- } finally {
1337
- await restoreMaterializedSkillsShadow({
1338
- workspaceDir: this.params.createParams.workspaceDir,
1339
- 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
1340
1453
  });
1341
- }
1342
- });
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
+ }
1343
1491
  }
1344
1492
  async uploadPathToRemote(localPath, remotePath) {
1345
1493
  await withTempWorkspace({
@@ -1350,10 +1498,10 @@ var OpenShellSandboxBackendImpl = class {
1350
1498
  const stagedRoot = path.join(tmpDir, remoteRootName);
1351
1499
  await stageDirectoryContents({
1352
1500
  sourceDir: localPath,
1353
- targetDir: stagedRoot
1501
+ targetDir: stagedRoot,
1502
+ excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS
1354
1503
  });
1355
- const stagedEntries = (await fsPromises.readdir(stagedRoot)).toSorted();
1356
- for (const entry of stagedEntries) {
1504
+ for (const entry of (await fsPromises.readdir(stagedRoot)).toSorted()) {
1357
1505
  const result = await runOpenShellCli({
1358
1506
  context: this.params.execContext,
1359
1507
  args: buildOpenShellDirectoryUploadArgs({
@@ -1367,6 +1515,12 @@ var OpenShellSandboxBackendImpl = class {
1367
1515
  }
1368
1516
  });
1369
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
+ }
1370
1524
  async maybeSeedRemoteWorkspace() {
1371
1525
  if (!this.remoteSeedPending) return false;
1372
1526
  this.remoteSeedPending = false;
@@ -1431,17 +1585,33 @@ function parseOpenShellSandboxPhasePage(stdout, sandboxName) {
1431
1585
  return;
1432
1586
  }
1433
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
+ }
1434
1597
  function resolveRemoteMaterializedSkillsWorkspaceDir(remoteWorkspaceDir) {
1435
1598
  const root = remoteWorkspaceDir.replace(/\\/g, "/").replace(/\/+$/, "") || "/";
1436
1599
  return path.posix.join(root, ...MATERIALIZED_SKILLS_REMOTE_PARTS);
1437
1600
  }
1438
- 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
+ }
1439
1609
  let cursor = tmpDir;
1440
- for (const [index, part] of MATERIALIZED_SKILLS_REMOTE_PARTS.entries()) {
1610
+ for (const [index, part] of parts.entries()) {
1441
1611
  const next = path.join(cursor, part);
1442
1612
  const stats = await fsPromises.lstat(next).catch(() => null);
1443
1613
  if (!stats) return;
1444
- if (index === MATERIALIZED_SKILLS_REMOTE_PARTS.length - 1) {
1614
+ if (index === parts.length - 1) {
1445
1615
  await fsPromises.rm(next, {
1446
1616
  recursive: true,
1447
1617
  force: true
@@ -1458,31 +1628,31 @@ async function removeMaterializedSkillsFromDownloadedWorkspace(tmpDir) {
1458
1628
  cursor = next;
1459
1629
  }
1460
1630
  }
1461
- async function moveMaterializedSkillsShadowAside(params) {
1462
- const shadowPath = path.join(params.workspaceDir, ...MATERIALIZED_SKILLS_REMOTE_PARTS);
1631
+ async function moveLocalShadowAside(params) {
1632
+ const shadowPath = path.join(params.workspaceDir, ...params.relativeParts);
1463
1633
  const parentStats = await fsPromises.lstat(path.dirname(shadowPath)).catch(() => null);
1464
1634
  if (!parentStats?.isDirectory() || parentStats.isSymbolicLink()) return;
1465
1635
  const shadowStats = await fsPromises.lstat(shadowPath).catch(() => null);
1466
1636
  if (!shadowStats || shadowStats.isSymbolicLink()) return;
1467
1637
  const preserveRoot = await fsPromises.mkdtemp(path.join(path.dirname(params.tmpDir), "openclaw-openshell-preserve-"));
1468
- const preservedPath = path.join(preserveRoot, "sandbox-skills");
1638
+ const preservedPath = path.join(preserveRoot, "shadow");
1469
1639
  await movePathWithCopyFallback({
1470
1640
  from: shadowPath,
1471
1641
  to: preservedPath
1472
1642
  });
1473
1643
  return {
1474
1644
  preservedPath,
1475
- preserveRoot
1645
+ preserveRoot,
1646
+ relativeParts: params.relativeParts
1476
1647
  };
1477
1648
  }
1478
- async function restoreMaterializedSkillsShadow(params) {
1479
- if (!params.preserved) return;
1649
+ async function restoreLocalShadow(params) {
1480
1650
  let restored = false;
1481
1651
  try {
1482
- const shadowPath = path.join(params.workspaceDir, ...MATERIALIZED_SKILLS_REMOTE_PARTS);
1652
+ const shadowPath = path.join(params.workspaceDir, ...params.preserved.relativeParts);
1483
1653
  const parentPath = path.dirname(shadowPath);
1484
1654
  const parentStats = await fsPromises.lstat(parentPath).catch(() => null);
1485
- 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}`);
1486
1656
  if (parentStats && !parentStats.isDirectory()) await fsPromises.rm(parentPath, {
1487
1657
  recursive: true,
1488
1658
  force: true
@@ -1512,10 +1682,6 @@ function normalizeRemotePath(remotePath) {
1512
1682
  if (!path.posix.isAbsolute(normalized)) throw new Error(`OpenShell remote path must be absolute: ${remotePath}`);
1513
1683
  return normalized;
1514
1684
  }
1515
- function isRemotePathInside(root, candidate) {
1516
- const relative = path.posix.relative(root, candidate);
1517
- return relative === "" || relative !== ".." && !relative.startsWith("../") && !path.posix.isAbsolute(relative);
1518
- }
1519
1685
  //#endregion
1520
1686
  //#region extensions/openshell/index.ts
1521
1687
  var openshell_default = definePluginEntry({
@@ -1526,11 +1692,18 @@ var openshell_default = definePluginEntry({
1526
1692
  register(api) {
1527
1693
  if (api.registrationMode !== "full") return;
1528
1694
  const pluginConfig = resolveOpenShellPluginConfig(api.pluginConfig);
1529
- registerSandboxBackend("openshell", {
1695
+ const unregister = registerSandboxBackend("openshell", {
1530
1696
  factory: createOpenShellSandboxBackendFactory({ pluginConfig }),
1531
1697
  manager: createOpenShellSandboxBackendManager({ pluginConfig }),
1532
1698
  resolveWorkdir: () => pluginConfig.remoteWorkspaceDir
1533
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
+ });
1534
1707
  }
1535
1708
  });
1536
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.2",
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.2"
24
+ "pluginApi": ">=2026.8.1"
25
25
  },
26
26
  "build": {
27
- "openclawVersion": "2026.8.1-beta.2",
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.2"
44
+ "openclaw": ">=2026.8.1"
45
45
  },
46
46
  "peerDependenciesMeta": {
47
47
  "openclaw": {