@openclaw/openshell-sandbox 2026.8.1-beta.3 → 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,11 +1,12 @@
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";
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
12
  import { isPathInside, root } from "openclaw/plugin-sdk/file-access-runtime";
@@ -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) {
@@ -351,8 +353,10 @@ var OpenShellFsBridge = class {
351
353
  }
352
354
  if (input.startsWith(`${workspaceContainerRoot}/`) || input === workspaceContainerRoot) {
353
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}`);
354
358
  return {
355
- hostPath: relative ? path.resolve(workspaceRoot, ...relative.split("/")) : workspaceRoot,
359
+ hostPath,
356
360
  relativePath: relative,
357
361
  containerPath: relative ? path.posix.join(workspaceContainerRoot, relative) : workspaceContainerRoot,
358
362
  mountHostRoot: workspaceRoot,
@@ -362,8 +366,10 @@ var OpenShellFsBridge = class {
362
366
  }
363
367
  if (hasAgentMount && (input.startsWith(`${agentContainerRoot}/`) || input === agentContainerRoot)) {
364
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}`);
365
371
  return {
366
- hostPath: relative ? path.resolve(agentRoot, ...relative.split("/")) : agentRoot,
372
+ hostPath,
367
373
  relativePath: relative ? agentContainerRoot + "/" + relative : agentContainerRoot,
368
374
  containerPath: relative ? path.posix.join(agentContainerRoot, relative) : agentContainerRoot,
369
375
  mountHostRoot: agentRoot,
@@ -642,6 +648,8 @@ async function stageDirectoryContents(params) {
642
648
  }
643
649
  //#endregion
644
650
  //#region extensions/openshell/src/backend.ts
651
+ const openShellWorkspaceOperations = new KeyedAsyncQueue();
652
+ let openShellDetachedCreateSupport;
645
653
  const MATERIALIZED_SKILLS_REMOTE_PARTS = [".openclaw", "sandbox-skills"];
646
654
  function buildOpenShellDirectoryUploadArgs(params) {
647
655
  return [
@@ -650,9 +658,10 @@ function buildOpenShellDirectoryUploadArgs(params) {
650
658
  "--no-git-ignore",
651
659
  params.sandboxName,
652
660
  params.localPath,
653
- normalizeRemotePath(params.remotePath)
661
+ `${normalizeRemotePath(params.remotePath)}/`
654
662
  ];
655
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\"";
656
665
  const PINNED_REMOTE_PATH_MUTATION_SCRIPT = [
657
666
  "set -eu",
658
667
  "die() { echo \"$1\" >&2; exit 1; }",
@@ -815,20 +824,22 @@ function createOpenShellSandboxBackendManager(params) {
815
824
  args: [
816
825
  "sandbox",
817
826
  "get",
818
- entry.containerName
827
+ entry.containerName,
828
+ "--output",
829
+ "json"
819
830
  ]
820
831
  });
821
832
  const configuredSource = execContext.config.from;
822
833
  return {
823
- running: result.code === 0,
834
+ running: result.code === 0 && parseOpenShellSandboxPhase(result.stdout) === "Ready",
824
835
  actualConfigLabel: entry.image,
825
836
  configLabelMatch: entry.image === configuredSource
826
837
  };
827
838
  },
828
- async removeRuntime({ entry }) {
829
- await runOpenShellCli({
839
+ async removeRuntime({ entry, config }) {
840
+ const result = await runOpenShellCli({
830
841
  context: {
831
- config: params.pluginConfig,
842
+ config: resolveOpenShellPluginConfigFromConfig(config, params.pluginConfig),
832
843
  sandboxName: entry.containerName
833
844
  },
834
845
  args: [
@@ -837,6 +848,7 @@ function createOpenShellSandboxBackendManager(params) {
837
848
  entry.containerName
838
849
  ]
839
850
  });
851
+ if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox delete failed");
840
852
  }
841
853
  };
842
854
  }
@@ -901,60 +913,121 @@ var OpenShellSandboxBackendImpl = class {
901
913
  finalizeExec: async ({ token }) => {
902
914
  await this.finalizeExec(token);
903
915
  },
904
- runShellCommand: async (command) => await this.runRemoteShellScript(command),
916
+ runShellCommand: async (command) => await this.runWorkspaceOperation(async () => await this.runRemoteShellScript(command)),
905
917
  createFsBridge: ({ sandbox }) => this.params.execContext.config.mode === "remote" ? createRemoteShellSandboxFsBridge({
906
918
  sandbox,
907
919
  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)
920
+ }) : this.createMirrorFsBridge(sandbox),
921
+ runRemoteShellScript: async (command) => await this.runWorkspaceOperation(async () => await this.runRemoteShellScript(command))
917
922
  };
918
923
  this.handle = handle;
919
924
  return handle;
920
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
+ } };
986
+ }
921
987
  async prepareExec(params) {
922
988
  const remoteWorkdir = params.workdir ?? this.params.remoteWorkspaceDir;
923
- const preparedWorkspace = this.consumePreparedRemoteWorkspaceForNextExec(remoteWorkdir);
924
989
  const remoteCommand = buildValidatedExecRemoteCommand({
925
990
  command: params.command,
926
991
  workdir: remoteWorkdir,
927
- env: params.env
992
+ env: {}
928
993
  });
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
- };
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
+ }
952
1022
  }
