@openclaw/openshell-sandbox 2026.6.11-beta.2 → 2026.7.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/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
2
- import { buildValidatedExecRemoteCommand, createRemoteShellSandboxFsBridge, createSshSandboxSessionFromConfigText, createWritableRenameTargetResolver, disposeSshSandboxSession, registerSandboxBackend, resolvePreferredOpenClawTmpDir, runPluginCommandWithTimeout, runSshSandboxCommand, sanitizeEnvVars, shellEscape, withTempWorkspace } from "openclaw/plugin-sdk/sandbox";
2
+ import { buildRemoteWorkdirValidationCommand, buildValidatedExecRemoteCommand, createRemoteShellSandboxFsBridge, createSshSandboxSessionFromConfigText, createWritableRenameTargetResolver, disposeSshSandboxSession, registerSandboxBackend, resolvePreferredOpenClawTmpDir, runPluginCommandWithTimeout, runSshSandboxCommand, sanitizeEnvVars, shellEscape, withTempWorkspace } from "openclaw/plugin-sdk/sandbox";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -8,7 +8,7 @@ import { formatPluginConfigIssue, mapPluginConfigIssues } from "openclaw/plugin-
8
8
  import { MAX_TIMER_TIMEOUT_SECONDS } from "openclaw/plugin-sdk/number-runtime";
9
9
  import { z } from "zod";
10
10
  import { root } from "openclaw/plugin-sdk/file-access-runtime";
11
- import { isPathInside, movePathWithCopyFallback } from "openclaw/plugin-sdk/security-runtime";
11
+ import { FsSafeError, isPathInside, movePathWithCopyFallback } from "openclaw/plugin-sdk/security-runtime";
12
12
  //#region extensions/openshell/src/cli.ts
13
13
  function resolveOpenShellCommand(command) {
14
14
  return command;
@@ -153,97 +153,6 @@ function resolveOpenShellPluginConfig(value) {
153
153
  };
154
154
  }
155
155
  //#endregion
156
- //#region extensions/openshell/src/mirror.ts
157
- const DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS = [
158
- "hooks",
159
- "git-hooks",
160
- ".git"
161
- ];
162
- const COPY_TREE_FS_CONCURRENCY = 16;
163
- function createExcludeMatcher(excludeDirs) {
164
- const excluded = new Set((excludeDirs ?? []).map((d) => normalizeLowercaseStringOrEmpty(d)));
165
- return (name) => excluded.has(normalizeLowercaseStringOrEmpty(name));
166
- }
167
- function createConcurrencyLimiter(limit) {
168
- let active = 0;
169
- const queue = [];
170
- const release = () => {
171
- active -= 1;
172
- queue.shift()?.();
173
- };
174
- return async (task) => {
175
- if (active >= limit) await new Promise((resolve) => {
176
- queue.push(resolve);
177
- });
178
- active += 1;
179
- try {
180
- return await task();
181
- } finally {
182
- release();
183
- }
184
- };
185
- }
186
- const runLimitedFs = createConcurrencyLimiter(COPY_TREE_FS_CONCURRENCY);
187
- async function lstatIfExists(targetPath) {
188
- return await runLimitedFs(async () => await fs.lstat(targetPath)).catch(() => null);
189
- }
190
- async function copyTreeWithoutSymlinks(params) {
191
- const stats = await runLimitedFs(async () => await fs.lstat(params.sourcePath));
192
- if (stats.isSymbolicLink()) return;
193
- const targetStats = await lstatIfExists(params.targetPath);
194
- if (params.preserveTargetSymlinks && targetStats?.isSymbolicLink()) return;
195
- if (stats.isDirectory()) {
196
- await runLimitedFs(async () => await fs.mkdir(params.targetPath, { recursive: true }));
197
- const entries = await runLimitedFs(async () => await fs.readdir(params.sourcePath));
198
- await Promise.all(entries.map(async (entry) => {
199
- await copyTreeWithoutSymlinks({
200
- sourcePath: path.join(params.sourcePath, entry),
201
- targetPath: path.join(params.targetPath, entry),
202
- preserveTargetSymlinks: params.preserveTargetSymlinks
203
- });
204
- }));
205
- return;
206
- }
207
- if (stats.isFile()) {
208
- await runLimitedFs(async () => await fs.mkdir(path.dirname(params.targetPath), { recursive: true }));
209
- await runLimitedFs(async () => await fs.copyFile(params.sourcePath, params.targetPath));
210
- }
211
- }
212
- async function replaceDirectoryContents(params) {
213
- const isExcluded = createExcludeMatcher(params.excludeDirs);
214
- await fs.mkdir(params.targetDir, { recursive: true });
215
- const existing = await fs.readdir(params.targetDir);
216
- await Promise.all(existing.filter((entry) => !isExcluded(entry)).map(async (entry) => {
217
- const targetPath = path.join(params.targetDir, entry);
218
- if ((await lstatIfExists(targetPath))?.isSymbolicLink()) return;
219
- await runLimitedFs(async () => await fs.rm(targetPath, {
220
- recursive: true,
221
- force: true
222
- }));
223
- }));
224
- const sourceEntries = await fs.readdir(params.sourceDir);
225
- for (const entry of sourceEntries) {
226
- if (isExcluded(entry)) continue;
227
- await copyTreeWithoutSymlinks({
228
- sourcePath: path.join(params.sourceDir, entry),
229
- targetPath: path.join(params.targetDir, entry),
230
- preserveTargetSymlinks: true
231
- });
232
- }
233
- }
234
- async function stageDirectoryContents(params) {
235
- const isExcluded = createExcludeMatcher(params.excludeDirs);
236
- await fs.mkdir(params.targetDir, { recursive: true });
237
- const sourceEntries = await fs.readdir(params.sourceDir);
238
- for (const entry of sourceEntries) {
239
- if (isExcluded(entry)) continue;
240
- await copyTreeWithoutSymlinks({
241
- sourcePath: path.join(params.sourceDir, entry),
242
- targetPath: path.join(params.targetDir, entry)
243
- });
244
- }
245
- }
246
- //#endregion
247
156
  //#region extensions/openshell/src/fs-bridge.ts
