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 @@ -66,6 +66,9 @@ Cutting that release is tracked in

### Changed

- Both engines now install the compiled agent-runtime resource once per
document; Chromium caches and safely invalidates its isolated context instead
of resending the runtime for every command.
- Context-budget pruning now measures each candidate once, removes oversized
entries regardless of array position, and byte-budgets text fallback.
- Removed stale screenshot and JSON conversion paths, honored per-operation
Expand Down
2 changes: 2 additions & 0 deletions apps/headless/Dockerfile.linux
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ RUN apt-get update \
COPY --from=builder /src/.build/release/headless /usr/local/bin/headless
COPY --from=builder /src/.build/release/headless-linux-host /usr/local/bin/headless-host
COPY --from=builder /src/.build/release/headless-mcp /usr/local/bin/headless-mcp
COPY --from=builder /src/.build/release/Headless_HeadlessProtocol.resources /usr/local/bin/Headless_HeadlessProtocol.resources
RUN chmod 0755 /usr/local/bin/headless /usr/local/bin/headless-host /usr/local/bin/headless-mcp
USER headless
ENV HEADLESS_HOST_EXECUTABLE=/usr/local/bin/headless-host
Expand All @@ -44,6 +45,7 @@ COPY install-linux.sh /opt/headless/package/install-linux.sh
COPY --from=builder /src/.build/release/headless /opt/headless/package/headless
COPY --from=builder /src/.build/release/headless-linux-host /opt/headless/package/headless-host
COPY --from=builder /src/.build/release/headless-mcp /opt/headless/package/headless-mcp
COPY --from=builder /src/.build/release/Headless_HeadlessProtocol.resources /opt/headless/package/Headless_HeadlessProtocol.resources
RUN chmod 0755 /opt/headless/linux-e2e.sh \
/opt/headless/package/install-linux.sh \
/opt/headless/package/headless \
Expand Down
76 changes: 48 additions & 28 deletions apps/headless/Host/AgentBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import CoreFoundation
import Foundation
import WebKit

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

