@openclaw/openshell-sandbox 2026.10.2 → 2026.10.5-beta.2

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.
Files changed (91) hide show
  1. package/dist/index.js +69 -111
  2. package/node_modules/@openclaw/fs-safe/CHANGELOG.md +35 -0
  3. package/node_modules/@openclaw/fs-safe/README.md +1 -1
  4. package/node_modules/@openclaw/fs-safe/dist/advanced.d.ts +1 -1
  5. package/node_modules/@openclaw/fs-safe/dist/advanced.js +1 -1
  6. package/node_modules/@openclaw/fs-safe/dist/archive-durability.js +1 -1
  7. package/node_modules/@openclaw/fs-safe/dist/archive-merge.js +4 -4
  8. package/node_modules/@openclaw/fs-safe/dist/archive-parser.wasm +0 -0
  9. package/node_modules/@openclaw/fs-safe/dist/archive-read.js +5 -4
  10. package/node_modules/@openclaw/fs-safe/dist/archive-staging.d.ts +1 -1
  11. package/node_modules/@openclaw/fs-safe/dist/archive-staging.js +22 -38
  12. package/node_modules/@openclaw/fs-safe/dist/copy-path-admission.d.ts +17 -0
  13. package/node_modules/@openclaw/fs-safe/dist/copy-path-admission.js +117 -0
  14. package/node_modules/@openclaw/fs-safe/dist/copy-root-file-sync.d.ts +8 -5
  15. package/node_modules/@openclaw/fs-safe/dist/copy-root-file-sync.js +27 -50
  16. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-acquire.d.ts +3 -0
  17. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-acquire.js +57 -11
  18. package/node_modules/@openclaw/fs-safe/dist/guarded-mutation.js +5 -5
  19. package/node_modules/@openclaw/fs-safe/dist/guest-native-python.js +52 -5
  20. package/node_modules/@openclaw/fs-safe/dist/guest.js +1 -1
  21. package/node_modules/@openclaw/fs-safe/dist/native-binding.d.ts +1 -0
  22. package/node_modules/@openclaw/fs-safe/dist/native-operations.d.ts +0 -1
  23. package/node_modules/@openclaw/fs-safe/dist/native-operations.js +22 -26
  24. package/node_modules/@openclaw/fs-safe/dist/native-pinned-write-windows.js +5 -1
  25. package/node_modules/@openclaw/fs-safe/dist/native-staged-file.js +4 -1
  26. package/node_modules/@openclaw/fs-safe/dist/permission-exec.js +2 -1
  27. package/node_modules/@openclaw/fs-safe/dist/pinned-write-types.d.ts +6 -1
  28. package/node_modules/@openclaw/fs-safe/dist/private-temp-workspace.js +2 -2
  29. package/node_modules/@openclaw/fs-safe/dist/publish-file.js +34 -7
  30. package/node_modules/@openclaw/fs-safe/dist/replace-file-copy-fallback.js +1 -4
  31. package/node_modules/@openclaw/fs-safe/dist/replace-file-descriptor.js +6 -0
  32. package/node_modules/@openclaw/fs-safe/dist/root-create-input.d.ts +15 -1
  33. package/node_modules/@openclaw/fs-safe/dist/root-create-input.js +4 -0
  34. package/node_modules/@openclaw/fs-safe/dist/root-directory-entry.js +1 -1
  35. package/node_modules/@openclaw/fs-safe/dist/root-directory-list.d.ts +0 -1
  36. package/node_modules/@openclaw/fs-safe/dist/root-directory-list.js +6 -12
  37. package/node_modules/@openclaw/fs-safe/dist/root-impl.js +15 -15
  38. package/node_modules/@openclaw/fs-safe/dist/root-paths.js +1 -1
  39. package/node_modules/@openclaw/fs-safe/dist/root-walk.js +4 -4
  40. package/node_modules/@openclaw/fs-safe/dist/root-write-verification.d.ts +10 -1
  41. package/node_modules/@openclaw/fs-safe/dist/root-write-verification.js +20 -0
  42. package/node_modules/@openclaw/fs-safe/dist/secure-temp-dir.js +19 -7
  43. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-acquire.js +25 -3
  44. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-admission.d.ts +2 -0
  45. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-retained.d.ts +14 -0
  46. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-retained.js +187 -0
  47. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock.js +15 -1
  48. package/node_modules/@openclaw/fs-safe/dist/temp-directory-diagnostics.d.ts +10 -0
  49. package/node_modules/@openclaw/fs-safe/dist/temp-directory-diagnostics.js +29 -0
  50. package/node_modules/@openclaw/fs-safe/dist/temp-target.js +1 -1
  51. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-admission.js +6 -6
  52. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-child-admission.d.ts +4 -4
  53. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-child-admission.js +17 -16
  54. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-descriptor.js +20 -28
  55. package/node_modules/@openclaw/fs-safe/dist/walk.js +2 -13
  56. package/node_modules/@openclaw/fs-safe/dist/watch-native.d.ts +2 -2
  57. package/node_modules/@openclaw/fs-safe/dist/watch-native.js +3 -1
  58. package/node_modules/@openclaw/fs-safe/dist/watch-rescan.js +3 -9
  59. package/node_modules/@openclaw/fs-safe/dist/watch.js +2 -3
  60. package/node_modules/@openclaw/fs-safe/docs/advanced.md +1 -1
  61. package/node_modules/@openclaw/fs-safe/docs/archive.md +3 -0
  62. package/node_modules/@openclaw/fs-safe/docs/atomic.md +11 -3
  63. package/node_modules/@openclaw/fs-safe/docs/contributing.md +1 -1
  64. package/node_modules/@openclaw/fs-safe/docs/copy.md +48 -0
  65. package/node_modules/@openclaw/fs-safe/docs/guest.md +21 -4
  66. package/node_modules/@openclaw/fs-safe/docs/path-scope.md +4 -0
  67. package/node_modules/@openclaw/fs-safe/docs/sidecar-lock.md +46 -8
  68. package/node_modules/@openclaw/fs-safe/docs/temp.md +12 -0
  69. package/node_modules/@openclaw/fs-safe/docs/watch.md +3 -2
  70. package/node_modules/@openclaw/fs-safe/package.json +15 -15
  71. package/node_modules/@openclaw/fs-safe-darwin-arm64/fs-safe-native.node +0 -0
  72. package/node_modules/@openclaw/fs-safe-darwin-arm64/package.json +1 -1
  73. package/node_modules/@openclaw/fs-safe-darwin-x64/fs-safe-native.node +0 -0
  74. package/node_modules/@openclaw/fs-safe-darwin-x64/package.json +1 -1
  75. package/node_modules/@openclaw/fs-safe-linux-arm64-gnu/fs-safe-native.node +0 -0
  76. package/node_modules/@openclaw/fs-safe-linux-arm64-gnu/package.json +1 -1
  77. package/node_modules/@openclaw/fs-safe-linux-arm64-musl/fs-safe-native.node +0 -0
  78. package/node_modules/@openclaw/fs-safe-linux-arm64-musl/package.json +1 -1
  79. package/node_modules/@openclaw/fs-safe-linux-x64-gnu/fs-safe-native.node +0 -0
  80. package/node_modules/@openclaw/fs-safe-linux-x64-gnu/package.json +1 -1
  81. package/node_modules/@openclaw/fs-safe-linux-x64-musl/fs-safe-native.node +0 -0
  82. package/node_modules/@openclaw/fs-safe-linux-x64-musl/package.json +1 -1
  83. package/node_modules/@openclaw/fs-safe-win32-x64-msvc/fs-safe-native.node +0 -0
  84. package/node_modules/@openclaw/fs-safe-win32-x64-msvc/package.json +1 -1
  85. package/package.json +5 -5
  86. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-arbitration.d.ts +0 -17
  87. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-arbitration.js +0 -66
  88. package/node_modules/@openclaw/fs-safe/dist/publish-file-native.d.ts +0 -4
  89. package/node_modules/@openclaw/fs-safe/dist/publish-file-native.js +0 -35
  90. package/node_modules/@openclaw/fs-safe/dist/root-move-preflight.d.ts +0 -7
  91. package/node_modules/@openclaw/fs-safe/dist/root-move-preflight.js +0 -16
