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
9 changes: 4 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,10 @@ If a change brushes against any of these, stop and record a decision in
without an architecture-decision entry. Match existing style (explicit
validation, small structs, no force-unwraps in host paths — existing ones
are backlog items §A2, don't add more).
- A new protocol command currently must be added in *both* hosts
(`main.swift` and `LinuxHost/main.swift`), the validator
(`Protocol.swift`), the CLI (`CLI.swift`), help text, and `capabilities` —
until the Phase 2 HostCore refactor lands, keep all copies in sync and add
parse + E2E coverage on both platforms.
- Add a new portable protocol command once in `HostCore`, plus the validator
(`Protocol.swift`), CLI (`CLI.swift`), help text, and capability declaration.
Add only engine-specific operations to both `BrowserEngineSession` adapters,
and retain parse + E2E coverage on both platforms.
- The agent runtime JS lives in `Sources/HeadlessProtocol/AgentRuntime.swift`
as a raw string; `Tests/agent-runtime.test.mjs` regex-extracts it — if you
touch the string delimiters, fix the extractor.
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ Cutting that release is tracked in

### Changed

- macOS WebKit and Linux Chromium now share one `HostCore` dispatcher and
lifecycle implementation behind small engine adapters, eliminating the two
divergent copies of flow, capture, recording, report, trace, and error logic.
- Protocol 0.5 marks diagnostic reports, events, derived issues, console
messages, and network evidence as untrusted page content. The macOS
page-world observer now has host-owned provenance and a per-document cap.
Expand Down
146 changes: 109 additions & 37 deletions apps/headless/Host/AgentBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@ extension BrowserWindowController {
}

func agentClick(parameters: [String: JSONValue]) throws -> JSONValue {
let args = try targetArguments(parameters)
let args = try browserTargetArguments(parameters)
return try callAgent("return globalThis.__headlessAgent.click(args);", arguments: ["args": args])
}

func agentFill(parameters: [String: JSONValue]) throws -> JSONValue {
var args = try targetArguments(parameters)
var args = try browserTargetArguments(parameters)
guard let value = parameters["value"]?.stringValue else {
throw HostError(code: .operationFailed, message: "Missing command parameter: value")
}
Expand Down Expand Up @@ -150,7 +150,7 @@ extension BrowserWindowController {
var requestedRect: CGRect?
let hasTarget = parameters["target"] != nil || parameters["role"] != nil || parameters["name"] != nil
if hasTarget {
let args = try targetArguments(parameters)
let args = try browserTargetArguments(parameters)
let value = try callAgent(
"return globalThis.__headlessAgent.rectangle(args);", arguments: ["args": args]
)
Expand Down Expand Up @@ -271,7 +271,7 @@ extension BrowserWindowController {
}

func agentStyles(parameters: [String: JSONValue]) throws -> JSONValue {
let args = try targetArguments(parameters)
let args = try browserTargetArguments(parameters)
var input = args
if case .array(let properties)? = parameters["properties"] {
input["properties"] = properties.compactMap(\.stringValue)
Expand All @@ -280,7 +280,7 @@ extension BrowserWindowController {
}

func agentStorage(scope: String, includeValues: Bool) throws -> JSONValue {
try requireSensitiveDiagnosticsIfNeeded(includeValues)
try requireSensitiveDiagnosticsAccess(if: includeValues)
return try callAgent(
"return globalThis.__headlessAgent.storage(args);",
arguments: ["args": ["scope": scope, "includeValues": includeValues]]
Expand All @@ -296,7 +296,7 @@ extension BrowserWindowController {
}

func agentCookies(includeValues: Bool) throws -> JSONValue {
try requireSensitiveDiagnosticsIfNeeded(includeValues)
try requireSensitiveDiagnosticsAccess(if: includeValues)
let semaphore = DispatchSemaphore(value: 0)
let lock = NSLock()
var result: [HTTPCookie] = []
Expand Down Expand Up @@ -339,38 +339,12 @@ extension BrowserWindowController {
])
}

private func requireSensitiveDiagnosticsIfNeeded(_ requested: Bool) throws {
guard !requested || ProcessInfo.processInfo.environment["HEADLESS_ALLOW_SENSITIVE_DIAGNOSTICS"] == "1" else {
throw HostError(code: .sensitiveDiagnosticsDisabled, message: "Sensitive diagnostic values are disabled.")
}
}

private func screenshotRect(_ object: [String: JSONValue]) throws -> CGRect {
guard let x = object["x"]?.numberValue, let y = object["y"]?.numberValue,
let width = object["width"]?.numberValue, let height = object["height"]?.numberValue,
x.isFinite, y.isFinite, width.isFinite, height.isFinite,
width > 0, height > 0, width <= 16_384, height <= 16_384,
width * height <= 64_000_000 else {
throw HostError(code: .operationFailed, message: "Screenshot dimensions exceed safety limits")
}
return CGRect(x: x, y: y, width: width, height: height)
}

private func targetArguments(_ parameters: [String: JSONValue]) throws -> [String: Any] {
var args: [String: Any] = [:]
if let target = parameters["target"]?.stringValue {
guard target.hasPrefix("@e"), target.count <= 16 else {
throw HostError(code: .operationFailed, message: "Invalid element reference")
}
args["target"] = target
} else {
if let role = parameters["role"]?.stringValue { args["role"] = role }
if let name = parameters["name"]?.stringValue { args["name"] = name }
guard !args.isEmpty else {
throw HostError(code: .operationFailed, message: "Missing command parameter: target")
}
}
return args
let rectangle = try BoundedScreenshotRectangle(object)
return CGRect(
x: rectangle.x, y: rectangle.y,
width: rectangle.width, height: rectangle.height
)
}

private func callAgent(
Expand Down Expand Up @@ -433,6 +407,104 @@ extension BrowserWindowController {
}
}

final class WebKitBrowserEngine: BrowserEngine {
typealias Session = BrowserWindowController

let name = "webkit"
let platform = "macos"
private let create: () throws -> BrowserWindowController
private let close: (BrowserWindowController) -> Void
private let stopEngine: () -> Void

init(
create: @escaping () throws -> BrowserWindowController,
close: @escaping (BrowserWindowController) -> Void,
stop: @escaping () -> Void = {}
) {
self.create = create
self.close = close
self.stopEngine = stop
}

func createSession() throws -> BrowserWindowController { try create() }
func closeSession(_ session: BrowserWindowController) { close(session) }
func stop() { stopEngine() }
}

extension BrowserWindowController: BrowserEngineSession {
func hostEnableAgentControl() { onMain { self.enableAgentControl() } }
func hostVisit(_ url: URL) throws -> JSONValue { try agentVisit(url) }
func hostInspect(parameters: [String: JSONValue]) throws -> JSONValue {
try agentInspect(parameters: parameters)
}
func hostClick(parameters: [String: JSONValue]) throws -> JSONValue {
try agentClick(parameters: parameters)
}
func hostFill(parameters: [String: JSONValue]) throws -> JSONValue {
try agentFill(parameters: parameters)
}
func hostPress(parameters: [String: JSONValue]) throws -> JSONValue {
try agentPress(parameters: parameters)
}
func hostScroll(parameters: [String: JSONValue]) throws -> JSONValue {
try agentScroll(parameters: parameters)
}
func hostWait(parameters: [String: JSONValue]) throws -> JSONValue {
try agentWait(parameters: parameters)
}
func hostTour(parameters: [String: JSONValue]) throws -> JSONValue {
try agentTour(parameters: parameters)
}
func hostBack() throws -> JSONValue {
onMain { _ = self.webView.goBack() }
return try agentWait(parameters: ["settled": .bool(true)])
}
func hostReload() throws -> JSONValue {
_ = onMain { self.webView.reload() }
return try agentWait(parameters: ["settled": .bool(true)])
}
func hostCaptureInfo() throws -> JSONValue { try agentCaptureInfo() }
func hostScreenshot(
parameters: [String: JSONValue], format: ScreenshotFormat, copyToClipboard: Bool
) throws -> BrowserScreenshot {
let screenshot = try agentScreenshotData(
parameters: parameters, format: format, copyToClipboard: copyToClipboard
)
return BrowserScreenshot(
data: screenshot.data, clipboardCopied: screenshot.clipboardCopied
)
}
func hostRecordingFrame() throws -> Data { try agentScreenshot(parameters: [:]) }
func hostScreenshotSeriesPlan(mode: String) throws -> JSONValue {
try agentScreenshotSeriesPlan(mode: mode)
}
func hostScrollToCapturePoint(y: Double) throws -> JSONValue {
try agentScrollToCapturePoint(y: y)
}
func hostQAReport() throws -> JSONValue { agentQAReport() }
func hostQAClear() throws -> JSONValue { agentQAClear() }
func hostConsole(level: String, limit: Int) throws -> JSONValue {
agentConsole(level: level, limit: limit)
}
func hostNetwork(failedOnly: Bool, status: Int?, limit: Int) throws -> JSONValue {
agentNetwork(failedOnly: failedOnly, status: status, limit: limit)
}
func hostNetworkDetail(requestID: String) throws -> JSONValue {
agentNetworkDetail(requestID: requestID)
}
func hostStyles(parameters: [String: JSONValue]) throws -> JSONValue {
try agentStyles(parameters: parameters)
}
func hostCookies(includeValues: Bool) throws -> JSONValue {
try agentCookies(includeValues: includeValues)
}
func hostStorage(scope: String, includeValues: Bool) throws -> JSONValue {
try agentStorage(scope: scope, includeValues: includeValues)
}
func hostPerformance() throws -> JSONValue { try agentPerformance() }
func hostAnimations() throws -> JSONValue { try agentAnimations() }
}

private func onMain<T>(_ body: @escaping () -> T) -> T {
if Thread.isMainThread { return body() }
return DispatchQueue.main.sync(execute: body)
Expand Down
46 changes: 11 additions & 35 deletions apps/headless/LinuxHost/BrowserProcess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ final class LinuxBrowserSession: @unchecked Sendable {
}

func click(parameters: [String: JSONValue]) throws -> JSONValue {
let args = try targetArguments(parameters)
let args = try browserTargetArguments(parameters)
let result = try evaluate("return globalThis.__headlessAgent.click(args);", input: ["args": args])
// A click can synchronously begin a cross-document navigation. Let the
// foreground wait command observe that transition before frame capture
Expand All @@ -349,7 +349,7 @@ final class LinuxBrowserSession: @unchecked Sendable {
}

func fill(parameters: [String: JSONValue]) throws -> JSONValue {
var args = try targetArguments(parameters)
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])
Expand Down Expand Up @@ -446,7 +446,7 @@ final class LinuxBrowserSession: @unchecked Sendable {
let hasTarget = parameters["target"] != nil || parameters["role"] != nil || parameters["name"] != nil
if hasTarget {
capture["captureBeyondViewport"] = true
let args = try targetArguments(parameters)
let args = try browserTargetArguments(parameters)
let rectangle = try evaluate(
"return globalThis.__headlessAgent.rectangle(args);", input: ["args": args]
)
Expand Down Expand Up @@ -541,15 +541,15 @@ final class LinuxBrowserSession: @unchecked Sendable {
}

func styles(parameters: [String: JSONValue]) throws -> JSONValue {
var args = try targetArguments(parameters)
var args = try browserTargetArguments(parameters)
if case .array(let properties)? = parameters["properties"] {
args["properties"] = properties.compactMap(\.stringValue)
}
return try evaluate("return globalThis.__headlessAgent.styles(args);", input: ["args": args])
}

func storage(scope: String, includeValues: Bool) throws -> JSONValue {
try requireSensitiveDiagnosticsIfNeeded(includeValues)
try requireSensitiveDiagnosticsAccess(if: includeValues)
return try evaluate(
"return globalThis.__headlessAgent.storage(args);",
input: ["args": ["scope": scope, "includeValues": includeValues]]
Expand Down Expand Up @@ -610,7 +610,7 @@ final class LinuxBrowserSession: @unchecked Sendable {
}

func cookies(includeValues: Bool) throws -> JSONValue {
try requireSensitiveDiagnosticsIfNeeded(includeValues)
try requireSensitiveDiagnosticsAccess(if: includeValues)
let response = try command("Network.getCookies")
let rawCookies = response["cookies"] as? [[String: Any]] ?? []
let cookies: [JSONValue] = rawCookies.prefix(200).map { cookie in
Expand Down Expand Up @@ -776,14 +776,11 @@ final class LinuxBrowserSession: @unchecked Sendable {
}

private func screenshotClip(_ rect: [String: JSONValue]) throws -> [String: Any] {
guard let x = rect["x"]?.numberValue, let y = rect["y"]?.numberValue,
let width = rect["width"]?.numberValue, let height = rect["height"]?.numberValue,
x.isFinite, y.isFinite, width.isFinite, height.isFinite,
width > 0, height > 0, width <= 16_384, height <= 16_384,
width * height <= 64_000_000 else {
throw CDPError.commandFailed("screenshot dimensions exceed safety limits")
}
return ["x": x, "y": y, "width": width, "height": height, "scale": 1]
let rectangle = try BoundedScreenshotRectangle(rect)
return [
"x": rectangle.x, "y": rectangle.y,
"width": rectangle.width, "height": rectangle.height, "scale": 1,
]
}

private func evaluate(_ body: String, input: [String: Any] = [:], timeout: TimeInterval = 10) throws -> JSONValue {
Expand Down Expand Up @@ -889,27 +886,6 @@ final class LinuxBrowserSession: @unchecked Sendable {
navigationLock.unlock()
}

private func targetArguments(_ parameters: [String: JSONValue]) throws -> [String: Any] {
if let target = parameters["target"]?.stringValue {
guard target.hasPrefix("@e"), target.count <= 16 else { throw CDPError.commandFailed("invalid element ref") }
return ["target": target]
}
var result: [String: Any] = [:]
if let role = parameters["role"]?.stringValue { result["role"] = role }
if let name = parameters["name"]?.stringValue { result["name"] = name }
guard !result.isEmpty else { throw CDPError.commandFailed("missing target") }
return result
}

private func requireSensitiveDiagnosticsIfNeeded(_ requested: Bool) throws {
guard !requested || ProcessInfo.processInfo.environment["HEADLESS_ALLOW_SENSITIVE_DIAGNOSTICS"] == "1" else {
throw HostError(
code: .sensitiveDiagnosticsDisabled,
message: "Sensitive diagnostic values are disabled."
)
}
}

private func stringHeaders(_ headers: [String: Any]?) -> [String: String] {
diagnosticStringHeaders(headers)
}
Expand Down
Loading
Loading