diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b6000bb..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,8 +251,8 @@ jobs:
files: release/*
publish-npm:
- if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
- needs: npm-package
+ if: github.event_name == 'push' && github.ref_type == 'tag'
+ needs: [npm-package, publish]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -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/.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..508ae39 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,19 @@ 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
+
+- 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
+ 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.
+
## 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..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:
@@ -179,3 +181,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 npm run test:live
+# 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..9b88a64
--- /dev/null
+++ b/RELEASE_1.4.11.md
@@ -0,0 +1,80 @@
+# 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 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.
+- 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.
+
+## 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 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. 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.
+
+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..c906af1 100644
--- a/extension/firefox-compat.js
+++ b/extension/firefox-compat.js
@@ -419,6 +419,43 @@
};
const debuggerAttachedTabs = new Set();
+ const lifecycleEnabledFrames = new Set();
+ const loaderIdsByFrame = new Map();
+ const pendingDocumentLoaders = 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 pendingDocumentLoaders.keys()) {
+ if (key.startsWith(`${tabId}:`)) pendingDocumentLoaders.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 +2166,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: "",
@@ -3110,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(
@@ -3134,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", {
@@ -3259,6 +3296,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 +3374,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":
@@ -3399,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 });
@@ -3877,6 +3934,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(() => {});
@@ -3924,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);
@@ -3933,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,
@@ -4001,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);
@@ -4009,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),
@@ -4057,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,
@@ -4088,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),
@@ -4114,7 +4187,7 @@
emitDebuggerEvent(tabId, "Page.frameNavigated", {
frame: {
id: cdpFrameId(tabId, 0),
- loaderId: `firefox-loader-${tabId}-0-${Date.now()}`,
+ loaderId: pendingDocumentLoaders.get(frameKey(tabId, 0))?.loaderId ?? loaderIdForFrame(tabId, 0),
url: changeInfo.url,
securityOrigin: (() => {
try { return new URL(changeInfo.url).origin; } catch { return "null"; }
@@ -4136,6 +4209,7 @@
});
firefox.tabs.onRemoved.addListener((tabId) => {
+ clearLifecycleState(tabId);
navigationStateByTab.delete(tabId);
initScriptsByTab.delete(tabId);
bindingNamesByTab.delete(tabId);
@@ -4182,6 +4256,11 @@
const { tabId, frameId } = details;
if (!debuggerAttachedTabs.has(tabId)) return;
const sessionId = frameId === 0 ? null : (sessionIdByFrame.get(frameKey(tabId, frameId)) ?? null);
+ 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),
executionContextUniqueId: `firefox-context-${tabId}-${frameId}`,
@@ -4196,7 +4275,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 +4290,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..3486930 100644
--- a/package.json
+++ b/package.json
@@ -2,12 +2,13 @@
"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",
"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"
+ "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.",
+);
diff --git a/tests/test-firefox-compat.mjs b/tests/test-firefox-compat.mjs
index 793c9e3..aeb5cfd 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,69 @@ 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.");
+
+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
new file mode 100644
index 0000000..7996890
--- /dev/null
+++ b/tests/test-firefox-lifecycle-live.mjs
@@ -0,0 +1,129 @@
+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, 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"),
+ 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 fixtureisolated 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,'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));
+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');
+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(
+ process.execPath,
+ [
+ process.env.npm_execpath,
+ "exec",
+ "--yes",
+ "--package=web-ext",
+ "--",
+ "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")
+ spawnSync("taskkill", ["/pid", String(child.pid), "/t", "/f"]);
+ 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"