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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,17 @@ Cutting that release is tracked in

### Fixed

- Phase 1 hardening now bounds Chromium teardown after `SIGKILL`, uses libc's
peer-credential constant, deterministically caps diagnostic headers, drops
malformed CDP header values, atomically finalizes artifacts without
overwriting, and derives the long transport timeout for flow replay.
- Recording startup replaces its three-second busy poll with six short,
bounded backoff attempts, and status no longer reads `Process.isRunning`
across threads.
- macOS now keeps manual and agent-controlled browsing inside the same HTTP(S)
navigation boundary instead of dispatching application or script schemes.
- Unknown `qa` subcommands now report the command error before inspecting
irrelevant trailing options.
- `fill` preserves quoted whitespace and accepts literal `--json` or
`--session` values after the standard `--` end-of-options sentinel.

Expand Down
10 changes: 6 additions & 4 deletions apps/headless/LinuxHost/BrowserProcess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ private final class ChromiumChildProcess {
while isRunning && Date() < deadline { Thread.sleep(forTimeInterval: 0.05) }
if isRunning {
_ = kill(processIdentifier, SIGKILL)
while isRunning { Thread.sleep(forTimeInterval: 0.01) }
// An uninterruptible child must not wedge host teardown forever.
// SIGKILL normally reaps immediately; after this final bound the
// operating system remains responsible for the stuck process.
let killDeadline = Date().addingTimeInterval(2)
while isRunning && Date() < killDeadline { Thread.sleep(forTimeInterval: 0.01) }
}
}
}
Expand Down Expand Up @@ -852,9 +856,7 @@ final class LinuxBrowserSession: @unchecked Sendable {
}

private func stringHeaders(_ headers: [String: Any]?) -> [String: String] {
(headers ?? [:]).reduce(into: [:]) { result, entry in
result[entry.key] = String(describing: entry.value)
}
diagnosticStringHeaders(headers)
}

