@openclaw/openshell-sandbox 2026.8.1-beta.2 → 2026.9.1-beta.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";
6
8
  import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
7
9
  import { buildPluginConfigSchema } from "openclaw/plugin-sdk/core";
8
- import { formatPluginConfigIssue, mapPluginConfigIssues } from "openclaw/plugin-sdk/extension-shared";
9
10
  import { MAX_TIMER_TIMEOUT_SECONDS } from "openclaw/plugin-sdk/number-runtime";
10
11
  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";
12
+ import { isPathInside, root } from "openclaw/plugin-sdk/file-access-runtime";
13
+ 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) {
@@ -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) {
@@ -354,8 +353,10 @@ var OpenShellFsBridge = class {
354
353
  }
355
354
  if (input.startsWith(`${workspaceContainerRoot}/`) || input === workspaceContainerRoot) {
356
355
  const relative = path.posix.relative(workspaceContainerRoot, input) || "";
356
+ const hostPath = relative ? path.resolve(workspaceRoot, ...relative.split("/")) : workspaceRoot;
357
+ if (!isPathInside(workspaceRoot, hostPath)) throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${input}`);
357
358
  return {
358
- hostPath: relative ? path.resolve(workspaceRoot, ...relative.split("/")) : workspaceRoot,
359
+ hostPath,
359
360
  relativePath: relative,
360
361
  containerPath: relative ? path.posix.join(workspaceContainerRoot, relative) : workspaceContainerRoot,
361
362
  mountHostRoot: workspaceRoot,
@@ -365,8 +366,10 @@ var OpenShellFsBridge = class {
365
366
  }
366
367
  if (hasAgentMount && (input.startsWith(`${agentContainerRoot}/`) || input === agentContainerRoot)) {
367
368
  const relative = path.posix.relative(agentContainerRoot, input) || "";
369
+ const hostPath = relative ? path.resolve(agentRoot, ...relative.split("/")) : agentRoot;
370
+ if (!isPathInside(agentRoot, hostPath)) throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${input}`);
368
371
  return {
369
- hostPath: relative ? path.resolve(agentRoot, ...relative.split("/")) : agentRoot,
372
+ hostPath,
370
373
  relativePath: relative ? agentContainerRoot + "/" + relative : agentContainerRoot,
371
374
  containerPath: relative ? path.posix.join(agentContainerRoot, relative) : agentContainerRoot,
372
375
  mountHostRoot: agentRoot,
@@ -645,6 +648,8 @@ async function stageDirectoryContents(params) {
645
648
  }
646
649
  //#endregion
647
650
  //#region extensions/openshell/src/backend.ts
651
+ const openShellWorkspaceOperations = new KeyedAsyncQueue();
652
+ let openShellDetachedCreateSupport;
648
653
  const MATERIALIZED_SKILLS_REMOTE_PARTS = [".openclaw", "sandbox-skills"];
649
654
  function buildOpenShellDirectoryUploadArgs(params) {
650
655
  return [
@@ -653,9 +658,10 @@ function buildOpenShellDirectoryUploadArgs(params) {
653
658
  "--no-git-ignore",
654
659
  params.sandboxName,
655
660
  params.localPath,
656
- normalizeRemotePath(params.remotePath)
661
+ `${normalizeRemotePath(params.remotePath)}/`
657
662
  ];
658
663
  }
664
+ 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
665
  const PINNED_REMOTE_PATH_MUTATION_SCRIPT = [
660
666
  "set -eu",
661
667
  "die() { echo \"$1\" >&2; exit 1; }",
@@ -818,20 +824,22 @@ function createOpenShellSandboxBackendManager(params) {
818
824
  args: [
819
825
  "sandbox",
820
826
  "get",
821
- entry.containerName
827
+ entry.containerName,
828
+ "--output",
829
+ "json"
822
830
  ]
823
831
  });
824
832
  const configuredSource = execContext.config.from;
825
833
  return {
826
- running: result.code === 0,
834
+ running: result.code === 0 && parseOpenShellSandboxPhase(result.stdout) === "Ready",
827
835
  actualConfigLabel: entry.image,
828
836
  configLabelMatch: entry.image === configuredSource
829
837
  };
830
838
  },
831
- async removeRuntime({ entry }) {
832
- await runOpenShellCli({
839
+ async removeRuntime({ entry, config }) {
840
+ const result = await runOpenShellCli({
833
841
  context: {
834
- config: params.pluginConfig,
842
+ config: resolveOpenShellPluginConfigFromConfig(config, params.pluginConfig),
835
843
  sandboxName: entry.containerName
836
844
  },
837
845
  args: [
@@ -840,6 +848,7 @@ function createOpenShellSandboxBackendManager(params) {
840
848
  entry.containerName
841
849
  ]
842
850
  });
851
+ if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox delete failed");
843
852
  }
844
853
  };
845
854
  }
@@ -854,69 +863,25 @@ async function createOpenShellSandboxBackend(params) {
854
863
  config: params.pluginConfig,
855
864
  sandboxName
856
865
  };
857
- const impl = new OpenShellSandboxBackendImpl({
866
+ return new OpenShellSandboxBackendImpl({
858
867
  createParams: params.createParams,
859
868
  execContext,
860
869
  legacyRuntimeAdopted: resolvedSandboxName.legacyRuntimeAdopted,
861
870
  remoteWorkspaceDir: params.pluginConfig.remoteWorkspaceDir,
862
871
  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
- };
872
+ }).asHandle();
910
873
  }
911
874
  var OpenShellSandboxBackendImpl = class {
912
875
  constructor(params) {
913
876
  this.params = params;
877
+ this.handle = null;
914
878
  this.ensurePromise = null;
915
879
  this.preparedRemoteWorkspaceForNextExec = null;
916
880
  this.remoteSeedPending = false;
917
881
  }
918
882
  asHandle() {
919
- return {
883
+ if (this.handle) return this.handle;
884
+ const handle = {
920
885
  id: "openshell",
921
886
  runtimeId: this.params.execContext.sandboxName,
922
887
  runtimeLabel: this.params.execContext.sandboxName,
@@ -948,58 +913,121 @@ var OpenShellSandboxBackendImpl = class {
948
913
  finalizeExec: async ({ token }) => {
949
914
  await this.finalizeExec(token);
950
915
  },
951
- runShellCommand: async (command) => await this.runRemoteShellScript(command),
916
+ runShellCommand: async (command) => await this.runWorkspaceOperation(async () => await this.runRemoteShellScript(command)),
952
917
  createFsBridge: ({ sandbox }) => this.params.execContext.config.mode === "remote" ? createRemoteShellSandboxFsBridge({
953
918
  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)
919
+ runtime: handle
920
+ }) : this.createMirrorFsBridge(sandbox),
921
+ runRemoteShellScript: async (command) => await this.runWorkspaceOperation(async () => await this.runRemoteShellScript(command))
964
922
  };
923
+ this.handle = handle;
924
+ return handle;
925
+ }
926
+ createMirrorFsBridge(sandbox) {
927
+ const bridge = createOpenShellFsBridge({
928
+ sandbox,
929
+ backend: {
930
+ remoteAgentWorkspaceDir: this.params.remoteAgentWorkspaceDir,
931
+ mkdirpRemotePath: (remotePath, signal) => this.mkdirpRemotePath(remotePath, signal),
932
+ removeRemotePath: (remotePath, params) => this.removeRemotePath(remotePath, params),
933
+ renameRemotePath: (from, to, signal) => this.renameRemotePath(from, to, signal),
934
+ syncLocalPathToRemote: (localPath, remotePath) => this.syncLocalPathToRemote(localPath, remotePath)
935
+ }
936
+ });
937
+ return {
938
+ resolvePath: (params) => bridge.resolvePath(params),
939
+ readFile: (params) => this.runWorkspaceOperation(() => bridge.readFile(params)),
940
+ writeFile: (params) => this.runWorkspaceOperation(() => bridge.writeFile(params)),
941
+ createFileExclusive: (params) => this.runWorkspaceOperation(() => bridge.createFileExclusive(params)),
942
+ mkdirp: (params) => this.runWorkspaceOperation(() => bridge.mkdirp(params)),
943
+ remove: (params) => this.runWorkspaceOperation(() => bridge.remove(params)),
944
+ rename: (params) => this.runWorkspaceOperation(() => bridge.rename(params)),
945
+ stat: (params) => this.runWorkspaceOperation(() => bridge.stat(params))
946
+ };
947
+ }
948
+ async runWorkspaceOperation(operation) {
949
+ const lease = await this.acquireWorkspaceLease();
950
+ try {
951
+ return await operation();
952
+ } finally {
953
+ lease.release();
954
+ }
955
+ }
956
+ async acquireWorkspaceLease() {
957
+ const { config, sandboxName } = this.params.execContext;
958
+ const keys = [`host:${path.resolve(this.params.createParams.workspaceDir)}`, `runtime:${JSON.stringify([
959
+ config.gatewayEndpoint ?? "",
960
+ config.gateway ?? "",
961
+ config.workspace ?? process.env.OPENSHELL_WORKSPACE ?? "",
962
+ sandboxName
963
+ ])}`].toSorted();
964
+ const releases = [];
965
+ try {
966
+ for (const key of keys) {
967
+ const acquired = createDeferred();
968
+ const released = createDeferred();
969
+ openShellWorkspaceOperations.enqueue(key, async () => {
970
+ acquired.resolve();
971
+ await released.promise;
972
+ });
973
+ await acquired.promise;
974
+ releases.push(released.resolve);
975
+ }
976
+ } catch (error) {
977
+ for (const release of releases.toReversed()) release();
978
+ throw error;
979
+ }
980
+ let active = true;
981
+ return { release: () => {
982
+ if (!active) return;
983
+ active = false;
984
+ for (const release of releases.toReversed()) release();
985
+ } };
965
986
  }
966
987
  async prepareExec(params) {
967
988
  const remoteWorkdir = params.workdir ?? this.params.remoteWorkspaceDir;
968
- const preparedWorkspace = this.consumePreparedRemoteWorkspaceForNextExec(remoteWorkdir);
969
989
  const remoteCommand = buildValidatedExecRemoteCommand({
970
990
  command: params.command,
971
991
  workdir: remoteWorkdir,
972
- env: params.env
992
+ env: {}
973
993
  });
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
- };
994
+ const preparedWorkspace = this.consumePreparedRemoteWorkspaceForNextExec(remoteWorkdir);
995
+ const workspaceLease = preparedWorkspace?.lease ?? await this.acquireWorkspaceLease();
996
+ try {
997
+ await (preparedWorkspace?.promise ?? this.prepareRemoteWorkspaceForExec());
998
+ const sshSession = await createOpenShellSshSession({ context: this.params.execContext });
999
+ try {
1000
+ const prepared = await prepareSshSandboxExec({
1001
+ session: sshSession,
1002
+ remoteCommand,
1003
+ env: params.env,
1004
+ tty: params.usePty
1005
+ });
1006
+ return {
1007
+ argv: prepared.argv,
1008
+ token: {
1009
+ sshSession,
1010
+ cleanup: prepared.cleanup,
1011
+ workspaceLease
1012
+ }
1013
+ };
1014
+ } catch (error) {
1015
+ await disposeSshSandboxSession(sshSession);
1016
+ throw error;
1017
+ }
1018
+ } catch (error) {
1019
+ workspaceLease.release();
1020
+ throw error;
1021
+ }
997
1022
  }
998
1023
  async validateWorkdir(workdir) {
1024
+ this.discardPreparedRemoteWorkspace();
1025
+ const lease = await this.acquireWorkspaceLease();
999
1026
  const preparedWorkspace = this.prepareRemoteWorkspaceForExec();
1000
1027
  const reusablePreparation = {
1001
1028
  workdir,
1002
- promise: preparedWorkspace
1029
+ promise: preparedWorkspace,
1030
+ lease
1003
1031
  };
1004
1032
  this.preparedRemoteWorkspaceForNextExec = reusablePreparation;
1005
1033
  try {
@@ -1015,16 +1043,21 @@ var OpenShellSandboxBackendImpl = class {
1015
1043
  allowFailure: true
1016
1044
  });
1017
1045
  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;
1046
+ if (this.preparedRemoteWorkspaceForNextExec === reusablePreparation) {
1047
+ this.preparedRemoteWorkspaceForNextExec = resolvedWorkdir ? {
1048
+ workdir: resolvedWorkdir,
1049
+ promise: preparedWorkspace,
1050
+ lease
1051
+ } : null;
1052
+ if (!resolvedWorkdir) lease.release();
1053
+ } else lease.release();
1022
1054
  return resolvedWorkdir || null;
1023
1055
  } finally {
1024
1056
  await disposeSshSandboxSession(sshSession);
1025
1057
  }
1026
1058
  } catch (error) {
1027
- if (this.preparedRemoteWorkspaceForNextExec === reusablePreparation) this.preparedRemoteWorkspaceForNextExec = null;
1059
+ if (this.preparedRemoteWorkspaceForNextExec?.lease === lease) this.preparedRemoteWorkspaceForNextExec = null;
1060
+ lease.release();
1028
1061
  throw error;
1029
1062
  }
1030
1063
  }
@@ -1039,14 +1072,20 @@ var OpenShellSandboxBackendImpl = class {
1039
1072
  consumePreparedRemoteWorkspaceForNextExec(workdir) {
1040
1073
  const preparedWorkspace = this.preparedRemoteWorkspaceForNextExec;
1041
1074
  if (!preparedWorkspace || preparedWorkspace.workdir !== workdir) {
1042
- this.preparedRemoteWorkspaceForNextExec = null;
1075
+ this.discardPreparedRemoteWorkspace();
1043
1076
  return null;
1044
1077
  }
1045
1078
  this.preparedRemoteWorkspaceForNextExec = null;
1046
- return preparedWorkspace.promise;
1079
+ return preparedWorkspace;
1047
1080
  }
1048
1081
  discardPreparedWorkdir(workdir) {
1049
- if (this.preparedRemoteWorkspaceForNextExec?.workdir === workdir) this.preparedRemoteWorkspaceForNextExec = null;
1082
+ if (this.preparedRemoteWorkspaceForNextExec?.workdir === workdir) this.discardPreparedRemoteWorkspace();
1083
+ }
1084
+ discardPreparedRemoteWorkspace() {
1085
+ const preparedWorkspace = this.preparedRemoteWorkspaceForNextExec;
1086
+ if (!preparedWorkspace) return;
1087
+ this.preparedRemoteWorkspaceForNextExec = null;
1088
+ preparedWorkspace.promise.then(() => preparedWorkspace.lease.release(), () => preparedWorkspace.lease.release());
1050
1089
  }
1051
1090
  async prepareRemoteWorkspaceForExec() {
1052
1091
  await this.ensureSandboxExists();
@@ -1057,10 +1096,19 @@ var OpenShellSandboxBackendImpl = class {
1057
1096
  if (!await this.maybeSeedRemoteWorkspace()) await this.syncSkillsWorkspaceToRemote();
1058
1097
  }
1059
1098
  async finalizeExec(token) {
1099
+ const workspaceLease = token?.workspaceLease ?? await this.acquireWorkspaceLease();
1060
1100
  try {
1061
1101
  if (this.params.execContext.config.mode === "mirror") await this.syncWorkspaceFromRemote();
1062
1102
  } finally {
1063
- if (token?.sshSession) await disposeSshSandboxSession(token.sshSession);
1103
+ try {
1104
+ if (token?.sshSession) try {
1105
+ await token.cleanup();
1106
+ } finally {
1107
+ await disposeSshSandboxSession(token.sshSession);
1108
+ }
1109
+ } finally {
1110
+ workspaceLease.release();
1111
+ }
1064
1112
  }
1065
1113
  }
1066
1114
  async runRemoteShellScript(params) {
@@ -1170,7 +1218,7 @@ var OpenShellSandboxBackendImpl = class {
1170
1218
  "--no-git-ignore",
1171
1219
  this.params.execContext.sandboxName,
1172
1220
  localPath,
1173
- path.posix.dirname(remotePath)
1221
+ remotePath
1174
1222
  ],
1175
1223
  cwd: this.params.createParams.workspaceDir
1176
1224
  });
@@ -1221,9 +1269,12 @@ var OpenShellSandboxBackendImpl = class {
1221
1269
  if (!phase) throw this.buildLegacyRuntimeUnavailableError("OpenShell did not report a lifecycle phase for this sandbox.");
1222
1270
  if (phase !== "Ready") throw this.buildLegacyRuntimeUnavailableError(`OpenShell reports phase "${phase}".`);
1223
1271
  }
1272
+ if (this.params.execContext.config.mode === "remote" && await this.remoteManagedRootsEmpty()) this.remoteSeedPending = true;
1224
1273
  return;
1225
1274
  }
1226
1275
  if (this.params.legacyRuntimeAdopted) throw this.buildLegacyRuntimeUnavailableError(getResult.stderr.trim());
1276
+ if (!/\bsandbox not found\b/iu.test(getResult.stderr)) throw new Error(getResult.stderr.trim() || "openshell sandbox get failed");
1277
+ const detachedCreateSupported = await this.supportsDetachedSandboxCreation();
1227
1278
  const createArgs = [
1228
1279
  "sandbox",
1229
1280
  "create",
@@ -1235,8 +1286,12 @@ var OpenShellSandboxBackendImpl = class {
1235
1286
  ...this.params.execContext.config.gpu ? ["--gpu"] : [],
1236
1287
  ...this.params.execContext.config.autoProviders ? ["--auto-providers"] : ["--no-auto-providers"],
1237
1288
  ...this.params.execContext.config.providers.flatMap((provider) => ["--provider", provider]),
1238
- "--",
1239
- "true"
1289
+ ...detachedCreateSupported ? [
1290
+ "--detach",
1291
+ "--",
1292
+ "sleep",
1293
+ "infinity"
1294
+ ] : ["--", "true"]
1240
1295
  ];
1241
1296
  const createResult = await runOpenShellCli({
1242
1297
  context: this.params.execContext,
@@ -1247,6 +1302,41 @@ var OpenShellSandboxBackendImpl = class {
1247
1302
  if (createResult.code !== 0) throw new Error(createResult.stderr.trim() || "openshell sandbox create failed");
1248
1303
  this.remoteSeedPending = true;
1249
1304
  }
1305
+ async supportsDetachedSandboxCreation() {
1306
+ const { config } = this.params.execContext;
1307
+ const cliIdentity = JSON.stringify([
1308
+ config.command,
1309
+ config.gatewayEndpoint ?? "",
1310
+ config.gateway ?? "",
1311
+ config.workspace ?? process.env.OPENSHELL_WORKSPACE ?? ""
1312
+ ]);
1313
+ let support = openShellDetachedCreateSupport?.key === cliIdentity ? openShellDetachedCreateSupport.promise : void 0;
1314
+ if (!support) {
1315
+ support = (async () => {
1316
+ const result = await runOpenShellCli({
1317
+ context: this.params.execContext,
1318
+ args: [
1319
+ "sandbox",
1320
+ "create",
1321
+ "--help"
1322
+ ],
1323
+ cwd: this.params.createParams.workspaceDir
1324
+ });
1325
+ if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox create capability check failed");
1326
+ return /^\s*--detach(?:\s|$)/mu.test(result.stdout);
1327
+ })();
1328
+ openShellDetachedCreateSupport = {
1329
+ key: cliIdentity,
1330
+ promise: support
1331
+ };
1332
+ }
1333
+ try {
1334
+ return await support;
1335
+ } catch (error) {
1336
+ if (openShellDetachedCreateSupport?.promise === support) openShellDetachedCreateSupport = void 0;
1337
+ throw error;
1338
+ }
1339
+ }
1250
1340
  async resolveLegacyRuntimePhase() {
1251
1341
  const pageSize = 100;
1252
1342
  for (let offset = 0;; offset += pageSize) {
@@ -1350,7 +1440,8 @@ var OpenShellSandboxBackendImpl = class {
1350
1440
  const stagedRoot = path.join(tmpDir, remoteRootName);
1351
1441
  await stageDirectoryContents({
1352
1442
  sourceDir: localPath,
1353
- targetDir: stagedRoot
1443
+ targetDir: stagedRoot,
1444
+ excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS
1354
1445
  });
1355
1446
  const stagedEntries = (await fsPromises.readdir(stagedRoot)).toSorted();
1356
1447
  for (const entry of stagedEntries) {
@@ -1367,6 +1458,12 @@ var OpenShellSandboxBackendImpl = class {
1367
1458
  }
1368
1459
  });
1369
1460
  }
1461
+ async remoteManagedRootsEmpty() {
1462
+ return (await this.runRemoteShellScriptInternal({
1463
+ script: REMOTE_MANAGED_ROOTS_EMPTY_SCRIPT,
1464
+ args: [this.params.remoteWorkspaceDir, this.params.remoteAgentWorkspaceDir]
1465
+ })).stdout.toString("utf8").trim() === "0";
1466
+ }
1370
1467
  async maybeSeedRemoteWorkspace() {
1371
1468
  if (!this.remoteSeedPending) return false;
1372
1469
  this.remoteSeedPending = false;
@@ -1431,6 +1528,15 @@ function parseOpenShellSandboxPhasePage(stdout, sandboxName) {
1431
1528
  return;
1432
1529
  }
1433
1530
  }
1531
+ function parseOpenShellSandboxPhase(stdout) {
1532
+ try {
1533
+ const parsed = JSON.parse(stdout);
1534
+ if (typeof parsed !== "object" || parsed === null || !("phase" in parsed)) return;
1535
+ return typeof parsed.phase === "string" ? parsed.phase : void 0;
1536
+ } catch {
1537
+ return;
1538
+ }
1539
+ }
1434
1540
  function resolveRemoteMaterializedSkillsWorkspaceDir(remoteWorkspaceDir) {
1435
1541
  const root = remoteWorkspaceDir.replace(/\\/g, "/").replace(/\/+$/, "") || "/";
1436
1542
  return path.posix.join(root, ...MATERIALIZED_SKILLS_REMOTE_PARTS);
@@ -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.9.1-beta.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.9.1-beta.1"
25
25
  },
26
26
  "build": {
27
- "openclawVersion": "2026.8.1-beta.2",
27
+ "openclawVersion": "2026.9.1-beta.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.9.1-beta.1"
45
45
  },
46
46
  "peerDependenciesMeta": {
47
47
  "openclaw": {