@openclaw/fs-safe 0.2.3 → 0.2.5

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 (44) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/README.md +2 -0
  3. package/dist/archive-staging.d.ts.map +1 -1
  4. package/dist/archive-staging.js +12 -1
  5. package/dist/file-store.d.ts.map +1 -1
  6. package/dist/file-store.js +6 -5
  7. package/dist/json-document-store.d.ts +1 -1
  8. package/dist/json-document-store.d.ts.map +1 -1
  9. package/dist/json-document-store.js +5 -3
  10. package/dist/json-durable-queue.d.ts.map +1 -1
  11. package/dist/json-durable-queue.js +157 -4
  12. package/dist/json-stringify.d.ts +2 -0
  13. package/dist/json-stringify.d.ts.map +1 -0
  14. package/dist/json-stringify.js +7 -0
  15. package/dist/json.d.ts.map +1 -1
  16. package/dist/json.js +3 -2
  17. package/dist/local-roots.d.ts.map +1 -1
  18. package/dist/local-roots.js +4 -1
  19. package/dist/path.js +1 -1
  20. package/dist/permissions.d.ts.map +1 -1
  21. package/dist/permissions.js +7 -10
  22. package/dist/pinned-python.d.ts.map +1 -1
  23. package/dist/pinned-python.js +125 -43
  24. package/dist/root-impl.d.ts.map +1 -1
  25. package/dist/root-impl.js +6 -2
  26. package/dist/root-paths.js +25 -3
  27. package/dist/secure-file.d.ts.map +1 -1
  28. package/dist/secure-file.js +3 -1
  29. package/dist/sidecar-lock.d.ts +8 -1
  30. package/dist/sidecar-lock.d.ts.map +1 -1
  31. package/dist/sidecar-lock.js +40 -7
  32. package/dist/timing.d.ts.map +1 -1
  33. package/dist/timing.js +8 -1
  34. package/docs/config.md +2 -2
  35. package/docs/index.md +1 -1
  36. package/docs/json-store.md +3 -1
  37. package/docs/sidecar-lock.md +33 -5
  38. package/package.json +4 -4
  39. package/dist/path-scope.d.ts +0 -2
  40. package/dist/path-scope.d.ts.map +0 -1
  41. package/dist/path-scope.js +0 -1
  42. package/dist/private-file-store.d.ts +0 -58
  43. package/dist/private-file-store.d.ts.map +0 -1
  44. package/dist/private-file-store.js +0 -220
@@ -87,6 +87,85 @@ def reject_unsafe_endpoint(st):
87
87
  if stat.S_ISREG(mode) and st.st_nlink > 1:
88
88
  raise OSError(errno.EPERM, "hardlinked file endpoint is not allowed")
89
89
 
