@openclaw/openshell-sandbox 2026.8.1-beta.3 → 2026.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -1
- package/dist/index.js +464 -246
- 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 { 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";
|
|
8
|
+
import { FsSafeError, canonicalPathFromExistingAncestor, extractErrorCode, movePathWithCopyFallback } from "openclaw/plugin-sdk/security-runtime";
|
|
6
9
|
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
7
10
|
import { buildPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
|
8
|
-
import { formatPluginConfigIssue, mapPluginConfigIssues } from "openclaw/plugin-sdk/extension-shared";
|
|
9
11
|
import { MAX_TIMER_TIMEOUT_SECONDS } from "openclaw/plugin-sdk/number-runtime";
|
|
10
12
|
import { z } from "zod";
|
|
11
13
|
import { isPathInside, root } from "openclaw/plugin-sdk/file-access-runtime";
|
|
12
|
-
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) {
|
|
@@ -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) {
|
|
@@ -154,6 +156,24 @@ function resolveOpenShellPluginConfig(value) {
|
|
|
154
156
|
};
|
|
155
157
|
}
|
|
156
158
|
//#endregion
|
|
159
|
+
//#region extensions/openshell/src/workspace-roots.ts
|
|
160
|
+
function remotePathDepth(remote) {
|
|
161
|
+
return remote.split("/").filter(Boolean).length;
|
|
162
|
+
}
|
|
163
|
+
function workspaceRootOwnerOrder(owner) {
|
|
164
|
+
return owner === "workspace" ? 0 : 1;
|
|
165
|
+
}
|
|
166
|
+
function isOpenShellRemotePathInside(root, candidate) {
|
|
167
|
+
const relative = path.posix.relative(root, candidate);
|
|
168
|
+
return relative === "" || relative !== ".." && !relative.startsWith("../") && !path.posix.isAbsolute(relative);
|
|
169
|
+
}
|
|
170
|
+
function orderOpenShellWorkspaceRoots(roots) {
|
|
171
|
+
return roots.toSorted((a, b) => remotePathDepth(a.remote) - remotePathDepth(b.remote) || workspaceRootOwnerOrder(a.owner) - workspaceRootOwnerOrder(b.owner));
|
|
172
|
+
}
|
|
173
|
+
function resolveOpenShellWorkspaceRoot(roots, candidate) {
|
|
174
|
+
return roots.toSorted((a, b) => remotePathDepth(b.remote) - remotePathDepth(a.remote) || workspaceRootOwnerOrder(a.owner) - workspaceRootOwnerOrder(b.owner)).find((root) => isOpenShellRemotePathInside(root.remote, candidate));
|
|
175
|
+
}
|
|
176
|
+
//#endregion
|
|
157
177
|
//#region extensions/openshell/src/fs-bridge.ts
|
|
158
178
|
const MATERIALIZED_SKILLS_CONTAINER_PARTS = [
|
|
159
179
|
".openclaw",
|
|
@@ -349,27 +369,43 @@ var OpenShellFsBridge = class {
|
|
|
349
369
|
});
|
|
350
370
|
if (protectedSkillTarget) return protectedSkillTarget;
|
|
351
371
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
372
|
+
const containerMounts = [{
|
|
373
|
+
remote: workspaceContainerRoot,
|
|
374
|
+
owner: "workspace",
|
|
375
|
+
value: {
|
|
376
|
+
hostRoot: workspaceRoot,
|
|
377
|
+
writable: this.sandbox.workspaceAccess === "rw"
|
|
378
|
+
}
|
|
379
|
+
}, ...hasAgentMount ? [{
|
|
380
|
+
remote: agentContainerRoot,
|
|
381
|
+
owner: "agent",
|
|
382
|
+
value: {
|
|
383
|
+
hostRoot: agentRoot,
|
|
384
|
+
writable: this.sandbox.workspaceAccess === "rw"
|
|
385
|
+
}
|
|
386
|
+
}] : []];
|
|
387
|
+
const resolveContainerTarget = (containerPath) => {
|
|
388
|
+
const containerMount = resolveOpenShellWorkspaceRoot(containerMounts, containerPath);
|
|
389
|
+
if (!containerMount) return;
|
|
390
|
+
const relative = path.posix.relative(containerMount.remote, containerPath) || "";
|
|
391
|
+
const hostPath = relative ? path.resolve(containerMount.value.hostRoot, ...relative.split("/")) : containerMount.value.hostRoot;
|
|
392
|
+
if (!isPathInside(containerMount.value.hostRoot, hostPath)) throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${input}`);
|
|
365
393
|
return {
|
|
366
|
-
hostPath
|
|
367
|
-
relativePath: relative ?
|
|
368
|
-
containerPath: relative ? path.posix.join(
|
|
369
|
-
mountHostRoot:
|
|
370
|
-
writable:
|
|
371
|
-
source:
|
|
394
|
+
hostPath,
|
|
395
|
+
relativePath: containerMount.owner === "agent" ? relative ? containerMount.remote + "/" + relative : containerMount.remote : relative,
|
|
396
|
+
containerPath: relative ? path.posix.join(containerMount.remote, relative) : containerMount.remote,
|
|
397
|
+
mountHostRoot: containerMount.value.hostRoot,
|
|
398
|
+
writable: containerMount.value.writable,
|
|
399
|
+
source: containerMount.owner
|
|
372
400
|
};
|
|
401
|
+
};
|
|
402
|
+
const containerCwd = params.cwd?.replace(/\\/g, "/");
|
|
403
|
+
const cwdMount = containerCwd ? resolveOpenShellWorkspaceRoot(containerMounts, containerCwd) : void 0;
|
|
404
|
+
const containerInput = path.posix.isAbsolute(input) ? input : cwdMount || !params.cwd ? path.posix.resolve(containerCwd ?? workspaceContainerRoot, input) : void 0;
|
|
405
|
+
if (containerInput) {
|
|
406
|
+
const target = resolveContainerTarget(containerInput);
|
|
407
|
+
if (target) return target;
|
|
408
|
+
if (cwdMount || containerMounts.some((mount) => input === mount.remote || input.startsWith(`${mount.remote}/`))) throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${input}`);
|
|
373
409
|
}
|
|
374
410
|
const cwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot;
|
|
375
411
|
const hostPath = path.isAbsolute(input) ? path.resolve(input) : path.resolve(cwd, input);
|
|
@@ -384,14 +420,7 @@ var OpenShellFsBridge = class {
|
|
|
384
420
|
}
|
|
385
421
|
if (isPathInside(workspaceRoot, hostPath)) {
|
|
386
422
|
const relative = path.relative(workspaceRoot, hostPath).split(path.sep).join(path.posix.sep);
|
|
387
|
-
return
|
|
388
|
-
hostPath,
|
|
389
|
-
relativePath: relative,
|
|
390
|
-
containerPath: relative ? path.posix.join(workspaceContainerRoot, relative) : workspaceContainerRoot,
|
|
391
|
-
mountHostRoot: workspaceRoot,
|
|
392
|
-
writable: this.sandbox.workspaceAccess === "rw",
|
|
393
|
-
source: "workspace"
|
|
394
|
-
};
|
|
423
|
+
return expectResolvedContainerTarget(resolveContainerTarget(path.posix.join(workspaceContainerRoot, relative)), input);
|
|
395
424
|
}
|
|
396
425
|
if (skillsRoot && this.sandbox.workspaceAccess === "rw" && isPathInside(skillsRoot, hostPath)) {
|
|
397
426
|
const relative = path.relative(skillsRoot, hostPath).split(path.sep).join(path.posix.sep);
|
|
@@ -406,18 +435,15 @@ var OpenShellFsBridge = class {
|
|
|
406
435
|
}
|
|
407
436
|
if (hasAgentMount && isPathInside(agentRoot, hostPath)) {
|
|
408
437
|
const relative = path.relative(agentRoot, hostPath).split(path.sep).join(path.posix.sep);
|
|
409
|
-
return
|
|
410
|
-
hostPath,
|
|
411
|
-
relativePath: relative ? `${agentContainerRoot}/${relative}` : agentContainerRoot,
|
|
412
|
-
containerPath: relative ? path.posix.join(agentContainerRoot, relative) : agentContainerRoot,
|
|
413
|
-
mountHostRoot: agentRoot,
|
|
414
|
-
writable: this.sandbox.workspaceAccess === "rw",
|
|
415
|
-
source: "agent"
|
|
416
|
-
};
|
|
438
|
+
return expectResolvedContainerTarget(resolveContainerTarget(path.posix.join(agentContainerRoot, relative)), input);
|
|
417
439
|
}
|
|
418
440
|
throw new Error(`Path escapes sandbox root (${workspaceRoot}): ${params.filePath}`);
|
|
419
441
|
}
|
|
420
442
|
};
|
|
443
|
+
function expectResolvedContainerTarget(target, input) {
|
|
444
|
+
if (!target) throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${input}`);
|
|
445
|
+
return target;
|
|
446
|
+
}
|
|
421
447
|
async function mkdirLocalRootPath(params) {
|
|
422
448
|
const relativePath = relativeToRoot(params.target, params.hostPath);
|
|
423
449
|
if (!relativePath) return;
|
|
@@ -582,66 +608,65 @@ function createExcludeMatcher(excludeDirs) {
|
|
|
582
608
|
}
|
|
583
609
|
const runLimitedFs = pLimit(COPY_TREE_FS_CONCURRENCY);
|
|
584
610
|
async function lstatIfExists(targetPath) {
|
|
585
|
-
return await runLimitedFs(
|
|
611
|
+
return await runLimitedFs(fsPromises.lstat, targetPath).catch((error) => {
|
|
612
|
+
if (extractErrorCode(error) === "ENOENT") return null;
|
|
613
|
+
throw error;
|
|
614
|
+
});
|
|
586
615
|
}
|
|
587
|
-
async function
|
|
588
|
-
const stats = await runLimitedFs(async () => await fsPromises.lstat(params.sourcePath));
|
|
589
|
-
if (stats.isSymbolicLink()) return;
|
|
616
|
+
async function reconcileMirrorPath(params) {
|
|
590
617
|
const targetStats = await lstatIfExists(params.targetPath);
|
|
591
|
-
if (
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
618
|
+
if (targetStats && !targetStats.isDirectory() && !targetStats.isFile()) return true;
|
|
619
|
+
const sourceStats = params.sourcePath ? await runLimitedFs(fsPromises.lstat, params.sourcePath) : null;
|
|
620
|
+
const sourceDir = sourceStats?.isDirectory() ? params.sourcePath : void 0;
|
|
621
|
+
const sourceFile = sourceStats?.isFile() ? params.sourcePath : void 0;
|
|
622
|
+
if (!params.replace && !sourceDir && !sourceFile) return false;
|
|
623
|
+
if (sourceDir || targetStats?.isDirectory()) {
|
|
624
|
+
if (targetStats && !targetStats.isDirectory()) await runLimitedFs(fsPromises.rm, params.targetPath, { force: true });
|
|
625
|
+
const preservedEntries = await reconcileMirrorDirectory({
|
|
626
|
+
sourceDir,
|
|
627
|
+
targetDir: params.targetPath,
|
|
628
|
+
replace: params.replace
|
|
629
|
+
});
|
|
630
|
+
if (sourceDir || preservedEntries) return preservedEntries;
|
|
631
|
+
await runLimitedFs(fsPromises.rmdir, params.targetPath);
|
|
632
|
+
} else if (targetStats) await runLimitedFs(fsPromises.rm, params.targetPath, { force: true });
|
|
633
|
+
if (sourceFile) await runLimitedFs(fsPromises.copyFile, sourceFile, params.targetPath);
|
|
634
|
+
return false;
|
|
608
635
|
}
|
|
609
|
-
async function
|
|
636
|
+
async function reconcileMirrorDirectory(params) {
|
|
637
|
+
const { sourceDir } = params;
|
|
610
638
|
const isExcluded = createExcludeMatcher(params.excludeDirs);
|
|
611
|
-
await fsPromises.mkdir
|
|
612
|
-
const
|
|
613
|
-
await
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
if (isExcluded(entry)) continue;
|
|
624
|
-
await copyTreeWithoutSymlinks({
|
|
625
|
-
sourcePath: path.join(params.sourceDir, entry),
|
|
626
|
-
targetPath: path.join(params.targetDir, entry),
|
|
627
|
-
preserveTargetSymlinks: true
|
|
628
|
-
});
|
|
639
|
+
await runLimitedFs(fsPromises.mkdir, params.targetDir, { recursive: true });
|
|
640
|
+
const sourceEntries = new Set(sourceDir ? await runLimitedFs(async () => await fsPromises.readdir(sourceDir)) : []);
|
|
641
|
+
const targetEntries = params.replace ? await runLimitedFs(async () => await fsPromises.readdir(params.targetDir)) : [];
|
|
642
|
+
const results = await Promise.allSettled([.../* @__PURE__ */ new Set([...sourceEntries, ...targetEntries])].filter((entry) => !isExcluded(entry)).map((entry) => reconcileMirrorPath({
|
|
643
|
+
sourcePath: sourceDir && sourceEntries.has(entry) ? path.join(sourceDir, entry) : void 0,
|
|
644
|
+
targetPath: path.join(params.targetDir, entry),
|
|
645
|
+
replace: params.replace
|
|
646
|
+
})));
|
|
647
|
+
let preservedEntries = false;
|
|
648
|
+
for (const result of results) {
|
|
649
|
+
if (result.status === "rejected") throw result.reason;
|
|
650
|
+
preservedEntries ||= result.value;
|
|
629
651
|
}
|
|
652
|
+
return preservedEntries;
|
|
653
|
+
}
|
|
654
|
+
async function replaceDirectoryContents(params) {
|
|
655
|
+
await reconcileMirrorDirectory({
|
|
656
|
+
...params,
|
|
657
|
+
replace: true
|
|
658
|
+
});
|
|
630
659
|
}
|
|
631
660
|
async function stageDirectoryContents(params) {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
if (isExcluded(entry)) continue;
|
|
637
|
-
await copyTreeWithoutSymlinks({
|
|
638
|
-
sourcePath: path.join(params.sourceDir, entry),
|
|
639
|
-
targetPath: path.join(params.targetDir, entry)
|
|
640
|
-
});
|
|
641
|
-
}
|
|
661
|
+
await reconcileMirrorDirectory({
|
|
662
|
+
...params,
|
|
663
|
+
replace: false
|
|
664
|
+
});
|
|
642
665
|
}
|
|
643
666
|
//#endregion
|
|
644
667
|
//#region extensions/openshell/src/backend.ts
|
|
668
|
+
const openShellWorkspaceOperations = new KeyedAsyncQueue();
|
|
669
|
+
let openShellDetachedCreateSupport;
|
|
645
670
|
const MATERIALIZED_SKILLS_REMOTE_PARTS = [".openclaw", "sandbox-skills"];
|
|
646
671
|
function buildOpenShellDirectoryUploadArgs(params) {
|
|
647
672
|
return [
|
|
@@ -650,9 +675,10 @@ function buildOpenShellDirectoryUploadArgs(params) {
|
|
|
650
675
|
"--no-git-ignore",
|
|
651
676
|
params.sandboxName,
|
|
652
677
|
params.localPath,
|
|
653
|
-
normalizeRemotePath(params.remotePath)
|
|
678
|
+
`${normalizeRemotePath(params.remotePath)}/`
|
|
654
679
|
];
|
|
655
680
|
}
|
|
681
|
+
const REMOTE_MANAGED_ROOTS_EMPTY_SCRIPT = "for root in \"$@\"; do if [ -d \"$root\" ] && [ -n \"$(ls -A \"$root\")\" ]; then printf \"1\\n\"; exit 0; fi; done; printf \"0\\n\"";
|
|
656
682
|
const PINNED_REMOTE_PATH_MUTATION_SCRIPT = [
|
|
657
683
|
"set -eu",
|
|
658
684
|
"die() { echo \"$1\" >&2; exit 1; }",
|
|
@@ -758,6 +784,7 @@ const ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT = [
|
|
|
758
784
|
"set -e",
|
|
759
785
|
"target=\"$1\"",
|
|
760
786
|
"root=\"${2:-$1}\"",
|
|
787
|
+
"replace_blocking=\"${3:-0}\"",
|
|
761
788
|
"case \"$target\" in /*) ;; *) echo \"remote directory must be absolute: $target\" >&2; exit 1 ;; esac",
|
|
762
789
|
"case \"$root\" in /*) ;; *) echo \"remote root must be absolute: $root\" >&2; exit 1 ;; esac",
|
|
763
790
|
"target=\"${target%/}\"",
|
|
@@ -785,9 +812,15 @@ const ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT = [
|
|
|
785
812
|
" if [ \"$part\" = \"$relative\" ]; then relative=\"\"; else relative=\"${relative#*/}\"; fi",
|
|
786
813
|
" [ -n \"$part\" ] || continue",
|
|
787
814
|
" if [ \"$current\" = \"/\" ]; then next=\"/$part\"; else next=\"$current/$part\"; fi",
|
|
788
|
-
" if [ -L \"$next\" ]; then
|
|
815
|
+
" if [ -L \"$next\" ]; then",
|
|
816
|
+
" if [ \"$replace_blocking\" != \"1\" ]; then echo \"unsafe remote directory symlink: $next\" >&2; exit 1; fi",
|
|
817
|
+
" rm -rf -- \"$next\"",
|
|
818
|
+
" elif [ -e \"$next\" ] && [ ! -d \"$next\" ]; then",
|
|
819
|
+
" if [ \"$replace_blocking\" != \"1\" ]; then echo \"unsafe remote directory component: $next\" >&2; exit 1; fi",
|
|
820
|
+
" rm -rf -- \"$next\"",
|
|
821
|
+
" fi",
|
|
789
822
|
" if [ -e \"$next\" ]; then",
|
|
790
|
-
"
|
|
823
|
+
" [ -d \"$next\" ] || { echo \"unsafe remote directory component: $next\" >&2; exit 1; }",
|
|
791
824
|
" else",
|
|
792
825
|
" mkdir -- \"$next\"",
|
|
793
826
|
" fi",
|
|
@@ -815,20 +848,22 @@ function createOpenShellSandboxBackendManager(params) {
|
|
|
815
848
|
args: [
|
|
816
849
|
"sandbox",
|
|
817
850
|
"get",
|
|
818
|
-
entry.containerName
|
|
851
|
+
entry.containerName,
|
|
852
|
+
"--output",
|
|
853
|
+
"json"
|
|
819
854
|
]
|
|
820
855
|
});
|
|
821
856
|
const configuredSource = execContext.config.from;
|
|
822
857
|
return {
|
|
823
|
-
running: result.code === 0,
|
|
858
|
+
running: result.code === 0 && parseOpenShellSandboxPhase(result.stdout) === "Ready",
|
|
824
859
|
actualConfigLabel: entry.image,
|
|
825
860
|
configLabelMatch: entry.image === configuredSource
|
|
826
861
|
};
|
|
827
862
|
},
|
|
828
|
-
async removeRuntime({ entry }) {
|
|
829
|
-
await runOpenShellCli({
|
|
863
|
+
async removeRuntime({ entry, config }) {
|
|
864
|
+
const result = await runOpenShellCli({
|
|
830
865
|
context: {
|
|
831
|
-
config: params.pluginConfig,
|
|
866
|
+
config: resolveOpenShellPluginConfigFromConfig(config, params.pluginConfig),
|
|
832
867
|
sandboxName: entry.containerName
|
|
833
868
|
},
|
|
834
869
|
args: [
|
|
@@ -837,6 +872,7 @@ function createOpenShellSandboxBackendManager(params) {
|
|
|
837
872
|
entry.containerName
|
|
838
873
|
]
|
|
839
874
|
});
|
|
875
|
+
if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox delete failed");
|
|
840
876
|
}
|
|
841
877
|
};
|
|
842
878
|
}
|
|
@@ -864,11 +900,11 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
864
900
|
this.params = params;
|
|
865
901
|
this.handle = null;
|
|
866
902
|
this.ensurePromise = null;
|
|
867
|
-
this.preparedRemoteWorkspaceForNextExec = null;
|
|
868
903
|
this.remoteSeedPending = false;
|
|
869
904
|
}
|
|
870
905
|
asHandle() {
|
|
871
906
|
if (this.handle) return this.handle;
|
|
907
|
+
const runRemoteShellScript = (command) => this.params.execContext.config.mode === "mirror" ? this.runWorkspaceOperation(() => this.runRemoteShellScript(command), command.signal) : this.runRemoteShellScript(command);
|
|
872
908
|
const handle = {
|
|
873
909
|
id: "openshell",
|
|
874
910
|
runtimeId: this.params.execContext.sandboxName,
|
|
@@ -880,7 +916,6 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
880
916
|
configLabelKind: "Source",
|
|
881
917
|
workdirValidation: "backend",
|
|
882
918
|
validateWorkdir: async (workdir) => await this.validateWorkdir(workdir),
|
|
883
|
-
discardPreparedWorkdir: (workdir) => this.discardPreparedWorkdir(workdir),
|
|
884
919
|
workdirRoots: [this.params.remoteWorkspaceDir, this.params.remoteAgentWorkspaceDir],
|
|
885
920
|
remoteWorkspaceDir: this.params.remoteWorkspaceDir,
|
|
886
921
|
remoteAgentWorkspaceDir: this.params.remoteAgentWorkspaceDir,
|
|
@@ -901,126 +936,189 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
901
936
|
finalizeExec: async ({ token }) => {
|
|
902
937
|
await this.finalizeExec(token);
|
|
903
938
|
},
|
|
904
|
-
runShellCommand:
|
|
939
|
+
runShellCommand: runRemoteShellScript,
|
|
905
940
|
createFsBridge: ({ sandbox }) => this.params.execContext.config.mode === "remote" ? createRemoteShellSandboxFsBridge({
|
|
906
941
|
sandbox,
|
|
907
942
|
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)
|
|
943
|
+
}) : this.createMirrorFsBridge(sandbox),
|
|
944
|
+
runRemoteShellScript
|
|
917
945
|
};
|
|
918
946
|
this.handle = handle;
|
|
919
947
|
return handle;
|
|
920
948
|
}
|
|
949
|
+
createMirrorFsBridge(sandbox) {
|
|
950
|
+
const bridge = createOpenShellFsBridge({
|
|
951
|
+
sandbox,
|
|
952
|
+
backend: {
|
|
953
|
+
remoteAgentWorkspaceDir: this.params.remoteAgentWorkspaceDir,
|
|
954
|
+
mkdirpRemotePath: (remotePath, signal) => this.mkdirpRemotePath(remotePath, signal),
|
|
955
|
+
removeRemotePath: (remotePath, params) => this.removeRemotePath(remotePath, params),
|
|
956
|
+
renameRemotePath: (from, to, signal) => this.renameRemotePath(from, to, signal),
|
|
957
|
+
syncLocalPathToRemote: (localPath, remotePath) => this.syncLocalPathToRemote(localPath, remotePath)
|
|
958
|
+
}
|
|
959
|
+
});
|
|
960
|
+
return {
|
|
961
|
+
resolvePath: (params) => bridge.resolvePath(params),
|
|
962
|
+
readFile: (params) => this.runWorkspaceOperation(() => bridge.readFile(params), params.signal),
|
|
963
|
+
writeFile: (params) => this.runWorkspaceOperation(() => bridge.writeFile(params), params.signal),
|
|
964
|
+
createFileExclusive: (params) => this.runWorkspaceOperation(() => bridge.createFileExclusive(params), params.signal),
|
|
965
|
+
mkdirp: (params) => this.runWorkspaceOperation(() => bridge.mkdirp(params), params.signal),
|
|
966
|
+
remove: (params) => this.runWorkspaceOperation(() => bridge.remove(params), params.signal),
|
|
967
|
+
rename: (params) => this.runWorkspaceOperation(() => bridge.rename(params), params.signal),
|
|
968
|
+
stat: (params) => this.runWorkspaceOperation(() => bridge.stat(params), params.signal)
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
async runWorkspaceOperation(operation, signal) {
|
|
972
|
+
signal?.throwIfAborted();
|
|
973
|
+
const lease = await this.acquireWorkspaceLease();
|
|
974
|
+
try {
|
|
975
|
+
signal?.throwIfAborted();
|
|
976
|
+
return await operation();
|
|
977
|
+
} finally {
|
|
978
|
+
lease.release();
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
async acquireWorkspaceLease() {
|
|
982
|
+
const { config, sandboxName } = this.params.execContext;
|
|
983
|
+
const keys = [`host:${await canonicalPathFromExistingAncestor(this.params.createParams.workspaceDir)}`, `runtime:${JSON.stringify([
|
|
984
|
+
config.gatewayEndpoint ?? "",
|
|
985
|
+
config.gateway ?? "",
|
|
986
|
+
config.workspace ?? process.env.OPENSHELL_WORKSPACE ?? "",
|
|
987
|
+
sandboxName
|
|
988
|
+
])}`].toSorted();
|
|
989
|
+
const releases = [];
|
|
990
|
+
for (const key of keys) {
|
|
991
|
+
const acquired = createDeferred();
|
|
992
|
+
openShellWorkspaceOperations.enqueue(key, () => new Promise(acquired.resolve));
|
|
993
|
+
releases.push(await acquired.promise);
|
|
994
|
+
}
|
|
995
|
+
return { release: () => {
|
|
996
|
+
for (const release of releases.toReversed()) release();
|
|
997
|
+
} };
|
|
998
|
+
}
|
|
921
999
|
async prepareExec(params) {
|
|
922
1000
|
const remoteWorkdir = params.workdir ?? this.params.remoteWorkspaceDir;
|
|
923
|
-
const preparedWorkspace = this.consumePreparedRemoteWorkspaceForNextExec(remoteWorkdir);
|
|
924
1001
|
const remoteCommand = buildValidatedExecRemoteCommand({
|
|
925
1002
|
command: params.command,
|
|
926
1003
|
workdir: remoteWorkdir,
|
|
927
|
-
env:
|
|
1004
|
+
env: {}
|
|
928
1005
|
});
|
|
929
|
-
|
|
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
|
-
};
|
|
952
|
-
}
|
|
953
|
-
async validateWorkdir(workdir) {
|
|
954
|
-
const preparedWorkspace = this.prepareRemoteWorkspaceForExec();
|
|
955
|
-
const reusablePreparation = {
|
|
956
|
-
workdir,
|
|
957
|
-
promise: preparedWorkspace
|
|
958
|
-
};
|
|
959
|
-
this.preparedRemoteWorkspaceForNextExec = reusablePreparation;
|
|
1006
|
+
const workspaceLease = this.params.execContext.config.mode === "mirror" ? await this.acquireWorkspaceLease() : void 0;
|
|
960
1007
|
try {
|
|
961
|
-
await
|
|
1008
|
+
await this.ensureSandboxExists();
|
|
1009
|
+
if (workspaceLease) {
|
|
1010
|
+
await this.syncWorkspaceToRemote();
|
|
1011
|
+
this.remoteSeedPending = false;
|
|
1012
|
+
}
|
|
962
1013
|
const sshSession = await createOpenShellSshSession({ context: this.params.execContext });
|
|
963
1014
|
try {
|
|
964
|
-
const
|
|
1015
|
+
const prepared = await prepareSshSandboxExec({
|
|
965
1016
|
session: sshSession,
|
|
966
|
-
remoteCommand
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
}),
|
|
970
|
-
allowFailure: true
|
|
1017
|
+
remoteCommand,
|
|
1018
|
+
env: params.env,
|
|
1019
|
+
tty: params.usePty
|
|
971
1020
|
});
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1021
|
+
return {
|
|
1022
|
+
argv: prepared.argv,
|
|
1023
|
+
token: {
|
|
1024
|
+
workspaceLease,
|
|
1025
|
+
cleanup: async () => {
|
|
1026
|
+
try {
|
|
1027
|
+
await prepared.cleanup();
|
|
1028
|
+
} finally {
|
|
1029
|
+
await disposeSshSandboxSession(sshSession);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
} catch (error) {
|
|
979
1035
|
await disposeSshSandboxSession(sshSession);
|
|
1036
|
+
throw error;
|
|
980
1037
|
}
|
|
981
1038
|
} catch (error) {
|
|
982
|
-
|
|
1039
|
+
workspaceLease?.release();
|
|
983
1040
|
throw error;
|
|
984
1041
|
}
|
|
985
1042
|
}
|
|
1043
|
+
async validateWorkdir(workdir) {
|
|
1044
|
+
if (this.params.execContext.config.mode === "mirror") return await this.runWorkspaceOperation(() => this.validateMirrorWorkdir(workdir));
|
|
1045
|
+
await this.ensureSandboxExists();
|
|
1046
|
+
const sshSession = await createOpenShellSshSession({ context: this.params.execContext });
|
|
1047
|
+
try {
|
|
1048
|
+
const result = await runSshSandboxCommand({
|
|
1049
|
+
session: sshSession,
|
|
1050
|
+
remoteCommand: buildRemoteWorkdirValidationCommand({
|
|
1051
|
+
workdir,
|
|
1052
|
+
root: this.resolveWorkdirValidationRoot(workdir)
|
|
1053
|
+
}),
|
|
1054
|
+
allowFailure: true
|
|
1055
|
+
});
|
|
1056
|
+
return result.code === 0 ? result.stdout.toString("utf8").trim() || null : null;
|
|
1057
|
+
} finally {
|
|
1058
|
+
await disposeSshSandboxSession(sshSession);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
986
1061
|
resolveWorkdirValidationRoot(workdir) {
|
|
987
1062
|
try {
|
|
988
1063
|
const normalized = normalizeRemotePath(workdir);
|
|
989
|
-
return [
|
|
1064
|
+
return resolveOpenShellWorkspaceRoot([{
|
|
1065
|
+
remote: normalizeRemotePath(this.params.remoteWorkspaceDir),
|
|
1066
|
+
owner: "workspace",
|
|
1067
|
+
value: void 0
|
|
1068
|
+
}, {
|
|
1069
|
+
remote: normalizeRemotePath(this.params.remoteAgentWorkspaceDir),
|
|
1070
|
+
owner: "agent",
|
|
1071
|
+
value: void 0
|
|
1072
|
+
}], normalized)?.remote ?? this.params.remoteWorkspaceDir;
|
|
990
1073
|
} catch {
|
|
991
1074
|
return this.params.remoteWorkspaceDir;
|
|
992
1075
|
}
|
|
993
1076
|
}
|
|
994
|
-
|
|
995
|
-
const
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
return;
|
|
1077
|
+
async validateMirrorWorkdir(workdir) {
|
|
1078
|
+
const normalized = normalizeRemotePath(workdir);
|
|
1079
|
+
const roots = this.workspaceUploadRoots();
|
|
1080
|
+
if (!roots.some((root) => isOpenShellRemotePathInside(root.remote, normalized))) return null;
|
|
1081
|
+
const { cfg, skillsWorkspaceDir } = this.params.createParams;
|
|
1082
|
+
if (cfg.workspaceAccess === "rw" && skillsWorkspaceDir) roots.push({
|
|
1083
|
+
remote: resolveRemoteMaterializedSkillsWorkspaceDir(this.params.remoteWorkspaceDir),
|
|
1084
|
+
local: skillsWorkspaceDir,
|
|
1085
|
+
owner: "workspace"
|
|
1086
|
+
});
|
|
1087
|
+
let createdAncestor = false;
|
|
1088
|
+
for (const root of roots.toReversed()) {
|
|
1089
|
+
if (!isOpenShellRemotePathInside(root.remote, normalized)) {
|
|
1090
|
+
createdAncestor ||= isOpenShellRemotePathInside(normalized, root.remote);
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
if (createdAncestor) return normalized;
|
|
1094
|
+
const relative = path.posix.relative(root.remote, normalized);
|
|
1095
|
+
if (!relative) return normalized;
|
|
1096
|
+
const parts = relative.split("/");
|
|
1097
|
+
if (DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS.some((excluded) => excluded === normalizeLowercaseStringOrEmpty(parts[0]))) return null;
|
|
1098
|
+
let local = root.local;
|
|
1099
|
+
for (const part of root.local === skillsWorkspaceDir ? ["", ...parts] : parts) {
|
|
1100
|
+
local = path.join(local, part);
|
|
1101
|
+
if (!(await fsPromises.lstat(local).catch(() => null))?.isDirectory()) return null;
|
|
1102
|
+
}
|
|
1103
|
+
return normalized;
|
|
1011
1104
|
}
|
|
1012
|
-
|
|
1105
|
+
return createdAncestor ? normalized : null;
|
|
1013
1106
|
}
|
|
1014
1107
|
async finalizeExec(token) {
|
|
1108
|
+
const workspaceLease = token?.workspaceLease ?? (this.params.execContext.config.mode === "mirror" ? await this.acquireWorkspaceLease() : void 0);
|
|
1015
1109
|
try {
|
|
1016
1110
|
if (this.params.execContext.config.mode === "mirror") await this.syncWorkspaceFromRemote();
|
|
1017
1111
|
} finally {
|
|
1018
|
-
|
|
1112
|
+
try {
|
|
1113
|
+
await token?.cleanup();
|
|
1114
|
+
} finally {
|
|
1115
|
+
workspaceLease?.release();
|
|
1116
|
+
}
|
|
1019
1117
|
}
|
|
1020
1118
|
}
|
|
1021
1119
|
async runRemoteShellScript(params) {
|
|
1022
1120
|
await this.ensureSandboxExists();
|
|
1023
|
-
if (
|
|
1121
|
+
if (this.params.execContext.config.mode === "mirror") await this.maybeSeedRemoteWorkspace();
|
|
1024
1122
|
return await this.runRemoteShellScriptInternal(params);
|
|
1025
1123
|
}
|
|
1026
1124
|
async mkdirpRemotePath(remotePath, signal) {
|
|
@@ -1125,7 +1223,7 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
1125
1223
|
"--no-git-ignore",
|
|
1126
1224
|
this.params.execContext.sandboxName,
|
|
1127
1225
|
localPath,
|
|
1128
|
-
|
|
1226
|
+
remotePath
|
|
1129
1227
|
],
|
|
1130
1228
|
cwd: this.params.createParams.workspaceDir
|
|
1131
1229
|
});
|
|
@@ -1140,8 +1238,16 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
1140
1238
|
}
|
|
1141
1239
|
resolveRemoteTarget(remotePath) {
|
|
1142
1240
|
const normalized = normalizeRemotePath(remotePath);
|
|
1143
|
-
const
|
|
1144
|
-
|
|
1241
|
+
const root = resolveOpenShellWorkspaceRoot([{
|
|
1242
|
+
remote: normalizeRemotePath(this.params.remoteWorkspaceDir),
|
|
1243
|
+
owner: "workspace",
|
|
1244
|
+
value: void 0
|
|
1245
|
+
}, {
|
|
1246
|
+
remote: normalizeRemotePath(this.params.remoteAgentWorkspaceDir),
|
|
1247
|
+
owner: "agent",
|
|
1248
|
+
value: void 0
|
|
1249
|
+
}], normalized)?.remote;
|
|
1250
|
+
if (root) {
|
|
1145
1251
|
const relativePath = path.posix.relative(root, normalized);
|
|
1146
1252
|
return {
|
|
1147
1253
|
root,
|
|
@@ -1152,7 +1258,11 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
1152
1258
|
}
|
|
1153
1259
|
async ensureSandboxExists() {
|
|
1154
1260
|
if (this.ensurePromise) return await this.ensurePromise;
|
|
1155
|
-
|
|
1261
|
+
const initialize = async () => {
|
|
1262
|
+
await this.ensureSandboxExistsInner();
|
|
1263
|
+
if (this.params.execContext.config.mode === "remote" && !await this.maybeSeedRemoteWorkspace()) await this.syncSkillsWorkspaceToRemote();
|
|
1264
|
+
};
|
|
1265
|
+
this.ensurePromise = this.params.execContext.config.mode === "remote" ? this.runWorkspaceOperation(initialize) : initialize();
|
|
1156
1266
|
try {
|
|
1157
1267
|
await this.ensurePromise;
|
|
1158
1268
|
} catch (error) {
|
|
@@ -1176,9 +1286,12 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
1176
1286
|
if (!phase) throw this.buildLegacyRuntimeUnavailableError("OpenShell did not report a lifecycle phase for this sandbox.");
|
|
1177
1287
|
if (phase !== "Ready") throw this.buildLegacyRuntimeUnavailableError(`OpenShell reports phase "${phase}".`);
|
|
1178
1288
|
}
|
|
1289
|
+
if (this.params.execContext.config.mode === "remote" && await this.remoteManagedRootsEmpty()) this.remoteSeedPending = true;
|
|
1179
1290
|
return;
|
|
1180
1291
|
}
|
|
1181
1292
|
if (this.params.legacyRuntimeAdopted) throw this.buildLegacyRuntimeUnavailableError(getResult.stderr.trim());
|
|
1293
|
+
if (!/\bsandbox not found\b/iu.test(getResult.stderr)) throw new Error(getResult.stderr.trim() || "openshell sandbox get failed");
|
|
1294
|
+
const detachedCreateSupported = await this.supportsDetachedSandboxCreation();
|
|
1182
1295
|
const createArgs = [
|
|
1183
1296
|
"sandbox",
|
|
1184
1297
|
"create",
|
|
@@ -1190,8 +1303,12 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
1190
1303
|
...this.params.execContext.config.gpu ? ["--gpu"] : [],
|
|
1191
1304
|
...this.params.execContext.config.autoProviders ? ["--auto-providers"] : ["--no-auto-providers"],
|
|
1192
1305
|
...this.params.execContext.config.providers.flatMap((provider) => ["--provider", provider]),
|
|
1193
|
-
|
|
1194
|
-
|
|
1306
|
+
...detachedCreateSupported ? [
|
|
1307
|
+
"--detach",
|
|
1308
|
+
"--",
|
|
1309
|
+
"sleep",
|
|
1310
|
+
"infinity"
|
|
1311
|
+
] : ["--", "true"]
|
|
1195
1312
|
];
|
|
1196
1313
|
const createResult = await runOpenShellCli({
|
|
1197
1314
|
context: this.params.execContext,
|
|
@@ -1202,6 +1319,41 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
1202
1319
|
if (createResult.code !== 0) throw new Error(createResult.stderr.trim() || "openshell sandbox create failed");
|
|
1203
1320
|
this.remoteSeedPending = true;
|
|
1204
1321
|
}
|
|
1322
|
+
async supportsDetachedSandboxCreation() {
|
|
1323
|
+
const { config } = this.params.execContext;
|
|
1324
|
+
const cliIdentity = JSON.stringify([
|
|
1325
|
+
config.command,
|
|
1326
|
+
config.gatewayEndpoint ?? "",
|
|
1327
|
+
config.gateway ?? "",
|
|
1328
|
+
config.workspace ?? process.env.OPENSHELL_WORKSPACE ?? ""
|
|
1329
|
+
]);
|
|
1330
|
+
let support = openShellDetachedCreateSupport?.key === cliIdentity ? openShellDetachedCreateSupport.promise : void 0;
|
|
1331
|
+
if (!support) {
|
|
1332
|
+
support = (async () => {
|
|
1333
|
+
const result = await runOpenShellCli({
|
|
1334
|
+
context: this.params.execContext,
|
|
1335
|
+
args: [
|
|
1336
|
+
"sandbox",
|
|
1337
|
+
"create",
|
|
1338
|
+
"--help"
|
|
1339
|
+
],
|
|
1340
|
+
cwd: this.params.createParams.workspaceDir
|
|
1341
|
+
});
|
|
1342
|
+
if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox create capability check failed");
|
|
1343
|
+
return /^\s*--detach(?:\s|$)/mu.test(result.stdout);
|
|
1344
|
+
})();
|
|
1345
|
+
openShellDetachedCreateSupport = {
|
|
1346
|
+
key: cliIdentity,
|
|
1347
|
+
promise: support
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
try {
|
|
1351
|
+
return await support;
|
|
1352
|
+
} catch (error) {
|
|
1353
|
+
if (openShellDetachedCreateSupport?.promise === support) openShellDetachedCreateSupport = void 0;
|
|
1354
|
+
throw error;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1205
1357
|
async resolveLegacyRuntimePhase() {
|
|
1206
1358
|
const pageSize = 100;
|
|
1207
1359
|
for (let offset = 0;; offset += pageSize) {
|
|
@@ -1234,18 +1386,37 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
1234
1386
|
`OpenClaw will not recreate this retired runtime name. Run \`${recreateCommand}\` to migrate this scope to the current naming format.`
|
|
1235
1387
|
].filter(Boolean).join(" "));
|
|
1236
1388
|
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1389
|
+
workspaceUploadRoots() {
|
|
1390
|
+
const { createParams, remoteWorkspaceDir, remoteAgentWorkspaceDir } = this.params;
|
|
1391
|
+
const roots = [{
|
|
1392
|
+
remote: remoteWorkspaceDir,
|
|
1393
|
+
owner: "workspace",
|
|
1394
|
+
value: createParams.workspaceDir
|
|
1395
|
+
}];
|
|
1396
|
+
if (createParams.cfg.workspaceAccess !== "none" && path.resolve(createParams.agentWorkspaceDir) !== path.resolve(createParams.workspaceDir)) roots.push({
|
|
1397
|
+
remote: remoteAgentWorkspaceDir,
|
|
1398
|
+
owner: "agent",
|
|
1399
|
+
value: createParams.agentWorkspaceDir
|
|
1241
1400
|
});
|
|
1242
|
-
|
|
1243
|
-
|
|
1401
|
+
return orderOpenShellWorkspaceRoots(roots).map(({ remote, owner, value: local }) => ({
|
|
1402
|
+
remote,
|
|
1403
|
+
local,
|
|
1404
|
+
owner
|
|
1405
|
+
}));
|
|
1406
|
+
}
|
|
1407
|
+
async syncWorkspaceToRemote() {
|
|
1408
|
+
const roots = this.workspaceUploadRoots();
|
|
1409
|
+
for (const [index, root] of roots.entries()) {
|
|
1410
|
+
const containingRoot = roots.slice(0, index).toReversed().find((candidate) => isOpenShellRemotePathInside(candidate.remote, root.remote));
|
|
1244
1411
|
await this.runRemoteShellScriptInternal({
|
|
1245
|
-
script:
|
|
1246
|
-
args: [
|
|
1412
|
+
script: `${ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT}\nfind "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +`,
|
|
1413
|
+
args: [
|
|
1414
|
+
root.remote,
|
|
1415
|
+
containingRoot?.remote ?? root.remote,
|
|
1416
|
+
containingRoot ? "1" : "0"
|
|
1417
|
+
]
|
|
1247
1418
|
});
|
|
1248
|
-
await this.uploadPathToRemote(
|
|
1419
|
+
await this.uploadPathToRemote(root.local, root.remote);
|
|
1249
1420
|
}
|
|
1250
1421
|
await this.syncSkillsWorkspaceToRemote();
|
|
1251
1422
|
}
|
|
@@ -1261,40 +1432,62 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
1261
1432
|
await this.uploadPathToRemote(this.params.createParams.skillsWorkspaceDir, remoteSkillsWorkspaceDir);
|
|
1262
1433
|
}
|
|
1263
1434
|
async syncWorkspaceFromRemote() {
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
this.params.execContext
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
workspaceDir: this.params.createParams.workspaceDir,
|
|
1283
|
-
tmpDir
|
|
1284
|
-
});
|
|
1285
|
-
try {
|
|
1286
|
-
await replaceDirectoryContents({
|
|
1287
|
-
sourceDir: tmpDir,
|
|
1288
|
-
targetDir: this.params.createParams.workspaceDir,
|
|
1289
|
-
excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS
|
|
1290
|
-
});
|
|
1291
|
-
} finally {
|
|
1292
|
-
await restoreMaterializedSkillsShadow({
|
|
1293
|
-
workspaceDir: this.params.createParams.workspaceDir,
|
|
1294
|
-
preserved: preservedSandboxSkills
|
|
1435
|
+
const remoteSkillsWorkspaceDir = resolveRemoteMaterializedSkillsWorkspaceDir(this.params.remoteWorkspaceDir);
|
|
1436
|
+
const roots = this.workspaceUploadRoots();
|
|
1437
|
+
for (const [index, root] of roots.entries()) {
|
|
1438
|
+
if (root.owner === "agent") continue;
|
|
1439
|
+
await withTempWorkspace({
|
|
1440
|
+
rootDir: resolveOpenShellTmpRoot(),
|
|
1441
|
+
prefix: "openclaw-openshell-sync-"
|
|
1442
|
+
}, async ({ dir: tmpDir }) => {
|
|
1443
|
+
const result = await runOpenShellCli({
|
|
1444
|
+
context: this.params.execContext,
|
|
1445
|
+
args: [
|
|
1446
|
+
"sandbox",
|
|
1447
|
+
"download",
|
|
1448
|
+
this.params.execContext.sandboxName,
|
|
1449
|
+
root.remote,
|
|
1450
|
+
tmpDir
|
|
1451
|
+
],
|
|
1452
|
+
cwd: this.params.createParams.workspaceDir
|
|
1295
1453
|
});
|
|
1296
|
-
|
|
1297
|
-
|
|
1454
|
+
if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox download failed");
|
|
1455
|
+
const preservedShadows = [];
|
|
1456
|
+
try {
|
|
1457
|
+
for (const shadowedRoot of roots.slice(index + 1)) {
|
|
1458
|
+
if (root.remote === shadowedRoot.remote || !isOpenShellRemotePathInside(root.remote, shadowedRoot.remote)) continue;
|
|
1459
|
+
const relativeParts = path.posix.relative(root.remote, shadowedRoot.remote).split("/").filter(Boolean);
|
|
1460
|
+
await removeDownloadedWorkspacePath(tmpDir, relativeParts);
|
|
1461
|
+
const preserved = await moveLocalShadowAside({
|
|
1462
|
+
workspaceDir: root.local,
|
|
1463
|
+
tmpDir,
|
|
1464
|
+
relativeParts
|
|
1465
|
+
});
|
|
1466
|
+
if (preserved) preservedShadows.push(preserved);
|
|
1467
|
+
}
|
|
1468
|
+
const relativeSkillsPath = path.posix.relative(root.remote, remoteSkillsWorkspaceDir);
|
|
1469
|
+
if (relativeSkillsPath === "" || !relativeSkillsPath.startsWith("../") && !path.posix.isAbsolute(relativeSkillsPath)) await removeDownloadedWorkspacePath(tmpDir, relativeSkillsPath.split("/").filter(Boolean));
|
|
1470
|
+
if (root.owner === "workspace") {
|
|
1471
|
+
const preserved = await moveLocalShadowAside({
|
|
1472
|
+
workspaceDir: root.local,
|
|
1473
|
+
tmpDir,
|
|
1474
|
+
relativeParts: MATERIALIZED_SKILLS_REMOTE_PARTS
|
|
1475
|
+
});
|
|
1476
|
+
if (preserved) preservedShadows.push(preserved);
|
|
1477
|
+
}
|
|
1478
|
+
await replaceDirectoryContents({
|
|
1479
|
+
sourceDir: tmpDir,
|
|
1480
|
+
targetDir: root.local,
|
|
1481
|
+
excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS
|
|
1482
|
+
});
|
|
1483
|
+
} finally {
|
|
1484
|
+
for (const preserved of preservedShadows.toReversed()) await restoreLocalShadow({
|
|
1485
|
+
workspaceDir: root.local,
|
|
1486
|
+
preserved
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1298
1491
|
}
|
|
1299
1492
|
async uploadPathToRemote(localPath, remotePath) {
|
|
1300
1493
|
await withTempWorkspace({
|
|
@@ -1305,10 +1498,10 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
1305
1498
|
const stagedRoot = path.join(tmpDir, remoteRootName);
|
|
1306
1499
|
await stageDirectoryContents({
|
|
1307
1500
|
sourceDir: localPath,
|
|
1308
|
-
targetDir: stagedRoot
|
|
1501
|
+
targetDir: stagedRoot,
|
|
1502
|
+
excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS
|
|
1309
1503
|
});
|
|
1310
|
-
const
|
|
1311
|
-
for (const entry of stagedEntries) {
|
|
1504
|
+
for (const entry of (await fsPromises.readdir(stagedRoot)).toSorted()) {
|
|
1312
1505
|
const result = await runOpenShellCli({
|
|
1313
1506
|
context: this.params.execContext,
|
|
1314
1507
|
args: buildOpenShellDirectoryUploadArgs({
|
|
@@ -1322,6 +1515,12 @@ var OpenShellSandboxBackendImpl = class {
|
|
|
1322
1515
|
}
|
|
1323
1516
|
});
|
|
1324
1517
|
}
|
|
1518
|
+
async remoteManagedRootsEmpty() {
|
|
1519
|
+
return (await this.runRemoteShellScriptInternal({
|
|
1520
|
+
script: REMOTE_MANAGED_ROOTS_EMPTY_SCRIPT,
|
|
1521
|
+
args: [this.params.remoteWorkspaceDir, this.params.remoteAgentWorkspaceDir]
|
|
1522
|
+
})).stdout.toString("utf8").trim() === "0";
|
|
1523
|
+
}
|
|
1325
1524
|
async maybeSeedRemoteWorkspace() {
|
|
1326
1525
|
if (!this.remoteSeedPending) return false;
|
|
1327
1526
|
this.remoteSeedPending = false;
|
|
@@ -1386,17 +1585,33 @@ function parseOpenShellSandboxPhasePage(stdout, sandboxName) {
|
|
|
1386
1585
|
return;
|
|
1387
1586
|
}
|
|
1388
1587
|
}
|
|
1588
|
+
function parseOpenShellSandboxPhase(stdout) {
|
|
1589
|
+
try {
|
|
1590
|
+
const parsed = JSON.parse(stdout);
|
|
1591
|
+
if (typeof parsed !== "object" || parsed === null || !("phase" in parsed)) return;
|
|
1592
|
+
return typeof parsed.phase === "string" ? parsed.phase : void 0;
|
|
1593
|
+
} catch {
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1389
1597
|
function resolveRemoteMaterializedSkillsWorkspaceDir(remoteWorkspaceDir) {
|
|
1390
1598
|
const root = remoteWorkspaceDir.replace(/\\/g, "/").replace(/\/+$/, "") || "/";
|
|
1391
1599
|
return path.posix.join(root, ...MATERIALIZED_SKILLS_REMOTE_PARTS);
|
|
1392
1600
|
}
|
|
1393
|
-
async function
|
|
1601
|
+
async function removeDownloadedWorkspacePath(tmpDir, parts) {
|
|
1602
|
+
if (parts.length === 0) {
|
|
1603
|
+
for (const entry of await fsPromises.readdir(tmpDir)) await fsPromises.rm(path.join(tmpDir, entry), {
|
|
1604
|
+
recursive: true,
|
|
1605
|
+
force: true
|
|
1606
|
+
});
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1394
1609
|
let cursor = tmpDir;
|
|
1395
|
-
for (const [index, part] of
|
|
1610
|
+
for (const [index, part] of parts.entries()) {
|
|
1396
1611
|
const next = path.join(cursor, part);
|
|
1397
1612
|
const stats = await fsPromises.lstat(next).catch(() => null);
|
|
1398
1613
|
if (!stats) return;
|
|
1399
|
-
if (index ===
|
|
1614
|
+
if (index === parts.length - 1) {
|
|
1400
1615
|
await fsPromises.rm(next, {
|
|
1401
1616
|
recursive: true,
|
|
1402
1617
|
force: true
|
|
@@ -1413,31 +1628,31 @@ async function removeMaterializedSkillsFromDownloadedWorkspace(tmpDir) {
|
|
|
1413
1628
|
cursor = next;
|
|
1414
1629
|
}
|
|
1415
1630
|
}
|
|
1416
|
-
async function
|
|
1417
|
-
const shadowPath = path.join(params.workspaceDir, ...
|
|
1631
|
+
async function moveLocalShadowAside(params) {
|
|
1632
|
+
const shadowPath = path.join(params.workspaceDir, ...params.relativeParts);
|
|
1418
1633
|
const parentStats = await fsPromises.lstat(path.dirname(shadowPath)).catch(() => null);
|
|
1419
1634
|
if (!parentStats?.isDirectory() || parentStats.isSymbolicLink()) return;
|
|
1420
1635
|
const shadowStats = await fsPromises.lstat(shadowPath).catch(() => null);
|
|
1421
1636
|
if (!shadowStats || shadowStats.isSymbolicLink()) return;
|
|
1422
1637
|
const preserveRoot = await fsPromises.mkdtemp(path.join(path.dirname(params.tmpDir), "openclaw-openshell-preserve-"));
|
|
1423
|
-
const preservedPath = path.join(preserveRoot, "
|
|
1638
|
+
const preservedPath = path.join(preserveRoot, "shadow");
|
|
1424
1639
|
await movePathWithCopyFallback({
|
|
1425
1640
|
from: shadowPath,
|
|
1426
1641
|
to: preservedPath
|
|
1427
1642
|
});
|
|
1428
1643
|
return {
|
|
1429
1644
|
preservedPath,
|
|
1430
|
-
preserveRoot
|
|
1645
|
+
preserveRoot,
|
|
1646
|
+
relativeParts: params.relativeParts
|
|
1431
1647
|
};
|
|
1432
1648
|
}
|
|
1433
|
-
async function
|
|
1434
|
-
if (!params.preserved) return;
|
|
1649
|
+
async function restoreLocalShadow(params) {
|
|
1435
1650
|
let restored = false;
|
|
1436
1651
|
try {
|
|
1437
|
-
const shadowPath = path.join(params.workspaceDir, ...
|
|
1652
|
+
const shadowPath = path.join(params.workspaceDir, ...params.preserved.relativeParts);
|
|
1438
1653
|
const parentPath = path.dirname(shadowPath);
|
|
1439
1654
|
const parentStats = await fsPromises.lstat(parentPath).catch(() => null);
|
|
1440
|
-
if (parentStats?.isSymbolicLink()) throw new Error(`Refusing to restore
|
|
1655
|
+
if (parentStats?.isSymbolicLink()) throw new Error(`Refusing to restore workspace shadow through symlink parent: ${parentPath}`);
|
|
1441
1656
|
if (parentStats && !parentStats.isDirectory()) await fsPromises.rm(parentPath, {
|
|
1442
1657
|
recursive: true,
|
|
1443
1658
|
force: true
|
|
@@ -1467,10 +1682,6 @@ function normalizeRemotePath(remotePath) {
|
|
|
1467
1682
|
if (!path.posix.isAbsolute(normalized)) throw new Error(`OpenShell remote path must be absolute: ${remotePath}`);
|
|
1468
1683
|
return normalized;
|
|
1469
1684
|
}
|
|
1470
|
-
function isRemotePathInside(root, candidate) {
|
|
1471
|
-
const relative = path.posix.relative(root, candidate);
|
|
1472
|
-
return relative === "" || relative !== ".." && !relative.startsWith("../") && !path.posix.isAbsolute(relative);
|
|
1473
|
-
}
|
|
1474
1685
|
//#endregion
|
|
1475
1686
|
//#region extensions/openshell/index.ts
|
|
1476
1687
|
var openshell_default = definePluginEntry({
|
|
@@ -1481,11 +1692,18 @@ var openshell_default = definePluginEntry({
|
|
|
1481
1692
|
register(api) {
|
|
1482
1693
|
if (api.registrationMode !== "full") return;
|
|
1483
1694
|
const pluginConfig = resolveOpenShellPluginConfig(api.pluginConfig);
|
|
1484
|
-
registerSandboxBackend("openshell", {
|
|
1695
|
+
const unregister = registerSandboxBackend("openshell", {
|
|
1485
1696
|
factory: createOpenShellSandboxBackendFactory({ pluginConfig }),
|
|
1486
1697
|
manager: createOpenShellSandboxBackendManager({ pluginConfig }),
|
|
1487
1698
|
resolveWorkdir: () => pluginConfig.remoteWorkspaceDir
|
|
1488
1699
|
});
|
|
1700
|
+
api.lifecycle.registerRuntimeLifecycle({
|
|
1701
|
+
id: "openshell-sandbox-cleanup",
|
|
1702
|
+
cleanup: ({ reason, sessionKey, runId }) => {
|
|
1703
|
+
if (sessionKey !== void 0 || runId !== void 0) return;
|
|
1704
|
+
if (reason === "disable" || reason === "restart") unregister();
|
|
1705
|
+
}
|
|
1706
|
+
});
|
|
1489
1707
|
}
|
|
1490
1708
|
});
|
|
1491
1709
|
//#endregion
|
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.8.1
|
|
3
|
+
"version": "2026.8.1",
|
|
4
4
|
"description": "OpenClaw sandbox backend for the NVIDIA OpenShell CLI with mirrored local workspaces and SSH command execution.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"minHostVersion": ">=2026.5.12-beta.1"
|
|
22
22
|
},
|
|
23
23
|
"compat": {
|
|
24
|
-
"pluginApi": ">=2026.8.1
|
|
24
|
+
"pluginApi": ">=2026.8.1"
|
|
25
25
|
},
|
|
26
26
|
"build": {
|
|
27
|
-
"openclawVersion": "2026.8.1
|
|
27
|
+
"openclawVersion": "2026.8.1",
|
|
28
28
|
"bundledDist": false
|
|
29
29
|
},
|
|
30
30
|
"release": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"README.md"
|
|
42
42
|
],
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"openclaw": ">=2026.8.1
|
|
44
|
+
"openclaw": ">=2026.8.1"
|
|
45
45
|
},
|
|
46
46
|
"peerDependenciesMeta": {
|
|
47
47
|
"openclaw": {
|