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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ Cutting that release is tracked in

### Fixed

- Browser-operation failures now cross both WebKit and CDP as structured,
allowlisted error codes instead of host-side matching on error-message text.
- Linux DevTools-pipe framing now tracks its scan cursor and amortizes buffer
compaction, avoiding quadratic work for large screenshot responses.
- Phase 1 hardening now bounds Chromium teardown after `SIGKILL`, uses libc's
Expand Down
85 changes: 39 additions & 46 deletions apps/headless/Host/AgentBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,6 @@ import CoreFoundation
import Foundation
import WebKit

enum AgentOperationError: Error, CustomStringConvertible {
case timedOut(String)
case invalidResult
case missingParameter(String)
case elementNotFound(String)
case regionNotFound(String)
case operationFailed(String)

var description: String {
switch self {
case .timedOut(let operation): return "Timed out while waiting for \(operation)"
case .invalidResult: return "Browser returned an invalid agent result"
case .missingParameter(let value): return "Missing command parameter: \(value)"
case .elementNotFound(let value): return "Element was not found: \(value)"
case .regionNotFound(let value): return "Region was not found: \(value)"
case .operationFailed(let value): return value
}
}
}

private let agentWorld = WKContentWorld.world(name: "HeadlessAgent")

struct ScreenshotArtifactData {
Expand Down Expand Up @@ -64,14 +44,16 @@ extension BrowserWindowController {

func agentFill(parameters: [String: JSONValue]) throws -> JSONValue {
var args = try targetArguments(parameters)
guard let value = parameters["value"]?.stringValue else { throw AgentOperationError.missingParameter("value") }
guard let value = parameters["value"]?.stringValue else {
throw HostError(code: .operationFailed, message: "Missing command parameter: value")
}
args["value"] = value
return try callAgent("return globalThis.__headlessAgent.fill(args);", arguments: ["args": args])
}

func agentPress(parameters: [String: JSONValue]) throws -> JSONValue {
guard let key = parameters["key"]?.stringValue, !key.isEmpty, key.count <= 32 else {
throw AgentOperationError.missingParameter("key")
throw HostError(code: .operationFailed, message: "Missing command parameter: key")
}
return try callAgent("return globalThis.__headlessAgent.press(key);", arguments: ["key": key])
}
Expand All @@ -93,7 +75,9 @@ extension BrowserWindowController {

repeat {
lastState = try callAgent("return globalThis.__headlessAgent.state();")
guard case .object(let state) = lastState else { throw AgentOperationError.invalidResult }
guard case .object(let state) = lastState else {
throw HostError(code: .operationFailed, message: "Browser returned an invalid agent result")
}
let urlMatches = expectedURL.map { state["url"]?.stringValue?.contains($0) == true } ?? true
let textMatches = expectedText.map { state["text"]?.stringValue?.localizedCaseInsensitiveContains($0) == true } ?? true
let isLoading = onMain { self.webView.isLoading }
Expand All @@ -105,7 +89,7 @@ extension BrowserWindowController {
Thread.sleep(forTimeInterval: 0.05)
} while Date() < deadline

throw AgentOperationError.timedOut("page condition")
throw HostError(code: .timedOut, message: "Timed out while waiting for page condition")
}

func agentTour(parameters: [String: JSONValue]) throws -> JSONValue {
Expand Down Expand Up @@ -148,11 +132,11 @@ extension BrowserWindowController {
) throws -> ScreenshotArtifactData {
let image = try agentScreenshotImage(parameters: parameters)
guard let data = encodeScreenshot(image, format: format) else {
throw AgentOperationError.invalidResult
throw HostError(code: .operationFailed, message: "Browser returned an invalid agent result")
}
if copyToClipboard {
guard format.isImage else {
throw AgentOperationError.operationFailed("Clipboard output is only supported for image screenshots")
throw HostError(code: .operationFailed, message: "Clipboard output is only supported for image screenshots")
}
onMain {
NSPasteboard.general.clearContents()
Expand All @@ -171,15 +155,17 @@ extension BrowserWindowController {
"return globalThis.__headlessAgent.rectangle(args);", arguments: ["args": args]
)
guard case .object(let outer) = value, case .object(let rect)? = outer["viewport"] else {
throw AgentOperationError.invalidResult
throw HostError(code: .operationFailed, message: "Browser returned an invalid agent result")
}
requestedRect = try screenshotRect(rect)
} else if parameters["fullPage"]?.boolValue == true {
let value = try callAgent("""
return {x: 0, y: 0, width: document.documentElement.scrollWidth,
height: document.documentElement.scrollHeight};
""")
guard case .object(let rect) = value else { throw AgentOperationError.invalidResult }
guard case .object(let rect) = value else {
throw HostError(code: .operationFailed, message: "Browser returned an invalid agent result")
}
requestedRect = try screenshotRect(rect)
}

Expand All @@ -199,16 +185,22 @@ extension BrowserWindowController {
self.webView.takeSnapshot(with: configuration) { image, error in
lock.lock()
if let image { captured = .success(image) }
else { captured = .failure(error ?? AgentOperationError.invalidResult) }
else {
captured = .failure(error ?? HostError(
code: .operationFailed, message: "Browser returned an invalid agent result"
))
}
lock.unlock()
semaphore.signal()
}
}
guard semaphore.wait(timeout: .now() + 30) == .success else {
throw AgentOperationError.timedOut("screenshot")
throw HostError(code: .timedOut, message: "Timed out while waiting for screenshot")
}
lock.lock(); let result = captured; lock.unlock()
guard let image = try result?.get() else { throw AgentOperationError.invalidResult }
guard let image = try result?.get() else {
throw HostError(code: .operationFailed, message: "Browser returned an invalid agent result")
}
return image
}

Expand Down Expand Up @@ -315,7 +307,7 @@ extension BrowserWindowController {
}
}
guard semaphore.wait(timeout: .now() + 5) == .success else {
throw AgentOperationError.timedOut("cookie inspection")
throw HostError(code: .timedOut, message: "Timed out while waiting for cookie inspection")
}
lock.lock(); let cookies = result; lock.unlock()
let host = currentURL?.host?.lowercased()
Expand Down Expand Up @@ -349,7 +341,7 @@ extension BrowserWindowController {

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

Expand All @@ -359,7 +351,7 @@ extension BrowserWindowController {
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 AgentOperationError.operationFailed("Screenshot dimensions exceed safety limits")
throw HostError(code: .operationFailed, message: "Screenshot dimensions exceed safety limits")
}
return CGRect(x: x, y: y, width: width, height: height)
}
Expand All @@ -368,13 +360,15 @@ extension BrowserWindowController {
var args: [String: Any] = [:]
if let target = parameters["target"]?.stringValue {
guard target.hasPrefix("@e"), target.count <= 16 else {
throw AgentOperationError.operationFailed("Invalid element reference")
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 AgentOperationError.missingParameter("target") }
guard !args.isEmpty else {
throw HostError(code: .operationFailed, message: "Missing command parameter: target")
}
}
return args
}
Expand All @@ -389,7 +383,7 @@ extension BrowserWindowController {
var capturedResult: Result<Any, Error>?
DispatchQueue.main.async {
self.webView.callAsyncJavaScript(
agentRuntimeJavaScript + "\n" + body,
agentRuntimeJavaScript + "\n" + agentEvaluationBody(body),
arguments: arguments,
in: nil,
in: agentWorld
Expand All @@ -401,21 +395,20 @@ extension BrowserWindowController {
}
}
guard semaphore.wait(timeout: .now() + timeout) == .success else {
throw AgentOperationError.timedOut("browser operation")
throw HostError(code: .timedOut, message: "Timed out while waiting for browser operation")
}
lock.lock()
let result = capturedResult
lock.unlock()
guard let result else { throw AgentOperationError.invalidResult }
guard let result else {
throw HostError(code: .operationFailed, message: "Browser returned an invalid agent result")
}
do {
return try jsonValue(from: result.get())
} catch let error as AgentOperationError {
return try unwrapAgentEvaluationResult(jsonValue(from: result.get()))
} catch let error as HostError {
throw error
} catch {
let message = String(describing: error)
if message.contains("ELEMENT_NOT_FOUND:") { throw AgentOperationError.elementNotFound(message) }
if message.contains("REGION_NOT_FOUND:") { throw AgentOperationError.regionNotFound(message) }
throw AgentOperationError.operationFailed(message)
throw HostError(code: .operationFailed, message: String(describing: error))
}
}
}
Expand All @@ -435,6 +428,6 @@ private func jsonValue(from value: Any) throws -> JSONValue {
case let value as [String: Any]:
return .object(try value.mapValues(jsonValue(from:)))
case is NSNull: return .null
default: throw AgentOperationError.invalidResult
default: throw HostError(code: .operationFailed, message: "Browser returned an invalid agent result")
}
}
11 changes: 8 additions & 3 deletions apps/headless/LinuxHost/BrowserProcess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -793,7 +793,7 @@ final class LinuxBrowserSession: @unchecked Sendable {
const key = __input.key;
const options = __input.options || {};
\(agentRuntimeJavaScript)
\(body)
\(agentEvaluationBody(body))
})()
"""
let response = try command("Runtime.evaluate", parameters: [
Expand All @@ -812,7 +812,9 @@ final class LinuxBrowserSession: @unchecked Sendable {
if let description = result["description"] as? String, result["subtype"] as? String == "error" {
throw CDPError.commandFailed(description)
}
return try JSONValue.foundationValue(result["value"] ?? NSNull())
return try unwrapAgentEvaluationResult(
JSONValue.foundationValue(result["value"] ?? NSNull())
)
}

/// Evaluate agent helpers in a fresh isolated world. Page scripts cannot
Expand Down Expand Up @@ -851,7 +853,10 @@ final class LinuxBrowserSession: @unchecked Sendable {

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

Expand Down
31 changes: 14 additions & 17 deletions apps/headless/LinuxHost/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -370,34 +370,31 @@ final class LinuxBrowserHost: @unchecked Sendable {
}
}
return .success(id: request.id, result: result)
} catch let error as HostError {
return .failure(
id: request.id, code: error.code.rawValue, message: error.message,
suggestion: error.suggestion
)
} catch let error as ProtocolValidationError {
if case .unsafeResourceType = error {
return failure(request, "UNSAFE_RESOURCE_TYPE", error.description,
suggestion: "Executable files, installers, scripts, and disk images are blocked. Use normal web pages or media only.")
}
return failure(request, "INVALID_INPUT", error.description)
} catch let error as CDPError {
let code: String
let suggestion: String?
switch error {
case .timedOut:
code = "TIMEOUT"; suggestion = "Inspect the page or wait for a narrower condition."
case .commandFailed(let message) where message.contains("ELEMENT_NOT_FOUND"):
code = "ELEMENT_NOT_FOUND"; suggestion = "Run `headless inspect --interactive` to refresh references."
case .commandFailed(let message) where message.contains("REGION_NOT_FOUND"):
code = "REGION_NOT_FOUND"; suggestion = "Run `headless inspect --context outline` to refresh region references."
case .commandFailed(let message) where message.contains("UNSAFE_NAVIGATION"):
code = "UNSAFE_NAVIGATION"; suggestion = "Agent-controlled sessions allow web navigation only."
case .commandFailed(let message) where message.contains("UNSAFE_RESOURCE_TYPE"):
code = "UNSAFE_RESOURCE_TYPE"
suggestion = "Executable files, installers, scripts, and disk images are blocked."
case .commandFailed(let message) where message.contains("SENSITIVE_DIAGNOSTICS_DISABLED"):
code = "SENSITIVE_DIAGNOSTICS_DISABLED"
suggestion = "Restart the host with HEADLESS_ALLOW_SENSITIVE_DIAGNOSTICS=1 only when cookie or storage values are required."
let hostError = HostError(code: .timedOut, message: error.description)
return .failure(
id: request.id, code: hostError.code.rawValue,
message: hostError.message, suggestion: hostError.suggestion
)
default:
code = "OPERATION_FAILED"; suggestion = nil
return .failure(
id: request.id, code: HostErrorCode.operationFailed.rawValue,
message: error.description
)
}
return .failure(id: request.id, code: code, message: error.description, suggestion: suggestion)
} catch let error as RecordingError {
let code: String
let suggestion: String?
Expand Down
19 changes: 12 additions & 7 deletions apps/headless/Sources/HeadlessProtocol/AgentRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ if (!globalThis.__headlessAgent) {
const normalize = value => String(value || '').replace(/\s+/g, ' ').trim();
const clipped = (value, maximum) => normalize(value).slice(0, maximum);
const clippedURL = value => String(value || '').slice(0, 512);
const fail = (code, message) => {
const error = new Error(message);
error.headlessCode = code;
throw error;
};
const blockedResourceExtensions = new Set([
'apk','app','bat','cmd','com','deb','dll','dmg','dylib','exe','img','iso','jar',
'msi','msp','pif','pkg','ps1','psm1','scr','sh','so','vbe','vbs','wsf','zsh'
Expand Down Expand Up @@ -234,13 +239,13 @@ if (!globalThis.__headlessAgent) {
if (!reference) return document;
const element = currentRegions.get(reference);
if (!element) {
throw new Error(issuedRegionRefs.has(reference)
fail('REGION_NOT_FOUND', issuedRegionRefs.has(reference)
? `REGION_NOT_FOUND:${reference} (expired: inspect again to refresh region references)`
: `REGION_NOT_FOUND:${reference} (unknown: no inspection has issued this reference)`);
}
if (!element.isConnected || !visible(element)) {
currentRegions.delete(reference);
throw new Error(`REGION_NOT_FOUND:${reference} (detached: the region is no longer visible on this page)`);
fail('REGION_NOT_FOUND', `REGION_NOT_FOUND:${reference} (detached: the region is no longer visible on this page)`);
}
return element;
};
Expand Down Expand Up @@ -448,13 +453,13 @@ if (!globalThis.__headlessAgent) {
// is the difference between an agent re-inspecting and an agent retrying
// the same dead reference.
if (!element) {
throw new Error(issuedRefs.has(target)
fail('ELEMENT_NOT_FOUND', issuedRefs.has(target)
? `ELEMENT_NOT_FOUND:${target} (expired: element references come from the most recent inspection — inspect again to refresh)`
: `ELEMENT_NOT_FOUND:${target} (unknown: no inspection has issued this reference)`);
}
if (!element.isConnected) {
current.delete(target);
throw new Error(`ELEMENT_NOT_FOUND:${target} (detached: the element is no longer in the page)`);
fail('ELEMENT_NOT_FOUND', `ELEMENT_NOT_FOUND:${target} (detached: the element is no longer in the page)`);
}
return element;
};
Expand All @@ -465,7 +470,7 @@ if (!globalThis.__headlessAgent) {
(!normalizedRole || role(element) === normalizedRole) &&
(!normalizedName || name(element).toLowerCase() === normalizedName)
);
if (matches.length === 0) throw new Error(`ELEMENT_NOT_FOUND:${wantedRole || ''}/${wantedName || ''}`);
if (matches.length === 0) fail('ELEMENT_NOT_FOUND', `ELEMENT_NOT_FOUND:${wantedRole || ''}/${wantedName || ''}`);
if (matches.length > 1) throw new Error(`ELEMENT_AMBIGUOUS:${matches.length}`);
refFor(matches[0]);
return matches[0];
Expand Down Expand Up @@ -527,10 +532,10 @@ if (!globalThis.__headlessAgent) {
const destination = new URL(element.href, document.baseURI);
const scheme = destination.protocol.toLowerCase();
if (!['http:', 'https:'].includes(scheme) || destination.username || destination.password) {
throw new Error(`UNSAFE_NAVIGATION:${scheme}`);
fail('UNSAFE_NAVIGATION', `UNSAFE_NAVIGATION:${scheme}`);
}
const safety = resourceSafety(destination.href);
if (safety.level === 'blocked') throw new Error(`UNSAFE_RESOURCE_TYPE:${safety.extension}`);
if (safety.level === 'blocked') fail('UNSAFE_RESOURCE_TYPE', `UNSAFE_RESOURCE_TYPE:${safety.extension}`);
}
element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'});
element.focus({preventScroll: true});
Expand Down
Loading
Loading