90
+ def copy_bytes(source_fd, dest_fd):
91
+ while True:
92
+ chunk = os.read(source_fd, 65536)
93
+ if not chunk:
94
+ break
95
+ view = memoryview(chunk)
96
+ while view:
97
+ written = os.write(dest_fd, view)
98
+ if written <= 0:
99
+ raise OSError(errno.EIO, "short write")
100
+ view = view[written:]
101
+
102
+ def write_all(fd, data):
103
+ view = memoryview(data)
104
+ while view:
105
+ written = os.write(fd, view)
106
+ if written <= 0:
107
+ raise OSError(errno.EIO, "short write")
108
+ view = view[written:]
109
+
110
+ def link_unsupported(exc):
111
+ unsupported = (errno.EPERM, errno.EOPNOTSUPP, getattr(errno, "ENOTSUP", errno.EOPNOTSUPP))
112
+ return getattr(exc, "errno", None) in unsupported
113
+
114
+ def link_no_replace(name, new_name, source_fd, target_fd):
115
+ linked = False
116
+ try:
117
+ os.link(name, new_name, src_dir_fd=source_fd, dst_dir_fd=target_fd, follow_symlinks=False)
118
+ linked = True
119
+ os.unlink(name, dir_fd=source_fd)
120
+ except Exception:
121
+ if linked:
122
+ try: os.unlink(new_name, dir_fd=target_fd)
123
+ except FileNotFoundError: pass
124
+ raise
125
+ os.fsync(source_fd)
126
+ if source_fd != target_fd:
127
+ os.fsync(target_fd)
128
+
129
+ def copy_file_no_replace(source_parent_fd, source_name, target_parent_fd, basename, mode, expected=None, unlink_source=False):
130
+ source_fd = os.open(source_name, READ_FLAGS, dir_fd=source_parent_fd)
131
+ dest_fd = None
132
+ success = False
133
+ try:
134
+ if expected is not None:
135
+ source_stat = os.fstat(source_fd)
136
+ if source_stat.st_dev != expected.st_dev or source_stat.st_ino != expected.st_ino:
137
+ raise RuntimeError("fs-safe-source-mismatch")
138
+ dest_fd = os.open(basename, WRITE_FLAGS, mode, dir_fd=target_parent_fd)
139
+ copy_bytes(source_fd, dest_fd)
140
+ os.fsync(dest_fd)
141
+ success = True
142
+ finally:
143
+ os.close(source_fd)
144
+ if dest_fd is not None:
145
+ os.close(dest_fd)
146
+ if dest_fd is not None and not success:
147
+ try: os.unlink(basename, dir_fd=target_parent_fd)
148
+ except FileNotFoundError: pass
149
+ if unlink_source:
150
+ try:
151
+ os.unlink(source_name, dir_fd=source_parent_fd)
152
+ except Exception:
153
+ try: os.unlink(basename, dir_fd=target_parent_fd)
154
+ except FileNotFoundError: pass
155
+ raise
156
+
157
+ def commit_temp_file(parent_fd, temp_name, basename, overwrite, mode):
158
+ if overwrite:
159
+ os.replace(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
160
+ else:
161
+ try:
162
+ os.link(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd, follow_symlinks=False)
163
+ os.unlink(temp_name, dir_fd=parent_fd)
164
+ except OSError as exc:
165
+ if not link_unsupported(exc):
166
+ raise
167
+ copy_file_no_replace(parent_fd, temp_name, parent_fd, basename, mode, unlink_source=True)
168
+
90
169
  def stat_path(root_fd, payload):
91
170
  relative = payload.get("relativePath", "")
92
171
  segments = split_relative(relative)
@@ -161,7 +240,18 @@ def rename_path(root_fd, payload):
161
240
  try:
162
241
  from_stat = os.lstat(from_base, dir_fd=from_parent_fd)
163
242
  reject_unsafe_endpoint(from_stat)
164
- if not payload.get("overwrite", True):
243
+ overwrite = payload.get("overwrite", True)
244
+ if not overwrite and stat.S_ISREG(from_stat.st_mode):
245
+ try:
246
+ link_no_replace(from_base, to_base, from_parent_fd, to_parent_fd)
247
+ except OSError as exc:
248
+ if not link_unsupported(exc):
249
+ raise
250
+ copy_file_no_replace(from_parent_fd, from_base, to_parent_fd, to_base, stat.S_IMODE(from_stat.st_mode), from_stat, True)
251
+ return None
252
+ if not overwrite and stat.S_ISDIR(from_stat.st_mode):
253
+ raise RuntimeError("fs-safe-directory-noreplace-unsupported")
254
+ if not overwrite:
165
255
  try:
166
256
  os.lstat(to_base, dir_fd=to_parent_fd)
167
257
  raise FileExistsError(errno.EEXIST, "destination exists", to_base)
@@ -206,16 +296,11 @@ def write_path(root_fd, payload):
206
296
  except FileNotFoundError:
207
297
  pass
208
298
  temp_name, temp_fd = create_temp_file(parent_fd, basename, mode)
209
- view = memoryview(data)
210
- while view:
211
- written = os.write(temp_fd, view)
212
- if written <= 0:
213
- raise OSError(errno.EIO, "short write")
214
- view = view[written:]
299
+ write_all(temp_fd, data)
215
300
  os.fsync(temp_fd)
216
301
  os.close(temp_fd)
217
302
  temp_fd = None
218
- os.replace(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
303
+ commit_temp_file(parent_fd, temp_name, basename, overwrite, mode)
219
304
  temp_name = None
220
305
  os.fsync(parent_fd)
221
306
  result_stat = os.stat(basename, dir_fd=parent_fd, follow_symlinks=False)
@@ -248,12 +333,6 @@ def copy_path(root_fd, payload):
248
333
  if max_bytes >= 0 and source_stat.st_size > max_bytes:
249
334
  raise RuntimeError("fs-safe-too-large:%d:%d" % (max_bytes, source_stat.st_size))
250
335
  parent_fd = walk_dir(root_fd, split_relative(payload.get("relativeParentPath", "")), bool(payload.get("mkdir", True)))
251
- if not overwrite:
252
- try:
253
- os.lstat(basename, dir_fd=parent_fd)
254
- raise FileExistsError(errno.EEXIST, "destination exists", basename)
255
- except FileNotFoundError:
256
- pass
257
336
  temp_name, temp_fd = create_temp_file(parent_fd, basename, mode)
258
337
  written_bytes = 0
259
338
  while True:
@@ -272,7 +351,7 @@ def copy_path(root_fd, payload):
272
351
  os.fsync(temp_fd)
273
352
  os.close(temp_fd)
274
353
  temp_fd = None
275
- os.replace(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
354
+ commit_temp_file(parent_fd, temp_name, basename, overwrite, mode)
276
355
  temp_name = None
277
356
  os.fsync(parent_fd)
278
357
  result_stat = os.stat(basename, dir_fd=parent_fd, follow_symlinks=False)
@@ -396,6 +475,9 @@ function mapWorkerError(response) {
396
475
  if (message.includes("fs-safe-source-mismatch")) {
397
476
  return new FsSafeError("path-mismatch", "source path changed during copy");
398
477
  }
478
+ if (message.includes("fs-safe-directory-noreplace-unsupported")) {
479
+ return new FsSafeError("invalid-path", "directory moves require overwrite: true");
480
+ }
399
481
  if (code === "FileNotFoundError" || errno === 2) {
400
482
  return new FsSafeError("not-found", "file not found");
401
483
  }
@@ -413,19 +495,19 @@ function mapWorkerError(response) {
413
495
  }
414
496
  return new FsSafeError("helper-failed", message);
415
497
  }
416
- function rejectPending(error) {
417
- if (!worker) {
498
+ function rejectPending(error, targetWorker = worker) {
499
+ if (!targetWorker || worker !== targetWorker) {
418
500
  return;
419
501
  }
420
- setWorkerRef(worker, false);
421
- for (const pending of worker.pending.values()) {
502
+ setWorkerRef(targetWorker, false);
503
+ for (const pending of targetWorker.pending.values()) {
422
504
  pending.reject(error);
423
505
  }
424
- worker.pending.clear();
506
+ targetWorker.pending.clear();
425
507
  worker = null;
426
508
  }
427
- function handleWorkerLine(line) {
428
- if (!worker || !line.trim()) {
509
+ function handleWorkerLine(currentWorker, line) {
510
+ if (worker !== currentWorker || !line.trim()) {
429
511
  return;
430
512
  }
431
513
  let decoded;
@@ -433,11 +515,11 @@ function handleWorkerLine(line) {
433
515
  decoded = JSON.parse(line);
434
516
  }
435
517
  catch {
436
- rejectPending(new FsSafeError("helper-failed", `pinned helper returned invalid JSON: ${line}`));
518
+ rejectPending(new FsSafeError("helper-failed", `pinned helper returned invalid JSON: ${line}`), currentWorker);
437
519
  return;
438
520
  }
439
521
  if (typeof decoded !== "object" || decoded === null || !("id" in decoded)) {
440
- rejectPending(new FsSafeError("helper-failed", "pinned helper returned invalid response"));
522
+ rejectPending(new FsSafeError("helper-failed", "pinned helper returned invalid response"), currentWorker);
441
523
  return;
442
524
  }
443
525
  const response = decoded;
@@ -445,13 +527,13 @@ function handleWorkerLine(line) {
445
527
  if (id === undefined) {
446
528
  return;
447
529
  }
448
- const pending = worker.pending.get(id);
530
+ const pending = currentWorker.pending.get(id);
449
531
  if (!pending) {
450
532
  return;
451
533
  }
452
- worker.pending.delete(id);
453
- if (worker.pending.size === 0) {
454
- setWorkerRef(worker, false);
534
+ currentWorker.pending.delete(id);
535
+ if (currentWorker.pending.size === 0) {
536
+ setWorkerRef(currentWorker, false);
455
537
  }
456
538
  if (response.ok === true) {
457
539
  pending.resolve(response.result);
@@ -467,28 +549,28 @@ function getWorker() {
467
549
  const child = spawn(resolvePython(), ["-u", "-c", PINNED_PYTHON_WORKER_SOURCE], {
468
550
  stdio: ["pipe", "pipe", "pipe"],
469
551
  });
470
- worker = { child, pending: new Map(), stderr: "", stdoutBuffer: "" };
552
+ const currentWorker = { child, pending: new Map(), stderr: "", stdoutBuffer: "" };
553
+ worker = currentWorker;
471
554
  child.stdout.setEncoding("utf8");
472
555
  child.stderr.setEncoding("utf8");
473
556
  child.stdout.on("data", (chunk) => {
474
- const current = worker;
475
- if (!current) {
557
+ if (worker !== currentWorker) {
476
558
  return;
477
559
  }
478
- current.stdoutBuffer += chunk;
560
+ currentWorker.stdoutBuffer += chunk;
479
561
  for (;;) {
480
- const newline = current.stdoutBuffer.indexOf("\n");
562
+ const newline = currentWorker.stdoutBuffer.indexOf("\n");
481
563
  if (newline < 0) {
482
564
  break;
483
565
  }
484
- const line = current.stdoutBuffer.slice(0, newline);
485
- current.stdoutBuffer = current.stdoutBuffer.slice(newline + 1);
486
- handleWorkerLine(line);
566
+ const line = currentWorker.stdoutBuffer.slice(0, newline);
567
+ currentWorker.stdoutBuffer = currentWorker.stdoutBuffer.slice(newline + 1);
568
+ handleWorkerLine(currentWorker, line);
487
569
  }
488
570
  });
489
571
  child.stderr.on("data", (chunk) => {
490
- if (worker) {
491
- worker.stderr = `${worker.stderr}${chunk}`.slice(-4096);
572
+ if (worker === currentWorker) {
573
+ currentWorker.stderr = `${currentWorker.stderr}${chunk}`.slice(-4096);
492
574
  }
493
575
  });
494
576
  child.once("error", (error) => {
@@ -497,17 +579,17 @@ function getWorker() {
497
579
  : error instanceof Error
498
580
  ? error
499
581
  : new Error(String(error));
500
- rejectPending(mapped);
582
+ rejectPending(mapped, currentWorker);
501
583
  });
502
584
  child.once("close", (code, signal) => {
503
- const stderr = worker?.stderr.trim();
504
- rejectPending(new FsSafeError("helper-failed", stderr || `pinned helper exited with code ${code ?? "null"} (${signal ?? "?"})`));
585
+ const stderr = currentWorker.stderr.trim();
586
+ rejectPending(new FsSafeError("helper-failed", stderr || `pinned helper exited with code ${code ?? "null"} (${signal ?? "?"})`), currentWorker);
505
587
  });
506
588
  process.once("exit", () => {
507
589
  child.kill("SIGTERM");
508
590
  });
509
- setWorkerRef(worker, false);
510
- return worker;
591
+ setWorkerRef(currentWorker, false);
592
+ return currentWorker;
511
593
  }
512
594
  function setRefable(value, ref) {
513
595
  if (!value) {
@@ -1 +1 @@
1
- {"version":3,"file":"root-impl.d.ts","sourceRoot":"","sources":["../src/root-impl.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"root-impl.d.ts","sourceRoot":"","sources":["../src/root-impl.ts"],"names":[],"mappings":"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"}
package/dist/root-impl.js CHANGED
@@ -20,6 +20,7 @@ import { resolveRootPath } from "./root-path.js";
20
20
  import { assertValidRootRelativePath, ensureTrailingSep, expandRelativePathWithHome, resolvePathInRoot, resolveRootContext, } from "./root-context.js";
21
21
  import { isAlreadyExistsError, normalizePinnedPathError, normalizePinnedWriteError, } from "./root-errors.js";
22
22
  import { getFsSafeTestHooks } from "./test-hooks.js";
23
+ import { stringifyJsonDocument } from "./json-stringify.js";
23
24
  import { registerTempPathForExit } from "./temp-cleanup.js";
24
25
  import { serializePathWrite } from "./write-queue.js";
25
26
  function logWarn(message) {
@@ -260,12 +261,12 @@ class RootHandle {
260
261
  }
261
262
  async writeJson(relativePath, data, options = {}) {
262
263
  const { replacer, space, trailingNewline = true, ...writeOptions } = options;
263
- const json = JSON.stringify(data, replacer, space);
264
+ const json = stringifyJsonDocument(data, replacer, space);
264
265
  await this.write(relativePath, trailingNewline ? `${json}\n` : json, writeOptions);
265
266
  }
266
267
  async createJson(relativePath, data, options = {}) {
267
268
  const { replacer, space, trailingNewline = true, ...writeOptions } = options;
268
- const json = JSON.stringify(data, replacer, space);
269
+ const json = stringifyJsonDocument(data, replacer, space);
269
270
  await this.create(relativePath, trailingNewline ? `${json}\n` : json, writeOptions);
270
271
  }
271
272
  async copyIn(relativePath, sourcePath, options = {}) {
@@ -1050,6 +1051,9 @@ async function movePathFallback(root, params) {
1050
1051
  if (sourceStat.isFile() && sourceStat.nlink > 1) {
1051
1052
  throw new FsSafeError("hardlink", "hardlinked path not allowed");
1052
1053
  }
1054
+ if (!params.overwrite && sourceStat.isDirectory()) {
1055
+ throw new FsSafeError("invalid-path", "directory moves require overwrite: true");
1056
+ }
1053
1057
  if (!params.overwrite) {
1054
1058
  try {
1055
1059
  await fs.lstat(target.resolved);
@@ -62,7 +62,7 @@ export function resolvePathWithinRoot(params) {
62
62
  }
63
63
  const resolved = path.resolve(root, raw);
64
64
  const rel = path.relative(root, resolved);
65
- if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
65
+ if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
66
66
  return { ok: false, error: `Invalid path: must stay within ${params.scopeLabel}` };
67
67
  }
68
68
  return { ok: true, path: resolved };
@@ -118,7 +118,7 @@ async function resolveNearestExistingPath(targetPath) {
118
118
  }
119
119
  async function assertNoSymlinkSegments(params) {
120
120
  const relative = path.relative(params.rootDir, params.targetPath);
121
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
121
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
122
122
  throw new Error(`Invalid path: must stay within ${params.scopeLabel}`);
123
123
  }
124
124
  let current = params.rootDir;
@@ -266,7 +266,10 @@ async function resolveCheckedPathsWithinRoot(params, allowMissingFallback) {
266
266
  const rootDir = path.resolve(params.rootDir);
267
267
  const rootRealPath = await resolveRealPathIfExists(rootDir);
268
268
  const root = rootRealPath ? await openRoot(rootDir) : undefined;
269
- const isInRoot = (relativePath) => Boolean(relativePath) && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
269
+ const isInRoot = (relativePath) => Boolean(relativePath) &&
270
+ relativePath !== ".." &&
271
+ !relativePath.startsWith(`..${path.sep}`) &&
272
+ !path.isAbsolute(relativePath);
270
273
  const resolveExistingRelativePath = async (requestedPath) => {
271
274
  const raw = requestedPath.trim();
272
275
  const lexicalPathResult = resolvePathWithinRoot({
@@ -316,6 +319,25 @@ async function resolveCheckedPathsWithinRoot(params, allowMissingFallback) {
316
319
  }
317
320
  catch (err) {
318
321
  if (allowMissingFallback && err instanceof FsSafeError && err.code === "not-found") {
322
+ if (!rootRealPath) {
323
+ resolvedPaths.push(pathResult.fallbackPath);
324
+ continue;
325
+ }
326
+ try {
327
+ await assertNoSymlinkSegments({
328
+ rootDir,
329
+ targetPath: pathResult.fallbackPath,
330
+ scopeLabel: params.scopeLabel,
331
+ });
332
+ const existingPath = await resolveNearestExistingPath(pathResult.fallbackPath);
333
+ const existingRealPath = await fs.realpath(existingPath);
334
+ if (!isPathInside(rootRealPath, existingRealPath)) {
335
+ return invalidPath(params.scopeLabel);
336
+ }
337
+ }
338
+ catch {
339
+ return invalidPath(params.scopeLabel);
340
+ }
319
341
  resolvedPaths.push(pathResult.fallbackPath);
320
342
  continue;
321
343
  }
@@ -1 +1 @@
1
- {"version":3,"file":"secure-file.d.ts","sourceRoot":"","sources":["../src/secure-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAUrC,OAAO,EAOL,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC5B,MAAM,kBAAkB,CAAC;AAK1B,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,sBAAsB,CAAC;IAC/B,WAAW,CAAC,EAAE,2BAA2B,CAAC;IAC1C,MAAM,CAAC,EAAE,uBAAuB,CAAC;IACjC,EAAE,CAAC,EAAE,mBAAmB,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG;IACxC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG,sBAAsB,CAAC;AAE7D,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,KAAK,CAAC;IACZ,WAAW,CAAC,EAAE,eAAe,CAAC;CAC/B,CAAC;AAkKF,wBAAsB,cAAc,CAClC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,oBAAoB,CAAC,CAa/B"}
1
+ {"version":3,"file":"secure-file.d.ts","sourceRoot":"","sources":["../src/secure-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAUrC,OAAO,EAOL,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC5B,MAAM,kBAAkB,CAAC;AAK1B,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,sBAAsB,CAAC;IAC/B,WAAW,CAAC,EAAE,2BAA2B,CAAC;IAC1C,MAAM,CAAC,EAAE,uBAAuB,CAAC;IACjC,EAAE,CAAC,EAAE,mBAAmB,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG;IACxC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG,sBAAsB,CAAC;AAE7D,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,KAAK,CAAC;IACZ,WAAW,CAAC,EAAE,eAAe,CAAC;CAC/B,CAAC;AAsKF,wBAAsB,cAAc,CAClC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,oBAAoB,CAAC,CAa/B"}
@@ -9,7 +9,9 @@ import { inspectPathPermissions, isGroupReadable, isGroupWritable, isWorldReadab
9
9
  const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
10
10
  const OPEN_READ_FLAGS = fsConstants.O_RDONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
11
11
  function isAbsolutePathname(value) {
12
- return path.isAbsolute(value) || isWindowsDriveLetterPath(value, "win32") || isWindowsNetworkPath(value, "win32");
12
+ return (path.isAbsolute(value) ||
13
+ (process.platform === "win32" &&
14
+ (isWindowsDriveLetterPath(value, "win32") || isWindowsNetworkPath(value, "win32"))));
13
15
  }
14
16
  function label(options) {
15
17
  return options.label ?? "Secure file";
@@ -5,7 +5,13 @@ export type SidecarLockRetryOptions = {
5
5
  maxTimeout?: number;
6
6
  randomize?: boolean;
7
7
  };
8
- export type SidecarLockStaleRecovery = "fail-closed";
8
+ export type SidecarLockStaleRecovery = "fail-closed" | "remove-if-unchanged";
9
+ export type SidecarLockStaleSnapshot = {
10
+ lockPath: string;
11
+ normalizedTargetPath: string;
12
+ raw: string;
13
+ payload: Record<string, unknown> | null;
14
+ };
9
15
  export type SidecarLockAcquireOptions<TPayload extends Record<string, unknown>> = {
10
16
  targetPath: string;
11
17
  lockPath?: string;
@@ -23,6 +29,7 @@ export type SidecarLockAcquireOptions<TPayload extends Record<string, unknown>>
23
29
  nowMs: number;
24
30
  heldByThisProcess: boolean;
25
31
  }) => boolean | Promise<boolean>;
32
+ shouldRemoveStaleLock?: (snapshot: SidecarLockStaleSnapshot) => boolean | Promise<boolean>;
26
33
  metadata?: Record<string, unknown>;
27
34
  };
28
35
  export type SidecarLockHandle = {
@@ -1 +1 @@
1
- {"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"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"}
@@ -64,6 +64,33 @@ async function lockSnapshotStillPresent(lockPath, observed) {
64
64
  const current = await readLockSnapshot(lockPath);
65
65
  return !!current && !!observed && snapshotMatches(current, observed);
66
66
  }
67
+ async function removeStaleLockIfAllowed(params) {
68
+ if (!params.shouldRemoveStaleLock) {
69
+ return "not-approved";
70
+ }
71
+ if (params.snapshot.raw === undefined) {
72
+ return "not-approved";
73
+ }
74
+ if (!(await params.shouldRemoveStaleLock({
75
+ lockPath: params.lockPath,
76
+ normalizedTargetPath: params.normalizedTargetPath,
77
+ raw: params.snapshot.raw,
78
+ payload: params.snapshot.payload,
79
+ }))) {
80
+ return "not-approved";
81
+ }
82
+ const current = await readLockSnapshot(params.lockPath);
83
+ if (!current || !snapshotMatches(current, params.snapshot)) {
84
+ return "changed";
85
+ }
86
+ try {
87
+ await fs.rm(params.lockPath, { force: true });
88
+ }
89
+ catch {
90
+ return "not-approved";
91
+ }
92
+ return "removed";
93
+ }
67
94
  function snapshotMatchesSync(lockPath, observed) {
68
95
  try {
69
96
  const stat = fsSync.lstatSync(lockPath);
@@ -248,17 +275,23 @@ export function createSidecarLockManager(key) {
248
275
  if (!(await lockSnapshotStillPresent(lockPath, snapshot))) {
249
276
  continue;
250
277
  }
251
- // Node exposes only path-based unlink/rename here. A stale-lock
252
- // reclaimer cannot bind the delete to the file it inspected, so a
253
- // concurrent release+fresh-acquire could otherwise lose its lock.
254
- // Fail closed and let callers choose a higher-level recovery path.
255
- if ((options.staleRecovery ?? "fail-closed") === "fail-closed") {
256
- throw Object.assign(new Error(`file lock stale for ${normalizedTargetPath}`), {
257
- code: "file_lock_stale",
278
+ const staleRecovery = options.staleRecovery ?? "fail-closed";
279
+ if (staleRecovery === "remove-if-unchanged") {
280
+ const removal = await removeStaleLockIfAllowed({
258
281
  lockPath,
259
282
  normalizedTargetPath,
283
+ snapshot,
284
+ shouldRemoveStaleLock: options.shouldRemoveStaleLock,
260
285
  });
286
+ if (removal === "removed" || removal === "changed") {
287
+ continue;
288
+ }
261
289
  }
290
+ throw Object.assign(new Error(`file lock stale for ${normalizedTargetPath}`), {
291
+ code: "file_lock_stale",
292
+ lockPath,
293
+ normalizedTargetPath,
294
+ });
262
295
  }
263
296
  const elapsed = Date.now() - startedAt;
264
297
  if ((options.timeoutMs !== undefined &&
@@ -1 +1 @@
1
- {"version":3,"file":"timing.d.ts","sourceRoot":"","sources":["../src/timing.ts"],"names":[],"mappings":"AAAA,wBAAsB,WAAW,CAAC,CAAC,EACjC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,SAAS,EAAE,MAAM,EACjB,cAAc,GAAE,MAAM,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,KAAK,CAAA;CAErF,GACA,OAAO,CAAC,CAAC,CAAC,CAuBZ"}
1
+ {"version":3,"file":"timing.d.ts","sourceRoot":"","sources":["../src/timing.ts"],"names":[],"mappings":"AAAA,wBAAsB,WAAW,CAAC,CAAC,EACjC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,SAAS,EAAE,MAAM,EACjB,cAAc,GAAE,MAAM,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,KAAK,CAAA;CAErF,GACA,OAAO,CAAC,CAAC,CAAC,CA6BZ"}
package/dist/timing.js CHANGED
@@ -12,7 +12,14 @@ export async function withTimeout(promise, timeoutMs, labelOrOptions = {
12
12
  return await Promise.race([
13
13
  promise,
14
14
  new Promise((_, reject) => {
15
- timeoutId = setTimeout(() => reject(createError()), timeoutMs);
15
+ timeoutId = setTimeout(() => {
16
+ try {
17
+ reject(createError());
18
+ }
19
+ catch (error) {
20
+ reject(error);
21
+ }
22
+ }, timeoutMs);
16
23
  }),
17
24
  ]);
18
25
  }
package/docs/config.md CHANGED
@@ -56,7 +56,7 @@ Return the effective configuration: programmatic overrides win, then env vars, t
56
56
  function configureFsSafeLocks(config: Partial<FsSafeLockConfig>): void;
57
57
 
58
58
  type FsSafeLockConfig = {
59
- staleRecovery: "fail-closed";
59
+ staleRecovery: "fail-closed" | "remove-if-unchanged";
60
60
  staleMs?: number;
61
61
  timeoutMs?: number;
62
62
  retry?: FileLockRetryOptions;
@@ -65,7 +65,7 @@ type FsSafeLockConfig = {
65
65
 
66
66
  Set process-wide defaults for sidecar lock options. This does **not** turn locking on globally; callers still need to pass `lock: true` or a lock options object for the specific JSON store/resource that needs cross-process coordination.
67
67
 
68
- `staleRecovery` currently supports `"fail-closed"` only. Stale third-party sidecars are not deleted by path because Node cannot atomically bind that deletion to the file that was inspected.
68
+ `staleRecovery` defaults to `"fail-closed"`. `"remove-if-unchanged"` is available for callers that also pass `shouldRemoveStaleLock`; fs-safe re-reads the observed sidecar and removes it only when the raw content and file identity still match.
69
69
 
70
70
  ## `getFsSafeLockConfig()`
71
71
 
package/docs/index.md CHANGED
@@ -64,7 +64,7 @@ await fs.remove("notes/archive/today.txt");
64
64
  | [`extractArchive`](archive.md) | ZIP/TAR extraction with size, count, link, and traversal limits. |
65
65
  | [Secret files](secret-file.md) | Mode-0600 credentials with size and TOCTOU defense. |
66
66
  | [Permissions](permissions.md) | POSIX mode and Windows ACL inspection/remediation helpers. |
67
- | [`acquireFileLock`](sidecar-lock.md) | Cross-process file lock with retry and fail-closed stale-lock handling. |
67
+ | [`acquireFileLock`](sidecar-lock.md) | Cross-process file lock with retry, fail-closed stale-lock handling, and opt-in caller-approved stale removal. |
68
68
  | [`FsSafeError`](errors.md) | Closed code union (with `policy` / `operational` category) you can branch on. |
69
69
  | [`pathScope()`](path-scope.md) | Lower-level absolute-path boundary helper; lives behind `@openclaw/fs-safe/advanced`. |
70
70
  | [`@openclaw/fs-safe/advanced`](advanced.md) | Directory of lower-level composition helpers (path scopes, regular-file I/O, install paths, sibling-temp writes, …). |
@@ -53,7 +53,7 @@ type JsonStoreLockOptions = {
53
53
  staleMs?: number; // default 30_000
54
54
  timeoutMs?: number; // default 30_000
55
55
  retry?: FileLockRetryOptions;
56
- staleRecovery?: "fail-closed";
56
+ staleRecovery?: "fail-closed" | "remove-if-unchanged";
57
57
  managerKey?: string; // default `fs-safe.json-store:<filePath>`
58
58
  };
59
59
 
@@ -141,6 +141,8 @@ When `lock` is falsy, `read` / `write` / `update` are unlocked. The `update` sha
141
141
 
142
142
  Process-wide lock defaults from `configureFsSafeLocks()` apply only after locking is explicitly enabled. They do not make JSON stores lock by default.
143
143
 
144
+ JSON store locks do not expose `shouldRemoveStaleLock`, so `staleRecovery: "remove-if-unchanged"` cannot remove a stale sidecar by itself. Use the lower-level [file lock](sidecar-lock.md) API when your application needs custom owner-liveness checks and caller-approved stale-lock removal.
145
+
144
146
  The default `managerKey` namespaces the in-process `FileLockManager` per absolute file path, so two `jsonStore` calls on the same file share lock state automatically.
145
147
 
146
148
  ## Common patterns