@openclaw/openshell-sandbox 2026.10.1-beta.2 → 2026.10.5-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.
Files changed (126) hide show
  1. package/dist/index.js +69 -111
  2. package/node_modules/@openclaw/fs-safe/CHANGELOG.md +91 -0
  3. package/node_modules/@openclaw/fs-safe/README.md +1 -1
  4. package/node_modules/@openclaw/fs-safe/dist/advanced.d.ts +1 -0
  5. package/node_modules/@openclaw/fs-safe/dist/advanced.js +1 -0
  6. package/node_modules/@openclaw/fs-safe/dist/archive-durability.js +1 -1
  7. package/node_modules/@openclaw/fs-safe/dist/archive-entry.js +7 -3
  8. package/node_modules/@openclaw/fs-safe/dist/archive-merge.js +4 -4
  9. package/node_modules/@openclaw/fs-safe/dist/archive-parser.wasm +0 -0
  10. package/node_modules/@openclaw/fs-safe/dist/archive-read.js +5 -4
  11. package/node_modules/@openclaw/fs-safe/dist/archive-staging.d.ts +1 -1
  12. package/node_modules/@openclaw/fs-safe/dist/archive-staging.js +22 -38
  13. package/node_modules/@openclaw/fs-safe/dist/copy-path-admission.d.ts +17 -0
  14. package/node_modules/@openclaw/fs-safe/dist/copy-path-admission.js +117 -0
  15. package/node_modules/@openclaw/fs-safe/dist/copy-root-file-sync.d.ts +38 -0
  16. package/node_modules/@openclaw/fs-safe/dist/copy-root-file-sync.js +195 -0
  17. package/node_modules/@openclaw/fs-safe/dist/create.d.ts +1 -0
  18. package/node_modules/@openclaw/fs-safe/dist/create.js +4 -1
  19. package/node_modules/@openclaw/fs-safe/dist/entry-publication.js +3 -1
  20. package/node_modules/@openclaw/fs-safe/dist/errors.d.ts +2 -0
  21. package/node_modules/@openclaw/fs-safe/dist/errors.js +5 -0
  22. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-acquire.d.ts +3 -0
  23. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-acquire.js +57 -11
  24. package/node_modules/@openclaw/fs-safe/dist/guarded-mutation.js +5 -5
  25. package/node_modules/@openclaw/fs-safe/dist/guest-native-python.js +52 -5
  26. package/node_modules/@openclaw/fs-safe/dist/guest.js +1 -1
  27. package/node_modules/@openclaw/fs-safe/dist/index.d.ts +1 -1
  28. package/node_modules/@openclaw/fs-safe/dist/index.js +1 -1
  29. package/node_modules/@openclaw/fs-safe/dist/native-binding.d.ts +4 -0
  30. package/node_modules/@openclaw/fs-safe/dist/native-move-fallback.d.ts +3 -0
  31. package/node_modules/@openclaw/fs-safe/dist/native-move-fallback.js +54 -0
  32. package/node_modules/@openclaw/fs-safe/dist/native-noreplace.d.ts +8 -0
  33. package/node_modules/@openclaw/fs-safe/dist/native-noreplace.js +55 -0
  34. package/node_modules/@openclaw/fs-safe/dist/native-operations.d.ts +0 -1
  35. package/node_modules/@openclaw/fs-safe/dist/native-operations.js +22 -26
  36. package/node_modules/@openclaw/fs-safe/dist/native-pinned-write-windows.js +5 -1
  37. package/node_modules/@openclaw/fs-safe/dist/native-pinned-write.d.ts +1 -1
  38. package/node_modules/@openclaw/fs-safe/dist/native-pinned-write.js +12 -2
  39. package/node_modules/@openclaw/fs-safe/dist/native-rename-outcome.js +3 -1
  40. package/node_modules/@openclaw/fs-safe/dist/native-staged-file.d.ts +1 -1
  41. package/node_modules/@openclaw/fs-safe/dist/native-staged-file.js +52 -6
  42. package/node_modules/@openclaw/fs-safe/dist/permission-exec.js +2 -1
  43. package/node_modules/@openclaw/fs-safe/dist/pinned-write-input.d.ts +2 -0
  44. package/node_modules/@openclaw/fs-safe/dist/pinned-write-input.js +17 -0
  45. package/node_modules/@openclaw/fs-safe/dist/pinned-write-types.d.ts +6 -1
  46. package/node_modules/@openclaw/fs-safe/dist/pinned-write.js +1 -1
  47. package/node_modules/@openclaw/fs-safe/dist/private-temp-workspace.js +2 -2
  48. package/node_modules/@openclaw/fs-safe/dist/publish-file-failure.d.ts +5 -0
  49. package/node_modules/@openclaw/fs-safe/dist/publish-file-failure.js +1 -0
  50. package/node_modules/@openclaw/fs-safe/dist/publish-file.d.ts +2 -0
  51. package/node_modules/@openclaw/fs-safe/dist/publish-file.js +35 -2
  52. package/node_modules/@openclaw/fs-safe/dist/replace-directory.js +6 -5
  53. package/node_modules/@openclaw/fs-safe/dist/replace-file-copy-fallback.js +1 -4
  54. package/node_modules/@openclaw/fs-safe/dist/replace-file-descriptor.js +6 -0
  55. package/node_modules/@openclaw/fs-safe/dist/root-create-input.d.ts +15 -1
  56. package/node_modules/@openclaw/fs-safe/dist/root-create-input.js +4 -0
  57. package/node_modules/@openclaw/fs-safe/dist/root-directory-entry.js +1 -1
  58. package/node_modules/@openclaw/fs-safe/dist/root-directory-list.d.ts +0 -1
  59. package/node_modules/@openclaw/fs-safe/dist/root-directory-list.js +6 -12
  60. package/node_modules/@openclaw/fs-safe/dist/root-errors.js +25 -4
  61. package/node_modules/@openclaw/fs-safe/dist/root-impl.js +18 -18
  62. package/node_modules/@openclaw/fs-safe/dist/root-move-noreplace.d.ts +4 -2
  63. package/node_modules/@openclaw/fs-safe/dist/root-move-noreplace.js +13 -17
  64. package/node_modules/@openclaw/fs-safe/dist/root-paths.js +1 -1
  65. package/node_modules/@openclaw/fs-safe/dist/root-walk.js +4 -4
  66. package/node_modules/@openclaw/fs-safe/dist/root-write-verification.d.ts +10 -1
  67. package/node_modules/@openclaw/fs-safe/dist/root-write-verification.js +20 -0
  68. package/node_modules/@openclaw/fs-safe/dist/secure-temp-dir.js +19 -7
  69. package/node_modules/@openclaw/fs-safe/dist/sibling-staged-file.js +18 -3
  70. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-acquire.js +25 -3
  71. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-admission.d.ts +2 -0
  72. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-retained.d.ts +14 -0
  73. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-retained.js +187 -0
  74. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock.js +15 -1
  75. package/node_modules/@openclaw/fs-safe/dist/staged-file-settlement.js +4 -1
  76. package/node_modules/@openclaw/fs-safe/dist/temp-directory-diagnostics.d.ts +10 -0
  77. package/node_modules/@openclaw/fs-safe/dist/temp-directory-diagnostics.js +29 -0
  78. package/node_modules/@openclaw/fs-safe/dist/temp-target.js +1 -1
  79. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-admission.js +6 -6
  80. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-child-admission.d.ts +4 -4
  81. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-child-admission.js +17 -16
  82. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-descriptor.js +20 -28
  83. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-owner.d.ts +1 -0
  84. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-owner.js +27 -4
  85. package/node_modules/@openclaw/fs-safe/dist/walk.js +2 -13
  86. package/node_modules/@openclaw/fs-safe/dist/watch-native.d.ts +2 -2
  87. package/node_modules/@openclaw/fs-safe/dist/watch-native.js +3 -1
  88. package/node_modules/@openclaw/fs-safe/dist/watch-rescan.js +3 -9
  89. package/node_modules/@openclaw/fs-safe/dist/watch.js +2 -3
  90. package/node_modules/@openclaw/fs-safe/docs/advanced.md +1 -0
  91. package/node_modules/@openclaw/fs-safe/docs/archive.md +5 -1
  92. package/node_modules/@openclaw/fs-safe/docs/atomic.md +11 -3
  93. package/node_modules/@openclaw/fs-safe/docs/contributing.md +1 -1
  94. package/node_modules/@openclaw/fs-safe/docs/copy.md +109 -1
  95. package/node_modules/@openclaw/fs-safe/docs/durability.md +50 -5
  96. package/node_modules/@openclaw/fs-safe/docs/errors.md +37 -0
  97. package/node_modules/@openclaw/fs-safe/docs/guest.md +21 -4
  98. package/node_modules/@openclaw/fs-safe/docs/native-helper.md +5 -3
  99. package/node_modules/@openclaw/fs-safe/docs/native.md +39 -6
  100. package/node_modules/@openclaw/fs-safe/docs/path-scope.md +4 -0
  101. package/node_modules/@openclaw/fs-safe/docs/public-api.md +3 -1
  102. package/node_modules/@openclaw/fs-safe/docs/sidecar-lock.md +46 -8
  103. package/node_modules/@openclaw/fs-safe/docs/staged-file.md +10 -3
  104. package/node_modules/@openclaw/fs-safe/docs/temp.md +12 -0
  105. package/node_modules/@openclaw/fs-safe/docs/watch.md +6 -3
  106. package/node_modules/@openclaw/fs-safe/docs/writing.md +40 -11
  107. package/node_modules/@openclaw/fs-safe/package.json +16 -16
  108. package/node_modules/@openclaw/fs-safe-darwin-arm64/fs-safe-native.node +0 -0
  109. package/node_modules/@openclaw/fs-safe-darwin-arm64/package.json +1 -1
  110. package/node_modules/@openclaw/fs-safe-darwin-x64/fs-safe-native.node +0 -0
  111. package/node_modules/@openclaw/fs-safe-darwin-x64/package.json +1 -1
  112. package/node_modules/@openclaw/fs-safe-linux-arm64-gnu/fs-safe-native.node +0 -0
  113. package/node_modules/@openclaw/fs-safe-linux-arm64-gnu/package.json +1 -1
  114. package/node_modules/@openclaw/fs-safe-linux-arm64-musl/fs-safe-native.node +0 -0
  115. package/node_modules/@openclaw/fs-safe-linux-arm64-musl/package.json +1 -1
  116. package/node_modules/@openclaw/fs-safe-linux-x64-gnu/fs-safe-native.node +0 -0
  117. package/node_modules/@openclaw/fs-safe-linux-x64-gnu/package.json +1 -1
  118. package/node_modules/@openclaw/fs-safe-linux-x64-musl/fs-safe-native.node +0 -0
  119. package/node_modules/@openclaw/fs-safe-linux-x64-musl/package.json +1 -1
  120. package/node_modules/@openclaw/fs-safe-win32-x64-msvc/fs-safe-native.node +0 -0
  121. package/node_modules/@openclaw/fs-safe-win32-x64-msvc/package.json +1 -1
  122. package/package.json +5 -5
  123. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-arbitration.d.ts +0 -17
  124. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-arbitration.js +0 -66
  125. package/node_modules/@openclaw/fs-safe/dist/root-move-preflight.d.ts +0 -7
  126. package/node_modules/@openclaw/fs-safe/dist/root-move-preflight.js +0 -16
