diff --git a/apps/headless/Sources/HeadlessCLI/main.swift b/apps/headless/Sources/HeadlessCLI/main.swift index 6522acf..7296324 100644 --- a/apps/headless/Sources/HeadlessCLI/main.swift +++ b/apps/headless/Sources/HeadlessCLI/main.swift @@ -30,6 +30,81 @@ private struct HostLauncher { let client = LocalSocketClient() + private final class LogCapture { + let store: HostLogStore + let process: Process + let writer: FileHandle + + init(environment: [String: String]) throws { + store = try HostLogStore(environment: environment) + try store.prepare() + let pipe = Pipe() + process = Process() + process.executableURL = try Self.runningCLIURL() + process.arguments = ["__host-log-writer"] + process.environment = [ + "HEADLESS_INTERNAL_LOG_WRITER": "1", + "HEADLESS_INTERNAL_LOG_PATH": store.url.path, + "HEADLESS_INTERNAL_LOG_MAX_BYTES": String(store.maximumBytes), + ] + process.standardInput = pipe.fileHandleForReading + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + writer = pipe.fileHandleForWriting + do { + try process.run() + try pipe.fileHandleForReading.close() + } catch { + try? pipe.fileHandleForReading.close() + try? pipe.fileHandleForWriting.close() + throw error + } + } + + func closeParentWriter() { + try? writer.close() + } + + func waitForDrain() { + closeParentWriter() + let deadline = Date().addingTimeInterval(1) + while process.isRunning, Date() < deadline { + Thread.sleep(forTimeInterval: 0.01) + } + if process.isRunning { + process.terminate() + let terminationDeadline = Date().addingTimeInterval(1) + while process.isRunning, Date() < terminationDeadline { + Thread.sleep(forTimeInterval: 0.01) + } + } + if process.isRunning { _ = kill(process.processIdentifier, SIGKILL) } + process.waitUntilExit() + } + + var diagnostic: HostLaunchDiagnostic { + HostLaunchDiagnostic(path: store.url.path, tail: store.diagnosticTail()) + } + + private static func runningCLIURL() throws -> URL { + #if os(Linux) + let candidate = URL(fileURLWithPath: "/proc/self/exe").resolvingSymlinksInPath() + #else + var requiredSize: UInt32 = 0 + _ = _NSGetExecutablePath(nil, &requiredSize) + var buffer = [CChar](repeating: 0, count: Int(requiredSize)) + guard _NSGetExecutablePath(&buffer, &requiredSize) == 0 else { + throw HostLogError.operationFailed("writer executable resolution") + } + let candidate = URL(fileURLWithPath: String(cString: buffer)).standardizedFileURL + #endif + guard FileManager.default.isExecutableFile(atPath: candidate.path) else { + throw HostLogError.operationFailed("writer executable resolution") + } + return candidate + } + } + func ping() -> CommandResponse? { try? client.send(CommandRequest(command: .ping), timeout: 0.5) } @@ -75,22 +150,17 @@ private struct HostLauncher { environment.removeValue(forKey: headlessNavigationAllowlistEnvironmentKey) } process.environment = environment + let logCapture = try LogCapture(environment: environment) let ownerPipe = supervised ? Pipe() : nil process.standardInput = ownerPipe?.fileHandleForReading ?? FileHandle.nullDevice - if let hostLog = environment["HEADLESS_HOST_LOG"], hostLog.hasPrefix("/") { - let logURL = URL(fileURLWithPath: hostLog) - FileManager.default.createFile(atPath: logURL.path, contents: nil) - let handle = try FileHandle(forWritingTo: logURL) - process.standardOutput = handle - process.standardError = handle - } else { - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - } + process.standardOutput = logCapture.writer + process.standardError = logCapture.writer do { try process.run() + logCapture.closeParentWriter() try ownerPipe?.fileHandleForReading.close() } catch { + logCapture.waitForDrain() try? ownerPipe?.fileHandleForReading.close() try? ownerPipe?.fileHandleForWriting.close() throw error @@ -117,13 +187,15 @@ private struct HostLauncher { if !process.isRunning { try? ownerPipe?.fileHandleForWriting.close() process.waitUntilExit() - throw HostLaunchError.exited(process.terminationStatus) + logCapture.waitForDrain() + throw HostLaunchError.exited(process.terminationStatus, logCapture.diagnostic) } Thread.sleep(forTimeInterval: 0.05) } while Date() < deadline try? ownerPipe?.fileHandleForWriting.close() terminateAndReap(process) - throw HostLaunchError.timedOut + logCapture.waitForDrain() + throw HostLaunchError.timedOut(logCapture.diagnostic) } func waitForSupervisedHost(_ launch: Launch) -> Int32 { @@ -228,12 +300,22 @@ private struct HostLauncher { } } +private struct HostLaunchDiagnostic { + let path: String + let tail: String? + + var description: String { + guard let tail, !tail.isEmpty else { return "Host log: \(path)" } + return "Host log: \(path)\nRecent host output:\n\(tail)" + } +} + private enum HostLaunchError: Error, CustomStringConvertible { case notFound case alreadyRunning case ownershipMismatch - case timedOut - case exited(Int32) + case timedOut(HostLaunchDiagnostic) + case exited(Int32, HostLaunchDiagnostic) case allowlistMismatch(running: [String], requested: [String]) var description: String { @@ -243,8 +325,10 @@ private enum HostLaunchError: Error, CustomStringConvertible { return "A shared Headless host is already running. Stop it before starting a supervised host." case .ownershipMismatch: return "A different Headless host answered during supervised startup." - case .timedOut: return "Headless host did not become ready within 8 seconds." - case .exited(let status): return "Headless host exited during startup (status \(status))." + case .timedOut(let diagnostic): + return "Headless host did not become ready within 8 seconds.\n\(diagnostic.description)" + case .exited(let status, let diagnostic): + return "Headless host exited during startup (status \(status)).\n\(diagnostic.description)" case .allowlistMismatch(let running, let requested): let runningText = running.isEmpty ? "unrestricted" : running.joined(separator: ", ") return "The running host navigation allowlist (\(runningText)) does not match (\(requested.joined(separator: ", "))). Run `headless stop` first." @@ -321,7 +405,30 @@ private enum CredentialBrokerLaunchError: Error, CustomStringConvertible { } } +private func runInternalHostLogWriterIfRequested() -> Bool { + guard Array(CommandLine.arguments.dropFirst()) == ["__host-log-writer"] else { return false } + let environment = ProcessInfo.processInfo.environment + guard environment["HEADLESS_INTERNAL_LOG_WRITER"] == "1", + let path = environment["HEADLESS_INTERNAL_LOG_PATH"], path.hasPrefix("/"), + let rawMaximum = environment["HEADLESS_INTERNAL_LOG_MAX_BYTES"], + let maximum = Int(rawMaximum) else { + fputs("headless: internal log writer authorization failed\n", stderr) + exit(64) + } + do { + let store = try HostLogStore( + environment: ["HEADLESS_HOST_LOG": path], maximumBytes: maximum + ) + try store.consume(.standardInput) + } catch { + fputs("headless: internal log writer failed\n", stderr) + exit(74) + } + return true +} + do { + if runInternalHostLogWriterIfRequested() { exit(0) } let invocation = try CLIParser().parse(Array(CommandLine.arguments.dropFirst())) if let local = invocation.local { switch local { @@ -423,6 +530,13 @@ do { ) try? printResponse(response) exit(69) +} catch let error as HostLogError { + let response = CommandResponse.failure( + id: "unknown", code: "HOST_LOG_UNAVAILABLE", message: error.description, + suggestion: "Check the private runtime directory or HEADLESS_HOST_LOG path." + ) + try? printResponse(response) + exit(69) } catch let error as SettingsError { let code: String switch error { diff --git a/apps/headless/Sources/HeadlessProtocol/CLI.swift b/apps/headless/Sources/HeadlessProtocol/CLI.swift index da71878..c6da0d3 100644 --- a/apps/headless/Sources/HeadlessProtocol/CLI.swift +++ b/apps/headless/Sources/HeadlessProtocol/CLI.swift @@ -867,4 +867,8 @@ Global options: Settings: macOS Command-, opens Settings; `config` is the portable path on every platform. \(SettingsRegistry.shared.helpLines.joined(separator: "\n")) + +Host logs: + Detached hosts write bounded private logs to /tmp/headless-/host.log. + HEADLESS_HOST_LOG=/absolute/path overrides the destination for operators and tests. """ diff --git a/apps/headless/Sources/HeadlessProtocol/HostLogging.swift b/apps/headless/Sources/HeadlessProtocol/HostLogging.swift new file mode 100644 index 0000000..18cda03 --- /dev/null +++ b/apps/headless/Sources/HeadlessProtocol/HostLogging.swift @@ -0,0 +1,293 @@ +import Foundation + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +public enum HostLogError: Error, CustomStringConvertible { + case invalidOverride + case insecureParent + case insecureFile + case operationFailed(String) + + public var description: String { + switch self { + case .invalidOverride: + return "HEADLESS_HOST_LOG must be an absolute file path" + case .insecureParent: + return "Host log parent is not a permitted directory" + case .insecureFile: + return "Host log is not a private regular file owned by the current user" + case .operationFailed(let operation): + return "Host log \(operation) failed: \(String(cString: strerror(errno)))" + } + } +} + +public struct HostLogStore { + public static let maximumFileBytes = 1_048_576 + public static let maximumLineBytes = 8_192 + + public let url: URL + public let archiveURL: URL + public let lockURL: URL + public let maximumBytes: Int + + public init( + environment: [String: String] = ProcessInfo.processInfo.environment, + maximumBytes: Int = HostLogStore.maximumFileBytes + ) throws { + guard maximumBytes >= HostLogStore.maximumLineBytes, + maximumBytes <= HostLogStore.maximumFileBytes else { + throw HostLogError.operationFailed("size configuration") + } + if let override = environment["HEADLESS_HOST_LOG"] { + guard override.hasPrefix("/") else { throw HostLogError.invalidOverride } + let supplied = URL(fileURLWithPath: override).standardizedFileURL + let parentPath = supplied.deletingLastPathComponent().path + guard let resolvedPointer = realpath(parentPath, nil) else { + throw HostLogError.insecureParent + } + defer { free(resolvedPointer) } + let resolvedParent = URL(fileURLWithPath: String(cString: resolvedPointer), isDirectory: true) + url = resolvedParent.appendingPathComponent(supplied.lastPathComponent, isDirectory: false) + } else { + try LocalRuntime.preparePrivateDirectory() + url = LocalRuntime.directoryURL.appendingPathComponent("host.log", isDirectory: false) + } + guard url.lastPathComponent != ".", url.lastPathComponent != "..", + url.deletingLastPathComponent().path != url.path else { + throw HostLogError.invalidOverride + } + archiveURL = URL(fileURLWithPath: url.path + ".1") + lockURL = URL(fileURLWithPath: url.path + ".lock") + self.maximumBytes = maximumBytes + } + + public func prepare() throws { + try withLock { + let descriptor = try openPrivateLog(append: true) + guard systemClose(descriptor) == 0 else { throw HostLogError.operationFailed("close") } + } + } + + public func consume(_ input: FileHandle) throws { + var pending = Data() + var deferredError: Error? + + func append(_ line: Data) { + guard deferredError == nil else { return } + do { try appendStructuredLine(line) } + catch { deferredError = error } + } + + while true { + let chunk = try input.read(upToCount: 16_384) ?? Data() + if chunk.isEmpty { break } + pending.append(chunk) + while let newline = pending.firstIndex(of: 0x0A) { + let line = pending.prefix(upTo: newline) + pending.removeSubrange(...newline) + append(Data(line)) + } + if pending.count > Self.maximumLineBytes * 2 { + append(Data(pending.prefix(Self.maximumLineBytes))) + pending.removeAll(keepingCapacity: true) + } + } + if !pending.isEmpty { append(pending) } + if let deferredError { throw deferredError } + } + + public func diagnosticTail(maximumBytes: Int = 8_192) -> String? { + guard maximumBytes > 0 else { return nil } + var info = stat() + guard lstat(url.path, &info) == 0, + (info.st_mode & S_IFMT) == S_IFREG, + info.st_uid == geteuid(), info.st_nlink == 1, + (info.st_mode & 0o077) == 0 else { return nil } + let descriptor = open(url.path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) + guard descriptor >= 0 else { return nil } + defer { _ = systemClose(descriptor) } + guard fstat(descriptor, &info) == 0, info.st_size >= 0 else { return nil } + let count = min(maximumBytes, Int(info.st_size)) + guard lseek(descriptor, off_t(-count), SEEK_END) >= 0 else { return nil } + var bytes = [UInt8](repeating: 0, count: count) + var offset = 0 + while offset < count { + let readCount = bytes.withUnsafeMutableBytes { buffer in + systemRead(descriptor, buffer.baseAddress!.advanced(by: offset), count - offset) + } + if readCount < 0 && errno == EINTR { continue } + guard readCount > 0 else { break } + offset += readCount + } + guard offset > 0 else { return nil } + let text = String(decoding: bytes.prefix(offset), as: UTF8.self) + if let newline = text.firstIndex(of: "\n"), count == maximumBytes { + return String(text[text.index(after: newline)...]).trimmingCharacters(in: .whitespacesAndNewlines) + } + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func appendStructuredLine(_ raw: Data) throws { + let messageLimit = 3_500 + var message = String(decoding: raw.prefix(messageLimit), as: UTF8.self) + .trimmingCharacters(in: .newlines) + message = Self.redacted(message) + if raw.count > messageLimit { message += " [truncated]" } + let record: [String: String] = [ + "timestamp": ISO8601DateFormatter().string(from: Date()), + "source": "host", + "message": message, + ] + var encoded = try JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) + encoded.append(0x0A) + guard encoded.count <= Self.maximumLineBytes else { + throw HostLogError.operationFailed("record encoding") + } + try withLock { + try rotateIfNeeded(incomingBytes: encoded.count) + let descriptor = try openPrivateLog(append: true) + defer { _ = systemClose(descriptor) } + try writeAll(encoded, to: descriptor) + } + } + + private func rotateIfNeeded(incomingBytes: Int) throws { + var info = stat() + guard lstat(url.path, &info) == 0 else { + if errno == ENOENT { return } + throw HostLogError.operationFailed("status") + } + try validatePrivateRegularFile(info) + guard Int(info.st_size) + incomingBytes > maximumBytes else { return } + + if lstat(archiveURL.path, &info) == 0 { + try validatePrivateRegularFile(info) + guard unlink(archiveURL.path) == 0 else { throw HostLogError.operationFailed("archive removal") } + } else if errno != ENOENT { + throw HostLogError.operationFailed("archive status") + } + guard rename(url.path, archiveURL.path) == 0 else { + throw HostLogError.operationFailed("rotation") + } + } + + private func withLock(_ body: () throws -> T) throws -> T { + try validateParent() + let descriptor = open( + lockURL.path, O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, mode_t(0o600) + ) + guard descriptor >= 0 else { throw HostLogError.operationFailed("lock open") } + defer { _ = systemClose(descriptor) } + try validatePrivateRegularDescriptor(descriptor) + guard flock(descriptor, LOCK_EX) == 0 else { throw HostLogError.operationFailed("lock") } + defer { _ = flock(descriptor, LOCK_UN) } + return try body() + } + + private func validateParent() throws { + var info = stat() + let parent = url.deletingLastPathComponent().path + guard lstat(parent, &info) == 0, (info.st_mode & S_IFMT) == S_IFDIR else { + throw HostLogError.insecureParent + } + if parent == LocalRuntime.directoryURL.path { + guard info.st_uid == geteuid(), (info.st_mode & 0o077) == 0 else { + throw HostLogError.insecureParent + } + } + } + + private func openPrivateLog(append: Bool) throws -> Int32 { + let flags = O_CREAT | O_WRONLY | O_CLOEXEC | O_NOFOLLOW | (append ? O_APPEND : O_TRUNC) + let descriptor = open(url.path, flags, mode_t(0o600)) + guard descriptor >= 0 else { throw HostLogError.operationFailed("open") } + do { try validatePrivateRegularDescriptor(descriptor) } + catch { + _ = systemClose(descriptor) + throw error + } + guard fchmod(descriptor, 0o600) == 0 else { + _ = systemClose(descriptor) + throw HostLogError.operationFailed("permissions") + } + return descriptor + } + + private func validatePrivateRegularDescriptor(_ descriptor: Int32) throws { + var info = stat() + guard fstat(descriptor, &info) == 0 else { throw HostLogError.operationFailed("validation") } + try validatePrivateRegularFile(info) + } + + private func validatePrivateRegularFile(_ info: stat) throws { + guard (info.st_mode & S_IFMT) == S_IFREG, info.st_uid == geteuid(), info.st_nlink == 1, + (info.st_mode & 0o077) == 0 else { + throw HostLogError.insecureFile + } + } + + private func writeAll(_ data: Data, to descriptor: Int32) throws { + try data.withUnsafeBytes { buffer in + guard let base = buffer.baseAddress else { return } + var offset = 0 + while offset < buffer.count { + let count = systemWrite(descriptor, base.advanced(by: offset), buffer.count - offset) + if count < 0 && errno == EINTR { continue } + guard count > 0 else { throw HostLogError.operationFailed("write") } + offset += count + } + } + } + + private static func redacted(_ source: String) -> String { + var result = source + let replacements = [ + ( + #"(?i)(["']?(?:authorization|cookie|set-cookie)["']?\s*[:=]\s*).*$"#, + "$1[REDACTED]" + ), + ( + #"(?i)(["']?(?:password|passwd|token|secret)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)"#, + "$1[REDACTED]" + ), + (#"(?i)(https?://)[^\s/@:]+:[^\s/@]+@"#, "$1[REDACTED]@"), + ] + for (pattern, replacement) in replacements { + guard let expression = try? NSRegularExpression(pattern: pattern) else { continue } + let range = NSRange(result.startIndex.. Int { + #if canImport(Darwin) + return Darwin.read(descriptor, buffer, count) + #else + return Glibc.read(descriptor, buffer, count) + #endif +} + +private func systemWrite(_ descriptor: Int32, _ buffer: UnsafeRawPointer?, _ count: Int) -> Int { + #if canImport(Darwin) + return Darwin.write(descriptor, buffer, count) + #else + return Glibc.write(descriptor, buffer, count) + #endif +} + +private func systemClose(_ descriptor: Int32) -> Int32 { + #if canImport(Darwin) + return Darwin.close(descriptor) + #else + return Glibc.close(descriptor) + #endif +} diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index 9fadb82..400a5f6 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -3160,6 +3160,108 @@ struct ProtocolTests { ) } + static func hostLoggingIsPrivateBoundedAndRedacted() throws { + let defaultStore = try HostLogStore(environment: [:]) + try expect( + defaultStore.url == LocalRuntime.directoryURL.appendingPathComponent("host.log"), + "host logging should default to the private runtime directory" + ) + + let root = "/tmp/headless-host-log-\(UUID().uuidString)" + defer { try? FileManager.default.removeItem(atPath: root) } + try FileManager.default.createDirectory( + atPath: root, withIntermediateDirectories: false, + attributes: [.posixPermissions: NSNumber(value: 0o700)] + ) + let logPath = root + "/host.log" + let store = try HostLogStore( + environment: ["HEADLESS_HOST_LOG": logPath], maximumBytes: 12_000 + ) + try store.prepare() + + let pipe = Pipe() + let repeated = String(repeating: "safe diagnostic line\n", count: 900) + let sensitive = "\"password\":\"visible\" token:visible https://user:visible@example.com/path\n" + + "Authorization: Bearer visible\nCookie: session=visible\n" + pipe.fileHandleForWriting.write(Data((repeated + sensitive).utf8)) + try pipe.fileHandleForWriting.close() + try store.consume(pipe.fileHandleForReading) + + let attributes = try FileManager.default.attributesOfItem(atPath: logPath) + let archiveAttributes = try FileManager.default.attributesOfItem(atPath: logPath + ".1") + try expect( + (attributes[.posixPermissions] as? NSNumber)?.intValue == 0o600, + "host log should be owner-only" + ) + try expect( + (archiveAttributes[.posixPermissions] as? NSNumber)?.intValue == 0o600, + "rotated host log should remain owner-only" + ) + try expect( + (attributes[.size] as? NSNumber)?.intValue ?? Int.max <= 12_000, + "active host log should remain bounded" + ) + try expect( + (archiveAttributes[.size] as? NSNumber)?.intValue ?? Int.max <= 12_000, + "rotated host log should remain bounded" + ) + let combined = try String(contentsOfFile: logPath, encoding: .utf8) + + String(contentsOfFile: logPath + ".1", encoding: .utf8) + try expect(!combined.contains("visible"), "host logs must redact common secret forms") + try expect(combined.contains("[REDACTED]"), "host logs should preserve an explicit redaction marker") + try expect(store.diagnosticTail(maximumBytes: 512)?.utf8.count ?? 0 <= 512, "diagnostic tail should be bounded") + + let concurrentErrors = ConcurrentSettingsErrors() + DispatchQueue.concurrentPerform(iterations: 8) { index in + do { + let writer = try HostLogStore( + environment: ["HEADLESS_HOST_LOG": logPath], maximumBytes: 12_000 + ) + let input = Pipe() + input.fileHandleForWriting.write( + Data(String(repeating: "concurrent writer \(index)\n", count: 80).utf8) + ) + try input.fileHandleForWriting.close() + try writer.consume(input.fileHandleForReading) + } catch { + concurrentErrors.append(error) + } + } + try expect( + concurrentErrors.messages.isEmpty, + "concurrent host log writers should serialize: \(concurrentErrors.messages.joined(separator: ", "))" + ) + for path in [logPath, logPath + ".1"] { + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + try expect(data.count <= 12_000, "concurrent host logging should preserve disk bounds") + for line in data.split(separator: 0x0A) { + _ = try JSONSerialization.jsonObject(with: Data(line)) + } + } + + let target = root + "/target.log" + _ = FileManager.default.createFile(atPath: target, contents: Data()) + let symlink = root + "/symlink.log" + try FileManager.default.createSymbolicLink(atPath: symlink, withDestinationPath: target) + try expectThrows("host logging must reject symlinks") { + try HostLogStore(environment: ["HEADLESS_HOST_LOG": symlink]).prepare() + } + let hardlink = root + "/hardlink.log" + guard link(target, hardlink) == 0 else { throw TestFailure(description: "hard-link setup") } + try expectThrows("host logging must reject multiply linked files") { + try HostLogStore(environment: ["HEADLESS_HOST_LOG": hardlink]).prepare() + } + try expectThrows("host logging must reject relative overrides") { + _ = try HostLogStore(environment: ["HEADLESS_HOST_LOG": "host.log"]) + } + try expectThrows("host logging must reject an unbounded size override") { + _ = try HostLogStore( + environment: ["HEADLESS_HOST_LOG": logPath], + maximumBytes: HostLogStore.maximumFileBytes + 1 + ) + } + } + static func diagnosticServices() throws { let store = QADiagnosticStore() let typedHeaders = diagnosticStringHeaders([ @@ -4300,6 +4402,7 @@ struct ProtocolTests { ("diagnostic bounds and URL redaction", diagnosticsBoundAndRedacted), ("responses fit the protocol frame", responsesFitTheProtocolFrame), ("artifact listing stays bounded", artifactListingStaysBounded), + ("host logging security and bounds", hostLoggingIsPrivateBoundedAndRedacted), ("diagnostic services", diagnosticServices), ("diagnostic CLI", diagnosticCLI), ("local socket round-trip", localSocketRoundTrip), diff --git a/apps/headless/Tests/linux-e2e.sh b/apps/headless/Tests/linux-e2e.sh index ea939e3..c31bc40 100755 --- a/apps/headless/Tests/linux-e2e.sh +++ b/apps/headless/Tests/linux-e2e.sh @@ -47,7 +47,7 @@ cleanup() { rm -rf "$HEADLESS_ARTIFACT_DIR" rm -rf "$XDG_DATA_HOME" rm -rf "$XDG_CONFIG_HOME" - rm -f "$HEADLESS_HOST_LOG" + rm -f "$HEADLESS_HOST_LOG" "$HEADLESS_HOST_LOG.1" "$HEADLESS_HOST_LOG.lock" [ -z "$SUPERVISED_FIFO" ] || rm -f "$SUPERVISED_FIFO" [ -z "$SUPERVISED_OUTPUT" ] || rm -f "$SUPERVISED_OUTPUT" exit "$status" @@ -155,6 +155,11 @@ SUPERVISED_OUTPUT="" STEP="host-start" headless start | grep -q '"ready":true' +test -f "$HEADLESS_HOST_LOG" +test ! -L "$HEADLESS_HOST_LOG" +test "$(stat -c %a "$HEADLESS_HOST_LOG")" = "600" +test "$(stat -c %u "$HEADLESS_HOST_LOG")" = "$(id -u)" +test "$(stat -c %h "$HEADLESS_HOST_LOG")" = "1" test "$(stat -c %a "$XDG_DATA_HOME/headless")" = "700" test "$(stat -c %a "$XDG_DATA_HOME/headless/chromium-profile")" = "700" if RUNNING_PRESENTATION_START="$(headless start --foreground 2>&1)"; then diff --git a/apps/headless/Tests/macos-e2e.sh b/apps/headless/Tests/macos-e2e.sh index 31c0ffc..2895dcb 100755 --- a/apps/headless/Tests/macos-e2e.sh +++ b/apps/headless/Tests/macos-e2e.sh @@ -655,7 +655,7 @@ cleanup() { fi rm -rf "$HEADLESS_ARTIFACT_DIR" rm -rf "$MENU_SNAPSHOT_DIR" - rm -f "$HEADLESS_SOCKET" "$LOG" "$HOST_LOG" "$RESTORE_LOG" + rm -f "$HEADLESS_SOCKET" "$LOG" "$HOST_LOG" "$HOST_LOG.1" "$HOST_LOG.lock" "$RESTORE_LOG" [[ -z "$SUPERVISED_FIFO" ]] || rm -f "$SUPERVISED_FIFO" [[ -z "$SUPERVISED_OUTPUT" ]] || rm -f "$SUPERVISED_OUTPUT" if [[ "$CLIPBOARD_SAVED" == 0 ]]; then @@ -799,6 +799,11 @@ echo "$START_RESULT" | grep -q '"ready":true' || { echo "▸ host ready" HOST_PID="$(echo "$START_RESULT" | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p')" test -n "$HOST_PID" +test -f "$HOST_LOG" +test ! -L "$HOST_LOG" +test "$(stat -f %Lp "$HOST_LOG")" = "600" +test "$(stat -f %u "$HOST_LOG")" = "$(id -u)" +test "$(stat -f %l "$HOST_LOG")" = "1" if [[ "$(frontmost_pid)" == "$HOST_PID" ]]; then echo "default agent startup stole focus" >&2 fail diff --git a/apps/headless/docs/COMMANDS.md b/apps/headless/docs/COMMANDS.md index 6aba677..d0efa47 100644 --- a/apps/headless/docs/COMMANDS.md +++ b/apps/headless/docs/COMMANDS.md @@ -42,6 +42,12 @@ schema attach to an existing host, verifies the launched host PID, and shuts the host down when the launcher input closes or the launcher exits. Normal starts remain shared and detached. +- Detached hosts write bounded structured output to + `/tmp/headless-/host.log`, with one rotated `host.log.1` generation. + Both files are private regular files capped at 1 MiB each. An operator or + test may set `HEADLESS_HOST_LOG` to an absolute path; unsafe files, symlinks, + hard links, and relative overrides are rejected. Startup failures report the + selected path and a bounded redacted tail. - `schema` prints the versioned SDK contract generated from the Swift request definitions. It is local-only and includes request and response envelopes, command parameters and bounds, errors, compatibility, cancellation, and diff --git a/docs/roadmap/architecture-decisions.md b/docs/roadmap/architecture-decisions.md index 88776f8..a749462 100644 --- a/docs/roadmap/architecture-decisions.md +++ b/docs/roadmap/architecture-decisions.md @@ -866,6 +866,43 @@ the schema so client packages cannot silently invent a different policy. --- +## 29. Detached hosts use a private bounded log writer + +**Decision:** a detached host no longer sends stdout and stderr to +`/dev/null`. The CLI starts a minimal writer process and connects the host to +it through an inherited pipe. The writer emits bounded JSON lines to +`/tmp/headless-/host.log`, keeps one `host.log.1` generation, and caps +each file at 1 MiB. A pipe is required instead of launch-time rotation because +only a live consumer can enforce a disk bound across a long host lifetime. + +The runtime directory remains the default trust boundary: it is an +owner-checked `0700` directory, while the active log, archive, and lock are +owner-owned regular files with one link and mode `0600`. Opens use +`O_NOFOLLOW`; rotation is serialized with a private file lock. Unsafe files, +symlinks, hard links, and relative overrides fail closed. The existing +`HEADLESS_HOST_LOG` absolute-path override remains for operators and tests, +but receives the same final-file checks, redaction, rotation, and bounds. + +The writer records only native host stdout and stderr. It does not receive +protocol requests, page snapshots, fill values, credentials, cookies, or +storage. Every line is byte-bounded and common secret assignments and URL +userinfo are redacted before persistence. Startup exit and timeout responses +may include only the selected path and an 8 KiB tail that has already passed +through that writer. Logging adds no protocol command, listener, or browser +capability. + +**Status:** implemented for +[#193](https://github.com/LockInTime/headless/issues/193). + +**Consequences:** normal failures are diagnosable without a special +environment variable, disk use is bounded to two generations, and logging +survives the launching CLI process without keeping that CLI resident. The +writer exits on pipe EOF when the host exits. Log content remains diagnostic +data, not trusted evidence, and must not be exposed through the agent-facing +protocol. + +--- + ## Decision log | # | Decision | Status | Date | @@ -891,5 +928,6 @@ the schema so client packages cannot silently invent a different policy. | 26 | Isolated sessions own one ephemeral browser context | Implemented | 2026-09-12 | | 27 | Interactive authentication keeps consent in trusted host | Implemented | 2026-09-12 | | 28 | SDKs derive from one Swift-owned protocol contract | Decided | 2026-09-12 | +| 29 | Detached hosts use a private bounded log writer | Implemented | 2026-09-19 | New decisions append here with the same format. diff --git a/docs/roadmap/improvements-backlog.md b/docs/roadmap/improvements-backlog.md index 1f6f3be..c013bb0 100644 --- a/docs/roadmap/improvements-backlog.md +++ b/docs/roadmap/improvements-backlog.md @@ -464,9 +464,13 @@ get an architecture-decision entry: - **G1. `headless doctor`** — one command validating runtime, ffmpeg, socket dir, permissions, and printing fix hints (pieces exist across `install-linux.sh`, `runtime`, sandbox `doctor`). -- **G2. Structured host logging** — today host stderr goes to `/dev/null` - unless `HEADLESS_HOST_LOG` is set (`HeadlessCLI/main.swift`); startup - failures are near-invisible. Default to a rotating log in the runtime dir. +- **G2. Structured host logging** ([#193](https://github.com/LockInTime/headless/issues/193)) — + ~~today host stderr goes to `/dev/null` unless `HEADLESS_HOST_LOG` is set + (`HeadlessCLI/main.swift`); startup failures are near-invisible. Default to + a rotating log in the runtime dir.~~ **Done:** detached hosts stream through + a bounded writer into private JSON-line logs in the runtime directory, keep + one rotated generation, preserve the absolute-path operator override, and + include a redacted tail in startup failures. - **G3. Response pagination primitives** (with A3) — cursor pattern reusable by `console list`, `network list`, `artifacts list`. - **G4. Session metadata** — `session list` returning current URL/title/age;