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

### Added

- A generated WebKit/Chromium capability matrix now declares exhaustive
command support and intentional engine differences; host ping responses
include the active engine profile.
- The single MCP tool now accurately declares its mutating, destructive,
non-idempotent, open-world behavior; integration tests lock the metadata and
deliberate `stop` / `session close` exposure.
Expand Down Expand Up @@ -88,6 +91,8 @@ Cutting that release is tracked in

### Fixed

- WebKit and Chromium now agree on empty-history `back` failures and enforce
the same bounded key input before dispatch.
- Portable name characters, local-development hosts, scroll bounds, and
network-emulation bounds now have one definition shared by CLI validation,
protocol validation, and diagnostics; tests also lock the Swift/JavaScript
Expand Down
5 changes: 4 additions & 1 deletion apps/headless/Host/AgentBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ final class WebKitBrowserEngine: BrowserEngine {

let name = "webkit"
let platform = "macos"
let capabilities = BrowserEngineCapabilities.webkit
private let create: () throws -> BrowserWindowController
private let close: (BrowserWindowController) -> Void
private let stopEngine: () -> Void
Expand Down Expand Up @@ -456,7 +457,9 @@ extension BrowserWindowController: BrowserEngineSession {
try agentTour(parameters: parameters)
}
func hostBack() throws -> JSONValue {
onMain { _ = self.webView.goBack() }
guard onMain({ self.webView.goBack() != nil }) else {
throw HostError(code: .operationFailed, message: "No back history")
}
return try agentWait(parameters: ["settled": .bool(true)])
}
func hostReload() throws -> JSONValue {
Expand Down
4 changes: 3 additions & 1 deletion apps/headless/LinuxHost/BrowserProcess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,9 @@ final class LinuxBrowserSession: @unchecked Sendable {
}

func press(parameters: [String: JSONValue]) throws -> JSONValue {
guard let key = parameters["key"]?.stringValue else { throw CDPError.commandFailed("missing key") }
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])
}

