diff --git a/CHANGELOG.md b/CHANGELOG.md index 7415e69..f5d469b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ Cutting that release is tracked in ### Added +- Chromium `click`, `fill`, and `press` now dispatch trusted mouse, keyboard, + and text input through CDP after isolated-world semantic target and link + safety validation; capabilities declare the WebKit synthetic-input boundary. - Tagged macOS releases now ship a universal Apple Silicon/Intel app through a checksum-pinned Homebrew cask, with Developer ID signing, hardened runtime, notarization, stapling, and Gatekeeper validation enforced by release CI. diff --git a/apps/headless/LinuxHost/BrowserProcess.swift b/apps/headless/LinuxHost/BrowserProcess.swift index 0f79707..fe06487 100644 --- a/apps/headless/LinuxHost/BrowserProcess.swift +++ b/apps/headless/LinuxHost/BrowserProcess.swift @@ -248,6 +248,89 @@ final class ChromiumProcess { } } +private struct ChromiumInputTarget { + let reference: String + let role: String + let name: String + let x: Double + let y: Double + + init(_ value: JSONValue) throws { + guard case .object(let object) = value, + let reference = object["ref"]?.stringValue, + let role = object["role"]?.stringValue, + let name = object["name"]?.stringValue, + let x = object["x"]?.numberValue, + let y = object["y"]?.numberValue, + x.isFinite, y.isFinite, + x >= 0, y >= 0, + x <= ProtocolBounds.screenshotDimension, + y <= ProtocolBounds.screenshotDimension else { + throw CDPError.invalidResponse("trusted input target") + } + self.reference = reference + self.role = role + self.name = name + self.x = x + self.y = y + } +} + +private struct ChromiumKey { + let key: String + let code: String + let virtualKeyCode: Int + let text: String? + let modifiers: Int + + static func resolve(_ input: String) -> ChromiumKey { + let named: [String: (key: String, code: String, virtualKeyCode: Int, text: String?)] = [ + "Enter": ("Enter", "Enter", 13, "\r"), + "Return": ("Enter", "Enter", 13, "\r"), + "Tab": ("Tab", "Tab", 9, nil), + "Escape": ("Escape", "Escape", 27, nil), + "Esc": ("Escape", "Escape", 27, nil), + "Backspace": ("Backspace", "Backspace", 8, nil), + "Delete": ("Delete", "Delete", 46, nil), + "ArrowLeft": ("ArrowLeft", "ArrowLeft", 37, nil), + "ArrowUp": ("ArrowUp", "ArrowUp", 38, nil), + "ArrowRight": ("ArrowRight", "ArrowRight", 39, nil), + "ArrowDown": ("ArrowDown", "ArrowDown", 40, nil), + "Home": ("Home", "Home", 36, nil), + "End": ("End", "End", 35, nil), + "PageUp": ("PageUp", "PageUp", 33, nil), + "PageDown": ("PageDown", "PageDown", 34, nil), + "Space": (" ", "Space", 32, " "), + " ": (" ", "Space", 32, " "), + ] + if let match = named[input] { + return ChromiumKey( + key: match.key, code: match.code, virtualKeyCode: match.virtualKeyCode, + text: match.text, modifiers: 0 + ) + } + if input.count == 1, let scalar = input.unicodeScalars.first { + let value = Int(scalar.value) + if scalar.isASCII, CharacterSet.letters.contains(scalar) { + let upper = input.uppercased() + return ChromiumKey( + key: input, code: "Key\(upper)", + virtualKeyCode: Int(upper.unicodeScalars.first?.value ?? scalar.value), + text: input, modifiers: input == upper ? 8 : 0 + ) + } + if scalar.isASCII, CharacterSet.decimalDigits.contains(scalar) { + return ChromiumKey( + key: input, code: "Digit\(input)", virtualKeyCode: value, + text: input, modifiers: 0 + ) + } + return ChromiumKey(key: input, code: "", virtualKeyCode: value, text: input, modifiers: 0) + } + return ChromiumKey(key: input, code: input, virtualKeyCode: 0, text: nil, modifiers: 0) + } +} + final class LinuxBrowserSession: @unchecked Sendable { private struct NetworkMock: Sendable { let url: String @@ -338,28 +421,83 @@ final class LinuxBrowserSession: @unchecked Sendable { ) } - func click(parameters: [String: JSONValue]) throws -> JSONValue { + private func trustedInputTarget( + parameters: [String: JSONValue], action: String + ) throws -> ChromiumInputTarget { let args = try browserTargetArguments(parameters) - let result = try evaluate("return globalThis.__headlessAgent.click(args);", input: ["args": args]) + return try ChromiumInputTarget(evaluate( + "return globalThis.__headlessAgent.inputTarget(args, '\(action)');", + input: ["args": args] + )) + } + + private func dispatchKey( + _ key: ChromiumKey, modifiers overrideModifiers: Int? = nil, includeText: Bool = true + ) throws { + let modifiers = overrideModifiers ?? key.modifiers + var parameters: [String: Any] = [ + "type": includeText && key.text != nil ? "keyDown" : "rawKeyDown", + "key": key.key, + "code": key.code, + "windowsVirtualKeyCode": key.virtualKeyCode, + "nativeVirtualKeyCode": key.virtualKeyCode, + "modifiers": modifiers, + ] + if includeText, let text = key.text { + parameters["text"] = text + parameters["unmodifiedText"] = text + } + _ = try command("Input.dispatchKeyEvent", parameters: parameters) + parameters["type"] = "keyUp" + parameters.removeValue(forKey: "text") + parameters.removeValue(forKey: "unmodifiedText") + _ = try command("Input.dispatchKeyEvent", parameters: parameters) + } + + func click(parameters: [String: JSONValue]) throws -> JSONValue { + let target = try trustedInputTarget(parameters: parameters, action: "click") + _ = try command("Input.dispatchMouseEvent", parameters: [ + "type": "mouseMoved", "x": target.x, "y": target.y, + ]) + _ = try command("Input.dispatchMouseEvent", parameters: [ + "type": "mousePressed", "x": target.x, "y": target.y, + "button": "left", "buttons": 1, "clickCount": 1, + ]) + _ = try command("Input.dispatchMouseEvent", parameters: [ + "type": "mouseReleased", "x": target.x, "y": target.y, + "button": "left", "buttons": 0, "clickCount": 1, + ]) // A click can synchronously begin a cross-document navigation. Let the // foreground wait command observe that transition before frame capture // resumes, instead of asking Chromium to composite a disappearing page. pauseRecordingCapture() - return result + return .object([ + "clicked": .string(target.reference), + "role": .string(target.role), + "name": .string(target.name), + ]) } func fill(parameters: [String: JSONValue]) throws -> JSONValue { - var args = try browserTargetArguments(parameters) guard let value = parameters["value"]?.stringValue else { throw CDPError.commandFailed("missing value") } - args["value"] = value - return try evaluate("return globalThis.__headlessAgent.fill(args);", input: ["args": args]) + let target = try trustedInputTarget(parameters: parameters, action: "fill") + try dispatchKey(ChromiumKey.resolve("a"), modifiers: 2, includeText: false) + try dispatchKey(ChromiumKey.resolve("Backspace")) + if !value.isEmpty { + _ = try command("Input.insertText", parameters: ["text": value]) + } + return .object([ + "filled": .string(target.reference), + "valueLength": .number(Double(value.utf16.count)), + ]) } func press(parameters: [String: JSONValue]) throws -> JSONValue { guard let key = parameters["key"]?.stringValue, !key.isEmpty, key.count <= 32 else { throw HostError(code: .operationFailed, message: "Missing command parameter: key") } - return try evaluate("return globalThis.__headlessAgent.press(key);", input: ["key": key]) + try dispatchKey(ChromiumKey.resolve(key)) + return .object(["pressed": .string(key)]) } func scroll(parameters: [String: JSONValue]) throws -> JSONValue { diff --git a/apps/headless/Sources/HeadlessProtocol/Capabilities.swift b/apps/headless/Sources/HeadlessProtocol/Capabilities.swift index ada309e..9198117 100644 --- a/apps/headless/Sources/HeadlessProtocol/Capabilities.swift +++ b/apps/headless/Sources/HeadlessProtocol/Capabilities.swift @@ -23,6 +23,7 @@ public struct BrowserEngineCapabilities: Sendable { public let qaDiagnosticSource: String public let qaDiagnosticSynchronization: String public let screenshotClipboard: Bool + public let inputDispatch: String public var supportedCommands: [CommandName] { CommandName.allCases.filter { !unsupportedCommands.contains($0) } @@ -64,7 +65,7 @@ public struct BrowserEngineCapabilities: Sendable { ), "screenshotClipboard": .bool(screenshotClipboard), "tourTimeoutMs": .number(65_000), - "inputDispatch": .string("synthetic-dom"), + "inputDispatch": .string(inputDispatch), ]), ]) } @@ -83,7 +84,8 @@ public struct BrowserEngineCapabilities: Sendable { recordingDuringNavigation: "continuous", qaDiagnosticSource: "webkit-page-bridge", qaDiagnosticSynchronization: "best-effort-page-world-observer", - screenshotClipboard: true + screenshotClipboard: true, + inputDispatch: "synthetic-dom" ) public static let chromium = BrowserEngineCapabilities( @@ -103,7 +105,8 @@ public struct BrowserEngineCapabilities: Sendable { recordingDuringNavigation: "pause-and-retry", qaDiagnosticSource: "chromium-cdp", qaDiagnosticSynchronization: "runtime-round-trip-flush", - screenshotClipboard: false + screenshotClipboard: false, + inputDispatch: "trusted-cdp" ) public static func profile(for engine: BrowserEngineName) -> BrowserEngineCapabilities { diff --git a/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js b/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js index 90d0283..85eb8ca 100644 --- a/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js +++ b/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js @@ -560,8 +560,7 @@ if (!globalThis.__headlessAgent) { } return {origin: String(location.origin).slice(0, 2048), stores}; }; - const click = args => { - const element = target(args); + const requireSafeClickTarget = element => { if (element instanceof HTMLAnchorElement && element.href) { const destination = new URL(element.href, document.baseURI); const scheme = destination.protocol.toLowerCase(); @@ -571,11 +570,39 @@ if (!globalThis.__headlessAgent) { const safety = resourceSafety(destination.href); if (safety.level === 'blocked') fail('UNSAFE_RESOURCE_TYPE', `UNSAFE_RESOURCE_TYPE:${safety.extension}`); } + }; + const click = args => { + const element = target(args); + requireSafeClickTarget(element); element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); element.focus({preventScroll: true}); element.click(); return {clicked: refFor(element), role: role(element), name: name(element)}; }; + // Chromium uses this isolated-world resolver only to select and validate a + // target. The host performs the action through CDP's trusted input domain. + // Page-derived coordinates remain bounded to the visible viewport. + const inputTarget = (args, action) => { + const element = target(args); + if (action === 'click') requireSafeClickTarget(element); + if (action === 'fill') { + const editable = element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element.isContentEditable; + if (!editable || element.disabled || element.readOnly) throw new Error('NOT_EDITABLE'); + } + element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); + element.focus({preventScroll: true}); + const rect = element.getBoundingClientRect(); + const left = Math.max(0, rect.left); + const right = Math.min(innerWidth, rect.right); + const top = Math.max(0, rect.top); + const bottom = Math.min(innerHeight, rect.bottom); + if (right <= left || bottom <= top) throw new Error('ELEMENT_NOT_VISIBLE'); + const x = left + (right - left) / 2; + const y = top + (bottom - top) / 2; + const hit = document.elementFromPoint(x, y); + if (!hit || (hit !== element && !element.contains(hit))) throw new Error('ELEMENT_OBSCURED'); + return {ref: refFor(element), role: role(element), name: name(element), x, y}; + }; const fill = args => { const element = target(args); if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element.isContentEditable)) { @@ -755,7 +782,7 @@ if (!globalThis.__headlessAgent) { return {count: document.getAnimations().length, animations: all, truncated: document.getAnimations().length > all.length}; }; return { - snapshot, click, fill, press, scroll, state, tour, screenshotPlan, + snapshot, click, fill, press, inputTarget, scroll, state, tour, screenshotPlan, scrollToCapturePoint, rectangle, styles, storage, performance: performanceSummary, animations }; diff --git a/apps/headless/Tests/Fixtures/trusted-input.html b/apps/headless/Tests/Fixtures/trusted-input.html new file mode 100644 index 0000000..2485eb9 --- /dev/null +++ b/apps/headless/Tests/Fixtures/trusted-input.html @@ -0,0 +1,27 @@ + + + + + Trusted input fixture + + +
+

