@openclaw/fs-safe 0.13.1 → 0.14.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 (39) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/dist/file-store-sync-directory.d.ts.map +1 -1
  3. package/dist/file-store-sync-directory.js +14 -7
  4. package/dist/guest-dispatch-python.js +1 -1
  5. package/dist/native-binding.d.ts +6 -0
  6. package/dist/native-binding.d.ts.map +1 -1
  7. package/dist/native-pinned-write.d.ts.map +1 -1
  8. package/dist/native-pinned-write.js +195 -161
  9. package/dist/native-policy-directory-observation.d.ts +12 -0
  10. package/dist/native-policy-directory-observation.d.ts.map +1 -0
  11. package/dist/native-policy-directory-observation.js +61 -0
  12. package/dist/pinned-mutation-admission.d.ts.map +1 -1
  13. package/dist/pinned-mutation-admission.js +13 -24
  14. package/dist/pinned-mutation-observation.d.ts +7 -1
  15. package/dist/pinned-mutation-observation.d.ts.map +1 -1
  16. package/dist/pinned-mutation-observation.js +87 -5
  17. package/dist/root-directory-creation.d.ts.map +1 -1
  18. package/dist/root-directory-creation.js +5 -4
  19. package/dist/root-impl.d.ts.map +1 -1
  20. package/dist/root-impl.js +2 -1
  21. package/dist/root-path-existing.d.ts.map +1 -1
  22. package/dist/root-path-existing.js +1 -2
  23. package/dist/root-write-compatibility.d.ts +1 -2
  24. package/dist/root-write-compatibility.d.ts.map +1 -1
  25. package/dist/root-write-compatibility.js +17 -68
  26. package/dist/root-write-complete-parent.d.ts.map +1 -1
  27. package/dist/root-write-complete-parent.js +3 -1
  28. package/dist/root-write-lock-binding.d.ts +15 -0
  29. package/dist/root-write-lock-binding.d.ts.map +1 -0
  30. package/dist/root-write-lock-binding.js +162 -0
  31. package/dist/staged-directory.d.ts +5 -2
  32. package/dist/staged-directory.d.ts.map +1 -1
  33. package/dist/staged-directory.js +37 -1
  34. package/dist/temp-workspace-admission.d.ts.map +1 -1
  35. package/dist/temp-workspace-admission.js +28 -4
  36. package/docs/guest.md +1 -1
  37. package/docs/native.md +8 -0
  38. package/docs/writing.md +6 -0
  39. package/package.json +8 -8
