@openclaw/openshell-sandbox 2026.10.1-beta.2 → 2026.10.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 (68) hide show
  1. package/node_modules/@openclaw/fs-safe/CHANGELOG.md +56 -0
  2. package/node_modules/@openclaw/fs-safe/dist/advanced.d.ts +1 -0
  3. package/node_modules/@openclaw/fs-safe/dist/advanced.js +1 -0
  4. package/node_modules/@openclaw/fs-safe/dist/archive-entry.js +7 -3
  5. package/node_modules/@openclaw/fs-safe/dist/copy-root-file-sync.d.ts +35 -0
  6. package/node_modules/@openclaw/fs-safe/dist/copy-root-file-sync.js +218 -0
  7. package/node_modules/@openclaw/fs-safe/dist/create.d.ts +1 -0
  8. package/node_modules/@openclaw/fs-safe/dist/create.js +4 -1
  9. package/node_modules/@openclaw/fs-safe/dist/entry-publication.js +3 -1
  10. package/node_modules/@openclaw/fs-safe/dist/errors.d.ts +2 -0
  11. package/node_modules/@openclaw/fs-safe/dist/errors.js +5 -0
  12. package/node_modules/@openclaw/fs-safe/dist/index.d.ts +1 -1
  13. package/node_modules/@openclaw/fs-safe/dist/index.js +1 -1
  14. package/node_modules/@openclaw/fs-safe/dist/native-binding.d.ts +3 -0
  15. package/node_modules/@openclaw/fs-safe/dist/native-move-fallback.d.ts +3 -0
  16. package/node_modules/@openclaw/fs-safe/dist/native-move-fallback.js +54 -0
  17. package/node_modules/@openclaw/fs-safe/dist/native-noreplace.d.ts +8 -0
  18. package/node_modules/@openclaw/fs-safe/dist/native-noreplace.js +55 -0
  19. package/node_modules/@openclaw/fs-safe/dist/native-pinned-write.d.ts +1 -1
  20. package/node_modules/@openclaw/fs-safe/dist/native-pinned-write.js +12 -2
  21. package/node_modules/@openclaw/fs-safe/dist/native-rename-outcome.js +3 -1
  22. package/node_modules/@openclaw/fs-safe/dist/native-staged-file.d.ts +1 -1
  23. package/node_modules/@openclaw/fs-safe/dist/native-staged-file.js +48 -5
  24. package/node_modules/@openclaw/fs-safe/dist/pinned-write-input.d.ts +2 -0
  25. package/node_modules/@openclaw/fs-safe/dist/pinned-write-input.js +17 -0
  26. package/node_modules/@openclaw/fs-safe/dist/pinned-write.js +1 -1
  27. package/node_modules/@openclaw/fs-safe/dist/publish-file-failure.d.ts +5 -0
  28. package/node_modules/@openclaw/fs-safe/dist/publish-file-failure.js +1 -0
  29. package/node_modules/@openclaw/fs-safe/dist/publish-file-native.d.ts +4 -0
  30. package/node_modules/@openclaw/fs-safe/dist/publish-file-native.js +35 -0
  31. package/node_modules/@openclaw/fs-safe/dist/publish-file.d.ts +2 -0
  32. package/node_modules/@openclaw/fs-safe/dist/publish-file.js +8 -2
  33. package/node_modules/@openclaw/fs-safe/dist/replace-directory.js +6 -5
  34. package/node_modules/@openclaw/fs-safe/dist/root-errors.js +25 -4
  35. package/node_modules/@openclaw/fs-safe/dist/root-impl.js +3 -3
  36. package/node_modules/@openclaw/fs-safe/dist/root-move-noreplace.d.ts +4 -2
  37. package/node_modules/@openclaw/fs-safe/dist/root-move-noreplace.js +13 -17
  38. package/node_modules/@openclaw/fs-safe/dist/sibling-staged-file.js +18 -3
  39. package/node_modules/@openclaw/fs-safe/dist/staged-file-settlement.js +4 -1
  40. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-owner.d.ts +1 -0
  41. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-owner.js +27 -4
  42. package/node_modules/@openclaw/fs-safe/docs/advanced.md +1 -0
  43. package/node_modules/@openclaw/fs-safe/docs/archive.md +2 -1
  44. package/node_modules/@openclaw/fs-safe/docs/copy.md +61 -1
  45. package/node_modules/@openclaw/fs-safe/docs/durability.md +50 -5
  46. package/node_modules/@openclaw/fs-safe/docs/errors.md +37 -0
  47. package/node_modules/@openclaw/fs-safe/docs/native-helper.md +5 -3
  48. package/node_modules/@openclaw/fs-safe/docs/native.md +39 -6
  49. package/node_modules/@openclaw/fs-safe/docs/public-api.md +3 -1
  50. package/node_modules/@openclaw/fs-safe/docs/staged-file.md +10 -3
  51. package/node_modules/@openclaw/fs-safe/docs/watch.md +3 -1
  52. package/node_modules/@openclaw/fs-safe/docs/writing.md +40 -11
  53. package/node_modules/@openclaw/fs-safe/package.json +11 -11
  54. package/node_modules/@openclaw/fs-safe-darwin-arm64/fs-safe-native.node +0 -0
  55. package/node_modules/@openclaw/fs-safe-darwin-arm64/package.json +1 -1
  56. package/node_modules/@openclaw/fs-safe-darwin-x64/fs-safe-native.node +0 -0
  57. package/node_modules/@openclaw/fs-safe-darwin-x64/package.json +1 -1
  58. package/node_modules/@openclaw/fs-safe-linux-arm64-gnu/fs-safe-native.node +0 -0
  59. package/node_modules/@openclaw/fs-safe-linux-arm64-gnu/package.json +1 -1
  60. package/node_modules/@openclaw/fs-safe-linux-arm64-musl/fs-safe-native.node +0 -0
  61. package/node_modules/@openclaw/fs-safe-linux-arm64-musl/package.json +1 -1
  62. package/node_modules/@openclaw/fs-safe-linux-x64-gnu/fs-safe-native.node +0 -0
  63. package/node_modules/@openclaw/fs-safe-linux-x64-gnu/package.json +1 -1
  64. package/node_modules/@openclaw/fs-safe-linux-x64-musl/fs-safe-native.node +0 -0
  65. package/node_modules/@openclaw/fs-safe-linux-x64-musl/package.json +1 -1
  66. package/node_modules/@openclaw/fs-safe-win32-x64-msvc/fs-safe-native.node +0 -0
  67. package/node_modules/@openclaw/fs-safe-win32-x64-msvc/package.json +1 -1
  68. package/package.json +5 -5
