| 1 | import { statSync } from "node:fs"; |
| 2 | import { isAbsolute } from "node:path"; |
| 3 | import type { DocumentBinding, DocumentRegistry, FrameBinding } from "./documents.js"; |
| 4 | import { noGrant, staleReference, takenOver } from "./errors.js"; |
| 5 | import type { GuestFrame, GuestPage } from "./guestView.js"; |
| 6 | import { chordEvents, parseKeySequence } from "./keys.js"; |
| 7 | import { FOCUS_SCRIPT_SOURCE, IDENTITY_SCRIPT_SOURCE, scriptCall, SELECT_SCRIPT_SOURCE, type ResolvedElement, type SelectOutput } from "./pageScripts.js"; |
| 8 | import { frameForRef, locateRef, resolveRef } from "./refResolver.js"; |
| 9 | import { ISOLATED_WORLD, REGISTRY_KEY, runInFrame } from "./snapshot.js"; |
| 10 | import type { BrowserSurfaceManager, BrowserTab } from "./surfaceManager.js"; |
| 11 | import { uploadFiles } from "./upload.js"; |
| 12 | |
| 13 | export const ACT_SETTLE_MS = 150; |
| 14 | |
| 15 | export interface ActRequest { |
| 16 | operationId: string; |
| 17 | tabId: string; |
| 18 | documentToken: string; |
| 19 | action: string; |
| 20 | ref: string; |
| 21 | text: string; |
| 22 | keys: string; |
| 23 | options: string[]; |
| 24 | files: string[]; |
| 25 | submit: boolean; |
| 26 | deltaX: number; |
| 27 | deltaY: number; |
| 28 | } |
| 29 | |
| 30 | export interface ActResult { |
| 31 | executed: boolean; |
| 32 | outcome?: "unknown"; |
| 33 | reason?: string; |
| 34 | documentToken?: string; |
| 35 | } |
| 36 | |
| 37 | export interface ActionDeps { |
| 38 | surfaces: BrowserSurfaceManager; |
| 39 | documents: DocumentRegistry; |
| 40 | settleMs?: number; |
| 41 | sleep?(ms: number): Promise<void>; |
| 42 | fileExists?(path: string): boolean; |
| 43 | } |
| 44 | |
| 45 | interface Point { |
| 46 | x: number; |
| 47 | y: number; |
| 48 | } |
| 49 | |
| 50 | const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)); |
| 51 | const defaultFileExists = (path: string) => { |
| 52 | try { |
| 53 | return statSync(path).isFile(); |
| 54 | } catch { |
| 55 | return false; |
| 56 | } |
| 57 | }; |
| 58 | |
| 59 | export class ActionExecutor { |
| 60 | private readonly sleep: (ms: number) => Promise<void>; |
| 61 | private readonly fileExists: (path: string) => boolean; |
| 62 | |
| 63 | constructor(private readonly deps: ActionDeps) { |
| 64 | this.sleep = deps.sleep ?? defaultSleep; |
| 65 | this.fileExists = deps.fileExists ?? defaultFileExists; |
| 66 | } |
| 67 | |
| 68 | // verify re-checks the grant at every checkpoint, so a revoke or take-over |
| 69 | // that lands while the action is still resolving cancels it before any |
| 70 | // input is dispatched; once dispatched, the receipt is honest about it. |
| 71 | async act(tab: BrowserTab, request: ActRequest, verify: () => void): Promise<ActResult> { |
| 72 | const binding = this.deps.documents.lookup(request.documentToken); |
| 73 | if (!binding || binding.tabId !== tab.id) throw staleReference("documentToken is not current for this tab"); |
| 74 | this.checkpoint(tab, binding, verify); |
| 75 | const page = tab.view.page; |
| 76 | let outcome: ActResult; |
| 77 | let dispatched = false; |
| 78 | const dispatch = () => { dispatched = true; }; |
| 79 | try { |
| 80 | switch (request.action) { |
| 81 | case "click": |
| 82 | outcome = await this.click(tab, binding, request, verify, dispatch); |
| 83 | break; |
| 84 | case "type": |
| 85 | outcome = await this.type(tab, binding, request, verify, dispatch); |
| 86 | break; |
| 87 | case "press": |
| 88 | outcome = await this.press(tab, binding, request, verify, dispatch); |
| 89 | break; |
| 90 | case "scroll": |
| 91 | outcome = await this.scroll(tab, binding, request, verify, dispatch); |
| 92 | break; |
| 93 | case "select": |
| 94 | outcome = await this.select(tab, binding, request, verify, dispatch, () => { dispatched = false; }); |
| 95 | break; |
| 96 | case "upload": |
| 97 | outcome = await this.upload(tab, binding, request, verify, dispatch); |
| 98 | break; |
| 99 | default: |
| 100 | return { executed: false, reason: `unsupported action ${JSON.stringify(request.action)}`, documentToken: request.documentToken }; |
| 101 | } |
| 102 | } catch (error) { |
| 103 | // A checkpoint failure after any dispatch cannot prove zero effects. |
| 104 | // Preserve uncertainty so the durable ledger never permits a replay. |
| 105 | if (dispatched) return { executed: false, outcome: "unknown", reason: String(error) }; |
| 106 | throw error; |
| 107 | } |
| 108 | if (!outcome.executed) return { ...outcome, documentToken: request.documentToken }; |
| 109 | await this.sleep(this.deps.settleMs ?? ACT_SETTLE_MS); |
| 110 | if (tab.epoch !== binding.epoch || page.isDestroyed()) return { executed: true }; |
| 111 | let same = false; |
| 112 | try { |
| 113 | same = (await page.executeJavaScriptInIsolatedWorld(ISOLATED_WORLD, [{ code: scriptCall(IDENTITY_SCRIPT_SOURCE, { key: REGISTRY_KEY, docId: binding.frames[0]?.docId ?? "" }) }])) === true; |
| 114 | } catch { |
| 115 | same = false; |
| 116 | } |
| 117 | if (!same) return { executed: true }; |
| 118 | const token = this.deps.documents.rotate(request.documentToken); |
| 119 | return token ? { executed: true, documentToken: token } : { executed: true }; |
| 120 | } |
| 121 | |
| 122 | private checkpoint(tab: BrowserTab, binding: DocumentBinding, verify: () => void): void { |
| 123 | verify(); |
| 124 | if (this.deps.surfaces.get(tab.id) !== tab) throw noGrant(`browser tab ${tab.id} is closed`); |
| 125 | if (tab.mode !== "agent") throw takenOver(`tab ${tab.id} is in human mode`); |
| 126 | if (tab.epoch !== binding.epoch) throw staleReference(`tab ${tab.id} changed since the snapshot (epoch ${binding.epoch} → ${tab.epoch})`); |
| 127 | if (tab.view.page.isDestroyed()) throw staleReference(`tab ${tab.id} has no page`); |
| 128 | } |
| 129 | |
| 130 | private async target(tab: BrowserTab, binding: DocumentBinding, ref: string, verify: () => void): Promise<{ ok: true; element: ResolvedElement; centre: Point; frame: GuestFrame; binding: FrameBinding } | { ok: false; reason: string }> { |
| 131 | if (ref === "") return { ok: false, reason: "this action needs a ref" }; |
| 132 | const resolved = await resolveRef(tab.view.page, binding, ref, true); |
| 133 | if (!resolved.ok) return resolved; |
| 134 | this.checkpoint(tab, binding, verify); |
| 135 | const zoom = tab.view.page.getZoomFactor() || 1; |
| 136 | const { element } = resolved.value; |
| 137 | const centre = { x: Math.round((element.x + element.width / 2) * zoom), y: Math.round((element.y + element.height / 2) * zoom) }; |
| 138 | return { ok: true, element, centre, frame: resolved.value.frame, binding: resolved.value.binding }; |
| 139 | } |
| 140 | |
| 141 | private mouseClick(tab: BrowserTab, at: Point): void { |
| 142 | const page = tab.view.page; |
| 143 | this.deps.surfaces.markAgentInput(tab); |
| 144 | page.sendInputEvent({ type: "mouseMove", x: at.x, y: at.y }); |
| 145 | page.sendInputEvent({ type: "mouseDown", x: at.x, y: at.y, button: "left", clickCount: 1 }); |
| 146 | page.sendInputEvent({ type: "mouseUp", x: at.x, y: at.y, button: "left", clickCount: 1 }); |
| 147 | } |
| 148 | |
| 149 | private sendKeys(tab: BrowserTab, keys: string): string | null { |
| 150 | let chords; |
| 151 | try { |
| 152 | chords = parseKeySequence(keys); |
| 153 | } catch (error) { |
| 154 | return error instanceof Error ? error.message : String(error); |
| 155 | } |
| 156 | if (chords.length === 0) return "no keys given"; |
| 157 | this.deps.surfaces.markAgentInput(tab); |
| 158 | for (const chord of chords) { |
| 159 | for (const event of chordEvents(chord)) tab.view.page.sendInputEvent(event); |
| 160 | } |
| 161 | return null; |
| 162 | } |
| 163 | |
| 164 | private async click(tab: BrowserTab, binding: DocumentBinding, request: ActRequest, verify: () => void, dispatch: () => void): Promise<ActResult> { |
| 165 | const target = await this.target(tab, binding, request.ref, verify); |
| 166 | if (!target.ok) return { executed: false, reason: target.reason }; |
| 167 | if (target.element.disabled) return { executed: false, reason: "element is disabled" }; |
| 168 | if (target.element.tag === "option") return { executed: false, reason: "use the select action for <option> elements" }; |
| 169 | dispatch(); |
| 170 | this.mouseClick(tab, target.centre); |
| 171 | return { executed: true }; |
| 172 | } |
| 173 | |
| 174 | private async type(tab: BrowserTab, binding: DocumentBinding, request: ActRequest, verify: () => void, dispatch: () => void): Promise<ActResult> { |
| 175 | const target = await this.target(tab, binding, request.ref, verify); |
| 176 | if (!target.ok) return { executed: false, reason: target.reason }; |
| 177 | if (target.element.disabled) return { executed: false, reason: "element is disabled" }; |
| 178 | if (!target.element.editable) return { executed: false, reason: "element is not editable" }; |
| 179 | dispatch(); |
| 180 | this.mouseClick(tab, target.centre); |
| 181 | await this.sleep(30); |
| 182 | this.checkpoint(tab, binding, verify); |
| 183 | this.deps.surfaces.markAgentInput(tab); |
| 184 | if (request.text !== "") await tab.view.page.insertText(request.text); |
| 185 | if (request.submit) { |
| 186 | this.checkpoint(tab, binding, verify); |
| 187 | const failure = this.sendKeys(tab, "Enter"); |
| 188 | if (failure) return { executed: false, reason: failure }; |
| 189 | } |
| 190 | return { executed: true }; |
| 191 | } |
| 192 | |
| 193 | private async press(tab: BrowserTab, binding: DocumentBinding, request: ActRequest, verify: () => void, dispatch: () => void): Promise<ActResult> { |
| 194 | if (request.keys.trim() === "") return { executed: false, reason: "press needs keys" }; |
| 195 | // Validate before focusing: focusing itself dispatches a physical click. |
| 196 | try { parseKeySequence(request.keys); } catch (error) { return { executed: false, reason: String(error) }; } |
| 197 | if (request.ref !== "") { |
| 198 | const target = await this.target(tab, binding, request.ref, verify); |
| 199 | if (!target.ok) return { executed: false, reason: target.reason }; |
| 200 | if (target.element.editable) { |
| 201 | dispatch(); |
| 202 | this.mouseClick(tab, target.centre); |
| 203 | await this.sleep(30); |
| 204 | this.checkpoint(tab, binding, verify); |
| 205 | } else { |
| 206 | this.checkpoint(tab, binding, verify); |
| 207 | const focused = await runInFrame(tab.view.page, target.frame, scriptCall(FOCUS_SCRIPT_SOURCE, { |
| 208 | key: REGISTRY_KEY, snapshotId: binding.snapshotId, docId: target.binding.docId, ref: request.ref, |
| 209 | })); |
| 210 | if (focused !== true) return { executed: false, reason: "element could not be focused" }; |
| 211 | this.checkpoint(tab, binding, verify); |
| 212 | } |
| 213 | } |
| 214 | dispatch(); |
| 215 | const failure = this.sendKeys(tab, request.keys); |
| 216 | return failure ? { executed: false, reason: failure } : { executed: true }; |
| 217 | } |
| 218 | |
| 219 | // Blink negates WebMouseWheelEvent deltas when it builds the DOM WheelEvent, |
| 220 | // so the request keeps DOM semantics (positive deltaY scrolls down) and the |
| 221 | // sign flips here. |
| 222 | private async scroll(tab: BrowserTab, binding: DocumentBinding, request: ActRequest, verify: () => void, dispatch: () => void): Promise<ActResult> { |
| 223 | let at: Point; |
| 224 | if (request.ref !== "") { |
| 225 | const target = await this.target(tab, binding, request.ref, verify); |
| 226 | if (!target.ok) return { executed: false, reason: target.reason }; |
| 227 | at = target.centre; |
| 228 | } else { |
| 229 | const size = await this.viewport(tab); |
| 230 | at = { x: Math.round(size.width / 2), y: Math.round(size.height / 2) }; |
| 231 | this.checkpoint(tab, binding, verify); |
| 232 | } |
| 233 | if (!Number.isFinite(request.deltaX) || !Number.isFinite(request.deltaY)) return { executed: false, reason: "scroll deltas must be numbers" }; |
| 234 | if (request.deltaX === 0 && request.deltaY === 0) return { executed: false, reason: "scroll deltas are both zero" }; |
| 235 | this.deps.surfaces.markAgentInput(tab); |
| 236 | dispatch(); |
| 237 | tab.view.page.sendInputEvent({ type: "mouseWheel", x: at.x, y: at.y, deltaX: -request.deltaX, deltaY: -request.deltaY, canScroll: true }); |
| 238 | return { executed: true }; |
| 239 | } |
| 240 | |
| 241 | private async viewport(tab: BrowserTab): Promise<{ width: number; height: number }> { |
| 242 | const zoom = tab.view.page.getZoomFactor() || 1; |
| 243 | const raw = await tab.view.page.executeJavaScriptInIsolatedWorld(ISOLATED_WORLD, [{ code: "({ width: window.innerWidth, height: window.innerHeight })" }]); |
| 244 | const size = typeof raw === "object" && raw !== null ? (raw as { width?: unknown; height?: unknown }) : {}; |
| 245 | return { width: (typeof size.width === "number" ? size.width : 0) * zoom, height: (typeof size.height === "number" ? size.height : 0) * zoom }; |
| 246 | } |
| 247 | |
| 248 | private async select(tab: BrowserTab, binding: DocumentBinding, request: ActRequest, verify: () => void, dispatch: () => void, noEffects: () => void): Promise<ActResult> { |
| 249 | if (request.ref === "") return { executed: false, reason: "select needs a ref" }; |
| 250 | const page: GuestPage = tab.view.page; |
| 251 | const target = frameForRef(page, binding, request.ref); |
| 252 | const code = scriptCall(SELECT_SCRIPT_SOURCE, { key: REGISTRY_KEY, snapshotId: binding.snapshotId, docId: target.binding.docId, ref: request.ref, options: request.options }); |
| 253 | let raw: unknown; |
| 254 | this.checkpoint(tab, binding, verify); |
| 255 | dispatch(); |
| 256 | try { |
| 257 | raw = await runInFrame(page, target.frame, code); |
| 258 | } catch (error) { |
| 259 | throw staleReference(`frame of ${request.ref} cannot run scripts: ${String(error)}`); |
| 260 | } |
| 261 | const out = raw as SelectOutput | null; |
| 262 | if (!out || typeof out.ok !== "boolean") throw staleReference("select script returned nothing"); |
| 263 | if (!out.ok) { |
| 264 | noEffects(); |
| 265 | if (out.reason === "stale") throw staleReference(`${request.ref} predates the current document`); |
| 266 | return { executed: false, reason: out.reason }; |
| 267 | } |
| 268 | return { executed: true }; |
| 269 | } |
| 270 | |
| 271 | private async upload(tab: BrowserTab, binding: DocumentBinding, request: ActRequest, verify: () => void, dispatch: () => void): Promise<ActResult> { |
| 272 | if (request.files.length === 0) return { executed: false, reason: "upload needs files" }; |
| 273 | for (const file of request.files) { |
| 274 | if (!isAbsolute(file)) return { executed: false, reason: `file path must be absolute: ${file}` }; |
| 275 | if (!this.fileExists(file)) return { executed: false, reason: `file not found: ${file}` }; |
| 276 | } |
| 277 | if (request.ref === "") return { executed: false, reason: "upload needs a ref" }; |
| 278 | const located = await locateRef(tab.view.page, binding, request.ref); |
| 279 | if (!located.ok) return { executed: false, reason: located.reason }; |
| 280 | this.checkpoint(tab, binding, verify); |
| 281 | return uploadFiles(tab.view.page, located.value, request.files, () => this.checkpoint(tab, binding, verify), dispatch); |
| 282 | } |
| 283 | } |
| 284 |