@hraness/slopcamera 3.10.0 → 3.10.2
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.
- package/PRIVACY.md +15 -0
- package/README.md +2 -2
- package/apps/desktop/application/operation.ts +1 -1
- package/apps/desktop/cli/commands.ts +1 -1
- package/apps/desktop/cli/html-overlay-kqueue.ts +184 -0
- package/apps/desktop/cli/html-overlay-renderer.ts +142 -81
- package/apps/desktop/cli/html-overlay-watcher-baseline.ts +65 -0
- package/apps/desktop/dist/cli/main.js +260 -260
- package/dist/cli.js +1 -1
- package/dist/{index-0cby5jc7.js → index-6a3k1hvj.js} +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/skills/slopcamera/references/install.md +5 -2
- package/src/version.ts +1 -1
package/PRIVACY.md
CHANGED
|
@@ -53,3 +53,18 @@ from vulnerability reports.
|
|
|
53
53
|
|
|
54
54
|
Report suspected vulnerabilities through GitHub private vulnerability
|
|
55
55
|
reporting at https://github.com/hraness/slopcamera/security/advisories/new.
|
|
56
|
+
|
|
57
|
+
## Website analytics
|
|
58
|
+
|
|
59
|
+
The public slopcamera.com website sends cookieless page views, page exits,
|
|
60
|
+
performance measurements, explicit link and install-copy actions, missing-page
|
|
61
|
+
reports, and rate-limited redacted client errors to PostHog. It keeps no person
|
|
62
|
+
profiles, analytics cookies, or session recordings and respects Do Not Track.
|
|
63
|
+
Public campaign parameters are retained; other query values and credentials
|
|
64
|
+
are removed. Sensitive paths collapse to `/private` and their campaign
|
|
65
|
+
attribution is removed. Preview builds and the inert preview page send nothing.
|
|
66
|
+
|
|
67
|
+
The site checks the shared regional analytics policy service. Analytics
|
|
68
|
+
waits for acceptance where that policy requires consent, or when the region is
|
|
69
|
+
unknown. Elsewhere it can run without cookies before a visitor responds.
|
|
70
|
+
A stored refusal or Do Not Track keeps analytics off.
|
package/README.md
CHANGED
|
@@ -34,11 +34,11 @@ for prompt-only hosted images; model usage is billed separately.
|
|
|
34
34
|
## Install SlopCamera
|
|
35
35
|
|
|
36
36
|
Install [Bun 1.3.14 or newer](https://bun.sh), then install the
|
|
37
|
-
[SlopCamera v3.
|
|
37
|
+
[SlopCamera v3.10.1 release](https://github.com/hraness/slopcamera/releases/tag/v3.10.1)
|
|
38
38
|
and its matching Agent Skill:
|
|
39
39
|
|
|
40
40
|
```sh
|
|
41
|
-
bun add --global https://github.com/hraness/slopcamera/releases/download/v3.
|
|
41
|
+
bun add --global https://github.com/hraness/slopcamera/releases/download/v3.10.1/hraness-slopcamera-3.10.1.tgz
|
|
42
42
|
slopcamera skill install --target agents
|
|
43
43
|
```
|
|
44
44
|
|
|
@@ -4,7 +4,7 @@ import type { z } from "zod";
|
|
|
4
4
|
|
|
5
5
|
import type { ApplicationContext } from "./context";
|
|
6
6
|
|
|
7
|
-
export const SLOPCAMERA_APPLICATION_TOOL_VERSION = "slopcamera-3.10.
|
|
7
|
+
export const SLOPCAMERA_APPLICATION_TOOL_VERSION = "slopcamera-3.10.2" as const;
|
|
8
8
|
|
|
9
9
|
export const OPERATION_KINDS = [
|
|
10
10
|
"scene.audit",
|
|
@@ -365,7 +365,7 @@ import {
|
|
|
365
365
|
workflowRunStore,
|
|
366
366
|
} from "./workflow-runs";
|
|
367
367
|
|
|
368
|
-
export const SLOPCAMERA_VERSION = "3.10.
|
|
368
|
+
export const SLOPCAMERA_VERSION = "3.10.2";
|
|
369
369
|
|
|
370
370
|
// Legacy direct renders predate per-target output contracts. Keep them
|
|
371
371
|
// bounded generously enough for long-form production while preventing one
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { fstatSync, lstatSync, type BigIntStats } from "node:fs";
|
|
2
|
+
import { endianness } from "node:os";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { ApplicationError } from "../application/errors";
|
|
5
|
+
|
|
6
|
+
export interface BrowserWatchIdentity {
|
|
7
|
+
readonly path: string;
|
|
8
|
+
readonly dev: string;
|
|
9
|
+
readonly ino: string;
|
|
10
|
+
readonly mode: number;
|
|
11
|
+
readonly size?: string;
|
|
12
|
+
readonly ctimeNs?: string;
|
|
13
|
+
readonly mtimeNs?: string;
|
|
14
|
+
readonly uid?: string;
|
|
15
|
+
readonly gid?: string;
|
|
16
|
+
readonly nlink?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const metadataKeys = ["size", "ctimeNs", "mtimeNs", "uid", "gid", "nlink"] as const;
|
|
20
|
+
function matchesIdentity(details: BigIntStats, identity: BrowserWatchIdentity): boolean {
|
|
21
|
+
return details.dev.toString() === identity.dev && details.ino.toString() === identity.ino
|
|
22
|
+
&& Number(details.mode & 0o177777n) === identity.mode
|
|
23
|
+
&& metadataKeys.every(key => identity[key] === undefined || details[key].toString() === identity[key]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface WatchTarget {
|
|
27
|
+
readonly absolute: string;
|
|
28
|
+
readonly identity: BrowserWatchIdentity;
|
|
29
|
+
readonly label: string;
|
|
30
|
+
readonly kind: "anchor" | "container" | "payload" | "runtime-root";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Kernel vnode notes persist until read, including writes whose bytes were restored. */
|
|
34
|
+
export async function createBrowserRuntimeKqueue(options: {
|
|
35
|
+
readonly anchor: string;
|
|
36
|
+
readonly anchorIdentity: BrowserWatchIdentity;
|
|
37
|
+
readonly container: string;
|
|
38
|
+
readonly containerIdentity: BrowserWatchIdentity;
|
|
39
|
+
readonly runtimeRoot: string;
|
|
40
|
+
readonly identities: readonly BrowserWatchIdentity[];
|
|
41
|
+
}): Promise<{ poll: (mutations: Set<string>) => void; close: () => void }> {
|
|
42
|
+
if (process.platform !== "darwin") throw new ApplicationError("unavailable", "Browser vnode watches require macOS.");
|
|
43
|
+
if (!["arm64", "x64"].includes(process.arch) || endianness() !== "LE") throw new ApplicationError("unavailable", "Unsupported browser vnode watch ABI.");
|
|
44
|
+
const { dlopen, FFIType: T, ptr } = await import("bun:ffi");
|
|
45
|
+
const native = dlopen("/usr/lib/libSystem.B.dylib", {
|
|
46
|
+
kqueue: { args: [], returns: T.i32 },
|
|
47
|
+
kevent64: { args: [T.i32, T.ptr, T.i32, T.ptr, T.i32, T.u32, T.ptr], returns: T.i32 },
|
|
48
|
+
open: { args: [T.ptr, T.i32], returns: T.i32 },
|
|
49
|
+
close: { args: [T.i32], returns: T.i32 },
|
|
50
|
+
fcntl: { args: [T.i32, T.i32, T.i32], returns: T.i32 },
|
|
51
|
+
getrlimit: { args: [T.i32, T.ptr], returns: T.i32 },
|
|
52
|
+
fgetattrlist: { args: [T.i32, T.ptr, T.ptr, T.u64, T.u32], returns: T.i32 },
|
|
53
|
+
});
|
|
54
|
+
const targets: WatchTarget[] = [
|
|
55
|
+
{ absolute: options.anchor, identity: options.anchorIdentity, label: "<snapshot-anchor>", kind: "anchor" },
|
|
56
|
+
{ absolute: options.container, identity: options.containerIdentity, label: "<snapshot-container>", kind: "container" },
|
|
57
|
+
...options.identities.map(identity => ({ absolute: identity.path === "." ? options.runtimeRoot : join(options.runtimeRoot, identity.path), identity, label: identity.path, kind: identity.path === "." ? "runtime-root" as const : "payload" as const })),
|
|
58
|
+
];
|
|
59
|
+
let queue = -1;
|
|
60
|
+
let closed = false;
|
|
61
|
+
let closeFailure: unknown;
|
|
62
|
+
let failure: unknown;
|
|
63
|
+
const descriptors = new Map<number, WatchTarget>();
|
|
64
|
+
const payloadAccess = new Map<number, { atimeNs: bigint; readonly flags: number }>();
|
|
65
|
+
const immutableFlags = (descriptor: number): number => {
|
|
66
|
+
// Darwin attrlist: five attribute groups, requesting only ATTR_CMN_FLAGS.
|
|
67
|
+
const request = Buffer.alloc(24), result = Buffer.alloc(8);
|
|
68
|
+
request.writeUInt16LE(5, 0);
|
|
69
|
+
request.writeUInt32LE(0x00040000, 4);
|
|
70
|
+
if (native.symbols.fgetattrlist(descriptor, ptr(request), ptr(result), 8n, 0) !== 0
|
|
71
|
+
|| result.readUInt32LE(0) !== 8) {
|
|
72
|
+
throw new ApplicationError("unavailable", "Browser vnode immutable flags could not be read completely.");
|
|
73
|
+
}
|
|
74
|
+
return result.readUInt32LE(4);
|
|
75
|
+
};
|
|
76
|
+
const close = () => {
|
|
77
|
+
if (closed) { if (closeFailure !== undefined) throw closeFailure; return; }
|
|
78
|
+
// A failed close may already have released its numeric descriptor. Never
|
|
79
|
+
// retry it: the number could belong to a different resource afterwards.
|
|
80
|
+
closed = true;
|
|
81
|
+
let failed = false;
|
|
82
|
+
for (const descriptor of descriptors.keys()) {
|
|
83
|
+
if (native.symbols.close(descriptor) === 0) descriptors.delete(descriptor);
|
|
84
|
+
else failed = true;
|
|
85
|
+
}
|
|
86
|
+
if (queue !== -1) {
|
|
87
|
+
if (native.symbols.close(queue) === 0) queue = -1;
|
|
88
|
+
else failed = true;
|
|
89
|
+
}
|
|
90
|
+
if (failed) {
|
|
91
|
+
closeFailure = new ApplicationError("unavailable", "Browser vnode descriptor collection is unproven.", { unresolvedDescriptors: descriptors.size + (queue === -1 ? 0 : 1) });
|
|
92
|
+
throw closeFailure;
|
|
93
|
+
}
|
|
94
|
+
native.close();
|
|
95
|
+
};
|
|
96
|
+
const reject = (message: string): never => { throw new ApplicationError("conflict", message); };
|
|
97
|
+
try {
|
|
98
|
+
const limits = new BigUint64Array(2);
|
|
99
|
+
// RLIMIT_NOFILE; never modify process or host resource limits.
|
|
100
|
+
if (targets.length > 8192 || native.symbols.getrlimit(8, ptr(limits)) !== 0 || limits[0]! < BigInt(targets.length + 64)) {
|
|
101
|
+
reject("Insufficient bounded descriptor capacity for complete browser vnode watches.");
|
|
102
|
+
}
|
|
103
|
+
queue = native.symbols.kqueue();
|
|
104
|
+
if (queue < 0) reject("Could not create browser vnode event queue.");
|
|
105
|
+
if (native.symbols.fcntl(queue, 2, 1) !== 0) reject("Could not prevent browser vnode queue inheritance.");
|
|
106
|
+
for (const target of targets) {
|
|
107
|
+
const path = Buffer.from(`${target.absolute}\0`);
|
|
108
|
+
// O_EVTONLY | O_SYMLINK | O_CLOEXEC. Symlinks bind their own vnode.
|
|
109
|
+
const descriptor = native.symbols.open(ptr(path), 0x8000 | 0x200000 | 0x1000000);
|
|
110
|
+
if (descriptor < 0) reject(`Could not open browser vnode watch: ${target.label}`);
|
|
111
|
+
descriptors.set(descriptor, target);
|
|
112
|
+
const details = fstatSync(descriptor, { bigint: true });
|
|
113
|
+
const identity = target.identity;
|
|
114
|
+
if (!matchesIdentity(details, identity)) {
|
|
115
|
+
reject(`Browser vnode identity changed before registration: ${target.label}`);
|
|
116
|
+
}
|
|
117
|
+
if (target.kind === "payload" && details.isFile()) {
|
|
118
|
+
payloadAccess.set(descriptor, { atimeNs: details.atimeNs, flags: immutableFlags(descriptor) });
|
|
119
|
+
}
|
|
120
|
+
const change = Buffer.alloc(48);
|
|
121
|
+
const receipt = Buffer.alloc(48);
|
|
122
|
+
change.writeBigUInt64LE(BigInt(descriptor), 0);
|
|
123
|
+
change.writeInt16LE(-4, 8); // EVFILT_VNODE
|
|
124
|
+
change.writeUInt16LE(1 | 0x20 | 0x40, 10); // EV_ADD | EV_CLEAR | EV_RECEIPT
|
|
125
|
+
// Unrelated children of /private/tmp are not this snapshot. Its own
|
|
126
|
+
// replacement/revocation/attribute changes remain observed; container
|
|
127
|
+
// and all payload directories independently retain child-write notes.
|
|
128
|
+
change.writeUInt32LE(target.kind === "anchor" ? 0x69 : 0x7f, 12);
|
|
129
|
+
const count = native.symbols.kevent64(queue, ptr(change), 1, ptr(receipt), 1, 1, null);
|
|
130
|
+
if (count !== 1 || receipt.readBigUInt64LE(0) !== BigInt(descriptor)
|
|
131
|
+
|| receipt.readInt16LE(8) !== -4 || !(receipt.readUInt16LE(10) & 0x4000)
|
|
132
|
+
|| receipt.readBigInt64LE(16) !== 0n) reject(`Browser vnode registration failed: ${target.label}`);
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
close,
|
|
136
|
+
poll(mutations) {
|
|
137
|
+
if (failure !== undefined) throw failure;
|
|
138
|
+
if (closed) reject("Browser vnode event queue was closed before verification.");
|
|
139
|
+
try {
|
|
140
|
+
// One pending entry per registered vnode; aggregate flags are sticky
|
|
141
|
+
// in the caller before the next read can clear kernel state.
|
|
142
|
+
const output = Buffer.alloc(48 * targets.length);
|
|
143
|
+
const count = native.symbols.kevent64(queue, null, 0, ptr(output), targets.length, 1, null);
|
|
144
|
+
if (count < 0 || count > targets.length) reject("Browser vnode event queue could not be read completely.");
|
|
145
|
+
for (let index = 0; index < count; index += 1) {
|
|
146
|
+
const offset = index * 48;
|
|
147
|
+
const descriptor = Number(output.readBigUInt64LE(offset));
|
|
148
|
+
const target = descriptors.get(descriptor);
|
|
149
|
+
const flags = output.readUInt16LE(offset + 10);
|
|
150
|
+
const notes = output.readUInt32LE(offset + 12);
|
|
151
|
+
if (target === undefined) throw new ApplicationError("conflict", "Browser vnode event has an unknown descriptor.");
|
|
152
|
+
if (output.readInt16LE(offset + 8) !== -4 || flags & (0x4000 | 0x8000) || notes === 0 || notes & ~0x7f) {
|
|
153
|
+
reject("Browser vnode event queue returned an untrusted event.");
|
|
154
|
+
}
|
|
155
|
+
// Preserve the existing launch-managed app-root ATTRIB exception.
|
|
156
|
+
// No WRITE/RENAME/DELETE/LINK combination becomes a metadata event.
|
|
157
|
+
if (target.kind === "runtime-root" && notes === 8) {
|
|
158
|
+
mutations.add(`change:${basename(options.runtimeRoot)}`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const access = payloadAccess.get(descriptor);
|
|
162
|
+
if (target.kind === "payload" && notes === 8 && access !== undefined
|
|
163
|
+
&& (access.flags & 2) !== 0 && metadataKeys.every(key => target.identity[key] !== undefined)) {
|
|
164
|
+
const details = fstatSync(descriptor, { bigint: true });
|
|
165
|
+
const pathDetails = lstatSync(target.absolute, { bigint: true });
|
|
166
|
+
// mmap can emit pure ATTRIB for access-time updates on immutable
|
|
167
|
+
// files. Original ctime and all status identity remain fixed:
|
|
168
|
+
// restored flags, xattrs, permissions, and times still reject.
|
|
169
|
+
// The renderer also retains its full content-manifest checks.
|
|
170
|
+
if (details.isFile() && pathDetails.isFile()
|
|
171
|
+
&& matchesIdentity(details, target.identity) && matchesIdentity(pathDetails, target.identity)
|
|
172
|
+
&& details.atimeNs > access.atimeNs && pathDetails.atimeNs >= details.atimeNs
|
|
173
|
+
&& immutableFlags(descriptor) === access.flags) {
|
|
174
|
+
access.atimeNs = pathDetails.atimeNs;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
mutations.add(`vnode-${String(notes)}:${target.label}`);
|
|
179
|
+
}
|
|
180
|
+
} catch (error) { failure = error; throw error; }
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
} catch (error) { close(); throw error; }
|
|
184
|
+
}
|
|
@@ -23,6 +23,9 @@ import { basename, dirname, join } from "node:path";
|
|
|
23
23
|
|
|
24
24
|
import { z } from "zod";
|
|
25
25
|
|
|
26
|
+
import { verifyStableBrowserWatcherBaseline } from "./html-overlay-watcher-baseline";
|
|
27
|
+
import { createBrowserRuntimeKqueue } from "./html-overlay-kqueue";
|
|
28
|
+
|
|
26
29
|
import {
|
|
27
30
|
chromium,
|
|
28
31
|
type Browser,
|
|
@@ -389,10 +392,14 @@ interface SnapshotAnchorIdentity {
|
|
|
389
392
|
interface SnapshotPathIdentity {
|
|
390
393
|
readonly ctimeNs: string;
|
|
391
394
|
readonly dev: string;
|
|
395
|
+
readonly gid: string;
|
|
392
396
|
readonly ino: string;
|
|
393
397
|
readonly mode: number;
|
|
398
|
+
readonly mtimeNs: string;
|
|
399
|
+
readonly nlink: string;
|
|
394
400
|
readonly path: string;
|
|
395
401
|
readonly size: string;
|
|
402
|
+
readonly uid: string;
|
|
396
403
|
}
|
|
397
404
|
|
|
398
405
|
interface SnapshotDirectChildIdentity {
|
|
@@ -543,10 +550,14 @@ async function captureBrowserRuntimeSnapshotIdentity(
|
|
|
543
550
|
identities.push({
|
|
544
551
|
ctimeNs: details.ctimeNs.toString(),
|
|
545
552
|
dev: details.dev.toString(),
|
|
553
|
+
gid: details.gid.toString(),
|
|
546
554
|
ino: details.ino.toString(),
|
|
547
555
|
mode: Number(details.mode & 0o177777n),
|
|
556
|
+
mtimeNs: details.mtimeNs.toString(),
|
|
557
|
+
nlink: details.nlink.toString(),
|
|
548
558
|
path: entry.path,
|
|
549
559
|
size: details.size.toString(),
|
|
560
|
+
uid: details.uid.toString(),
|
|
550
561
|
});
|
|
551
562
|
}
|
|
552
563
|
return identities;
|
|
@@ -567,10 +578,14 @@ async function captureBrowserRuntimeSnapshotContainerIdentity(
|
|
|
567
578
|
return {
|
|
568
579
|
ctimeNs: details.ctimeNs.toString(),
|
|
569
580
|
dev: details.dev.toString(),
|
|
581
|
+
gid: details.gid.toString(),
|
|
570
582
|
ino: details.ino.toString(),
|
|
571
583
|
mode: Number(details.mode & 0o177777n),
|
|
584
|
+
mtimeNs: details.mtimeNs.toString(),
|
|
585
|
+
nlink: details.nlink.toString(),
|
|
572
586
|
path: "<snapshot-container>",
|
|
573
587
|
size: details.size.toString(),
|
|
588
|
+
uid: details.uid.toString(),
|
|
574
589
|
};
|
|
575
590
|
}
|
|
576
591
|
|
|
@@ -1456,8 +1471,10 @@ export async function removeBrowserRuntimeSnapshot(
|
|
|
1456
1471
|
async function assertNoBrowserRuntimeMutationEvents(
|
|
1457
1472
|
mutations: ReadonlySet<string>,
|
|
1458
1473
|
label: string,
|
|
1474
|
+
poll?: () => void,
|
|
1459
1475
|
): Promise<void> {
|
|
1460
1476
|
await new Promise<void>(resolve => setImmediate(resolve));
|
|
1477
|
+
poll?.();
|
|
1461
1478
|
if (mutations.size > 0) {
|
|
1462
1479
|
throw new ApplicationError(
|
|
1463
1480
|
"conflict",
|
|
@@ -2009,6 +2026,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
|
|
|
2009
2026
|
let browserRuntimeSnapshot: PreparedBrowserRuntimeSnapshot | undefined;
|
|
2010
2027
|
const browserRuntimeWatchers: FSWatcher[] = [];
|
|
2011
2028
|
const browserRuntimeMutations = new Set<string>();
|
|
2029
|
+
let kernelWatcher: Awaited<ReturnType<typeof createBrowserRuntimeKqueue>> | undefined;
|
|
2012
2030
|
let renderFailure: Readonly<{ error: unknown }> | undefined;
|
|
2013
2031
|
let renderResult: HtmlOverlayFrameRenderResult | undefined;
|
|
2014
2032
|
let gpuEvidence: HtmlOverlayGpuEvidence | undefined;
|
|
@@ -2020,94 +2038,126 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
|
|
|
2020
2038
|
signal,
|
|
2021
2039
|
);
|
|
2022
2040
|
browserRuntimeSnapshot = preparedBrowserRuntime;
|
|
2041
|
+
let watcherVersion = 0;
|
|
2042
|
+
let watcherFailure: Error | undefined;
|
|
2023
2043
|
const runtimeName = basename(preparedBrowserRuntime.runtimeRoot);
|
|
2024
2044
|
const snapshotName = basename(preparedBrowserRuntime.directory);
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
}
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2045
|
+
if (process.platform === "darwin") {
|
|
2046
|
+
kernelWatcher = await createBrowserRuntimeKqueue({
|
|
2047
|
+
anchor: preparedBrowserRuntime.anchorPath,
|
|
2048
|
+
anchorIdentity: preparedBrowserRuntime.anchorIdentity,
|
|
2049
|
+
container: preparedBrowserRuntime.directory,
|
|
2050
|
+
containerIdentity: preparedBrowserRuntime.containerIdentity,
|
|
2051
|
+
runtimeRoot: preparedBrowserRuntime.runtimeRoot,
|
|
2052
|
+
identities: preparedBrowserRuntime.identity,
|
|
2053
|
+
});
|
|
2054
|
+
} else {
|
|
2055
|
+
const anchorWatcher = watch(
|
|
2056
|
+
preparedBrowserRuntime.anchorPath,
|
|
2057
|
+
{ persistent: false },
|
|
2058
|
+
(eventType, filename) => {
|
|
2059
|
+
const path = filename?.toString() ?? "<snapshot-anchor>";
|
|
2060
|
+
if (path === snapshotName) {
|
|
2061
|
+
watcherVersion += 1;
|
|
2062
|
+
browserRuntimeMutations.add(`anchor-${eventType}:${path}`);
|
|
2063
|
+
}
|
|
2064
|
+
},
|
|
2065
|
+
);
|
|
2066
|
+
anchorWatcher.on("error", error => {
|
|
2067
|
+
watcherFailure = error;
|
|
2068
|
+
browserRuntimeMutations.add(`anchor-watch-error:${error.message}`);
|
|
2069
|
+
});
|
|
2070
|
+
browserRuntimeWatchers.push(anchorWatcher);
|
|
2071
|
+
const containerWatcher = watch(
|
|
2072
|
+
preparedBrowserRuntime.directory,
|
|
2073
|
+
{ persistent: false, recursive: false },
|
|
2074
|
+
(eventType, filename) => {
|
|
2075
|
+
const path = filename?.toString().replaceAll("\\", "/")
|
|
2076
|
+
?? "<snapshot-container>";
|
|
2077
|
+
if (
|
|
2078
|
+
path === "home"
|
|
2079
|
+
|| path.startsWith("home/")
|
|
2080
|
+
|| path === "tmp"
|
|
2081
|
+
|| path.startsWith("tmp/")
|
|
2082
|
+
) return;
|
|
2083
|
+
if (path === runtimeName && eventType === "change") {
|
|
2084
|
+
// Gatekeeper may change the app root's first-launch metadata. The
|
|
2085
|
+
// immutable parent and its exact ctime identity independently
|
|
2086
|
+
// prove that this pathname was never removed or replaced.
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
if (
|
|
2090
|
+
request.browserRuntime.provenance.kind === "test-only-unverified"
|
|
2091
|
+
&& request.browserRuntime.manifest.layout === "single-executable"
|
|
2092
|
+
&& path === runtimeName
|
|
2093
|
+
&& eventType === "rename"
|
|
2094
|
+
) {
|
|
2095
|
+
// Darwin reports synthetic rename events for accesses to the
|
|
2096
|
+
// test-only single-file fixture. Production rejects this layout;
|
|
2097
|
+
// its exact path identity still detects fixture substitution.
|
|
2098
|
+
return;
|
|
2099
|
+
}
|
|
2100
|
+
watcherVersion += 1;
|
|
2101
|
+
if (path === runtimeName || path.startsWith(`${runtimeName}/`)) {
|
|
2102
|
+
browserRuntimeMutations.add(`${eventType}:${path}`);
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2069
2105
|
browserRuntimeMutations.add(`${eventType}:${path}`);
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
}
|
|
2078
|
-
browserRuntimeWatchers.push(containerWatcher);
|
|
2079
|
-
await new Promise<void>(resolve => setTimeout(resolve, 25));
|
|
2080
|
-
if (signal.aborted) throw cancellationReason(signal);
|
|
2081
|
-
// FSEvents can deliver the just-created direct-child notification after
|
|
2082
|
-
// both watches attach. Clear that bounded baseline before re-verifying
|
|
2083
|
-
// every manifest and path identity. A swap during this window changes
|
|
2084
|
-
// the already-captured container ctime and therefore fails below.
|
|
2085
|
-
browserRuntimeMutations.clear();
|
|
2106
|
+
},
|
|
2107
|
+
);
|
|
2108
|
+
containerWatcher.on("error", error => {
|
|
2109
|
+
watcherFailure = error;
|
|
2110
|
+
browserRuntimeMutations.add(`watch-error:${error.message}`);
|
|
2111
|
+
});
|
|
2112
|
+
browserRuntimeWatchers.push(containerWatcher);
|
|
2113
|
+
}
|
|
2086
2114
|
let browser: Browser | undefined;
|
|
2087
2115
|
let browserFailure: Readonly<{ error: unknown }> | undefined;
|
|
2088
2116
|
try {
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
signal,
|
|
2094
|
-
);
|
|
2095
|
-
await assertBrowserRuntimeSnapshotIdentity(
|
|
2096
|
-
preparedBrowserRuntime,
|
|
2097
|
-
request.browserRuntime.manifest.entries,
|
|
2098
|
-
"immediately before launch",
|
|
2099
|
-
signal,
|
|
2100
|
-
);
|
|
2101
|
-
await assertBrowserRuntimeSnapshotContainerIdentity(
|
|
2102
|
-
preparedBrowserRuntime,
|
|
2103
|
-
"immediately before launch",
|
|
2104
|
-
signal,
|
|
2105
|
-
);
|
|
2106
|
-
await assertBrowserRuntimeSnapshotAnchorIdentity(
|
|
2107
|
-
preparedBrowserRuntime,
|
|
2108
|
-
"immediately before launch",
|
|
2117
|
+
// Delayed preparation notifications can arrive during full verification.
|
|
2118
|
+
// Recheck the SAME pre-wait identities until one pass is stable. Nothing
|
|
2119
|
+
// after the synchronous baseline commit is discarded.
|
|
2120
|
+
const baselineOptions = {
|
|
2109
2121
|
signal,
|
|
2110
|
-
|
|
2122
|
+
version: () => watcherVersion,
|
|
2123
|
+
failure: () => watcherFailure,
|
|
2124
|
+
quietMs: process.platform === "darwin" ? 2_000 : 25,
|
|
2125
|
+
deadlineMs: 10_000,
|
|
2126
|
+
verify: async () => {
|
|
2127
|
+
await verifyBrowserRuntimeSnapshot(
|
|
2128
|
+
preparedBrowserRuntime,
|
|
2129
|
+
request.browserRuntime,
|
|
2130
|
+
"immediately before launch",
|
|
2131
|
+
signal,
|
|
2132
|
+
);
|
|
2133
|
+
await assertBrowserRuntimeSnapshotIdentity(
|
|
2134
|
+
preparedBrowserRuntime,
|
|
2135
|
+
request.browserRuntime.manifest.entries,
|
|
2136
|
+
"immediately before launch",
|
|
2137
|
+
signal,
|
|
2138
|
+
);
|
|
2139
|
+
await assertBrowserRuntimeSnapshotContainerIdentity(
|
|
2140
|
+
preparedBrowserRuntime,
|
|
2141
|
+
"immediately before launch",
|
|
2142
|
+
signal,
|
|
2143
|
+
);
|
|
2144
|
+
await assertBrowserRuntimeSnapshotAnchorIdentity(
|
|
2145
|
+
preparedBrowserRuntime,
|
|
2146
|
+
"immediately before launch",
|
|
2147
|
+
signal,
|
|
2148
|
+
);
|
|
2149
|
+
},
|
|
2150
|
+
commit: () => { browserRuntimeMutations.clear(); },
|
|
2151
|
+
};
|
|
2152
|
+
if (kernelWatcher !== undefined) {
|
|
2153
|
+
// Vnode notes begin at registration, not an asynchronous history
|
|
2154
|
+
// stream. Never clear a payload event, including during baseline.
|
|
2155
|
+
await baselineOptions.verify();
|
|
2156
|
+
kernelWatcher.poll(browserRuntimeMutations);
|
|
2157
|
+
} else {
|
|
2158
|
+
await verifyStableBrowserWatcherBaseline(baselineOptions);
|
|
2159
|
+
}
|
|
2160
|
+
kernelWatcher?.poll(browserRuntimeMutations);
|
|
2111
2161
|
discardProvenLaunchManagedRootMetadataEvent(
|
|
2112
2162
|
browserRuntimeMutations,
|
|
2113
2163
|
runtimeName,
|
|
@@ -2115,6 +2165,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
|
|
|
2115
2165
|
await assertNoBrowserRuntimeMutationEvents(
|
|
2116
2166
|
browserRuntimeMutations,
|
|
2117
2167
|
"before browser launch",
|
|
2168
|
+
() => kernelWatcher?.poll(browserRuntimeMutations),
|
|
2118
2169
|
);
|
|
2119
2170
|
const launchedBrowser = await boundedBrowserStep(
|
|
2120
2171
|
async () => {
|
|
@@ -2174,6 +2225,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
|
|
|
2174
2225
|
"during browser launch",
|
|
2175
2226
|
signal,
|
|
2176
2227
|
);
|
|
2228
|
+
kernelWatcher?.poll(browserRuntimeMutations);
|
|
2177
2229
|
discardProvenLaunchManagedRootMetadataEvent(
|
|
2178
2230
|
browserRuntimeMutations,
|
|
2179
2231
|
runtimeName,
|
|
@@ -2181,6 +2233,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
|
|
|
2181
2233
|
await assertNoBrowserRuntimeMutationEvents(
|
|
2182
2234
|
browserRuntimeMutations,
|
|
2183
2235
|
"during browser launch",
|
|
2236
|
+
() => kernelWatcher?.poll(browserRuntimeMutations),
|
|
2184
2237
|
);
|
|
2185
2238
|
const contextContract = HTML_OVERLAY_RENDERER_CONTRACT.browserContext;
|
|
2186
2239
|
const context = await boundedBrowserStep(
|
|
@@ -2475,6 +2528,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
|
|
|
2475
2528
|
"through browser shutdown",
|
|
2476
2529
|
signal,
|
|
2477
2530
|
);
|
|
2531
|
+
kernelWatcher?.poll(browserRuntimeMutations);
|
|
2478
2532
|
discardProvenLaunchManagedRootMetadataEvent(
|
|
2479
2533
|
browserRuntimeMutations,
|
|
2480
2534
|
runtimeName,
|
|
@@ -2482,6 +2536,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
|
|
|
2482
2536
|
await assertNoBrowserRuntimeMutationEvents(
|
|
2483
2537
|
browserRuntimeMutations,
|
|
2484
2538
|
"through browser shutdown",
|
|
2539
|
+
() => kernelWatcher?.poll(browserRuntimeMutations),
|
|
2485
2540
|
);
|
|
2486
2541
|
renderResult = {
|
|
2487
2542
|
...(gpuEvidence === undefined ? {} : { gpuEvidence }),
|
|
@@ -2496,6 +2551,12 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
|
|
|
2496
2551
|
.catch(() => undefined);
|
|
2497
2552
|
} finally {
|
|
2498
2553
|
for (const watcher of browserRuntimeWatchers) watcher.close();
|
|
2554
|
+
try { kernelWatcher?.close(); } catch (error) {
|
|
2555
|
+
renderFailure = { error: new ApplicationError("unavailable", "Browser vnode watch cleanup failed; frames were not published.", {
|
|
2556
|
+
cause: error,
|
|
2557
|
+
...(renderFailure === undefined ? {} : { renderFailure: renderFailure.error }),
|
|
2558
|
+
}) };
|
|
2559
|
+
}
|
|
2499
2560
|
if (browserRuntimeSnapshot !== undefined) {
|
|
2500
2561
|
if (browserShutdownRequired) {
|
|
2501
2562
|
const retainedError = new ApplicationError("unavailable", "HTML overlay browser shutdown is unproven; its active runtime snapshot was retained and frames were not published.", {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
|
|
3
|
+
interface BaselineClock {
|
|
4
|
+
readonly now: () => number;
|
|
5
|
+
readonly wait: (milliseconds: number, signal: AbortSignal) => Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const clock: BaselineClock = {
|
|
9
|
+
now: () => performance.now(),
|
|
10
|
+
wait: async (milliseconds, signal) => { await delay(milliseconds, undefined, { signal }); },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Drain asynchronous preparation notifications before revalidating the saved identities. */
|
|
14
|
+
interface BrowserWatcherBaselineOptions {
|
|
15
|
+
readonly signal: AbortSignal;
|
|
16
|
+
readonly version: () => number;
|
|
17
|
+
readonly failure: () => Error | undefined;
|
|
18
|
+
readonly quietMs: number;
|
|
19
|
+
readonly deadlineMs: number;
|
|
20
|
+
readonly clock?: BaselineClock;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function settleBrowserWatcherBaseline(options: BrowserWatcherBaselineOptions): Promise<void> {
|
|
24
|
+
const timing = options.clock ?? clock;
|
|
25
|
+
const started = timing.now();
|
|
26
|
+
let lastActivity = started;
|
|
27
|
+
let observedVersion = options.version();
|
|
28
|
+
for (;;) {
|
|
29
|
+
options.signal.throwIfAborted();
|
|
30
|
+
const failure = options.failure();
|
|
31
|
+
if (failure !== undefined) throw failure;
|
|
32
|
+
const now = timing.now();
|
|
33
|
+
const version = options.version();
|
|
34
|
+
if (version !== observedVersion) {
|
|
35
|
+
observedVersion = version;
|
|
36
|
+
lastActivity = now;
|
|
37
|
+
}
|
|
38
|
+
if (now - started >= options.deadlineMs) {
|
|
39
|
+
throw new Error("Browser runtime filesystem notifications did not settle before launch.");
|
|
40
|
+
}
|
|
41
|
+
if (now - lastActivity >= options.quietMs) return;
|
|
42
|
+
await timing.wait(Math.min(50, options.quietMs - (now - lastActivity), options.deadlineMs - (now - started)), options.signal);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Every discarded notification batch must precede a stable, complete identity check. */
|
|
47
|
+
export async function verifyStableBrowserWatcherBaseline(options: BrowserWatcherBaselineOptions & {
|
|
48
|
+
readonly verify: () => Promise<void>;
|
|
49
|
+
readonly commit: () => void;
|
|
50
|
+
}): Promise<void> {
|
|
51
|
+
for (let pass = 0; pass < 3; pass += 1) {
|
|
52
|
+
await settleBrowserWatcherBaseline(options);
|
|
53
|
+
const version = options.version();
|
|
54
|
+
await options.verify();
|
|
55
|
+
options.signal.throwIfAborted();
|
|
56
|
+
const failure = options.failure();
|
|
57
|
+
if (failure !== undefined) throw failure;
|
|
58
|
+
if (options.version() === version) {
|
|
59
|
+
// No await may separate the stable-generation check from arming the guard.
|
|
60
|
+
options.commit();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
throw new Error("Browser runtime filesystem notifications changed during all three verification passes.");
|
|
65
|
+
}
|