diff --git a/CHANGELOG.md b/CHANGELOG.md index 1929f29..0e1f320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,10 @@ Cutting that release is tracked in ### Fixed +- Portable name characters, local-development hosts, scroll bounds, and + network-emulation bounds now have one definition shared by CLI validation, + protocol validation, and diagnostics; tests also lock the Swift/JavaScript + unsafe-resource extension sets together. - 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 diff --git a/apps/headless/Sources/HeadlessProtocol/CLI.swift b/apps/headless/Sources/HeadlessProtocol/CLI.swift index 06bbe3e..b2c8b3d 100644 --- a/apps/headless/Sources/HeadlessProtocol/CLI.swift +++ b/apps/headless/Sources/HeadlessProtocol/CLI.swift @@ -270,7 +270,8 @@ public struct CLIParser { guard args.count <= 1 else { throw CLIParseError.invalidOption(args[1]) } var parameters: [String: JSONValue] = ["direction": .string(direction)] if let amountText { - guard let amount = Double(amountText), amount > 0, amount <= 100_000 else { + guard let amount = Double(amountText), amount.isFinite, + ProtocolBounds.scrollAmount.contains(amount) else { throw CLIParseError.invalidNumber(amountText) } parameters["amount"] = .number(amount) @@ -469,9 +470,15 @@ public struct CLIParser { let up = try removeOption("--upload-kbps", from: &args) try requireEmpty(args) var parameters: [String: JSONValue] = ["offline": .bool(offline)] - for (option, value) in [("latencyMs", latency), ("downloadKbps", down), ("uploadKbps", up)] { + for (option, value, range) in [ + ("latencyMs", latency, ProtocolBounds.networkLatencyMilliseconds), + ("downloadKbps", down, ProtocolBounds.networkThroughputKbps), + ("uploadKbps", up, ProtocolBounds.networkThroughputKbps), + ] { if let value { - guard let number = Double(value), number.isFinite else { throw CLIParseError.invalidNumber(value) } + guard let number = Double(value), number.isFinite, range.contains(number) else { + throw CLIParseError.invalidNumber(value) + } parameters[option] = .number(number) } } diff --git a/apps/headless/Sources/HeadlessProtocol/Diagnostics.swift b/apps/headless/Sources/HeadlessProtocol/Diagnostics.swift index a9b399e..3371e67 100644 --- a/apps/headless/Sources/HeadlessProtocol/Diagnostics.swift +++ b/apps/headless/Sources/HeadlessProtocol/Diagnostics.swift @@ -245,7 +245,7 @@ public final class QADiagnosticStore: @unchecked Sendable { private func isLocalURL(_ value: String) -> Bool { guard let host = URL(string: value)?.host?.lowercased() else { return false } - return ["localhost", "127.0.0.1", "0.0.0.0", "::1"].contains(host) + return isLocalDevelopmentHost(host) } private func redactedURL(_ value: String) -> String { diff --git a/apps/headless/Sources/HeadlessProtocol/Protocol.swift b/apps/headless/Sources/HeadlessProtocol/Protocol.swift index 9861c53..3c78359 100644 --- a/apps/headless/Sources/HeadlessProtocol/Protocol.swift +++ b/apps/headless/Sources/HeadlessProtocol/Protocol.swift @@ -258,7 +258,10 @@ public struct CommandRequest: Codable, Equatable, Sendable { guard ["up", "down", "top", "bottom"].contains(direction) else { throw ProtocolValidationError.invalidParameter("Invalid scroll direction") } - _ = try number("amount", minimum: 0.1, maximum: 100_000) + _ = try number( + "amount", minimum: ProtocolBounds.scrollAmount.lowerBound, + maximum: ProtocolBounds.scrollAmount.upperBound + ) case .wait: try allow(["settled", "url", "text", "timeoutMs"]) try boolean("settled") @@ -393,9 +396,18 @@ public struct CommandRequest: Codable, Equatable, Sendable { case .networkEmulate: try allow(["offline", "latencyMs", "downloadKbps", "uploadKbps"]) try boolean("offline") - _ = try number("latencyMs", minimum: 0, maximum: 120_000) - _ = try number("downloadKbps", minimum: -1, maximum: 1_000_000) - _ = try number("uploadKbps", minimum: -1, maximum: 1_000_000) + _ = try number( + "latencyMs", minimum: ProtocolBounds.networkLatencyMilliseconds.lowerBound, + maximum: ProtocolBounds.networkLatencyMilliseconds.upperBound + ) + _ = try number( + "downloadKbps", minimum: ProtocolBounds.networkThroughputKbps.lowerBound, + maximum: ProtocolBounds.networkThroughputKbps.upperBound + ) + _ = try number( + "uploadKbps", minimum: ProtocolBounds.networkThroughputKbps.lowerBound, + maximum: ProtocolBounds.networkThroughputKbps.upperBound + ) case .networkMockSet: try allow(["url", "status", "body", "contentType"]) if let url = try string("url", required: true, maximumBytes: 8_192) { _ = try normalizedWebURL(url) } @@ -490,8 +502,7 @@ public func validateIdentifier(_ value: String, field: String) throws { guard !value.isEmpty, value.utf8.count <= 64 else { throw ProtocolValidationError.invalidIdentifier(field: field) } - let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-") - guard value.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { + guard hasPortableNameCharacters(value) else { throw ProtocolValidationError.invalidIdentifier(field: field) } } @@ -511,8 +522,7 @@ public func validateArtifactName(_ value: String, expectedExtensions: Set Bool { + value.unicodeScalars.allSatisfy(portableNameCharacters.contains) +} + +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 +} + /// Agent navigation is deliberately limited to web URLs in P0. File URLs and /// application schemes would let an untrusted page or prompt cross the browser /// boundary and are not accepted by the host. @@ -617,7 +640,13 @@ public func isLocalDevelopmentAddress(_ input: String) -> Bool { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) guard let components = URLComponents(string: "//" + trimmed), let host = components.host?.lowercased() else { return false } - return ["localhost", "127.0.0.1", "0.0.0.0", "::1"].contains(host) + return isLocalDevelopmentHost(host) +} + +public let localDevelopmentHosts: Set = ["localhost", "127.0.0.1", "0.0.0.0", "::1"] + +public func isLocalDevelopmentHost(_ host: String) -> Bool { + localDevelopmentHosts.contains(host.lowercased()) } public enum ProtocolCodec { diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index 1b82952..39ad035 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -1578,6 +1578,60 @@ struct ProtocolTests { } } + static func singleSourceContractConstants() throws { + func javaScriptSet(named name: String) throws -> Set { + let marker = "const \(name) = new Set([" + guard let start = agentRuntimeJavaScript.range(of: marker), + let end = agentRuntimeJavaScript.range( + of: "]);", range: start.upperBound..0` vs `>=0.1`, `CLI.swift:248` / `Protocol.swift:261`; network emulate unbounded in CLI, `CLI.swift:449` / `Protocol.swift:396-398`). One definition -each + a test asserting the JS copy contains the Swift set. +each + a test asserting the JS copy contains the Swift set.~~ **Done:** name +characters, local hosts, and numeric bounds have one shared definition; the +CLI enforces the validator's scroll and network ranges; and protocol coverage +parses both JavaScript extension sets and requires exact equality with Swift. **B4. Dead code removal.** ([#24](https://github.com/LockInTime/headless/issues/24)) `screenshotSeriesPoints(from:)` (`HP/ScreenshotSeries.swift:74-76`), `JSONValue.foundationObject`