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
11 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 −5
manifest.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": {

view file

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",

view file

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",

view file

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/,

view file

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 ];

view file

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 

view file

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 

view file

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+}

view file

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 }

view file

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 }

view file

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}`,

view file