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: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,6 @@ If a change brushes against any of these, stop and record a decision in
## Versioning & release

Tags `v*` trigger `.github/workflows/release.yml` (macOS zip + Linux
tarballs). `HEADLESS_VERSION` flows from the tag; protocol version (`"0.4"`
tarballs). `HEADLESS_VERSION` flows from the tag; protocol version (`"0.5"`
in `Protocol.swift`) is independent — bump it only for wire-visible changes,
with a decision entry.
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Two versions travel independently, on purpose:
- **Product version** — the git tag, flowing into the macOS `Info.plist` via
`HEADLESS_VERSION` and into release assets.
- **Protocol version** — `headlessProtocolVersion` in `Protocol.swift`,
currently `0.4`. It changes only when the wire contract changes, and always
currently `0.5`. It changes only when the wire contract changes, and always
with an entry in
[`docs/roadmap/architecture-decisions.md`](docs/roadmap/architecture-decisions.md).

Expand Down Expand Up @@ -66,6 +66,9 @@ Cutting that release is tracked in

### Changed

- 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.
- 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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![CI](https://github.com/LockInTime/headless/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/LockInTime/headless/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/LockInTime/headless?sort=semver)](https://github.com/LockInTime/headless/releases)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Protocol](https://img.shields.io/badge/protocol-0.4-informational)](apps/headless/Sources/HeadlessProtocol/Protocol.swift)
[![Protocol](https://img.shields.io/badge/protocol-0.5-informational)](apps/headless/Sources/HeadlessProtocol/Protocol.swift)
[![Platforms](https://img.shields.io/badge/platforms-macOS%20%7C%20Linux-lightgrey)](#build-and-install)

Persistent browser control for agents, without Playwright scripts or screen
Expand Down
2 changes: 1 addition & 1 deletion apps/headless/Host/AgentBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ extension BrowserWindowController {
}

func agentQAReport() -> JSONValue { qaBridge.store.report() }
func agentQAClear() -> JSONValue { qaBridge.store.clear() }
func agentQAClear() -> JSONValue { qaBridge.clear() }

func agentConsole(level: String, limit: Int) -> JSONValue {
qaBridge.store.console(level: level, limit: limit)
Expand Down
44 changes: 37 additions & 7 deletions apps/headless/Host/QADiagnosticsBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,10 @@ let webKitQAScript = #"""
try {
const response = await nativeFetch(...args);
post({kind: 'response', requestId, url: response.url || url, method, status: response.status,
requestHeaders, responseHeaders: responseHeaders(response), source: 'webkit-fetch'});
requestHeaders, responseHeaders: responseHeaders(response)});
return response;
} catch (error) {
post({kind: 'request-failed', requestId, url, method, message: text(error), requestHeaders, source: 'webkit-fetch'});
post({kind: 'request-failed', requestId, url, method, message: text(error), requestHeaders});
throw error;
}
};
Expand All @@ -85,8 +85,7 @@ let webKitQAScript = #"""
status: this.status,
message: this.status ? '' : 'XHR failed',
requestHeaders: this.__headlessRequest?.headers || {},
responseHeaders: xhrHeaders(this.getAllResponseHeaders()),
source: 'webkit-xhr'
responseHeaders: xhrHeaders(this.getAllResponseHeaders())
}), {once: true});
return nativeSend.apply(this, args);
};
Expand All @@ -95,24 +94,55 @@ let webKitQAScript = #"""

