From 7c037defb9713918dca473fd1619cdf7981d4588 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:37:53 +0000 Subject: [PATCH 1/4] refactor(host): extract shared HostCore --- AGENTS.md | 9 +- CHANGELOG.md | 3 + apps/headless/Host/AgentBridge.swift | 146 ++-- apps/headless/LinuxHost/BrowserProcess.swift | 46 +- apps/headless/LinuxHost/main.swift | 582 ++++------------ .../Sources/HeadlessProtocol/HostCore.swift | 628 ++++++++++++++++++ .../Sources/HeadlessProtocol/HostError.swift | 9 + .../Sources/HeadlessProtocol/Protocol.swift | 57 ++ .../HeadlessProtocolTests/ProtocolTests.swift | 154 +++++ apps/headless/docs/P2.md | 7 + apps/headless/main.swift | 477 +------------ docs/roadmap/architecture-decisions.md | 4 +- docs/roadmap/improvements-backlog.md | 9 +- 13 files changed, 1153 insertions(+), 978 deletions(-) create mode 100644 apps/headless/Sources/HeadlessProtocol/HostCore.swift diff --git a/AGENTS.md b/AGENTS.md index 3dcd39c..e2e28a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,11 +104,10 @@ If a change brushes against any of these, stop and record a decision in without an architecture-decision entry. Match existing style (explicit validation, small structs, no force-unwraps in host paths — existing ones are backlog items §A2, don't add more). -- A new protocol command currently must be added in *both* hosts - (`main.swift` and `LinuxHost/main.swift`), the validator - (`Protocol.swift`), the CLI (`CLI.swift`), help text, and `capabilities` — - until the Phase 2 HostCore refactor lands, keep all copies in sync and add - parse + E2E coverage on both platforms. +- Add a new portable protocol command once in `HostCore`, plus the validator + (`Protocol.swift`), CLI (`CLI.swift`), help text, and capability declaration. + Add only engine-specific operations to both `BrowserEngineSession` adapters, + and retain parse + E2E coverage on both platforms. - The agent runtime JS lives in `Sources/HeadlessProtocol/AgentRuntime.swift` as a raw string; `Tests/agent-runtime.test.mjs` regex-extracts it — if you touch the string delimiters, fix the extractor. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4af3b38..1214b60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,9 @@ Cutting that release is tracked in ### Changed +- macOS WebKit and Linux Chromium now share one `HostCore` dispatcher and + lifecycle implementation behind small engine adapters, eliminating the two + divergent copies of flow, capture, recording, report, trace, and error logic. - 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. diff --git a/apps/headless/Host/AgentBridge.swift b/apps/headless/Host/AgentBridge.swift index 0fc4b8a..674bd30 100644 --- a/apps/headless/Host/AgentBridge.swift +++ b/apps/headless/Host/AgentBridge.swift @@ -38,12 +38,12 @@ extension BrowserWindowController { } func agentClick(parameters: [String: JSONValue]) throws -> JSONValue { - let args = try targetArguments(parameters) + let args = try browserTargetArguments(parameters) return try callAgent("return globalThis.__headlessAgent.click(args);", arguments: ["args": args]) } func agentFill(parameters: [String: JSONValue]) throws -> JSONValue { - var args = try targetArguments(parameters) + var args = try browserTargetArguments(parameters) guard let value = parameters["value"]?.stringValue else { throw HostError(code: .operationFailed, message: "Missing command parameter: value") } @@ -150,7 +150,7 @@ extension BrowserWindowController { var requestedRect: CGRect? let hasTarget = parameters["target"] != nil || parameters["role"] != nil || parameters["name"] != nil if hasTarget { - let args = try targetArguments(parameters) + let args = try browserTargetArguments(parameters) let value = try callAgent( "return globalThis.__headlessAgent.rectangle(args);", arguments: ["args": args] ) @@ -271,7 +271,7 @@ extension BrowserWindowController { } func agentStyles(parameters: [String: JSONValue]) throws -> JSONValue { - let args = try targetArguments(parameters) + let args = try browserTargetArguments(parameters) var input = args if case .array(let properties)? = parameters["properties"] { input["properties"] = properties.compactMap(\.stringValue) @@ -280,7 +280,7 @@ extension BrowserWindowController { } func agentStorage(scope: String, includeValues: Bool) throws -> JSONValue { - try requireSensitiveDiagnosticsIfNeeded(includeValues) + try requireSensitiveDiagnosticsAccess(if: includeValues) return try callAgent( "return globalThis.__headlessAgent.storage(args);", arguments: ["args": ["scope": scope, "includeValues": includeValues]] @@ -296,7 +296,7 @@ extension BrowserWindowController { } func agentCookies(includeValues: Bool) throws -> JSONValue { - try requireSensitiveDiagnosticsIfNeeded(includeValues) + try requireSensitiveDiagnosticsAccess(if: includeValues) let semaphore = DispatchSemaphore(value: 0) let lock = NSLock() var result: [HTTPCookie] = [] @@ -339,38 +339,12 @@ extension BrowserWindowController { ]) } - private func requireSensitiveDiagnosticsIfNeeded(_ requested: Bool) throws { - guard !requested || ProcessInfo.processInfo.environment["HEADLESS_ALLOW_SENSITIVE_DIAGNOSTICS"] == "1" else { - throw HostError(code: .sensitiveDiagnosticsDisabled, message: "Sensitive diagnostic values are disabled.") - } - } - private func screenshotRect(_ object: [String: JSONValue]) throws -> CGRect { - guard let x = object["x"]?.numberValue, let y = object["y"]?.numberValue, - let width = object["width"]?.numberValue, let height = object["height"]?.numberValue, - 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 HostError(code: .operationFailed, message: "Screenshot dimensions exceed safety limits") - } - return CGRect(x: x, y: y, width: width, height: height) - } - - private func targetArguments(_ parameters: [String: JSONValue]) throws -> [String: Any] { - var args: [String: Any] = [:] - if let target = parameters["target"]?.stringValue { - guard target.hasPrefix("@e"), target.count <= 16 else { - 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 HostError(code: .operationFailed, message: "Missing command parameter: target") - } - } - return args + let rectangle = try BoundedScreenshotRectangle(object) + return CGRect( + x: rectangle.x, y: rectangle.y, + width: rectangle.width, height: rectangle.height + ) } private func callAgent( @@ -433,6 +407,104 @@ extension BrowserWindowController { } } +final class WebKitBrowserEngine: BrowserEngine { + typealias Session = BrowserWindowController + + let name = "webkit" + let platform = "macos" + private let create: () throws -> BrowserWindowController + private let close: (BrowserWindowController) -> Void + private let stopEngine: () -> Void + + init( + create: @escaping () throws -> BrowserWindowController, + close: @escaping (BrowserWindowController) -> Void, + stop: @escaping () -> Void = {} + ) { + self.create = create + self.close = close + self.stopEngine = stop + } + + func createSession() throws -> BrowserWindowController { try create() } + func closeSession(_ session: BrowserWindowController) { close(session) } + func stop() { stopEngine() } +} + +extension BrowserWindowController: BrowserEngineSession { + func hostEnableAgentControl() { onMain { self.enableAgentControl() } } + func hostVisit(_ url: URL) throws -> JSONValue { try agentVisit(url) } + func hostInspect(parameters: [String: JSONValue]) throws -> JSONValue { + try agentInspect(parameters: parameters) + } + func hostClick(parameters: [String: JSONValue]) throws -> JSONValue { + try agentClick(parameters: parameters) + } + func hostFill(parameters: [String: JSONValue]) throws -> JSONValue { + try agentFill(parameters: parameters) + } + func hostPress(parameters: [String: JSONValue]) throws -> JSONValue { + try agentPress(parameters: parameters) + } + func hostScroll(parameters: [String: JSONValue]) throws -> JSONValue { + try agentScroll(parameters: parameters) + } + func hostWait(parameters: [String: JSONValue]) throws -> JSONValue { + try agentWait(parameters: parameters) + } + func hostTour(parameters: [String: JSONValue]) throws -> JSONValue { + try agentTour(parameters: parameters) + } + func hostBack() throws -> JSONValue { + onMain { _ = self.webView.goBack() } + return try agentWait(parameters: ["settled": .bool(true)]) + } + func hostReload() throws -> JSONValue { + _ = onMain { self.webView.reload() } + return try agentWait(parameters: ["settled": .bool(true)]) + } + func hostCaptureInfo() throws -> JSONValue { try agentCaptureInfo() } + func hostScreenshot( + parameters: [String: JSONValue], format: ScreenshotFormat, copyToClipboard: Bool + ) throws -> BrowserScreenshot { + let screenshot = try agentScreenshotData( + parameters: parameters, format: format, copyToClipboard: copyToClipboard + ) + return BrowserScreenshot( + data: screenshot.data, clipboardCopied: screenshot.clipboardCopied + ) + } + func hostRecordingFrame() throws -> Data { try agentScreenshot(parameters: [:]) } + func hostScreenshotSeriesPlan(mode: String) throws -> JSONValue { + try agentScreenshotSeriesPlan(mode: mode) + } + func hostScrollToCapturePoint(y: Double) throws -> JSONValue { + try agentScrollToCapturePoint(y: y) + } + func hostQAReport() throws -> JSONValue { agentQAReport() } + func hostQAClear() throws -> JSONValue { agentQAClear() } + func hostConsole(level: String, limit: Int) throws -> JSONValue { + agentConsole(level: level, limit: limit) + } + func hostNetwork(failedOnly: Bool, status: Int?, limit: Int) throws -> JSONValue { + agentNetwork(failedOnly: failedOnly, status: status, limit: limit) + } + func hostNetworkDetail(requestID: String) throws -> JSONValue { + agentNetworkDetail(requestID: requestID) + } + func hostStyles(parameters: [String: JSONValue]) throws -> JSONValue { + try agentStyles(parameters: parameters) + } + func hostCookies(includeValues: Bool) throws -> JSONValue { + try agentCookies(includeValues: includeValues) + } + func hostStorage(scope: String, includeValues: Bool) throws -> JSONValue { + try agentStorage(scope: scope, includeValues: includeValues) + } + func hostPerformance() throws -> JSONValue { try agentPerformance() } + func hostAnimations() throws -> JSONValue { try agentAnimations() } +} + private func onMain(_ body: @escaping () -> T) -> T { if Thread.isMainThread { return body() } return DispatchQueue.main.sync(execute: body) diff --git a/apps/headless/LinuxHost/BrowserProcess.swift b/apps/headless/LinuxHost/BrowserProcess.swift index 790be25..f813444 100644 --- a/apps/headless/LinuxHost/BrowserProcess.swift +++ b/apps/headless/LinuxHost/BrowserProcess.swift @@ -339,7 +339,7 @@ final class LinuxBrowserSession: @unchecked Sendable { } func click(parameters: [String: JSONValue]) throws -> JSONValue { - let args = try targetArguments(parameters) + let args = try browserTargetArguments(parameters) let result = try evaluate("return globalThis.__headlessAgent.click(args);", input: ["args": args]) // A click can synchronously begin a cross-document navigation. Let the // foreground wait command observe that transition before frame capture @@ -349,7 +349,7 @@ final class LinuxBrowserSession: @unchecked Sendable { } func fill(parameters: [String: JSONValue]) throws -> JSONValue { - var args = try targetArguments(parameters) + var args = try browserTargetArguments(parameters) guard let value = parameters["value"]?.stringValue else { throw CDPError.commandFailed("missing value") } args["value"] = value return try evaluate("return globalThis.__headlessAgent.fill(args);", input: ["args": args]) @@ -446,7 +446,7 @@ final class LinuxBrowserSession: @unchecked Sendable { let hasTarget = parameters["target"] != nil || parameters["role"] != nil || parameters["name"] != nil if hasTarget { capture["captureBeyondViewport"] = true - let args = try targetArguments(parameters) + let args = try browserTargetArguments(parameters) let rectangle = try evaluate( "return globalThis.__headlessAgent.rectangle(args);", input: ["args": args] ) @@ -541,7 +541,7 @@ final class LinuxBrowserSession: @unchecked Sendable { } func styles(parameters: [String: JSONValue]) throws -> JSONValue { - var args = try targetArguments(parameters) + var args = try browserTargetArguments(parameters) if case .array(let properties)? = parameters["properties"] { args["properties"] = properties.compactMap(\.stringValue) } @@ -549,7 +549,7 @@ final class LinuxBrowserSession: @unchecked Sendable { } func storage(scope: String, includeValues: Bool) throws -> JSONValue { - try requireSensitiveDiagnosticsIfNeeded(includeValues) + try requireSensitiveDiagnosticsAccess(if: includeValues) return try evaluate( "return globalThis.__headlessAgent.storage(args);", input: ["args": ["scope": scope, "includeValues": includeValues]] @@ -610,7 +610,7 @@ final class LinuxBrowserSession: @unchecked Sendable { } func cookies(includeValues: Bool) throws -> JSONValue { - try requireSensitiveDiagnosticsIfNeeded(includeValues) + try requireSensitiveDiagnosticsAccess(if: includeValues) let response = try command("Network.getCookies") let rawCookies = response["cookies"] as? [[String: Any]] ?? [] let cookies: [JSONValue] = rawCookies.prefix(200).map { cookie in @@ -776,14 +776,11 @@ final class LinuxBrowserSession: @unchecked Sendable { } private func screenshotClip(_ rect: [String: JSONValue]) throws -> [String: Any] { - guard let x = rect["x"]?.numberValue, let y = rect["y"]?.numberValue, - let width = rect["width"]?.numberValue, let height = rect["height"]?.numberValue, - 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 CDPError.commandFailed("screenshot dimensions exceed safety limits") - } - return ["x": x, "y": y, "width": width, "height": height, "scale": 1] + let rectangle = try BoundedScreenshotRectangle(rect) + return [ + "x": rectangle.x, "y": rectangle.y, + "width": rectangle.width, "height": rectangle.height, "scale": 1, + ] } private func evaluate(_ body: String, input: [String: Any] = [:], timeout: TimeInterval = 10) throws -> JSONValue { @@ -889,27 +886,6 @@ final class LinuxBrowserSession: @unchecked Sendable { navigationLock.unlock() } - private func targetArguments(_ parameters: [String: JSONValue]) throws -> [String: Any] { - if let target = parameters["target"]?.stringValue { - guard target.hasPrefix("@e"), target.count <= 16 else { throw CDPError.commandFailed("invalid element ref") } - return ["target": target] - } - var result: [String: Any] = [:] - if let role = parameters["role"]?.stringValue { result["role"] = role } - if let name = parameters["name"]?.stringValue { result["name"] = name } - guard !result.isEmpty else { throw CDPError.commandFailed("missing target") } - return result - } - - private func requireSensitiveDiagnosticsIfNeeded(_ requested: Bool) throws { - guard !requested || ProcessInfo.processInfo.environment["HEADLESS_ALLOW_SENSITIVE_DIAGNOSTICS"] == "1" else { - throw HostError( - code: .sensitiveDiagnosticsDisabled, - message: "Sensitive diagnostic values are disabled." - ) - } - } - private func stringHeaders(_ headers: [String: Any]?) -> [String: String] { diagnosticStringHeaders(headers) } diff --git a/apps/headless/LinuxHost/main.swift b/apps/headless/LinuxHost/main.swift index 48d49aa..4b0ec17 100644 --- a/apps/headless/LinuxHost/main.swift +++ b/apps/headless/LinuxHost/main.swift @@ -4,478 +4,170 @@ import Foundation import Glibc #endif -final class LinuxBrowserHost: @unchecked Sendable { - private let browser: ChromiumProcess - private var sessions: [String: LinuxBrowserSession] = [:] - private var trace: [String: [JSONValue]] = [:] - private var activeFlows: [String: [RecordedFlowStep]] = [:] - private var recordings: [String: BrowserRecording] = [:] - private let traceStartedAt = ProcessInfo.processInfo.systemUptime - private let artifacts: ArtifactStore - private let stateLock = NSLock() - private var stopping = false - var onShutdown: (() -> Void)? +final class ChromiumBrowserEngine: BrowserEngine { + typealias Session = ChromiumBrowserEngineSession + + let name = "chromium" + let platform = "linux" + let browser: ChromiumProcess init() throws { - artifacts = try ArtifactStore() browser = try ChromiumProcess() - sessions["default"] = try browser.createSession() - trace["default"] = [] } - /// Every mutation of `sessions`, `trace`, `activeFlows`, and `recordings` - /// goes through here. `shutdown` deliberately bypasses the transport's - /// request queue so an operator can always stop a stalled browser action, - /// which means `stop()` runs while a normal command may still be in - /// flight — without this lock both threads mutate the same dictionaries. - /// Hold it only around collection access, never across browser I/O, so a - /// stalled command cannot delay teardown. - private func withState(_ body: () -> T) -> T { - stateLock.lock() - defer { stateLock.unlock() } - return body() + func createSession() throws -> ChromiumBrowserEngineSession { + ChromiumBrowserEngineSession(engine: self, browserSession: try browser.createSession()) } - private func lookupSession(_ name: String) -> LinuxBrowserSession? { - withState { sessions[name] } + func closeSession(_ session: ChromiumBrowserEngineSession) { + browser.closeSession(session.browserSession) } - private func lookupRecording(_ name: String) -> BrowserRecording? { - withState { recordings[name] } - } + func stop() { browser.stop() } - private func traceEvents(for name: String) -> [JSONValue] { - withState { trace[name] ?? [] } + func pingDetails() -> [String: JSONValue] { + [ + "browserExecutable": .string(browser.runtime.executableURL.path), + "browserRuntimeSource": .string(browser.runtime.source.rawValue), + "browserTransport": .string("inherited-devtools-pipe"), + ] } - func stop() { - let (activeRecordings, openSessions) = withState { - () -> ([BrowserRecording], [LinuxBrowserSession]) in - if stopping { return ([], []) } - stopping = true - let capturedRecordings = Array(recordings.values) - let capturedSessions = Array(sessions.values) - recordings.removeAll() - sessions.removeAll() - trace.removeAll() - activeFlows.removeAll() - return (capturedRecordings, capturedSessions) + func hostError(for error: Error) -> HostError? { + guard let error = error as? CDPError else { return nil } + switch error { + case .timedOut: + return HostError(code: .timedOut, message: error.description) + default: + return HostError(code: .operationFailed, message: error.description) } - for recording in activeRecordings { _ = try? recording.stop(timeout: 5) } - for session in openSessions { browser.closeSession(session) } - browser.stop() } +} - private func captureScreenshotSeries( - session: LinuxBrowserSession, - parameters: [String: JSONValue] - ) throws -> JSONValue { - let mode = parameters["series"]?.stringValue ?? "viewport" - let format = try screenshotFormat( - explicit: parameters["format"]?.stringValue, - output: nil - ) - let rawPlan = try session.screenshotSeriesPlan(mode: mode) - let plan = try parseScreenshotSeriesPlan(rawPlan) - let points = plan.points - let prefix = try screenshotSeriesPrefix(parameters: parameters, mode: mode) - defer { _ = try? session.scrollToCapturePoint(y: plan.initialY) } - let reserved = try reserveScreenshotSeriesArtifacts( - store: artifacts, points: points, prefix: prefix, mode: mode, format: format - ) - do { - let metadata = try points.enumerated().map { index, point -> JSONValue in - _ = try session.scrollToCapturePoint(y: point.y) - let data = try session.screenshot(parameters: [:], format: format) - return try artifacts.writeReserved(data, to: reserved[index]) - } - return screenshotSeriesSummary( - mode: mode, points: points, artifacts: metadata, - truncated: plan.truncated, totalPoints: plan.totalPoints - ) - } catch { - artifacts.discardReserved(reserved) - throw error - } - } +final class ChromiumBrowserEngineSession: BrowserEngineSession { + unowned let engine: ChromiumBrowserEngine + let browserSession: LinuxBrowserSession - func handle(_ request: CommandRequest) -> CommandResponse { - if request.command == .ping { - return .success(id: request.id, result: .object([ - "ready": .bool(true), "pid": .number(Double(ProcessInfo.processInfo.processIdentifier)), - "engine": .string("chromium"), "platform": .string("linux"), - "protocolVersion": .string(headlessProtocolVersion), - "recordingAvailable": .bool(BrowserRecording.isAvailable()), - "artifactDirectory": .string(artifacts.rootURL.path), - "browserExecutable": .string(browser.runtime.executableURL.path), - "browserRuntimeSource": .string(browser.runtime.source.rawValue), - "browserTransport": .string("inherited-devtools-pipe"), - ])) - } - if request.command == .shutdown { - DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) { self.onShutdown?() } - return .success(id: request.id, result: .object(["stopping": .bool(true)])) - } - do { - if request.command == .artifactList { - return .success(id: request.id, result: try artifacts.list()) - } - switch request.command { - case .sessionCreate: - guard let name = request.parameters["name"]?.stringValue else { return failure(request, "MISSING_PARAMETER", "Session name is required.") } - try validateIdentifier(name, field: "session") - guard withState({ sessions[name] == nil }) else { return failure(request, "SESSION_EXISTS", "Session already exists: \(name)") } - let created = try browser.createSession() - // Re-check under the lock: teardown may have started while the - // browser was creating the target. - let rejection = withState { () -> String? in - if stopping { return "HOST_UNAVAILABLE" } - if sessions[name] != nil { return "SESSION_EXISTS" } - sessions[name] = created - trace[name] = [] - return nil - } - if let rejection { - browser.closeSession(created) - return failure( - request, rejection, - rejection == "SESSION_EXISTS" - ? "Session already exists: \(name)" - : "Host is shutting down." - ) - } - record(.sessionCreate, session: name) - return .success(id: request.id, result: .object(["session": .string(name)])) - case .sessionList: - let names = withState { sessions.keys.sorted() } - return .success(id: request.id, result: .object(["sessions": .array(names.map(JSONValue.string))])) - case .sessionClose: - let name = request.session ?? "default" - let closing = withState { () -> (LinuxBrowserSession?, BrowserRecording?) in - let session = sessions.removeValue(forKey: name) - guard session != nil else { return (nil, nil) } - let recording = recordings.removeValue(forKey: name) - trace.removeValue(forKey: name) - activeFlows.removeValue(forKey: name) - return (session, recording) - } - guard let session = closing.0 else { return missingSession(request, name) } - if let recording = closing.1 { _ = try? recording.stop(timeout: 5) } - browser.closeSession(session) - return .success(id: request.id, result: .object(["closed": .string(name)])) - default: - break - } + init(engine: ChromiumBrowserEngine, browserSession: LinuxBrowserSession) { + self.engine = engine + self.browserSession = browserSession + } - let name = request.session ?? "default" - guard let session = lookupSession(name) else { return missingSession(request, name) } - let result: JSONValue - switch request.command { - case .visit: - guard let value = request.parameters["url"]?.stringValue else { return failure(request, "MISSING_PARAMETER", "URL is required.") } - result = try session.visit(normalizedWebURL(value)) - case .inspect: - result = try session.inspect(parameters: request.parameters) - case .click: result = try session.click(parameters: request.parameters) - case .fill: result = try session.fill(parameters: request.parameters) - case .press: result = try session.press(parameters: request.parameters) - case .scroll: result = try session.scroll(parameters: request.parameters) - case .wait: result = try session.wait(parameters: request.parameters) - case .tour: result = try session.tour(parameters: request.parameters) - case .back: result = try session.back() - case .reload: result = try session.reload() - case .captureInfo: - result = .object([ - "engine": .string("chromium"), "headless": .bool(browser.headless), - "browserPid": .number(Double(browser.processIdentifier)), - "browserExecutable": .string(browser.runtime.executableURL.path), - "browserRuntimeSource": .string(browser.runtime.source.rawValue), - "browserTransport": .string("inherited-devtools-pipe"), - "targetId": .string(session.targetID), "page": try session.state(), - "trace": .array(traceEvents(for: name)), - "recording": lookupRecording(name)?.status() ?? .object(["active": .bool(false)]), - ]) - case .screenshot: - if request.parameters["series"]?.stringValue != nil { - result = try captureScreenshotSeries(session: session, parameters: request.parameters) - } else { - if request.parameters["clipboard"]?.boolValue == true { - throw CDPError.commandFailed("Clipboard screenshots are only supported by the macOS host") - } - let format = try screenshotFormat( - explicit: request.parameters["format"]?.stringValue, - output: request.parameters["output"]?.stringValue - ) - let data = try session.screenshot(parameters: request.parameters, format: format) - result = try artifacts.write( - data, requestedName: request.parameters["output"]?.stringValue, - extension: artifactExtension(request.parameters["output"]?.stringValue ?? "") ?? format.fileExtension, - prefix: "screenshot-\(name)" - ) - } - case .recordStart: - guard lookupRecording(name) == nil else { throw RecordingError.alreadyActive } - let format = try recordingFormat( - explicit: request.parameters["format"]?.stringValue, - output: request.parameters["output"]?.stringValue - ) - let quality = try RecordingQuality.parse(request.parameters["quality"]?.stringValue ?? "balanced") - let output = try artifacts.reserve( - requestedName: request.parameters["output"]?.stringValue, - extension: format.fileExtension, prefix: "recording-\(name)" - ) - let fps = request.parameters["fps"]?.numberValue ?? 10 - let recording: BrowserRecording - do { - recording = try BrowserRecording( - outputURL: output, fps: fps, format: format, quality: quality - ) { [weak session] in - guard let session else { throw RecordingError.captureFailed("session closed") } - return try session.recordingFrame() - } - } catch { - try? FileManager.default.removeItem(at: output) - throw error - } - // Registering under the lock keeps a recording started during - // teardown from outliving the host as an orphaned FFmpeg - // process that nothing will ever stop. - let registered = withState { () -> Bool in - guard !stopping, recordings[name] == nil else { return false } - recordings[name] = recording - return true - } - guard registered else { - _ = try? recording.stop(timeout: 5) - try? FileManager.default.removeItem(at: output) - throw CDPError.commandFailed("Host is shutting down") - } - result = recording.status() - case .recordStatus: - result = lookupRecording(name)?.status() ?? .object(["active": .bool(false)]) - case .recordStop: - guard let activeRecording = lookupRecording(name) else { throw RecordingError.notActive } - if let output = request.parameters["output"]?.stringValue, - let actual = artifactExtension(output), - actual != activeRecording.format.fileExtension { - throw CaptureFormatError.mismatchedOutputFormat( - expected: activeRecording.format.fileExtension, - actual: actual - ) - } - guard let recording = withState({ recordings.removeValue(forKey: name) }) else { throw RecordingError.notActive } - let recordingStatus: JSONValue - do { recordingStatus = try recording.stop() } - catch { - try? FileManager.default.removeItem(at: recording.outputURL) - throw error - } - let artifact = try artifacts.finalize( - recording.outputURL, renameTo: request.parameters["output"]?.stringValue - ) - result = merge(recordingStatus, with: artifact) - case .qaReport: - result = try session.qaReport() - case .qaClear: - result = session.diagnostics.clear() - case .consoleList: - result = session.console( - level: request.parameters["level"]?.stringValue ?? "all", - limit: Int(request.parameters["limit"]?.numberValue ?? 100) - ) - case .networkList: - result = session.network( - failedOnly: request.parameters["failed"]?.boolValue ?? false, - status: request.parameters["status"]?.numberValue.map(Int.init), - limit: Int(request.parameters["limit"]?.numberValue ?? 100) - ) - case .networkGet: - guard let requestID = request.parameters["requestId"]?.stringValue else { - return failure(request, "MISSING_PARAMETER", "Network request ID is required.") - } - result = session.networkDetail(requestID: requestID) - case .stylesGet: - result = try session.styles(parameters: request.parameters) - case .cookiesList: - result = try session.cookies(includeValues: request.parameters["includeValues"]?.boolValue ?? false) - case .storageList: - result = try session.storage( - scope: request.parameters["scope"]?.stringValue ?? "all", - includeValues: request.parameters["includeValues"]?.boolValue ?? false - ) - case .performanceGet: result = try session.performance() - case .animationList: result = try session.animations() - case .networkEmulate: result = try session.emulateNetwork(parameters: request.parameters) - case .networkMockSet: result = try session.setNetworkMock(parameters: request.parameters) - case .networkMockClear: result = try session.clearNetworkMocks() - case .visualCompare: - guard let before = request.parameters["before"]?.stringValue else { - return failure(request, "MISSING_PARAMETER", "Before artifact name is required.") - } - guard let after = request.parameters["after"]?.stringValue else { - return failure(request, "MISSING_PARAMETER", "After artifact name is required.") - } - _ = try artifacts.read(name: before, expectedExtension: "png", maximumBytes: 100 * 1_024 * 1_024) - _ = try artifacts.read(name: after, expectedExtension: "png", maximumBytes: 100 * 1_024 * 1_024) - let difference = try artifacts.reserve(requestedName: request.parameters["output"]?.stringValue, - extension: "png", prefix: "difference-\(name)") - let comparison = try VisualComparison.compare( - before: artifacts.rootURL.appendingPathComponent(before), - after: artifacts.rootURL.appendingPathComponent(after), difference: difference - ) - result = merge(comparison, with: try artifacts.finalize(difference, renameTo: nil)) - case .reportCreate: - let report: JSONValue = .object([ - "format": .string("headless-qa-report-v1"), - "createdAt": .number(Date().timeIntervalSince1970), "session": .string(name), - "page": .object(["engine": .string("chromium"), "state": try session.state()]), - "qa": try session.qaReport(), "trace": .array(traceEvents(for: name)), - "artifacts": try artifacts.list(), - "security": .object(["sensitiveValuesIncluded": .bool(false), "transport": .string("local-unix-socket")]), - ]) - result = try artifacts.write(ProtocolCodec.encoder.encode(report), - requestedName: request.parameters["output"]?.stringValue, - extension: "json", prefix: "qa-report-\(name)") - case .flowStart: - withState { activeFlows[name] = [] } - result = .object(["recording": .bool(true), "note": .string("Only safe navigation actions are recorded; typed values and credentials are never stored.")]) - case .flowStop: - let steps = withState { activeFlows.removeValue(forKey: name) } ?? [] - result = try artifacts.write(ProtocolCodec.encoder.encode(RecordedFlow(commands: steps)), - requestedName: request.parameters["output"]?.stringValue, - extension: "json", prefix: "flow-\(name)") - case .flowRun: - guard let input = request.parameters["input"]?.stringValue else { return failure(request, "MISSING_PARAMETER", "Flow input is required.") } - let flow = try ProtocolCodec.decoder.decode(RecordedFlow.self, from: artifacts.read(name: input, expectedExtension: "json", maximumBytes: 1_024 * 1_024)) - guard flow.version == 1, flow.commands.count <= 200, - flow.commands.allSatisfy({ replayableFlowCommands.contains($0.command) }) else { - return failure(request, "INVALID_FLOW", "Flow contains unsupported commands.") - } - var completed = 0 - for step in flow.commands { - try CommandRequest(command: step.command, session: name, parameters: step.parameters).validate() - let response = handle(CommandRequest(command: step.command, session: name, parameters: step.parameters)) - guard response.ok else { return failure(request, "FLOW_FAILED", "Step \(completed + 1) (\(step.command.rawValue)) failed: \(response.error?.message ?? "unknown error")") } - completed += 1 - } - result = .object(["completed": .number(Double(completed)), "input": .string(input)]) - case .ping, .shutdown, .sessionCreate, .sessionList, .sessionClose, .artifactList: - return failure(request, "INVALID_COMMAND", "Command is not valid in this context.") - } - record(request.command, session: name, result: result) - if let step = flowStepIfSafe(command: request.command, parameters: request.parameters) { - withState { () -> Void in - guard let steps = activeFlows[name], steps.count < 200 else { return } - activeFlows[name] = steps + [step] - } - } - 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 { - switch error { - case .timedOut: - let hostError = HostError(code: .timedOut, message: error.description) - return .failure( - id: request.id, code: hostError.code.rawValue, - message: hostError.message, suggestion: hostError.suggestion - ) - default: - return .failure( - id: request.id, code: HostErrorCode.operationFailed.rawValue, - message: error.description - ) - } - } catch let error as RecordingError { - let code: String - let suggestion: String? - switch error { - case .unavailable: - code = "RECORDER_UNAVAILABLE" - suggestion = "Install FFmpeg or set HEADLESS_FFMPEG_EXECUTABLE." - case .alreadyActive: code = "RECORDING_ACTIVE"; suggestion = nil - case .notActive: code = "RECORDING_NOT_ACTIVE"; suggestion = nil - default: code = "RECORDING_FAILED"; suggestion = nil - } - return .failure( - id: request.id, code: code, message: error.description, - suggestion: suggestion + func hostVisit(_ url: URL) throws -> JSONValue { try browserSession.visit(url) } + func hostInspect(parameters: [String: JSONValue]) throws -> JSONValue { + try browserSession.inspect(parameters: parameters) + } + func hostClick(parameters: [String: JSONValue]) throws -> JSONValue { + try browserSession.click(parameters: parameters) + } + func hostFill(parameters: [String: JSONValue]) throws -> JSONValue { + try browserSession.fill(parameters: parameters) + } + func hostPress(parameters: [String: JSONValue]) throws -> JSONValue { + try browserSession.press(parameters: parameters) + } + func hostScroll(parameters: [String: JSONValue]) throws -> JSONValue { + try browserSession.scroll(parameters: parameters) + } + func hostWait(parameters: [String: JSONValue]) throws -> JSONValue { + try browserSession.wait(parameters: parameters) + } + func hostTour(parameters: [String: JSONValue]) throws -> JSONValue { + try browserSession.tour(parameters: parameters) + } + func hostBack() throws -> JSONValue { try browserSession.back() } + func hostReload() throws -> JSONValue { try browserSession.reload() } + func hostCaptureInfo() throws -> JSONValue { + .object([ + "engine": .string("chromium"), + "headless": .bool(engine.browser.headless), + "browserPid": .number(Double(engine.browser.processIdentifier)), + "browserExecutable": .string(engine.browser.runtime.executableURL.path), + "browserRuntimeSource": .string(engine.browser.runtime.source.rawValue), + "browserTransport": .string("inherited-devtools-pipe"), + "targetId": .string(browserSession.targetID), + "page": try browserSession.state(), + ]) + } + func hostScreenshot( + parameters: [String: JSONValue], format: ScreenshotFormat, copyToClipboard: Bool + ) throws -> BrowserScreenshot { + if copyToClipboard { + throw HostError( + code: .unsupportedCapability, + message: "Clipboard screenshots are only supported by the macOS WebKit engine." ) - } catch let error as CaptureFormatError { - return .failure(id: request.id, code: "INVALID_CAPTURE_FORMAT", message: error.description) - } catch let error as ArtifactError { - return .failure(id: request.id, code: "ARTIFACT_ERROR", message: error.description) - } catch { - return failure(request, "INTERNAL_ERROR", String(describing: error)) } + return BrowserScreenshot(data: try browserSession.screenshot(parameters: parameters, format: format)) } - - private func record(_ command: CommandName, session: String, result: JSONValue? = nil) { - var event: [String: JSONValue] = [ - "time": .number(ProcessInfo.processInfo.systemUptime - traceStartedAt), - "command": .string(command.rawValue), - ] - if case .object(let object) = result, let url = object["url"]?.stringValue { - event["url"] = .string(String(decoding: url.utf8.prefix(2_048), as: UTF8.self)) - } - withState { () -> Void in - var entries = trace[session] ?? [] - entries.append(.object(event)) - if entries.count > 256 { entries.removeFirst(entries.count - 256) } - trace[session] = entries - } + func hostRecordingFrame() throws -> Data { try browserSession.recordingFrame() } + func hostScreenshotSeriesPlan(mode: String) throws -> JSONValue { + try browserSession.screenshotSeriesPlan(mode: mode) } - - private func missingSession(_ request: CommandRequest, _ name: String) -> CommandResponse { - .failure(id: request.id, code: "SESSION_NOT_FOUND", message: "Session does not exist: \(name)", - suggestion: "Run `headless session create \(name)`.") + func hostScrollToCapturePoint(y: Double) throws -> JSONValue { + try browserSession.scrollToCapturePoint(y: y) } - - private func failure( - _ request: CommandRequest, _ code: String, _ message: String, suggestion: String? = nil - ) -> CommandResponse { - .failure(id: request.id, code: code, message: message, suggestion: suggestion) + func hostQAReport() throws -> JSONValue { try browserSession.qaReport() } + func hostQAClear() throws -> JSONValue { browserSession.diagnostics.clear() } + func hostConsole(level: String, limit: Int) throws -> JSONValue { + browserSession.console(level: level, limit: limit) } - - private func merge(_ first: JSONValue, with second: JSONValue) -> JSONValue { - guard case .object(var result) = first, case .object(let extra) = second else { return second } - result.merge(extra) { _, new in new } - return .object(result) + func hostNetwork(failedOnly: Bool, status: Int?, limit: Int) throws -> JSONValue { + browserSession.network(failedOnly: failedOnly, status: status, limit: limit) } + func hostNetworkDetail(requestID: String) throws -> JSONValue { + browserSession.networkDetail(requestID: requestID) + } + func hostStyles(parameters: [String: JSONValue]) throws -> JSONValue { + try browserSession.styles(parameters: parameters) + } + func hostCookies(includeValues: Bool) throws -> JSONValue { + try browserSession.cookies(includeValues: includeValues) + } + func hostStorage(scope: String, includeValues: Bool) throws -> JSONValue { + try browserSession.storage(scope: scope, includeValues: includeValues) + } + func hostPerformance() throws -> JSONValue { try browserSession.performance() } + func hostAnimations() throws -> JSONValue { try browserSession.animations() } + func hostEmulateNetwork(parameters: [String: JSONValue]) throws -> JSONValue { + try browserSession.emulateNetwork(parameters: parameters) + } + func hostSetNetworkMock(parameters: [String: JSONValue]) throws -> JSONValue { + try browserSession.setNetworkMock(parameters: parameters) + } + func hostClearNetworkMocks() throws -> JSONValue { try browserSession.clearNetworkMocks() } } do { - #if canImport(Glibc) - signal(SIGPIPE, SIG_IGN) - #endif - let host = try LinuxBrowserHost() - let server = LocalSocketServer() - let stopped = DispatchSemaphore(value: 0) - host.onShutdown = { stopped.signal() } - try server.start { request in host.handle(request) } - - #if canImport(Glibc) - signal(SIGTERM, SIG_IGN) - signal(SIGINT, SIG_IGN) - let term = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .global()) - let interrupt = DispatchSource.makeSignalSource(signal: SIGINT, queue: .global()) - term.setEventHandler { stopped.signal() } - interrupt.setEventHandler { stopped.signal() } - term.resume(); interrupt.resume() - #endif - - stopped.wait() - server.stop() - host.stop() + #if canImport(Glibc) + signal(SIGPIPE, SIG_IGN) + #endif + let engine = try ChromiumBrowserEngine() + let artifacts = try ArtifactStore() + let stopped = DispatchSemaphore(value: 0) + let core = HostCore( + engine: engine, + artifacts: artifacts, + defaultSession: try engine.createSession(), + shutdownHandler: { stopped.signal() } + ) + let server = LocalSocketServer() + try server.start { request in core.handle(request) } + + #if canImport(Glibc) + signal(SIGTERM, SIG_IGN) + signal(SIGINT, SIG_IGN) + let term = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .global()) + let interrupt = DispatchSource.makeSignalSource(signal: SIGINT, queue: .global()) + term.setEventHandler { stopped.signal() } + interrupt.setEventHandler { stopped.signal() } + term.resume() + interrupt.resume() + #endif + + stopped.wait() + server.stop() + core.stop() } catch { fputs("headless-host: \(error)\n", stderr) exit(70) diff --git a/apps/headless/Sources/HeadlessProtocol/HostCore.swift b/apps/headless/Sources/HeadlessProtocol/HostCore.swift new file mode 100644 index 0000000..a58e39f --- /dev/null +++ b/apps/headless/Sources/HeadlessProtocol/HostCore.swift @@ -0,0 +1,628 @@ +import Foundation + +public struct BrowserScreenshot: Sendable { + public let data: Data + public let clipboardCopied: Bool + + public init(data: Data, clipboardCopied: Bool = false) { + self.data = data + self.clipboardCopied = clipboardCopied + } +} + +/// The portable browser surface used by `HostCore`. Platform adapters keep +/// WKWebView and CDP details out of the command dispatcher. +public protocol BrowserEngineSession: AnyObject { + func hostEnableAgentControl() + func hostVisit(_ url: URL) throws -> JSONValue + func hostInspect(parameters: [String: JSONValue]) throws -> JSONValue + func hostClick(parameters: [String: JSONValue]) throws -> JSONValue + func hostFill(parameters: [String: JSONValue]) throws -> JSONValue + func hostPress(parameters: [String: JSONValue]) throws -> JSONValue + func hostScroll(parameters: [String: JSONValue]) throws -> JSONValue + func hostWait(parameters: [String: JSONValue]) throws -> JSONValue + func hostTour(parameters: [String: JSONValue]) throws -> JSONValue + func hostBack() throws -> JSONValue + func hostReload() throws -> JSONValue + func hostCaptureInfo() throws -> JSONValue + func hostScreenshot( + parameters: [String: JSONValue], format: ScreenshotFormat, copyToClipboard: Bool + ) throws -> BrowserScreenshot + func hostRecordingFrame() throws -> Data + func hostScreenshotSeriesPlan(mode: String) throws -> JSONValue + func hostScrollToCapturePoint(y: Double) throws -> JSONValue + func hostQAReport() throws -> JSONValue + func hostQAClear() throws -> JSONValue + func hostConsole(level: String, limit: Int) throws -> JSONValue + func hostNetwork(failedOnly: Bool, status: Int?, limit: Int) throws -> JSONValue + func hostNetworkDetail(requestID: String) throws -> JSONValue + func hostStyles(parameters: [String: JSONValue]) throws -> JSONValue + func hostCookies(includeValues: Bool) throws -> JSONValue + func hostStorage(scope: String, includeValues: Bool) throws -> JSONValue + func hostPerformance() throws -> JSONValue + func hostAnimations() throws -> JSONValue + func hostEmulateNetwork(parameters: [String: JSONValue]) throws -> JSONValue + func hostSetNetworkMock(parameters: [String: JSONValue]) throws -> JSONValue + func hostClearNetworkMocks() throws -> JSONValue +} + +public extension BrowserEngineSession { + func hostEnableAgentControl() {} + + func hostEmulateNetwork(parameters: [String: JSONValue]) throws -> JSONValue { + throw HostError( + code: .unsupportedCapability, + message: "Network simulation requires the Chromium CDP engine." + ) + } + + func hostSetNetworkMock(parameters: [String: JSONValue]) throws -> JSONValue { + throw HostError( + code: .unsupportedCapability, + message: "Request mocking requires the Chromium CDP engine." + ) + } + + func hostClearNetworkMocks() throws -> JSONValue { + throw HostError( + code: .unsupportedCapability, + message: "Request mocking requires the Chromium CDP engine." + ) + } +} + +public protocol BrowserEngine: AnyObject { + associatedtype Session: BrowserEngineSession + var name: String { get } + var platform: String { get } + func createSession() throws -> Session + func closeSession(_ session: Session) + func stop() + func pingDetails() -> [String: JSONValue] + func hostError(for error: Error) -> HostError? +} + +public extension BrowserEngine { + func pingDetails() -> [String: JSONValue] { [:] } + func hostError(for error: Error) -> HostError? { nil } +} + +/// Shared command dispatcher and lifecycle state for every browser engine. +/// New portable commands belong here exactly once. +public final class HostCore: @unchecked Sendable { + private let engine: Engine + private let artifacts: ArtifactStore + private let shutdownHandler: @Sendable () -> Void + private let lock = NSLock() + private var sessions: [String: Engine.Session] + private var trace: [String: [JSONValue]] = ["default": []] + private var activeFlows: [String: [RecordedFlowStep]] = [:] + private var recordings: [String: BrowserRecording] = [:] + private var stopping = false + private let traceStartedAt = ProcessInfo.processInfo.systemUptime + + public init( + engine: Engine, + artifacts: ArtifactStore, + defaultSession: Engine.Session, + shutdownHandler: @escaping @Sendable () -> Void + ) { + self.engine = engine + self.artifacts = artifacts + self.sessions = ["default": defaultSession] + self.shutdownHandler = shutdownHandler + } + + private func withState(_ body: () -> T) -> T { + lock.lock() + defer { lock.unlock() } + return body() + } + + public func sessionDidClose(_ closed: Engine.Session) { + let stoppedRecordings = withState { () -> [BrowserRecording] in + let names = sessions.compactMap { $0.value === closed ? $0.key : nil } + var stopped: [BrowserRecording] = [] + for name in names { + sessions.removeValue(forKey: name) + trace.removeValue(forKey: name) + activeFlows.removeValue(forKey: name) + if let recording = recordings.removeValue(forKey: name) { stopped.append(recording) } + } + return stopped + } + DispatchQueue.global(qos: .utility).async { + for recording in stoppedRecordings { _ = try? recording.stop(timeout: 5) } + } + } + + public func stop() { + let captured = withState { () -> ([BrowserRecording], [Engine.Session]) in + if stopping { return ([], []) } + stopping = true + let activeRecordings = Array(recordings.values) + let openSessions = Array(sessions.values) + recordings.removeAll() + sessions.removeAll() + trace.removeAll() + activeFlows.removeAll() + return (activeRecordings, openSessions) + } + for recording in captured.0 { _ = try? recording.stop(timeout: 5) } + for session in captured.1 { engine.closeSession(session) } + engine.stop() + } + + public func handle(_ request: CommandRequest) -> CommandResponse { + if request.command == .ping { return ping(request) } + if request.command == .shutdown { + DispatchQueue.global().asyncAfter(deadline: .now() + 0.1, execute: shutdownHandler) + return .success(id: request.id, result: .object(["stopping": .bool(true)])) + } + + do { + if request.command == .artifactList { + return .success(id: request.id, result: try artifacts.list()) + } + switch request.command { + case .sessionCreate: + return try createSession(request) + case .sessionList: + let names = withState { sessions.keys.sorted() } + return .success( + id: request.id, + result: .object(["sessions": .array(names.map(JSONValue.string))]) + ) + case .sessionClose: + return closeSession(request) + default: + break + } + + let name = request.session ?? "default" + guard let session = withState({ sessions[name] }) else { + return missingSession(request, name) + } + session.hostEnableAgentControl() + let result = try execute(request, sessionName: name, session: session) + record(request.command, session: name, result: result) + if let step = flowStepIfSafe(command: request.command, parameters: request.parameters) { + withState { + guard let steps = activeFlows[name], steps.count < 200 else { return } + activeFlows[name] = steps + [step] + } + } + return .success(id: request.id, result: result) + } catch let error as HostError { + return hostFailure(request, error) + } 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 RecordingError { + let code: String + let suggestion: String? + switch error { + case .unavailable: + code = "RECORDER_UNAVAILABLE" + suggestion = "Install FFmpeg or set HEADLESS_FFMPEG_EXECUTABLE." + case .alreadyActive: code = "RECORDING_ACTIVE"; suggestion = nil + case .notActive: code = "RECORDING_NOT_ACTIVE"; suggestion = nil + default: code = "RECORDING_FAILED"; suggestion = nil + } + return failure(request, code, error.description, suggestion: suggestion) + } catch let error as CaptureFormatError { + return failure(request, "INVALID_CAPTURE_FORMAT", error.description) + } catch let error as ArtifactError { + return failure(request, "ARTIFACT_ERROR", error.description) + } catch { + if let translated = engine.hostError(for: error) { + return hostFailure(request, translated) + } + return failure(request, "INTERNAL_ERROR", String(describing: error)) + } + } + + private func ping(_ request: CommandRequest) -> CommandResponse { + var details: [String: JSONValue] = [ + "ready": .bool(true), + "pid": .number(Double(ProcessInfo.processInfo.processIdentifier)), + "engine": .string(engine.name), + "platform": .string(engine.platform), + "protocolVersion": .string(headlessProtocolVersion), + "recordingAvailable": .bool(BrowserRecording.isAvailable()), + "artifactDirectory": .string(artifacts.rootURL.path), + ] + details.merge(engine.pingDetails()) { _, engineValue in engineValue } + return .success(id: request.id, result: .object(details)) + } + + private func createSession(_ request: CommandRequest) throws -> CommandResponse { + guard let name = request.parameters["name"]?.stringValue else { + return failure(request, "MISSING_PARAMETER", "Session name is required.") + } + do { try validateIdentifier(name, field: "session") } + catch { return failure(request, "INVALID_SESSION", String(describing: error)) } + let preflightRejection = withState { () -> String? in + if stopping { return "HOST_UNAVAILABLE" } + if sessions[name] != nil { return "SESSION_EXISTS" } + return nil + } + if let preflightRejection { + return failure( + request, preflightRejection, + preflightRejection == "HOST_UNAVAILABLE" + ? "Host is shutting down." : "Session already exists: \(name)" + ) + } + let created = try engine.createSession() + let rejection = withState { () -> String? in + if stopping { return "HOST_UNAVAILABLE" } + if sessions[name] != nil { return "SESSION_EXISTS" } + sessions[name] = created + trace[name] = [] + return nil + } + if let rejection { + engine.closeSession(created) + return failure( + request, rejection, + rejection == "SESSION_EXISTS" ? "Session already exists: \(name)" : "Host is shutting down." + ) + } + created.hostEnableAgentControl() + record(.sessionCreate, session: name) + return .success(id: request.id, result: .object(["session": .string(name)])) + } + + private func closeSession(_ request: CommandRequest) -> CommandResponse { + let name = request.session ?? "default" + let closing = withState { () -> (Engine.Session?, BrowserRecording?) in + let session = sessions.removeValue(forKey: name) + guard session != nil else { return (nil, nil) } + let recording = recordings.removeValue(forKey: name) + trace.removeValue(forKey: name) + activeFlows.removeValue(forKey: name) + return (session, recording) + } + guard let session = closing.0 else { return missingSession(request, name) } + if let recording = closing.1 { _ = try? recording.stop(timeout: 5) } + engine.closeSession(session) + return .success(id: request.id, result: .object(["closed": .string(name)])) + } + + private func execute( + _ request: CommandRequest, sessionName name: String, session: Engine.Session + ) throws -> JSONValue { + switch request.command { + case .visit: + guard let value = request.parameters["url"]?.stringValue else { + throw HostError(code: .missingParameter, message: "URL is required.") + } + return try session.hostVisit(normalizedWebURL(value)) + case .inspect: return try session.hostInspect(parameters: request.parameters) + case .click: return try session.hostClick(parameters: request.parameters) + case .fill: return try session.hostFill(parameters: request.parameters) + case .press: return try session.hostPress(parameters: request.parameters) + case .scroll: return try session.hostScroll(parameters: request.parameters) + case .wait: return try session.hostWait(parameters: request.parameters) + case .tour: return try session.hostTour(parameters: request.parameters) + case .back: return try session.hostBack() + case .reload: return try session.hostReload() + case .captureInfo: + return try captureInfo(session, name: name) + case .screenshot: + if request.parameters["series"]?.stringValue != nil { + return try captureScreenshotSeries(session: session, parameters: request.parameters) + } + let format = try screenshotFormat( + explicit: request.parameters["format"]?.stringValue, + output: request.parameters["output"]?.stringValue + ) + let screenshot = try session.hostScreenshot( + parameters: request.parameters, + format: format, + copyToClipboard: request.parameters["clipboard"]?.boolValue ?? false + ) + var metadata = try artifacts.write( + screenshot.data, + requestedName: request.parameters["output"]?.stringValue, + extension: artifactExtension(request.parameters["output"]?.stringValue ?? "") + ?? format.fileExtension, + prefix: "screenshot-\(name)" + ) + if screenshot.clipboardCopied { + metadata = merge(metadata, with: .object(["clipboard": .bool(true)])) + } + return metadata + case .recordStart: + return try startRecording(request, name: name, session: session) + case .recordStatus: + return withState { recordings[name]?.status() ?? .object(["active": .bool(false)]) } + case .recordStop: + return try stopRecording(request, name: name) + case .qaReport: return try session.hostQAReport() + case .qaClear: return try session.hostQAClear() + case .consoleList: + return try session.hostConsole( + level: request.parameters["level"]?.stringValue ?? "all", + limit: Int(request.parameters["limit"]?.numberValue ?? 100) + ) + case .networkList: + return try session.hostNetwork( + failedOnly: request.parameters["failed"]?.boolValue ?? false, + status: request.parameters["status"]?.numberValue.map(Int.init), + limit: Int(request.parameters["limit"]?.numberValue ?? 100) + ) + case .networkGet: + guard let requestID = request.parameters["requestId"]?.stringValue else { + throw HostError(code: .missingParameter, message: "Network request ID is required.") + } + return try session.hostNetworkDetail(requestID: requestID) + case .stylesGet: return try session.hostStyles(parameters: request.parameters) + case .cookiesList: + return try session.hostCookies( + includeValues: request.parameters["includeValues"]?.boolValue ?? false + ) + case .storageList: + return try session.hostStorage( + scope: request.parameters["scope"]?.stringValue ?? "all", + includeValues: request.parameters["includeValues"]?.boolValue ?? false + ) + case .performanceGet: return try session.hostPerformance() + case .animationList: return try session.hostAnimations() + case .networkEmulate: return try session.hostEmulateNetwork(parameters: request.parameters) + case .networkMockSet: return try session.hostSetNetworkMock(parameters: request.parameters) + case .networkMockClear: return try session.hostClearNetworkMocks() + case .visualCompare: + return try visualCompare(request, sessionName: name) + case .reportCreate: + return try createReport(request, sessionName: name, session: session) + case .flowStart: + withState { activeFlows[name] = [] } + return .object([ + "recording": .bool(true), + "note": .string("Only safe navigation actions are recorded; typed values and credentials are never stored."), + ]) + case .flowStop: + let steps = withState { activeFlows.removeValue(forKey: name) } ?? [] + return try artifacts.write( + ProtocolCodec.encoder.encode(RecordedFlow(commands: steps)), + requestedName: request.parameters["output"]?.stringValue, + extension: "json", prefix: "flow-\(name)" + ) + case .flowRun: + return try runFlow(request, sessionName: name) + case .ping, .shutdown, .sessionCreate, .sessionList, .sessionClose, .artifactList: + throw HostError(code: .invalidCommand, message: "Command is not valid in this context.") + } + } + + private func captureInfo(_ session: Engine.Session, name: String) throws -> JSONValue { + let base = try session.hostCaptureInfo() + guard case .object(var object) = base else { return base } + object["trace"] = .array(withState { trace[name] ?? [] }) + object["recording"] = withState { + recordings[name]?.status() ?? .object(["active": .bool(false)]) + } + return .object(object) + } + + private func captureScreenshotSeries( + session: Engine.Session, parameters: [String: JSONValue] + ) throws -> JSONValue { + let mode = parameters["series"]?.stringValue ?? "viewport" + let format = try screenshotFormat(explicit: parameters["format"]?.stringValue, output: nil) + let plan = try parseScreenshotSeriesPlan(try session.hostScreenshotSeriesPlan(mode: mode)) + let prefix = try screenshotSeriesPrefix(parameters: parameters, mode: mode) + defer { _ = try? session.hostScrollToCapturePoint(y: plan.initialY) } + let reserved = try reserveScreenshotSeriesArtifacts( + store: artifacts, points: plan.points, prefix: prefix, mode: mode, format: format + ) + do { + let metadata = try plan.points.enumerated().map { index, point -> JSONValue in + _ = try session.hostScrollToCapturePoint(y: point.y) + let screenshot = try session.hostScreenshot( + parameters: [:], format: format, copyToClipboard: false + ) + return try artifacts.writeReserved(screenshot.data, to: reserved[index]) + } + return screenshotSeriesSummary( + mode: mode, points: plan.points, artifacts: metadata, + truncated: plan.truncated, totalPoints: plan.totalPoints + ) + } catch { + artifacts.discardReserved(reserved) + throw error + } + } + + private func startRecording( + _ request: CommandRequest, name: String, session: Engine.Session + ) throws -> JSONValue { + guard withState({ recordings[name] == nil }) else { throw RecordingError.alreadyActive } + let format = try recordingFormat( + explicit: request.parameters["format"]?.stringValue, + output: request.parameters["output"]?.stringValue + ) + let quality = try RecordingQuality.parse( + request.parameters["quality"]?.stringValue ?? "balanced" + ) + let output = try artifacts.reserve( + requestedName: request.parameters["output"]?.stringValue, + extension: format.fileExtension, prefix: "recording-\(name)" + ) + let recording: BrowserRecording + do { + recording = try BrowserRecording( + outputURL: output, + fps: request.parameters["fps"]?.numberValue ?? 10, + format: format, + quality: quality + ) { [weak session] in + guard let session else { throw RecordingError.captureFailed("session closed") } + return try session.hostRecordingFrame() + } + } catch { + try? FileManager.default.removeItem(at: output) + throw error + } + let registered = withState { () -> Bool in + guard !stopping, sessions[name] === session, recordings[name] == nil else { return false } + recordings[name] = recording + return true + } + guard registered else { + _ = try? recording.stop(timeout: 5) + try? FileManager.default.removeItem(at: output) + throw HostError(code: .operationFailed, message: "Host is shutting down") + } + return recording.status() + } + + private func stopRecording(_ request: CommandRequest, name: String) throws -> JSONValue { + guard let active = withState({ recordings[name] }) else { throw RecordingError.notActive } + if let output = request.parameters["output"]?.stringValue, + let actual = artifactExtension(output), + actual != active.format.fileExtension { + throw CaptureFormatError.mismatchedOutputFormat( + expected: active.format.fileExtension, actual: actual + ) + } + guard let recording = withState({ recordings.removeValue(forKey: name) }) else { + throw RecordingError.notActive + } + let status: JSONValue + do { status = try recording.stop() } + catch { + try? FileManager.default.removeItem(at: recording.outputURL) + throw error + } + let artifact = try artifacts.finalize( + recording.outputURL, renameTo: request.parameters["output"]?.stringValue + ) + return merge(status, with: artifact) + } + + private func visualCompare(_ request: CommandRequest, sessionName: String) throws -> JSONValue { + guard let before = request.parameters["before"]?.stringValue else { + throw HostError(code: .missingParameter, message: "Before artifact name is required.") + } + guard let after = request.parameters["after"]?.stringValue else { + throw HostError(code: .missingParameter, message: "After artifact name is required.") + } + _ = try artifacts.read(name: before, expectedExtension: "png", maximumBytes: 100 * 1_024 * 1_024) + _ = try artifacts.read(name: after, expectedExtension: "png", maximumBytes: 100 * 1_024 * 1_024) + let difference = try artifacts.reserve( + requestedName: request.parameters["output"]?.stringValue, + extension: "png", prefix: "difference-\(sessionName)" + ) + let comparison = try VisualComparison.compare( + before: artifacts.rootURL.appendingPathComponent(before), + after: artifacts.rootURL.appendingPathComponent(after), + difference: difference + ) + return merge(comparison, with: try artifacts.finalize(difference, renameTo: nil)) + } + + private func createReport( + _ request: CommandRequest, sessionName name: String, session: Engine.Session + ) throws -> JSONValue { + let report: JSONValue = .object([ + "format": .string("headless-qa-report-v1"), + "createdAt": .number(Date().timeIntervalSince1970), + "session": .string(name), + "page": try captureInfo(session, name: name), + "qa": try session.hostQAReport(), + "trace": .array(withState { trace[name] ?? [] }), + "artifacts": try artifacts.list(), + "security": .object([ + "sensitiveValuesIncluded": .bool(false), + "transport": .string("local-unix-socket"), + ]), + ]) + return try artifacts.write( + ProtocolCodec.encoder.encode(report), + requestedName: request.parameters["output"]?.stringValue, + extension: "json", prefix: "qa-report-\(name)" + ) + } + + private func runFlow(_ request: CommandRequest, sessionName name: String) throws -> JSONValue { + guard let input = request.parameters["input"]?.stringValue else { + throw HostError(code: .missingParameter, message: "Flow input is required.") + } + let data = try artifacts.read( + name: input, expectedExtension: "json", maximumBytes: 1_024 * 1_024 + ) + let flow = try ProtocolCodec.decoder.decode(RecordedFlow.self, from: data) + guard flow.version == 1, + flow.commands.count <= 200, + flow.commands.allSatisfy({ replayableFlowCommands.contains($0.command) }) else { + throw HostError(code: .invalidFlow, message: "Flow contains unsupported commands.") + } + var completed = 0 + for step in flow.commands { + let replay = CommandRequest( + command: step.command, session: name, parameters: step.parameters + ) + try replay.validate() + let response = handle(replay) + guard response.ok else { + throw HostError( + code: .flowFailed, + message: "Step \(completed + 1) (\(step.command.rawValue)) failed: \(response.error?.message ?? "unknown error")" + ) + } + completed += 1 + } + return .object(["completed": .number(Double(completed)), "input": .string(input)]) + } + + private func record(_ command: CommandName, session: String, result: JSONValue? = nil) { + var event: [String: JSONValue] = [ + "time": .number(ProcessInfo.processInfo.systemUptime - traceStartedAt), + "command": .string(command.rawValue), + ] + if case .object(let object) = result, let url = object["url"]?.stringValue { + event["url"] = .string(String(decoding: url.utf8.prefix(2_048), as: UTF8.self)) + } + withState { + guard sessions[session] != nil else { return } + var entries = trace[session] ?? [] + entries.append(.object(event)) + if entries.count > 256 { entries.removeFirst(entries.count - 256) } + trace[session] = entries + } + } + + private func hostFailure(_ request: CommandRequest, _ error: HostError) -> CommandResponse { + .failure( + id: request.id, code: error.code.rawValue, + message: error.message, suggestion: error.suggestion + ) + } + + private func missingSession(_ request: CommandRequest, _ name: String) -> CommandResponse { + failure( + request, "SESSION_NOT_FOUND", "Session does not exist: \(name)", + suggestion: "Run `headless session create \(name)`." + ) + } + + private func failure( + _ request: CommandRequest, _ code: String, _ message: String, suggestion: String? = nil + ) -> CommandResponse { + .failure(id: request.id, code: code, message: message, suggestion: suggestion) + } + + private func merge(_ first: JSONValue, with second: JSONValue) -> JSONValue { + guard case .object(var result) = first, case .object(let extra) = second else { return second } + result.merge(extra) { _, new in new } + return .object(result) + } +} diff --git a/apps/headless/Sources/HeadlessProtocol/HostError.swift b/apps/headless/Sources/HeadlessProtocol/HostError.swift index a36e120..6309e03 100644 --- a/apps/headless/Sources/HeadlessProtocol/HostError.swift +++ b/apps/headless/Sources/HeadlessProtocol/HostError.swift @@ -7,6 +7,11 @@ public enum HostErrorCode: String, Sendable { case unsafeNavigation = "UNSAFE_NAVIGATION" case unsafeResourceType = "UNSAFE_RESOURCE_TYPE" case sensitiveDiagnosticsDisabled = "SENSITIVE_DIAGNOSTICS_DISABLED" + case unsupportedCapability = "UNSUPPORTED_CAPABILITY" + case missingParameter = "MISSING_PARAMETER" + case invalidFlow = "INVALID_FLOW" + case flowFailed = "FLOW_FAILED" + case invalidCommand = "INVALID_COMMAND" case operationFailed = "OPERATION_FAILED" } @@ -37,6 +42,10 @@ public struct HostError: Error, CustomStringConvertible, Sendable { return "Executable files, installers, scripts, and disk images are blocked." case .sensitiveDiagnosticsDisabled: return "Restart the host with HEADLESS_ALLOW_SENSITIVE_DIAGNOSTICS=1 only when cookie or storage values are required." + case .unsupportedCapability: + return "Use an engine that declares support for this capability." + case .missingParameter, .invalidFlow, .flowFailed, .invalidCommand: + return nil case .operationFailed: return nil } diff --git a/apps/headless/Sources/HeadlessProtocol/Protocol.swift b/apps/headless/Sources/HeadlessProtocol/Protocol.swift index 1fe274b..07d7536 100644 --- a/apps/headless/Sources/HeadlessProtocol/Protocol.swift +++ b/apps/headless/Sources/HeadlessProtocol/Protocol.swift @@ -550,6 +550,63 @@ public enum ProtocolBounds { public static let scrollAmount = 0.1...100_000.0 public static let networkLatencyMilliseconds = 0.0...120_000.0 public static let networkThroughputKbps = -1.0...1_000_000.0 + public static let screenshotDimension = 16_384.0 + public static let screenshotPixels = 64_000_000.0 +} + +public struct BoundedScreenshotRectangle: Equatable, Sendable { + public let x: Double + public let y: Double + public let width: Double + public let height: Double + + public init(_ object: [String: JSONValue]) throws { + guard let x = object["x"]?.numberValue, let y = object["y"]?.numberValue, + let width = object["width"]?.numberValue, + let height = object["height"]?.numberValue, + x.isFinite, y.isFinite, width.isFinite, height.isFinite, + width > 0, height > 0, + width <= ProtocolBounds.screenshotDimension, + height <= ProtocolBounds.screenshotDimension, + width * height <= ProtocolBounds.screenshotPixels else { + throw HostError( + code: .operationFailed, + message: "Screenshot dimensions exceed safety limits" + ) + } + self.x = x + self.y = y + self.width = width + self.height = height + } +} + +public func browserTargetArguments(_ parameters: [String: JSONValue]) throws -> [String: Any] { + if let target = parameters["target"]?.stringValue { + guard target.hasPrefix("@e"), target.count <= 16 else { + throw HostError(code: .operationFailed, message: "Invalid element reference") + } + return ["target": target] + } + var result: [String: Any] = [:] + if let role = parameters["role"]?.stringValue { result["role"] = role } + if let name = parameters["name"]?.stringValue { result["name"] = name } + guard !result.isEmpty else { + throw HostError(code: .operationFailed, message: "Missing command parameter: target") + } + return result +} + +public func requireSensitiveDiagnosticsAccess( + if requested: Bool, + environment: [String: String] = ProcessInfo.processInfo.environment +) throws { + guard !requested || environment["HEADLESS_ALLOW_SENSITIVE_DIAGNOSTICS"] == "1" else { + throw HostError( + code: .sensitiveDiagnosticsDisabled, + message: "Sensitive diagnostic values are disabled." + ) + } } /// Agent navigation is deliberately limited to web URLs in P0. File URLs and diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index e93eb90..b5b6fee 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -138,6 +138,70 @@ private func readRawSocketLine(descriptor: Int32) throws -> Data { throw TestFailure(description: "raw socket response exceeded the protocol limit") } +private final class TestBrowserSession: BrowserEngineSession { + private(set) var agentControlEnableCount = 0 + + func hostEnableAgentControl() { agentControlEnableCount += 1 } + func hostVisit(_ url: URL) throws -> JSONValue { .object(["url": .string(url.absoluteString)]) } + func hostInspect(parameters: [String: JSONValue]) throws -> JSONValue { + .object(["engineResult": .bool(true), "parameters": .object(parameters)]) + } + func hostClick(parameters: [String: JSONValue]) throws -> JSONValue { .object(["clicked": .bool(true)]) } + func hostFill(parameters: [String: JSONValue]) throws -> JSONValue { .object(["filled": .bool(true)]) } + func hostPress(parameters: [String: JSONValue]) throws -> JSONValue { .object(["pressed": .bool(true)]) } + func hostScroll(parameters: [String: JSONValue]) throws -> JSONValue { .object(["scrolled": .bool(true)]) } + func hostWait(parameters: [String: JSONValue]) throws -> JSONValue { .object(["waited": .bool(true)]) } + func hostTour(parameters: [String: JSONValue]) throws -> JSONValue { .object(["toured": .bool(true)]) } + func hostBack() throws -> JSONValue { .object(["back": .bool(true)]) } + func hostReload() throws -> JSONValue { .object(["reloaded": .bool(true)]) } + func hostCaptureInfo() throws -> JSONValue { .object(["engine": .string("fake")]) } + func hostScreenshot( + parameters: [String: JSONValue], format: ScreenshotFormat, copyToClipboard: Bool + ) throws -> BrowserScreenshot { BrowserScreenshot(data: Data("image".utf8)) } + func hostRecordingFrame() throws -> Data { Data("frame".utf8) } + func hostScreenshotSeriesPlan(mode: String) throws -> JSONValue { + .object([ + "initialY": .number(0), "totalPoints": .number(1), "truncated": .bool(false), + "points": .array([.object(["y": .number(0), "label": .string("viewport")])]), + ]) + } + func hostScrollToCapturePoint(y: Double) throws -> JSONValue { .object(["y": .number(y)]) } + func hostQAReport() throws -> JSONValue { .object(["issues": .array([])]) } + func hostQAClear() throws -> JSONValue { .object(["cleared": .bool(true)]) } + func hostConsole(level: String, limit: Int) throws -> JSONValue { .object(["entries": .array([])]) } + func hostNetwork(failedOnly: Bool, status: Int?, limit: Int) throws -> JSONValue { + .object(["requests": .array([])]) + } + func hostNetworkDetail(requestID: String) throws -> JSONValue { + .object(["requestId": .string(requestID)]) + } + func hostStyles(parameters: [String: JSONValue]) throws -> JSONValue { .object(["styles": .array([])]) } + func hostCookies(includeValues: Bool) throws -> JSONValue { .object(["cookies": .array([])]) } + func hostStorage(scope: String, includeValues: Bool) throws -> JSONValue { .object(["scope": .string(scope)]) } + func hostPerformance() throws -> JSONValue { .object(["metrics": .array([])]) } + func hostAnimations() throws -> JSONValue { .object(["animations": .array([])]) } +} + +private final class TestBrowserEngine: BrowserEngine { + typealias Session = TestBrowserSession + + let name = "fake" + let platform = "test" + private(set) var createdSessions: [TestBrowserSession] = [] + private(set) var closedSessions: [TestBrowserSession] = [] + private(set) var stopped = false + + func createSession() throws -> TestBrowserSession { + let session = TestBrowserSession() + createdSessions.append(session) + return session + } + + func closeSession(_ session: TestBrowserSession) { closedSessions.append(session) } + func stop() { stopped = true } + func pingDetails() -> [String: JSONValue] { ["adapter": .string("test-adapter")] } +} + @main struct ProtocolTests { typealias TestCase = (String, () throws -> Void) @@ -1628,6 +1692,35 @@ struct ProtocolTests { localDevelopmentHosts == ["localhost", "127.0.0.1", "0.0.0.0", "::1"], "local development host allowlist changed" ) + let maximumScreenshot = try BoundedScreenshotRectangle([ + "x": .number(0), "y": .number(0), + "width": .number(ProtocolBounds.screenshotDimension), + "height": .number(ProtocolBounds.screenshotPixels / ProtocolBounds.screenshotDimension), + ]) + try expect( + maximumScreenshot.width * maximumScreenshot.height == ProtocolBounds.screenshotPixels, + "the shared screenshot pixel bound should accept its exact limit" + ) + try expectThrows("the shared screenshot bound should reject oversized captures") { + _ = try BoundedScreenshotRectangle([ + "x": .number(0), "y": .number(0), + "width": .number(ProtocolBounds.screenshotDimension), + "height": .number(ProtocolBounds.screenshotDimension), + ]) + } + try expect( + try browserTargetArguments(["target": .string("@e123")])["target"] as? String == "@e123", + "shared target conversion should preserve validated references" + ) + try expectThrows("shared target conversion should reject invalid references") { + _ = try browserTargetArguments(["target": .string("#page-owned-selector")]) + } + try requireSensitiveDiagnosticsAccess( + if: true, environment: ["HEADLESS_ALLOW_SENSITIVE_DIAGNOSTICS": "1"] + ) + try expectThrows("sensitive diagnostics should stay double-gated") { + try requireSensitiveDiagnosticsAccess(if: true, environment: [:]) + } let minimumScroll = try CLIParser().parse([ "scroll", "down", "--amount", String(ProtocolBounds.scrollAmount.lowerBound), @@ -1651,6 +1744,66 @@ struct ProtocolTests { } } + static func sharedHostCoreDispatch() throws { + let root = "/tmp/headless-host-core-test-\(UUID().uuidString)" + defer { try? FileManager.default.removeItem(atPath: root) } + let defaultSession = TestBrowserSession() + let engine = TestBrowserEngine() + let core = HostCore( + engine: engine, + artifacts: try ArtifactStore(environment: ["HEADLESS_ARTIFACT_DIR": root]), + defaultSession: defaultSession, + shutdownHandler: {} + ) + defer { core.stop() } + + let ping = core.handle(CommandRequest(command: .ping)) + guard ping.ok, case .object(let pingResult) = ping.result else { + throw TestFailure(description: "shared host ping should succeed") + } + 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") + + let created = core.handle(CommandRequest( + command: .sessionCreate, parameters: ["name": .string("secondary")] + )) + try expect(created.ok, "shared session creation should succeed") + try expect(engine.createdSessions.count == 1, "session creation should delegate to the engine") + + let inspected = core.handle(CommandRequest( + command: .inspect, session: "secondary", parameters: ["interactive": .bool(true)] + )) + guard inspected.ok, case .object(let inspectResult) = inspected.result else { + throw TestFailure(description: "shared inspect dispatch should succeed") + } + try expect(inspectResult["engineResult"] == .bool(true), "inspect should delegate to the session") + try expect( + engine.createdSessions[0].agentControlEnableCount == 2, + "agent control should be enabled at creation and before command execution" + ) + + let capture = core.handle(CommandRequest(command: .captureInfo, session: "secondary")) + guard capture.ok, case .object(let captureResult) = capture.result else { + throw TestFailure(description: "shared capture info should succeed") + } + try expect(captureResult["engine"] == .string("fake"), "capture info should retain engine fields") + try expect(captureResult["trace"] != nil, "capture info should include the shared trace") + try expect(captureResult["recording"] != nil, "capture info should include recording state") + + let unsupported = core.handle(CommandRequest(command: .networkEmulate, session: "secondary")) + try expect( + unsupported.error?.code == "UNSUPPORTED_CAPABILITY", + "unsupported engine features should return a typed capability error" + ) + + let closed = core.handle(CommandRequest(command: .sessionClose, session: "secondary")) + try expect(closed.ok, "shared session close should succeed") + try expect(engine.closedSessions.count == 1, "session close should delegate to the engine") + let missing = core.handle(CommandRequest(command: .inspect, session: "secondary")) + try expect(missing.error?.code == "SESSION_NOT_FOUND", "closed sessions should be removed from shared state") + } + static func main() { if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--peer-denied-client" { @@ -1714,6 +1867,7 @@ struct ProtocolTests { ("incremental NUL message buffering", nullTerminatedBufferScansIncrementally), ("typed host errors", typedHostErrorsRoundTrip), ("single-source contract constants", singleSourceContractConstants), + ("shared host core dispatch", sharedHostCoreDispatch), ] var failures = 0 diff --git a/apps/headless/docs/P2.md b/apps/headless/docs/P2.md index f90200b..9f15ec1 100644 --- a/apps/headless/docs/P2.md +++ b/apps/headless/docs/P2.md @@ -3,6 +3,13 @@ P2 adds comparison, reproducible QA evidence, controlled Chromium networking, and a stdio MCP adapter to the existing local browser protocol. +Both platform hosts now run through one `HostCore` command dispatcher in the +shared protocol library. `WebKitBrowserEngine` and `ChromiumBrowserEngine` +adapters contain only engine-specific browser operations; shared session +lifecycle, traces, flows, captures, recordings, reports, artifacts, and error +mapping are implemented once. Unsupported engine features remain explicit +typed capability errors. + ## Commands ```sh diff --git a/apps/headless/main.swift b/apps/headless/main.swift index 0a27e3f..a136211 100644 --- a/apps/headless/main.swift +++ b/apps/headless/main.swift @@ -818,15 +818,10 @@ private func onAgentMain(_ body: @escaping () -> T) -> T { final class AppDelegate: NSObject, NSApplicationDelegate { var controllers: [BrowserWindowController] = [] private let agentServer = LocalSocketServer() - private var agentSessions: [String: BrowserWindowController] = [:] - private var agentTrace: [String: [JSONValue]] = [:] - private var activeFlows: [String: [RecordedFlowStep]] = [:] - private var recordings: [String: BrowserRecording] = [:] - private let recordingsLock = NSLock() - private var artifacts: ArtifactStore? - private let traceStartedAt = ProcessInfo.processInfo.systemUptime + private var hostCore: HostCore? func applicationDidFinishLaunching(_ notification: Notification) { + let artifacts: ArtifactStore do { artifacts = try ArtifactStore() } catch { fputs("headless: artifact directory failed: \(error)\n", stderr) @@ -842,17 +837,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return URL(string: value) }() let url = isAgentHost ? nil : launchOptions.url ?? restoredStartupURL - openWindow( + let primaryController = openWindow( url: url, restoredStartupURL: restoredStartupURL, size: launchOptions.size, snap: launchOptions.snap, - isPrimary: true, - sessionName: "default" + isPrimary: true ) + let engine = WebKitBrowserEngine( + create: { [weak self] in + guard let self else { + throw HostError(code: .operationFailed, message: "Headless host is stopping.") + } + return onAgentMain { self.openWindow(url: nil) } + }, + close: { controller in onAgentMain { controller.close() } } + ) + let core = HostCore( + engine: engine, + artifacts: artifacts, + defaultSession: primaryController, + shutdownHandler: { DispatchQueue.main.async { NSApp.terminate(nil) } } + ) + hostCore = core do { try agentServer.start { [weak self] request in - self?.handleAgentRequest(request) ?? CommandResponse.failure( + self?.hostCore?.handle(request) ?? CommandResponse.failure( id: request.id, code: "HOST_STOPPING", message: "Headless host is stopping." ) } @@ -875,8 +885,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { restoredStartupURL: URL? = nil, size: NSSize? = nil, snap: SnapJob? = nil, - isPrimary: Bool = false, - sessionName: String? = nil + isPrimary: Bool = false ) -> BrowserWindowController { let controller = BrowserWindowController( url: url, @@ -888,22 +897,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { controller.onClose = { [weak self, weak controller] in guard let self, let controller else { return } self.controllers.removeAll { $0 === controller } - let closedSessions = self.agentSessions.compactMap { $0.value === controller ? $0.key : nil } - for session in closedSessions { - self.agentSessions.removeValue(forKey: session) - self.agentTrace.removeValue(forKey: session) - self.activeFlows.removeValue(forKey: session) - } - let stoppedRecordings = closedSessions.compactMap { self.takeRecording(for: $0) } - DispatchQueue.global(qos: .utility).async { - for recording in stoppedRecordings { _ = try? recording.stop(timeout: 5) } - } + self.hostCore?.sessionDidClose(controller) } controllers.append(controller) - if let sessionName { - agentSessions[sessionName] = controller - agentTrace[sessionName] = [] - } controller.showWindow(nil) controller.window?.makeKeyAndOrderFront(nil) return controller @@ -917,7 +913,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { true } func applicationWillTerminate(_ notification: Notification) { - for recording in takeAllRecordings() { _ = try? recording.stop(timeout: 5) } + hostCore?.stop() agentServer.stop() } @@ -925,431 +921,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { for url in urls { openWindow(url: url) } } - private func captureScreenshotSeries( - controller: BrowserWindowController, - parameters: [String: JSONValue] - ) throws -> JSONValue { - guard let artifacts else { throw ArtifactError.invalidRoot } - let mode = parameters["series"]?.stringValue ?? "viewport" - let format = try screenshotFormat( - explicit: parameters["format"]?.stringValue, - output: nil - ) - let rawPlan = try controller.agentScreenshotSeriesPlan(mode: mode) - let plan = try parseScreenshotSeriesPlan(rawPlan) - let points = plan.points - let prefix = try screenshotSeriesPrefix(parameters: parameters, mode: mode) - defer { _ = try? controller.agentScrollToCapturePoint(y: plan.initialY) } - let reserved = try reserveScreenshotSeriesArtifacts( - store: artifacts, points: points, prefix: prefix, mode: mode, format: format - ) - do { - let metadata = try points.enumerated().map { index, point -> JSONValue in - _ = try controller.agentScrollToCapturePoint(y: point.y) - let data = try controller.agentScreenshotData( - parameters: [:], format: format, copyToClipboard: false - ).data - return try artifacts.writeReserved(data, to: reserved[index]) - } - return screenshotSeriesSummary( - mode: mode, points: points, artifacts: metadata, - truncated: plan.truncated, totalPoints: plan.totalPoints - ) - } catch { - artifacts.discardReserved(reserved) - throw error - } - } - - // MARK: Agent protocol - - private func handleAgentRequest(_ request: CommandRequest) -> CommandResponse { - if request.command == .ping { - return .success(id: request.id, result: .object([ - "ready": .bool(true), - "pid": .number(Double(ProcessInfo.processInfo.processIdentifier)), - "engine": .string("webkit"), - "platform": .string("macos"), - "protocolVersion": .string(headlessProtocolVersion), - "recordingAvailable": .bool(BrowserRecording.isAvailable()), - "artifactDirectory": .string(artifacts?.rootURL.path ?? ""), - ])) - } - if request.command == .shutdown { - DispatchQueue.main.async { NSApp.terminate(nil) } - return .success(id: request.id, result: .object(["stopping": .bool(true)])) - } - - switch request.command { - case .artifactList: - do { - guard let artifacts else { throw ArtifactError.invalidRoot } - return .success(id: request.id, result: try artifacts.list()) - } catch { - return failure(request, code: "ARTIFACT_ERROR", message: String(describing: error)) - } - case .sessionCreate: - guard let name = request.parameters["name"]?.stringValue else { - return failure(request, code: "MISSING_PARAMETER", message: "Session name is required.") - } - do { - try validateIdentifier(name, field: "session") - } catch { - return failure(request, code: "INVALID_SESSION", message: String(describing: error)) - } - return onAgentMain { - if self.agentSessions[name] != nil { - return self.failure(request, code: "SESSION_EXISTS", message: "Session already exists: \(name)") - } - let controller = self.openWindow(url: nil, sessionName: name) - controller.enableAgentControl() - self.recordTrace(command: request.command, session: name) - return .success(id: request.id, result: .object(["session": .string(name)])) - } - case .sessionList: - return onAgentMain { - let sessions = self.agentSessions.keys.sorted().map { JSONValue.string($0) } - return .success(id: request.id, result: .object(["sessions": .array(sessions)])) - } - case .sessionClose: - let name = request.session ?? "default" - guard let controller = onAgentMain({ self.agentSessions[name] }) else { - return missingSession(request, name) - } - let recording = takeRecording(for: name) - onAgentMain { - self.agentSessions.removeValue(forKey: name) - self.agentTrace.removeValue(forKey: name) - self.activeFlows.removeValue(forKey: name) - controller.close() - } - if let recording { _ = try? recording.stop(timeout: 5) } - return .success(id: request.id, result: .object(["closed": .string(name)])) - default: - break - } - - let session = request.session ?? "default" - guard let controller = onAgentMain({ self.agentSessions[session] }) else { - return missingSession(request, session) - } - onAgentMain { controller.enableAgentControl() } - - do { - let result: JSONValue - switch request.command { - case .visit: - guard let value = request.parameters["url"]?.stringValue else { - return failure(request, code: "MISSING_PARAMETER", message: "URL is required.") - } - result = try controller.agentVisit(normalizedWebURL(value)) - case .inspect: - result = try controller.agentInspect(parameters: request.parameters) - case .click: - result = try controller.agentClick(parameters: request.parameters) - case .fill: - result = try controller.agentFill(parameters: request.parameters) - case .press: - result = try controller.agentPress(parameters: request.parameters) - case .scroll: - result = try controller.agentScroll(parameters: request.parameters) - case .wait: - result = try controller.agentWait(parameters: request.parameters) - case .tour: - result = try controller.agentTour(parameters: request.parameters) - case .captureInfo: - let info = try controller.agentCaptureInfo() - let trace = onAgentMain { self.agentTrace[session] ?? [] } - if case .object(var object) = info { - object["trace"] = .array(trace) - object["recording"] = recording(for: session)?.status() ?? .object(["active": .bool(false)]) - result = .object(object) - } else { - result = info - } - case .back: - onAgentMain { _ = controller.webView.goBack() } - result = try controller.agentWait(parameters: ["settled": .bool(true)]) - case .reload: - _ = onAgentMain { controller.webView.reload() } - result = try controller.agentWait(parameters: ["settled": .bool(true)]) - case .screenshot: - guard let artifacts else { throw ArtifactError.invalidRoot } - if request.parameters["series"]?.stringValue != nil { - result = try captureScreenshotSeries(controller: controller, parameters: request.parameters) - } else { - let format = try screenshotFormat( - explicit: request.parameters["format"]?.stringValue, - output: request.parameters["output"]?.stringValue - ) - let artifact = try controller.agentScreenshotData( - parameters: request.parameters, - format: format, - copyToClipboard: request.parameters["clipboard"]?.boolValue ?? false - ) - var metadata = try artifacts.write( - artifact.data, requestedName: request.parameters["output"]?.stringValue, - extension: artifactExtension(request.parameters["output"]?.stringValue ?? "") ?? format.fileExtension, - prefix: "screenshot-\(session)" - ) - if artifact.clipboardCopied { - metadata = merge(metadata, with: .object(["clipboard": .bool(true)])) - } - result = metadata - } - case .recordStart: - guard recording(for: session) == nil else { throw RecordingError.alreadyActive } - guard let artifacts else { throw ArtifactError.invalidRoot } - let format = try recordingFormat( - explicit: request.parameters["format"]?.stringValue, - output: request.parameters["output"]?.stringValue - ) - let quality = try RecordingQuality.parse(request.parameters["quality"]?.stringValue ?? "balanced") - let output = try artifacts.reserve( - requestedName: request.parameters["output"]?.stringValue, - extension: format.fileExtension, prefix: "recording-\(session)" - ) - let fps = request.parameters["fps"]?.numberValue ?? 10 - var startedRecording: BrowserRecording? - do { - let recording = try BrowserRecording( - outputURL: output, fps: fps, format: format, quality: quality - ) { [weak controller] in - guard let controller else { throw RecordingError.captureFailed("session closed") } - return try controller.agentScreenshot(parameters: [:]) - } - startedRecording = recording - try storeRecording(recording, for: session) - } catch { - if let startedRecording { _ = try? startedRecording.stop(timeout: 5) } - try? FileManager.default.removeItem(at: output) - throw error - } - guard let recording = startedRecording else { throw RecordingError.captureFailed("recorder did not start") } - result = recording.status() - case .recordStatus: - result = recording(for: session)?.status() ?? .object(["active": .bool(false)]) - case .recordStop: - guard let activeRecording = recording(for: session) else { - throw RecordingError.notActive - } - guard let artifacts else { throw ArtifactError.invalidRoot } - if let output = request.parameters["output"]?.stringValue, - let actual = artifactExtension(output), - actual != activeRecording.format.fileExtension { - throw CaptureFormatError.mismatchedOutputFormat( - expected: activeRecording.format.fileExtension, - actual: actual - ) - } - guard let recording = takeRecording(for: session) else { throw RecordingError.notActive } - let status: JSONValue - do { status = try recording.stop() } - catch { - try? FileManager.default.removeItem(at: recording.outputURL) - throw error - } - let artifact = try artifacts.finalize( - recording.outputURL, renameTo: request.parameters["output"]?.stringValue - ) - result = merge(status, with: artifact) - case .qaReport: - result = controller.agentQAReport() - case .qaClear: - result = controller.agentQAClear() - case .consoleList: - result = controller.agentConsole( - level: request.parameters["level"]?.stringValue ?? "all", - limit: Int(request.parameters["limit"]?.numberValue ?? 100) - ) - case .networkList: - result = controller.agentNetwork( - failedOnly: request.parameters["failed"]?.boolValue ?? false, - status: request.parameters["status"]?.numberValue.map(Int.init), - limit: Int(request.parameters["limit"]?.numberValue ?? 100) - ) - case .networkGet: - guard let requestID = request.parameters["requestId"]?.stringValue else { - return failure(request, code: "MISSING_PARAMETER", message: "Network request ID is required.") - } - result = controller.agentNetworkDetail(requestID: requestID) - case .stylesGet: - result = try controller.agentStyles(parameters: request.parameters) - case .cookiesList: - result = try controller.agentCookies( - includeValues: request.parameters["includeValues"]?.boolValue ?? false - ) - case .storageList: - result = try controller.agentStorage( - scope: request.parameters["scope"]?.stringValue ?? "all", - includeValues: request.parameters["includeValues"]?.boolValue ?? false - ) - case .performanceGet: - result = try controller.agentPerformance() - case .animationList: - result = try controller.agentAnimations() - case .visualCompare: - guard let before = request.parameters["before"]?.stringValue else { - return failure(request, code: "MISSING_PARAMETER", message: "Before artifact name is required.") - } - guard let after = request.parameters["after"]?.stringValue else { - return failure(request, code: "MISSING_PARAMETER", message: "After artifact name is required.") - } - guard let artifacts else { throw ArtifactError.invalidRoot } - _ = try artifacts.read(name: before, expectedExtension: "png", maximumBytes: 100 * 1_024 * 1_024) - _ = try artifacts.read(name: after, expectedExtension: "png", maximumBytes: 100 * 1_024 * 1_024) - let difference = try artifacts.reserve( - requestedName: request.parameters["output"]?.stringValue, - extension: "png", prefix: "difference-\(session)" - ) - let comparison = try VisualComparison.compare( - before: artifacts.rootURL.appendingPathComponent(before), - after: artifacts.rootURL.appendingPathComponent(after), difference: difference - ) - result = merge(comparison, with: try artifacts.finalize(difference, renameTo: nil)) - case .reportCreate: - guard let artifacts else { throw ArtifactError.invalidRoot } - let report: JSONValue = .object([ - "format": .string("headless-qa-report-v1"), - "createdAt": .number(Date().timeIntervalSince1970), - "session": .string(session), - "page": try controller.agentCaptureInfo(), - "qa": controller.agentQAReport(), - "trace": .array(onAgentMain { self.agentTrace[session] ?? [] }), - "artifacts": try artifacts.list(), - "security": .object(["sensitiveValuesIncluded": .bool(false), "transport": .string("local-unix-socket")]), - ]) - let data = try ProtocolCodec.encoder.encode(report) - result = try artifacts.write(data, requestedName: request.parameters["output"]?.stringValue, - extension: "json", prefix: "qa-report-\(session)") - case .flowStart: - onAgentMain { self.activeFlows[session] = [] } - result = .object(["recording": .bool(true), "note": .string("Only safe navigation actions are recorded; typed values and credentials are never stored.")]) - case .flowStop: - guard let artifacts else { throw ArtifactError.invalidRoot } - let steps = onAgentMain { self.activeFlows.removeValue(forKey: session) ?? [] } - let data = try ProtocolCodec.encoder.encode(RecordedFlow(commands: steps)) - result = try artifacts.write(data, requestedName: request.parameters["output"]?.stringValue, - extension: "json", prefix: "flow-\(session)") - case .flowRun: - guard let input = request.parameters["input"]?.stringValue else { return failure(request, code: "MISSING_PARAMETER", message: "Flow input is required.") } - guard let artifacts else { throw ArtifactError.invalidRoot } - let flow = try ProtocolCodec.decoder.decode(RecordedFlow.self, from: artifacts.read(name: input, expectedExtension: "json", maximumBytes: 1_024 * 1_024)) - guard flow.version == 1, flow.commands.count <= 200, - flow.commands.allSatisfy({ replayableFlowCommands.contains($0.command) }) else { - return failure(request, code: "INVALID_FLOW", message: "Flow contains unsupported commands.") - } - var completed = 0 - for step in flow.commands { - try CommandRequest(command: step.command, session: session, parameters: step.parameters).validate() - let response = handleAgentRequest(CommandRequest(command: step.command, session: session, parameters: step.parameters)) - guard response.ok else { - return failure(request, code: "FLOW_FAILED", message: "Step \(completed + 1) (\(step.command.rawValue)) failed: \(response.error?.message ?? "unknown error")") - } - completed += 1 - } - result = .object(["completed": .number(Double(completed)), "input": .string(input)]) - case .networkEmulate, .networkMockSet, .networkMockClear: - return failure(request, code: "UNSUPPORTED_CAPABILITY", message: "Network simulation and request mocking require the Chromium CDP host on Linux.", suggestion: "Use the Linux runtime for controlled CDP network emulation; no partial WebKit interception is exposed.") - case .ping, .shutdown, .sessionCreate, .sessionList, .sessionClose, .artifactList: - return failure(request, code: "INVALID_COMMAND", message: "Command is not valid in this context.") - } - onAgentMain { - self.recordTrace(command: request.command, session: session, result: result) - if let step = flowStepIfSafe(command: request.command, parameters: request.parameters), self.activeFlows[session] != nil { - if (self.activeFlows[session]?.count ?? 0) < 200 { self.activeFlows[session]?.append(step) } - } - } - return .success(id: request.id, result: result) - } catch let error as HostError { - return failure( - request, code: error.code.rawValue, message: error.message, - suggestion: error.suggestion - ) - } catch let error as ProtocolValidationError { - if case .unsafeResourceType = error { - return failure(request, code: "UNSAFE_RESOURCE_TYPE", message: error.description, - suggestion: "Executable files, installers, scripts, and disk images are blocked. Use normal web pages or media only.") - } - return failure(request, code: "INVALID_URL", message: error.description) - } catch let error as CaptureFormatError { - return failure(request, code: "INVALID_CAPTURE_FORMAT", message: error.description) - } catch let error as RecordingError { - let code: String - let suggestion: String? - switch error { - case .unavailable: - code = "RECORDER_UNAVAILABLE" - suggestion = "Install FFmpeg or set HEADLESS_FFMPEG_EXECUTABLE." - case .alreadyActive: code = "RECORDING_ACTIVE"; suggestion = nil - case .notActive: code = "RECORDING_NOT_ACTIVE"; suggestion = nil - default: code = "RECORDING_FAILED"; suggestion = nil - } - return failure(request, code: code, message: error.description, suggestion: suggestion) - } catch let error as ArtifactError { - return failure(request, code: "ARTIFACT_ERROR", message: error.description) - } catch { - return failure(request, code: "INTERNAL_ERROR", message: String(describing: error)) - } - } - - private func recordTrace(command: CommandName, session: String, result: JSONValue? = nil) { - var event: [String: JSONValue] = [ - "time": .number(ProcessInfo.processInfo.systemUptime - traceStartedAt), - "command": .string(command.rawValue), - ] - if case .object(let object) = result, let url = object["url"]?.stringValue { - event["url"] = .string(String(decoding: url.utf8.prefix(2_048), as: UTF8.self)) - } - var entries = agentTrace[session] ?? [] - entries.append(.object(event)) - if entries.count > 256 { entries.removeFirst(entries.count - 256) } - agentTrace[session] = entries - } - - private func recording(for session: String) -> BrowserRecording? { - recordingsLock.lock(); defer { recordingsLock.unlock() } - return recordings[session] - } - - private func storeRecording(_ recording: BrowserRecording, for session: String) throws { - recordingsLock.lock(); defer { recordingsLock.unlock() } - guard recordings[session] == nil else { throw RecordingError.alreadyActive } - recordings[session] = recording - } - - private func takeRecording(for session: String) -> BrowserRecording? { - recordingsLock.lock(); defer { recordingsLock.unlock() } - return recordings.removeValue(forKey: session) - } - - private func takeAllRecordings() -> [BrowserRecording] { - recordingsLock.lock(); defer { recordingsLock.unlock() } - let active = Array(recordings.values) - recordings.removeAll() - return active - } - - private func missingSession(_ request: CommandRequest, _ name: String) -> CommandResponse { - failure(request, code: "SESSION_NOT_FOUND", message: "Session does not exist: \(name)", - suggestion: "Run `headless session create \(name)`." ) - } - - private func failure( - _ request: CommandRequest, - code: String, - message: String, - suggestion: String? = nil - ) -> CommandResponse { - .failure(id: request.id, code: code, message: message, suggestion: suggestion) - } - - private func merge(_ first: JSONValue, with second: JSONValue) -> JSONValue { - guard case .object(var result) = first, case .object(let extra) = second else { return second } - result.merge(extra) { _, new in new } - return .object(result) - } - // MARK: Menu private func buildMenu() { diff --git a/docs/roadmap/architecture-decisions.md b/docs/roadmap/architecture-decisions.md index c9e535d..06ad21a 100644 --- a/docs/roadmap/architecture-decisions.md +++ b/docs/roadmap/architecture-decisions.md @@ -61,6 +61,8 @@ imports data instead of transcribing it. ## 3. Two hosts → one `HostCore` + `BrowserEngine` interface (change, Phase 2) +**Status:** implemented 2026-08-10. + **Decision:** extract everything currently duplicated between `apps/headless/main.swift` (macOS, ~340-line dispatch) and `apps/headless/LinuxHost/main.swift` (~280-line dispatch) into a shared @@ -339,7 +341,7 @@ override, the 500-event bound, and truncation reporting. | # | Decision | Status | Date | | --- | --- | --- | --- | | 1 | Keep Swift core; Rust only via revisit trigger | Decided | 2026-08-04 | -| 3 | Extract HostCore + BrowserEngine, typed errors | Planned (Phase 2) | 2026-08-04 | +| 3 | Extract HostCore + BrowserEngine, typed errors | Implemented | 2026-08-10 | | 5 | Remote stays SSH-only; no cloud offering | Decided (owner) | 2026-08-04 | | 6 | Windows = stretch via Chromium engine; WSL2/Docker interim | Decided (owner) | 2026-08-04 | | 8 | Real CDP input on Linux as capability upgrade | Planned (Phase 4) | 2026-08-04 | diff --git a/docs/roadmap/improvements-backlog.md b/docs/roadmap/improvements-backlog.md index 07acbd3..d699831 100644 --- a/docs/roadmap/improvements-backlog.md +++ b/docs/roadmap/improvements-backlog.md @@ -147,7 +147,7 @@ enforces web-only navigation in manual as well as agent-controlled use. ## §B — Structure & contract (Phase 2) -**B1. HostCore extraction** ([#21](https://github.com/LockInTime/headless/issues/21)) — the headline refactor; full spec in +**B1. HostCore extraction** ([#21](https://github.com/LockInTime/headless/issues/21)) — ~~the headline refactor; full spec in [architecture-decisions §3](architecture-decisions.md). Duplicated pairs to collapse (verified line ranges): dispatch switches (`main.swift:949-1291` / `LinuxHost/main.swift:65-343`), screenshot-series loops (`main.swift:911-945` @@ -157,7 +157,12 @@ collapse (verified line ranges): dispatch switches (`main.swift:949-1291` / `LinuxHost/main.swift:250-261` — shapes already diverged), error ladders (`main.swift:1243-1290` / `LinuxHost/main.swift:293-342`), sensitive-diag gate, target validation (3 copies + JS), screenshot bounds, syscall shims -(`Transport.swift:387-407` / `CDP.swift:278-288`). +(`Transport.swift:387-407` / `CDP.swift:278-288`).~~ **Done:** `HostCore` now +owns the dispatcher, lifecycle state, flows, traces, captures, recordings, +reports, artifacts, and common error mapping. Thin WebKit and Chromium +adapters implement one `BrowserEngineSession` contract, with a fake-engine +protocol test locking the shared path. Element-target conversion, the +sensitive-diagnostics gate, and screenshot safety bounds are shared as well. **B2. Typed errors end-to-end.** ([#22](https://github.com/LockInTime/headless/issues/22)) ~~Replace `message.contains("ELEMENT_NOT_FOUND")` string matching (both hosts; `Host/AgentBridge.swift:416-418`) with an error From 113e172ff6207afed219fdb06e10aa8c4a029f4a Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:38:13 +0000 Subject: [PATCH 2/4] ci: run labeled macOS E2E From 54e9a6dc326dd8aa699eb76d41698574c16219fb Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:41:02 +0000 Subject: [PATCH 3/4] fix(ci): include HostError in SDK probe --- apps/headless/build.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/headless/build.sh b/apps/headless/build.sh index 96b5238..b6d0a50 100755 --- a/apps/headless/build.sh +++ b/apps/headless/build.sh @@ -25,6 +25,7 @@ if [[ -z "${SDKROOT:-}" ]]; then if swiftc -module-cache-path build/module-cache -sdk "$sdk" \ -target "$ARCH-apple-macos13.0" -typecheck \ Sources/HeadlessProtocol/Protocol.swift \ + Sources/HeadlessProtocol/HostError.swift \ Sources/HeadlessProtocol/CaptureFormats.swift >/dev/null 2>&1; then export SDKROOT="$sdk" SDK_ARGS=(--sdk "$sdk") From 39fc879628ae8d7da009f005c957eb9c333c47ee Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:43:49 +0000 Subject: [PATCH 4/4] fix(ci): include HostError in test SDK probe --- apps/headless/test.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/headless/test.sh b/apps/headless/test.sh index 23be71b..65ea996 100755 --- a/apps/headless/test.sh +++ b/apps/headless/test.sh @@ -22,6 +22,7 @@ if [[ "$(uname -s)" == "Darwin" ]]; then if swiftc -module-cache-path build/module-cache -sdk "$sdk" \ -target "$(uname -m)-apple-macos13.0" -typecheck \ Sources/HeadlessProtocol/Protocol.swift \ + Sources/HeadlessProtocol/HostError.swift \ Sources/HeadlessProtocol/CaptureFormats.swift >/dev/null 2>&1; then export SDKROOT="$sdk" SDK_ARGS=(--sdk "$sdk")