diff --git a/CHANGELOG.md b/CHANGELOG.md index 1214b60..5cad654 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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 diff --git a/apps/headless/Host/AgentBridge.swift b/apps/headless/Host/AgentBridge.swift index 674bd30..ca4804d 100644 --- a/apps/headless/Host/AgentBridge.swift +++ b/apps/headless/Host/AgentBridge.swift @@ -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 @@ -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 { diff --git a/apps/headless/LinuxHost/BrowserProcess.swift b/apps/headless/LinuxHost/BrowserProcess.swift index f813444..0f79707 100644 --- a/apps/headless/LinuxHost/BrowserProcess.swift +++ b/apps/headless/LinuxHost/BrowserProcess.swift @@ -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]) } diff --git a/apps/headless/LinuxHost/main.swift b/apps/headless/LinuxHost/main.swift index 4b0ec17..db2289f 100644 --- a/apps/headless/LinuxHost/main.swift +++ b/apps/headless/LinuxHost/main.swift @@ -9,6 +9,7 @@ final class ChromiumBrowserEngine: BrowserEngine { let name = "chromium" let platform = "linux" + let capabilities = BrowserEngineCapabilities.chromium let browser: ChromiumProcess init() throws { diff --git a/apps/headless/Sources/HeadlessProtocol/CLI.swift b/apps/headless/Sources/HeadlessProtocol/CLI.swift index 39dd744..5942dff 100644 --- a/apps/headless/Sources/HeadlessProtocol/CLI.swift +++ b/apps/headless/Sources/HeadlessProtocol/CLI.swift @@ -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)), - ]), -]) diff --git a/apps/headless/Sources/HeadlessProtocol/Capabilities.swift b/apps/headless/Sources/HeadlessProtocol/Capabilities.swift new file mode 100644 index 0000000..736f5ad --- /dev/null +++ b/apps/headless/Sources/HeadlessProtocol/Capabilities.swift @@ -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 + 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(_ 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)), + ]), + ]) +}() diff --git a/apps/headless/Sources/HeadlessProtocol/HostCore.swift b/apps/headless/Sources/HeadlessProtocol/HostCore.swift index a58e39f..e68d723 100644 --- a/apps/headless/Sources/HeadlessProtocol/HostCore.swift +++ b/apps/headless/Sources/HeadlessProtocol/HostCore.swift @@ -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() @@ -234,6 +235,7 @@ public final class HostCore: @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), ] diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index b5b6fee..e2f6436 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -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 @@ -1183,6 +1184,7 @@ 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") } @@ -1190,6 +1192,60 @@ struct ProtocolTests { 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" @@ -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")] diff --git a/apps/headless/docs/P2.md b/apps/headless/docs/P2.md index 9f15ec1..dcf8fb2 100644 --- a/apps/headless/docs/P2.md +++ b/apps/headless/docs/P2.md @@ -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 diff --git a/docs/roadmap/architecture-decisions.md b/docs/roadmap/architecture-decisions.md index 06ad21a..a1886b2 100644 --- a/docs/roadmap/architecture-decisions.md +++ b/docs/roadmap/architecture-decisions.md @@ -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, diff --git a/docs/roadmap/improvements-backlog.md b/docs/roadmap/improvements-backlog.md index d699831..50f4584 100644 --- a/docs/roadmap/improvements-backlog.md +++ b/docs/roadmap/improvements-backlog.md @@ -205,7 +205,7 @@ each candidate is measured once, largest entries are pruned regardless of position while retained order is stable, text uses a byte-budgeted prefix search, and jsdom locks the mid-array and text-fallback cases. -**B6. Declared capability matrix.** ([#26](https://github.com/LockInTime/headless/issues/26)) Silent per-platform divergences to either +**B6. Declared capability matrix.** ([#26](https://github.com/LockInTime/headless/issues/26)) ~~Silent per-platform divergences to either fix or promote to declared differences asserted in tests: PDF raster (macOS, `Host/AgentBridge.swift:236-250`) vs vector (`Page.printToPDF`, `BrowserProcess.swift:471-496`); element-screenshot coordinate space viewport @@ -220,7 +220,12 @@ capture-info/report shapes; `back` with no history errors on Linux through it; `press` length enforced in bridge only on macOS (`AgentBridge.swift:73`); Linux `qa report` flush hack (`BrowserProcess.swift:521`). Generate `capabilities` from code -(`CLI.swift:648-682` is a hand-written literal today) and assert it. +(`CLI.swift:648-682` is a hand-written literal today) and assert it.~~ **Done:** +WebKit and Chromium now declare exhaustive command sets and typed profiles for +every audited difference. The CLI document and host ping output are generated +from those profiles and the protocol/format enums. Empty-history `back`, tour +timeouts, and key validation are aligned; irreducible engine behavior is +explicit and regression-tested. **B7. Runtime injection cost** ([#27](https://github.com/LockInTime/headless/issues/27)) — ~~cache the isolated world / install runtime per-navigation instead of per-call on both engines; extract the JS to a