Trusted input fixture

+ + + + waiting +
+ + + diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index 7af5aaa..b1cf63b 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -108,7 +108,12 @@ private func writeRawSocket(_ data: Data, descriptor: Int32) throws -> Int { #endif if count < 0 { if errno == EINTR { continue } - if sent > headlessMaximumMessageBytes { return sent } + // The server deliberately closes after recognizing an + // oversized frame. Darwin may surface that close before the + // client's send count crosses the logical limit even though + // the typed rejection response is already queued. The caller + // verifies that response, which is the security contract. + if errno == EPIPE || errno == ECONNRESET { return sent } throw TestFailure(description: "raw socket write failed before the size limit") } guard count > 0 else { return sent } @@ -1247,6 +1252,20 @@ struct ProtocolTests { BrowserEngineCapabilities.chromium.unsupportedCommands.isEmpty, "Chromium should implement every protocol command" ) + guard case .object(let webkitDocument) = BrowserEngineCapabilities.webkit.document, + case .object(let webkitFeatures)? = webkitDocument["features"], + case .object(let chromiumDocument) = BrowserEngineCapabilities.chromium.document, + case .object(let chromiumFeatures)? = chromiumDocument["features"] else { + throw TestFailure(description: "engine feature capability shape") + } + try expect( + webkitFeatures["inputDispatch"] == .string("synthetic-dom"), + "WebKit should declare its portable synthetic input path" + ) + try expect( + chromiumFeatures["inputDispatch"] == .string("trusted-cdp"), + "Chromium should declare trusted CDP input" + ) try expect( document["currentEngine"] == .string(currentBrowserEngineCapabilities.engine.rawValue), "capabilities should identify the engine for this binary" @@ -1290,8 +1309,7 @@ struct ProtocolTests { defer { closeRawSocket(descriptor) } var request = Data(repeating: 0x78, count: headlessMaximumMessageBytes + 8_192) request.append(0x0A) - let sent = try writeRawSocket(request, descriptor: descriptor) - try expect(sent > headlessMaximumMessageBytes, "raw request should cross the protocol limit") + _ = try writeRawSocket(request, descriptor: descriptor) let response = try ProtocolCodec.decodeLine( CommandResponse.self, from: readRawSocketLine(descriptor: descriptor) ) diff --git a/apps/headless/Tests/agent-runtime.test.mjs b/apps/headless/Tests/agent-runtime.test.mjs index af7718f..a2972a1 100644 --- a/apps/headless/Tests/agent-runtime.test.mjs +++ b/apps/headless/Tests/agent-runtime.test.mjs @@ -182,6 +182,8 @@ controls.innerHTML = ` window.document.body.prepend(controls); const button = controls.querySelector('button'); const input = controls.querySelector('input'); +button.getBoundingClientRect = () => ({x: 20, y: 20, top: 20, left: 20, right: 120, bottom: 60, width: 100, height: 40}); +input.getBoundingClientRect = () => ({x: 20, y: 80, top: 80, left: 20, right: 220, bottom: 120, width: 200, height: 40}); let clicks = 0; let inputs = 0; let changes = 0; @@ -192,6 +194,20 @@ input.addEventListener('change', () => { changes += 1; }); input.addEventListener('keydown', event => pressed.push(`down:${event.key}`)); input.addEventListener('keyup', event => pressed.push(`up:${event.key}`)); +window.document.elementFromPoint = () => button; +const trustedClickTarget = agent.inputTarget({role: 'button', name: 'Runtime action'}, 'click'); +assert.match(trustedClickTarget.ref, /^@e\d+$/); +assert.equal(trustedClickTarget.role, 'button'); +assert(Number.isFinite(trustedClickTarget.x) && Number.isFinite(trustedClickTarget.y)); +window.document.elementFromPoint = () => input; +const trustedFillTarget = agent.inputTarget({role: 'textbox', name: 'Runtime input'}, 'fill'); +assert.equal(trustedFillTarget.role, 'textbox'); +window.document.elementFromPoint = () => window.document.body; +assert.throws( + () => agent.inputTarget({role: 'button', name: 'Runtime action'}, 'click'), + /ELEMENT_OBSCURED/, +); + const clicked = agent.click({role: 'button', name: 'Runtime action'}); assert.match(clicked.clicked, /^@e\d+$/); assert.equal(clicks, 1, 'click should dispatch exactly once'); diff --git a/apps/headless/Tests/linux-e2e.sh b/apps/headless/Tests/linux-e2e.sh index c480d76..37d9c85 100755 --- a/apps/headless/Tests/linux-e2e.sh +++ b/apps/headless/Tests/linux-e2e.sh @@ -5,11 +5,12 @@ export HEADLESS_ARTIFACT_DIR="/tmp/headless-artifacts-e2e-$$" FIXTURE_ROOT="$(mktemp -d /tmp/headless-fixture.XXXXXX)" INSTALL_ROOT="$(mktemp -d /tmp/headless-install.XXXXXX)" -mkdir -p "$FIXTURE_ROOT/designers/dashboard" "$FIXTURE_ROOT/next" "$FIXTURE_ROOT/hostile" "$FIXTURE_ROOT/large-document" "$FIXTURE_ROOT/api" +mkdir -p "$FIXTURE_ROOT/designers/dashboard" "$FIXTURE_ROOT/next" "$FIXTURE_ROOT/hostile" "$FIXTURE_ROOT/large-document" "$FIXTURE_ROOT/trusted-input" "$FIXTURE_ROOT/api" cp /opt/headless/fixtures/dashboard.html "$FIXTURE_ROOT/designers/dashboard/index.html" cp /opt/headless/fixtures/next.html "$FIXTURE_ROOT/next/index.html" cp /opt/headless/fixtures/hostile.html "$FIXTURE_ROOT/hostile/index.html" cp /opt/headless/fixtures/large-document.html "$FIXTURE_ROOT/large-document/index.html" +cp /opt/headless/fixtures/trusted-input.html "$FIXTURE_ROOT/trusted-input/index.html" cp /opt/headless/fixtures/api-diagnostic.json "$FIXTURE_ROOT/api/diagnostic" busybox httpd -f -p 127.0.0.1:41739 -h "$FIXTURE_ROOT" & FIXTURE_PID=$! @@ -131,6 +132,16 @@ headless --session qa qa clear | grep -q '"cleared"' headless --session qa qa report | grep -q '"events":0' headless --session qa fill @e1 'Ada Lovelace' | grep -q '"valueLength":12' headless --session qa press Escape | grep -q '"pressed":"Escape"' +headless --session qa visit http://127.0.0.1:41739/trusted-input/ | grep -q 'Trusted input fixture' +headless --session qa inspect --interactive | grep -q '"name":"Trusted input"' +headless --session qa fill @e1 'CDP value' | grep -q '"valueLength":9' +headless --session qa press Enter | grep -q '"pressed":"Enter"' +headless --session qa click --role button --name 'Trusted click' | grep -q '"clicked"' +TRUSTED_INPUT="$(headless --session qa inspect --text)" +echo "$TRUSTED_INPUT" | grep -q 'input:true' +echo "$TRUSTED_INPUT" | grep -q 'key:Enter:true' +echo "$TRUSTED_INPUT" | grep -q 'click:true' +headless --session qa visit http://127.0.0.1:41739/designers/dashboard/ | grep -q 'Designers Dashboard' if EXTERNAL_RESULT="$(headless --session qa click --role link --name 'External application')"; then echo "external application link was not blocked" >&2 exit 1 diff --git a/apps/headless/docs/P4.md b/apps/headless/docs/P4.md new file mode 100644 index 0000000..ac3ae06 --- /dev/null +++ b/apps/headless/docs/P4.md @@ -0,0 +1,29 @@ +# P4 agent ecosystem contract + +P4 deepens harness integration without adding a second browser-control +surface. CLI, MCP, flows, and every engine continue to use the same validated +protocol commands. + +## Trusted Chromium input + +On Linux, `click`, `fill`, and `press` use Chromium's DevTools input domain. +The isolated agent runtime still resolves semantic targets and element +references, rejects unsafe links and blocked resource types, scrolls the +element into view, and verifies that its visible center is not obscured. It +returns only bounded target metadata and viewport coordinates. The host then +dispatches mouse and keyboard input through CDP; text values use +`Input.insertText` and are never echoed in responses or recorded in flows. + +`press` maps common navigation/editing keys and sends other validated key names +through the same CDP path. `fill` selects the focused editable control, clears +it with trusted key events, and inserts the requested text. Page event handlers +observe `isTrusted: true` for Chromium mouse, keyboard, and input events. + +WKWebView has no equivalent isolated, host-level input API and retains the +synthetic DOM implementation. This fidelity boundary is machine-readable: + +- Chromium: `features.inputDispatch = "trusted-cdp"` +- WebKit: `features.inputDispatch = "synthetic-dom"` + +The same verbs and response shapes remain portable. Hover and drag are not +implied by this upgrade and remain future command-design work. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 821b530..a13c0cd 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -252,10 +252,10 @@ Make Headless the obvious choice inside every harness: `.claude/skills/` so Claude Code finds it natively. - Capabilities doc generated from `CommandName.allCases` and asserted in tests so agents can trust `headless capabilities` (backlog §C4). -- Close the highest-value command gaps found in real agent use: real key - input (CDP `Input.dispatchKeyEvent` on Linux), `--` end-of-options sentinel - so `fill` can type literal `--json`, response pagination for large reports - (backlog §G). +- Close the highest-value command gaps found in real agent use: trusted CDP + mouse/key/text input is implemented on Linux; `--` end-of-options support is + implemented so `fill` can type literal `--json`; response pagination for + large reports remains (backlog §G). *Exit test:* a fresh Claude Code, Cursor, and Codex session can each discover and drive Headless with zero manual prompting beyond repo checkout. diff --git a/docs/roadmap/architecture-decisions.md b/docs/roadmap/architecture-decisions.md index 5aa8bec..4ba86a2 100644 --- a/docs/roadmap/architecture-decisions.md +++ b/docs/roadmap/architecture-decisions.md @@ -178,15 +178,20 @@ is documented as detectable and forgeable; the host fixes its provenance, bounds it per document, and marks its evidence untrusted. Agent actions and inspection remain in `WKContentWorld`. -## 8. In-page action model: synthetic events now, real input later (Linux) +## 8. In-page action model: trusted CDP input on Linux -**Decision:** today `click`/`fill`/`press` are synthetic DOM events from the -isolated world (`AgentRuntime.swift:492-537`) on both engines — no trusted- -event semantics, no hover/drag, `press` only special-cases Enter/Space. -Keep this as the _portable baseline_, and in Phase 4 add real input on the -Chromium engine via CDP `Input.dispatchKeyEvent`/`dispatchMouseEvent`, exposed -as the same verbs (upgrade, not new commands), with WKWebView staying on the -synthetic path as a declared capability difference. +**Decision:** `click`/`fill`/`press` keep one portable command contract. On +Linux, the isolated agent world resolves the semantic target, applies the +existing link-safety policy, focuses it, and returns a bounded visible point; +the host then acts through CDP `Input.dispatchMouseEvent`, +`Input.dispatchKeyEvent`, and `Input.insertText`. Page handlers consequently +receive trusted browser input. Fill values travel directly in the validated +CDP command and are never returned or added to flows. + +WKWebView retains the synthetic isolated-world implementation because it has +no equivalent safe host input API. `capabilities` declares `trusted-cdp` for +Chromium and `synthetic-dom` for WebKit. This is an engine fidelity difference, +not a second set of verbs. **Rationale:** synthetic events fail on real-world widgets (rich editors, canvas apps, key-repeat handlers); Chromium can do better cheaply; the @@ -418,7 +423,7 @@ credentials. | 3 | Extract HostCore + BrowserEngine, typed errors | Implemented | 2026-08-10 | | 5 | Remote stays SSH-only; no cloud offering | Decided (owner) | 2026-08-04 | | 6 | Windows = stretch via Chromium engine; WSL2/Docker interim | Decided (owner) | 2026-08-04 | -| 8 | Real CDP input on Linux as capability upgrade | Planned (Phase 4) | 2026-08-04 | +| 8 | Real CDP input on Linux as capability upgrade | Implemented | 2026-08-13 | | 12 | Version unification on git tag | Implemented | 2026-08-04 | | 14 | Run one conformance scenario against every engine | Implemented | 2026-08-10 | | 15 | Package-manager distribution set | Decided (owner) | 2026-08-04 | diff --git a/docs/roadmap/improvements-backlog.md b/docs/roadmap/improvements-backlog.md index 13a1494..d183a84 100644 --- a/docs/roadmap/improvements-backlog.md +++ b/docs/roadmap/improvements-backlog.md @@ -285,10 +285,12 @@ Claude's discovery path, all three clients use one repository-relative stdio launcher, the setup guide includes native and Docker variants, and web lint checks the configs for drift. -**C6. Input fidelity (Phase 4).** ([#34](https://github.com/LockInTime/headless/issues/34)) Real CDP input on Linux -(`Input.dispatchKeyEvent`/`dispatchMouseEvent`) behind the same verbs; today -both engines dispatch synthetic DOM events and `press` special-cases only -Enter/Space (`HP/AgentRuntime.swift:492-537`). Declared divergence per B6. +**C6. Input fidelity (Phase 4).** ([#34](https://github.com/LockInTime/headless/issues/34)) ~~Real CDP input on Linux +(`Input.dispatchKeyEvent`/`dispatchMouseEvent`) behind the same verbs.~~ +**Done:** Chromium resolves safe semantic targets in the isolated world, then +uses trusted CDP mouse, key, and text input. Linux E2E proves page handlers see +`isTrusted`; WebKit keeps its synthetic path and the capability matrix declares +the difference. **C7. Considered-and-worth-designing (not committed):** ([#35](https://github.com/LockInTime/headless/issues/35)) hover/drag verbs; `select` for dropdowns; scoped `evaluate` never (see what-is-excellent §3);