From 0c36205676284f8185bb9b60c8aae0312747146e Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Fri, 5 Jun 2026 12:02:21 +0200 Subject: [PATCH 01/11] remove junk --- AMO_SOURCE_REVIEW.md | 63 -------------------------------------------- 1 file changed, 63 deletions(-) delete mode 100644 AMO_SOURCE_REVIEW.md diff --git a/AMO_SOURCE_REVIEW.md b/AMO_SOURCE_REVIEW.md deleted file mode 100644 index 33fb5e6..0000000 --- a/AMO_SOURCE_REVIEW.md +++ /dev/null @@ -1,63 +0,0 @@ -# AMO Source Review Build - -This repository contains the source used to build the `FF-CLI Bridge` WebExtension. The npm package and CLI command are named `firefox-cli`. - -Source repository: `https://github.com/respawn-llc/firefox-cli` - -`FF-CLI Bridge` is free and open-source software under the AGPL-3.0-only license. The extension is distributed with the local `firefox-cli` package and communicates with the user-local native messaging host; it does not use a hosted backend service for browser control. - -The extension manifest uses `https://opensource.respawn.pro/firefox-cli/updates.json` as its Firefox update manifest URL. - -## Build Environment - -- Operating system: macOS, Linux, or Windows. -- Bun: `1.3.14`, matching `packageManager` in `package.json`. -- Node.js: `>=22.0.0`, matching `engines.node` in `package.json`. -- Git: any recent version that can unpack the submitted source archive. - -Install Bun from `https://bun.sh/docs/installation`. Bun installs dependencies from `bun.lock`; no npm, yarn, or pnpm lockfile is used. - -## Build Steps - -From the repository root: - -```sh -bun install --frozen-lockfile -bun run extension:build -``` - -The build script executes the WebExtension build pipeline: - -- `scripts/build-extension.ts`: runs Vite/Rollup on the TypeScript entry points. -- `scripts/copy-extension-assets.ts`: copies `manifest.json`, `popup.html`, and `popup.css`; `manifest.json` receives the release version from root `package.json`. -- `scripts/build-extension-archive.ts`: writes the unsigned add-on ZIP. - -## Expected Output - -After the build, the add-on files are in `dist/extension`: - -- `background.js` -- `content.js` -- `popup.js` -- `manifest.json` -- `popup.html` -- `popup.css` - -The unsigned add-on archive is: - -```text -dist/extension-artifacts/firefox-cli-0.1.1.zip -``` - -The submitted source archive does not include `dist/` or `node_modules/`; both are generated locally from source and dependencies. - -## Source Mapping - -- `packages/extension/src/background.ts` builds to `dist/extension/background.js`. -- `packages/extension/src/content.ts` builds to `dist/extension/content.js`. -- `packages/extension/src/popup.ts` builds to `dist/extension/popup.js`. -- `packages/extension/src/manifest.json` is copied to `dist/extension/manifest.json` with the version synchronized from root `package.json`. -- `packages/extension/src/popup.html` and `packages/extension/src/popup.css` are copied to `dist/extension`. -- `docs/firefox-cli/updates.json` is the public update manifest published at `https://opensource.respawn.pro/firefox-cli/updates.json`. - -Vite/Rollup bundles the TypeScript modules and esbuild minifies the generated JavaScript. Source files in this archive are not generated, concatenated, transpiled, or minified. From cf9bffedec48a7d15e43ce601b4e0a6e50c4f50b Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Thu, 11 Jun 2026 13:37:49 +0200 Subject: [PATCH 02/11] Persist extension approval token --- .../src/background-bootstrap-storage.test.ts | 183 ++++++++++++++++++ .../extension/src/background-bootstrap.ts | 16 ++ 2 files changed, 199 insertions(+) create mode 100644 packages/extension/src/background-bootstrap-storage.test.ts diff --git a/packages/extension/src/background-bootstrap-storage.test.ts b/packages/extension/src/background-bootstrap-storage.test.ts new file mode 100644 index 0000000..efcc02e --- /dev/null +++ b/packages/extension/src/background-bootstrap-storage.test.ts @@ -0,0 +1,183 @@ +import { createOkResponse, parseBoundaryRequest, type RequestEnvelope } from "@firefox-cli/protocol"; +import { describe, expect, it } from "vitest"; +import { type BackgroundBrowserApi, startBackground } from "./background-bootstrap.js"; +import type { NativePortLike } from "./background-controller.js"; + +const PAIR_TOKEN_STORAGE_KEY = "firefoxCliPairToken"; + +describe("background bootstrap storage", () => { + it("persists popup approval tokens in extension storage", async () => { + const port = new FakeNativePort(); + const browser = createFakeBrowserApi(port); + const lifecycle = startBackground({ + browser, + manifest: { version: "0.0.0" }, + controllerOptions: { reconnectDelaysMs: [] }, + }); + await completeNativeHello(port); + + const approval = lifecycle.controller.handleRuntimeMessage({ type: "firefox-cli:approve" }); + const approve = latestHostRequest(port); + expect(approve.command).toBe("pair.approve"); + port.emitMessage( + createOkResponse(approve, { + hostId: "host-1", + extensionId: "ff-cli-bridge@respawn.pro", + token: "paired-token", + generation: 1, + approvedAt: "2026-01-02T03:04:05.000Z", + }), + ); + + await approval; + + await expect(browser.storage.local.get(PAIR_TOKEN_STORAGE_KEY)).resolves.toEqual({ + [PAIR_TOKEN_STORAGE_KEY]: "paired-token", + }); + }); + + it("restores stored approval tokens from extension storage on startup", async () => { + const port = new FakeNativePort(); + const browser = createFakeBrowserApi(port, { [PAIR_TOKEN_STORAGE_KEY]: "stored-token" }); + + startBackground({ + browser, + manifest: { version: "0.0.0" }, + controllerOptions: { reconnectDelaysMs: [] }, + }); + await flushPromises(); + + expect( + port.messages.some((message) => { + const parsed = parseBoundaryRequest("host-to-extension", message, { protocolVersion: 1 }); + return parsed.ok && parsed.value.command === "hello" && parsed.value.params.pairToken === "stored-token"; + }), + ).toBe(true); + }); +}); + +class FakeNativePort implements NativePortLike { + readonly messages: unknown[] = []; + readonly onMessage = createEvent(); + readonly onDisconnect = createEvent<{ readonly message?: string } | undefined>(); + + postMessage(message: unknown): void { + this.messages.push(message); + } + + emitMessage(message: unknown): void { + this.onMessage.emit(message); + } +} + +function createFakeBrowserApi(port: NativePortLike, initialStorage: Record = {}): BackgroundBrowserApi { + const runtimeOnMessage = createEvent<{ readonly type?: string }, unknown>(); + const storageValues: Record = { ...initialStorage }; + + return { + runtime: { + onMessage: runtimeOnMessage, + connectNative: () => port, + sendMessage: async (): Promise => { + throw new Error("runtime.sendMessage is not implemented in this fake."); + }, + reload: () => undefined, + }, + windows: { + getAll: async () => [], + create: async () => ({ id: 1 }), + update: async (id: number) => ({ id, focused: true }), + remove: async () => undefined, + }, + tabs: { + create: async () => ({ id: 1, index: 0, active: true, windowId: 1 }), + update: async (id: number) => ({ id, index: 0, active: true, windowId: 1 }), + get: async (id: number) => ({ id, index: 0, active: true, windowId: 1 }), + remove: async () => undefined, + goBack: async () => undefined, + goForward: async () => undefined, + reload: async () => undefined, + sendMessage: async () => ({}), + captureVisibleTab: async () => "data:image/png;base64,", + onRemoved: createEvent(), + }, + permissions: { + contains: async () => true, + request: async () => true, + }, + scripting: { + executeScript: async () => [], + }, + storage: { + local: { + get: async (key: string) => ({ [key]: storageValues[key] }), + set: async (values: Record) => { + Object.assign(storageValues, values); + }, + }, + }, + downloads: { + download: async () => 1, + search: async () => [], + }, + cookies: { + getAll: async () => [], + set: async (cookie) => cookie, + remove: async () => undefined, + }, + }; +} + +async function completeNativeHello(port: FakeNativePort): Promise { + const hello = latestHostRequest(port); + expect(hello.command).toBe("hello"); + port.emitMessage( + createOkResponse(hello, { + accepted: true, + negotiatedProtocolVersion: 1, + peer: { + component: "native-host", + productName: "firefox-cli", + productVersion: "0.0.0", + protocolMin: 1, + protocolMax: 1, + features: [], + }, + }), + ); + await flushPromises(); +} + +function latestHostRequest(port: FakeNativePort): RequestEnvelope { + const raw = port.messages.at(-1); + if (raw === undefined) { + throw new Error("Expected native-host request."); + } + const parsed = parseBoundaryRequest("host-to-extension", raw, { protocolVersion: 1 }); + if (!parsed.ok) { + throw new Error(parsed.error.message); + } + return parsed.value; +} + +function createEvent() { + const listeners: ((value: T) => TResult)[] = []; + return { + addListener(listener: (value: T) => TResult): void { + listeners.push(listener); + }, + removeListener(listener: (value: T) => TResult): void { + const index = listeners.indexOf(listener); + if (index >= 0) { + listeners.splice(index, 1); + } + }, + emit(value: T): readonly TResult[] { + return listeners.map((listener) => listener(value)); + }, + }; +} + +async function flushPromises(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} diff --git a/packages/extension/src/background-bootstrap.ts b/packages/extension/src/background-bootstrap.ts index 00bb359..84feb3e 100644 --- a/packages/extension/src/background-bootstrap.ts +++ b/packages/extension/src/background-bootstrap.ts @@ -11,6 +11,8 @@ type RuntimeMessageListener = (message: RuntimeMessage) => Promise; export type BackgroundBrowserApi = typeof browser; +const PAIR_TOKEN_STORAGE_KEY = "firefoxCliPairToken"; + export interface BackgroundLifecycle { readonly controller: FirefoxCliBackgroundController; dispose(): void; @@ -45,6 +47,7 @@ export function startBackground(options: { }), connectNative: (name) => options.browser.runtime.connectNative(name), productVersion: options.manifest.version, + storageAdapter: createBackgroundStorageAdapter(options.browser), ...createControllerOptions(options.controllerOptions), }); @@ -69,6 +72,19 @@ export function startBackground(options: { }; } +function createBackgroundStorageAdapter(browser: BackgroundBrowserApi): BackgroundStorageAdapter { + return { + getPairToken: async () => { + const values = await browser.storage.local.get(PAIR_TOKEN_STORAGE_KEY); + const value = values[PAIR_TOKEN_STORAGE_KEY]; + return typeof value === "string" && value.length > 0 ? value : null; + }, + setPairToken: async (token) => { + await browser.storage.local.set({ [PAIR_TOKEN_STORAGE_KEY]: token }); + }, + }; +} + function createControllerOptions( options: | { From 8fe7edc3939837e34fbd209c5d491290a62e3f08 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Thu, 11 Jun 2026 14:08:21 +0200 Subject: [PATCH 03/11] Add Firefox notification command --- docs/commands.md | 1 + docs/firefox-cli-spec.md | 2 +- packages/cli/src/argv-contracts.ts | 4 ++ packages/cli/src/cli-phase8.test.ts | 4 ++ packages/cli/src/cli-test-support.ts | 1 + packages/cli/src/commands/content.ts | 20 +++++++ packages/cli/src/help.ts | 6 +- packages/cli/src/parse-options.ts | 5 +- packages/cli/src/route-registry.ts | 3 + .../src/background-bootstrap-storage.test.ts | 14 +++++ .../src/background-bootstrap-test-cases.ts | 57 +++++++++++++++++++ .../src/background-bootstrap.test.ts | 3 +- .../src/background-browser-adapter.ts | 14 +++++ .../src/background-controller-test-support.ts | 4 ++ .../src/background-default-browser-adapter.ts | 3 + .../extension/src/browser-command/types.ts | 3 +- .../src/browser-commands-content.test.ts | 10 ++++ .../src/browser-commands-test-smoke.ts | 1 + .../src/browser-commands-test-utils.ts | 6 ++ .../src/browser-handlers/phase8-browser.ts | 10 +++- packages/extension/src/manifest.json | 2 +- packages/extension/src/webextension.d.ts | 11 ++++ packages/protocol/src/browser/output.ts | 15 +++++ .../protocol/src/extension-requirements.ts | 2 + packages/protocol/src/metadata.ts | 3 +- .../src/protocol-metadata-behavior.test.ts | 1 + .../protocol/src/protocol-metadata.test.ts | 7 +++ .../protocol/src/protocol-test-support.ts | 1 + packages/protocol/src/registry/phase8.ts | 15 +++++ .../protocol/src/registry/registry.test.ts | 1 + 30 files changed, 220 insertions(+), 9 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 9ca25a3..36ecd2e 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -158,6 +158,7 @@ Example: | `firefox-cli network list|clear [--url glob] [--json]` | List or clear observed web requests. | | `firefox-cli console|errors list|clear [--json]` | List or clear page console/error capture buffers. | | `firefox-cli highlight [--json]` | Outline an element. | +| `firefox-cli notify [--id id] [message...] [--json]` | Show a native Firefox notification. | | `firefox-cli set viewport <width> <height> [--json]` | Request a target browser window resize and report Firefox's observed window dimensions. Tiling/window-manager rules can prevent the requested size from taking effect. | | `firefox-cli diff url|title|snapshot <expected> [--json]` | Compare URL, title, or snapshot text with an expected value. | | `firefox-cli pdf <path> [--json]` | Returns `UNSUPPORTED_CAPABILITY`; Firefox saves PDFs through a browser dialog rather than a requested CLI path. | diff --git a/docs/firefox-cli-spec.md b/docs/firefox-cli-spec.md index 79942c2..4aaa229 100644 --- a/docs/firefox-cli-spec.md +++ b/docs/firefox-cli-spec.md @@ -146,7 +146,7 @@ Use broad host access for the MVP full-control model after explicit first-use ap Expected manifest shape: -- `permissions`: native messaging, content scripting, tab access, local extension storage, downloads, cookies, clipboard, and web request observation. +- `permissions`: native messaging, content scripting, tab access, local extension storage, downloads, cookies, notifications, clipboard, and web request observation. - `host_permissions`: broad web access for normal web pages. - `browser_specific_settings.gecko.strict_min_version`: Firefox `150.0`. - `browser_specific_settings.gecko.data_collection_permissions`: browsing activity, website activity, and website content because command results can leave the extension through the local native host and CLI. diff --git a/packages/cli/src/argv-contracts.ts b/packages/cli/src/argv-contracts.ts index 6dacd1f..680a0de 100644 --- a/packages/cli/src/argv-contracts.ts +++ b/packages/cli/src/argv-contracts.ts @@ -71,6 +71,10 @@ export const routeParserSpecs = { console: parser("console"), errors: parser("errors"), highlight: parser("highlight", { valueOptions: ["--generation", "--duration"] }), + notify: parser("notify", { + valueOptions: ["--id"], + payload: { payloadStartPositionals: 0, minPositionals: 1, variadicAfterMin: true }, + }), pdf: parser("pdf"), "set.viewport": parser("set"), diff: parser("diff", { diff --git a/packages/cli/src/cli-phase8.test.ts b/packages/cli/src/cli-phase8.test.ts index 48dc431..a73daf7 100644 --- a/packages/cli/src/cli-phase8.test.ts +++ b/packages/cli/src/cli-phase8.test.ts @@ -113,6 +113,10 @@ describe("runCli Phase 8 commands", () => { argv: ["highlight", "#save", "--duration", "1000", "--json"], expected: { command: "highlight", params: { selector: "#save", durationMs: 1000 } }, }, + { + argv: ["notify", "--id", "approval", "Action needed", "Open Firefox to approve control.", "--json"], + expected: { command: "notify", params: { id: "approval", title: "Action needed", message: "Open Firefox to approve control." } }, + }, { argv: ["pdf", "page.pdf", "--json"], expected: { command: "pdf", params: { path: join(cwd, "page.pdf") } }, diff --git a/packages/cli/src/cli-test-support.ts b/packages/cli/src/cli-test-support.ts index d87efac..3a31d27 100644 --- a/packages/cli/src/cli-test-support.ts +++ b/packages/cli/src/cli-test-support.ts @@ -88,6 +88,7 @@ export function phase8CliResultFor(request: RequestEnvelope): unknown { console: { action: "list", ok: true, entries: [] }, errors: { action: "clear", ok: true }, highlight: { ok: true, element }, + notify: { ok: true, id: "approval" }, pdf: { path: "/work/page.pdf" }, "set.viewport": { window: { id: 7, index: 0, focused: true, tabCount: 1 } }, diff: { diff --git a/packages/cli/src/commands/content.ts b/packages/cli/src/commands/content.ts index 3e75da1..554f561 100644 --- a/packages/cli/src/commands/content.ts +++ b/packages/cli/src/commands/content.ts @@ -246,6 +246,26 @@ export function buildHighlightRequest(argv: readonly string[]): RequestEnvelope }); } +export function buildNotifyRequest(argv: readonly string[]): RequestEnvelope { + const parsed = parsePayloadPositionalsAndOptions( + argv.slice(1).filter((arg) => arg !== "--json"), + { + payloadStartPositionals: 0, + minPositionals: 1, + variadicAfterMin: true, + }, + ); + const [title, ...messageParts] = parsed.positionals; + if (title === undefined) { + throw new CliUsageError("Missing notification title."); + } + return createValidatedRequest("notify", { + title, + ...(messageParts.length === 0 ? {} : { message: messageParts.join(" ") }), + ...optionalStringOption(parsed.optionArgs, ["--id"], "id"), + }); +} + export function buildDiffRequest(argv: readonly string[]): RequestEnvelope { const parsed = parsePayloadPositionalsAndOptions(argv.slice(1), { payloadStartPositionals: 1, diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 484ec56..ed0ea04 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -65,6 +65,9 @@ const routeHelpSpecs = { console: helpSpec("List or clear captured console messages."), errors: helpSpec("List or clear captured page errors."), highlight: helpSpec("Temporarily highlight an element for visual inspection."), + notify: helpSpec("Show a native Firefox notification with a title and optional message.", [ + "Use `--id <id>` to update or replace an existing notification with the same id.", + ]), pdf: helpSpec("Report Firefox PDF export support for a target path."), "set.viewport": helpSpec("Resize the target window viewport."), diff: helpSpec("Compare URL, title, or snapshot content against an expected value."), @@ -155,7 +158,7 @@ const helpGroups: readonly HelpGroup[] = [ { title: "Browser data and files", summary: "Use browser-adjacent data and file operations.", - routes: ["screenshot", "download", "clipboard", "cookies", "storage", "pdf", "set.viewport"], + routes: ["screenshot", "download", "clipboard", "cookies", "storage", "notify", "pdf", "set.viewport"], }, { title: "Automation", @@ -204,6 +207,7 @@ const commandExamples: Partial<Record<RouteBindingId, readonly string[]>> = { wait: ["firefox-cli wait --url '*dashboard*'", "firefox-cli wait --selector '#ready'"], click: ["firefox-cli click 'button[type=submit]'", "firefox-cli click @e12"], fill: ["firefox-cli fill '#email' user@example.com"], + notify: ["firefox-cli notify 'Action needed' 'Open Firefox to approve control'"], batch: ['firefox-cli batch \'[["open","https://example.com"],["snapshot","-i"]]\' --json'], }; diff --git a/packages/cli/src/parse-options.ts b/packages/cli/src/parse-options.ts index cae7b47..037a893 100644 --- a/packages/cli/src/parse-options.ts +++ b/packages/cli/src/parse-options.ts @@ -54,11 +54,12 @@ export function optionalUrl(url: string | undefined): { readonly url?: string } export function optionalStringOption(args: readonly string[], names: readonly string[], outputKey: "selector"): { readonly selector?: string }; export function optionalStringOption(args: readonly string[], names: readonly string[], outputKey: "generationId"): { readonly generationId?: string }; export function optionalStringOption(args: readonly string[], names: readonly string[], outputKey: "urlGlob"): { readonly urlGlob?: string }; +export function optionalStringOption(args: readonly string[], names: readonly string[], outputKey: "id"): { readonly id?: string }; export function optionalStringOption( args: readonly string[], names: readonly string[], - outputKey: "selector" | "generationId" | "urlGlob", -): { readonly selector?: string; readonly generationId?: string; readonly urlGlob?: string } { + outputKey: "selector" | "generationId" | "urlGlob" | "id", +): { readonly selector?: string; readonly generationId?: string; readonly urlGlob?: string; readonly id?: string } { const value = getOptionValue(args, names); if (value === undefined) { return {}; diff --git a/packages/cli/src/route-registry.ts b/packages/cli/src/route-registry.ts index eaa8733..4ed9d43 100644 --- a/packages/cli/src/route-registry.ts +++ b/packages/cli/src/route-registry.ts @@ -26,6 +26,7 @@ import { buildIsRequest, buildLogRequest, buildNetworkRequest, + buildNotifyRequest, buildRefRequest, buildSnapshotRequest, buildStorageRequest, @@ -91,6 +92,7 @@ const routeFormatterSpecs = { console: routeFormatter("console", "json-object", cliResponseFormatters["json-object"]), errors: routeFormatter("errors", "json-object", cliResponseFormatters["json-object"]), highlight: routeFormatter("highlight", "json-object", cliResponseFormatters["json-object"]), + notify: routeFormatter("notify", "json-object", cliResponseFormatters["json-object"]), pdf: routeFormatter("pdf", "json-object", cliResponseFormatters["json-object"]), "set.viewport": routeFormatter("set.viewport", "json-object", cliResponseFormatters["json-object"]), diff: routeFormatter("diff", "json-object", cliResponseFormatters["json-object"]), @@ -184,6 +186,7 @@ export const cliRouteBindings = { console: bindCliRoute("console", "firefox-cli console list|clear [--json]", buildLogRequest), errors: bindCliRoute("errors", "firefox-cli errors list|clear [--json]", buildLogRequest), highlight: bindCliRoute("highlight", "firefox-cli highlight <selector|@ref> [--json]", buildHighlightRequest), + notify: bindCliRoute("notify", "firefox-cli notify [--id id] <title> [message...] [--json]", buildNotifyRequest), pdf: bindCliRoute("pdf", "firefox-cli pdf <path> [--json]", buildPdfRequest), "set.viewport": bindCliRoute("set.viewport", "firefox-cli set viewport <width> <height> [--json]", buildSetViewportRequest), diff: bindCliRoute("diff", "firefox-cli diff url|title|snapshot <expected> [--json]", buildDiffRequest), diff --git a/packages/extension/src/background-bootstrap-storage.test.ts b/packages/extension/src/background-bootstrap-storage.test.ts index efcc02e..1190593 100644 --- a/packages/extension/src/background-bootstrap-storage.test.ts +++ b/packages/extension/src/background-bootstrap-storage.test.ts @@ -4,6 +4,11 @@ import { type BackgroundBrowserApi, startBackground } from "./background-bootstr import type { NativePortLike } from "./background-controller.js"; const PAIR_TOKEN_STORAGE_KEY = "firefoxCliPairToken"; +interface NotificationOptions { + readonly type: "basic"; + readonly title: string; + readonly message: string; +} describe("background bootstrap storage", () => { it("persists popup approval tokens in extension storage", async () => { @@ -125,9 +130,18 @@ function createFakeBrowserApi(port: NativePortLike, initialStorage: Record<strin set: async (cookie) => cookie, remove: async () => undefined, }, + notifications: { + create: createNotification, + }, }; } +async function createNotification(options: NotificationOptions): Promise<string>; +async function createNotification(id: string, options: NotificationOptions): Promise<string>; +async function createNotification(idOrOptions: string | NotificationOptions): Promise<string> { + return typeof idOrOptions === "string" ? idOrOptions : "notification-1"; +} + async function completeNativeHello(port: FakeNativePort): Promise<void> { const hello = latestHostRequest(port); expect(hello.command).toBe("hello"); diff --git a/packages/extension/src/background-bootstrap-test-cases.ts b/packages/extension/src/background-bootstrap-test-cases.ts index 0a8a044..61643c1 100644 --- a/packages/extension/src/background-bootstrap-test-cases.ts +++ b/packages/extension/src/background-bootstrap-test-cases.ts @@ -6,6 +6,12 @@ import type { NativePortLike } from "./background-controller.js"; import { NetworkObservationService } from "./network-observation-service.js"; import { NetworkRequestTracker } from "./network-tracker.js"; +interface NotificationOptions { + readonly type: "basic"; + readonly title: string; + readonly message: string; +} + export async function runCase01() { const port = new FakeNativePort(); const browser = createFakeBrowserApi(port); @@ -114,6 +120,29 @@ export async function runCase04() { expect(browser.scripting.calls).toEqual([]); } +export async function runCase05() { + const port = new FakeNativePort(); + const browser = createFakeBrowserApi(port); + const networkTracker = new NetworkRequestTracker(); + const networkObservation = new NetworkObservationService({ browser, tracker: networkTracker }); + const adapter = createBackgroundBrowserAdapter({ browser, networkObservation }); + + await expect(adapter.showNotification({ id: "approval", title: "Action needed", message: "Open Firefox." })).resolves.toEqual({ + ok: true, + id: "approval", + }); + expect(browser.notifications.calls).toEqual([ + { + id: "approval", + options: { + type: "basic", + title: "Action needed", + message: "Open Firefox.", + }, + }, + ]); +} + class FakeNativePort implements NativePortLike { readonly onMessage = createEvent<unknown>(); readonly onDisconnect = createEvent<{ readonly message?: string } | undefined>(); @@ -131,6 +160,24 @@ function createFakeBrowserApi(port: NativePortLike): FakeBackgroundBrowserApi { const onRemoved = createEvent<number>(); let sendMessageCalls = 0; const scriptingCalls: unknown[] = []; + const notificationCalls: { + readonly id: string | undefined; + readonly options: NotificationOptions; + }[] = []; + + function createNotification(options: NotificationOptions): Promise<string>; + function createNotification(id: string, options: NotificationOptions): Promise<string>; + async function createNotification(idOrOptions: string | NotificationOptions, options?: NotificationOptions): Promise<string> { + if (typeof idOrOptions !== "string") { + notificationCalls.push({ id: undefined, options: idOrOptions }); + return "generated-notification"; + } + if (options === undefined) { + throw new Error("Missing notification options."); + } + notificationCalls.push({ id: idOrOptions, options }); + return idOrOptions; + } return { runtime: { @@ -202,6 +249,10 @@ function createFakeBrowserApi(port: NativePortLike): FakeBackgroundBrowserApi { set: async (cookie: BrowserCookie) => cookie, remove: async () => undefined, }, + notifications: { + calls: notificationCalls, + create: createNotification, + }, webRequest: { onBeforeRequest, onCompleted, @@ -242,6 +293,12 @@ type FakeBackgroundBrowserApi = Omit<BackgroundBrowserApi, "runtime" | "tabs" | readonly scripting: BackgroundBrowserApi["scripting"] & { readonly calls: readonly unknown[]; }; + readonly notifications: BackgroundBrowserApi["notifications"] & { + readonly calls: readonly { + readonly id: string | undefined; + readonly options: NotificationOptions; + }[]; + }; readonly webRequest: { readonly onBeforeRequest: FakeWebRequestEvent; readonly onCompleted: FakeWebRequestEvent; diff --git a/packages/extension/src/background-bootstrap.test.ts b/packages/extension/src/background-bootstrap.test.ts index dd787a8..7529c25 100644 --- a/packages/extension/src/background-bootstrap.test.ts +++ b/packages/extension/src/background-bootstrap.test.ts @@ -1,9 +1,10 @@ import { describe, it } from "vitest"; -import { runCase01, runCase02, runCase03, runCase04 } from "./background-bootstrap-test-cases.js"; +import { runCase01, runCase02, runCase03, runCase04, runCase05 } from "./background-bootstrap-test-cases.js"; describe("background bootstrap", () => { it("registers runtime eagerly and webRequest listeners lazily by target tab", runCase01); it("preserves content injection and eval execution product contracts", runCase02); it("does not refresh content scripts when an existing script returns a structured mismatch", runCase03); it("does not inject content scripts for classified non-recoverable send failures", runCase04); + it("creates native notifications through the browser adapter", runCase05); }); diff --git a/packages/extension/src/background-browser-adapter.ts b/packages/extension/src/background-browser-adapter.ts index fbf1a29..bb62cf7 100644 --- a/packages/extension/src/background-browser-adapter.ts +++ b/packages/extension/src/background-browser-adapter.ts @@ -169,6 +169,20 @@ export function createBackgroundBrowserAdapter(options: { } }); }, + showNotification: async (notificationOptions) => { + const payload = { + type: "basic", + title: notificationOptions.title, + message: notificationOptions.message ?? "", + } as const; + return { + ok: true, + id: + notificationOptions.id === undefined + ? await options.browser.notifications.create(payload) + : await options.browser.notifications.create(notificationOptions.id, payload), + }; + }, resizeWindow: async (windowId, size) => { await options.browser.windows.update(windowId, size); const windows = await options.browser.windows.getAll({ populate: true }); diff --git a/packages/extension/src/background-controller-test-support.ts b/packages/extension/src/background-controller-test-support.ts index 1592575..aadbc9b 100644 --- a/packages/extension/src/background-controller-test-support.ts +++ b/packages/extension/src/background-controller-test-support.ts @@ -108,6 +108,10 @@ export function createTestBrowserAdapter( listNetworkRequests: async () => [], clearNetworkRequests: async () => undefined, waitForNetworkIdle: async () => undefined, + showNotification: async (options) => ({ + ok: true, + id: options.id ?? "notification-1", + }), resizeWindow: async () => { throw new Error("not implemented"); }, diff --git a/packages/extension/src/background-default-browser-adapter.ts b/packages/extension/src/background-default-browser-adapter.ts index 7192542..8b168f4 100644 --- a/packages/extension/src/background-default-browser-adapter.ts +++ b/packages/extension/src/background-default-browser-adapter.ts @@ -67,6 +67,9 @@ export function createUnconfiguredBrowserAdapter(): BackgroundBrowserAdapter { listNetworkRequests: async () => [], clearNetworkRequests: async () => undefined, waitForNetworkIdle: async () => undefined, + showNotification: async () => { + throw new Error("Browser adapter is not configured."); + }, resizeWindow: async () => { throw new Error("Browser adapter is not configured."); }, diff --git a/packages/extension/src/browser-command/types.ts b/packages/extension/src/browser-command/types.ts index 496d0ad..39aef45 100644 --- a/packages/extension/src/browser-command/types.ts +++ b/packages/extension/src/browser-command/types.ts @@ -1,4 +1,4 @@ -import type { DownloadResult, NetworkResult, CookieResult, RequestEnvelope, ResolvedTarget, TabSummary } from "@firefox-cli/protocol"; +import type { CookieResult, DownloadResult, NetworkResult, NotifyResult, RequestEnvelope, ResolvedTarget, TabSummary } from "@firefox-cli/protocol"; import type { EvalExecutorPayload, EvalExecutorResult } from "../eval-executor.js"; export interface BrowserWindowSnapshot { @@ -49,6 +49,7 @@ export interface BackgroundBrowserAdapter { listNetworkRequests(options: { readonly tabId: number; readonly urlGlob?: string }): Promise<NonNullable<NetworkResult["requests"]>>; clearNetworkRequests(options: { readonly tabId: number; readonly urlGlob?: string }): Promise<void>; waitForNetworkIdle(options: { readonly tabId: number; readonly timeoutMs: number; readonly idleMs: number }): Promise<void>; + showNotification(options: { readonly id?: string; readonly title: string; readonly message?: string }): Promise<NotifyResult>; resizeWindow(windowId: number, size: { readonly width: number; readonly height: number }): Promise<BrowserWindowSnapshot>; } diff --git a/packages/extension/src/browser-commands-content.test.ts b/packages/extension/src/browser-commands-content.test.ts index 62836a6..e69c8ca 100644 --- a/packages/extension/src/browser-commands-content.test.ts +++ b/packages/extension/src/browser-commands-content.test.ts @@ -179,6 +179,16 @@ describe("browser command handling", () => { }); expect(adapter.networkClearRequests).toEqual([{ tabId: 101 }]); expect(adapter.networkRequests).toEqual([]); + await expect( + handleBrowserRequest( + createRequest("notify", { id: "approval", title: "Action needed", message: "Open Firefox to approve control." }, "notify-1"), + adapter, + ), + ).resolves.toMatchObject({ + ok: true, + result: { id: "approval" }, + }); + expect(adapter.notifications).toEqual([{ id: "approval", title: "Action needed", message: "Open Firefox to approve control." }]); await expect(handleBrowserRequest(createRequest("set.viewport", { width: 1200, height: 800 }, "viewport-1"), adapter)).resolves.toMatchObject({ ok: true, diff --git a/packages/extension/src/browser-commands-test-smoke.ts b/packages/extension/src/browser-commands-test-smoke.ts index b86f9de..a4ad7e9 100644 --- a/packages/extension/src/browser-commands-test-smoke.ts +++ b/packages/extension/src/browser-commands-test-smoke.ts @@ -37,6 +37,7 @@ export const browserSmokeRequests = new Map<CommandId, unknown>([ ["console", { action: "list" }], ["errors", { action: "list" }], ["highlight", { selector: "button" }], + ["notify", { title: "Action needed" }], ["pdf", { path: "/tmp/page.pdf" }], ["set.viewport", { width: 1200, height: 800 }], ["diff", { kind: "title", expected: "Expected title" }], diff --git a/packages/extension/src/browser-commands-test-utils.ts b/packages/extension/src/browser-commands-test-utils.ts index 2fa412d..bee44b3 100644 --- a/packages/extension/src/browser-commands-test-utils.ts +++ b/packages/extension/src/browser-commands-test-utils.ts @@ -50,6 +50,7 @@ export class FakeBrowserAdapter implements BackgroundBrowserAdapter { readonly timeoutMs: number; readonly idleMs: number; }[] = []; + readonly notifications: { readonly id?: string; readonly title: string; readonly message?: string }[] = []; clipboardText = ""; networkRequests: { readonly id: string; readonly tabId: number; readonly url: string }[] = []; listWindowCalls = 0; @@ -271,6 +272,11 @@ export class FakeBrowserAdapter implements BackgroundBrowserAdapter { this.networkIdleWaits.push(options); } + async showNotification(options: { readonly id?: string; readonly title: string; readonly message?: string }) { + this.notifications.push(options); + return { ok: true as const, id: options.id ?? `notification-${String(this.notifications.length)}` }; + } + async resizeWindow(windowId: number, size: { readonly width: number; readonly height: number }): Promise<BrowserWindowSnapshot> { const window = this.#windows.find((candidate) => candidate.id === windowId); if (window === undefined) { diff --git a/packages/extension/src/browser-handlers/phase8-browser.ts b/packages/extension/src/browser-handlers/phase8-browser.ts index cd2c30c..83cc2e8 100644 --- a/packages/extension/src/browser-handlers/phase8-browser.ts +++ b/packages/extension/src/browser-handlers/phase8-browser.ts @@ -11,7 +11,7 @@ import { BrowserCommandError } from "../browser-command/errors.js"; import { toOrderedWindows, toWindowSummary } from "../browser-command/targets.js"; import type { BrowserHandlerMap } from "./types.js"; -type Phase8BrowserCommand = "download" | "clipboard" | "cookies" | "network" | "pdf" | "set.viewport"; +type Phase8BrowserCommand = "download" | "clipboard" | "cookies" | "network" | "notify" | "pdf" | "set.viewport"; export const phase8BrowserHandlers: BrowserHandlerMap<Phase8BrowserCommand> = { download: async (request, adapter) => { @@ -102,6 +102,14 @@ export const phase8BrowserHandlers: BrowserHandlerMap<Phase8BrowserCommand> = { }), }); }, + notify: async (request, adapter) => { + const result = await adapter.showNotification({ + ...(request.params.id === undefined ? {} : { id: request.params.id }), + title: request.params.title, + ...(request.params.message === undefined ? {} : { message: request.params.message }), + }); + return createOkResponse(request, result); + }, pdf: async (request) => { return createErrorResponseForRequest(request, { code: "UNSUPPORTED_CAPABILITY", diff --git a/packages/extension/src/manifest.json b/packages/extension/src/manifest.json index 256022b..4ce789f 100644 --- a/packages/extension/src/manifest.json +++ b/packages/extension/src/manifest.json @@ -14,7 +14,7 @@ } } }, - "permissions": ["nativeMessaging", "scripting", "tabs", "storage", "downloads", "cookies", "clipboardRead", "clipboardWrite", "webRequest"], + "permissions": ["nativeMessaging", "scripting", "tabs", "storage", "downloads", "cookies", "notifications", "clipboardRead", "clipboardWrite", "webRequest"], "host_permissions": ["<all_urls>"], "background": { "scripts": ["background.js"] diff --git a/packages/extension/src/webextension.d.ts b/packages/extension/src/webextension.d.ts index cd75275..fcb1d35 100644 --- a/packages/extension/src/webextension.d.ts +++ b/packages/extension/src/webextension.d.ts @@ -107,6 +107,17 @@ declare const browser: { }): Promise<BrowserCookie>; remove(options: { readonly url: string; readonly name: string }): Promise<unknown>; }; + readonly notifications: { + create(options: { readonly type: "basic"; readonly title: string; readonly message: string }): Promise<string>; + create( + notificationId: string, + options: { + readonly type: "basic"; + readonly title: string; + readonly message: string; + }, + ): Promise<string>; + }; readonly webRequest?: { readonly onBeforeRequest?: BrowserWebRequestEvent; readonly onCompleted?: BrowserWebRequestEvent; diff --git a/packages/protocol/src/browser/output.ts b/packages/protocol/src/browser/output.ts index 82370df..53ad30e 100644 --- a/packages/protocol/src/browser/output.ts +++ b/packages/protocol/src/browser/output.ts @@ -53,6 +53,21 @@ export const highlightResultSchema = z .strict(); export type HighlightResult = z.infer<typeof highlightResultSchema>; +export const notifyParamsSchema = z + .object({ + id: z.string().min(1).max(256).optional(), + title: z.string().min(1).max(256), + message: z.string().max(1024).optional(), + }) + .strict(); +export const notifyResultSchema = z + .object({ + ok: z.literal(true), + id: z.string().min(1), + }) + .strict(); +export type NotifyResult = z.infer<typeof notifyResultSchema>; + export const pdfParamsSchema = z .object({ target: targetSelectorSchema.optional(), diff --git a/packages/protocol/src/extension-requirements.ts b/packages/protocol/src/extension-requirements.ts index 8885ca5..b271e4c 100644 --- a/packages/protocol/src/extension-requirements.ts +++ b/packages/protocol/src/extension-requirements.ts @@ -8,6 +8,7 @@ export type FirefoxManifestPermission = | "storage" | "downloads" | "cookies" + | "notifications" | "clipboardRead" | "clipboardWrite" | "webRequest"; @@ -69,6 +70,7 @@ const privilegeManifestPermissions = { downloads: ["downloads"], cookies: ["cookies"], "network-observation": ["webRequest"], + notifications: ["notifications"], } as const satisfies Record<CommandPrivilegeReason, readonly FirefoxManifestPermission[]>; const pageAccessReasons = new Set<CommandPrivilegeReason>([ diff --git a/packages/protocol/src/metadata.ts b/packages/protocol/src/metadata.ts index 4f74103..2295b91 100644 --- a/packages/protocol/src/metadata.ts +++ b/packages/protocol/src/metadata.ts @@ -19,7 +19,8 @@ export type CommandPrivilegeReason = | "clipboard" | "downloads" | "cookies" - | "network-observation"; + | "network-observation" + | "notifications"; export type CommandSecurityMetadata = | { readonly level: "normal"; diff --git a/packages/protocol/src/protocol-metadata-behavior.test.ts b/packages/protocol/src/protocol-metadata-behavior.test.ts index 05f65da..0e44612 100644 --- a/packages/protocol/src/protocol-metadata-behavior.test.ts +++ b/packages/protocol/src/protocol-metadata-behavior.test.ts @@ -169,6 +169,7 @@ describe("protocol command metadata", () => { "clipboard", "cookies", "network", + "notify", "click", "dblclick", "focus", diff --git a/packages/protocol/src/protocol-metadata.test.ts b/packages/protocol/src/protocol-metadata.test.ts index 989ef65..9085674 100644 --- a/packages/protocol/src/protocol-metadata.test.ts +++ b/packages/protocol/src/protocol-metadata.test.ts @@ -39,6 +39,7 @@ describe("protocol command metadata", () => { "cookies", "downloads", "nativeMessaging", + "notifications", "scripting", "storage", "tabs", @@ -58,6 +59,12 @@ describe("protocol command metadata", () => { }); expect(commandRequiresExtensionHostAccess("click")).toBe(true); expect(commandRequiresExtensionHostAccess("download")).toBe(false); + expect(commandRequiresExtensionHostAccess("notify")).toBe(false); + expect(requirements.commands.find((requirement) => requirement.command === "notify")).toMatchObject({ + securityReasons: ["notifications"], + manifestPermissions: ["notifications"], + networkObservation: false, + }); expect(requirements.commands.find((requirement) => requirement.command === "network")).toMatchObject({ securityReasons: ["network-observation"], manifestPermissions: ["webRequest"], diff --git a/packages/protocol/src/protocol-test-support.ts b/packages/protocol/src/protocol-test-support.ts index 2414a20..8a2347c 100644 --- a/packages/protocol/src/protocol-test-support.ts +++ b/packages/protocol/src/protocol-test-support.ts @@ -61,6 +61,7 @@ export const expectedCliRoutesByCommand: Partial<Record<CommandId, readonly CliR console: [{ id: "console", path: ["console"], batch: true }], errors: [{ id: "errors", path: ["errors"], batch: true }], highlight: [{ id: "highlight", path: ["highlight"], batch: true }], + notify: [{ id: "notify", path: ["notify"], batch: true }], pdf: [{ id: "pdf", path: ["pdf"], batch: true }], "set.viewport": [{ id: "set.viewport", path: ["set", "viewport"], batch: true }], diff: [{ id: "diff", path: ["diff"], batch: true }], diff --git a/packages/protocol/src/registry/phase8.ts b/packages/protocol/src/registry/phase8.ts index 2fa7d6e..f81f7bc 100644 --- a/packages/protocol/src/registry/phase8.ts +++ b/packages/protocol/src/registry/phase8.ts @@ -27,6 +27,8 @@ import { mouseParamsSchema, networkParamsSchema, networkResultSchema, + notifyParamsSchema, + notifyResultSchema, pdfParamsSchema, pdfResultSchema, screenshotParamsSchema, @@ -279,6 +281,19 @@ export const phase8CommandEntries = defineCommandEntries({ batch: { allowed: true, extensionDefaultTarget: true }, cliRoutes: [{ id: "highlight", path: ["highlight"], batch: true }], }, + notify: { + params: notifyParamsSchema, + result: notifyResultSchema, + status: "mvp", + owner: "extension", + target: "none", + content: "never", + action: false, + timeout: "none", + security: { level: "sensitive", reasons: ["notifications"] }, + batch: { allowed: true }, + cliRoutes: [{ id: "notify", path: ["notify"], batch: true }], + }, pdf: { params: pdfParamsSchema, result: pdfResultSchema, diff --git a/packages/protocol/src/registry/registry.test.ts b/packages/protocol/src/registry/registry.test.ts index 428b0e3..516d09c 100644 --- a/packages/protocol/src/registry/registry.test.ts +++ b/packages/protocol/src/registry/registry.test.ts @@ -54,6 +54,7 @@ const expectedCommandIds = [ "console", "errors", "highlight", + "notify", "pdf", "set.viewport", "diff", From 159933d41f4a722c19615cd181d7b03e863571c4 Mon Sep 17 00:00:00 2001 From: Nek-12 <vaizin.nikita@gmail.com> Date: Thu, 11 Jun 2026 14:30:28 +0200 Subject: [PATCH 04/11] Add CLI approval opener --- docs/commands.md | 1 + docs/firefox-cli-spec.md | 8 ++--- docs/setup.md | 2 +- packages/cli/src/argv-contracts.ts | 1 + packages/cli/src/cli-help.test.ts | 5 ++- packages/cli/src/cli.test.ts | 22 +++++++++++++ packages/cli/src/commands/pairing.ts | 6 ++++ packages/cli/src/help.ts | 4 ++- packages/cli/src/route-registry.ts | 3 ++ .../src/background-bootstrap-storage.test.ts | 1 + .../src/background-bootstrap-test-cases.ts | 10 +++++- .../src/background-browser-adapter.ts | 5 +++ .../src/background-controller-test-cases.ts | 29 ++++++++++++++++- .../src/background-controller-test-support.ts | 1 + .../src/background-controller.test.ts | 7 ++-- .../src/background-default-browser-adapter.ts | 3 ++ .../src/background-request-handler.ts | 3 +- .../extension/src/browser-command/types.ts | 1 + .../src/browser-commands-content.test.ts | 5 +++ .../src/browser-commands-test-smoke.ts | 1 + .../src/browser-commands-test-utils.ts | 6 ++++ .../src/browser-handlers/phase8-browser.ts | 8 ++++- packages/extension/src/webextension.d.ts | 1 + packages/native-host/src/host-broker.test.ts | 32 ++++++++++++++++++- packages/native-host/src/host-broker.ts | 9 ++++-- packages/protocol/src/pairing.ts | 8 +++++ .../src/protocol-metadata-behavior.test.ts | 2 +- .../protocol/src/protocol-test-support.ts | 1 + packages/protocol/src/registry/index.ts | 4 +++ packages/protocol/src/registry/pairing.ts | 21 +++++++++++- .../protocol/src/registry/registry.test.ts | 1 + 31 files changed, 189 insertions(+), 22 deletions(-) create mode 100644 packages/cli/src/commands/pairing.ts diff --git a/docs/commands.md b/docs/commands.md index 36ecd2e..b106316 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -26,6 +26,7 @@ Private windows are listed and readable. Mutating commands against private windo | `firefox-cli setup` | Print extension setup guidance and the native-host setup command. | | `firefox-cli setup native-host [--dry-run] [--json]` | Write or print the native messaging manifest. | | `firefox-cli doctor [--fix] [--json]` | Diagnose native-host manifest and extension connection state. | +| `firefox-cli approve [--json]` | Open the extension approval UI in a Firefox tab. | | `firefox-cli unpair` | Clear CLI/native-host pair state. | | `firefox-cli capabilities [--json]` | List supported and gated protocol capabilities. | diff --git a/docs/firefox-cli-spec.md b/docs/firefox-cli-spec.md index 4aaa229..9672dec 100644 --- a/docs/firefox-cli-spec.md +++ b/docs/firefox-cli-spec.md @@ -22,7 +22,7 @@ The happy path: 1. User installs the npm package and gets one executable: `firefox-cli`. 2. User manually installs or temporarily loads the Firefox extension from the URL printed by `firefox-cli setup`. 3. User runs `firefox-cli setup native-host` or `firefox-cli doctor --fix` to register the native messaging host. -4. User opens the extension popup and approves the first connection after seeing native-host identity details. +4. User runs `firefox-cli approve` or opens the extension popup and approves the first connection after seeing native-host identity details. 5. Commands control the active Firefox tab/window unless a command or flag selects another target. Example workflow: @@ -96,7 +96,7 @@ Unpaired handshake: 1. Extension connects to the native host. 2. Native host sends identity metadata: host name, executable path, package version, protocol min/max, native manifest path, extension ID, and a generated pairing nonce. -3. Extension popup shows the metadata and asks the user to approve first use. +3. The extension approval UI shows the metadata and asks the user to approve first use. 4. Until approval, the native host rejects or queues CLI commands with `NOT_APPROVED`. 5. On approval, extension and native host persist the minimum pair state needed to reconnect. @@ -138,7 +138,7 @@ Keep the extension UI smaller than a control panel. The popup should show: - Approval/reset action for the first connection. - Copy diagnostics action. -Do not mirror CLI commands in the popup. Setup text should tell users to click the Firefox extension popup to approve; do not depend on auto-opening the popup. +Do not mirror CLI commands in the popup. Setup text can tell users to run `firefox-cli approve` or click the Firefox extension popup to approve. ## Permissions @@ -481,7 +481,7 @@ Errors should be concise and actionable: - If extension is not installed: print the matching extension download URL. - If native host is not registered: print `firefox-cli setup native-host`. - If Firefox is not running or extension is disconnected: tell the user to open Firefox and check the extension popup. -- If first-use approval is pending: tell the user to open the extension popup and approve. +- If first-use approval is pending: tell the user to run `firefox-cli approve` or open the extension popup and approve. - If a page is restricted: name the restriction and suggest trying a normal web page/tab. - If a ref is stale: tell the user to run `firefox-cli snapshot -i` again. - If a command is unsupported: name the Firefox limitation or missing implementation gate. diff --git a/docs/setup.md b/docs/setup.md index 4e60bf3..fa44931 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -62,7 +62,7 @@ firefox-cli doctor --fix ## Approve Pairing -Open the `firefox-cli` extension popup in Firefox and approve the native host. The extension stores the pair token in Firefox extension storage; the native host stores pair state under the user-local `firefox-cli` state directory. +Run `firefox-cli approve` or open the `firefox-cli` extension popup in Firefox and approve the native host. The extension stores the pair token in Firefox extension storage; the native host stores pair state under the user-local `firefox-cli` state directory. Verify the connection: diff --git a/packages/cli/src/argv-contracts.ts b/packages/cli/src/argv-contracts.ts index 680a0de..9fca728 100644 --- a/packages/cli/src/argv-contracts.ts +++ b/packages/cli/src/argv-contracts.ts @@ -85,6 +85,7 @@ export const routeParserSpecs = { flags: ["--bail", "--stdin"], valueOptions: ["--timeout", "--max-output"], }), + approve: parser("approve"), click: parser("click", { valueOptions: ["--generation"] }), dblclick: parser("dblclick", { valueOptions: ["--generation"] }), focus: parser("focus", { valueOptions: ["--generation"] }), diff --git a/packages/cli/src/cli-help.test.ts b/packages/cli/src/cli-help.test.ts index b231091..d9c077f 100644 --- a/packages/cli/src/cli-help.test.ts +++ b/packages/cli/src/cli-help.test.ts @@ -3,7 +3,7 @@ import { baseDependencies } from "./cli-test-support.js"; import { runCli } from "./index.js"; describe("CLI help", () => { - it("renders workflow-oriented root help without setup approval warnings", async () => { + it("renders workflow-oriented root help without popup approval warnings", async () => { const output = await runCli(["-h"], baseDependencies()); expect(output.exitCode).toBe(0); @@ -13,9 +13,8 @@ describe("CLI help", () => { expect(output.stdout).toContain("Act on elements:"); expect(output.stdout).toContain("firefox-cli snapshot -i"); expect(output.stdout).toContain("firefox-cli <command> -h"); - expect(output.stdout).not.toContain("approve"); - expect(output.stdout).not.toContain("approval"); expect(output.stdout).not.toContain("extension popup"); + expect(output.stdout).toContain("firefox-cli approve"); }); it("renders grouped contextual help for command families", async () => { diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 7c2a369..e12d1fc 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -79,6 +79,28 @@ describe("runCli", () => { }); }); + it("builds approval page requests", async () => { + const output = await runCli(["approve", "--json"], { + ...baseDependencies(), + sendRequest: async (request) => { + expect(request).toMatchObject({ + command: "pair.openApproval", + params: {}, + }); + return createOkResponse(request, { + ok: true, + url: "moz-extension://test/popup.html", + }); + }, + }); + + expect(output).toEqual({ + exitCode: 0, + stdout: `${JSON.stringify({ ok: true, url: "moz-extension://test/popup.html" }, null, 2)}\n`, + stderr: "", + }); + }); + it("validates injected transport response payloads before formatting", async () => { const output = await runCli(["capabilities"], { ...baseDependencies(), diff --git a/packages/cli/src/commands/pairing.ts b/packages/cli/src/commands/pairing.ts new file mode 100644 index 0000000..c5000e3 --- /dev/null +++ b/packages/cli/src/commands/pairing.ts @@ -0,0 +1,6 @@ +import type { RequestEnvelope } from "@firefox-cli/protocol"; +import { createValidatedRequest } from "../protocol-validation.js"; + +export function buildOpenApprovalRequest(): RequestEnvelope { + return createValidatedRequest("pair.openApproval", {}); +} diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index ed0ea04..2421b17 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -17,6 +17,7 @@ interface HelpGroup { const routeHelpSpecs = { capabilities: helpSpec("List supported command families and browser capability metadata."), + approve: helpSpec("Open the existing extension approval UI in a Firefox tab."), "tab.list": helpSpec("List tabs with indexes, ids, active state, titles, and URLs.", [ "Use listed indexes with `--tab <index>` and ids with `--tab id:<id>`.", ]), @@ -96,7 +97,7 @@ const helpGroups: readonly HelpGroup[] = [ { title: "Setup and diagnostics", summary: "Install, repair, inspect, and reset the Firefox/native-host connection.", - routes: ["capabilities"], + routes: ["capabilities", "approve"], }, { title: "Tabs, windows, and navigation", @@ -199,6 +200,7 @@ const builtinHelpSpecs = new Map<string, HelpSpec>([ const commandExamples: Partial<Record<RouteBindingId, readonly string[]>> = { "tab.list": ["firefox-cli tab --json"], + approve: ["firefox-cli approve"], "tab.new": ["firefox-cli tab new https://example.com"], "tab.select": ["firefox-cli tab select 1", "firefox-cli tab select id:42"], open: ["firefox-cli open https://example.com", "firefox-cli open --new-tab https://example.com"], diff --git a/packages/cli/src/route-registry.ts b/packages/cli/src/route-registry.ts index 4ed9d43..028350f 100644 --- a/packages/cli/src/route-registry.ts +++ b/packages/cli/src/route-registry.ts @@ -33,6 +33,7 @@ import { } from "./commands/content.js"; import { buildEvalRequest } from "./commands/eval.js"; import { buildCapabilitiesRequest, buildNavigationRequest, buildOpenRequest } from "./commands/navigation.js"; +import { buildOpenApprovalRequest } from "./commands/pairing.js"; import { buildPdfRequest, buildSetViewportRequest } from "./commands/phase8.js"; import { buildScreenshotRequest } from "./commands/screenshot.js"; import { buildTabsRequest, buildWindowsRequest } from "./commands/tabs-windows.js"; @@ -97,6 +98,7 @@ const routeFormatterSpecs = { "set.viewport": routeFormatter("set.viewport", "json-object", cliResponseFormatters["json-object"]), diff: routeFormatter("diff", "json-object", cliResponseFormatters["json-object"]), batch: routeFormatter("batch", "batch", cliResponseFormatters.batch), + approve: routeFormatter("pair.openApproval", "json-object", cliResponseFormatters["json-object"]), click: routeFormatter("click", "action", cliResponseFormatters.action), dblclick: routeFormatter("dblclick", "action", cliResponseFormatters.action), focus: routeFormatter("focus", "action", cliResponseFormatters.action), @@ -191,6 +193,7 @@ export const cliRouteBindings = { "set.viewport": bindCliRoute("set.viewport", "firefox-cli set viewport <width> <height> [--json]", buildSetViewportRequest), diff: bindCliRoute("diff", "firefox-cli diff url|title|snapshot <expected> [--json]", buildDiffRequest), batch: bindCliRoute("batch", "firefox-cli batch <json> | --stdin [--bail] [--json]", buildBatchRequest), + approve: bindCliRoute("approve", "firefox-cli approve [--json]", buildOpenApprovalRequest), click: bindCliRoute("click", "firefox-cli click <selector|@ref> [--json]", buildElementActionRequest), dblclick: bindCliRoute("dblclick", "firefox-cli dblclick <selector|@ref> [--json]", buildElementActionRequest), focus: bindCliRoute("focus", "firefox-cli focus <selector|@ref> [--json]", buildElementActionRequest), diff --git a/packages/extension/src/background-bootstrap-storage.test.ts b/packages/extension/src/background-bootstrap-storage.test.ts index 1190593..d387f18 100644 --- a/packages/extension/src/background-bootstrap-storage.test.ts +++ b/packages/extension/src/background-bootstrap-storage.test.ts @@ -83,6 +83,7 @@ function createFakeBrowserApi(port: NativePortLike, initialStorage: Record<strin runtime: { onMessage: runtimeOnMessage, connectNative: () => port, + getURL: (path) => `moz-extension://fake/${path}`, sendMessage: async <T = unknown>(): Promise<T> => { throw new Error("runtime.sendMessage is not implemented in this fake."); }, diff --git a/packages/extension/src/background-bootstrap-test-cases.ts b/packages/extension/src/background-bootstrap-test-cases.ts index 61643c1..c60d9a7 100644 --- a/packages/extension/src/background-bootstrap-test-cases.ts +++ b/packages/extension/src/background-bootstrap-test-cases.ts @@ -131,6 +131,7 @@ export async function runCase05() { ok: true, id: "approval", }); + await expect(adapter.openExtensionPage("popup.html")).resolves.toBe("moz-extension://fake/popup.html"); expect(browser.notifications.calls).toEqual([ { id: "approval", @@ -141,6 +142,7 @@ export async function runCase05() { }, }, ]); + expect(browser.tabs.created).toEqual([{ active: true, url: "moz-extension://fake/popup.html" }]); } class FakeNativePort implements NativePortLike { @@ -183,6 +185,7 @@ function createFakeBrowserApi(port: NativePortLike): FakeBackgroundBrowserApi { runtime: { onMessage: runtimeOnMessage, connectNative: () => port, + getURL: (path) => `moz-extension://fake/${path}`, sendMessage: async <T = unknown>(): Promise<T> => { throw new Error("runtime.sendMessage is not implemented in this fake."); }, @@ -197,7 +200,10 @@ function createFakeBrowserApi(port: NativePortLike): FakeBackgroundBrowserApi { tabs: { failNextSendMessage: false, sendMessageFailure: undefined, - create: async () => ({ id: 1, index: 0, active: true, windowId: 1 }), + create: async function create(this: { readonly created: unknown[] }, options: unknown) { + this.created.push(options); + return { id: 1, index: 0, active: true, windowId: 1 }; + }, update: async (id: number) => ({ id, index: 0, active: true, windowId: 1 }), get: async (id: number) => ({ id, index: 0, active: true, windowId: 1 }), remove: async () => undefined, @@ -205,6 +211,7 @@ function createFakeBrowserApi(port: NativePortLike): FakeBackgroundBrowserApi { goForward: async () => undefined, reload: async () => undefined, response: undefined, + created: [], sendMessage: async function sendMessage(this: { failNextSendMessage: boolean; response: unknown; @@ -283,6 +290,7 @@ type FakeBackgroundBrowserApi = Omit<BackgroundBrowserApi, "runtime" | "tabs" | failNextSendMessage: boolean; response: unknown; sendMessageFailure: Error | undefined; + readonly created: unknown[]; sendMessage(this: { failNextSendMessage: boolean; response: unknown; diff --git a/packages/extension/src/background-browser-adapter.ts b/packages/extension/src/background-browser-adapter.ts index bb62cf7..af57ff3 100644 --- a/packages/extension/src/background-browser-adapter.ts +++ b/packages/extension/src/background-browser-adapter.ts @@ -183,6 +183,11 @@ export function createBackgroundBrowserAdapter(options: { : await options.browser.notifications.create(notificationOptions.id, payload), }; }, + openExtensionPage: async (path) => { + const url = options.browser.runtime.getURL(path); + await options.browser.tabs.create({ active: true, url }); + return url; + }, resizeWindow: async (windowId, size) => { await options.browser.windows.update(windowId, size); const windows = await options.browser.windows.getAll({ populate: true }); diff --git a/packages/extension/src/background-controller-test-cases.ts b/packages/extension/src/background-controller-test-cases.ts index 16d983c..c2092ea 100644 --- a/packages/extension/src/background-controller-test-cases.ts +++ b/packages/extension/src/background-controller-test-cases.ts @@ -177,6 +177,33 @@ export async function runCase05() { } export async function runCase06() { + const port = new FakeNativePort(); + const controller = new FirefoxCliBackgroundController({ + browserAdapter: createTestBrowserAdapter([], { + openExtensionPage: async (path) => `moz-extension://test/${path}`, + }), + connectNative: () => port, + productVersion: "0.0.0", + }); + controller.start(); + await completeNativeHello(port); + + const request = createRequest("pair.openApproval", {}, "approval-page-1"); + port.emitMessage(request); + await flushPromises(); + + expect(port.messages[1]).toEqual({ + protocolVersion: request.protocolVersion, + id: "approval-page-1", + ok: true, + result: { + ok: true, + url: "moz-extension://test/popup.html", + }, + }); +} + +export async function runCase07() { const port = new FakeNativePort(); const browserCalls: string[] = []; const controller = new FirefoxCliBackgroundController({ @@ -237,7 +264,7 @@ export async function runCase06() { expect(browserCalls).toEqual([]); } -export async function runCase07() { +export async function runCase08() { const port = new FakeNativePort(); const browserCalls: string[] = []; const controller = new FirefoxCliBackgroundController({ diff --git a/packages/extension/src/background-controller-test-support.ts b/packages/extension/src/background-controller-test-support.ts index aadbc9b..c7d69a8 100644 --- a/packages/extension/src/background-controller-test-support.ts +++ b/packages/extension/src/background-controller-test-support.ts @@ -112,6 +112,7 @@ export function createTestBrowserAdapter( ok: true, id: options.id ?? "notification-1", }), + openExtensionPage: async (path) => `moz-extension://test/${path}`, resizeWindow: async () => { throw new Error("not implemented"); }, diff --git a/packages/extension/src/background-controller.test.ts b/packages/extension/src/background-controller.test.ts index eaeb0a9..3f5ab44 100644 --- a/packages/extension/src/background-controller.test.ts +++ b/packages/extension/src/background-controller.test.ts @@ -1,5 +1,5 @@ import { describe, it } from "vitest"; -import { runCase01, runCase02, runCase03, runCase04, runCase05, runCase06, runCase07 } from "./background-controller-test-cases.js"; +import { runCase01, runCase02, runCase03, runCase04, runCase05, runCase06, runCase07, runCase08 } from "./background-controller-test-cases.js"; describe("FirefoxCliBackgroundController", () => { it("connects to the native host and sends hello", runCase01); @@ -7,6 +7,7 @@ describe("FirefoxCliBackgroundController", () => { it("answers native-host capability and no-op requests", runCase03); it("lists tabs through the injected Firefox browser adapter", runCase04); it("rejects native-host requests before popup approval", runCase05); - it("gates unapproved privilege-sensitive native-host requests before browser handlers", runCase06); - it("rejects malformed sensitive native-host requests before browser handlers", runCase07); + it("opens the existing approval UI before popup approval", runCase06); + it("gates unapproved privilege-sensitive native-host requests before browser handlers", runCase07); + it("rejects malformed sensitive native-host requests before browser handlers", runCase08); }); diff --git a/packages/extension/src/background-default-browser-adapter.ts b/packages/extension/src/background-default-browser-adapter.ts index 8b168f4..c435878 100644 --- a/packages/extension/src/background-default-browser-adapter.ts +++ b/packages/extension/src/background-default-browser-adapter.ts @@ -70,6 +70,9 @@ export function createUnconfiguredBrowserAdapter(): BackgroundBrowserAdapter { showNotification: async () => { throw new Error("Browser adapter is not configured."); }, + openExtensionPage: async () => { + throw new Error("Browser adapter is not configured."); + }, resizeWindow: async () => { throw new Error("Browser adapter is not configured."); }, diff --git a/packages/extension/src/background-request-handler.ts b/packages/extension/src/background-request-handler.ts index dc27063..c4dce01 100644 --- a/packages/extension/src/background-request-handler.ts +++ b/packages/extension/src/background-request-handler.ts @@ -1,4 +1,5 @@ import { + commandAllowedBeforeApproval, createLocalComponentIdentity, kernelCapabilities, localProtocolVersionRange, @@ -35,7 +36,7 @@ export function handleRequest(options: { }); } - if (!approved) { + if (!approved && !commandAllowedBeforeApproval(request.command)) { return protocolSession.createErrorResponse(request.id, { code: "NOT_APPROVED", message: "Approve firefox-cli in the extension popup before running CLI commands.", diff --git a/packages/extension/src/browser-command/types.ts b/packages/extension/src/browser-command/types.ts index 39aef45..678e1ca 100644 --- a/packages/extension/src/browser-command/types.ts +++ b/packages/extension/src/browser-command/types.ts @@ -50,6 +50,7 @@ export interface BackgroundBrowserAdapter { clearNetworkRequests(options: { readonly tabId: number; readonly urlGlob?: string }): Promise<void>; waitForNetworkIdle(options: { readonly tabId: number; readonly timeoutMs: number; readonly idleMs: number }): Promise<void>; showNotification(options: { readonly id?: string; readonly title: string; readonly message?: string }): Promise<NotifyResult>; + openExtensionPage(path: string): Promise<string>; resizeWindow(windowId: number, size: { readonly width: number; readonly height: number }): Promise<BrowserWindowSnapshot>; } diff --git a/packages/extension/src/browser-commands-content.test.ts b/packages/extension/src/browser-commands-content.test.ts index e69c8ca..86d1394 100644 --- a/packages/extension/src/browser-commands-content.test.ts +++ b/packages/extension/src/browser-commands-content.test.ts @@ -189,6 +189,11 @@ describe("browser command handling", () => { result: { id: "approval" }, }); expect(adapter.notifications).toEqual([{ id: "approval", title: "Action needed", message: "Open Firefox to approve control." }]); + await expect(handleBrowserRequest(createRequest("pair.openApproval", {}, "approval-page-1"), adapter)).resolves.toMatchObject({ + ok: true, + result: { url: "moz-extension://test/popup.html" }, + }); + expect(adapter.extensionPages).toEqual(["popup.html"]); await expect(handleBrowserRequest(createRequest("set.viewport", { width: 1200, height: 800 }, "viewport-1"), adapter)).resolves.toMatchObject({ ok: true, diff --git a/packages/extension/src/browser-commands-test-smoke.ts b/packages/extension/src/browser-commands-test-smoke.ts index a4ad7e9..b599fee 100644 --- a/packages/extension/src/browser-commands-test-smoke.ts +++ b/packages/extension/src/browser-commands-test-smoke.ts @@ -57,4 +57,5 @@ export const browserSmokeRequests = new Map<CommandId, unknown>([ ["scroll", actionParamsFor("scroll")], ["scrollintoview", actionParamsFor("scrollintoview")], ["swipe", actionParamsFor("swipe")], + ["pair.openApproval", {}], ]); diff --git a/packages/extension/src/browser-commands-test-utils.ts b/packages/extension/src/browser-commands-test-utils.ts index bee44b3..bee1197 100644 --- a/packages/extension/src/browser-commands-test-utils.ts +++ b/packages/extension/src/browser-commands-test-utils.ts @@ -51,6 +51,7 @@ export class FakeBrowserAdapter implements BackgroundBrowserAdapter { readonly idleMs: number; }[] = []; readonly notifications: { readonly id?: string; readonly title: string; readonly message?: string }[] = []; + readonly extensionPages: string[] = []; clipboardText = ""; networkRequests: { readonly id: string; readonly tabId: number; readonly url: string }[] = []; listWindowCalls = 0; @@ -277,6 +278,11 @@ export class FakeBrowserAdapter implements BackgroundBrowserAdapter { return { ok: true as const, id: options.id ?? `notification-${String(this.notifications.length)}` }; } + async openExtensionPage(path: string): Promise<string> { + this.extensionPages.push(path); + return `moz-extension://test/${path}`; + } + async resizeWindow(windowId: number, size: { readonly width: number; readonly height: number }): Promise<BrowserWindowSnapshot> { const window = this.#windows.find((candidate) => candidate.id === windowId); if (window === undefined) { diff --git a/packages/extension/src/browser-handlers/phase8-browser.ts b/packages/extension/src/browser-handlers/phase8-browser.ts index 83cc2e8..9cb6381 100644 --- a/packages/extension/src/browser-handlers/phase8-browser.ts +++ b/packages/extension/src/browser-handlers/phase8-browser.ts @@ -11,7 +11,7 @@ import { BrowserCommandError } from "../browser-command/errors.js"; import { toOrderedWindows, toWindowSummary } from "../browser-command/targets.js"; import type { BrowserHandlerMap } from "./types.js"; -type Phase8BrowserCommand = "download" | "clipboard" | "cookies" | "network" | "notify" | "pdf" | "set.viewport"; +type Phase8BrowserCommand = "download" | "clipboard" | "cookies" | "network" | "notify" | "pair.openApproval" | "pdf" | "set.viewport"; export const phase8BrowserHandlers: BrowserHandlerMap<Phase8BrowserCommand> = { download: async (request, adapter) => { @@ -110,6 +110,12 @@ export const phase8BrowserHandlers: BrowserHandlerMap<Phase8BrowserCommand> = { }); return createOkResponse(request, result); }, + "pair.openApproval": async (request, adapter) => { + return createOkResponse(request, { + ok: true, + url: await adapter.openExtensionPage("popup.html"), + }); + }, pdf: async (request) => { return createErrorResponseForRequest(request, { code: "UNSUPPORTED_CAPABILITY", diff --git a/packages/extension/src/webextension.d.ts b/packages/extension/src/webextension.d.ts index fcb1d35..34993fe 100644 --- a/packages/extension/src/webextension.d.ts +++ b/packages/extension/src/webextension.d.ts @@ -35,6 +35,7 @@ declare const browser: { }; postMessage(message: unknown): void; }; + getURL(path: string): string; sendMessage<T = unknown>(message: unknown): Promise<T>; reload(): void; }; diff --git a/packages/native-host/src/host-broker.test.ts b/packages/native-host/src/host-broker.test.ts index 47509ab..74cace9 100644 --- a/packages/native-host/src/host-broker.test.ts +++ b/packages/native-host/src/host-broker.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "vitest"; -import { PROTOCOL_VERSION, createProtocolSession, createOkResponse, createRequest, kernelCapabilities, parseBoundaryResponse } from "@firefox-cli/protocol"; +import { + PROTOCOL_VERSION, + createProtocolSession, + createOkResponse, + createRequest, + kernelCapabilities, + parseBoundaryResponse, + type RequestEnvelope, +} from "@firefox-cli/protocol"; import { FIREFOX_CLI_EXTENSION_ID } from "./host-launch.js"; import { createHostIdentity } from "./pair-state.js"; import { NativeHostBroker } from "./host-broker.js"; @@ -155,6 +163,28 @@ describe("NativeHostBroker", () => { }); }); + it("allows opening the approval UI before extension approval", async () => { + const request = createRequest("pair.openApproval", {}, "approval-1"); + let forwardedRequest: RequestEnvelope | undefined; + const broker = new NativeHostBroker({ + hostIdentity: createHostIdentity({ + extensionId: FIREFOX_CLI_EXTENSION_ID, + generateId: () => "host-1", + }), + }); + broker.connectExtension({ + approved: false, + token: undefined, + send: async (forwarded) => { + forwardedRequest = forwarded; + return createOkResponse(forwarded, { ok: true, url: "moz-extension://test/popup.html" }); + }, + }); + + await expect(broker.handleCliRequest(request)).resolves.toEqual(createOkResponse(request, { ok: true, url: "moz-extension://test/popup.html" })); + expect(forwardedRequest).toMatchObject({ command: "pair.openApproval" }); + }); + it("rejects CLI requests when the extension pair token is invalid", async () => { const request = createRequest("noop", {}, "request-1"); const broker = new NativeHostBroker({ diff --git a/packages/native-host/src/host-broker.ts b/packages/native-host/src/host-broker.ts index fab393b..c4e1df5 100644 --- a/packages/native-host/src/host-broker.ts +++ b/packages/native-host/src/host-broker.ts @@ -2,6 +2,7 @@ import { Buffer } from "node:buffer"; import { writeFile } from "node:fs/promises"; import { MAX_SCREENSHOT_BYTES, + commandAllowedBeforeApproval, createLocalComponentIdentity, createProtocolSession, createRequestProtocolMismatchError, @@ -150,9 +151,11 @@ export class NativeHostBroker { return { ok: false, response: cliSession.createErrorResponseForRequest(request, extensionSession.error) }; } - const approval = await this.#verifyExtensionApproval(connection); - if (!approval.ok) { - return { ok: false, response: cliSession.createErrorResponseForRequest(request, approval.error) }; + if (!commandAllowedBeforeApproval(request.command)) { + const approval = await this.#verifyExtensionApproval(connection); + if (!approval.ok) { + return { ok: false, response: cliSession.createErrorResponseForRequest(request, approval.error) }; + } } if (!getRequestProtocolCompatibility(request, extensionSession.value.protocolVersion).compatible) { diff --git a/packages/protocol/src/pairing.ts b/packages/protocol/src/pairing.ts index bfd7427..11663ae 100644 --- a/packages/protocol/src/pairing.ts +++ b/packages/protocol/src/pairing.ts @@ -15,3 +15,11 @@ export const pairResetParamsSchema = z.object({}).strict(); export const pairResetResultSchema = z.object({ ok: z.literal(true), }); + +export const pairOpenApprovalParamsSchema = z.object({}).strict(); +export const pairOpenApprovalResultSchema = z + .object({ + ok: z.literal(true), + url: z.string().min(1), + }) + .strict(); diff --git a/packages/protocol/src/protocol-metadata-behavior.test.ts b/packages/protocol/src/protocol-metadata-behavior.test.ts index 0e44612..9d9d599 100644 --- a/packages/protocol/src/protocol-metadata-behavior.test.ts +++ b/packages/protocol/src/protocol-metadata-behavior.test.ts @@ -68,7 +68,7 @@ describe("protocol command metadata", () => { } const nonBatchableCommands = commandIds().filter((command) => !isBatchableCommandId(command)); - expect(nonBatchableCommands).toEqual(["hello", "capabilities", "noop", "batch", "pair.approve", "pair.reset"]); + expect(nonBatchableCommands).toEqual(["hello", "capabilities", "noop", "batch", "pair.approve", "pair.reset", "pair.openApproval"]); }); it("marks only required tab/window selectors for protocol batch default targets", () => { diff --git a/packages/protocol/src/protocol-test-support.ts b/packages/protocol/src/protocol-test-support.ts index 8a2347c..e71f93a 100644 --- a/packages/protocol/src/protocol-test-support.ts +++ b/packages/protocol/src/protocol-test-support.ts @@ -81,4 +81,5 @@ export const expectedCliRoutesByCommand: Partial<Record<CommandId, readonly CliR scroll: [{ id: "scroll", path: ["scroll"], batch: true }], scrollintoview: [{ id: "scrollintoview", path: ["scrollintoview"], batch: true }], swipe: [{ id: "swipe", path: ["swipe"], batch: true }], + "pair.openApproval": [{ id: "approve", path: ["approve"], batch: false }], } as const; diff --git a/packages/protocol/src/registry/index.ts b/packages/protocol/src/registry/index.ts index 288cd7f..34a4fd5 100644 --- a/packages/protocol/src/registry/index.ts +++ b/packages/protocol/src/registry/index.ts @@ -79,6 +79,10 @@ export const commandSchemas = assembleCommandRegistry( export type CommandId = keyof typeof commandSchemas; +export function commandAllowedBeforeApproval(command: CommandId): boolean { + return command === "pair.openApproval"; +} + type CommandsWithContentPolicy<P> = { readonly [C in CommandId]: (typeof commandSchemas)[C]["content"] extends P ? C : never; }[CommandId]; diff --git a/packages/protocol/src/registry/pairing.ts b/packages/protocol/src/registry/pairing.ts index 7a3283c..8740d30 100644 --- a/packages/protocol/src/registry/pairing.ts +++ b/packages/protocol/src/registry/pairing.ts @@ -1,4 +1,11 @@ -import { pairApproveParamsSchema, pairApproveResultSchema, pairResetParamsSchema, pairResetResultSchema } from "../pairing.js"; +import { + pairApproveParamsSchema, + pairApproveResultSchema, + pairOpenApprovalParamsSchema, + pairOpenApprovalResultSchema, + pairResetParamsSchema, + pairResetResultSchema, +} from "../pairing.js"; import { defineCommandEntries } from "./define.js"; export const pairingCommandEntries = defineCommandEntries({ @@ -26,4 +33,16 @@ export const pairingCommandEntries = defineCommandEntries({ batch: { allowed: false }, cliRoutes: [], }, + "pair.openApproval": { + params: pairOpenApprovalParamsSchema, + result: pairOpenApprovalResultSchema, + status: "mvp", + owner: "extension", + target: "none", + content: "never", + action: false, + timeout: "none", + batch: { allowed: false }, + cliRoutes: [{ id: "approve", path: ["approve"], batch: false }], + }, }); diff --git a/packages/protocol/src/registry/registry.test.ts b/packages/protocol/src/registry/registry.test.ts index 516d09c..2116a17 100644 --- a/packages/protocol/src/registry/registry.test.ts +++ b/packages/protocol/src/registry/registry.test.ts @@ -76,6 +76,7 @@ const expectedCommandIds = [ "swipe", "pair.approve", "pair.reset", + "pair.openApproval", ] as const satisfies readonly CommandId[]; type Assert<T extends true> = T; From a85b150c4f9ab52aa094c9f77e3aa2f3d6ff98b4 Mon Sep 17 00:00:00 2001 From: Nek-12 <vaizin.nikita@gmail.com> Date: Thu, 11 Jun 2026 15:49:28 +0200 Subject: [PATCH 05/11] Add dedicated connection approval request --- README.md | 2 +- docs/all-commands-qa.md | 2 +- docs/architecture.md | 6 +- docs/commands.md | 4 +- docs/firefox-cli-spec.md | 16 +- docs/setup.md | 8 +- packages/cli/src/argv-contracts.ts | 2 +- packages/cli/src/cli-help.test.ts | 2 +- packages/cli/src/cli-setup-doctor.test.ts | 2 +- packages/cli/src/cli.test.ts | 46 +++- packages/cli/src/commands/pairing.ts | 2 +- packages/cli/src/commands/setup-doctor.ts | 2 +- packages/cli/src/format.test.ts | 2 +- packages/cli/src/format.ts | 8 + packages/cli/src/help.ts | 6 +- packages/cli/src/route-registry.ts | 6 +- packages/cli/src/runner.ts | 2 +- packages/cli/src/transport.ts | 6 +- .../extension/src/approval-permissions.ts | 19 ++ .../extension/src/approval-request-service.ts | 246 ++++++++++++++++++ packages/extension/src/approval-request.css | 109 ++++++++ packages/extension/src/approval-request.html | 23 ++ .../extension/src/approval-request.test.ts | 89 +++++++ packages/extension/src/approval-request.ts | 94 +++++++ .../extension/src/background-bootstrap.ts | 10 +- .../src/background-browser-adapter.ts | 10 +- ...und-controller-approval-race-test-cases.ts | 111 ++++++++ ...ckground-controller-approval-test-cases.ts | 137 +++++++++- .../background-controller-approval.test.ts | 9 +- .../src/background-controller-test-cases.ts | 52 +++- .../src/background-controller-test-support.ts | 4 +- .../src/background-controller.test.ts | 4 +- .../extension/src/background-controller.ts | 97 ++++--- .../src/background-default-browser-adapter.ts | 3 + .../src/background-native-session.ts | 4 + .../src/background-request-forwarder.ts | 7 + .../src/background-request-handler.ts | 2 +- .../extension/src/browser-command/types.ts | 1 + .../src/browser-commands-content.test.ts | 6 - .../src/browser-commands-test-cases.ts | 9 +- .../src/browser-commands-test-smoke.ts | 1 - .../src/browser-commands-test-utils.ts | 8 + .../src/browser-handlers/phase8-browser.ts | 8 +- packages/extension/src/popup.ts | 20 +- packages/extension/src/webextension.d.ts | 6 +- .../native-host/src/host-broker-helpers.ts | 2 +- packages/native-host/src/host-broker.test.ts | 12 +- packages/native-host/src/host-broker.ts | 2 +- packages/protocol/src/capabilities.ts | 6 - packages/protocol/src/constants.ts | 2 +- packages/protocol/src/pairing.ts | 8 + .../src/protocol-metadata-behavior.test.ts | 24 +- .../protocol/src/protocol-test-support.ts | 3 +- packages/protocol/src/registry/index.ts | 2 +- packages/protocol/src/registry/pairing.ts | 24 +- .../protocol/src/registry/registry.test.ts | 1 + scripts/build-extension.ts | 1 + scripts/copy-extension-assets.ts | 2 + scripts/extension-payload-check.ts | 18 +- scripts/test/copy-extension-assets.test.ts | 2 + scripts/test/package-check-test-utils.ts | 3 + skills/firefox-cli/SKILL.md | 29 +-- 62 files changed, 1183 insertions(+), 171 deletions(-) create mode 100644 packages/extension/src/approval-permissions.ts create mode 100644 packages/extension/src/approval-request-service.ts create mode 100644 packages/extension/src/approval-request.css create mode 100644 packages/extension/src/approval-request.html create mode 100644 packages/extension/src/approval-request.test.ts create mode 100644 packages/extension/src/approval-request.ts create mode 100644 packages/extension/src/background-controller-approval-race-test-cases.ts diff --git a/README.md b/README.md index 25ed009..bbbdf96 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Register the native messaging host: firefox-cli setup native-host ``` -Open the `firefox-cli` extension popup in Firefox and approve the native host. The approval pairs the extension with the local native host and enables CLI requests from the machine. +Run `firefox-cli connect` and respond to the approval request in Firefox. The approval pairs the extension with the local native host and enables CLI requests from the machine. Verify the installation: diff --git a/docs/all-commands-qa.md b/docs/all-commands-qa.md index a808f24..915c430 100644 --- a/docs/all-commands-qa.md +++ b/docs/all-commands-qa.md @@ -37,6 +37,7 @@ Create `UPLOAD_FILE` with any small text payload. Capture IDs from JSON output w - [ ] Run `$CLI doctor --json`; expect the disposable extension connection to be `"connected"`. - [ ] Run `$CLI doctor --fix --json`; expect the manifest status to be healthy and the connection to remain `"connected"`. - [ ] Run `$CLI capabilities --json`; expect MVP capabilities plus explicit unsupported entries. +- [ ] Run `$CLI connect`; expect an already-approved rejection that identifies the extension instance. - [ ] Run `$CLI window new "$BASE" --json`; save `WINDOW` and `TAB`. - [ ] Run `$CLI window --json`; expect `WINDOW` in the window list. - [ ] Run `$CLI window select "id:$WINDOW" --json`; expect `WINDOW` to be selected. @@ -140,7 +141,6 @@ Create `UPLOAD_FILE` with any small text payload. Capture IDs from JSON output w - [ ] Run `$CLI close`; expect `UNSUPPORTED_CAPABILITY`. - [ ] Run `$CLI quit`; expect `UNSUPPORTED_CAPABILITY`. - [ ] Run `$CLI exit`; expect `UNSUPPORTED_CAPABILITY`. -- [ ] Run `$CLI connect`; expect `UNSUPPORTED_CAPABILITY`. - [ ] Run `$CLI inspect`; expect `UNSUPPORTED_CAPABILITY`. - [ ] Run `$CLI tab close "id:$TAB3" --json`; expect `TAB3` to close. - [ ] Run `$CLI tab close "id:$TAB2" --json`; expect `TAB2` to close. diff --git a/docs/architecture.md b/docs/architecture.md index ab8e25a..068da58 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,7 +4,7 @@ - CLI: parses commands, prints terminal output, stores local configuration, and sends requests to the native host IPC endpoint. - Native host: owns Firefox native messaging, local IPC, pair state, auth tokens, native-host manifest setup, and file writes for binary outputs such as screenshots. -- Extension: owns Firefox APIs, popup approval, tab/window targeting, content-script injection, command routing, and browser permission errors. +- Extension: owns Firefox APIs, first-use approval, tab/window targeting, content-script injection, command routing, and browser permission errors. - Protocol: defines command IDs, request/response schemas, capability metadata, stable errors, and runtime validation. ## Transport @@ -15,9 +15,9 @@ Native-host stdout is reserved for Firefox native messaging frames. Human-readab ## Pairing -The first popup approval creates a pair token. The extension stores the token in extension storage; the native host stores a hash and extension identity in user-local state. CLI requests are forwarded only when the connected extension has presented a valid token. +The first approval request creates a pair token. The extension stores the token in extension storage; the native host stores a hash and extension identity in user-local state. CLI requests are forwarded only when the connected extension has presented a valid token. -`firefox-cli unpair` clears native-host pair state. The extension popup can approve again and receive a new token. +`firefox-cli unpair` clears native-host pair state. Run `firefox-cli connect` to request approval again. ## Targeting diff --git a/docs/commands.md b/docs/commands.md index b106316..17b226d 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -26,7 +26,7 @@ Private windows are listed and readable. Mutating commands against private windo | `firefox-cli setup` | Print extension setup guidance and the native-host setup command. | | `firefox-cli setup native-host [--dry-run] [--json]` | Write or print the native messaging manifest. | | `firefox-cli doctor [--fix] [--json]` | Diagnose native-host manifest and extension connection state. | -| `firefox-cli approve [--json]` | Open the extension approval UI in a Firefox tab. | +| `firefox-cli connect [--json]` | Request Firefox control approval and wait for the user's decision. | | `firefox-cli unpair` | Clear CLI/native-host pair state. | | `firefox-cli capabilities [--json]` | List supported and gated protocol capabilities. | @@ -168,4 +168,4 @@ Network route/mock/block and HAR export are unsupported. ## Unsupported Families -The CLI returns `UNSUPPORTED_CAPABILITY` for unsupported command families and options, including `screenshot --full`, `pdf`, `connect`, `inspect`, top-level `close`, `quit`, and `exit`. +The CLI returns `UNSUPPORTED_CAPABILITY` for unsupported command families and options, including `screenshot --full`, `pdf`, `inspect`, top-level `close`, `quit`, and `exit`. diff --git a/docs/firefox-cli-spec.md b/docs/firefox-cli-spec.md index 9672dec..19d4663 100644 --- a/docs/firefox-cli-spec.md +++ b/docs/firefox-cli-spec.md @@ -22,7 +22,7 @@ The happy path: 1. User installs the npm package and gets one executable: `firefox-cli`. 2. User manually installs or temporarily loads the Firefox extension from the URL printed by `firefox-cli setup`. 3. User runs `firefox-cli setup native-host` or `firefox-cli doctor --fix` to register the native messaging host. -4. User runs `firefox-cli approve` or opens the extension popup and approves the first connection after seeing native-host identity details. +4. User runs `firefox-cli connect` and responds to the Firefox approval request, or opens the extension popup and approves the first connection. 5. Commands control the active Firefox tab/window unless a command or flag selects another target. Example workflow: @@ -96,8 +96,8 @@ Unpaired handshake: 1. Extension connects to the native host. 2. Native host sends identity metadata: host name, executable path, package version, protocol min/max, native manifest path, extension ID, and a generated pairing nonce. -3. The extension approval UI shows the metadata and asks the user to approve first use. -4. Until approval, the native host rejects or queues CLI commands with `NOT_APPROVED`. +3. The extension approval UI asks the user to approve first use. +4. Until approval, the native host rejects CLI commands with `NOT_APPROVED`, except for the dedicated approval request command. 5. On approval, extension and native host persist the minimum pair state needed to reconnect. After approval, all commands are allowed without per-action confirmations, domain allowlists, or action policies. @@ -138,7 +138,7 @@ Keep the extension UI smaller than a control panel. The popup should show: - Approval/reset action for the first connection. - Copy diagnostics action. -Do not mirror CLI commands in the popup. Setup text can tell users to run `firefox-cli approve` or click the Firefox extension popup to approve. +Do not mirror CLI commands in the popup. Setup text can tell users to run `firefox-cli connect` or click the Firefox extension popup to approve. ## Permissions @@ -322,7 +322,7 @@ Agent-browser family compatibility summary: | Dialogs, downloads, clipboard, cookies, storage, network list/clear | MVP with Firefox/WebExtension limits; HAR unsupported | | Debug/repro: console/errors, `highlight`, diff, trace/profiler, vitals | MVP for listed commands; deferred as listed below | | Auth/state/session/profile/security gates/content boundaries | Deferred or unsupported in MVP because this controls the existing Firefox session after pairing | -| Chrome/CDP/provider/browser-install features: `connect`, `get cdp-url`, `inspect`, `--extension`, external providers, iOS, Chrome profile import, browser install/upgrade | Unsupported unless Firefox provides an equivalent | +| Chrome/CDP/provider/browser-install features: CDP attach, `get cdp-url`, `inspect`, `--extension`, external providers, iOS, Chrome profile import, browser install/upgrade | Unsupported unless Firefox provides an equivalent | Global options: @@ -403,7 +403,7 @@ Unsupported unless Firefox provides an equivalent: - Top-level `close`, `quit`, `exit`, and `close --all`; use explicit `tab close` and `window close`. - `confirm` and `deny`, because MVP has no per-action confirmation queue. - Chrome `debugger`/CDP-specific commands. -- `connect <port|url>` and `get cdp-url`. +- CDP attach by port/URL and `get cdp-url`. - DevTools opening/inspection behavior equivalent to `agent-browser inspect`. - Chrome extension loading flags such as `--extension`. - External browser providers and iOS provider. @@ -480,8 +480,8 @@ Errors should be concise and actionable: - If extension is not installed: print the matching extension download URL. - If native host is not registered: print `firefox-cli setup native-host`. -- If Firefox is not running or extension is disconnected: tell the user to open Firefox and check the extension popup. -- If first-use approval is pending: tell the user to run `firefox-cli approve` or open the extension popup and approve. +- If Firefox is not running or extension is disconnected: tell the user to open Firefox and run `firefox-cli connect`. +- If first-use approval is pending: tell the user to run `firefox-cli connect` or open the extension popup and approve. - If a page is restricted: name the restriction and suggest trying a normal web page/tab. - If a ref is stale: tell the user to run `firefox-cli snapshot -i` again. - If a command is unsupported: name the Firefox limitation or missing implementation gate. diff --git a/docs/setup.md b/docs/setup.md index fa44931..1ebbe90 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -5,7 +5,7 @@ - the CLI/native host executable from the npm package; - the Firefox extension that connects Firefox to the native host. -The CLI cannot inspect Firefox until the extension is loaded, the native messaging manifest is installed, and the extension popup has approved the pair. +The CLI cannot inspect Firefox until the extension is loaded, the native messaging manifest is installed, and the user approves the Firefox control request. ## Install The CLI @@ -60,9 +60,9 @@ firefox-cli doctor --fix `doctor --fix` repairs missing or stale native-host manifests. -## Approve Pairing +## Connect Firefox -Run `firefox-cli approve` or open the `firefox-cli` extension popup in Firefox and approve the native host. The extension stores the pair token in Firefox extension storage; the native host stores pair state under the user-local `firefox-cli` state directory. +Run `firefox-cli connect` and respond to the approval request in Firefox, or open the `firefox-cli` extension popup in Firefox and approve the native host. The extension stores the pair token in Firefox extension storage; the native host stores pair state under the user-local `firefox-cli` state directory. Verify the connection: @@ -86,7 +86,7 @@ firefox-cli tab : Load or enable the extension and keep Firefox running. `Extension connection: not-approved` -: Open the extension popup and approve the native host. +: Run `firefox-cli connect` and respond to the approval request in Firefox. `Version mismatch` : Upgrade or rebuild the CLI, native host, and extension from the same package version. diff --git a/packages/cli/src/argv-contracts.ts b/packages/cli/src/argv-contracts.ts index 9fca728..ecee97f 100644 --- a/packages/cli/src/argv-contracts.ts +++ b/packages/cli/src/argv-contracts.ts @@ -85,7 +85,7 @@ export const routeParserSpecs = { flags: ["--bail", "--stdin"], valueOptions: ["--timeout", "--max-output"], }), - approve: parser("approve"), + connect: parser("connect"), click: parser("click", { valueOptions: ["--generation"] }), dblclick: parser("dblclick", { valueOptions: ["--generation"] }), focus: parser("focus", { valueOptions: ["--generation"] }), diff --git a/packages/cli/src/cli-help.test.ts b/packages/cli/src/cli-help.test.ts index d9c077f..3b9794c 100644 --- a/packages/cli/src/cli-help.test.ts +++ b/packages/cli/src/cli-help.test.ts @@ -14,7 +14,7 @@ describe("CLI help", () => { expect(output.stdout).toContain("firefox-cli snapshot -i"); expect(output.stdout).toContain("firefox-cli <command> -h"); expect(output.stdout).not.toContain("extension popup"); - expect(output.stdout).toContain("firefox-cli approve"); + expect(output.stdout).toContain("firefox-cli connect"); }); it("renders grouped contextual help for command families", async () => { diff --git a/packages/cli/src/cli-setup-doctor.test.ts b/packages/cli/src/cli-setup-doctor.test.ts index 7ffa529..f5ae56f 100644 --- a/packages/cli/src/cli-setup-doctor.test.ts +++ b/packages/cli/src/cli-setup-doctor.test.ts @@ -379,7 +379,7 @@ describe("runCli setup and doctor", () => { expect(output).toEqual({ exitCode: 0, - stdout: "Pair state cleared. Approve firefox-cli again from the extension popup.\n", + stdout: "Pair state cleared. Run `firefox-cli connect` to request approval again.\n", stderr: "", }); expect(unpairCalls).toEqual(["cleared"]); diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index e12d1fc..2fb1aca 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -68,39 +68,73 @@ describe("runCli", () => { sendRequest: async (request) => createErrorResponse(request.id, { code: "NOT_APPROVED", - message: "Approve firefox-cli in the extension popup before running CLI commands.", + message: "Run `firefox-cli connect` before running Firefox control commands.", }), }); expect(output).toEqual({ exitCode: 1, stdout: "", - stderr: "Not approved: Approve firefox-cli in the extension popup before running CLI commands.\n", + stderr: "Not approved: Run `firefox-cli connect` before running Firefox control commands.\n", }); }); it("builds approval page requests", async () => { - const output = await runCli(["approve", "--json"], { + const output = await runCli(["connect", "--json"], { ...baseDependencies(), sendRequest: async (request) => { expect(request).toMatchObject({ - command: "pair.openApproval", + command: "pair.requestApproval", params: {}, }); return createOkResponse(request, { ok: true, - url: "moz-extension://test/popup.html", + url: "moz-extension://test/approval-request.html", }); }, }); expect(output).toEqual({ exitCode: 0, - stdout: `${JSON.stringify({ ok: true, url: "moz-extension://test/popup.html" }, null, 2)}\n`, + stdout: `${JSON.stringify({ ok: true, url: "moz-extension://test/approval-request.html" }, null, 2)}\n`, stderr: "", }); }); + it("prints accepted approval requests with user-decision wording", async () => { + const output = await runCli(["connect"], { + ...baseDependencies(), + sendRequest: async (request) => + createOkResponse(request, { + ok: true, + url: "moz-extension://test/approval-request.html", + }), + }); + + expect(output).toEqual({ + exitCode: 0, + stdout: "User approved the request\n", + stderr: "", + }); + }); + + it("prints rejected approval requests without an error-code prefix", async () => { + const output = await runCli(["connect"], { + ...baseDependencies(), + sendRequest: async (request) => + createErrorResponse(request.id, { + code: "ACTION_REJECTED", + message: "User explicitly denied your request.", + }), + }); + + expect(output).toEqual({ + exitCode: 1, + stdout: "", + stderr: "User explicitly denied your request.\n", + }); + }); + it("validates injected transport response payloads before formatting", async () => { const output = await runCli(["capabilities"], { ...baseDependencies(), diff --git a/packages/cli/src/commands/pairing.ts b/packages/cli/src/commands/pairing.ts index c5000e3..c2216db 100644 --- a/packages/cli/src/commands/pairing.ts +++ b/packages/cli/src/commands/pairing.ts @@ -2,5 +2,5 @@ import type { RequestEnvelope } from "@firefox-cli/protocol"; import { createValidatedRequest } from "../protocol-validation.js"; export function buildOpenApprovalRequest(): RequestEnvelope { - return createValidatedRequest("pair.openApproval", {}); + return createValidatedRequest("pair.requestApproval", {}); } diff --git a/packages/cli/src/commands/setup-doctor.ts b/packages/cli/src/commands/setup-doctor.ts index ccf7ffe..1120ab1 100644 --- a/packages/cli/src/commands/setup-doctor.ts +++ b/packages/cli/src/commands/setup-doctor.ts @@ -227,7 +227,7 @@ async function checkExtensionConnection(dependencies: CliDependencies): Promise< if (response.error.code === "NOT_APPROVED") { return { status: "not-approved", - nextAction: "Open the firefox-cli extension popup and approve this native host.", + nextAction: "Run `firefox-cli connect` and respond to the approval request in Firefox.", }; } diff --git a/packages/cli/src/format.test.ts b/packages/cli/src/format.test.ts index 163309a..a5d5f24 100644 --- a/packages/cli/src/format.test.ts +++ b/packages/cli/src/format.test.ts @@ -41,7 +41,7 @@ describe("CLI response formatting", () => { exitCode: 1, stdout: "", stderr: - "Native host unavailable: Native host is offline. Run `firefox-cli setup`, install the extension, run `firefox-cli setup native-host`, then approve the extension popup.\n", + "Native host unavailable: Native host is offline. Open Firefox, run `firefox-cli setup` if setup is incomplete, then run `firefox-cli connect`.\n", }); }); }); diff --git a/packages/cli/src/format.ts b/packages/cli/src/format.ts index c0df971..2dcce70 100644 --- a/packages/cli/src/format.ts +++ b/packages/cli/src/format.ts @@ -198,6 +198,14 @@ const formatJsonOrObject: CliResponseFormatter = (response, json) => { return json ? ok(`${JSON.stringify(response.result, null, 2)}\n`) : ok(`${JSON.stringify(response.result)}\n`); }; +export const formatApprovalRequest: CliResponseFormatter<"pair.requestApproval"> = (response, json) => { + if (!response.ok) { + return error(formatProtocolError(response.error)); + } + + return json ? ok(`${JSON.stringify(response.result, null, 2)}\n`) : ok("User approved the request\n"); +}; + export const cliResponseFormatters = { capabilities: formatCapabilities, "tab-list": formatTabList, diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 2421b17..3319255 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -17,7 +17,7 @@ interface HelpGroup { const routeHelpSpecs = { capabilities: helpSpec("List supported command families and browser capability metadata."), - approve: helpSpec("Open the existing extension approval UI in a Firefox tab."), + connect: helpSpec("Request Firefox control approval through a dedicated approval page."), "tab.list": helpSpec("List tabs with indexes, ids, active state, titles, and URLs.", [ "Use listed indexes with `--tab <index>` and ids with `--tab id:<id>`.", ]), @@ -97,7 +97,7 @@ const helpGroups: readonly HelpGroup[] = [ { title: "Setup and diagnostics", summary: "Install, repair, inspect, and reset the Firefox/native-host connection.", - routes: ["capabilities", "approve"], + routes: ["capabilities", "connect"], }, { title: "Tabs, windows, and navigation", @@ -200,7 +200,7 @@ const builtinHelpSpecs = new Map<string, HelpSpec>([ const commandExamples: Partial<Record<RouteBindingId, readonly string[]>> = { "tab.list": ["firefox-cli tab --json"], - approve: ["firefox-cli approve"], + connect: ["firefox-cli connect"], "tab.new": ["firefox-cli tab new https://example.com"], "tab.select": ["firefox-cli tab select 1", "firefox-cli tab select id:42"], open: ["firefox-cli open https://example.com", "firefox-cli open --new-tab https://example.com"], diff --git a/packages/cli/src/route-registry.ts b/packages/cli/src/route-registry.ts index 028350f..70abcd9 100644 --- a/packages/cli/src/route-registry.ts +++ b/packages/cli/src/route-registry.ts @@ -38,7 +38,7 @@ import { buildPdfRequest, buildSetViewportRequest } from "./commands/phase8.js"; import { buildScreenshotRequest } from "./commands/screenshot.js"; import { buildTabsRequest, buildWindowsRequest } from "./commands/tabs-windows.js"; import { buildWaitRequest } from "./commands/wait.js"; -import { cliResponseFormatters } from "./format.js"; +import { cliResponseFormatters, formatApprovalRequest } from "./format.js"; import { getPositionals } from "./parse.js"; import type { CliRequestBuilder, CliResponseFormatter, CliResponseFormatterKind, CliRouteBinding, CliRouteParserSpec } from "./types.js"; @@ -98,7 +98,7 @@ const routeFormatterSpecs = { "set.viewport": routeFormatter("set.viewport", "json-object", cliResponseFormatters["json-object"]), diff: routeFormatter("diff", "json-object", cliResponseFormatters["json-object"]), batch: routeFormatter("batch", "batch", cliResponseFormatters.batch), - approve: routeFormatter("pair.openApproval", "json-object", cliResponseFormatters["json-object"]), + connect: routeFormatter("pair.requestApproval", "json-object", formatApprovalRequest), click: routeFormatter("click", "action", cliResponseFormatters.action), dblclick: routeFormatter("dblclick", "action", cliResponseFormatters.action), focus: routeFormatter("focus", "action", cliResponseFormatters.action), @@ -193,7 +193,7 @@ export const cliRouteBindings = { "set.viewport": bindCliRoute("set.viewport", "firefox-cli set viewport <width> <height> [--json]", buildSetViewportRequest), diff: bindCliRoute("diff", "firefox-cli diff url|title|snapshot <expected> [--json]", buildDiffRequest), batch: bindCliRoute("batch", "firefox-cli batch <json> | --stdin [--bail] [--json]", buildBatchRequest), - approve: bindCliRoute("approve", "firefox-cli approve [--json]", buildOpenApprovalRequest), + connect: bindCliRoute("connect", "firefox-cli connect [--json]", buildOpenApprovalRequest), click: bindCliRoute("click", "firefox-cli click <selector|@ref> [--json]", buildElementActionRequest), dblclick: bindCliRoute("dblclick", "firefox-cli dblclick <selector|@ref> [--json]", buildElementActionRequest), focus: bindCliRoute("focus", "firefox-cli focus <selector|@ref> [--json]", buildElementActionRequest), diff --git a/packages/cli/src/runner.ts b/packages/cli/src/runner.ts index 5266794..02e2ad9 100644 --- a/packages/cli/src/runner.ts +++ b/packages/cli/src/runner.ts @@ -52,7 +52,7 @@ async function runCliOrThrow(args: readonly string[], dependencies: CliDependenc if (args[0] === "unpair") { await dependencies.clearPairState?.(); - return ok("Pair state cleared. Approve firefox-cli again from the extension popup.\n"); + return ok("Pair state cleared. Run `firefox-cli connect` to request approval again.\n"); } const routeBinding = findCliRouteBindingForArgv(args); diff --git a/packages/cli/src/transport.ts b/packages/cli/src/transport.ts index efdbcdd..bb07a93 100644 --- a/packages/cli/src/transport.ts +++ b/packages/cli/src/transport.ts @@ -43,7 +43,7 @@ export function formatProtocolError(error: ProtocolError): string { } if (error.code === "NATIVE_HOST_UNAVAILABLE") { - return `Native host unavailable: ${error.message} Run \`firefox-cli setup\`, install the extension, run \`firefox-cli setup native-host\`, then approve the extension popup.\n`; + return `Native host unavailable: ${error.message} Open Firefox, run \`firefox-cli setup\` if setup is incomplete, then run \`firefox-cli connect\`.\n`; } if (error.code === "VERSION_MISMATCH") { @@ -58,5 +58,9 @@ export function formatProtocolError(error: ProtocolError): string { return `${error.code}: ${error.message} Try a normal web page tab and reload it after updating the extension.\n`; } + if (error.code === "ACTION_REJECTED") { + return `${error.message}\n`; + } + return `${error.code}: ${error.message}\n`; } diff --git a/packages/extension/src/approval-permissions.ts b/packages/extension/src/approval-permissions.ts new file mode 100644 index 0000000..95d670f --- /dev/null +++ b/packages/extension/src/approval-permissions.ts @@ -0,0 +1,19 @@ +import { getExtensionPermissionRequirements } from "@firefox-cli/protocol"; + +export async function requestHostAccess(): Promise<boolean> { + const permissions = browser.permissions; + if (permissions === undefined) { + throw new Error("Firefox permissions API is unavailable."); + } + + const required = { origins: getExtensionPermissionRequirements().popupApprovalOrigins }; + const captureApiRequiresReload = typeof browser.tabs.captureVisibleTab !== "function"; + if (await permissions.contains(required)) { + return captureApiRequiresReload; + } + + if (!(await permissions.request(required))) { + throw new Error("Approve host access for all websites to enable browser control."); + } + return true; +} diff --git a/packages/extension/src/approval-request-service.ts b/packages/extension/src/approval-request-service.ts new file mode 100644 index 0000000..b76cb8d --- /dev/null +++ b/packages/extension/src/approval-request-service.ts @@ -0,0 +1,246 @@ +import { createErrorResponseForRequest, createOkResponse, type ProtocolError, type RequestEnvelope, type ResponseEnvelope } from "@firefox-cli/protocol"; +import type { BackgroundBrowserAdapter } from "./browser-commands.js"; + +export const USER_DENIED_APPROVAL_MESSAGE = + "User explicitly denied your request. Do not try to circumvent this decision by any means; do not try to re-request approval. If your desired usage was optional, skip it and use other tools. If denial materially affects your work (you need the CLI legitimately), ask the user how they'd like to proceed."; + +const RATE_LIMIT_MESSAGE_PREFIX = + "Request rate-limited: to prevent disturbing the user, approval auto-denied. If the user wants you to request approval again, ask them to manually open the extension popup and approve; otherwise wait "; +const RATE_LIMIT_SECONDS = [3, 27, 81] as const; +const APPROVAL_PAGE = "approval-request.html"; + +interface PendingApprovalRequest { + readonly request: RequestEnvelope<"pair.requestApproval">; + readonly resolve: (response: ResponseEnvelope<"pair.requestApproval">) => void; + readonly requestId: string; + status: "pending" | "approving"; + url: string; +} + +export interface ApprovalRequestViewState { + readonly active: boolean; + readonly url?: string; +} + +export class ApprovalRequestService { + readonly #adapter: BackgroundBrowserAdapter; + readonly #nowMs: () => number; + #pending: PendingApprovalRequest | undefined; + #nextAllowedAtMs = 0; + #rateLimitIndex = 0; + + constructor(options: { readonly adapter: BackgroundBrowserAdapter; readonly nowMs?: () => number }) { + this.#adapter = options.adapter; + this.#nowMs = options.nowMs ?? Date.now; + } + + async requestApproval(request: RequestEnvelope<"pair.requestApproval">, approved: boolean): Promise<ResponseEnvelope<"pair.requestApproval">> { + if (approved) { + return createErrorResponseForRequest(request, { + code: "ACTION_REJECTED", + message: await this.#alreadyApprovedMessage(), + }); + } + + const rateLimited = this.#rateLimitError(); + if (rateLimited !== undefined) { + return createErrorResponseForRequest(request, rateLimited); + } + + if (this.#pending !== undefined) { + return createErrorResponseForRequest(request, { + code: "ACTION_REJECTED", + message: "An approval request is already open in Firefox.", + }); + } + + return new Promise<ResponseEnvelope<"pair.requestApproval">>((resolve) => { + const pagePath = `${APPROVAL_PAGE}?request=${encodeURIComponent(request.id)}`; + this.#pending = { request, resolve, requestId: request.id, status: "pending", url: pagePath }; + this.#recordApprovalRequest(); + this.#openApprovalPage(pagePath).catch((error: unknown) => { + this.#rejectRequest(request.id, { + code: "NATIVE_HOST_UNAVAILABLE", + message: error instanceof Error ? error.message : String(error), + }); + }); + }); + } + + async openApprovalPage(request: RequestEnvelope<"pair.openApproval">, approved: boolean): Promise<ResponseEnvelope<"pair.openApproval">> { + if (approved) { + return createErrorResponseForRequest(request, { + code: "ACTION_REJECTED", + message: await this.#alreadyApprovedMessage(), + }); + } + + const rateLimited = this.#rateLimitError(); + if (rateLimited !== undefined) { + return createErrorResponseForRequest(request, rateLimited); + } + if (this.#pending !== undefined) { + return createErrorResponseForRequest(request, { + code: "ACTION_REJECTED", + message: "An approval request is already open in Firefox.", + }); + } + + this.#recordApprovalRequest(); + await this.#showApprovalNotification(); + try { + return createOkResponse(request, { ok: true, url: await this.#adapter.openExtensionPage(`${APPROVAL_PAGE}?manual=1`) }); + } catch (error: unknown) { + return createErrorResponseForRequest(request, { + code: "NATIVE_HOST_UNAVAILABLE", + message: error instanceof Error ? error.message : String(error), + }); + } + } + + getViewState(requestId: string | undefined): ApprovalRequestViewState { + if (!this.#requestMatchesPending(requestId)) { + return { active: false }; + } + return this.#pending === undefined ? { active: false } : { active: true, url: this.#pending.url }; + } + + async approve(requestId: string | undefined, sourceTabId: number | undefined, approvePairing: () => Promise<boolean>): Promise<ApprovalRequestViewState> { + if (!this.#requestMatchesPending(requestId)) { + return { active: false }; + } + const pending = this.#pending; + if (pending?.status !== "pending") { + return { active: false }; + } + pending.status = "approving"; + const approved = await approvePairing(); + if (approved) { + this.#pending = undefined; + this.#rateLimitIndex = 0; + this.#nextAllowedAtMs = 0; + pending.resolve(createOkResponse(pending.request, { ok: true, url: pending.url })); + await this.#closeSourceTab(sourceTabId); + } else if (this.#pending === pending) { + pending.status = "pending"; + } + return this.getViewState(requestId); + } + + async deny(requestId: string | undefined, sourceTabId: number | undefined): Promise<ApprovalRequestViewState> { + if (!this.#requestMatchesPending(requestId)) { + return { active: false }; + } + const pending = this.#pending; + if (pending?.status === "pending") { + this.#pending = undefined; + pending.resolve( + createErrorResponseForRequest(pending.request, { + code: "ACTION_REJECTED", + message: USER_DENIED_APPROVAL_MESSAGE, + }), + ); + await this.#closeSourceTab(sourceTabId); + } + return this.getViewState(requestId); + } + + acceptExistingApproval(): void { + const pending = this.#pending; + if (pending !== undefined) { + this.#pending = undefined; + this.#rateLimitIndex = 0; + this.#nextAllowedAtMs = 0; + pending.resolve(createOkResponse(pending.request, { ok: true, url: pending.url })); + } + } + + rejectPending(error: ProtocolError): void { + const pending = this.#pending; + if (pending !== undefined) { + this.#pending = undefined; + pending.resolve(createErrorResponseForRequest(pending.request, error)); + } + } + + async #alreadyApprovedMessage(): Promise<string> { + const instance = await this.#adapter.getExtensionInstance(); + const windowSuffix = instance.focusedWindowId === undefined ? "" : `, focused window id ${String(instance.focusedWindowId)}`; + return `firefox-cli is already approved for Firefox extension instance ${instance.extensionUrl}${windowSuffix}.`; + } + + #rateLimitError(): ProtocolError | undefined { + const remainingMs = this.#nextAllowedAtMs - this.#nowMs(); + if (remainingMs <= 0) { + return undefined; + } + const retryAfterSeconds = this.#rateLimitSeconds(); + this.#rateLimitIndex += 1; + this.#nextAllowedAtMs = this.#nowMs() + retryAfterSeconds * 1000; + return { + code: "ACTION_REJECTED", + message: `${RATE_LIMIT_MESSAGE_PREFIX}${formatSeconds(retryAfterSeconds)} before trying again.`, + details: { remainingSeconds: retryAfterSeconds }, + }; + } + + #recordApprovalRequest(): void { + this.#nextAllowedAtMs = this.#nowMs() + this.#rateLimitSeconds() * 1000; + } + + #requestMatchesPending(requestId: string | undefined): boolean { + return this.#pending !== undefined && this.#pending.status === "pending" && requestId === this.#pending.requestId; + } + + async #openApprovalPage(pagePath: string): Promise<void> { + await this.#showApprovalNotification(); + const url = await this.#adapter.openExtensionPage(pagePath); + if (this.#pending !== undefined && this.#pending.url === pagePath) { + this.#pending.url = url; + } + } + + #rejectRequest(requestId: string, error: ProtocolError): void { + const pending = this.#pending; + if (pending?.requestId === requestId) { + this.#pending = undefined; + pending.resolve(createErrorResponseForRequest(pending.request, error)); + } + } + + async #closeSourceTab(sourceTabId: number | undefined): Promise<void> { + if (sourceTabId === undefined) { + return; + } + try { + await this.#adapter.closeTab(sourceTabId); + } catch { + // The CLI outcome is already settled. Tab cleanup should not mask approval results. + } + } + + async #showApprovalNotification(): Promise<void> { + try { + await this.#adapter.showNotification({ + id: "firefox-cli-approval", + title: "firefox-cli approval requested", + message: "A CLI client is asking for Firefox control approval right now.", + }); + } catch { + // The approval page is the authoritative prompt. Notification failures must not block it. + } + } + + #rateLimitSeconds(): number { + const configured = RATE_LIMIT_SECONDS[this.#rateLimitIndex]; + if (configured !== undefined) { + return configured; + } + const lastConfigured = RATE_LIMIT_SECONDS[2]; + return lastConfigured * 3 ** (this.#rateLimitIndex - RATE_LIMIT_SECONDS.length + 1); + } +} + +function formatSeconds(seconds: number): string { + return seconds === 1 ? "1 second" : `${String(seconds)} seconds`; +} diff --git a/packages/extension/src/approval-request.css b/packages/extension/src/approval-request.css new file mode 100644 index 0000000..f603eb6 --- /dev/null +++ b/packages/extension/src/approval-request.css @@ -0,0 +1,109 @@ +:root { + color-scheme: dark; + --flow-primary: #00d46a; + --flow-primary-light: #15ff8a; + --surface: #111418; + --surface-elevated: #1e2227; + --text: #f4fff8; + --text-muted: #9facb8; + --danger: #ff6678; + --border: rgba(21, 255, 138, 0.18); + --base-font: "Comfortaa", "Montserrat", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + --heading-font: "Montserrat Alternates", "Comfortaa", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + background: var(--surface); + color: var(--text); + font: 13px / 1.45 var(--base-font); + margin: 0; + min-height: 100vh; +} + +.approval-page { + display: grid; + min-height: 100vh; + padding: 24px; + place-items: center; +} + +.approval-dialog { + background: var(--surface-elevated); + border: 1px solid var(--border); + border-radius: 18px; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.24); + display: grid; + gap: 12px; + padding: 18px; + width: min(320px, 100%); +} + +h1, +.request-state, +.error { + margin: 0; +} + +h1 { + font: 700 18px / 1.25 var(--heading-font); +} + +.request-state { + color: var(--text-muted); +} + +.error { + background: rgba(255, 102, 120, 0.11); + border: 1px solid rgba(255, 102, 120, 0.28); + border-radius: 12px; + color: #ffd8de; + padding: 9px 10px; +} + +.actions { + display: grid; + gap: 8px; + grid-template-columns: 1fr 1fr; + margin-top: 4px; +} + +button { + border-radius: 999px; + cursor: pointer; + font: 700 13px / 1 var(--base-font); + padding: 11px 14px; +} + +button:focus-visible { + outline: 2px solid var(--flow-primary-light); + outline-offset: 3px; +} + +button:disabled { + cursor: progress; + opacity: 0.72; +} + +.primary-action { + background: var(--flow-primary); + border: 0; + color: #041008; +} + +.primary-action:hover { + filter: brightness(1.05); +} + +.deny-action { + background: transparent; + border: 1px solid var(--danger); + color: var(--text); +} + +.deny-action:hover { + color: #ffd8de; +} diff --git a/packages/extension/src/approval-request.html b/packages/extension/src/approval-request.html new file mode 100644 index 0000000..57220a1 --- /dev/null +++ b/packages/extension/src/approval-request.html @@ -0,0 +1,23 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>firefox-cli approval request + + + +
+
+

