@hraness/slopcamera 3.10.1 → 3.10.3
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 +7 -0
- package/README.md +2 -14
- package/apps/desktop/application/operation.ts +1 -1
- package/apps/desktop/cli/asset-ingest.ts +31 -7
- 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 +105 -58
- package/apps/desktop/dist/cli/main.js +259 -259
- package/apps/desktop/workflows/fragments.ts +5 -1
- package/dist/cli.js +1 -1
- package/dist/{index-33nxxj9a.js → index-xm1zqhzc.js} +1 -1
- package/dist/index.js +1 -1
- package/docs/README.md +8 -8
- package/package.json +2 -2
- package/skills/slopcamera/references/install.md +5 -2
- package/src/version.ts +1 -1
package/PRIVACY.md
CHANGED
|
@@ -54,6 +54,8 @@ from vulnerability reports.
|
|
|
54
54
|
Report suspected vulnerabilities through GitHub private vulnerability
|
|
55
55
|
reporting at https://github.com/hraness/slopcamera/security/advisories/new.
|
|
56
56
|
|
|
57
|
+
## Website analytics
|
|
58
|
+
|
|
57
59
|
The public slopcamera.com website sends cookieless page views, page exits,
|
|
58
60
|
performance measurements, explicit link and install-copy actions, missing-page
|
|
59
61
|
reports, and rate-limited redacted client errors to PostHog. It keeps no person
|
|
@@ -61,3 +63,8 @@ profiles, analytics cookies, or session recordings and respects Do Not Track.
|
|
|
61
63
|
Public campaign parameters are retained; other query values and credentials
|
|
62
64
|
are removed. Sensitive paths collapse to `/private` and their campaign
|
|
63
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
|
|
|
@@ -365,15 +365,3 @@ to support development and opens no browser. `support dismiss` opts out across
|
|
|
365
365
|
the local suite, `support snooze` pauses the notice for 30 days, and
|
|
366
366
|
`HRANESS_SUPPORT_AUDIENCE=off` disables ambient notices. Agents follow the
|
|
367
367
|
[support reference](skills/slopcamera/references/support.md).
|
|
368
|
-
|
|
369
|
-
## Former name
|
|
370
|
-
|
|
371
|
-
<details>
|
|
372
|
-
<summary>Historical Atet release evidence</summary>
|
|
373
|
-
|
|
374
|
-
SlopCamera was called Atet until September 2026.
|
|
375
|
-
[Atet v3.2.3](https://github.com/hraness/atet/releases/tag/v3.2.3) and its
|
|
376
|
-
[original archive](https://github.com/hraness/atet/releases/download/v3.2.3/hraness-atet-3.2.3.tgz)
|
|
377
|
-
still install Atet, not SlopCamera.
|
|
378
|
-
|
|
379
|
-
</details>
|
|
@@ -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.3" as const;
|
|
8
8
|
|
|
9
9
|
export const OPERATION_KINDS = [
|
|
10
10
|
"scene.audit",
|
|
@@ -93,6 +93,31 @@ function isWithin(root: string, candidate: string): boolean {
|
|
|
93
93
|
return pathRelative === "" || (!pathRelative.startsWith("..") && !isAbsolute(pathRelative));
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
function svgRootAttributes(contents: string): string | undefined {
|
|
97
|
+
let offset = 0;
|
|
98
|
+
const whitespace = /\s+/uy;
|
|
99
|
+
const skipWhitespace = (): void => {
|
|
100
|
+
whitespace.lastIndex = offset;
|
|
101
|
+
offset += whitespace.exec(contents)?.[0].length ?? 0;
|
|
102
|
+
};
|
|
103
|
+
skipWhitespace();
|
|
104
|
+
const declaration = /<\?xml[^>]*>\s*/iuy;
|
|
105
|
+
declaration.lastIndex = offset;
|
|
106
|
+
offset += declaration.exec(contents)?.[0].length ?? 0;
|
|
107
|
+
|
|
108
|
+
// Consume each comment once. A repeated lazy regex can reconsider every
|
|
109
|
+
// earlier comment terminator when the SVG root is missing.
|
|
110
|
+
while (contents.startsWith("<!--", offset)) {
|
|
111
|
+
const end = contents.indexOf("-->", offset + 4);
|
|
112
|
+
if (end === -1) return undefined;
|
|
113
|
+
offset = end + 3;
|
|
114
|
+
skipWhitespace();
|
|
115
|
+
}
|
|
116
|
+
const root = /<svg(?=[\s>])([^>]*)>/iuy;
|
|
117
|
+
root.lastIndex = offset;
|
|
118
|
+
return root.exec(contents)?.[1];
|
|
119
|
+
}
|
|
120
|
+
|
|
96
121
|
function validateSvg(bytes: Uint8Array): boolean {
|
|
97
122
|
if (bytes.length > MAX_SVG_BYTES) return false;
|
|
98
123
|
let contents: string;
|
|
@@ -101,8 +126,7 @@ function validateSvg(bytes: Uint8Array): boolean {
|
|
|
101
126
|
} catch {
|
|
102
127
|
return false;
|
|
103
128
|
}
|
|
104
|
-
|
|
105
|
-
if (!/^(?:<\?xml[^>]*>\s*)?(?:<!--[\s\S]*?-->\s*)*<svg(?:\s|>)/iu.test(start)) return false;
|
|
129
|
+
if (svgRootAttributes(contents) === undefined) return false;
|
|
106
130
|
if (
|
|
107
131
|
/<!DOCTYPE|<!ENTITY|<\?xml-stylesheet|<\s*(?:script|foreignObject|iframe|object|embed)\b/iu.test(contents)
|
|
108
132
|
|| /\s+on[a-z][a-z0-9_-]*\s*=/iu.test(contents)
|
|
@@ -227,13 +251,13 @@ export function parseSvgIntrinsicSize(bytes: Uint8Array): OverlayIntrinsicSize {
|
|
|
227
251
|
throw new CliError("invalid-data", "SVG overlay is not valid UTF-8.");
|
|
228
252
|
}
|
|
229
253
|
if (!validateSvg(bytes)) throw new CliError("invalid-data", "SVG overlay failed the safe SVG profile.");
|
|
230
|
-
const
|
|
231
|
-
if (
|
|
254
|
+
const attributes = svgRootAttributes(contents);
|
|
255
|
+
if (attributes === undefined) {
|
|
232
256
|
throw new CliError("invalid-data", "SVG overlay omits its root element.");
|
|
233
257
|
}
|
|
234
|
-
const width = svgLength(svgAttribute(
|
|
235
|
-
const height = svgLength(svgAttribute(
|
|
236
|
-
const viewBox = svgAttribute(
|
|
258
|
+
const width = svgLength(svgAttribute(attributes, "width"));
|
|
259
|
+
const height = svgLength(svgAttribute(attributes, "height"));
|
|
260
|
+
const viewBox = svgAttribute(attributes, "viewBox")
|
|
237
261
|
?.split(/[\s,]+/u)
|
|
238
262
|
.filter(Boolean)
|
|
239
263
|
.map(Number);
|
|
@@ -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.3";
|
|
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
|
+
}
|
|
@@ -24,6 +24,7 @@ import { basename, dirname, join } from "node:path";
|
|
|
24
24
|
import { z } from "zod";
|
|
25
25
|
|
|
26
26
|
import { verifyStableBrowserWatcherBaseline } from "./html-overlay-watcher-baseline";
|
|
27
|
+
import { createBrowserRuntimeKqueue } from "./html-overlay-kqueue";
|
|
27
28
|
|
|
28
29
|
import {
|
|
29
30
|
chromium,
|
|
@@ -391,10 +392,14 @@ interface SnapshotAnchorIdentity {
|
|
|
391
392
|
interface SnapshotPathIdentity {
|
|
392
393
|
readonly ctimeNs: string;
|
|
393
394
|
readonly dev: string;
|
|
395
|
+
readonly gid: string;
|
|
394
396
|
readonly ino: string;
|
|
395
397
|
readonly mode: number;
|
|
398
|
+
readonly mtimeNs: string;
|
|
399
|
+
readonly nlink: string;
|
|
396
400
|
readonly path: string;
|
|
397
401
|
readonly size: string;
|
|
402
|
+
readonly uid: string;
|
|
398
403
|
}
|
|
399
404
|
|
|
400
405
|
interface SnapshotDirectChildIdentity {
|
|
@@ -545,10 +550,14 @@ async function captureBrowserRuntimeSnapshotIdentity(
|
|
|
545
550
|
identities.push({
|
|
546
551
|
ctimeNs: details.ctimeNs.toString(),
|
|
547
552
|
dev: details.dev.toString(),
|
|
553
|
+
gid: details.gid.toString(),
|
|
548
554
|
ino: details.ino.toString(),
|
|
549
555
|
mode: Number(details.mode & 0o177777n),
|
|
556
|
+
mtimeNs: details.mtimeNs.toString(),
|
|
557
|
+
nlink: details.nlink.toString(),
|
|
550
558
|
path: entry.path,
|
|
551
559
|
size: details.size.toString(),
|
|
560
|
+
uid: details.uid.toString(),
|
|
552
561
|
});
|
|
553
562
|
}
|
|
554
563
|
return identities;
|
|
@@ -569,10 +578,14 @@ async function captureBrowserRuntimeSnapshotContainerIdentity(
|
|
|
569
578
|
return {
|
|
570
579
|
ctimeNs: details.ctimeNs.toString(),
|
|
571
580
|
dev: details.dev.toString(),
|
|
581
|
+
gid: details.gid.toString(),
|
|
572
582
|
ino: details.ino.toString(),
|
|
573
583
|
mode: Number(details.mode & 0o177777n),
|
|
584
|
+
mtimeNs: details.mtimeNs.toString(),
|
|
585
|
+
nlink: details.nlink.toString(),
|
|
574
586
|
path: "<snapshot-container>",
|
|
575
587
|
size: details.size.toString(),
|
|
588
|
+
uid: details.uid.toString(),
|
|
576
589
|
};
|
|
577
590
|
}
|
|
578
591
|
|
|
@@ -1458,8 +1471,10 @@ export async function removeBrowserRuntimeSnapshot(
|
|
|
1458
1471
|
async function assertNoBrowserRuntimeMutationEvents(
|
|
1459
1472
|
mutations: ReadonlySet<string>,
|
|
1460
1473
|
label: string,
|
|
1474
|
+
poll?: () => void,
|
|
1461
1475
|
): Promise<void> {
|
|
1462
1476
|
await new Promise<void>(resolve => setImmediate(resolve));
|
|
1477
|
+
poll?.();
|
|
1463
1478
|
if (mutations.size > 0) {
|
|
1464
1479
|
throw new ApplicationError(
|
|
1465
1480
|
"conflict",
|
|
@@ -2011,6 +2026,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
|
|
|
2011
2026
|
let browserRuntimeSnapshot: PreparedBrowserRuntimeSnapshot | undefined;
|
|
2012
2027
|
const browserRuntimeWatchers: FSWatcher[] = [];
|
|
2013
2028
|
const browserRuntimeMutations = new Set<string>();
|
|
2029
|
+
let kernelWatcher: Awaited<ReturnType<typeof createBrowserRuntimeKqueue>> | undefined;
|
|
2014
2030
|
let renderFailure: Readonly<{ error: unknown }> | undefined;
|
|
2015
2031
|
let renderResult: HtmlOverlayFrameRenderResult | undefined;
|
|
2016
2032
|
let gpuEvidence: HtmlOverlayGpuEvidence | undefined;
|
|
@@ -2026,71 +2042,82 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
|
|
|
2026
2042
|
let watcherFailure: Error | undefined;
|
|
2027
2043
|
const runtimeName = basename(preparedBrowserRuntime.runtimeRoot);
|
|
2028
2044
|
const snapshotName = basename(preparedBrowserRuntime.directory);
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
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
|
+
}
|
|
2035
2100
|
watcherVersion += 1;
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
anchorWatcher.on("error", error => {
|
|
2041
|
-
watcherFailure = error;
|
|
2042
|
-
browserRuntimeMutations.add(`anchor-watch-error:${error.message}`);
|
|
2043
|
-
});
|
|
2044
|
-
browserRuntimeWatchers.push(anchorWatcher);
|
|
2045
|
-
const containerWatcher = watch(
|
|
2046
|
-
preparedBrowserRuntime.directory,
|
|
2047
|
-
{ persistent: false, recursive: process.platform === "darwin" },
|
|
2048
|
-
(eventType, filename) => {
|
|
2049
|
-
const path = filename?.toString().replaceAll("\\", "/")
|
|
2050
|
-
?? "<snapshot-container>";
|
|
2051
|
-
if (
|
|
2052
|
-
path === "home"
|
|
2053
|
-
|| path.startsWith("home/")
|
|
2054
|
-
|| path === "tmp"
|
|
2055
|
-
|| path.startsWith("tmp/")
|
|
2056
|
-
) return;
|
|
2057
|
-
if (path === runtimeName && eventType === "change") {
|
|
2058
|
-
// Gatekeeper may change the app root's first-launch metadata. The
|
|
2059
|
-
// immutable parent and its exact ctime identity independently
|
|
2060
|
-
// prove that this pathname was never removed or replaced.
|
|
2061
|
-
return;
|
|
2062
|
-
}
|
|
2063
|
-
if (
|
|
2064
|
-
request.browserRuntime.provenance.kind === "test-only-unverified"
|
|
2065
|
-
&& request.browserRuntime.manifest.layout === "single-executable"
|
|
2066
|
-
&& path === runtimeName
|
|
2067
|
-
&& eventType === "rename"
|
|
2068
|
-
) {
|
|
2069
|
-
// Darwin reports synthetic rename events for accesses to the
|
|
2070
|
-
// test-only single-file fixture. Production rejects this layout;
|
|
2071
|
-
// its exact path identity still detects fixture substitution.
|
|
2072
|
-
return;
|
|
2073
|
-
}
|
|
2074
|
-
watcherVersion += 1;
|
|
2075
|
-
if (path === runtimeName || path.startsWith(`${runtimeName}/`)) {
|
|
2101
|
+
if (path === runtimeName || path.startsWith(`${runtimeName}/`)) {
|
|
2102
|
+
browserRuntimeMutations.add(`${eventType}:${path}`);
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2076
2105
|
browserRuntimeMutations.add(`${eventType}:${path}`);
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
});
|
|
2086
|
-
browserRuntimeWatchers.push(containerWatcher);
|
|
2106
|
+
},
|
|
2107
|
+
);
|
|
2108
|
+
containerWatcher.on("error", error => {
|
|
2109
|
+
watcherFailure = error;
|
|
2110
|
+
browserRuntimeMutations.add(`watch-error:${error.message}`);
|
|
2111
|
+
});
|
|
2112
|
+
browserRuntimeWatchers.push(containerWatcher);
|
|
2113
|
+
}
|
|
2087
2114
|
let browser: Browser | undefined;
|
|
2088
2115
|
let browserFailure: Readonly<{ error: unknown }> | undefined;
|
|
2089
2116
|
try {
|
|
2090
2117
|
// Delayed preparation notifications can arrive during full verification.
|
|
2091
2118
|
// Recheck the SAME pre-wait identities until one pass is stable. Nothing
|
|
2092
2119
|
// after the synchronous baseline commit is discarded.
|
|
2093
|
-
|
|
2120
|
+
const baselineOptions = {
|
|
2094
2121
|
signal,
|
|
2095
2122
|
version: () => watcherVersion,
|
|
2096
2123
|
failure: () => watcherFailure,
|
|
@@ -2121,7 +2148,16 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
|
|
|
2121
2148
|
);
|
|
2122
2149
|
},
|
|
2123
2150
|
commit: () => { browserRuntimeMutations.clear(); },
|
|
2124
|
-
}
|
|
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);
|
|
2125
2161
|
discardProvenLaunchManagedRootMetadataEvent(
|
|
2126
2162
|
browserRuntimeMutations,
|
|
2127
2163
|
runtimeName,
|
|
@@ -2129,6 +2165,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
|
|
|
2129
2165
|
await assertNoBrowserRuntimeMutationEvents(
|
|
2130
2166
|
browserRuntimeMutations,
|
|
2131
2167
|
"before browser launch",
|
|
2168
|
+
() => kernelWatcher?.poll(browserRuntimeMutations),
|
|
2132
2169
|
);
|
|
2133
2170
|
const launchedBrowser = await boundedBrowserStep(
|
|
2134
2171
|
async () => {
|
|
@@ -2188,6 +2225,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
|
|
|
2188
2225
|
"during browser launch",
|
|
2189
2226
|
signal,
|
|
2190
2227
|
);
|
|
2228
|
+
kernelWatcher?.poll(browserRuntimeMutations);
|
|
2191
2229
|
discardProvenLaunchManagedRootMetadataEvent(
|
|
2192
2230
|
browserRuntimeMutations,
|
|
2193
2231
|
runtimeName,
|
|
@@ -2195,6 +2233,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
|
|
|
2195
2233
|
await assertNoBrowserRuntimeMutationEvents(
|
|
2196
2234
|
browserRuntimeMutations,
|
|
2197
2235
|
"during browser launch",
|
|
2236
|
+
() => kernelWatcher?.poll(browserRuntimeMutations),
|
|
2198
2237
|
);
|
|
2199
2238
|
const contextContract = HTML_OVERLAY_RENDERER_CONTRACT.browserContext;
|
|
2200
2239
|
const context = await boundedBrowserStep(
|
|
@@ -2489,6 +2528,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
|
|
|
2489
2528
|
"through browser shutdown",
|
|
2490
2529
|
signal,
|
|
2491
2530
|
);
|
|
2531
|
+
kernelWatcher?.poll(browserRuntimeMutations);
|
|
2492
2532
|
discardProvenLaunchManagedRootMetadataEvent(
|
|
2493
2533
|
browserRuntimeMutations,
|
|
2494
2534
|
runtimeName,
|
|
@@ -2496,6 +2536,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
|
|
|
2496
2536
|
await assertNoBrowserRuntimeMutationEvents(
|
|
2497
2537
|
browserRuntimeMutations,
|
|
2498
2538
|
"through browser shutdown",
|
|
2539
|
+
() => kernelWatcher?.poll(browserRuntimeMutations),
|
|
2499
2540
|
);
|
|
2500
2541
|
renderResult = {
|
|
2501
2542
|
...(gpuEvidence === undefined ? {} : { gpuEvidence }),
|
|
@@ -2510,6 +2551,12 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
|
|
|
2510
2551
|
.catch(() => undefined);
|
|
2511
2552
|
} finally {
|
|
2512
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
|
+
}
|
|
2513
2560
|
if (browserRuntimeSnapshot !== undefined) {
|
|
2514
2561
|
if (browserShutdownRequired) {
|
|
2515
2562
|
const retainedError = new ApplicationError("unavailable", "HTML overlay browser shutdown is unproven; its active runtime snapshot was retained and frames were not published.", {
|