package/dist/index.js CHANGED
@@ -189,29 +189,24 @@ var OpenShellFsBridge = class {
189
189
  return entries;
190
190
  }
191
191
  async writeFile(params) {
192
- const target = this.resolveTarget(params);
193
- const hostPath = target.hostPath;
194
- this.ensureWritable(target, "write files");
195
- const buffer = Buffer.isBuffer(params.data) ? params.data : Buffer.from(params.data, params.encoding ?? "utf8");
196
- await (await root(target.mountHostRoot)).write(relativeToRoot(target, hostPath), buffer, {
197
- mkdir: params.mkdir,
198
- mutationSymlinks: "reject"
199
- });
200
- await this.backend.syncLocalPathToRemote(hostPath, target.containerPath);
192
+ await this.writeLocalFile(params, "write");
201
193
  }
202
194
  async createFileExclusive(params) {
195
+ return this.writeLocalFile(params, "create");
196
+ }
197
+ async writeLocalFile(params, method) {
203
198
  const target = this.resolveTarget(params);
204
199
  const hostPath = target.hostPath;
205
- this.ensureWritable(target, "create files");
200
+ this.ensureWritable(target, method === "create" ? "create files" : "write files");
206
201
  const buffer = Buffer.isBuffer(params.data) ? params.data : Buffer.from(params.data, params.encoding ?? "utf8");
207
202
  const root$2 = await root(target.mountHostRoot);
208
203
  try {
209
- await root$2.create(relativeToRoot(target, hostPath), buffer, {
210
- mkdir: params.mkdir !== false,
204
+ await root$2[method](relativeToRoot(target, hostPath), buffer, {
205
+ mkdir: method === "create" ? params.mkdir !== false : params.mkdir,
211
206
  mutationSymlinks: "reject"
212
207
  });
213
208
  } catch (error) {
214
- if (error instanceof FsSafeError && error.code === "already-exists") return "exists";
209
+ if (method === "create" && error instanceof FsSafeError && error.code === "already-exists") return "exists";
215
210
  throw error;
216
211
  }
217
212
  await this.backend.syncLocalPathToRemote(hostPath, target.containerPath);
@@ -366,25 +361,25 @@ var OpenShellFsBridge = class {
366
361
  }
367
362
  const cwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot;
368
363
  const hostPath = path.isAbsolute(input) ? path.resolve(input) : path.resolve(cwd, input);
369
- for (const mount of readOnlyMounts) if (isPathInside(mount.hostPath, hostPath)) {
364
+ for (const mount of [
365
+ ...readOnlyMounts,
366
+ {
367
+ hostPath: workspaceRoot,
368
+ containerPath: workspaceContainerRoot
369
+ },
370
+ ...hasAgentMount ? [{
371
+ hostPath: agentRoot,
372
+ containerPath: agentContainerRoot
373
+ }] : []
374
+ ]) if (isPathInside(mount.hostPath, hostPath)) {
370
375
  const relative = path.relative(mount.hostPath, hostPath).split(path.sep).join(path.posix.sep);
371
- return expectResolvedContainerTarget(resolveContainerTarget(path.posix.join(mount.containerPath, relative)), input);
372
- }
373
- if (isPathInside(workspaceRoot, hostPath)) {
374
- const relative = path.relative(workspaceRoot, hostPath).split(path.sep).join(path.posix.sep);
375
- return expectResolvedContainerTarget(resolveContainerTarget(path.posix.join(workspaceContainerRoot, relative)), input);
376
- }
377
- if (hasAgentMount && isPathInside(agentRoot, hostPath)) {
378
- const relative = path.relative(agentRoot, hostPath).split(path.sep).join(path.posix.sep);
379
- return expectResolvedContainerTarget(resolveContainerTarget(path.posix.join(agentContainerRoot, relative)), input);
376
+ const target = resolveContainerTarget(path.posix.join(mount.containerPath, relative));
377
+ if (!target) throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${input}`);
378
+ return target;
380
379
  }
381
380
  throw new Error(`Path escapes sandbox root (${workspaceRoot}): ${params.filePath}`);
382
381
  }
383
382
  };
384
- function expectResolvedContainerTarget(target, input) {
385
- if (!target) throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${input}`);
386
- return target;
387
- }
388
383
  async function removeLocalRootPath(params) {
389
384
  const root$4 = await root(params.target.mountHostRoot);
390
385
  const relativePath = relativeToRoot(params.target, params.target.hostPath);
@@ -529,16 +524,6 @@ async function stageDirectoryContents(params) {
529
524
  const openShellWorkspaceOperations = new KeyedAsyncQueue();
530
525
  let openShellDetachedCreateSupport;
531
526
  const MATERIALIZED_SKILLS_REMOTE_PARTS = [".openclaw", "sandbox-skills"];
532
- function buildOpenShellDirectoryUploadArgs(params) {
533
- return [
534
- "sandbox",
535
- "upload",
536
- "--no-git-ignore",
537
- params.sandboxName,
538
- params.localPath,
539
- `${normalizeRemotePath(params.remotePath)}/`
540
- ];
541
- }
542
527
  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\"";
543
528
  const OPEN_SHELL_GUEST_MUTATION_PYTHON = `
544
529
  import os, sys
@@ -973,19 +958,27 @@ var OpenShellSandboxBackendImpl = class {
973
958
  target.root,
974
959
  path.posix.dirname(target.relativePath) === "." ? "" : path.posix.dirname(target.relativePath)
975
960
  ] });
961
+ await this.uploadFileToRemote(localPath, remotePath);
962
+ }
963
+ async uploadFileToRemote(localPath, remotePath) {
964
+ await this.runCli([
965
+ "sandbox",
966
+ "upload",
967
+ "--no-git-ignore",
968
+ this.params.execContext.sandboxName,
969
+ localPath,
970
+ remotePath
971
+ ], "openshell sandbox upload failed");
972
+ }
973
+ async runCli(args, failureMessage, timeoutMs) {
976
974
  const result = await runOpenShellCli({
977
975
  context: this.params.execContext,
978
- args: [
979
- "sandbox",
980
- "upload",
981
- "--no-git-ignore",
982
- this.params.execContext.sandboxName,
983
- localPath,
984
- remotePath
985
- ],
986
- cwd: this.params.createParams.workspaceDir
976
+ args,
977
+ cwd: this.params.createParams.workspaceDir,
978
+ ...timeoutMs === void 0 ? {} : { timeoutMs }
987
979
  });
988
- if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox upload failed");
980
+ if (failureMessage && result.code !== 0) throw new Error(result.stderr.trim() || failureMessage);
981
+ return result;
989
982
  }
990
983
  async runPinnedRemotePathMutation(params) {
991
984
  return await this.runRemoteShellScript({
@@ -1034,15 +1027,11 @@ var OpenShellSandboxBackendImpl = class {
1034
1027
  }
1035
1028
  }
1036
1029
  async ensureSandboxExistsInner() {
1037
- const getResult = await runOpenShellCli({
1038
- context: this.params.execContext,
1039
- args: [
1040
- "sandbox",
1041
- "get",
1042
- this.params.execContext.sandboxName
1043
- ],
1044
- cwd: this.params.createParams.workspaceDir
1045
- });
1030
+ const getResult = await this.runCli([
1031
+ "sandbox",
1032
+ "get",
1033
+ this.params.execContext.sandboxName
1034
+ ]);
1046
1035
  if (getResult.code === 0) {
1047
1036
  if (this.params.legacyRuntimeAdopted) {
1048
1037
  const phase = await this.resolveLegacyRuntimePhase();
@@ -1073,13 +1062,7 @@ var OpenShellSandboxBackendImpl = class {
1073
1062
  "infinity"
1074
1063
  ] : ["--", "true"]
1075
1064
  ];
1076
- const createResult = await runOpenShellCli({
1077
- context: this.params.execContext,
1078
- args: createArgs,
1079
- cwd: this.params.createParams.workspaceDir,
1080
- timeoutMs: Math.max(this.params.execContext.config.timeoutMs, 3e5)
1081
- });
1082
- if (createResult.code !== 0) throw new Error(createResult.stderr.trim() || "openshell sandbox create failed");
1065
+ await this.runCli(createArgs, "openshell sandbox create failed", Math.max(this.params.execContext.config.timeoutMs, 3e5));
1083
1066
  this.remoteSeedPending = true;
1084
1067
  }
1085
1068
  async supportsDetachedSandboxCreation() {
@@ -1093,16 +1076,11 @@ var OpenShellSandboxBackendImpl = class {
1093
1076
  let support = openShellDetachedCreateSupport?.key === cliIdentity ? openShellDetachedCreateSupport.promise : void 0;
1094
1077
  if (!support) {
1095
1078
  support = (async () => {
1096
- const result = await runOpenShellCli({
1097
- context: this.params.execContext,
1098
- args: [
1099
- "sandbox",
1100
- "create",
1101
- "--help"
1102
- ],
1103
- cwd: this.params.createParams.workspaceDir
1104
- });
1105
- if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox create capability check failed");
1079
+ const result = await this.runCli([
1080
+ "sandbox",
1081
+ "create",
1082
+ "--help"
1083
+ ], "openshell sandbox create capability check failed");
1106
1084
  return /^\s*--detach(?:\s|$)/mu.test(result.stdout);
1107
1085
  })();
1108
1086
  openShellDetachedCreateSupport = {
@@ -1120,20 +1098,16 @@ var OpenShellSandboxBackendImpl = class {
1120
1098
  async resolveLegacyRuntimePhase() {
1121
1099
  const pageSize = 100;
1122
1100
  for (let offset = 0;; offset += pageSize) {
1123
- const listResult = await runOpenShellCli({
1124
- context: this.params.execContext,
1125
- args: [
1126
- "sandbox",
1127
- "list",
1128
- "--limit",
1129
- String(pageSize),
1130
- "--offset",
1131
- String(offset),
1132
- "--output",
1133
- "json"
1134
- ],
1135
- cwd: this.params.createParams.workspaceDir
1136
- });
1101
+ const listResult = await this.runCli([
1102
+ "sandbox",
1103
+ "list",
1104
+ "--limit",
1105
+ String(pageSize),
1106
+ "--offset",
1107
+ String(offset),
1108
+ "--output",
1109
+ "json"
1110
+ ]);
1137
1111
  if (listResult.code !== 0) throw this.buildLegacyRuntimeUnavailableError(listResult.stderr.trim());
1138
1112
  const page = parseOpenShellSandboxPhasePage(listResult.stdout, this.params.execContext.sandboxName);
1139
1113
  if (!page) throw this.buildLegacyRuntimeUnavailableError("OpenShell returned malformed sandbox lifecycle data.");
@@ -1203,18 +1177,13 @@ var OpenShellSandboxBackendImpl = class {
1203
1177
  rootDir: resolveOpenShellTmpRoot(),
1204
1178
  prefix: "openclaw-openshell-sync-"
1205
1179
  }, async ({ dir: tmpDir }) => {
1206
- const result = await runOpenShellCli({
1207
- context: this.params.execContext,
1208
- args: [
1209
- "sandbox",
1210
- "download",
1211
- this.params.execContext.sandboxName,
1212
- root.remote,
1213
- tmpDir
1214
- ],
1215
- cwd: this.params.createParams.workspaceDir
1216
- });
1217
- if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox download failed");
1180
+ await this.runCli([
1181
+ "sandbox",
1182
+ "download",
1183
+ this.params.execContext.sandboxName,
1184
+ root.remote,
1185
+ tmpDir
1186
+ ], "openshell sandbox download failed");
1218
1187
  const preservedShadows = [];
1219
1188
  const failures = [];
1220
1189
  try {
@@ -1276,18 +1245,7 @@ var OpenShellSandboxBackendImpl = class {
1276
1245
  targetDir: stagedRoot,
1277
1246
  excludeDirs: DEFAULT_OPEN_SHELL_MIRROR_EXCLUDE_DIRS
1278
1247
  });
1279
- for (const entry of (await fsPromises.readdir(stagedRoot)).toSorted()) {
1280
- const result = await runOpenShellCli({
1281
- context: this.params.execContext,
1282
- args: buildOpenShellDirectoryUploadArgs({
1283
- sandboxName: this.params.execContext.sandboxName,
1284
- localPath: path.join(stagedRoot, entry),
1285
- remotePath
1286
- }),
1287
- cwd: this.params.createParams.workspaceDir
1288
- });
1289
- if (result.code !== 0) throw new Error(result.stderr.trim() || "openshell sandbox upload failed");
1290
- }
1248
+ for (const entry of (await fsPromises.readdir(stagedRoot)).toSorted()) await this.uploadFileToRemote(path.join(stagedRoot, entry), `${normalizeRemotePath(remotePath)}/`);
1291
1249
  });
1292
1250
  }
1293
1251
  async remoteManagedRootsEmpty() {
@@ -2,6 +2,41 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.25.0 - 2026-10-08
6
+
7
+ ### Highlights
8
+
9
+ - **Faster synchronous plugin captures:** 16% faster than pre-change main on a measured 6,000-file tree; the new `createRootFileCopyBatchSync` API on `advanced` also reuses directory admissions across copies. ([#878](https://github.com/openclaw/fs-safe/pull/878), [#849](https://github.com/openclaw/fs-safe/pull/849))
10
+ - **Guest workspace publication:** works on gVisor and other Linux filesystems that reject `RENAME_NOREPLACE`. ([#876](https://github.com/openclaw/fs-safe/pull/876))
11
+ - **Root-backed sidecar locks:** native asynchronous release and exit cleanup no longer strand locks after their Root moves. ([#879](https://github.com/openclaw/fs-safe/pull/879))
12
+ - **Guest cross-device moves:** preserve the admitted file permissions. ([#869](https://github.com/openclaw/fs-safe/pull/869))
13
+
14
+ ### Features
15
+
16
+ - **Guarded copy batches:** `createRootFileCopyBatchSync` on `advanced` reuses directory admissions across synchronous copies on canonical POSIX paths while rechecking ancestor and file identities for every copy; single-file copies also reuse admission within the operation. ([#849](https://github.com/openclaw/fs-safe/pull/849))
17
+
18
+ ### Fixes
19
+
20
+ - **Root-backed file locks:** native asynchronous release and exit cleanup follow the created lock's retained identity after its Root moves, preserving replacement files and reporting ownership mismatches. ([#879](https://github.com/openclaw/fs-safe/pull/879)) Thanks @axiom-ncis.
21
+ - **Temp directory diagnostics:** name rejected roots and ancestors, explain ownership, mode, or symlink failures, and provide safe recovery guidance, including for root-run appliances. Thanks @joequant. ([#877](https://github.com/openclaw/fs-safe/pull/877))
22
+ - **Guest directory publication:** publish staged workspaces when Linux filesystems reject `RENAME_NOREPLACE`, with checked file link/unlink fallback and directory rename that can only replace a concurrently created empty directory. ([#876](https://github.com/openclaw/fs-safe/pull/876)) Thanks @valkyriweb.
23
+ - **Guest cross-device moves:** preserve the admitted source file's permissions instead of leaving the destination with a broader default mode. ([#869](https://github.com/openclaw/fs-safe/pull/869))
24
+ - **Move receipts:** publication receipts capture the source identity after synchronous authorization callbacks return, so a source replaced during authorization is reported accurately. ([#868](https://github.com/openclaw/fs-safe/pull/868))
25
+ - **Atomic copy fallback:** report a failed restoration as a failure even when the underlying error carries a restored receipt. ([#851](https://github.com/openclaw/fs-safe/pull/851))
26
+ - **Archive read limits:** keep the validated entry-read byte limit fixed when callers mutate the options during a read. ([#852](https://github.com/openclaw/fs-safe/pull/852))
27
+ - **Watch on macOS:** reconcile notifications for changes to the watched Root itself immediately instead of waiting for the periodic scan. ([#858](https://github.com/openclaw/fs-safe/pull/858))
28
+ - **`pathScope` tilde names:** keep literal `~` directory names inside scoped file and existing-path lookups instead of expanding them. ([#856](https://github.com/openclaw/fs-safe/pull/856))
29
+
30
+ ### Performance
31
+
32
+ - **Guarded file copies:** reuse checked canonical POSIX roots during initial path admission, reducing repeated path-resolution work in synchronous plugin source captures without changing per-file identity or race checks.
33
+ - **Root walks:** reuse normalized directory prefixes, cutting a 50k-file walk by 5.6% and its sampled allocation by about 9% while keeping every filesystem and identity check. ([#870](https://github.com/openclaw/fs-safe/pull/870))
34
+ - **Atomic writes:** non-durable async POSIX writes skip a redundant mode change when the staged file already has the requested permissions. ([#871](https://github.com/openclaw/fs-safe/pull/871))
35
+ - **Guarded mutations:** fewer promise allocations around synchronous guard checks, with directory identity checks unchanged. ([#857](https://github.com/openclaw/fs-safe/pull/857))
36
+ - **Archive publication:** fewer promise allocations in archive guard checks, without removing identity or containment checks. ([#864](https://github.com/openclaw/fs-safe/pull/864))
37
+ - **Watch:** Linux and Windows watch passes skip macOS stream-path preparation. ([#860](https://github.com/openclaw/fs-safe/pull/860))
38
+ - **Permission diagnostics:** bound escaping work for large error messages without changing the displayed excerpts. ([#859](https://github.com/openclaw/fs-safe/pull/859))
39
+
5
40
  ## 0.24.2 - 2026-10-07
6
41
 
7
42
  ### Fixes
@@ -22,7 +22,7 @@ await fs.write("../escape.txt", "x"); // throws FsSafeError("outside-
22
22
 
23
23
  That's the whole pitch. `root()` is the product; the rest of the package — JSON stores, atomic writes, secret files, archive extraction, temp workspaces — is supporting cast for the same boundary.
24
24
 
25
- Full docs and reference at **[fs-safe.io](https://fs-safe.io)**.
25
+ Full docs and reference at **[fs-safe.io](https://fs-safe.io)**. For synchronous plugin captures, [guarded file copies and batches](docs/copy.md#synchronous-guarded-file-copies) return owned destination descriptors while preserving per-file checks.
26
26
 
27
27
  ## Contents
28
28
 
@@ -1,4 +1,4 @@
1
- export { copyRootFileSync, type CopyRootFileSyncOptions, type CopiedRootFileSync } from "./copy-root-file-sync.js";
1
+ export { copyRootFileSync, createRootFileCopyBatchSync, type RootFileCopyBatchSync, type CopyRootFileSyncOptions, type CopiedRootFileSync } from "./copy-root-file-sync.js";
2
2
  export { createAsyncLock } from "./async-lock.js";
3
3
  export { createDirectory, createDirectorySync, createFileSync, type CreateDirectoryOptions, type CreateFileOptions, type OwnedFileDescriptorSync, } from "./create.js";
4
4
  export { copyFileHandle, copyFileDescriptorSync, type CopyFileHandleOptions } from "./file-handle-transfer.js";
@@ -1,4 +1,4 @@
1
- export { copyRootFileSync } from "./copy-root-file-sync.js";
1
+ export { copyRootFileSync, createRootFileCopyBatchSync } from "./copy-root-file-sync.js";
2
2
  // Advanced composition surface. These exports are less stable than the focused
3
3
  // public subpaths; prefer root/json/store/temp/archive unless you are building a
4
4
  // higher-level primitive.
@@ -65,7 +65,7 @@ export async function finalizeArchivePublication(params) {
65
65
  const check = () => params.deadline?.check();
66
66
  const assertGuards = async (guards) => {
67
67
  for (const guard of [params.destinationGuard, ...guards, params.sourceGuard]) {
68
- await assertDirectoryIdentityGuard(guard);
68
+ assertDirectoryIdentityGuard(guard);
69
69
  check();
70
70
  }
71
71
  };
@@ -98,15 +98,15 @@ async function mergeTree(params, publication, durable = true, entryUmask = 0) {
98
98
  assertSourceDirectory(ancestor);
99
99
  };
100
100
  const assertGuards = async () => {
101
- await assertDirectoryIdentityGuard(destinationGuard);
101
+ assertDirectoryIdentityGuard(destinationGuard);
102
102
  check();
103
103
  for (const ancestor of ancestors) {
104
- await assertDirectoryIdentityGuard(ancestor.guard);
104
+ assertDirectoryIdentityGuard(ancestor.guard);
105
105
  check();
106
106
  await ancestor.owner.verify(check);
107
107
  check();
108
108
  }
109
- await assertDirectoryIdentityGuard(sourceGuard);
109
+ assertDirectoryIdentityGuard(sourceGuard);
110
110
  check();
111
111
  };
112
112
  const walk = async (sourceDir) => {
@@ -184,7 +184,7 @@ async function mergeTree(params, publication, durable = true, entryUmask = 0) {
184
184
  await owner.apply(mode, { check, beforeChmod: async () => {
185
185
  await assertGuards();
186
186
  assertSourceAncestors();
187
- await assertDirectoryIdentityGuard(guard);
187
+ assertDirectoryIdentityGuard(guard);
188
188
  check();
189
189
  await assertResolvedInsideDestination({
190
190
  destinationRealDir, targetPath: destinationPath, originalPath,
@@ -213,7 +213,8 @@ async function readNativeBufferEntry(native, buffer, kind, requested, displayPat
213
213
  }
214
214
  }
215
215
  export async function readArchiveEntry(archivePath, entryPath, options) {
216
- if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0) {
216
+ const maxBytes = options.maxBytes;
217
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
217
218
  throw new RangeError("maxBytes must be a non-negative safe integer");
218
219
  }
219
220
  const kind = options.kind ?? resolveArchiveKind(archivePath);
@@ -228,7 +229,7 @@ export async function readArchiveEntry(archivePath, entryPath, options) {
228
229
  admitZipBuffer(buffer, resolveExtractLimits(), entry => { zipEntries.push(entry); });
229
230
  const native = getNativeBinding();
230
231
  if (native)
231
- return await readNativeBufferEntry(native, buffer, kind, requestedEntry, entryPath, options.maxBytes, zipEntries);
232
- return kind === "zip" ? await readZipEntry(buffer, requestedEntry, options.maxBytes, zipEntries)
233
- : await readTarEntry(buffer, requestedEntry, options.maxBytes, kind);
232
+ return await readNativeBufferEntry(native, buffer, kind, requestedEntry, entryPath, maxBytes, zipEntries);
233
+ return kind === "zip" ? await readZipEntry(buffer, requestedEntry, maxBytes, zipEntries)
234
+ : await readTarEntry(buffer, requestedEntry, maxBytes, kind);
234
235
  }
@@ -5,7 +5,7 @@ import { ArchiveSecurityError } from "./archive-errors.js";
5
5
  export type ArchiveDirectoryGuard = AsyncDirectoryGuard<BigIntStats>;
6
6
  export declare function createArchiveSymlinkTraversalError(originalPath: string): ArchiveSecurityError;
7
7
  export declare function createDirectoryIdentityGuard(dir: string): Promise<ArchiveDirectoryGuard>;
8
- export declare function assertDirectoryIdentityGuard(guard: ArchiveDirectoryGuard): Promise<void>;
8
+ export declare function assertDirectoryIdentityGuard(guard: ArchiveDirectoryGuard): void;
9
9
  export declare function prepareArchiveDestinationGuard(destDir: string): Promise<ArchiveDirectoryGuard>;
10
10
  export declare function prepareArchiveDestinationDir(destDir: string): Promise<string>;
11
11
  export declare function assertResolvedInsideDestination(params: {
@@ -1,7 +1,7 @@
1
1
  import fsSync, {} from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard, } from "./directory-guard.js";
4
+ import { assertDirectoryGuard, createAsyncDirectoryGuard, } from "./directory-guard.js";
5
5
  import { ownExtractionDestinationMutation, } from "./archive-deadline.js";
6
6
  import { ArchiveSecurityError, } from "./archive-errors.js";
7
7
  import { FsSafeError } from "./errors.js";
@@ -38,9 +38,9 @@ export async function createDirectoryIdentityGuard(dir) {
38
38
  throw err;
39
39
  }
40
40
  }
41
- export async function assertDirectoryIdentityGuard(guard) {
41
+ export function assertDirectoryIdentityGuard(guard) {
42
42
  try {
43
- await assertAsyncDirectoryGuard(guard);
43
+ assertDirectoryGuard(guard, "native");
44
44
  }
45
45
  catch (err) {
46
46
  if (err instanceof FsSafeError || isNotFoundPathError(err)) {
@@ -200,7 +200,7 @@ async function prepareOutputPath(params, assertGuards, privateWorkingMode = fals
200
200
  if (existingDestinationGuard && assertGuards)
201
201
  await assertGuards();
202
202
  else {
203
- await assertDirectoryIdentityGuard(destinationGuard);
203
+ assertDirectoryIdentityGuard(destinationGuard);
204
204
  checkExtractionDeadline(params.deadline);
205
205
  await assertGuards?.();
206
206
  }
@@ -213,46 +213,30 @@ async function prepareOutputPath(params, assertGuards, privateWorkingMode = fals
213
213
  originalPath: params.originalPath,
214
214
  });
215
215
  checkExtractionDeadline(params.deadline);
216
- if (params.isDirectory) {
217
- await getFsSafeTestHooks()?.beforeArchiveOutputMutation?.("mkdir", params.outPath);
216
+ const relativePath = params.isDirectory ? relPath : path.posix.dirname(relPath);
217
+ if (params.isDirectory || relativePath !== ".") {
218
+ const directoryPath = params.isDirectory ? params.outPath : path.dirname(params.outPath);
219
+ await getFsSafeTestHooks()?.beforeArchiveOutputMutation?.("mkdir", directoryPath);
218
220
  checkExtractionDeadline(params.deadline);
219
221
  await ownExtractionDestinationMutation(params.deadline, async () => {
220
222
  await assertOutputGuards();
221
223
  checkExtractionDeadline(params.deadline);
222
- await mkdirArchiveOutput({
223
- targetRoot,
224
- relativePath: relPath,
225
- originalPath: params.originalPath,
226
- });
227
- checkExtractionDeadline(params.deadline);
228
- await assertDirectoryIdentityGuard(destinationGuard);
224
+ await mkdirArchiveOutput({ targetRoot, relativePath, originalPath: params.originalPath });
229
225
  checkExtractionDeadline(params.deadline);
230
- await assertResolvedInsideDestination({
231
- destinationRealDir: params.destinationRealDir,
232
- targetPath: params.outPath,
233
- originalPath: params.originalPath,
234
- });
235
- checkExtractionDeadline(params.deadline);
236
- });
237
- return;
238
- }
239
- const parentRel = path.posix.dirname(relPath);
240
- if (parentRel !== ".") {
241
- await getFsSafeTestHooks()?.beforeArchiveOutputMutation?.("mkdir", path.dirname(params.outPath));
242
- checkExtractionDeadline(params.deadline);
243
- await ownExtractionDestinationMutation(params.deadline, async () => {
244
- await assertOutputGuards();
245
- checkExtractionDeadline(params.deadline);
246
- await mkdirArchiveOutput({
247
- targetRoot,
248
- relativePath: parentRel,
249
- originalPath: params.originalPath,
250
- });
251
- checkExtractionDeadline(params.deadline);
252
- await assertDirectoryIdentityGuard(destinationGuard);
226
+ assertDirectoryIdentityGuard(destinationGuard);
253
227
  checkExtractionDeadline(params.deadline);
228
+ if (params.isDirectory) {
229
+ await assertResolvedInsideDestination({
230
+ destinationRealDir: params.destinationRealDir,
231
+ targetPath: params.outPath,
232
+ originalPath: params.originalPath,
233
+ });
234
+ checkExtractionDeadline(params.deadline);
235
+ }
254
236
  });
255
237
  }
238
+ if (params.isDirectory)
239
+ return;
256
240
  await assertResolvedInsideDestination({
257
241
  destinationRealDir: params.destinationRealDir,
258
242
  targetPath: path.dirname(params.outPath),
@@ -292,12 +276,12 @@ export async function withStagedArchiveDestination(params) {
292
276
  const stagingGuard = await createDirectoryIdentityGuard(stagingDir);
293
277
  try {
294
278
  await fs.chmod(stagingDir, ARCHIVE_STAGING_MODE).catch(() => undefined);
295
- await assertDirectoryIdentityGuard(stagingGuard);
279
+ assertDirectoryIdentityGuard(stagingGuard);
296
280
  return await params.run(stagingDir);
297
281
  }
298
282
  finally {
299
283
  try {
300
- await assertDirectoryIdentityGuard(stagingGuard);
284
+ assertDirectoryIdentityGuard(stagingGuard);
301
285
  await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
302
286
  }
303
287
  catch {
@@ -0,0 +1,17 @@
1
+ import { type AnyAsyncDirectoryGuard } from "./directory-guard.js";
2
+ export type CopyPath = {
3
+ rootPath: string;
4
+ absolutePath: string;
5
+ };
6
+ declare function admitUncachedCopyPath(input: CopyPath, create?: boolean, admittedRoot?: AnyAsyncDirectoryGuard): {
7
+ path: string;
8
+ parent: AnyAsyncDirectoryGuard;
9
+ assertCurrent: () => void;
10
+ rootCanonicalPath: string | undefined;
11
+ };
12
+ /** Metadata custody lasts only for the owning copy or explicit batch. */
13
+ export declare function createCopyPathAdmissionCache(): {
14
+ admit: (input: CopyPath, create?: boolean) => ReturnType<typeof admitUncachedCopyPath>;
15
+ clear(): void;
16
+ };
17
+ export {};
@@ -0,0 +1,117 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { assertSyncDirectoryGuard, captureDirectoryGuard, inspectDirectoryIdentitySync } from "./directory-guard.js";
4
+ import { FsSafeError } from "./errors.js";
5
+ import { sameFileIdentity } from "./file-identity.js";
6
+ import { realpathSync } from "./realpath.js";
7
+ import { resolveRootPathSync, ROOT_PATH_ALIAS_POLICIES } from "./root-path.js";
8
+ function admitUncachedCopyPath(input, create = false, admittedRoot) {
9
+ if (!path.isAbsolute(input.rootPath) || !path.isAbsolute(input.absolutePath)) {
10
+ throw new FsSafeError("invalid-path", "copy roots and paths must be absolute");
11
+ }
12
+ const root = admittedRoot ?? captureDirectoryGuard(realpathSync(input.rootPath), "native", { bigint: true });
13
+ // Canonical POSIX callers already own a freshly checked root. Keep the
14
+ // resolver's lexical/symlink walk, but do not rediscover that root each time.
15
+ const rootCanonicalPath = admittedRoot?.realPath;
16
+ const resolve = () => {
17
+ const parent = create ? resolveRootPathSync({
18
+ ...input, rootCanonicalPath, absolutePath: path.dirname(input.absolutePath), boundaryLabel: "copy parent",
19
+ rejectSymlinks: true, rejectFinalSymlink: true,
20
+ }) : undefined;
21
+ const selected = resolveRootPathSync({
22
+ ...input, rootCanonicalPath, boundaryLabel: "copy", rejectSymlinks: !create, rejectFinalSymlink: !create,
23
+ policy: create ? ROOT_PATH_ALIAS_POLICIES.unlinkTarget : undefined,
24
+ });
25
+ if (parent && path.dirname(selected.canonicalPath) !== parent.canonicalPath) {
26
+ throw new FsSafeError("path-mismatch", "copy destination parent changed");
27
+ }
28
+ return selected;
29
+ };
30
+ const selected = resolve();
31
+ const parent = captureDirectoryGuard(path.dirname(selected.canonicalPath), "native", { bigint: true });
32
+ const assertCurrent = () => {
33
+ assertSyncDirectoryGuard(root);
34
+ assertSyncDirectoryGuard(parent);
35
+ const current = resolve();
36
+ if (current.rootCanonicalPath !== root.realPath || current.canonicalPath !== selected.canonicalPath) {
37
+ throw new FsSafeError("path-mismatch", "copy path changed during operation");
38
+ }
39
+ assertSyncDirectoryGuard(root);
40
+ assertSyncDirectoryGuard(parent);
41
+ };
42
+ assertCurrent();
43
+ return { path: selected.canonicalPath, parent, assertCurrent, rootCanonicalPath: undefined };
44
+ }
45
+ /** Metadata custody lasts only for the owning copy or explicit batch. */
46
+ export function createCopyPathAdmissionCache() {
47
+ const roots = new Map();
48
+ const parents = new Map();
49
+ const directories = new Map();
50
+ const assertParent = ({ root, parent, chain }) => {
51
+ assertSyncDirectoryGuard(root);
52
+ for (const guard of chain) {
53
+ inspectDirectoryIdentitySync(guard.dir, { dev: BigInt(guard.stat.dev), ino: BigInt(guard.stat.ino) });
54
+ }
55
+ assertSyncDirectoryGuard(parent);
56
+ assertSyncDirectoryGuard(root);
57
+ };
58
+ const admit = (input, create = false) => {
59
+ // Keep the public resolver for Windows aliases and noncanonical spellings,
60
+ // including raw dot segments whose traversal must not be normalized away.
61
+ if (process.platform === "win32" || !path.isAbsolute(input.rootPath) ||
62
+ !path.isAbsolute(input.absolutePath) || input.rootPath.includes("\0") || input.absolutePath.includes("\0") ||
63
+ path.resolve(input.rootPath) !== input.rootPath || path.resolve(input.absolutePath) !== input.absolutePath ||
64
+ !input.absolutePath.startsWith(input.rootPath.endsWith(path.sep) ? input.rootPath : `${input.rootPath}${path.sep}`)) {
65
+ return admitUncachedCopyPath(input, create);
66
+ }
67
+ let root = roots.get(input.rootPath);
68
+ if (!root) {
69
+ const canonical = realpathSync(input.rootPath);
70
+ if (canonical !== input.rootPath)
71
+ return admitUncachedCopyPath(input, create);
72
+ root = captureDirectoryGuard(canonical, "native", { bigint: true });
73
+ roots.set(input.rootPath, root);
74
+ }
75
+ assertSyncDirectoryGuard(root);
76
+ const parentPath = path.dirname(input.absolutePath);
77
+ let byParent = parents.get(input.rootPath);
78
+ let admitted = byParent?.get(parentPath);
79
+ if (!admitted) {
80
+ // The first file in each parent retains the existing lexical admission.
81
+ const selected = admitUncachedCopyPath(input, create, root);
82
+ const chain = [];
83
+ let cursor = input.rootPath;
84
+ const relative = path.relative(input.rootPath, parentPath);
85
+ for (const segment of relative ? relative.split(path.sep) : []) {
86
+ cursor = path.join(cursor, segment);
87
+ let guard = directories.get(cursor);
88
+ if (!guard) {
89
+ guard = captureDirectoryGuard(cursor, "native", { bigint: true });
90
+ directories.set(cursor, guard);
91
+ }
92
+ chain.push(guard);
93
+ }
94
+ admitted = { root, parent: chain.at(-1) ?? root, chain };
95
+ assertParent(admitted);
96
+ if (selected.path !== input.absolutePath || selected.parent.realPath !== parentPath ||
97
+ !sameFileIdentity(selected.parent.stat, admitted.parent.stat)) {
98
+ throw new FsSafeError("path-mismatch", "copy path changed during admission");
99
+ }
100
+ byParent ??= new Map();
101
+ byParent.set(parentPath, admitted);
102
+ parents.set(input.rootPath, byParent);
103
+ }
104
+ const selected = admitted;
105
+ const assertCurrent = () => {
106
+ assertParent(selected);
107
+ // No file observation is cached. Preserve the resolver's symlink errors.
108
+ if (!create && fs.lstatSync(input.absolutePath, { throwIfNoEntry: false })?.isSymbolicLink()) {
109
+ admitUncachedCopyPath(input, create);
110
+ throw new FsSafeError("symlink", "symlink not allowed");
111
+ }
112
+ };
113
+ assertCurrent();
114
+ return { path: input.absolutePath, parent: selected.parent, assertCurrent, rootCanonicalPath: selected.root.realPath };
115
+ };
116
+ return { admit, clear() { roots.clear(); parents.clear(); directories.clear(); } };
117
+ }