@@ -0,0 +1,162 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { inspectDirectoryIdentitySync } from "./directory-guard.js";
4
+ import { FsSafeError } from "./errors.js";
5
+ import { hasNodeErrorCode, isNotFoundPathError } from "./path.js";
6
+ import { admitPathInsideRoot } from "./root-boundary.js";
7
+ import { realpathSync } from "./realpath.js";
8
+ const lockObservations = new WeakMap();
9
+ export function canReuseParentWithMutationAssertion(assertion, rootPath, targetPath) {
10
+ if (assertion === undefined)
11
+ return true;
12
+ const binding = lockObservations.get(assertion);
13
+ return binding?.rootPath === rootPath && binding.targetPath === targetPath;
14
+ }
15
+ function unsupportedSpelling() {
16
+ return new FsSafeError("path-alias", "Windows compatibility writes require lower-case ASCII destination components");
17
+ }
18
+ // Missing names have no portable case/Unicode lock key. Keep this admission
19
+ // narrower than general Root paths and never change keys after acquisition.
20
+ function effectiveDestination(rootPath, targetPath, rootIdentity) {
21
+ let effective;
22
+ let missingHint;
23
+ try {
24
+ effective = realpathSync.native(targetPath);
25
+ }
26
+ catch (error) {
27
+ if (!isNotFoundPathError(error))
28
+ throw error;
29
+ let cursor = targetPath;
30
+ const missing = [];
31
+ for (;;) {
32
+ try {
33
+ fs.lstatSync(cursor);
34
+ break;
35
+ }
36
+ catch (error) {
37
+ if (!isNotFoundPathError(error) || cursor === rootPath)
38
+ throw error;
39
+ const parent = path.dirname(cursor);
40
+ if (parent === cursor)
41
+ throw error;
42
+ missing.unshift(path.basename(cursor));
43
+ cursor = parent;
44
+ }
45
+ }
46
+ // Existing dangling links must fail resolution, not become missing names.
47
+ const canonicalParent = realpathSync.native(cursor);
48
+ effective = path.join(canonicalParent, ...missing);
49
+ // The hint must describe the selected route exactly. The full resolver can
50
+ // normalize a missing raw `..` component out of the eventual lock path.
51
+ if (missing.length > 1 && path.resolve(canonicalParent) === canonicalParent &&
52
+ missing.every(component => /^[a-z0-9._-]+$/.test(component) &&
53
+ component !== "." && component !== ".." && !component.endsWith("."))) {
54
+ missingHint = Object.freeze({ parentPath: canonicalParent, parts: Object.freeze(missing), nextIndex: 0 });
55
+ }
56
+ }
57
+ const admitted = admitPathInsideRoot({ rootPath, candidatePath: effective, rootIdentity });
58
+ if (!admitted)
59
+ throw unsupportedSpelling();
60
+ const relative = admitted.relativePath;
61
+ if (!relative || !relative.split(path.sep).every(component => /^[a-z0-9._-]+$/.test(component) && !component.endsWith(".")))
62
+ throw unsupportedSpelling();
63
+ return {
64
+ path: admitted.path,
65
+ // Case-folded or rebased root spellings keep the full resolver and its
66
+ // existing root-identity admission at every mutation.
67
+ missing: admitted.admission === "exact" && admitted.path === effective ? missingHint : undefined,
68
+ };
69
+ }
70
+ // The hint stores only a location. Each call freshly binds the nearest existing
71
+ // prefix and its first absent child; no identity or authorization crosses calls.
72
+ function observeMissingDestination(hint) {
73
+ // A complete parent needs only the ordinary leaf check, not a prefix fence.
74
+ if (hint.parts.length - hint.nextIndex < 2)
75
+ return undefined;
76
+ try {
77
+ let parentPath = hint.parentPath;
78
+ let parent = inspectDirectoryIdentitySync(parentPath);
79
+ for (let nextIndex = hint.nextIndex; nextIndex < hint.parts.length; nextIndex++) {
80
+ const childPath = path.join(parentPath, hint.parts[nextIndex]);
81
+ let child;
82
+ try {
83
+ child = fs.lstatSync(childPath, { bigint: true });
84
+ }
85
+ catch (error) {
86
+ if (!hasNodeErrorCode(error, "ENOENT"))
87
+ return undefined;
88
+ if (realpathSync.native(parentPath) !== parentPath)
89
+ return undefined;
90
+ inspectDirectoryIdentitySync(parentPath, parent);
91
+ try {
92
+ fs.lstatSync(childPath);
93
+ }
94
+ catch (error) {
95
+ if (!hasNodeErrorCode(error, "ENOENT"))
96
+ return undefined;
97
+ // Absence can follow a renamed ancestor through a new alias while
98
+ // preserving the directory's identity. Finish at its current name.
99
+ if (realpathSync.native(parentPath) !== parentPath)
100
+ return undefined;
101
+ return Object.freeze({ parentPath, parts: hint.parts, nextIndex });
102
+ }
103
+ return undefined;
104
+ }
105
+ // An existing leaf always receives the historical full resolution,
106
+ // including final links, collisions and unsupported canonical spellings.
107
+ if (nextIndex === hint.parts.length - 1)
108
+ return undefined;
109
+ parent = inspectDirectoryIdentitySync(childPath, undefined, child);
110
+ parentPath = childPath;
111
+ }
112
+ }
113
+ catch {
114
+ // Removal, redirection and uncertain observations only discard the hint.
115
+ // The full resolver below still determines the destination and error.
116
+ }
117
+ return undefined;
118
+ }
119
+ export function assertRootFallbackWritePath(expected, actual) {
120
+ if (expected !== undefined && actual !== expected) {
121
+ throw new FsSafeError("path-mismatch", "compatibility write destination changed after lock selection");
122
+ }
123
+ }
124
+ export function createRootWriteLockBinding(params) {
125
+ const { rootPath } = params;
126
+ const rootIdentity = params.rootIdentity && Object.freeze({
127
+ dev: params.rootIdentity.dev, ino: params.rootIdentity.ino,
128
+ });
129
+ const selected = effectiveDestination(rootPath, params.targetPath, rootIdentity);
130
+ const targetPath = selected.path;
131
+ let missingHint = selected.missing;
132
+ const relativePath = path.relative(rootPath, targetPath);
133
+ let active = true;
134
+ const assertCurrent = () => {
135
+ if (!active)
136
+ throw new FsSafeError("path-mismatch", "compatibility lock observation expired");
137
+ const observed = missingHint && observeMissingDestination(missingHint);
138
+ if (observed) {
139
+ missingHint = observed;
140
+ return;
141
+ }
142
+ missingHint = undefined;
143
+ const current = effectiveDestination(rootPath, targetPath, rootIdentity);
144
+ assertRootFallbackWritePath(targetPath, current.path);
145
+ missingHint = current.missing;
146
+ };
147
+ // Only this library-owned, observation-only closure permits parent reuse.
148
+ // Callers cannot obtain that status by setting properties on their callback.
149
+ lockObservations.set(assertCurrent, Object.freeze({ rootPath, targetPath }));
150
+ return Object.freeze({
151
+ targetPath,
152
+ relativePath,
153
+ // The existing lock protocol uses slashes; the writer needs native spelling.
154
+ relativeLockPath: relativePath.split(path.sep).join("/"),
155
+ assertCurrent,
156
+ dispose() {
157
+ active = false;
158
+ missingHint = undefined;
159
+ lockObservations.delete(assertCurrent);
160
+ },
161
+ });
162
+ }
@@ -1,13 +1,16 @@
1
1
  import { type BigIntStats } from "node:fs";
