@crouton-kit/capture 1.3.8 → 1.3.9

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 (136) hide show
  1. package/bin/capture +29841 -8539
  2. package/package.json +6 -3
  3. package/src/capture.ts +86 -173
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -205
  6. package/src/cdp/bridge/protocol.ts +93 -6
  7. package/src/cdp/bridge/server.ts +118 -53
  8. package/src/cdp/bridge/spawn.ts +53 -6
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +20 -14
  11. package/src/cdp/commands/cdp.ts +259 -90
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +76 -20
  15. package/src/cdp/commands/measure/check.ts +100 -26
  16. package/src/cdp/commands/measure/diff.ts +109 -21
  17. package/src/cdp/commands/measure/explain.ts +121 -19
  18. package/src/cdp/commands/measure/index.ts +31 -39
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +41 -13
  21. package/src/cdp/commands/measure/map-layers.ts +49 -15
  22. package/src/cdp/commands/measure/map-scroll.ts +85 -15
  23. package/src/cdp/commands/measure/snap.ts +405 -25
  24. package/src/cdp/commands/measure/sweep.ts +217 -20
  25. package/src/cdp/commands/motion/index.ts +18 -17
  26. package/src/cdp/commands/motion/jank.ts +119 -9
  27. package/src/cdp/commands/motion/mask.ts +81 -12
  28. package/src/cdp/commands/motion/rec.ts +520 -19
  29. package/src/cdp/commands/motion/response.ts +65 -15
  30. package/src/cdp/commands/motion/timeline.ts +139 -14
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/detect.ts +42 -13
  46. package/src/cdp/dispatch.ts +74 -28
  47. package/src/cdp/har-recorder.ts +648 -115
  48. package/src/cdp/leaf-grammar.ts +99 -0
  49. package/src/cdp/measure/census.ts +205 -0
  50. package/src/cdp/measure/check.ts +318 -0
  51. package/src/cdp/measure/collectors/animation.ts +385 -0
  52. package/src/cdp/measure/collectors/ax.ts +278 -0
  53. package/src/cdp/measure/collectors/focus.ts +1041 -0
  54. package/src/cdp/measure/collectors/forms.ts +905 -0
  55. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  56. package/src/cdp/measure/collectors/hittest.ts +835 -0
  57. package/src/cdp/measure/collectors/layers.ts +697 -0
  58. package/src/cdp/measure/collectors/media.ts +522 -0
  59. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  60. package/src/cdp/measure/collectors/queries.ts +464 -0
  61. package/src/cdp/measure/collectors/scroll.ts +599 -0
  62. package/src/cdp/measure/collectors/states.ts +992 -0
  63. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  64. package/src/cdp/measure/collectors/styles.ts +355 -0
  65. package/src/cdp/measure/collectors/text.ts +713 -0
  66. package/src/cdp/measure/diff.ts +470 -0
  67. package/src/cdp/measure/explain.ts +498 -0
  68. package/src/cdp/measure/map-ax.ts +633 -0
  69. package/src/cdp/measure/map-focus.ts +188 -0
  70. package/src/cdp/measure/map-layers.ts +177 -0
  71. package/src/cdp/measure/map-scroll.ts +317 -0
  72. package/src/cdp/measure/redaction.ts +51 -0
  73. package/src/cdp/measure/settle.ts +1051 -0
  74. package/src/cdp/measure/snapshot.ts +390 -0
  75. package/src/cdp/measure/sweep.ts +330 -0
  76. package/src/cdp/measure/types.ts +220 -0
  77. package/src/cdp/measure/v2/collectors.ts +19 -0
  78. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  79. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  80. package/src/cdp/measure/v2/target-lock.ts +15 -0
  81. package/src/cdp/measure/v2/target.ts +16 -0
  82. package/src/cdp/motion/jank.ts +433 -0
  83. package/src/cdp/motion/mask.ts +421 -0
  84. package/src/cdp/motion/recorder.ts +764 -0
  85. package/src/cdp/motion/response.ts +356 -0
  86. package/src/cdp/motion/timeline.ts +215 -0
  87. package/src/cdp/record.ts +5 -212
  88. package/src/cdp/recorder-bridge.ts +1478 -105
  89. package/src/cdp/recorder-client.ts +326 -0
  90. package/src/cdp/scope-lock.ts +73 -0
  91. package/src/cdp/screenshot.ts +199 -66
  92. package/src/cdp/targets.ts +21 -1
  93. package/src/cdp/timing.ts +15 -10
  94. package/src/cdp/types.ts +22 -7
  95. package/src/cdp/viewport.ts +21 -0
  96. package/src/cdp.ts +12 -58
  97. package/src/command-help.ts +29 -0
  98. package/src/command-resolution.ts +22 -0
  99. package/src/command-walker.ts +35 -0
  100. package/src/contracts/a11y.ts +33 -0
  101. package/src/contracts/cursor.ts +35 -0
  102. package/src/contracts/index.ts +15 -0
  103. package/src/contracts/motion.ts +19 -0
  104. package/src/contracts/neutral.ts +56 -0
  105. package/src/contracts/primitives.ts +205 -0
  106. package/src/contracts/release.ts +17 -0
  107. package/src/contracts/results.ts +208 -0
  108. package/src/contracts/routes.ts +140 -0
  109. package/src/contracts/snapshot.ts +92 -0
  110. package/src/cursor/index.ts +13 -0
  111. package/src/cursor/service.ts +310 -0
  112. package/src/errors.ts +71 -0
  113. package/src/har-manager.ts +992 -41
  114. package/src/interact.ts +410 -98
  115. package/src/output/artifact-lifecycle.ts +371 -0
  116. package/src/output/artifact.ts +6 -0
  117. package/src/output/exact-raw.ts +114 -0
  118. package/src/output/render.ts +396 -2
  119. package/src/output/structured.ts +211 -0
  120. package/src/registry.ts +208 -0
  121. package/src/session/artifacts.ts +452 -200
  122. package/src/session/commands.ts +1162 -397
  123. package/src/session/coordinator.ts +251 -0
  124. package/src/session/har-filter.ts +57 -0
  125. package/src/session/log-tailer.ts +653 -0
  126. package/src/session-context.ts +279 -51
  127. package/src/vault/bundle.ts +32 -17
  128. package/src/cdp/commands/exec.ts +0 -105
  129. package/src/cdp/commands/har.ts +0 -128
  130. package/src/cdp/commands/tabs.ts +0 -183
  131. package/src/cdp/commands/traffic.ts +0 -121
  132. package/src/cdp/commands/ui.ts +0 -172
  133. package/src/cdp/execute.ts +0 -113
  134. package/src/cdp/har-output.ts +0 -47
  135. package/src/cdp/usage.ts +0 -79
  136. package/src/tab-session.ts +0 -91
@@ -1,229 +1,481 @@
1
- /**
2
- * Secure artifact filesystem — the one place every session/oneshot artifact
3
- * writer (screenshots, HAR, snapshot substrate, recordings) goes through.
4
- *
5
- * Contract: directories are created `0700`, files are created `0600`,
6
- * writes are atomic (temp file + rename) and never follow a symlink planted
7
- * at the destination, and every helper refuses to operate outside
8
- * `CAPTURE_ROOT`. Downstream units (snapshot collectors, the recorder
9
- * bridge, motion query leaves) write ALL artifacts through these helpers
10
- * instead of ad-hoc `fs.writeFile`/`fs.mkdirSync`.
11
- */
1
+ import * as crypto from 'node:crypto';
2
+ import { execFileSync } from 'node:child_process';
3
+ import * as fs from 'node:fs';
4
+ import * as os from 'node:os';
5
+ import * as path from 'node:path';
12
6
 
13
- import * as fs from 'fs';
14
- import * as os from 'os';
15
- import * as path from 'path';
16
-
17
- /** The one canonical session/oneshot root. Every artifact lives under here. */
18
- export const CAPTURE_ROOT = path.join(os.tmpdir(), 'capture-sessions');
19
-
20
- /** Owner rwx only. */
7
+ const configuredRoot = process.env.CAPTURE_ROOT;
8
+ if (configuredRoot !== undefined && !path.isAbsolute(configuredRoot)) throw new Error('CAPTURE_ROOT must be an absolute path');
9
+ /** The private artifact root pathname, frozen at process start; the directory itself is
10
+ * established lazily by the first artifact transaction. Intentionally not realpathed. */
11
+ export const CAPTURE_ROOT = path.resolve(configuredRoot ?? path.join(os.tmpdir(), 'capture-sessions'));
21
12
  export const DIR_MODE = 0o700;
22
- /** Owner rw only. */
23
13
  export const FILE_MODE = 0o600;
14
+ const MAX_TIMING_MS = 86_400_000;
24
15
 
