@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
@@ -6,6 +6,7 @@ import { recursiveMkdirPath } from "./recursive-mkdir-path.js";
6
6
  import { hasNodeErrorCode } from "./path.js";
7
7
  import { assertSafePathSegment } from "./safe-path-segment.js";
8
8
  import { assertNoWindowsPathAlias, pathForWindowsFilesystem } from "./windows-path-alias.js";
9
+ import { privateTempDirectoryFix, secureTempDirectoryFailure } from "./temp-directory-diagnostics.js";
9
10
  import { captureSecureTempRepairAdapter, repairSecureTempDirectory, secureTempDirectoryReceipt, } from "./secure-temp-repair.js";
10
11
  export function resolveSecureTempRoot(options) {
11
12
  const { fallbackPrefix: prefix, accessSync: suppliedAccess, lstatSync: suppliedLstat, mkdirSync: suppliedMkdir, descriptor: suppliedDescriptor, getuid: suppliedGetuid, tmpdir: suppliedTmpdir, platform: suppliedPlatform, preferredDir, skipPreferredOnWindows, warn: suppliedWarn, warningPrefix: suppliedWarningPrefix, unsafeFallbackLabel: suppliedFallbackLabel, } = options;
@@ -82,8 +83,17 @@ export function resolveSecureTempRoot(options) {
82
83
  candidate = lstatSync(candidatePath);
83
84
  }
84
85
  catch (error) {
85
- return { kind: hasNodeErrorCode(error, "ENOENT") ? "missing" : "invalid", error };
86
+ return { kind: hasNodeErrorCode(error, "ENOENT") ? "missing" : "invalid", error,
87
+ detail: `directory metadata could not be read; check path traversal/access permissions; ${privateTempDirectoryFix(uid)}` };
86
88
  }
89
+ const failureDetail = () => {
90
+ try {
91
+ return secureTempDirectoryFailure(candidate, uid, windows);
92
+ }
93
+ catch {
94
+ return `directory security facts could not be read; ${privateTempDirectoryFix(uid)}`;
95
+ }
96
+ };
87
97
  let receipt;
88
98
  try {
89
99
  if (descriptor && uid !== undefined) {
@@ -91,26 +101,28 @@ export function resolveSecureTempRoot(options) {
91
101
  }
92
102
  if (receipt ? (receipt.mode & 18n) !== 0n :
93
103
  candidate.isDirectory() !== true || candidate.isSymbolicLink() !== false || !isSecureDirForUser(candidate)) {
94
- return { kind: "invalid", receipt };
104
+ return { kind: "invalid", receipt, detail: failureDetail() };
95
105
  }
96
106
  accessSync(candidatePath, TMP_DIR_ACCESS_MODE);
97
107
  return { kind: "available", receipt };
98
108
  }
99
109
  catch (error) {
100
- return { kind: "invalid", receipt, error };
110
+ return { kind: "invalid", receipt, error, detail: failureDetail() };
101
111
  }
102
112
  };
103
113
  const tryRepair = (candidatePath, state, finalize = false) => {
104
114
  assertNoWindowsPathAlias(candidatePath, "filesystem", "temp directory uses a Windows filesystem namespace alias", admissionPlatform);
105
115
  if (!descriptor || uid === undefined || !state.receipt) {
106
- return finalize ? { kind: "invalid", error: new Error("Secure temp descriptor finalization is unavailable.") } : state;
116
+ return finalize ? { ...state, kind: "invalid", error: new Error("Secure temp descriptor finalization is unavailable."),
117
+ detail: `descriptor finalization is unavailable; ${privateTempDirectoryFix(uid)}` } : state;
107
118
  }
108
119
  try {
109
120
  repairSecureTempDirectory(candidatePath, state.receipt, uid, descriptor, () => accessSync(candidatePath, TMP_DIR_ACCESS_MODE), () => warn(`${warningPrefix} tightened permissions on temp dir: ${candidatePath}`), finalize);
110
121
  return { kind: "available" };
111
122
  }
112
123
  catch (error) {
113
- return { kind: "invalid", error };
124
+ return { kind: "invalid", error,
125
+ detail: state.detail ?? `descriptor identity, permissions, or access could not be verified during repair; ${privateTempDirectoryFix(uid)}` };
114
126
  }
115
127
  };
116
128
  const ensureTrustedFallbackDir = () => {
@@ -128,7 +140,7 @@ export function resolveSecureTempRoot(options) {
128
140
  finalize = !windows && (suppliedMkdir !== undefined || created !== undefined);
129
141
  }
130
142
  catch (cause) {
131
- throw new Error(`Unable to create fallback ${unsafeFallbackLabel}: ${fallbackPath}`, { cause });
143
+ throw new Error(`Unable to create fallback ${unsafeFallbackLabel}: ${JSON.stringify(fallbackPath)}; check parent directory write/search access; ${privateTempDirectoryFix(uid)}`, { cause });
132
144
  }
133
145
  // A concurrent mkdir winner has no implicit ownership or mode authority.
134
146
  state = resolveDirState(entryPath);
@@ -137,7 +149,7 @@ export function resolveSecureTempRoot(options) {
137
149
  }
138
150
  state = tryRepair(entryPath, state, finalize);
139
151
  if (state.kind !== "available") {
140
- throw new Error(`Unsafe fallback ${unsafeFallbackLabel}: ${fallbackPath}`, { cause: state.error });
152
+ throw new Error(`Unsafe fallback ${unsafeFallbackLabel}: ${JSON.stringify(fallbackPath)}; ${state.detail}`, { cause: state.error });
141
153
  }
142
154
  return fallbackPath;
143
155
  };
@@ -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
  }