struct ScreenshotArtifactData {
let data: Data
Expand Down Expand Up @@ -378,37 +378,57 @@ extension BrowserWindowController {
arguments: [String: Any] = [:],
timeout: TimeInterval = 10
) throws -> JSONValue {
let semaphore = DispatchSemaphore(value: 0)
let lock = NSLock()
var capturedResult: Result<Any, Error>?
DispatchQueue.main.async {
self.webView.callAsyncJavaScript(
agentRuntimeJavaScript + "\n" + agentEvaluationBody(body),
arguments: arguments,
in: nil,
in: agentWorld
) { result in
lock.lock()
capturedResult = result.map { $0 as Any }
lock.unlock()
semaphore.signal()
func evaluate(_ source: String) throws -> JSONValue {
let semaphore = DispatchSemaphore(value: 0)
let lock = NSLock()
var capturedResult: Result<Any, Error>?
DispatchQueue.main.async {
self.webView.callAsyncJavaScript(
source,
arguments: arguments,
in: nil,
in: agentWorld
) { result in
lock.lock()
capturedResult = result.map { $0 as Any }
lock.unlock()
semaphore.signal()
}
}
guard semaphore.wait(timeout: .now() + timeout) == .success else {
throw HostError(code: .timedOut, message: "Timed out while waiting for browser operation")
}
lock.lock()
let result = capturedResult
lock.unlock()
guard let result else {
throw HostError(code: .operationFailed, message: "Browser returned an invalid agent result")
}
do {
return try unwrapAgentEvaluationResult(JSONValue.foundationValue(result.get()))
} catch let error as HostError {
throw error
} catch {
throw HostError(code: .operationFailed, message: String(describing: error))
}
}
guard semaphore.wait(timeout: .now() + timeout) == .success else {
throw HostError(code: .timedOut, message: "Timed out while waiting for browser operation")
}
lock.lock()
let result = capturedResult
lock.unlock()
guard let result else {
throw HostError(code: .operationFailed, message: "Browser returned an invalid agent result")

let runtimeUnavailable = "Headless agent runtime is not installed in this document"
let guardedBody = """
if (!globalThis.__headlessAgent) {
const error = new Error('\(runtimeUnavailable)');
error.headlessCode = 'OPERATION_FAILED';
throw error;
}
\(body)
"""
do {
return try unwrapAgentEvaluationResult(JSONValue.foundationValue(result.get()))
} catch let error as HostError {
throw error
} catch {
throw HostError(code: .operationFailed, message: String(describing: error))
return try evaluate(agentEvaluationBody(guardedBody))
} catch let error as HostError where error.code == .operationFailed && error.message == runtimeUnavailable {
// WKWebView can expose its initial about:blank document before
// document-start scripts run. Install once in that document, then
// subsequent calls use the cached isolated-world runtime.
return try evaluate(agentRuntimeJavaScript + "\n" + agentEvaluationBody(body))
}
}
}
Expand Down
74 changes: 62 additions & 12 deletions apps/headless/LinuxHost/BrowserProcess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ final class LinuxBrowserSession: @unchecked Sendable {
private var mainFrameID: String?
private var lastSafeURL: String?
private var navigationRecoveryPending = false
private var isolatedContextID: Int?
private let mockLock = NSLock()
private var networkMocks: [NetworkMock] = []

Expand All @@ -278,6 +279,11 @@ final class LinuxBrowserSession: @unchecked Sendable {
_ = try command("Page.enable")
_ = try command("Runtime.enable")
_ = try command("Log.enable")
_ = try command("Page.addScriptToEvaluateOnNewDocument", parameters: [
"source": agentRuntimeJavaScript,
"worldName": "HeadlessAgent",
"runImmediately": true,
])
_ = try command("Network.enable", parameters: [
"maxTotalBufferSize": 10_000_000,
"maxResourceBufferSize": 1_000_000,
Expand Down Expand Up @@ -690,6 +696,11 @@ final class LinuxBrowserSession: @unchecked Sendable {
message: exception?["description"] as? String ?? details["text"] as? String ?? "JavaScript exception",
url: details["url"] as? String
)
case "Runtime.executionContextsCleared":
clearIsolatedContext()
case "Runtime.executionContextDestroyed":
let identifier = (parameters["executionContextId"] as? NSNumber)?.intValue
clearIsolatedContext(matching: identifier)
case "Log.entryAdded":
let entry = parameters["entry"] as? [String: Any] ?? [:]
diagnostics.append(kind: "console", level: entry["level"] as? String,
Expand Down Expand Up @@ -736,6 +747,7 @@ final class LinuxBrowserSession: @unchecked Sendable {
navigationLock.lock()
if mainFrameID == nil { mainFrameID = frameID }
guard mainFrameID == frameID else { navigationLock.unlock(); return }
if committed { isolatedContextID = nil }
if let parsed = URL(string: url), agentMayNavigate(to: parsed) {
if committed { lastSafeURL = url }
navigationLock.unlock()
Expand Down Expand Up @@ -788,21 +800,33 @@ final class LinuxBrowserSession: @unchecked Sendable {
const args = __input.args;
const key = __input.key;
const options = __input.options || {};
\(agentRuntimeJavaScript)
\(agentEvaluationBody(body))
})()
"""
let response = try command(
"Runtime.evaluate",
parameters: [
func evaluateParameters() throws -> [String: Any] {
[
"expression": expression,
"awaitPromise": true,
"returnByValue": true,
"userGesture": true,
"contextId": try isolatedExecutionContextID(),
],
timeoutMilliseconds: timeoutMilliseconds
)
]
}
let response: [String: Any]
do {
response = try command(
"Runtime.evaluate",
parameters: evaluateParameters(),
timeoutMilliseconds: timeoutMilliseconds
)
} catch let error as CDPError where isTransientNavigationContext(error) {
clearIsolatedContext()
response = try command(
"Runtime.evaluate",
parameters: evaluateParameters(),
timeoutMilliseconds: timeoutMilliseconds
)
}
if let exception = response["exceptionDetails"] as? [String: Any] {
throw CDPError.commandFailed(exception["text"] as? String ?? String(describing: exception))
}
Expand All @@ -817,11 +841,14 @@ final class LinuxBrowserSession: @unchecked Sendable {
)
}

/// Evaluate agent helpers in a fresh isolated world. Page scripts cannot
/// discover or replace `__headlessAgent`, while the world still has DOM
/// access. Recreating the world also avoids stale context IDs after a
/// cross-document navigation.
/// Reuse one isolated world per document. Page scripts cannot discover or
/// replace `__headlessAgent`, while the world still has DOM access.
private func isolatedExecutionContextID() throws -> Int {
navigationLock.lock()
let cached = isolatedContextID
navigationLock.unlock()
if let cached { return cached }

let frameTree = try command("Page.getFrameTree")
guard let tree = frameTree["frameTree"] as? [String: Any],
let frame = tree["frame"] as? [String: Any],
Expand All @@ -836,7 +863,30 @@ final class LinuxBrowserSession: @unchecked Sendable {
guard let contextID = result["executionContextId"] as? NSNumber else {
throw CDPError.invalidResponse("Page.createIsolatedWorld did not return an execution context")
}
return contextID.intValue
let identifier = contextID.intValue
let installed = try command("Runtime.evaluate", parameters: [
"expression": "typeof globalThis.__headlessAgent === 'object'",
"returnByValue": true,
"contextId": identifier,
])
let installedValue = (installed["result"] as? [String: Any])?["value"] as? Bool ?? false
if !installedValue {
_ = try command("Runtime.evaluate", parameters: [
"expression": agentRuntimeJavaScript,
"returnByValue": true,
"contextId": identifier,
])
}
navigationLock.lock()
isolatedContextID = identifier
navigationLock.unlock()
return identifier
}

private func clearIsolatedContext(matching identifier: Int? = nil) {
navigationLock.lock()
if identifier == nil || isolatedContextID == identifier { isolatedContextID = nil }
navigationLock.unlock()
}

private func targetArguments(_ parameters: [String: JSONValue]) throws -> [String: Any] {
Expand Down
5 changes: 4 additions & 1 deletion apps/headless/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ let package = Package(
.library(name: "HeadlessProtocol", targets: ["HeadlessProtocol"]),
],
targets: [
.target(name: "HeadlessProtocol"),
.target(
name: "HeadlessProtocol",
resources: [.process("Resources")]
),
.executableTarget(
name: "HeadlessCLI",
dependencies: ["HeadlessProtocol"]
Expand Down
Loading