test(access): add privacy acceptance gate [skip ci]

Local privacy gate and focused evidence.
This commit is contained in:
AAA authored and GitHub committed 2026-08-21 17:44:58 +08:00
1 parent 120f3c0306
commit f3d60cc779
9 files changed
+287 -28

No files matched your search

+21 -9
View File
@@ -1,6 +1,6 @@
# ADR-0002: Persistent Rooms And Scoped Viewer Access
- Status: Accepted; room access deployed, site-access ordering source-only
- Status: Accepted; access boundary deployed, local privacy gate source-only
- Date: 2026-08-19
## Context
@@ -237,11 +237,23 @@ SQLite or participates in authorization, routing, or quality decisions.
## Implementation Status
The deployed 2026-08-20 boundary implements the single-version v2 runtime,
private/public room policy, fragment consumption, room passwords and
commit-first grant teardown on SQLite v3. The current source-only candidate adds
the ordered site-access check for every code-only Viewer and atomically renames
the environment, HTTP endpoint, cookie, Web/Native consumers and nginx limiter.
Focused tests cover valid grant direct entry, public/password denial without
site access, public/password entry with site access, config fail-fast and the
Native wire contract. Deployment and production request/log inspection remain.
The deployed boundary implements the single-version v2 runtime, ordered site
access for code-only Viewers, private/public room policy, fragment consumption,
room passwords, and commit-first grant teardown on SQLite v3.
The source-only `npm run gate:access-privacy` gate builds the current Web client,
uses an ephemeral real-Chrome context against a loopback persistent room, and
reports only fixed booleans and counts. It proves that the fragment is consumed
through `replaceState` before `DOMContentLoaded`, the grant appears only under
that room's `sessionStorage` key, and the run's site password plus grant do not
appear in observed request/referrer records, the tracked nginx combined-log
model, or captured application diagnostics. None of those values or the room
password appears raw in temporary SQLite files. The room password enters at the
RoomStore boundary, so this gate makes no claim about its browser/WebSocket/log
ingress. It also runs the complete signaling test file containing the strong
rotate/revoke media-edge teardown case.
This local headless gate neither reads production credentials nor inspects a
production request log, nginx log, service journal, database, or headful browser.
Those exact-environment checks and a production rotation/revoke remain separate
acceptance work.
+11
View File
@@ -310,6 +310,17 @@ Only `POST /api/site-access` accepts the site access secret in an
`__Host-` name. The cookie contains no account or server-side session ID. There
is no account database, JWT, session map, or logout endpoint.
Before an access-boundary release, set `CHROME_PATH` to a local Chrome or
Chromium executable and run `npm run gate:access-privacy`. The gate creates only
a loopback room and temporary SQLite/profile state, navigates the fragment
through CDP rather than a process argument, disposes its ephemeral browser
context, and emits only fixed booleans and counts. It also checks the tracked
nginx logging shape and runs the complete signaling suite containing the strong
rotate/revoke teardown case. The room password enters only at the RoomStore
boundary, so this run proves its raw SQLite non-persistence, not its WebSocket or
log ingress. It never connects to production or accepts a production credential;
exact production request/nginx/journal/database inspection remains separate.
nginx limits this exact endpoint per source at `5r/m` with `burst=5 nodelay` and
returns 429 when exhausted. The limiter uses nginx shared memory; do not add the
secret, Authorization header, request body, or a new source-address field to
+1 -1
View File
@@ -48,7 +48,7 @@ Last updated: 2026-08-21
- Web relay is cap2 with one upstream; child3 rejects and no UA/visibility split exists.
- Four services are active/running at `NRestarts=0`; local/public health are 200. SQLite v3 has five rooms, SHA-256 `aef724ae52c4107be8ec8791e72f8dcaa11b0ec849fb432d022e2cfb9cfe0974`, owner/mode `screener:screener`/0600.
- Production keeps ordinary ICE STUN-only, SFU roots <=2, selected-edge UDP TTL 120, and one pending/answered `peer-selected` attempt per room excluding Host ingress. This rollout ran no browser/media/SFU/TURN/performance canary; earlier exact-`16f6eab` evidence proves active Host ingress only.
- Production requires the independent site-access secret through `SITE_ACCESS_PASSWORD`. Its stateless cookie authorizes creation/Host and permits code-only Viewer attempts; valid private fragment grants remain direct. Public-watch accepts site access plus code, while private code-only entry additionally requires the room password. The env, endpoint, cookie, and nginx limiter naming migrated atomically; anonymous failures stay neutral, and there are no accounts/JWT/session rows.
- Production requires independent `SITE_ACCESS_PASSWORD`: its stateless cookie authorizes creation/Host and code-only Viewer attempts; private grants stay direct, while public/private code entry needs site access alone/plus room password. Env/endpoint/cookie/nginx naming migrated atomically; failures remain neutral; there are no accounts/JWT/session rows. Local `gate:access-privacy` proves fragment isolation, site/grant transport-log absence, and raw room-password SQLite absence; room-password WebSocket/log ingress and live/headful evidence remain open.
- Production uses nginx, Node.js 24.19.0, coturn 4.17.2, and LiveKit 1.13.5 on UDP 7882 (TCP fallback off). Ordinary peer ICE receives no TURN; the selected-edge tuple is configured only for the current controller edge, with no credential pre-advertised to ordinary peers. Coturn retains the old authenticated-relay ports. Services are healthy with zero restarts.
- ADR-0005 rejects `PEER_ASSISTED_ROOM_IDS`; `PEER_ASSISTED_MEDIA=true` enables every room and ordinary peers stay STUN-only. Initial or active Host ingress may consume one bound relay-only grant; ready/abort clears it and failure restores peer baseline. Stale room-ID config fails startup; `screener-v2` is the only deployed wire.
- Web SFU uses LiveKit 2.22.0 `q,h`; local SFU/UDP and selected-TURN ingress work, while BWE remains open. P2P now reapplies its selected profile after each answer on the same sender. A local A/B supports the lifecycle candidate, but natural ramp, real capture, production, and SFU validation remain open.
+3 -3
View File
@@ -16,7 +16,7 @@ Parallel flagship work; minimum relevant checks and rollback proof. Research plu
## Current Snapshot
- Capture precedes room creation; source/quality changes and AV pause preserve peers. Recommended profiles and advanced defaults use `balanced`; clarity/fluid remain explicit. P2P reapplies the selected profile after each answer on the same sender; the browser still owns degradation and stats/readback expose it.
- Production access is `screener-v2`: a valid private fragment grant enters directly; every code-only Viewer must first hold site access, after which public-watch accepts the code and private rooms still require their room password. `SITE_ACCESS_PASSWORD`, `/api/site-access`, and the site-access cookie are deployed atomically; raw credentials are never stored.
- Production `screener-v2` admits private fragment grants directly. Code-only Viewers need site access, then public rooms accept the code while private rooms also require their password. `SITE_ACCESS_PASSWORD`, `/api/site-access`, and its cookie are deployed atomically; raw credentials are never stored.
- Source UI uses adjacent nickname editing, distinct site/room prompts, and a shared room-code form. Status and details stay neutral before media proof; current evidence labels green P2P/yellow `SFU fallback`, while relay evidence adds `TURN` plus only a locally observed protocol.
- Native v2 is source-only (memory rooms, 300s reclaim); it follows authenticated direct-child assignments up to two and fails unsupported SFU/selected ingress boundedly.
- Production is roots <=2/Web relay <=2; every Web Viewer keeps one upstream and bounded sticky recovery, while child3 is rejected. Room `1` is historical; HTTPS/WSS is TLS/TCP.
@@ -32,7 +32,7 @@ Parallel flagship work; minimum relevant checks and rollback proof. Research plu
- Chrome 151 loopback proves H.264 negotiation/decode interoperability, not performance. Production orders H.264 first and keeps browser repair/fallback codecs.
- Native WGC/MF used no browser capture/encoder. One Chrome Viewer rendered 203 1280x720 frames and received 500 Opus packets; process/LUID-correlated `VideoEncode` was nonzero, with no fatal/encoder errors.
- Native Win11 audio is source-only/default-off: target isolation was 4018x and one Viewer got 495 Opus packets. Download, game sync, Win10, and other routes remain open.
- Access protocol/config/HTTP/storage/SQLite/signaling focused tests pass, including commit-first teardown and v1 rollback.
- Local gate proves pre-DOM fragment clearing, room-scoped session isolation, no site/grant transport-log hits, and no raw room-password SQLite sentinel; rotate/revoke tests pass. Headless loopback only.
- Native wire/session tests enforce at most two authoritative children, no presence-created edge, stale-revision ignore, and one bounded unsupported-route failure per revision.
- Production caps pending/answered `peer-selected` at one per room; Host ingress is independent. Focused admission/release/rollback/STUN-only tests pass; earlier Chrome proves active Host-ingress function only.
- Exact `1331fbdd` passed fresh typecheck, builds, hygiene and 32 files/473 tests. Bounded runtime, UTF-8 artifact, inode and deploy gates preserved config/nft/DB hashes and four zero-restart services.
@@ -46,7 +46,7 @@ Parallel flagship work; minimum relevant checks and rollback proof. Research plu
- ADR-0006 has one Viewer proof; two-edge/FIFO, hardware, endurance, public/downloaded-package, and browser-diversity proof remain open.
- Production fanout is Host2/Viewer2 and rejects child3. Relay resource behavior remains open.
- Active SFU already publishes `q,h`; only BWE/resource/zero-child/root gates are unverified. No local per-leaf UDP shaper; resume with Linux `tc` or public canary, never CDP. Web P2P/SVC shortcuts remain no-go.
- Headful fragment-to-sessionStorage consumption, invitation rotation after migration, and request/log leak inspection remain production UX gates. Accounts stay out.
- Access remains unverified headfully and in production: fragment consumption, invitation rotation/revoke, and exact request/nginx/journal/SQLite leak inspection. The source gate is loopback-only; accounts stay out.
- Structure debt: split `HybridMediaRouter`, `HostPage`, `SignalingServer`, and `ViewerPage` only at proven consumer boundaries, never by file length.
## Next Milestone
+1
View File
@@ -14,6 +14,7 @@
"build:server": "tsc -p tsconfig.server.json",
"start": "node dist/server/server/index.js",
"benchmark:peer-assisted": "tsx scripts/peer-assisted-benchmark.ts",
"gate:access-privacy": "npm run build:client && vitest run tests/server-signal.test.ts && tsx scripts/access-privacy-gate.ts",
"gate:turn-allocation": "tsx scripts/turn-allocation-gate.ts",
"gate:native-one-viewer": "tsx scripts/native-one-viewer-gate.ts",
"typecheck": "tsc -b --pretty false",
+208
View File
@@ -0,0 +1,208 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { randomBytes } from "node:crypto";
import { access, mkdtemp, readFile, readdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { createRoomResponseSchema } from "../src/shared/protocol";
import { createScreenerServer, type ScreenerServer } from "../src/server/app";
import type { ServerConfig } from "../src/server/config";
import { CdpConnection, cleanupRun, createPage, evaluate, reservePort, waitForVersion } from "./native-one-viewer-gate";
const root = resolve(import.meta.dirname, "..");
const nginxConfigPath = resolve(root, "deploy/nginx/share.bonfire.icu.conf.example");
const teardownTest = "strongly rotates and revokes every Viewer generation and media edge";
const fragmentProbe = `(() => {
let domContentLoaded = false;
const state = { fragmentAtStart: location.hash.length > 0, replaced: false, beforeDomContentLoaded: false };
document.addEventListener("DOMContentLoaded", () => { domContentLoaded = true; }, { once: true });
const replaceState = history.replaceState;
history.replaceState = function(...args) {
if (location.hash) { state.replaced = true; state.beforeDomContentLoaded = !domContentLoaded; }
return replaceState.apply(this, args);
};
Object.defineProperty(globalThis, "__SCREENER_ACCESS_GATE__", { value: state });
})();`;
interface GateReport {
passed: boolean; serverReady: boolean; roomPrepared: boolean;
chromeConnected: boolean; pageLoaded: boolean; browserAudited: boolean;
fragmentSeenAtDocumentStart: boolean; fragmentReplaceObserved: boolean;
fragmentClearedBeforeDomContentLoaded: boolean; fragmentCleared: boolean;
canonicalRoomPath: boolean; roomSessionGrantMatches: number;
otherSessionGrantMatches: number; localGrantMatches: number;
cookieGrantMatches: number; resourceGrantMatches: number;
requestLeakCount: number; nginxLogLeakCount: number; nginxConfigSafe: boolean;
appLogLeakCount: number; sqliteLeakCount: number; leakAuditCompleted: boolean;
rotateRevokeCasePresent: boolean; cleanupPassed: boolean;
}
interface BrowserAudit {
fragmentSeenAtDocumentStart: boolean; fragmentReplaceObserved: boolean;
fragmentClearedBeforeDomContentLoaded: boolean; fragmentCleared: boolean;
canonicalRoomPath: boolean; roomSessionGrantMatches: number;
otherSessionGrantMatches: number; localGrantMatches: number;
cookieGrantMatches: number; resourceGrantMatches: number;
}
function secret(): string { return randomBytes(24).toString("base64url"); }
function leakCount(values: readonly (string | Buffer)[], secrets: readonly string[]): number {
return values.filter((value) => secrets.some((item) => value.includes(item))).length;
}
function diagnosticText(values: readonly unknown[]): string {
return values.map((value) => {
if (typeof value === "string") return value;
if (value instanceof Error) return `${value.name}:${value.message}`;
try { return JSON.stringify(value) ?? ""; } catch { return ""; }
}).join(" ");
}
async function sqliteFiles(profile: string): Promise<Buffer[]> {
const names = (await readdir(profile)).filter((name) => name.startsWith("rooms.sqlite"));
return Promise.all(names.map((name) => readFile(join(profile, name))));
}
async function main(): Promise<void> {
const report: GateReport = {
passed: false, serverReady: false, roomPrepared: false,
chromeConnected: false, pageLoaded: false, browserAudited: false,
fragmentSeenAtDocumentStart: false, fragmentReplaceObserved: false,
fragmentClearedBeforeDomContentLoaded: false, fragmentCleared: false,
canonicalRoomPath: false, roomSessionGrantMatches: 0,
otherSessionGrantMatches: 0, localGrantMatches: 0, cookieGrantMatches: 0,
resourceGrantMatches: 0, requestLeakCount: 0, nginxLogLeakCount: 0,
nginxConfigSafe: false, appLogLeakCount: 0, sqliteLeakCount: 0,
leakAuditCompleted: false, rotateRevokeCasePresent: false,
cleanupPassed: false,
};
const chromePath = process.env.CHROME_PATH?.trim() ?? "";
const sitePassword = secret();
const roomPassword = secret();
const requestRecords: string[] = [];
const nginxLines: string[] = [];
const appLogs: string[] = [];
const originalConsoleError = console.error;
let profile: string | null = null;
let server: ScreenerServer | null = null;
let chrome: ChildProcessWithoutNullStreams | null = null;
let cdp: CdpConnection | null = null;
let appPort = 0;
let debugPort = 0;
console.error = (...values: unknown[]) => { appLogs.push(diagnosticText(values)); };
try {
report.rotateRevokeCasePresent = (await readFile(
resolve(root, "tests/server-signal.test.ts"), "utf8",
)).includes(teardownTest);
await access(chromePath);
appPort = await reservePort();
do { debugPort = await reservePort(); } while (debugPort === appPort);
profile = await mkdtemp(join(tmpdir(), "screener-access-privacy-"));
const baseUrl = `http://127.0.0.1:${appPort}`;
const config: ServerConfig = {
nodeEnv: "production", port: appPort, listenHost: "127.0.0.1",
publicBaseUrl: new URL(baseUrl), allowedOrigins: new Set([baseUrl]),
siteAccessPassword: sitePassword, roomDatabasePath: join(profile, "rooms.sqlite"),
roomTtlMs: 14_400_000, maxRooms: 4, maxViewersPerRoom: 2,
peerAssistedMedia: false, stunUrls: [],
};
server = await createScreenerServer({ config, staticDirectory: resolve(root, "dist/client") });
server.httpServer.prependListener("request", (request, response) => {
const target = request.url ?? "";
const record = `${request.method ?? ""} ${target} ${request.headers.referer ?? ""}`;
requestRecords.push(record);
response.once("finish", () => nginxLines.push(`${record} ${response.statusCode}`));
});
await server.listen(appPort, "127.0.0.1");
report.serverReady = true;
const login = await fetch(`${baseUrl}/api/site-access`, {
method: "POST", headers: { Authorization: `Bearer ${sitePassword}`, Origin: baseUrl },
});
const cookie = login.headers.get("set-cookie")?.split(";", 1)[0] ?? "";
const createdResponse = await fetch(`${baseUrl}/api/rooms`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: cookie, Origin: baseUrl },
body: JSON.stringify({ viewerPolicy: "private-link" }),
});
const room = createRoomResponseSchema.parse(await createdResponse.json());
const invite = new URL(room.inviteUrl);
const grant = new URLSearchParams(invite.hash.slice(1)).get("v") ?? "";
if (!grant) throw new Error("Private room did not return a Viewer grant");
server.roomStore.connectParticipant({
roomId: room.roomId, role: "host", token: room.hostToken,
clientId: "privacy-gate-host", sessionId: "privacy-gate-host-session",
});
await server.roomStore.setViewerPassword(room.roomId, roomPassword, "privacy-gate-host-session");
report.roomPrepared = true;
chrome = spawn(chromePath, [
`--remote-debugging-port=${debugPort}`, `--user-data-dir=${profile}`,
"--headless=new", "--no-first-run", "--disable-extensions",
"--disable-logging", "about:blank",
], { cwd: root, stdio: "pipe", windowsHide: true });
chrome.stdout.resume();
chrome.stderr.resume();
const version = await waitForVersion(debugPort, chrome);
cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 10_000);
report.chromeConnected = true;
const context = await cdp.call<{ browserContextId: string }>(
"Target.createBrowserContext", { disposeOnDetach: true }, undefined, Date.now() + 5_000,
);
const page = await createPage(cdp, room.inviteUrl, fragmentProbe, true, context.browserContextId);
report.pageLoaded = true;
const expected = JSON.stringify({ roomId: room.roomId, grant });
const audit = await evaluate<BrowserAudit>(cdp, page, `(() => {
const expected = ${expected};
const fragment = globalThis.__SCREENER_ACCESS_GATE__;
const entries = (storage) => Object.keys(storage).map((key) => [key, storage.getItem(key)]);
const session = entries(sessionStorage); const local = entries(localStorage);
return {
fragmentSeenAtDocumentStart: fragment?.fragmentAtStart === true,
fragmentReplaceObserved: fragment?.replaced === true,
fragmentClearedBeforeDomContentLoaded: fragment?.beforeDomContentLoaded === true,
fragmentCleared: location.hash === "",
canonicalRoomPath: location.pathname === "/r/" + expected.roomId && location.search === "",
roomSessionGrantMatches: session.filter(([key, value]) => key === "screener:viewer-grant:" + expected.roomId && value === expected.grant).length,
otherSessionGrantMatches: session.filter(([key, value]) => value?.includes(expected.grant) && key !== "screener:viewer-grant:" + expected.roomId).length,
localGrantMatches: local.filter(([, value]) => value && value.includes(expected.grant)).length,
resourceGrantMatches: performance.getEntriesByType("resource").filter((entry) => entry.name.includes(expected.grant)).length,
};
})()`, Date.now() + 5_000);
const cookies = await cdp.call<{ cookies: { name: string; value: string }[] }>(
"Storage.getCookies", { browserContextId: context.browserContextId }, undefined, Date.now() + 5_000);
audit.cookieGrantMatches = leakCount(cookies.cookies.map(({ name, value }) => `${name}=${value}`), [grant]);
Object.assign(report, audit, { browserAudited: true });
await cdp.call("Target.disposeBrowserContext", { browserContextId: context.browserContextId },
undefined, Date.now() + 5_000);
await server.close();
server = null;
const transportSecrets = [sitePassword, grant];
const nginxConfig = await readFile(nginxConfigPath, "utf8");
report.requestLeakCount = leakCount(requestRecords, transportSecrets);
report.nginxLogLeakCount = leakCount(nginxLines, transportSecrets);
report.nginxConfigSafe = !/\$(?:http_authorization|request_body)(?:\b|_)/.test(nginxConfig);
report.appLogLeakCount = leakCount(appLogs, transportSecrets);
report.sqliteLeakCount = leakCount(await sqliteFiles(profile), [...transportSecrets, roomPassword]);
report.leakAuditCompleted = true;
} catch {
// The report intentionally exposes no exception text or secret-bearing diagnostics.
} finally {
console.error = originalConsoleError;
const cleanup = await cleanupRun({
cdp, native: null, chrome, server, profile,
ports: [appPort, debugPort].filter((port) => port > 0),
});
report.cleanupPassed = Object.values(cleanup).every(Boolean);
}
report.passed = report.browserAudited && report.fragmentSeenAtDocumentStart &&
report.fragmentReplaceObserved && report.fragmentClearedBeforeDomContentLoaded &&
report.fragmentCleared &&
report.canonicalRoomPath && report.roomSessionGrantMatches === 1 &&
report.otherSessionGrantMatches === 0 && report.localGrantMatches === 0 &&
report.cookieGrantMatches === 0 && report.resourceGrantMatches === 0 &&
report.leakAuditCompleted && report.requestLeakCount === 0 &&
report.nginxLogLeakCount === 0 && report.nginxConfigSafe &&
report.appLogLeakCount === 0 && report.sqliteLeakCount === 0 &&
report.rotateRevokeCasePresent && report.cleanupPassed;
process.stdout.write(`${JSON.stringify(report)}\n`);
process.exitCode = report.passed ? 0 : 1;
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
await main();
}
+1 -1
View File
@@ -215,5 +215,5 @@ export function isExactGateProfile(profile: string, systemTemp: string): boolean
const same = process.platform === "win32"
? dirname(candidate).toLowerCase() === temp.toLowerCase()
: dirname(candidate) === temp;
return same && /^screener-native-one-viewer-[A-Za-z0-9_-]{6}$/.test(basename(candidate));
return same && /^screener-(?:native-one-viewer|access-privacy)-[A-Za-z0-9_-]{6}$/.test(basename(candidate));
}
+40 -14
View File
@@ -1,7 +1,7 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { randomBytes } from "node:crypto";
import { closeSync, fsyncSync, openSync, writeFileSync } from "node:fs";
import { mkdtemp } from "node:fs/promises";
import { lstat, mkdtemp, readdir, rm } from "node:fs/promises";
import { createConnection, createServer } from "node:net";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
@@ -41,7 +41,7 @@ import {
type Stage = "preflight" | "sender-start" | "node-host" | "viewer-signal" | "viewer-media" | "cleanup";
interface PageHandle {
export interface PageHandle {
sessionId: string;
}
@@ -136,7 +136,7 @@ interface ViewerSnapshot {
audioBytesReceived: number;
}
class CdpConnection {
export class CdpConnection {
private nextId = 1;
private readonly pending = new Map<number, {
resolve: (value: unknown) => void;
@@ -941,15 +941,19 @@ function viewerProbe(): string {
})();`;
}
async function createPage(
export async function createPage(
cdp: CdpConnection,
url: string,
probe?: string,
newWindow = false,
browserContextId?: string,
): Promise<PageHandle> {
const deadline = Date.now() + 15_000;
const created = await cdp.call<{ targetId: string }>(
"Target.createTarget", { url: "about:blank", background: false, newWindow }, undefined, deadline,
"Target.createTarget", {
url: "about:blank", background: false, newWindow,
...(browserContextId ? { browserContextId } : {}),
}, undefined, deadline,
);
const attached = await cdp.call<{ sessionId: string }>("Target.attachToTarget", { targetId: created.targetId, flatten: true }, undefined, deadline);
const page = { sessionId: attached.sessionId };
@@ -967,7 +971,7 @@ async function createPage(
return page;
}
async function evaluate<T>(cdp: CdpConnection, page: PageHandle, expression: string, deadline: number): Promise<T> {
export async function evaluate<T>(cdp: CdpConnection, page: PageHandle, expression: string, deadline: number): Promise<T> {
const value = await cdp.call<CdpResult<T>>("Runtime.evaluate", {
expression, awaitPromise: true, returnByValue: true, userGesture: true,
}, page.sessionId, deadline);
@@ -998,7 +1002,7 @@ function safeRoute(value: string | undefined): string {
.has(value ?? "") ? value! : "unknown";
}
async function reservePort(): Promise<number> {
export async function reservePort(): Promise<number> {
const server = createServer();
return new Promise((resolvePort, rejectPort) => {
server.once("error", rejectPort);
@@ -1034,7 +1038,7 @@ async function readLaunchUrl(child: ChildProcessWithoutNullStreams): Promise<str
});
}
async function waitForVersion(port: number, child: ChildProcessWithoutNullStreams): Promise<{ Browser: string; webSocketDebuggerUrl: string }> {
export async function waitForVersion(port: number, child: ChildProcessWithoutNullStreams): Promise<{ Browser: string; webSocketDebuggerUrl: string }> {
const deadline = Date.now() + 20_000;
while (Date.now() < deadline && child.exitCode === null) {
try {
@@ -1047,7 +1051,7 @@ async function waitForVersion(port: number, child: ChildProcessWithoutNullStream
throw new Error("Chrome startup timed out");
}
interface CleanupResources {
export interface CleanupResources {
cdp: CdpConnection | null;
native: ChildProcessWithoutNullStreams | null;
chrome: ChildProcessWithoutNullStreams | null;
@@ -1056,7 +1060,7 @@ interface CleanupResources {
ports: number[];
}
async function cleanupRun(resources: CleanupResources): Promise<CleanupResult> {
export async function cleanupRun(resources: CleanupResources): Promise<CleanupResult> {
if (resources.cdp) {
try { await resources.cdp.call("Browser.close", {}, undefined, Date.now() + 2_000); } catch {}
resources.cdp.close();
@@ -1082,8 +1086,13 @@ function processRunning(child: ChildProcessWithoutNullStreams): boolean {
async function stopProcessTree(child: ChildProcessWithoutNullStreams): Promise<boolean> {
if (!processRunning(child)) return true;
if (await waitForExit(child, 2_000)) return true;
if (process.platform !== "win32" || child.pid === undefined) return false;
await runBounded("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], 5_000);
if (process.platform === "win32" && child.pid !== undefined) {
await runBounded("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], 5_000);
} else {
child.kill("SIGTERM");
if (await waitForExit(child, 2_000)) return true;
child.kill("SIGKILL");
}
return waitForExit(child, 3_000);
}
@@ -1145,7 +1154,7 @@ $root = [IO.Path]::GetFullPath($env:SCREENER_GATE_PROFILE_TO_REMOVE)
$temp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd([IO.Path]::DirectorySeparatorChar)
$parent = [IO.Path]::GetDirectoryName($root).TrimEnd([IO.Path]::DirectorySeparatorChar)
$name = [IO.Path]::GetFileName($root)
if ($parent -ine $temp -or $name -notmatch '^screener-native-one-viewer-[A-Za-z0-9_-]{6}$') { exit 31 }
if ($parent -ine $temp -or $name -notmatch '^screener-(native-one-viewer|access-privacy)-[A-Za-z0-9_-]{6}$') { exit 31 }
$rootEntries = @([IO.Directory]::EnumerateFileSystemEntries($parent, $name, [IO.SearchOption]::TopDirectoryOnly))
if ($rootEntries.Count -eq 0) { exit 0 }
if ($rootEntries.Count -ne 1) { exit 34 }
@@ -1171,7 +1180,24 @@ if ($rootEntries.Count -ne 0) { exit 33 }
`;
async function removeGateProfile(profile: string): Promise<boolean> {
if (process.platform !== "win32" || !isExactGateProfile(profile, tmpdir())) return false;
if (!isExactGateProfile(profile, tmpdir())) return false;
if (process.platform !== "win32") {
const pending = [profile];
try {
while (pending.length > 0) {
const current = pending.pop()!;
const stat = await lstat(current);
if (stat.isSymbolicLink()) return false;
if (stat.isDirectory()) {
pending.push(...(await readdir(current)).map((name) => join(current, name)));
}
}
await rm(profile, { recursive: true });
return true;
} catch {
return false;
}
}
return runBounded("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", profileCleanupScript],
10_000, { ...process.env, SCREENER_GATE_PROFILE_TO_REMOVE: profile });
}
@@ -287,6 +287,7 @@ describe("native one-viewer gate invariants", () => {
it("accepts only the exact task profile directly under system Temp", () => {
const temp = join("C:", "Temp");
expect(isExactGateProfile(join(temp, "screener-native-one-viewer-a1B_2-"), temp)).toBe(true);
expect(isExactGateProfile(join(temp, "screener-access-privacy-a1B_2-"), temp)).toBe(true);
expect(isExactGateProfile(join(temp, "nested", "screener-native-one-viewer-a1B_2-"), temp)).toBe(false);
expect(isExactGateProfile(join(temp, "other-a1B_2-"), temp)).toBe(false);
expect(profileCleanupScript).toContain("EnumerateFileSystemEntries($parent, $name");