@openclaw/crabline 0.1.10 → 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 +12 -2
  34. package/dist/src/openclaw/artifact-generation.js +436 -15
  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 +40 -3
  54. package/dist/src/openclaw/private-file.js +2070 -95
  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 +696 -187
  61. package/dist/src/openclaw/smoke-lock.js.map +1 -1
  62. package/dist/src/openclaw.js +352 -29
  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 +532 -42
  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 +1241 -22
  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 +1193 -118
  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,10 +218,20 @@ function consumeRecordedChunk(state, chunk) {
188
218
  }
189
219
  return events;
190
220
  }
191
- async function appendJsonLine(filePath, line) {
192
- await serializeAppend(filePath, async (publicationPath, logicalPath) => {
193
- await appendCommittedLine(publicationPath, logicalPath, line, true);
194
- });
221
+ async function appendJsonLine(filePath, line, firstCreatedDirectory) {
222
+ for (let attempt = 0;; attempt++) {
223
+ try {
224
+ await serializeAppend(filePath, async (publicationPath, logicalPath, _lockCreatedFile, lockedIdentity) => {
225
+ await appendCommittedLine(publicationPath, logicalPath, line, true, lockedIdentity, firstCreatedDirectory);
226
+ });
227
+ return;
228
+ }
229
+ catch (error) {
230
+ if (!(error instanceof RecorderRotatedError) || attempt + 1 >= RECORDER_ROTATION_ATTEMPTS) {
231
+ throw error;
232
+ }
233
+ }
234
+ }
195
235
  }
196
236
  async function readRecorderFileIdentity(filePath) {
197
237
  try {
@@ -202,6 +242,7 @@ async function readRecorderFileIdentity(filePath) {
202
242
  return {
203
243
  dev: stats.dev,
204
244
  ino: stats.ino,
245
+ nlink: stats.nlink,
205
246
  };
206
247
  }
207
248
  catch (error) {
@@ -214,6 +255,19 @@ async function readRecorderFileIdentity(filePath) {
214
255
  function sameRecorderFileIdentity(left, right) {
215
256
  return left?.dev === right?.dev && left?.ino === right?.ino;
216
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
+ }
217
271
  async function resolveRecorderPublicationPath(filePath) {
218
272
  try {
219
273
  return await realpath(filePath);
@@ -237,6 +291,94 @@ async function resolveRecorderPublicationPath(filePath) {
237
291
  }
238
292
  return path.join(await realpath(path.dirname(filePath)), path.basename(filePath));
239
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
+ }
240
382
  async function prepareRecorderTailForAppend(handle) {
241
383
  const stats = await handle.stat();
242
384
  if (stats.size === 0) {
@@ -255,8 +397,8 @@ async function prepareRecorderTailForAppend(handle) {
255
397
  const tailStart = stats.size - windowSize + lastNewline + 1;
256
398
  const tail = tailWindow.subarray(lastNewline + 1).toString("utf8");
257
399
  try {
258
- JSON.parse(tail);
259
- await handle.writeFile("\n", "utf8");
400
+ parseRecordedLine(tail);
401
+ await appendRecorderData(handle, "\n");
260
402
  return true;
261
403
  }
262
404
  catch (error) {
@@ -267,11 +409,15 @@ async function prepareRecorderTailForAppend(handle) {
267
409
  return true;
268
410
  }
269
411
  }
270
- async function prepareRecorderPathForAppend(publicationPath, logicalPath, durable) {
271
- const handle = await open(publicationPath, "a+");
272
- const identity = await handle.stat({ bigint: true });
273
- 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 }));
274
416
  try {
417
+ if (expectedIdentity !== undefined &&
418
+ !sameRecorderFileIdentity(expectedIdentity, recorderIdentity)) {
419
+ throw new RecorderRotatedError("Recorder rotated before preparing a committed line.");
420
+ }
275
421
  const changed = await prepareRecorderTailForAppend(handle);
276
422
  if (changed && durable) {
277
423
  await handle.sync();
@@ -283,35 +429,299 @@ async function prepareRecorderPathForAppend(publicationPath, logicalPath, durabl
283
429
  if (!sameRecorderFileIdentity(recorderIdentity, await readRecorderFileIdentity(await resolveRecorderPublicationPath(logicalPath)))) {
284
430
  throw new RecorderRotatedError("Recorder rotated while preparing a committed line.");
285
431
  }
286
- return recorderIdentity;
432
+ return { created: opened.created, identity: recorderIdentity };
433
+ }
434
+ function providerRecorderCloseError(filePath, operationError, closeError) {
435
+ return new AggregateError([operationError, closeError], `Provider recorder operation and file close both failed for "${filePath}".`, { cause: closeError });
287
436
  }
288
- async function appendCommittedLine(publicationPath, logicalPath, line, durable, expectedIdentity) {
289
- const handle = await open(publicationPath, "a+");
290
- const identity = await handle.stat({ bigint: true });
291
- const recorderIdentity = { dev: identity.dev, ino: identity.ino };
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;
292
443
  try {
293
444
  if (expectedIdentity !== undefined &&
294
445
  !sameRecorderFileIdentity(expectedIdentity, recorderIdentity)) {
295
446
  throw new RecorderRotatedError("Recorder rotated before appending a committed line.");
296
447
  }
297
448
  await prepareRecorderTailForAppend(handle);
298
- await handle.writeFile(line, "utf8");
449
+ published = true;
450
+ await appendRecorderData(handle, line);
299
451
  if (durable) {
300
452
  await handle.sync();
301
453
  }
454
+ if (durable) {
455
+ const firstCreatedPath = firstCreatedDirectory ?? (opened.created ? publicationPath : undefined);
456
+ await syncRecorderPathAncestry(publicationPath, firstCreatedPath);
457
+ }
302
458
  }
303
- finally {
459
+ catch (error) {
460
+ operationError = published ? new ProviderRecorderCommittedError(logicalPath, error) : error;
461
+ }
462
+ try {
304
463
  await handle.close();
305
464
  }
306
- 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)) {
307
485
  throw new RecorderRotatedError("Recorder rotated while appending a committed line.");
308
486
  }
309
487
  }
310
- function recorderLockReleaseError(filePath, operationError, releaseError) {
311
- 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 });
312
490
  }
313
- async function withRecorderLock(filePath, operation) {
314
- 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(),
315
725
  realpath: false,
316
726
  retries: {
317
727
  factor: 1,
@@ -322,28 +732,97 @@ async function withRecorderLock(filePath, operation) {
322
732
  stale: RECORDER_LOCK_STALE_MS,
323
733
  update: RECORDER_LOCK_UPDATE_MS,
324
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
+ }
325
804
  let operationFailed = false;
326
805
  let operationError;
327
806
  let result;
328
807
  try {
329
- result = await operation();
808
+ result = await operation(lockCreatedFile, lockedIdentity);
330
809
  }
331
810
  catch (error) {
332
811
  operationFailed = true;
333
812
  operationError = error;
334
813
  }
335
- try {
336
- await release();
337
- }
338
- catch (releaseError) {
339
- if (operationFailed) {
340
- throw recorderLockReleaseError(filePath, operationError, releaseError);
341
- }
342
- throw releaseError;
343
- }
814
+ const releaseErrors = await releaseRecorderLocks(releases);
344
815
  if (operationFailed) {
816
+ if (releaseErrors.length > 0) {
817
+ throw recorderLockReleaseError(filePath, operationError, releaseErrors);
818
+ }
345
819
  throw operationError;
346
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
+ }
347
826
  return result;
348
827
  }
349
828
  async function serializeAppend(filePath, operation) {
@@ -354,7 +833,7 @@ async function serializeAppend(filePath, operation) {
354
833
  const current = previous
355
834
  .catch(() => { })
356
835
  .then(async () => {
357
- result = await withRecorderLock(key, async () => await operation(key, logicalPath));
836
+ result = await withRecorderLock(key, async (lockCreatedFile, lockedIdentity) => await operation(key, logicalPath, lockCreatedFile, lockedIdentity));
358
837
  });
359
838
  pendingAppends.set(key, current);
360
839
  try {
@@ -368,6 +847,7 @@ async function serializeAppend(filePath, operation) {
368
847
  }
369
848
  }
370
849
  async function readRecordedInboundAppend(filePath, state) {
850
+ const snapshot = snapshotIncrementalReadState(state);
371
851
  let handle;
372
852
  try {
373
853
  handle = await open(filePath, "r");
@@ -383,7 +863,8 @@ async function readRecordedInboundAppend(filePath, state) {
383
863
  }
384
864
  try {
385
865
  const stats = await handle.stat();
386
- 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 };
387
868
  const sameFile = state.identity?.dev === identity.dev && state.identity.ino === identity.ino;
388
869
  const rotated = state.identity !== undefined && !sameFile;
389
870
  let hasContinuity = stats.size >= state.offset;
@@ -420,12 +901,16 @@ async function readRecordedInboundAppend(filePath, state) {
420
901
  state.caughtUp = reachedUnexpectedEof || position >= stats.size;
421
902
  return events;
422
903
  }
904
+ catch (error) {
905
+ restoreIncrementalReadState(state, snapshot);
906
+ throw error;
907
+ }
423
908
  finally {
424
909
  await handle.close();
425
910
  }
426
911
  }
427
912
  export async function appendRecordedInbound(filePath, event) {
428
- await mkdir(path.dirname(filePath), { recursive: true });
913
+ const firstCreatedDirectory = await prepareRecorderDirectory(filePath);
429
914
  const recorded = {
430
915
  ...event,
431
916
  recordedAt: new Date().toISOString(),
@@ -434,18 +919,18 @@ export async function appendRecordedInbound(filePath, event) {
434
919
  if (Buffer.byteLength(line) > MAX_PENDING_RECORD_BYTES) {
435
920
  throw new Error(`Recorder record exceeded ${MAX_PENDING_RECORD_BYTES} bytes without a newline.`);
436
921
  }
437
- await appendJsonLine(filePath, line);
922
+ await appendJsonLine(filePath, line, firstCreatedDirectory);
438
923
  return recorded;
439
924
  }
440
925
  export async function appendRecordedInboundBatch(filePath, events) {
441
926
  if (events.length === 0) {
442
927
  return [];
443
928
  }
444
- await mkdir(path.dirname(filePath), { recursive: true });
929
+ const firstCreatedDirectory = await prepareRecorderDirectory(filePath);
445
930
  for (let attempt = 0;; attempt++) {
446
931
  try {
447
- return await serializeAppend(filePath, async (publicationPath, logicalPath) => {
448
- 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);
449
934
  const seen = await syncRecordIdentityIndex(publicationPath);
450
935
  const pendingIdentities = new Set();
451
936
  const recorded = [];
@@ -470,10 +955,10 @@ export async function appendRecordedInboundBatch(filePath, events) {
470
955
  if (Buffer.byteLength(line) > MAX_PENDING_RECORD_BYTES) {
471
956
  throw new Error(`Recorder record exceeded ${MAX_PENDING_RECORD_BYTES} bytes without a newline.`);
472
957
  }
473
- await appendCommittedLine(publicationPath, logicalPath, line, true, generation);
958
+ await appendCommittedLine(publicationPath, logicalPath, line, true, generation.identity, firstCreatedDirectory);
474
959
  await syncRecordIdentityIndex(publicationPath);
475
960
  }
476
- else if (!sameRecorderFileIdentity(generation, await readRecorderFileIdentity(await resolveRecorderPublicationPath(logicalPath)))) {
961
+ else if (!sameRecorderFileIdentity(generation.identity, await readRecorderFileIdentity(await resolveRecorderPublicationPath(logicalPath)))) {
477
962
  throw new RecorderRotatedError("Recorder rotated before confirming a duplicate batch.");
478
963
  }
479
964
  return recorded;
@@ -509,6 +994,11 @@ async function syncRecordIdentityIndex(filePath) {
509
994
  if (index.readState.generation !== generation) {
510
995
  index.seen.clear();
511
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;
512
1002
  }
513
1003
  for (const event of appended) {
514
1004
  rememberRecentRecord(index.seen, event);