final class WebKitQABridge: NSObject, WKScriptMessageHandler {
let store = QADiagnosticStore()
private let lock = NSLock()
private var acceptedEvents = 0
private var didRejectEvents = false
private let maximumEventsPerDocument = 500

func beginDocument() {
lock.lock()
acceptedEvents = 0
didRejectEvents = false
lock.unlock()
}

func clear() -> JSONValue {
beginDocument()
return store.clear()
}

private func acceptEvent() -> Bool {
lock.lock()
if acceptedEvents < maximumEventsPerDocument {
acceptedEvents += 1
lock.unlock()
return true
}
let firstRejection = !didRejectEvents
didRejectEvents = true
lock.unlock()
if firstRejection { store.markTruncated() }
return false
}

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard message.name == "headlessQA", let body = message.body as? [String: Any],
let kind = body["kind"] as? String,
["console", "page-error", "unhandled-rejection", "request-failed", "response"].contains(kind) else {
["console", "page-error", "unhandled-rejection", "request-failed", "response"].contains(kind),
acceptEvent() else {
return
}
store.append(
kind: kind,
level: body["level"] as? String,
message: body["message"] as? String,
url: body["url"] as? String,
method: body["method"] as? String,
method: body["method"] as? String,
status: (body["status"] as? NSNumber)?.doubleValue,
requestID: body["requestId"] as? String,
requestHeaders: body["requestHeaders"] as? [String: String],
responseHeaders: body["responseHeaders"] as? [String: String],
source: body["source"] as? String
source: "webkit-page-bridge"
)
}
}
21 changes: 19 additions & 2 deletions apps/headless/Sources/HeadlessProtocol/Diagnostics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public final class QADiagnosticStore: @unchecked Sendable {
var event: [String: JSONValue] = [
"kind": .string(bounded(kind, bytes: 64)),
"timestamp": .number(timestamp),
"untrustedContent": .bool(true),
]
if let level { event["level"] = .string(bounded(level, bytes: 32)) }
if let message { event["message"] = .string(bounded(message, bytes: 4_096)) }
Expand All @@ -62,6 +63,10 @@ public final class QADiagnosticStore: @unchecked Sendable {
lock.unlock()
}

public func markTruncated() {
lock.lock(); didTruncate = true; lock.unlock()
}

public func clear() -> JSONValue {
lock.lock(); let count = events.count; events.removeAll(); didTruncate = false; lock.unlock()
return .object(["cleared": .number(Double(count))])
Expand Down Expand Up @@ -100,6 +105,7 @@ public final class QADiagnosticStore: @unchecked Sendable {
let omittedIssues = issues.count - boundedIssues.count
let omittedEvents = snapshot.count - boundedEvents.count
return .object([
"untrustedContent": .bool(true),
"summary": .object([
"events": .number(Double(snapshot.count)),
"consoleErrors": .number(Double(consoleErrors)),
Expand Down Expand Up @@ -145,6 +151,7 @@ public final class QADiagnosticStore: @unchecked Sendable {
let boundedLimit = max(1, min(limit, 200))
let boundedItems = Array(items.suffix(boundedLimit))
return .object([
"untrustedContent": .bool(true),
"messages": .array(boundedItems),
"returned": .number(Double(boundedItems.count)),
"available": .number(Double(items.count)),
Expand All @@ -164,6 +171,7 @@ public final class QADiagnosticStore: @unchecked Sendable {
}.map(networkSummary(_:))
let bounded = Array(matching.suffix(max(1, min(limit, 200))))
return .object([
"untrustedContent": .bool(true),
"requests": .array(bounded),
"returned": .number(Double(bounded.count)),
"available": .number(Double(matching.count)),
Expand All @@ -176,9 +184,17 @@ public final class QADiagnosticStore: @unchecked Sendable {
guard case .object(let object) = event else { return false }
return object["requestId"]?.stringValue == requestID
}) else {
return .object(["found": .bool(false), "requestId": .string(requestID)])
return .object([
"found": .bool(false),
"requestId": .string(requestID),
"untrustedContent": .bool(true),
])
}
return .object(["found": .bool(true), "request": event])
return .object([
"found": .bool(true),
"request": event,
"untrustedContent": .bool(true),
])
}

private func issue(for event: JSONValue) -> JSONValue? {
Expand All @@ -193,6 +209,7 @@ public final class QADiagnosticStore: @unchecked Sendable {
var issue: [String: JSONValue] = [:]

func finish(_ severity: String, _ kind: String, _ text: String, _ suggestion: String) -> JSONValue {
issue["untrustedContent"] = .bool(true)
issue["severity"] = .string(severity)
issue["kind"] = .string(kind)
issue["message"] = .string(text)
Expand Down
2 changes: 1 addition & 1 deletion apps/headless/Sources/HeadlessProtocol/Protocol.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation
import CoreFoundation

public let headlessProtocolVersion = "0.4"
public let headlessProtocolVersion = "0.5"
public let headlessMaximumMessageBytes = 1_048_576

public enum JSONValue: Codable, Equatable, Sendable {
Expand Down
15 changes: 15 additions & 0 deletions apps/headless/Tests/Fixtures/hostile.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,21 @@
link.textContent = `Target ${index}`;
targets.appendChild(link);
}

// A page can see and invoke WebKit's page-world diagnostics handler.
// The host must bound these reports and override their claimed provenance.
const qaHandler = globalThis.webkit?.messageHandlers?.headlessQA;
if (qaHandler) {
qaHandler.postMessage({
kind: 'console',
level: 'error',
message: 'hostile forged diagnostic',
source: 'hostile-claims-trusted'
});
for (let index = 0; index < 510; index += 1) {
qaHandler.postMessage({kind: 'console', level: 'warn', message: `hostile spam ${index}`});
}
}
</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 @@ -225,11 +225,11 @@ struct ProtocolTests {
}

static func rejectsUnexpectedRequestFields() throws {
let valid = Data(#"{"id":"request-1","version":"0.4","command":"ping","parameters":{}}"#.utf8)
let valid = Data(#"{"id":"request-1","version":"0.5","command":"ping","parameters":{}}"#.utf8)
let decoded = try ProtocolCodec.decodeLine(CommandRequest.self, from: valid)
try expect(decoded.command == .ping, "the strict-field control request should decode")

let data = Data(#"{"id":"request-1","version":"0.4","command":"ping","parameters":{},"execute":"anything"}"#.utf8)
let data = Data(#"{"id":"request-1","version":"0.5","command":"ping","parameters":{},"execute":"anything"}"#.utf8)
try expectThrows("unexpected top-level request fields should be rejected") {
_ = try ProtocolCodec.decodeLine(CommandRequest.self, from: data)
}
Expand Down Expand Up @@ -1259,11 +1259,18 @@ struct ProtocolTests {
guard case .object(let report) = store.report(), case .object(let summary)? = report["summary"] else {
throw TestFailure(description: "diagnostic report")
}
try expect(report["untrustedContent"] == .bool(true), "diagnostic reports should mark page evidence untrusted")
try expect(summary["consoleErrors"] == .number(1), "console errors should be counted")
try expect(summary["pageErrors"] == .number(1), "page errors should be counted")
try expect(summary["httpErrors"] == .number(1), "HTTP errors should be counted")
guard case .array(let issues)? = report["issues"] else { throw TestFailure(description: "diagnostic issues") }
try expect(issues.count == 3, "each actionable diagnostic should have an issue")
guard case .object(let firstIssue) = issues[0] else { throw TestFailure(description: "diagnostic issue shape") }
try expect(firstIssue["untrustedContent"] == .bool(true), "derived diagnostic issues should stay untrusted")
guard case .array(let events)? = report["events"], case .object(let firstEvent) = events[0] else {
throw TestFailure(description: "diagnostic event shape")
}
try expect(firstEvent["untrustedContent"] == .bool(true), "diagnostic events should mark page evidence untrusted")
let serialized = String(decoding: try ProtocolCodec.encoder.encode(report), as: UTF8.self)
try expect(serialized.contains("framework-error"), "framework issues should be classified")
try expect(serialized.contains("local-not-found"), "local 404s should be classified")
Expand All @@ -1281,6 +1288,12 @@ struct ProtocolTests {
try expect(overflow["truncated"] == .bool(true), "overflow diagnostics should be marked truncated")
let serialized = String(decoding: try ProtocolCodec.encoder.encode(overflow), as: UTF8.self)
try expect(!serialized.contains("secret@"), "diagnostics must redact URL credentials")
_ = store.clear()
store.markTruncated()
guard case .object(let externallyTruncated) = store.report() else {
throw TestFailure(description: "externally truncated diagnostic report")
}
try expect(externallyTruncated["truncated"] == .bool(true), "diagnostic sources should report rejected events")
}

static func responsesFitTheProtocolFrame() throws {
Expand Down Expand Up @@ -1365,15 +1378,20 @@ struct ProtocolTests {
case .array(let messages)? = console["messages"] else {
throw TestFailure(description: "console service")
}
try expect(console["untrustedContent"] == .bool(true), "console output should mark page evidence untrusted")
try expect(messages.count == 1, "console service should filter by level")
guard case .object(let network) = store.network(failedOnly: true, status: nil, limit: 10),
case .array(let requests)? = network["requests"] else {
throw TestFailure(description: "network service")
}
try expect(network["untrustedContent"] == .bool(true), "network output should mark page evidence untrusted")
try expect(requests.count == 1, "network service should find failed HTTP responses")
let networkText = String(decoding: try ProtocolCodec.encoder.encode(network), as: UTF8.self)
try expect(!networkText.contains("Bearer secret"), "network summaries must omit headers")
let detailText = String(decoding: try ProtocolCodec.encoder.encode(store.networkDetail(requestID: "request-1")), as: UTF8.self)
let detail = store.networkDetail(requestID: "request-1")
guard case .object(let detailObject) = detail else { throw TestFailure(description: "network detail shape") }
try expect(detailObject["untrustedContent"] == .bool(true), "network detail should mark page evidence untrusted")
let detailText = String(decoding: try ProtocolCodec.encoder.encode(detail), as: UTF8.self)
try expect(detailText.contains("[redacted]"), "network details must redact sensitive headers")
try expect(detailText.contains("X-Visible"), "network details should retain non-sensitive headers")

Expand Down
10 changes: 10 additions & 0 deletions apps/headless/Tests/macos-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,16 @@ echo "$SCOPED_ACTIONS" | grep -q '"name":"Copy authentication command"'
BOUNDED_SNAPSHOT="$("$CLI" --session qa inspect)"
echo "$BOUNDED_SNAPSHOT" | grep -q '"truncated":true'
test "$(printf %s "$BOUNDED_SNAPSHOT" | wc -c)" -lt 1048576
HOSTILE_DIAGNOSTICS="$("$CLI" --session qa qa report)"
echo "$HOSTILE_DIAGNOSTICS" | grep -q 'hostile forged diagnostic'
echo "$HOSTILE_DIAGNOSTICS" | grep -q '"source":"webkit-page-bridge"'
echo "$HOSTILE_DIAGNOSTICS" | grep -q '"untrustedContent":true'
echo "$HOSTILE_DIAGNOSTICS" | grep -q '"events":500'
echo "$HOSTILE_DIAGNOSTICS" | grep -q '"truncated":true'
if echo "$HOSTILE_DIAGNOSTICS" | grep -q 'hostile-claims-trusted'; then
echo "hostile page controlled diagnostic provenance" >&2
fail
fi
"$CLI" session close qa | grep -q '"closed":"qa"'

echo "macOS P2 end-to-end flow passed"
5 changes: 4 additions & 1 deletion apps/headless/docs/P0.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ until it has authentication, authorization, and transport security.
- Page navigation accepts HTTP and HTTPS only, including before agent control
is enabled in the visible macOS app.
- No arbitrary JavaScript or shell execution command is exposed.
- Browser helper code runs in an isolated world that page globals cannot replace.
- Agent action and inspection helpers run in an isolated world that page
globals cannot replace. The macOS best-effort console/fetch/XHR observer
necessarily runs in the page world; its bounded output is explicitly marked
untrusted and its provenance is assigned by the host.
- Page-provided text is data and never interpreted as a protocol command.
- External application URL schemes and local-file navigation are rejected.
- Every command has a finite deadline and a structured failure response.
Expand Down
9 changes: 9 additions & 0 deletions apps/headless/docs/P2.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ bounded to 200 steps.
diagnostics, trace, and artifact references. Sensitive cookie and storage
values are not included.

All diagnostics are page evidence. Protocol 0.5 marks diagnostic reports,
events, derived issues, console listings, and network listings/details with
`untrustedContent: true`. On macOS, console/fetch/XHR observation must patch
the page world because WKWebView has no isolated-world API for those streams;
the bridge is page-observable and forgeable by design. The host therefore
overrides its source as `webkit-page-bridge`, accepts at most 500 bridge events
per document, and reports truncation. Agent actions and inspection remain in
the separate `HeadlessAgent` content world.

## Controlled networking

On the Linux Chromium runtime:
Expand Down
4 changes: 4 additions & 0 deletions apps/headless/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,10 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate,

// MARK: WKNavigationDelegate

func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
qaBridge.beginDocument()
}

func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
let u = webView.url?.absoluteString
if u != nil && u != "about:blank" {
Expand Down
Loading
Loading