Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions apps/headless/Sources/HeadlessProtocol/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
Expand Down
2 changes: 1 addition & 1 deletion apps/headless/Sources/HeadlessProtocol/Diagnostics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
51 changes: 40 additions & 11 deletions apps/headless/Sources/HeadlessProtocol/Protocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -511,8 +522,7 @@ public func validateArtifactName(_ value: String, expectedExtensions: Set<String
"Artifact output must be a simple \(extensionDescription) filename"
)
}
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
guard value.unicodeScalars.allSatisfy({ allowed.contains($0) }) else {
guard hasPortableNameCharacters(value) else {
throw ProtocolValidationError.invalidParameter("Artifact output contains unsupported characters")
}
}
Expand All @@ -523,12 +533,25 @@ public func validateArtifactPrefix(_ value: String) throws {
!value.hasPrefix(".") else {
throw ProtocolValidationError.invalidParameter("Artifact prefix must be a simple filename prefix")
}
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
guard value.unicodeScalars.allSatisfy({ allowed.contains($0) }) else {
guard hasPortableNameCharacters(value) else {
throw ProtocolValidationError.invalidParameter("Artifact prefix contains unsupported characters")
}
}

private let portableNameCharacters = CharacterSet(
charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-"
)

public func hasPortableNameCharacters(_ value: String) -> 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.
Expand Down Expand Up @@ -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<String> = ["localhost", "127.0.0.1", "0.0.0.0", "::1"]

public func isLocalDevelopmentHost(_ host: String) -> Bool {
localDevelopmentHosts.contains(host.lowercased())
}

public enum ProtocolCodec {
Expand Down
55 changes: 55 additions & 0 deletions apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1578,6 +1578,60 @@ struct ProtocolTests {
}
}

static func singleSourceContractConstants() throws {
func javaScriptSet(named name: String) throws -> Set<String> {
let marker = "const \(name) = new Set(["
guard let start = agentRuntimeJavaScript.range(of: marker),
let end = agentRuntimeJavaScript.range(
of: "]);", range: start.upperBound..<agentRuntimeJavaScript.endIndex
) else {
throw TestFailure(description: "missing JavaScript set: \(name)")
}
return Set(agentRuntimeJavaScript[start.upperBound..<end.lowerBound]
.split(separator: ",")
.map {
$0.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "'\""))
})
}

try expect(
javaScriptSet(named: "blockedResourceExtensions") == blockedRemoteResourceExtensions,
"blocked resource extensions drifted between Swift and the isolated runtime"
)
try expect(
javaScriptSet(named: "cautionResourceExtensions") == cautionRemoteResourceExtensions,
"caution resource extensions drifted between Swift and the isolated runtime"
)
try expect(hasPortableNameCharacters("artifact-1_name.json"), "portable artifact characters changed")
try expect(!hasPortableNameCharacters("artifact/name.json"), "path separators must not be portable name characters")
try expect(
localDevelopmentHosts == ["localhost", "127.0.0.1", "0.0.0.0", "::1"],
"local development host allowlist changed"
)

let minimumScroll = try CLIParser().parse([
"scroll", "down", "--amount", String(ProtocolBounds.scrollAmount.lowerBound),
])
try minimumScroll.request?.validate()
try expectThrows("CLI should reject scroll amounts below the validator minimum") {
_ = try CLIParser().parse(["scroll", "down", "--amount", "0.09"])
}
let maximumNetwork = try CLIParser().parse([
"network", "emulate",
"--latency", String(ProtocolBounds.networkLatencyMilliseconds.upperBound),
"--download-kbps", String(ProtocolBounds.networkThroughputKbps.upperBound),
"--upload-kbps", String(ProtocolBounds.networkThroughputKbps.lowerBound),
])
try maximumNetwork.request?.validate()
try expectThrows("CLI should reject latency above the validator maximum") {
_ = try CLIParser().parse(["network", "emulate", "--latency", "120001"])
}
try expectThrows("CLI should reject throughput below the validator minimum") {
_ = try CLIParser().parse(["network", "emulate", "--download-kbps", "-2"])
}
}

static func main() {
if CommandLine.arguments.count == 3,
CommandLine.arguments[1] == "--peer-denied-client" {
Expand Down Expand Up @@ -1640,6 +1694,7 @@ struct ProtocolTests {
("different peer uid", differentPeerUserIsRejected),
("incremental NUL message buffering", nullTerminatedBufferScansIncrementally),
("typed host errors", typedHostErrorsRoundTrip),
("single-source contract constants", singleSourceContractConstants),
]

var failures = 0
Expand Down
7 changes: 5 additions & 2 deletions docs/roadmap/improvements-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,16 @@ 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
**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
(`HP/AgentRuntime.swift:17-21`) with no cross-check; artifact charset written
4×; local-address list 3×; CLI vs validator bounds disagree (scroll amount
`>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`
Expand Down