From f3d60cc7791c03cd52c7025e648763669edbcc07 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:44:58 +0800 Subject: [PATCH] test(access): add privacy acceptance gate [skip ci] Local privacy gate and focused evidence. --- docs/adr/0002-persistent-protected-rooms.md | 30 ++- docs/deployment.md | 11 ++ docs/project-memory.md | 2 +- docs/status.md | 6 +- package.json | 1 + scripts/access-privacy-gate.ts | 208 ++++++++++++++++++++ scripts/native-one-viewer-gate-core.ts | 2 +- scripts/native-one-viewer-gate.ts | 54 +++-- tests/native-one-viewer-gate-core.test.ts | 1 + 9 files changed, 287 insertions(+), 28 deletions(-) create mode 100644 scripts/access-privacy-gate.ts diff --git a/docs/adr/0002-persistent-protected-rooms.md b/docs/adr/0002-persistent-protected-rooms.md index ef86ab0..00cecfe 100644 --- a/docs/adr/0002-persistent-protected-rooms.md +++ b/docs/adr/0002-persistent-protected-rooms.md @@ -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. diff --git a/docs/deployment.md b/docs/deployment.md index 21308d8..a087a0c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 diff --git a/docs/project-memory.md b/docs/project-memory.md index ee043e8..4119420 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -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. diff --git a/docs/status.md b/docs/status.md index 901caca..de4a114 100644 --- a/docs/status.md +++ b/docs/status.md @@ -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 diff --git a/package.json b/package.json index a98618d..ca0d429 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/access-privacy-gate.ts b/scripts/access-privacy-gate.ts new file mode 100644 index 0000000..4f9d56e --- /dev/null +++ b/scripts/access-privacy-gate.ts @@ -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 { + 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 { + 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(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(); +} diff --git a/scripts/native-one-viewer-gate-core.ts b/scripts/native-one-viewer-gate-core.ts index 7dd284f..a78af9a 100644 --- a/scripts/native-one-viewer-gate-core.ts +++ b/scripts/native-one-viewer-gate-core.ts @@ -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)); } diff --git a/scripts/native-one-viewer-gate.ts b/scripts/native-one-viewer-gate.ts index 96f98e5..5dd1725 100644 --- a/scripts/native-one-viewer-gate.ts +++ b/scripts/native-one-viewer-gate.ts @@ -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 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 { 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(cdp: CdpConnection, page: PageHandle, expression: string, deadline: number): Promise { +export async function evaluate(cdp: CdpConnection, page: PageHandle, expression: string, deadline: number): Promise { const value = await cdp.call>("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 { +export async function reservePort(): Promise { const server = createServer(); return new Promise((resolvePort, rejectPort) => { server.once("error", rejectPort); @@ -1034,7 +1038,7 @@ async function readLaunchUrl(child: ChildProcessWithoutNullStreams): Promise { +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 { +export async function cleanupRun(resources: CleanupResources): Promise { 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 { 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 { - 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 }); } diff --git a/tests/native-one-viewer-gate-core.test.ts b/tests/native-one-viewer-gate-core.test.ts index e7bd3dd..a1a9b57 100644 --- a/tests/native-one-viewer-gate-core.test.ts +++ b/tests/native-one-viewer-gate-core.test.ts @@ -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");