| 1 | import assert from "node:assert/strict"; |
| 2 | import type { IpcMain } from "electron"; |
| 3 | import { test } from "node:test"; |
| 4 | import { IPC, type IpcResult } from "../shared/ipc.js"; |
| 5 | import { ActionExecutor, type ActRequest } from "./browser/actions.js"; |
| 6 | import { DocumentRegistry } from "./browser/documents.js"; |
| 7 | import { BROWSER_ERR_STALE_REFERENCE, BROWSER_ERR_TAKEN_OVER } from "./browser/errors.js"; |
| 8 | import { FakeGuestView, FakeViewFactory, silentLog } from "./browser/fakeGuestViews.js"; |
| 9 | import { BrowserSurfaceManager } from "./browser/surfaceManager.js"; |
| 10 | import { parseContract } from "./contract.js"; |
| 11 | import { registerRendererIpc } from "./ipc.js"; |
| 12 | import { RpcError } from "./rpc.js"; |
| 13 | |
| 14 | test("trusted toolbar takeover bypasses input suppression immediately, fences actions and permits Resume", async () => { |
| 15 | type Handler = (event: unknown, ...args: unknown[]) => Promise<IpcResult>; |
| 16 | const handlers = new Map<string, Handler>(); |
| 17 | const ipcMain = { handle: (channel: string, run: Handler) => handlers.set(channel, run), on() {} } as unknown as IpcMain; |
| 18 | const trustedSender = { id: 1 }, trustedFrame = {}; |
| 19 | const trusted = { sender: trustedSender, senderFrame: trustedFrame }; |
| 20 | const reports: string[] = []; |
| 21 | const manager = new BrowserSurfaceManager({ views: new FakeViewFactory(), contentSize: () => null, |
| 22 | onTakeover: (_tab, reason) => reports.push(reason), onCrash() {}, log: silentLog, now: () => 1000, openWaitMs: 5 }); |
| 23 | const tab = await manager.open("https://example.com", { taskId: "task", temporary: false }); |
| 24 | const page = (tab.view as FakeGuestView).page; |
| 25 | const documents = new DocumentRegistry(() => "token"); |
| 26 | const documentToken = documents.issue({ tabId: tab.id, epoch: tab.epoch, snapshotId: "snapshot", |
| 27 | frames: [{ prefix: "", frameTreeNodeId: page.mainFrame.frameTreeNodeId, docId: "doc" }] }); |
| 28 | const actions = new ActionExecutor({ surfaces: manager, documents, fileExists: () => false, sleep: async () => {} }); |
| 29 | const request: ActRequest = { operationId: "op", tabId: tab.id, documentToken, action: "click", ref: "e1", |
| 30 | text: "", keys: "", options: [], files: [], submit: false, deltaX: 0, deltaY: 0 }; |
| 31 | registerRendererIpc({ ipcMain, contract: parseContract({ digest: "test", commands: ["ListTabs"] }), |
| 32 | window: { isTrustedSender: (sender, frame) => sender === trustedSender && frame === trustedFrame, |
| 33 | minimise() {}, toggleMaximise() {}, isMaximised: () => false, close() {}, |
| 34 | bounds: () => ({ x: 0, y: 0, width: 1, height: 1, maximised: false }), setTheme() {}, setBackgroundColour() {}, |
| 35 | getAppZoom: async () => 1, setAppZoom: async () => 1, resetAppZoom: async () => 1 }, |
| 36 | invoke: async () => undefined, serviceState: () => ({ phase: "ready", generation: "g" }), |
| 37 | clipboard: { writeText() {}, readText: () => "" }, openExternal: async () => {}, log: silentLog, |
| 38 | browser: { list: () => manager.list(), open: async (url, options) => { const opened = await manager.open(url, options); return manager.list().find((entry) => entry.id === opened.id)!; }, |
| 39 | close: (id) => manager.close(id), activate: (id) => manager.activate(id), navigate: async (id, target) => { await manager.navigate(id, target); }, |
| 40 | setZoom: (id, factor) => manager.setZoom(id, factor), toggleDevTools: (id) => manager.toggleDevTools(id), |
| 41 | takeover: (id) => manager.takeover(id, "user takeover"), resume: (id) => manager.resume(id), |
| 42 | setLayout: (rect) => manager.setLayout(rect), setOverlay: (active) => manager.setOverlay(active) }, |
| 43 | }); |
| 44 | const takeover = handlers.get(IPC.browserUserTakeover)!; |
| 45 | assert.equal(handlers.has(IPC.browserTakeover), false, "guest input reports use a separate channel"); |
| 46 | const epoch = tab.epoch; |
| 47 | manager.markAgentInput(tab); |
| 48 | assert.equal(manager.takeoverFromSender(page.id, "mousedown"), false, "physical detection is suppressed during the 750ms grace window"); |
| 49 | assert.deepEqual(await takeover({ sender: { id: page.id }, senderFrame: {} }, tab.id), { ok: false, message: "untrusted sender" }); |
| 50 | assert.deepEqual(await takeover({ sender: trustedSender, senderFrame: {} }, tab.id), { ok: false, message: "untrusted sender" }); |
| 51 | assert.equal((await takeover(trusted, "")).ok, false); |
| 52 | assert.equal(tab.epoch, epoch, "untrusted and malformed calls cannot alter ownership"); |
| 53 | const pending = takeover(trusted, tab.id); |
| 54 | assert.equal(tab.mode, "human", "explicit takeover is synchronous even inside the suppression window"); |
| 55 | assert.equal(tab.epoch, epoch + 1); |
| 56 | assert.deepEqual(await pending, { ok: true, value: undefined }); |
| 57 | assert.deepEqual(reports, ["user takeover"]); |
| 58 | await assert.rejects(actions.act(tab, request, () => {}), (error: unknown) => error instanceof RpcError && error.code === BROWSER_ERR_TAKEN_OVER); |
| 59 | assert.deepEqual(await handlers.get(IPC.browserResume)!(trusted, tab.id), { ok: true, value: undefined }); |
| 60 | assert.equal(tab.mode, "agent"); |
| 61 | assert.equal(tab.epoch, epoch + 2); |
| 62 | await assert.rejects(actions.act(tab, request, () => {}), (error: unknown) => error instanceof RpcError && error.code === BROWSER_ERR_STALE_REFERENCE); |
| 63 | assert.equal(page.inputs.length, 0, "takeover and Resume never replay a stale write"); |
| 64 | manager.destroyAll(); |
| 65 | }); |
| 66 |