25
- // ============================================================================
26
- // Meta.json contracts — the shape session stop() reads back out of
27
- // measure/snaps/*/meta.json and motion/recs/*/meta.json to build the bundle
28
- // manifest's `snaps`/`recs` arrays. Later units (U06 snapshot orchestrator,
29
- // U13/U14 recorder) write meta.json in this shape.
30
- // ============================================================================
31
-
32
- export interface SnapMeta {
33
- id: string;
34
- url: string | null;
35
- viewport: string | null;
36
- settled: boolean;
37
- capturedAt: string;
16
+ type Identity = { dev: number; ino: number };
17
+ export type ArtifactHookOperation = 'root-bootstrap' | 'traversal' | 'final-file' | 'recursive-removal' | 'lock';
18
+ export type ArtifactHookPhase = 'afterComponentLstat' | 'beforeComponentCreate' | 'beforeComponentChdir' | 'afterComponentChdirBeforeIdentityCheck' | 'afterRootPinned' | 'afterParentPinned' | 'afterFinalOpen' | 'afterDescriptorValidated' | 'beforeTempCreate' | 'afterTempCreate' | 'beforeRename' | 'beforeUnlink' | 'beforeOwnedCleanupUnlink' | 'afterChildLstat' | 'beforeChildChdir' | 'afterChildChdirBeforeIdentityCheck' | 'beforeChildUnlink' | 'beforeDirectoryRmdir' | 'beforePublishAttempt' | 'afterStageMkdirPinned' | 'beforeStageChdir' | 'afterStageChdirBeforeIdentityCheck' | 'afterOwnerWrite' | 'beforePublishRename' | 'beforeStageCleanup' | 'afterCanonicalOwnerValidation' | 'afterOwnerRemoval' | 'beforeCanonicalRmdir';
19
+ export interface ArtifactHookDetail { operation: ArtifactHookOperation; phase: ArtifactHookPhase; path: string; component?: string; }
20
+ type Hook = (detail: ArtifactHookDetail) => void;
21
+ export interface ArtifactTestHooks {
22
+ onHook?: Hook;
23
+ afterComponentLstat?: Hook; beforeComponentCreate?: Hook; beforeComponentChdir?: Hook; afterComponentChdirBeforeIdentityCheck?: Hook; afterRootPinned?: Hook; afterParentPinned?: Hook;
24
+ afterFinalOpen?: Hook; afterDescriptorValidated?: Hook; beforeTempCreate?: Hook; afterTempCreate?: Hook; beforeRename?: Hook; beforeUnlink?: Hook; beforeOwnedCleanupUnlink?: Hook;
25
+ afterChildLstat?: Hook; beforeChildChdir?: Hook; afterChildChdirBeforeIdentityCheck?: Hook; beforeChildUnlink?: Hook; beforeDirectoryRmdir?: Hook;
26
+ beforePublishAttempt?: Hook; afterStageMkdirPinned?: Hook; beforeStageChdir?: Hook; afterStageChdirBeforeIdentityCheck?: Hook; afterOwnerWrite?: Hook; beforePublishRename?: Hook; beforeStageCleanup?: Hook; afterCanonicalOwnerValidation?: Hook; afterOwnerRemoval?: Hook; beforeCanonicalRmdir?: Hook;
38
27
  }
28
+ /** A hook may be installed via this global symbol before importing this module, so the first transaction's root bootstrap is observable. */
29
+ export const ARTIFACT_TEST_HOOKS_SYMBOL = Symbol.for('capture.artifacts.test-hooks');
30
+ let hooks = globalThis[ARTIFACT_TEST_HOOKS_SYMBOL as keyof typeof globalThis] as ArtifactTestHooks | undefined;
31
+ function hook(operation: ArtifactHookOperation, phase: ArtifactHookPhase, path: string, component?: string): void { const detail = { operation, phase, path, component }; hooks?.onHook?.(detail); hooks?.[phase]?.(detail); }
32
+ /** Test-only synchronous race seams. Production callers must not configure these. */
33
+ export function __setArtifactTestHooks(next?: ArtifactTestHooks): void { hooks = next; }
34
+ export type ArtifactSyscallRole = 'artifact-data-write' | 'lock-owner-write' | 'artifact-temp-fsync' | 'lock-owner-fsync' | 'lock-stage-dir-fsync' | 'artifact-temp-close' | 'lock-owner-close' | 'artifact-rename' | 'lock-publish-rename' | 'artifact-cleanup-unlink' | 'lock-canonical-rmdir' | 'artifact-open' | 'artifact-fstat' | 'artifact-fchmod';
35
+ export interface ArtifactTestFaults { before?: (role: ArtifactSyscallRole) => void; after?: (role: ArtifactSyscallRole) => void; write?: (role: Extract<ArtifactSyscallRole, 'artifact-data-write' | 'lock-owner-write'>, real: () => number) => number; }
36
+ let faults: ArtifactTestFaults | undefined;
37
+ /** Test-only syscall adapter. Calls are always real by default and this is reset by passing undefined. */
38
+ export function __setArtifactTestFaults(next?: ArtifactTestFaults): void { faults = next; }
39
+ let artifactTempToken = () => crypto.randomBytes(12).toString('hex');
40
+ let lockToken = () => crypto.randomBytes(24).toString('hex');
41
+ /** Deterministic test-only sources for nameable collision and publication cases. */
42
+ export function __setArtifactTestTokens(next?: { artifactTemp?: () => string; lock?: () => string }): void { artifactTempToken = next?.artifactTemp ?? (() => crypto.randomBytes(12).toString('hex')); lockToken = next?.lock ?? (() => crypto.randomBytes(24).toString('hex')); }
39
43
 
40
- export interface RecMeta {
41
- id: string;
42
- action: string | null;
43
- frames: number;
44
- durationMs: number;
45
- state: string;
44
+ type RuntimeContext = {
45
+ hooks: ArtifactTestHooks | undefined;
46
+ faults: ArtifactTestFaults | undefined;
47
+ artifactTempToken: () => string;
48
+ lockToken: () => string;
49
+ execFileSyncForProvider: ExecFileSync;
50
+ linuxProviderRead: (file: '/proc/sys/kernel/random/boot_id' | `/proc/${number}/stat`) => string;
51
+ };
52
+ function snapshotRuntime(): RuntimeContext { return { hooks, faults, artifactTempToken, lockToken, execFileSyncForProvider, linuxProviderRead }; }
53
+ function withRuntime<T>(runtime: RuntimeContext, action: () => T): T {
54
+ const prior = { hooks, faults, artifactTempToken, lockToken, execFileSyncForProvider, linuxProviderRead };
55
+ hooks = runtime.hooks;
56
+ faults = runtime.faults;
57
+ artifactTempToken = runtime.artifactTempToken;
58
+ lockToken = runtime.lockToken;
59
+ execFileSyncForProvider = runtime.execFileSyncForProvider;
60
+ linuxProviderRead = runtime.linuxProviderRead;
61
+ try { return action(); }
62
+ finally {
63
+ hooks = prior.hooks;
64
+ faults = prior.faults;
65
+ artifactTempToken = prior.artifactTempToken;
66
+ lockToken = prior.lockToken;
67
+ execFileSyncForProvider = prior.execFileSyncForProvider;
68
+ linuxProviderRead = prior.linuxProviderRead;
69
+ }
46
70
  }
47
-
48
- // ============================================================================
49
- // Path boundary
50
- // ============================================================================
51
-
52
- /**
53
- * Resolves `targetPath` to an absolute path and throws unless it is
54
- * strictly under `CAPTURE_ROOT`. Every write/cleanup helper below routes
55
- * through this so a caller can never be tricked (or mistaken) into writing
56
- * or deleting outside the capture artifact tree.
57
- */
58
- export function assertUnderCaptureRoot(targetPath: string): string {
59
- const resolved = path.resolve(targetPath);
60
- const root = path.resolve(CAPTURE_ROOT);
61
- const rel = path.relative(root, resolved);
62
- if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
63
- throw new Error(`artifact path escapes capture root ${root}: ${targetPath}`);
71
+ async function withRuntimeAsync<T>(runtime: RuntimeContext, action: () => Promise<T>): Promise<T> {
72
+ const prior = { hooks, faults, artifactTempToken, lockToken, execFileSyncForProvider, linuxProviderRead };
73
+ hooks = runtime.hooks;
74
+ faults = runtime.faults;
75
+ artifactTempToken = runtime.artifactTempToken;
76
+ lockToken = runtime.lockToken;
77
+ execFileSyncForProvider = runtime.execFileSyncForProvider;
78
+ linuxProviderRead = runtime.linuxProviderRead;
79
+ try { return await action(); }
80
+ finally {
81
+ hooks = prior.hooks;
82
+ faults = prior.faults;
83
+ artifactTempToken = prior.artifactTempToken;
84
+ lockToken = prior.lockToken;
85
+ execFileSyncForProvider = prior.execFileSyncForProvider;
86
+ linuxProviderRead = prior.linuxProviderRead;
64
87
  }
65
- return resolved;
66
88
  }
89
+ let privateLockGate: Promise<unknown> = Promise.resolve();
90
+ function withPrivateLockSerial<T>(task: () => Promise<T>): Promise<T> {
91
+ const run = privateLockGate.then(() => task());
92
+ privateLockGate = run.then(() => undefined, () => undefined);
93
+ return run;
94
+ }
95
+ function syscall<T>(role: ArtifactSyscallRole, real: () => T): T { faults?.before?.(role); return real(); }
96
+ function closeDescriptor(fd: number, role: Extract<ArtifactSyscallRole, 'artifact-temp-close' | 'lock-owner-close'>, afterRealClose?: () => void): void {
97
+ faults?.before?.(role);
98
+ fs.closeSync(fd);
99
+ afterRealClose?.();
100
+ // Close is the sole after-real-call fault seam: the descriptor is definitely closed
101
+ // before this callback can throw.
102
+ faults?.after?.(role);
103
+ }
104
+ function combineFailure(primary: unknown, secondary: unknown, message: string): never {
105
+ throw new AggregateError([primary, secondary], message);
106
+ }
107
+ // The root is established lazily by the first pinned artifact transaction: every
108
+ // transaction traverses it, pinning its identity on first use and verifying it
109
+ // on every later traversal. Non-artifact invocations touch no filesystem state.
110
+ let captureRootIdentity: Identity | undefined;
111
+ let cwdPinned = false;
67
112
 
68
- // ============================================================================
69
- // Directories
70
- // ============================================================================
113
+ export interface SnapMeta { id: string; url: string | null; viewport: string | null; settled: boolean; capturedAt: string; }
114
+ export interface RecMeta { id: string; action: string | null; frames: number; durationMs: number; state: string; viewportRestored?: boolean | null; }
115
+ function identity(stat: fs.Stats): Identity { return { dev: stat.dev, ino: stat.ino }; }
116
+ function sameIdentity(a: Identity, b: Identity): boolean { return a.dev === b.dev && a.ino === b.ino; }
117
+ function errno(error: unknown): string | undefined { return (error as NodeJS.ErrnoException).code; }
118
+ function isMissing(error: unknown): boolean { return errno(error) === 'ENOENT'; }
119
+ function assertName(name: string): void { if (!name || name === '.' || name === '..' || name.includes(path.sep)) throw new Error(`invalid artifact path component: ${name}`); }
120
+ function writeAll(fd: number, data: string | Buffer, role: Extract<ArtifactSyscallRole, 'artifact-data-write' | 'lock-owner-write'> = 'artifact-data-write'): void { const bytes = Buffer.isBuffer(data) ? data : Buffer.from(data); for (let off = 0; off < bytes.length;) { const remaining = bytes.length - off; faults?.before?.(role); if (!faults?.write) { const wrote = fs.writeSync(fd, bytes, off, remaining); if (!Number.isSafeInteger(wrote) || wrote <= 0 || wrote > remaining) throw new Error('short private artifact write'); off += wrote; continue; }
121
+ // A fault callback prescribes the segment to persist. Its `real` callback reports
122
+ // the unmodified syscall capacity; delaying the actual write avoids writing a full
123
+ // buffer and then falsely reporting a partial result.
124
+ const prescribed = faults.write(role, () => remaining); if (!Number.isSafeInteger(prescribed) || prescribed <= 0 || prescribed > remaining) throw new Error('short private artifact write');
125
+ const wrote = fs.writeSync(fd, bytes, off, prescribed); if (!Number.isSafeInteger(wrote) || wrote <= 0 || wrote > prescribed) throw new Error('short private artifact write'); off += wrote;
126
+ } }
127
+ function privateDirHere(): void { const fd = fs.openSync('.', fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW); try { fs.fchmodSync(fd, DIR_MODE); } finally { fs.closeSync(fd); } }
128
+ function verifyRoot(actual: Identity): void { if (captureRootIdentity) { if (!sameIdentity(captureRootIdentity, actual)) throw new Error('capture root changed since it was first established'); } else captureRootIdentity = actual; }
129
+ function rootOrAncestor(current: string): boolean { return current === CAPTURE_ROOT || CAPTURE_ROOT.startsWith(`${current}${path.sep}`); }
71
130
 
72
- /** Creates (or re-secures) one path segment as a private, non-symlink dir. */
73
- function ensureSegmentPrivate(dirPath: string): void {
74
- let st: fs.Stats;
131
+ /** Performs only synchronous single-component operations while cwd is inode-pinned. */
132
+ function inPinnedDirectory<T>(absoluteDir: string, create: boolean, operation: () => T, operationKind: ArtifactHookOperation = 'traversal'): T {
133
+ if (cwdPinned) throw new Error('nested private artifact cwd transaction');
134
+ if (!path.isAbsolute(absoluteDir)) throw new Error(`private artifact directory must be absolute: ${absoluteDir}`);
135
+ cwdPinned = true;
136
+ const original = process.cwd();
137
+ const originalIdentity = identity(fs.statSync('.'));
138
+ let result: T | undefined; let failure: unknown;
75
139
  try {
76
- st = fs.lstatSync(dirPath);
77
- } catch (err) {
78
- if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
79
- fs.mkdirSync(dirPath, { mode: DIR_MODE });
80
- // mkdir's mode is masked by umask; enforce explicitly.
81
- fs.chmodSync(dirPath, DIR_MODE);
82
- return;
83
- }
84
- if (st.isSymbolicLink()) {
85
- throw new Error(`refusing to use a symlinked artifact directory: ${dirPath}`);
86
- }
87
- if (!st.isDirectory()) {
88
- throw new Error(`artifact path exists and is not a directory: ${dirPath}`);
89
- }
90
- fs.chmodSync(dirPath, DIR_MODE);
140
+ const root = path.parse(absoluteDir).root; process.chdir(root); let current = root;
141
+ for (const component of absoluteDir.slice(root.length).split(path.sep).filter(Boolean)) {
142
+ assertName(component); current = path.join(current, component);
143
+ const privateComponent = current === CAPTURE_ROOT || current.startsWith(`${CAPTURE_ROOT}${path.sep}`);
144
+ const darwinVarAlias = process.platform === 'darwin' && current === '/var' && CAPTURE_ROOT.startsWith('/var/');
145
+ const hookOperation: ArtifactHookOperation = current === CAPTURE_ROOT || darwinVarAlias ? 'root-bootstrap' : operationKind;
146
+ let before: fs.Stats;
147
+ try { before = fs.lstatSync(component); hook(hookOperation, 'afterComponentLstat', current, component); }
148
+ catch (error) {
149
+ if (!create || !(privateComponent || rootOrAncestor(current)) || !isMissing(error)) throw error;
150
+ hook(hookOperation, 'beforeComponentCreate', current, component);
151
+ // A concurrent honest bootstrap can create this component between the
152
+ // ENOENT lstat and this mkdir. Tolerate exactly EEXIST, then re-lstat the
153
+ // same name: the symlink/non-directory/identity/mode checks below remain
154
+ // authoritative over whatever won the create.
155
+ try { fs.mkdirSync(component, { mode: DIR_MODE }); }
156
+ catch (createError) { if (errno(createError) !== 'EEXIST') throw createError; }
157
+ before = fs.lstatSync(component);
158
+ }
159
+ // Darwin exposes /var as a kernel-owned alias for /private/var. It is the only
160
+ // host alias accepted before the configured root; all user-controlled components
161
+ // including the configured root itself must be real directories.
162
+ // Pin and compare the followed vnode, not the alias directory entry.
163
+ if (before.isSymbolicLink() && darwinVarAlias) {
164
+ const expected = identity(fs.statSync(component));
165
+ hook(hookOperation, 'beforeComponentChdir', current, component);
166
+ process.chdir(component);
167
+ hook(hookOperation, 'afterComponentChdirBeforeIdentityCheck', current, component);
168
+ if (!sameIdentity(expected, identity(fs.statSync('.')))) throw new Error(`artifact directory changed while pinning: ${component}`);
169
+ continue;
170
+ }
171
+ if (before.isSymbolicLink()) throw new Error(`refusing symlinked artifact directory component: ${component}`);
172
+ if (!before.isDirectory()) throw new Error(`refusing non-directory artifact component: ${component}`);
173
+ const expected = identity(before); hook(hookOperation, 'beforeComponentChdir', current, component); process.chdir(component); hook(hookOperation, 'afterComponentChdirBeforeIdentityCheck', current, component);
174
+ if (!sameIdentity(expected, identity(fs.statSync('.')))) throw new Error(`artifact directory changed while pinning: ${component}`);
175
+ if (current === CAPTURE_ROOT) { verifyRoot(expected); hook('root-bootstrap', 'afterRootPinned', current, component); }
176
+ if (privateComponent) privateDirHere();
177
+ }
178
+ hook(absoluteDir === CAPTURE_ROOT ? 'root-bootstrap' : operationKind, 'afterParentPinned', absoluteDir); result = operation();
179
+ } catch (error) { failure = error; }
180
+ let restoreFailure: unknown;
181
+ try { process.chdir(original); if (!sameIdentity(originalIdentity, identity(fs.statSync('.')))) restoreFailure = new Error('private artifact cwd restoration changed directory identity'); }
182
+ catch (error) { restoreFailure = error; }
183
+ cwdPinned = false;
184
+ if (restoreFailure) { if (failure) throw new AggregateError([failure, restoreFailure], 'private artifact cwd restoration failed'); throw restoreFailure; }
185
+ if (failure) throw failure;
186
+ return result as T;
91
187
  }
92
188
 
93
- /**
94
- * Ensures `dirPath` and every missing ancestor up to (and including)
95
- * `CAPTURE_ROOT` exist as private (`0700`), non-symlink directories.
96
- * Existing directories in the chain are re-chmod'd to `0700` rather than
97
- * trusted. Returns the resolved absolute path. Throws if `dirPath` is not
98
- * under `CAPTURE_ROOT`, or if any path segment is a symlink or a
99
- * non-directory.
100
- */
101
- export function ensurePrivateDir(dirPath: string): string {
102
- const resolved = assertUnderCaptureRoot(dirPath);
103
- const root = path.resolve(CAPTURE_ROOT);
104
-
105
- ensureSegmentPrivate(root);
106
- const rel = path.relative(root, resolved);
107
- let current = root;
108
- for (const part of rel.split(path.sep).filter(Boolean)) {
109
- current = path.join(current, part);
110
- ensureSegmentPrivate(current);
111
- }
112
- return resolved;
189
+ export function assertUnderCaptureRoot(targetPath: string): string { const resolved = path.resolve(targetPath); const relative = path.relative(CAPTURE_ROOT, resolved); if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) throw new Error(`artifact path escapes capture root ${CAPTURE_ROOT}: ${targetPath}`); return resolved; }
190
+ function pinnedParent<T>(target: string, operation: (name: string) => T, create = true, operationKind: ArtifactHookOperation = 'traversal'): T { const resolved = assertUnderCaptureRoot(target); const name = path.basename(resolved); assertName(name); return inPinnedDirectory(path.dirname(resolved), create, () => operation(name), operationKind); }
191
+ export function ensurePrivateDir(dirPath: string): string { const resolved = assertUnderCaptureRoot(dirPath); inPinnedDirectory(resolved, true, () => undefined); return resolved; }
192
+ function openRegular(name: string, flags: number, create = false, onOpened?: (fd: number) => void, closeRole: Extract<ArtifactSyscallRole, 'artifact-temp-close' | 'lock-owner-close'> = 'artifact-temp-close', hookOperation: ArtifactHookOperation = 'final-file'): number {
193
+ let fd: number | undefined;
194
+ try { fd = syscall('artifact-open', () => fs.openSync(name, flags | fs.constants.O_NOFOLLOW, create ? FILE_MODE : undefined)); onOpened?.(fd); hook(hookOperation, 'afterFinalOpen', path.resolve(process.cwd(), name), name); const stat = syscall('artifact-fstat', () => fs.fstatSync(fd!)); if (!stat.isFile()) throw new Error(`artifact is not a regular file: ${name}`); syscall('artifact-fchmod', () => fs.fchmodSync(fd!, FILE_MODE)); hook(hookOperation, 'afterDescriptorValidated', path.resolve(process.cwd(), name), name); return fd; }
195
+ catch (error) { if (fd !== undefined) { try { closeDescriptor(fd, closeRole); } catch (closeError) { combineFailure(error, closeError, 'private artifact descriptor setup failed'); } } throw error; }
113
196
  }
114
-
115
- // ============================================================================
116
- // Atomic, no-symlink-follow writes
117
- // ============================================================================
118
-
119
- function refuseExistingSymlink(resolved: string): void {
197
+ export function createPrivateFile(filePath: string, data: string | Buffer = ''): void { pinnedParent(filePath, name => {
198
+ let fd: number | undefined; let created: Identity | undefined; let closed = false;
199
+ let primary: unknown;
120
200
  try {
121
- const st = fs.lstatSync(resolved);
122
- if (st.isSymbolicLink()) {
123
- throw new Error(`refusing to write through a symlink: ${resolved}`);
201
+ fd = openRegular(name, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, true, opened => { created = identity(fs.fstatSync(opened)); });
202
+ writeAll(fd, data); syscall('artifact-temp-fsync', () => fs.fsyncSync(fd));
203
+ closeDescriptor(fd, 'artifact-temp-close', () => { closed = true; });
204
+ } catch (error) { primary = error; }
205
+ if (fd !== undefined && !closed) { try { closeDescriptor(fd, 'artifact-temp-close', () => { closed = true; }); } catch (closeError) { if (primary) combineFailure(primary, closeError, 'private artifact creation failed'); throw closeError; } }
206
+ if (primary) { try { cleanupOwnedFile(name, created); } catch (cleanupError) { combineFailure(primary, cleanupError, 'private artifact creation cleanup failed'); } throw primary; }
207
+ }); }
208
+ function readFd(fd: number): Buffer { const chunks: Buffer[] = []; const chunk = Buffer.allocUnsafe(65536); for (;;) { const n = fs.readSync(fd, chunk, 0, chunk.length, null); if (!n) return Buffer.concat(chunks); chunks.push(Buffer.from(chunk.subarray(0, n))); } }
209
+ export function readPrivateFile(filePath: string): Buffer { return pinnedParent(filePath, name => { const fd = openRegular(name, fs.constants.O_RDONLY); let value: Buffer | undefined; let primary: unknown; try { value = readFd(fd); } catch (error) { primary = error; } try { closeDescriptor(fd, 'artifact-temp-close'); } catch (closeError) { if (primary) combineFailure(primary, closeError, 'private artifact read failed'); throw closeError; } if (primary) throw primary; return value!; }); }
210
+ /** Opens a contained, no-follow, append descriptor the caller owns and must close.
211
+ * Reuses the pinned-parent transaction and fchmod-on-descriptor private mode, so a
212
+ * planted symlink at the final component fails the open (O_NOFOLLOW) and the target's
213
+ * bytes/mode are never touched. */
214
+ export function openPrivateAppendFd(filePath: string): number {
215
+ return pinnedParent(filePath, name => openRegular(name, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT, true));
216
+ }
217
+ export function appendPrivateFile(filePath: string, data: string | Buffer): void { pinnedParent(filePath, name => { const fd = openRegular(name, fs.constants.O_WRONLY | fs.constants.O_APPEND); let primary: unknown; try { writeAll(fd, data); syscall('artifact-temp-fsync', () => fs.fsyncSync(fd)); } catch (error) { primary = error; } try { closeDescriptor(fd, 'artifact-temp-close'); } catch (closeError) { if (primary) combineFailure(primary, closeError, 'private artifact append failed'); throw closeError; } if (primary) throw primary; }); }
218
+ function cleanupOwnedFile(name: string, expected?: Identity): void {
219
+ if (!expected) return;
220
+ let lastFailure: unknown;
221
+ for (let attempt = 0; attempt < 2; attempt++) {
222
+ try {
223
+ const current = fs.lstatSync(name);
224
+ if (!sameIdentity(expected, identity(current)) || !current.isFile()) return;
225
+ hook('final-file', 'beforeOwnedCleanupUnlink', path.resolve(process.cwd(), name), name);
226
+ const rechecked = fs.lstatSync(name);
227
+ if (!sameIdentity(expected, identity(rechecked)) || !rechecked.isFile()) return;
228
+ syscall('artifact-cleanup-unlink', () => fs.unlinkSync(name));
229
+ return;
230
+ } catch (error) {
231
+ if (isMissing(error)) return;
232
+ lastFailure = error;
124
233
  }
125
- } catch (err) {
126
- if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
127
234
  }
235
+ throw lastFailure;
128
236
  }
129
-
130
- /**
131
- * Writes `data` to `filePath` as a private (`0600`) file, atomically:
132
- * writes to a uniquely-named temp file in the same directory (opened with
133
- * `O_NOFOLLOW`, so a pre-planted symlink at the temp name can't be written
134
- * through) and renames it over the destination. `rename(2)` replaces
135
- * whatever is at the destination — including a symlink — without ever
136
- * following it, so a symlink swapped in at `filePath` between calls is
137
- * unlinked, never written through. Ensures the parent directory exists
138
- * (and is private) first. This is the one primitive every other write
139
- * helper in this module is built on.
140
- */
141
- export function writePrivateFile(filePath: string, data: string | Buffer): void {
142
- const resolved = assertUnderCaptureRoot(filePath);
143
- const dir = path.dirname(resolved);
144
- ensurePrivateDir(dir);
145
- refuseExistingSymlink(resolved);
146
-
147
- const tmpPath = path.join(dir, `.${path.basename(resolved)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`);
148
- const fd = fs.openSync(
149
- tmpPath,
150
- fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW,
151
- FILE_MODE,
152
- );
237
+ export function writePrivateFile(filePath: string, data: string | Buffer): void { pinnedParent(filePath, name => {
238
+ try { if (fs.lstatSync(name).isSymbolicLink()) throw new Error(`refusing to replace symlinked private artifact: ${name}`); } catch (error) { if (!isMissing(error)) throw error; }
239
+ const temporary = `.${name}.${process.pid}.${artifactTempToken()}.tmp`; let fd: number | undefined; let created: Identity | undefined; let closed = false; let published = false;
240
+ let primary: unknown;
153
241
  try {
154
- fs.writeSync(fd, data);
155
- } finally {
156
- fs.closeSync(fd);
242
+ hook('final-file', 'beforeTempCreate', path.resolve(process.cwd(), temporary), temporary);
243
+ fd = openRegular(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, true, opened => { created = identity(fs.fstatSync(opened)); });
244
+ hook('final-file', 'afterTempCreate', path.resolve(process.cwd(), temporary), temporary);
245
+ writeAll(fd, data); syscall('artifact-temp-fsync', () => fs.fsyncSync(fd)); closeDescriptor(fd, 'artifact-temp-close', () => { closed = true; });
246
+ hook('final-file', 'beforeRename', path.resolve(process.cwd(), name), name);
247
+ const temporaryState = fs.lstatSync(temporary);
248
+ if (!created || !temporaryState.isFile() || !sameIdentity(created, identity(temporaryState))) throw new Error(`private artifact temporary replaced before rename: ${temporary}`);
249
+ syscall('artifact-rename', () => fs.renameSync(temporary, name)); published = true;
250
+ } catch (error) { primary = error; }
251
+ if (fd !== undefined && !closed) { try { closeDescriptor(fd, 'artifact-temp-close', () => { closed = true; }); } catch (closeError) { if (primary) combineFailure(primary, closeError, 'private artifact replacement failed'); throw closeError; } }
252
+ if (primary) { if (!published) { try { cleanupOwnedFile(temporary, created); } catch (cleanupError) { combineFailure(primary, cleanupError, 'private artifact replacement cleanup failed'); } } throw primary; }
253
+ }); }
254
+ export function unlinkPrivateFile(filePath: string): void { pinnedParent(filePath, name => { const stat = fs.lstatSync(name); if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`refusing to unlink non-regular private artifact: ${name}`); hook('final-file', 'beforeUnlink', path.resolve(process.cwd(), name), name); syscall('artifact-cleanup-unlink', () => fs.unlinkSync(name)); }); }
255
+ export function writeJsonPrivate(filePath: string, value: unknown): void { writePrivateFile(filePath, JSON.stringify(value, null, 2)); }
256
+ export function writeNdjsonPrivate(filePath: string, records: unknown[]): void { writePrivateFile(filePath, records.map(record => JSON.stringify(record)).join('\n') + (records.length ? '\n' : '')); }
257
+ export function appendNdjsonPrivate(filePath: string, record: unknown): void { const line = `${JSON.stringify(record)}\n`; try { appendPrivateFile(filePath, line); } catch (error) { if (!isMissing(error)) throw error; try { createPrivateFile(filePath, line); } catch (created) { if (errno(created) === 'EEXIST') appendPrivateFile(filePath, line); else throw created; } } }
258
+ export function writeBinaryPrivate(filePath: string, data: Buffer): void { writePrivateFile(filePath, data); }
259
+ function removePinnedEntry(name: string): void { const absolute = path.resolve(process.cwd(), name); const stat = fs.lstatSync(name); hook('recursive-removal', 'afterChildLstat', absolute, name); if (stat.isSymbolicLink() || stat.isFile()) { hook('recursive-removal', 'beforeChildUnlink', absolute, name); syscall('artifact-cleanup-unlink', () => fs.unlinkSync(name)); return; } if (!stat.isDirectory()) throw new Error(`refusing to remove unusual artifact entry: ${name}`); const parent = identity(fs.statSync('.')); const expected = identity(stat); hook('recursive-removal', 'beforeChildChdir', absolute, name); process.chdir(name); hook('recursive-removal', 'afterChildChdirBeforeIdentityCheck', absolute, name); if (!sameIdentity(expected, identity(fs.statSync('.')))) throw new Error(`artifact directory changed while removing: ${name}`); for (const child of fs.readdirSync('.')) removePinnedEntry(child); process.chdir('..'); if (!sameIdentity(parent, identity(fs.statSync('.')))) throw new Error('artifact parent changed while removing');
260
+ let removalName = name; let displaced = false;
261
+ try { displaced = !sameIdentity(expected, identity(fs.lstatSync(name))); } catch (error) { if (!isMissing(error)) throw error; displaced = true; }
262
+ if (displaced) {
263
+ removalName = fs.readdirSync('.').find(candidate => { try { return sameIdentity(expected, identity(fs.lstatSync(candidate))); } catch { return false; } }) ?? '';
264
+ if (!removalName) throw new Error(`artifact directory changed while removing: ${name}`);
157
265
  }
158
- fs.chmodSync(tmpPath, FILE_MODE);
159
- fs.renameSync(tmpPath, resolved);
266
+ hook('recursive-removal', 'beforeDirectoryRmdir', path.resolve(process.cwd(), removalName), removalName);
267
+ const finalEntry = fs.lstatSync(removalName);
268
+ if (!sameIdentity(expected, identity(finalEntry)) || !finalEntry.isDirectory()) throw new Error(`artifact directory changed while removing: ${name}`);
269
+ syscall('artifact-cleanup-unlink', () => fs.rmdirSync(removalName));
270
+ if (displaced) throw new Error(`artifact directory changed while removing: ${name}`);
160
271
  }
272
+ export function removeArtifactTree(targetPath: string): void { try { pinnedParent(targetPath, removePinnedEntry, false); } catch (error) { if (!isMissing(error)) throw error; } }
161
273
 
162
- /** Writes `value` as pretty-printed JSON through {@link writePrivateFile}. */
163
- export function writeJsonPrivate(filePath: string, value: unknown): void {
164
- writePrivateFile(filePath, JSON.stringify(value, null, 2));
274
+ export type PidBirth = { provider: 'linux-proc-v1'; bootId: string; startTicks: string } | { provider: 'darwin-kern-proc-v1'; startSec: string; startUsec: number };
275
+ export type PidBirthRead = { status: 'found'; identity: PidBirth } | { status: 'absent' } | { status: 'unknown'; reason: string };
276
+ export interface PidBirthProvider { read(pid: number): PidBirthRead; }
277
+ function unknown(reason: string): PidBirthRead { return { status: 'unknown', reason }; }
278
+ const BOOT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
279
+ /** True when `value` carries exactly `keys` — the strict-record shape gate shared with the log tailer. */
280
+ export function exactKeys(value: Record<string, unknown>, keys: string[]): boolean { const actual = Object.keys(value).sort(); return actual.length === keys.length && actual.every((key, index) => key === keys.slice().sort()[index]); }
281
+ /** Strictly parses a persisted PID-birth identity; anything malformed is undefined. */
282
+ export function parseBirth(value: unknown): PidBirth | undefined {
283
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
284
+ const b = value as Record<string, unknown>;
285
+ if (b.provider === 'linux-proc-v1' && exactKeys(b, ['provider', 'bootId', 'startTicks']) && typeof b.bootId === 'string' && BOOT_ID.test(b.bootId) && typeof b.startTicks === 'string' && /^[1-9][0-9]*$/.test(b.startTicks)) return { provider: 'linux-proc-v1', bootId: b.bootId, startTicks: b.startTicks };
286
+ if (b.provider === 'darwin-kern-proc-v1' && exactKeys(b, ['provider', 'startSec', 'startUsec']) && typeof b.startSec === 'string' && /^[1-9][0-9]*$/.test(b.startSec) && typeof b.startUsec === 'number' && Number.isInteger(b.startUsec) && b.startUsec >= 0 && b.startUsec < 1_000_000) return { provider: 'darwin-kern-proc-v1', startSec: b.startSec, startUsec: b.startUsec };
287
+ return undefined;
165
288
  }
166
-
167
- /**
168
- * Writes `records` as a complete newline-delimited JSON file (one JSON
169
- * value per line), replacing any existing content atomically. For
170
- * incremental line-at-a-time appends (e.g. a live recorder streaming
171
- * frames/events), use {@link appendNdjsonPrivate} instead.
172
- */
173
- export function writeNdjsonPrivate(filePath: string, records: unknown[]): void {
174
- const body = records.map((record) => JSON.stringify(record)).join('\n') + (records.length ? '\n' : '');
175
- writePrivateFile(filePath, body);
289
+ export const MAX_LOG_LABEL_BYTES = 64;
290
+ /** Returns a diagnostic for an invalid log destination filename component, otherwise null. */
291
+ export function rejectLogLabel(label: string): string | null {
292
+ if (label.length === 0) return 'empty';
293
+ if (label === '.' || label === '..') return `\`${label}\` is not a filename`;
294
+ if (label.includes('/') || label.includes('\\')) return 'contains a path separator';
295
+ if (label.includes('\0')) return 'contains a NUL byte';
296
+ if (Buffer.byteLength(label, 'utf-8') > MAX_LOG_LABEL_BYTES) return `exceeds ${MAX_LOG_LABEL_BYTES} bytes`;
297
+ return null;
176
298
  }
177
-
178
- /**
179
- * Appends one record as a single NDJSON line to `filePath`, creating it
180
- * (private, `0600`) if absent. Not atomic across the whole file the way
181
- * {@link writePrivateFile} is — it's the incremental-write counterpart used
182
- * while a recording is live — but still refuses to append through a
183
- * symlink and enforces the private mode on every call.
184
- */
185
- export function appendNdjsonPrivate(filePath: string, record: unknown): void {
186
- const resolved = assertUnderCaptureRoot(filePath);
187
- const dir = path.dirname(resolved);
188
- ensurePrivateDir(dir);
189
- refuseExistingSymlink(resolved);
190
-
191
- const line = JSON.stringify(record) + '\n';
192
- const fd = fs.openSync(
193
- resolved,
194
- fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | fs.constants.O_NOFOLLOW,
195
- FILE_MODE,
196
- );
197
- try {
198
- fs.writeSync(fd, line);
199
- } finally {
200
- fs.closeSync(fd);
201
- }
202
- fs.chmodSync(resolved, FILE_MODE);
299
+ export const LOG_TAILER_SOCKET_TOKEN = /^[0-9a-f]{16}$/;
300
+ export const LOG_TAILER_NONCE = /^[0-9a-f]{48}$/;
301
+ /** Private control-socket directory shared by every session log tailer. */
302
+ export const LOG_TAILER_SOCKET_DIR = path.join(CAPTURE_ROOT, 'sock');
303
+ const DECIMAL_IDENTITY = /^(?:0|[1-9][0-9]*)$/;
304
+ /** An identity-bearing registered log tailer — exactly the persisted `logPids` record shape. */
305
+ export interface RegisteredLogTailer { pid: number; name: string; sourcePath: string; birth: PidBirth; socketPath: string; socketDev: string; socketIno: string; nonce: string; }
306
+ /** Strictly parses a persisted log tailer registration; anything weaker is undefined. */
307
+ export function parseRegisteredLogTailer(value: unknown): RegisteredLogTailer | undefined {
308
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
309
+ const record = value as Record<string, unknown>;
310
+ const birth = parseBirth(record.birth);
311
+ const valid = birth !== undefined
312
+ && exactKeys(record, ['pid', 'name', 'sourcePath', 'birth', 'socketPath', 'socketDev', 'socketIno', 'nonce'])
313
+ && Number.isSafeInteger(record.pid) && (record.pid as number) > 0
314
+ && typeof record.name === 'string' && rejectLogLabel(record.name) === null
315
+ && typeof record.sourcePath === 'string' && path.isAbsolute(record.sourcePath)
316
+ && typeof record.socketPath === 'string'
317
+ && path.dirname(record.socketPath) === LOG_TAILER_SOCKET_DIR
318
+ && LOG_TAILER_SOCKET_TOKEN.test(path.basename(record.socketPath, '.sock'))
319
+ && path.extname(record.socketPath) === '.sock'
320
+ && typeof record.socketDev === 'string' && DECIMAL_IDENTITY.test(record.socketDev)
321
+ && typeof record.socketIno === 'string' && DECIMAL_IDENTITY.test(record.socketIno)
322
+ && typeof record.nonce === 'string' && LOG_TAILER_NONCE.test(record.nonce);
323
+ // `valid` already implies a parsed birth, but the explicit disjunct is what
324
+ // narrows `birth` for the return type — do not "simplify" it away.
325
+ if (!valid || birth === undefined) return undefined;
326
+ return { pid: record.pid as number, name: record.name as string, sourcePath: record.sourcePath as string, birth, socketPath: record.socketPath as string, socketDev: record.socketDev as string, socketIno: record.socketIno as string, nonce: record.nonce as string };
203
327
  }
204
-
205
- /**
206
- * Writes binary artifact data (PNG crops, `video.webm`, ...) through
207
- * {@link writePrivateFile}. A distinct named export for call-site clarity;
208
- * behavior is identical to passing a `Buffer` to `writePrivateFile`.
209
- */
210
- export function writeBinaryPrivate(filePath: string, data: Buffer): void {
211
- writePrivateFile(filePath, data);
328
+ export interface DarwinKernProcValidationContext { arch: string; selfPid: number; selfStartSeconds: number; nowSeconds: number; }
329
+ export function parseDarwinKernProc(snapshot: Buffer, pid: number, context: DarwinKernProcValidationContext = { arch: process.arch, selfPid: process.pid, selfStartSeconds: Math.floor(Date.now() / 1000 - process.uptime()), nowSeconds: Math.floor(Date.now() / 1000) }): PidBirthRead {
330
+ if (!['arm64', 'x64'].includes(context.arch) || !Number.isSafeInteger(pid) || pid <= 0 || !snapshot.length || snapshot.length % 648) return unknown('unexpected kern.proc layout');
331
+ const seen = new Set<number>(); let found: PidBirth | undefined; let selfStart: bigint | undefined; const future = BigInt(context.nowSeconds + 60);
332
+ for (let offset = 0; offset < snapshot.length; offset += 648) {
333
+ const recordPid = snapshot.readInt32LE(offset + 40); const sec = snapshot.readBigInt64LE(offset); const usec = snapshot.readBigInt64LE(offset + 8);
334
+ // kern.proc can include the kernel PID 0 record; it has no user-process
335
+ // birth timeval and is not a liveness candidate. Every real process record
336
+ // is globally validated before this snapshot can answer for any target.
337
+ if (recordPid < 0 || recordPid > 10_000_000 || seen.has(recordPid) || (recordPid !== 0 && (sec <= 0n || sec > future || usec < 0n || usec >= 1_000_000n))) return unknown('invalid kern.proc snapshot');
338
+ seen.add(recordPid); if (recordPid === context.selfPid) selfStart = sec;
339
+ if (recordPid === pid) found = { provider: 'darwin-kern-proc-v1', startSec: sec.toString(), startUsec: Number(usec) };
340
+ }
341
+ const expectedSelf = BigInt(Math.floor(context.selfStartSeconds));
342
+ if (!seen.has(1) || selfStart === undefined || abs(selfStart - expectedSelf) > 60n) return unknown('kern.proc snapshot missing or invalid self identity');
343
+ return found ? { status: 'found', identity: found } : { status: 'absent' };
212
344
  }
345
+ export function parseLinuxProcStat(stat: string, expectedPid: number, bootId: string): PidBirthRead { if (!BOOT_ID.test(bootId.trim()) || !stat.startsWith(`${expectedPid} (`)) return unknown('malformed proc identity'); const close = stat.lastIndexOf(') '); const fields = close < 0 ? [] : stat.slice(close + 2).trim().split(/\s+/); const startTicks = fields[19]; if (!/^[1-9][0-9]*$/.test(startTicks ?? '')) return unknown('malformed proc identity'); return { status: 'found', identity: { provider: 'linux-proc-v1', bootId: bootId.trim(), startTicks } }; }
346
+ type ExecFileSync = typeof execFileSync;
347
+ let execFileSyncForProvider: ExecFileSync = execFileSync;
348
+ type LinuxProviderRead = (file: '/proc/sys/kernel/random/boot_id' | `/proc/${number}/stat`) => string;
349
+ let linuxProviderRead: LinuxProviderRead = file => fs.readFileSync(file, 'utf8');
350
+ /** Test-only provider seams are limited to process-identity inputs. */
351
+ export function __setArtifactTestExecFileSync(next?: ExecFileSync): void { execFileSyncForProvider = next ?? execFileSync; }
352
+ export function __setArtifactTestLinuxProviderRead(next?: LinuxProviderRead): void { linuxProviderRead = next ?? (file => fs.readFileSync(file, 'utf8')); }
353
+ export const processPidBirthProvider: PidBirthProvider = { read(pid) { try { if (!Number.isSafeInteger(pid) || pid <= 0) return unknown('invalid pid'); if (process.platform === 'linux') { let boot: string; try { boot = linuxProviderRead('/proc/sys/kernel/random/boot_id'); } catch (error) { return unknown(String(error)); } try { return parseLinuxProcStat(linuxProviderRead(`/proc/${pid}/stat`), pid, boot); } catch (error) { return isMissing(error) ? { status: 'absent' } : unknown(String(error)); } } if (process.platform === 'darwin') return parseDarwinKernProc(Buffer.from(execFileSyncForProvider('/usr/sbin/sysctl', ['-b', 'kern.proc'], { encoding: null, maxBuffer: 64 * 1024 * 1024, timeout: 1_000 }) as Buffer), pid); return unknown(`unsupported platform ${process.platform}`); } catch (error) { return unknown(String(error)); } } };
354
+ function abs(value: bigint): bigint { return value < 0n ? -value : value; }
213
355
 
214
- // ============================================================================
215
- // Cleanup
216
- // ============================================================================
217
-
218
- /**
219
- * Recursively removes the artifact tree at `targetPath` (a one-shot
220
- * session dir, an active session's `measure/snaps/{id}`, a
221
- * `motion/recs/{id}`, ...). Refuses any path that is not strictly under
222
- * `CAPTURE_ROOT` (including `CAPTURE_ROOT` itself). If `targetPath` is
223
- * itself a symlink, only the symlink is unlinked — its target is never
224
- * traversed or deleted.
225
- */
226
- export function removeArtifactTree(targetPath: string): void {
227
- const resolved = assertUnderCaptureRoot(targetPath);
228
- fs.rmSync(resolved, { recursive: true, force: true });
356
+ interface LockOwner { version: 1; token: string; pid: number; birth: PidBirth; leaseDeadlineNs: string; }
357
+ export interface PrivateLockHandle { readonly token: string; readonly ownerBirth: PidBirth; release(): void; }
358
+ export interface PrivateLockOptions { acquireTimeoutMs: number; leaseMs: number; pidBirthProvider?: PidBirthProvider; nowNs?: () => bigint; sleep?: (ms: number) => Promise<void>; token?: () => string; afterOwnerValidated?: () => void; afterOwnerRemoved?: () => void; beforeCanonicalRmdir?: () => void; beforePublishRename?: () => void; }
359
+ /** Field-exact birth-identity equality — the gate before any signal is authorized. */
360
+ export function sameBirth(a: PidBirth, b: PidBirth): boolean { return a.provider === b.provider && (a.provider === 'linux-proc-v1' ? a.bootId === (b as Extract<PidBirth, { provider: 'linux-proc-v1' }>).bootId && a.startTicks === (b as Extract<PidBirth, { provider: 'linux-proc-v1' }>).startTicks : a.startSec === (b as Extract<PidBirth, { provider: 'darwin-kern-proc-v1' }>).startSec && a.startUsec === (b as Extract<PidBirth, { provider: 'darwin-kern-proc-v1' }>).startUsec); }
361
+ function sameOwner(a: LockOwner, b: LockOwner): boolean { return a.token === b.token && a.pid === b.pid && a.leaseDeadlineNs === b.leaseDeadlineNs && sameBirth(a.birth, b.birth); }
362
+ function parseOwner(value: Buffer): LockOwner | undefined { try { const o = JSON.parse(value.toString('utf8')) as Record<string, unknown>; const birth = parseBirth(o?.birth); if (!o || Array.isArray(o) || !exactKeys(o, ['version', 'token', 'pid', 'birth', 'leaseDeadlineNs']) || o.version !== 1 || typeof o.token !== 'string' || !/^[0-9a-f]{32,}$/.test(o.token) || !Number.isSafeInteger(o.pid) || (o.pid as number) <= 0 || typeof o.leaseDeadlineNs !== 'string' || !/^(0|[1-9][0-9]*)$/.test(o.leaseDeadlineNs) || !birth) return undefined; BigInt(o.leaseDeadlineNs); return { version: 1, token: o.token, pid: o.pid as number, birth, leaseDeadlineNs: o.leaseDeadlineNs }; } catch { return undefined; } }
363
+ type OwnerState = { kind: 'missing' } | { kind: 'valid'; owner: LockOwner } | { kind: 'malformed' };
364
+ function ownerStateHere(): OwnerState { let fd: number; try { fd = openRegular('owner', fs.constants.O_RDONLY, false, undefined, 'lock-owner-close', 'lock'); } catch (error) { return isMissing(error) ? { kind: 'missing' } : { kind: 'malformed' }; } let state: OwnerState; try { const parsed = parseOwner(readFd(fd)); state = parsed ? { kind: 'valid', owner: parsed } : { kind: 'malformed' }; } catch { state = { kind: 'malformed' }; } try { closeDescriptor(fd, 'lock-owner-close'); } catch { return { kind: 'malformed' }; } return state; }
365
+ function validTiming(value: number, positive: boolean): boolean { return Number.isFinite(value) && value <= MAX_TIMING_MS && (positive ? value > 0 : value >= 0); }
366
+ const sleepDefault = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
367
+ /** Acquires a fully-published private lock directory; it never falls back to unlocked operation. */
368
+ export async function acquirePrivateLock(lockPath: string, options: PrivateLockOptions): Promise<PrivateLockHandle> {
369
+ if (!validTiming(options.acquireTimeoutMs, false) || !validTiming(options.leaseMs, true)) throw new Error('invalid private lock timing');
370
+ const canonical = assertUnderCaptureRoot(lockPath); const provider = options.pidBirthProvider ?? processPidBirthProvider; const self = provider.read(process.pid); if (self.status !== 'found') throw new Error(`cannot identify lock owner birth: ${self.status === 'unknown' ? self.reason : 'process absent'}`);
371
+ const now = options.nowNs ?? process.hrtime.bigint; const enforceTimeout = options.acquireTimeoutMs > 0; const deadline = now() + BigInt(Math.floor(options.acquireTimeoutMs * 1e6)); const sleep = options.sleep ?? sleepDefault; const random = options.token ?? lockToken;
372
+ const publish = (): { owner: LockOwner; generation: Identity } | undefined => pinnedParent(canonical, name => {
373
+ hook('lock', 'beforePublishAttempt', canonical, name);
374
+ const token = random(); if (!/^[0-9a-f]{32,}$/.test(token)) throw new Error('lock token source returned unsafe token');
375
+ const stage = `.${name}.${process.pid}.${token}.pending`; const owner: LockOwner = { version: 1, token, pid: process.pid, birth: self.identity, leaseDeadlineNs: (now() + BigInt(Math.floor(options.leaseMs * 1e6))).toString() };
376
+ let inside = false; let stageIdentity: Identity | undefined; let ownerIdentity: Identity | undefined; let primary: unknown;
377
+ try {
378
+ fs.mkdirSync(stage, { mode: DIR_MODE }); stageIdentity = identity(fs.lstatSync(stage));
379
+ const stageParent = identity(fs.statSync('.'));
380
+ hook('lock', 'afterStageMkdirPinned', path.resolve(process.cwd(), stage), stage);
381
+ hook('lock', 'beforeStageChdir', path.resolve(process.cwd(), stage), stage);
382
+ process.chdir(stage); inside = true;
383
+ hook('lock', 'afterStageChdirBeforeIdentityCheck', path.resolve(process.cwd()), stage);
384
+ if (!sameIdentity(stageIdentity, identity(fs.statSync('.'))) || !sameIdentity(stageParent, identity(fs.statSync('..')))) throw new Error('lock stage changed while pinning');
385
+ privateDirHere();
386
+ const fd = openRegular('owner', fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, true, opened => { ownerIdentity = identity(fs.fstatSync(opened)); }, 'lock-owner-close', 'lock'); let ownerPrimary: unknown;
387
+ try { writeAll(fd, JSON.stringify(owner), 'lock-owner-write'); syscall('lock-owner-fsync', () => fs.fsyncSync(fd)); hook('lock', 'afterOwnerWrite', path.resolve(process.cwd(), 'owner'), 'owner'); } catch (error) { ownerPrimary = error; }
388
+ try { closeDescriptor(fd, 'lock-owner-close'); } catch (closeError) { if (ownerPrimary) combineFailure(ownerPrimary, closeError, 'lock owner publication failed'); throw closeError; }
389
+ if (ownerPrimary) throw ownerPrimary;
390
+ const dfd = fs.openSync('.', fs.constants.O_RDONLY | fs.constants.O_DIRECTORY); try { syscall('lock-stage-dir-fsync', () => fs.fsyncSync(dfd)); } finally { fs.closeSync(dfd); }
391
+ process.chdir('..'); inside = false;
392
+ if (!sameIdentity(stageParent, identity(fs.statSync('.')))) throw new Error('lock stage parent changed before publication');
393
+ if (enforceTimeout && options.acquireTimeoutMs > 0 && now() >= deadline) throw new Error(`private lock acquisition timed out: ${canonical}`);
394
+ hook('lock', 'beforePublishRename', canonical, name); options.beforePublishRename?.();
395
+ const currentOwner = fs.lstatSync(path.join(stage, 'owner'));
396
+ if (!ownerIdentity || !currentOwner.isFile() || !sameIdentity(ownerIdentity, identity(currentOwner))) throw new Error('lock owner changed while publishing');
397
+ try {
398
+ syscall('lock-publish-rename', () => fs.renameSync(stage, name));
399
+ const generation = identity(fs.lstatSync(name));
400
+ if (enforceTimeout && options.acquireTimeoutMs > 0 && now() >= deadline) {
401
+ // The rename published this exact stage; retire only that retained generation.
402
+ if (sameIdentity(generation, identity(fs.lstatSync(name)))) { process.chdir(name); try { if (sameIdentity(generation, identity(fs.statSync('.')))) { try { fs.unlinkSync('owner'); } catch (error) { if (!isMissing(error)) throw error; } } } finally { process.chdir('..'); } try { if (sameIdentity(generation, identity(fs.lstatSync(name)))) fs.rmdirSync(name); } catch (error) { if (!isMissing(error) && errno(error) !== 'ENOTEMPTY') throw error; } }
403
+ throw new Error(`private lock acquisition timed out: ${canonical}`);
404
+ }
405
+ return { owner, generation };
406
+ } catch (error) { if (errno(error) === 'EEXIST' || errno(error) === 'ENOTEMPTY' || errno(error) === 'ENOTDIR') return undefined; throw error; }
407
+ } catch (error) { primary = error; throw error; } finally {
408
+ if (inside) process.chdir('..');
409
+ // Only remove the exact staging generation this attempt created. A substituted
410
+ // pathname is untrusted and deliberately left for its owner.
411
+ try {
412
+ const current = identity(fs.lstatSync(stage));
413
+ if (stageIdentity && sameIdentity(current, stageIdentity)) {
414
+ hook('lock', 'beforeStageCleanup', path.resolve(process.cwd(), stage), stage);
415
+ const rechecked = identity(fs.lstatSync(stage));
416
+ if (sameIdentity(rechecked, stageIdentity)) {
417
+ process.chdir(stage);
418
+ if (sameIdentity(stageIdentity, identity(fs.statSync('.')))) {
419
+ try { const child = fs.lstatSync('owner'); if (child.isFile()) { for (let attempt = 0; attempt < 2; attempt++) { try { syscall('artifact-cleanup-unlink', () => fs.unlinkSync('owner')); break; } catch (error) { if (isMissing(error) || attempt) throw error; } } } } catch (error) { if (!isMissing(error)) throw error; }
420
+ process.chdir('..');
421
+ const finalStage = fs.lstatSync(stage);
422
+ if (sameIdentity(stageIdentity, identity(finalStage)) && finalStage.isDirectory()) syscall('lock-canonical-rmdir', () => fs.rmdirSync(stage));
423
+ } else process.chdir('..');
424
+ }
425
+ }
426
+ } catch (error) { if (!isMissing(error) && errno(error) !== 'ENOTEMPTY') { if (primary) combineFailure(primary, error, 'lock stage cleanup failed'); throw error; } }
427
+ }
428
+ }, true, 'lock');
429
+ const retire = (): boolean => pinnedParent(canonical, name => {
430
+ try {
431
+ let stat: fs.Stats; try { stat = fs.lstatSync(name); } catch (error) { return isMissing(error); }
432
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error('malformed private lock publication');
433
+ const parent = identity(fs.statSync('.')); const generation = identity(stat); try { process.chdir(name); } catch (error) { if (isMissing(error)) return true; throw error; }
434
+ if (!sameIdentity(generation, identity(fs.statSync('.')))) { process.chdir('..'); return true; }
435
+ const state = ownerStateHere(); process.chdir('..'); if (!sameIdentity(parent, identity(fs.statSync('.')))) return true;
436
+ if (state.kind === 'malformed') throw new Error('malformed private lock publication');
437
+ if (state.kind === 'missing') { hook('lock', 'beforeCanonicalRmdir', canonical, name); options.beforeCanonicalRmdir?.(); try { const final = fs.lstatSync(name); if (!sameIdentity(generation, identity(final)) || !final.isDirectory()) return true; syscall('lock-canonical-rmdir', () => fs.rmdirSync(name)); } catch (error) { if (!isMissing(error) && errno(error) !== 'ENOTEMPTY') throw error; } return true; }
438
+ const owner = state.owner; if (now() < BigInt(owner.leaseDeadlineNs)) return false;
439
+ const observed = provider.read(owner.pid); if (observed.status === 'unknown' || (observed.status === 'found' && sameBirth(observed.identity, owner.birth))) return false;
440
+ try { process.chdir(name); } catch (error) { if (isMissing(error)) return true; throw error; }
441
+ if (!sameIdentity(generation, identity(fs.statSync('.')))) { process.chdir('..'); return true; }
442
+ const current = ownerStateHere(); if (current.kind !== 'valid' || !sameOwner(current.owner, owner)) { process.chdir('..'); return true; }
443
+ hook('lock', 'afterCanonicalOwnerValidation', path.resolve(process.cwd(), 'owner'), 'owner'); options.afterOwnerValidated?.(); try { syscall('artifact-cleanup-unlink', () => fs.unlinkSync('owner')); } catch (error) { if (ownerStateHere().kind !== 'missing') throw error; } hook('lock', 'afterOwnerRemoval', path.resolve(process.cwd(), 'owner'), 'owner'); options.afterOwnerRemoved?.(); process.chdir('..'); hook('lock', 'beforeCanonicalRmdir', canonical, name); options.beforeCanonicalRmdir?.();
444
+ try { const final = fs.lstatSync(name); if (!sameIdentity(generation, identity(final)) || !final.isDirectory()) return true; syscall('lock-canonical-rmdir', () => fs.rmdirSync(name)); } catch (error) { if (!isMissing(error) && errno(error) !== 'ENOTEMPTY') throw error; } return true;
445
+ } catch (error) { if (isMissing(error)) return true; throw error; }
446
+ }, true, 'lock');
447
+ let immediate = true;
448
+ for (;;) {
449
+ if (!immediate && now() >= deadline) throw new Error(`private lock acquisition timed out: ${canonical}`);
450
+ immediate = false;
451
+ const published = publish();
452
+ if (published) {
453
+ const owner = published.owner;
454
+ let released = false; let ownerRemoved = false; const acquiredGeneration = published.generation;
455
+ return { token: owner.token, ownerBirth: self.identity, release() {
456
+ if (released) return;
457
+ pinnedParent(canonical, name => {
458
+ let stat: fs.Stats;
459
+ try { stat = fs.lstatSync(name); } catch (error) { if (isMissing(error)) { released = true; return; } throw error; }
460
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return;
461
+ const generation = identity(stat);
462
+ if (!sameIdentity(acquiredGeneration, generation)) return;
463
+ const parent = identity(fs.statSync('.'));
464
+ if (!ownerRemoved) {
465
+ try { process.chdir(name); } catch (error) { if (isMissing(error)) { released = true; return; } throw error; }
466
+ if (!sameIdentity(generation, identity(fs.statSync('.')))) { process.chdir('..'); return; }
467
+ const current = ownerStateHere();
468
+ if (current.kind !== 'valid' || !sameOwner(current.owner, owner)) { process.chdir('..'); return; }
469
+ hook('lock', 'afterCanonicalOwnerValidation', path.resolve(process.cwd(), 'owner'), 'owner'); options.afterOwnerValidated?.(); try { syscall('artifact-cleanup-unlink', () => fs.unlinkSync('owner')); ownerRemoved = true; } catch (error) { if (ownerStateHere().kind === 'missing') ownerRemoved = true; throw error; } hook('lock', 'afterOwnerRemoval', path.resolve(process.cwd(), 'owner'), 'owner'); options.afterOwnerRemoved?.(); process.chdir('..');
470
+ }
471
+ if (!sameIdentity(parent, identity(fs.statSync('.')))) throw new Error('lock parent changed before release');
472
+ hook('lock', 'beforeCanonicalRmdir', canonical, name); options.beforeCanonicalRmdir?.();
473
+ try { const final = fs.lstatSync(name); if (!sameIdentity(acquiredGeneration, identity(final)) || !final.isDirectory()) return; syscall('lock-canonical-rmdir', () => fs.rmdirSync(name)); released = true; } catch (error) { if (isMissing(error)) { released = true; return; } if (errno(error) !== 'ENOTEMPTY') throw error; }
474
+ }, true, 'lock');
475
+ } };
476
+ }
477
+ retire();
478
+ if (now() >= deadline) throw new Error(`private lock acquisition timed out: ${canonical}`);
479
+ await sleep(Math.max(0, Math.min(10, Number((deadline - now()) / 1_000_000n))));
480
+ }
229
481
  }