Expand Down
1 change: 1 addition & 0 deletions apps/headless/LinuxHost/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ final class ChromiumBrowserEngine: BrowserEngine {

let name = "chromium"
let platform = "linux"
let capabilities = BrowserEngineCapabilities.chromium
let browser: ChromiumProcess

init() throws {
Expand Down
36 changes: 0 additions & 36 deletions apps/headless/Sources/HeadlessProtocol/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -676,39 +676,3 @@ Global options:
--session NAME target a named browser session
-- stop parsing global options; quote multi-word fill values
"""

public let capabilitiesDocument: JSONValue = .object([
"protocolVersion": .string(headlessProtocolVersion),
"transport": .array([.string("local-unix-socket")]),
"commands": .array(CommandName.allCases.map { .string($0.rawValue) }),
"artifacts": .array([.string("png"), .string("jpg"), .string("jpeg"), .string("pdf"), .string("mp4"), .string("mov"), .string("webm"), .string("gif"), .string("json")]),
"screenshotFormats": .array([.string("png"), .string("jpg"), .string("jpeg"), .string("pdf")]),
"pdfScreenshots": .string("full-page only"),
"screenshotClipboard": .string("macOS image screenshots only"),
"recordingFormats": .array([.string("mp4"), .string("mov"), .string("webm"), .string("gif")]),
"recordingQuality": .array([.string("fast"), .string("balanced"), .string("high")]),
"recordingProviders": .array([.string("browser-ffmpeg")]),
"inspectContexts": .array([.string("summary"), .string("outline"), .string("text"), .string("actions"), .string("full")]),
"inspectPruning": .object([
"regionReferences": .string("@rN"),
"maximumItems": .number(250),
"budgetRangeEstimatedTokens": .array([.number(256), .number(16_000)]),
"maximumOutlineDepth": .number(8),
]),
"screenshotSeries": .array([.string("viewport"), .string("section")]),
"security": .object([
"tcpListener": .bool(false),
"arbitraryJavaScript": .bool(false),
"allowedNavigationSchemes": .array([.string("http"), .string("https")]),
"blockedRemoteResourceExtensions": .array(blockedRemoteResourceExtensions.sorted().map(JSONValue.string)),
"cautionRemoteResourceExtensions": .array(cautionRemoteResourceExtensions.sorted().map(JSONValue.string)),
"downloadsDenied": .bool(true),
"remoteControl": .string("stdio MCP bridge or SSH forwarding only; no TCP listener"),
"networkSimulation": .string("Chromium CDP host on Linux; explicitly unsupported on WebKit"),
"sensitiveDiagnostics": .object([
"default": .string("redacted"),
"enableEnvironment": .string("HEADLESS_ALLOW_SENSITIVE_DIAGNOSTICS=1"),
]),
"maximumMessageBytes": .number(Double(headlessMaximumMessageBytes)),
]),
])
168 changes: 168 additions & 0 deletions apps/headless/Sources/HeadlessProtocol/Capabilities.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import Foundation

public enum BrowserEngineName: String, CaseIterable, Sendable {
case webkit
case chromium
}

/// A machine-readable declaration of the behavior an engine actually exposes.
/// Named fields make new divergences a compiler-visible change instead of an
/// undocumented string added to the CLI output.
public struct BrowserEngineCapabilities: Sendable {
public let engine: BrowserEngineName
public let platforms: [String]
public let unsupportedCommands: Set<CommandName>
public let pdfOutput: String
public let elementScreenshotCoordinates: String
public let elementScreenshotBeyondViewport: Bool
public let jpegEncoder: String
public let cookieScope: String
public let cookieFields: [String]
public let backWithoutHistory: String
public let recordingDuringNavigation: String
public let qaDiagnosticSource: String
public let qaDiagnosticSynchronization: String
public let screenshotClipboard: Bool

public var supportedCommands: [CommandName] {
CommandName.allCases.filter { !unsupportedCommands.contains($0) }
}

public var document: JSONValue {
.object([
"engine": .string(engine.rawValue),
"platforms": .array(platforms.map(JSONValue.string)),
"commands": .array(supportedCommands.map { .string($0.rawValue) }),
"unsupportedCommands": .array(
CommandName.allCases.filter(unsupportedCommands.contains).map { .string($0.rawValue) }
),
"features": .object([
"pdfScreenshot": .object([
"supported": .bool(true), "output": .string(pdfOutput),
]),
"elementScreenshot": .object([
"coordinateSpace": .string(elementScreenshotCoordinates),
"beyondViewport": .bool(elementScreenshotBeyondViewport),
]),
"jpeg": .object([
"encoder": .string(jpegEncoder), "quality": .number(88),
]),
"cookies": .object([
"scope": .string(cookieScope),
"fields": .array(cookieFields.map(JSONValue.string)),
]),
"backWithoutHistory": .string(backWithoutHistory),
"recordingDuringNavigation": .string(recordingDuringNavigation),
"qaDiagnostics": .object([
"source": .string(qaDiagnosticSource),
"synchronization": .string(qaDiagnosticSynchronization),
]),
"networkEmulation": .bool(!unsupportedCommands.contains(.networkEmulate)),
"networkMocking": .bool(
!unsupportedCommands.contains(.networkMockSet)
&& !unsupportedCommands.contains(.networkMockClear)
),
"screenshotClipboard": .bool(screenshotClipboard),
"tourTimeoutMs": .number(65_000),
"inputDispatch": .string("synthetic-dom"),
]),
])
}

public static let webkit = BrowserEngineCapabilities(
engine: .webkit,
platforms: ["macos"],
unsupportedCommands: [.networkEmulate, .networkMockSet, .networkMockClear],
pdfOutput: "rasterized-page-image",
elementScreenshotCoordinates: "viewport",
elementScreenshotBeyondViewport: false,
jpegEncoder: "apple-imageio",
cookieScope: "current-origin",
cookieFields: ["name", "domain", "path", "secure", "httpOnly", "expiresAt"],
backWithoutHistory: "operation-failed",
recordingDuringNavigation: "continuous",
qaDiagnosticSource: "webkit-page-bridge",
qaDiagnosticSynchronization: "best-effort-page-world-observer",
screenshotClipboard: true
)

public static let chromium = BrowserEngineCapabilities(
engine: .chromium,
platforms: ["linux"],
unsupportedCommands: [],
pdfOutput: "vector-print",
elementScreenshotCoordinates: "document",
elementScreenshotBeyondViewport: true,
jpegEncoder: "chromium-cdp",
cookieScope: "browser-context",
cookieFields: [
"name", "domain", "path", "sameSite", "priority", "sourceScheme",
"secure", "httpOnly", "session", "partitionKeyOpaque", "expiresAt",
],
backWithoutHistory: "operation-failed",
recordingDuringNavigation: "pause-and-retry",
qaDiagnosticSource: "chromium-cdp",
qaDiagnosticSynchronization: "runtime-round-trip-flush",
screenshotClipboard: false
)

public static let all: [BrowserEngineCapabilities] = [.webkit, .chromium]
}

public var currentBrowserEngineCapabilities: BrowserEngineCapabilities {
#if os(macOS)
.webkit
#else
.chromium
#endif
}

private func stringArray<S: Sequence>(_ values: S) -> JSONValue where S.Element == String {
.array(values.map(JSONValue.string))
}

public let capabilitiesDocument: JSONValue = {
let profiles = BrowserEngineCapabilities.all
let engines = Dictionary(uniqueKeysWithValues: profiles.map { profile in
(profile.engine.rawValue, profile.document)
})
let screenshotExtensions = ScreenshotFormat.artifactExtensions.sorted()
let recordingExtensions = RecordingFormat.artifactExtensions.sorted()
return .object([
"protocolVersion": .string(headlessProtocolVersion),
"transport": stringArray(["local-unix-socket"]),
"currentEngine": .string(currentBrowserEngineCapabilities.engine.rawValue),
"commands": .array(CommandName.allCases.map { .string($0.rawValue) }),
"engines": .object(engines),
"artifacts": stringArray((screenshotExtensions + recordingExtensions + ["json"]).sorted()),
"screenshotFormats": stringArray(screenshotExtensions),
"pdfScreenshots": .string("full-page only"),
"screenshotClipboard": .string("macOS image screenshots only"),
"recordingFormats": stringArray(recordingExtensions),
"recordingQuality": stringArray(RecordingQuality.allCases.map(\.rawValue)),
"recordingProviders": stringArray(["browser-ffmpeg"]),
"inspectContexts": stringArray(["summary", "outline", "text", "actions", "full"]),
"inspectPruning": .object([
"regionReferences": .string("@rN"),
"maximumItems": .number(250),
"budgetRangeEstimatedTokens": .array([.number(256), .number(16_000)]),
"maximumOutlineDepth": .number(8),
]),
"screenshotSeries": stringArray(["viewport", "section"]),
"security": .object([
"tcpListener": .bool(false),
"arbitraryJavaScript": .bool(false),
"allowedNavigationSchemes": stringArray(["http", "https"]),
"blockedRemoteResourceExtensions": stringArray(blockedRemoteResourceExtensions.sorted()),
"cautionRemoteResourceExtensions": stringArray(cautionRemoteResourceExtensions.sorted()),
"downloadsDenied": .bool(true),
"remoteControl": .string("stdio MCP bridge or SSH forwarding only; no TCP listener"),
"networkSimulation": .string("Chromium CDP host on Linux; explicitly unsupported on WebKit"),
"sensitiveDiagnostics": .object([
"default": .string("redacted"),
"enableEnvironment": .string("HEADLESS_ALLOW_SENSITIVE_DIAGNOSTICS=1"),
]),
"maximumMessageBytes": .number(Double(headlessMaximumMessageBytes)),
]),
])
}()
2 changes: 2 additions & 0 deletions apps/headless/Sources/HeadlessProtocol/HostCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ public protocol BrowserEngine: AnyObject {
associatedtype Session: BrowserEngineSession
var name: String { get }
var platform: String { get }
var capabilities: BrowserEngineCapabilities { get }
func createSession() throws -> Session
func closeSession(_ session: Session)
func stop()
Expand Down Expand Up @@ -234,6 +235,7 @@ public final class HostCore<Engine: BrowserEngine>: @unchecked Sendable {
"engine": .string(engine.name),
"platform": .string(engine.platform),
"protocolVersion": .string(headlessProtocolVersion),
"capabilities": engine.capabilities.document,
"recordingAvailable": .bool(BrowserRecording.isAvailable()),
"artifactDirectory": .string(artifacts.rootURL.path),
]
Expand Down
57 changes: 57 additions & 0 deletions apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ private final class TestBrowserEngine: BrowserEngine {

let name = "fake"
let platform = "test"
let capabilities = BrowserEngineCapabilities.chromium
private(set) var createdSessions: [TestBrowserSession] = []
private(set) var closedSessions: [TestBrowserSession] = []
private(set) var stopped = false
Expand Down Expand Up @@ -1183,13 +1184,68 @@ struct ProtocolTests {
static func capabilitiesMatchProtocolCommands() throws {
guard case .object(let document) = capabilitiesDocument,
case .array(let rawCommands)? = document["commands"],
case .object(let engines)? = document["engines"],
case .object(let security)? = document["security"] else {
throw TestFailure(description: "capabilities document shape")
}
let commands = rawCommands.compactMap(\.stringValue)
let expected = CommandName.allCases.map(\.rawValue)
try expect(commands.count == expected.count, "capabilities should not omit or duplicate commands")
try expect(Set(commands) == Set(expected), "capabilities should match CommandName.allCases")
try expect(
engines.count == BrowserEngineName.allCases.count,
"capabilities should contain exactly one profile for every engine"
)
for profile in BrowserEngineCapabilities.all {
guard case .object(let engine)? = engines[profile.engine.rawValue],
case .array(let rawSupported)? = engine["commands"],
case .array(let rawUnsupported)? = engine["unsupportedCommands"],
case .object(let features)? = engine["features"] else {
throw TestFailure(description: "missing engine capability profile: \(profile.engine.rawValue)")
}
let supported = Set(rawSupported.compactMap(\.stringValue))
let unsupported = Set(rawUnsupported.compactMap(\.stringValue))
try expect(supported.isDisjoint(with: unsupported), "engine command sets must not overlap")
try expect(supported.union(unsupported) == Set(expected), "engine command sets must be exhaustive")
try expect(
features["tourTimeoutMs"] == .number(65_000),
"both engine profiles should declare the shared tour timeout"
)
try expect(
features["backWithoutHistory"] == .string("operation-failed"),
"both engines should fail consistently when back history is empty"
)
}
try expect(
BrowserEngineCapabilities.webkit.unsupportedCommands
== [.networkEmulate, .networkMockSet, .networkMockClear],
"WebKit unsupported commands should be explicit and exact"
)
try expect(
BrowserEngineCapabilities.chromium.unsupportedCommands.isEmpty,
"Chromium should implement every protocol command"
)
try expect(
document["currentEngine"] == .string(currentBrowserEngineCapabilities.engine.rawValue),
"capabilities should identify the engine for this binary"
)
guard case .array(let screenshotFormats)? = document["screenshotFormats"],
case .array(let recordingFormats)? = document["recordingFormats"],
case .array(let recordingQuality)? = document["recordingQuality"] else {
throw TestFailure(description: "generated capture capability shape")
}
try expect(
Set(screenshotFormats.compactMap(\.stringValue)) == ScreenshotFormat.artifactExtensions,
"screenshot capabilities should be generated from ScreenshotFormat"
)
try expect(
Set(recordingFormats.compactMap(\.stringValue)) == RecordingFormat.artifactExtensions,
"recording capabilities should be generated from RecordingFormat"
)
try expect(
Set(recordingQuality.compactMap(\.stringValue)) == Set(RecordingQuality.allCases.map(\.rawValue)),
"recording quality capabilities should be generated from RecordingQuality"
)
try expect(
security["maximumMessageBytes"] == .number(Double(headlessMaximumMessageBytes)),
"capabilities should publish the real frame bound"
Expand Down Expand Up @@ -1764,6 +1820,7 @@ struct ProtocolTests {
try expect(pingResult["engine"] == .string("fake"), "ping should identify the engine")
try expect(pingResult["platform"] == .string("test"), "ping should identify the platform")
try expect(pingResult["adapter"] == .string("test-adapter"), "engine ping details should be merged")
try expect(pingResult["capabilities"] != nil, "ping should publish the active engine profile")

let created = core.handle(CommandRequest(
command: .sessionCreate, parameters: ["name": .string("secondary")]
Expand Down
7 changes: 7 additions & 0 deletions apps/headless/docs/P2.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ lifecycle, traces, flows, captures, recordings, reports, artifacts, and error
mapping are implemented once. Unsupported engine features remain explicit
typed capability errors.

`headless capabilities` includes an exhaustive `engines` matrix generated from
the same engine profiles used by `HostCore`; `ping` returns the active profile.
The matrix declares command support and the intentional PDF, element-capture,
JPEG, cookie, recording-navigation, diagnostics, clipboard, and network
differences. Both engines return `OPERATION_FAILED` for `back` without history,
share the 65-second tour bound, and enforce the same key-length contract.

## Commands

```sh
Expand Down
7 changes: 7 additions & 0 deletions docs/roadmap/architecture-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ explicit (`UNSUPPORTED_CAPABILITY`); the point is that the *common* path is
single-sourced and the divergent one is declared, generated into
`capabilities`, and asserted by tests.

**Capability-matrix consequence (implemented 2026-08-10):** each engine owns
one exhaustive profile used by both `headless capabilities` and the active
host's additive `ping.capabilities` field. Profiles partition every protocol
command into supported and unsupported sets and declare behavioral differences
that cannot be made identical without weakening an engine. This compatible
response addition does not bump protocol 0.5.

## 4. Wire protocol: keep as-is, version bump only when necessary

**Decision:** keep newline-delimited JSON over the private Unix socket,
Expand Down
Loading
Loading