private func matchingMock(_ url: String) -> NetworkMock? {
Expand Down
24 changes: 20 additions & 4 deletions apps/headless/Sources/HeadlessProtocol/Artifacts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,14 @@ public final class ArtifactStore: @unchecked Sendable {

public func finalize(_ source: URL, renameTo requestedName: String?) throws -> JSONValue {
lock.lock(); defer { lock.unlock() }
guard source.deletingLastPathComponent().standardizedFileURL == rootURL.standardizedFileURL else {
throw ArtifactError.invalidName(source.lastPathComponent)
}
var sourceInfo = stat()
guard lstat(source.path, &sourceInfo) == 0,
(sourceInfo.st_mode & S_IFMT) == S_IFREG else {
throw ArtifactError.invalidName(source.lastPathComponent)
}
var finalURL = source
if let requestedName, requestedName != source.lastPathComponent {
do { try validateArtifactName(requestedName, expectedExtension: source.pathExtension) }
Expand All @@ -145,11 +153,19 @@ public final class ArtifactStore: @unchecked Sendable {
guard destination.deletingLastPathComponent().standardizedFileURL == rootURL.standardizedFileURL else {
throw ArtifactError.invalidName(requestedName)
}
guard !FileManager.default.fileExists(atPath: destination.path) else {
throw ArtifactError.alreadyExists(requestedName)
// Both names live in the private artifact directory. A hard link
// is an atomic no-replace operation on that filesystem: unlike a
// fileExists + move pair, an external creator cannot win a race
// and have its destination overwritten.
guard link(source.path, destination.path) == 0 else {
if errno == EEXIST { throw ArtifactError.alreadyExists(requestedName) }
throw ArtifactError.writeFailed(String(cString: strerror(errno)))
}
guard unlink(source.path) == 0 else {
let reason = String(cString: strerror(errno))
_ = unlink(destination.path)
throw ArtifactError.writeFailed(reason)
}
do { try FileManager.default.moveItem(at: source, to: destination) }
catch { throw ArtifactError.writeFailed(error.localizedDescription) }
finalURL = destination
}
guard chmod(finalURL.path, 0o600) == 0 else {
Expand Down
17 changes: 12 additions & 5 deletions apps/headless/Sources/HeadlessProtocol/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public func requestTimeout(for request: CommandRequest) -> TimeInterval {
if let milliseconds = request.parameters["timeoutMs"]?.numberValue {
return min(125, max(10, milliseconds / 1_000 + 5))
}
if request.command == .tour { return 125 }
if request.command == .tour || request.command == .flowRun { return 125 }
if request.command == .recordStop { return 30 }
if request.command == .screenshot {
return request.parameters["series"]?.stringValue == nil ? 30 : 125
Expand Down Expand Up @@ -131,10 +131,17 @@ public struct CLIParser {
return try parseRecord(arguments, session: session, jsonOutput: jsonOutput)
case "qa":
guard let subcommand = arguments.first else { throw CLIParseError.missingArgument("qa report|clear") }
try requireEmpty(Array(arguments.dropFirst()))
if subcommand == "report" { return remote(.qaReport, session: session, jsonOutput: jsonOutput) }
if subcommand == "clear" { return remote(.qaClear, session: session, jsonOutput: jsonOutput) }
throw CLIParseError.unknownCommand("qa \(subcommand)")
let trailing = Array(arguments.dropFirst())
switch subcommand {
case "report":
try requireEmpty(trailing)
return remote(.qaReport, session: session, jsonOutput: jsonOutput)
case "clear":
try requireEmpty(trailing)
return remote(.qaClear, session: session, jsonOutput: jsonOutput)
default:
throw CLIParseError.unknownCommand("qa \(subcommand)")
}
case "console":
return try parseConsole(arguments, session: session, jsonOutput: jsonOutput)
case "network":
Expand Down
10 changes: 9 additions & 1 deletion apps/headless/Sources/HeadlessProtocol/Diagnostics.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import Foundation

/// CDP defines header values as strings. Drop malformed values instead of
/// turning arbitrary JSON containers into implementation-dependent debug text.
public func diagnosticStringHeaders(_ headers: [String: Any]?) -> [String: String] {
(headers ?? [:]).reduce(into: [:]) { result, entry in
if let value = entry.value as? String { result[entry.key] = value }
}
}

public final class QADiagnosticStore: @unchecked Sendable {
private let lock = NSLock()
private var events: [JSONValue] = []
Expand Down Expand Up @@ -249,7 +257,7 @@ public final class QADiagnosticStore: @unchecked Sendable {

private func headersValue(_ headers: [String: String]) -> JSONValue {
var safe: [String: JSONValue] = [:]
for (name, value) in headers.prefix(64) {
for (name, value) in headers.sorted(by: { $0.key < $1.key }).prefix(64) {
let normalizedName = bounded(name, bytes: 128)
safe[normalizedName] = .string(
sensitiveHeader(normalizedName) ? "[redacted]" : bounded(value, bytes: 1_024)
Expand Down
29 changes: 17 additions & 12 deletions apps/headless/Sources/HeadlessProtocol/Recording.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public final class BrowserRecording: @unchecked Sendable {
private var frameCount = 0
private var droppedFrames = 0
private var failure: Error?
private var processExited = false

public init(
outputURL: URL,
Expand All @@ -66,26 +67,29 @@ public final class BrowserRecording: @unchecked Sendable {
process.standardInput = inputPipe
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
do { try process.run() }
catch { throw RecordingError.captureFailed(error.localizedDescription) }
let firstFrameDeadline = Date().addingTimeInterval(3)
process.terminationHandler = { [weak self] _ in
guard let self else { return }
self.lock.lock(); self.processExited = true; self.lock.unlock()
}
var firstFrame: Data?
var firstFrameError: Error?
repeat {
let initialCaptureAttempts = 6
for attempt in 0..<initialCaptureAttempts where firstFrame == nil {
do { firstFrame = try captureFrame() }
catch {
firstFrameError = error
Thread.sleep(forTimeInterval: 0.05)
if attempt < initialCaptureAttempts - 1 {
Thread.sleep(forTimeInterval: 0.05 * pow(2, Double(attempt)))
}
}
} while firstFrame == nil && Date() < firstFrameDeadline
}
guard let firstFrame else {
try? inputPipe.fileHandleForWriting.close()
process.terminate()
process.waitUntilExit()
throw RecordingError.captureFailed(
"initial browser frame was unavailable: \(firstFrameError.map(String.init(describing:)) ?? "unknown error")"
"initial browser frame was unavailable after \(initialCaptureAttempts) attempts: \(firstFrameError.map(String.init(describing:)) ?? "unknown error")"
)
}
do { try process.run() }
catch { throw RecordingError.captureFailed(error.localizedDescription) }
do {
try inputPipe.fileHandleForWriting.write(contentsOf: firstFrame)
frameCount = 1
Expand Down Expand Up @@ -150,7 +154,7 @@ public final class BrowserRecording: @unchecked Sendable {

public func status() -> JSONValue {
lock.lock()
let active = process.isRunning && !stopRequested && failure == nil
let active = !processExited && !stopRequested && failure == nil
let frames = frameCount
let dropped = droppedFrames
let error = failure.map(String.init(describing:))
Expand Down Expand Up @@ -187,7 +191,7 @@ public final class BrowserRecording: @unchecked Sendable {
let interval = 1.0 / fps
var consecutiveFailures = 0
while true {
lock.lock(); let shouldStop = stopRequested; lock.unlock()
lock.lock(); let shouldStop = stopRequested || processExited; lock.unlock()
if shouldStop { break }
let began = Date()
do {
Expand All @@ -211,6 +215,7 @@ public final class BrowserRecording: @unchecked Sendable {
while process.isRunning && Date() < deadline { Thread.sleep(forTimeInterval: 0.02) }
if process.isRunning { process.terminate() }
process.waitUntilExit()
lock.lock(); processExited = true; lock.unlock()
finished.signal()
}

Expand Down
3 changes: 1 addition & 2 deletions apps/headless/Sources/HeadlessProtocol/Transport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -423,9 +423,8 @@ private func peerUserID(fd: Int32) throws -> uid_t {
struct PeerCredentials { var pid: pid_t = 0; var uid: uid_t = 0; var gid: gid_t = 0 }
var credentials = PeerCredentials()
var length = socklen_t(MemoryLayout<PeerCredentials>.size)
let soPeerCred: Int32 = 17
guard withUnsafeMutablePointer(to: &credentials, {
getsockopt(fd, SOL_SOCKET, soPeerCred, $0, &length)
getsockopt(fd, SOL_SOCKET, SO_PEERCRED, $0, &length)
}) == 0 else { throw LocalTransportError.socketFailure("peer credential check") }
return credentials.uid
#endif
Expand Down
84 changes: 84 additions & 0 deletions apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,7 @@ struct ProtocolTests {
try expect(shortWait.request.map { requestTimeout(for: $0) } == 10, "wait timeout should retain the transport minimum")

try expect(requestTimeout(for: CommandRequest(command: .tour)) == 125, "tour should use the long timeout")
try expect(requestTimeout(for: CommandRequest(command: .flowRun)) == 125, "flow replay should use the long timeout")
try expect(
requestTimeout(for: CommandRequest(command: .screenshot, parameters: ["series": .string("viewport")])) == 125,
"screenshot series should use the long timeout"
Expand Down Expand Up @@ -780,6 +781,30 @@ struct ProtocolTests {
)
let recordingMode = (try FileManager.default.attributesOfItem(atPath: recording.path)[.posixPermissions] as? NSNumber)?.intValue
try expect(recordingMode == 0o600, "reserved recording should be private")
_ = try store.writeReserved(Data("recording".utf8), to: recording)
let finalized = try store.finalize(recording, renameTo: "final-recording.mp4")
guard case .object(let finalizedMetadata) = finalized else {
throw TestFailure(description: "finalized artifact metadata")
}
try expect(finalizedMetadata["name"] == .string("final-recording.mp4"), "artifact rename should return its final name")
try expect(!FileManager.default.fileExists(atPath: recording.path), "artifact rename should remove its reserved source name")

let collisionSource = try store.reserve(
requestedName: "collision-source.mp4", extension: "mp4", prefix: "unused"
)
_ = try store.writeReserved(Data("source".utf8), to: collisionSource)
_ = try store.write(
Data("destination".utf8), requestedName: "collision.mp4",
extension: "mp4", prefix: "unused"
)
try expectThrows("artifact finalization must never replace an existing destination") {
_ = try store.finalize(collisionSource, renameTo: "collision.mp4")
}
try expect(
try Data(contentsOf: URL(fileURLWithPath: root + "/collision.mp4")) == Data("destination".utf8),
"artifact collision should preserve the existing destination"
)
try expect(FileManager.default.fileExists(atPath: collisionSource.path), "failed finalization should preserve its source")
try expectThrows("artifact overwrite should be rejected") {
_ = try store.write(Data(), requestedName: "sample.png", extension: "png", prefix: "unused")
}
Expand Down Expand Up @@ -919,6 +944,9 @@ struct ProtocolTests {
last=''
for argument in "$@"; do last="$argument"; done
printf '%s\n' "$@" > "$last.arguments"
case "$last" in
*exit-early*) dd bs=1 count=1 of=/dev/null 2>/dev/null; : > "$last"; exit 0 ;;
esac
cat >/dev/null
: > "$last"
"""
Expand Down Expand Up @@ -963,6 +991,37 @@ struct ProtocolTests {
return arguments[index + 1]
}

enum SyntheticInitialCaptureFailure: Error { case unavailable }
let initialFailureBegan = Date()
do {
_ = try BrowserRecording(
outputURL: URL(fileURLWithPath: root + "/initial-failure.mp4"), fps: 8,
captureFrame: { 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"
)
}

let earlyExitRecording = try BrowserRecording(
outputURL: URL(fileURLWithPath: root + "/exit-early.mp4"), fps: 4,
captureFrame: { Data([0x01, 0x02, 0x03, 0x04]) }
)
let earlyExitDeadline = Date().addingTimeInterval(2)
while Date() < earlyExitDeadline {
guard case .object(let status) = earlyExitRecording.status(),
status["active"] == .bool(true) else { break }
Thread.sleep(forTimeInterval: 0.01)
}
guard case .object(let earlyExitStatus) = earlyExitRecording.status() else {
throw TestFailure(description: "early-exit recording status")
}
try expect(earlyExitStatus["active"] == .bool(false), "encoder termination should update recording status")
_ = try earlyExitRecording.stop(timeout: 2)

for format in RecordingFormat.allCases {
for quality in RecordingQuality.allCases {
let output = URL(fileURLWithPath: root)
Expand Down Expand Up @@ -1287,6 +1346,10 @@ struct ProtocolTests {

static func diagnosticServices() throws {
let store = QADiagnosticStore()
let typedHeaders = diagnosticStringHeaders([
"X-String": "value", "X-Number": 42, "X-Object": ["nested": true],
])
try expect(typedHeaders == ["X-String": "value"], "non-string CDP header values should be dropped")
store.append(kind: "console", level: "warn", message: "first")
store.append(kind: "console", level: "error", message: "second")
store.append(
Expand All @@ -1309,6 +1372,21 @@ struct ProtocolTests {
let detailText = String(decoding: try ProtocolCodec.encoder.encode(store.networkDetail(requestID: "request-1")), as: UTF8.self)
try expect(detailText.contains("[redacted]"), "network details must redact sensitive headers")
try expect(detailText.contains("X-Visible"), "network details should retain non-sensitive headers")

var manyHeaders: [String: String] = [:]
for index in (0..<70).reversed() {
manyHeaders[String(format: "X-%03d", index)] = "value-\(index)"
}
store.append(kind: "response", requestID: "request-headers", requestHeaders: manyHeaders)
guard case .object(let headerDetail) = store.networkDetail(requestID: "request-headers"),
case .object(let requestEvent)? = headerDetail["request"],
case .object(let boundedHeaders)? = requestEvent["requestHeaders"] else {
throw TestFailure(description: "bounded diagnostic headers")
}
try expect(boundedHeaders.count == 64, "diagnostic headers should remain capped at 64")
try expect(boundedHeaders["X-000"] == .string("value-0"), "header selection should use sorted keys")
try expect(boundedHeaders["X-063"] == .string("value-63"), "the deterministic header boundary changed")
try expect(boundedHeaders["X-064"] == nil, "headers beyond the sorted cap should be omitted")
}

static func diagnosticCLI() throws {
Expand All @@ -1320,6 +1398,12 @@ struct ProtocolTests {
try expect(styles.request?.parameters["properties"] == .array([.string("display")]), "style property should parse")
let storage = try CLIParser().parse(["storage", "list", "--scope", "local"])
try expect(storage.request?.command == .storageList, "storage command should parse")
do {
_ = try CLIParser().parse(["qa", "bogus", "--x"])
throw TestFailure(description: "unknown QA subcommands should fail")
} catch let error as CLIParseError {
try expect(error == .unknownCommand("qa bogus"), "unknown QA subcommands should win over trailing-option validation")
}
}

static func localSocketRoundTrip() throws {
Expand Down
7 changes: 6 additions & 1 deletion apps/headless/Tests/linux-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,12 @@ if headless screenshot --output ../escape.png >/dev/null 2>&1; then
echo "artifact path traversal was not rejected" >&2
exit 1
fi
headless --session qa record start --fps 5 | grep -q '"active":true'
if ! RECORD_START="$(headless --session qa record start --fps 5)"; then
echo "$RECORD_START" >&2
exit 1
fi
echo "$RECORD_START"
echo "$RECORD_START" | grep -q '"active":true'
RECORDING_STATUS="$(headless --session qa record status)"
echo "$RECORDING_STATUS" | grep -q '"active":true'
echo "$RECORDING_STATUS" | grep -Eq '"frames":[1-9][0-9]*'
Expand Down
3 changes: 2 additions & 1 deletion apps/headless/docs/P0.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ until it has authentication, authorization, and transport security.
- Socket access is limited to the current operating-system user.
- Requests and identifiers are size-bounded and validated.
- Only allowlisted commands are decoded.
- Agent navigation accepts HTTP and HTTPS only.
- Page navigation accepts HTTP and HTTPS only, including before agent control
is enabled in the visible macOS app.
- No arbitrary JavaScript or shell execution command is exposed.
- Browser helper code runs in an isolated world that page globals cannot replace.
- Page-provided text is data and never interpreted as a protocol command.
Expand Down
Loading
Loading