953
1023
  async validateWorkdir(workdir) {
1024
+ this.discardPreparedRemoteWorkspace();
1025
+ const lease = await this.acquireWorkspaceLease();
954
1026
  const preparedWorkspace = this.prepareRemoteWorkspaceForExec();
955
1027
  const reusablePreparation = {
956
1028
  workdir,
957
- promise: preparedWorkspace
1029
+ promise: preparedWorkspace,
1030
+ lease
958
1031
  };
959
1032
  this.preparedRemoteWorkspaceForNextExec = reusablePreparation;
960
1033
  try {
@@ -970,16 +1043,21 @@ var OpenShellSandboxBackendImpl = class {
970
1043
  allowFailure: true
971
1044
  });
972
1045
  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;
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();
977
1054
  return resolvedWorkdir || null;
978
1055
  } finally {
979
1056
  await disposeSshSandboxSession(sshSession);
980
1057
  }
981
1058
  } catch (error) {
982
- if (this.preparedRemoteWorkspaceForNextExec === reusablePreparation) this.preparedRemoteWorkspaceForNextExec = null;
1059
+ if (this.preparedRemoteWorkspaceForNextExec?.lease === lease) this.preparedRemoteWorkspaceForNextExec = null;
1060
+ lease.release();
983
1061
  throw error;
984
1062
  }
985
1063
  }
@@ -994,14 +1072,20 @@ var OpenShellSandboxBackendImpl = class {
994
1072
  consumePreparedRemoteWorkspaceForNextExec(workdir) {
995
1073
  const preparedWorkspace = this.preparedRemoteWorkspaceForNextExec;
996
1074
  if (!preparedWorkspace || preparedWorkspace.workdir !== workdir) {
997
- this.preparedRemoteWorkspaceForNextExec = null;
1075
+ this.discardPreparedRemoteWorkspace();
998
1076
  return null;
999
1077
  }
1000
1078
  this.preparedRemoteWorkspaceForNextExec = null;
1001
- return preparedWorkspace.promise;
1079
+ return preparedWorkspace;
1002
1080
  }
1003
1081
  discardPreparedWorkdir(workdir) {
1004
- 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());
1005
1089
  }
1006
1090
  async prepareRemoteWorkspaceForExec() {
1007
1091
  await this.ensureSandboxExists();
@@ -1012,10 +1096,19 @@ var OpenShellSandboxBackendImpl = class {
1012
1096
  if (!await this.maybeSeedRemoteWorkspace()) await this.syncSkillsWorkspaceToRemote();
1013
1097
  }
1014
1098
  async finalizeExec(token) {
1099
+ const workspaceLease = token?.workspaceLease ?? await this.acquireWorkspaceLease();
1015
1100
  try {
1016
1101
  if (this.params.execContext.config.mode === "mirror") await this.syncWorkspaceFromRemote();
1017
1102
  } finally {
1018
- 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
+ }
1019
1112
  }
1020
1113
  }