248
157
  const MATERIALIZED_SKILLS_CONTAINER_PARTS = [
249
158
  ".openclaw",
@@ -313,7 +222,10 @@ var OpenShellFsBridge = class {
313
222
  allowFinalSymlinkForUnlink: false
314
223
  });
315
224
  await this.backend.mkdirpRemotePath(target.containerPath, params.signal);
316
- await fs.mkdir(hostPath, { recursive: true });
225
+ await mkdirLocalRootPath({
226
+ hostPath,
227
+ target
228
+ });
317
229
  }
318
230
  async remove(params) {
319
231
  const target = this.resolveTarget(params);
@@ -330,9 +242,11 @@ var OpenShellFsBridge = class {
330
242
  signal: params.signal,
331
243
  ignoreMissing: params.force !== false
332
244
  });
333
- await fs.rm(hostPath, {
334
- recursive: params.recursive ?? false,
335
- force: params.force !== false
245
+ await removeLocalRootPath({
246
+ force: params.force,
247
+ hostPath,
248
+ recursive: params.recursive,
249
+ target
336
250
  });
337
251
  }
338
252
  async rename(params) {
@@ -351,11 +265,19 @@ var OpenShellFsBridge = class {
351
265
  allowMissingLeaf: true,
352
266
  allowFinalSymlinkForUnlink: false
353
267
  });
268
+ await assertRenameSourceSupported(fromHostPath);
269
+ if (from.mountHostRoot !== to.mountHostRoot) throw new Error("OpenShell cross-root mirror renames require pinned fs-safe support");
270
+ await assertSameDeviceRenameSupported({
271
+ fromHostPath,
272
+ root: from.mountHostRoot,
273
+ toHostPath
274
+ });
354
275
  await this.backend.renameRemotePath(from.containerPath, to.containerPath, params.signal);
355
- await fs.mkdir(path.dirname(toHostPath), { recursive: true });
356
- await movePathWithCopyFallback({
357
- from: fromHostPath,
358
- to: toHostPath
276
+ await moveLocalRootPath({
277
+ from,
278
+ fromHostPath,
279
+ to,
280
+ toHostPath
359
281
  });
360
282
  }
361
283
  async stat(params) {
@@ -469,6 +391,92 @@ var OpenShellFsBridge = class {
469
391
  throw new Error(`Path escapes sandbox root (${workspaceRoot}): ${params.filePath}`);
470
392
  }
471
393
  };
394
+ async function mkdirLocalRootPath(params) {
395
+ const relativePath = relativeToRoot(params.target, params.hostPath);
396
+ if (!relativePath) return;
397
+ await (await root(params.target.mountHostRoot)).mkdir(relativePath);
398
+ }
399
+ async function removeLocalRootPath(params) {
400
+ const root$1 = await root(params.target.mountHostRoot);
401
+ const relativePath = relativeToRoot(params.target, params.hostPath);
402
+ try {
403
+ if (params.force === false) await fs.lstat(params.hostPath);
404
+ if (params.recursive) {
405
+ if ((await fs.lstat(params.hostPath).catch((err) => {
406
+ if (isNotFoundError(err)) return null;
407
+ throw err;
408
+ }))?.isSymbolicLink()) {
409
+ await root$1.remove(relativePath);
410
+ return;
411
+ }
412
+ await removeRootTree(root$1, relativePath);
413
+ return;
414
+ }
415
+ await root$1.remove(relativePath);
416
+ } catch (err) {
417
+ if (params.force !== false && isNotFoundError(err)) return;
418
+ throw err;
419
+ }
420
+ }
421
+ async function removeRootTree(root, relativePath, knownStats) {
422
+ const stats = knownStats ?? await root.stat(relativePath);
423
+ if (stats.isDirectory && !stats.isSymbolicLink) {
424
+ const entries = await root.list(relativePath, { withFileTypes: true });
425
+ for (const entry of entries) await removeRootTree(root, path.join(relativePath, entry.name), entry);
426
+ if (!relativePath) return;
427
+ }
428
+ await root.remove(relativePath);
429
+ }
430
+ async function moveLocalRootPath(params) {
431
+ const root$2 = await root(params.from.mountHostRoot);
432
+ const fromRelativePath = relativeToRoot(params.from, params.fromHostPath);
433
+ const toRelativePath = relativeToRoot(params.to, params.toHostPath);
434
+ await mkdirParentPath(root$2, toRelativePath);
435
+ await root$2.move(fromRelativePath, toRelativePath, { overwrite: true });
436
+ }
437
+ async function mkdirParentPath(root, relativePath) {
438
+ const parentPath = path.dirname(relativePath);
439
+ if (parentPath === "." || parentPath === "") return;
440
+ await root.mkdir(parentPath);
441
+ }
442
+ function relativeToRoot(target, hostPath) {
443
+ const relativePath = path.relative(target.mountHostRoot, hostPath);
444
+ return relativePath === "." ? "" : relativePath;
445
+ }
446
+ async function assertRenameSourceSupported(fromHostPath) {
447
+ const stats = await fs.lstat(fromHostPath);
448
+ if (stats.isSymbolicLink()) throw new Error("Sandbox symlink rename sources are not supported by the local mirror bridge");
449
+ if (stats.isFile() && stats.nlink > 1) throw new Error("Sandbox hardlinked rename sources are not supported by the local mirror bridge");
450
+ }
451
+ async function assertSameDeviceRenameSupported(params) {
452
+ const sourceStats = await fs.lstat(params.fromHostPath);
453
+ const destinationParentStats = await nearestExistingDirectoryStats({
454
+ root: params.root,
455
+ targetPath: path.dirname(params.toHostPath)
456
+ });
457
+ if (sourceStats.dev !== destinationParentStats.dev) throw new Error("OpenShell cross-device mirror renames require pinned fs-safe support");
458
+ }
459
+ async function nearestExistingDirectoryStats(params) {
460
+ const rootPath = path.resolve(params.root);
461
+ let cursor = path.resolve(params.targetPath);
462
+ while (isPathInside(rootPath, cursor)) {
463
+ const stats = await fs.lstat(cursor).catch((err) => {
464
+ if (isNotFoundError(err)) return null;
465
+ throw err;
466
+ });
467
+ if (stats) {
468
+ if (!stats.isDirectory()) throw new Error(`Sandbox rename destination parent is not a directory: ${cursor}`);
469
+ return stats;
470
+ }
471
+ const next = path.dirname(cursor);
472
+ if (next === cursor) break;
473
+ cursor = next;
474
+ }
475
+ return await fs.lstat(rootPath);
476
+ }
477
+ function isNotFoundError(err) {
478
+ return err instanceof FsSafeError && err.code === "not-found" || typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
479
+ }
472
480
  function resolveProtectedSkillTarget(params) {
473
481
  const relativeRoot = path.posix.join(...MATERIALIZED_SKILLS_CONTAINER_PARTS);
474
482
  const normalizedInput = path.posix.normalize(params.input.replace(/\\/g, "/"));
@@ -503,7 +511,9 @@ function resolveProtectedSkillShadowTarget(params) {
503
511
  }
504
512
  async function assertLocalPathSafety(params) {
505
513
  if (!params.target.hostPath) throw new Error(`Missing local host path for ${params.target.containerPath}`);
506
- if (!isPathInside(await fs.realpath(params.root).catch(() => path.resolve(params.root)), await resolveCanonicalCandidate(params.target.hostPath))) throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${params.target.containerPath}`);
514
+ const canonicalRoot = await fs.realpath(params.root).catch(() => path.resolve(params.root));
515
+ const targetStats = await fs.lstat(params.target.hostPath).catch(() => null);
516
+ if (!isPathInside(canonicalRoot, params.allowFinalSymlinkForUnlink && targetStats?.isSymbolicLink() ? path.resolve(canonicalRoot, path.relative(params.root, params.target.hostPath)) : await resolveCanonicalCandidate(params.target.hostPath))) throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${params.target.containerPath}`);
507
517
  const relative = path.relative(params.root, params.target.hostPath);
508
518
  const segments = relative.split(path.sep).filter(Boolean).slice(0, Math.max(0, relative.split(path.sep).filter(Boolean).length));
509
519
  let cursor = params.root;
@@ -533,8 +543,109 @@ async function resolveCanonicalCandidate(targetPath) {
533
543
  }
534
544
  }
535
545
  //#endregion
546
+ //#region extensions/openshell/src/mirror.ts
547
+ const DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS = [
548
+ "hooks",
549
+ "git-hooks",
550
+ ".git"
551
+ ];
552
+ const COPY_TREE_FS_CONCURRENCY = 16;
553
+ function createExcludeMatcher(excludeDirs) {
554
+ const excluded = new Set((excludeDirs ?? []).map((d) => normalizeLowercaseStringOrEmpty(d)));
555
+ return (name) => excluded.has(normalizeLowercaseStringOrEmpty(name));
556
+ }
557
+ function createConcurrencyLimiter(limit) {
558
+ let active = 0;
559
+ const queue = [];
560
+ const release = () => {
561
+ active -= 1;
562
+ queue.shift()?.();
563
+ };
564
+ return async (task) => {
565
+ if (active >= limit) await new Promise((resolve) => {
566
+ queue.push(resolve);
567
+ });
568
+ active += 1;
569
+ try {
570
+ return await task();
571
+ } finally {
572
+ release();
573
+ }
574
+ };
575
+ }
576
+ const runLimitedFs = createConcurrencyLimiter(COPY_TREE_FS_CONCURRENCY);
577
+ async function lstatIfExists(targetPath) {
578
+ return await runLimitedFs(async () => await fs.lstat(targetPath)).catch(() => null);
579
+ }
580
+ async function copyTreeWithoutSymlinks(params) {
581
+ const stats = await runLimitedFs(async () => await fs.lstat(params.sourcePath));
582
+ if (stats.isSymbolicLink()) return;
583
+ const targetStats = await lstatIfExists(params.targetPath);
584
+ if (params.preserveTargetSymlinks && targetStats?.isSymbolicLink()) return;
585
+ if (stats.isDirectory()) {
586
+ await runLimitedFs(async () => await fs.mkdir(params.targetPath, { recursive: true }));
587
+ const entries = await runLimitedFs(async () => await fs.readdir(params.sourcePath));
588
+ await Promise.all(entries.map(async (entry) => {
589
+ await copyTreeWithoutSymlinks({
590
+ sourcePath: path.join(params.sourcePath, entry),
591
+ targetPath: path.join(params.targetPath, entry),
592
+ preserveTargetSymlinks: params.preserveTargetSymlinks
593
+ });
594
+ }));
595
+ return;
596
+ }
597
+ if (stats.isFile()) {
598
+ await runLimitedFs(async () => await fs.mkdir(path.dirname(params.targetPath), { recursive: true }));
599
+ await runLimitedFs(async () => await fs.copyFile(params.sourcePath, params.targetPath));
600
+ }
601
+ }
602
+ async function replaceDirectoryContents(params) {
603
+ const isExcluded = createExcludeMatcher(params.excludeDirs);
604
+ await fs.mkdir(params.targetDir, { recursive: true });
605
+ const existing = await fs.readdir(params.targetDir);
606
+ await Promise.all(existing.filter((entry) => !isExcluded(entry)).map(async (entry) => {
607
+ const targetPath = path.join(params.targetDir, entry);
608
+ if ((await lstatIfExists(targetPath))?.isSymbolicLink()) return;
609
+ await runLimitedFs(async () => await fs.rm(targetPath, {
610
+ recursive: true,
611
+ force: true
612
+ }));
613
+ }));
614
+ const sourceEntries = await fs.readdir(params.sourceDir);
615
+ for (const entry of sourceEntries) {
616
+ if (isExcluded(entry)) continue;
617
+ await copyTreeWithoutSymlinks({
618
+ sourcePath: path.join(params.sourceDir, entry),
619
+ targetPath: path.join(params.targetDir, entry),
620
+ preserveTargetSymlinks: true
621
+ });
622
+ }
623
+ }
624
+ async function stageDirectoryContents(params) {
625
+ const isExcluded = createExcludeMatcher(params.excludeDirs);
626
+ await fs.mkdir(params.targetDir, { recursive: true });
627
+ const sourceEntries = await fs.readdir(params.sourceDir);
628
+ for (const entry of sourceEntries) {
629
+ if (isExcluded(entry)) continue;
630
+ await copyTreeWithoutSymlinks({
631
+ sourcePath: path.join(params.sourceDir, entry),
632
+ targetPath: path.join(params.targetDir, entry)
633
+ });
634
+ }
635
+ }
636
+ //#endregion
536
637
  //#region extensions/openshell/src/backend.ts
537
638
  const MATERIALIZED_SKILLS_REMOTE_PARTS = [".openclaw", "sandbox-skills"];
639
+ function buildOpenShellDirectoryUploadArgs(params) {
640
+ return [
641
+ "sandbox",
642
+ "upload",
643
+ "--no-git-ignore",
644
+ params.sandboxName,
645
+ params.localPath,
646
+ normalizeRemotePath(params.remotePath)
647
+ ];
648
+ }
538
649
  const PINNED_REMOTE_PATH_MUTATION_SCRIPT = [
539
650
  "set -eu",
540
651
  "die() { echo \"$1\" >&2; exit 1; }",
@@ -744,6 +855,10 @@ async function createOpenShellSandboxBackend(params) {
744
855
  mode: params.pluginConfig.mode,
745
856
  configLabel: params.pluginConfig.from,
746
857
  configLabelKind: "Source",
858
+ workdirValidation: "backend",
859
+ validateWorkdir: async (workdir) => await impl.validateWorkdir(workdir),
860
+ discardPreparedWorkdir: (workdir) => impl.discardPreparedWorkdir(workdir),
861
+ workdirRoots: [params.pluginConfig.remoteWorkspaceDir, params.pluginConfig.remoteAgentWorkspaceDir],
747
862
  buildExecSpec: async ({ command, workdir, env, usePty }) => {
748
863
  const pending = await impl.prepareExec({
749
864
  command,
@@ -782,6 +897,7 @@ var OpenShellSandboxBackendImpl = class {
782
897
  constructor(params) {
783
898
  this.params = params;
784
899
  this.ensurePromise = null;
900
+ this.preparedRemoteWorkspaceForNextExec = null;
785
901
  this.remoteSeedPending = false;
786
902
  }
787
903
  asHandle() {
@@ -794,6 +910,10 @@ var OpenShellSandboxBackendImpl = class {
794
910
  mode: this.params.execContext.config.mode,
795
911
  configLabel: this.params.execContext.config.from,
796
912
  configLabelKind: "Source",
913
+ workdirValidation: "backend",
914
+ validateWorkdir: async (workdir) => await this.validateWorkdir(workdir),
915
+ discardPreparedWorkdir: (workdir) => this.discardPreparedWorkdir(workdir),
916
+ workdirRoots: [this.params.remoteWorkspaceDir, this.params.remoteAgentWorkspaceDir],
797
917
  remoteWorkspaceDir: this.params.remoteWorkspaceDir,
798
918
  remoteAgentWorkspaceDir: this.params.remoteAgentWorkspaceDir,
799
919
  buildExecSpec: async ({ command, workdir, env, usePty }) => {
@@ -829,14 +949,14 @@ var OpenShellSandboxBackendImpl = class {
829
949
  };
830
950
  }
831
951
  async prepareExec(params) {
952
+ const remoteWorkdir = params.workdir ?? this.params.remoteWorkspaceDir;
953
+ const preparedWorkspace = this.consumePreparedRemoteWorkspaceForNextExec(remoteWorkdir);
832
954
  const remoteCommand = buildValidatedExecRemoteCommand({
833
955
  command: params.command,
834
- workdir: params.workdir ?? this.params.remoteWorkspaceDir,
956
+ workdir: remoteWorkdir,
835
957
  env: params.env
836
958
  });
837
- await this.ensureSandboxExists();
838
- if (this.params.execContext.config.mode === "mirror") await this.syncWorkspaceToRemote();
839
- else if (!await this.maybeSeedRemoteWorkspace()) await this.syncSkillsWorkspaceToRemote();
959
+ await (preparedWorkspace ?? this.prepareRemoteWorkspaceForExec());
840
960
  const sshSession = await createOpenShellSshSession({ context: this.params.execContext });
841
961
  return {
842
962
  argv: [
@@ -860,6 +980,67 @@ var OpenShellSandboxBackendImpl = class {
860
980
  token: { sshSession }
861
981
  };
862
982
  }
983
+ async validateWorkdir(workdir) {
984
+ const preparedWorkspace = this.prepareRemoteWorkspaceForExec();
985
+ const reusablePreparation = {
986
+ workdir,
987
+ promise: preparedWorkspace
988
+ };
989
+ this.preparedRemoteWorkspaceForNextExec = reusablePreparation;
990
+ try {
991
+ await preparedWorkspace;
992
+ const sshSession = await createOpenShellSshSession({ context: this.params.execContext });
993
+ try {
994
+ const result = await runSshSandboxCommand({
995
+ session: sshSession,
996
+ remoteCommand: buildRemoteWorkdirValidationCommand({
997
+ workdir,
998
+ root: this.resolveWorkdirValidationRoot(workdir)
999
+ }),
1000
+ allowFailure: true
1001
+ });
1002
+ const resolvedWorkdir = result.code === 0 ? result.stdout.toString("utf8").trim() : "";
1003
+ if (this.preparedRemoteWorkspaceForNextExec === reusablePreparation) this.preparedRemoteWorkspaceForNextExec = resolvedWorkdir ? {
1004
+ workdir: resolvedWorkdir,
1005
+ promise: preparedWorkspace
1006
+ } : null;
1007
+ return resolvedWorkdir || null;
1008
+ } finally {
1009
+ await disposeSshSandboxSession(sshSession);
1010
+ }
1011
+ } catch (error) {
1012
+ if (this.preparedRemoteWorkspaceForNextExec === reusablePreparation) this.preparedRemoteWorkspaceForNextExec = null;
1013
+ throw error;
1014
+ }
1015
+ }
1016
+ resolveWorkdirValidationRoot(workdir) {
1017
+ try {
1018
+ const normalized = normalizeRemotePath(workdir);
1019
+ return [normalizeRemotePath(this.params.remoteAgentWorkspaceDir), normalizeRemotePath(this.params.remoteWorkspaceDir)].toSorted((a, b) => b.length - a.length).find((root) => isRemotePathInside(root, normalized)) ?? this.params.remoteWorkspaceDir;
1020
+ } catch {
1021
+ return this.params.remoteWorkspaceDir;
1022
+ }
1023
+ }
1024
+ consumePreparedRemoteWorkspaceForNextExec(workdir) {
1025
+ const preparedWorkspace = this.preparedRemoteWorkspaceForNextExec;
1026
+ if (!preparedWorkspace || preparedWorkspace.workdir !== workdir) {
1027
+ this.preparedRemoteWorkspaceForNextExec = null;
1028
+ return null;
1029
+ }
1030
+ this.preparedRemoteWorkspaceForNextExec = null;
1031
+ return preparedWorkspace.promise;
1032
+ }
1033
+ discardPreparedWorkdir(workdir) {
1034
+ if (this.preparedRemoteWorkspaceForNextExec?.workdir === workdir) this.preparedRemoteWorkspaceForNextExec = null;
1035
+ }
1036
+ async prepareRemoteWorkspaceForExec() {
1037
+ await this.ensureSandboxExists();
1038
+ if (this.params.execContext.config.mode === "mirror") {
1039
+ await this.syncWorkspaceToRemote();
1040
+ return;
1041
+ }
1042
+ if (!await this.maybeSeedRemoteWorkspace()) await this.syncSkillsWorkspaceToRemote();
1043
+ }
863
1044
  async finalizeExec(token) {
864
1045
  try {
865
1046
  if (this.params.execContext.config.mode === "mirror") await this.syncWorkspaceFromRemote();
@@ -1109,23 +1290,25 @@ var OpenShellSandboxBackendImpl = class {
1109
1290
  rootDir: resolveOpenShellTmpRoot(),
1110
1291
  prefix: "openclaw-openshell-upload-"
1111
1292
  }, async ({ dir: tmpDir }) => {
1293
+ const remoteRootName = path.posix.basename(normalizeRemotePath(remotePath));
1294
+ const stagedRoot = path.join(tmpDir, remoteRootName);
1112
1295
  await stageDirectoryContents({
1113
1296
  sourceDir: localPath,
1114
- targetDir: tmpDir
1297
+ targetDir: stagedRoot
1115
1298
  });
1116
- const result = await runOpenShellCli({
1117
- context: this.params.execContext,
1118
- args: [
1119
- "sandbox",
1120
- "upload",
1121
- "--no-git-ignore",
1122
- this.params.execContext.sandboxName,
1123
- tmpDir,
1124
- remotePath
1125
- ],
1126
- cwd: this.params.createParams.workspaceDir
1127
- });
1128
- if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox upload failed");
1299
+ const stagedEntries = (await fs.readdir(stagedRoot)).toSorted();
1300
+ for (const entry of stagedEntries) {
1301
+ const result = await runOpenShellCli({
1302
+ context: this.params.execContext,
1303
+ args: buildOpenShellDirectoryUploadArgs({
1304
+ sandboxName: this.params.execContext.sandboxName,
1305
+ localPath: path.join(stagedRoot, entry),
1306
+ remotePath
1307
+ }),
1308
+ cwd: this.params.createParams.workspaceDir
1309
+ });
1310
+ if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox upload failed");
1311
+ }
1129
1312
  });
1130
1313
  }
1131
1314
  async maybeSeedRemoteWorkspace() {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@openclaw/openshell-sandbox",
3
- "version": "2026.6.11-beta.2",
3
+ "version": "2026.7.1-beta.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@openclaw/openshell-sandbox",
9
- "version": "2026.6.11-beta.2",
9
+ "version": "2026.7.1-beta.1",
10
10
  "dependencies": {
11
11
  "zod": "4.4.3"
12
12
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/openshell-sandbox",
3
- "version": "2026.6.11-beta.2",
3
+ "version": "2026.7.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",
@@ -20,10 +20,10 @@
20
20
  "minHostVersion": ">=2026.5.12-beta.1"
21
21
  },
22
22
  "compat": {
23
- "pluginApi": ">=2026.6.11-beta.2"
23
+ "pluginApi": ">=2026.7.1-beta.1"
24
24
  },
25
25
  "build": {
26
- "openclawVersion": "2026.6.11-beta.2",
26
+ "openclawVersion": "2026.7.1-beta.1",
27
27
  "bundledDist": false
28
28
  },
29
29
  "release": {
@@ -41,7 +41,7 @@
41
41
  "README.md"
42
42
  ],
43
43
  "peerDependencies": {
44
- "openclaw": ">=2026.6.11-beta.2"
44
+ "openclaw": ">=2026.7.1-beta.1"
45
45
  },
46
46
  "peerDependenciesMeta": {
47
47
  "openclaw": {