@dsh-enhanced/assistant-automations 0.1.24 → 0.1.30

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 (51) hide show
  1. package/README.md +18 -5
  2. package/lib/acceptance.d.ts +52 -0
  3. package/lib/acceptance.d.ts.map +1 -0
  4. package/lib/acceptance.js +2 -0
  5. package/lib/acceptance.js.map +1 -0
  6. package/lib/coordinator.d.ts +5 -0
  7. package/lib/coordinator.d.ts.map +1 -1
  8. package/lib/coordinator.js +21 -0
  9. package/lib/coordinator.js.map +1 -1
  10. package/lib/external-event.d.ts +32 -0
  11. package/lib/external-event.d.ts.map +1 -0
  12. package/lib/external-event.js +114 -0
  13. package/lib/external-event.js.map +1 -0
  14. package/lib/growth.d.ts +10 -0
  15. package/lib/growth.d.ts.map +1 -1
  16. package/lib/growth.js +49 -16
  17. package/lib/growth.js.map +1 -1
  18. package/lib/index.d.ts +6 -1
  19. package/lib/index.d.ts.map +1 -1
  20. package/lib/index.js +2 -1
  21. package/lib/index.js.map +1 -1
  22. package/lib/operator.d.ts +68 -2
  23. package/lib/operator.d.ts.map +1 -1
  24. package/lib/operator.js +922 -6
  25. package/lib/operator.js.map +1 -1
  26. package/lib/preparation.d.ts +52 -0
  27. package/lib/preparation.d.ts.map +1 -0
  28. package/lib/preparation.js +191 -0
  29. package/lib/preparation.js.map +1 -0
  30. package/lib/runner.d.ts +29 -0
  31. package/lib/runner.d.ts.map +1 -1
  32. package/lib/runner.js +72 -2
  33. package/lib/runner.js.map +1 -1
  34. package/lib/service.d.ts +37 -0
  35. package/lib/service.d.ts.map +1 -1
  36. package/lib/service.js +269 -60
  37. package/lib/service.js.map +1 -1
  38. package/lib/sqlite.d.ts +1 -1
  39. package/lib/sqlite.d.ts.map +1 -1
  40. package/lib/sqlite.js +77 -3
  41. package/lib/sqlite.js.map +1 -1
  42. package/lib/store.d.ts +55 -0
  43. package/lib/store.d.ts.map +1 -1
  44. package/lib/store.js +233 -14
  45. package/lib/store.js.map +1 -1
  46. package/lib/types.d.ts +3 -0
  47. package/lib/types.d.ts.map +1 -1
  48. package/lib/types.js.map +1 -1
  49. package/lib/version.d.ts +1 -1
  50. package/lib/version.js +1 -1
  51. package/package.json +29 -23
