@openclaw/crabline 0.1.11 → 0.1.12

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 (197) hide show
  1. package/README.md +99 -12
  2. package/dist/src/bin/crabline.js +3 -1
  3. package/dist/src/bin/crabline.js.map +1 -1
  4. package/dist/src/cli/program.d.ts +5 -1
  5. package/dist/src/cli/program.js +264 -50
  6. package/dist/src/cli/program.js.map +1 -1
  7. package/dist/src/config/load.js +39 -3
  8. package/dist/src/config/load.js.map +1 -1
  9. package/dist/src/config/schema.d.ts +20 -0
  10. package/dist/src/config/schema.js +163 -35
  11. package/dist/src/config/schema.js.map +1 -1
  12. package/dist/src/core/errors.d.ts +1 -0
  13. package/dist/src/core/errors.js +6 -3
  14. package/dist/src/core/errors.js.map +1 -1
  15. package/dist/src/core/http-path.d.ts +1 -0
  16. package/dist/src/core/http-path.js +14 -0
  17. package/dist/src/core/http-path.js.map +1 -0
  18. package/dist/src/core/matcher.d.ts +3 -1
  19. package/dist/src/core/matcher.js +58 -2
  20. package/dist/src/core/matcher.js.map +1 -1
  21. package/dist/src/core/nonces.d.ts +2 -0
  22. package/dist/src/core/nonces.js +11 -1
  23. package/dist/src/core/nonces.js.map +1 -1
  24. package/dist/src/core/reporters.d.ts +5 -0
  25. package/dist/src/core/reporters.js +26 -2
  26. package/dist/src/core/reporters.js.map +1 -1
  27. package/dist/src/core/run.d.ts +3 -1
  28. package/dist/src/core/run.js +178 -66
  29. package/dist/src/core/run.js.map +1 -1
  30. package/dist/src/matrix-ids.d.ts +5 -0
  31. package/dist/src/matrix-ids.js +83 -0
  32. package/dist/src/matrix-ids.js.map +1 -0
  33. package/dist/src/openclaw/artifact-generation.d.ts +7 -2
  34. package/dist/src/openclaw/artifact-generation.js +399 -27
  35. package/dist/src/openclaw/artifact-generation.js.map +1 -1
  36. package/dist/src/openclaw/bridges/matrix.js +21 -4
  37. package/dist/src/openclaw/bridges/matrix.js.map +1 -1
  38. package/dist/src/openclaw/bridges/mattermost.js +13 -8
  39. package/dist/src/openclaw/bridges/mattermost.js.map +1 -1
  40. package/dist/src/openclaw/bridges/probe-response.d.ts +1 -0
  41. package/dist/src/openclaw/bridges/probe-response.js +5 -0
  42. package/dist/src/openclaw/bridges/probe-response.js.map +1 -0
  43. package/dist/src/openclaw/bridges/signal.js +15 -6
  44. package/dist/src/openclaw/bridges/signal.js.map +1 -1
  45. package/dist/src/openclaw/bridges/slack.js +116 -23
  46. package/dist/src/openclaw/bridges/slack.js.map +1 -1
  47. package/dist/src/openclaw/bridges/telegram.js +45 -19
  48. package/dist/src/openclaw/bridges/telegram.js.map +1 -1
  49. package/dist/src/openclaw/bridges/whatsapp.js +32 -16
  50. package/dist/src/openclaw/bridges/whatsapp.js.map +1 -1
  51. package/dist/src/openclaw/bridges/zalo.js +8 -3
  52. package/dist/src/openclaw/bridges/zalo.js.map +1 -1
  53. package/dist/src/openclaw/private-file.d.ts +38 -3
  54. package/dist/src/openclaw/private-file.js +2008 -87
  55. package/dist/src/openclaw/private-file.js.map +1 -1
  56. package/dist/src/openclaw/shared.d.ts +1 -0
  57. package/dist/src/openclaw/shared.js +48 -13
  58. package/dist/src/openclaw/shared.js.map +1 -1
  59. package/dist/src/openclaw/smoke-lock.d.ts +9 -0
  60. package/dist/src/openclaw/smoke-lock.js +626 -172
  61. package/dist/src/openclaw/smoke-lock.js.map +1 -1
  62. package/dist/src/openclaw.js +225 -42
  63. package/dist/src/openclaw.js.map +1 -1
  64. package/dist/src/platform/process-owned-lock.d.ts +11 -0
  65. package/dist/src/platform/process-owned-lock.js +1698 -0
  66. package/dist/src/platform/process-owned-lock.js.map +1 -0
  67. package/dist/src/platform/recorder-directory.d.ts +1 -0
  68. package/dist/src/platform/recorder-directory.js +14 -0
  69. package/dist/src/platform/recorder-directory.js.map +1 -0
  70. package/dist/src/platform/windows-acl.d.ts +18 -0
  71. package/dist/src/platform/windows-acl.js +1367 -0
  72. package/dist/src/platform/windows-acl.js.map +1 -0
  73. package/dist/src/platform/windows-lock-root.d.ts +13 -0
  74. package/dist/src/platform/windows-lock-root.js +98 -0
  75. package/dist/src/platform/windows-lock-root.js.map +1 -0
  76. package/dist/src/providers/builtin/discord.d.ts +4 -0
  77. package/dist/src/providers/builtin/discord.js +52 -9
  78. package/dist/src/providers/builtin/discord.js.map +1 -1
  79. package/dist/src/providers/builtin/external-webhook-auth.d.ts +1 -0
  80. package/dist/src/providers/builtin/external-webhook-auth.js +38 -1
  81. package/dist/src/providers/builtin/external-webhook-auth.js.map +1 -1
  82. package/dist/src/providers/builtin/feishu.d.ts +1 -0
  83. package/dist/src/providers/builtin/feishu.js +96 -26
  84. package/dist/src/providers/builtin/feishu.js.map +1 -1
  85. package/dist/src/providers/builtin/googlechat.d.ts +1 -0
  86. package/dist/src/providers/builtin/googlechat.js +95 -26
  87. package/dist/src/providers/builtin/googlechat.js.map +1 -1
  88. package/dist/src/providers/builtin/imessage.js +60 -17
  89. package/dist/src/providers/builtin/imessage.js.map +1 -1
  90. package/dist/src/providers/builtin/loopback.js +88 -35
  91. package/dist/src/providers/builtin/loopback.js.map +1 -1
  92. package/dist/src/providers/builtin/matrix.d.ts +2 -2
  93. package/dist/src/providers/builtin/matrix.js +78 -21
  94. package/dist/src/providers/builtin/matrix.js.map +1 -1
  95. package/dist/src/providers/builtin/mattermost.d.ts +11 -7
  96. package/dist/src/providers/builtin/mattermost.js +138 -24
  97. package/dist/src/providers/builtin/mattermost.js.map +1 -1
  98. package/dist/src/providers/builtin/msteams.d.ts +1 -0
  99. package/dist/src/providers/builtin/msteams.js +118 -38
  100. package/dist/src/providers/builtin/msteams.js.map +1 -1
  101. package/dist/src/providers/builtin/native-local-mock.js +4 -4
  102. package/dist/src/providers/builtin/native-local-mock.js.map +1 -1
  103. package/dist/src/providers/builtin/script.js +1057 -146
  104. package/dist/src/providers/builtin/script.js.map +1 -1
  105. package/dist/src/providers/builtin/slack.d.ts +8 -1
  106. package/dist/src/providers/builtin/slack.js +327 -24
  107. package/dist/src/providers/builtin/slack.js.map +1 -1
  108. package/dist/src/providers/builtin/telegram.d.ts +1 -1
  109. package/dist/src/providers/builtin/telegram.js +55 -14
  110. package/dist/src/providers/builtin/telegram.js.map +1 -1
  111. package/dist/src/providers/builtin/whatsapp.d.ts +4 -3
  112. package/dist/src/providers/builtin/whatsapp.js +157 -29
  113. package/dist/src/providers/builtin/whatsapp.js.map +1 -1
  114. package/dist/src/providers/builtin/zalo.d.ts +5 -2
  115. package/dist/src/providers/builtin/zalo.js +31 -7
  116. package/dist/src/providers/builtin/zalo.js.map +1 -1
  117. package/dist/src/providers/catalog.d.ts +1 -1
  118. package/dist/src/providers/catalog.js +1 -1
  119. package/dist/src/providers/catalog.js.map +1 -1
  120. package/dist/src/providers/local-mock.d.ts +4 -0
  121. package/dist/src/providers/local-mock.js +162 -35
  122. package/dist/src/providers/local-mock.js.map +1 -1
  123. package/dist/src/providers/native-ids.d.ts +2 -0
  124. package/dist/src/providers/native-ids.js +3 -0
  125. package/dist/src/providers/native-ids.js.map +1 -1
  126. package/dist/src/providers/recorder.d.ts +14 -3
  127. package/dist/src/providers/recorder.js +521 -41
  128. package/dist/src/providers/recorder.js.map +1 -1
  129. package/dist/src/providers/registry.d.ts +1 -0
  130. package/dist/src/providers/registry.js +65 -20
  131. package/dist/src/providers/registry.js.map +1 -1
  132. package/dist/src/providers/signed-jwt.d.ts +6 -1
  133. package/dist/src/providers/signed-jwt.js +241 -50
  134. package/dist/src/providers/signed-jwt.js.map +1 -1
  135. package/dist/src/providers/slack-ids.d.ts +1 -0
  136. package/dist/src/providers/slack-ids.js +5 -0
  137. package/dist/src/providers/slack-ids.js.map +1 -1
  138. package/dist/src/providers/target-normalizers.d.ts +9 -1
  139. package/dist/src/providers/target-normalizers.js +177 -58
  140. package/dist/src/providers/target-normalizers.js.map +1 -1
  141. package/dist/src/providers/webhook-server.d.ts +3 -0
  142. package/dist/src/providers/webhook-server.js +268 -47
  143. package/dist/src/providers/webhook-server.js.map +1 -1
  144. package/dist/src/servers/http.d.ts +14 -2
  145. package/dist/src/servers/http.js +229 -58
  146. package/dist/src/servers/http.js.map +1 -1
  147. package/dist/src/servers/matrix.d.ts +3 -0
  148. package/dist/src/servers/matrix.js +268 -110
  149. package/dist/src/servers/matrix.js.map +1 -1
  150. package/dist/src/servers/mattermost.d.ts +5 -0
  151. package/dist/src/servers/mattermost.js +389 -91
  152. package/dist/src/servers/mattermost.js.map +1 -1
  153. package/dist/src/servers/recorder.d.ts +11 -0
  154. package/dist/src/servers/recorder.js +1072 -69
  155. package/dist/src/servers/recorder.js.map +1 -1
  156. package/dist/src/servers/signal.d.ts +1 -0
  157. package/dist/src/servers/signal.js +279 -44
  158. package/dist/src/servers/signal.js.map +1 -1
  159. package/dist/src/servers/slack.d.ts +15 -0
  160. package/dist/src/servers/slack.js +259 -51
  161. package/dist/src/servers/slack.js.map +1 -1
  162. package/dist/src/servers/telegram-identity.d.ts +6 -0
  163. package/dist/src/servers/telegram-identity.js +27 -0
  164. package/dist/src/servers/telegram-identity.js.map +1 -0
  165. package/dist/src/servers/telegram.d.ts +17 -6
  166. package/dist/src/servers/telegram.js +1169 -107
  167. package/dist/src/servers/telegram.js.map +1 -1
  168. package/dist/src/servers/webhook-target.d.ts +12 -4
  169. package/dist/src/servers/webhook-target.js +226 -28
  170. package/dist/src/servers/webhook-target.js.map +1 -1
  171. package/dist/src/servers/whatsapp-baileys-websocket.d.ts +60 -1
  172. package/dist/src/servers/whatsapp-baileys-websocket.js +782 -112
  173. package/dist/src/servers/whatsapp-baileys-websocket.js.map +1 -1
  174. package/dist/src/servers/whatsapp-jid.d.ts +1 -0
  175. package/dist/src/servers/whatsapp-jid.js +9 -5
  176. package/dist/src/servers/whatsapp-jid.js.map +1 -1
  177. package/dist/src/servers/whatsapp-wire/binary-node.d.ts +1 -0
  178. package/dist/src/servers/whatsapp-wire/binary-node.js +37 -13
  179. package/dist/src/servers/whatsapp-wire/binary-node.js.map +1 -1
  180. package/dist/src/servers/whatsapp-wire/crypto.d.ts +3 -0
  181. package/dist/src/servers/whatsapp-wire/crypto.js +23 -8
  182. package/dist/src/servers/whatsapp-wire/crypto.js.map +1 -1
  183. package/dist/src/servers/whatsapp-wire/handshake.d.ts +9 -3
  184. package/dist/src/servers/whatsapp-wire/handshake.js +173 -12
  185. package/dist/src/servers/whatsapp-wire/handshake.js.map +1 -1
  186. package/dist/src/servers/whatsapp-wire/signal.js +3 -3
  187. package/dist/src/servers/whatsapp-wire/signal.js.map +1 -1
  188. package/dist/src/servers/whatsapp.d.ts +7 -0
  189. package/dist/src/servers/whatsapp.js +149 -24
  190. package/dist/src/servers/whatsapp.js.map +1 -1
  191. package/dist/src/servers/zalo.d.ts +1 -0
  192. package/dist/src/servers/zalo.js +193 -82
  193. package/dist/src/servers/zalo.js.map +1 -1
  194. package/docs/channel-setup.md +185 -43
  195. package/fixtures/examples/crabline.example.yaml +3 -2
  196. package/package.json +18 -10
  197. package/fixtures/examples/openclaw-bridge.yaml +0 -125
