From 4f1fd76d999e9de0661014f5a538e19064a1bab7 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:32:07 -0400 Subject: [PATCH 1/4] Fix Firefox lifecycle attachment and prepare 1.4.11 release --- .github/workflows/release.yml | 4 +- .github/workflows/verify.yml | 34 ++++++++ CHANGELOG.md | 9 ++ PORT_STATUS.md | 18 ++++ README.md | 12 +++ RELEASE_1.4.11.md | 49 +++++++++++ extension/firefox-compat.js | 57 +++++++++++- extension/manifest.json | 2 +- native-host/Cargo.lock | 2 +- native-host/Cargo.toml | 2 +- npm/package.json | 2 +- package.json | 2 +- tests/test-firefox-compat.mjs | 35 +++++++- tests/test-firefox-lifecycle-live.mjs | 119 ++++++++++++++++++++++++++ version.json | 2 +- 15 files changed, 336 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/verify.yml create mode 100644 RELEASE_1.4.11.md create mode 100644 tests/test-firefox-lifecycle-live.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b6000bb..40169bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -221,8 +221,8 @@ jobs: files: release/* publish-npm: - if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' - needs: npm-package + if: github.ref_type == 'tag' + needs: [npm-package, publish] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..9cb41a2 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,34 @@ +name: Verify +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-14, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - uses: dtolnay/rust-toolchain@stable + - run: npm test + - run: npm test --prefix npm + - run: cargo test --locked --manifest-path native-host/Cargo.toml + - name: Package extension and matching review source + if: runner.os == 'Windows' + run: npm run package + - name: Pack npm installer + if: runner.os == 'Windows' + run: npm pack ./npm --pack-destination dist + - uses: actions/upload-artifact@v4 + if: runner.os == 'Windows' + with: + name: release-candidate + path: dist/* diff --git a/CHANGELOG.md b/CHANGELOG.md index c37f449..75bd34b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ This project follows semantic versioning. The Firefox add-on and native companio always share one version and are released from a matching `vMAJOR.MINOR.PATCH` Git tag. +## 1.4.11 - Unreleased + +- Support `Page.setLifecycleEventsEnabled` during Codex tab attachment, fixing + the unsupported-command error that prevented direct Firefox/Zen control. +- Translate navigation init, DOMContentLoaded, and load notifications into + session-scoped lifecycle events with consistent frame loader IDs. +- Cover background-tab attachment, lifecycle toggling, and detach cleanup in + the protocol regression suite. Attachment does not activate the target tab. + ## 1.4.10 - 2026-08-15 - Completed the strict-CSP Browser Use path for `playwright.domSnapshot()`, diff --git a/PORT_STATUS.md b/PORT_STATUS.md index 4fcb7f4..8fbe390 100644 --- a/PORT_STATUS.md +++ b/PORT_STATUS.md @@ -69,3 +69,21 @@ Developer ID-sign, notarize, and staple the macOS package when the repository signing secrets are configured. Unsigned workflow-dispatch artifacts remain suitable for development testing but should not be presented as production installers. + +## 1.4.11 lifecycle compatibility + +`Page.setLifecycleEventsEnabled` accepts enable/disable requests without +activating the tab. Firefox webNavigation notifications provide `init`, +`DOMContentLoaded`, and `load` events, with a loader ID shared by the committed +frame and frame tree. Subscriptions are cleared on detach and tab close. +Network-idle lifecycle events and replay of events predating subscription are +not synthesized. Screenshot fallbacks elsewhere can still briefly activate a +tab; this change only makes attachment and lifecycle subscription passive. + +Regression: the updated protocol test fails against 1.4.10 with the exact +`Page.setLifecycleEventsEnabled` error and passes against the patched source. + +The isolated headless Zen smoke test passed on macOS on 2026-09-10: real +WebExtension attach, lifecycle notifications, matching loader IDs, and unchanged +foreground tab. The signed-in Codex transport has not been retested in the +user's profile; the installed add-on remains untouched. diff --git a/README.md b/README.md index 4407732..7682a24 100644 --- a/README.md +++ b/README.md @@ -179,3 +179,15 @@ npm run version:set -- MAJOR.MINOR.PATCH Read [PRIVACY.md](PRIVACY.md) for the data-handling disclosure. The native adapter pins its relay origin to the official OpenAI extension ID and rejects unrelated messages. `extension/codex-sidepanel` and `extension/background.js` are OpenAI's packaged distribution, not a clean-room source reimplementation. Those upstream assets remain subject to OpenAI's applicable terms. The compatibility code in this repository is provided for review and development; no additional license is granted for the bundled upstream assets. + +### Isolated lifecycle smoke test + +```sh +FIREFOX_BINARY=/path/to/firefox node tests/test-firefox-lifecycle-live.mjs +# macOS Zen: /Applications/Zen.app/Contents/MacOS/zen +``` + +This optional test uses `web-ext` and a disposable headless profile, never your +signed-in profile. It exercises the real compatibility layer against local +HTTP fixtures and verifies lifecycle delivery and foreground-tab preservation. +It does not exercise the signed-in Codex native transport. diff --git a/RELEASE_1.4.11.md b/RELEASE_1.4.11.md new file mode 100644 index 0000000..ea3d000 --- /dev/null +++ b/RELEASE_1.4.11.md @@ -0,0 +1,49 @@ +# 1.4.11 release candidate + +Fixes direct Codex tab attachment failing with +`Firefox CDP compatibility layer does not implement Page.setLifecycleEventsEnabled`. +The add-on and native/npm companion versions are synchronized at 1.4.11. +No new add-on permissions or upstream bundle changes are included. + +## Verification + +- `npm test`: passed, including the regression that fails against 1.4.10. +- `npm test --prefix npm`: passed on macOS (Linux-only install tests run in CI). +- `FIREFOX_BINARY=/Applications/Zen.app/Contents/MacOS/zen node tests/test-firefox-lifecycle-live.mjs`: + passed using a disposable headless profile, with foreground-tab preservation. +- `npx --yes web-ext lint --source-dir extension --no-input`: zero errors, + zero notices, 72 warnings from the existing distribution. +- PR Verify workflow tests Windows, macOS, and Linux and attaches a + `release-candidate` artifact containing the extension ZIP, matching source ZIP, + SHA-256 files, and npm tarball. + +The isolated test verifies real Firefox WebExtension behavior, not the full +signed-in Codex transport. Before publishing, test the release candidate in a +separate signed-in development profile: attach to an inactive tab with +`cua.getTab`, read its state, and confirm the user's active tab stays selected. +The user's installed extension and native bridge have not been replaced. + +## Publication sequence + +1. Merge the reviewed PR and confirm Verify is green for the merged commit. +2. Update the changelog's Unreleased date if needed. Tag the chosen release + commit `v1.4.11` and push that tag to run Release. +3. Wait for all release builds, GitHub asset publication, npm provenance + publication, and macOS/Linux public-install smoke tests to succeed. + npm now waits for the GitHub assets its installer downloads; manual + workflow_dispatch only builds candidates and does not publish npm. +4. Submit `codex-computer-use-firefox-zen-1.4.11.zip` as a new version of the + existing AMO listing, keeping `codex-computer-use-firefox-zen@sunkenintime`. + Attach the matching `-source.zip`, use AMO_LISTING.md reviewer notes, and + include the release notes below. Wait for Mozilla validation/signing. +5. Verify the signed AMO version with `codex-firefox-bridge@1.4.11 doctor` and + repeat background-tab attachment on a separate development profile. + +No tag, public npm version, or AMO submission is created by this PR. + +## Store release notes + +Fixed a compatibility error that prevented Codex from connecting directly to +Firefox and Zen tabs. Added page lifecycle notifications and regression +coverage to keep tab attachment from switching the active tab. Update the +native bridge to 1.4.11 to match the add-on version. diff --git a/extension/firefox-compat.js b/extension/firefox-compat.js index 976e5c5..432b94d 100644 --- a/extension/firefox-compat.js +++ b/extension/firefox-compat.js @@ -419,6 +419,39 @@ }; const debuggerAttachedTabs = new Set(); + const lifecycleEnabledFrames = new Set(); + const loaderIdsByFrame = new Map(); + let nextLoaderId = 1; + + function loaderIdForFrame(tabId, frameId) { + const key = frameKey(tabId, frameId); + if (!loaderIdsByFrame.has(key)) { + loaderIdsByFrame.set(key, `firefox-loader-${tabId}-${frameId}-${nextLoaderId++}`); + } + return loaderIdsByFrame.get(key); + } + + function clearLifecycleState(tabId) { + for (const key of loaderIdsByFrame.keys()) { + if (key.startsWith(`${tabId}:`)) loaderIdsByFrame.delete(key); + } + for (const key of lifecycleEnabledFrames) { + if (key.startsWith(`${tabId}:`)) lifecycleEnabledFrames.delete(key); + } + } + + function emitLifecycleEvent(tabId, frameId, name) { + const sessionId = frameId === 0 ? null : (sessionIdByFrame.get(frameKey(tabId, frameId)) ?? null); + // Frames without a separate target session belong to the root page session. + const enabledFrame = sessionId == null ? 0 : frameId; + if (!lifecycleEnabledFrames.has(frameKey(tabId, enabledFrame))) return; + emitDebuggerEvent(tabId, "Page.lifecycleEvent", { + frameId: cdpFrameId(tabId, frameId), + loaderId: loaderIdForFrame(tabId, frameId), + name, + timestamp: performance.now() / 1000, + }, sessionId); + } const debuggerOnEvent = new CompatEvent(); const debuggerOnDetach = new CompatEvent(); const initScriptsByTab = new Map(); @@ -2129,7 +2162,7 @@ const url = entry.url || (entry.frameId === 0 ? topUrl : "about:blank"); const frame = { id: cdpFrameId(tabId, entry.frameId), - loaderId: `firefox-loader-${tabId}-${entry.frameId}`, + loaderId: loaderIdForFrame(tabId, entry.frameId), url, name: frameMetadata.name ?? "", domainAndRegistry: "", @@ -3259,6 +3292,7 @@ case "Target.detachFromTarget": { const session = sessionFrameById.get(params.sessionId); if (session?.tabId === tabId) { + lifecycleEnabledFrames.delete(frameKey(tabId, session.frameId)); sessionFrameById.delete(params.sessionId); sessionIdByFrame.delete(frameKey(tabId, session.frameId)); emitDebuggerEvent(tabId, "Target.detachedFromTarget", { sessionId: params.sessionId, targetId: frameTargetId(tabId, session.frameId) }); @@ -3336,6 +3370,10 @@ ); return {}; } + case "Page.setLifecycleEventsEnabled": + if (params.enabled === true) lifecycleEnabledFrames.add(frameKey(tabId, frameId)); + else lifecycleEnabledFrames.delete(frameKey(tabId, frameId)); + return {}; case "Page.enable": case "Page.disable": case "Page.setAdBlockingEnabled": @@ -3877,6 +3915,7 @@ async detach(debuggee) { const tabId = resolveTabId(debuggee); const wasAttached = debuggerAttachedTabs.delete(tabId); + clearLifecycleState(tabId); await clearViewportOverride(tabId); emulationStateByTab.delete(tabId); await applyEmulationState(tabId).catch(() => {}); @@ -4114,7 +4153,7 @@ emitDebuggerEvent(tabId, "Page.frameNavigated", { frame: { id: cdpFrameId(tabId, 0), - loaderId: `firefox-loader-${tabId}-0-${Date.now()}`, + loaderId: loaderIdForFrame(tabId, 0), url: changeInfo.url, securityOrigin: (() => { try { return new URL(changeInfo.url).origin; } catch { return "null"; } @@ -4136,6 +4175,7 @@ }); firefox.tabs.onRemoved.addListener((tabId) => { + clearLifecycleState(tabId); navigationStateByTab.delete(tabId); initScriptsByTab.delete(tabId); bindingNamesByTab.delete(tabId); @@ -4182,6 +4222,8 @@ const { tabId, frameId } = details; if (!debuggerAttachedTabs.has(tabId)) return; const sessionId = frameId === 0 ? null : (sessionIdByFrame.get(frameKey(tabId, frameId)) ?? null); + loaderIdsByFrame.set(frameKey(tabId, frameId), `firefox-loader-${tabId}-${frameId}-${nextLoaderId++}`); + emitLifecycleEvent(tabId, frameId, "init"); emitDebuggerEvent(tabId, "Runtime.executionContextDestroyed", { executionContextId: executionContextIdForFrame(frameId), executionContextUniqueId: `firefox-context-${tabId}-${frameId}`, @@ -4196,7 +4238,7 @@ frame: { id: cdpFrameId(tabId, frameId), ...(frameId === 0 ? {} : { parentId: cdpFrameId(tabId, details.parentFrameId) }), - loaderId: `firefox-loader-${tabId}-${frameId}-${Date.now()}`, + loaderId: loaderIdForFrame(tabId, frameId), url: details.url, securityOrigin: (() => { try { return new URL(details.url).origin; } catch { return "null"; } })(), mimeType: "text/html", @@ -4211,8 +4253,15 @@ })(); }); + firefox.webNavigation.onDOMContentLoaded.addListener(({ tabId, frameId }) => { + if (!debuggerAttachedTabs.has(tabId)) return; + emitLifecycleEvent(tabId, frameId, "DOMContentLoaded"); + }); + firefox.webNavigation.onCompleted.addListener(({ tabId, frameId }) => { - if (!debuggerAttachedTabs.has(tabId) || frameId === 0) return; + if (!debuggerAttachedTabs.has(tabId)) return; + emitLifecycleEvent(tabId, frameId, "load"); + if (frameId === 0) return; const sessionId = sessionIdByFrame.get(frameKey(tabId, frameId)) ?? null; emitDebuggerEvent(tabId, "Page.frameStoppedLoading", { frameId: cdpFrameId(tabId, frameId) }, sessionId); }); diff --git a/extension/manifest.json b/extension/manifest.json index abcd7a4..67bccf8 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Codex Computer Use for Firefox", "description": "Bring Codex computer use and its signed-in sidebar to Firefox and Zen Browser.", - "version": "1.4.10", + "version": "1.4.11", "icons": { "16": "images/firefox-zen-icon16.png", "24": "images/firefox-zen-icon24.png", diff --git a/native-host/Cargo.lock b/native-host/Cargo.lock index e9708c0..ae1d2e8 100644 --- a/native-host/Cargo.lock +++ b/native-host/Cargo.lock @@ -31,7 +31,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "codex-firefox-bridge" -version = "1.4.10" +version = "1.4.11" dependencies = [ "base64", "regex", diff --git a/native-host/Cargo.toml b/native-host/Cargo.toml index bf1ff56..aed406b 100644 --- a/native-host/Cargo.toml +++ b/native-host/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-firefox-bridge" -version = "1.4.10" +version = "1.4.11" edition = "2021" description = "Firefox native-messaging bridge for the installed OpenAI Codex extension host" license = "UNLICENSED" diff --git a/npm/package.json b/npm/package.json index a33415f..6cfe308 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "codex-firefox-bridge", - "version": "1.4.10", + "version": "1.4.11", "description": "Install and manage the Codex native-messaging bridge for Firefox and Zen Browser", "type": "module", "bin": { diff --git a/package.json b/package.json index 83e26b3..5f63f7a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "codex-computer-use-firefox-zen", "private": true, "type": "module", - "version": "1.4.10", + "version": "1.4.11", "description": "Codex computer-use compatibility port for Firefox and Zen Browser", "scripts": { "check": "node scripts/check-version.mjs && node scripts/verify-extension.mjs", diff --git a/tests/test-firefox-compat.mjs b/tests/test-firefox-compat.mjs index 793c9e3..f43460d 100644 --- a/tests/test-firefox-compat.mjs +++ b/tests/test-firefox-compat.mjs @@ -190,7 +190,7 @@ const browser = { }, windows: { async get() { return { id: 10, state: "normal", width: 1200, height: 800, left: 0, top: 0 }; }, async update() { return {}; } }, webNavigation: { - onCommitted: new EventMock(), onCompleted: new EventMock(), + onCommitted: new EventMock(), onDOMContentLoaded: new EventMock(), onCompleted: new EventMock(), async getAllFrames() { return [ { frameId: 0, parentFrameId: -1, url: "https://top.test/" }, @@ -670,4 +670,37 @@ const unpausedResponse = beforeRequest({ requestId: "req-3", tabId: 1, frameId: assert.equal(JSON.stringify(unpausedResponse), "{}", "Empty Fetch patterns must clear interception instead of pausing every request."); assert.equal(events.filter((event) => event.method === "Fetch.requestPaused").length, pauseCount); +// Reproduce Codex attachment without activating the user's background target. +await compat.debugger.detach({ tabId: 1 }); +const updatesBeforeAttach = tabUpdateCalls.length; +targetTabActive = false; +await compat.debugger.attach({ tabId: 1 }); +await compat.debugger.sendCommand({ tabId: 1 }, "Page.enable", {}); +await compat.debugger.sendCommand({ tabId: 1 }, "Page.setLifecycleEventsEnabled", { enabled: true }); +const lifecycleStart = events.length; +const navigation = { tabId: 1, frameId: 0, parentFrameId: -1, url: "https://top.test/next" }; +browser.webNavigation.onCommitted.emit(navigation); +browser.webNavigation.onDOMContentLoaded.emit(navigation); +browser.webNavigation.onCompleted.emit(navigation); +const lifecycle = events.slice(lifecycleStart).filter(event => event.method === "Page.lifecycleEvent"); +assert.deepEqual(lifecycle.map(event => event.params.name), ["init", "DOMContentLoaded", "load"]); +assert.equal(new Set(lifecycle.map(event => event.params.loaderId)).size, 1); +assert.ok(lifecycle.every(event => Number.isFinite(event.params.timestamp) && event.params.frameId === "firefox-frame-1")); +const navigated = events.slice(lifecycleStart).find(event => event.method === "Page.frameNavigated"); +assert.equal(navigated.params.frame.loaderId, lifecycle[0].params.loaderId); +const lifecycleTree = await compat.debugger.sendCommand({ tabId: 1 }, "Page.getFrameTree", {}); +assert.equal(lifecycleTree.frameTree.frame.loaderId, lifecycle[0].params.loaderId); +assert.equal(tabUpdateCalls.length, updatesBeforeAttach, "Attaching and subscribing must not activate a background tab."); +browser.webNavigation.onCommitted.emit(navigation); +assert.notEqual(events.findLast(event => event.method === "Page.lifecycleEvent").params.loaderId, lifecycle[0].params.loaderId); +await compat.debugger.sendCommand({ tabId: 1 }, "Page.setLifecycleEventsEnabled", { enabled: false }); +const disabledCount = events.filter(event => event.method === "Page.lifecycleEvent").length; +browser.webNavigation.onCompleted.emit(navigation); +assert.equal(events.filter(event => event.method === "Page.lifecycleEvent").length, disabledCount); +await compat.debugger.sendCommand({ tabId: 1 }, "Page.setLifecycleEventsEnabled", { enabled: true }); +await compat.debugger.detach({ tabId: 1 }); +await compat.debugger.attach({ tabId: 1 }); +browser.webNavigation.onCompleted.emit(navigation); +assert.equal(events.filter(event => event.method === "Page.lifecycleEvent").length, disabledCount, "Detach must clear lifecycle subscriptions."); + console.log(JSON.stringify({ ok: true, bridgeIdentity: true, toolbarSettings: true, nativeSidebarTracking: true, hostAccessPreflight: true, screenshotFallback: true, frameTree: true, childExecution: true, cspSafeInput: true, strictCspBrowserUse: true, liveNetworkEvents: true, responseBody: true, fetchInterception: true, fetchEmptyPatternClear: true }, null, 2)); diff --git a/tests/test-firefox-lifecycle-live.mjs b/tests/test-firefox-lifecycle-live.mjs new file mode 100644 index 0000000..3e12386 --- /dev/null +++ b/tests/test-firefox-lifecycle-live.mjs @@ -0,0 +1,119 @@ +import fs from "node:fs"; +import path from "node:path"; +import http from "node:http"; +import os from "node:os"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const firefoxBinary = process.env.FIREFOX_BINARY; +if (!firefoxBinary) + throw new Error( + "Set FIREFOX_BINARY to a Firefox or Zen executable. This test uses a disposable headless profile.", + ); +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "firefox-lifecycle-test-")); +fs.copyFileSync( + path.join(root, "extension/firefox-compat.js"), + path.join(dir, "firefox-compat.js"), +); +const original = JSON.parse( + fs.readFileSync(path.join(root, "extension/manifest.json")), +); +const manifest = { + manifest_version: 3, + name: "Lifecycle isolated test", + version: "1.0", + browser_specific_settings: { gecko: { id: "lifecycle-test@localhost" } }, + permissions: original.permissions, + host_permissions: [""], + background: { scripts: ["firefox-compat.js", "test.js"] }, + action: {}, + content_security_policy: original.content_security_policy, +}; +fs.writeFileSync(path.join(dir, "manifest.json"), JSON.stringify(manifest)); +let finish; +const result = new Promise((r) => (finish = r)); +const server = http.createServer((req, res) => { + if (req.url === "/result") { + let body = ""; + req.on("data", (x) => (body += x)); + req.on("end", () => { + res.end("ok"); + finish(JSON.parse(body)); + }); + } else { + res.setHeader("content-type", "text/html"); + res.end( + "Lifecycle fixture

isolated lifecycle test

", + ); + } +}); +await new Promise((r) => server.listen(0, "127.0.0.1", r)); +const url = `http://127.0.0.1:${server.address().port}`; +fs.writeFileSync( + path.join(dir, "test.js"), + `(async()=>{try{ +const target=await browser.tabs.create({url:${JSON.stringify(url)},active:false}); +const foreground=await browser.tabs.create({url:${JSON.stringify(url + "/foreground")},active:true}); +for(let i=0;i<100&&(await browser.tabs.get(target.id)).status!=='complete';i++)await new Promise(r=>setTimeout(r,100)); +if((await browser.tabs.get(target.id)).status!=='complete')throw Error('Initial fixture did not load'); +const activations=[];browser.tabs.onActivated.addListener(info=>activations.push(info.tabId)); +const debuggee={tabId:target.id};const events=[]; +chrome.debugger.onEvent.addListener((source,method,params)=>{if(source.tabId===target.id)events.push({method,params});}); +await chrome.debugger.attach(debuggee); +await chrome.debugger.sendCommand(debuggee,'Page.enable',{}); +await chrome.debugger.sendCommand(debuggee,'Page.setLifecycleEventsEnabled',{enabled:true}); +await browser.tabs.update(target.id,{url:${JSON.stringify(url + "/next")}}); +for(let i=0;i<100&&!events.some(e=>e.method==='Page.lifecycleEvent'&&e.params.name==='load');i++)await new Promise(r=>setTimeout(r,100)); +const lifecycle=events.filter(e=>e.method==='Page.lifecycleEvent'); +const active=(await browser.tabs.query({active:true,currentWindow:true}))[0]; +if(active.id!==foreground.id||activations.includes(target.id))throw Error('Foreground tab changed'); +for(const name of ['init','DOMContentLoaded','load'])if(!lifecycle.some(e=>e.params.name===name))throw Error('Missing '+name); +const lastLoad=lifecycle.findLast(e=>e.params.name==='load'); +if(new Set(lifecycle.map(e=>e.params.loaderId)).size!==1)throw Error('Mixed navigation events'); +const tree=await chrome.debugger.sendCommand(debuggee,'Page.getFrameTree',{}); +if(tree.frameTree.frame.loaderId!==lastLoad.params.loaderId)throw Error('Loader mismatch'); +await fetch(${JSON.stringify(url + "/result")},{method:'POST',body:JSON.stringify({ok:true,backgroundTabPreserved:true,lifecycle:lifecycle.map(e=>e.params.name),loaderId:lastLoad.params.loaderId})}); +}catch(e){await fetch(${JSON.stringify(url + "/result")},{method:'POST',body:JSON.stringify({ok:false,error:String(e)+' '+e.stack})});}})();`, +); +const child = spawn( + "npx", + [ + "--yes", + "web-ext", + "run", + "--source-dir", + dir, + "--firefox", + firefoxBinary, + "--no-reload", + "--args=-headless", + ], + { stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32" }, +); +child.on("error", (error) => finish({ ok: false, error: String(error) })); +child.on("exit", (code, signal) => + finish({ + ok: false, + error: `web-ext exited before reporting: ${code ?? signal}`, + logs, + }), +); +let logs = ""; +child.stdout.on("data", (x) => (logs += x)); +child.stderr.on("data", (x) => (logs += x)); +const timeout = setTimeout( + () => finish({ ok: false, error: "Timed out", logs }), + 90000, +); +const outcome = await result; +clearTimeout(timeout); +try { + if (process.platform === "win32") child.kill("SIGTERM"); + else process.kill(-child.pid, "SIGTERM"); +} catch (error) { + if (error.code !== "ESRCH") throw error; +} +fs.rmSync(dir, { recursive: true, force: true }); +server.close(); +console.log(JSON.stringify(outcome, null, 2)); +process.exitCode = outcome.ok ? 0 : 1; diff --git a/version.json b/version.json index 558fa7c..491b813 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "version": "1.4.10", + "version": "1.4.11", "upstream_extension_version": "1.2.27236.6274", "native_host_name": "com.openai.codexextension", "gecko_extension_id": "codex-computer-use-firefox-zen@sunkenintime" From 6cd7415f5caebb3419ad726984093ee09dfa9590 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:45:38 -0400 Subject: [PATCH 2/4] Align network and lifecycle loaders across navigation --- CHANGELOG.md | 2 + README.md | 2 +- RELEASE_1.4.11.md | 2 +- extension/firefox-compat.js | 55 ++++++++++++++++++++++----- package.json | 3 +- tests/test-firefox-compat.mjs | 32 ++++++++++++++++ tests/test-firefox-lifecycle-live.mjs | 16 ++++++-- 7 files changed, 97 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75bd34b..d7624b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Git tag. the unsupported-command error that prevented direct Firefox/Zen control. - Translate navigation init, DOMContentLoaded, and load notifications into session-scoped lifecycle events with consistent frame loader IDs. +- Keep navigation, lifecycle, request, redirect, and response loader IDs aligned, + retaining the original document identity for late network responses. - Cover background-tab attachment, lifecycle toggling, and detach cleanup in the protocol regression suite. Attachment does not activate the target tab. diff --git a/README.md b/README.md index 7682a24..8dc4506 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ Read [PRIVACY.md](PRIVACY.md) for the data-handling disclosure. The native adapt ### Isolated lifecycle smoke test ```sh -FIREFOX_BINARY=/path/to/firefox node tests/test-firefox-lifecycle-live.mjs +FIREFOX_BINARY=/path/to/firefox npm run test:live # macOS Zen: /Applications/Zen.app/Contents/MacOS/zen ``` diff --git a/RELEASE_1.4.11.md b/RELEASE_1.4.11.md index ea3d000..234a0b8 100644 --- a/RELEASE_1.4.11.md +++ b/RELEASE_1.4.11.md @@ -9,7 +9,7 @@ No new add-on permissions or upstream bundle changes are included. - `npm test`: passed, including the regression that fails against 1.4.10. - `npm test --prefix npm`: passed on macOS (Linux-only install tests run in CI). -- `FIREFOX_BINARY=/Applications/Zen.app/Contents/MacOS/zen node tests/test-firefox-lifecycle-live.mjs`: +- `FIREFOX_BINARY=/Applications/Zen.app/Contents/MacOS/zen npm run test:live`: passed using a disposable headless profile, with foreground-tab preservation. - `npx --yes web-ext lint --source-dir extension --no-input`: zero errors, zero notices, 72 warnings from the existing distribution. diff --git a/extension/firefox-compat.js b/extension/firefox-compat.js index 432b94d..c906af1 100644 --- a/extension/firefox-compat.js +++ b/extension/firefox-compat.js @@ -421,6 +421,7 @@ const debuggerAttachedTabs = new Set(); const lifecycleEnabledFrames = new Set(); const loaderIdsByFrame = new Map(); + const pendingDocumentLoaders = new Map(); let nextLoaderId = 1; function loaderIdForFrame(tabId, frameId) { @@ -435,6 +436,9 @@ for (const key of loaderIdsByFrame.keys()) { if (key.startsWith(`${tabId}:`)) loaderIdsByFrame.delete(key); } + for (const key of pendingDocumentLoaders.keys()) { + if (key.startsWith(`${tabId}:`)) pendingDocumentLoaders.delete(key); + } for (const key of lifecycleEnabledFrames) { if (key.startsWith(`${tabId}:`)) lifecycleEnabledFrames.delete(key); } @@ -3143,6 +3147,7 @@ async function emitNetworkResources(tabId) { if (!networkEnabledTabs.has(tabId) || !debuggerAttachedTabs.has(tabId)) return; + const loaderId = loaderIdForFrame(tabId, 0); let resources; try { resources = await executeUserScript( @@ -3167,7 +3172,6 @@ seen.add(resource.url); index += 1; const requestId = `firefox-resource-${tabId}-${Date.now()}-${index}`; - const loaderId = `firefox-loader-${tabId}`; const timestamp = performance.now() / 1000; const type = cdpResourceType(resource.initiatorType); emitDebuggerEvent(tabId, "Network.requestWillBeSent", { @@ -3437,9 +3441,24 @@ case "Page.getInstallabilityErrors": return { installabilityErrors: [] }; case "Page.navigate": { - if (frameId === 0) await firefox.tabs.update(tabId, { url: params.url }); - else await executeUserScript(tabId, operationScript(`location.href = ${JSON.stringify(params.url)}; return {};`), { frameId }); - return { frameId: cdpFrameId(tabId, frameId), loaderId: `firefox-loader-${tabId}-${frameId}-${Date.now()}` }; + const key = frameKey(tabId, frameId); + const currentFrame = await firefox.webNavigation.getFrame({ tabId, frameId }).catch(() => null); + let sameDocument = false; + try { + const previous = new URL(currentFrame?.url); + const next = new URL(params.url, previous); + sameDocument = previous.href !== next.href && previous.href.split("#")[0] === next.href.split("#")[0]; + } catch {} + const loaderId = sameDocument ? null : `firefox-loader-${tabId}-${frameId}-${nextLoaderId++}`; + if (loaderId != null) pendingDocumentLoaders.set(key, { loaderId, requestId: null }); + try { + if (frameId === 0) await firefox.tabs.update(tabId, { url: params.url }); + else await executeUserScript(tabId, operationScript(`location.href = ${JSON.stringify(params.url)}; return {};`), { frameId }); + } catch (error) { + if (pendingDocumentLoaders.get(key)?.loaderId === loaderId) pendingDocumentLoaders.delete(key); + throw error; + } + return { frameId: cdpFrameId(tabId, frameId), ...(loaderId == null ? {} : { loaderId }) }; } case "Page.reload": if (frameId === 0) await firefox.tabs.reload(tabId, { bypassCache: params.ignoreCache === true }); @@ -3963,6 +3982,18 @@ type: webRequestResourceType(details.type), startedAt: details.timeStamp / 1000, }; + if (state.loaderId == null) { + const key = frameKey(details.tabId, state.frameId); + if (details.type === "main_frame" || details.type === "sub_frame") { + const pending = pendingDocumentLoaders.get(key); + state.loaderId = pending?.requestId === null + ? pending.loaderId + : `firefox-loader-${details.tabId}-${state.frameId}-${nextLoaderId++}`; + pendingDocumentLoaders.set(key, { loaderId: state.loaderId, requestId }); + } else { + state.loaderId = loaderIdForFrame(details.tabId, state.frameId); + } + } state.url = details.url; state.method = details.method ?? state.method; state.postData = requestBodyText(details.requestBody); @@ -3972,7 +4003,7 @@ if (networkEnabledTabs.has(details.tabId)) { emitDebuggerEvent(details.tabId, "Network.requestWillBeSent", { requestId, - loaderId: `firefox-loader-${details.tabId}-${state.frameId}`, + loaderId: state.loaderId, documentURL: details.documentUrl ?? details.originUrl ?? details.url, request: networkRequestFromDetails(details, state), timestamp: details.timeStamp / 1000, @@ -4040,6 +4071,7 @@ if (details.tabId < 0 || !debuggerAttachedTabs.has(details.tabId)) return {}; const requestId = cdpRequestId(details.requestId); const state = networkRequests.get(requestId) ?? { tabId: details.tabId, frameId: Math.max(0, Number(details.frameId) || 0), type: webRequestResourceType(details.type), url: details.url }; + state.loaderId ??= loaderIdForFrame(details.tabId, state.frameId); state.statusCode = details.statusCode; state.statusLine = details.statusLine; state.responseHeaders = responseHeadersForCdp(details.responseHeaders); @@ -4048,7 +4080,7 @@ if (networkEnabledTabs.has(details.tabId)) { emitDebuggerEvent(details.tabId, "Network.responseReceived", { requestId, - loaderId: `firefox-loader-${details.tabId}-${state.frameId}`, + loaderId: state.loaderId, timestamp: details.timeStamp / 1000, type: state.type, frameId: cdpFrameId(details.tabId, state.frameId), @@ -4096,7 +4128,7 @@ const state = networkRequests.get(cdpRequestId(details.requestId)); emitDebuggerEvent(details.tabId, "Network.requestWillBeSent", { requestId: cdpRequestId(details.requestId), - loaderId: `firefox-loader-${details.tabId}-${state?.frameId ?? 0}`, + loaderId: state?.loaderId ?? loaderIdForFrame(details.tabId, Math.max(0, Number(details.frameId) || 0)), documentURL: details.redirectUrl, request: { ...networkRequestFromDetails({ ...details, url: details.redirectUrl }, state), url: details.redirectUrl }, timestamp: details.timeStamp / 1000, @@ -4127,6 +4159,8 @@ firefox.webRequest.onErrorOccurred.addListener((details) => { if (!debuggerAttachedTabs.has(details.tabId)) return; + const key = frameKey(details.tabId, Math.max(0, Number(details.frameId) || 0)); + if (pendingDocumentLoaders.get(key)?.requestId === cdpRequestId(details.requestId)) pendingDocumentLoaders.delete(key); if (networkEnabledTabs.has(details.tabId)) { emitDebuggerEvent(details.tabId, "Network.loadingFailed", { requestId: cdpRequestId(details.requestId), @@ -4153,7 +4187,7 @@ emitDebuggerEvent(tabId, "Page.frameNavigated", { frame: { id: cdpFrameId(tabId, 0), - loaderId: loaderIdForFrame(tabId, 0), + loaderId: pendingDocumentLoaders.get(frameKey(tabId, 0))?.loaderId ?? loaderIdForFrame(tabId, 0), url: changeInfo.url, securityOrigin: (() => { try { return new URL(changeInfo.url).origin; } catch { return "null"; } @@ -4222,7 +4256,10 @@ const { tabId, frameId } = details; if (!debuggerAttachedTabs.has(tabId)) return; const sessionId = frameId === 0 ? null : (sessionIdByFrame.get(frameKey(tabId, frameId)) ?? null); - loaderIdsByFrame.set(frameKey(tabId, frameId), `firefox-loader-${tabId}-${frameId}-${nextLoaderId++}`); + const key = frameKey(tabId, frameId); + const pending = pendingDocumentLoaders.get(key); + loaderIdsByFrame.set(key, pending?.loaderId ?? `firefox-loader-${tabId}-${frameId}-${nextLoaderId++}`); + pendingDocumentLoaders.delete(key); emitLifecycleEvent(tabId, frameId, "init"); emitDebuggerEvent(tabId, "Runtime.executionContextDestroyed", { executionContextId: executionContextIdForFrame(frameId), diff --git a/package.json b/package.json index 5f63f7a..ee79d21 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "check": "node scripts/check-version.mjs && node scripts/verify-extension.mjs", "version:set": "node scripts/set-version.mjs", "test": "npm run check && node tests/test-companion-required.mjs && node tests/test-sidepanel-focus-compat.mjs && node tests/test-sidepanel-host-access.mjs && node tests/test-sidepanel-bootstrap.mjs && node tests/test-sidepanel-companion-setup.mjs && node tests/test-chatgpt-feature-parity.mjs && node tests/test-firefox-compat.mjs && node tests/test-editing-assets.mjs && node tests/test-native-host.mjs", - "package": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/package-extension.ps1" + "package": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/package-extension.ps1", + "test:live": "node tests/test-firefox-lifecycle-live.mjs" } } diff --git a/tests/test-firefox-compat.mjs b/tests/test-firefox-compat.mjs index f43460d..aeb5cfd 100644 --- a/tests/test-firefox-compat.mjs +++ b/tests/test-firefox-compat.mjs @@ -703,4 +703,36 @@ await compat.debugger.attach({ tabId: 1 }); browser.webNavigation.onCompleted.emit(navigation); assert.equal(events.filter(event => event.method === "Page.lifecycleEvent").length, disabledCount, "Detach must clear lifecycle subscriptions."); +const fragmentNavigation = await compat.debugger.sendCommand({ tabId: 1 }, "Page.navigate", { url: "https://top.test/#section" }); +assert.equal(fragmentNavigation.loaderId, undefined, "Fragment-only navigation must not reserve a new document loader."); + +// Document requests precede commit; redirects and late responses retain their loader. +await compat.debugger.sendCommand({ tabId: 1 }, "Network.enable", {}); +await compat.debugger.sendCommand({ tabId: 1 }, "Page.setLifecycleEventsEnabled", { enabled: true }); +for (const frameId of [0, 7]) { + const navResult = frameId === 0 + ? await compat.debugger.sendCommand({ tabId: 1 }, "Page.navigate", { url: "https://top.test/document" }) + : null; + const request = { requestId: `document-${frameId}`, tabId: 1, frameId, parentFrameId: frameId ? 0 : -1, url: "https://top.test/document", method: "GET", type: frameId ? "sub_frame" : "main_frame", timeStamp: 2000 }; + // Child navigation uses the frame's own target session in production; reserve + // only the root navigation here, then exercise child webRequest allocation. + beforeRequest(request); + const requestEvent = events.findLast(event => event.method === "Network.requestWillBeSent"); + if (frameId === 0) assert.equal(requestEvent.params.loaderId, navResult.loaderId); + webRequest.onBeforeRedirect.emit({ ...request, redirectUrl: "https://top.test/redirected", statusCode: 302 }); + assert.equal(events.findLast(event => event.method === "Network.requestWillBeSent").params.loaderId, requestEvent.params.loaderId); + beforeRequest({ ...request, url: "https://top.test/redirected" }); + browser.webNavigation.onCommitted.emit({ ...request, url: "https://top.test/redirected" }); + const committedLoader = events.findLast(event => event.method === "Page.frameNavigated").params.frame.loaderId; + assert.equal(committedLoader, requestEvent.params.loaderId); + assert.equal(events.findLast(event => event.method === "Page.lifecycleEvent").params.loaderId, committedLoader); + const subresource = { ...request, requestId: `resource-${frameId}`, type: "xmlhttprequest" }; + beforeRequest(subresource); + assert.equal(events.findLast(event => event.method === "Network.requestWillBeSent").params.loaderId, committedLoader); + // A response from the old document must keep its original loader after a new navigation. + browser.webNavigation.onCommitted.emit({ ...request, url: "https://top.test/newer" }); + webRequest.onHeadersReceived.emit({ ...subresource, statusCode: 200, responseHeaders: [] }); + assert.equal(events.findLast(event => event.method === "Network.responseReceived").params.loaderId, committedLoader); +} + console.log(JSON.stringify({ ok: true, bridgeIdentity: true, toolbarSettings: true, nativeSidebarTracking: true, hostAccessPreflight: true, screenshotFallback: true, frameTree: true, childExecution: true, cspSafeInput: true, strictCspBrowserUse: true, liveNetworkEvents: true, responseBody: true, fetchInterception: true, fetchEmptyPatternClear: true }, null, 2)); diff --git a/tests/test-firefox-lifecycle-live.mjs b/tests/test-firefox-lifecycle-live.mjs index 3e12386..7996890 100644 --- a/tests/test-firefox-lifecycle-live.mjs +++ b/tests/test-firefox-lifecycle-live.mjs @@ -3,13 +3,15 @@ import path from "node:path"; import http from "node:http"; import os from "node:os"; import { fileURLToPath } from "node:url"; -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const firefoxBinary = process.env.FIREFOX_BINARY; if (!firefoxBinary) throw new Error( "Set FIREFOX_BINARY to a Firefox or Zen executable. This test uses a disposable headless profile.", ); +if (!process.env.npm_execpath) + throw new Error("Run this test with npm run test:live."); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "firefox-lifecycle-test-")); fs.copyFileSync( path.join(root, "extension/firefox-compat.js"), @@ -61,6 +63,7 @@ const debuggee={tabId:target.id};const events=[]; chrome.debugger.onEvent.addListener((source,method,params)=>{if(source.tabId===target.id)events.push({method,params});}); await chrome.debugger.attach(debuggee); await chrome.debugger.sendCommand(debuggee,'Page.enable',{}); +await chrome.debugger.sendCommand(debuggee,'Network.enable',{}); await chrome.debugger.sendCommand(debuggee,'Page.setLifecycleEventsEnabled',{enabled:true}); await browser.tabs.update(target.id,{url:${JSON.stringify(url + "/next")}}); for(let i=0;i<100&&!events.some(e=>e.method==='Page.lifecycleEvent'&&e.params.name==='load');i++)await new Promise(r=>setTimeout(r,100)); @@ -72,13 +75,19 @@ const lastLoad=lifecycle.findLast(e=>e.params.name==='load'); if(new Set(lifecycle.map(e=>e.params.loaderId)).size!==1)throw Error('Mixed navigation events'); const tree=await chrome.debugger.sendCommand(debuggee,'Page.getFrameTree',{}); if(tree.frameTree.frame.loaderId!==lastLoad.params.loaderId)throw Error('Loader mismatch'); +const documentEvents=events.filter(e=>['Network.requestWillBeSent','Network.responseReceived'].includes(e.method)&&e.params.type==='Document'&&e.params.requestId.startsWith('firefox-request-')); +if(!documentEvents.length||documentEvents.some(e=>e.params.loaderId!==lastLoad.params.loaderId))throw Error('Network loader mismatch'); await fetch(${JSON.stringify(url + "/result")},{method:'POST',body:JSON.stringify({ok:true,backgroundTabPreserved:true,lifecycle:lifecycle.map(e=>e.params.name),loaderId:lastLoad.params.loaderId})}); }catch(e){await fetch(${JSON.stringify(url + "/result")},{method:'POST',body:JSON.stringify({ok:false,error:String(e)+' '+e.stack})});}})();`, ); const child = spawn( - "npx", + process.execPath, [ + process.env.npm_execpath, + "exec", "--yes", + "--package=web-ext", + "--", "web-ext", "run", "--source-dir", @@ -108,7 +117,8 @@ const timeout = setTimeout( const outcome = await result; clearTimeout(timeout); try { - if (process.platform === "win32") child.kill("SIGTERM"); + if (process.platform === "win32") + spawnSync("taskkill", ["/pid", String(child.pid), "/t", "/f"]); else process.kill(-child.pid, "SIGTERM"); } catch (error) { if (error.code !== "ESRCH") throw error; From 83bf793a18f18eb35a176604af5f89c67738f5a8 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:17:13 -0400 Subject: [PATCH 3/4] Automate AMO release submission and verify publishing credentials --- .github/workflows/release.yml | 73 ++++++++++++++++++++++++++++--- CHANGELOG.md | 2 + RELEASE_1.4.11.md | 41 ++++++++++++++--- package.json | 2 +- scripts/check-amo-auth.mjs | 75 ++++++++++++++++++++++++++++++++ scripts/prepare-amo-metadata.mjs | 24 ++++++++++ tests/test-amo-auth.mjs | 70 +++++++++++++++++++++++++++++ 7 files changed, 276 insertions(+), 11 deletions(-) create mode 100644 scripts/check-amo-auth.mjs create mode 100644 scripts/prepare-amo-metadata.mjs create mode 100644 tests/test-amo-auth.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40169bc..9e24226 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,13 +5,39 @@ on: tags: - "v*" workflow_dispatch: + inputs: + check_auth: + description: Check AMO credentials and add-on ownership without publishing + type: boolean + default: false + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false permissions: contents: write id-token: write jobs: + auth-check: + if: (github.event_name == 'push' && github.ref_type == 'tag') || inputs.check_auth == true + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Verify AMO credentials and add-on ownership + env: + AMO_JWT_ISSUER: ${{ secrets.AMO_JWT_ISSUER }} + AMO_JWT_SECRET: ${{ secrets.AMO_JWT_SECRET }} + run: node scripts/check-amo-auth.mjs + verify: + if: inputs.check_auth != true runs-on: windows-latest steps: - uses: actions/checkout@v4 @@ -27,6 +53,7 @@ jobs: path: dist/* windows-companion: + if: inputs.check_auth != true runs-on: windows-latest env: WINDOWS_SIGNING_CERTIFICATE_PFX: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_PFX }} @@ -76,6 +103,7 @@ jobs: path: dist/* macos-companion: + if: inputs.check_auth != true runs-on: macos-14 env: MACOS_INSTALLER_CERTIFICATE_P12: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_P12 }} @@ -160,6 +188,7 @@ jobs: path: dist/* linux-companion: + if: inputs.check_auth != true runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -189,6 +218,7 @@ jobs: path: dist/* npm-package: + if: inputs.check_auth != true runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -207,8 +237,8 @@ jobs: path: dist/*.tgz publish: - if: github.ref_type == 'tag' - needs: [verify, windows-companion, macos-companion, linux-companion, npm-package] + if: github.event_name == 'push' && github.ref_type == 'tag' + needs: [auth-check, verify, windows-companion, macos-companion, linux-companion, npm-package] runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v4 @@ -221,7 +251,7 @@ jobs: files: release/* publish-npm: - if: github.ref_type == 'tag' + if: github.event_name == 'push' && github.ref_type == 'tag' needs: [npm-package, publish] runs-on: ubuntu-latest steps: @@ -234,7 +264,7 @@ jobs: run: npm publish ./npm --access public --provenance release-smoke: - if: github.ref_type == 'tag' + if: github.event_name == 'push' && github.ref_type == 'tag' needs: [publish, publish-npm] runs-on: macos-14 steps: @@ -289,7 +319,7 @@ jobs: test "$("$binary" --version)" = "$version" linux-release-smoke: - if: github.ref_type == 'tag' + if: github.event_name == 'push' && github.ref_type == 'tag' needs: [publish, publish-npm] runs-on: ubuntu-latest steps: @@ -330,3 +360,36 @@ jobs: test "$("$binary" --version)" = "$version" zen_binary="$(node -p "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')).path" "$zen_manifest")" test "$binary" = "$zen_binary" + + publish-amo: + if: github.event_name == 'push' && github.ref_type == 'tag' + needs: [auth-check, release-smoke, linux-release-smoke] + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - uses: actions/download-artifact@v4 + with: + name: firefox-extension + path: dist + - name: Prepare matching source and release notes + run: node scripts/prepare-amo-metadata.mjs + - name: Submit update to the existing Firefox Add-ons listing + env: + WEB_EXT_API_KEY: ${{ secrets.AMO_JWT_ISSUER }} + WEB_EXT_API_SECRET: ${{ secrets.AMO_JWT_SECRET }} + run: | + set -euo pipefail + version="$(node -p "require('./version.json').version")" + npx --yes web-ext@10.6.0 sign \ + --source-dir extension \ + --channel listed \ + --upload-source-code "dist/codex-computer-use-firefox-zen-${version}-source.zip" \ + --amo-metadata dist/amo-metadata.json \ + --approval-timeout 0 \ + --no-input + echo "Submitted to AMO; Mozilla approval may still be pending." >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index d7624b8..508ae39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Git tag. ## 1.4.11 - Unreleased +- Automate Firefox Add-ons submission after tagged npm releases, with a read-only AMO authentication check and matching review-source upload. + - Support `Page.setLifecycleEventsEnabled` during Codex tab attachment, fixing the unsupported-command error that prevented direct Firefox/Zen control. - Translate navigation init, DOMContentLoaded, and load notifications into diff --git a/RELEASE_1.4.11.md b/RELEASE_1.4.11.md index 234a0b8..9b88a64 100644 --- a/RELEASE_1.4.11.md +++ b/RELEASE_1.4.11.md @@ -23,19 +23,50 @@ separate signed-in development profile: attach to an inactive tab with `cua.getTab`, read its state, and confirm the user's active tab stays selected. The user's installed extension and native bridge have not been replaced. +## CI authentication + +npm uses its existing trusted publisher for GitHub Actions: owner +`SunkenInTime`, repository `codex-computer-use-firefox-zen`, workflow +`release.yml`. `publish-npm` requests a short-lived OIDC identity and publishes +with provenance; no npm token secret is required. The 1.4.10 release successfully +used this path. Current account-side trust settings still require npm account +access to inspect; a local unauthenticated `npm trust list` does not test CI auth. + +For Firefox Add-ons, create or retrieve API credentials from + using an account that is +an author of the existing add-on. Add these repository Actions secrets: + +- `AMO_JWT_ISSUER`: the JWT issuer/API key. +- `AMO_JWT_SECRET`: the JWT secret/API secret. + +Do not commit or paste the secret into an issue, PR, or chat. Test access without +uploading a version: + +```sh +gh workflow run release.yml --ref main -f check_auth=true +``` + +Before this PR is merged, use `--ref fix/firefox-lifecycle-attachment` instead. +This mode runs only the authenticated, read-only add-on ownership check. The +same check gates tag publication so missing or invalid AMO credentials prevent +the GitHub/npm release from starting. Manual dispatch with `check_auth=false` +builds candidates only, including when the selected ref is a tag. + ## Publication sequence 1. Merge the reviewed PR and confirm Verify is green for the merged commit. 2. Update the changelog's Unreleased date if needed. Tag the chosen release commit `v1.4.11` and push that tag to run Release. -3. Wait for all release builds, GitHub asset publication, npm provenance +3. Wait for the AMO auth check, all release builds, GitHub asset publication, npm provenance publication, and macOS/Linux public-install smoke tests to succeed. npm now waits for the GitHub assets its installer downloads; manual workflow_dispatch only builds candidates and does not publish npm. -4. Submit `codex-computer-use-firefox-zen-1.4.11.zip` as a new version of the - existing AMO listing, keeping `codex-computer-use-firefox-zen@sunkenintime`. - Attach the matching `-source.zip`, use AMO_LISTING.md reviewer notes, and - include the release notes below. Wait for Mozilla validation/signing. +4. The `publish-amo` job then submits an update to the existing listed add-on, + using the manifest ID, matching source archive, changelog release notes, and + AMO_LISTING.md reviewer notes. It submits with `web-ext sign --channel listed` + and `--approval-timeout 0`: CI does not wait for human approval. A successful + job confirms submission, not Mozilla approval or public availability. Check + the AMO developer dashboard for validation/signing or requests from reviewers. 5. Verify the signed AMO version with `codex-firefox-bridge@1.4.11 doctor` and repeat background-tab attachment on a separate development profile. diff --git a/package.json b/package.json index ee79d21..3486930 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "check": "node scripts/check-version.mjs && node scripts/verify-extension.mjs", "version:set": "node scripts/set-version.mjs", - "test": "npm run check && node tests/test-companion-required.mjs && node tests/test-sidepanel-focus-compat.mjs && node tests/test-sidepanel-host-access.mjs && node tests/test-sidepanel-bootstrap.mjs && node tests/test-sidepanel-companion-setup.mjs && node tests/test-chatgpt-feature-parity.mjs && node tests/test-firefox-compat.mjs && node tests/test-editing-assets.mjs && node tests/test-native-host.mjs", + "test": "npm run check && node tests/test-amo-auth.mjs && node tests/test-companion-required.mjs && node tests/test-sidepanel-focus-compat.mjs && node tests/test-sidepanel-host-access.mjs && node tests/test-sidepanel-bootstrap.mjs && node tests/test-sidepanel-companion-setup.mjs && node tests/test-chatgpt-feature-parity.mjs && node tests/test-firefox-compat.mjs && node tests/test-editing-assets.mjs && node tests/test-native-host.mjs", "package": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/package-extension.ps1", "test:live": "node tests/test-firefox-lifecycle-live.mjs" } diff --git a/scripts/check-amo-auth.mjs b/scripts/check-amo-auth.mjs new file mode 100644 index 0000000..6ad9261 --- /dev/null +++ b/scripts/check-amo-auth.mjs @@ -0,0 +1,75 @@ +import { createHmac, randomUUID } from "node:crypto"; +import fs from "node:fs"; +import { pathToFileURL } from "node:url"; + +export async function checkAmoAuth({ + issuer, + secret, + guid, + fetchImpl = fetch, +}) { + if (!issuer || !secret) + throw new Error( + "Add AMO_JWT_ISSUER and AMO_JWT_SECRET to GitHub Actions secrets.", + ); + const base = "https://addons.mozilla.org/api/v5/"; + let next = `${base}addons/addon/?page_size=50`; + const visited = new Set(); + while (next) { + const url = new URL(next); + if ( + url.origin !== "https://addons.mozilla.org" || + url.pathname !== "/api/v5/addons/addon/" || + visited.has(url.href) + ) { + throw new Error("Unexpected AMO pagination URL."); + } + visited.add(url.href); + const now = Math.floor(Date.now() / 1000); + const encode = (value) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + const unsigned = `${encode({ alg: "HS256", typ: "JWT" })}.${encode({ iss: issuer, jti: randomUUID(), iat: now, exp: now + 60 })}`; + const signature = createHmac("sha256", secret) + .update(unsigned) + .digest("base64url"); + const response = await fetchImpl(url.href, { + headers: { Authorization: `JWT ${unsigned}.${signature}` }, + redirect: "error", + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) + throw new Error( + `AMO authentication/access check failed (HTTP ${response.status}).`, + ); + const page = await response.json(); + if (!Array.isArray(page.results)) + throw new Error("Unexpected AMO add-on list response."); + if (page.results.some((addon) => addon.guid === guid)) return; + next = page.next; + } + throw new Error( + "AMO credentials are valid, but this account is not an author of the configured add-on.", + ); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + const manifest = JSON.parse( + fs.readFileSync("extension/manifest.json", "utf8"), + ); + try { + await checkAmoAuth({ + issuer: process.env.AMO_JWT_ISSUER, + secret: process.env.AMO_JWT_SECRET, + guid: manifest.browser_specific_settings.gecko.id, + }); + console.log( + "AMO credentials authenticate an author of the configured Firefox add-on. No version was uploaded.", + ); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/prepare-amo-metadata.mjs b/scripts/prepare-amo-metadata.mjs new file mode 100644 index 0000000..7e19ad6 --- /dev/null +++ b/scripts/prepare-amo-metadata.mjs @@ -0,0 +1,24 @@ +import fs from "node:fs"; +const { version } = JSON.parse(fs.readFileSync("version.json", "utf8")); +const changelog = fs.readFileSync("CHANGELOG.md", "utf8"); +const heading = `## ${version} -`; +const start = changelog.indexOf(heading); +if (start < 0) throw new Error(`Missing changelog for ${version}.`); +const bodyStart = changelog.indexOf("\n", start); +const end = changelog.indexOf("\n## ", bodyStart); +const notes = changelog.slice(bodyStart, end < 0 ? undefined : end).trim(); +fs.mkdirSync("dist", { recursive: true }); +fs.writeFileSync( + "dist/amo-metadata.json", + JSON.stringify( + { + version: { + release_notes: { "en-US": notes }, + approval_notes: fs.readFileSync("AMO_LISTING.md", "utf8"), + }, + }, + null, + 2, + ) + "\n", +); +console.log(`Prepared AMO release notes and reviewer metadata for ${version}.`); diff --git a/tests/test-amo-auth.mjs b/tests/test-amo-auth.mjs new file mode 100644 index 0000000..3d27441 --- /dev/null +++ b/tests/test-amo-auth.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import { checkAmoAuth } from "../scripts/check-amo-auth.mjs"; +const guid = "test-addon@example"; +const options = { issuer: "test-issuer", secret: "test-secret", guid }; +const response = (results, next = null) => ({ + ok: true, + json: async () => ({ results, next }), +}); +let calls = 0; +await checkAmoAuth({ + ...options, + fetchImpl: async (url, init) => { + assert.equal(init.redirect, "error"); + const token = init.headers.Authorization.slice(4); + const [header, payload, signature] = token.split("."); + assert.equal( + signature, + createHmac("sha256", options.secret) + .update(`${header}.${payload}`) + .digest("base64url"), + ); + const claims = JSON.parse(Buffer.from(payload, "base64url")); + assert.equal(claims.iss, options.issuer); + assert.equal(claims.exp - claims.iat, 60); + calls += 1; + return calls === 1 + ? response([], "https://addons.mozilla.org/api/v5/addons/addon/?page=2") + : response([{ guid }]); + }, +}); +assert.equal(calls, 2); +await assert.rejects( + checkAmoAuth({ + ...options, + secret: "", + fetchImpl: () => assert.fail("No request without credentials"), + }), + /AMO_JWT_SECRET/, +); +await assert.rejects( + checkAmoAuth({ + ...options, + fetchImpl: async () => ({ ok: false, status: 401 }), + }), + /HTTP 401/, +); +await assert.rejects( + checkAmoAuth({ ...options, fetchImpl: async () => response([]) }), + /not an author/, +); +let unsafeCalls = 0; +await assert.rejects( + checkAmoAuth({ + ...options, + fetchImpl: async () => { + unsafeCalls++; + return response([], "https://other.example/"); + }, + }), + /pagination/, +); +assert.equal( + unsafeCalls, + 1, + "Credentials must not follow pagination to another origin.", +); +console.log( + "AMO auth checks passed: JWT signing, pagination, ownership, missing credentials, and HTTP failures.", +); From d9bd7d6ac3a6d0e0875a8e91a6f837d4cd7cbd0c Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:19:07 -0400 Subject: [PATCH 4/4] Document automated Firefox and npm release authentication --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8dc4506..6916e6a 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,9 @@ npm run package `npm test` checks synchronized release versions, the manifest and compatibility surface, bridge/extension version reporting, and the native protocol, upload, and WebSocket-relay integrations. Packaging writes the unsigned extension archive, a matching review-source archive, and SHA-256 checksums to `dist/`. -Pushing a semantic-version tag such as `v1.4.7` runs the release workflow. It builds and tests the extension, Windows installer, universal macOS package, and Linux x64 binary; verifies all release versions; publishes npm with provenance; attaches release artifacts; and smoke-tests a clean install of the exact public npm version on macOS and Linux. Submit the matching signed Firefox add-on to AMO only after that smoke test passes. +Pushing a semantic-version tag such as `v1.4.11` runs the release workflow. It checks Mozilla Add-ons credentials, builds and tests every platform, publishes GitHub assets and then npm with OIDC provenance, and smoke-tests the public npm install on macOS and Linux. After those checks pass, CI submits the listed Firefox add-on with its matching review source and release notes. Mozilla approval may remain pending after submission. + +Configure `AMO_JWT_ISSUER` and `AMO_JWT_SECRET` as repository Actions secrets using an author's [Mozilla API credentials](https://addons.mozilla.org/developers/addon/api/key/). npm uses the existing trusted publisher for `release.yml` and does not need an npm token. Run `gh workflow run release.yml --ref main -f check_auth=true` to verify AMO access without uploading a version. Other manual runs build candidates only; publishing requires a tag push. See [release authentication and publication steps](RELEASE_1.4.11.md). Prepare a release with: