f52b546d4612
run one release wrapper for Chrome sentinels and both Firefox security harnesses
- time
- 2026-09-04 17:08 UTC · 1 month ago
- machine
- koh2
- agent
- claude
- session
- PB-26-210 mandatory browser gate
- parent
- 5d2f2da905fc
↓ peach-browser-f52b546d.tar.gzatlas at this save
files changed11 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1playwright.config.ts+2 −28scripts/ci.ts+5 −15scripts/release-gate.test.ts+48 −25scripts/release-gate.ts+25 −6scripts/run-release-browser-gate.ts+18 −4scripts/test-firefox-passkeys.mjs+86 −6scripts/test-firefox-sender-context.mjs+83 −6tests/helpers/vault.ts+5 −5manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.0.92",4+ "version": "5.0.94",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -2,7 +2,7 @@22 "manifest_version": 3,33 "minimum_chrome_version": "116",44 "name": "Peach Password Manager",5- "version": "5.0.92",5+ "version": "5.0.94",66 "description": "Zero-knowledge password manager",77 "action": {88 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "5.0.92",3+ "version": "5.0.94",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",playwright.config.ts+2 −28
@@ -1,6 +1,6 @@11 import { defineConfig } from "@playwright/test";2-import { fileURLToPath } from "url";3-import { dirname, join } from "path";2+import { fileURLToPath } from "node:url";3+import { dirname, join } from "node:path";44 55 const __filename = fileURLToPath(import.meta.url);66 const __dirname = dirname(__filename);@@ -51,32 +51,6 @@5151 `--disable-gpu`,5252 `--disable-dev-shm-usage`,5353 ],54- },55- },56- testMatch: /security-sentinels\.spec\.ts/,57- timeout: 120_000,58- retries: 0,59- },60- // Firefox release uses software rendering and disabled content sandboxes61- // because the CI host cannot map the hardware compositor.62- {63- name: "firefox-release",64- use: {65- browserName: "firefox",66- headless: true,67- launchOptions: {68- env: {69- ...process.env,70- MOZ_HEADLESS: "1",71- MOZ_DISABLE_CONTENT_SANDBOX: "1",72- MOZ_DISABLE_RDD_SANDBOX: "1",73- LIBGL_ALWAYS_SOFTWARE: "1",74- },75- },76- firefoxUserPrefs: {77- "gfx.webrender.all": false,78- "gfx.webrender.software": false,79- "media.hardware-video-decoding.force-enabled": false,8054 },8155 },8256 testMatch: /security-sentinels\.spec\.ts/,scripts/ci.ts+5 −15
@@ -11,14 +11,13 @@1111 const RESET = '\x1b[0m';1212 const GREEN = '\x1b[32m';1313 const RED = '\x1b[31m';14-const YELLOW = '\x1b[33m';1514 const BOLD = '\x1b[1m';1615 const DIM = '\x1b[2m';17-const RUN_FIREFOX_BROWSER_CHECKS = process.env.CI === 'true' || process.env.PEACH_E2E_FIREFOX === '1';1816 // PB-26-210: the release browser gate is mandatory in CI and opt-in for1917 // local pre-release verification. When enabled, both Chrome and Firefox2018 // browser tests MUST run and pass — they do not silently skip.2119 const RUN_BROWSER_GATE = process.env.CI === 'true' || process.env.PEACH_RELEASE_BROWSER_GATE === '1';20+const RUN_FIREFOX_SENDER_ONLY = process.env.PEACH_E2E_FIREFOX === '1' && !RUN_BROWSER_GATE;2221 2322 interface Step {2423 name: string;@@ -60,27 +59,18 @@6059 name: 'Firefox build',6160 cmd: ['npm', 'run', 'build:firefox'],6261 },63- ...(RUN_FIREFOX_BROWSER_CHECKS62+ ...(RUN_FIREFOX_SENDER_ONLY6463 ? [{6564 name: 'Firefox sender context',6665 cmd: ['npm', 'run', 'test:firefox:sender'],6766 }]6867 : []),69- // PB-26-210: mandatory release browser gate.70- // The wrapper runs Chrome and Firefox sentinels for PB-26-201/203.68+ // PB-26-210: one mandatory wrapper owns the Chrome security sentinels and69+ // both real-Firefox harnesses, so no browser can be double-run or mislabeled.7170 ...(RUN_BROWSER_GATE7271 ? [{73- name: 'Chrome and Firefox security sentinel gate',72+ name: 'Release browser gate',7473 cmd: ['bun', 'run', 'scripts/run-release-browser-gate.ts'],75- }]76- : []),77- // Firefox passkey matrix covers PB-26-209 (exportFunction privileged bridge).78- // If Firefox/Marionette cannot launch, the script exits non-zero — the gate79- // fails rather than silently skipping.80- ...(RUN_BROWSER_GATE81- ? [{82- name: 'Firefox release browser gate',83- cmd: ['node', 'scripts/test-firefox-passkeys.mjs'],8474 }]8575 : []),8676 ];scripts/release-gate.test.ts+48 −25
@@ -4,6 +4,7 @@44 import { describe, expect, it } from "vitest";55 66 import {7+ gateResultFromPlaywright,78 validateGateResult,89 REQUIRED_BROWSER_PROJECTS,910 type GateResult,@@ -17,13 +18,22 @@1718 }1819 1920 describe("PB-26-210: gate validation — fail-closed conditions", () => {21+ it("reads the project name from Playwright test records", () => {22+ expect(gateResultFromPlaywright({23+ suites: [{24+ title: "security-sentinels.spec.ts",25+ specs: [{ tests: [{ projectName: "chrome-release", results: [{ status: "passed" }] }] }],26+ }],27+ })).toEqual({ projects: [project("chrome-release", { totalTests: 1, passed: 1 })] });28+ });29+2030 it("passes a clean result with the required project and no skips", () => {21- expect(validateGateResult({ projects: [project("chrome-release"), project("firefox-release")] })).toEqual([]);31+ expect(validateGateResult({ projects: [project("chrome-release")] })).toEqual([]);2232 });2333 2434 it("fails on zero discovered tests across all projects", () => {2535 const result: GateResult = {26- projects: [project("chrome-release", { totalTests: 0 }), project("firefox-release", { totalTests: 0 })],36+ projects: [project("chrome-release", { totalTests: 0 })],2737 };2838 const errors = validateGateResult(result);2939 expect(errors).toContain("zero tests discovered across all projects");@@ -32,7 +42,7 @@3242 3343 it("fails on a skipped required test", () => {3444 const result: GateResult = {35- projects: [project("chrome-release", { skipped: 2 }), project("firefox-release")],45+ projects: [project("chrome-release", { skipped: 2 })],3646 };3747 const errors = validateGateResult(result);3848 expect(errors.some((e) => e.includes("chrome-release") && e.includes("skipped"))).toBe(true);@@ -40,7 +50,7 @@4050 4151 it("fails on an empty project (zero tests in one required project)", () => {4252 const result: GateResult = {43- projects: [project("chrome-release", { totalTests: 0, passed: 0 }), project("firefox-release")],53+ projects: [project("chrome-release", { totalTests: 0, passed: 0 })],4454 };4555 const errors = validateGateResult(result);4656 expect(errors.some((e) => e.includes("chrome-release") && e.includes("zero tests"))).toBe(true);@@ -48,7 +58,7 @@4858 4959 it("fails on a missing required project name", () => {5060 const result: GateResult = {51- projects: [project("firefox-release")],61+ projects: [],5262 };5363 const errors = validateGateResult(result);5464 expect(errors.some((e) => e.includes("chrome-release") && e.includes("missing"))).toBe(true);@@ -56,18 +66,22 @@5666 5767 it("fails on flaky retries that remain flaky", () => {5868 const result: GateResult = {59- projects: [project("chrome-release", { flaky: 1 }), project("firefox-release")],69+ projects: [project("chrome-release", { flaky: 1 })],6070 };6171 const errors = validateGateResult(result);6272 expect(errors.some((e) => e.includes("chrome-release") && e.includes("flaky"))).toBe(true);6373 });6474 75+ it("fails when any required-project test did not pass", () => {76+ const errors = validateGateResult({77+ projects: [project("chrome-release", { totalTests: 5, passed: 4 })],78+ });79+ expect(errors.some((error) => error.includes("did not pass"))).toBe(true);80+ });81+6582 it("fails on a simulated missing fixture (project exists but zero tests and zero passed)", () => {6683 const result: GateResult = {67- projects: [68- project("chrome-release", { totalTests: 0, passed: 0, skipped: 0 }),69- project("firefox-release", { totalTests: 0, passed: 0, skipped: 0 }),70- ],84+ projects: [project("chrome-release", { totalTests: 0, passed: 0, skipped: 0 })],7185 };7286 const errors = validateGateResult(result);7387 expect(errors.length).toBeGreaterThan(0);@@ -95,20 +109,30 @@95109 });96110 }97111 98- it("scripts/ci.ts includes both Chrome and Firefox release browser gate steps", () => {112+ it("scripts/ci.ts invokes one mandatory browser gate wrapper", () => {99113 const source = readFileSync(join(ROOT, "scripts/ci.ts"), "utf8");100- expect(source).toMatch(/Chrome.*release.*browser|chrome-release/i);101- expect(source).toMatch(/Firefox.*release.*browser|firefox.*passkey/i);114+ expect(source.match(/scripts\/run-release-browser-gate\.ts/g)).toHaveLength(1);115+ expect(source).not.toContain("scripts/test-firefox-passkeys.mjs");102116 });103117 104- it("scripts/ci.ts does not silently skip the browser gate when enabled", () => {105- const source = readFileSync(join(ROOT, "scripts/ci.ts"), "utf8");106- // When the browser gate is enabled, both Chrome and Firefox steps must be107- // present in the steps array — not behind a comment or a skip condition108- // that would make them disappear entirely.109- expect(source).toContain("chrome-release");110- expect(source).toContain("firefox");118+ it("the mandatory wrapper runs real Chrome and combined real-Firefox evidence", () => {119+ const source = readFileSync(join(ROOT, "scripts/run-release-browser-gate.ts"), "utf8");120+ expect(source).toContain("--project=chrome-release");121+ expect(source).not.toContain("--project=firefox-release");122+ expect(source).toContain("scripts/test-firefox-sender-context.mjs");123+ expect(source).toContain("scripts/test-firefox-passkeys.mjs");124+ expect(source).toContain("process.stderr.write(chromeResult.stdout)");125+126+ const firefoxSource = readFileSync(join(ROOT, "scripts/test-firefox-passkeys.mjs"), "utf8");127+ expect(firefoxSource).toContain("firefox-sender-probe.js");128+ expect(firefoxSource).toContain('sendSenderMessage({ type: "GET_RECOVERY_WORDS" })');129+ expect(firefoxSource).toContain("peach_session_dk");111130 });131+132+ it("the Chrome context honors headless release-gate execution", () => {133+ const source = readFileSync(join(ROOT, "tests/helpers/vault.ts"), "utf8");134+ expect(source).toContain('headless: process.env.PEACH_RELEASE_BROWSER_GATE === "1"');135+ });112136 });113137 114138 describe("PB-26-210: playwright config defines required release project", () => {@@ -118,10 +142,10 @@118142 });119143 120144 it("REQUIRED_BROWSER_PROJECTS matches the projects Playwright actually defines", () => {121- expect(REQUIRED_BROWSER_PROJECTS).toEqual(["chrome-release", "firefox-release"]);145+ expect(REQUIRED_BROWSER_PROJECTS).toEqual(["chrome-release"]);122146 const source = readFileSync(join(ROOT, "playwright.config.ts"), "utf8");123147 expect(source).toContain('name: "chrome-release"');124- expect(source).toContain('name: "firefox-release"');148+ expect(source).not.toContain('name: "firefox-release"');125149 });126150 127151 it("release entry points force the browser gate in their CI child environment", () => {@@ -132,9 +156,8 @@132156 });133157 134158 it("CI invokes the validator against Playwright JSON output", () => {135- const source = readFileSync(join(ROOT, "scripts/ci.ts"), "utf8");136- expect(source).toContain("run-release-browser-gate");137- expect(source).toContain("validateGateResult");159+ const source = readFileSync(join(ROOT, "scripts/run-release-browser-gate.ts"), "utf8");160+ expect(source).toContain("assertValidPlaywrightJsonReport");138161 });139162 });140163 scripts/release-gate.ts+25 −6
@@ -18,12 +18,18 @@1818 projects: ProjectResult[];1919 }2020 21-export const REQUIRED_BROWSER_PROJECTS = ["chrome-release", "firefox-release"] as const;21+export const REQUIRED_BROWSER_PROJECTS = ["chrome-release"] as const;2222 2323 export interface PlaywrightJsonReport {2424 suites?: Array<{2525 title?: string;26- specs?: Array<{ tests?: Array<{ status?: string; results?: Array<{ status?: string }> }> }>;26+ specs?: Array<{27+ tests?: Array<{28+ projectName?: string;29+ status?: string;30+ results?: Array<{ status?: string }>;31+ }>;32+ }>;2733 suites?: PlaywrightJsonReport["suites"];2834 }>;2935 }@@ -32,17 +38,27 @@3238 const projects = new Map<string, ProjectResult>();3339 const visit = (suites: PlaywrightJsonReport["suites"] = []) => {3440 for (const suite of suites) {35- if (suite.title && REQUIRED_BROWSER_PROJECTS.includes(suite.title as (typeof REQUIRED_BROWSER_PROJECTS)[number])) {36- const project = projects.get(suite.title) ?? { name: suite.title, totalTests: 0, passed: 0, skipped: 0, flaky: 0 };37- for (const spec of suite.specs ?? []) for (const test of spec.tests ?? []) {41+ for (const spec of suite.specs ?? []) {42+ for (const test of spec.tests ?? []) {43+ const projectName = test.projectName;44+ if (!projectName || !REQUIRED_BROWSER_PROJECTS.includes(projectName as (typeof REQUIRED_BROWSER_PROJECTS)[number])) {45+ continue;46+ }47+ const project = projects.get(projectName) ?? {48+ name: projectName,49+ totalTests: 0,50+ passed: 0,51+ skipped: 0,52+ flaky: 0,53+ };3854 project.totalTests++;3955 const results = test.results ?? [];4056 const status = results.at(-1)?.status ?? test.status;4157 if (status === "skipped") project.skipped++;4258 else if (status === "passed") project.passed++;4359 if (results.length > 1 && results.some((result) => result.status === "passed")) project.flaky++;60+ projects.set(projectName, project);4461 }45- projects.set(suite.title, project);4662 }4763 visit(suite.suites);4864 }@@ -94,6 +110,9 @@94110 }95111 if (project.flaky > 0) {96112 errors.push(`project "${name}" has ${project.flaky} flaky retry/retr(y)ies`);113+ }114+ if (project.passed !== project.totalTests) {115+ errors.push(`project "${name}" has ${project.totalTests - project.passed} test(s) that did not pass`);97116 }98117 }99118 scripts/run-release-browser-gate.ts+18 −4
@@ -1,16 +1,30 @@11 import { resolveBin } from "./release-lib";22 import { assertValidPlaywrightJsonReport } from "./release-gate";33 4-const result = Bun.spawnSync([4+const chromeResult = Bun.spawnSync([55 resolveBin("playwright"),66 "test",77 "--project=chrome-release",8- "--project=firefox-release",98 "--reporter=json",109 ], {1110 stdout: "pipe",1211 stderr: "inherit",1312 env: { ...process.env, PEACH_RELEASE_BROWSER_GATE: "1" },1413 });15-if (result.exitCode !== 0) process.exit(result.exitCode);16-assertValidPlaywrightJsonReport(JSON.parse(result.stdout.toString()));14+if (chromeResult.exitCode !== 0) {15+ process.stderr.write(chromeResult.stdout);16+ process.exit(chromeResult.exitCode);17+}18+assertValidPlaywrightJsonReport(JSON.parse(chromeResult.stdout.toString()));19+20+for (const script of [21+ "scripts/test-firefox-sender-context.mjs",22+ "scripts/test-firefox-passkeys.mjs",23+]) {24+ const firefoxResult = Bun.spawnSync(["node", script], {25+ stdout: "inherit",26+ stderr: "inherit",27+ env: process.env,28+ });29+ if (firefoxResult.exitCode !== 0) process.exit(firefoxResult.exitCode);30+}scripts/test-firefox-passkeys.mjs+86 −6
@@ -29,6 +29,7 @@2929 const WEB_EXT = join(ROOT, "node_modules/.bin/web-ext");3030 const TEST_BACKGROUND = join(ROOT, "tests/fixtures/firefox-passkey-background.js");3131 const CONTEXT_PROBE = join(ROOT, "tests/fixtures/firefox-passkey-context-probe.js");32+const SENDER_PROBE = join(ROOT, "tests/fixtures/firefox-sender-probe.js");3233 const CREATE_CREDENTIAL_ID = "AQIDBA";3334 const GET_CREDENTIAL_ID = "BQYHCA";3435 const EXTENSION_ID = "[email protected]";@@ -65,7 +66,7 @@6566 const deadline = Date.now() + 30_000;6667 while (Date.now() < deadline) {6768 try {68- const response = await fetchWithTimeout(`http://127.0.0.1:${port}/status`, {}, 500);69+ const response = await fetchWithTimeout(`http://127.0.0.1:${port}/status`, {}, 5000);6970 if (response.ok && (await response.json()).value?.ready) return;7071 } catch {7172 // geckodriver is still starting.@@ -104,6 +105,24 @@104105 return body.value;105106 }106107 108+async function stopDriver(driver) {109+ if (!driver || driver.exitCode !== null || driver.signalCode !== null) return;110+ const exited = new Promise((resolveExit) => driver.once("exit", resolveExit));111+ driver.kill("SIGTERM");112+ let timeoutId;113+ const stopped = await Promise.race([114+ exited.then(() => true),115+ new Promise((resolveTimeout) => {116+ timeoutId = setTimeout(() => resolveTimeout(false), 5000);117+ }),118+ ]);119+ clearTimeout(timeoutId);120+ if (!stopped && driver.exitCode === null && driver.signalCode === null) {121+ driver.kill("SIGKILL");122+ await exited;123+ }124+}125+107126 async function buildTestExtension(tempRoot) {108127 const sourceDir = join(tempRoot, "extension");109128 const artifactsDir = join(tempRoot, "artifacts");@@ -111,17 +130,25 @@111130 await mkdir(artifactsDir, { recursive: true });112131 await cp(TEST_BACKGROUND, join(sourceDir, "firefox-passkey-background.js"));113132 await cp(CONTEXT_PROBE, join(sourceDir, "firefox-passkey-context-probe.js"));133+ await cp(SENDER_PROBE, join(sourceDir, "firefox-sender-probe.js"));114134 115135 const manifestPath = join(sourceDir, "manifest.json");116136 const manifest = JSON.parse(await readFile(manifestPath, "utf8"));117- manifest.background = {118- scripts: ["firefox-passkey-background.js"],119- };137+ manifest.background.scripts = [138+ "firefox-passkey-background.js",139+ ...manifest.background.scripts,140+ ];120141 manifest.content_scripts.push({121142 matches: ["<all_urls>"],122143 js: ["firefox-passkey-context-probe.js"],123144 run_at: "document_start",124145 });146+ manifest.content_scripts.push({147+ matches: ["<all_urls>"],148+ js: ["firefox-sender-probe.js"],149+ run_at: "document_start",150+ all_frames: false,151+ });125152 await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);126153 127154 await execFileAsync(WEB_EXT, [@@ -150,6 +177,29 @@150177 }151178 });152179 180+ const senderSource = "peach-firefox-sender-probe";181+ const sendSenderMessage = (message) => new Promise((resolve) => {182+ const id = crypto.randomUUID();183+ const timer = setTimeout(() => {184+ window.removeEventListener("message", onResponse);185+ resolve({ ok: false, error: "probe_timeout" });186+ }, 5000);187+ function onResponse(event) {188+ let response;189+ try {190+ response = JSON.parse(event.data);191+ } catch {192+ return;193+ }194+ if (response?.source !== senderSource + ":response" || response.id !== id) return;195+ clearTimeout(timer);196+ window.removeEventListener("message", onResponse);197+ resolve(response.response);198+ }199+ window.addEventListener("message", onResponse);200+ window.postMessage(JSON.stringify({ source: senderSource, id, message }), "*");201+ });202+153203 // 4. Exercise both wrappers against the test-only background responder.154204 // Abort quickly if the native authenticator receives either request.155205 const createAbort = new AbortController();@@ -193,6 +243,13 @@193243 clearTimeout(getTimer);194244 }195245 246+ const [allowedSender, entriesSender, recoverySender, storageSender] = await Promise.all([247+ sendSenderMessage({ type: "GET_SETTINGS" }),248+ sendSenderMessage({ type: "GET_ENTRIES" }),249+ sendSenderMessage({ type: "GET_RECOVERY_WORDS" }),250+ sendSenderMessage({ type: "STORAGE_SESSION" }),251+ ]);252+196253 done({197254 pageOrigin: location.origin,198255 context: JSON.parse(document.documentElement.dataset.peachFirefoxPasskeyContext ?? "null"),@@ -202,6 +259,11 @@202259 getResult,203260 getError,204261 passkeyMessages,262+ senderProbeReady: document.documentElement.dataset.peachFirefoxSenderProbe ?? null,263+ allowedSender,264+ entriesSender,265+ recoverySender,266+ storageSender,205267 });206268 })().catch((error) => done({207269 probeError: error?.stack ?? error?.message ?? String(error),@@ -366,11 +428,29 @@366428 failures.push(`page observed passkey postMessage traffic: ${JSON.stringify(result.passkeyMessages)}`);367429 }368430 431+ if (result.senderProbeReady !== "ready") {432+ failures.push(`Firefox sender-context probe did not execute: ${JSON.stringify(result)}`);433+ }434+ if (Object.hasOwn(result.storageSender ?? {}, "peach_session_dk")) {435+ failures.push(`Firefox content script accessed the session escrow key: ${JSON.stringify(result.storageSender)}`);436+ }437+ if (result.allowedSender?.error === "invalid_sender") {438+ failures.push("Content-script-allowed GET_SETTINGS was rejected");439+ }440+ for (const [name, response] of [441+ ["GET_ENTRIES", result.entriesSender],442+ ["GET_RECOVERY_WORDS", result.recoverySender],443+ ]) {444+ if (response?.error !== "invalid_sender") {445+ failures.push(`${name} was not rejected from the real Firefox content-script sender: ${JSON.stringify(response)}`);446+ }447+ }448+369449 if (failures.length > 0) {370450 throw new Error(`Firefox passkey matrix failures:\n - ${failures.join("\n - ")}\n Context: ${JSON.stringify(result.context)}\n Add-on: ${JSON.stringify(addonInfo)}`);371451 }372452 373- console.log("Firefox passkey matrix passed: exportFunction bridge returned extension credentials and emitted no forgeable postMessage traffic.");453+ console.log("Firefox security matrix passed: sender authorization and storage isolation held; exportFunction returned extension credentials with no forgeable passkey postMessage traffic.");374454 } finally {375455 if (driver && sessionId) {376456 try {@@ -382,7 +462,7 @@382462 if (fixtureServer) {383463 await new Promise((resolveClose) => fixtureServer.close(resolveClose));384464 }385- driver?.kill("SIGTERM");465+ await stopDriver(driver);386466 await rm(tempRoot, { recursive: true, force: true });387467 }388468 }scripts/test-firefox-sender-context.mjs+83 −6
@@ -4,6 +4,7 @@44 import { promisify } from "node:util";55 import { cp, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";66 import { existsSync } from "node:fs";7+import { createServer as createHttpServer } from "node:http";78 import { tmpdir } from "node:os";89 import { join, resolve } from "node:path";910 import net from "node:net";@@ -50,7 +51,7 @@5051 const deadline = Date.now() + 30_000;5152 while (Date.now() < deadline) {5253 try {53- const response = await fetchWithTimeout(`http://127.0.0.1:${port}/status`, {}, 500);54+ const response = await fetchWithTimeout(`http://127.0.0.1:${port}/status`, {}, 5000);5455 if (response.ok && (await response.json()).value?.ready) return;5556 } catch {5657 if (driver.exitCode !== null || driver.signalCode !== null) {@@ -93,6 +94,24 @@9394 return body.value;9495 }9596 97+async function stopDriver(driver) {98+ if (!driver || driver.exitCode !== null || driver.signalCode !== null) return;99+ const exited = new Promise((resolveExit) => driver.once("exit", resolveExit));100+ driver.kill("SIGTERM");101+ let timeoutId;102+ const stopped = await Promise.race([103+ exited.then(() => true),104+ new Promise((resolveTimeout) => {105+ timeoutId = setTimeout(() => resolveTimeout(false), 5000);106+ }),107+ ]);108+ clearTimeout(timeoutId);109+ if (!stopped && driver.exitCode === null && driver.signalCode === null) {110+ driver.kill("SIGKILL");111+ await exited;112+ }113+}114+96115 async function buildTestExtension(tempRoot) {97116 const sourceDir = join(tempRoot, "extension");98117 const artifactsDir = join(tempRoot, "artifacts");@@ -129,6 +148,7 @@129148 let driver;130149 let driverPort;131150 let sessionId;151+ let fixtureServer;132152 try {133153 const xpiPath = await buildTestExtension(tempRoot);134154 const firefoxBinary = findFirefoxBinary();@@ -177,6 +197,7 @@177197 "gfx.webrender.software": false,178198 "layers.acceleration.disabled": true,179199 "media.hardware-video-decoding.force-enabled": false,200+ "remote.system-access-check.enabled": false,180201 },181202 },182203 };@@ -186,19 +207,72 @@186207 });187208 sessionId = session.sessionId;188209 210+ fixtureServer = createHttpServer((_request, response) => {211+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });212+ response.end("<!doctype html><html><head><title>Peach sender test</title></head><body></body></html>");213+ });214+ await new Promise((resolveListen, rejectListen) => {215+ fixtureServer.once("error", rejectListen);216+ fixtureServer.listen(0, "127.0.0.1", resolveListen);217+ });218+ const fixtureAddress = fixtureServer.address();219+ if (!fixtureAddress || typeof fixtureAddress === "string") {220+ throw new Error("Could not allocate the Firefox fixture server port");221+ }222+ const fixtureOrigin = `http://127.0.0.1:${fixtureAddress.port}`;223+189224 const install = await webdriverRequest(port, `/session/${sessionId}/moz/addon/install`, {190225 method: "POST",191226 body: JSON.stringify({192- addon: (await readFile(xpiPath)).toString("base64"),227+ path: xpiPath,193228 temporary: true,194229 }),195230 });196231 if (install?.id && install.id !== EXTENSION_ID) {197232 throw new Error(`Unexpected Firefox add-on id: ${install.id}`);198233 }234+235+ await webdriverRequest(port, `/session/${sessionId}/moz/context`, {236+ method: "POST",237+ body: JSON.stringify({ context: "chrome" }),238+ });239+ const addonInfo = await webdriverRequest(port, `/session/${sessionId}/execute/async`, {240+ method: "POST",241+ body: JSON.stringify({242+ script: `243+ const done = arguments[arguments.length - 1];244+ const { AddonManager } = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs");245+ const { ExtensionPermissions } = ChromeUtils.importESModule("resource://gre/modules/ExtensionPermissions.sys.mjs");246+ AddonManager.getAddonByID(arguments[0]).then(async (addon) => {247+ if (!addon) return null;248+ const policy = WebExtensionPolicy.getByID(addon.id);249+ await ExtensionPermissions.add(250+ addon.id,251+ { permissions: [], origins: ["<all_urls>"] },252+ policy.extension,253+ );254+ await addon.reload();255+ const reloadedAddon = await AddonManager.getAddonByID(addon.id);256+ return {257+ active: reloadedAddon.isActive,258+ permissions: await ExtensionPermissions.get(addon.id),259+ };260+ }).then(done, (error) => done({ error: String(error), stack: error?.stack }));261+ `,262+ args: [EXTENSION_ID],263+ }),264+ });265+ await webdriverRequest(port, `/session/${sessionId}/moz/context`, {266+ method: "POST",267+ body: JSON.stringify({ context: "content" }),268+ });269+ if (!addonInfo?.active || !addonInfo.permissions?.origins?.includes("<all_urls>")) {270+ throw new Error(`Firefox add-on host permission was not granted: ${JSON.stringify(addonInfo)}`);271+ }272+199273 await webdriverRequest(port, `/session/${sessionId}/url`, {200274 method: "POST",201- body: JSON.stringify({ url: "https://example.com/" }),275+ body: JSON.stringify({ url: `${fixtureOrigin}/` }),202276 });203277 // Firefox may finish navigation before document_start content scripts have204278 // registered their relay listener; wait for the extension boundary to settle.@@ -248,13 +322,13 @@248322 }),249323 });250324 251- if (result.pageOrigin !== "https://example.com") {325+ if (result.pageOrigin !== fixtureOrigin) {252326 throw new Error(`Unexpected page origin: ${result.pageOrigin}`);253327 }254328 if (result.probeReady !== "ready") {255329 throw new Error(`Firefox content-script probe did not execute: ${JSON.stringify(result)}`);256330 }257- if (Object.prototype.hasOwnProperty.call(result.storage ?? {}, "peach_session_dk")) {331+ if (Object.hasOwn(result.storage ?? {}, "peach_session_dk")) {258332 throw new Error(`Firefox content-script accessed the session escrow key: ${JSON.stringify(result.storage)}`);259333 }260334 if (result.allowed?.error === "invalid_sender") {@@ -274,7 +348,10 @@274348 // The browser may already have exited after a failed startup.275349 }276350 }277- driver?.kill("SIGTERM");351+ if (fixtureServer) {352+ await new Promise((resolveClose) => fixtureServer.close(resolveClose));353+ }354+ await stopDriver(driver);278355 await rm(tempRoot, { recursive: true, force: true });279356 }280357 }tests/helpers/vault.ts+5 −5
@@ -9,10 +9,10 @@99 * mode is now a loud error instead of a silent pass.1010 */1111 import { chromium, expect, type BrowserContext, type Page } from "@playwright/test";12-import { fileURLToPath } from "url";13-import { dirname, join } from "path";14-import os from "os";15-import fs from "fs/promises";12+import { fileURLToPath } from "node:url";13+import { dirname, join } from "node:path";14+import os from "node:os";15+import fs from "node:fs/promises";1616 1717 const __filename = fileURLToPath(import.meta.url);1818 const __dirname = dirname(__filename);@@ -31,7 +31,7 @@3131 await fs.mkdir(userDataDir, { recursive: true });3232 return chromium.launchPersistentContext(userDataDir, {3333 executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,34- headless: false,34+ headless: process.env.PEACH_RELEASE_BROWSER_GATE === "1",3535 args: [3636 `--load-extension=${EXTENSION_PATH}`,3737 `--disable-extensions-except=${EXTENSION_PATH}`,