@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
@@ -1,9 +1,6 @@
1
+ import { type CopyPath } from "./copy-path-admission.js";
1
2
  import { type CopyCloneMode } from "./copy-policy.js";
2
3
  import { type OwnedFileDescriptorSync } from "./create-owned-file.js";
3
- type CopyPath = {
4
- rootPath: string;
5
- absolutePath: string;
6
- };
7
4
  export type CopyRootFileSyncOptions = {
8
5
  source: CopyPath;
9
6
  destination: CopyPath;
@@ -32,4 +29,10 @@ export type CopiedRootFileSync = OwnedFileDescriptorSync & Readonly<{
32
29
  }>;
33
30
  /** Exclusively copies an admitted source and transfers the owned destination fd. */
34
31
  export declare function copyRootFileSync(options: CopyRootFileSyncOptions): CopiedRootFileSync;
35
- export {};
32
+ export type RootFileCopyBatchSync = {
33
+ copyFile(options: CopyRootFileSyncOptions): CopiedRootFileSync;
34
+ close(): void;
35
+ [Symbol.dispose](): void;
36
+ };
37
+ /** Reuses directory admissions while retaining fresh checks for every copy. */
38
+ export declare function createRootFileCopyBatchSync(): RootFileCopyBatchSync;
@@ -1,19 +1,17 @@
1
1
  import fs, {} from "node:fs";
2
2
  import path from "node:path";
3
3
  import { normalizeMaxBytes } from "./byte-budget.js";
4
+ import { createCopyPathAdmissionCache } from "./copy-path-admission.js";
4
5
  import { resolveCopyCloneMode } from "./copy-policy.js";
5
6
  import { createFileWithAdmissionSync } from "./create.js";
6
7
  import { ownFileDescriptorSync } from "./create-owned-file.js";
7
8
  import { creationAdmissionFromParent, removeRecordedCreationFileSync } from "./creation-boundary.js";
8
- import { assertSyncDirectoryGuard, captureDirectoryGuard } from "./directory-guard.js";
9
9
  import { FsSafeError } from "./errors.js";
10
10
  import { assertExclusiveCreateLeaf } from "./exclusive-create.js";
11
11
  import { copyFileDescriptorSync } from "./file-handle-transfer.js";
12
12
  import { captureNativeFdClose } from "./native-binding.js";
13
13
  import { getNativeBinding } from "./native.js";
14
- import { realpathSync } from "./realpath.js";
15
14
  import { openRootFileSync } from "./root-file.js";
16
- import { resolveRootPathSync, ROOT_PATH_ALIAS_POLICIES } from "./root-path.js";
17
15
  import { inspectOpenedPathIdentitySync } from "./root-read-admission.js";
18
16
  import { openStagedDirectory } from "./staged-directory.js";
19
17
  import { inspectFileIdentitySync } from "./strict-file-identity.js";
@@ -26,42 +24,26 @@ function copyError(error) {
26
24
  ["ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EXDEV", "EINVAL"].includes(code ?? "")
27
25
  ? "unsupported-platform" : "helper-failed", "guarded synchronous file copy failed", { cause: error });
28
26
  }
29
- function admitCopyPath(input, create = false) {
30
- if (!path.isAbsolute(input.rootPath) || !path.isAbsolute(input.absolutePath)) {
31
- throw new FsSafeError("invalid-path", "copy roots and paths must be absolute");
32
- }
33
- const root = captureDirectoryGuard(realpathSync(input.rootPath), "native", { bigint: true });
34
- const resolve = () => {
35
- const parent = create ? resolveRootPathSync({
36
- ...input, absolutePath: path.dirname(input.absolutePath), boundaryLabel: "copy parent",
37
- rejectSymlinks: true, rejectFinalSymlink: true,
38
- }) : undefined;
39
- const selected = resolveRootPathSync({
40
- ...input, boundaryLabel: "copy", rejectSymlinks: !create, rejectFinalSymlink: !create,
41
- policy: create ? ROOT_PATH_ALIAS_POLICIES.unlinkTarget : undefined,
42
- });
43
- if (parent && path.dirname(selected.canonicalPath) !== parent.canonicalPath) {
44
- throw new FsSafeError("path-mismatch", "copy destination parent changed");
45
- }
46
- return selected;
47
- };
48
- const selected = resolve();
49
- const parent = captureDirectoryGuard(path.dirname(selected.canonicalPath), "native", { bigint: true });
50
- const assertCurrent = () => {
51
- assertSyncDirectoryGuard(root);
52
- assertSyncDirectoryGuard(parent);
53
- const current = resolve();
54
- if (current.rootCanonicalPath !== root.realPath || current.canonicalPath !== selected.canonicalPath) {
55
- throw new FsSafeError("path-mismatch", "copy path changed during operation");
56
- }
57
- assertSyncDirectoryGuard(root);
58
- assertSyncDirectoryGuard(parent);
59
- };
60
- assertCurrent();
61
- return { path: selected.canonicalPath, parent, assertCurrent };
62
- }
63
27
  /** Exclusively copies an admitted source and transfers the owned destination fd. */