2
2
  import type { DirectoryReceipt } from "./directory-durability.js";
3
3
  import type { FileIdentityStat } from "./file-identity.js";
4
- import { type MutationDirectoryObservation } from "./pinned-mutation-observation.js";
4
+ import type { NativeBinding } from "./native-binding.js";
5
+ import { type MutationDirectoryObservation, type MutationDirectoryObserver } from "./pinned-mutation-observation.js";
5
6
  import type { StagedFileReceipt } from "./staged-file-types.js";
6
7
  export type StagedDirectorySnapshot = StagedFileReceipt["directory"];
7
8
  export type PolicyStagedDirectory = Readonly<{
8
9
  directory: StagedDirectorySnapshot;
9
10
  observation: MutationDirectoryObservation;
10
11
  stat: BigIntStats;
12
+ observeCurrent?: MutationDirectoryObserver;
13
+ disposeObservation?(): void;
11
14
  }>;
12
15
  export declare function exactIdentityMatches(expected: FileIdentityStat, actual: Readonly<{
13
16
  dev: bigint;
@@ -15,7 +18,7 @@ export declare function exactIdentityMatches(expected: FileIdentityStat, actual:
15
18
  }>): boolean;
16
19
  export declare function describeStagedDirectory(fd: number, pathname: string): StagedDirectorySnapshot;
17
20
  export declare function assertStagedDirectoryCurrent(receipt: StagedDirectorySnapshot): BigIntStats;
18
- export declare function describePolicyStagedDirectory(fd: number, pathname: string): PolicyStagedDirectory;
21
+ export declare function describePolicyStagedDirectory(fd: number, pathname: string, binding?: NativeBinding): PolicyStagedDirectory;
19
22
  export declare function assertPolicyStagedDirectoryCurrent(captured: PolicyStagedDirectory): BigIntStats;
20
23
  export declare function refreshPolicyStagedDirectoryObservation(captured: PolicyStagedDirectory): MutationDirectoryObservation;
21
24
  export declare function openStagedDirectory(directory: string | DirectoryReceipt): {
@@ -1 +1 @@
1
- {"version":3,"file":"staged-directory.d.ts","sourceRoot":"","sources":["../src/staged-directory.ts"],"names":[],"mappings":"AAAA,OAAW,EAAE,KAAK,WAAW,EAAE,MAAM,SAAS,CAAC;AAE/C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAElE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAE3D,OAAO,EAEL,KAAK,4BAA4B,EAClC,MAAM,kCAAkC,CAAC;AAE1C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAGhE,MAAM,MAAM,uBAAuB,GAAG,iBAAiB,CAAC,WAAW,CAAC,CAAC;AAErE,MAAM,MAAM,qBAAqB,GAAG,QAAQ,CAAC;IAC3C,SAAS,EAAE,uBAAuB,CAAC;IACnC,WAAW,EAAE,4BAA4B,CAAC;IAC1C,IAAI,EAAE,WAAW,CAAC;CACnB,CAAC,CAAC;AAEH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,gBAAgB,EAC1B,MAAM,EAAE,QAAQ,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,GAC7C,OAAO,CAKT;AAED,wBAAgB,uBAAuB,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,uBAAuB,CAY7F;AAED,wBAAgB,4BAA4B,CAAC,OAAO,EAAE,uBAAuB,GAAG,WAAW,CAS1F;AASD,wBAAgB,6BAA6B,CAC3C,EAAE,EAAE,MAAM,EACV,QAAQ,EAAE,MAAM,GACf,qBAAqB,CAwBvB;AAED,wBAAgB,kCAAkC,CAChD,QAAQ,EAAE,qBAAqB,GAC9B,WAAW,CAOb;AAED,wBAAgB,uCAAuC,CACrD,QAAQ,EAAE,qBAAqB,GAC9B,4BAA4B,CAU9B;AAED,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB,GAAG;IACzE,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,uBAAuB,CAAC;CAClC,CAiCA"}
1
+ {"version":3,"file":"staged-directory.d.ts","sourceRoot":"","sources":["../src/staged-directory.ts"],"names":[],"mappings":"AAAA,OAAW,EAAE,KAAK,WAAW,EAAE,MAAM,SAAS,CAAC;AAE/C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAElE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAE3D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,OAAO,EAEL,KAAK,4BAA4B,EACjC,KAAK,yBAAyB,EAE/B,MAAM,kCAAkC,CAAC;AAE1C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAGhE,MAAM,MAAM,uBAAuB,GAAG,iBAAiB,CAAC,WAAW,CAAC,CAAC;AAErE,MAAM,MAAM,qBAAqB,GAAG,QAAQ,CAAC;IAC3C,SAAS,EAAE,uBAAuB,CAAC;IACnC,WAAW,EAAE,4BAA4B,CAAC;IAC1C,IAAI,EAAE,WAAW,CAAC;IAClB,cAAc,CAAC,EAAE,yBAAyB,CAAC;IAC3C,kBAAkB,CAAC,IAAI,IAAI,CAAC;CAC7B,CAAC,CAAC;AAEH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,gBAAgB,EAC1B,MAAM,EAAE,QAAQ,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,GAC7C,OAAO,CAKT;AAED,wBAAgB,uBAAuB,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,uBAAuB,CAY7F;AAED,wBAAgB,4BAA4B,CAAC,OAAO,EAAE,uBAAuB,GAAG,WAAW,CAS1F;AASD,wBAAgB,6BAA6B,CAC3C,EAAE,EAAE,MAAM,EACV,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,aAAa,GACtB,qBAAqB,CA2CvB;AAED,wBAAgB,kCAAkC,CAChD,QAAQ,EAAE,qBAAqB,GAC9B,WAAW,CAeb;AAED,wBAAgB,uCAAuC,CACrD,QAAQ,EAAE,qBAAqB,GAC9B,4BAA4B,CAoB9B;AAED,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB,GAAG;IACzE,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,uBAAuB,CAAC;CAClC,CAiCA"}
@@ -2,6 +2,7 @@ import fs, {} from "node:fs";
2
2
  import path from "node:path";
3
3
  import { FsSafeError } from "./errors.js";
4
4
  import { inspectDirectoryIdentitySync } from "./directory-guard.js";
5
+ import { nativePolicyDirectoryObserver, NativePolicyDirectoryMismatch } from "./native-policy-directory-observation.js";
5
6
  import { checkedMutationDirectory, } from "./pinned-mutation-observation.js";
6
7
  import { realpathSync } from "./realpath.js";
7
8
  import { resolvePathPreservingWindowsRoot } from "./windows-path-alias.js";
@@ -38,12 +39,31 @@ function sameDirectoryMetadata(left, right) {
38
39
  }
39
40
  // The policy hot path is Node/POSIX-only. It deliberately uses native
40
41
  // canonicalization so a successful capture contains no JS realpath work.
41
- export function describePolicyStagedDirectory(fd, pathname) {
42
+ export function describePolicyStagedDirectory(fd, pathname, binding) {
42
43
  const resolved = path.resolve(pathname);
43
44
  const descriptor = fs.fstatSync(fd, { bigint: true });
44
45
  if (!descriptor.isDirectory()) {
45
46
  throw new FsSafeError("not-file", "staging parent must be a directory");
46
47
  }
48
+ const observeCurrent = nativePolicyDirectoryObserver(binding, fd, resolved);
49
+ const observed = observeCurrent?.();
50
+ if (observed) {
51
+ if (!sameDirectoryMetadata(descriptor, observed.identity)) {
52
+ throw new NativePolicyDirectoryMismatch();
53
+ }
54
+ const directory = Object.freeze({
55
+ path: resolved,
56
+ realPath: observed.canonicalPath,
57
+ identity: Object.freeze({ dev: descriptor.dev, ino: descriptor.ino }),
58
+ });
59
+ return Object.freeze({
60
+ directory,
61
+ observation: checkedMutationDirectory(resolved, observed.canonicalPath, observed.identity, observeCurrent),
62
+ stat: descriptor,
63
+ observeCurrent,
64
+ disposeObservation: observeCurrent.dispose,
65
+ });
66
+ }
47
67
  const before = inspectDirectoryIdentitySync(resolved);
48
68
  const canonicalPath = path.resolve(realpathSync.native(resolved));
49
69
  const after = inspectDirectoryIdentitySync(resolved, descriptor);
@@ -64,6 +84,14 @@ export function describePolicyStagedDirectory(fd, pathname) {
64
84
  });
65
85
  }
66
86
  export function assertPolicyStagedDirectoryCurrent(captured) {
87
+ const observed = captured.observeCurrent?.();
88
+ if (observed) {
89
+ if (!sameDirectoryMetadata(captured.stat, observed.identity) ||
90
+ observed.canonicalPath !== captured.directory.realPath) {
91
+ throw new FsSafeError("path-mismatch", "staging directory pathname changed");
92
+ }
93
+ return captured.stat;
94
+ }
67
95
  const current = inspectDirectoryIdentitySync(captured.directory.path, captured.directory.identity);
68
96
  if (!sameDirectoryMetadata(captured.stat, current) ||
69
97
  path.resolve(realpathSync.native(captured.directory.path)) !== captured.directory.realPath) {
@@ -72,6 +100,14 @@ export function assertPolicyStagedDirectoryCurrent(captured) {
72
100
  return current;
73
101
  }
74
102
  export function refreshPolicyStagedDirectoryObservation(captured) {
103
+ const observed = captured.observeCurrent?.();
104
+ if (observed) {
105
+ if (!exactIdentityMatches(captured.directory.identity, observed.identity) ||
106
+ observed.canonicalPath !== captured.directory.realPath) {
107
+ throw new FsSafeError("path-mismatch", "staging directory pathname changed");
108
+ }
109
+ return checkedMutationDirectory(captured.directory.path, observed.canonicalPath, observed.identity, captured.observeCurrent);
110
+ }
75
111
  const current = inspectDirectoryIdentitySync(captured.directory.path, captured.directory.identity);
76
112
  const canonicalPath = path.resolve(realpathSync.native(captured.directory.path));
77
113
  if (canonicalPath !== captured.directory.realPath) {
@@ -1 +1 @@
1
- {"version":3,"file":"temp-workspace-admission.d.ts","sourceRoot":"","sources":["../src/temp-workspace-admission.ts"],"names":[],"mappings":"AA6BA,KAAK,aAAa,GAAG,QAAQ,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAiB5D,MAAM,MAAM,0BAA0B,GAAG;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,aAAa,CAAC;IACxB,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,mBAAmB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAChD,mBAAmB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAChD,oBAAoB,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAClD,aAAa,IAAI,IAAI,CAAC;IACtB,cAAc,IAAI,IAAI,CAAC;IACvB,gBAAgB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7C,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/C,CAAC;AA0UF,wBAAgB,8BAA8B,CAAC,OAAO,EAAE,MAAM,GAAG,0BAA0B,CAM1F;AAED,wBAAsB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,0BAA0B,CAAC,CAyBjG;AAED,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,GAAG,0BAA0B,CAsBtF"}
1
+ {"version":3,"file":"temp-workspace-admission.d.ts","sourceRoot":"","sources":["../src/temp-workspace-admission.ts"],"names":[],"mappings":"AA6BA,KAAK,aAAa,GAAG,QAAQ,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAiB5D,MAAM,MAAM,0BAA0B,GAAG;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,aAAa,CAAC;IACxB,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,mBAAmB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAChD,mBAAmB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAChD,oBAAoB,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAClD,aAAa,IAAI,IAAI,CAAC;IACtB,cAAc,IAAI,IAAI,CAAC;IACvB,gBAAgB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7C,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/C,CAAC;AAsWF,wBAAgB,8BAA8B,CAAC,OAAO,EAAE,MAAM,GAAG,0BAA0B,CAM1F;AAED,wBAAsB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,0BAA0B,CAAC,CAyBjG;AAED,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,GAAG,0BAA0B,CAsBtF"}
@@ -116,6 +116,30 @@ function assertSnapshot(entry, uid) {
116
116
  assertTrustedTempWorkspaceDirectory(stat, uid);
117
117
  assertCanonicalRoot(entry);
118
118
  }
119
+ function snapshotMissingRootComponent(parent, dir, ownerUid) {
120
+ if (parent.dir !== parent.realPath) {
121
+ assertSnapshot(parent, ownerUid);
122
+ return snapshot(dir, ownerUid);
123
+ }
124
+ const current = inspectSnapshotIdentity(parent);
125
+ assertTrustedTempWorkspaceDirectory(current, ownerUid);
126
+ let realPath;
127
+ try {
128
+ realPath = canonicalTempWorkspacePath(dir);
129
+ }
130
+ catch (error) {
131
+ // Keep parent resolution failures ahead of a missing or unreadable child.
132
+ assertCanonicalRoot(parent);
133
+ throw error;
134
+ }
135
+ if (path.dirname(realPath) !== parent.realPath) {
136
+ assertCanonicalRoot(parent);
137
+ return snapshot(dir, ownerUid);
138
+ }
139
+ // The child's canonical parent confirms the same parent name at this phase.
140
+ // Its exact non-symlink/owner observation still precedes mode initialization.
141
+ return snapshot(dir, ownerUid, realPath);
142
+ }
119
143
  function assertChain(chain, uid) {
120
144
  for (const entry of chain) {
121
145
  const current = inspectSnapshotIdentity(entry);
@@ -321,6 +345,7 @@ export async function admitTempWorkspaceRoot(rootDir) {
321
345
  return admission;
322
346
  const guardedChain = chain;
323
347
  for (const segment of missing) {
348
+ const parentEntry = guardedChain[guardedChain.length - 1];
324
349
  const parent = rootAdmission(guardedChain, ownerUid);
325
350
  const dir = path.join(parent.dir, segment);
326
351
  assertNoWindowsPathAlias(dir, "filesystem");
@@ -334,8 +359,7 @@ export async function admitTempWorkspaceRoot(rootDir) {
334
359
  if (error.code !== "EEXIST")
335
360
  throw error;
336
361
  }
337
- parent.assertCurrent();
338
- const observed = snapshot(dir, ownerUid);
362
+ const observed = snapshotMissingRootComponent(parentEntry, dir, ownerUid);
339
363
  if (created) {
340
364
  const modeInitialization = admitTempWorkspaceChild(dir, observed.stat, parent, 0o700);
341
365
  if (modeInitialization)
@@ -351,6 +375,7 @@ export function admitTempWorkspaceRootSync(rootDir) {
351
375
  return admission;
352
376
  const guardedChain = chain;
353
377
  for (const segment of missing) {
378
+ const parentEntry = guardedChain[guardedChain.length - 1];
354
379
  const parent = rootAdmission(guardedChain, ownerUid);
355
380
  const dir = path.join(parent.dir, segment);
356
381
  assertNoWindowsPathAlias(dir, "filesystem");
@@ -364,8 +389,7 @@ export function admitTempWorkspaceRootSync(rootDir) {
364
389
  if (error.code !== "EEXIST")
365
390
  throw error;
366
391
  }
367
- parent.assertCurrent();
368
- const observed = snapshot(dir, ownerUid);
392
+ const observed = snapshotMissingRootComponent(parentEntry, dir, ownerUid);
369
393
  if (created)
370
394
  admitTempWorkspaceChildSync(dir, observed.stat, parent, 0o700);
371
395
  guardedChain.push(observed.entry);
package/docs/guest.md CHANGED
@@ -80,7 +80,7 @@ disables. Do not omit required fields.
80
80
  | Rename | `rename srcRoot srcParent srcBasename dstRoot dstParent dstBasename mkdir` | Rename with cross-device copy/delete fallback. |
81
81
  | Remove | `remove root parent basename recursive force` | Removes the leaf, or recursively removes its tree. |
82
82
  | Make directories | `mkdirp root directory` | Creates missing relative directory components. |
83
- | List directory | `readdir root directory` | JSON array of `{ name, isDirectory }`; no sorting guarantee. |
83
+ | List directory | `readdir root directory` | JSON array of `{ name, isDirectory, isFile }`; both kind fields are false for symlinks and special entries; no sorting guarantee. |
84
84
 
85
85
  The complete program rejects empty, `.`, `..`, slash-containing, and NUL
86
86
  basenames before opening roots or creating parents. Both leaf operands of copy
package/docs/native.md CHANGED
@@ -206,6 +206,14 @@ that fallback does not apply to POSIX no-read modes.
206
206
 
207
207
  ## JavaScript fallback guarantees and delta
208
208
 
209
+ Policy-bound parent creation can refresh exact directory facts through the
210
+ retained POSIX descriptor. The optional native observer compares descriptor and
211
+ no-follow pathname metadata and verifies the descriptor's canonical path before
212
+ and after the observation. Ordinary paths can share that observation within one
213
+ synchronous admission phase; callbacks, mutations, and later phases require fresh
214
+ evidence. Unsupported helpers retain the guarded pathname checks. Policy and
215
+ denied-path decisions remain in TypeScript.
216
+
209
217
  Public policy does not change with the selected mechanism: traversal and link
210
218
  rejection, archive filters/limits/modes, exclusive target creation, source and
211
219
  target identity fencing, publication cleanup receipts, and secret/lock policy
package/docs/writing.md CHANGED
@@ -537,6 +537,12 @@ Windows `Root.write()` and `Root.writeJson()` honor this policy both as a Root d
537
537
 
538
538
  The Windows buffered compatibility path resolves permitted in-root aliases before choosing its lock and binds publication to that effective destination. With the existing lock protocol, effective path components beneath the Root must contain only lower-case ASCII letters, digits, `.`, `_`, or `-`, with no trailing `.`. Unsupported spellings, including missing upper-case or non-ASCII names, fail with `path-alias` before mutation; no filesystem case-sensitivity or Unicode-folding behavior is guessed. This restriction does not apply to strict writes. Opaque Windows pathname identities still use strict verification against the retained original descriptor: they never, by themselves, authorize content-based acceptance of a replacement.
539
539
 
540
+ The library's own lock-destination check permits the writer to reuse parent
541
+ admission within that operation. The lock key and destination checks remain
542
+ unchanged, and evidence is revoked when the locked operation finishes. Supplying
543
+ an `assertBeforeMutation` callback retains full parent admission because caller
544
+ code can change the filesystem before dispatch.
545
+
540
546
  Lock recovery is fail-closed. If a process crashes and leaves the root-level `.fs-safe-write-<sha256>.lock`, a later write reports the stale lock instead of deleting it based on a host-local PID. Recover only under external authority that excludes every competing writer; see [File lock](sidecar-lock.md#stale-recovery-guarded-remove-if-unchanged).
541
547
 
542
548
  ## See also
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "keywords": [
6
6
  "filesystem",
@@ -167,13 +167,13 @@
167
167
  "archive:producer-smoke": "node scripts/archive-producer-smoke.mjs"
168
168
  },
169
169
  "optionalDependencies": {
170
- "@openclaw/fs-safe-darwin-arm64": "0.13.1",
171
- "@openclaw/fs-safe-darwin-x64": "0.13.1",
172
- "@openclaw/fs-safe-linux-arm64-gnu": "0.13.1",
173
- "@openclaw/fs-safe-linux-arm64-musl": "0.13.1",
174
- "@openclaw/fs-safe-linux-x64-gnu": "0.13.1",
175
- "@openclaw/fs-safe-linux-x64-musl": "0.13.1",
176
- "@openclaw/fs-safe-win32-x64-msvc": "0.13.1",
170
+ "@openclaw/fs-safe-darwin-arm64": "0.14.0",
171
+ "@openclaw/fs-safe-darwin-x64": "0.14.0",
172
+ "@openclaw/fs-safe-linux-arm64-gnu": "0.14.0",
173
+ "@openclaw/fs-safe-linux-arm64-musl": "0.14.0",
174
+ "@openclaw/fs-safe-linux-x64-gnu": "0.14.0",
175
+ "@openclaw/fs-safe-linux-x64-musl": "0.14.0",
176
+ "@openclaw/fs-safe-win32-x64-msvc": "0.14.0",
177
177
  "jszip": "^3.10.2"
178
178
  },
179
179
  "devDependencies": {