1021
1114
  async runRemoteShellScript(params) {
@@ -1125,7 +1218,7 @@ var OpenShellSandboxBackendImpl = class {
1125
1218
  "--no-git-ignore",
1126
1219
  this.params.execContext.sandboxName,
1127
1220
  localPath,
1128
- path.posix.dirname(remotePath)
1221
+ remotePath
1129
1222
  ],
1130
1223
  cwd: this.params.createParams.workspaceDir
1131
1224
  });
@@ -1176,9 +1269,12 @@ var OpenShellSandboxBackendImpl = class {
1176
1269
  if (!phase) throw this.buildLegacyRuntimeUnavailableError("OpenShell did not report a lifecycle phase for this sandbox.");
1177
1270
  if (phase !== "Ready") throw this.buildLegacyRuntimeUnavailableError(`OpenShell reports phase "${phase}".`);
1178
1271
  }
1272
+ if (this.params.execContext.config.mode === "remote" && await this.remoteManagedRootsEmpty()) this.remoteSeedPending = true;
1179
1273
  return;
1180
1274
  }
1181
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();
1182
1278
  const createArgs = [
1183
1279
  "sandbox",
1184
1280
  "create",
@@ -1190,8 +1286,12 @@ var OpenShellSandboxBackendImpl = class {
1190
1286
  ...this.params.execContext.config.gpu ? ["--gpu"] : [],
1191
1287
  ...this.params.execContext.config.autoProviders ? ["--auto-providers"] : ["--no-auto-providers"],
1192
1288
  ...this.params.execContext.config.providers.flatMap((provider) => ["--provider", provider]),
1193
- "--",
1194
- "true"
1289
+ ...detachedCreateSupported ? [
1290
+ "--detach",
1291
+ "--",
1292
+ "sleep",
1293
+ "infinity"
1294
+ ] : ["--", "true"]
1195
1295
  ];
1196
1296
  const createResult = await runOpenShellCli({
1197
1297
  context: this.params.execContext,
@@ -1202,6 +1302,41 @@ var OpenShellSandboxBackendImpl = class {
1202
1302
  if (createResult.code !== 0) throw new Error(createResult.stderr.trim() || "openshell sandbox create failed");
1203
1303
  this.remoteSeedPending = true;
1204
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
+ }
1205
1340
  async resolveLegacyRuntimePhase() {
1206
1341
  const pageSize = 100;
1207
1342
  for (let offset = 0;; offset += pageSize) {
@@ -1305,7 +1440,8 @@ var OpenShellSandboxBackendImpl = class {
1305
1440
  const stagedRoot = path.join(tmpDir, remoteRootName);
1306
1441
  await stageDirectoryContents({
1307
1442
  sourceDir: localPath,
1308
- targetDir: stagedRoot
1443
+ targetDir: stagedRoot,
1444
+ excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS
1309
1445
  });
1310
1446
  const stagedEntries = (await fsPromises.readdir(stagedRoot)).toSorted();
1311
1447
  for (const entry of stagedEntries) {
@@ -1322,6 +1458,12 @@ var OpenShellSandboxBackendImpl = class {
1322
1458
  }
1323
1459
  });
1324
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
+ }
1325
1467
  async maybeSeedRemoteWorkspace() {
1326
1468
  if (!this.remoteSeedPending) return false;
1327
1469
  this.remoteSeedPending = false;
@@ -1386,6 +1528,15 @@ function parseOpenShellSandboxPhasePage(stdout, sandboxName) {
1386
1528
  return;
1387
1529
  }
1388
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
+ }
1389
1540
  function resolveRemoteMaterializedSkillsWorkspaceDir(remoteWorkspaceDir) {
1390
1541
  const root = remoteWorkspaceDir.replace(/\\/g, "/").replace(/\/+$/, "") || "/";
1391
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.3",
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.3"
24
+ "pluginApi": ">=2026.9.1-beta.1"
25
25
  },
26
26
  "build": {
27
- "openclawVersion": "2026.8.1-beta.3",
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.3"
44
+ "openclaw": ">=2026.9.1-beta.1"
45
45
  },
46
46
  "peerDependenciesMeta": {
47
47
  "openclaw": {