@openclaw/fs-safe 0.18.1 → 0.19.0

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 (94) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/advanced.d.ts +2 -1
  3. package/dist/advanced.js +1 -1
  4. package/dist/archive-durability.js +1 -1
  5. package/dist/archive-merge.js +1 -2
  6. package/dist/archive-staging.d.ts +1 -2
  7. package/dist/archive-staging.js +3 -3
  8. package/dist/archive-zip-preflight.d.ts +0 -2
  9. package/dist/archive-zip-preflight.js +0 -1
  10. package/dist/archive.d.ts +6 -3
  11. package/dist/archive.js +5 -3
  12. package/dist/directory-guard.d.ts +0 -1
  13. package/dist/directory-guard.js +0 -3
  14. package/dist/directory-mode-node.d.ts +20 -2
  15. package/dist/directory-mode-node.js +77 -1
  16. package/dist/file-lock-sync-acquisition.js +1 -1
  17. package/dist/file-lock-sync-root-held.d.ts +4 -11
  18. package/dist/file-lock-sync-root-io.d.ts +1 -4
  19. package/dist/file-lock-sync-root-options.d.ts +2 -10
  20. package/dist/file-lock-sync-root.d.ts +2 -4
  21. package/dist/file-store-boundary.d.ts +3 -7
  22. package/dist/file-store-boundary.js +7 -10
  23. package/dist/file-store-prune.js +3 -2
  24. package/dist/file-store.js +19 -21
  25. package/dist/guest-native-python.js +23 -31
  26. package/dist/guest.js +21 -12
  27. package/dist/json-durable-queue-ownership.d.ts +1 -5
  28. package/dist/json-durable-queue.d.ts +0 -1
  29. package/dist/json-durable-queue.js +2 -7
  30. package/dist/local-file-descriptor.d.ts +2 -5
  31. package/dist/local-roots.d.ts +2 -7
  32. package/dist/native-binding.d.ts +12 -13
  33. package/dist/permission-exec.d.ts +6 -0
  34. package/dist/permissions-public.d.ts +2 -1
  35. package/dist/permissions-windows.d.ts +3 -6
  36. package/dist/permissions.d.ts +3 -10
  37. package/dist/permissions.js +0 -1
  38. package/dist/pinned-mutation-shared-route.d.ts +2 -8
  39. package/dist/pinned-open.d.ts +1 -0
  40. package/dist/pinned-open.js +1 -1
  41. package/dist/replace-directory.js +5 -17
  42. package/dist/replace-file-copy-fallback.d.ts +1 -8
  43. package/dist/replace-file-descriptor.d.ts +3 -11
  44. package/dist/replace-file-descriptor.js +1 -1
  45. package/dist/retained-directory-replacement.d.ts +1 -0
  46. package/dist/retained-directory-replacement.js +1 -1
  47. package/dist/root-context.js +3 -2
  48. package/dist/root-file-final-admission.d.ts +1 -1
  49. package/dist/root-file-final-admission.js +1 -6
  50. package/dist/root-file.js +2 -5
  51. package/dist/root-move-noreplace.d.ts +2 -7
  52. package/dist/root-paths.d.ts +2 -6
  53. package/dist/root-remove-identity.d.ts +1 -3
  54. package/dist/root-walk.js +8 -3
  55. package/dist/root-write-admission.js +1 -4
  56. package/dist/root-write-complete-parent.d.ts +2 -0
  57. package/dist/root-write-complete-parent.js +1 -1
  58. package/dist/secret-file.d.ts +6 -2
  59. package/dist/secret-file.js +12 -16
  60. package/dist/secret-read-policy.js +1 -1
  61. package/dist/secure-file-windows.js +5 -14
  62. package/dist/secure-temp-dir.js +2 -7
  63. package/dist/sidecar-lock-acquire.js +1 -1
  64. package/dist/sidecar-lock-admission-parser.d.ts +1 -2
  65. package/dist/sidecar-lock-handle.d.ts +2 -8
  66. package/dist/sidecar-lock-policy.d.ts +2 -7
  67. package/dist/sidecar-lock-reclaim.js +5 -6
  68. package/dist/sidecar-lock-stale-admission.d.ts +1 -5
  69. package/dist/stat-observation.js +3 -10
  70. package/dist/strict-file-identity.d.ts +2 -0
  71. package/dist/strict-file-identity.js +6 -6
  72. package/dist/temp-target.js +3 -8
  73. package/dist/temp-workspace-admission.js +6 -12
  74. package/dist/temp-workspace-child-admission.js +2 -8
  75. package/dist/temp-workspace-descriptor.js +1 -2
  76. package/dist/test-hooks.d.ts +1 -1
  77. package/dist/windows-owner.d.ts +3 -6
  78. package/dist/windows-security-bridge.cs +0 -2
  79. package/dist/windows-security-command.js +1 -4
  80. package/dist/windows-security-facts.d.ts +1 -0
  81. package/dist/windows-security-facts.js +2 -2
  82. package/docs/archive.md +2 -0
  83. package/docs/copy.md +2 -0
  84. package/docs/file-store.md +5 -0
  85. package/docs/guest.md +7 -1
  86. package/docs/native-helper.md +6 -0
  87. package/docs/root.md +10 -1
  88. package/docs/secret-file.md +19 -0
  89. package/docs/sidecar-lock.md +2 -0
  90. package/docs/walk.md +12 -0
  91. package/docs/writing.md +11 -0
  92. package/package.json +8 -8
  93. package/dist/directory-mode-owner.d.ts +0 -20
  94. package/dist/directory-mode-owner.js +0 -78