package/lib/operator.js CHANGED
@@ -1,13 +1,929 @@
1
- import { AutomationStore } from './store.js';
2
- /** Read-only local inspection for setup tools; never starts the scheduler. */
3
- export function listAutomationsLocally(databasePath) {
4
- const store = new AutomationStore({ path: databasePath });
1
+ import { createHash } from 'node:crypto';
2
+ import { closeSync, fstatSync, lstatSync, mkdtempSync, openSync, readdirSync, readFileSync, readSync, realpathSync, rmSync, rmdirSync, unlinkSync, writeSync } from 'node:fs';
3
+ import { constants } from 'node:fs';
4
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { DatabaseSync } from 'node:sqlite';
7
+ import { pathToFileURL } from 'node:url';
8
+ import { automationSchemaVersion } from './sqlite.js';
9
+ import { normalizeAutomationDefinition } from './store.js';
10
+ export const automationsOperatorSnapshotProtocol = 'assistant-automations-operator-snapshot/v1';
11
+ export class AutomationOperatorSnapshotError extends Error {
12
+ code;
13
+ constructor(code, message) {
14
+ super(message);
15
+ this.code = code;
16
+ this.name = 'AutomationOperatorSnapshotError';
17
+ }
18
+ }
19
+ const digestPattern = /^[a-f0-9]{64}$/u;
20
+ function fail(code, message) {
21
+ throw new AutomationOperatorSnapshotError(code, `automation operator snapshot: ${message}`);
22
+ }
23
+ function freeze(value) {
24
+ if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
25
+ for (const child of Object.values(value))
26
+ freeze(child);
27
+ Object.freeze(value);
28
+ }
29
+ return value;
30
+ }
31
+ function sha256(value) {
32
+ return createHash('sha256').update(value).digest('hex');
33
+ }
34
+ /** Pure canonical digest used by both writers and trusted local attestation. */
35
+ export function automationDefinitionDigest(definition) {
36
+ return sha256(JSON.stringify(normalizeAutomationDefinition(definition, 16 * 1024 * 1024, 10_000)));
37
+ }
38
+ function safeInteger(value, minimum = 0) {
39
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum;
40
+ }
41
+ function canonicalText(value, maximum) {
42
+ return typeof value === 'string' && value.length > 0 && value === value.normalize('NFC').trim()
43
+ && Buffer.byteLength(value, 'utf8') <= maximum
44
+ && ![...value].some(character => {
45
+ const point = character.codePointAt(0) ?? 0;
46
+ return point <= 0x1f || point === 0x7f;
47
+ });
48
+ }
49
+ function sameFile(left, right) {
50
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size
51
+ && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs
52
+ && left.mode === right.mode && left.uid === right.uid && left.nlink === right.nlink;
53
+ }
54
+ function digestDescriptor(descriptor, size) {
55
+ const hash = createHash('sha256');
56
+ const buffer = Buffer.allocUnsafe(64 * 1024);
57
+ let position = 0n;
58
+ while (position < size) {
59
+ const length = Number(size - position > BigInt(buffer.length) ? BigInt(buffer.length) : size - position);
60
+ const count = readSync(descriptor, buffer, 0, length, Number(position));
61
+ if (count <= 0)
62
+ fail('database-drift', 'database file changed while hashing');
63
+ hash.update(buffer.subarray(0, count));
64
+ position += BigInt(count);
65
+ }
66
+ return hash.digest('hex');
67
+ }
68
+ function identity(file) {
69
+ return freeze({
70
+ device: file.stat.dev.toString(),
71
+ inode: file.stat.ino.toString(),
72
+ size: file.stat.size.toString(),
73
+ mtimeNs: file.stat.mtimeNs.toString(),
74
+ digest: file.digest,
75
+ });
76
+ }
77
+ function pinPrivateParent(path) {
78
+ let descriptor;
5
79
  try {
6
- return store.list();
80
+ descriptor = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
81
+ }
82
+ catch (error) {
83
+ if (typeof error === 'object' && error !== null && 'code' in error
84
+ && error.code === 'ENOENT') {
85
+ fail('unsafe-parent', 'database parent disappeared before the snapshot');
86
+ }
87
+ fail('unsafe-parent', 'database parent is unavailable');
88
+ }
89
+ try {
90
+ const pathname = lstatSync(path, { bigint: true });
91
+ const metadata = fstatSync(descriptor, { bigint: true });
92
+ const uid = process.getuid?.();
93
+ const physical = realpathSync(path);
94
+ if (!pathname.isDirectory() || pathname.isSymbolicLink() || !metadata.isDirectory()
95
+ || pathname.dev !== metadata.dev || pathname.ino !== metadata.ino || physical !== path
96
+ || uid === undefined || metadata.uid !== BigInt(uid)
97
+ || (metadata.mode & 3647n) !== 0n || (metadata.mode & 320n) !== 320n) {
98
+ fail('unsafe-parent', 'database parent must be a private owner directory without symlinks');
99
+ }
100
+ const pinned = { path, descriptor, device: metadata.dev, inode: metadata.ino, mode: metadata.mode };
101
+ descriptor = undefined;
102
+ return pinned;
103
+ }
104
+ catch (error) {
105
+ if (error instanceof AutomationOperatorSnapshotError)
106
+ throw error;
107
+ fail('unsafe-parent', 'database parent cannot be resolved');
7
108
  }
8
109
  finally {
9
- store.close();
110
+ if (descriptor !== undefined)
111
+ closeSync(descriptor);
10
112
  }
113
+ throw new Error('unreachable');
114
+ }
115
+ function pinnedParentMatches(parent) {
116
+ try {
117
+ const pathname = lstatSync(parent.path, { bigint: true });
118
+ const descriptor = fstatSync(parent.descriptor, { bigint: true });
119
+ return pathname.isDirectory() && !pathname.isSymbolicLink() && descriptor.isDirectory()
120
+ && pathname.dev === parent.device && pathname.ino === parent.inode
121
+ && descriptor.dev === parent.device && descriptor.ino === parent.inode
122
+ && descriptor.mode === parent.mode;
123
+ }
124
+ catch {
125
+ return false;
126
+ }
127
+ }
128
+ function pinnedFileIdentityMatches(file) {
129
+ try {
130
+ const pathname = lstatSync(file.path, { bigint: true });
131
+ const opened = fstatSync(file.descriptor, { bigint: true });
132
+ return pathname.isFile() && !pathname.isSymbolicLink()
133
+ && pathname.dev === file.stat.dev && pathname.ino === file.stat.ino
134
+ && opened.dev === file.stat.dev && opened.ino === file.stat.ino;
135
+ }
136
+ catch {
137
+ return false;
138
+ }
139
+ }
140
+ // Inode binding is enforced through Linux /proc/self/fd. On other platforms the
141
+ // snapshot keeps its historical path-based pinning; CI still exercises that code.
142
+ const inodeBindSupported = process.platform === 'linux';
143
+ const procSelfFd = '/proc/self/fd';
144
+ /**
145
+ * Enumerate the regular-file inodes held by THIS process, excluding the
146
+ * descriptors the snapshot pinned itself. After the exclusion the only
147
+ * remaining database fds are SQLite's, so a (dev,ino) match proves the
148
+ * connection genuinely opened the pinned inode rather than an attacker's
149
+ * same-name replacement. An attacker in another process cannot plant an fd
150
+ * here because /proc/self/fd never lists another process's descriptors.
151
+ */
152
+ function connectionInodes(exclude) {
153
+ let entries;
154
+ try {
155
+ entries = readdirSync(procSelfFd);
156
+ }
157
+ catch {
158
+ fail('database-corrupt', 'cannot inspect the database connection file descriptors');
159
+ }
160
+ const inodes = [];
161
+ for (const entry of entries) {
162
+ const descriptor = Number(entry);
163
+ if (!Number.isInteger(descriptor) || descriptor <= 2 || exclude.has(descriptor))
164
+ continue;
165
+ let stat;
166
+ try {
167
+ stat = fstatSync(descriptor, { bigint: true });
168
+ }
169
+ catch {
170
+ continue;
171
+ } // transient/closed fd
172
+ if (stat.isFile())
173
+ inodes.push({ device: stat.dev, inode: stat.ino });
174
+ }
175
+ return inodes;
176
+ }
177
+ function assertConnectionBound(files, exclude) {
178
+ const held = connectionInodes(exclude);
179
+ for (const file of files) {
180
+ if (!held.some(inode => inode.device === file.stat.dev && inode.inode === file.stat.ino)) {
181
+ fail('database-drift', 'database connection is not bound to the pinned inode');
182
+ }
183
+ }
184
+ }
185
+ /** Pin the private mkdtemp copy directory itself so a same-name swap cannot retarget the copies. */
186
+ function pinTempDirectory(path) {
187
+ let descriptor;
188
+ try {
189
+ descriptor = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
190
+ const linked = lstatSync(path, { bigint: true });
191
+ const opened = fstatSync(descriptor, { bigint: true });
192
+ const uid = process.getuid?.();
193
+ if (!linked.isDirectory() || linked.isSymbolicLink() || !opened.isDirectory()
194
+ || linked.dev !== opened.dev || linked.ino !== opened.ino) {
195
+ fail('unsafe-path', 'temporary snapshot directory cannot be pinned');
196
+ }
197
+ if ((opened.mode & 4095n) !== 448n || uid === undefined || opened.uid !== BigInt(uid)) {
198
+ fail('unsafe-path', 'temporary snapshot directory must be a private owner-only 0700 directory');
199
+ }
200
+ const pinned = { path, descriptor, device: opened.dev, inode: opened.ino };
201
+ descriptor = undefined;
202
+ return pinned;
203
+ }
204
+ catch (error) {
205
+ if (error instanceof AutomationOperatorSnapshotError)
206
+ throw error;
207
+ fail('unsafe-path', 'temporary snapshot directory cannot be pinned');
208
+ }
209
+ finally {
210
+ if (descriptor !== undefined)
211
+ try {
212
+ closeSync(descriptor);
213
+ }
214
+ catch { }
215
+ }
216
+ throw new Error('unreachable');
217
+ }
218
+ /**
219
+ * Materialise one private byte-for-byte copy THROUGH the pinned directory fd
220
+ * (`/proc/self/fd/<dirfd>/<name>`), then reopen it O_NOFOLLOW through the same
221
+ * anchor and keep that descriptor open. Neither the create, the verify, the
222
+ * SQLite open, or the cleanup ever resolve a swappable path component, so a
223
+ * same-name directory swap cannot retarget the replica. The returned file is
224
+ * left open and must be closed by the caller.
225
+ */
226
+ function copyAndPin(source, targetRef) {
227
+ const buffer = Buffer.allocUnsafe(64 * 1024);
228
+ let destination;
229
+ try {
230
+ destination = openSync(targetRef, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
231
+ let position = 0n;
232
+ while (position < source.stat.size) {
233
+ const length = Number(source.stat.size - position > BigInt(buffer.length)
234
+ ? BigInt(buffer.length) : source.stat.size - position);
235
+ const count = readSync(source.descriptor, buffer, 0, length, Number(position));
236
+ if (count <= 0)
237
+ fail('database-drift', `${basename(source.path)} changed while it was copied`);
238
+ let written = 0;
239
+ while (written < count)
240
+ written += writeSync(destination, buffer, written, count - written);
241
+ position += BigInt(count);
242
+ }
243
+ }
244
+ catch (error) {
245
+ if (destination !== undefined)
246
+ try {
247
+ closeSync(destination);
248
+ }
249
+ catch { }
250
+ if (error instanceof AutomationOperatorSnapshotError)
251
+ throw error;
252
+ throw error;
253
+ }
254
+ if (destination !== undefined)
255
+ closeSync(destination);
256
+ const copied = openPrivateFile(targetRef, 'database-drift');
257
+ if (copied.stat.size !== source.stat.size || copied.digest !== source.digest) {
258
+ try {
259
+ closeSync(copied.descriptor);
260
+ }
261
+ catch { }
262
+ fail('database-drift', `${basename(source.path)} copy does not match its pinned source`);
263
+ }
264
+ return copied;
265
+ }
266
+ /**
267
+ * Remove the replica THROUGH the pinned directory fd, unlinking only the fixed
268
+ * names SQLite could create. The directory itself is rmdir'd by path only after
269
+ * the pinned fd proves the path still names the same empty inode; a swapped or
270
+ * non-empty directory is left untouched and the snapshot fails closed rather
271
+ * than recursively deleting an attacker-controlled replacement.
272
+ */
273
+ function removePinnedTemp(pinned, names) {
274
+ const anchor = `${procSelfFd}/${pinned.descriptor}`;
275
+ for (const name of names) {
276
+ try {
277
+ unlinkSync(`${anchor}/${name}`);
278
+ }
279
+ catch (error) {
280
+ if (error.code !== 'ENOENT') {
281
+ fail('database-drift', 'temporary snapshot copy cannot be removed through its pinned directory');
282
+ }
283
+ }
284
+ }
285
+ let entries;
286
+ try {
287
+ entries = readdirSync(anchor);
288
+ }
289
+ catch {
290
+ fail('database-drift', 'temporary snapshot directory became unavailable');
291
+ }
292
+ if (entries.length !== 0)
293
+ fail('database-drift', 'temporary snapshot directory contains unexpected entries');
294
+ const linked = lstatSync(pinned.path, { bigint: true });
295
+ const opened = fstatSync(pinned.descriptor, { bigint: true });
296
+ if (linked.dev !== pinned.device || linked.ino !== pinned.inode
297
+ || opened.dev !== pinned.device || opened.ino !== pinned.inode) {
298
+ fail('database-drift', 'temporary snapshot directory was retargeted before cleanup');
299
+ }
300
+ closeSync(pinned.descriptor);
301
+ try {
302
+ rmdirSync(pinned.path);
303
+ }
304
+ catch (error) {
305
+ if (error.code !== 'ENOENT') {
306
+ fail('database-drift', 'temporary snapshot directory could not be removed');
307
+ }
308
+ }
309
+ }
310
+ function openPrivateFile(path, missing) {
311
+ let linked;
312
+ try {
313
+ linked = lstatSync(path, { bigint: true });
314
+ }
315
+ catch (error) {
316
+ if (typeof error === 'object' && error !== null && 'code' in error
317
+ && error.code === 'ENOENT')
318
+ fail(missing, `${basename(path)} does not exist`);
319
+ fail('unsafe-path', `${basename(path)} cannot be inspected`);
320
+ }
321
+ const uid = process.getuid?.();
322
+ if (!linked.isFile() || linked.isSymbolicLink() || linked.nlink !== 1n
323
+ || (linked.mode & 4095n) !== 384n
324
+ || (uid !== undefined && linked.uid !== BigInt(uid))
325
+ || linked.size > BigInt(Number.MAX_SAFE_INTEGER)) {
326
+ fail('unsafe-path', `${basename(path)} must be one private owned regular file`);
327
+ }
328
+ let descriptor;
329
+ try {
330
+ descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
331
+ }
332
+ catch {
333
+ fail('unsafe-path', `${basename(path)} cannot be opened safely`);
334
+ }
335
+ try {
336
+ const opened = fstatSync(descriptor, { bigint: true });
337
+ const after = lstatSync(path, { bigint: true });
338
+ if (!sameFile(linked, opened) || !sameFile(opened, after)) {
339
+ fail('database-drift', `${basename(path)} changed while being opened`);
340
+ }
341
+ const digest = digestDescriptor(descriptor, opened.size);
342
+ const hashed = fstatSync(descriptor, { bigint: true });
343
+ if (!sameFile(opened, hashed))
344
+ fail('database-drift', `${basename(path)} changed while being hashed`);
345
+ return { path, descriptor, stat: opened, digest };
346
+ }
347
+ catch (error) {
348
+ closeSync(descriptor);
349
+ throw error;
350
+ }
351
+ }
352
+ function openOptionalSidecar(path) {
353
+ try {
354
+ lstatSync(path);
355
+ }
356
+ catch (error) {
357
+ if (typeof error === 'object' && error !== null && 'code' in error
358
+ && error.code === 'ENOENT')
359
+ return null;
360
+ fail('unsafe-path', `${basename(path)} cannot be inspected`);
361
+ }
362
+ return openPrivateFile(path, 'database-drift');
363
+ }
364
+ function assertStillIdentical(file) {
365
+ let linked;
366
+ try {
367
+ linked = lstatSync(file.path, { bigint: true });
368
+ }
369
+ catch {
370
+ fail('database-drift', `${basename(file.path)} disappeared during the snapshot`);
371
+ }
372
+ const opened = fstatSync(file.descriptor, { bigint: true });
373
+ if (!sameFile(file.stat, opened) || !sameFile(opened, linked)
374
+ || digestDescriptor(file.descriptor, opened.size) !== file.digest) {
375
+ fail('database-drift', `${basename(file.path)} changed during the snapshot`);
376
+ }
377
+ }
378
+ function assertSidecarState(path, before) {
379
+ if (before === null) {
380
+ try {
381
+ lstatSync(path);
382
+ fail('database-drift', `${basename(path)} appeared during the snapshot`);
383
+ }
384
+ catch (error) {
385
+ if (error instanceof AutomationOperatorSnapshotError)
386
+ throw error;
387
+ if (!(typeof error === 'object' && error !== null && 'code' in error
388
+ && error.code === 'ENOENT')) {
389
+ fail('database-drift', `${basename(path)} changed during the snapshot`);
390
+ }
391
+ }
392
+ return;
393
+ }
394
+ assertStillIdentical(before);
395
+ }
396
+ /**
397
+ * Decode a Linux user-space dev_t per <sys/sysmacros.h>, preserving the full
398
+ * bit width:
399
+ * major(dev) = ((dev >> 8) & 0xfff) | ((dev >> 32) & 0xfffff000)
400
+ * minor(dev) = (dev & 0xff) | ((dev >> 12) & 0xffffff00)
401
+ */
402
+ export function decodeDeviceId(dev) {
403
+ return {
404
+ major: ((dev >> 8n) & 0xfffn) | ((dev >> 32n) & 0xfffff000n),
405
+ minor: (dev & 0xffn) | ((dev >> 12n) & 0xffffff00n),
406
+ };
407
+ }
408
+ function lockTargets(files) {
409
+ return files.filter((file) => file !== null)
410
+ .map(file => ({ dev: file.stat.dev, ino: file.stat.ino }));
411
+ }
412
+ /** Parse the MAJ:MIN:INO out of one /proc/locks row, including blocked-lock rows. */
413
+ function parseLockIdentity(fields) {
414
+ // /proc/locks rows are "num: KIND MODE ACCESS PID MAJ:MIN:INO START END";
415
+ // blocked locks insert "->" after the number, shifting the pid/identity by one.
416
+ const blockedOffset = fields[1] === '->' ? 1 : 0;
417
+ const identity = fields[5 + blockedOffset];
418
+ if (identity === undefined || !/^[0-9a-f]+:[0-9a-f]+:[0-9]+$/iu.test(identity))
419
+ return null;
420
+ const [major, minor, inode] = identity.split(':');
421
+ if (major === undefined || minor === undefined || inode === undefined)
422
+ return null;
423
+ return { major: BigInt(`0x${major}`), minor: BigInt(`0x${minor}`), inode };
424
+ }
425
+ /** Identity of a WRITE lock in one /proc/locks row; null for READ locks or malformed rows. */
426
+ export function parseWritableProcLock(line) {
427
+ const fields = line.trim().split(/\s+/u);
428
+ // blocked locks insert "->" after the number, shifting MODE/ACCESS/pid by one.
429
+ const blockedOffset = fields[1] === '->' ? 1 : 0;
430
+ if (fields[3 + blockedOffset] !== 'WRITE')
431
+ return null;
432
+ return parseLockIdentity(fields);
433
+ }
434
+ function assertNoExclusiveWriter(targets) {
435
+ let locks;
436
+ try {
437
+ locks = readFileSync('/proc/locks', 'utf8');
438
+ }
439
+ catch {
440
+ // The lock table is the only available writer probe; never proceed when it
441
+ // cannot be read, since a live writer could then go undetected.
442
+ fail('database-busy', 'cannot inspect active database locks');
443
+ }
444
+ const decoded = targets.map(target => ({ ...decodeDeviceId(target.dev), inode: target.ino.toString() }));
445
+ const locked = locks.split('\n').some(line => {
446
+ const identity = parseWritableProcLock(line);
447
+ return identity !== null && decoded.some(target => target.major === identity.major
448
+ && target.minor === identity.minor && target.inode === identity.inode);
449
+ });
450
+ if (locked)
451
+ fail('database-busy', 'database has an exclusive writer lock');
452
+ }
453
+ function retryExclusiveWriterProbe(targets) {
454
+ for (let attempt = 0; attempt < 3; attempt += 1) {
455
+ assertNoExclusiveWriter(targets);
456
+ const until = process.hrtime.bigint() + 1000000n;
457
+ while (process.hrtime.bigint() < until) { /* bounded race-window probe */ }
458
+ }
459
+ }
460
+ /**
461
+ * A hot rollback journal means an interrupted transaction may be embedded in
462
+ * the main file; immutable=1 opens skip journal replay, so its mere presence
463
+ * forces a busy verdict. Runs before the source is pinned or opened.
464
+ */
465
+ function assertNoRollbackJournal(path) {
466
+ try {
467
+ lstatSync(`${path}-journal`);
468
+ fail('database-busy', 'database has a rollback journal');
469
+ }
470
+ catch (error) {
471
+ if (error instanceof AutomationOperatorSnapshotError)
472
+ throw error;
473
+ if (typeof error === 'object' && error !== null && 'code' in error
474
+ && error.code === 'ENOENT')
475
+ return;
476
+ fail('unsafe-path', 'rollback journal cannot be inspected');
477
+ }
478
+ }
479
+ /** A journal appearing once the snapshot started is writer drift and must fail closed. */
480
+ function assertJournalAbsent(path) {
481
+ try {
482
+ lstatSync(`${path}-journal`);
483
+ fail('database-drift', 'rollback journal appeared during the snapshot');
484
+ }
485
+ catch (error) {
486
+ if (error instanceof AutomationOperatorSnapshotError)
487
+ throw error;
488
+ if (typeof error === 'object' && error !== null && 'code' in error
489
+ && error.code === 'ENOENT')
490
+ return;
491
+ fail('database-drift', 'rollback journal cannot be inspected during the snapshot');
492
+ }
493
+ }
494
+ function copyOpenedFile(source, target) {
495
+ const buffer = Buffer.allocUnsafe(64 * 1024);
496
+ const destination = openSync(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
497
+ try {
498
+ let position = 0n;
499
+ while (position < source.stat.size) {
500
+ const length = Number(source.stat.size - position > BigInt(buffer.length)
501
+ ? BigInt(buffer.length) : source.stat.size - position);
502
+ const count = readSync(source.descriptor, buffer, 0, length, Number(position));
503
+ if (count <= 0)
504
+ fail('database-drift', `${basename(source.path)} changed while being copied`);
505
+ let written = 0;
506
+ while (written < count)
507
+ written += writeSync(destination, buffer, written, count - written);
508
+ position += BigInt(count);
509
+ }
510
+ }
511
+ finally {
512
+ closeSync(destination);
513
+ }
514
+ const copied = openPrivateFile(target, 'database-drift');
515
+ try {
516
+ if (copied.stat.size !== source.stat.size || copied.digest !== source.digest) {
517
+ fail('database-drift', `${basename(source.path)} copy does not match its pinned source`);
518
+ }
519
+ }
520
+ finally {
521
+ closeSync(copied.descriptor);
522
+ }
523
+ }
524
+ function privateDatabasePath(path) {
525
+ if (typeof path !== 'string' || path.length === 0 || !isAbsolute(path) || resolve(path) !== path
526
+ || path === '/' || path !== path.normalize('NFC') || /[\p{Cc}]/u.test(path)) {
527
+ fail('invalid-path', 'database path must be an absolute canonical path');
528
+ }
529
+ let physical;
530
+ let parentPhysical;
531
+ try {
532
+ parentPhysical = realpathSync(dirname(path));
533
+ }
534
+ catch (error) {
535
+ if (typeof error === 'object' && error !== null && 'code' in error
536
+ && error.code === 'ENOENT')
537
+ fail('unsafe-parent', 'database parent disappeared');
538
+ fail('unsafe-parent', 'database parent cannot be resolved');
539
+ }
540
+ if (parentPhysical !== dirname(path)) {
541
+ fail('unsafe-path', 'database path must not traverse a symbolic link');
542
+ }
543
+ try {
544
+ physical = realpathSync(path);
545
+ }
546
+ catch (error) {
547
+ if (typeof error === 'object' && error !== null && 'code' in error
548
+ && error.code === 'ENOENT')
549
+ fail('database-missing', 'database does not exist');
550
+ fail('unsafe-path', 'database path cannot be resolved');
551
+ }
552
+ if (physical !== path || parentPhysical !== dirname(path)) {
553
+ fail('unsafe-path', 'database path must not traverse a symbolic link');
554
+ }
555
+ return physical;
556
+ }
557
+ function parseDefinition(row) {
558
+ if (typeof row.definition_json !== 'string'
559
+ || Buffer.byteLength(row.definition_json, 'utf8') > 16 * 1024 * 1024
560
+ || typeof row.definition_hash !== 'string' || !digestPattern.test(row.definition_hash)) {
561
+ fail('database-corrupt', 'automation definition metadata is invalid');
562
+ }
563
+ let parsed;
564
+ try {
565
+ parsed = JSON.parse(row.definition_json);
566
+ }
567
+ catch {
568
+ fail('database-corrupt', 'automation definition JSON is invalid');
569
+ }
570
+ const definition = (() => {
571
+ try {
572
+ return normalizeAutomationDefinition(parsed, 16 * 1024 * 1024, 10_000);
573
+ }
574
+ catch {
575
+ return fail('database-corrupt', 'automation definition is not canonical');
576
+ }
577
+ })();
578
+ const canonical = JSON.stringify(definition);
579
+ if (row.definition_json !== canonical || sha256(canonical) !== row.definition_hash) {
580
+ fail('database-corrupt', 'automation definition digest is invalid');
581
+ }
582
+ return freeze(definition);
583
+ }
584
+ function parseRecord(row) {
585
+ if (!canonicalText(row.id, 500)
586
+ || (row.system_owner !== null && !canonicalText(row.system_owner, 200))
587
+ || (row.status !== 'active' && row.status !== 'paused' && row.status !== 'deleted')
588
+ || !safeInteger(row.created_at) || !safeInteger(row.updated_at) || row.updated_at < row.created_at
589
+ || !safeInteger(row.version, 1) || !safeInteger(row.running_task_count)
590
+ || (row.next_run_at !== null && !safeInteger(row.next_run_at))) {
591
+ fail('database-corrupt', 'automation definition row is invalid');
592
+ }
593
+ if (row.status !== 'active' && row.next_run_at !== null) {
594
+ fail('database-corrupt', 'inactive automation has a next run time');
595
+ }
596
+ const definition = parseDefinition(row);
597
+ return freeze({
598
+ id: row.id,
599
+ ...(row.system_owner === null ? {} : { owner: row.system_owner }),
600
+ definitionHash: row.definition_hash,
601
+ definition,
602
+ status: row.status,
603
+ ...(row.next_run_at === null ? {} : { nextRunAt: row.next_run_at }),
604
+ createdAt: row.created_at,
605
+ updatedAt: row.updated_at,
606
+ version: row.version,
607
+ runningTaskCount: row.running_task_count,
608
+ });
609
+ }
610
+ function publicRecord(record) {
611
+ return freeze({
612
+ id: record.id,
613
+ ...(record.owner === undefined ? {} : { owner: record.owner }),
614
+ definitionHash: record.definitionHash,
615
+ status: record.status,
616
+ ...(record.nextRunAt === undefined ? {} : { nextRunAt: record.nextRunAt }),
617
+ createdAt: record.createdAt,
618
+ updatedAt: record.updatedAt,
619
+ version: record.version,
620
+ runningTaskCount: record.runningTaskCount,
621
+ });
622
+ }
623
+ function readSnapshot(databasePath) {
624
+ const path = privateDatabasePath(databasePath);
625
+ // Pin the private parent directory for the whole snapshot so an ancestor
626
+ // rename or a same-name directory swap cannot retarget the database path
627
+ // between the path checks and the read.
628
+ const pinnedParent = pinPrivateParent(dirname(path));
629
+ const walPath = `${path}-wal`;
630
+ const shmPath = `${path}-shm`;
631
+ let source;
632
+ let wal = null;
633
+ let shm = null;
634
+ let temporary;
635
+ let temporaryPinned;
636
+ let copyFiles = [];
637
+ let database;
638
+ let transaction = false;
639
+ let result;
640
+ let deferred;
641
+ // Descriptors the snapshot itself holds; these are excluded when proving that
642
+ // SQLite opened the pinned inode, so the connection can never vouch for itself.
643
+ const selfDescriptors = () => new Set([
644
+ pinnedParent.descriptor, source?.descriptor, wal?.descriptor, shm?.descriptor,
645
+ temporaryPinned?.descriptor, ...copyFiles.map(file => file.descriptor),
646
+ ].filter((descriptor) => descriptor !== undefined));
647
+ try {
648
+ assertNoRollbackJournal(path);
649
+ source = openPrivateFile(path, 'database-missing');
650
+ assertJournalAbsent(path);
651
+ wal = openOptionalSidecar(walPath);
652
+ shm = openOptionalSidecar(shmPath);
653
+ if ((wal === null) !== (shm === null)) {
654
+ // An exclusive WAL writer can legitimately be observed between creating
655
+ // its WAL and SHM entries. Re-probe the lock before classifying a stable
656
+ // orphan sidecar as source drift.
657
+ retryExclusiveWriterProbe(lockTargets([source, wal, shm]));
658
+ fail('database-drift', 'WAL sidecar set is incomplete');
659
+ }
660
+ // In WAL mode the writer's POSIX WRITE lock is held on the SHM inode
661
+ // (byte 120), not the main database, so every sidecar inode is probed.
662
+ assertNoExclusiveWriter(lockTargets([source, wal, shm]));
663
+ assertJournalAbsent(path);
664
+ assertStillIdentical(source);
665
+ assertSidecarState(walPath, wal);
666
+ assertSidecarState(shmPath, shm);
667
+ // Files SQLite must prove it is reading. Without WAL that is the pinned
668
+ // source inode; with WAL it is the pinned private replica inodes.
669
+ let bound = [];
670
+ let target;
671
+ if (wal === null) {
672
+ if (inodeBindSupported) {
673
+ // Open the pinned descriptor itself by inode (Linux) so a same-name swap
674
+ // cannot retarget the connection; immutable=1 forbids WAL/journal access.
675
+ target = `file://${procSelfFd}/${source.descriptor}?mode=ro&immutable=1`;
676
+ }
677
+ else {
678
+ const location = pathToFileURL(path);
679
+ location.searchParams.set('mode', 'ro');
680
+ location.searchParams.set('immutable', '1');
681
+ target = location;
682
+ }
683
+ bound = [source];
684
+ }
685
+ else {
686
+ temporary = mkdtempSync(join(tmpdir(), 'automations-operator-snapshot-'));
687
+ const temporaryMetadata = lstatSync(temporary, { bigint: true });
688
+ if (!temporaryMetadata.isDirectory() || temporaryMetadata.isSymbolicLink()
689
+ || (temporaryMetadata.mode & 4095n) !== 448n
690
+ || (process.getuid?.() !== undefined && temporaryMetadata.uid !== BigInt(process.getuid()))) {
691
+ fail('unsafe-path', 'temporary snapshot directory is not private');
692
+ }
693
+ if (inodeBindSupported) {
694
+ temporaryPinned = pinTempDirectory(temporary);
695
+ const anchor = `${procSelfFd}/${temporaryPinned.descriptor}`;
696
+ const mainRef = `${anchor}/automations.sqlite`;
697
+ // Only the main file and WAL are copied; a rollback journal is never
698
+ // copied or replayed, and its appearance fails the snapshot below.
699
+ const copiedMain = copyAndPin(source, mainRef);
700
+ let copiedWal;
701
+ try {
702
+ copiedWal = copyAndPin(wal, `${mainRef}-wal`);
703
+ }
704
+ catch (error) {
705
+ try {
706
+ closeSync(copiedMain.descriptor);
707
+ }
708
+ catch { }
709
+ ;
710
+ throw error;
711
+ }
712
+ copyFiles = Object.freeze([copiedMain, copiedWal]);
713
+ target = mainRef;
714
+ bound = copyFiles;
715
+ }
716
+ else {
717
+ const copy = join(temporary, 'automations.sqlite');
718
+ // Only the main file and WAL are copied; a rollback journal is never
719
+ // copied or replayed, and its appearance fails the snapshot below.
720
+ copyOpenedFile(source, copy);
721
+ copyOpenedFile(wal, `${copy}-wal`);
722
+ target = copy;
723
+ }
724
+ assertStillIdentical(source);
725
+ assertSidecarState(walPath, wal);
726
+ assertSidecarState(shmPath, shm);
727
+ }
728
+ assertJournalAbsent(path);
729
+ if (!pinnedFileIdentityMatches(source)) {
730
+ fail('database-drift', 'database path changed before open');
731
+ }
732
+ database = new DatabaseSync(target, { readOnly: true });
733
+ // The WAL/SHM fds are opened lazily on first query, so right after the
734
+ // constructor only the main database inode can be claimed.
735
+ if (inodeBindSupported)
736
+ assertConnectionBound([bound[0]], selfDescriptors());
737
+ if (!pinnedFileIdentityMatches(source)) {
738
+ fail('database-drift', 'database path changed during open');
739
+ }
740
+ database.exec('PRAGMA query_only = ON; PRAGMA busy_timeout = 0; BEGIN');
741
+ transaction = true;
742
+ const queryOnly = database.prepare('PRAGMA query_only').get().query_only;
743
+ if (queryOnly !== 1)
744
+ fail('database-unavailable', 'read-only guard was not enabled');
745
+ const version = database.prepare('PRAGMA user_version').get().user_version;
746
+ if (version !== automationSchemaVersion)
747
+ fail('schema-unsupported', `expected schema ${automationSchemaVersion}`);
748
+ const check = database.prepare('PRAGMA quick_check').all();
749
+ if (check.length !== 1 || check[0]?.quick_check !== 'ok')
750
+ fail('database-corrupt', 'SQLite quick_check failed');
751
+ const rows = database.prepare(`
752
+ SELECT definition.id, definition.system_owner, definition.definition_hash, definition.definition_json,
753
+ definition.status, definition.next_run_at, definition.created_at, definition.updated_at, definition.version,
754
+ COALESCE(SUM(CASE WHEN task.status IN ('claimed', 'running') THEN 1 ELSE 0 END), 0) AS running_task_count
755
+ FROM automation_definitions AS definition
756
+ LEFT JOIN automation_tasks AS task ON task.automation_id = definition.id
757
+ GROUP BY definition.id
758
+ ORDER BY definition.id COLLATE BINARY
759
+ `).all();
760
+ const internalRecords = freeze(rows.map(parseRecord));
761
+ // The main query has now opened the WAL/SHM fds; re-prove every inode the
762
+ // result depends on is still the pinned one (non-WAL keeps just the source).
763
+ if (inodeBindSupported)
764
+ assertConnectionBound(bound, selfDescriptors());
765
+ const inFlight = database.prepare(`
766
+ SELECT COUNT(*) AS count FROM automation_tasks WHERE status IN ('claimed', 'running')
767
+ `).get();
768
+ if (!safeInteger(inFlight.count))
769
+ fail('database-corrupt', 'in-flight task count is invalid');
770
+ const summed = internalRecords.reduce((total, record) => total + record.runningTaskCount, 0);
771
+ if (!Number.isSafeInteger(summed) || summed !== inFlight.count) {
772
+ fail('database-corrupt', 'in-flight tasks are not bound to the definition inventory');
773
+ }
774
+ const foreignKeys = database.prepare('PRAGMA foreign_key_check').all();
775
+ if (foreignKeys.length !== 0)
776
+ fail('database-corrupt', 'foreign-key integrity check failed');
777
+ const records = freeze(internalRecords.map(publicRecord));
778
+ database.exec('ROLLBACK');
779
+ transaction = false;
780
+ database.close();
781
+ database = undefined;
782
+ assertJournalAbsent(path);
783
+ assertStillIdentical(source);
784
+ assertSidecarState(walPath, wal);
785
+ assertSidecarState(shmPath, shm);
786
+ const storageDigest = sha256(JSON.stringify({
787
+ protocol: 'assistant-automations/operator-storage/v1',
788
+ database: source.digest,
789
+ wal: wal?.digest ?? null,
790
+ }));
791
+ const inventoryDigest = sha256(JSON.stringify({
792
+ protocol: automationsOperatorSnapshotProtocol,
793
+ schemaVersion: 15,
794
+ storageDigest,
795
+ inFlightCount: inFlight.count,
796
+ records,
797
+ }));
798
+ const snapshot = freeze({
799
+ protocol: automationsOperatorSnapshotProtocol,
800
+ schemaVersion: 15,
801
+ database: identity(source),
802
+ sidecars: freeze({ wal: wal === null ? null : identity(wal), shm: shm === null ? null : identity(shm) }),
803
+ storageDigest,
804
+ inFlightCount: inFlight.count,
805
+ inventoryDigest,
806
+ records,
807
+ });
808
+ result = { snapshot, internalRecords };
809
+ }
810
+ catch (error) {
811
+ deferred = error;
812
+ }
813
+ finally {
814
+ if (transaction)
815
+ try {
816
+ database?.exec('ROLLBACK');
817
+ }
818
+ catch (error) {
819
+ deferred ??= error;
820
+ }
821
+ try {
822
+ database?.close();
823
+ }
824
+ catch (error) {
825
+ deferred ??= error;
826
+ }
827
+ if (source !== undefined) {
828
+ try {
829
+ assertJournalAbsent(path);
830
+ assertStillIdentical(source);
831
+ assertSidecarState(walPath, wal);
832
+ assertSidecarState(shmPath, shm);
833
+ }
834
+ catch (error) {
835
+ deferred ??= error;
836
+ }
837
+ }
838
+ if (!pinnedParentMatches(pinnedParent)) {
839
+ deferred = new AutomationOperatorSnapshotError('unsafe-parent', 'automation operator snapshot: database parent changed while it was inspected');
840
+ }
841
+ for (const file of [shm, wal, source])
842
+ if (file !== null && file !== undefined) {
843
+ try {
844
+ closeSync(file.descriptor);
845
+ }
846
+ catch { }
847
+ }
848
+ for (const file of copyFiles) {
849
+ try {
850
+ closeSync(file.descriptor);
851
+ }
852
+ catch { }
853
+ }
854
+ if (temporaryPinned !== undefined) {
855
+ try {
856
+ removePinnedTemp(temporaryPinned, ['automations.sqlite', 'automations.sqlite-wal', 'automations.sqlite-shm']);
857
+ }
858
+ catch (error) {
859
+ deferred ??= error;
860
+ }
861
+ }
862
+ else if (temporary !== undefined) {
863
+ try {
864
+ rmSync(temporary, { recursive: true, force: true });
865
+ }
866
+ catch (error) {
867
+ deferred ??= error;
868
+ }
869
+ }
870
+ try {
871
+ closeSync(pinnedParent.descriptor);
872
+ }
873
+ catch (error) {
874
+ deferred ??= error;
875
+ }
876
+ }
877
+ if (deferred !== undefined) {
878
+ if (deferred instanceof AutomationOperatorSnapshotError)
879
+ throw deferred;
880
+ const message = deferred instanceof Error ? deferred.message.toLowerCase() : '';
881
+ if (/(?:busy|locked)/u.test(message))
882
+ return fail('database-busy', 'database is busy');
883
+ return fail('database-corrupt', 'database cannot produce a valid snapshot');
884
+ }
885
+ return result;
886
+ }
887
+ /**
888
+ * Side-effect-free, content-free operator inventory. It never creates, migrates, chmods,
889
+ * checkpoints, or changes journal mode, and fails closed if its source drifts.
890
+ */
891
+ export function inspectAutomationsOperatorSnapshot(databasePath) {
892
+ return readSnapshot(databasePath).snapshot;
893
+ }
894
+ /** Compatibility name for callers built against the earlier inventory proposal. */
895
+ export const inspectAutomationInventoryLocally = inspectAutomationsOperatorSnapshot;
896
+ /**
897
+ * Whether a snapshot failure is solely the legacy writable-store condition
898
+ * "database file has not been created yet". The strict operator snapshot keeps
899
+ * failing closed; only the list-* compatibility projections treat it as the
900
+ * empty inventory that a fresh store would have created and returned.
901
+ */
902
+ function isMissingDatabase(error) {
903
+ return error instanceof AutomationOperatorSnapshotError && error.code === 'database-missing';
904
+ }
905
+ function compatibilityRecords(databasePath) {
906
+ try {
907
+ return readSnapshot(databasePath).internalRecords;
908
+ }
909
+ catch (error) {
910
+ if (isMissingDatabase(error))
911
+ return Object.freeze([]);
912
+ throw error;
913
+ }
914
+ }
915
+ /** Read-only local compatibility projection; never starts the scheduler. */
916
+ export function listAutomationsLocally(databasePath) {
917
+ return compatibilityRecords(databasePath).map(record => freeze({
918
+ id: record.id,
919
+ ...(record.owner === undefined ? {} : { owner: record.owner }),
920
+ definition: record.definition,
921
+ status: record.status,
922
+ nextRunAt: record.nextRunAt,
923
+ createdAt: record.createdAt,
924
+ updatedAt: record.updatedAt,
925
+ version: record.version,
926
+ }));
11
927
  }
12
928
  /** Compatibility projection retained for callers that only gate active work. */
13
929
  export function listActiveAutomationsLocally(databasePath) {