@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 +24 -1
- package/dist/index.js +222 -116
- 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,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,
|
|
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:
|
|
91
|
-
remoteAgentWorkspaceDir:
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
955
|
-
}) :
|
|
956
|
-
|
|
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:
|
|
992
|
+
env: {}
|
|
973
993
|
});
|
|
974
|
-
|
|
975
|
-
const
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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)
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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 ===
|
|
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.
|
|
1075
|
+
this.discardPreparedRemoteWorkspace();
|
|
1043
1076
|
return null;
|
|
1044
1077
|
}
|
|
1045
1078
|
this.preparedRemoteWorkspaceForNextExec = null;
|
|
1046
|
-
return preparedWorkspace
|
|
1079
|
+
return preparedWorkspace;
|
|
1047
1080
|
}
|
|
1048
1081
|
discardPreparedWorkdir(workdir) {
|
|
1049
|
-
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());
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
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": {
|