@@ -2,6 +2,7 @@ import fsSync from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import { FsSafeError } from "./errors.js";
4
4
  import { fileObservation } from "./file-observation.js";
5
+ import { sameFileIdentityForCleanup } from "./file-identity.js";
5
6
  import { readFileHandleBounded } from "./bounded-read.js";
6
7
  import { openSidecarRoot } from "./sidecar-lock-root.js";
7
8
  import { createNativeExclusiveFile } from "./native-operations.js";
@@ -14,7 +15,8 @@ import { ancestryHasSidecarAdmission, captureSidecarAdmissionAncestry, createSid
14
15
  import { resolveSidecarLockPaths } from "./sidecar-lock-target.js";
15
16
  import { createSuppressedError } from "./suppressed-error.js";
16
17
  import { sleep } from "./timing.js";
17
- import { sidecarExclusiveCreate } from "./root-create-input.js";
18
+ import { sidecarRetainedCreate } from "./root-create-input.js";
19
+ import { cleanupCreatedSidecar, retainCreatedSidecar } from "./sidecar-lock-retained.js";
18
20
  import { stopSidecarLockMonitoring } from "./sidecar-lock-handle.js";
19
21
  export async function acquireSidecarLock(options, context) {
20
22
  const admissionAncestry = captureSidecarAdmissionAncestry();
@@ -166,6 +168,7 @@ export async function acquireSidecarLock(options, context) {
166
168
  continue;
167
169
  }
168
170
  let handle = null;
171
+ let retainedSidecar;
169
172
  let createdSnapshot = null, createdHeld;
170
173
  let admissionConflict = false;
171
174
  let exclusiveCreateConflict = false;
@@ -247,7 +250,13 @@ export async function acquireSidecarLock(options, context) {
247
250
  const relativeLockPath = relativeSidecarLockPath(lockRoot, lockPath);
248
251
  const observation = fileObservation();
249
252
  try {
250
- await observation.run(() => lockRoot.create(relativeLockPath, raw, { ...sidecarExclusiveCreate, mkdir: true, mode: 0o600 }));
253
+ await observation.run(() => lockRoot.create(relativeLockPath, raw, {
254
+ mkdir: true, mode: 0o600,
255
+ ...sidecarRetainedCreate({
256
+ retain(fd, parent, nativeParent) { retainedSidecar = retainCreatedSidecar(lockPath, raw, fd, parent, nativeParent); },
257
+ cleanup(fd, parent, identity) { cleanupCreatedSidecar(lockPath, raw, fd, parent, identity); },
258
+ }),
259
+ }));
251
260
  }
252
261
  catch (error) {
253
262
  // Only this invocation's failed exclusive open grants denial retry authority.
@@ -262,6 +271,7 @@ export async function acquireSidecarLock(options, context) {
262
271
  createdSnapshot = { raw, payload, ownershipToken };
263
272
  const opened = await openSidecarRoot(lockRoot, relativeLockPath, "unlinked");
264
273
  if (!opened) {
274
+ retainedSidecar?.settle(true, false);
265
275
  await waitForRetry();
266
276
  continue;
267
277
  }
@@ -299,7 +309,11 @@ export async function acquireSidecarLock(options, context) {
299
309
  }
300
310
  const snapshot = { raw, payload, stat: fsSync.fstatSync(handle.fd, { bigint: true }), ownershipToken };
301
311
  createdSnapshot = snapshot;
312
+ if (retainedSidecar && !sameFileIdentityForCleanup(retainedSidecar.identity, snapshot.stat)) {
313
+ throw new FsSafeError("path-mismatch", "created sidecar lock identity changed before admission");
314
+ }
302
315
  if (snapshot.stat.nlink === 0n) {
316
+ retainedSidecar?.settle(true, false);
303
317
  await handle.close();
304
318
  handle = null;
305
319
  await waitForRetry();
@@ -310,10 +324,15 @@ export async function acquireSidecarLock(options, context) {
310
324
  admissionConflict = true;
311
325
  throw Object.assign(new Error("sidecar lock admission changed"), { code: "EEXIST" });
312
326
  }
327
+ // An open descendant file prevents Windows directory relocation. The
328
+ // native receipt retains its parent and verifies the leaf at settlement.
329
+ if (retainedSidecar && process.platform === "win32")
330
+ await handle.close();
313
331
  createdHeld = {
314
332
  refCount: 1,
315
333
  reentrantOwner: requestedReentrantOwner,
316
334
  handle,
335
+ retainedSidecar,
317
336
  lockPath,
318
337
  snapshot,
319
338
  acquiredAt: Date.now(),
@@ -380,7 +399,10 @@ export async function acquireSidecarLock(options, context) {
380
399
  await handle.close().catch(() => undefined);
381
400
  }
382
401
  // Root records use the creator receipt; partial raw writes use fd identity.
383
- if (failedSnapshot) {
402
+ if (retainedSidecar) {
403
+ retainedSidecar.settle(true, false);
404
+ }
405
+ else if (failedSnapshot) {
384
406
  await removeSidecarLockIfUnchanged(lockPath, failedSnapshot, {
385
407
  lockRoot,
386
408
  parsePayload: conditionalSidecarLockParser(parserState, admission, () => admission.hasToken() && !context.held.has(normalizedTargetPath)),
@@ -2,10 +2,12 @@ import type { NativeFileHandle } from "./native-operations.js";
2
2
  import type { Root } from "./root-impl.js";
3
3
  import type { SidecarLockSnapshot } from "./sidecar-lock-reclaim.js";
4
4
  import type { SidecarLockHandle } from "./sidecar-lock-types.js";
5
+ import type { RetainedSidecar } from "./sidecar-lock-retained.js";
5
6
  export type HeldSidecarLock = {
6
7
  refCount: number;
7
8
  reentrantOwner?: string;
8
9
  handle: NativeFileHandle;
10
+ retainedSidecar?: RetainedSidecar;
9
11
  lockPath: string;
10
12
  snapshot: SidecarLockSnapshot;
11
13
  acquiredAt: number;
@@ -0,0 +1,14 @@
1
+ import type { AnyAsyncDirectoryGuard } from "./directory-guard.js";
2
+ import type { NativeWriteParent, PublishedWriteIdentity } from "./pinned-write-types.js";
3
+ export interface RetainedSidecar {
4
+ readonly identity: Readonly<{
5
+ dev: bigint;
6
+ ino: bigint;
7
+ }>;
8
+ /** Synchronous settlement also works in the process exit handler. Always closes. */
9
+ settle(remove: boolean, reportMismatch?: boolean): void;
10
+ }
11
+ /** Borrowed creation authority: never reopens a path or closes the writer's fds. */
12
+ export declare function cleanupCreatedSidecar(lockPath: string, raw: string, fd: number, parent: NativeWriteParent, identity: PublishedWriteIdentity): void;
13
+ /** Called only by the exclusive creator while its verified descriptor is alive. */
14
+ export declare function retainCreatedSidecar(lockPath: string, raw: string, createdFd: number, guard: AnyAsyncDirectoryGuard, nativeParent?: NativeWriteParent): RetainedSidecar | undefined;
@@ -0,0 +1,187 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { FsSafeError } from "./errors.js";
4
+ import { hasErrorCode } from "./file-cleanup.js";
5
+ import { captureNativeFdClose } from "./native-binding.js";
6
+ import { nativeOpenFlags } from "./native-operations.js";
7
+ import { sameFileIdentityForCleanup, sha256Hex } from "./file-identity.js";
8
+ function mismatch(check) {
9
+ return new FsSafeError("path-mismatch", `created sidecar lock ${check} changed; replacement preserved`);
10
+ }
11
+ function assertOwnerRecord(fd, expected) {
12
+ if (fs.fstatSync(fd, { bigint: true }).size !== BigInt(expected.length))
13
+ throw mismatch("owner record");
14
+ const bytes = Buffer.alloc(expected.length);
15
+ let offset = 0;
16
+ while (offset < bytes.length) {
17
+ const read = fs.readSync(fd, bytes, offset, bytes.length - offset, offset);
18
+ if (read === 0)
19
+ throw mismatch("owner record");
20
+ offset += read;
21
+ }
22
+ if (!bytes.equals(expected))
23
+ throw mismatch("owner record");
24
+ }
25
+ /** Borrowed creation authority: never reopens a path or closes the writer's fds. */
26
+ export function cleanupCreatedSidecar(lockPath, raw, fd, parent, identity) {
27
+ const name = path.basename(lockPath);
28
+ const windows = process.platform === "win32";
29
+ try {
30
+ if (windows) {
31
+ if (!parent.binding.rootRemovalStat || !parent.binding.rootRemovalUnlink) {
32
+ throw new FsSafeError("helper-unavailable", "creator cleanup is unavailable");
33
+ }
34
+ const entry = parent.binding.rootRemovalStat(parent.fd, name);
35
+ if (entry.directory || entry.symlink || !sameFileIdentityForCleanup(identity, entry))
36
+ throw mismatch("identity");
37
+ }
38
+ else if (!parent.binding.stagedFileMatches || !parent.binding.removeStagedFile) {
39
+ throw new FsSafeError("helper-unavailable", "creator cleanup is unavailable");
40
+ }
41
+ else if (!parent.binding.stagedFileMatches(parent.fd, name, fd)) {
42
+ throw mismatch("identity");
43
+ }
44
+ }
45
+ catch (error) {
46
+ if (hasErrorCode(error, "ENOENT"))
47
+ return;
48
+ throw error;
49
+ }
50
+ const stat = fs.fstatSync(fd, { bigint: true });
51
+ if (!stat.isFile() || stat.nlink !== 1n || !sameFileIdentityForCleanup(identity, stat))
52
+ throw mismatch("identity");
53
+ assertOwnerRecord(fd, Buffer.from(raw));
54
+ if (windows) {
55
+ try {
56
+ parent.binding.rootRemovalUnlink(parent.fd, name, identity.dev, identity.ino, false);
57
+ }
58
+ catch (error) {
59
+ if (!hasErrorCode(error, "ENOENT"))
60
+ throw error;
61
+ }
62
+ }
63
+ else {
64
+ if (parent.binding.removeStagedFile(parent.fd, name, fd) === "preserved")
65
+ throw mismatch("identity");
66
+ }
67
+ }
68
+ /** Called only by the exclusive creator while its verified descriptor is alive. */
69
+ export function retainCreatedSidecar(lockPath, raw, createdFd, guard, nativeParent) {
70
+ const binding = nativeParent?.binding;
71
+ if (!binding)
72
+ return undefined;
73
+ const created = fs.fstatSync(createdFd, { bigint: true });
74
+ if (!created.isFile() || created.nlink !== 1n)
75
+ throw mismatch("identity");
76
+ const identity = Object.freeze({ dev: created.dev, ino: created.ino });
77
+ const name = path.basename(lockPath);
78
+ if (process.platform === "win32") {
79
+ if (!binding.retainWindowsSidecar)
80
+ return undefined;
81
+ const native = binding.retainWindowsSidecar(nativeParent.fd, guard.realPath, name, BigInt(guard.stat.dev), BigInt(guard.stat.ino), created.dev, created.ino, BigInt(Buffer.byteLength(raw)), sha256Hex(raw), 1024 * 1024);
82
+ const admitted = native.admission;
83
+ if (admitted.status === "unsupported" && admitted.resources === "closed")
84
+ return undefined;
85
+ if (admitted.status !== "retained") {
86
+ throw new FsSafeError("helper-failed", "could not retain created sidecar lock", { details: { result: admitted } });
87
+ }
88
+ return { identity, settle(remove, reportMismatch = true) {
89
+ const result = native.settle(remove);
90
+ if (result.status === "preserved-mismatch") {
91
+ if (reportMismatch || result.resources !== "closed") {
92
+ throw mismatch(result.errors.some(error => /digest|size changed/u.test(error.message)) ? "owner record" : "identity");
93
+ }
94
+ return;
95
+ }
96
+ if (result.errors.length || result.resources !== "closed" ||
97
+ (remove && result.disposition !== "accepted" && result.namespace !== "absent")) {
98
+ throw new FsSafeError("helper-failed", "retained sidecar lock settlement failed", { details: { result } });
99
+ }
100
+ } };
101
+ }
102
+ if (!binding.stagedFileMatches || !binding.removeStagedFile)
103
+ return undefined;
104
+ const closeFile = captureNativeFdClose(binding);
105
+ const parentFd = binding.openBeneath(nativeParent.fd, "", nativeOpenFlags(fs.constants.O_RDONLY | fs.constants.O_DIRECTORY)).fd;
106
+ let fd;
107
+ try {
108
+ const parentStat = fs.fstatSync(parentFd, { bigint: true });
109
+ if (!parentStat.isDirectory() || !sameFileIdentityForCleanup(guard.stat, parentStat))
110
+ throw mismatch("identity");
111
+ fd = binding.openBeneath(parentFd, name, nativeOpenFlags(fs.constants.O_RDONLY | fs.constants.O_NONBLOCK)).fd;
112
+ const ownedFd = fd;
113
+ const assertIdentity = () => {
114
+ try {
115
+ if (!binding.stagedFileMatches(parentFd, name, ownedFd))
116
+ throw mismatch("identity");
117
+ }
118
+ catch (error) {
119
+ if (hasErrorCode(error, "ENOENT"))
120
+ return false;
121
+ throw error;
122
+ }
123
+ const current = fs.fstatSync(ownedFd, { bigint: true });
124
+ if (!current.isFile() || current.nlink !== 1n || !sameFileIdentityForCleanup(created, current))
125
+ throw mismatch("identity");
126
+ return true;
127
+ };
128
+ const expected = Buffer.from(raw);
129
+ const assertOwner = () => assertOwnerRecord(ownedFd, expected);
130
+ if (!assertIdentity())
131
+ throw mismatch("identity");
132
+ assertOwner();
133
+ let settled = false;
134
+ return { identity, settle(remove, reportMismatch = true) {
135
+ if (settled)
136
+ return;
137
+ settled = true;
138
+ const errors = [];
139
+ try {
140
+ if (remove && assertIdentity()) {
141
+ assertOwner();
142
+ if (assertIdentity() && binding.removeStagedFile(parentFd, name, ownedFd) === "preserved")
143
+ throw mismatch("identity");
144
+ }
145
+ }
146
+ catch (error) {
147
+ if (reportMismatch || !(error instanceof FsSafeError && error.code === "path-mismatch"))
148
+ errors.push(error);
149
+ }
150
+ try {
151
+ closeFile(ownedFd);
152
+ }
153
+ catch (error) {
154
+ errors.push(error);
155
+ }
156
+ try {
157
+ closeFile(parentFd);
158
+ }
159
+ catch (error) {
160
+ errors.push(error);
161
+ }
162
+ if (errors.length === 1)
163
+ throw errors[0];
164
+ if (errors.length)
165
+ throw new AggregateError(errors, "sidecar lock cleanup and close failed");
166
+ } };
167
+ }
168
+ catch (error) {
169
+ const errors = [error];
170
+ try {
171
+ if (fd !== undefined)
172
+ closeFile(fd);
173
+ }
174
+ catch (closeError) {
175
+ errors.push(closeError);
176
+ }
177
+ try {
178
+ closeFile(parentFd);
179
+ }
180
+ catch (closeError) {
181
+ errors.push(closeError);
182
+ }
183
+ if (errors.length > 1)
184
+ throw new AggregateError(errors, "sidecar retention and close failed");
185
+ throw error;
186
+ }
187
+ }
@@ -108,7 +108,10 @@ function releaseAllLocksSync(state, options) {
108
108
  void held.handle.close().catch(() => undefined);
109
109
  try {
110
110
  const retained = options?.preserveRetained === true && held.retainOnExit;
111
- if (!retained && !held.lockRoot && snapshotMatchesSync(held.lockPath, held.snapshot)) {
111
+ if (held.retainedSidecar) {
112
+ held.retainedSidecar.settle(!retained);
113
+ }
114
+ else if (!retained && !held.lockRoot && snapshotMatchesSync(held.lockPath, held.snapshot)) {
112
115
  fsSync.rmSync(held.lockPath, { force: true });
113
116
  }
114
117
  }
@@ -186,6 +189,17 @@ async function releaseHeldLock(state, normalizedTargetPath, held, options = {})
186
189
  }
187
190
  held.releasePromise = (async () => {
188
191
  await held.handle.close().catch(() => undefined);
192
+ if (held.retainedSidecar) {
193
+ try {
194
+ held.retainedSidecar.settle(true);
195
+ }
196
+ finally {
197
+ if (state.held.get(normalizedTargetPath) === held)
198
+ state.held.delete(normalizedTargetPath);
199
+ stopSidecarLockMonitoring(held);
200
+ }
201
+ return;
202
+ }
189
203
  await removeSidecarLockIfUnchanged(held.lockPath, held.snapshot, {
190
204
  lockRoot: held.lockRoot,
191
205
  parsePayload: held.parsePayload,
@@ -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) {
@@ -0,0 +1,10 @@
1
+ export declare function tempDirectoryMode(mode: number | bigint): string;
2
+ export declare function privateTempDirectoryFix(uid: number | undefined): string;
3
+ type TempDirectoryStat = {
4
+ isDirectory(): boolean;
5
+ isSymbolicLink(): boolean;
6
+ uid?: number | bigint;
7
+ mode?: number | bigint;
8
+ };
9
+ export declare function secureTempDirectoryFailure(stat: TempDirectoryStat, uid: number | undefined, windows: boolean): string;
10
+ export {};
@@ -0,0 +1,29 @@
1
+ export function tempDirectoryMode(mode) {
2
+ return (typeof mode === "bigint" ? mode & 4095n : mode & 0o7777).toString(8).padStart(4, "0");
3
+ }
4
+ export function privateTempDirectoryFix(uid) {
5
+ return `use a real private directory${uid === undefined ? "" : ` owned by uid ${uid}`} with mode 0700 under trusted ancestors (or use resolveSecureTempRoot()); do not chmod a shared temp directory to 0700`;
6
+ }
7
+ // Called only after failed admission; diagnostics never authorize repair.
8
+ export function secureTempDirectoryFailure(stat, uid, windows) {
9
+ if (stat.isSymbolicLink())
10
+ return "is a symbolic link; choose a real directory, not a symlink";
11
+ if (!stat.isDirectory())
12
+ return "is not a directory; choose a real directory";
13
+ if (!windows && uid !== undefined) {
14
+ if (stat.uid !== undefined && stat.uid !== uid && stat.uid !== BigInt(uid)) {
15
+ return `has owner uid ${stat.uid}, expected uid ${uid}; ${privateTempDirectoryFix(uid)}`;
16
+ }
17
+ const mode = stat.mode;
18
+ if (mode !== undefined) {
19
+ if ((typeof mode === "number" && (!Number.isSafeInteger(mode) || mode < 0)) ||
20
+ (typeof mode === "bigint" && mode < 0n)) {
21
+ return `has invalid mode bits; ${privateTempDirectoryFix(uid)}`;
22
+ }
23
+ if (typeof mode === "bigint" ? (mode & 18n) !== 0n : (mode & 0o022) !== 0) {
24
+ return `has group/world-writable mode ${tempDirectoryMode(mode)} (a sticky bit does not make a private root safe); ${privateTempDirectoryFix(uid)}`;
25
+ }
26
+ }
27
+ }
28
+ return `directory identity, permissions, or write/search access could not be verified; ${privateTempDirectoryFix(uid)}`;
29
+ }
@@ -143,7 +143,7 @@ export async function createOwnedTempFile(params) {
143
143
  capability.assertCurrent();
144
144
  const initial = inspectDirectoryIdentitySync(dir);
145
145
  identity = Object.freeze({ dev: initial.dev, ino: initial.ino });
146
- const needsModeInitialization = validateInitialTempWorkspaceChild(initial, admission.ownerUid, 0o700);
146
+ const needsModeInitialization = validateInitialTempWorkspaceChild(initial, admission.ownerUid, 0o700, dir);
147
147
  retainedChild = TempWorkspaceRetainedChild.retain(dir, identity);
148
148
  if (needsModeInitialization) {
149
149
  await retainedChild.initializeMode(0o700, admission.ownerUid, admission.assertCurrent);
@@ -46,7 +46,7 @@ function snapshotFromExactObservation(dir, uid, realPath, stat, privateDirectory
46
46
  if (stat.symbolicLink || !stat.directory) {
47
47
  throw new FsSafeError("not-file", "temp workspace root component must be a real directory");
48
48
  }
49
- assertTrustedTempWorkspaceDirectory(stat, uid, privateDirectory);
49
+ assertTrustedTempWorkspaceDirectory(stat, uid, privateDirectory, dir);
50
50
  const identity = Object.freeze({ dev: stat.dev, ino: stat.ino });
51
51
  // Retain copied immutable scalars, never a mutable Stats object supplied by
52
52
  // an observation hook. Every later permission check uses a fresh snapshot.
@@ -106,7 +106,7 @@ function inspectSnapshotIdentity(entry) {
106
106
  }
107
107
  function assertSnapshot(entry, uid) {
108
108
  const stat = inspectSnapshotIdentity(entry);
109
- assertTrustedTempWorkspaceDirectory(stat, uid, entry.privateDirectory);
109
+ assertTrustedTempWorkspaceDirectory(stat, uid, entry.privateDirectory, entry.dir);
110
110
  assertCanonicalRoot(entry);
111
111
  }
112
112
  function snapshotMissingRootComponent(parent, dir, ownerUid, privateDirectory) {
@@ -115,7 +115,7 @@ function snapshotMissingRootComponent(parent, dir, ownerUid, privateDirectory) {
115
115
  return snapshot(dir, ownerUid, undefined, privateDirectory);
116
116
  }
117
117
  const current = inspectSnapshotIdentity(parent);
118
- assertTrustedTempWorkspaceDirectory(current, ownerUid, parent.privateDirectory);
118
+ assertTrustedTempWorkspaceDirectory(current, ownerUid, parent.privateDirectory, parent.dir);
119
119
  let realPath;
120
120
  try {
121
121
  realPath = canonicalTempWorkspacePath(dir);
@@ -136,7 +136,7 @@ function snapshotMissingRootComponent(parent, dir, ownerUid, privateDirectory) {
136
136
  function assertChain(chain, uid) {
137
137
  for (const entry of chain) {
138
138
  const current = inspectSnapshotIdentity(entry);
139
- assertTrustedTempWorkspaceDirectory(current, uid, entry.privateDirectory);
139
+ assertTrustedTempWorkspaceDirectory(current, uid, entry.privateDirectory, entry.dir);
140
140
  }
141
141
  const last = chain[chain.length - 1];
142
142
  assertCanonicalRoot(last);
@@ -159,7 +159,7 @@ function associateTempWorkspaceRoot(entry, ownerUid, descriptorFd) {
159
159
  if (stat.isSymbolicLink() || !stat.isDirectory()) {
160
160
  throw new FsSafeError("not-file", "temp workspace cleanup parent must be a real directory");
161
161
  }
162
- assertTrustedTempWorkspaceDirectory(stat, ownerUid, entry.privateDirectory);
162
+ assertTrustedTempWorkspaceDirectory(stat, ownerUid, entry.privateDirectory, entry.dir);
163
163
  }
164
164
  function discoverMissingTempWorkspaceAncestor(ancestor, missing, missingError) {
165
165
  // The caller already classified the first ENOENT. Keep this handoff out of
@@ -290,7 +290,7 @@ function canonicalRootAdmission(discovery, ownerUid) {
290
290
  return snapshot(dir, ownerUid, dir, index === ancestry.length - 1 && discovery.privateDirectory).entry;
291
291
  }
292
292
  const current = inspectSnapshotIdentity(discovery);
293
- assertTrustedTempWorkspaceDirectory(current, ownerUid, discovery.privateDirectory);
293
+ assertTrustedTempWorkspaceDirectory(current, ownerUid, discovery.privateDirectory, discovery.dir);
294
294
  return discovery;
295
295
  });
296
296
  const current = candidate[candidate.length - 1];
@@ -14,10 +14,10 @@ export declare function projectTempWorkspaceNumericIdentity(identity: TempWorksp
14
14
  export declare function inspectTempWorkspaceDescriptorIdentitySync(fd: number, expected: TempWorkspaceIdentity, numeric: TempWorkspaceNumericIdentity | undefined): TempWorkspaceIdentityStat;
15
15
  export declare function inspectTempWorkspaceDirectoryIdentitySync(dir: string, expected: TempWorkspaceIdentity, numeric: TempWorkspaceNumericIdentity | undefined): TempWorkspaceIdentityStat;
16
16
  export declare function validateTempWorkspaceDirMode(mode: number): void;
17
- export declare function assertTrustedTempWorkspaceDirectory(stat: Pick<BigIntStats, "uid" | "gid" | "mode"> | Pick<Stats, "uid" | "gid" | "mode">, uid: number | undefined, privateDirectory?: boolean): void;
18
- export declare function assertTempWorkspaceChildState(stat: BigIntStats | Stats, ownerUid: number | undefined): void;
19
- export declare function validateInitialTempWorkspaceChild(stat: BigIntStats, ownerUid: number | undefined, mode: number): boolean;
17
+ export declare function assertTrustedTempWorkspaceDirectory(stat: Pick<BigIntStats, "uid" | "gid" | "mode"> | Pick<Stats, "uid" | "gid" | "mode">, uid: number | undefined, privateDirectory: boolean, dir: string): void;
18
+ export declare function assertTempWorkspaceChildState(stat: BigIntStats | Stats, ownerUid: number | undefined, dir: string): void;
19
+ export declare function validateInitialTempWorkspaceChild(stat: BigIntStats, ownerUid: number | undefined, mode: number, dir: string): boolean;
20
20
  export declare function childHasRequestedMode(stat: BigIntStats | Stats, mode: number): boolean;
21
21
  export declare function admitTempWorkspaceChild(dir: string, expected: BigIntStats, parent: TempWorkspaceRootAdmission, mode: number): Promise<void> | undefined;
22
22
  export declare function admitTempWorkspaceChildSync(dir: string, expected: BigIntStats, parent: TempWorkspaceRootAdmission, mode: number): void;
23
- export declare function validateAdmittedTempWorkspaceChild(current: TempWorkspaceIdentityStat, ownerUid: number | undefined, mode: number): void;
23
+ export declare function validateAdmittedTempWorkspaceChild(current: TempWorkspaceIdentityStat, ownerUid: number | undefined, mode: number, dir: string): void;