64
28
  export function copyRootFileSync(options) {
29
+ return copyRootFileWithAdmissionSync(options, createCopyPathAdmissionCache());
30
+ }
31
+ /** Reuses directory admissions while retaining fresh checks for every copy. */
32
+ export function createRootFileCopyBatchSync() {
33
+ const cache = createCopyPathAdmissionCache();
34
+ let closed = false;
35
+ const close = () => { closed = true; cache.clear(); };
36
+ return Object.freeze({
37
+ copyFile(options) {
38
+ if (closed)
39
+ throw new FsSafeError("invalid-path", "copy batch is closed");
40
+ return copyRootFileWithAdmissionSync(options, cache);
41
+ },
42
+ close,
43
+ [Symbol.dispose]: close,
44
+ });
45
+ }
46
+ function copyRootFileWithAdmissionSync(options, cache) {
65
47
  // Snapshot caller options before the first filesystem observation.
66
48
  const sourceInput = options.source;
67
49
  const sourcePath = { rootPath: sourceInput.rootPath, absolutePath: sourceInput.absolutePath };
@@ -89,17 +71,16 @@ export function copyRootFileSync(options) {
89
71
  const native = getNativeBinding();
90
72
  const nativeCopy = process.platform !== "win32" && native?.copyFileExclusiveSync && native.removeStagedFile
91
73
  ? native.copyFileExclusiveSync.bind(native) : undefined;
92
- let nativeTarget = false;
93
74
  try {
94
- const source = admitCopyPath(sourcePath);
95
- target = admitCopyPath(targetPath, true);
75
+ const source = cache.admit(sourcePath);
76
+ target = cache.admit(targetPath, true);
96
77
  // Preserve exclusive-create collision semantics for dangling Windows leaves.
97
78
  assertExclusiveCreateLeaf(targetPath.absolutePath);
98
79
  if (fs.lstatSync(target.path, { throwIfNoEntry: false })) {
99
80
  throw new FsSafeError("already-exists", "copy destination already exists");
100
81
  }
101
82
  const opened = openRootFileSync({
102
- ...sourcePath, boundaryLabel: "copy source", maxBytes,
83
+ ...sourcePath, rootRealPath: source.rootCanonicalPath, boundaryLabel: "copy source", maxBytes,
103
84
  rejectHardlinks: sourceHardlinks !== "allow",
104
85
  });
105
86
  if (!opened.ok)
@@ -121,7 +102,6 @@ export function copyRootFileSync(options) {
121
102
  }
122
103
  };
123
104
  const selectedMode = mode ?? (preserveSourceMode ? Number(sourceIdentity.mode & 511n) : 0o600 & ~process.umask());
124
- let method = "copy";
125
105
  let copied;
126
106
  verifySource();
127
107
  target.assertCurrent();
@@ -134,7 +114,6 @@ export function copyRootFileSync(options) {
134
114
  target.assertCurrent();
135
115
  copied = nativeCopy(opened.fd, parent.fd, path.basename(target.path), clone, maxBytes !== undefined && Number.isFinite(maxBytes) ? maxBytes : undefined);
136
116
  targetOwner = ownFileDescriptorSync(copied.fd, closeNative);
137
- nativeTarget = true;
138
117
  }
139
118
  else {
140
119
  if (clone === "always")
@@ -155,13 +134,11 @@ export function copyRootFileSync(options) {
155
134
  if (copied?.errorCode) {
156
135
  throw copyError(Object.assign(new Error(copied.errorMessage), { code: copied.errorCode }));
157
136
  }
158
- if (copied) {
159
- if (!["clone", "copy-file-range", "copy"].includes(copied.method)) {
160
- throw new FsSafeError("helper-failed", "native copy returned an unknown method");
161
- }
162
- method = copied.method;
137
+ const method = copied ? copied.method : "copy";
138
+ if (method !== "clone" && method !== "copy-file-range" && method !== "copy") {
139
+ throw new FsSafeError("helper-failed", "native copy returned an unknown method");
163
140
  }
164
- else {
141
+ if (!copied) {
165
142
  copyFileDescriptorSync(opened.fd, targetOwner.fd, { maxBytes });
166
143
  }
167
144
  verifySource();
@@ -186,7 +163,7 @@ export function copyRootFileSync(options) {
186
163
  const cleanup = [];
187
164
  if (targetOwner && target) {
188
165
  try {
189
- if (nativeTarget && parentOpen) {
166
+ if (parentOpen) {
190
167
  const removed = native.removeStagedFile(parentOwner.fd, path.basename(target.path), targetOwner.fd);
191
168
  if (removed === "preserved")
192
169
  throw new FsSafeError("path-mismatch", "copy cleanup preserved a replacement");
@@ -1,3 +1,6 @@
1
1
  import type { FileLockSyncAcquireOptions, FileLockSyncHandle } from "./file-lock-sync.js";
2
2
  import type { Root } from "./root-impl.js";
3
+ import { type FileLockSyncRootPath } from "./file-lock-sync-root.js";
4
+ import { type FileLockSyncRootFileReceipt } from "./file-lock-sync-root-io.js";
5
+ export declare function cleanupCreatedRootSyncLock(lockRootPath: FileLockSyncRootPath, fd: number, receipt: FileLockSyncRootFileReceipt, timer?: NodeJS.Timeout): void;
3
6
  export declare function acquireFileLockSyncWithRoot<TPayload extends Record<string, unknown>>(targetPath: string, inputOptions: FileLockSyncAcquireOptions<TPayload>, lockRoot: Root): FileLockSyncHandle;
@@ -10,10 +10,40 @@ import { SyncLockAcquisition } from "./file-lock-sync-acquisition.js";
10
10
  import { assertSynchronousCallbackResult } from "./mutation-authority.js";
11
11
  import { assertNoWindowsPathAlias } from "./windows-path-alias.js";
12
12
  import { admitFileLockSyncRootPath, assertFileLockSyncRootPathsCurrent, captureFileLockSyncRootAuthority, normalizeFileLockSyncTargetWithRoot, } from "./file-lock-sync-root.js";
13
- import { cleanupCreatedRootSyncLock, FileLockSyncRootArbitrationCollision, tryReuseCurrentRootSyncHeldLock, } from "./file-lock-sync-root-arbitration.js";
14
13
  import { fileLockSyncRootSnapshotStillCurrent, } from "./file-lock-sync-root-io.js";
15
14
  import { createFileLockSyncRootDirectory, createFileLockSyncRootFile, fileLockSyncRootGuardExists, fileLockSyncRootDirectoryReceiptStillCurrent, refreshFileLockSyncRootFileReceipt, removeFileLockSyncRootDirectory, removeFileLockSyncRootFile, } from "./file-lock-sync-root-mutation.js";
16
- import { createRootSyncHeldLockHandle, ensureRootSyncExitCleanupRegistered, getRootSyncHeldLocks, readRootSidecarSnapshotSync, } from "./file-lock-sync-root-held.js";
15
+ import { createRootSyncHeldLockHandle, ensureRootSyncExitCleanupRegistered, getRootSyncHeldLocks, readRootSidecarSnapshotSync, verifyRootSyncHeldLock, } from "./file-lock-sync-root-held.js";
16
+ class FileLockSyncRootArbitrationCollision extends FsSafeError {
17
+ constructor() {
18
+ super("path-mismatch", "file lock arbitration changed during local creation");
19
+ }
20
+ }
21
+ export function cleanupCreatedRootSyncLock(lockRootPath, fd, receipt, timer) {
22
+ let timerCleanupFailed = false;
23
+ let timerCleanupError;
24
+ try {
25
+ if (timer)
26
+ clearInterval(timer);
27
+ }
28
+ catch (error) {
29
+ timerCleanupFailed = true;
30
+ timerCleanupError = error;
31
+ }
32
+ try {
33
+ fs.closeSync(fd);
34
+ if (!removeFileLockSyncRootFile(lockRootPath, receipt)) {
35
+ throw new FsSafeError("path-mismatch", "created sidecar lock changed before cleanup");
36
+ }
37
+ }
38
+ catch (fileCleanupError) {
39
+ if (timerCleanupFailed) {
40
+ throw createSuppressedError(timerCleanupError, fileCleanupError, "unpublished lock timer and file cleanup both failed");
41
+ }
42
+ throw fileCleanupError;
43
+ }
44
+ if (timerCleanupFailed)
45
+ throw timerCleanupError;
46
+ }
17
47
  export function acquireFileLockSyncWithRoot(targetPath, inputOptions, lockRoot) {
18
48
  // The public dispatcher already read lockRoot exactly once. Validate and
19
49
  // snapshot that genuine Root before any remaining option or retry getter can
@@ -37,23 +67,39 @@ export function acquireFileLockSyncWithRoot(targetPath, inputOptions, lockRoot)
37
67
  const heldLocks = getRootSyncHeldLocks();
38
68
  const currentTargetHolder = () => heldLocks.get(normalizedTargetPath) ??
39
69
  foreignSyncHeldLock("root", normalizedTargetPath);
40
- const arbitration = Object.freeze({
41
- authority,
42
- heldLocks,
43
- lockRootPath,
44
- normalizedTargetPath,
45
- reentrantOwner: options.reentrantOwner,
46
- });
70
+ const tryReuseCurrentHeld = () => {
71
+ const held = heldLocks.get(normalizedTargetPath);
72
+ const reusable = held && !foreignSyncHeldLock("root", normalizedTargetPath) &&
73
+ options.reentrantOwner !== undefined && held.reentrantOwner !== undefined &&
74
+ options.reentrantOwner === held.reentrantOwner &&
75
+ authority.adapter === held.rootAuthority.adapter &&
76
+ lockRootPath.relativePath === held.rootPath.relativePath &&
77
+ path.relative(lockRootPath.path, held.rootPath.path) === "" &&
78
+ held.releaseState !== "released";
79
+ if (!reusable)
80
+ return undefined;
81
+ if (!verifyRootSyncHeldLock(held)) {
82
+ throw new FsSafeError("path-mismatch", "held sidecar lock changed before reentrant reuse");
83
+ }
84
+ // The parser can reenter acquisition; recheck the exact held entry before reuse.
85
+ if (heldLocks.get(normalizedTargetPath) !== held || held.releaseState === "released" ||
86
+ foreignSyncHeldLock("root", normalizedTargetPath)) {
87
+ throw new FsSafeError("path-mismatch", "held sidecar lock changed during reentrant reuse");
88
+ }
89
+ held.refCount += 1;
90
+ held.revision += 1;
91
+ return createRootSyncHeldLockHandle(held);
92
+ };
47
93
  if (heldLocks.has(normalizedTargetPath) && !foreignSyncHeldLock("root", normalizedTargetPath)) {
48
94
  assertFileLockSyncRootPathsCurrent(guardedPaths);
49
- const initiallyReusable = tryReuseCurrentRootSyncHeldLock(arbitration);
95
+ const initiallyReusable = tryReuseCurrentHeld();
50
96
  if (initiallyReusable)
51
97
  return initiallyReusable;
52
98
  }
53
99
  const acquisition = new SyncLockAcquisition(lockPath, normalizedTargetPath, options.retry, options.timeoutMs);
54
100
  let ownedReclaimGuard;
55
101
  let reclaimCleanupAttempted = false;
56
- const reuseCurrentHeld = () => ownedReclaimGuard ? undefined : tryReuseCurrentRootSyncHeldLock(arbitration);
102
+ const reuseCurrentHeld = () => ownedReclaimGuard ? undefined : tryReuseCurrentHeld();
57
103
  const releaseReclaimGuard = () => {
58
104
  const receipt = ownedReclaimGuard;
59
105
  if (!receipt)
@@ -1,16 +1,16 @@
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, assertSyncDirectoryGuard, createAsyncDirectoryGuard, createSyncDirectoryGuard, } from "./directory-guard.js";
4
+ import { assertDirectoryGuard, assertSyncDirectoryGuard, createAsyncDirectoryGuard, createSyncDirectoryGuard, } from "./directory-guard.js";
5
5
  export async function withAsyncDirectoryGuards(guards, mutate, options = {}) {
6
6
  for (const guard of guards) {
7
- await assertAsyncDirectoryGuard(guard);
7
+ assertDirectoryGuard(guard, "native");
8
8
  }
9
9
  const result = await mutate();
10
10
  if (options.verifyAfter !== false) {
11
11
  try {
12
12
  for (const guard of guards) {
13
- await assertAsyncDirectoryGuard(guard);
13
+ assertDirectoryGuard(guard, "native");
14
14
  }
15
15
  }
16
16
  catch (error) {
@@ -46,11 +46,11 @@ export async function guardedRename(params) {
46
46
  const sourceGuard = await createAsyncDirectoryGuard(path.dirname(params.from));
47
47
  const targetGuard = await createAsyncDirectoryGuard(path.dirname(params.to));
48
48
  await withAsyncDirectoryGuards([sourceGuard, targetGuard], async () => {
49
+ // Authority must survive all awaited guards; do not yield before rename dispatch.
50
+ params.assertBeforeRename?.();
49
51
  if (params.onSourceInspected) {
50
52
  params.onSourceInspected(fsSync.lstatSync(params.from, { bigint: true }));
51
53
  }
52
- // Authority must survive all awaited guards; do not yield before rename dispatch.
53
- params.assertBeforeRename?.();
54
54
  await fs.rename(params.from, params.to);
55
55
  params.onRenamed?.();
56
56
  });
@@ -32,6 +32,16 @@ export const GUEST_FILESYSTEM_CREATE_STAGING_PYTHON = [
32
32
  ].join("\n");
33
33
  export const GUEST_FILESYSTEM_RENAME_NO_REPLACE_PYTHON = [
34
34
  "def rename_no_replace(src_parent_fd, src_basename, dst_parent_fd, dst_basename):",
35
+ " import stat",
36
+ " source = os.lstat(src_basename, dir_fd=src_parent_fd)",
37
+ " kind = stat.S_IFMT(source.st_mode)",
38
+ " expected = (source.st_dev, source.st_ino, kind)",
39
+ " def verify(parent_fd, basename, links):",
40
+ " current = os.lstat(basename, dir_fd=parent_fd)",
41
+ " if (current.st_dev, current.st_ino, stat.S_IFMT(current.st_mode)) != expected:",
42
+ " raise OSError(errno.ESTALE, 'no-replace publication identity changed', basename)",
43
+ " if kind == stat.S_IFREG and current.st_nlink != links:",
44
+ " raise OSError(errno.ESTALE, 'no-replace publication link count changed', basename)",
35
45
  " libc = ctypes.CDLL(None, use_errno=True)",
36
46
  " is_linux = sys.platform.startswith('linux')",
37
47
  " if is_linux:",
@@ -62,6 +72,31 @@ export const GUEST_FILESYSTEM_RENAME_NO_REPLACE_PYTHON = [
62
72
  " raise OSError(error_code, os.strerror(error_code), dst_basename)",
63
73
  " elif not is_linux:",
64
74
  " raise OSError(errno.ENOSYS, 'atomic no-replace rename is unavailable')",
75
+ " # Only a definitive rejection of the fixed flags reaches this fallback.",
76
+ " if kind not in (stat.S_IFDIR, stat.S_IFREG):",
77
+ " raise OSError(errno.EINVAL, 'no-replace fallback requires a file or directory', src_basename)",
78
+ " verify(src_parent_fd, src_basename, 1)",
79
+ " try:",
80
+ " os.lstat(dst_basename, dir_fd=dst_parent_fd)",
81
+ " except FileNotFoundError:",
82
+ " pass",
83
+ " else:",
84
+ " raise FileExistsError(errno.EEXIST, os.strerror(errno.EEXIST), dst_basename)",
85
+ " if kind == stat.S_IFDIR:",
86
+ " verify(src_parent_fd, src_basename, 1)",
87
+ " # A directory rename can replace only an EMPTY directory raced into",
88
+ " # the absent target. Non-empty directories and files are refused.",
89
+ " # The slash enforces source directory type at the syscall, even if",
90
+ " # a peer substitutes a file/symlink after verification. Identity",
91
+ " # checks are not compare-and-swap; verify publication afterward.",
92
+ " try:",
93
+ " os.rename(src_basename + '/', dst_basename, src_dir_fd=src_parent_fd, dst_dir_fd=dst_parent_fd)",
94
+ " except OSError as error:",
95
+ " if error.errno in (errno.ENOTEMPTY, errno.ENOTDIR, errno.EEXIST):",
96
+ " raise FileExistsError(errno.EEXIST, os.strerror(errno.EEXIST), dst_basename) from error",
97
+ " raise",
98
+ " verify(dst_parent_fd, dst_basename, 1)",
99
+ " return",
65
100
  " os.link(",
66
101
  " src_basename,",
67
102
  " dst_basename,",
@@ -69,12 +104,19 @@ export const GUEST_FILESYSTEM_RENAME_NO_REPLACE_PYTHON = [
69
104
  " dst_dir_fd=dst_parent_fd,",
70
105
  " follow_symlinks=False,",
71
106
  " )",
107
+ " # Only this exact two-name interval permits nlink=2. Never roll back the",
108
+ " # target on failure: it may be the only remaining link to the source.",
109
+ " verify(dst_parent_fd, dst_basename, 2)",
110
+ " verify(src_parent_fd, src_basename, 2)",
111
+ " os.unlink(src_basename, dir_fd=src_parent_fd)",
112
+ " verify(dst_parent_fd, dst_basename, 1)",
72
113
  ].join("\n");
73
114
  export const GUEST_FILESYSTEM_CREATE_EXCLUSIVE_PYTHON = [
74
115
  "def create_exclusive(parent_fd, basename, stdin_buffer):",
75
116
  " staging_fd = None",
76
117
  " staging_name = None",
77
118
  " temp_fd = None",
119
+ " temp_inode = None",
78
120
  " temp_name = 'payload'",
79
121
  " try:",
80
122
  " try:",
@@ -85,6 +127,7 @@ export const GUEST_FILESYSTEM_CREATE_EXCLUSIVE_PYTHON = [
85
127
  " raise FileExistsError(errno.EEXIST, os.strerror(errno.EEXIST), basename)",
86
128
  " staging_name, staging_fd = create_staging_dir(parent_fd)",
87
129
  " temp_fd = os.open(temp_name, WRITE_FLAGS, 0o600, dir_fd=staging_fd)",
130
+ " temp_inode = inode_identity(os.fstat(temp_fd))",
88
131
  " while True:",
89
132
  " chunk = stdin_buffer.read(65536)",
90
133
  " if not chunk:",
@@ -92,8 +135,7 @@ export const GUEST_FILESYSTEM_CREATE_EXCLUSIVE_PYTHON = [
92
135
  " write_all(temp_fd, chunk)",
93
136
  " # exclusive create payload is durable before publication",
94
137
  " os.fsync(temp_fd)",
95
- " temp_inode = inode_identity(os.fstat(temp_fd))",
96
- " # Publish with a native atomic no-replace rename.",
138
+ " # Publish with no-replace rename or the checked file fallback.",
97
139
  " rename_no_replace(staging_fd, temp_name, parent_fd, basename)",
98
140
  " target_stat = os.lstat(basename, dir_fd=parent_fd)",
99
141
  " if temp_inode != inode_identity(target_stat):",
@@ -105,14 +147,19 @@ export const GUEST_FILESYSTEM_CREATE_EXCLUSIVE_PYTHON = [
105
147
  " if staging_fd is not None:",
106
148
  " # Cleanup stays relative to the private pinned directory, so a",
107
149
  " # parent-path substitution cannot redirect payload deletion.",
150
+ " payload_removed = False",
108
151
  " try:",
109
- " os.unlink(temp_name, dir_fd=staging_fd)",
152
+ " current_payload = os.lstat(temp_name, dir_fd=staging_fd)",
153
+ " # Preserve ambiguous or partially published link pairs.",
154
+ " if temp_inode == inode_identity(current_payload) and current_payload.st_nlink == 1:",
155
+ " os.unlink(temp_name, dir_fd=staging_fd)",
156
+ " payload_removed = True",
110
157
  " except FileNotFoundError:",
111
- " pass",
158
+ " payload_removed = True",
112
159
  " staging_identity = entry_identity(os.fstat(staging_fd))",
113
160
  " os.close(staging_fd)",
114
161
  " staging_removed = False",
115
- " if staging_name is not None:",
162
+ " if staging_name is not None and payload_removed:",
116
163
  " try:",
117
164
  " current_staging = os.lstat(staging_name, dir_fd=parent_fd)",
118
165
  " if same_identity(staging_identity, current_staging):",
@@ -395,7 +395,7 @@ export const GUEST_FILESYSTEM_PYTHON = [
395
395
  " break",
396
396
  " write_all(temp_fd, chunk)",
397
397
  " try:",
398
- " os.fchmod(temp_fd, stat.S_IMODE(src_stat.st_mode))",
398
+ " os.fchmod(temp_fd, stat.S_IMODE(src_file_stat.st_mode))",
399
399
  " except AttributeError:",
400
400
  " pass",
401
401
  " os.fsync(temp_fd)",
@@ -139,6 +139,7 @@ export interface NativeBinding {
139
139
  renameReplaceWithIdentity?(sourceParent: number, source: string, targetParent: number, target: string, dev: bigint, ino: bigint): void;
140
140
  /** Windows-only, private handle custody; no borrowed/runtime descriptors. */
141
141
  retainWindowsFile?(directory: string, basename: string, parentDev: bigint, parentIno: bigint, dev: bigint, ino: bigint, size: bigint, mtimeNs: bigint, ctimeNs: bigint, sha256: string, maxBytes: number): NativeRetainedFile;
142
+ retainWindowsSidecar?(parentFd: number, directory: string, basename: string, parentDev: bigint, parentIno: bigint, dev: bigint, ino: bigint, size: bigint, sha256: string, maxBytes: number): NativeRetainedFile;
142
143
  watchRegister?(root: string, limit: number, callback: (batch: import("./watch-native.js").NativeWatchWireBatch) => void, persistent: boolean): number;
143
144
  watchConfigure?(id: number, anchors: string[], exclusions: string[]): void;
144
145
  watchEntries?(id: number, entries: import("./watch-native.js").NativeWatchEntry[]): {
@@ -5,7 +5,6 @@ export type NativeFileHandle = {
5
5
  writeFile(data: string | Buffer, encoding?: BufferEncoding): Promise<void>;
6
6
  };
7
7
  export declare function nativeOpenFlags(flags: number): number;
8
- export declare function writeNativeFd(fd: number, data: Buffer): void;
9
8
  export declare function removeNativeCreatedFileIfStillPinned(params: {
10
9
  parentPath: string;
11
10
  parentFd: number;
@@ -10,31 +10,6 @@ export function nativeOpenFlags(flags) {
10
10
  (closeOnExec ?? 0) |
11
11
  (typeof fsSync.constants.O_NOFOLLOW === "number" ? fsSync.constants.O_NOFOLLOW : 0));
12
12
  }
13
- export function writeNativeFd(fd, data) {
14
- let offset = 0;
15
- while (offset < data.byteLength) {
16
- const written = fsSync.writeSync(fd, data, offset, data.byteLength - offset);
17
- if (written <= 0) {
18
- throw Object.assign(new Error("native file write made no progress"), { code: "EIO" });
19
- }
20
- offset += written;
21
- }
22
- }
23
- function wrapNativeFd(fd, closeFd) {
24
- let open = true;
25
- return {
26
- fd,
27
- async close() {
28
- if (open) {
29
- open = false;
30
- closeFd(fd);
31
- }
32
- },
33
- async writeFile(data, encoding) {
34
- writeNativeFd(fd, Buffer.isBuffer(data) ? data : Buffer.from(data, encoding ?? "utf8"));
35
- },
36
- };
37
- }
38
13
  export function removeNativeCreatedFileIfStillPinned(params) {
39
14
  if (!params.created) {
40
15
  return;
@@ -86,7 +61,28 @@ export async function createNativeExclusiveFile(targetPath, mode) {
86
61
  fd = opened.fd;
87
62
  fsSync.fchmodSync(fd, mode);
88
63
  created = fsSync.fstatSync(fd, { bigint: true });
89
- return wrapNativeFd(fd, closeFd);
64
+ const ownedFd = fd;
65
+ let open = true;
66
+ return {
67
+ fd: ownedFd,
68
+ async close() {
69
+ if (open) {
70
+ open = false;
71
+ closeFd(ownedFd);
72
+ }
73
+ },
74
+ async writeFile(data, encoding) {
75
+ const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, encoding ?? "utf8");
76
+ let offset = 0;
77
+ while (offset < buffer.byteLength) {
78
+ const written = fsSync.writeSync(ownedFd, buffer, offset, buffer.byteLength - offset);
79
+ if (written <= 0) {
80
+ throw Object.assign(new Error("native file write made no progress"), { code: "EIO" });
81
+ }
82
+ offset += written;
83
+ }
84
+ },
85
+ };
90
86
  }
91
87
  catch (error) {
92
88
  if (fd !== undefined) {
@@ -188,7 +188,11 @@ export async function runPinnedWriteWindows(binding, params, root, parentFd, par
188
188
  if (params.sync !== false)
189
189
  syncFileBestEffortSync(parentFd);
190
190
  // Verification follows publication and final chmod, outside rollback handling.
191
- await params.verifyPublished?.(targetFd, verificationIdentity, parentGuard);
191
+ if (params.overwrite === false && params.input.kind === "buffer" && params.input.stageBeforePublish === false) {
192
+ await params.verifyPublished?.(targetFd, verificationIdentity, parentGuard, { fd: parentFd, binding });
193
+ }
194
+ else
195
+ await params.verifyPublished?.(targetFd, verificationIdentity, parentGuard);
192
196
  completed = true;
193
197
  return { dev: targetIdentity.dev, ino: targetIdentity.ino };
194
198
  }
@@ -177,7 +177,10 @@ class NativeStagedFile {
177
177
  }
178
178
  const identity = published.staged.identity;
179
179
  try {
180
- await params.verifyPublished?.(staged.#file(), identity, parentGuard);
180
+ if (exclusive)
181
+ await params.verifyPublished?.(staged.#file(), identity, parentGuard, { fd: parentFd, binding });
182
+ else
183
+ await params.verifyPublished?.(staged.#file(), identity, parentGuard);
181
184
  }
182
185
  catch (error) {
183
186
  if (params.overwrite === false && params.input.kind !== "file" && params.input.stageBeforePublish === true) {
@@ -5,7 +5,8 @@ import { formatErrorDetail } from "./error-detail.js";
5
5
  const execFileAsync = promisify(execFile);
6
6
  export const DEFAULT_PERMISSION_EXEC_TIMEOUT_MS = 30_000;
7
7
  export function formatPermissionErrorDetail(value) {
8
- const formatted = formatErrorDetail(value);
8
+ // Escaping cannot shrink input; 401 code units decide the 400-character excerpt.
9
+ const formatted = formatErrorDetail(value.slice(0, 401));
9
10
  return formatted.length > 400 ? `${formatted.slice(0, 399)}…` : formatted;
10
11
  }
11
12
  const MAX_CAUGHT_FAILURE_PROTOTYPES = 4;
@@ -2,6 +2,11 @@ import type { CopyFileInput } from "./copy-file-input.js";
2
2
  import type { AnyAsyncDirectoryGuard } from "./directory-guard.js";
3
3
  import type { FileIdentityStat } from "./file-identity.js";
4
4
  import type { MutationDirectoryObservation } from "./pinned-mutation-observation.js";
5
+ import type { NativeBinding } from "./native-binding.js";
6
+ export type NativeWriteParent = Readonly<{
7
+ fd: number;
8
+ binding: NativeBinding;
9
+ }>;
5
10
  export type PinnedWriteInput = {
6
11
  kind: "buffer";
7
12
  data: string | Buffer;
@@ -64,5 +69,5 @@ export type PinnedWriteParams = {
64
69
  mutationAdmission?: PinnedWriteMutationAdmission;
65
70
  onRenameIdentityMismatch?: "verify-content";
66
71
  onPublished?: (identity: PublishedWriteIdentity) => void;
67
- verifyPublished?: (fd: number, identity: PublishedWriteIdentity, parentGuard: AnyAsyncDirectoryGuard) => Promise<void>;
72
+ verifyPublished?: (fd: number, identity: PublishedWriteIdentity, parentGuard: AnyAsyncDirectoryGuard, nativeParent?: NativeWriteParent) => Promise<void>;
68
73
  };
@@ -166,7 +166,7 @@ async function createTempWorkspace(options, scopedPrefix = false) {
166
166
  else
167
167
  admission.assertCurrent();
168
168
  const stat = inspectDirectoryIdentitySync(dir);
169
- const needsModeInitialization = validateInitialTempWorkspaceChild(stat, admission.ownerUid, dirMode);
169
+ const needsModeInitialization = validateInitialTempWorkspaceChild(stat, admission.ownerUid, dirMode, dir);
170
170
  // Retain while the child still has its private creation mode so an
171
171
  // explicit dirMode such as 0 cannot make identity descriptor acquisition fail.
172
172
  retainedChild = TempWorkspaceRetainedChild.retain(dir, stat);
@@ -261,7 +261,7 @@ function createTempWorkspaceSync(options, scopedPrefix = false) {
261
261
  else {
262
262
  stat = inspectDirectoryIdentitySync(dir);
263
263
  }
264
- const needsModeInitialization = validateInitialTempWorkspaceChild(stat, admission.ownerUid, dirMode);
264
+ const needsModeInitialization = validateInitialTempWorkspaceChild(stat, admission.ownerUid, dirMode, dir);
265
265
  retainedChild ??= TempWorkspaceRetainedChild.retain(dir, stat);
266
266
  if (needsModeInitialization) {
267
267
  retainedChild.initializeModeSync(dirMode, admission.ownerUid, admission.assertCurrent);
@@ -1,4 +1,5 @@
1
- import { publishFileNoReplaceNative } from "./publish-file-native.js";
1
+ import { nativeMoveNoReplace } from "./native-move-fallback.js";
2
+ import { getFsSafeNativeConfig } from "./native-config.js";
2
3
  import { createHash } from "node:crypto";
3
4
  import fsSync, {} from "node:fs";
4
5
  import fs, {} from "node:fs/promises";
@@ -270,12 +271,38 @@ export async function publishFileExclusive(params) {
270
271
  }
271
272
  if (strategy === "rename-noreplace") {
272
273
  const binding = requireNativeBinding();
273
- publishFileNoReplaceNative(binding, [
274
- sourceNativeParent.handle.fd,
275
- sourceNativeParent.basename,
276
- targetNativeParent.handle.fd,
277
- targetNativeParent.basename,
278
- ], sourceExactIdentity, failure);
274
+ try {
275
+ const fallback = nativeMoveNoReplace(binding, [
276
+ sourceNativeParent.handle.fd, sourceNativeParent.basename,
277
+ targetNativeParent.handle.fd, targetNativeParent.basename,
278
+ ], sourceExactIdentity, getFsSafeNativeConfig().mode === "auto", "file publication");
279
+ if (fallback) {
280
+ failure.fallback = fallback;
281
+ failure.sourceRemoval = "removed";
282
+ }
283
+ }
284
+ catch (error) {
285
+ if (error instanceof FsSafeError) {
286
+ if (error.details?.publication === "published") {
287
+ failure.targetCreated = true;
288
+ failure.preserveTarget = true;
289
+ failure.phase = "rename-verify";
290
+ failure.fallback = "link-unlink";
291
+ failure.sourceRemoval = error.details.sourceRemoval;
292
+ // Changed/unverified pairs cannot establish which inode was published.
293
+ if (failure.sourceRemoval === "still-linked")
294
+ rememberCreatedTarget(failure, sourceExactIdentity, "rename-verify");
295
+ }
296
+ throw error;
297
+ }
298
+ const code = error?.code;
299
+ if (code === "path-mismatch" || code === "hardlink") {
300
+ throw new FsSafeError(code, "publication source changed before fallback", { cause: error });
301
+ }
302
+ if (code === "ELOOP")
303
+ throw new FsSafeError("symlink", "publication source became a symlink", { cause: error });
304
+ throw error;
305
+ }
279
306
  rememberCreatedTarget(failure, sourceExactIdentity, "rename-verify");
280
307
  // A failed post-rename fence must not delete the only remaining name.
281
308
  failure.preserveTarget = true;