@@ -1,6 +1,12 @@
1
- import { lstat, mkdir, open, readFile, readlink, realpath } from "node:fs/promises";
1
+ import { constants as fsConstants } from "node:fs";
2
+ import { chmod, lstat, mkdir, open, readFile, readlink, realpath } from "node:fs/promises";
3
+ import { userInfo } from "node:os";
2
4
  import path from "node:path";
3
5
  import { lock } from "proper-lockfile";
6
+ import { createProcessOwnedLockFileSystem } from "../platform/process-owned-lock.js";
7
+ import { isManagedRecorderDirectory } from "../platform/recorder-directory.js";
8
+ import { createOwnerOnlyWindowsDirectory, readWindowsDirectoryNamespaceSecuritySnapshot, readWindowsDirectorySecuritySnapshot, } from "../platform/windows-acl.js";
9
+ import { secureCachedWindowsLockRoot, } from "../platform/windows-lock-root.js";
4
10
  function sleep(ms, signal) {
5
11
  if (signal?.aborted) {
6
12
  return Promise.resolve();
@@ -17,6 +23,8 @@ function sleep(ms, signal) {
17
23
  }
18
24
  const pendingAppends = new Map();
19
25
  const recordIdentityIndexes = new Map();
26
+ const securedWindowsLockRoots = new Map();
27
+ const securedWindowsRecorderDirectories = new Map();
20
28
  const MAX_RECORD_IDENTITY_INDEXES = 128;
21
29
  const MAX_RECENT_RECORD_KEYS = 4096;
22
30
  const RECORDER_BATCH_VERSION = 1;
@@ -25,6 +33,14 @@ const RECORDER_LOCK_UPDATE_MS = 10_000;
25
33
  const RECORDER_ROTATION_ATTEMPTS = 3;
26
34
  class RecorderRotatedError extends Error {
27
35
  }
36
+ export class ProviderRecorderCommittedError extends AggregateError {
37
+ committed = true;
38
+ indeterminate = true;
39
+ constructor(filePath, cause, relatedErrors = []) {
40
+ super([cause, ...relatedErrors], `Provider recorder append was published for "${filePath}", but durability or identity confirmation failed.`, { cause });
41
+ this.name = "ProviderRecorderCommittedError";
42
+ }
43
+ }
28
44
  const CONTINUITY_BYTES = 4096;
29
45
  const MAX_INCREMENTAL_READ_BYTES = 256 * 1024;
30
46
  const MAX_PENDING_RECORD_BYTES = 4 * 1024 * 1024;
@@ -38,6 +54,20 @@ function createIncrementalReadState() {
38
54
  pending: Buffer.alloc(0),
39
55
  };
40
56
  }
57
+ function snapshotIncrementalReadState(state) {
58
+ return {
59
+ ...state,
60
+ identity: state.identity ? { ...state.identity } : undefined,
61
+ };
62
+ }
63
+ function restoreIncrementalReadState(state, snapshot) {
64
+ state.caughtUp = snapshot.caughtUp;
65
+ state.continuity = snapshot.continuity;
66
+ state.generation = snapshot.generation;
67
+ state.identity = snapshot.identity;
68
+ state.offset = snapshot.offset;
69
+ state.pending = snapshot.pending;
70
+ }
41
71
  export function createRecordedInboundCursor() {
42
72
  return {
43
73
  buffered: [],
@@ -188,11 +218,11 @@ function consumeRecordedChunk(state, chunk) {
188
218
  }
189
219
  return events;
190
220
  }
191
- async function appendJsonLine(filePath, line) {
221
+ async function appendJsonLine(filePath, line, firstCreatedDirectory) {
192
222
  for (let attempt = 0;; attempt++) {
193
223
  try {
194
- await serializeAppend(filePath, async (publicationPath, logicalPath) => {
195
- await appendCommittedLine(publicationPath, logicalPath, line, true);
224
+ await serializeAppend(filePath, async (publicationPath, logicalPath, _lockCreatedFile, lockedIdentity) => {
225
+ await appendCommittedLine(publicationPath, logicalPath, line, true, lockedIdentity, firstCreatedDirectory);
196
226
  });
197
227
  return;
198
228
  }
@@ -212,6 +242,7 @@ async function readRecorderFileIdentity(filePath) {
212
242
  return {
213
243
  dev: stats.dev,
214
244
  ino: stats.ino,
245
+ nlink: stats.nlink,
215
246
  };
216
247
  }
217
248
  catch (error) {
@@ -224,6 +255,19 @@ async function readRecorderFileIdentity(filePath) {
224
255
  function sameRecorderFileIdentity(left, right) {
225
256
  return left?.dev === right?.dev && left?.ino === right?.ino;
226
257
  }
258
+ function sameRecorderLockIdentity(left, right) {
259
+ return sameRecorderFileIdentity(left, right) && left?.nlink === right?.nlink;
260
+ }
261
+ function requireRecorderHandleIdentity(stats) {
262
+ if (stats.isFile?.() === false) {
263
+ throw new Error("Recorder path is not a regular file.");
264
+ }
265
+ return {
266
+ dev: stats.dev,
267
+ ino: stats.ino,
268
+ nlink: stats.nlink,
269
+ };
270
+ }
227
271
  async function resolveRecorderPublicationPath(filePath) {
228
272
  try {
229
273
  return await realpath(filePath);
@@ -247,6 +291,94 @@ async function resolveRecorderPublicationPath(filePath) {
247
291
  }
248
292
  return path.join(await realpath(path.dirname(filePath)), path.basename(filePath));
249
293
  }
294
+ async function openRecorderForAppend(filePath) {
295
+ const createFlags = process.platform === "win32"
296
+ ? "wx+"
297
+ : fsConstants.O_RDWR |
298
+ fsConstants.O_APPEND |
299
+ fsConstants.O_CREAT |
300
+ fsConstants.O_EXCL |
301
+ fsConstants.O_NONBLOCK |
302
+ fsConstants.O_NOFOLLOW;
303
+ const existingFlags = process.platform === "win32"
304
+ ? "r+"
305
+ : fsConstants.O_RDWR | fsConstants.O_APPEND | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW;
306
+ try {
307
+ return {
308
+ created: true,
309
+ handle: await open(filePath, createFlags, 0o600),
310
+ };
311
+ }
312
+ catch (error) {
313
+ if (error.code !== "EEXIST") {
314
+ throw error;
315
+ }
316
+ return {
317
+ created: false,
318
+ handle: await open(filePath, existingFlags, 0o600),
319
+ };
320
+ }
321
+ }
322
+ async function appendRecorderData(handle, data) {
323
+ if (process.platform !== "win32") {
324
+ await handle.writeFile(data, "utf8");
325
+ return;
326
+ }
327
+ const bytes = Buffer.from(data, "utf8");
328
+ const position = (await handle.stat()).size;
329
+ let offset = 0;
330
+ while (offset < bytes.length) {
331
+ const { bytesWritten } = await handle.write(bytes, offset, bytes.length - offset, position + offset);
332
+ if (bytesWritten === 0) {
333
+ throw new Error("Could not append recorder data.");
334
+ }
335
+ offset += bytesWritten;
336
+ }
337
+ }
338
+ async function syncParentDirectory(filePath) {
339
+ if (process.platform === "win32") {
340
+ return;
341
+ }
342
+ const directory = await open(path.dirname(filePath), "r");
343
+ try {
344
+ await directory.sync();
345
+ }
346
+ finally {
347
+ await directory.close();
348
+ }
349
+ }
350
+ async function syncRecorderPathAncestry(filePath, firstCreatedDirectory) {
351
+ if (process.platform === "win32") {
352
+ return;
353
+ }
354
+ const resolvedFilePath = path.resolve(filePath);
355
+ let currentPath = resolvedFilePath;
356
+ const syncThroughPath = firstCreatedDirectory === undefined ? undefined : path.resolve(firstCreatedDirectory);
357
+ for (;;) {
358
+ const directoryPath = path.dirname(currentPath);
359
+ const mandatory = syncThroughPath !== undefined || currentPath === resolvedFilePath;
360
+ try {
361
+ await syncParentDirectory(currentPath);
362
+ }
363
+ catch (error) {
364
+ const code = error.code;
365
+ if (!mandatory && (code === "EACCES" || code === "EPERM")) {
366
+ return;
367
+ }
368
+ throw error;
369
+ }
370
+ if (syncThroughPath === undefined) {
371
+ return;
372
+ }
373
+ if (currentPath === syncThroughPath) {
374
+ return;
375
+ }
376
+ if (path.dirname(directoryPath) === directoryPath) {
377
+ return;
378
+ }
379
+ currentPath = directoryPath;
380
+ }
381
+ }
250
382
  async function prepareRecorderTailForAppend(handle) {
251
383
  const stats = await handle.stat();
252
384
  if (stats.size === 0) {
@@ -265,8 +397,8 @@ async function prepareRecorderTailForAppend(handle) {
265
397
  const tailStart = stats.size - windowSize + lastNewline + 1;
266
398
  const tail = tailWindow.subarray(lastNewline + 1).toString("utf8");
267
399
  try {
268
- JSON.parse(tail);
269
- await handle.writeFile("\n", "utf8");
400
+ parseRecordedLine(tail);
401
+ await appendRecorderData(handle, "\n");
270
402
  return true;
271
403
  }
272
404
  catch (error) {
@@ -277,11 +409,15 @@ async function prepareRecorderTailForAppend(handle) {
277
409
  return true;
278
410
  }
279
411
  }
280
- async function prepareRecorderPathForAppend(publicationPath, logicalPath, durable) {
281
- const handle = await open(publicationPath, "a+");
282
- const identity = await handle.stat({ bigint: true });
283
- const recorderIdentity = { dev: identity.dev, ino: identity.ino };
412
+ async function prepareRecorderPathForAppend(publicationPath, logicalPath, durable, expectedIdentity) {
413
+ const opened = await openRecorderForAppend(publicationPath);
414
+ const { handle } = opened;
415
+ const recorderIdentity = requireRecorderHandleIdentity(await handle.stat({ bigint: true }));
284
416
  try {
417
+ if (expectedIdentity !== undefined &&
418
+ !sameRecorderFileIdentity(expectedIdentity, recorderIdentity)) {
419
+ throw new RecorderRotatedError("Recorder rotated before preparing a committed line.");
420
+ }
285
421
  const changed = await prepareRecorderTailForAppend(handle);
286
422
  if (changed && durable) {
287
423
  await handle.sync();
@@ -293,35 +429,299 @@ async function prepareRecorderPathForAppend(publicationPath, logicalPath, durabl
293
429
  if (!sameRecorderFileIdentity(recorderIdentity, await readRecorderFileIdentity(await resolveRecorderPublicationPath(logicalPath)))) {
294
430
  throw new RecorderRotatedError("Recorder rotated while preparing a committed line.");
295
431
  }
296
- return recorderIdentity;
432
+ return { created: opened.created, identity: recorderIdentity };
297
433
  }
298
- async function appendCommittedLine(publicationPath, logicalPath, line, durable, expectedIdentity) {
299
- const handle = await open(publicationPath, "a+");
300
- const identity = await handle.stat({ bigint: true });
301
- const recorderIdentity = { dev: identity.dev, ino: identity.ino };
434
+ function providerRecorderCloseError(filePath, operationError, closeError) {
435
+ return new AggregateError([operationError, closeError], `Provider recorder operation and file close both failed for "${filePath}".`, { cause: closeError });
436
+ }
437
+ async function appendCommittedLine(publicationPath, logicalPath, line, durable, expectedIdentity, firstCreatedDirectory) {
438
+ const opened = await openRecorderForAppend(publicationPath);
439
+ const { handle } = opened;
440
+ const recorderIdentity = requireRecorderHandleIdentity(await handle.stat({ bigint: true }));
441
+ let published = false;
442
+ let operationError;
302
443
  try {
303
444
  if (expectedIdentity !== undefined &&
304
445
  !sameRecorderFileIdentity(expectedIdentity, recorderIdentity)) {
305
446
  throw new RecorderRotatedError("Recorder rotated before appending a committed line.");
306
447
  }
307
448
  await prepareRecorderTailForAppend(handle);
308
- await handle.writeFile(line, "utf8");
449
+ published = true;
450
+ await appendRecorderData(handle, line);
309
451
  if (durable) {
310
452
  await handle.sync();
311
453
  }
454
+ if (durable) {
455
+ const firstCreatedPath = firstCreatedDirectory ?? (opened.created ? publicationPath : undefined);
456
+ await syncRecorderPathAncestry(publicationPath, firstCreatedPath);
457
+ }
312
458
  }
313
- finally {
459
+ catch (error) {
460
+ operationError = published ? new ProviderRecorderCommittedError(logicalPath, error) : error;
461
+ }
462
+ try {
314
463
  await handle.close();
315
464
  }
316
- if (!sameRecorderFileIdentity(recorderIdentity, await readRecorderFileIdentity(await resolveRecorderPublicationPath(logicalPath)))) {
465
+ catch (closeError) {
466
+ if (operationError !== undefined) {
467
+ if (published) {
468
+ throw new ProviderRecorderCommittedError(logicalPath, operationError, [closeError]);
469
+ }
470
+ throw providerRecorderCloseError(logicalPath, operationError, closeError);
471
+ }
472
+ throw published ? new ProviderRecorderCommittedError(logicalPath, closeError) : closeError;
473
+ }
474
+ if (operationError !== undefined) {
475
+ throw operationError;
476
+ }
477
+ let publishedIdentity;
478
+ try {
479
+ publishedIdentity = await readRecorderFileIdentity(await resolveRecorderPublicationPath(logicalPath));
480
+ }
481
+ catch (error) {
482
+ throw new ProviderRecorderCommittedError(logicalPath, error);
483
+ }
484
+ if (!sameRecorderFileIdentity(recorderIdentity, publishedIdentity)) {
317
485
  throw new RecorderRotatedError("Recorder rotated while appending a committed line.");
318
486
  }
319
487
  }
320
- function recorderLockReleaseError(filePath, operationError, releaseError) {
321
- return new AggregateError([operationError, releaseError], `Recorder append and lock release both failed for "${filePath}".`, { cause: operationError });
488
+ function recorderLockReleaseError(filePath, operationError, releaseErrors) {
489
+ return new AggregateError([operationError, ...releaseErrors], `Recorder append and lock release both failed for "${filePath}".`, { cause: operationError });
322
490
  }
323
- async function withRecorderLock(filePath, operation) {
324
- const release = await lock(filePath, {
491
+ function recorderErrorDetail(error) {
492
+ if (error instanceof AggregateError) {
493
+ return error.errors.map(recorderErrorDetail).join("; ");
494
+ }
495
+ return error instanceof Error ? error.message : String(error);
496
+ }
497
+ function reportRecorderLockReleaseFailure(filePath, releaseError) {
498
+ const detail = recorderErrorDetail(releaseError);
499
+ try {
500
+ process.emitWarning(`Provider recorder append committed but lock cleanup failed for "${filePath}": ${detail}`, {
501
+ code: "CRABLINE_RECORDER_LOCK_CLEANUP",
502
+ type: "ProviderRecorderWarning",
503
+ });
504
+ }
505
+ catch {
506
+ // Cleanup reporting must not change the result of a committed append.
507
+ }
508
+ }
509
+ async function verifyProviderRecorderLockAncestry(directory, currentUserId) {
510
+ let currentPath = path.resolve(directory);
511
+ for (;;) {
512
+ const current = await lstat(currentPath, { bigint: true });
513
+ const ownerIsTrusted = current.uid === BigInt(currentUserId) || current.uid === 0n;
514
+ if (current.isSymbolicLink()) {
515
+ if (!ownerIsTrusted) {
516
+ throw new Error("Provider recorder lock directory parent namespace is not trusted.");
517
+ }
518
+ }
519
+ else {
520
+ const peerWritable = (current.mode & 18n) !== 0n;
521
+ const sticky = (current.mode & 512n) !== 0n;
522
+ if (!current.isDirectory() || !ownerIsTrusted || (peerWritable && !sticky)) {
523
+ throw new Error("Provider recorder lock directory parent namespace is not trusted.");
524
+ }
525
+ }
526
+ const parent = path.dirname(currentPath);
527
+ if (parent === currentPath) {
528
+ return;
529
+ }
530
+ currentPath = parent;
531
+ }
532
+ }
533
+ async function prepareProviderRecorderLockRoot(root, currentUserId) {
534
+ let existingPath = path.resolve(root);
535
+ const missingComponents = [];
536
+ for (;;) {
537
+ try {
538
+ await lstat(existingPath);
539
+ break;
540
+ }
541
+ catch (error) {
542
+ if (error.code !== "ENOENT") {
543
+ throw error;
544
+ }
545
+ const parent = path.dirname(existingPath);
546
+ if (parent === existingPath) {
547
+ throw error;
548
+ }
549
+ missingComponents.unshift(path.basename(existingPath));
550
+ existingPath = parent;
551
+ }
552
+ }
553
+ await verifyProviderRecorderLockAncestry(existingPath, currentUserId);
554
+ let canonicalPath = await realpath(existingPath);
555
+ if (canonicalPath !== existingPath) {
556
+ await verifyProviderRecorderLockAncestry(canonicalPath, currentUserId);
557
+ }
558
+ for (const component of missingComponents) {
559
+ canonicalPath = path.join(canonicalPath, component);
560
+ try {
561
+ await mkdir(canonicalPath, { mode: 0o700 });
562
+ }
563
+ catch (error) {
564
+ if (error.code !== "EEXIST") {
565
+ throw error;
566
+ }
567
+ }
568
+ const identity = await lstat(canonicalPath, { bigint: true });
569
+ if (!identity.isDirectory() ||
570
+ identity.isSymbolicLink() ||
571
+ identity.uid !== BigInt(currentUserId)) {
572
+ throw new Error("Provider recorder lock directory is not privately owned.");
573
+ }
574
+ await chmod(canonicalPath, 0o700);
575
+ }
576
+ return canonicalPath;
577
+ }
578
+ export async function secureProviderRecorderLockRoot(root, currentUserId, options = {}) {
579
+ if ((options.platform ?? process.platform) === "win32") {
580
+ const cacheKey = path.win32.normalize(path.resolve(root)).toLowerCase();
581
+ return secureCachedWindowsLockRoot({
582
+ cache: securedWindowsLockRoots,
583
+ cacheKey,
584
+ createDirectory: () => (options.createWindowsDirectory ?? createOwnerOnlyWindowsDirectory)(root),
585
+ errorPrefix: "Provider recorder lock directory",
586
+ readSecuritySnapshot: () => (options.readWindowsDirectorySecuritySnapshot ?? readWindowsDirectorySecuritySnapshot)(root),
587
+ root,
588
+ });
589
+ }
590
+ if (currentUserId === undefined) {
591
+ throw new Error("Provider recorder identity locking requires a current user id.");
592
+ }
593
+ const canonicalRoot = await prepareProviderRecorderLockRoot(root, currentUserId);
594
+ const handle = await open(canonicalRoot, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
595
+ try {
596
+ const identity = await handle.stat({ bigint: true });
597
+ const current = await lstat(canonicalRoot, { bigint: true });
598
+ if (!identity.isDirectory() ||
599
+ !current.isDirectory() ||
600
+ identity.dev !== current.dev ||
601
+ identity.ino !== current.ino ||
602
+ identity.uid !== BigInt(currentUserId) ||
603
+ current.uid !== BigInt(currentUserId)) {
604
+ throw new Error("Provider recorder lock directory is not privately owned.");
605
+ }
606
+ if ((identity.mode & 511n) !== 448n) {
607
+ await handle.chmod(0o700);
608
+ const secured = await handle.stat({ bigint: true });
609
+ if ((secured.mode & 511n) !== 448n) {
610
+ throw new Error("Provider recorder lock directory permissions are not private.");
611
+ }
612
+ }
613
+ }
614
+ finally {
615
+ await handle.close();
616
+ }
617
+ return canonicalRoot;
618
+ }
619
+ async function secureProviderRecorderWindowsDirectory(root) {
620
+ const cacheKey = path.win32.normalize(path.resolve(root)).toLowerCase();
621
+ return secureCachedWindowsLockRoot({
622
+ cache: securedWindowsRecorderDirectories,
623
+ cacheKey,
624
+ createDirectory: async () => {
625
+ try {
626
+ const identity = await lstat(root);
627
+ if (!identity.isDirectory() || identity.isSymbolicLink()) {
628
+ throw new Error("Provider recorder Windows directory is not a private directory.");
629
+ }
630
+ if (isManagedRecorderDirectory(root)) {
631
+ await createOwnerOnlyWindowsDirectory(root);
632
+ }
633
+ }
634
+ catch (error) {
635
+ if (error.code !== "ENOENT") {
636
+ throw error;
637
+ }
638
+ await createOwnerOnlyWindowsDirectory(root);
639
+ }
640
+ },
641
+ errorPrefix: "Provider recorder Windows directory",
642
+ readSecuritySnapshot: () => readWindowsDirectoryNamespaceSecuritySnapshot(root),
643
+ root,
644
+ });
645
+ }
646
+ async function prepareRecorderDirectory(filePath) {
647
+ const directory = path.dirname(filePath);
648
+ if (process.platform === "win32") {
649
+ await secureProviderRecorderWindowsDirectory(directory);
650
+ return undefined;
651
+ }
652
+ const createdDirectory = await mkdir(directory, { recursive: true });
653
+ return createdDirectory === undefined ? undefined : await realpath(createdDirectory);
654
+ }
655
+ function recorderLockRootUnavailable(error) {
656
+ const code = error.code;
657
+ return (code === "EACCES" ||
658
+ code === "EDQUOT" ||
659
+ code === "ENOSPC" ||
660
+ code === "EPERM" ||
661
+ code === "EROFS");
662
+ }
663
+ function adjacentRecorderLockRoot(filePath, currentUserId) {
664
+ return path.join(path.dirname(filePath), currentUserId === undefined
665
+ ? ".crabline-provider-recorder-locks"
666
+ : `.crabline-provider-recorder-locks-${currentUserId}`);
667
+ }
668
+ function recorderIdentityLockPath(root, identity) {
669
+ return path.join(root, `recorder-${identity.dev}-${identity.ino}`);
670
+ }
671
+ async function recorderIdentityLockTargets(filePath, identity) {
672
+ const currentUserId = process.platform === "win32" ? undefined : process.geteuid?.();
673
+ let sharedRoot;
674
+ try {
675
+ const account = userInfo();
676
+ sharedRoot =
677
+ process.platform === "win32"
678
+ ? path.join(account.homedir, "AppData", "Local", "Crabline", "locks", "provider-recorder")
679
+ : path.join(account.homedir, ".cache", "crabline", "locks", "provider-recorder");
680
+ }
681
+ catch {
682
+ // Arbitrary container UIDs may not have an OS account entry.
683
+ }
684
+ if (sharedRoot) {
685
+ try {
686
+ return {
687
+ ...(identity.nlink === 1n
688
+ ? { fallbackRoot: adjacentRecorderLockRoot(filePath, currentUserId) }
689
+ : {}),
690
+ preferred: recorderIdentityLockPath(await secureProviderRecorderLockRoot(sharedRoot, currentUserId), identity),
691
+ userId: currentUserId,
692
+ };
693
+ }
694
+ catch (error) {
695
+ if (!recorderLockRootUnavailable(error)) {
696
+ throw error;
697
+ }
698
+ }
699
+ }
700
+ if (identity.nlink > 1n) {
701
+ throw new Error("Provider recorder hardlinks require a writable shared per-user lock directory.");
702
+ }
703
+ const adjacentRoot = await secureProviderRecorderLockRoot(adjacentRecorderLockRoot(filePath, currentUserId), currentUserId);
704
+ return {
705
+ preferred: recorderIdentityLockPath(adjacentRoot, identity),
706
+ userId: currentUserId,
707
+ };
708
+ }
709
+ async function acquireRecorderIdentityLock(filePath, identity) {
710
+ const targets = await recorderIdentityLockTargets(filePath, identity);
711
+ try {
712
+ return await acquireRecorderLock(targets.preferred);
713
+ }
714
+ catch (error) {
715
+ if (!targets.fallbackRoot || !recorderLockRootUnavailable(error)) {
716
+ throw error;
717
+ }
718
+ const fallbackRoot = await secureProviderRecorderLockRoot(targets.fallbackRoot, targets.userId);
719
+ return await acquireRecorderLock(recorderIdentityLockPath(fallbackRoot, identity));
720
+ }
721
+ }
722
+ async function acquireRecorderLock(filePath) {
723
+ return await lock(filePath, {
724
+ fs: createProcessOwnedLockFileSystem(),
325
725
  realpath: false,
326
726
  retries: {
327
727
  factor: 1,
@@ -332,28 +732,97 @@ async function withRecorderLock(filePath, operation) {
332
732
  stale: RECORDER_LOCK_STALE_MS,
333
733
  update: RECORDER_LOCK_UPDATE_MS,
334
734
  });
735
+ }
736
+ async function ensureRecorderExistsForLock(filePath) {
737
+ if (await readRecorderFileIdentity(filePath)) {
738
+ return false;
739
+ }
740
+ let handle;
741
+ try {
742
+ handle = await open(filePath, "ax", 0o600);
743
+ }
744
+ catch (error) {
745
+ if (error.code === "EEXIST") {
746
+ return false;
747
+ }
748
+ throw error;
749
+ }
750
+ await handle.close();
751
+ return true;
752
+ }
753
+ async function releaseRecorderLocks(releases) {
754
+ const errors = [];
755
+ for (const release of releases.reverse()) {
756
+ try {
757
+ await release();
758
+ }
759
+ catch (error) {
760
+ errors.push(error);
761
+ }
762
+ }
763
+ return errors;
764
+ }
765
+ async function withRecorderLock(filePath, operation) {
766
+ const releases = [];
767
+ let lockCreatedFile = false;
768
+ let lockedIdentity;
769
+ try {
770
+ releases.push(await acquireRecorderLock(filePath));
771
+ for (let attempt = 0; attempt < RECORDER_ROTATION_ATTEMPTS; attempt += 1) {
772
+ lockCreatedFile = await ensureRecorderExistsForLock(filePath);
773
+ const identity = await readRecorderFileIdentity(filePath);
774
+ if (!identity) {
775
+ continue;
776
+ }
777
+ const releaseIdentityLock = await acquireRecorderIdentityLock(filePath, identity);
778
+ releases.push(releaseIdentityLock);
779
+ const currentIdentity = await readRecorderFileIdentity(filePath);
780
+ if (sameRecorderLockIdentity(identity, currentIdentity)) {
781
+ lockedIdentity = identity;
782
+ break;
783
+ }
784
+ const registeredIdentityRelease = releases.pop();
785
+ const releaseErrors = await releaseRecorderLocks(registeredIdentityRelease ? [registeredIdentityRelease] : []);
786
+ if (releaseErrors.length > 0) {
787
+ throw recorderLockReleaseError(filePath, new RecorderRotatedError("Recorder rotated while acquiring its identity lock."), releaseErrors);
788
+ }
789
+ if (attempt + 1 >= RECORDER_ROTATION_ATTEMPTS) {
790
+ throw new RecorderRotatedError("Recorder kept rotating while acquiring its identity lock.");
791
+ }
792
+ }
793
+ if (!lockedIdentity) {
794
+ throw new RecorderRotatedError("Recorder disappeared while acquiring its identity lock.");
795
+ }
796
+ }
797
+ catch (error) {
798
+ const releaseErrors = await releaseRecorderLocks(releases);
799
+ if (releaseErrors.length > 0) {
800
+ throw recorderLockReleaseError(filePath, error, releaseErrors);
801
+ }
802
+ throw error;
803
+ }
335
804
  let operationFailed = false;
336
805
  let operationError;
337
806
  let result;
338
807
  try {
339
- result = await operation();
808
+ result = await operation(lockCreatedFile, lockedIdentity);
340
809
  }
341
810
  catch (error) {
342
811
  operationFailed = true;
343
812
  operationError = error;
344
813
  }
345
- try {
346
- await release();
347
- }
348
- catch (releaseError) {
349
- if (operationFailed) {
350
- throw recorderLockReleaseError(filePath, operationError, releaseError);
351
- }
352
- throw releaseError;
353
- }
814
+ const releaseErrors = await releaseRecorderLocks(releases);
354
815
  if (operationFailed) {
816
+ if (releaseErrors.length > 0) {
817
+ throw recorderLockReleaseError(filePath, operationError, releaseErrors);
818
+ }
355
819
  throw operationError;
356
820
  }
821
+ if (releaseErrors.length > 0) {
822
+ reportRecorderLockReleaseFailure(filePath, releaseErrors.length === 1
823
+ ? releaseErrors[0]
824
+ : new AggregateError(releaseErrors, "Multiple recorder lock cleanup operations failed."));
825
+ }
357
826
  return result;
358
827
  }
359
828
  async function serializeAppend(filePath, operation) {
@@ -364,7 +833,7 @@ async function serializeAppend(filePath, operation) {
364
833
  const current = previous
365
834
  .catch(() => { })
366
835
  .then(async () => {
367
- result = await withRecorderLock(key, async () => await operation(key, logicalPath));
836
+ result = await withRecorderLock(key, async (lockCreatedFile, lockedIdentity) => await operation(key, logicalPath, lockCreatedFile, lockedIdentity));
368
837
  });
369
838
  pendingAppends.set(key, current);
370
839
  try {
@@ -378,6 +847,7 @@ async function serializeAppend(filePath, operation) {
378
847
  }
379
848
  }
380
849
  async function readRecordedInboundAppend(filePath, state) {
850
+ const snapshot = snapshotIncrementalReadState(state);
381
851
  let handle;
382
852
  try {
383
853
  handle = await open(filePath, "r");
@@ -393,7 +863,8 @@ async function readRecordedInboundAppend(filePath, state) {
393
863
  }
394
864
  try {
395
865
  const stats = await handle.stat();
396
- const identity = { dev: stats.dev, ino: stats.ino };
866
+ const identityStats = await handle.stat({ bigint: true });
867
+ const identity = { dev: identityStats.dev, ino: identityStats.ino };
397
868
  const sameFile = state.identity?.dev === identity.dev && state.identity.ino === identity.ino;
398
869
  const rotated = state.identity !== undefined && !sameFile;
399
870
  let hasContinuity = stats.size >= state.offset;
@@ -430,12 +901,16 @@ async function readRecordedInboundAppend(filePath, state) {
430
901
  state.caughtUp = reachedUnexpectedEof || position >= stats.size;
431
902
  return events;
432
903
  }
904
+ catch (error) {
905
+ restoreIncrementalReadState(state, snapshot);
906
+ throw error;
907
+ }
433
908
  finally {
434
909
  await handle.close();
435
910
  }
436
911
  }
437
912
  export async function appendRecordedInbound(filePath, event) {
438
- await mkdir(path.dirname(filePath), { recursive: true });
913
+ const firstCreatedDirectory = await prepareRecorderDirectory(filePath);
439
914
  const recorded = {
440
915
  ...event,
441
916
  recordedAt: new Date().toISOString(),
@@ -444,18 +919,18 @@ export async function appendRecordedInbound(filePath, event) {
444
919
  if (Buffer.byteLength(line) > MAX_PENDING_RECORD_BYTES) {
445
920
  throw new Error(`Recorder record exceeded ${MAX_PENDING_RECORD_BYTES} bytes without a newline.`);
446
921
  }
447
- await appendJsonLine(filePath, line);
922
+ await appendJsonLine(filePath, line, firstCreatedDirectory);
448
923
  return recorded;
449
924
  }
450
925
  export async function appendRecordedInboundBatch(filePath, events) {
451
926
  if (events.length === 0) {
452
927
  return [];
453
928
  }
454
- await mkdir(path.dirname(filePath), { recursive: true });
929
+ const firstCreatedDirectory = await prepareRecorderDirectory(filePath);
455
930
  for (let attempt = 0;; attempt++) {
456
931
  try {
457
- return await serializeAppend(filePath, async (publicationPath, logicalPath) => {
458
- const generation = await prepareRecorderPathForAppend(publicationPath, logicalPath, true);
932
+ return await serializeAppend(filePath, async (publicationPath, logicalPath, _lockCreatedFile, lockedIdentity) => {
933
+ const generation = await prepareRecorderPathForAppend(publicationPath, logicalPath, true, lockedIdentity);
459
934
  const seen = await syncRecordIdentityIndex(publicationPath);
460
935
  const pendingIdentities = new Set();
461
936
  const recorded = [];
@@ -480,10 +955,10 @@ export async function appendRecordedInboundBatch(filePath, events) {
480
955
  if (Buffer.byteLength(line) > MAX_PENDING_RECORD_BYTES) {
481
956
  throw new Error(`Recorder record exceeded ${MAX_PENDING_RECORD_BYTES} bytes without a newline.`);
482
957
  }
483
- await appendCommittedLine(publicationPath, logicalPath, line, true, generation);
958
+ await appendCommittedLine(publicationPath, logicalPath, line, true, generation.identity, firstCreatedDirectory);
484
959
  await syncRecordIdentityIndex(publicationPath);
485
960
  }
486
- else if (!sameRecorderFileIdentity(generation, await readRecorderFileIdentity(await resolveRecorderPublicationPath(logicalPath)))) {
961
+ else if (!sameRecorderFileIdentity(generation.identity, await readRecorderFileIdentity(await resolveRecorderPublicationPath(logicalPath)))) {
487
962
  throw new RecorderRotatedError("Recorder rotated before confirming a duplicate batch.");
488
963
  }
489
964
  return recorded;
@@ -519,6 +994,11 @@ async function syncRecordIdentityIndex(filePath) {
519
994
  if (index.readState.generation !== generation) {
520
995
  index.seen.clear();
521
996
  generation = index.readState.generation;
997
+ index.readState.caughtUp = false;
998
+ index.readState.continuity = Buffer.alloc(0);
999
+ index.readState.offset = 0;
1000
+ index.readState.pending = Buffer.alloc(0);
1001
+ continue;
522
1002
  }
523
1003
  for (const event of appended) {
524
1004
  rememberRecentRecord(index.seen, event);