From 5a3e74c059365a9bc502d639adb2b9bace726177 Mon Sep 17 00:00:00 2001 From: Kasper Kulikowski Date: Tue, 13 Oct 2026 23:53:43 +0200 Subject: [PATCH 1/2] Reject executeTool() failures with the draft's DOMException names Draft b206dae replaced UnknownError with NotFoundError, DataError, OperationError, AbortError, and InvalidStateError, and eada13b rejects a target without a navigable with InvalidStateError. Frames now carry the failure name in their error replies, and the draft pin moves to eada13b. Pinned WPT still expects, and Chromium still throws, UnknownError (crbug.com/509555636), so six subtests become expected failures and one detachment case passes. --- README.md | 4 + TESTING.md | 25 ++-- src/frames.ts | 102 +++++++++++---- src/index.ts | 62 ++++++--- tests/app.test.ts | 2 +- tests/execute.test.ts | 14 +- tests/frames.test.ts | 121 ++++++++++++++++-- ...teTool-error-window-onerror.https.html.ini | 7 + ...cuteTool-invalid-dictionary.https.html.ini | 5 + ...ecuteTool-target-detachment.https.html.ini | 8 +- ...ecuteTool-target-navigation.https.html.ini | 5 + ...uteTool-unauthorized-origin.https.html.ini | 5 + 12 files changed, 282 insertions(+), 78 deletions(-) create mode 100644 wpt/metadata/webmcp/imperative/executeTool-error-window-onerror.https.html.ini create mode 100644 wpt/metadata/webmcp/imperative/executeTool-invalid-dictionary.https.html.ini create mode 100644 wpt/metadata/webmcp/imperative/executeTool-target-navigation.https.html.ini create mode 100644 wpt/metadata/webmcp/imperative/executeTool-unauthorized-origin.https.html.ini diff --git a/README.md b/README.md index 8bd317f..5c3fe98 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,10 @@ Native contexts do not join the polyfill's channels. See [TESTING.md](https://gi `executeTool()` accepts an object and returns a JSON-serialized result. Omitted or `undefined` input defaults to a fresh empty object. Callbacks must validate their inputs; schema inference provides TypeScript checks only. +A failed execution rejects with the draft's `DOMException` name: `NotFoundError` when the target has no such tool exposed to the caller, `DataError` when the input does not parse to an object, `OperationError` when the callback throws, rejects, or returns an unserializable result, `AbortError` when the target document unloads during the call, and `InvalidStateError` when the target frame was already removed or is in another frame tree. The callback's own error stays in its frame. + +Where Chromium's native WebMCP is enabled, the polyfill leaves it in place. Chromium still rejects missing tools, invalid input, callback failures, and unloaded targets with `UnknownError` ([crbug.com/509555636](https://crbug.com/509555636)), so treat any name you do not handle as a generic failure. + Breaking API changes ship with notes: in minor releases while the version is 0.x, in majors after 1.0. ## Development diff --git a/TESTING.md b/TESTING.md index 316b960..f74ed6f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2,7 +2,7 @@ ## Results -**223 browser tests pass** across Chromium 153.0.8010.12, Firefox 155.0, and +**235 browser tests pass** across Chromium 153.0.8010.12, Firefox 155.0, and Playwright WebKit 26.6. Package checks also pass: imports, types, SSR, and tarball contents. CI runs these checks plus WPT in Chrome, Firefox, and actual Safari. Safari runs on `macos-26`; Playwright WebKit is a separate build. @@ -12,12 +12,12 @@ in Chrome and Firefox: | Subtest result | Count | | --- | ---: | -| PASS | 92 | -| Expected FAIL | 52 | +| PASS | 87 | +| Expected FAIL | 57 | | Expected TIMEOUT | 26 | | Expected NOTRUN | 20 | -Tested with Chrome Canary 157.0.8080.0 and Firefox Nightly 159.0a1 (20260930214513). +Tested with Chrome Canary 155.0.8057.0 and Firefox Nightly 160.0a1 (20261013094244). Safari has not yet run at this pin; none of the expectations are browser-specific. At the file level: 43 OK, 26 expected timeouts, one expected error. @@ -65,14 +65,20 @@ other non-testharness files are outside this suite. ## Draft alignment and limitations -Checked against [draft `d61d0e6`](https://github.com/webmachinelearning/webmcp/blob/d61d0e6d297ddb6bff3510b1330dbb215c6ef43c/index.bs) +Checked against [draft `eada13b`](https://github.com/webmachinelearning/webmcp/blob/eada13b575187ba9a523adc0c52e294ebdb6b597/index.bs) and `webmcp-types@0.1.9`. -- **Missing APIs:** declarative forms, CSS states, and lifecycle events - (`toolactivated`/`toolcancel`, their handlers, and `ToolActivatedEvent`/`ToolCancelEvent`) - are not implemented. +- **Missing APIs:** lifecycle events (`toolactivated`/`toolcancel`, their handlers, and + `ToolActivatedEvent`/`ToolCancelEvent`) are not implemented. Declarative forms and CSS + states are not implemented either; the draft removed them in `14ae813`, but pinned WPT + still tests them. - **Draft differences:** results are JSON-serialized; some pinned tests expect raw strings. Omitted or `undefined` input becomes `{}`; `null` and primitives reject. +- **Execution errors:** failures reject with the draft's `NotFoundError`, `DataError`, + `OperationError`, `AbortError`, or `InvalidStateError`. Pinned WPT and Chromium still + expect `UnknownError` ([crbug.com/509555636](https://crbug.com/509555636)), so six + subtests are expected failures. Failures of the polyfill's own frame messages, such as a + malformed reply, have no draft counterpart and reject with `OperationError`. - **Timing:** MessagePorts approximate native task ordering. Aborting before dispatch skips the callback; the draft dispatches and then aborts its signal. Delegated permission checks are asynchronous, so argument errors can precede @@ -82,7 +88,8 @@ and `webmcp-types@0.1.9`. Opaque-origin handshakes and inaccessible cross-origin shadow frames are unsupported; closed shadow trees limit discovery and ordering. - **Deadlines:** initial discovery, handshakes, and discovery or permission replies - wait up to 500 ms. Late peers can be absent from results. Callbacks and + wait up to 500 ms. Late peers can be absent from results, and executing a tool in a + frame that misses the handshake deadline rejects with `NotFoundError`. Callbacks and `toolchange` listeners have no deadline, so an unresponsive peer can leave a call pending. Navigation cleanup depends on `pagehide`; removed frames are polled every 100 ms. Background suspension affects timing. diff --git a/src/frames.ts b/src/frames.ts index 1458b1f..5ec3790 100644 --- a/src/frames.ts +++ b/src/frames.ts @@ -119,7 +119,8 @@ export class FrameBridge { toRegisteredTool(readToolMetadata(value), peer, reply.origin), ); } catch (error) { - if (!(error instanceof NativeDOMException && error.name === "UnknownError")) { + // Refusals and missing peers are expected; malformed replies and other errors are not. + if (!isExecutionError(error)) { console.warn("WebMCP: could not read tools from a frame.", error); } return []; @@ -143,7 +144,7 @@ export class FrameBridge { signal, ); if (typeof reply.value !== "string") { - throw executionError(); + throw executionError("OperationError"); } return reply.value; } @@ -173,8 +174,17 @@ export class FrameBridge { ): Promise<{ value: unknown; origin: string }> { return new Promise((resolve, reject) => { signal?.throwIfAborted(); - if (this.#hidden || !this.#related(target)) { - reject(executionError()); + if (this.#hidden) { + reject(new NativeDOMException("The document is not fully active", "InvalidStateError")); + return; + } + // executeTool() rejects a target that is already closed, so one closed since has unloaded. + if (target.closed) { + reject(executionError("AbortError")); + return; + } + if (!this.#related(target)) { + reject(executionError("InvalidStateError")); return; } @@ -189,7 +199,7 @@ export class FrameBridge { this.#handshakes.delete(id); port1.close(); }; - const fail = (error: unknown = executionError()): void => { + const fail = (error: unknown): void => { port1.postMessage({ kind: "cancel" }); close(); reject(error); @@ -199,7 +209,8 @@ export class FrameBridge { if (peerOrigin === undefined) { this.#forget(target); } - fail(); + // A window that misses the deadline is treated as holding no polyfill, and so no tools. + fail(executionError("NotFoundError")); }; const connected = (event: MessageEvent): void => { if (event.source !== target) { @@ -207,7 +218,7 @@ export class FrameBridge { } this.#handshakes.delete(id); if (origins && !origins.includes(event.origin)) { - fail(); + fail(executionError("NotFoundError")); return; } peerOrigin = event.origin; @@ -220,12 +231,12 @@ export class FrameBridge { }; let timer = setTimeout(expire, deadline); - const stopWatching = this.#watch(target, fail); + const stopWatching = this.#watch(target, () => fail(executionError("AbortError"))); port1.onmessage = (event: MessageEvent) => { const reply = record(event.data); // A peer that gave up may say so before its ready message is handled here. if (reply?.kind === "error") { - fail(); + fail(executionError(readExecutionFailure(reply.name))); return; } if (reply?.kind !== "result" || peerOrigin === undefined) { @@ -234,7 +245,7 @@ export class FrameBridge { close(); resolve({ value: reply.value, origin: peerOrigin }); }; - port1.onmessageerror = () => fail(); + port1.onmessageerror = () => fail(executionError("OperationError")); signal?.addEventListener("abort", abort, { once: true }); this.#handshakes.set(id, connected); // The peer's origin is unknown until it answers, and this message carries only the id. @@ -242,7 +253,7 @@ export class FrameBridge { target.postMessage(`${protocol}:connect:${id}`, "*", [port2]); } catch { port2.close(); - fail(); + fail(executionError("OperationError")); } }); } @@ -319,15 +330,16 @@ export class FrameBridge { controller.abort(); close(); }; - const fail = (): void => { - port.postMessage({ kind: "error" }); + const fail = (name: ExecutionFailure): void => { + port.postMessage({ kind: "error", name }); cancel(); }; // Tell a requester that was too slow to send its request; it may have no deadline left. - const timer = setTimeout(fail, deadline); - const stopWatching = this.#watch(source, fail); + const timer = setTimeout(() => fail("OperationError"), deadline); + // The target leaving the tree unloads its document; if the caller left, no one reads this. + const stopWatching = this.#watch(source, () => fail("AbortError")); - port.onmessageerror = fail; + port.onmessageerror = () => fail("OperationError"); port.onmessage = async (event: MessageEvent) => { const request = record(event.data); if (request?.kind === "cancel") { @@ -343,8 +355,8 @@ export class FrameBridge { const value = await this.#respond(source, origin, request, controller.signal); port.postMessage({ kind: "result", value }); close(); - } catch { - fail(); + } catch (error) { + fail(isExecutionError(error) ? error.name : "OperationError"); } }; source.postMessage(`${protocol}:ready:${id}`, origin); @@ -356,8 +368,11 @@ export class FrameBridge { request: Record | undefined, signal: AbortSignal, ): Promise { - if (!request || !this.#related(source)) { - throw executionError(); + if (!request) { + throw executionError("OperationError"); + } + if (!this.#related(source)) { + throw executionError("InvalidStateError"); } if (request.kind === "permission") { const { childIndex, childOrigin } = request; @@ -367,16 +382,20 @@ export class FrameBridge { childIndex < 0 || typeof childOrigin !== "string" ) { - throw executionError(); + throw executionError("OperationError"); } return this.#childPermission(childIndex, childOrigin); } - if (!(await this.allowed()) || !(await this.#sourceAllowed(source, origin))) { - throw executionError(); + if (!(await this.allowed())) { + // A document that may not use the API cannot register tools, so it has none to find. + throw executionError(this.#hidden ? "AbortError" : "NotFoundError"); + } + if (!(await this.#sourceAllowed(source, origin))) { + throw executionError("NotAllowedError"); } // The checks above may have waited on other frames. if (!this.#related(source)) { - throw executionError(); + throw executionError("InvalidStateError"); } signal.throwIfAborted(); if (request.kind === "getTools") { @@ -393,7 +412,7 @@ export class FrameBridge { await this.#handlers.changed(); return undefined; } - throw executionError(); + throw executionError("OperationError"); } async #childPermission(childIndex: number, childOrigin: string): Promise { @@ -768,6 +787,35 @@ function isWindow(value: MessageEventSource): value is Window { } } -export function executionError(): DOMException { - return new NativeDOMException("Tool execution failed", "UnknownError"); +// The first four names are the draft's tool execution errors: +// https://webmachinelearning.github.io/webmcp/#tool-execution-error +// InvalidStateError and NotAllowedError come from other executeTool() steps. Protocol failures +// without a draft counterpart, such as a malformed reply, are reported as OperationError. +const executionFailures = { + NotFoundError: "The tool is not registered in the target document", + DataError: "The tool input could not be parsed", + OperationError: "The tool failed or returned an unserializable result", + AbortError: "The target document was unloaded", + InvalidStateError: "The target document is detached or in another frame tree", + NotAllowedError: "WebMCP is disabled by Permissions Policy", +} as const; + +export type ExecutionFailure = keyof typeof executionFailures; + +export function executionError(name: ExecutionFailure): DOMException { + // Each name has one fixed message, so a message reveals nothing beyond its name. + return new NativeDOMException(executionFailures[name], name); +} + +function isExecutionError(error: unknown): error is DOMException & { name: ExecutionFailure } { + return error instanceof NativeDOMException && Object.hasOwn(executionFailures, error.name); +} + +// Another frame's reply is untrusted input. +function readExecutionFailure(value: unknown): ExecutionFailure { + if (typeof value === "string" && Object.hasOwn(executionFailures, value)) { + // SAFETY: the own-property check above limits the string to the table's names. + return value as ExecutionFailure; + } + return "OperationError"; } diff --git a/src/index.ts b/src/index.ts index 4b7a150..048f494 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { annotationNames, executionError, readToolsPolicy, + type ExecutionFailure, type ToolMetadata, } from "./frames.js"; export type { WebMCP } from "webmcp-types"; @@ -256,6 +257,10 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { } const serializedInput = serializeJSON(inputObject); + // A closed window, such as a removed frame's, has no navigable to run the tool in. + if (target.window.closed) { + throw executionError("InvalidStateError"); + } callerSignal?.throwIfAborted(); const frames = await this.#requireFrames(); callerSignal?.throwIfAborted(); @@ -303,11 +308,11 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { }); }; - const rejectExecution = (): void => { + const rejectExecution = (failure: ExecutionFailure): void => { callbackFinished = true; queueTask(() => { callerSignal?.removeEventListener("abort", onCallerAbort); - reject(executionError()); + reject(executionError(failure)); }); }; @@ -325,39 +330,54 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { resolve(serializedResult); }); } catch { - rejectExecution(); + rejectExecution("OperationError"); } }; + const rejectCallback = (): void => rejectExecution("OperationError"); const dispatchTool = (): void => { if (callerSignal?.aborted) { return; } - try { - const ownerWindow = requireActiveWindow(this.#document); - const storedTool = this.#tools.get(name); - if ( - !storedTool || - expectedOrigin !== ownerWindow.origin || - !isExposedTo(storedTool, ownerWindow.origin, callerOrigin) - ) { - rejectExecution(); - return; - } + // executeTool() and #respond() checked Permissions Policy, which cannot change while the + // document lives, so only an unloaded document remains to reject here. + const ownerWindow = activeWindow(this.#document); + if (!ownerWindow) { + rejectExecution("AbortError"); + return; + } + const storedTool = this.#tools.get(name); + // The same failure for all three keeps unexposed tools and origins from being probed. + if ( + !storedTool || + expectedOrigin !== ownerWindow.origin || + !isExposedTo(storedTool, ownerWindow.origin, callerOrigin) + ) { + rejectExecution("NotFoundError"); + return; + } - const input: unknown = JSON.parse(serializedInput); - if (!isObject(input)) { - rejectExecution(); - return; - } + let input: unknown; + try { + input = JSON.parse(serializedInput); + } catch { + rejectExecution("DataError"); + return; + } + if (!isObject(input)) { + rejectExecution("DataError"); + return; + } + // A Promise-returning callback that throws is treated as a rejection. + try { // The callback runs without the registration object as its receiver. const execute = storedTool.execute; const callbackResult = execute(input, { signal: callbackController.signal }); - Promise.resolve(callbackResult).then(completeExecution, rejectExecution); + Promise.resolve(callbackResult).then(completeExecution, rejectCallback); } catch { - rejectExecution(); + rejectCallback(); } }; diff --git a/tests/app.test.ts b/tests/app.test.ts index 93a7778..dcbb531 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -23,7 +23,7 @@ test("a served application executes, rejects invalid input, unregisters, and res await page.getByLabel("Amount").fill("-1"); await page.getByRole("button", { name: "Execute on page" }).click(); - await expect(page.locator("#result")).toHaveText("UnknownError"); + await expect(page.locator("#result")).toHaveText("OperationError"); await expect(page.locator("#count")).toHaveText("2"); await page.getByRole("button", { name: "Unregister", exact: true }).click(); diff --git a/tests/execute.test.ts b/tests/execute.test.ts index e614fdf..a980ffd 100644 --- a/tests/execute.test.ts +++ b/tests/execute.test.ts @@ -242,9 +242,9 @@ test("serializes results as JSON and rejects callback or serialization failures" "null", "42", '{"success":true}', - "UnknownError", - "UnknownError", - "UnknownError", + "OperationError", + "OperationError", + "OperationError", ]); expect(pageErrors).toEqual([]); }); @@ -481,7 +481,7 @@ test("execution rejects when the target frame has no tool with that name", async } }); - expect(outcome).toBe("UnknownError"); + expect(outcome).toBe("NotFoundError"); }); test("descriptor origins are parsed, and a mismatch fails like a missing tool", async ({ @@ -516,7 +516,7 @@ test("descriptor origins are parsed, and a mismatch fails like a missing tool", expect(outcome).toEqual([ ["unparseable", "NotSupportedError"], ["opaque", "NotSupportedError"], - ["another origin", "UnknownError"], + ["another origin", "NotFoundError"], ]); }); @@ -570,7 +570,7 @@ test("unregistering before dispatch rejects without running the callback", async return [await pending, executions]; }); - expect(outcome).toEqual(["UnknownError", 0]); + expect(outcome).toEqual(["NotFoundError", 0]); }); test("input that serializes to a non-object rejects before the callback runs", async ({ page }) => { @@ -592,7 +592,7 @@ test("input that serializes to a non-object rejects before the callback runs", a return [name, executions]; }); - expect(outcome).toEqual(["UnknownError", 0]); + expect(outcome).toEqual(["DataError", 0]); }); test("chained calls are not slowed by nested timer clamping", async ({ page }) => { diff --git a/tests/frames.test.ts b/tests/frames.test.ts index af6cf6c..3531ba5 100644 --- a/tests/frames.test.ts +++ b/tests/frames.test.ts @@ -165,7 +165,7 @@ test("cross-origin discovery requires exposure and an explicit origin filter", a filtered: ["local", "public"], ownerWindow: true, remoteResult: '"public"', - rejected: ["UnknownError", "UnknownError"], + rejected: ["NotFoundError", "NotFoundError"], }); await expect(owner.locator("body")).toHaveAttribute("data-private-executions", "0"); }); @@ -216,7 +216,10 @@ test("raw messages cannot bypass a caller frame's tools policy", async ({ page } const replies = await denied.evaluate(sendToolRequests, { origin: localOrigin, name: "exposed" }); - expect(replies).toEqual([{ kind: "error" }, { kind: "error" }]); + expect(replies).toEqual([ + { kind: "error", name: "NotAllowedError" }, + { kind: "error", name: "NotAllowedError" }, + ]); await expect(page.locator("body")).toHaveAttribute("data-executions", "0"); }); @@ -316,8 +319,8 @@ test("an intermediate frame cannot forge permissions denied by an ancestor", asy }); expect(failures).toEqual(["NotAllowedError", "NotAllowedError"]); expect(await child.evaluate(sendToolRequests, { origin: localOrigin, name: "exposed" })).toEqual([ - { kind: "error" }, - { kind: "error" }, + { kind: "error", name: "NotAllowedError" }, + { kind: "error", name: "NotAllowedError" }, ]); expect( Number(await denied.locator("body").getAttribute("data-forged-permissions")), @@ -668,7 +671,7 @@ for (const change of ["navigate", "remove"] as const) { await page.locator('iframe[name="owner"]').evaluate((iframe) => iframe.remove()); } - expect(await execution.evaluate(async ({ result }) => result)).toBe("UnknownError"); + expect(await execution.evaluate(async ({ result }) => result)).toBe("AbortError"); const tools = await page.evaluate(async (origin) => { return (await document.modelContext!.getTools({ fromOrigins: [origin] })).map( (tool) => tool.name, @@ -720,6 +723,82 @@ for (const change of ["navigate", "remove"] as const) { }); } +test("another frame's execution failures keep their exception names", async ({ page }) => { + const owner = await addFrame(page.mainFrame(), "owner", { origin: remoteOrigin }); + await owner.evaluate(async (origin) => { + const circular = {}; + Object.assign(circular, { self: circular }); + await document.modelContext!.registerTool( + { + name: "failing", + description: "Failing", + execute({ value }) { + if (value === "throw") { + throw new Error("callback"); + } + return circular; + }, + }, + { exposedTo: [origin] }, + ); + }, localOrigin); + + const outcome = await page.evaluate(async (origin) => { + const context = document.modelContext!; + const tool = (await context.getTools({ fromOrigins: [origin] }))[0]!; + const results = []; + for (const input of [{ value: "throw" }, { value: "circular" }, { toJSON: () => 5 }]) { + results.push( + await context.executeTool(tool, input).then( + () => "resolved", + (error: Error) => error.name, + ), + ); + } + return results; + }, remoteOrigin); + + expect(outcome).toEqual(["OperationError", "OperationError", "DataError"]); +}); + +for (const [removal, expected] of [ + ["before", "InvalidStateError"], + ["after", "AbortError"], +] as const) { + test(`a target frame removed ${removal} the call rejects with ${expected}`, async ({ page }) => { + const owner = await addFrame(page.mainFrame(), "owner", { origin: remoteOrigin }); + await owner.evaluate(async (origin) => { + await document.modelContext!.registerTool( + { name: "removed", description: "Removed", execute: () => new Promise(() => {}) }, + { exposedTo: [origin] }, + ); + }, localOrigin); + + const outcome = await page.evaluate( + async (settings) => { + const context = document.modelContext!; + const tool = (await context.getTools({ fromOrigins: [settings.origin] }))[0]!; + const iframe = document.querySelector('iframe[name="owner"]')!; + if (settings.removal === "before") { + iframe.remove(); + } + const execution = context.executeTool(tool, {}); + if (settings.removal === "after") { + iframe.remove(); + } + return execution.then( + () => "resolved", + (error: Error) => error.name, + ); + }, + { origin: remoteOrigin, removal }, + ); + + // A detached target fails the call's own check; a later removal unloads the target document. + expect(outcome).toBe(expected); + }); +} + test("excludes another frame tree even when a same-origin window supplies its descriptor", async ({ page, }) => { @@ -751,7 +830,7 @@ test("excludes another frame tree even when a same-origin window supplies its de ), }; }); - expect(result).toEqual({ discovered: 0, execution: "UnknownError" }); + expect(result).toEqual({ discovered: 0, execution: "InvalidStateError" }); await expect(popup.locator("body")).toHaveAttribute("data-executions", "0"); await popup.close(); }); @@ -780,6 +859,30 @@ test("frames without the polyfill delay neither registration nor discovery", asy expect(timings.discovery).toBeLessThan(250); }); +test("executing in a frame without the polyfill rejects with NotFoundError", async ({ page }) => { + await addFrame(page.mainFrame(), "silent", { path: "/" }); + + const outcome = await page.evaluate(() => + document + .modelContext!.executeTool( + { + name: "absent", + title: "", + description: "Absent", + origin: location.origin, + window: frames[0]!, + }, + {}, + ) + .then( + () => "resolved", + (error: Error) => error.name, + ), + ); + + expect(outcome).toBe("NotFoundError"); +}); + test("a frame that registers at startup notifies and discovers its parent", async ({ page, }) => { @@ -1168,7 +1271,7 @@ test("a peer's malformed discovery replies are discarded and reported", async ({ expect(outcome).toEqual({ discoveries: Array.from({ length: replyCount }, () => ["top"]), - execution: "UnknownError", + execution: "OperationError", }); expect(warnings).toEqual( Array.from({ length: replyCount }, () => @@ -1191,7 +1294,7 @@ test("a connection that never sends its request is answered with an error", asyn localOrigin, ); - expect(reply).toEqual({ kind: "error" }); + expect(reply).toEqual({ kind: "error", name: "OperationError" }); }); test("an error that overtakes the ready message rejects the call", async ({ page }) => { @@ -1231,7 +1334,7 @@ test("an error that overtakes the ready message rejects the call", async ({ page ), ); - expect(outcome).toBe("UnknownError"); + expect(outcome).toBe("OperationError"); }); test("cross-origin requests cause no blocked-frame errors", async ({ page }) => { diff --git a/wpt/metadata/webmcp/imperative/executeTool-error-window-onerror.https.html.ini b/wpt/metadata/webmcp/imperative/executeTool-error-window-onerror.https.html.ini new file mode 100644 index 0000000..8663b45 --- /dev/null +++ b/wpt/metadata/webmcp/imperative/executeTool-error-window-onerror.https.html.ini @@ -0,0 +1,7 @@ +# The draft rejects a throwing callback and an unserializable result with OperationError; +# the test expects UnknownError. +[executeTool-error-window-onerror.https.html] + [Failed tool execution does not trigger window.onerror] + expected: FAIL + [Tool execution returning circular object rejects and does not trigger window.onerror] + expected: FAIL diff --git a/wpt/metadata/webmcp/imperative/executeTool-invalid-dictionary.https.html.ini b/wpt/metadata/webmcp/imperative/executeTool-invalid-dictionary.https.html.ini new file mode 100644 index 0000000..4ffa36d --- /dev/null +++ b/wpt/metadata/webmcp/imperative/executeTool-invalid-dictionary.https.html.ini @@ -0,0 +1,5 @@ +# The draft rejects a tool missing from its target with NotFoundError; the test expects +# UnknownError. +[executeTool-invalid-dictionary.https.html] + [executeTool() rejects with UnknownError when a non-existent tool object is supplied] + expected: FAIL diff --git a/wpt/metadata/webmcp/imperative/executeTool-target-detachment.https.html.ini b/wpt/metadata/webmcp/imperative/executeTool-target-detachment.https.html.ini index 307151f..fcf3722 100644 --- a/wpt/metadata/webmcp/imperative/executeTool-target-detachment.https.html.ini +++ b/wpt/metadata/webmcp/imperative/executeTool-target-detachment.https.html.ini @@ -1,8 +1,8 @@ -# The draft rejects a removed target with UnknownError; the first case expects -# InvalidStateError. The last two cases register in /common/blank.html helper frames, -# where WPT injection does not install the polyfill. +# The draft rejects a call whose target document unloads mid-execution with AbortError; the +# second case expects UnknownError. The last two cases register in /common/blank.html helper +# frames, where WPT injection does not install the polyfill. [executeTool-target-detachment.https.html] - [executeTool() rejects when target frame is detached before execution] + [executeTool() rejects when target frame is detached mid-execution] expected: FAIL [executeTool() rejects cleanly when tool callback synchronously detaches its own frame] expected: FAIL diff --git a/wpt/metadata/webmcp/imperative/executeTool-target-navigation.https.html.ini b/wpt/metadata/webmcp/imperative/executeTool-target-navigation.https.html.ini new file mode 100644 index 0000000..32c5cba --- /dev/null +++ b/wpt/metadata/webmcp/imperative/executeTool-target-navigation.https.html.ini @@ -0,0 +1,5 @@ +# The draft rejects a call whose target document unloads with AbortError; the test expects +# UnknownError. +[executeTool-target-navigation.https.html] + [executeTool() rejects when target document navigates away] + expected: FAIL diff --git a/wpt/metadata/webmcp/imperative/executeTool-unauthorized-origin.https.html.ini b/wpt/metadata/webmcp/imperative/executeTool-unauthorized-origin.https.html.ini new file mode 100644 index 0000000..23c3628 --- /dev/null +++ b/wpt/metadata/webmcp/imperative/executeTool-unauthorized-origin.https.html.ini @@ -0,0 +1,5 @@ +# The draft rejects a tool not exposed to the caller with NotFoundError; the test expects +# UnknownError. +[executeTool-unauthorized-origin.https.html] + [executeTool() rejects when parent frame attempts unauthorized execution of cross-origin iframe tool] + expected: FAIL From a116f561666fa8f8d6801c1abd5a394415c7fc6b Mon Sep 17 00:00:00 2001 From: Kasper Kulikowski Date: Sun, 18 Oct 2026 16:10:03 +0000 Subject: [PATCH 2/2] Match Chromium's executeTool() error messages --- README.md | 4 +--- TESTING.md | 18 ++++++++++-------- src/frames.ts | 15 ++++++++------- src/index.ts | 12 +++++++++--- tests/frames.test.ts | 13 ++++++++++--- 5 files changed, 38 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 5c3fe98..e285012 100644 --- a/README.md +++ b/README.md @@ -99,9 +99,7 @@ Native contexts do not join the polyfill's channels. See [TESTING.md](https://gi `executeTool()` accepts an object and returns a JSON-serialized result. Omitted or `undefined` input defaults to a fresh empty object. Callbacks must validate their inputs; schema inference provides TypeScript checks only. -A failed execution rejects with the draft's `DOMException` name: `NotFoundError` when the target has no such tool exposed to the caller, `DataError` when the input does not parse to an object, `OperationError` when the callback throws, rejects, or returns an unserializable result, `AbortError` when the target document unloads during the call, and `InvalidStateError` when the target frame was already removed or is in another frame tree. The callback's own error stays in its frame. - -Where Chromium's native WebMCP is enabled, the polyfill leaves it in place. Chromium still rejects missing tools, invalid input, callback failures, and unloaded targets with `UnknownError` ([crbug.com/509555636](https://crbug.com/509555636)), so treat any name you do not handle as a generic failure. +A failed execution rejects with the draft's `DOMException` name, such as `NotFoundError` or `OperationError`, rather than the callback's own error. Breaking API changes ship with notes: in minor releases while the version is 0.x, in majors after 1.0. diff --git a/TESTING.md b/TESTING.md index f74ed6f..bbfff8a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2,7 +2,7 @@ ## Results -**235 browser tests pass** across Chromium 153.0.8010.12, Firefox 155.0, and +**223 browser tests pass** across Chromium 153.0.8010.12, Firefox 155.0, and Playwright WebKit 26.6. Package checks also pass: imports, types, SSR, and tarball contents. CI runs these checks plus WPT in Chrome, Firefox, and actual Safari. Safari runs on `macos-26`; Playwright WebKit is a separate build. @@ -12,12 +12,12 @@ in Chrome and Firefox: | Subtest result | Count | | --- | ---: | -| PASS | 87 | -| Expected FAIL | 57 | +| PASS | 92 | +| Expected FAIL | 52 | | Expected TIMEOUT | 26 | | Expected NOTRUN | 20 | -Tested with Chrome Canary 155.0.8057.0 and Firefox Nightly 160.0a1 (20261013094244). +Tested with Chrome Canary 157.0.8080.0 and Firefox Nightly 159.0a1 (20260930214513). Safari has not yet run at this pin; none of the expectations are browser-specific. At the file level: 43 OK, 26 expected timeouts, one expected error. @@ -75,10 +75,12 @@ and `webmcp-types@0.1.9`. - **Draft differences:** results are JSON-serialized; some pinned tests expect raw strings. Omitted or `undefined` input becomes `{}`; `null` and primitives reject. - **Execution errors:** failures reject with the draft's `NotFoundError`, `DataError`, - `OperationError`, `AbortError`, or `InvalidStateError`. Pinned WPT and Chromium still - expect `UnknownError` ([crbug.com/509555636](https://crbug.com/509555636)), so six - subtests are expected failures. Failures of the polyfill's own frame messages, such as a - malformed reply, have no draft counterpart and reject with `OperationError`. + `OperationError`, `AbortError`, or `InvalidStateError`, with Chromium's messages. + Chromium 158.0.8109.0 and later use the same names + ([crbug.com/509555636](https://crbug.com/509555636)). Six pinned subtests still expect + `UnknownError` and are expected failures; upstream WPT updated or removed them by + `22b4a4d`. Failures of the polyfill's own frame messages, such as a malformed reply, + have no draft counterpart and reject with `OperationError`. - **Timing:** MessagePorts approximate native task ordering. Aborting before dispatch skips the callback; the draft dispatches and then aborts its signal. Delegated permission checks are asynchronous, so argument errors can precede diff --git a/src/frames.ts b/src/frames.ts index 5ec3790..2aa29f5 100644 --- a/src/frames.ts +++ b/src/frames.ts @@ -175,7 +175,7 @@ export class FrameBridge { return new Promise((resolve, reject) => { signal?.throwIfAborted(); if (this.#hidden) { - reject(new NativeDOMException("The document is not fully active", "InvalidStateError")); + reject(new NativeDOMException("The document is not active.", "InvalidStateError")); return; } // executeTool() rejects a target that is already closed, so one closed since has unloaded. @@ -791,13 +791,14 @@ function isWindow(value: MessageEventSource): value is Window { // https://webmachinelearning.github.io/webmcp/#tool-execution-error // InvalidStateError and NotAllowedError come from other executeTool() steps. Protocol failures // without a draft counterpart, such as a malformed reply, are reported as OperationError. +// The messages are Chromium's: https://crrev.com/c/8546218 const executionFailures = { - NotFoundError: "The tool is not registered in the target document", - DataError: "The tool input could not be parsed", - OperationError: "The tool failed or returned an unserializable result", - AbortError: "The target document was unloaded", - InvalidStateError: "The target document is detached or in another frame tree", - NotAllowedError: "WebMCP is disabled by Permissions Policy", + NotFoundError: "The requested tool was not found or is not accessible.", + DataError: "Failed to parse tool input arguments.", + OperationError: "Tool execution failed.", + AbortError: "Tool execution aborted.", + InvalidStateError: "Target frame is not accessible.", + NotAllowedError: 'Access to the feature "tools" is disallowed by permissions policy.', } as const; export type ExecutionFailure = keyof typeof executionFailures; diff --git a/src/index.ts b/src/index.ts index 048f494..e933ed0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -389,7 +389,10 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { async #requireFrames(): Promise { const frames = this.#frames; if (!frames || !(await frames.allowed())) { - throw new NativeDOMException("WebMCP is disabled by Permissions Policy", "NotAllowedError"); + throw new NativeDOMException( + 'Access to the feature "tools" is disallowed by permissions policy.', + "NotAllowedError", + ); } requireActiveWindow(this.#document); return frames; @@ -625,12 +628,15 @@ function isPotentiallyTrustworthy({ protocol, hostname }: URL): boolean { function requireActiveWindow(owner: Document): Window { const view = activeWindow(owner); if (!view) { - throw new NativeDOMException("The document is not fully active", "InvalidStateError"); + throw new NativeDOMException("The document is not active.", "InvalidStateError"); } // Synchronous where the browser exposes the policy; FrameBridge.allowed() covers the rest. if (readToolsPolicy(owner) === false) { - throw new NativeDOMException("WebMCP is disabled by Permissions Policy", "NotAllowedError"); + throw new NativeDOMException( + 'Access to the feature "tools" is disallowed by permissions policy.', + "NotAllowedError", + ); } return view; } diff --git a/tests/frames.test.ts b/tests/frames.test.ts index 3531ba5..95186c8 100644 --- a/tests/frames.test.ts +++ b/tests/frames.test.ts @@ -723,7 +723,9 @@ for (const change of ["navigate", "remove"] as const) { }); } -test("another frame's execution failures keep their exception names", async ({ page }) => { +test("another frame's execution failures keep their names with fixed messages", async ({ + page, +}) => { const owner = await addFrame(page.mainFrame(), "owner", { origin: remoteOrigin }); await owner.evaluate(async (origin) => { const circular = {}; @@ -751,14 +753,19 @@ test("another frame's execution failures keep their exception names", async ({ p results.push( await context.executeTool(tool, input).then( () => "resolved", - (error: Error) => error.name, + (error: Error) => `${error.name}: ${error.message}`, ), ); } return results; }, remoteOrigin); - expect(outcome).toEqual(["OperationError", "OperationError", "DataError"]); + // Each name has one message, so the callback's own message stays in its frame. + expect(outcome).toEqual([ + "OperationError: Tool execution failed.", + "OperationError: Tool execution failed.", + "DataError: Failed to parse tool input arguments.", + ]); }); for (const [removal, expected] of [