@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 +24 -1
- package/dist/index.js +212 -61
- package/openclaw.plugin.json +4 -2
- package/package.json +4 -4
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
|
|
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:
|
|
88
|
-
remoteAgentWorkspaceDir:
|
|
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
|
|
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
|
|
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
|
-
}) :
|
|
909
|
-
|
|
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:
|
|
992
|
+
env: {}
|
|
928
993
|
});
|
|
929
|
-
|
|
930
|
-
const
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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)
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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 ===
|
|
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.
|
|
1075
|
+
this.discardPreparedRemoteWorkspace();
|
|
998
1076
|
return null;
|
|
999
1077
|
}
|
|
1000
1078
|
this.preparedRemoteWorkspaceForNextExec = null;
|
|
1001
|
-
return preparedWorkspace
|
|
1079
|
+
return preparedWorkspace;
|
|
1002
1080
|
}
|
|
1003
1081
|
discardPreparedWorkdir(workdir) {
|
|
1004
|
-
if (this.preparedRemoteWorkspaceForNextExec?.workdir === workdir) this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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.
|
|
24
|
+
"pluginApi": ">=2026.9.1-beta.1"
|
|
25
25
|
},
|
|
26
26
|
"build": {
|
|
27
|
-
"openclawVersion": "2026.
|
|
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.
|
|
44
|
+
"openclaw": ">=2026.9.1-beta.1"
|
|
45
45
|
},
|
|
46
46
|
"peerDependenciesMeta": {
|
|
47
47
|
"openclaw": {
|