@@ -4,6 +4,7 @@ import { pinNodeDirectoryForMode, pinNodeDirectoryForModeSync } from "./director
4
4
  import { FsSafeError } from "./errors.js";
5
5
  import { fileIdentityMismatchError, inspectFileIdentitySync } from "./strict-file-identity.js";
6
6
  import { classifyTempWorkspaceOwner, warnUnmappedTempWorkspaceAncestor } from "./temp-workspace-ownership.js";
7
+ import { privateTempDirectoryFix, tempDirectoryMode } from "./temp-directory-diagnostics.js";
7
8
  export const TEMP_WORKSPACE_NUMERIC_IDENTITY_REPLAY = process.platform === "linux" || process.platform === "darwin";
8
9
  export function projectTempWorkspaceNumericIdentity(identity) {
9
10
  const dev = Number(identity.dev);
@@ -43,12 +44,12 @@ export function validateTempWorkspaceDirMode(mode) {
43
44
  throw new FsSafeError("insecure-permissions", "temp workspace must not be group/world writable");
44
45
  }
45
46
  }
46
- export function assertTrustedTempWorkspaceDirectory(stat, uid, privateDirectory = false) {
47
+ export function assertTrustedTempWorkspaceDirectory(stat, uid, privateDirectory, dir) {
47
48
  if (uid === undefined)
48
49
  return;
49
50
  const ownership = classifyTempWorkspaceOwner(stat, uid);
50
51
  if (ownership === "foreign" || (privateDirectory && ownership !== "user")) {
51
- throw new FsSafeError("not-owned", "temp workspace directory has an untrusted owner; use a private temp root owned by the effective user under a trusted directory hierarchy");
52
+ throw new FsSafeError("not-owned", `temp workspace directory ${JSON.stringify(dir)} has an untrusted owner uid ${stat.uid}; expected uid ${uid}${privateDirectory || uid === 0 ? "" : " or root uid 0"}; ${privateTempDirectoryFix(uid)}`);
52
53
  }
53
54
  // Unmapped host owners are unverifiable; trust the host directory hierarchy.
54
55
  // Admit sticky ancestors so PrivateUsers keeps host /tmp and PrivateTmp usable.
@@ -60,18 +61,18 @@ export function assertTrustedTempWorkspaceDirectory(stat, uid, privateDirectory
60
61
  ? (stat.mode & 512n) !== 0n
61
62
  : Number.isSafeInteger(stat.mode) && stat.mode >= 0 && (stat.mode & 0o1000) !== 0;
62
63
  if (typeof stat.mode !== "bigint" && (!Number.isSafeInteger(stat.mode) || stat.mode < 0)) {
63
- throw new FsSafeError("insecure-permissions", "temp workspace directory permissions are invalid");
64
+ throw new FsSafeError("insecure-permissions", `temp workspace directory ${JSON.stringify(dir)} permissions are invalid; ${privateTempDirectoryFix(uid)}`);
64
65
  }
65
66
  if (writable && (privateDirectory || !sticky)) {
66
67
  throw new FsSafeError("insecure-permissions", privateDirectory
67
- ? "temp workspace root and child must not be group/world writable; use a private temp directory"
68
- : "temp workspace ancestor is group/world writable without sticky protection");
68
+ ? `temp workspace directory ${JSON.stringify(dir)} has group/world-writable mode ${tempDirectoryMode(stat.mode)}; root and child must not be group/world writable, even with the sticky bit; ${privateTempDirectoryFix(uid)}`
69
+ : `temp workspace ancestor ${JSON.stringify(dir)} has group/world-writable mode ${tempDirectoryMode(stat.mode)} without sticky protection (sticky bit missing); have its administrator restore safe permissions (1777 for a shared system temp directory), or move the private root under trusted ancestors`);
69
70
  }
70
71
  if (ownership === "unmapped")
71
72
  warnUnmappedTempWorkspaceAncestor();
72
73
  }
73
74
  const WINDOWS = process.platform === "win32";
74
- export function assertTempWorkspaceChildState(stat, ownerUid) {
75
+ export function assertTempWorkspaceChildState(stat, ownerUid, dir) {
75
76
  const exactIdentity = typeof stat.dev === "bigint" && typeof stat.ino === "bigint" &&
76
77
  (!WINDOWS || (stat.dev !== 0n && stat.ino !== 0n));
77
78
  const safeNumericIdentity = TEMP_WORKSPACE_NUMERIC_IDENTITY_REPLAY &&
@@ -81,12 +82,12 @@ export function assertTempWorkspaceChildState(stat, ownerUid) {
81
82
  throw new FsSafeError("path-mismatch", "temp workspace child identity could not be verified");
82
83
  }
83
84
  if (stat.isSymbolicLink() || !stat.isDirectory()) {
84
- throw new FsSafeError("not-file", "temp workspace child must be a real directory");
85
+ throw new FsSafeError("not-file", `temp workspace child ${JSON.stringify(dir)} ${stat.isSymbolicLink() ? "is a symbolic link" : "is not a directory"}; ${privateTempDirectoryFix(ownerUid)}`);
85
86
  }
86
- assertTrustedTempWorkspaceDirectory(stat, ownerUid, true);
87
+ assertTrustedTempWorkspaceDirectory(stat, ownerUid, true, dir);
87
88
  }
88
- export function validateInitialTempWorkspaceChild(stat, ownerUid, mode) {
89
- assertTempWorkspaceChildState(stat, ownerUid);
89
+ export function validateInitialTempWorkspaceChild(stat, ownerUid, mode, dir) {
90
+ assertTempWorkspaceChildState(stat, ownerUid, dir);
90
91
  return !childHasRequestedMode(stat, mode);
91
92
  }
92
93
  export function childHasRequestedMode(stat, mode) {
@@ -105,7 +106,7 @@ async function initializeTempWorkspaceChildMode(dir, expected, parent, mode) {
105
106
  try {
106
107
  await owner.apply(mode, { check: parent.assertCurrent });
107
108
  const current = inspectDirectoryIdentitySync(dir, expected);
108
- validateAdmittedTempWorkspaceChild(current, parent.ownerUid, mode);
109
+ validateAdmittedTempWorkspaceChild(current, parent.ownerUid, mode, dir);
109
110
  parent.assertCurrent();
110
111
  }
111
112
  finally {
@@ -113,12 +114,12 @@ async function initializeTempWorkspaceChildMode(dir, expected, parent, mode) {
113
114
  }
114
115
  }
115
116
  export function admitTempWorkspaceChild(dir, expected, parent, mode) {
116
- if (!validateInitialTempWorkspaceChild(expected, parent.ownerUid, mode))
117
+ if (!validateInitialTempWorkspaceChild(expected, parent.ownerUid, mode, dir))
117
118
  return undefined;
118
119
  return initializeTempWorkspaceChildMode(dir, expected, parent, mode);
119
120
  }
120
121
  export function admitTempWorkspaceChildSync(dir, expected, parent, mode) {
121
- if (!validateInitialTempWorkspaceChild(expected, parent.ownerUid, mode))
122
+ if (!validateInitialTempWorkspaceChild(expected, parent.ownerUid, mode, dir))
122
123
  return;
123
124
  parent.assertCurrent();
124
125
  const owner = pinNodeDirectoryForModeSync(dir, {
@@ -128,15 +129,15 @@ export function admitTempWorkspaceChildSync(dir, expected, parent, mode) {
128
129
  try {
129
130
  owner.apply(mode, parent.assertCurrent);
130
131
  const current = inspectDirectoryIdentitySync(dir, expected);
131
- validateAdmittedTempWorkspaceChild(current, parent.ownerUid, mode);
132
+ validateAdmittedTempWorkspaceChild(current, parent.ownerUid, mode, dir);
132
133
  parent.assertCurrent();
133
134
  }
134
135
  finally {
135
136
  owner.close();
136
137
  }
137
138
  }
138
- export function validateAdmittedTempWorkspaceChild(current, ownerUid, mode) {
139
- assertTempWorkspaceChildState(current, ownerUid);
139
+ export function validateAdmittedTempWorkspaceChild(current, ownerUid, mode, dir) {
140
+ assertTempWorkspaceChildState(current, ownerUid, dir);
140
141
  if (!childHasRequestedMode(current, mode)) {
141
142
  throw new FsSafeError("path-mismatch", "temp workspace final mode could not be verified");
142
143
  }
@@ -133,9 +133,8 @@ export class TempWorkspaceRetainedChild {
133
133
  // the synchronous caller validates it and decides whether to correct.
134
134
  const stat = fsSync.fstatSync(descriptor.fd, { bigint: true });
135
135
  Object.freeze(stat);
136
- const receipt = Object.freeze({ stat });
137
136
  return {
138
- retained: new TempWorkspaceRetainedChild(dir, stat, descriptor, receipt),
137
+ retained: new TempWorkspaceRetainedChild(dir, stat, descriptor, stat),
139
138
  stat,
140
139
  };
141
140
  }
@@ -151,23 +150,27 @@ export class TempWorkspaceRetainedChild {
151
150
  // Clear first so validation failures, procfs checks, and later admission
152
151
  // can never reuse the creation observation.
153
152
  this.#initialReceipt = undefined;
154
- return receipt?.stat;
153
+ return receipt;
155
154
  }
156
155
  finalizeAdmission(ownerUid, mode) {
157
156
  this.#assertModeLeaseReleased();
158
157
  this.discardInitialReceipt();
159
- if (this.#fd === undefined) {
160
- throw new FsSafeError("path-mismatch", "temp workspace child descriptor is unavailable");
161
- }
158
+ const fd = this.#requireDescriptor();
162
159
  // A failed revalidation must never leave an earlier receipt transferable.
163
160
  this.#transferAuthorized = false;
164
- const current = inspectTempWorkspaceDescriptorIdentitySync(this.#fd, this.#identity, this.#numericIdentity);
165
- validateAdmittedTempWorkspaceChild(current, ownerUid, mode);
161
+ const current = inspectTempWorkspaceDescriptorIdentitySync(fd, this.#identity, this.#numericIdentity);
162
+ validateAdmittedTempWorkspaceChild(current, ownerUid, mode, this.#dir);
166
163
  const named = inspectTempWorkspaceDirectoryIdentitySync(this.#dir, this.#identity, this.#numericIdentity);
167
- validateAdmittedTempWorkspaceChild(named, ownerUid, mode);
164
+ validateAdmittedTempWorkspaceChild(named, ownerUid, mode, this.#dir);
168
165
  this.#transferAuthorized = true;
169
166
  return named;
170
167
  }
168
+ #requireDescriptor() {
169
+ if (this.#fd === undefined) {
170
+ throw new FsSafeError("path-mismatch", "temp workspace child descriptor is unavailable");
171
+ }
172
+ return this.#fd;
173
+ }
171
174
  #assertModeLeaseReleased() {
172
175
  if (this.#modeLease) {
173
176
  throw new FsSafeError("path-mismatch", "temp workspace child mode operation is active");
@@ -175,9 +178,9 @@ export class TempWorkspaceRetainedChild {
175
178
  }
176
179
  #inspectModeTarget(fd, ownerUid, initial) {
177
180
  const descriptor = initial ?? inspectTempWorkspaceDescriptorIdentitySync(fd, this.#identity, this.#numericIdentity);
178
- assertTempWorkspaceChildState(descriptor, ownerUid);
181
+ assertTempWorkspaceChildState(descriptor, ownerUid, this.#dir);
179
182
  const named = inspectTempWorkspaceDirectoryIdentitySync(this.#dir, this.#identity, this.#numericIdentity);
180
- assertTempWorkspaceChildState(named, ownerUid);
183
+ assertTempWorkspaceChildState(named, ownerUid, this.#dir);
181
184
  return descriptor;
182
185
  }
183
186
  async #assertProcAuthority(fd, ownerUid) {
@@ -196,16 +199,13 @@ export class TempWorkspaceRetainedChild {
196
199
  const opened = inspectFileIdentitySync(() => fsSync.fstatSync(fd, { bigint: true }), this.#identity);
197
200
  const followed = inspectFileIdentitySync(() => fsSync.statSync(procPath, { bigint: true }), this.#identity);
198
201
  assertOwnedDirectory(opened, followed);
199
- assertTempWorkspaceChildState(opened, ownerUid);
200
- assertTempWorkspaceChildState(followed, ownerUid);
202
+ assertTempWorkspaceChildState(opened, ownerUid, this.#dir);
203
+ assertTempWorkspaceChildState(followed, ownerUid, this.#dir);
201
204
  }
202
205
  async initializeMode(mode, ownerUid, assertParent) {
203
206
  this.#assertModeLeaseReleased();
204
207
  this.discardInitialReceipt();
205
- if (this.#fd === undefined) {
206
- throw new FsSafeError("path-mismatch", "temp workspace child descriptor is unavailable");
207
- }
208
- const fd = this.#fd;
208
+ const fd = this.#requireDescriptor();
209
209
  this.#transferAuthorized = false;
210
210
  this.#modeLease = true;
211
211
  try {
@@ -238,10 +238,7 @@ export class TempWorkspaceRetainedChild {
238
238
  }
239
239
  initializeModeSync(mode, ownerUid, assertParent) {
240
240
  this.#assertModeLeaseReleased();
241
- if (this.#fd === undefined) {
242
- throw new FsSafeError("path-mismatch", "temp workspace child descriptor is unavailable");
243
- }
244
- const fd = this.#fd;
241
+ const fd = this.#requireDescriptor();
245
242
  this.#transferAuthorized = false;
246
243
  this.#modeLease = true;
247
244
  try {
@@ -270,9 +267,7 @@ export class TempWorkspaceRetainedChild {
270
267
  this.discardInitialReceipt();
271
268
  if (this.canEnumerate)
272
269
  return true;
273
- if (this.#fd === undefined) {
274
- throw new FsSafeError("path-mismatch", "temp workspace child descriptor is unavailable");
275
- }
270
+ this.#requireDescriptor();
276
271
  // Opening or validating a replacement crosses a new pathname boundary.
277
272
  // Even failure must revoke any earlier final-admission receipt.
278
273
  this.#transferAuthorized = false;
@@ -312,13 +307,10 @@ export class TempWorkspaceRetainedChild {
312
307
  transfer(retainDescriptor) {
313
308
  this.#assertModeLeaseReleased();
314
309
  this.discardInitialReceipt();
315
- if (this.#fd === undefined) {
316
- throw new FsSafeError("path-mismatch", "temp workspace child descriptor is unavailable");
317
- }
310
+ const fd = this.#requireDescriptor();
318
311
  if (!this.#transferAuthorized) {
319
312
  throw new FsSafeError("path-mismatch", "temp workspace child has not completed final admission");
320
313
  }
321
- const fd = this.#fd;
322
314
  this.#fd = undefined;
323
315
  if (retainDescriptor && !this.canEnumerate) {
324
316
  closeAfterAdmissionFailure(fd, new FsSafeError("helper-unavailable", "temp workspace cleanup requires a readable child descriptor"), "temp workspace child descriptor rejection and close failed");
@@ -9,6 +9,7 @@ export declare class TempWorkspaceCleanupCapability {
9
9
  readonly parent: RetainedDirectory | undefined;
10
10
  constructor(root: string, safety: TempWorkspaceCleanupSafety, admission: TempWorkspaceRootAdmission, dirMode: number);
11
11
  get canRemoveOwnedTree(): boolean;
12
+ get canUseCompatibleCleanup(): boolean;
12
13
  prepareChildCreation(): void;
13
14
  admitChildDescriptor(canEnumerate: boolean): boolean;
14
15
  assertCurrent(): void;
@@ -8,6 +8,8 @@ import { sameFileIdentityForCleanup } from "./file-identity.js";
8
8
  import { getNativeBinding } from "./native.js";
9
9
  import { openTempWorkspaceCleanupParent, TempWorkspaceRetainedChild, } from "./temp-workspace-descriptor.js";
10
10
  import { getFsSafeTestHooks } from "./test-hooks.js";
11
+ import { isFsSafeNativeRequired } from "./native-config.js";
12
+ import { cachedNoReplaceUnavailable, noReplaceUnavailable, rememberNoReplaceUnavailable } from "./native-noreplace.js";
11
13
  function isNativeCleanupBinding(binding) {
12
14
  return typeof binding?.renameNoReplace === "function" &&
13
15
  typeof binding.removeOwnedTree === "function" &&
@@ -94,6 +96,9 @@ export class TempWorkspaceCleanupCapability {
94
96
  return (this.#phase === "ready" || this.#phase === "sealed") &&
95
97
  this.#ownedTreeRemovalAvailable;
96
98
  }
99
+ get canUseCompatibleCleanup() {
100
+ return this.#safety === "compatible" && !isFsSafeNativeRequired();
101
+ }
97
102
  prepareChildCreation() {
98
103
  const replay = this.#phase === "ready";
99
104
  if (this.#phase !== "new" && !replay) {
@@ -269,11 +274,27 @@ export class TempWorkspaceCleanupOwner {
269
274
  }
270
275
  const name = `.fs-safe-workspace-cleanup-${randomUUID()}`;
271
276
  const quarantinePath = path.join(parent.receipt.path, name);
272
- const nativeRemoval = this.#capability.canRemoveOwnedTree && this.#directory !== undefined;
277
+ let nativeRemoval = this.#capability.canRemoveOwnedTree && this.#directory !== undefined;
273
278
  if (nativeRemoval) {
274
- this.#capability.binding.renameNoReplace(parent.fd, path.basename(this.#dir), parent.fd, name);
279
+ const binding = this.#capability.binding;
280
+ try {
281
+ const cached = cachedNoReplaceUnavailable(binding, parent.fd, "workspace quarantine");
282
+ if (cached)
283
+ throw cached;
284
+ binding.renameNoReplace(parent.fd, path.basename(this.#dir), parent.fd, name);
285
+ }
286
+ catch (error) {
287
+ const unavailable = error instanceof FsSafeError && error.code === "helper-unavailable"
288
+ ? error : noReplaceUnavailable(error, "workspace quarantine", true);
289
+ if (!unavailable)
290
+ throw error;
291
+ rememberNoReplaceUnavailable(binding, parent.fd, unavailable);
292
+ if (!this.#capability.canUseCompatibleCleanup)
293
+ throw unavailable;
294
+ nativeRemoval = false;
295
+ }
275
296
  }
276
- else {
297
+ if (!nativeRemoval) {
277
298
  // The admitted receipt is exact and descriptor-associated. Reuse it as
278
299
  // the pre/post parent fence instead of layering a numeric guard over it.
279
300
  this.#capability.assertCurrent();
@@ -288,7 +309,9 @@ export class TempWorkspaceCleanupOwner {
288
309
  }
289
310
  return { name, path: quarantinePath, nativeRemoval };
290
311
  }
291
- catch {
312
+ catch (error) {
313
+ if (error instanceof FsSafeError && error.code === "helper-unavailable")
314
+ throw error;
292
315
  // A failed rename can still have committed on a remote filesystem.
293
316
  return "indeterminate";
294
317
  }
@@ -32,17 +32,6 @@ function kindForDirent(dirent) {
32
32
  function shouldStop(result, options) {
33
33
  return options.maxEntries !== undefined && result.scannedEntryCount >= Math.max(0, options.maxEntries);
34
34
  }
35
- function buildEntry(params) {
36
- const fullPath = params.fullPath;
37
- return {
38
- name: params.dirent.name,
39
- path: fullPath,
40
- relativePath: params.relativePath,
41
- depth: params.depth,
42
- kind: params.kind,
43
- dirent: params.dirent,
44
- };
45
- }
46
35
  function recordFailedDir(result, root, dir, depth, error) {
47
36
  const relativePath = path.relative(root, dir);
48
37
  result.failedDirs.push({
@@ -138,7 +127,7 @@ export function walkDirectorySync(rootDir, options = {}) {
138
127
  if (!kind)
139
128
  continue;
140
129
  const relativePath = relativeDir ? `${relativeDir}${path.sep}${dirent.name}` : dirent.name;
141
- const entry = buildEntry({ relativePath, fullPath, dirent, depth, kind });
130
+ const entry = { name: dirent.name, path: fullPath, relativePath, depth, kind, dirent };
142
131
  if (options.include?.(entry) ?? true) {
143
132
  result.entries.push(entry);
144
133
  }
@@ -229,7 +218,7 @@ export async function walkDirectory(rootDir, options = {}) {
229
218
  if (!kind)
230
219
  continue;
231
220
  const relativePath = relativeDir ? `${relativeDir}${path.sep}${dirent.name}` : dirent.name;
232
- const entry = buildEntry({ relativePath, fullPath, dirent, depth, kind });
221
+ const entry = { name: dirent.name, path: fullPath, relativePath, depth, kind, dirent };
233
222
  const include = options.include;
234
223
  const included = include == null ? true : Reflect.apply(include, options, [entry]);
235
224
  if ((isObjectResult(included) ? await included : included) ?? true) {
@@ -1,7 +1,7 @@
1
1
  import { type NativeBinding } from "./native.js";
2
2
  import type { RootContext } from "./root-context.js";
3
3
  import type { DirectoryIdentity, WatchSnapshot } from "./watch-scan.js";
4
- import type { WatchStreamPaths } from "./watch-stream.js";
4
+ import type { WatchScope } from "./watch-types.js";
5
5
  export type NativeWatchHint = {
6
6
  directory: string;
7
7
  name: string;
@@ -50,6 +50,6 @@ export declare class NativeWatchBackend {
50
50
  add(name: string, identity: DirectoryIdentity): void;
51
51
  testEvent(path: string, flags: number): void;
52
52
  entries(snapshot: WatchSnapshot): boolean;
53
- configure(paths: WatchStreamPaths): boolean;
53
+ configure(snapshot: WatchSnapshot, scopes: readonly WatchScope[]): boolean;
54
54
  close(): void;
55
55
  }
@@ -1,6 +1,7 @@
1
1
  import { FsSafeError } from "./errors.js";
2
2
  import { getNativeBinding } from "./native.js";
3
3
  import { getFsSafeNativeConfig } from "./native-config.js";
4
+ import { watchStreamPaths } from "./watch-stream.js";
4
5
  export function watchBinding(mode) {
5
6
  if (mode === "poll")
6
7
  return;
@@ -61,9 +62,10 @@ export class NativeWatchBackend {
61
62
  throw watchError(cause);
62
63
  }
63
64
  }
64
- configure(paths) {
65
+ configure(snapshot, scopes) {
65
66
  if (process.platform !== "darwin")
66
67
  return false;
68
+ const paths = watchStreamPaths(snapshot, scopes);
67
69
  const key = JSON.stringify(paths);
68
70
  if (this.streamPaths === key)
69
71
  return false;
@@ -55,15 +55,9 @@ export function mergeWatchRescan(scopes, before, slice, requested) {
55
55
  for (const name of slice.childPaths?.get(directory)?.values() ?? [])
56
56
  selected.add(name);
57
57
  }
58
- for (const [name, previous] of before.entries) {
59
- if (!selected.has(name))
60
- continue;
61
- const current = slice.entries.get(name);
62
- if ((previous.startsWith("directory:") || current?.startsWith("directory:")) && identity(previous) !== identity(current))
63
- return;
64
- }
65
- for (const [name, current] of slice.entries) {
66
- if (selected.has(name) && current.startsWith("directory:") && identity(current) !== identity(before.entries.get(name)))
58
+ for (const name of selected) {
59
+ const previous = before.entries.get(name), current = slice.entries.get(name);
60
+ if ((previous?.startsWith("directory:") || current?.startsWith("directory:")) && identity(previous) !== identity(current))
67
61
  return;
68
62
  }
69
63
  // Exclusion changes can retire or admit a subtree, including callback-driven changes.
@@ -5,7 +5,6 @@ import { rootHandleContext } from "./root-handle-context.js";
5
5
  import { createSuppressedError } from "./suppressed-error.js";
6
6
  import { getFsSafeTestHooks } from "./test-hooks.js";
7
7
  import { mergeWatchRescan, watchRescanScopes } from "./watch-rescan.js";
8
- import { watchStreamPaths } from "./watch-stream.js";
9
8
  import { admittedNativeChanges, changedEntries, excludedWatchPath, guardedHintChanges, scopedChanges } from "./watch-hints.js";
10
9
  import path from "node:path";
11
10
  import { watchBinding, NativeWatchBackend } from "./watch-native.js";
@@ -338,7 +337,7 @@ export function watch(root, input) {
338
337
  }, maxPendingPaths, persistent);
339
338
  backend = candidate;
340
339
  if (snapshot)
341
- candidate.configure(watchStreamPaths(snapshot, g.scopes));
340
+ candidate.configure(snapshot, g.scopes);
342
341
  const hookResult = getFsSafeTestHooks()?.afterWatchBackendCreated?.(context.rootReal, batch => {
343
342
  if (backend === candidate)
344
343
  onHint(g, batch);
@@ -401,7 +400,7 @@ export function watch(root, input) {
401
400
  }
402
401
  try {
403
402
  const entriesChanged = backend?.entries(next);
404
- const streamsChanged = backend?.configure(watchStreamPaths(next, g.scopes));
403
+ const streamsChanged = backend?.configure(next, g.scopes);
405
404
  if (entriesChanged || streamsChanged) {
406
405
  // Complete a guarded pass with the new transport before publishing readiness.
407
406
  // Bound handovers under churn; later passes still reconcile ongoing changes.
@@ -82,6 +82,7 @@ concurrent namespace changes.
82
82
  | `appendRegularFile`, `appendRegularFileSync`, `readRegularFile`, `readRegularFileSync`, `statRegularFile`, `statRegularFileSync`, `resolveRegularFileAppendFlags`, `AppendRegularFileOptions`, `RegularFileStatResult` | [regular-file.md](regular-file.md) | Type-checked regular-file I/O. |
83
83
  | `retainEntryForPublication`, `RetainedEntryPublication`, `RetainEntryForPublicationOptions`, `EntryPublicationReceipt`, `EntryPublicationResult`, `EntryPublicationIssue`, `PublicationIdentity`, `PublicationParent` | [One-way entry publication](entry-publication.md) | Native no-replace directory/regular-file/symlink export, including Windows NTFS junctions, under caller-exclusive namespace; explicit transition receipts, close-only disposal, no source CAS. |
84
84
  | `retainFileInDirectory`, `RetainedFile`, related receipt/result types | [Retained Windows files](retained-file.md) | Existing-file native handle custody and explicit removal; local NTFS, producer authority required, no persistence guarantee. |
85
+ | `copyRootFileSync`, `createRootFileCopyBatchSync`, `RootFileCopyBatchSync`, `CopyRootFileSyncOptions`, `CopiedRootFileSync` | [copy.md](copy.md#synchronous-guarded-file-copies) | Synchronous guarded exclusive file copy, optionally cloned and bound to an expected source identity, returning an owned destination descriptor and both identities. |
85
86
  | `sameFileIdentity`, `FileIdentityStat` | – | Compare two stats for same-inode equality. |
86
87
  | `readDirectoryIdentity`, `assertDirectoryIdentitySync`, `DirectoryIdentity` | [directory-identity.md](directory-identity.md) | Observe exact bigint directory identity and synchronously check a caller-selected path, optionally retaining its canonical path. |
87
88
  | `pathExists`, `pathExistsSync` | – | Boolean existence check that does not throw on `ENOENT`. |
@@ -179,7 +179,8 @@ between native and JavaScript paths rather than reimplementing it in Rust.
179
179
 
180
180
  ZIP extraction and bounded reads admit every physical central-directory record and its referenced local header before either decoder can normalize or collapse names. Raw names and valid Unicode Path names must pass traversal checks before stripping, filtering, or selecting a requested member; duplicate or colliding names reject with `entry-path`, even in unrelated or skipped members. Materially conflicting local/central or Unicode interpretations, malformed critical metadata, and ambiguous framing reject with `ArchiveFormatError`. Harmless internal separator and dot-component equivalence is allowed only after validation; every raw and Unicode interpretation must also agree on whether its name ends in `/` or `\`. Ordinary legacy filename decoding remains backend-selected. Native ZIP extraction groups nearby metadata reads into at most two 4 KiB read-ahead buffers per admission pass; larger records retain separately bounded reads. Buffered record views remain stable across eviction, and cached work periodically yields for deadline checks.
181
181
 
182
- ZIP preflight and bounded reads apply extraction's case-insensitive NFC collision
182
+ ZIP preflight and bounded reads use extraction's canonical path identity, including
183
+ removing directory trailing separators, and apply its case-insensitive NFC collision
183
184
  policy to the complete archive, including entries that extraction would strip
184
185
  or skip. Known UTF-8 and Unicode Path names are checked during physical admission;
185
186
  legacy names retain backend-selected decoding and undergo the same collision
@@ -701,6 +702,9 @@ await extractArchive({
701
702
 
702
703
  ## `readArchiveEntry`
703
704
 
705
+ The byte limit is selected and validated once before reading the archive; changing
706
+ the options object during a read does not change the active limit.
707
+
704
708
  `readArchiveEntry(archivePath, entryPath, { maxBytes, kind? })` reads one
705
709
  regular-file entry into a bounded `Buffer` without extracting a tree. It reads
706
710
  the input through an identity-checked descriptor, rejects a requested link or
@@ -16,6 +16,11 @@ import {
16
16
 
17
17
  Write `content` to a sibling temp file in the destination directory, apply the parent-directory and final file modes through verified descriptors, optionally `fsync` the file descriptor, optionally `fsync` the parent directory after rename, then atomically rename over the destination. No permission change follows a caller-supplied pathname.
18
18
 
19
+ On POSIX, non-durable asynchronous writes using Node inspect the staged file's
20
+ complete permission bits with their final identity check and skip a redundant
21
+ mode change when they already match. A restrictive umask or differing special
22
+ bits still require descriptor mode correction before publication.
23
+
19
24
  On POSIX, the parent is opened with no-follow and directory-only flags, checked against its exact pre-open device/inode identity, and mode-adjusted through that descriptor. A replacement symlink is rejected rather than followed. If the directory cannot be opened for descriptor access, the operation fails closed instead of retrying by pathname. Windows does not enforce POSIX directory modes and Node cannot consistently open directory descriptors there, so `dirMode` is passed only to `mkdir`; no pathname `chmod` fallback is attempted.
20
25
 
21
26
  Async replacements to the same destination are serialized inside the current process, so two overlapping `replaceFileAtomic()` calls do not interleave their temp-write/rename phases. Use a sidecar lock when multiple processes may write the same target.
@@ -258,7 +263,9 @@ opened, removed, or replaced. A successor at that name is left untouched.
258
263
  `details.cleanup: "restored"` means the retained original file's bytes and mode
259
264
  were restored and synchronized, not that the pathname still names it. The existing
260
265
  `writing` receipt identifies that file; no `published` receipt is emitted for a
261
- failed replacement. Restoration I/O failures report `"restore-failed"`.
266
+ failed replacement. Restoration I/O failures report `"restore-failed"`, even if
267
+ the thrown error itself carries a `cleanup: "restored"` receipt from another
268
+ operation; the cause retains both the replacement and restoration failures.
262
269
 
263
270
  Callback refusals, detected identity/type/link changes, and other metadata errors
264
271
  remain terminal. Failed descriptor revalidation also stops restoration. These
@@ -539,8 +546,9 @@ remain a best-effort sequence, not atomic.
539
546
  `onDestinationPublished` runs exactly once after a successful rename resolves,
540
547
  before awaited post-rename directory checks or source cleanup. It receives a
541
548
  frozen receipt with the resolved absolute destination path and the exact bigint
542
- device/inode identity observed on the rename source before dispatch: the
543
- original source for a direct rename, or the staged copy for fallback. Failed
549
+ device/inode identity observed on the rename source after the synchronous
550
+ authority callbacks return and before dispatch: the source selected for a direct
551
+ rename, or the staged copy for fallback. Failed
544
552
  rename attempts never emit receipts. The return contract remains `Promise<void>`.
545
553
 
546
554
  The receipt records an observed identity, not authorization, durable storage,
@@ -175,7 +175,7 @@ temporary storage; session scratch trees may suppress macOS filesystem events.
175
175
  ### Optional Linux Testbox
176
176
 
177
177
  The manual `testbox-validation.yml` workflow prepares a 16-vCPU Ubuntu 24.04
178
- Blacksmith Testbox with Node 24.21.0, pnpm 12.4.2, dependencies, the Rust WASM
178
+ Blacksmith Testbox with Node 24.21.0, pnpm 12.10.1, dependencies, the Rust WASM
179
179
  target, and the pinned portable archive compiler. It leaves library builds and
180
180
  validation commands to the caller and does not replace required CI checks.
181
181
 
@@ -58,7 +58,7 @@ Apple [strongly discourages general directory cloning](https://github.com/apple-
58
58
  | `"always"` | Require native cloning. Unsupported operations fail without a byte-copy fallback. |
59
59
  | `"never"` | Copy regular file bytes using reads and writes. No native cloning or copy-offload calls. Works without a native binding. |
60
60
 
61
- Automatic copying does not recover from permission errors, I/O errors, cancellation, or rejected source contents such as ReFS named streams. A failed clone must leave the destination absent before fallback can create it; otherwise copying fails rather than merging into a partial tree.
61
+ On Linux XFS and ZFS, `FICLONE` capability failures (including an ioctl denied by container seccomp or LSM policy with `EPERM`) select byte copying in `"auto"` mode; `"always"` reports `unsupported-platform`. Automatic copying does not recover from ordinary file-access permission errors, I/O errors, cancellation, or rejected source contents such as ReFS named streams. Native reflink workers settle and remove partial output before returning a clone failure. A failed clone must leave the destination absent before fallback can create it; otherwise copying fails rather than merging into a partial tree.
62
62
 
63
63
  `concurrency` accepts integers from 1 through 32 and bounds active file copies. ReFS, XFS, and ZFS cloning default to 16 workers. Byte copying defaults to four concurrent files on Windows and one elsewhere. Btrfs uses its bulk operation. APFS uses a bulk clone followed by native directory-entry enumeration to restore directory timestamps; known regular files and symbolic links need no additional stat or open.
64
64
 
@@ -197,3 +197,111 @@ Run `node scripts/clone-xfs-proof.mjs MOUNT` on a real XFS volume to verify the
197
197
  Run `node scripts/clone-zfs-proof.mjs MOUNT POOL` on a dedicated, otherwise idle Linux ZFS pool with compression and deduplication disabled. It verifies both `copyTree` and `Root.copyIn` through hashes and changes in the documented `bclonesaved` pool counter. It requires `zfs`, `zpool`, and `findmnt`, including permission to run `zpool sync`. Add `no-reflink` for a pool without block cloning to verify strict refusal and automatic byte fallback. The script creates and removes only its temporary directory; it does not create pools or change their properties.
198
198
 
199
199
  Run `node benchmarks/clone.mjs SOURCE DESTINATION_PARENT` after `pnpm build` to compare one, four, and 16 workers on the same immutable source. Add `3 auto` or `3 never` to measure three samples of ordinary copying, including NTFS destinations. It records copying time separately from fixture preparation and full file-hash verification, and retains its uniquely named output directory for inspection. Prepare Btrfs sources with `createCloneSource` first.
200
+
201
+ ## Synchronous guarded file copies
202
+
203
+ `copyRootFileSync` from `@openclaw/fs-safe/advanced` is for synchronous module
204
+ hooks and capture owners. Both roots and file paths are absolute; destination
205
+ parents must exist. The destination must be absent, and is created exclusively.
206
+
207
+ ```ts
208
+ import { copyRootFileSync } from "@openclaw/fs-safe/advanced";
209
+
210
+ using copied = copyRootFileSync({
211
+ source: { rootPath: "/srv/plugins", absolutePath: "/srv/plugins/plugin.js" },
212
+ destination: { rootPath: "/srv/capture", absolutePath: "/srv/capture/plugin.js" },
213
+ clone: "auto",
214
+ maxBytes: 16 * 1024 * 1024,
215
+ mode: 0o600,
216
+ });
217
+ // Hash or read copied.fd: this is the owned destination, never a reopened path.
218
+ console.log(copied.bytes, copied.method, copied.identity, copied.sourceIdentity);
219
+ ```
220
+
221
+ `CopyRootFileSyncOptions` selects the source and destination, `clone`, `maxBytes`,
222
+ `mode`, `preserveSourceMode`, `sourceHardlinks`, and optional `expectedSourceIdentity`.
223
+ `CopiedRootFileSync` owns a
224
+ readable/writable `fd`, with idempotent `close()` and `Symbol.dispose`, and returns
225
+ `path`, `bytes`, `method` (`"clone"`, `"copy-file-range"`, or `"copy"`), and exact
226
+ bigint destination `identity` and `sourceIdentity` (`dev`, `ino`). Both identities
227
+ are readonly and frozen. Closing releases the descriptor, not the file.
228
+
229
+ Callers that already pinned a source can pass `expectedSourceIdentity: { dev, ino }`
230
+ from `fs.fstatSync(pinnedSourceFd, { bigint: true })`. fs-safe still admits and
231
+ opens its own source descriptor, and compares its exact bigint identity before
232
+ creating any destination. A mismatch throws `FsSafeError("path-mismatch")`.
233
+ Keep the caller's descriptor open across the call; it remains caller-owned and
234
+ its cursor is unchanged. Do not derive the expectation from numeric stat fields,
235
+ which may have lost identity bits. Omitting the option preserves ordinary source
236
+ admission. Every successful copy method returns `sourceIdentity` for the source
237
+ actually copied, verified current again at the end of the operation. This binds
238
+ identity only; keep any existing source fingerprint/content and destination
239
+ receipt checks for concurrent edits and later replacements.
240
+
241
+ Clone policy matches `Root.copyIn`: the default is `"never"`; `"auto"` tries
242
+ native cloning, then bounded copy offload and byte copying on capability failure;
243
+ `"always"` requires cloning or throws `unsupported-platform`. APFS cloning
244
+ creates the guarded destination name from the admitted source descriptor. Linux
245
+ uses FICLONE and copy_file_range. Windows and native-disabled operation copy
246
+ bytes; they cannot satisfy `"always"`.
247
+
248
+ The source descriptor remains open through before/after source identity checks.
249
+ Symlink components are rejected; source hardlinks are rejected unless
250
+ `sourceHardlinks: "allow"` is selected. The byte limit is an EOF bound, not a
251
+ prefix length: overflow throws `too-large`. Omitted limits are unlimited. An
252
+ explicit mode wins over `preserveSourceMode`; otherwise new files use `0o600`
253
+ subject to the process umask, consistently with `copyIn`.
254
+
255
+ Failures attempt identity-checked removal of the owned destination and preserve
256
+ replacement entries. This helper creates directly at the destination name, so
257
+ other processes may observe partial bytes until it returns. It does not promise
258
+ crash durability or a coherent snapshot of concurrent in-place source edits.
259
+ Use a private capture directory and keep sources unchanged while capturing.
260
+
261
+ ### Batches of synchronous guarded copies
262
+
263
+ `createRootFileCopyBatchSync()` on `advanced` returns a disposable
264
+ `RootFileCopyBatchSync`. Its `copyFile(options)` takes the same
265
+ `CopyRootFileSyncOptions` and returns the same owned `CopiedRootFileSync` as
266
+ `copyRootFileSync`:
267
+
268
+ ```ts
269
+ import { createRootFileCopyBatchSync } from "@openclaw/fs-safe/advanced";
270
+
271
+ using batch = createRootFileCopyBatchSync();
272
+ for (const name of ["plugin.js", "package.json"]) {
273
+ using copied = batch.copyFile({
274
+ source: { rootPath: "/srv/plugins", absolutePath: `/srv/plugins/${name}` },
275
+ destination: { rootPath: "/srv/capture", absolutePath: `/srv/capture/${name}` },
276
+ clone: "auto",
277
+ maxBytes: 16 * 1024 * 1024,
278
+ });
279
+ console.log(copied.bytes, copied.sourceIdentity);
280
+ }
281
+ ```
282
+
283
+ On canonical POSIX paths, the batch retains root and ancestor directory
284
+ identities across files. Every use still checks those identities without
285
+ following directory symlinks; root and parent canonical paths are rechecked.
286
+ Source opens, source and destination file identities, hardlink policy, exclusive
287
+ creation, bounds, permissions, and cleanup are checked separately for every file.
288
+ No source descriptor, file contents, or successful file-admission result is cached.
289
+ An ancestor replacement rejects even when the leaf parent has not changed.
290
+ Windows and noncanonical path spellings keep the full per-file resolver and its
291
+ alias handling. There is no option to bypass validation.
292
+
293
+ Keep the batch scoped to one capture or materialization, with unchanged source
294
+ and destination directory topology. `close()` and `Symbol.dispose` idempotently
295
+ release its retained metadata; later calls reject with `invalid-path`. Returned
296
+ file descriptors remain independently caller-owned. Successful earlier files
297
+ are not rolled back if a later copy fails. A failed copy keeps the ordinary
298
+ identity-checked cleanup contract; the batch is not an atomic tree transaction
299
+ or a coherent snapshot of concurrent edits. The single-file helper also reuses
300
+ directory admissions for the duration of its one copy.
301
+
302
+ Both APIs reuse the freshly checked canonical root during initial POSIX path
303
+ admission. This avoids repeatedly resolving every absolute root component while
304
+ retaining lexical traversal and the before/after root and parent identity checks.
305
+ Windows and root aliases retain the ordinary resolver, including its handling
306
+ of dot segments in symlink targets. No caller changes are needed for the
307
+ single-file improvement.