diff --git a/CHANGELOG.md b/CHANGELOG.md index 91ac6a3..1929f29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,8 @@ Cutting that release is tracked in ### Fixed +- Browser-operation failures now cross both WebKit and CDP as structured, + allowlisted error codes instead of host-side matching on error-message text. - Linux DevTools-pipe framing now tracks its scan cursor and amortizes buffer compaction, avoiding quadratic work for large screenshot responses. - Phase 1 hardening now bounds Chromium teardown after `SIGKILL`, uses libc's diff --git a/apps/headless/Host/AgentBridge.swift b/apps/headless/Host/AgentBridge.swift index d4504f4..4b71198 100644 --- a/apps/headless/Host/AgentBridge.swift +++ b/apps/headless/Host/AgentBridge.swift @@ -4,26 +4,6 @@ import CoreFoundation import Foundation import WebKit -enum AgentOperationError: Error, CustomStringConvertible { - case timedOut(String) - case invalidResult - case missingParameter(String) - case elementNotFound(String) - case regionNotFound(String) - case operationFailed(String) - - var description: String { - switch self { - case .timedOut(let operation): return "Timed out while waiting for \(operation)" - case .invalidResult: return "Browser returned an invalid agent result" - case .missingParameter(let value): return "Missing command parameter: \(value)" - case .elementNotFound(let value): return "Element was not found: \(value)" - case .regionNotFound(let value): return "Region was not found: \(value)" - case .operationFailed(let value): return value - } - } -} - private let agentWorld = WKContentWorld.world(name: "HeadlessAgent") struct ScreenshotArtifactData { @@ -64,14 +44,16 @@ extension BrowserWindowController { func agentFill(parameters: [String: JSONValue]) throws -> JSONValue { var args = try targetArguments(parameters) - guard let value = parameters["value"]?.stringValue else { throw AgentOperationError.missingParameter("value") } + guard let value = parameters["value"]?.stringValue else { + throw HostError(code: .operationFailed, message: "Missing command parameter: value") + } args["value"] = value return try callAgent("return globalThis.__headlessAgent.fill(args);", arguments: ["args": args]) } func agentPress(parameters: [String: JSONValue]) throws -> JSONValue { guard let key = parameters["key"]?.stringValue, !key.isEmpty, key.count <= 32 else { - throw AgentOperationError.missingParameter("key") + throw HostError(code: .operationFailed, message: "Missing command parameter: key") } return try callAgent("return globalThis.__headlessAgent.press(key);", arguments: ["key": key]) } @@ -93,7 +75,9 @@ extension BrowserWindowController { repeat { lastState = try callAgent("return globalThis.__headlessAgent.state();") - guard case .object(let state) = lastState else { throw AgentOperationError.invalidResult } + guard case .object(let state) = lastState else { + throw HostError(code: .operationFailed, message: "Browser returned an invalid agent result") + } let urlMatches = expectedURL.map { state["url"]?.stringValue?.contains($0) == true } ?? true let textMatches = expectedText.map { state["text"]?.stringValue?.localizedCaseInsensitiveContains($0) == true } ?? true let isLoading = onMain { self.webView.isLoading } @@ -105,7 +89,7 @@ extension BrowserWindowController { Thread.sleep(forTimeInterval: 0.05) } while Date() < deadline - throw AgentOperationError.timedOut("page condition") + throw HostError(code: .timedOut, message: "Timed out while waiting for page condition") } func agentTour(parameters: [String: JSONValue]) throws -> JSONValue { @@ -148,11 +132,11 @@ extension BrowserWindowController { ) throws -> ScreenshotArtifactData { let image = try agentScreenshotImage(parameters: parameters) guard let data = encodeScreenshot(image, format: format) else { - throw AgentOperationError.invalidResult + throw HostError(code: .operationFailed, message: "Browser returned an invalid agent result") } if copyToClipboard { guard format.isImage else { - throw AgentOperationError.operationFailed("Clipboard output is only supported for image screenshots") + throw HostError(code: .operationFailed, message: "Clipboard output is only supported for image screenshots") } onMain { NSPasteboard.general.clearContents() @@ -171,7 +155,7 @@ extension BrowserWindowController { "return globalThis.__headlessAgent.rectangle(args);", arguments: ["args": args] ) guard case .object(let outer) = value, case .object(let rect)? = outer["viewport"] else { - throw AgentOperationError.invalidResult + throw HostError(code: .operationFailed, message: "Browser returned an invalid agent result") } requestedRect = try screenshotRect(rect) } else if parameters["fullPage"]?.boolValue == true { @@ -179,7 +163,9 @@ extension BrowserWindowController { return {x: 0, y: 0, width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight}; """) - guard case .object(let rect) = value else { throw AgentOperationError.invalidResult } + guard case .object(let rect) = value else { + throw HostError(code: .operationFailed, message: "Browser returned an invalid agent result") + } requestedRect = try screenshotRect(rect) } @@ -199,16 +185,22 @@ extension BrowserWindowController { self.webView.takeSnapshot(with: configuration) { image, error in lock.lock() if let image { captured = .success(image) } - else { captured = .failure(error ?? AgentOperationError.invalidResult) } + else { + captured = .failure(error ?? HostError( + code: .operationFailed, message: "Browser returned an invalid agent result" + )) + } lock.unlock() semaphore.signal() } } guard semaphore.wait(timeout: .now() + 30) == .success else { - throw AgentOperationError.timedOut("screenshot") + throw HostError(code: .timedOut, message: "Timed out while waiting for screenshot") } lock.lock(); let result = captured; lock.unlock() - guard let image = try result?.get() else { throw AgentOperationError.invalidResult } + guard let image = try result?.get() else { + throw HostError(code: .operationFailed, message: "Browser returned an invalid agent result") + } return image } @@ -315,7 +307,7 @@ extension BrowserWindowController { } } guard semaphore.wait(timeout: .now() + 5) == .success else { - throw AgentOperationError.timedOut("cookie inspection") + throw HostError(code: .timedOut, message: "Timed out while waiting for cookie inspection") } lock.lock(); let cookies = result; lock.unlock() let host = currentURL?.host?.lowercased() @@ -349,7 +341,7 @@ extension BrowserWindowController { private func requireSensitiveDiagnosticsIfNeeded(_ requested: Bool) throws { guard !requested || ProcessInfo.processInfo.environment["HEADLESS_ALLOW_SENSITIVE_DIAGNOSTICS"] == "1" else { - throw AgentOperationError.operationFailed("SENSITIVE_DIAGNOSTICS_DISABLED") + throw HostError(code: .sensitiveDiagnosticsDisabled, message: "Sensitive diagnostic values are disabled.") } } @@ -359,7 +351,7 @@ extension BrowserWindowController { 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 AgentOperationError.operationFailed("Screenshot dimensions exceed safety limits") + throw HostError(code: .operationFailed, message: "Screenshot dimensions exceed safety limits") } return CGRect(x: x, y: y, width: width, height: height) } @@ -368,13 +360,15 @@ extension BrowserWindowController { var args: [String: Any] = [:] if let target = parameters["target"]?.stringValue { guard target.hasPrefix("@e"), target.count <= 16 else { - throw AgentOperationError.operationFailed("Invalid element reference") + 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 AgentOperationError.missingParameter("target") } + guard !args.isEmpty else { + throw HostError(code: .operationFailed, message: "Missing command parameter: target") + } } return args } @@ -389,7 +383,7 @@ extension BrowserWindowController { var capturedResult: Result? DispatchQueue.main.async { self.webView.callAsyncJavaScript( - agentRuntimeJavaScript + "\n" + body, + agentRuntimeJavaScript + "\n" + agentEvaluationBody(body), arguments: arguments, in: nil, in: agentWorld @@ -401,21 +395,20 @@ extension BrowserWindowController { } } guard semaphore.wait(timeout: .now() + timeout) == .success else { - throw AgentOperationError.timedOut("browser operation") + throw HostError(code: .timedOut, message: "Timed out while waiting for browser operation") } lock.lock() let result = capturedResult lock.unlock() - guard let result else { throw AgentOperationError.invalidResult } + guard let result else { + throw HostError(code: .operationFailed, message: "Browser returned an invalid agent result") + } do { - return try jsonValue(from: result.get()) - } catch let error as AgentOperationError { + return try unwrapAgentEvaluationResult(jsonValue(from: result.get())) + } catch let error as HostError { throw error } catch { - let message = String(describing: error) - if message.contains("ELEMENT_NOT_FOUND:") { throw AgentOperationError.elementNotFound(message) } - if message.contains("REGION_NOT_FOUND:") { throw AgentOperationError.regionNotFound(message) } - throw AgentOperationError.operationFailed(message) + throw HostError(code: .operationFailed, message: String(describing: error)) } } } @@ -435,6 +428,6 @@ private func jsonValue(from value: Any) throws -> JSONValue { case let value as [String: Any]: return .object(try value.mapValues(jsonValue(from:))) case is NSNull: return .null - default: throw AgentOperationError.invalidResult + default: throw HostError(code: .operationFailed, message: "Browser returned an invalid agent result") } } diff --git a/apps/headless/LinuxHost/BrowserProcess.swift b/apps/headless/LinuxHost/BrowserProcess.swift index 36caa7c..ac0d01c 100644 --- a/apps/headless/LinuxHost/BrowserProcess.swift +++ b/apps/headless/LinuxHost/BrowserProcess.swift @@ -793,7 +793,7 @@ final class LinuxBrowserSession: @unchecked Sendable { const key = __input.key; const options = __input.options || {}; \(agentRuntimeJavaScript) - \(body) + \(agentEvaluationBody(body)) })() """ let response = try command("Runtime.evaluate", parameters: [ @@ -812,7 +812,9 @@ final class LinuxBrowserSession: @unchecked Sendable { if let description = result["description"] as? String, result["subtype"] as? String == "error" { throw CDPError.commandFailed(description) } - return try JSONValue.foundationValue(result["value"] ?? NSNull()) + return try unwrapAgentEvaluationResult( + JSONValue.foundationValue(result["value"] ?? NSNull()) + ) } /// Evaluate agent helpers in a fresh isolated world. Page scripts cannot @@ -851,7 +853,10 @@ final class LinuxBrowserSession: @unchecked Sendable { private func requireSensitiveDiagnosticsIfNeeded(_ requested: Bool) throws { guard !requested || ProcessInfo.processInfo.environment["HEADLESS_ALLOW_SENSITIVE_DIAGNOSTICS"] == "1" else { - throw CDPError.commandFailed("SENSITIVE_DIAGNOSTICS_DISABLED") + throw HostError( + code: .sensitiveDiagnosticsDisabled, + message: "Sensitive diagnostic values are disabled." + ) } } diff --git a/apps/headless/LinuxHost/main.swift b/apps/headless/LinuxHost/main.swift index 38a4f00..48d49aa 100644 --- a/apps/headless/LinuxHost/main.swift +++ b/apps/headless/LinuxHost/main.swift @@ -370,6 +370,11 @@ final class LinuxBrowserHost: @unchecked Sendable { } } 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, @@ -377,27 +382,19 @@ final class LinuxBrowserHost: @unchecked Sendable { } return failure(request, "INVALID_INPUT", error.description) } catch let error as CDPError { - let code: String - let suggestion: String? switch error { case .timedOut: - code = "TIMEOUT"; suggestion = "Inspect the page or wait for a narrower condition." - case .commandFailed(let message) where message.contains("ELEMENT_NOT_FOUND"): - code = "ELEMENT_NOT_FOUND"; suggestion = "Run `headless inspect --interactive` to refresh references." - case .commandFailed(let message) where message.contains("REGION_NOT_FOUND"): - code = "REGION_NOT_FOUND"; suggestion = "Run `headless inspect --context outline` to refresh region references." - case .commandFailed(let message) where message.contains("UNSAFE_NAVIGATION"): - code = "UNSAFE_NAVIGATION"; suggestion = "Agent-controlled sessions allow web navigation only." - case .commandFailed(let message) where message.contains("UNSAFE_RESOURCE_TYPE"): - code = "UNSAFE_RESOURCE_TYPE" - suggestion = "Executable files, installers, scripts, and disk images are blocked." - case .commandFailed(let message) where message.contains("SENSITIVE_DIAGNOSTICS_DISABLED"): - code = "SENSITIVE_DIAGNOSTICS_DISABLED" - suggestion = "Restart the host with HEADLESS_ALLOW_SENSITIVE_DIAGNOSTICS=1 only when cookie or storage values are required." + let hostError = HostError(code: .timedOut, message: error.description) + return .failure( + id: request.id, code: hostError.code.rawValue, + message: hostError.message, suggestion: hostError.suggestion + ) default: - code = "OPERATION_FAILED"; suggestion = nil + return .failure( + id: request.id, code: HostErrorCode.operationFailed.rawValue, + message: error.description + ) } - return .failure(id: request.id, code: code, message: error.description, suggestion: suggestion) } catch let error as RecordingError { let code: String let suggestion: String? diff --git a/apps/headless/Sources/HeadlessProtocol/AgentRuntime.swift b/apps/headless/Sources/HeadlessProtocol/AgentRuntime.swift index 6cdb9dd..67199f5 100644 --- a/apps/headless/Sources/HeadlessProtocol/AgentRuntime.swift +++ b/apps/headless/Sources/HeadlessProtocol/AgentRuntime.swift @@ -19,6 +19,11 @@ if (!globalThis.__headlessAgent) { const normalize = value => String(value || '').replace(/\s+/g, ' ').trim(); const clipped = (value, maximum) => normalize(value).slice(0, maximum); const clippedURL = value => String(value || '').slice(0, 512); + const fail = (code, message) => { + const error = new Error(message); + error.headlessCode = code; + throw error; + }; const blockedResourceExtensions = new Set([ 'apk','app','bat','cmd','com','deb','dll','dmg','dylib','exe','img','iso','jar', 'msi','msp','pif','pkg','ps1','psm1','scr','sh','so','vbe','vbs','wsf','zsh' @@ -234,13 +239,13 @@ if (!globalThis.__headlessAgent) { if (!reference) return document; const element = currentRegions.get(reference); if (!element) { - throw new Error(issuedRegionRefs.has(reference) + fail('REGION_NOT_FOUND', issuedRegionRefs.has(reference) ? `REGION_NOT_FOUND:${reference} (expired: inspect again to refresh region references)` : `REGION_NOT_FOUND:${reference} (unknown: no inspection has issued this reference)`); } if (!element.isConnected || !visible(element)) { currentRegions.delete(reference); - throw new Error(`REGION_NOT_FOUND:${reference} (detached: the region is no longer visible on this page)`); + fail('REGION_NOT_FOUND', `REGION_NOT_FOUND:${reference} (detached: the region is no longer visible on this page)`); } return element; }; @@ -448,13 +453,13 @@ if (!globalThis.__headlessAgent) { // is the difference between an agent re-inspecting and an agent retrying // the same dead reference. if (!element) { - throw new Error(issuedRefs.has(target) + fail('ELEMENT_NOT_FOUND', issuedRefs.has(target) ? `ELEMENT_NOT_FOUND:${target} (expired: element references come from the most recent inspection — inspect again to refresh)` : `ELEMENT_NOT_FOUND:${target} (unknown: no inspection has issued this reference)`); } if (!element.isConnected) { current.delete(target); - throw new Error(`ELEMENT_NOT_FOUND:${target} (detached: the element is no longer in the page)`); + fail('ELEMENT_NOT_FOUND', `ELEMENT_NOT_FOUND:${target} (detached: the element is no longer in the page)`); } return element; }; @@ -465,7 +470,7 @@ if (!globalThis.__headlessAgent) { (!normalizedRole || role(element) === normalizedRole) && (!normalizedName || name(element).toLowerCase() === normalizedName) ); - if (matches.length === 0) throw new Error(`ELEMENT_NOT_FOUND:${wantedRole || ''}/${wantedName || ''}`); + if (matches.length === 0) fail('ELEMENT_NOT_FOUND', `ELEMENT_NOT_FOUND:${wantedRole || ''}/${wantedName || ''}`); if (matches.length > 1) throw new Error(`ELEMENT_AMBIGUOUS:${matches.length}`); refFor(matches[0]); return matches[0]; @@ -527,10 +532,10 @@ if (!globalThis.__headlessAgent) { const destination = new URL(element.href, document.baseURI); const scheme = destination.protocol.toLowerCase(); if (!['http:', 'https:'].includes(scheme) || destination.username || destination.password) { - throw new Error(`UNSAFE_NAVIGATION:${scheme}`); + fail('UNSAFE_NAVIGATION', `UNSAFE_NAVIGATION:${scheme}`); } const safety = resourceSafety(destination.href); - if (safety.level === 'blocked') throw new Error(`UNSAFE_RESOURCE_TYPE:${safety.extension}`); + if (safety.level === 'blocked') fail('UNSAFE_RESOURCE_TYPE', `UNSAFE_RESOURCE_TYPE:${safety.extension}`); } element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); element.focus({preventScroll: true}); diff --git a/apps/headless/Sources/HeadlessProtocol/HostError.swift b/apps/headless/Sources/HeadlessProtocol/HostError.swift new file mode 100644 index 0000000..a36e120 --- /dev/null +++ b/apps/headless/Sources/HeadlessProtocol/HostError.swift @@ -0,0 +1,78 @@ +import Foundation + +public enum HostErrorCode: String, Sendable { + case timedOut = "TIMEOUT" + case elementNotFound = "ELEMENT_NOT_FOUND" + case regionNotFound = "REGION_NOT_FOUND" + case unsafeNavigation = "UNSAFE_NAVIGATION" + case unsafeResourceType = "UNSAFE_RESOURCE_TYPE" + case sensitiveDiagnosticsDisabled = "SENSITIVE_DIAGNOSTICS_DISABLED" + case operationFailed = "OPERATION_FAILED" +} + +public struct HostError: Error, CustomStringConvertible, Sendable { + public let code: HostErrorCode + public let message: String + + public init(code: HostErrorCode, message: String) { + self.code = code + self.message = code == .sensitiveDiagnosticsDisabled + ? "Sensitive diagnostic values are disabled." + : String(decoding: message.utf8.prefix(4_096), as: UTF8.self) + } + + public var description: String { message } + + public var suggestion: String? { + switch code { + case .timedOut: + return "Inspect the current page or wait for a narrower condition." + case .elementNotFound: + return "Run `headless inspect --interactive` to refresh element references." + case .regionNotFound: + return "Run `headless inspect --context outline` to refresh region references." + case .unsafeNavigation: + return "Agent-controlled sessions allow web navigation only." + case .unsafeResourceType: + 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 .operationFailed: + return nil + } + } +} + +/// Wrap an agent operation in a JSON-safe result so JavaScript error codes +/// cross WebKit and CDP without being recovered from human-readable text. +public func agentEvaluationBody(_ body: String) -> String { + """ + return await (async () => { + try { + const value = await (async () => { + \(body) + })(); + return {__headlessAgentResult: true, ok: true, value}; + } catch (error) { + const code = typeof error?.headlessCode === 'string' + ? error.headlessCode : 'OPERATION_FAILED'; + const message = String(error?.message || error || 'Browser operation failed').slice(0, 4096); + return {__headlessAgentResult: true, ok: false, error: {code, message}}; + } + })(); + """ +} + +public func unwrapAgentEvaluationResult(_ value: JSONValue) throws -> JSONValue { + guard case .object(let envelope) = value, + envelope["__headlessAgentResult"] == .bool(true), + let ok = envelope["ok"]?.boolValue else { + throw HostError(code: .operationFailed, message: "Browser returned an invalid agent result") + } + if ok { return envelope["value"] ?? .null } + guard case .object(let error)? = envelope["error"] else { + throw HostError(code: .operationFailed, message: "Browser returned an invalid agent error") + } + let code = error["code"]?.stringValue.flatMap(HostErrorCode.init(rawValue:)) ?? .operationFailed + throw HostError(code: code, message: error["message"]?.stringValue ?? "Browser operation failed") +} diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index 3a660fd..1b82952 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -992,17 +992,20 @@ struct ProtocolTests { } enum SyntheticInitialCaptureFailure: Error { case unavailable } - let initialFailureBegan = Date() + var initialCaptureAttempts = 0 do { _ = try BrowserRecording( outputURL: URL(fileURLWithPath: root + "/initial-failure.mp4"), fps: 8, - captureFrame: { throw SyntheticInitialCaptureFailure.unavailable } + captureFrame: { + initialCaptureAttempts += 1 + throw SyntheticInitialCaptureFailure.unavailable + } ) throw TestFailure(description: "an unavailable initial frame should fail recording startup") } catch RecordingError.captureFailed { try expect( - Date().timeIntervalSince(initialFailureBegan) < 2, - "recording startup should use short bounded backoff instead of busy-polling for three seconds" + initialCaptureAttempts == 6, + "recording startup should use six bounded attempts instead of polling for three seconds" ) } @@ -1539,6 +1542,42 @@ struct ProtocolTests { try expect(buffer.popFirst() == Array("two".utf8), "second buffered CDP message changed") } + static func typedHostErrorsRoundTrip() throws { + let success = try unwrapAgentEvaluationResult(.object([ + "__headlessAgentResult": .bool(true), "ok": .bool(true), + "value": .object(["clicked": .string("@e1")]), + ])) + try expect( + success == .object(["clicked": .string("@e1")]), + "agent result envelope should preserve successful values" + ) + + do { + _ = try unwrapAgentEvaluationResult(.object([ + "__headlessAgentResult": .bool(true), "ok": .bool(false), + "error": .object([ + "code": .string("ELEMENT_NOT_FOUND"), + "message": .string("reference expired"), + ]), + ])) + throw TestFailure(description: "typed agent error should throw") + } catch let error as HostError { + try expect(error.code == .elementNotFound, "agent error code should survive the engine boundary") + try expect(error.message == "reference expired", "agent error message should survive the engine boundary") + try expect(error.suggestion?.contains("inspect --interactive") == true, "typed error should own its suggestion") + } + + do { + _ = try unwrapAgentEvaluationResult(.object([ + "__headlessAgentResult": .bool(true), "ok": .bool(false), + "error": .object(["code": .string("PAGE_DEFINED_CODE"), "message": .string("failed")]), + ])) + throw TestFailure(description: "unknown agent error should throw") + } catch let error as HostError { + try expect(error.code == .operationFailed, "unknown error codes must fail closed") + } + } + static func main() { if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--peer-denied-client" { @@ -1600,6 +1639,7 @@ struct ProtocolTests { ("oversized socket request", oversizedSocketRequestIsRejected), ("different peer uid", differentPeerUserIsRejected), ("incremental NUL message buffering", nullTerminatedBufferScansIncrementally), + ("typed host errors", typedHostErrorsRoundTrip), ] var failures = 0 diff --git a/apps/headless/Tests/agent-runtime.test.mjs b/apps/headless/Tests/agent-runtime.test.mjs index bdc57fd..2064416 100644 --- a/apps/headless/Tests/agent-runtime.test.mjs +++ b/apps/headless/Tests/agent-runtime.test.mjs @@ -101,7 +101,7 @@ assert(scopedActions.elements.every(element => element.actions.length > 0)); assert.throws( () => agent.snapshot(false, false, {context: 'text', within: '@r999999'}), - /REGION_NOT_FOUND/, + error => error.headlessCode === 'REGION_NOT_FOUND' && /REGION_NOT_FOUND/.test(error.message), ); // A region reference issued by an earlier inspection stays usable, which is @@ -121,8 +121,14 @@ assert.throws( const staleRef = full.elements[full.elements.length - 1].ref; assert.match(staleRef, /^@e\d+$/); agent.snapshot(false, false, {context: 'summary', limit: 8, budget: 700}); -assert.throws(() => agent.click({target: staleRef}), /ELEMENT_NOT_FOUND.*expired/); -assert.throws(() => agent.click({target: '@e999999'}), /ELEMENT_NOT_FOUND.*unknown/); +assert.throws( + () => agent.click({target: staleRef}), + error => error.headlessCode === 'ELEMENT_NOT_FOUND' && /ELEMENT_NOT_FOUND.*expired/.test(error.message), +); +assert.throws( + () => agent.click({target: '@e999999'}), + error => error.headlessCode === 'ELEMENT_NOT_FOUND' && /ELEMENT_NOT_FOUND.*unknown/.test(error.message), +); // A reference from the latest inspection still resolves. const fresh = agent.snapshot(false, false, {context: 'actions', limit: 5}); @@ -167,7 +173,7 @@ assert.throws( ); assert.throws( () => agent.click({role: 'link', name: 'Unsafe runtime link'}), - /UNSAFE_NAVIGATION:javascript:/, + error => error.headlessCode === 'UNSAFE_NAVIGATION' && /UNSAFE_NAVIGATION:javascript:/.test(error.message), ); window.scrollY = 0; diff --git a/apps/headless/main.swift b/apps/headless/main.swift index cdd3f65..e2d385f 100644 --- a/apps/headless/main.swift +++ b/apps/headless/main.swift @@ -1251,29 +1251,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } return .success(id: request.id, result: result) - } catch let error as AgentOperationError { - switch error { - case .timedOut: - return failure(request, code: "TIMEOUT", message: error.description, - suggestion: "Inspect the current page or wait for a narrower condition.") - case .elementNotFound: - return failure(request, code: "ELEMENT_NOT_FOUND", message: error.description, - suggestion: "Run `headless inspect --interactive` to refresh element references.") - case .regionNotFound: - return failure(request, code: "REGION_NOT_FOUND", message: error.description, - suggestion: "Run `headless inspect --context outline` to refresh region references.") - case .operationFailed(let message) where message.contains("UNSAFE_NAVIGATION"): - return failure(request, code: "UNSAFE_NAVIGATION", message: error.description, - suggestion: "Agent-controlled sessions allow web navigation only.") - case .operationFailed(let message) where message.contains("UNSAFE_RESOURCE_TYPE"): - return failure(request, code: "UNSAFE_RESOURCE_TYPE", message: error.description, - suggestion: "Executable files, installers, scripts, and disk images are blocked.") - case .operationFailed(let message) where message.contains("SENSITIVE_DIAGNOSTICS_DISABLED"): - return failure(request, code: "SENSITIVE_DIAGNOSTICS_DISABLED", message: "Sensitive diagnostic values are disabled.", - suggestion: "Restart the host with HEADLESS_ALLOW_SENSITIVE_DIAGNOSTICS=1 only when cookie or storage values are required.") - default: - return failure(request, code: "OPERATION_FAILED", message: error.description) - } + } 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, diff --git a/docs/roadmap/improvements-backlog.md b/docs/roadmap/improvements-backlog.md index 7a6d90f..692c915 100644 --- a/docs/roadmap/improvements-backlog.md +++ b/docs/roadmap/improvements-backlog.md @@ -159,9 +159,13 @@ collapse (verified line ranges): dispatch switches (`main.swift:949-1291` / gate, target validation (3 copies + JS), screenshot bounds, syscall shims (`Transport.swift:387-407` / `CDP.swift:278-288`). -**B2. Typed errors end-to-end.** ([#22](https://github.com/LockInTime/headless/issues/22)) Replace `message.contains("ELEMENT_NOT_FOUND")` +**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 -enum carrying the protocol code. +enum carrying the protocol code.~~ **Done:** the isolated runtime assigns an +allowlisted error code, WebKit and CDP return the same bounded JSON envelope, +and both hosts propagate a shared `HostError` whose typed code owns the +protocol response and recovery suggestion. Unknown page codes fail closed as +`OPERATION_FAILED`; no host classifies human-readable error text. **B3. Single-source constants + drift tests.** ([#23](https://github.com/LockInTime/headless/issues/23)) Blocked/caution extensions exist in Swift (`HP/Protocol.swift:576-591`) and JS