diff --git a/CHANGELOG.md b/CHANGELOG.md index f85dcd6..aa14506 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/apps/headless/LinuxHost/BrowserProcess.swift b/apps/headless/LinuxHost/BrowserProcess.swift index eac9925..36caa7c 100644 --- a/apps/headless/LinuxHost/BrowserProcess.swift +++ b/apps/headless/LinuxHost/BrowserProcess.swift @@ -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) } } } } @@ -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? { diff --git a/apps/headless/Sources/HeadlessProtocol/Artifacts.swift b/apps/headless/Sources/HeadlessProtocol/Artifacts.swift index d97b0ca..2bab394 100644 --- a/apps/headless/Sources/HeadlessProtocol/Artifacts.swift +++ b/apps/headless/Sources/HeadlessProtocol/Artifacts.swift @@ -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) } @@ -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 { diff --git a/apps/headless/Sources/HeadlessProtocol/CLI.swift b/apps/headless/Sources/HeadlessProtocol/CLI.swift index 16057d4..06bbe3e 100644 --- a/apps/headless/Sources/HeadlessProtocol/CLI.swift +++ b/apps/headless/Sources/HeadlessProtocol/CLI.swift @@ -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 @@ -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": diff --git a/apps/headless/Sources/HeadlessProtocol/Diagnostics.swift b/apps/headless/Sources/HeadlessProtocol/Diagnostics.swift index 462ad41..a9b399e 100644 --- a/apps/headless/Sources/HeadlessProtocol/Diagnostics.swift +++ b/apps/headless/Sources/HeadlessProtocol/Diagnostics.swift @@ -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] = [] @@ -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) diff --git a/apps/headless/Sources/HeadlessProtocol/Recording.swift b/apps/headless/Sources/HeadlessProtocol/Recording.swift index 3c919d3..a780cce 100644 --- a/apps/headless/Sources/HeadlessProtocol/Recording.swift +++ b/apps/headless/Sources/HeadlessProtocol/Recording.swift @@ -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, @@ -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.. 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:)) @@ -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 { @@ -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() } diff --git a/apps/headless/Sources/HeadlessProtocol/Transport.swift b/apps/headless/Sources/HeadlessProtocol/Transport.swift index 9cd2e5d..1b9c988 100644 --- a/apps/headless/Sources/HeadlessProtocol/Transport.swift +++ b/apps/headless/Sources/HeadlessProtocol/Transport.swift @@ -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.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 diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index ff5ee8a..a89437e 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -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" @@ -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") } @@ -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" """ @@ -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) @@ -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( @@ -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 { @@ -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 { diff --git a/apps/headless/Tests/linux-e2e.sh b/apps/headless/Tests/linux-e2e.sh index dd1599d..deb8edb 100755 --- a/apps/headless/Tests/linux-e2e.sh +++ b/apps/headless/Tests/linux-e2e.sh @@ -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]*' diff --git a/apps/headless/docs/P0.md b/apps/headless/docs/P0.md index 8d5ac32..47efbae 100644 --- a/apps/headless/docs/P0.md +++ b/apps/headless/docs/P0.md @@ -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. diff --git a/apps/headless/docs/P1.md b/apps/headless/docs/P1.md index 8a91662..ddd8690 100644 --- a/apps/headless/docs/P1.md +++ b/apps/headless/docs/P1.md @@ -175,9 +175,11 @@ files, installers, scripts, libraries, and disk images is blocked; archive links are marked as a caution. Page downloads are denied and Headless never opens, executes, or unpacks a remote file. -When an app window switches from manual browsing to agent control, any existing -non-web page is discarded before the agent can inspect it. CLI-hosted sessions -also start on a clean page. +The visible macOS app applies the same web-only navigation boundary before and +after agent control is enabled: it never dispatches application schemes to the +operating system and rejects file, data, blob, and JavaScript URLs. A legacy +non-web URL restored from an older build is discarded before an agent can +inspect it. CLI-hosted sessions also start on a clean page. ## Repository media policy diff --git a/apps/headless/main.swift b/apps/headless/main.swift index 57962b4..cdd3f65 100644 --- a/apps/headless/main.swift +++ b/apps/headless/main.swift @@ -455,16 +455,15 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate, } } - /// Once a window is driven through the local agent protocol, keep page - /// navigation inside the browser boundary. This prevents an untrusted page - /// from using an agent click to open another macOS application. + /// Once a window is driven through the local agent protocol, abandon any + /// legacy local page before its contents can become agent-visible. func enableAgentControl() { if !agentControlEnabled, let currentURL = webView.url, currentURL.absoluteString != "about:blank", !agentMayNavigate(to: currentURL) { - // A user may have opened a file or app URL before deciding to use - // the CLI. Do not make that existing content readable to an agent. + // Older builds allowed a user to open file or application URLs. + // Do not make restored legacy content readable to an agent. loadStartPage() } agentControlEnabled = true @@ -702,7 +701,9 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate, func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - if agentControlEnabled, let url = navigationAction.request.url, !agentMayNavigate(to: url) { + if let url = navigationAction.request.url, + !(onStartPage && url.absoluteString == "about:blank"), + !agentMayNavigate(to: url) { decisionHandler(.cancel) return } @@ -712,13 +713,6 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate, decisionHandler(.download) return } - // Hand non-web schemes (mailto:, facetime:, app links…) to the system. - if let url = navigationAction.request.url, let scheme = url.scheme?.lowercased(), - !["http", "https", "file", "about", "data", "blob", "javascript"].contains(scheme) { - NSWorkspace.shared.open(url) - decisionHandler(.cancel) - return - } decisionHandler(.allow) } @@ -766,7 +760,7 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { // No tabs, no popups: target=_blank loads right here. if let url = navigationAction.request.url, - !agentControlEnabled || agentMayNavigate(to: url) { + agentMayNavigate(to: url) { webView.load(URLRequest(url: url)) } return nil diff --git a/docs/roadmap/improvements-backlog.md b/docs/roadmap/improvements-backlog.md index a81c156..6fd9bcd 100644 --- a/docs/roadmap/improvements-backlog.md +++ b/docs/roadmap/improvements-backlog.md @@ -113,27 +113,35 @@ base64 screenshot triggers thousands of full scans under a 128 MiB cap. Track a scan offset / use a ring buffer. **A9. Misc hardening (smaller, same phase).** ([#20](https://github.com/LockInTime/headless/issues/20)) -- `ChromiumChildProcess.stop()` can busy-wait forever post-SIGKILL - (`LinuxHost/BrowserProcess.swift:38`); bound it. -- `SO_PEERCRED` hard-coded as `17` + hand-rolled `ucred` +- ~~`ChromiumChildProcess.stop()` can busy-wait forever post-SIGKILL + (`LinuxHost/BrowserProcess.swift:38`); bound it.~~ +- ~~`SO_PEERCRED` hard-coded as `17` + hand-rolled `ucred` (`HP/Transport.swift:372-378`); use the libc constant and add the missing - **peer-UID test** (the most security-critical untested branch). -- `Diagnostics.headersValue` uses `prefix(64)` on a Dictionary — - non-deterministic header survival (`HP/Diagnostics.swift:213`). -- `ArtifactStore.finalize` has a `fileExists`+`move` TOCTOU vs the `O_EXCL` - used elsewhere (`HP/Artifacts.swift:148-151`). -- `Recording.status()` reads `process.isRunning` cross-thread; recording init - busy-polls 3 s for the first frame (`HP/Recording.swift:71-88,186-215`). -- `stringHeaders` stringifies non-string header values via - `String(describing:)` (`LinuxHost/BrowserProcess.swift:854-858`). -- `flow run` executes up to 200 steps inside one socket request while the CLI + **peer-UID test** (the most security-critical untested branch).~~ +- ~~`Diagnostics.headersValue` uses `prefix(64)` on a Dictionary — + non-deterministic header survival (`HP/Diagnostics.swift:213`).~~ +- ~~`ArtifactStore.finalize` has a `fileExists`+`move` TOCTOU vs the `O_EXCL` + used elsewhere (`HP/Artifacts.swift:148-151`).~~ +- ~~`Recording.status()` reads `process.isRunning` cross-thread; recording init + busy-polls 3 s for the first frame (`HP/Recording.swift:71-88,186-215`).~~ +- ~~`stringHeaders` stringifies non-string header values via + `String(describing:)` (`LinuxHost/BrowserProcess.swift:854-858`).~~ +- ~~`flow run` executes up to 200 steps inside one socket request while the CLI timeout is 15 s (`main.swift:1224`, `HeadlessCLI/main.swift:113`) — stream - progress or raise/derive the client timeout. -- `qa` subcommand validates trailing args before checking the subcommand is - known → wrong error for `headless qa bogus --x` (`HP/CLI.swift:115-119`). -- macOS non-agent nav policy hands unknown schemes to `NSWorkspace.open` and + progress or raise/derive the client timeout.~~ +- ~~`qa` subcommand validates trailing args before checking the subcommand is + known → wrong error for `headless qa bogus --x` (`HP/CLI.swift:115-119`).~~ +- ~~macOS non-agent nav policy hands unknown schemes to `NSWorkspace.open` and allows `data:`/`blob:`/`javascript:` when agent control is off - (`main.swift:710-716`) — fine for humans, but document why, or tighten. + (`main.swift:710-716`) — fine for humans, but document why, or tighten.~~ + +**Done:** child teardown has a post-SIGKILL bound; Linux uses the libc socket +constant and the cross-UID test; header selection is sorted and malformed CDP +values are dropped; artifact finalization uses atomic no-replace linking; +recording startup uses a short six-attempt exponential backoff and status +tracks process termination under its lock; flow replay gets the transport's +125-second bound; QA errors are ordered correctly; and the visible macOS app +enforces web-only navigation in manual as well as agent-controlled use. ## §B — Structure & contract (Phase 2)