@@ -59,12 +59,14 @@ import path from "node:path";
59
59
  import { FsSafeError } from "./errors.js";
60
60
  import { assertSynchronousCallbackResult, isMutationAuthorityError } from "./mutation-authority.js";
61
61
  import { captureNativeFdClose } from "./native-binding.js";
62
- import { writePinnedInput } from "./pinned-write-input.js";
62
+ import { replayPinnedInput, writePinnedInput } from "./pinned-write-input.js";
63
63
  import { assertNativeCopyCompleted, createNativeCopyFile } from "./copy-file-input.js";
64
64
  import { assertStagedDirectoryCurrent, openStagedDirectory } from "./staged-directory.js";
65
65
  import { assertFinalSymlinkRejected } from "./root-symlink-policy.js";
66
66
  import { classifyNativeRenameFailure } from "./native-rename-outcome.js";
67
67
  import { createStagedFileReceipt, stagedFailure } from "./staged-file-settlement.js";
68
+ import { noReplaceUnavailable, rememberNoReplaceUnavailable } from "./native-noreplace.js";
69
+ import { isFsSafeNativeRequired } from "./native-config.js";
68
70
  export function assertNativeStaging(binding) {
69
71
  if ([
70
72
  binding.closeOwnedFd,
@@ -93,6 +95,9 @@ class NativeStagedFile {
93
95
  #state = { status: "open", publication: NOT_PUBLISHED };
94
96
  #receipt;
95
97
  #rejectFinalSymlink = false;
98
+ #unsupportedNoReplace;
99
+ #fallbackCleanup;
100
+ #fallbackCleanupError;
96
101
  constructor(owner) {
97
102
  const name = owner.name ?? `.fs-safe-${randomUUID()}.tmp`;
98
103
  assertBasename(name, owner.portableNames !== false);
@@ -121,7 +126,7 @@ class NativeStagedFile {
121
126
  await staged.#prepare(input, maxBytes);
122
127
  return staged;
123
128
  }
124
- static async write(binding, parentFd, closeParentFd, directory, params, parentGuard) {
129
+ static async write(binding, parentFd, closeParentFd, directory, params, parentGuard, fallback) {
125
130
  const env_1 = { stack: [], error: void 0, hasError: false };
126
131
  try {
127
132
  const exclusive = params.overwrite === false && params.input.kind === "buffer" && params.input.stageBeforePublish === false;
@@ -143,9 +148,33 @@ class NativeStagedFile {
143
148
  if (staged.#verifyMode)
144
149
  staged.#assertCurrent();
145
150
  }
146
- const published = exclusive
147
- ? staged.#completePublication(params.basename, false, params.onPublished)
148
- : await staged.publish(params.basename, { overwrite: params.overwrite !== false }, params.onPublished);
151
+ let published;
152
+ try {
153
+ published = exclusive
154
+ ? staged.#completePublication(params.basename, false, params.onPublished)
155
+ : await staged.publish(params.basename, { overwrite: params.overwrite !== false }, params.onPublished);
156
+ }
157
+ catch (error) {
158
+ if (!staged.#unsupportedNoReplace || isFsSafeNativeRequired() ||
159
+ (params.input.kind === "file" && params.input.clone === "always"))
160
+ throw error;
161
+ // Remove only our identity-known stage before re-admitting the JS write.
162
+ // Keep its descriptor alive to replay a one-shot stream in bounded chunks.
163
+ staged.#fallbackCleanup = "failed";
164
+ let removed;
165
+ try {
166
+ removed = binding.removeStagedFile(parentFd, staged.#owner.name, staged.#file());
167
+ staged.#fallbackCleanup = removed;
168
+ }
169
+ catch (cleanupError) {
170
+ staged.#fallbackCleanupError = { error: cleanupError };
171
+ throw error;
172
+ }
173
+ if (removed !== "removed" && removed !== "name-absent")
174
+ throw error;
175
+ rememberNoReplaceUnavailable(binding, parentFd, staged.#unsupportedNoReplace);
176
+ return await fallback(replayPinnedInput(staged.#file(), params.input));
177
+ }
149
178
  const identity = published.staged.identity;
150
179
  try {
151
180
  await params.verifyPublished?.(staged.#file(), identity, parentGuard);
@@ -321,6 +350,15 @@ class NativeStagedFile {
321
350
  catch (error) {
322
351
  // Record before inspecting metadata, whose getters can reenter cleanup.
323
352
  state.publication = Object.freeze({ status: "indeterminate", basename, overwrite });
353
+ // Linux VFS and rename(2): unsupported flags return EINVAL; ENOSYS
354
+ // means no syscall. A regular file renamed to a distinct sibling cannot
355
+ // hit the directory-into-itself EINVAL case. Nothing was published.
356
+ const unavailable = !overwrite ? noReplaceUnavailable(error, "publication", true) : undefined;
357
+ if (unavailable && this.#state === state && state.status === "open") {
358
+ state.publication = NOT_PUBLISHED;
359
+ this.#unsupportedNoReplace = unavailable;
360
+ throw unavailable;
361
+ }
324
362
  // Only explicit pre-dispatch provenance can rule out a committed rename.
325
363
  if (classifyNativeRenameFailure(error) === "uncommitted") {
326
364
  state.publication = NOT_PUBLISHED;
@@ -377,6 +415,11 @@ class NativeStagedFile {
377
415
  if (state.publication.status === "indeterminate") {
378
416
  outcome = "preserved";
379
417
  }
418
+ else if (this.#fallbackCleanup) {
419
+ outcome = this.#fallbackCleanup;
420
+ if (this.#fallbackCleanupError)
421
+ errors.push(this.#fallbackCleanupError.error);
422
+ }
380
423
  else if (state.publication.status === "not-published" && state.fileFd !== undefined) {
381
424
  try {
382
425
  outcome = this.#owner.binding.removeStagedFile(this.#owner.parentFd, this.#owner.name, state.fileFd);
@@ -1,3 +1,5 @@
1
1
  import type { FileHandle } from "node:fs/promises";
2
2
  import type { PinnedWriteInput } from "./pinned-write-types.js";
3
+ /** Replay a consumed stream from an owned, readable stage descriptor. */
4
+ export declare function replayPinnedInput(fd: number, input: PinnedWriteInput): PinnedWriteInput;
3
5
  export declare function writePinnedInput(target: FileHandle | number, input: PinnedWriteInput, maxBytes?: number, assertBeforeMutation?: () => void): Promise<void>;
@@ -1,8 +1,25 @@
1
+ import { readSync } from "node:fs";
1
2
  import { types } from "node:util";
2
3
  import { snapshotByteView } from "./byte-view.js";
3
4
  import { writeCopyFileToFd } from "./copy-file-input.js";
4
5
  import { FsSafeError } from "./errors.js";
5
6
  import { writeAllToFile } from "./write-file-handle.js";
7
+ /** Replay a consumed stream from an owned, readable stage descriptor. */
8
+ export function replayPinnedInput(fd, input) {
9
+ if (input.kind !== "stream")
10
+ return input;
11
+ return { kind: "stream", stageBeforePublish: true, stream: (async function* () {
12
+ let position = 0;
13
+ const buffer = Buffer.alloc(64 * 1024);
14
+ for (;;) {
15
+ const bytes = readSync(fd, buffer, 0, buffer.length, position);
16
+ if (!bytes)
17
+ return;
18
+ position += bytes;
19
+ yield buffer.subarray(0, bytes);
20
+ }
21
+ })() };
22
+ }
6
23
  const isUint8Array = types.isUint8Array;
7
24
  function streamChunkBytes(chunk) {
8
25
  if (Buffer.isBuffer(chunk))
@@ -90,7 +90,7 @@ export async function runPinnedWriteHelper(params) {
90
90
  }
91
91
  const native = getNativeBinding();
92
92
  if (native) {
93
- return await runPinnedWriteNative(native, normalizedParams);
93
+ return await runPinnedWriteNative(native, normalizedParams, input => runPinnedWriteFallback({ ...normalizedParams, input }));
94
94
  }
95
95
  return await runPinnedWriteFallback(normalizedParams);
96
96
  }
@@ -13,6 +13,9 @@ export type PublishFileExclusiveFailureDetails = {
13
13
  targetIdentity?: FileIdentityStat;
14
14
  cleanup: PublishFileExclusiveCleanup;
15
15
  directorySync?: PublishFileExclusiveDirectorySyncFailure;
16
+ fallback?: "link-unlink";
17
+ publication?: "published";
18
+ sourceRemoval?: "not-attempted" | "still-linked" | "unverified" | "removed";
16
19
  };
17
20
  export type PublishFailureState = {
18
21
  phase: PublishFileExclusiveFailurePhase;
@@ -21,6 +24,8 @@ export type PublishFailureState = {
21
24
  targetCleanupIdentity?: FileIdentityStat;
22
25
  preserveTarget: boolean;
23
26
  directorySync?: PublishFileExclusiveDirectorySyncFailure;
27
+ fallback?: PublishFileExclusiveFailureDetails["fallback"];
28
+ sourceRemoval?: PublishFileExclusiveFailureDetails["sourceRemoval"];
24
29
  };
25
30
  export declare function rememberCreatedTarget(state: PublishFailureState, identity: FileIdentityStat, phase: PublishFileExclusiveFailurePhase): void;
26
31
  export declare function publicationFailure(error: unknown, state: PublishFailureState, cleanup: PublishFileExclusiveCleanup): FsSafeError;
@@ -12,6 +12,7 @@ export function publicationFailure(error, state, cleanup) {
12
12
  targetCreated: state.targetCreated,
13
13
  ...(state.targetIdentity ? { targetIdentity: state.targetIdentity } : {}),
14
14
  ...(state.directorySync ? { directorySync: state.directorySync } : {}),
15
+ ...(state.fallback ? { fallback: state.fallback, publication: "published", sourceRemoval: state.sourceRemoval } : {}),
15
16
  cleanup,
16
17
  };
17
18
  return new FsSafeError(error instanceof FsSafeError ? error.code : "helper-failed", `exclusive file publication failed during ${state.phase}: ${cause.message}`, { cause, details });
@@ -0,0 +1,4 @@
1
+ import type { BigIntStats } from "node:fs";
2
+ import type { NativeBinding } from "./native-binding.js";
3
+ import { type PublishFailureState } from "./publish-file-failure.js";
4
+ export declare function publishFileNoReplaceNative(binding: NativeBinding, paths: Parameters<NativeBinding["renameNoReplace"]>, expected: BigIntStats, failure: PublishFailureState): "link-unlink" | undefined;
@@ -0,0 +1,35 @@
1
+ import { FsSafeError } from "./errors.js";
2
+ import { getFsSafeNativeConfig } from "./native-config.js";
3
+ import { nativeMoveNoReplace } from "./native-move-fallback.js";
4
+ import { rememberCreatedTarget } from "./publish-file-failure.js";
5
+ export function publishFileNoReplaceNative(binding, paths, expected, failure) {
6
+ try {
7
+ const fallback = nativeMoveNoReplace(binding, paths, expected, getFsSafeNativeConfig().mode === "auto", "file publication");
8
+ if (fallback) {
9
+ failure.fallback = fallback;
10
+ failure.sourceRemoval = "removed";
11
+ }
12
+ return fallback;
13
+ }
14
+ catch (error) {
15
+ if (error instanceof FsSafeError && error.details?.publication === "published") {
16
+ failure.targetCreated = true;
17
+ failure.preserveTarget = true;
18
+ failure.phase = "rename-verify";
19
+ failure.fallback = "link-unlink";
20
+ failure.sourceRemoval = error.details.sourceRemoval;
21
+ // Changed/unverified pairs cannot establish which inode was published.
22
+ if (failure.sourceRemoval === "still-linked")
23
+ rememberCreatedTarget(failure, expected, "rename-verify");
24
+ }
25
+ if (error instanceof FsSafeError)
26
+ throw error;
27
+ const code = error?.code;
28
+ if (code === "path-mismatch" || code === "hardlink") {
29
+ throw new FsSafeError(code, "publication source changed before fallback", { cause: error });
30
+ }
31
+ if (code === "ELOOP")
32
+ throw new FsSafeError("symlink", "publication source became a symlink", { cause: error });
33
+ throw error;
34
+ }
35
+ }
@@ -5,6 +5,8 @@ import { type PublishFileExclusiveSyncFailurePolicy } from "./publish-file-failu
5
5
  export type PublishFileExclusiveStrategy = "link-or-copy" | "link-required" | "rename-noreplace";
6
6
  export type PublishFileExclusiveResult = {
7
7
  method: "hardlink" | "exclusive-copy" | "rename-noreplace";
8
+ /** Linux auto fallback; exclusive and identity-checked, but not crash-atomic. */
9
+ fallback?: "link-unlink";
8
10
  identity: Stats;
9
11
  directorySync: DirectorySyncOutcome;
10
12
  };
@@ -1,3 +1,4 @@
1
+ import { publishFileNoReplaceNative } from "./publish-file-native.js";
1
2
  import { createHash } from "node:crypto";
2
3
  import fsSync, {} from "node:fs";
3
4
  import fs, {} from "node:fs/promises";
@@ -256,7 +257,7 @@ export async function publishFileExclusive(params) {
256
257
  else
257
258
  assertSourceCurrent();
258
259
  assertPublishedTargetCurrent(targetPath, identity, "publication target changed during directory sync");
259
- return { method, identity: fsSync.fstatSync(handle.fd), directorySync };
260
+ return { method, ...(failure.fallback ? { fallback: failure.fallback } : {}), identity: fsSync.fstatSync(handle.fd), directorySync };
260
261
  };
261
262
  await parent.assertCurrent();
262
263
  assertSourceCurrent();
@@ -269,7 +270,12 @@ export async function publishFileExclusive(params) {
269
270
  }
270
271
  if (strategy === "rename-noreplace") {
271
272
  const binding = requireNativeBinding();
272
- binding.renameNoReplace(sourceNativeParent.handle.fd, sourceNativeParent.basename, targetNativeParent.handle.fd, targetNativeParent.basename);
273
+ publishFileNoReplaceNative(binding, [
274
+ sourceNativeParent.handle.fd,
275
+ sourceNativeParent.basename,
276
+ targetNativeParent.handle.fd,
277
+ targetNativeParent.basename,
278
+ ], sourceExactIdentity, failure);
273
279
  rememberCreatedTarget(failure, sourceExactIdentity, "rename-verify");
274
280
  // A failed post-rename fence must not delete the only remaining name.
275
281
  failure.preserveTarget = true;
@@ -1,3 +1,4 @@
1
+ import { noReplaceUnavailable } from "./native-noreplace.js";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import fsSync from "node:fs";
3
4
  import fs from "node:fs/promises";
@@ -19,7 +20,8 @@ function assertGuardsCurrent(guards) {
19
20
  }
20
21
  function replacementFailure(error, message, details, cause = error) {
21
22
  if (error instanceof FsSafeError) {
22
- return new FsSafeError(error.code, message, { cause, details });
23
+ const code = error.code;
24
+ return new FsSafeError(code, code === "helper-unavailable" ? `${message}: ${error.message}` : message, { cause, details });
23
25
  }
24
26
  const failure = new Error(message, { cause });
25
27
  copyOperationalCode(failure, error);
@@ -46,14 +48,13 @@ function observeOptionalDirectory(pathname) {
46
48
  return inspectDirectoryIdentitySync(pathname, undefined, initial);
47
49
  }
48
50
  function normalizeNativeRenameFailure(error) {
51
+ const unavailable = noReplaceUnavailable(error, "directory rename", true);
52
+ if (unavailable)
53
+ return unavailable;
49
54
  const code = error?.code;
50
55
  if (code === NATIVE_RENAME_SOURCE_IDENTITY_MISMATCH) {
51
56
  return new FsSafeError("path-mismatch", "directory replacement source identity changed before native rename", { cause: error });
52
57
  }
53
- if (code === "EINVAL" || code === "ENOSYS" || code === "ENOTSUP" ||
54
- code === "EOPNOTSUPP") {
55
- return new FsSafeError("helper-unavailable", "native no-replace directory rename is unavailable on this filesystem", { cause: error });
56
- }
57
58
  return error;
58
59
  }
59
60
  function renameRetainedDirectory(params) {
@@ -29,7 +29,7 @@ export function hardlinkedPathNotAllowedError() {
29
29
  export function isAlreadyExistsError(error) {
30
30
  return hasNodeErrorCode(error, "EEXIST") || /File exists|EEXIST/i.test(String(error));
31
31
  }
32
- function descriptorExhaustion(error) {
32
+ function resourceExhaustion(error) {
33
33
  const pending = [error];
34
34
  let details;
35
35
  let code;
@@ -45,7 +45,11 @@ function descriptorExhaustion(error) {
45
45
  const diagnostic = current;
46
46
  if (current instanceof FsSafeError && (!details || current.details?.cleanup))
47
47
  details = current.details ?? details;
48
- if (diagnostic.code === "EMFILE" || diagnostic.code === "ENFILE")
48
+ if (diagnostic.code === "EMFILE" || diagnostic.code === "ENFILE") {
49
+ if (!code || code === "ENOSPC")
50
+ code = diagnostic.code;
51
+ }
52
+ else if (diagnostic.code === "ENOSPC")
49
53
  code ??= diagnostic.code;
50
54
  if (diagnostic.name === "SuppressedError")
51
55
  pending.push(diagnostic.suppressed, diagnostic.error);
@@ -55,15 +59,32 @@ function descriptorExhaustion(error) {
55
59
  }
56
60
  if (!code)
57
61
  return;
62
+ // Disk-space diagnostics retain the published direct-write code and already-classified errors.
63
+ if (code === "ENOSPC" && error instanceof FsSafeError)
64
+ return;
58
65
  const cleanup = details?.cleanup;
59
66
  const publication = details?.publication;
60
67
  const preserved = cleanup?.status === "preserved" && publication?.status === "indeterminate";
61
- return new FsSafeError("helper-failed", `filesystem write failed: too many open files (${code})${preserved ? "; publication outcome is indeterminate; staged file preserved" : ""}`, { cause: error, details });
68
+ const message = code === "ENOSPC" ? PINNED_WRITE_ERRNO_MESSAGES.get(code) : "filesystem write failed: too many open files";
69
+ return new FsSafeError(code === "ENOSPC" ? "invalid-path" : "helper-failed", `${message} (${code})${preserved ? "; publication outcome is indeterminate; staged file preserved" : ""}`, { cause: error, details });
62
70
  }
63
71
  export function normalizePinnedWriteError(error) {
64
- const exhausted = descriptorExhaustion(error);
72
+ const exhausted = resourceExhaustion(error);
65
73
  if (exhausted)
66
74
  return exhausted;
75
+ // Disposal retains cleanup evidence, but the failed publication remains primary.
76
+ let primary = error;
77
+ const seen = new Set();
78
+ try {
79
+ while (primary instanceof Error && primary.name === "SuppressedError" && !seen.has(primary)) {
80
+ seen.add(primary);
81
+ primary = primary.suppressed;
82
+ }
83
+ if (primary !== error && primary instanceof FsSafeError && primary.details?.phase === "publish") {
84
+ return new FsSafeError(primary.code, primary.message, { cause: error, details: primary.details });
85
+ }
86
+ }
87
+ catch { /* Unreadable diagnostics retain the original failure. */ }
67
88
  if (error instanceof FsSafeError) {
68
89
  return error;
69
90
  }
@@ -1167,10 +1167,10 @@ async function movePathFallback(root, params) {
1167
1167
  let sourceIdentity;
1168
1168
  try {
1169
1169
  if (params.overwrite && params.assertBeforeMutation) {
1170
- sourceIdentity = inspectFileIdentitySync(() => admitMoveSourceStat(fsSync.lstatSync(source.resolved, { bigint: true }), true));
1170
+ sourceIdentity = inspectFileIdentitySync(() => admitMoveSourceStat(fsSync.lstatSync(source.resolved, { bigint: true })));
1171
1171
  }
1172
1172
  else {
1173
- admitMoveSourceStat(fsSync.lstatSync(source.resolved), params.overwrite);
1173
+ admitMoveSourceStat(fsSync.lstatSync(source.resolved));
1174
1174
  }
1175
1175
  }
1176
1176
  catch (error) {
@@ -1234,7 +1234,7 @@ async function movePathFallback(root, params) {
1234
1234
  }
1235
1235
  }
1236
1236
  }
1237
- inspectFileIdentitySync(() => admitMoveSourceStat(fsSync.lstatSync(source.resolved, { bigint: true }), true), sourceIdentity);
1237
+ inspectFileIdentitySync(() => admitMoveSourceStat(fsSync.lstatSync(source.resolved, { bigint: true })), sourceIdentity);
1238
1238
  }
1239
1239
  await fs.rename(source.resolved, target.resolved);
1240
1240
  }
@@ -1,8 +1,10 @@
1
1
  import { type BigIntStats, type Stats } from "node:fs";
2
2
  import type { RootMoveOptions } from "./root-options.js";
3
3
  import { type RootContext } from "./root-context.js";
4
- export declare function admitMoveSourceStat<T extends Stats | BigIntStats>(stat: T, overwrite?: boolean): T;
5
- export declare function movePathNative(root: RootContext, params: Pick<RootMoveOptions, "assertBeforeMutation" | "denyMutations" | "mutationSymlinks">, paths: {
4
+ export declare function admitMoveSourceStat<T extends Stats | BigIntStats>(stat: T): T;
5
+ export declare function movePathNative(root: RootContext, params: Pick<RootMoveOptions, "assertBeforeMutation" | "denyMutations" | "mutationSymlinks"> & {
6
+ requireNative: boolean;
7
+ }, paths: {
6
8
  sourcePath: string;
7
9
  sourceParentPath: string;
8
10
  targetPath: string;
@@ -15,6 +15,7 @@ import { PATH_ALIAS_POLICIES } from "./path-policy.js";
15
15
  import { createSuppressedError } from "./suppressed-error.js";
16
16
  import { inspectFileIdentitySync } from "./strict-file-identity.js";
17
17
  import { getFsSafeTestHooks } from "./test-hooks.js";
18
+ import { nativeMoveNoReplace } from "./native-move-fallback.js";
18
19
  function nativeParentRelativePath(rootReal, parentPath) {
19
20
  const relative = path.relative(rootReal, parentPath);
20
21
  if (relative === "" || relative === ".")
@@ -57,22 +58,13 @@ function normalizeMoveError(error) {
57
58
  }
58
59
  return error;
59
60
  }
60
- function normalizeRenameNoReplaceError(error) {
61
- if (error?.code === "EINVAL") {
62
- return new FsSafeError("helper-unavailable", "native no-replace move is unavailable on this filesystem", errorCauseOptions(error));
63
- }
64
- return normalizeMoveError(error);
65
- }
66
- export function admitMoveSourceStat(stat, overwrite = false) {
61
+ export function admitMoveSourceStat(stat) {
67
62
  if (stat.isSymbolicLink()) {
68
63
  throw new FsSafeError("symlink", "symlink not allowed");
69
64
  }
70
65
  if (stat.isFile() && stat.nlink > 1) {
71
66
  throw hardlinkedPathNotAllowedError();
72
67
  }
73
- if (!overwrite && stat.isDirectory()) {
74
- throw new FsSafeError("invalid-path", "directory moves require overwrite: true");
75
- }
76
68
  return stat;
77
69
  }
78
70
  export async function movePathNative(root, params, paths, overwrite = false) {
@@ -124,10 +116,9 @@ export async function movePathNative(root, params, paths, overwrite = false) {
124
116
  await assertRootIdentityCurrent(root);
125
117
  for (const admission of parentAdmissions)
126
118
  assertSyncDirectoryGuard(admission.guard);
127
- // Overwrite publication always binds an exact source; no-clobber retains
128
- // its existing callback-dependent identity contract.
129
- const sourceStat = overwrite || params.assertBeforeMutation
130
- ? inspectFileIdentitySync(() => admitMoveSourceStat(fsSync.lstatSync(admittedSourcePath, { bigint: true }), overwrite), paths.expectedSourceIdentity)
119
+ // Linux auto fallback needs an exact source even without an authority callback.
120
+ const sourceStat = overwrite || params.assertBeforeMutation || (process.platform === "linux" && !params.requireNative)
121
+ ? inspectFileIdentitySync(() => admitMoveSourceStat(fsSync.lstatSync(admittedSourcePath, { bigint: true })), paths.expectedSourceIdentity)
131
122
  : undefined;
132
123
  if (!sourceStat)
133
124
  admitMoveSourceStat(fsSync.lstatSync(admittedSourcePath));
@@ -172,20 +163,25 @@ export async function movePathNative(root, params, paths, overwrite = false) {
172
163
  }
173
164
  }
174
165
  if (sourceStat !== undefined) {
175
- inspectFileIdentitySync(() => admitMoveSourceStat(fsSync.lstatSync(admittedSourcePath, { bigint: true }), overwrite), sourceStat);
166
+ inspectFileIdentitySync(() => admitMoveSourceStat(fsSync.lstatSync(admittedSourcePath, { bigint: true })), sourceStat);
176
167
  }
177
168
  try {
178
169
  assertFinalSymlinkRejected(admittedTargetPath, params.mutationSymlinks !== undefined);
179
170
  if (overwrite)
180
171
  binding.renameReplaceWithIdentity(sourceParent.fd, path.basename(paths.sourcePath), targetParent.fd, path.basename(paths.targetPath), sourceStat.dev, sourceStat.ino);
181
172
  else
182
- binding.renameNoReplace(sourceParent.fd, path.basename(paths.sourcePath), targetParent.fd, path.basename(paths.targetPath));
173
+ nativeMoveNoReplace(binding, [
174
+ sourceParent.fd,
175
+ path.basename(paths.sourcePath),
176
+ targetParent.fd,
177
+ path.basename(paths.targetPath),
178
+ ], sourceStat, !params.requireNative);
183
179
  }
184
180
  catch (error) {
185
181
  if (isAlreadyExistsError(error) || (!overwrite && error?.code === "ENOTEMPTY")) {
186
182
  throw new FsSafeError("already-exists", "destination exists", errorCauseOptions(error));
187
183
  }
188
- throw overwrite ? normalizeMoveError(error) : normalizeRenameNoReplaceError(error);
184
+ throw normalizeMoveError(error);
189
185
  }
190
186
  try {
191
187
  for (const admission of parentAdmissions)
@@ -8,6 +8,8 @@ import { syncDirectoryBestEffort } from "./directory-durability.js";
8
8
  import { isWindowsReservedDeviceName } from "./device-path.js";
9
9
  import { FsSafeError } from "./errors.js";
10
10
  import { getNativeBinding } from "./native.js";
11
+ import { isFsSafeNativeRequired } from "./native-config.js";
12
+ import { cachedNoReplaceDevice, isNoReplaceUnavailable } from "./native-noreplace.js";
11
13
  import { handoffPrivateProducerFile, } from "./private-producer-handoff.js";
12
14
  import { resolveReadOpenFlags } from "./read-open-flags.js";
13
15
  import { rootFromDirectoryGuard } from "./root-impl.js";
@@ -109,8 +111,18 @@ async function writeIsolatedProducer(params) {
109
111
  const result = await Reflect.apply(write, writeReceiver, [producerPath]);
110
112
  assertCurrent();
111
113
  if (native) {
112
- await targetRoot.move(path.relative(targetRoot.rootReal, producerPath), path.basename(tempPath), { assertBeforeMutation: assertCurrent });
113
- return { cleanupWorkspace, result };
114
+ try {
115
+ const cached = cachedNoReplaceDevice(native, parentGuard.stat.dev, "producer handoff");
116
+ if (cached)
117
+ throw cached;
118
+ await targetRoot.move(path.relative(targetRoot.rootReal, producerPath), path.basename(tempPath), { assertBeforeMutation: assertCurrent });
119
+ return { cleanupWorkspace, result };
120
+ }
121
+ catch (error) {
122
+ if (isFsSafeNativeRequired() || !isNoReplaceUnavailable(error))
123
+ throw error;
124
+ assertCurrent();
125
+ }
114
126
  }
115
127
  const handoff = await handoffPrivateProducerFile({
116
128
  sourcePath: producerPath,
@@ -126,7 +138,10 @@ async function writeIsolatedProducer(params) {
126
138
  await cleanupWorkspace();
127
139
  }
128
140
  catch (cleanupError) {
129
- throw new AggregateError([error, cleanupError], "isolated producer operation and workspace cleanup failed");
141
+ const combined = new AggregateError([error, cleanupError], "isolated producer operation and workspace cleanup failed");
142
+ if (isNoReplaceUnavailable(error))
143
+ throw new FsSafeError(error.code, error.message, { cause: combined });
144
+ throw combined;
130
145
  }
131
146
  throw error;
132
147
  }
@@ -1,4 +1,5 @@
1
1
  import { FsSafeError } from "./errors.js";
2
+ import { noReplaceUnavailable } from "./native-noreplace.js";
2
3
  export function createStagedFileReceipt(directory, temporaryBasename, stat) {
3
4
  return Object.freeze({
4
5
  directory,
@@ -11,6 +12,7 @@ export function createStagedFileReceipt(directory, temporaryBasename, stat) {
11
12
  });
12
13
  }
13
14
  export function stagedFailure(kind, error, details) {
15
+ error = noReplaceUnavailable(error, `${kind} publication`) ?? error;
14
16
  let code = "helper-failed";
15
17
  try {
16
18
  code = error instanceof FsSafeError ? error.code
@@ -19,7 +21,8 @@ export function stagedFailure(kind, error, details) {
19
21
  catch {
20
22
  // Uninspectable error metadata must not interrupt terminal settlement.
21
23
  }
22
- return new FsSafeError(code, `staged ${kind} ${details.phase} failed`, { cause: error, details });
24
+ return new FsSafeError(code, code === "helper-unavailable" && error instanceof FsSafeError
25
+ ? error.message : `staged ${kind} ${details.phase} failed`, { cause: error, details });
23
26
  }
24
27
  /** Finish owned cleanup and every close before surfacing publication evidence. */
25
28
  export async function settleStagedFile(params) {
@@ -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
  }
@@ -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`, `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