Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
152 changes: 145 additions & 7 deletions apps/headless/LinuxHost/BrowserProcess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 6 additions & 3 deletions apps/headless/Sources/HeadlessProtocol/Capabilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -64,7 +65,7 @@ public struct BrowserEngineCapabilities: Sendable {
),
"screenshotClipboard": .bool(screenshotClipboard),
"tourTimeoutMs": .number(65_000),
"inputDispatch": .string("synthetic-dom"),
"inputDispatch": .string(inputDispatch),
]),
])
}
Expand All @@ -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(
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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)) {
Expand Down Expand Up @@ -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
};
Expand Down
27 changes: 27 additions & 0 deletions apps/headless/Tests/Fixtures/trusted-input.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Trusted input fixture</title>
</head>
<body>
<main>
<h1>Trusted input fixture</h1>
<label for="trusted-input">Trusted input</label>
<input id="trusted-input" autocomplete="off">
<button type="button">Trusted click</button>
<output aria-label="Input evidence">waiting</output>
</main>
<script>
const evidence = [];
const output = document.querySelector('output');
const record = value => {
evidence.push(value);
output.textContent = evidence.join(' ');
};
document.querySelector('input').addEventListener('input', event => record(`input:${event.isTrusted}`));
document.querySelector('input').addEventListener('keydown', event => record(`key:${event.key}:${event.isTrusted}`));
document.querySelector('button').addEventListener('click', event => record(`click:${event.isTrusted}`));
</script>
</body>
</html>
24 changes: 21 additions & 3 deletions apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)
)
Expand Down
16 changes: 16 additions & 0 deletions apps/headless/Tests/agent-runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');
Expand Down
Loading
Loading