Approve CLI control?

+

A CLI client is requesting control of this Firefox instance.

+ +
+ + +
+
+
+ + + diff --git a/packages/extension/src/approval-request.test.ts b/packages/extension/src/approval-request.test.ts new file mode 100644 index 0000000..cb3346d --- /dev/null +++ b/packages/extension/src/approval-request.test.ts @@ -0,0 +1,89 @@ +import { readFile } from "node:fs/promises"; +import { JSDOM } from "jsdom"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("approval request page", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("renders a dedicated centered approval dialog with approve and deny actions", async () => { + const { document } = await renderApprovalRequestPage(); + const css = await readFile(new URL("./approval-request.css", import.meta.url), "utf8"); + + expect(document.title).toBe("firefox-cli approval request"); + expect(document.querySelector(".approval-page")).not.toBeNull(); + expect(document.querySelector(".approval-dialog")).not.toBeNull(); + expect(document.querySelector(".approval-dialog")?.textContent).toContain("A CLI client is requesting control of this Firefox instance."); + expect(document.querySelector("#approve")?.textContent).toBe("Approve"); + expect(document.querySelector("#deny")?.textContent).toBe("Deny"); + expect(css).toContain("width: min(320px, 100%)"); + expect(css).toContain("border: 1px solid var(--danger)"); + expect(css).toContain("--danger: #ff6678"); + }); + + it("requests Firefox host access before approving the pending CLI request", async () => { + const sendMessage = vi.fn().mockResolvedValueOnce({ active: true }).mockResolvedValueOnce({ active: false }); + const contains = vi.fn(async () => false); + const request = vi.fn(async () => true); + + const { document } = await renderApprovalRequestPage({ sendMessage, contains, request }); + document.querySelector("#approve")?.click(); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenLastCalledWith({ type: "firefox-cli:approve-request", requestId: "approval-1" }); + }); + + expect(contains).toHaveBeenCalledWith({ origins: [""] }); + expect(request).toHaveBeenCalledWith({ origins: [""] }); + }); + + it("denies the pending CLI request without asking for Firefox host access", async () => { + const sendMessage = vi.fn().mockResolvedValueOnce({ active: true }).mockResolvedValueOnce({ active: false }); + const request = vi.fn(async () => true); + + const { document } = await renderApprovalRequestPage({ sendMessage, request }); + document.querySelector("#deny")?.click(); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenLastCalledWith({ type: "firefox-cli:deny-approval-request", requestId: "approval-1" }); + }); + expect(request).not.toHaveBeenCalled(); + }); +}); + +async function renderApprovalRequestPage( + options: { + readonly sendMessage?: (message: unknown) => Promise; + readonly contains?: (permissions: { readonly origins: readonly string[] }) => Promise; + readonly request?: (permissions: { readonly origins: readonly string[] }) => Promise; + } = {}, +): Promise<{ readonly document: Document }> { + vi.resetModules(); + const dom = new JSDOM(await readFile(new URL("./approval-request.html", import.meta.url), "utf8"), { + url: "moz-extension://test/approval-request.html?request=approval-1", + }); + + vi.stubGlobal("window", dom.window); + vi.stubGlobal("document", dom.window.document); + vi.stubGlobal("browser", { + runtime: { + sendMessage: options.sendMessage ?? vi.fn(async () => ({ active: true })), + reload: vi.fn(), + }, + permissions: { + contains: options.contains ?? vi.fn(async () => true), + request: options.request ?? vi.fn(async () => true), + }, + tabs: { + captureVisibleTab: vi.fn(), + }, + }); + + await import("./approval-request.js"); + await vi.waitFor(() => { + expect(dom.window.document.querySelector("#request-state")?.textContent).not.toBe("Checking approval..."); + }); + return { document: dom.window.document }; +} diff --git a/packages/extension/src/approval-request.ts b/packages/extension/src/approval-request.ts new file mode 100644 index 0000000..4d452b7 --- /dev/null +++ b/packages/extension/src/approval-request.ts @@ -0,0 +1,94 @@ +import { requestHostAccess } from "./approval-permissions.js"; + +interface ApprovalRequestState { + readonly active: boolean; +} + +interface ExtensionStatus { + readonly approved: boolean; + readonly lastError?: string; +} + +const stateElement = document.querySelector("#request-state"); +const errorElement = document.querySelector("#error"); +const approveButton = document.querySelector("#approve"); +const denyButton = document.querySelector("#deny"); +const query = new URLSearchParams(window.location.search); +const manualApproval = query.get("manual") === "1"; +const requestId = manualApproval ? undefined : (query.get("request") ?? undefined); + +approveButton?.addEventListener("click", () => { + approve().catch(renderError); +}); + +denyButton?.addEventListener("click", () => { + deny().catch(renderError); +}); + +loadRequest().catch(renderError); + +async function loadRequest(): Promise { + renderState(manualApproval ? { active: true } : await sendMessage("firefox-cli:get-approval-request")); +} + +async function approve(): Promise { + setBusy(true); + const reloadAfterApproval = await requestHostAccess(); + renderState( + manualApproval + ? statusToState(await sendMessage("firefox-cli:approve")) + : await sendMessage("firefox-cli:approve-request"), + ); + if (reloadAfterApproval) { + browser.runtime.reload(); + } +} + +async function deny(): Promise { + setBusy(true); + renderState(manualApproval ? { active: false } : await sendMessage("firefox-cli:deny-approval-request")); +} + +async function sendMessage(type: string): Promise { + const response: T = await browser.runtime.sendMessage({ type, requestId }); + return response; +} + +function renderState(state: ApprovalRequestState): void { + setBusy(false); + if (!state.active) { + if (stateElement) { + stateElement.textContent = "There is no active CLI approval request."; + } + if (approveButton) { + approveButton.disabled = true; + } + if (denyButton) { + denyButton.disabled = true; + } + } +} + +function statusToState(status: ExtensionStatus): ApprovalRequestState { + if (status.lastError !== undefined) { + renderError(status.lastError); + } + return { active: !status.approved }; +} + +function renderError(error: unknown): void { + setBusy(false); + if (errorElement) { + errorElement.hidden = false; + errorElement.textContent = error instanceof Error ? error.message : String(error); + } +} + +function setBusy(busy: boolean): void { + if (approveButton) { + approveButton.disabled = busy; + } + if (denyButton) { + denyButton.disabled = busy; + } +} diff --git a/packages/extension/src/background-bootstrap.ts b/packages/extension/src/background-bootstrap.ts index 84feb3e..4c9fda2 100644 --- a/packages/extension/src/background-bootstrap.ts +++ b/packages/extension/src/background-bootstrap.ts @@ -7,7 +7,12 @@ import { NetworkRequestTracker } from "./network-tracker.js"; interface RuntimeMessage { readonly type?: string; } -type RuntimeMessageListener = (message: RuntimeMessage) => Promise; +interface RuntimeMessageSender { + readonly tab?: { + readonly id?: number; + }; +} +type RuntimeMessageListener = (message: RuntimeMessage, sender?: RuntimeMessageSender) => Promise; export type BackgroundBrowserApi = typeof browser; @@ -51,7 +56,8 @@ export function startBackground(options: { ...createControllerOptions(options.controllerOptions), }); - const runtimeListener: RuntimeMessageListener = async (message) => controller.handleRuntimeMessage(message); + const runtimeListener: RuntimeMessageListener = async (message, sender) => + controller.handleRuntimeMessage(message, sender?.tab?.id === undefined ? {} : { sourceTabId: sender.tab.id }); const onTabRemoved = (tabId: number) => { networkObservation.pruneTab(tabId); contentScriptState.forgetTab(tabId); diff --git a/packages/extension/src/background-browser-adapter.ts b/packages/extension/src/background-browser-adapter.ts index af57ff3..e4b88c9 100644 --- a/packages/extension/src/background-browser-adapter.ts +++ b/packages/extension/src/background-browser-adapter.ts @@ -1,5 +1,5 @@ import { getExtensionPermissionRequirements } from "@firefox-cli/protocol"; -import type { BackgroundBrowserAdapter } from "./background-controller.js"; +import type { BackgroundBrowserAdapter } from "./browser-commands.js"; import { createBrowserCommandDeadline } from "./browser-command/deadline.js"; import { createContentScriptInjectionState, deliverContentScriptRequest, type ContentScriptInjectionState } from "./content-script-delivery.js"; import { executeEvalInPage } from "./eval-executor.js"; @@ -183,6 +183,14 @@ export function createBackgroundBrowserAdapter(options: { : await options.browser.notifications.create(notificationOptions.id, payload), }; }, + getExtensionInstance: async () => { + const windows = await options.browser.windows.getAll({ populate: false }); + const focused = windows.find((window) => window.focused === true); + return { + extensionUrl: options.browser.runtime.getURL(""), + ...(focused?.id === undefined ? {} : { focusedWindowId: focused.id }), + }; + }, openExtensionPage: async (path) => { const url = options.browser.runtime.getURL(path); await options.browser.tabs.create({ active: true, url }); diff --git a/packages/extension/src/background-controller-approval-race-test-cases.ts b/packages/extension/src/background-controller-approval-race-test-cases.ts new file mode 100644 index 0000000..499f2c1 --- /dev/null +++ b/packages/extension/src/background-controller-approval-race-test-cases.ts @@ -0,0 +1,111 @@ +import { createOkResponse, createRequest } from "@firefox-cli/protocol"; +import { expect } from "vitest"; +import { FirefoxCliBackgroundController } from "./background-controller.js"; +import { + completeNativeHello, + createTestBrowserAdapter, + FakeNativePort, + flushPromises, + latestPairApproveRequest, +} from "./background-controller-test-support.js"; + +export async function runCase09() { + let resolveOpenPage: ((url: string) => void) | undefined; + const openPage = new Promise((resolve) => { + resolveOpenPage = resolve; + }); + const port = new FakeNativePort(); + const controller = new FirefoxCliBackgroundController({ + browserAdapter: createTestBrowserAdapter([], { + openExtensionPage: async () => openPage, + }), + connectNative: () => port, + productVersion: "0.0.0", + }); + controller.start(); + await completeNativeHello(port); + + port.emitMessage(createRequest("pair.requestApproval", {}, "approval-race-1")); + await flushPromises(); + + await expect(controller.handleRuntimeMessage({ type: "firefox-cli:get-approval-request", requestId: "approval-race-1" })).resolves.toEqual({ + active: true, + url: "approval-request.html?request=approval-race-1", + }); + resolveOpenPage?.("moz-extension://test/approval-request.html?request=approval-race-1"); + await controller.handleRuntimeMessage({ type: "firefox-cli:deny-approval-request", requestId: "approval-race-1" }); +} + +export async function runCase10() { + const port = new FakeNativePort(); + const controller = new FirefoxCliBackgroundController({ + browserAdapter: createTestBrowserAdapter([], { + openExtensionPage: async (path) => `moz-extension://test/${path}`, + }), + connectNative: () => port, + productVersion: "0.0.0", + }); + controller.start(); + await completeNativeHello(port); + + const request = createRequest("pair.requestApproval", {}, "approval-atomic-1"); + port.emitMessage(request); + await flushPromises(); + const approval = controller.handleRuntimeMessage({ type: "firefox-cli:approve-request", requestId: "approval-atomic-1" }); + await flushPromises(); + + await expect(controller.handleRuntimeMessage({ type: "firefox-cli:deny-approval-request", requestId: "approval-atomic-1" })).resolves.toEqual({ + active: false, + }); + expect(port.messages).toHaveLength(2); + + const approve = latestPairApproveRequest(port); + port.emitMessage( + createOkResponse(approve, { + hostId: "host-1", + extensionId: "ff-cli-bridge@respawn.pro", + token: "paired-token", + generation: 1, + approvedAt: "2026-01-02T03:04:05.000Z", + }), + ); + await approval; + await flushPromises(); + + expect(port.messages[2]).toEqual({ + protocolVersion: request.protocolVersion, + id: "approval-atomic-1", + ok: true, + result: { + ok: true, + url: "moz-extension://test/approval-request.html?request=approval-atomic-1", + }, + }); +} + +export async function runCase11() { + const port = new FakeNativePort(); + const controller = new FirefoxCliBackgroundController({ + browserAdapter: createTestBrowserAdapter([], { + openExtensionPage: async (path) => `moz-extension://test/${path}`, + }), + connectNative: () => port, + productVersion: "0.0.0", + }); + controller.start(); + await completeNativeHello(port); + + const request = createRequest("pair.openApproval", {}, "legacy-approval-1"); + port.emitMessage(request); + await flushPromises(); + + expect(port.messages[1]).toEqual({ + protocolVersion: request.protocolVersion, + id: "legacy-approval-1", + ok: true, + result: { + ok: true, + url: "moz-extension://test/approval-request.html?manual=1", + }, + }); +} diff --git a/packages/extension/src/background-controller-approval-test-cases.ts b/packages/extension/src/background-controller-approval-test-cases.ts index 8715305..10657a6 100644 --- a/packages/extension/src/background-controller-approval-test-cases.ts +++ b/packages/extension/src/background-controller-approval-test-cases.ts @@ -1,15 +1,17 @@ -import { createOkResponse } from "@firefox-cli/protocol"; +import { createRequest, createOkResponse } from "@firefox-cli/protocol"; import { expect } from "vitest"; import { FirefoxCliBackgroundController } from "./background-controller.js"; import { completeNativeHello, + createTestBrowserAdapter, FakeNativePort, flushPromises, latestHelloRequest, latestPairApproveRequest, sleep, } from "./background-controller-test-support.js"; +import { USER_DENIED_APPROVAL_MESSAGE } from "./approval-request-service.js"; export async function runCase01() { const port = new FakeNativePort(); @@ -229,3 +231,136 @@ export function runCase05() { expect(secondPort.messages).toEqual([]); expect(controller.getStatus().connected).toBe(false); } + +export async function runCase06() { + const port = new FakeNativePort(); + const closedTabs: number[] = []; + const controller = new FirefoxCliBackgroundController({ + browserAdapter: createTestBrowserAdapter([], { + openExtensionPage: async (path) => `moz-extension://test/${path}`, + closeTab: async (tabId) => { + closedTabs.push(tabId); + }, + }), + connectNative: () => port, + productVersion: "0.0.0", + }); + controller.start(); + await completeNativeHello(port); + + const request = createRequest("pair.requestApproval", {}, "approval-deny-1"); + port.emitMessage(request); + await flushPromises(); + + await expect( + controller.handleRuntimeMessage({ type: "firefox-cli:deny-approval-request", requestId: "approval-deny-1" }, { sourceTabId: 456 }), + ).resolves.toEqual({ + active: false, + }); + await flushPromises(); + + expect(port.messages[1]).toEqual({ + protocolVersion: request.protocolVersion, + id: "approval-deny-1", + ok: false, + error: { + code: "ACTION_REJECTED", + message: USER_DENIED_APPROVAL_MESSAGE, + }, + }); + expect(closedTabs).toEqual([456]); +} + +export async function runCase07() { + let nowMs = 1000; + const port = new FakeNativePort(); + const controller = new FirefoxCliBackgroundController({ + browserAdapter: createTestBrowserAdapter([], { + openExtensionPage: async (path) => `moz-extension://test/${path}`, + }), + connectNative: () => port, + productVersion: "0.0.0", + nowMs: () => nowMs, + }); + controller.start(); + await completeNativeHello(port); + + const first = createRequest("pair.requestApproval", {}, "approval-rate-1"); + port.emitMessage(first); + await flushPromises(); + await controller.handleRuntimeMessage({ type: "firefox-cli:deny-approval-request", requestId: "approval-rate-1" }); + await flushPromises(); + + nowMs = 2000; + const second = createRequest("pair.requestApproval", {}, "approval-rate-2"); + port.emitMessage(second); + await flushPromises(); + + expect(port.messages[2]).toEqual({ + protocolVersion: second.protocolVersion, + id: "approval-rate-2", + ok: false, + error: { + code: "ACTION_REJECTED", + message: + "Request rate-limited: to prevent disturbing the user, approval auto-denied. If the user wants you to request approval again, ask them to manually open the extension popup and approve; otherwise wait 3 seconds before trying again.", + details: { remainingSeconds: 3 }, + }, + }); + + nowMs = 3000; + const third = createRequest("pair.requestApproval", {}, "approval-rate-3"); + port.emitMessage(third); + await flushPromises(); + + expect(port.messages[3]).toEqual({ + protocolVersion: third.protocolVersion, + id: "approval-rate-3", + ok: false, + error: { + code: "ACTION_REJECTED", + message: + "Request rate-limited: to prevent disturbing the user, approval auto-denied. If the user wants you to request approval again, ask them to manually open the extension popup and approve; otherwise wait 27 seconds before trying again.", + details: { remainingSeconds: 27 }, + }, + }); +} + +export async function runCase08() { + const port = new FakeNativePort(); + const controller = new FirefoxCliBackgroundController({ + browserAdapter: createTestBrowserAdapter([], { + getExtensionInstance: async () => ({ extensionUrl: "moz-extension://test/", focusedWindowId: 17 }), + }), + connectNative: () => port, + productVersion: "0.0.0", + }); + controller.start(); + await completeNativeHello(port); + const approval = controller.handleRuntimeMessage({ type: "firefox-cli:approve" }); + const approve = latestPairApproveRequest(port); + port.emitMessage( + createOkResponse(approve, { + hostId: "host-1", + extensionId: "ff-cli-bridge@respawn.pro", + token: "paired-token", + generation: 1, + approvedAt: "2026-01-02T03:04:05.000Z", + }), + ); + await approval; + + const request = createRequest("pair.requestApproval", {}, "approval-approved-1"); + port.emitMessage(request); + await flushPromises(); + + expect(port.messages[2]).toEqual({ + protocolVersion: request.protocolVersion, + id: "approval-approved-1", + ok: false, + error: { + code: "ACTION_REJECTED", + message: "firefox-cli is already approved for Firefox extension instance moz-extension://test/, focused window id 17.", + }, + }); +} diff --git a/packages/extension/src/background-controller-approval.test.ts b/packages/extension/src/background-controller-approval.test.ts index d2572b7..4234ec4 100644 --- a/packages/extension/src/background-controller-approval.test.ts +++ b/packages/extension/src/background-controller-approval.test.ts @@ -1,5 +1,6 @@ import { describe, it } from "vitest"; -import { runCase01, runCase02, runCase03, runCase04, runCase05 } from "./background-controller-approval-test-cases.js"; +import { runCase01, runCase02, runCase03, runCase04, runCase05, runCase06, runCase07, runCase08 } from "./background-controller-approval-test-cases.js"; +import { runCase09, runCase10, runCase11 } from "./background-controller-approval-race-test-cases.js"; describe("FirefoxCliBackgroundController", () => { it("ignores responses that arrive after request timeout", runCase01); @@ -7,4 +8,10 @@ describe("FirefoxCliBackgroundController", () => { it("clears incompatible protocol state on reconnect", runCase03); it("stops controller effects, drains pending requests, and ignores stale native messages", runCase04); it("suppresses reconnect callbacks after stop", runCase05); + it("settles CLI approval requests when the user denies the dedicated page", runCase06); + it("auto-denies repeated approval requests inside the extension rate limit", runCase07); + it("rejects approval requests after approval with Firefox instance diagnostics", runCase08); + it("exposes pending approval state before the approval tab finishes opening", runCase09); + it("ignores deny events while native approval is in flight", runCase10); + it("keeps legacy open-approval requests compatible with the dedicated page", runCase11); }); diff --git a/packages/extension/src/background-controller-test-cases.ts b/packages/extension/src/background-controller-test-cases.ts index c2092ea..02926b0 100644 --- a/packages/extension/src/background-controller-test-cases.ts +++ b/packages/extension/src/background-controller-test-cases.ts @@ -9,6 +9,7 @@ import { FakeNativePort, flushPromises, latestHelloRequest, + latestPairApproveRequest, } from "./background-controller-test-support.js"; export function runCase01() { @@ -171,16 +172,29 @@ export async function runCase05() { ok: false, error: { code: "NOT_APPROVED", - message: "Approve firefox-cli in the extension popup before running CLI commands.", + message: "Run `firefox-cli connect` before running Firefox control commands.", }, }); } export async function runCase06() { const port = new FakeNativePort(); + const notifications: unknown[] = []; + const pages: string[] = []; + const closedTabs: number[] = []; const controller = new FirefoxCliBackgroundController({ browserAdapter: createTestBrowserAdapter([], { - openExtensionPage: async (path) => `moz-extension://test/${path}`, + showNotification: async (options) => { + notifications.push(options); + return { ok: true, id: options.id ?? "notification-1" }; + }, + openExtensionPage: async (path) => { + pages.push(path); + return `moz-extension://test/${path}`; + }, + closeTab: async (tabId) => { + closedTabs.push(tabId); + }, }), connectNative: () => port, productVersion: "0.0.0", @@ -188,19 +202,43 @@ export async function runCase06() { controller.start(); await completeNativeHello(port); - const request = createRequest("pair.openApproval", {}, "approval-page-1"); + const request = createRequest("pair.requestApproval", {}, "approval-request-1"); port.emitMessage(request); await flushPromises(); - expect(port.messages[1]).toEqual({ + expect(notifications).toEqual([ + { id: "firefox-cli-approval", title: "firefox-cli approval requested", message: "A CLI client is asking for Firefox control approval right now." }, + ]); + expect(pages).toEqual(["approval-request.html?request=approval-request-1"]); + await expect(controller.handleRuntimeMessage({ type: "firefox-cli:get-approval-request", requestId: "approval-request-1" })).resolves.toEqual({ + active: true, + url: "moz-extension://test/approval-request.html?request=approval-request-1", + }); + + const approval = controller.handleRuntimeMessage({ type: "firefox-cli:approve-request", requestId: "approval-request-1" }, { sourceTabId: 123 }); + const approve = latestPairApproveRequest(port); + port.emitMessage( + createOkResponse(approve, { + hostId: "host-1", + extensionId: "ff-cli-bridge@respawn.pro", + token: "paired-token", + generation: 1, + approvedAt: "2026-01-02T03:04:05.000Z", + }), + ); + await approval; + await flushPromises(); + + expect(port.messages[2]).toEqual({ protocolVersion: request.protocolVersion, - id: "approval-page-1", + id: "approval-request-1", ok: true, result: { ok: true, - url: "moz-extension://test/popup.html", + url: "moz-extension://test/approval-request.html?request=approval-request-1", }, }); + expect(closedTabs).toEqual([123]); } export async function runCase07() { @@ -258,7 +296,7 @@ export async function runCase07() { ok: false, error: { code: "NOT_APPROVED", - message: "Approve firefox-cli in the extension popup before running CLI commands.", + message: "Run `firefox-cli connect` before running Firefox control commands.", }, }); expect(browserCalls).toEqual([]); diff --git a/packages/extension/src/background-controller-test-support.ts b/packages/extension/src/background-controller-test-support.ts index c7d69a8..55033ba 100644 --- a/packages/extension/src/background-controller-test-support.ts +++ b/packages/extension/src/background-controller-test-support.ts @@ -1,6 +1,7 @@ import { createOkResponse, createRequest, PROTOCOL_VERSION, parseBoundaryRequest, type RequestEnvelope } from "@firefox-cli/protocol"; import { expect } from "vitest"; -import type { BackgroundBrowserAdapter, BrowserWindowSnapshot, FirefoxCliBackgroundController, NativePortLike } from "./background-controller.js"; +import type { FirefoxCliBackgroundController, NativePortLike } from "./background-controller.js"; +import type { BackgroundBrowserAdapter, BrowserWindowSnapshot } from "./browser-commands.js"; export class FakeNativePort implements NativePortLike { readonly messages: unknown[] = []; @@ -112,6 +113,7 @@ export function createTestBrowserAdapter( ok: true, id: options.id ?? "notification-1", }), + getExtensionInstance: async () => ({ extensionUrl: "moz-extension://test/" }), openExtensionPage: async (path) => `moz-extension://test/${path}`, resizeWindow: async () => { throw new Error("not implemented"); diff --git a/packages/extension/src/background-controller.test.ts b/packages/extension/src/background-controller.test.ts index 3f5ab44..716eb20 100644 --- a/packages/extension/src/background-controller.test.ts +++ b/packages/extension/src/background-controller.test.ts @@ -6,8 +6,8 @@ describe("FirefoxCliBackgroundController", () => { it("accepts valid hello responses regardless of request ID shape", runCase02); it("answers native-host capability and no-op requests", runCase03); it("lists tabs through the injected Firefox browser adapter", runCase04); - it("rejects native-host requests before popup approval", runCase05); - it("opens the existing approval UI before popup approval", runCase06); + it("rejects native-host requests before first-use approval", runCase05); + it("opens the dedicated approval UI before first-use approval", runCase06); it("gates unapproved privilege-sensitive native-host requests before browser handlers", runCase07); it("rejects malformed sensitive native-host requests before browser handlers", runCase08); }); diff --git a/packages/extension/src/background-controller.ts b/packages/extension/src/background-controller.ts index 3d146cd..f536f57 100644 --- a/packages/extension/src/background-controller.ts +++ b/packages/extension/src/background-controller.ts @@ -4,6 +4,7 @@ import { createErrorResponseForRequest, createLocalComponentIdentity, createRequest, + isRequestCommand, localProtocolVersionRange, NATIVE_HOST_NAME, PendingRequestTracker, @@ -15,26 +16,20 @@ import type { BackgroundRuntimeAdapter, BackgroundStorageAdapter, ExtensionStatu import { createUnconfiguredBrowserAdapter } from "./background-default-browser-adapter.js"; import { NativeConnectionManager } from "./background-native-connection.js"; import { isResponseLike } from "./background-native-protocol-state.js"; -import { NativeSessionService } from "./background-native-session.js"; +import { isHelloResponse, NativeSessionService } from "./background-native-session.js"; import { PairingStateService } from "./background-pairing-service.js"; import { BackgroundRequestForwarder } from "./background-request-forwarder.js"; -import type { BackgroundBrowserAdapter, BrowserWindowSnapshot } from "./browser-commands.js"; +import { ApprovalRequestService } from "./approval-request-service.js"; +import type { BackgroundBrowserAdapter } from "./browser-commands.js"; -export type { - BackgroundRuntimeAdapter, - BackgroundStorageAdapter, - ExtensionStatus, - NativePortLike, -} from "./background-controller-types.js"; -export type { BackgroundBrowserAdapter, BrowserWindowSnapshot }; - -const DEFAULT_PENDING_REQUEST_TIMEOUT_MS = 660_000; +export type { BackgroundRuntimeAdapter, BackgroundStorageAdapter, ExtensionStatus, NativePortLike } from "./background-controller-types.js"; export class FirefoxCliBackgroundController { readonly #connection: NativeConnectionManager; readonly #pairing: PairingStateService; readonly #nativeSession = new NativeSessionService(); readonly #requestForwarder: BackgroundRequestForwarder; + readonly #approvalRequests: ApprovalRequestService; readonly #productVersion: string; readonly #pendingCommands: PendingRequestTracker; #lastError: string | undefined; @@ -47,6 +42,7 @@ export class FirefoxCliBackgroundController { readonly reconnectDelaysMs?: readonly number[]; readonly scheduleTimer?: (callback: () => void, delayMs: number) => void; readonly requestTimeoutMs?: number; + readonly nowMs?: () => number; }) { const browserAdapter = options.browserAdapter ?? createUnconfiguredBrowserAdapter(); const storageAdapter = options.storageAdapter ?? { @@ -55,9 +51,19 @@ export class FirefoxCliBackgroundController { }; this.#productVersion = options.productVersion; this.#pairing = new PairingStateService(storageAdapter); + this.#approvalRequests = new ApprovalRequestService({ + adapter: browserAdapter, + ...(options.nowMs === undefined ? {} : { nowMs: options.nowMs }), + }); this.#requestForwarder = new BackgroundRequestForwarder({ browserAdapter, productVersion: this.#productVersion, + intercept: (request, approved): Promise | undefined => + isRequestCommand(request, "pair.requestApproval") + ? this.#approvalRequests.requestApproval(request, approved) + : isRequestCommand(request, "pair.openApproval") + ? this.#approvalRequests.openApprovalPage(request, approved) + : undefined, }); this.#connection = new NativeConnectionManager({ connectNative: options.connectNative, @@ -80,6 +86,10 @@ export class FirefoxCliBackgroundController { this.#nativeSession.markDisconnected(); this.#lastError = message; this.#drainPendingOnDisconnect(); + this.#approvalRequests.rejectPending({ + code: "NATIVE_HOST_UNAVAILABLE", + message: "Native host disconnected before the user responded to the approval request.", + }); }, onConnectError: (message) => { this.#nativeSession.markDisconnected(); @@ -88,7 +98,7 @@ export class FirefoxCliBackgroundController { }, }); - const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_PENDING_REQUEST_TIMEOUT_MS; + const requestTimeoutMs = options.requestTimeoutMs ?? 660_000; this.#pendingCommands = new PendingRequestTracker({ timeoutMs: requestTimeoutMs, onDuplicate: (request) => @@ -150,21 +160,29 @@ export class FirefoxCliBackgroundController { }; } - async handleRuntimeMessage(message: { readonly type?: string }): Promise { + async handleRuntimeMessage( + message: { readonly type?: string; readonly requestId?: string }, + context: { readonly sourceTabId?: number } = {}, + ): Promise { if (message.type === "firefox-cli:get-status") { return this.getStatus(); } + if (message.type === "firefox-cli:get-approval-request") { + return this.#approvalRequests.getViewState(message.requestId); + } + + if (message.type === "firefox-cli:deny-approval-request") { + return this.#approvalRequests.deny(message.requestId, context.sourceTabId); + } + + if (message.type === "firefox-cli:approve-request") { + return this.#approvalRequests.approve(message.requestId, context.sourceTabId, async () => this.#approveWithNativeHost()); + } + if (message.type === "firefox-cli:approve") { - this.#pairing.beginMutation(); - const request = createRequest("pair.approve", {}); - const response = await this.#sendNativeRequest(request); - if (response.ok) { - await this.#pairing.approve(response.result.token); - this.#lastError = undefined; - } else { - this.#pairing.markRejected(); - this.#lastError = response.error.message; + if (await this.#approveWithNativeHost()) { + this.#approvalRequests.acceptExistingApproval(); } return this.getStatus(); } @@ -204,6 +222,24 @@ export class FirefoxCliBackgroundController { request.protocolVersion ?? localProtocolVersionRange.protocolMax, ), ); + this.#approvalRequests.rejectPending({ + code: "NATIVE_HOST_UNAVAILABLE", + message: "Extension background stopped before the user responded to the approval request.", + }); + } + + async #approveWithNativeHost(): Promise { + this.#pairing.beginMutation(); + const request = createRequest("pair.approve", {}); + const response = await this.#sendNativeRequest(request); + if (response.ok) { + await this.#pairing.approve(response.result.token); + this.#lastError = undefined; + return true; + } + this.#pairing.markRejected(); + this.#lastError = response.error.message; + return false; } #postHello(): void { @@ -231,9 +267,8 @@ export class FirefoxCliBackgroundController { }); } - async #sendNativeRequest(request: RequestEnvelope<"hello">): Promise>; async #sendNativeRequest(request: RequestEnvelope<"pair.approve">): Promise>; - async #sendNativeRequest(request: RequestEnvelope<"pair.reset">): Promise>; + async #sendNativeRequest(request: RequestEnvelope): Promise; async #sendNativeRequest(request: RequestEnvelope): Promise { if (this.#connection.stopped) { return createErrorResponseForRequest(request, { @@ -303,12 +338,10 @@ export class FirefoxCliBackgroundController { } let helloPairingError: string | undefined; - if (command === "hello") { - if (isHelloResponse(command, response.value)) { - this.#nativeSession.applyHelloResponse(response.value); - if (response.value.ok) { - helloPairingError = await this.#pairing.applyHelloPairing(response.value.result.pairing); - } + if (command === "hello" && isHelloResponse(command, response.value)) { + this.#nativeSession.applyHelloResponse(response.value); + if (response.value.ok) { + helloPairingError = await this.#pairing.applyHelloPairing(response.value.result.pairing); } } this.#pendingCommands.settle(message.id, response.value); @@ -346,7 +379,3 @@ export class FirefoxCliBackgroundController { ); } } - -function isHelloResponse(command: CommandId, response: ResponseEnvelope): response is ResponseEnvelope<"hello"> { - return command === "hello" && (!response.ok || ("accepted" in response.result && "peer" in response.result)); -} diff --git a/packages/extension/src/background-default-browser-adapter.ts b/packages/extension/src/background-default-browser-adapter.ts index c435878..5590fd9 100644 --- a/packages/extension/src/background-default-browser-adapter.ts +++ b/packages/extension/src/background-default-browser-adapter.ts @@ -70,6 +70,9 @@ export function createUnconfiguredBrowserAdapter(): BackgroundBrowserAdapter { showNotification: async () => { throw new Error("Browser adapter is not configured."); }, + getExtensionInstance: async () => { + throw new Error("Browser adapter is not configured."); + }, openExtensionPage: async () => { throw new Error("Browser adapter is not configured."); }, diff --git a/packages/extension/src/background-native-session.ts b/packages/extension/src/background-native-session.ts index 40dfbf7..65e9847 100644 --- a/packages/extension/src/background-native-session.ts +++ b/packages/extension/src/background-native-session.ts @@ -121,3 +121,7 @@ export class NativeSessionService { return getMessageProtocolVersion(message); } } + +export function isHelloResponse(command: CommandId, response: ResponseEnvelope): response is ResponseEnvelope<"hello"> { + return command === "hello" && (!response.ok || ("accepted" in response.result && "peer" in response.result)); +} diff --git a/packages/extension/src/background-request-forwarder.ts b/packages/extension/src/background-request-forwarder.ts index ebf5bf5..06c7502 100644 --- a/packages/extension/src/background-request-forwarder.ts +++ b/packages/extension/src/background-request-forwarder.ts @@ -5,16 +5,23 @@ import { handleRequest } from "./background-request-handler.js"; export class BackgroundRequestForwarder { readonly #browserAdapter: BackgroundBrowserAdapter; readonly #productVersion: string; + readonly #intercept: ((request: RequestEnvelope, approved: boolean) => Promise | ResponseEnvelope | undefined) | undefined; constructor(options: { readonly browserAdapter: BackgroundBrowserAdapter; readonly productVersion: string; + readonly intercept?: (request: RequestEnvelope, approved: boolean) => Promise | ResponseEnvelope | undefined; }) { this.#browserAdapter = options.browserAdapter; this.#productVersion = options.productVersion; + this.#intercept = options.intercept; } forward(request: RequestEnvelope, approved: boolean, protocolSession: ProtocolSession): Promise | ResponseEnvelope { + const intercepted = this.#intercept?.(request, approved); + if (intercepted !== undefined) { + return intercepted; + } return handleRequest({ request, productVersion: this.#productVersion, diff --git a/packages/extension/src/background-request-handler.ts b/packages/extension/src/background-request-handler.ts index c4dce01..2da82af 100644 --- a/packages/extension/src/background-request-handler.ts +++ b/packages/extension/src/background-request-handler.ts @@ -39,7 +39,7 @@ export function handleRequest(options: { if (!approved && !commandAllowedBeforeApproval(request.command)) { return protocolSession.createErrorResponse(request.id, { code: "NOT_APPROVED", - message: "Approve firefox-cli in the extension popup before running CLI commands.", + message: "Run `firefox-cli connect` before running Firefox control commands.", }); } diff --git a/packages/extension/src/browser-command/types.ts b/packages/extension/src/browser-command/types.ts index 678e1ca..dc8ea1a 100644 --- a/packages/extension/src/browser-command/types.ts +++ b/packages/extension/src/browser-command/types.ts @@ -50,6 +50,7 @@ export interface BackgroundBrowserAdapter { clearNetworkRequests(options: { readonly tabId: number; readonly urlGlob?: string }): Promise; waitForNetworkIdle(options: { readonly tabId: number; readonly timeoutMs: number; readonly idleMs: number }): Promise; showNotification(options: { readonly id?: string; readonly title: string; readonly message?: string }): Promise; + getExtensionInstance(): Promise<{ readonly extensionUrl: string; readonly focusedWindowId?: number }>; openExtensionPage(path: string): Promise; resizeWindow(windowId: number, size: { readonly width: number; readonly height: number }): Promise; } diff --git a/packages/extension/src/browser-commands-content.test.ts b/packages/extension/src/browser-commands-content.test.ts index 86d1394..a7d886e 100644 --- a/packages/extension/src/browser-commands-content.test.ts +++ b/packages/extension/src/browser-commands-content.test.ts @@ -189,12 +189,6 @@ describe("browser command handling", () => { result: { id: "approval" }, }); expect(adapter.notifications).toEqual([{ id: "approval", title: "Action needed", message: "Open Firefox to approve control." }]); - await expect(handleBrowserRequest(createRequest("pair.openApproval", {}, "approval-page-1"), adapter)).resolves.toMatchObject({ - ok: true, - result: { url: "moz-extension://test/popup.html" }, - }); - expect(adapter.extensionPages).toEqual(["popup.html"]); - await expect(handleBrowserRequest(createRequest("set.viewport", { width: 1200, height: 800 }, "viewport-1"), adapter)).resolves.toMatchObject({ ok: true, result: { window: { id: 10, width: 1200, height: 800 } }, diff --git a/packages/extension/src/browser-commands-test-cases.ts b/packages/extension/src/browser-commands-test-cases.ts index 5932480..1bdff06 100644 --- a/packages/extension/src/browser-commands-test-cases.ts +++ b/packages/extension/src/browser-commands-test-cases.ts @@ -8,7 +8,14 @@ import { browserSmokeRequests } from "./browser-commands-test-smoke.js"; export async function runCase01() { const expectedCommands = Object.keys(commandSchemas) .filter(isCommandId) - .filter((command) => commandSchemas[command].owner === "extension" && command !== "capabilities" && command !== "noop"); + .filter( + (command) => + commandSchemas[command].owner === "extension" && + command !== "capabilities" && + command !== "noop" && + command !== "pair.requestApproval" && + command !== "pair.openApproval", + ); const unsupportedPdfMessage: unknown = expect.stringContaining("PDF export is unsupported"); expect([...browserSmokeRequests.keys()].sort()).toEqual(expectedCommands.sort()); diff --git a/packages/extension/src/browser-commands-test-smoke.ts b/packages/extension/src/browser-commands-test-smoke.ts index b599fee..a4ad7e9 100644 --- a/packages/extension/src/browser-commands-test-smoke.ts +++ b/packages/extension/src/browser-commands-test-smoke.ts @@ -57,5 +57,4 @@ export const browserSmokeRequests = new Map([ ["scroll", actionParamsFor("scroll")], ["scrollintoview", actionParamsFor("scrollintoview")], ["swipe", actionParamsFor("swipe")], - ["pair.openApproval", {}], ]); diff --git a/packages/extension/src/browser-commands-test-utils.ts b/packages/extension/src/browser-commands-test-utils.ts index bee1197..0c4a4a4 100644 --- a/packages/extension/src/browser-commands-test-utils.ts +++ b/packages/extension/src/browser-commands-test-utils.ts @@ -278,6 +278,14 @@ export class FakeBrowserAdapter implements BackgroundBrowserAdapter { return { ok: true as const, id: options.id ?? `notification-${String(this.notifications.length)}` }; } + async getExtensionInstance(): Promise<{ readonly extensionUrl: string; readonly focusedWindowId?: number }> { + const focused = this.#windows.find((window) => window.focused); + return { + extensionUrl: "moz-extension://test/", + ...(focused === undefined ? {} : { focusedWindowId: focused.id }), + }; + } + async openExtensionPage(path: string): Promise { this.extensionPages.push(path); return `moz-extension://test/${path}`; diff --git a/packages/extension/src/browser-handlers/phase8-browser.ts b/packages/extension/src/browser-handlers/phase8-browser.ts index 9cb6381..83cc2e8 100644 --- a/packages/extension/src/browser-handlers/phase8-browser.ts +++ b/packages/extension/src/browser-handlers/phase8-browser.ts @@ -11,7 +11,7 @@ import { BrowserCommandError } from "../browser-command/errors.js"; import { toOrderedWindows, toWindowSummary } from "../browser-command/targets.js"; import type { BrowserHandlerMap } from "./types.js"; -type Phase8BrowserCommand = "download" | "clipboard" | "cookies" | "network" | "notify" | "pair.openApproval" | "pdf" | "set.viewport"; +type Phase8BrowserCommand = "download" | "clipboard" | "cookies" | "network" | "notify" | "pdf" | "set.viewport"; export const phase8BrowserHandlers: BrowserHandlerMap = { download: async (request, adapter) => { @@ -110,12 +110,6 @@ export const phase8BrowserHandlers: BrowserHandlerMap = { }); return createOkResponse(request, result); }, - "pair.openApproval": async (request, adapter) => { - return createOkResponse(request, { - ok: true, - url: await adapter.openExtensionPage("popup.html"), - }); - }, pdf: async (request) => { return createErrorResponseForRequest(request, { code: "UNSUPPORTED_CAPABILITY", diff --git a/packages/extension/src/popup.ts b/packages/extension/src/popup.ts index f5d33b9..986cc78 100644 --- a/packages/extension/src/popup.ts +++ b/packages/extension/src/popup.ts @@ -1,4 +1,4 @@ -import { getExtensionPermissionRequirements } from "@firefox-cli/protocol"; +import { requestHostAccess } from "./approval-permissions.js"; interface Status { readonly connected: boolean; @@ -68,21 +68,3 @@ async function approve(): Promise { browser.runtime.reload(); } } - -async function requestHostAccess(): Promise { - const permissions = browser.permissions; - if (permissions === undefined) { - throw new Error("Firefox permissions API is unavailable."); - } - - const required = { origins: getExtensionPermissionRequirements().popupApprovalOrigins }; - const captureApiRequiresReload = typeof browser.tabs.captureVisibleTab !== "function"; - if (await permissions.contains(required)) { - return captureApiRequiresReload; - } - - if (!(await permissions.request(required))) { - throw new Error("Approve host access for all websites to enable browser control."); - } - return true; -} diff --git a/packages/extension/src/webextension.d.ts b/packages/extension/src/webextension.d.ts index 34993fe..bc9b73e 100644 --- a/packages/extension/src/webextension.d.ts +++ b/packages/extension/src/webextension.d.ts @@ -23,8 +23,8 @@ interface BrowserWindow { declare const browser: { readonly runtime: { readonly onMessage: { - addListener(listener: (message: { readonly type?: string }) => unknown): void; - removeListener(listener: (message: { readonly type?: string }) => unknown): void; + addListener(listener: (message: { readonly type?: string }, sender?: { readonly tab?: { readonly id?: number } }) => unknown): void; + removeListener(listener: (message: { readonly type?: string }, sender?: { readonly tab?: { readonly id?: number } }) => unknown): void; }; connectNative(name: string): { readonly onMessage: { @@ -40,7 +40,7 @@ declare const browser: { reload(): void; }; readonly windows: { - getAll(options: { readonly populate: true }): Promise; + getAll(options: { readonly populate: boolean }): Promise; create(options: { readonly url?: string }): Promise; update(windowId: number, options: { readonly focused?: boolean; readonly width?: number; readonly height?: number }): Promise; remove(windowId: number): Promise; diff --git a/packages/native-host/src/host-broker-helpers.ts b/packages/native-host/src/host-broker-helpers.ts index e78172b..2ebe409 100644 --- a/packages/native-host/src/host-broker-helpers.ts +++ b/packages/native-host/src/host-broker-helpers.ts @@ -14,7 +14,7 @@ export function pairVerificationToProtocolError(verification: PairTokenVerificat if (verification.ok) { return { code: "NOT_APPROVED", - message: "Approve firefox-cli in the extension popup before running CLI commands.", + message: "Run `firefox-cli connect` before running Firefox control commands.", }; } diff --git a/packages/native-host/src/host-broker.test.ts b/packages/native-host/src/host-broker.test.ts index 74cace9..dcd5fe7 100644 --- a/packages/native-host/src/host-broker.test.ts +++ b/packages/native-host/src/host-broker.test.ts @@ -158,13 +158,13 @@ describe("NativeHostBroker", () => { ok: false, error: { code: "NOT_APPROVED", - message: "Approve firefox-cli in the extension popup before running CLI commands.", + message: "Run `firefox-cli connect` before running Firefox control commands.", }, }); }); - it("allows opening the approval UI before extension approval", async () => { - const request = createRequest("pair.openApproval", {}, "approval-1"); + it("allows requesting approval before extension approval", async () => { + const request = createRequest("pair.requestApproval", {}, "approval-1"); let forwardedRequest: RequestEnvelope | undefined; const broker = new NativeHostBroker({ hostIdentity: createHostIdentity({ @@ -177,12 +177,12 @@ describe("NativeHostBroker", () => { token: undefined, send: async (forwarded) => { forwardedRequest = forwarded; - return createOkResponse(forwarded, { ok: true, url: "moz-extension://test/popup.html" }); + return createOkResponse(forwarded, { ok: true, url: "moz-extension://test/approval-request.html" }); }, }); - await expect(broker.handleCliRequest(request)).resolves.toEqual(createOkResponse(request, { ok: true, url: "moz-extension://test/popup.html" })); - expect(forwardedRequest).toMatchObject({ command: "pair.openApproval" }); + await expect(broker.handleCliRequest(request)).resolves.toEqual(createOkResponse(request, { ok: true, url: "moz-extension://test/approval-request.html" })); + expect(forwardedRequest).toMatchObject({ command: "pair.requestApproval" }); }); it("rejects CLI requests when the extension pair token is invalid", async () => { diff --git a/packages/native-host/src/host-broker.ts b/packages/native-host/src/host-broker.ts index c4e1df5..79a8156 100644 --- a/packages/native-host/src/host-broker.ts +++ b/packages/native-host/src/host-broker.ts @@ -179,7 +179,7 @@ export class NativeHostBroker { ok: false, error: { code: "NOT_APPROVED", - message: "Approve firefox-cli in the extension popup before running CLI commands.", + message: "Run `firefox-cli connect` before running Firefox control commands.", }, }; } diff --git a/packages/protocol/src/capabilities.ts b/packages/protocol/src/capabilities.ts index 7be736c..19b516d 100644 --- a/packages/protocol/src/capabilities.ts +++ b/packages/protocol/src/capabilities.ts @@ -29,12 +29,6 @@ export const gatedCapabilities: readonly GatedCapabilitySummary[] = [ reason: "exit is unsupported because firefox-cli must not terminate the user's Firefox process.", cliCommands: ["exit"], }, - { - command: "connect", - status: "unsupported", - reason: "connect is unsupported because Firefox does not provide Chrome CDP attach semantics.", - cliCommands: ["connect"], - }, { command: "inspect", status: "unsupported", diff --git a/packages/protocol/src/constants.ts b/packages/protocol/src/constants.ts index 04c7fca..2497ccb 100644 --- a/packages/protocol/src/constants.ts +++ b/packages/protocol/src/constants.ts @@ -2,7 +2,7 @@ export const PRODUCT_NAME = "firefox-cli"; export const NATIVE_HOST_NAME = "firefox_cli"; export const FIREFOX_CLI_EXTENSION_ID = "ff-cli-bridge@respawn.pro"; export const FIREFOX_CLI_EXTENSION_UPDATE_URL = "https://opensource.respawn.pro/firefox-cli/updates.json"; -export const PROTOCOL_VERSION = 3; +export const PROTOCOL_VERSION = 4; export const PROTOCOL_MIN_VERSION = 1; export const PROTOCOL_MAX_VERSION = PROTOCOL_VERSION; export const MAX_EVAL_SCRIPT_BYTES = 100_000; diff --git a/packages/protocol/src/pairing.ts b/packages/protocol/src/pairing.ts index 11663ae..8a38d33 100644 --- a/packages/protocol/src/pairing.ts +++ b/packages/protocol/src/pairing.ts @@ -23,3 +23,11 @@ export const pairOpenApprovalResultSchema = z url: z.string().min(1), }) .strict(); + +export const pairRequestApprovalParamsSchema = z.object({}).strict(); +export const pairRequestApprovalResultSchema = z + .object({ + ok: z.literal(true), + url: z.string().min(1), + }) + .strict(); diff --git a/packages/protocol/src/protocol-metadata-behavior.test.ts b/packages/protocol/src/protocol-metadata-behavior.test.ts index 9d9d599..9822a86 100644 --- a/packages/protocol/src/protocol-metadata-behavior.test.ts +++ b/packages/protocol/src/protocol-metadata-behavior.test.ts @@ -68,7 +68,7 @@ describe("protocol command metadata", () => { } const nonBatchableCommands = commandIds().filter((command) => !isBatchableCommandId(command)); - expect(nonBatchableCommands).toEqual(["hello", "capabilities", "noop", "batch", "pair.approve", "pair.reset", "pair.openApproval"]); + expect(nonBatchableCommands).toEqual(["hello", "capabilities", "noop", "batch", "pair.approve", "pair.reset", "pair.requestApproval", "pair.openApproval"]); }); it("marks only required tab/window selectors for protocol batch default targets", () => { @@ -263,6 +263,28 @@ describe("request protocol compatibility", () => { } }); + it("requires protocol v4 for CLI approval requests", () => { + const request = createRequest("pair.requestApproval", {}, "approval-v4"); + + expect(getRequestProtocolCompatibility(request, 3)).toMatchObject({ + compatible: false, + requiredProtocolVersion: 4, + }); + expect(parseBoundaryRequest("host-to-extension", { ...request, protocolVersion: 3 }, { protocolVersion: 3 })).toMatchObject({ + ok: false, + error: { + code: "VERSION_MISMATCH", + details: { + requiredProtocolVersion: 4, + negotiatedProtocolVersion: 3, + }, + }, + }); + expect(parseBoundaryRequest("host-to-extension", { ...request, protocolVersion: 4 }, { protocolVersion: 4 })).toMatchObject({ + ok: true, + }); + }); + it("keeps non-network commands compatible with protocol v1 sessions", () => { const request = createRequest("capabilities", {}, "capabilities-v1", 1); diff --git a/packages/protocol/src/protocol-test-support.ts b/packages/protocol/src/protocol-test-support.ts index e71f93a..adf9559 100644 --- a/packages/protocol/src/protocol-test-support.ts +++ b/packages/protocol/src/protocol-test-support.ts @@ -81,5 +81,6 @@ export const expectedCliRoutesByCommand: Partial = { diff --git a/packages/protocol/src/registry/pairing.ts b/packages/protocol/src/registry/pairing.ts index 8740d30..53e80c1 100644 --- a/packages/protocol/src/registry/pairing.ts +++ b/packages/protocol/src/registry/pairing.ts @@ -3,6 +3,8 @@ import { pairApproveResultSchema, pairOpenApprovalParamsSchema, pairOpenApprovalResultSchema, + pairRequestApprovalParamsSchema, + pairRequestApprovalResultSchema, pairResetParamsSchema, pairResetResultSchema, } from "../pairing.js"; @@ -33,6 +35,26 @@ export const pairingCommandEntries = defineCommandEntries({ batch: { allowed: false }, cliRoutes: [], }, + "pair.requestApproval": { + params: pairRequestApprovalParamsSchema, + result: pairRequestApprovalResultSchema, + status: "mvp", + owner: "extension", + target: "none", + content: "never", + action: false, + timeout: "none", + compatibility: { + requirements: [ + { + minProtocolVersion: 4, + reason: "CLI approval requests use a dedicated decision page and wait for explicit user approval or denial.", + }, + ], + }, + batch: { allowed: false }, + cliRoutes: [{ id: "connect", path: ["connect"], batch: false }], + }, "pair.openApproval": { params: pairOpenApprovalParamsSchema, result: pairOpenApprovalResultSchema, @@ -43,6 +65,6 @@ export const pairingCommandEntries = defineCommandEntries({ action: false, timeout: "none", batch: { allowed: false }, - cliRoutes: [{ id: "approve", path: ["approve"], batch: false }], + cliRoutes: [], }, }); diff --git a/packages/protocol/src/registry/registry.test.ts b/packages/protocol/src/registry/registry.test.ts index 2116a17..6611c11 100644 --- a/packages/protocol/src/registry/registry.test.ts +++ b/packages/protocol/src/registry/registry.test.ts @@ -76,6 +76,7 @@ const expectedCommandIds = [ "swipe", "pair.approve", "pair.reset", + "pair.requestApproval", "pair.openApproval", ] as const satisfies readonly CommandId[]; diff --git a/scripts/build-extension.ts b/scripts/build-extension.ts index 21a3bd6..2919e22 100644 --- a/scripts/build-extension.ts +++ b/scripts/build-extension.ts @@ -9,6 +9,7 @@ const entries = [ { name: "background", path: resolve(extensionRoot, "src/background.ts") }, { name: "content", path: resolve(extensionRoot, "src/content.ts") }, { name: "popup", path: resolve(extensionRoot, "src/popup.ts") }, + { name: "approval-request", path: resolve(extensionRoot, "src/approval-request.ts") }, ] as const; for (const [index, entry] of entries.entries()) { diff --git a/scripts/copy-extension-assets.ts b/scripts/copy-extension-assets.ts index 67ef8ae..be8e521 100644 --- a/scripts/copy-extension-assets.ts +++ b/scripts/copy-extension-assets.ts @@ -15,6 +15,8 @@ export async function copyExtensionAssets(options: { readonly sourceDir: string; await writeFile(resolve(options.outputDir, "manifest.json"), JSON.stringify(manifest, null, 2)); await cp(resolve(options.sourceDir, "popup.html"), resolve(options.outputDir, "popup.html")); await cp(resolve(options.sourceDir, "popup.css"), resolve(options.outputDir, "popup.css")); + await cp(resolve(options.sourceDir, "approval-request.html"), resolve(options.outputDir, "approval-request.html")); + await cp(resolve(options.sourceDir, "approval-request.css"), resolve(options.outputDir, "approval-request.css")); } if (import.meta.main) { diff --git a/scripts/extension-payload-check.ts b/scripts/extension-payload-check.ts index fdf3a28..4b07a9f 100644 --- a/scripts/extension-payload-check.ts +++ b/scripts/extension-payload-check.ts @@ -3,20 +3,32 @@ import rootPackage from "../package.json" with { type: "json" }; import { listRegularFilesUnder, readRegularFileUnder } from "./safe-extension-files.js"; export async function verifyExtensionBundlePayload(payload: ReadonlyMap): Promise { - const requiredFiles = ["manifest.json", "background.js", "content.js", "popup.js", "popup.html", "popup.css"] as const; + const requiredFiles = [ + "manifest.json", + "background.js", + "content.js", + "popup.js", + "popup.html", + "popup.css", + "approval-request.js", + "approval-request.html", + "approval-request.css", + ] as const; for (const artifact of requiredFiles) { if (!payload.has(artifact)) { throw new Error(`Expected extension artifact: ${artifact}`); } } - const unexpectedJs = [...payload.keys()].filter((file) => file.endsWith(".js")).filter((file) => !["background.js", "content.js", "popup.js"].includes(file)); + const unexpectedJs = [...payload.keys()] + .filter((file) => file.endsWith(".js")) + .filter((file) => !["background.js", "content.js", "popup.js", "approval-request.js"].includes(file)); if (unexpectedJs.length > 0) { throw new Error(`Unexpected extension JavaScript artifacts: ${unexpectedJs.join(", ")}`); } await Promise.all( - ["background.js", "content.js", "popup.js"].map(async (artifact) => { + ["background.js", "content.js", "popup.js", "approval-request.js"].map(async (artifact) => { const source = payload.get(artifact)?.toString("utf8") ?? ""; if ( source.includes('from"./') || diff --git a/scripts/test/copy-extension-assets.test.ts b/scripts/test/copy-extension-assets.test.ts index 6f58396..5a655fd 100644 --- a/scripts/test/copy-extension-assets.test.ts +++ b/scripts/test/copy-extension-assets.test.ts @@ -63,5 +63,7 @@ async function createExtensionAssetFixture(): Promise<{ ); await writeFile(join(sourceDir, "popup.html"), "\n"); await writeFile(join(sourceDir, "popup.css"), "body {}\n"); + await writeFile(join(sourceDir, "approval-request.html"), "\n"); + await writeFile(join(sourceDir, "approval-request.css"), "body {}\n"); return { sourceDir, outputDir }; } diff --git a/scripts/test/package-check-test-utils.ts b/scripts/test/package-check-test-utils.ts index d910979..2b8751b 100644 --- a/scripts/test/package-check-test-utils.ts +++ b/scripts/test/package-check-test-utils.ts @@ -90,8 +90,11 @@ export async function createPackageRoot( await writeFile(join(packageRoot, "extension/development/background.js"), "console.log('bg');\n"); await writeFile(join(packageRoot, "extension/development/content.js"), "console.log('cs');\n"); await writeFile(join(packageRoot, "extension/development/popup.js"), "console.log('popup');\n"); + await writeFile(join(packageRoot, "extension/development/approval-request.js"), "console.log('approval');\n"); await writeFile(join(packageRoot, "extension/development/popup.html"), "\n"); await writeFile(join(packageRoot, "extension/development/popup.css"), "body {}\n"); + await writeFile(join(packageRoot, "extension/development/approval-request.html"), "\n"); + await writeFile(join(packageRoot, "extension/development/approval-request.css"), "body {}\n"); } if (options.includeBinary !== false) { diff --git a/skills/firefox-cli/SKILL.md b/skills/firefox-cli/SKILL.md index bd2ea25..a608c31 100644 --- a/skills/firefox-cli/SKILL.md +++ b/skills/firefox-cli/SKILL.md @@ -1,13 +1,9 @@ --- name: firefox-cli -description: Control the user's normal Firefox session from a terminal. Use when a task needs using the browser to perform work, user's browser context or authenticated session, navigation, tab/window control, screenshots, DOM reads, waits, or page interactions in Firefox. +description: Control the user's Firefox from a terminal. Use when your task needs the user's browser or authenticated session, page navigation, tab/window control, screenshots, DOM reads, waits, or page interactions in Firefox. Do not use as web search replacement. --- -## Purpose - -`firefox-cli` gives agents terminal access to the user's real Firefox session through the installed Firefox extension and native host. - -Use it when browser state matters and the task benefits from the user's normal Firefox profile, signed-in websites, active tabs, or real page behavior. Prefer it over starting a separate automation browser when the user asks to inspect, navigate, test, read, or manipulate pages in Firefox. +`firefox-cli` gives agents terminal access to the user's real authenticated Firefox session through the installed Firefox extension. ## Command Discovery @@ -30,21 +26,6 @@ firefox-cli wait -h Use `--json` when another program or agent consumes the output. -## When To Use - -Good fits: -- Read the active page or a URL into agent context. -- Inspect page title, URL, text, element state, frames, console logs, errors, or network observations. -- Navigate Firefox, open pages, reload, move through history, or manage tabs/windows. -- Click, fill, type, press keys, scroll, upload files, or run a multi-step browser workflow. -- Capture screenshots or other browser-adjacent artifacts. -- Synchronize on page state with waits instead of fixed sleeps. - -Poor fits: -- Tasks that only need HTTP fetching, static source code inspection, or public web search. -- Work that must run in an isolated disposable browser profile. -- Browser-internal or privileged Firefox pages that WebExtensions cannot script. - ## Startup Pattern For a page-reading task: @@ -80,7 +61,9 @@ firefox-cli setup -h firefox-cli doctor -h ``` -## Boundaries +## Usage Rules - Element refs from `snapshot -i` are useful handles for follow-up actions, but page navigation or reload can make them stale. -- Respect CLI errors as the authority for unsupported pages, stale refs, setup gaps, and version mismatches. +- Be careful with user's data: the Firefox you're using contains real cookies, auth credentials, logins, tabs and PII. Under no circumstance perform any actions that may harm the user or exfiltrate their data. Under no circumstance perform any payments, cash transfers, other dangeorous, destructive, irreversible operations, or leak PII without asking for user's explicit approval before every such action. +- If you need to work with FF, but you see interference (tabs changing, real user tabs being open) or the user is unhappy, prefer opening a new FF window for yourself, because every action you do is visibly reflected in the browser window and can disrupt user's work. The good side of that is that you can demonstrate something to the user in their real browser, such as running demos, opening pages, showing your work etc. +- If the cli denies requests with approval and asks you to `firefox-cli connect`, invoke that **once** to show the user a permission prompt. The command will exit once it's granted. Do not attempt to circumvent denials in any way. From 1dcfd6e2e8118ae9bee95821d16a81e2e10494e8 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Thu, 11 Jun 2026 15:50:54 +0200 Subject: [PATCH 06/11] Fix wait help examples --- packages/cli/src/cli-help.test.ts | 11 +++++++++++ packages/cli/src/help.ts | 6 +++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/cli-help.test.ts b/packages/cli/src/cli-help.test.ts index 3b9794c..cdb62d6 100644 --- a/packages/cli/src/cli-help.test.ts +++ b/packages/cli/src/cli-help.test.ts @@ -37,6 +37,17 @@ describe("CLI help", () => { expect(output.stdout).toContain("firefox-cli snapshot -i"); }); + it("renders wait examples accepted by the wait parser", async () => { + const output = await runCli(["wait", "--help"], baseDependencies()); + + expect(output.exitCode).toBe(0); + expect(output.stdout).toContain("Wait for a duration, element, text, URL, function predicate, load state, or download."); + expect(output.stdout).toContain("firefox-cli wait '#ready'"); + expect(output.stdout).not.toContain("title"); + expect(output.stdout).not.toContain("dialog"); + expect(output.stdout).not.toContain("firefox-cli wait --selector"); + }); + it("renders command help without sending a browser request", async () => { const sendRequest = vi.fn(async () => { throw new Error("help must not call transport"); diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 3319255..166a03e 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -43,7 +43,7 @@ const routeHelpSpecs = { "Pass a selector or `@ref` for element-scoped values.", ]), is: helpSpec("Check element/page state such as visible, enabled, checked, or selected."), - wait: helpSpec("Wait for URL, title, element, text, network-idle, download, or dialog conditions.", [ + wait: helpSpec("Wait for a duration, element, text, URL, function predicate, load state, or download.", [ "Use waits between navigation/actions and reads instead of fixed sleeps.", ]), eval: helpSpec("Evaluate JavaScript in the target page and return a serialized result.", [ @@ -206,7 +206,7 @@ const commandExamples: Partial> = { open: ["firefox-cli open https://example.com", "firefox-cli open --new-tab https://example.com"], snapshot: ["firefox-cli snapshot -i", "firefox-cli snapshot -s main -d 3 --json"], get: ["firefox-cli get title", "firefox-cli get text '#content' --json"], - wait: ["firefox-cli wait --url '*dashboard*'", "firefox-cli wait --selector '#ready'"], + wait: ["firefox-cli wait --url '*dashboard*'", "firefox-cli wait '#ready'"], click: ["firefox-cli click 'button[type=submit]'", "firefox-cli click @e12"], fill: ["firefox-cli fill '#email' user@example.com"], notify: ["firefox-cli notify 'Action needed' 'Open Firefox to approve control'"], @@ -258,7 +258,7 @@ export function renderHelp(): string { ' Inspect content: firefox-cli get title --json; firefox-cli find text "Sign in"', ' Act on elements: firefox-cli click "button[type=submit]"; firefox-cli fill "#email" user@example.com', " Manage targets: firefox-cli tab; firefox-cli tab select 1; firefox-cli window", - ' Synchronize: firefox-cli wait --url "*dashboard*"; firefox-cli wait --network-idle', + ' Synchronize: firefox-cli wait --url "*dashboard*"; firefox-cli wait --load networkidle', ' Run a workflow: firefox-cli batch \'[["open","https://example.com"],["snapshot","-i"]]\'', "", "Usage:", From 440a6344669eef61d0513e935cd29a437722d566 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Thu, 11 Jun 2026 15:51:06 +0200 Subject: [PATCH 07/11] Release 0.2.0 --- .claude-plugin/plugin.json | 2 +- bun.lock | 10 +++++----- docs/firefox-cli/updates.json | 9 +++++++++ package.json | 2 +- packages/cli/package.json | 2 +- packages/extension/package.json | 2 +- packages/extension/src/manifest.json | 29 +++++++++++++++++++++++----- packages/native-host/package.json | 2 +- packages/protocol/package.json | 2 +- packages/test-support/package.json | 2 +- 10 files changed, 45 insertions(+), 17 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index c0cc156..4d3f47e 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "firefox-cli", "description": "Firefox control CLI skill for AI agents", - "version": "0.1.1", + "version": "0.2.0", "repository": "https://github.com/respawn-llc/firefox-cli" } diff --git a/bun.lock b/bun.lock index 30964b9..ffc74d0 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/cli": { "name": "@firefox-cli/cli", - "version": "0.1.1", + "version": "0.2.0", "dependencies": { "@firefox-cli/native-host": "workspace:*", "@firefox-cli/protocol": "workspace:*", @@ -37,22 +37,22 @@ }, "packages/extension": { "name": "@firefox-cli/extension", - "version": "0.1.1", + "version": "0.2.0", "dependencies": { "@firefox-cli/protocol": "workspace:*", }, }, "packages/native-host": { "name": "@firefox-cli/native-host", - "version": "0.1.1", + "version": "0.2.0", }, "packages/protocol": { "name": "@firefox-cli/protocol", - "version": "0.1.1", + "version": "0.2.0", }, "packages/test-support": { "name": "@firefox-cli/test-support", - "version": "0.1.1", + "version": "0.2.0", }, }, "packages": { diff --git a/docs/firefox-cli/updates.json b/docs/firefox-cli/updates.json index c942083..4f60869 100644 --- a/docs/firefox-cli/updates.json +++ b/docs/firefox-cli/updates.json @@ -10,6 +10,15 @@ "strict_min_version": "150.0" } } + }, + { + "applications": { + "gecko": { + "strict_min_version": "150.0" + } + }, + "version": "0.2.0", + "update_link": "https://github.com/respawn-llc/firefox-cli/releases/download/v0.2.0/firefox-cli-0.2.0.xpi" } ] } diff --git a/package.json b/package.json index cfd7320..d9a9617 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firefox-cli-workspace", - "version": "0.1.1", + "version": "0.2.0", "private": true, "type": "module", "packageManager": "bun@1.3.14", diff --git a/packages/cli/package.json b/packages/cli/package.json index 0c2a0fe..6219265 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@firefox-cli/cli", - "version": "0.1.1", + "version": "0.2.0", "private": true, "type": "module", "exports": { diff --git a/packages/extension/package.json b/packages/extension/package.json index dacddb9..75250c2 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,6 +1,6 @@ { "name": "@firefox-cli/extension", - "version": "0.1.1", + "version": "0.2.0", "private": true, "type": "module", "dependencies": { diff --git a/packages/extension/src/manifest.json b/packages/extension/src/manifest.json index 4ce789f..15e7c66 100644 --- a/packages/extension/src/manifest.json +++ b/packages/extension/src/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "FF-CLI Bridge", - "version": "0.1.1", + "version": "0.2.0", "description": "Browser extension bridge for CLI control.", "browser_specific_settings": { "gecko": { @@ -9,15 +9,34 @@ "update_url": "https://opensource.respawn.pro/firefox-cli/updates.json", "strict_min_version": "150.0", "data_collection_permissions": { - "required": ["browsingActivity", "websiteActivity", "websiteContent"], + "required": [ + "browsingActivity", + "websiteActivity", + "websiteContent" + ], "optional": [] } } }, - "permissions": ["nativeMessaging", "scripting", "tabs", "storage", "downloads", "cookies", "notifications", "clipboardRead", "clipboardWrite", "webRequest"], - "host_permissions": [""], + "permissions": [ + "nativeMessaging", + "scripting", + "tabs", + "storage", + "downloads", + "cookies", + "notifications", + "clipboardRead", + "clipboardWrite", + "webRequest" + ], + "host_permissions": [ + "" + ], "background": { - "scripts": ["background.js"] + "scripts": [ + "background.js" + ] }, "action": { "default_title": "FF-CLI Bridge", diff --git a/packages/native-host/package.json b/packages/native-host/package.json index 77263de..698f086 100644 --- a/packages/native-host/package.json +++ b/packages/native-host/package.json @@ -1,6 +1,6 @@ { "name": "@firefox-cli/native-host", - "version": "0.1.1", + "version": "0.2.0", "private": true, "type": "module", "exports": { diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 1165f1c..056a28e 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@firefox-cli/protocol", - "version": "0.1.1", + "version": "0.2.0", "private": true, "type": "module", "exports": { diff --git a/packages/test-support/package.json b/packages/test-support/package.json index 5bc72e4..fc2679f 100644 --- a/packages/test-support/package.json +++ b/packages/test-support/package.json @@ -1,6 +1,6 @@ { "name": "@firefox-cli/test-support", - "version": "0.1.1", + "version": "0.2.0", "private": true, "type": "module", "exports": { From f1d7b1ca3ac4550a520c1bcec86016ae28ee2b0c Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Thu, 11 Jun 2026 15:51:39 +0200 Subject: [PATCH 08/11] Polish firefox-cli skill guidance --- skills/firefox-cli/SKILL.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/skills/firefox-cli/SKILL.md b/skills/firefox-cli/SKILL.md index a608c31..1a3c930 100644 --- a/skills/firefox-cli/SKILL.md +++ b/skills/firefox-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: firefox-cli -description: Control the user's Firefox from a terminal. Use when your task needs the user's browser or authenticated session, page navigation, tab/window control, screenshots, DOM reads, waits, or page interactions in Firefox. Do not use as web search replacement. +description: Control the user's Firefox from a terminal. Use when your task needs the user's browser or authenticated session, page navigation, tab/window control, screenshots, DOM reads, waits, or page interactions in Firefox. Do not use it as a web search replacement. --- `firefox-cli` gives agents terminal access to the user's real authenticated Firefox session through the installed Firefox extension. @@ -64,6 +64,6 @@ firefox-cli doctor -h ## Usage Rules - Element refs from `snapshot -i` are useful handles for follow-up actions, but page navigation or reload can make them stale. -- Be careful with user's data: the Firefox you're using contains real cookies, auth credentials, logins, tabs and PII. Under no circumstance perform any actions that may harm the user or exfiltrate their data. Under no circumstance perform any payments, cash transfers, other dangeorous, destructive, irreversible operations, or leak PII without asking for user's explicit approval before every such action. -- If you need to work with FF, but you see interference (tabs changing, real user tabs being open) or the user is unhappy, prefer opening a new FF window for yourself, because every action you do is visibly reflected in the browser window and can disrupt user's work. The good side of that is that you can demonstrate something to the user in their real browser, such as running demos, opening pages, showing your work etc. -- If the cli denies requests with approval and asks you to `firefox-cli connect`, invoke that **once** to show the user a permission prompt. The command will exit once it's granted. Do not attempt to circumvent denials in any way. +- Be careful with the user's data: the Firefox instance you're using contains real cookies, auth credentials, logins, tabs, and PII. Under no circumstances should you perform actions that may harm the user or exfiltrate their data. Under no circumstances should you perform payments, cash transfers, other dangerous, destructive, or irreversible operations, or leak PII without asking for the user's explicit approval before each such action. +- If you need to work with Firefox, but you see interference, such as tabs changing or real user tabs being open, or the user is unhappy, prefer opening a new Firefox window for yourself. Every action you take is visibly reflected in the browser window and can disrupt the user's work. The upside is that you can demonstrate something to the user in their real browser, such as running demos, opening pages, or showing your work. +- If the CLI denies requests with approval and asks you to run `firefox-cli connect`, invoke that **once** to show the user a permission prompt. The command will exit once approval is granted. Do not attempt to circumvent denials in any way. From 643717b1a0c7086b8bc981538ca2a0c8961d4392 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Thu, 11 Jun 2026 15:52:04 +0200 Subject: [PATCH 09/11] Format extension manifest --- packages/extension/src/manifest.json | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/packages/extension/src/manifest.json b/packages/extension/src/manifest.json index 15e7c66..52717ec 100644 --- a/packages/extension/src/manifest.json +++ b/packages/extension/src/manifest.json @@ -9,34 +9,15 @@ "update_url": "https://opensource.respawn.pro/firefox-cli/updates.json", "strict_min_version": "150.0", "data_collection_permissions": { - "required": [ - "browsingActivity", - "websiteActivity", - "websiteContent" - ], + "required": ["browsingActivity", "websiteActivity", "websiteContent"], "optional": [] } } }, - "permissions": [ - "nativeMessaging", - "scripting", - "tabs", - "storage", - "downloads", - "cookies", - "notifications", - "clipboardRead", - "clipboardWrite", - "webRequest" - ], - "host_permissions": [ - "" - ], + "permissions": ["nativeMessaging", "scripting", "tabs", "storage", "downloads", "cookies", "notifications", "clipboardRead", "clipboardWrite", "webRequest"], + "host_permissions": [""], "background": { - "scripts": [ - "background.js" - ] + "scripts": ["background.js"] }, "action": { "default_title": "FF-CLI Bridge", From 32b3f84fb74b828947d13ad11c64f203498b2e71 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Thu, 11 Jun 2026 16:27:15 +0200 Subject: [PATCH 10/11] Fix PR review follow-ups Gate notify on protocol v4, including batch children, and let approval request pages reload before closing themselves after terminal approve or deny flows. Update development tooling dependencies to latest, resolve the shell-quote audit through an override to 1.8.4, and record the major-tooling migration plan in docs/dependency-migrations.md. Major release-age evidence: eslint 10.0.0 published 2026-02-06; @eslint/js 10.0.1 published 2026-02-06; @types/node 25.0.0 published 2025-12-10; installed typescript 6.0.3 published 2026-04-16; vite 8.0.0 published 2026-03-12. Verification: bun run deps:check; bun run check; bun run release:check:local. --- biome.json | 2 +- bun.lock | 331 +++++++++--------- docs/dependency-migrations.md | 39 +++ package.json | 23 +- .../extension/src/approval-request-service.ts | 20 +- .../extension/src/approval-request.test.ts | 60 +++- packages/extension/src/approval-request.ts | 36 +- .../src/background-bootstrap-storage.test.ts | 1 + .../src/background-bootstrap-test-cases.ts | 1 + .../extension/src/background-bootstrap.ts | 10 +- ...ckground-controller-approval-test-cases.ts | 10 +- .../src/background-controller-test-cases.ts | 13 +- .../extension/src/background-controller.ts | 30 +- packages/extension/src/webextension.d.ts | 5 +- packages/extension/vite.config.ts | 1 - .../protocol/src/protocol-compatibility.ts | 2 +- .../src/protocol-metadata-behavior.test.ts | 44 +++ packages/protocol/src/registry/phase8.ts | 8 + scripts/manifest-validation.ts | 2 +- scripts/marionette-client.ts | 3 +- scripts/signed-extension-signature.ts | 2 +- tsconfig.base.json | 1 + 22 files changed, 401 insertions(+), 243 deletions(-) create mode 100644 docs/dependency-migrations.md diff --git a/biome.json b/biome.json index 154b8ba..aec3967 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/bun.lock b/bun.lock index ffc74d0..65a4347 100644 --- a/bun.lock +++ b/bun.lock @@ -11,20 +11,20 @@ "@firefox-cli/test-support": "workspace:*", }, "devDependencies": { - "@biomejs/biome": "^2.3.8", - "@eslint/js": "^9.39.4", - "@types/bun": "^1.3.4", + "@biomejs/biome": "^2.4.16", + "@eslint/js": "^10.0.1", + "@types/bun": "^1.3.14", "@types/jsdom": "^28.0.3", - "@types/node": "^24.10.2", - "eslint": "^9.39.4", + "@types/node": "^25.9.1", + "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", "jsdom": "^29.1.1", - "typescript": "^5.9.3", - "typescript-eslint": "^8.48.0", - "vite": "^7.2.4", - "vitest": "^4.0.14", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.1", + "vite": "^8.0.16", + "vitest": "^4.1.8", "web-ext": "^10.3.0", - "zod": "^4.1.13", + "zod": "^4.4.3", }, }, "packages/cli": { @@ -55,6 +55,9 @@ "version": "0.2.0", }, }, + "overrides": { + "shell-quote": "1.8.4", + }, "packages": { "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="], @@ -70,23 +73,23 @@ "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], - "@biomejs/biome": ["@biomejs/biome@2.4.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.15", "@biomejs/cli-darwin-x64": "2.4.15", "@biomejs/cli-linux-arm64": "2.4.15", "@biomejs/cli-linux-arm64-musl": "2.4.15", "@biomejs/cli-linux-x64": "2.4.15", "@biomejs/cli-linux-x64-musl": "2.4.15", "@biomejs/cli-win32-arm64": "2.4.15", "@biomejs/cli-win32-x64": "2.4.15" }, "bin": { "biome": "bin/biome" } }, "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw=="], + "@biomejs/biome": ["@biomejs/biome@2.4.16", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.15", "", { "os": "linux", "cpu": "x64" }, "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.15", "", { "os": "linux", "cpu": "x64" }, "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.15", "", { "os": "win32", "cpu": "x64" }, "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="], "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], @@ -108,75 +111,29 @@ "@devicefarmer/adbkit-monkey": ["@devicefarmer/adbkit-monkey@1.2.1", "", {}, "sha512-ZzZY/b66W2Jd6NHbAhLyDWOEIBWC11VizGFk7Wx7M61JZRz7HR9Cq5P+65RKWUU7u6wgsE8Lmh9nE4Mz+U2eTg=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], + "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="], - "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], "@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="], - "@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], + "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], - "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], "@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="], @@ -208,6 +165,10 @@ "@mdn/browser-compat-data": ["@mdn/browser-compat-data@7.3.16", "", {}, "sha512-JQ6SGcHeyqSYGVwWe7NzOeXfp/vZgo2yz+fsEbMOyWLyNRVP4RoG8+dqaF/VTx1zPtF4J8XvxiWXH1l2cMZTwQ=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + + "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], "@pnpm/config.env-replace": ["@pnpm/config.env-replace@1.1.0", "", {}, "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w=="], @@ -216,64 +177,50 @@ "@pnpm/npm-conf": ["@pnpm/npm-conf@3.0.2", "", { "dependencies": { "@pnpm/config.env-replace": "^1.1.0", "@pnpm/network.ca-file": "^1.0.1", "config-chain": "^1.1.11" } }, "sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="], - - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="], - - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA=="], - - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg=="], - - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g=="], - - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="], - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="], - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ=="], + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="], - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.4", "", { "os": "none", "cpu": "arm64" }, "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg=="], - - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw=="], - - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA=="], - - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw=="], - - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/jsdom": ["@types/jsdom@28.0.3", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^8.0.0", "undici-types": "^7.21.0" } }, "sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ=="], @@ -282,43 +229,43 @@ "@types/minimatch": ["@types/minimatch@3.0.5", "", {}, "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ=="], - "@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="], + "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.4", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/type-utils": "8.59.4", "@typescript-eslint/utils": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.4", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.60.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/type-utils": "8.60.1", "@typescript-eslint/utils": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.60.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.4", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.60.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/types": "8.60.1", "@typescript-eslint/typescript-estree": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.4", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.4", "@typescript-eslint/types": "^8.59.4", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.60.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.60.1", "@typescript-eslint/types": "^8.60.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4" } }, "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.1", "", { "dependencies": { "@typescript-eslint/types": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1" } }, "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.4", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.60.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.60.1", "", { "dependencies": { "@typescript-eslint/types": "8.60.1", "@typescript-eslint/typescript-estree": "8.60.1", "@typescript-eslint/utils": "8.60.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.59.4", "", {}, "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.60.1", "", {}, "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.4", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.4", "@typescript-eslint/tsconfig-utils": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.60.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.60.1", "@typescript-eslint/tsconfig-utils": "8.60.1", "@typescript-eslint/types": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.60.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/types": "8.60.1", "@typescript-eslint/typescript-estree": "8.60.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "eslint-visitor-keys": "^5.0.0" } }, "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.60.1", "", { "dependencies": { "@typescript-eslint/types": "8.60.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag=="], - "@vitest/expect": ["@vitest/expect@4.1.6", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.6", "@vitest/utils": "4.1.6", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg=="], + "@vitest/expect": ["@vitest/expect@4.1.8", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ=="], - "@vitest/mocker": ["@vitest/mocker@4.1.6", "", { "dependencies": { "@vitest/spy": "4.1.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ=="], + "@vitest/mocker": ["@vitest/mocker@4.1.8", "", { "dependencies": { "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.6", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="], - "@vitest/runner": ["@vitest/runner@4.1.6", "", { "dependencies": { "@vitest/utils": "4.1.6", "pathe": "^2.0.3" } }, "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA=="], + "@vitest/runner": ["@vitest/runner@4.1.8", "", { "dependencies": { "@vitest/utils": "4.1.8", "pathe": "^2.0.3" } }, "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg=="], - "@vitest/snapshot": ["@vitest/snapshot@4.1.6", "", { "dependencies": { "@vitest/pretty-format": "4.1.6", "@vitest/utils": "4.1.6", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw=="], + "@vitest/snapshot": ["@vitest/snapshot@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ=="], - "@vitest/spy": ["@vitest/spy@4.1.6", "", {}, "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg=="], + "@vitest/spy": ["@vitest/spy@4.1.8", "", {}, "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA=="], - "@vitest/utils": ["@vitest/utils@4.1.6", "", { "dependencies": { "@vitest/pretty-format": "4.1.6", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ=="], + "@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -356,7 +303,7 @@ "atomically": ["atomically@2.1.1", "", { "dependencies": { "stubborn-fs": "^2.0.0", "when-exit": "^2.1.4" } }, "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], @@ -366,7 +313,7 @@ "boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="], - "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], @@ -384,7 +331,7 @@ "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="], @@ -452,6 +399,8 @@ "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -474,25 +423,23 @@ "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], - "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-goat": ["escape-goat@4.0.0", "", {}, "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], + "eslint": ["eslint@10.4.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw=="], "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], "eslint-plugin-no-unsanitized": ["eslint-plugin-no-unsanitized@4.1.5", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-MSB4hXPVFQrI8weqzs6gzl7reP2k/qSjtCoL2vUMSDejIIq9YL1ZKvq5/ORBXab/PvfBBrWO2jWviYpL+4Ghfg=="], - "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], - "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], @@ -652,6 +599,30 @@ "lighthouse-logger": ["lighthouse-logger@2.0.2", "", { "dependencies": { "debug": "^4.4.1", "marky": "^1.2.2" } }, "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], @@ -680,7 +651,7 @@ "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], - "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -778,7 +749,7 @@ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], - "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], + "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], @@ -800,7 +771,7 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shell-quote": ["shell-quote@1.7.3", "", {}, "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw=="], + "shell-quote": ["shell-quote@1.8.4", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="], "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], @@ -852,7 +823,7 @@ "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], @@ -868,15 +839,17 @@ "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "typescript-eslint": ["typescript-eslint@8.59.4", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.4", "@typescript-eslint/parser": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ=="], + "typescript-eslint": ["typescript-eslint@8.60.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.60.1", "@typescript-eslint/parser": "8.60.1", "@typescript-eslint/typescript-estree": "8.60.1", "@typescript-eslint/utils": "8.60.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA=="], "undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], @@ -892,9 +865,9 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="], + "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], - "vitest": ["vitest@4.1.6", "", { "dependencies": { "@vitest/expect": "4.1.6", "@vitest/mocker": "4.1.6", "@vitest/pretty-format": "4.1.6", "@vitest/runner": "4.1.6", "@vitest/snapshot": "4.1.6", "@vitest/spy": "4.1.6", "@vitest/utils": "4.1.6", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.6", "@vitest/browser-preview": "4.1.6", "@vitest/browser-webdriverio": "4.1.6", "@vitest/coverage-istanbul": "4.1.6", "@vitest/coverage-v8": "4.1.6", "@vitest/ui": "4.1.6", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ=="], + "vitest": ["vitest@4.1.8", "", { "dependencies": { "@vitest/expect": "4.1.8", "@vitest/mocker": "4.1.8", "@vitest/pretty-format": "4.1.8", "@vitest/runner": "4.1.8", "@vitest/snapshot": "4.1.8", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.8", "@vitest/browser-preview": "4.1.8", "@vitest/browser-webdriverio": "4.1.8", "@vitest/coverage-istanbul": "4.1.8", "@vitest/coverage-v8": "4.1.8", "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig=="], "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], @@ -958,32 +931,36 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@eslint/eslintrc/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "@eslint/eslintrc/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "@eslint/eslintrc/strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], - "@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@types/jsdom/@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="], - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "@typescript-eslint/typescript-estree/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "addons-linter/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "addons-linter/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - - "addons-linter/espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], - - "boxen/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "addons-linter/eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], "boxen/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "bun-types/@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="], + "cheerio/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + "chrome-launcher/@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="], + "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], @@ -1000,6 +977,8 @@ "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "multimatch/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "parse5-htmlparser2-tree-adapter/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "parse5-parser-stream/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -1008,11 +987,9 @@ "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], - "rollup/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], - "update-notifier/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "vitest/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -1024,18 +1001,50 @@ "wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + "@eslint/eslintrc/espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + + "@types/jsdom/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "addons-linter/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "addons-linter/eslint/@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], + + "addons-linter/eslint/@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + + "addons-linter/eslint/@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "addons-linter/eslint/@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], + + "addons-linter/eslint/@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + + "addons-linter/eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "addons-linter/eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "addons-linter/eslint/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "addons-linter/eslint/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "addons-linter/eslint/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "addons-linter/eslint/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "boxen/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "boxen/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "cheerio/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "chrome-launcher/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "fx-runner/which/isexe": ["isexe@1.1.2", "", {}, "sha512-d2eJzK691yZwPHcv1LbeAOa91yMJ9QmfTgSO1oXB65ezVhXQsxBac2vEB4bMVms9cGzaA99n6V2viHMq82VLDw=="], + "multimatch/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "parse5-htmlparser2-tree-adapter/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "parse5-parser-stream/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -1048,10 +1057,18 @@ "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "@eslint/eslintrc/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "addons-linter/eslint/@eslint/config-array/@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "addons-linter/eslint/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], "boxen/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "multimatch/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "widest-line/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "addons-linter/eslint/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/docs/dependency-migrations.md b/docs/dependency-migrations.md new file mode 100644 index 0000000..df9c2e1 --- /dev/null +++ b/docs/dependency-migrations.md @@ -0,0 +1,39 @@ +# Dependency Migrations + +This page records major dependency migration plans required by the dependency-upgrade policy in `docs/development.md`. + +## 2026-06-11 Development Tooling + +Scope: + +- `eslint` 10 and `@eslint/js` 10. +- `@types/node` 25. +- `typescript` 6. +- `vite` 8. + +Release-age evidence as of 2026-06-11: + +- `eslint` 10.0.0 was published on 2026-02-06; installed `eslint` 10.4.1 was published on 2026-05-29. +- `@eslint/js` 10.0.1 was published on 2026-02-06. +- `@types/node` 25.0.0 was published on 2025-12-10; installed `@types/node` 25.9.1 was published on 2026-05-19. +- Installed `typescript` 6.0.3 was published on 2026-04-16. +- `vite` 8.0.0 was published on 2026-03-12; installed `vite` 8.0.16 was published on 2026-06-01. + +Migration plan: + +- Keep the existing ESLint flat config and attach caught errors as `cause` where ESLint 10 reports `preserve-caught-error`. +- Keep TypeScript path aliases and set `ignoreDeprecations` for the TypeScript 6 `baseUrl` deprecation until path aliasing is redesigned. +- Normalize Marionette socket chunks before `Buffer.concat` for the Node 25 type surface. +- Remove explicit `manualChunks: undefined` from the Vite extension build output, preserving Rollup's default chunking behavior. +- Update the Biome schema URL to match the installed Biome CLI. +- Use a Bun override for `shell-quote` 1.8.4 because latest `web-ext` pins `fx-runner` 1.4.0, which pins vulnerable `shell-quote` 1.7.3. + +Verification: + +- `bun run deps:check` +- `bun run check` +- `bun run release:check:local` + +Rollback scope: + +- Revert `package.json`, `bun.lock`, and the TypeScript, ESLint, Biome, Vite, and Marionette compatibility edits in the same change. diff --git a/package.json b/package.json index d9a9617..ae9aedb 100644 --- a/package.json +++ b/package.json @@ -53,19 +53,22 @@ "check": "bun run ci" }, "devDependencies": { - "@biomejs/biome": "^2.3.8", - "@eslint/js": "^9.39.4", - "@types/bun": "^1.3.4", + "@biomejs/biome": "^2.4.16", + "@eslint/js": "^10.0.1", + "@types/bun": "^1.3.14", "@types/jsdom": "^28.0.3", - "@types/node": "^24.10.2", - "eslint": "^9.39.4", + "@types/node": "^25.9.1", + "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", "jsdom": "^29.1.1", - "typescript": "^5.9.3", - "typescript-eslint": "^8.48.0", - "vite": "^7.2.4", - "vitest": "^4.0.14", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.1", + "vite": "^8.0.16", + "vitest": "^4.1.8", "web-ext": "^10.3.0", - "zod": "^4.1.13" + "zod": "^4.4.3" + }, + "overrides": { + "shell-quote": "1.8.4" } } diff --git a/packages/extension/src/approval-request-service.ts b/packages/extension/src/approval-request-service.ts index b76cb8d..de438d3 100644 --- a/packages/extension/src/approval-request-service.ts +++ b/packages/extension/src/approval-request-service.ts @@ -19,6 +19,7 @@ interface PendingApprovalRequest { export interface ApprovalRequestViewState { readonly active: boolean; + readonly close?: boolean; readonly url?: string; } @@ -105,7 +106,7 @@ export class ApprovalRequestService { return this.#pending === undefined ? { active: false } : { active: true, url: this.#pending.url }; } - async approve(requestId: string | undefined, sourceTabId: number | undefined, approvePairing: () => Promise): Promise { + async approve(requestId: string | undefined, approvePairing: () => Promise): Promise { if (!this.#requestMatchesPending(requestId)) { return { active: false }; } @@ -120,14 +121,14 @@ export class ApprovalRequestService { this.#rateLimitIndex = 0; this.#nextAllowedAtMs = 0; pending.resolve(createOkResponse(pending.request, { ok: true, url: pending.url })); - await this.#closeSourceTab(sourceTabId); + return { active: false, close: true }; } else if (this.#pending === pending) { pending.status = "pending"; } return this.getViewState(requestId); } - async deny(requestId: string | undefined, sourceTabId: number | undefined): Promise { + deny(requestId: string | undefined): ApprovalRequestViewState { if (!this.#requestMatchesPending(requestId)) { return { active: false }; } @@ -140,7 +141,7 @@ export class ApprovalRequestService { message: USER_DENIED_APPROVAL_MESSAGE, }), ); - await this.#closeSourceTab(sourceTabId); + return { active: false, close: true }; } return this.getViewState(requestId); } @@ -208,17 +209,6 @@ export class ApprovalRequestService { } } - async #closeSourceTab(sourceTabId: number | undefined): Promise { - if (sourceTabId === undefined) { - return; - } - try { - await this.#adapter.closeTab(sourceTabId); - } catch { - // The CLI outcome is already settled. Tab cleanup should not mask approval results. - } - } - async #showApprovalNotification(): Promise { try { await this.#adapter.showNotification({ diff --git a/packages/extension/src/approval-request.test.ts b/packages/extension/src/approval-request.test.ts index cb3346d..e1a857d 100644 --- a/packages/extension/src/approval-request.test.ts +++ b/packages/extension/src/approval-request.test.ts @@ -24,11 +24,22 @@ describe("approval request page", () => { }); it("requests Firefox host access before approving the pending CLI request", async () => { - const sendMessage = vi.fn().mockResolvedValueOnce({ active: true }).mockResolvedValueOnce({ active: false }); + const sendMessage = vi.fn().mockResolvedValueOnce({ active: true }).mockResolvedValueOnce({ active: false, close: true }); const contains = vi.fn(async () => false); const request = vi.fn(async () => true); + const events: string[] = []; - const { document } = await renderApprovalRequestPage({ sendMessage, contains, request }); + const { document } = await renderApprovalRequestPage({ + sendMessage, + contains, + request, + reload: () => { + events.push("reload"); + }, + removeTab: async () => { + events.push("close"); + }, + }); document.querySelector("#approve")?.click(); await vi.waitFor(() => { @@ -37,32 +48,67 @@ describe("approval request page", () => { expect(contains).toHaveBeenCalledWith({ origins: [""] }); expect(request).toHaveBeenCalledWith({ origins: [""] }); + await vi.waitFor(() => { + expect(events).toEqual(["reload", "close"]); + }); }); it("denies the pending CLI request without asking for Firefox host access", async () => { - const sendMessage = vi.fn().mockResolvedValueOnce({ active: true }).mockResolvedValueOnce({ active: false }); + const sendMessage = vi.fn().mockResolvedValueOnce({ active: true }).mockResolvedValueOnce({ active: false, close: true }); const request = vi.fn(async () => true); + const removeTab = vi.fn(async () => undefined); - const { document } = await renderApprovalRequestPage({ sendMessage, request }); + const { document } = await renderApprovalRequestPage({ sendMessage, request, removeTab }); document.querySelector("#deny")?.click(); await vi.waitFor(() => { expect(sendMessage).toHaveBeenLastCalledWith({ type: "firefox-cli:deny-approval-request", requestId: "approval-1" }); }); expect(request).not.toHaveBeenCalled(); + await vi.waitFor(() => { + expect(removeTab).toHaveBeenCalledWith(99); + }); + }); + + it("reloads after manual approval grants Firefox host access without closing the tab", async () => { + const sendMessage = vi.fn().mockResolvedValueOnce({ approved: true }); + const contains = vi.fn(async () => false); + const request = vi.fn(async () => true); + const reload = vi.fn(); + const removeTab = vi.fn(async () => undefined); + + const { document } = await renderApprovalRequestPage({ + search: "?manual=1", + sendMessage, + contains, + request, + reload, + removeTab, + }); + document.querySelector("#approve")?.click(); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenLastCalledWith({ type: "firefox-cli:approve" }); + }); + + expect(reload).toHaveBeenCalledTimes(1); + expect(removeTab).not.toHaveBeenCalled(); }); }); async function renderApprovalRequestPage( options: { + readonly search?: string; readonly sendMessage?: (message: unknown) => Promise; readonly contains?: (permissions: { readonly origins: readonly string[] }) => Promise; readonly request?: (permissions: { readonly origins: readonly string[] }) => Promise; + readonly reload?: () => void; + readonly removeTab?: (tabId: number) => Promise; } = {}, ): Promise<{ readonly document: Document }> { vi.resetModules(); const dom = new JSDOM(await readFile(new URL("./approval-request.html", import.meta.url), "utf8"), { - url: "moz-extension://test/approval-request.html?request=approval-1", + url: `moz-extension://test/approval-request.html${options.search ?? "?request=approval-1"}`, }); vi.stubGlobal("window", dom.window); @@ -70,7 +116,7 @@ async function renderApprovalRequestPage( vi.stubGlobal("browser", { runtime: { sendMessage: options.sendMessage ?? vi.fn(async () => ({ active: true })), - reload: vi.fn(), + reload: options.reload ?? vi.fn(), }, permissions: { contains: options.contains ?? vi.fn(async () => true), @@ -78,6 +124,8 @@ async function renderApprovalRequestPage( }, tabs: { captureVisibleTab: vi.fn(), + getCurrent: vi.fn(async () => ({ id: 99, index: 0, active: true, windowId: 1 })), + remove: options.removeTab ?? vi.fn(async () => undefined), }, }); diff --git a/packages/extension/src/approval-request.ts b/packages/extension/src/approval-request.ts index 4d452b7..6bbe3cf 100644 --- a/packages/extension/src/approval-request.ts +++ b/packages/extension/src/approval-request.ts @@ -2,6 +2,7 @@ import { requestHostAccess } from "./approval-permissions.js"; interface ApprovalRequestState { readonly active: boolean; + readonly close?: boolean; } interface ExtensionStatus { @@ -34,19 +35,18 @@ async function loadRequest(): Promise { async function approve(): Promise { setBusy(true); const reloadAfterApproval = await requestHostAccess(); - renderState( - manualApproval - ? statusToState(await sendMessage("firefox-cli:approve")) - : await sendMessage("firefox-cli:approve-request"), - ); - if (reloadAfterApproval) { - browser.runtime.reload(); - } + const state = manualApproval + ? statusToState(await sendMessage("firefox-cli:approve")) + : await sendMessage("firefox-cli:approve-request"); + renderState(state); + await finishTerminalRequest(state, reloadAfterApproval); } async function deny(): Promise { setBusy(true); - renderState(manualApproval ? { active: false } : await sendMessage("firefox-cli:deny-approval-request")); + const state = manualApproval ? { active: false } : await sendMessage("firefox-cli:deny-approval-request"); + renderState(state); + await finishTerminalRequest(state, false); } async function sendMessage(type: string): Promise { @@ -69,6 +69,24 @@ function renderState(state: ApprovalRequestState): void { } } +async function finishTerminalRequest(state: ApprovalRequestState, reloadAfterApproval: boolean): Promise { + if (state.close !== true) { + if (reloadAfterApproval) { + browser.runtime.reload(); + } + return; + } + const tab = await browser.tabs.getCurrent(); + if (reloadAfterApproval) { + browser.runtime.reload(); + } + if (tab?.id !== undefined) { + await browser.tabs.remove(tab.id); + } else { + window.close(); + } +} + function statusToState(status: ExtensionStatus): ApprovalRequestState { if (status.lastError !== undefined) { renderError(status.lastError); diff --git a/packages/extension/src/background-bootstrap-storage.test.ts b/packages/extension/src/background-bootstrap-storage.test.ts index d387f18..3c33f94 100644 --- a/packages/extension/src/background-bootstrap-storage.test.ts +++ b/packages/extension/src/background-bootstrap-storage.test.ts @@ -98,6 +98,7 @@ function createFakeBrowserApi(port: NativePortLike, initialStorage: Record ({ id: 1, index: 0, active: true, windowId: 1 }), update: async (id: number) => ({ id, index: 0, active: true, windowId: 1 }), + getCurrent: async () => ({ id: 1, index: 0, active: true, windowId: 1 }), get: async (id: number) => ({ id, index: 0, active: true, windowId: 1 }), remove: async () => undefined, goBack: async () => undefined, diff --git a/packages/extension/src/background-bootstrap-test-cases.ts b/packages/extension/src/background-bootstrap-test-cases.ts index c60d9a7..046fddd 100644 --- a/packages/extension/src/background-bootstrap-test-cases.ts +++ b/packages/extension/src/background-bootstrap-test-cases.ts @@ -205,6 +205,7 @@ function createFakeBrowserApi(port: NativePortLike): FakeBackgroundBrowserApi { return { id: 1, index: 0, active: true, windowId: 1 }; }, update: async (id: number) => ({ id, index: 0, active: true, windowId: 1 }), + getCurrent: async () => ({ id: 1, index: 0, active: true, windowId: 1 }), get: async (id: number) => ({ id, index: 0, active: true, windowId: 1 }), remove: async () => undefined, goBack: async () => undefined, diff --git a/packages/extension/src/background-bootstrap.ts b/packages/extension/src/background-bootstrap.ts index 4c9fda2..84feb3e 100644 --- a/packages/extension/src/background-bootstrap.ts +++ b/packages/extension/src/background-bootstrap.ts @@ -7,12 +7,7 @@ import { NetworkRequestTracker } from "./network-tracker.js"; interface RuntimeMessage { readonly type?: string; } -interface RuntimeMessageSender { - readonly tab?: { - readonly id?: number; - }; -} -type RuntimeMessageListener = (message: RuntimeMessage, sender?: RuntimeMessageSender) => Promise; +type RuntimeMessageListener = (message: RuntimeMessage) => Promise; export type BackgroundBrowserApi = typeof browser; @@ -56,8 +51,7 @@ export function startBackground(options: { ...createControllerOptions(options.controllerOptions), }); - const runtimeListener: RuntimeMessageListener = async (message, sender) => - controller.handleRuntimeMessage(message, sender?.tab?.id === undefined ? {} : { sourceTabId: sender.tab.id }); + const runtimeListener: RuntimeMessageListener = async (message) => controller.handleRuntimeMessage(message); const onTabRemoved = (tabId: number) => { networkObservation.pruneTab(tabId); contentScriptState.forgetTab(tabId); diff --git a/packages/extension/src/background-controller-approval-test-cases.ts b/packages/extension/src/background-controller-approval-test-cases.ts index 10657a6..29488c4 100644 --- a/packages/extension/src/background-controller-approval-test-cases.ts +++ b/packages/extension/src/background-controller-approval-test-cases.ts @@ -234,13 +234,9 @@ export function runCase05() { export async function runCase06() { const port = new FakeNativePort(); - const closedTabs: number[] = []; const controller = new FirefoxCliBackgroundController({ browserAdapter: createTestBrowserAdapter([], { openExtensionPage: async (path) => `moz-extension://test/${path}`, - closeTab: async (tabId) => { - closedTabs.push(tabId); - }, }), connectNative: () => port, productVersion: "0.0.0", @@ -252,10 +248,9 @@ export async function runCase06() { port.emitMessage(request); await flushPromises(); - await expect( - controller.handleRuntimeMessage({ type: "firefox-cli:deny-approval-request", requestId: "approval-deny-1" }, { sourceTabId: 456 }), - ).resolves.toEqual({ + await expect(controller.handleRuntimeMessage({ type: "firefox-cli:deny-approval-request", requestId: "approval-deny-1" })).resolves.toEqual({ active: false, + close: true, }); await flushPromises(); @@ -268,7 +263,6 @@ export async function runCase06() { message: USER_DENIED_APPROVAL_MESSAGE, }, }); - expect(closedTabs).toEqual([456]); } export async function runCase07() { diff --git a/packages/extension/src/background-controller-test-cases.ts b/packages/extension/src/background-controller-test-cases.ts index 02926b0..0a0f05f 100644 --- a/packages/extension/src/background-controller-test-cases.ts +++ b/packages/extension/src/background-controller-test-cases.ts @@ -181,7 +181,6 @@ export async function runCase06() { const port = new FakeNativePort(); const notifications: unknown[] = []; const pages: string[] = []; - const closedTabs: number[] = []; const controller = new FirefoxCliBackgroundController({ browserAdapter: createTestBrowserAdapter([], { showNotification: async (options) => { @@ -192,9 +191,6 @@ export async function runCase06() { pages.push(path); return `moz-extension://test/${path}`; }, - closeTab: async (tabId) => { - closedTabs.push(tabId); - }, }), connectNative: () => port, productVersion: "0.0.0", @@ -215,7 +211,8 @@ export async function runCase06() { url: "moz-extension://test/approval-request.html?request=approval-request-1", }); - const approval = controller.handleRuntimeMessage({ type: "firefox-cli:approve-request", requestId: "approval-request-1" }, { sourceTabId: 123 }); + const approval = controller.handleRuntimeMessage({ type: "firefox-cli:approve-request", requestId: "approval-request-1" }); + await flushPromises(); const approve = latestPairApproveRequest(port); port.emitMessage( createOkResponse(approve, { @@ -226,7 +223,10 @@ export async function runCase06() { approvedAt: "2026-01-02T03:04:05.000Z", }), ); - await approval; + await expect(approval).resolves.toEqual({ + active: false, + close: true, + }); await flushPromises(); expect(port.messages[2]).toEqual({ @@ -238,7 +238,6 @@ export async function runCase06() { url: "moz-extension://test/approval-request.html?request=approval-request-1", }, }); - expect(closedTabs).toEqual([123]); } export async function runCase07() { diff --git a/packages/extension/src/background-controller.ts b/packages/extension/src/background-controller.ts index f536f57..998f67d 100644 --- a/packages/extension/src/background-controller.ts +++ b/packages/extension/src/background-controller.ts @@ -160,10 +160,7 @@ export class FirefoxCliBackgroundController { }; } - async handleRuntimeMessage( - message: { readonly type?: string; readonly requestId?: string }, - context: { readonly sourceTabId?: number } = {}, - ): Promise { + async handleRuntimeMessage(message: { readonly type?: string; readonly requestId?: string }): Promise { if (message.type === "firefox-cli:get-status") { return this.getStatus(); } @@ -173,11 +170,11 @@ export class FirefoxCliBackgroundController { } if (message.type === "firefox-cli:deny-approval-request") { - return this.#approvalRequests.deny(message.requestId, context.sourceTabId); + return this.#approvalRequests.deny(message.requestId); } if (message.type === "firefox-cli:approve-request") { - return this.#approvalRequests.approve(message.requestId, context.sourceTabId, async () => this.#approveWithNativeHost()); + return this.#approvalRequests.approve(message.requestId, async () => this.#approveWithNativeHost()); } if (message.type === "firefox-cli:approve") { @@ -232,14 +229,19 @@ export class FirefoxCliBackgroundController { this.#pairing.beginMutation(); const request = createRequest("pair.approve", {}); const response = await this.#sendNativeRequest(request); - if (response.ok) { - await this.#pairing.approve(response.result.token); - this.#lastError = undefined; - return true; + if (!response.ok) { + this.#pairing.markRejected(); + this.#lastError = response.error.message; + return false; + } + if (!("token" in response.result)) { + this.#pairing.markRejected(); + this.#lastError = "Native host returned an invalid approval response."; + return false; } - this.#pairing.markRejected(); - this.#lastError = response.error.message; - return false; + await this.#pairing.approve(response.result.token); + this.#lastError = undefined; + return true; } #postHello(): void { @@ -267,8 +269,6 @@ export class FirefoxCliBackgroundController { }); } - async #sendNativeRequest(request: RequestEnvelope<"pair.approve">): Promise>; - async #sendNativeRequest(request: RequestEnvelope): Promise; async #sendNativeRequest(request: RequestEnvelope): Promise { if (this.#connection.stopped) { return createErrorResponseForRequest(request, { diff --git a/packages/extension/src/webextension.d.ts b/packages/extension/src/webextension.d.ts index bc9b73e..9b92749 100644 --- a/packages/extension/src/webextension.d.ts +++ b/packages/extension/src/webextension.d.ts @@ -23,8 +23,8 @@ interface BrowserWindow { declare const browser: { readonly runtime: { readonly onMessage: { - addListener(listener: (message: { readonly type?: string }, sender?: { readonly tab?: { readonly id?: number } }) => unknown): void; - removeListener(listener: (message: { readonly type?: string }, sender?: { readonly tab?: { readonly id?: number } }) => unknown): void; + addListener(listener: (message: { readonly type?: string }) => unknown): void; + removeListener(listener: (message: { readonly type?: string }) => unknown): void; }; connectNative(name: string): { readonly onMessage: { @@ -48,6 +48,7 @@ declare const browser: { readonly tabs: { create(options: { readonly active?: boolean; readonly url?: string; readonly windowId?: number }): Promise; update(tabId: number, options: { readonly active?: boolean; readonly url?: string }): Promise; + getCurrent(): Promise; get(tabId: number): Promise; remove(tabId: number): Promise; goBack(tabId: number): Promise; diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index e8574b3..230a2de 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -17,7 +17,6 @@ export default defineConfig({ output: { entryFileNames: "[name].js", assetFileNames: "assets/[name][extname]", - manualChunks: undefined, }, }, }, diff --git a/packages/protocol/src/protocol-compatibility.ts b/packages/protocol/src/protocol-compatibility.ts index cc39b9d..8d4f80a 100644 --- a/packages/protocol/src/protocol-compatibility.ts +++ b/packages/protocol/src/protocol-compatibility.ts @@ -66,7 +66,7 @@ function getRequestProtocolRequirementForSubject( ? undefined : { minProtocolVersion: childRequirement.minProtocolVersion, - reason: "Batch contains scoped network command semantics.", + reason: childRequirement.reason, }; } diff --git a/packages/protocol/src/protocol-metadata-behavior.test.ts b/packages/protocol/src/protocol-metadata-behavior.test.ts index 9822a86..6313fcb 100644 --- a/packages/protocol/src/protocol-metadata-behavior.test.ts +++ b/packages/protocol/src/protocol-metadata-behavior.test.ts @@ -226,6 +226,14 @@ describe("request protocol compatibility", () => { }, }); expect(getRequestProtocolRequirement(createRequest("wait", { kind: "load-state", state: "complete" }))).toBeUndefined(); + expect(getCommandCompatibilityMetadata("notify")).toEqual({ + requirements: [ + { + minProtocolVersion: 4, + reason: "Native notifications were added in protocol v4.", + }, + ], + }); }); it("requires protocol v2 for scoped network semantics", () => { @@ -285,6 +293,42 @@ describe("request protocol compatibility", () => { }); }); + it("requires protocol v4 for native notifications, including batch steps", () => { + const notify = createRequest("notify", { title: "Action needed" }, "notify-v4"); + const batch = createRequest( + "batch", + { + steps: [ + { command: "snapshot", params: {} }, + { command: "notify", params: { title: "Action needed" } }, + ], + }, + "notify-batch-v4", + ); + + for (const request of [notify, batch]) { + expect(getRequestProtocolCompatibility(request, 3)).toMatchObject({ + compatible: false, + requiredProtocolVersion: 4, + reason: "Native notifications were added in protocol v4.", + }); + expect(parseBoundaryRequest("host-to-extension", { ...request, protocolVersion: 3 }, { protocolVersion: 3 })).toMatchObject({ + ok: false, + error: { + code: "VERSION_MISMATCH", + details: { + requiredProtocolVersion: 4, + negotiatedProtocolVersion: 3, + reason: "Native notifications were added in protocol v4.", + }, + }, + }); + expect(parseBoundaryRequest("host-to-extension", { ...request, protocolVersion: 4 }, { protocolVersion: 4 })).toMatchObject({ + ok: true, + }); + } + }); + it("keeps non-network commands compatible with protocol v1 sessions", () => { const request = createRequest("capabilities", {}, "capabilities-v1", 1); diff --git a/packages/protocol/src/registry/phase8.ts b/packages/protocol/src/registry/phase8.ts index f81f7bc..1836a91 100644 --- a/packages/protocol/src/registry/phase8.ts +++ b/packages/protocol/src/registry/phase8.ts @@ -291,6 +291,14 @@ export const phase8CommandEntries = defineCommandEntries({ action: false, timeout: "none", security: { level: "sensitive", reasons: ["notifications"] }, + compatibility: { + requirements: [ + { + minProtocolVersion: 4, + reason: "Native notifications were added in protocol v4.", + }, + ], + }, batch: { allowed: true }, cliRoutes: [{ id: "notify", path: ["notify"], batch: true }], }, diff --git a/scripts/manifest-validation.ts b/scripts/manifest-validation.ts index 3394b72..0cb5a65 100644 --- a/scripts/manifest-validation.ts +++ b/scripts/manifest-validation.ts @@ -56,7 +56,7 @@ export function parseJsonWithSchema(content: string, label: string, location: try { raw = JSON.parse(content); } catch (error) { - throw new Error(`Invalid ${label} JSON at ${location}: ${error instanceof Error ? error.message : String(error)}`); + throw new Error(`Invalid ${label} JSON at ${location}: ${error instanceof Error ? error.message : String(error)}`, { cause: error }); } const parsed = schema.safeParse(raw); diff --git a/scripts/marionette-client.ts b/scripts/marionette-client.ts index e09d67b..da9960a 100644 --- a/scripts/marionette-client.ts +++ b/scripts/marionette-client.ts @@ -114,7 +114,8 @@ export class MarionetteClient { this.#commandTimeoutMs = options.commandTimeoutMs ?? timeoutPolicies.marionetteCommand.timeoutMs; this.#maxFrameBytes = options.maxFrameBytes ?? timeoutPolicies.marionetteFrame.maxBytes; this.#socket.on("data", (chunk) => { - this.#buffer = Buffer.concat([this.#buffer, chunk]); + const data = typeof chunk === "string" ? Buffer.from(chunk) : chunk; + this.#buffer = Buffer.concat([this.#buffer, data]); try { this.#parse(); } catch (error) { diff --git a/scripts/signed-extension-signature.ts b/scripts/signed-extension-signature.ts index 313d3a6..8a5737d 100644 --- a/scripts/signed-extension-signature.ts +++ b/scripts/signed-extension-signature.ts @@ -65,7 +65,7 @@ export async function verifySignedExtensionSignature(input: SignedExtensionSigna ); } catch (error) { const message = error instanceof Error ? error.message : String(error); - throw new Error(`Signed extension PKCS7 verification failed: ${message}`); + throw new Error(`Signed extension PKCS7 verification failed: ${message}`, { cause: error }); } if (result.exitCode !== 0) { diff --git a/tsconfig.base.json b/tsconfig.base.json index afa6c7e..e7f2059 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -14,6 +14,7 @@ "isolatedModules": true, "skipLibCheck": false, "baseUrl": ".", + "ignoreDeprecations": "6.0", "paths": { "@firefox-cli/cli": ["packages/cli/src/index.ts"], "@firefox-cli/native-host": ["packages/native-host/src/index.ts"], From 09396155a73bded6cc67d4ca99b6a56e287494f2 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Thu, 11 Jun 2026 16:32:14 +0200 Subject: [PATCH 11/11] Settle approval failures Resolve follow-up review findings by settling approval requests when native approval throws, keeping in-memory approval false when pair-token persistence fails, normalizing approval request CSS fallback casing, and removing the showNotification test assertion. Verification: bun run test packages/extension/src/background-controller-approval.test.ts packages/extension/src/approval-request.test.ts packages/extension/src/background-controller.test.ts; bun run typecheck; bun run lint; bun run check. --- .../extension/src/approval-request-service.ts | 17 +++++- packages/extension/src/approval-request.css | 4 +- ...und-controller-approval-race-test-cases.ts | 55 +++++++++++++++++++ .../background-controller-approval.test.ts | 3 +- .../src/background-pairing-service.ts | 2 +- .../src/browser-commands-test-utils.ts | 8 ++- 6 files changed, 82 insertions(+), 7 deletions(-) diff --git a/packages/extension/src/approval-request-service.ts b/packages/extension/src/approval-request-service.ts index de438d3..7d30f1f 100644 --- a/packages/extension/src/approval-request-service.ts +++ b/packages/extension/src/approval-request-service.ts @@ -115,7 +115,22 @@ export class ApprovalRequestService { return { active: false }; } pending.status = "approving"; - const approved = await approvePairing(); + let approved: boolean; + try { + approved = await approvePairing(); + } catch (error) { + if (this.#pending === pending) { + this.#pending = undefined; + pending.resolve( + createErrorResponseForRequest(pending.request, { + code: "NATIVE_HOST_UNAVAILABLE", + message: error instanceof Error ? error.message : String(error), + }), + ); + return { active: false, close: true }; + } + return { active: false }; + } if (approved) { this.#pending = undefined; this.#rateLimitIndex = 0; diff --git a/packages/extension/src/approval-request.css b/packages/extension/src/approval-request.css index f603eb6..2e55905 100644 --- a/packages/extension/src/approval-request.css +++ b/packages/extension/src/approval-request.css @@ -8,8 +8,8 @@ --text-muted: #9facb8; --danger: #ff6678; --border: rgba(21, 255, 138, 0.18); - --base-font: "Comfortaa", "Montserrat", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; - --heading-font: "Montserrat Alternates", "Comfortaa", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + --base-font: "Comfortaa", "Montserrat", system-ui, -apple-system, blinkmacsystemfont, "Segoe UI", helvetica, arial, sans-serif; + --heading-font: "Montserrat Alternates", "Comfortaa", system-ui, -apple-system, blinkmacsystemfont, "Segoe UI", helvetica, arial, sans-serif; } * { diff --git a/packages/extension/src/background-controller-approval-race-test-cases.ts b/packages/extension/src/background-controller-approval-race-test-cases.ts index 499f2c1..f5815bb 100644 --- a/packages/extension/src/background-controller-approval-race-test-cases.ts +++ b/packages/extension/src/background-controller-approval-race-test-cases.ts @@ -109,3 +109,58 @@ export async function runCase11() { }, }); } + +export async function runCase12() { + const port = new FakeNativePort(); + const controller = new FirefoxCliBackgroundController({ + browserAdapter: createTestBrowserAdapter([], { + openExtensionPage: async (path) => `moz-extension://test/${path}`, + }), + connectNative: () => port, + productVersion: "0.0.0", + storageAdapter: { + getPairToken: async () => null, + setPairToken: async () => { + throw new Error("Could not persist pair token."); + }, + }, + }); + controller.start(); + await completeNativeHello(port); + + const request = createRequest("pair.requestApproval", {}, "approval-throw-1"); + port.emitMessage(request); + await flushPromises(); + const approval = controller.handleRuntimeMessage({ type: "firefox-cli:approve-request", requestId: "approval-throw-1" }); + await flushPromises(); + + const approve = latestPairApproveRequest(port); + port.emitMessage( + createOkResponse(approve, { + hostId: "host-1", + extensionId: "ff-cli-bridge@respawn.pro", + token: "paired-token", + generation: 1, + approvedAt: "2026-01-02T03:04:05.000Z", + }), + ); + + await expect(approval).resolves.toEqual({ + active: false, + close: true, + }); + await flushPromises(); + + expect(port.messages[2]).toEqual({ + protocolVersion: request.protocolVersion, + id: "approval-throw-1", + ok: false, + error: { + code: "NATIVE_HOST_UNAVAILABLE", + message: "Could not persist pair token.", + }, + }); + await expect(controller.handleRuntimeMessage({ type: "firefox-cli:get-status" })).resolves.toMatchObject({ + approved: false, + }); +} diff --git a/packages/extension/src/background-controller-approval.test.ts b/packages/extension/src/background-controller-approval.test.ts index 4234ec4..e6e49dc 100644 --- a/packages/extension/src/background-controller-approval.test.ts +++ b/packages/extension/src/background-controller-approval.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "vitest"; import { runCase01, runCase02, runCase03, runCase04, runCase05, runCase06, runCase07, runCase08 } from "./background-controller-approval-test-cases.js"; -import { runCase09, runCase10, runCase11 } from "./background-controller-approval-race-test-cases.js"; +import { runCase09, runCase10, runCase11, runCase12 } from "./background-controller-approval-race-test-cases.js"; describe("FirefoxCliBackgroundController", () => { it("ignores responses that arrive after request timeout", runCase01); @@ -14,4 +14,5 @@ describe("FirefoxCliBackgroundController", () => { it("exposes pending approval state before the approval tab finishes opening", runCase09); it("ignores deny events while native approval is in flight", runCase10); it("keeps legacy open-approval requests compatible with the dedicated page", runCase11); + it("settles pending approval requests when native approval throws", runCase12); }); diff --git a/packages/extension/src/background-pairing-service.ts b/packages/extension/src/background-pairing-service.ts index 076a8d8..5d18d37 100644 --- a/packages/extension/src/background-pairing-service.ts +++ b/packages/extension/src/background-pairing-service.ts @@ -40,9 +40,9 @@ export class PairingStateService { } async approve(pairToken: string): Promise { + await this.#storageAdapter.setPairToken(pairToken); this.#pairToken = pairToken; this.#approved = true; - await this.#storageAdapter.setPairToken(pairToken); } markRejected(): void { diff --git a/packages/extension/src/browser-commands-test-utils.ts b/packages/extension/src/browser-commands-test-utils.ts index 0c4a4a4..a482570 100644 --- a/packages/extension/src/browser-commands-test-utils.ts +++ b/packages/extension/src/browser-commands-test-utils.ts @@ -273,9 +273,13 @@ export class FakeBrowserAdapter implements BackgroundBrowserAdapter { this.networkIdleWaits.push(options); } - async showNotification(options: { readonly id?: string; readonly title: string; readonly message?: string }) { + async showNotification(options: { + readonly id?: string; + readonly title: string; + readonly message?: string; + }): Promise<{ readonly ok: true; readonly id: string }> { this.notifications.push(options); - return { ok: true as const, id: options.id ?? `notification-${String(this.notifications.length)}` }; + return { ok: true, id: options.id ?? `notification-${String(this.notifications.length)}` }; } async getExtensionInstance(): Promise<{ readonly extensionUrl: string; readonly focusedWindowId?: number }> {