@@ -6,6 +6,7 @@ import { suffixWindowsReservedDeviceName } from "./filename.js";
6
6
  import { sameFileIdentityForCleanup } from "./file-identity.js";
7
7
  import { assertSafePathSegment, sanitizeSafePathSegment, trimHyphenEdges } from "./safe-path-segment.js";
8
8
  import { resolveSecureTempRoot } from "./secure-temp-dir.js";
9
+ import { hasNodeErrorCode } from "./path.js";
9
10
  import { registerTempPathForExit } from "./temp-cleanup.js";
10
11
  import { assertNoWindowsPathAlias, resolvePathPreservingWindowsRoot, } from "./windows-path-alias.js";
11
12
  const HYPHEN_CHAR_CODE = 0x2d;
@@ -76,12 +77,6 @@ export function buildRandomTempFilePath(params) {
76
77
  assertNoWindowsPathAlias(filePath, "filesystem", "temp file path uses a Windows filesystem namespace alias");
77
78
  return filePath;
78
79
  }
79
- function isNodeErrorWithCode(err, code) {
80
- return (typeof err === "object" &&
81
- err !== null &&
82
- "code" in err &&
83
- err.code === code);
84
- }
85
80
  async function cleanupTempDir(dir, identity, onCleanupError) {
86
81
  try {
87
82
  const current = fsSync.lstatSync(dir, { bigint: true });
@@ -91,7 +86,7 @@ async function cleanupTempDir(dir, identity, onCleanupError) {
91
86
  await fs.rm(dir, { recursive: true, force: true });
92
87
  }
93
88
  catch (err) {
94
- if (!isNodeErrorWithCode(err, "ENOENT")) {
89
+ if (!hasNodeErrorCode(err, "ENOENT")) {
95
90
  onCleanupError?.(err);
96
91
  }
97
92
  }
@@ -174,7 +169,7 @@ export async function createOwnedTempFile(params) {
174
169
  await owner.cleanup();
175
170
  }
176
171
  catch (err) {
177
- if (!isNodeErrorWithCode(err, "ENOENT")) {
172
+ if (!hasNodeErrorCode(err, "ENOENT")) {
178
173
  params.onCleanupError?.(err);
179
174
  }
180
175
  }
@@ -4,10 +4,9 @@ import path from "node:path";
4
4
  import { inspectDirectoryIdentitySync, observeDirectoryIdentitySync, } from "./directory-guard.js";
5
5
  import { admitTempWorkspaceChild, admitTempWorkspaceChildSync, assertTrustedTempWorkspaceDirectory, inspectTempWorkspaceDescriptorIdentitySync, projectTempWorkspaceNumericIdentity, TEMP_WORKSPACE_NUMERIC_IDENTITY_REPLAY, } from "./temp-workspace-child-admission.js";
6
6
  import { FsSafeError } from "./errors.js";
7
- import { recordFileObservationFailure } from "./file-observation.js";
8
7
  import { realpathSync } from "./realpath.js";
9
8
  import { assertNoWindowsPathAlias, pathForWindowsFilesystem, resolvePathPreservingWindowsRoot, } from "./windows-path-alias.js";
10
- import { inspectFileIdentitySync } from "./strict-file-identity.js";
9
+ import { fileIdentityMismatchError, inspectFileIdentitySync } from "./strict-file-identity.js";
11
10
  const WINDOWS = process.platform === "win32";
12
11
  function effectiveOwner() {
13
12
  // Windows mode/uid fields do not describe ACL authority. The supplied root's
@@ -72,11 +71,6 @@ function assertCanonicalRoot(entry) {
72
71
  throw new FsSafeError("path-mismatch", "temp workspace root ancestry changed");
73
72
  }
74
73
  }
75
- function identityMismatch() {
76
- const error = new FsSafeError("path-mismatch", "file identity changed or could not be verified");
77
- recordFileObservationFailure(error, "identity");
78
- throw error;
79
- }
80
74
  function inspectSnapshotIdentity(entry) {
81
75
  const expected = entry.numericIdentity;
82
76
  if (!expected) {
@@ -87,21 +81,21 @@ function inspectSnapshotIdentity(entry) {
87
81
  const devKnown = Number.isSafeInteger(stat.dev) && stat.dev >= 0 && (!WINDOWS || stat.dev !== 0);
88
82
  if (devKnown) {
89
83
  if (stat.dev !== expected.dev)
90
- identityMismatch();
84
+ throw fileIdentityMismatchError();
91
85
  }
92
86
  else if (WINDOWS)
93
87
  requiresExactRetry = true;
94
88
  else
95
- identityMismatch();
89
+ throw fileIdentityMismatchError();
96
90
  const inoKnown = Number.isSafeInteger(stat.ino) && stat.ino >= 0 && (!WINDOWS || stat.ino !== 0);
97
91
  if (inoKnown) {
98
92
  if (stat.ino !== expected.ino)
99
- identityMismatch();
93
+ throw fileIdentityMismatchError();
100
94
  }
101
95
  else if (WINDOWS)
102
96
  requiresExactRetry = true;
103
97
  else
104
- identityMismatch();
98
+ throw fileIdentityMismatchError();
105
99
  if (!requiresExactRetry)
106
100
  return stat;
107
101
  // Read exact identity only once. The strict helper may re-check this constant
@@ -157,7 +151,7 @@ function canonicalAncestry(root) {
157
151
  }
158
152
  function exactIdentityMatches(current, expected) {
159
153
  if (current.dev !== expected.dev || current.ino !== expected.ino)
160
- identityMismatch();
154
+ throw fileIdentityMismatchError();
161
155
  }
162
156
  function associateTempWorkspaceRoot(entry, ownerUid, descriptorFd) {
163
157
  const stat = inspectTempWorkspaceDescriptorIdentitySync(descriptorFd, entry.identity, entry.numericIdentity);
@@ -2,8 +2,7 @@ import fsSync, {} from "node:fs";
2
2
  import { inspectDirectoryIdentitySync, observeDirectoryIdentitySync } from "./directory-guard.js";
3
3
  import { pinNodeDirectoryForMode, pinNodeDirectoryForModeSync } from "./directory-mode-node.js";
4
4
  import { FsSafeError } from "./errors.js";
5
- import { recordFileObservationFailure } from "./file-observation.js";
6
- import { inspectFileIdentitySync } from "./strict-file-identity.js";
5
+ import { fileIdentityMismatchError, inspectFileIdentitySync } from "./strict-file-identity.js";
7
6
  export const TEMP_WORKSPACE_NUMERIC_IDENTITY_REPLAY = process.platform === "linux" || process.platform === "darwin";
8
7
  export function projectTempWorkspaceNumericIdentity(identity) {
9
8
  const dev = Number(identity.dev);
@@ -14,15 +13,10 @@ export function projectTempWorkspaceNumericIdentity(identity) {
14
13
  }
15
14
  return Object.freeze({ dev, ino });
16
15
  }
17
- function identityMismatch() {
18
- const error = new FsSafeError("path-mismatch", "file identity changed or could not be verified");
19
- recordFileObservationFailure(error, "identity");
20
- throw error;
21
- }
22
16
  function inspectNumericIdentity(current, expected) {
23
17
  if (!Number.isSafeInteger(current.dev) || current.dev < 0 || current.dev !== expected.dev ||
24
18
  !Number.isSafeInteger(current.ino) || current.ino < 0 || current.ino !== expected.ino) {
25
- identityMismatch();
19
+ throw fileIdentityMismatchError();
26
20
  }
27
21
  return current;
28
22
  }
@@ -1,7 +1,6 @@
1
1
  import fsSync from "node:fs";
2
2
  import fs from "node:fs/promises";
3
- import { nodeDirectorySearchOnlyFlags } from "./directory-mode-node.js";
4
- import { assertOwnedDirectory } from "./directory-mode-owner.js";
3
+ import { nodeDirectorySearchOnlyFlags, assertOwnedDirectory } from "./directory-mode-node.js";
5
4
  import { FsSafeError } from "./errors.js";
6
5
  import { assertNoWindowsPathAlias, pathForWindowsFilesystem, resolvePathPreservingWindowsRoot, } from "./windows-path-alias.js";
7
6
  import { inspectFileIdentitySync } from "./strict-file-identity.js";
@@ -25,7 +25,7 @@ export type FsSafeTestHooks = {
25
25
  beforeTempWorkspaceNativeRemovalSync?: (quarantinePath: string) => void;
26
26
  beforeTrashMove?: (targetPath: string, destPath: string) => void;
27
27
  afterPublishTargetCreated?: (method: "hardlink" | "exclusive-copy" | "rename-noreplace", targetPath: string, identity: FileIdentityStat) => Promise<void> | void;
28
- beforePublishDirectorySync?: (method: "hardlink" | "exclusive-copy" | "rename-noreplace", targetPath: string, identity: FileIdentityStat) => Promise<void> | void;
28
+ beforePublishDirectorySync?: NonNullable<FsSafeTestHooks["afterPublishTargetCreated"]>;
29
29
  };
30
30
  export declare function getFsSafeTestHooks(): FsSafeTestHooks | undefined;
31
31
  export declare function __setFsSafeTestHooksForTest(hooks?: FsSafeTestHooks): void;
@@ -1,9 +1,9 @@
1
- import { type PermissionCommandFailure } from "./permission-exec.js";
1
+ import { type PermissionFailureFields } from "./permission-exec.js";
2
2
  export type WindowsOwnerExec = (command: string, args: string[]) => Promise<{
3
3
  stdout: string;
4
4
  stderr: string;
5
5
  }>;
6
- export type WindowsOwnerSummary = {
6
+ export type WindowsOwnerSummary = Omit<PermissionFailureFields & {
7
7
  sid?: string;
8
8
  currentUserSid?: string;
9
9
  daclPresent?: boolean;
@@ -11,10 +11,7 @@ export type WindowsOwnerSummary = {
11
11
  aclError?: string;
12
12
  remote?: boolean;
13
13
  trusted?: boolean;
14
- error?: string;
15
- errorDetail?: PermissionCommandFailure;
16
- errorCause?: unknown;
17
- };
14
+ }, never>;
18
15
  export type WindowsOwnerAce = {
19
16
  sid: string;
20
17
  mask: number;
@@ -2,8 +2,6 @@
2
2
  // descriptor never passes through CommonSecurityDescriptor's ACE normalization.
3
3
  using System;
4
4
  using System.Collections.Generic;
5
- using System.ComponentModel;
6
- using System.IO;
7
5
  using System.Runtime.InteropServices;
8
6
  using System.Security.AccessControl;
9
7
  using System.Security.Principal;
@@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url";
3
3
  import { FsSafeError } from "./errors.js";
4
4
  import { DEFAULT_PERMISSION_EXEC_TIMEOUT_MS, PermissionCommandError } from "./permission-exec.js";
5
5
  import { resolveWindowsSystemCommand } from "./windows-command.js";
6
- import { parseWindowsSecurityCommandFacts } from "./windows-security-facts.js";
6
+ import { parseWindowsSecurityCommandFacts, unverified } from "./windows-security-facts.js";
7
7
  const MAX_OUTPUT_BYTES = 1024 * 1024;
8
8
  const TERMINATION_GRACE_MS = 1_000;
9
9
  const FULL_IDENTITY = /^[0-9a-f]{16}:[0-9a-f]{32}$/;
@@ -50,9 +50,6 @@ function command(operation, params) {
50
50
  },
51
51
  };
52
52
  }
53
- function unverified(message, cause) {
54
- throw new FsSafeError("permission-unverified", message, cause === undefined ? {} : { cause });
55
- }
56
53
  function record(value) {
57
54
  return value !== null && typeof value === "object" && !Array.isArray(value);
58
55
  }
@@ -1,4 +1,5 @@
1
1
  import type { NativeWindowsSecurityFacts } from "./native-binding.js";
2
+ export declare function unverified(message: string, cause?: unknown): never;
2
3
  /** Raw reporting retains unknown flag bits; secure admission validates them below. */
3
4
  export declare function parseWindowsSecurityCommandFacts(value: unknown): NativeWindowsSecurityFacts;
4
5
  /** Native and command observations share the same fail-closed admission policy. */
@@ -6,8 +6,8 @@ const WORLD_SIDS = new Set([
6
6
  "s-1-1-0", "s-1-5-11", "s-1-5-32-545", "s-1-5-7",
7
7
  "s-1-5-32-546", "s-1-5-4", "s-1-5-2",
8
8
  ]);
9
- function unverified(message) {
10
- throw new FsSafeError("permission-unverified", message);
9
+ export function unverified(message, cause) {
10
+ throw new FsSafeError("permission-unverified", message, cause === undefined ? {} : { cause });
11
11
  }
12
12
  function record(value) {
13
13
  return value !== null && typeof value === "object" && !Array.isArray(value);
package/docs/archive.md CHANGED
@@ -238,6 +238,8 @@ directory paths. For example, `./pkg//state\cache/value` is presented as
238
238
  `pkg/state/cache/value`, even with `stripComponents: 1`. Case and Unicode
239
239
  spelling are preserved. Local PAX `path`, GNU long-name, and supported ZIP
240
240
  Unicode Path names use the same canonicalization.
241
+ A leading `~` is a literal archive entry name and is never expanded to the
242
+ user's home directory during extraction, publication, or durability checks.
241
243
  Callbacks follow physical archive order, including ZIP names that look like
242
244
  integer object keys. The public ZIP loader's `files` object retains ordinary
243
245
  JavaScript object enumeration and mutation behavior.
package/docs/copy.md CHANGED
@@ -74,6 +74,8 @@ Native Windows byte copies can store large zero-filled chunks as sparse ranges w
74
74
 
75
75
  The ReFS backend rejects files with alternate data streams and unsupported reparse-point types instead of silently losing their contents. Symbolic links and junctions are preserved.
76
76
 
77
+ Failed ReFS clones attempt to remove their partial output through the retained directory handles. On Windows versions that reject the ignore-readonly deletion flag, clone rollback retries without that flag so ordinary output can be removed. It never clears readonly attributes: readonly output can remain, and the original clone error includes the cleanup failure. Other processes retaining output handles can delay deletion beyond settlement; a failed call does not guarantee an absent destination.
78
+
77
79
  XFS and ZFS preserve regular-file and directory modes, timestamps, extended attributes, and ACLs. They reject special files, symlink extended attributes, non-UTF-8 names, and directory nesting deeper than 128 levels. Hardlinked source files become independent reflinked files. Portable byte copying preserves file contents, empty directories, modes where supported, file and directory timestamps, and literal symbolic links; it does not promise ownership, ACL, extended-attribute, alternate-stream, or sparse-layout preservation. On Windows, byte copying rejects unresolved symbolic links because Node does not expose their file/directory link type; resolved links keep their literal target and source type. POSIX dangling links are preserved. Choose a copying policy that meets the caller's metadata requirements; automatic copying can select either path.
78
80
 
79
81
  `readCloneFileMetadata(files)` asynchronously reads APFS data-stream identities and file metadata in one native batch. Results correspond to input order; missing or unsupported entries return `undefined`. The returned `CloneFileMetadata` includes clone ID, device/inode, size, mode, ownership, and timestamps. These are point-in-time observations, not authorization or proof that later reads remain unchanged. Consumers such as Git index adapters must validate their own content and timestamp invariants. The reader does not follow leaf symbolic links.
@@ -101,6 +101,11 @@ such as `internal space/a b.txt` are accepted. On POSIX, colons elsewhere, such
101
101
  as the timestamp in `logs/2026-08-02T10:30:00Z.log`, remain lexically valid;
102
102
  Windows rejects that spelling as stream syntax.
103
103
 
104
+ Keys such as `~` and `~/state.json` name literal entries inside the store; they
105
+ do not expand the user's home directory. Reads, writes, removal, and pruning
106
+ all use that literal identity. The `Root` returned by `root()` retains its own
107
+ home-expansion behavior.
108
+
104
109
  Validation retains each method's operation order. Async reads, `exists`, and
105
110
  `remove` open the root first: if the root is missing, strict methods report
106
111
  `not-found` and `readTextIfExists` / `readJsonIfExists` return `null`, even for an
package/docs/guest.md CHANGED
@@ -119,7 +119,13 @@ publication failure preserves the existing destination and source link; ordinary
119
119
  failure cleanup removes the staging directory.
120
120
 
121
121
  Cross-device directory moves build a copy manifest and check it during source
122
- cleanup. Source changes can leave the published destination and some or all
122
+ cleanup. Directory creation keeps the source mode subject to the guest's umask;
123
+ mode `000` is not replaced with a default. A top-level mode-000 directory uses
124
+ owner-only staging until publication, then restores zero through its retained
125
+ descriptor. Reading a mode-000 source still requires sufficient OS privileges;
126
+ the guest does not change source permissions to gain access. A permission error
127
+ after publication preserves the source and published copy for reconciliation.
128
+ Source changes can leave the published destination and some or all
123
129
  of the source. Regular-file and symlink move fallbacks unlink the source
124
130
  pathname after publication; they do not perform the directory manifest's
125
131
  identity checks. Directory cleanup also has check-to-unlink race windows.
@@ -107,6 +107,12 @@ normalization, and the decision to fall back.
107
107
  - macOS 15.4 and newer prefer `O_RESOLVE_BENEATH`; older kernels resolve components with `O_NOFOLLOW` and restart in-root symlinks from the pinned root descriptor. Both routes use an `F_GETPATH` post-open escape detector and report `best-effort` because directory rename races are not atomic with that check. Direct-child no-replace renames borrow already-retained parents without another `F_GETPATH`; deeper paths retain the guarded reopen. Publication uses `renameat` for replacement and `renameatx_np(RENAME_EXCL)` for no-replace; owned-tree cleanup uses descriptor-relative `openat`/`unlinkat`.
108
108
  - Windows uses handle-relative `NtCreateFile`, rejects reparse points during root-bounded traversal, and uses `FileRenameInfoEx` with replacement selected explicitly by the TypeScript policy layer. The dedicated retained-directory `renameNoReplaceWithIdentity` primitive compares its required exact source receipt with the handle opened for rename before mutation. Its internal mismatch status is normalized to public `path-mismatch`; the legacy four-argument `renameNoReplace` export and its other callers are unchanged. Owned trees are deleted through exact opened handles with `FileDispositionInfoEx`; symlink/reparse entries in owned trees are removed as leaves and never traversed. Descriptors crossing N-API are converted only by the host executable's paired `uv_get_osfhandle` and `uv_open_osfhandle` exports. A runtime without both exports is unsupported for these native operations; the binding never guesses a raw HANDLE or uses a foreign CRT descriptor table. Descriptor-producing operations also require that same host's synchronous libuv close and request-management APIs before exporting an owned descriptor.
109
109
 
110
+ Linux root lookups reject negative descriptor sentinels before borrowing a handle or resolving a relative path; they never substitute the process working directory for an admitted root. Public Root operations already supply retained, admitted handles.
111
+
112
+ On Linux and macOS, native asynchronous file-copy admission rejects negative source and parent descriptors before creating a stage, preserving source-before-parent error ordering. Callers must keep nonnegative source and parent descriptors open until the operation settles.
113
+
114
+ Low-level Unix query, hash, copy, clone, staging, and owned-tree cleanup calls also reject negative descriptors before using them, preserving each operation's path-validation, cancellation, and cleanup order. Descriptor-relative mutations never accept a working-directory sentinel as a retained capability. On modern macOS, beneath opens reject negative roots with `EBADF` before calling `openat`, rather than returning `EIO` after an OS failure or working-directory operation. This check does not establish the validity of arbitrary nonnegative integers: callers must supply live descriptors and retain them until synchronous calls return or asynchronous operations settle.
115
+
110
116
  `replaceDirectoryAtomic()` requires `renameNoReplaceWithIdentity` before it
111
117
  creates a missing target parent. On POSIX the dedicated entry point keeps the
112
118
  existing pre-dispatch exact receipt fence but dispatches direct-child names
package/docs/root.md CHANGED
@@ -24,7 +24,7 @@ type RootDefaults = {
24
24
  denyMutations?: DenyMutationPolicy; // absolute paths/prefixes mutation methods may not change
25
25
  maxBytes?: number; // refuse reads larger than this many bytes; defaults to 16 MiB
26
26
  mkdir?: boolean; // create missing parent dirs on write/openWritable/append; default true
27
- mode?: number; // file mode applied to new writes; per-call override available
27
+ mode?: number; // requested file mode; per-call override available
28
28
  nonBlockingRead?: boolean; // compatibility hint; safe opens are already nonblocking where supported
29
29
  renameIdentity?: "strict" | "verify-content-with-lock"; // default "strict"
30
30
  symlinks?: "reject" | "follow-within-root" | "follow-parents-within-root"; // read policy
@@ -117,6 +117,10 @@ created through a directory symlink or Windows junction. An absolute path
117
117
  outside the root is still rejected. On Windows, alternate casing is accepted
118
118
  only when the differently cased Root prefix has the Root's exact directory
119
119
  identity; the operation then continues under the trusted Root spelling.
120
+ Absolute paths keep literal `~` components: `readAbsolute("/srv/root/~/file")`
121
+ reads that entry under the root, without expanding the user's home directory.
122
+ Relative `~/file` inputs still expand the home directory and must remain inside
123
+ the Root; use `./~/file` for a literal relative `~` directory.
120
124
 
121
125
  ### Writes
122
126
 
@@ -153,6 +157,11 @@ await fs.create("private-data/credential", "synthetic credential", { private: tr
153
157
 
154
158
  `write`, `create`, `append`, `writeJson`, and `createJson` accept `mode?: number`; use `0o600` for credentials and other private state. `writeJson` also accepts the same options as `JSON.stringify` plus `trailingNewline?: boolean` (defaults `true` so the file ends in `\n`).
155
159
 
160
+ For `append` and `openWritable`, `mode` only affects new-file creation: POSIX
161
+ permissions remain subject to the process umask. These methods do not chmod
162
+ existing files. Replacement and create-only writes apply their final mode
163
+ through the retained descriptor; see [Writing](writing.md#write-options).
164
+
156
165
  Buffered `create` and `createJson` also accept `atomic?: boolean`. With `true`,
157
166
  complete content is staged before exclusive publication even in native-off mode;
158
167
  the fallback requires hardlinks. Omitted or `false` keeps the existing buffered
@@ -113,6 +113,10 @@ If an already validated descriptor fails while reading, both readers throw an
113
113
  operational `FsSafeError` with `code: "read-failed"`; inspect `cause` for the
114
114
  underlying Node filesystem code such as `EIO`.
115
115
 
116
+ Caught `null` or `undefined` inspection and read failures are reported as
117
+ structured errors with an `Error` cause carrying `"null"` or `"undefined"`,
118
+ instead of an internal `TypeError` while inspecting the thrown value.
119
+
116
120
  A synchronous reader closes its descriptor once. A close failure preserves an
117
121
  earlier read or identity-validation error; after a successful read, the close
118
122
  failure is reported before trimming or rejecting empty content.
@@ -137,6 +141,11 @@ startWebhookVerifier(signingKey);
137
141
 
138
142
  Async. Creates the parent directory at `dirMode` (default `0o700`) if missing, writes content to a sibling temp file, finalizes `mode` (default `0o600`) through an owned descriptor after content writes, and atomically renames over the destination. Publication verification checks the final file identity and mode.
139
143
 
144
+ Both secret writers capture top-level parameter values when called, before
145
+ asynchronous filesystem preparation; a supplied byte buffer is captured by
146
+ reference. A parameter getter throwing `null` or `undefined` rejects with that
147
+ same value before filesystem inspection.
148
+
140
149
  On POSIX, both native and JavaScript writers verify actual `0o600` permission
141
150
  bits through the retained descriptor before writing content. A filesystem that
142
151
  reports successful chmod without enforcing those bits fails with
@@ -219,6 +228,15 @@ if anything already occupies the target path it throws
219
228
  `FsSafeError("secret-exists")` without modifying that entry. Use the distinct
220
229
  name when first-writer-wins is part of the credential protocol.
221
230
 
231
+ Its `durable` option also accepts `"file"`, matching `Root.create()`. This
232
+ requires every file `fsync` to succeed, including on `EPERM`, while parent-directory
233
+ synchronization remains best effort. The default and boolean options retain their
234
+ existing behavior. `writeSecretFileAtomic()` continues to accept boolean durability.
235
+
236
+ Strict file synchronization preserves the existing publication strategy and
237
+ identity-checked cleanup. A failed file flush before staged publication prevents
238
+ publication; a failure after publication can leave the complete file present.
239
+
222
240
  Distinct leaves can share missing-parent creation without a `secret-exists`
223
241
  error. Concurrent creates at the same leaf still have exactly one winner;
224
242
  the loser receives `secret-exists` and leaves the winner's bytes intact.
@@ -235,6 +253,7 @@ try {
235
253
  rootDir: "/var/lib/app/credentials",
236
254
  filePath: "/var/lib/app/credentials/provider.refresh-token",
237
255
  content: refreshToken,
256
+ durable: "file",
238
257
  });
239
258
  } catch (error) {
240
259
  if (!(error instanceof FsSafeError) || error.code !== "secret-exists") throw error;
@@ -267,6 +267,8 @@ Pass `lockRoot` to place sidecar create, read, verification, and removal behind
267
267
  an existing `Root` capability. `lockPath` must resolve inside that root.
268
268
  Identity-conditioned removal remains the only release and reclaim deletion
269
269
  path.
270
+ Root mutation refusals during stale removal propagate unchanged, including
271
+ `null`, `undefined`, and errors carrying `ENOENT`; they do not become missing-sidecar retries.
270
272
 
271
273
  Async Root-backed acquisition normalizes the target's parent without creating
272
274
  it, checking the retained Root before and after normalization. A deleted or
package/docs/walk.md CHANGED
@@ -113,6 +113,18 @@ typed `FsSafeError("too-large")` instead.
113
113
 
114
114
  For followed symlinks, both `kind` and `size` describe the resolved target.
115
115
 
116
+ The caller's starting path retains Root home shorthand: `~` and `~/dir` expand
117
+ the home directory when iteration starts and must resolve inside the Root.
118
+ Home-started walks report actual Root-relative paths, such as `home/dir/file`,
119
+ rather than `~/dir/file`. Use `./~/dir` to start at a literal `~` directory.
120
+ An alias within a home-started path is reported under its admitted canonical
121
+ target; ordinary non-home starting aliases retain their caller-supplied spelling.
122
+
123
+ Entry names remain literal filesystem data, including a directory named `~`
124
+ and its descendants. To reuse an entry path in another Root method without
125
+ home-directory expansion, prefix it with `./`, as in
126
+ `capability.open("./" + entry.relativePath)`.
127
+
116
128
  The default `order: "sorted"` visits each directory's names in lexicographic
117
129
  order before descending depth first. It reads and sorts all names in each
118
130
  visited directory. With `maxEntries`, it prepares small metadata batches capped
package/docs/writing.md CHANGED
@@ -301,6 +301,12 @@ type RootWriteJsonOptions = RootWriteOptions & {
301
301
 
302
302
  Open in append mode, write, sync the file handle, and close. Honors `mkdir` for the parent directory and syncs the parent directory when the append creates the file. `durable: false` skips both syncs. Pass `prependNewlineIfNeeded: true` to insert a `\n` if the file does not already end in one.
303
303
 
304
+ `mode` selects the creation mode, defaulting to `0o600` when neither the call nor
305
+ the Root supplies it. On POSIX, the process umask can further restrict that mode;
306
+ for example, `mode: 0o640` with umask `0o077` creates a `0o600` file. Existing
307
+ files are not chmodded, even when an explicit `mode` is supplied. Empty appends
308
+ use the same creation rules.
309
+
304
310
  ```ts
305
311
  await fs.append("logs/today.log", `[${ts}] ${line}\n`);
306
312
  await fs.append("notes/scratch.md", "* new bullet", { prependNewlineIfNeeded: true });
@@ -522,6 +528,11 @@ destination — there is no atomic-rename step. For exclusive publication of a
522
528
  complete stream, use [`create()`](#streamed-creation). For streamed replacement,
523
529
  the [`atomic`](atomic.md) helpers provide a staged writer.
524
530
 
531
+ For all three write modes, `mode` only selects new-file creation permissions,
532
+ defaulting to `0o600` when neither the call nor the Root supplies it. POSIX
533
+ permissions remain subject to the process umask; existing files are not chmodded.
534
+ The returned numeric `stat` records the admitted descriptor before caller writes.
535
+
525
536
  On POSIX, existing-target opens use `O_NONBLOCK` as an admission safeguard so
526
537
  a no-reader FIFO cannot stall regular-file validation. This does not change
527
538
  ordinary regular-file write semantics. `replace` and `update` remain write-only
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "keywords": [
6
6
  "filesystem",
@@ -169,13 +169,13 @@
169
169
  "archive:producer-smoke": "node scripts/archive-producer-smoke.mjs"
170
170
  },
171
171
  "optionalDependencies": {
172
- "@openclaw/fs-safe-darwin-arm64": "0.18.1",
173
- "@openclaw/fs-safe-darwin-x64": "0.18.1",
174
- "@openclaw/fs-safe-linux-arm64-gnu": "0.18.1",
175
- "@openclaw/fs-safe-linux-arm64-musl": "0.18.1",
176
- "@openclaw/fs-safe-linux-x64-gnu": "0.18.1",
177
- "@openclaw/fs-safe-linux-x64-musl": "0.18.1",
178
- "@openclaw/fs-safe-win32-x64-msvc": "0.18.1",
172
+ "@openclaw/fs-safe-darwin-arm64": "0.19.0",
173
+ "@openclaw/fs-safe-darwin-x64": "0.19.0",
174
+ "@openclaw/fs-safe-linux-arm64-gnu": "0.19.0",
175
+ "@openclaw/fs-safe-linux-arm64-musl": "0.19.0",
176
+ "@openclaw/fs-safe-linux-x64-gnu": "0.19.0",
177
+ "@openclaw/fs-safe-linux-x64-musl": "0.19.0",
178
+ "@openclaw/fs-safe-win32-x64-msvc": "0.19.0",
179
179
  "jszip": "^3.10.2"
180
180
  },
181
181
  "devDependencies": {
@@ -1,20 +0,0 @@
1
- import type { BigIntStats, Stats } from "node:fs";
2
- export type DirectoryModeChecks = {
3
- check?: () => void;
4
- beforeChmod?: () => Promise<void>;
5
- };
6
- export type DirectoryModeOwner = {
7
- verify(check?: () => void): Promise<void>;
8
- apply(mode: number, checks?: DirectoryModeChecks): Promise<void>;
9
- close(): Promise<void>;
10
- };
11
- export declare function assertOwnedDirectory(expected: Stats | BigIntStats, actual: Stats | BigIntStats): void;
12
- /** Serializes use and close: even a queued path-based fd operation retains its descriptor. */
13
- export declare function ownDirectoryMode(params: {
14
- inspect: () => Promise<number>;
15
- chmod: (mode: number) => Promise<void>;
16
- prepareChmod?: () => Promise<void>;
17
- verifyChmod?: () => Promise<void>;
18
- close: () => Promise<void>;
19
- ignoreChmodError?: boolean;
20
- }): DirectoryModeOwner;
@@ -1,78 +0,0 @@
1
- import { FsSafeError } from "./errors.js";
2
- import { sameFileIdentityForCleanup } from "./file-identity.js";
3
- export function assertOwnedDirectory(expected, actual) {
4
- if (actual.isSymbolicLink() || !actual.isDirectory()) {
5
- throw new FsSafeError("not-file", "directory mode target must be a real directory");
6
- }
7
- if (!sameFileIdentityForCleanup(expected, actual)) {
8
- throw new FsSafeError("path-mismatch", "directory changed before its mode could be applied");
9
- }
10
- }
11
- /** Serializes use and close: even a queued path-based fd operation retains its descriptor. */
12
- export function ownDirectoryMode(params) {
13
- let pending = Promise.resolve();
14
- let closing;
15
- const enqueue = (run) => {
16
- if (closing)
17
- return Promise.reject(new FsSafeError("path-mismatch", "directory mode owner is closed"));
18
- const operation = pending.then(run);
19
- pending = operation.catch(() => undefined);
20
- return operation;
21
- };
22
- return {
23
- verify: (check) => enqueue(async () => {
24
- check?.();
25
- await params.inspect();
26
- check?.();
27
- }),
28
- apply: (mode, checks = {}) => enqueue(async () => {
29
- // inspect() reports permission bits only; chmod ignores file-type bits,
30
- // so tolerate raw stat modes (e.g. S_IFDIR | 0o755) by masking up front.
31
- mode &= 0o7777;
32
- checks.check?.();
33
- const currentMode = await params.inspect();
34
- checks.check?.();
35
- if (currentMode === mode && !checks.beforeChmod && !checks.check)
36
- return;
37
- if (currentMode !== mode) {
38
- await params.prepareChmod?.();
39
- checks.check?.();
40
- }
41
- await checks.beforeChmod?.();
42
- checks.check?.();
43
- // Hooks/ancestor checks can yield; recheck the original named association.
44
- await params.inspect();
45
- checks.check?.();
46
- let dispatchDeadlineFailure;
47
- if (currentMode !== mode) {
48
- checks.check?.();
49
- try {
50
- await params.chmod(mode);
51
- }
52
- catch (error) {
53
- if (!params.ignoreChmodError)
54
- throw error;
55
- }
56
- // Expiry cannot release the fd while post-dispatch verification is pending.
57
- try {
58
- checks.check?.();
59
- }
60
- catch (error) {
61
- dispatchDeadlineFailure = { error };
62
- }
63
- await params.verifyChmod?.();
64
- }
65
- const finalMode = await params.inspect();
66
- if (dispatchDeadlineFailure)
67
- throw dispatchDeadlineFailure.error;
68
- checks.check?.();
69
- if (!params.ignoreChmodError && finalMode !== mode) {
70
- throw new FsSafeError("path-mismatch", "directory final mode could not be verified");
71
- }
72
- }),
73
- close() {
74
- closing ??= pending.then(params.close);
75
- return closing;
76
- },
77
- };
78
- }