From 222be89d821a42ebb266b4a6a87f35c054cb097f Mon Sep 17 00:00:00 2001
From: Aditya Garud <153842990+yashranaway@users.noreply.github.com>
Date: Thu, 13 Aug 2026 05:11:39 +0000
Subject: [PATCH 1/3] feat(chromium): dispatch trusted CDP input
---
CHANGELOG.md | 3 +
apps/headless/LinuxHost/BrowserProcess.swift | 149 +++++++++++++++++-
.../HeadlessProtocol/Capabilities.swift | 9 +-
.../Resources/AgentRuntime.js | 33 +++-
.../Tests/Fixtures/trusted-input.html | 27 ++++
.../HeadlessProtocolTests/ProtocolTests.swift | 14 ++
apps/headless/Tests/agent-runtime.test.mjs | 16 ++
apps/headless/Tests/linux-e2e.sh | 13 +-
apps/headless/docs/P4.md | 29 ++++
docs/ROADMAP.md | 8 +-
docs/roadmap/architecture-decisions.md | 23 +--
docs/roadmap/improvements-backlog.md | 10 +-
12 files changed, 303 insertions(+), 31 deletions(-)
create mode 100644 apps/headless/Tests/Fixtures/trusted-input.html
create mode 100644 apps/headless/docs/P4.md
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..428185e 100644
--- a/apps/headless/LinuxHost/BrowserProcess.swift
+++ b/apps/headless/LinuxHost/BrowserProcess.swift
@@ -248,6 +248,86 @@ 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 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 +418,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
+
+
+
+
+
+
+
+
diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift
index 7af5aaa..35dc797 100644
--- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift
+++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift
@@ -1247,6 +1247,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"
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);
From 7032d4ffcfee456cd5cdc1a8a34ed2c0fc50ff8f Mon Sep 17 00:00:00 2001
From: Aditya Garud <153842990+yashranaway@users.noreply.github.com>
Date: Thu, 13 Aug 2026 05:15:57 +0000
Subject: [PATCH 2/3] fix(chromium): bound trusted input coordinates
---
apps/headless/LinuxHost/BrowserProcess.swift | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/apps/headless/LinuxHost/BrowserProcess.swift b/apps/headless/LinuxHost/BrowserProcess.swift
index 428185e..fe06487 100644
--- a/apps/headless/LinuxHost/BrowserProcess.swift
+++ b/apps/headless/LinuxHost/BrowserProcess.swift
@@ -262,7 +262,10 @@ private struct ChromiumInputTarget {
let name = object["name"]?.stringValue,
let x = object["x"]?.numberValue,
let y = object["y"]?.numberValue,
- x.isFinite, y.isFinite else {
+ x.isFinite, y.isFinite,
+ x >= 0, y >= 0,
+ x <= ProtocolBounds.screenshotDimension,
+ y <= ProtocolBounds.screenshotDimension else {
throw CDPError.invalidResponse("trusted input target")
}
self.reference = reference
From 375260a8fb3ffc752d571345669dd73cabc4a87f Mon Sep 17 00:00:00 2001
From: Aditya Garud <153842990+yashranaway@users.noreply.github.com>
Date: Thu, 13 Aug 2026 05:18:31 +0000
Subject: [PATCH 3/3] test(transport): accept early oversized-frame close
---
.../Tests/HeadlessProtocolTests/ProtocolTests.swift | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift
index 35dc797..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 }
@@ -1304,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)
)