From ca11f8127a1a60609ceb2bf8a86913fd54f83ccf Mon Sep 17 00:00:00 2001 From: SarthakWade Date: Sat, 19 Sep 2026 13:41:46 +0530 Subject: [PATCH] feat(cli): add read-only doctor diagnostics --- apps/headless/Sources/HeadlessCLI/main.swift | 4 + .../Sources/HeadlessProtocol/Artifacts.swift | 19 +- .../Sources/HeadlessProtocol/CLI.swift | 7 +- .../HeadlessProtocol/ChromiumRuntime.swift | 6 + .../Sources/HeadlessProtocol/Doctor.swift | 482 ++++++++++++++++++ .../Sources/HeadlessProtocol/Settings.swift | 41 +- .../HeadlessProtocolTests/ProtocolTests.swift | 144 ++++++ apps/headless/Tests/linux-e2e.sh | 4 + apps/headless/docs/COMMANDS.md | 9 +- apps/headless/test.sh | 23 + docs/roadmap/architecture-decisions.md | 35 ++ docs/roadmap/improvements-backlog.md | 9 +- 12 files changed, 766 insertions(+), 17 deletions(-) create mode 100644 apps/headless/Sources/HeadlessProtocol/Doctor.swift diff --git a/apps/headless/Sources/HeadlessCLI/main.swift b/apps/headless/Sources/HeadlessCLI/main.swift index 7296324..5eef5e6 100644 --- a/apps/headless/Sources/HeadlessCLI/main.swift +++ b/apps/headless/Sources/HeadlessCLI/main.swift @@ -449,6 +449,10 @@ do { "supported": .bool(true), "transport": .string("native-webkit"), ])) #endif + case .doctor: + let report = try HeadlessDoctor().run() + printJSON(report.document) + if report.hasFailures { exit(69) } case .start(let presentation, let allowlist, let supervised): let launch = try HostLauncher().start( presentation: presentation, allowlist: allowlist, supervised: supervised diff --git a/apps/headless/Sources/HeadlessProtocol/Artifacts.swift b/apps/headless/Sources/HeadlessProtocol/Artifacts.swift index 3d5b488..c1cb852 100644 --- a/apps/headless/Sources/HeadlessProtocol/Artifacts.swift +++ b/apps/headless/Sources/HeadlessProtocol/Artifacts.swift @@ -28,14 +28,21 @@ public final class ArtifactStore: @unchecked Sendable { private let lock = NSLock() public init(environment: [String: String] = ProcessInfo.processInfo.environment) throws { + rootURL = try Self.resolvedRootURL(environment: environment, platform: .current) + try prepareRoot() + } + + public static func resolvedRootURL( + environment: [String: String], platform: SettingPlatform = .current + ) throws -> URL { if let override = environment["HEADLESS_ARTIFACT_DIR"] { guard override.hasPrefix("/") else { throw ArtifactError.invalidRoot } - rootURL = URL(fileURLWithPath: override, isDirectory: true).standardizedFileURL + return URL(fileURLWithPath: override, isDirectory: true).standardizedFileURL } else { - #if os(macOS) - rootURL = FileManager.default.homeDirectoryForCurrentUser + if platform == .macOS { + return FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/Headless/Artifacts", isDirectory: true) - #else + } let base: URL if let stateHome = environment["XDG_STATE_HOME"], stateHome.hasPrefix("/") { base = URL(fileURLWithPath: stateHome, isDirectory: true) @@ -43,10 +50,8 @@ public final class ArtifactStore: @unchecked Sendable { base = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".local/state", isDirectory: true) } - rootURL = base.appendingPathComponent("headless/artifacts", isDirectory: true) - #endif + return base.appendingPathComponent("headless/artifacts", isDirectory: true) } - try prepareRoot() } public func reserve( diff --git a/apps/headless/Sources/HeadlessProtocol/CLI.swift b/apps/headless/Sources/HeadlessProtocol/CLI.swift index c6da0d3..d919687 100644 --- a/apps/headless/Sources/HeadlessProtocol/CLI.swift +++ b/apps/headless/Sources/HeadlessProtocol/CLI.swift @@ -19,6 +19,7 @@ public enum LocalCommand: Equatable, Sendable { case capabilities case schema case runtime + case doctor case start( presentation: AgentStartupPresentation?, allowlist: NavigationAllowlist, supervised: Bool @@ -102,6 +103,10 @@ public struct CLIParser { case "runtime": try requireEmpty(arguments) return CLIInvocation(local: .runtime, jsonOutput: true) + case "doctor": + guard session == nil else { throw CLIParseError.invalidOption("--session") } + try requireEmpty(arguments) + return CLIInvocation(local: .doctor, jsonOutput: true) case "start": return try parseStart(arguments, jsonOutput: jsonOutput) case "config": @@ -816,7 +821,7 @@ Core workflow: Commands: version | --version - start [--background|--foreground] [--allow PATTERN]... [--supervised] | status | stop | runtime + start [--background|--foreground] [--allow PATTERN]... [--supervised] | status | stop | runtime | doctor profile clear config list | config describe KEY | config get KEY config set KEY VALUE | config reset KEY diff --git a/apps/headless/Sources/HeadlessProtocol/ChromiumRuntime.swift b/apps/headless/Sources/HeadlessProtocol/ChromiumRuntime.swift index 22fb21c..9876b55 100644 --- a/apps/headless/Sources/HeadlessProtocol/ChromiumRuntime.swift +++ b/apps/headless/Sources/HeadlessProtocol/ChromiumRuntime.swift @@ -164,6 +164,12 @@ public struct ChromiumRuntimeResolver { return candidates.filter { seen.insert($0).inserted } } + public static func defaultCandidatePaths( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> [String] { + defaultSystemCandidates(environment: environment) + } + private static func isSnapPath(_ path: String) -> Bool { let standardized = URL(fileURLWithPath: path).standardizedFileURL.path return standardized == "/usr/bin/snap" diff --git a/apps/headless/Sources/HeadlessProtocol/Doctor.swift b/apps/headless/Sources/HeadlessProtocol/Doctor.swift new file mode 100644 index 0000000..5ddf556 --- /dev/null +++ b/apps/headless/Sources/HeadlessProtocol/Doctor.swift @@ -0,0 +1,482 @@ +import Foundation + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +public enum DoctorCheckStatus: String, Sendable { + case healthy + case warning + case unsupported + case failed +} + +public enum DoctorCheckSeverity: String, Sendable { + case info + case warning + case error +} + +public struct DoctorCheck: Equatable, Sendable { + public let id: String + public let status: DoctorCheckStatus + public let severity: DoctorCheckSeverity + public let detail: String + public let suggestion: String? + + public init( + id: String, + status: DoctorCheckStatus, + severity: DoctorCheckSeverity, + detail: String, + suggestion: String? = nil + ) { + self.id = id + self.status = status + self.severity = severity + self.detail = String(detail.prefix(512)) + self.suggestion = suggestion.map { String($0.prefix(512)) } + } + + public var document: JSONValue { + var object: [String: JSONValue] = [ + "id": .string(id), + "status": .string(status.rawValue), + "severity": .string(severity.rawValue), + "detail": .string(detail), + ] + if let suggestion { object["suggestion"] = .string(suggestion) } + return .object(object) + } +} + +public struct DoctorReport: Sendable { + public static let schemaVersion = 1 + public static let maximumChecks = 32 + + public let platform: SettingPlatform + public let checks: [DoctorCheck] + + public init(platform: SettingPlatform, checks: [DoctorCheck]) { + self.platform = platform + self.checks = Array(checks.prefix(Self.maximumChecks)) + } + + public var hasFailures: Bool { checks.contains { $0.status == .failed } } + + public var status: DoctorCheckStatus { + if hasFailures { return .failed } + if checks.contains(where: { $0.status == .warning }) { return .warning } + return .healthy + } + + public var document: JSONValue { + .object([ + "schemaVersion": .number(Double(Self.schemaVersion)), + "productVersion": .string(headlessProductVersion), + "protocolVersion": .string(headlessProtocolVersion), + "platform": .string(platform.rawValue), + "status": .string(status.rawValue), + "ok": .bool(!hasFailures), + "checks": .array(checks.map(\.document)), + ]) + } +} + +public struct DoctorConfiguration: Sendable { + public let environment: [String: String] + public let platform: SettingPlatform + public let executableURL: URL + public let runtimeDirectoryURL: URL + public let socketURL: URL + public let artifactRootURL: URL + public let settingsRootURL: URL? + public let chromiumCandidates: [String] + public let ffmpegCandidates: [String] + public let runningAsRoot: Bool + let artifactConfigurationValid: Bool + let settingsConfigurationValid: Bool + + public init( + environment: [String: String] = ProcessInfo.processInfo.environment, + platform: SettingPlatform = .current, + executableURL: URL? = nil, + runtimeDirectoryURL: URL = LocalRuntime.directoryURL, + socketURL: URL? = nil, + artifactRootURL: URL? = nil, + settingsRootURL: URL? = nil, + chromiumCandidates: [String]? = nil, + ffmpegCandidates: [String]? = nil, + runningAsRoot: Bool = geteuid() == 0 + ) throws { + self.environment = environment + self.platform = platform + self.executableURL = try executableURL ?? Self.runningExecutableURL() + self.runtimeDirectoryURL = runtimeDirectoryURL.standardizedFileURL + let environmentSocket = environment["HEADLESS_SOCKET"].flatMap { value in + value.hasPrefix("/") ? URL(fileURLWithPath: value) : nil + } + self.socketURL = (socketURL ?? environmentSocket + ?? runtimeDirectoryURL.appendingPathComponent("host.sock")).standardizedFileURL + if let artifactRootURL { + self.artifactRootURL = artifactRootURL.standardizedFileURL + artifactConfigurationValid = artifactRootURL.path.hasPrefix("/") + } else if let resolved = try? ArtifactStore.resolvedRootURL( + environment: environment, platform: platform + ) { + self.artifactRootURL = resolved + artifactConfigurationValid = true + } else { + self.artifactRootURL = URL(fileURLWithPath: "/") + artifactConfigurationValid = false + } + if let settingsRootURL { + self.settingsRootURL = settingsRootURL.standardizedFileURL + settingsConfigurationValid = settingsRootURL.path.hasPrefix("/") + } else if platform == .linux { + self.settingsRootURL = try? FileSettingsBackend.resolvedRootURL(environment: environment) + settingsConfigurationValid = self.settingsRootURL != nil + } else { + self.settingsRootURL = nil + settingsConfigurationValid = true + } + self.chromiumCandidates = chromiumCandidates ?? ChromiumRuntimeResolver.defaultCandidatePaths( + environment: environment + ) + self.ffmpegCandidates = ffmpegCandidates ?? [ + "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg", + ] + self.runningAsRoot = runningAsRoot + } + + private static func runningExecutableURL() throws -> URL { + #if os(Linux) + let candidate = URL(fileURLWithPath: "/proc/self/exe").resolvingSymlinksInPath().standardizedFileURL + #else + var size: UInt32 = 0 + _ = _NSGetExecutablePath(nil, &size) + var buffer = [CChar](repeating: 0, count: Int(size)) + guard _NSGetExecutablePath(&buffer, &size) == 0 else { + throw DoctorConfigurationError.executableUnavailable + } + let candidate = URL(fileURLWithPath: String(cString: buffer)) + .resolvingSymlinksInPath().standardizedFileURL + #endif + guard FileManager.default.isExecutableFile(atPath: candidate.path) else { + throw DoctorConfigurationError.executableUnavailable + } + return candidate + } +} + +public enum DoctorConfigurationError: Error { + case executableUnavailable +} + +public struct HeadlessDoctor { + private let configuration: DoctorConfiguration + + public init(configuration: DoctorConfiguration) { + self.configuration = configuration + } + + public init(environment: [String: String] = ProcessInfo.processInfo.environment) throws { + try self.init(configuration: DoctorConfiguration(environment: environment)) + } + + public func run() -> DoctorReport { + var checks = [ + executableCheck(), + runtimeDirectoryCheck(), + socketCheck(), + artifactStoreCheck(), + hostLogCheck(), + ffmpegCheck(), + browserCheck(), + settingsCheck(), + sandboxCheck(), + ] + checks.sort { $0.id < $1.id } + return DoctorReport(platform: configuration.platform, checks: checks) + } + + private func executableCheck() -> DoctorCheck { + var info = stat() + guard lstat(configuration.executableURL.path, &info) == 0, + (info.st_mode & S_IFMT) == S_IFREG, + FileManager.default.isExecutableFile(atPath: configuration.executableURL.path) else { + return failed( + "executable.cli", "The running CLI executable cannot be validated.", + "Reinstall Headless from a trusted release." + ) + } + return healthy("executable.cli", "The running CLI resolves to an executable regular file.") + } + + private func runtimeDirectoryCheck() -> DoctorCheck { + switch privateDirectoryState(configuration.runtimeDirectoryURL) { + case .absent: + return warning( + "runtime.directory", "The private runtime directory has not been created yet.", + "Run `headless start` to create it." + ) + case .safe: + return healthy("runtime.directory", "The runtime directory is private and owned by the current user.") + case .unsafe: + return failed( + "runtime.directory", "The runtime directory is not a private owned directory.", + "Remove or secure the runtime entry before starting Headless." + ) + } + } + + private func socketCheck() -> DoctorCheck { + guard configuration.socketURL.deletingLastPathComponent().standardizedFileURL + == configuration.runtimeDirectoryURL.standardizedFileURL else { + return failed( + "runtime.socket", "The configured socket is outside the private runtime directory.", + "Unset HEADLESS_SOCKET and retry." + ) + } + var info = stat() + guard lstat(configuration.socketURL.path, &info) == 0 else { + if errno == ENOENT { return healthy("runtime.socket", "No host socket is present; the host is stopped.") } + return failed( + "runtime.socket", "The host socket cannot be inspected.", + "Check runtime-directory ownership and permissions." + ) + } + guard (info.st_mode & S_IFMT) == S_IFSOCK, info.st_uid == geteuid(), + (info.st_mode & 0o077) == 0 else { + return failed( + "runtime.socket", "The socket entry has an unsafe type, owner, or mode.", + "Stop using this runtime directory and inspect the entry manually." + ) + } + do { + let response = try LocalSocketClient(socketPath: configuration.socketURL.path).send( + CommandRequest(command: .ping), timeout: 0.5 + ) + guard response.ok else { + return failed( + "runtime.socket", "A host answered but rejected the health probe.", + "Restart the Headless host." + ) + } + return healthy("runtime.socket", "A running host answered the non-disruptive health probe.") + } catch { + return failed( + "runtime.socket", "A socket exists but no healthy host answered it.", + "Stop any stale process and remove the socket only after verifying no host is running." + ) + } + } + + private func artifactStoreCheck() -> DoctorCheck { + guard configuration.artifactConfigurationValid else { + return failed( + "storage.artifacts", "The artifact-directory override is not an absolute path.", + "Use an absolute path or unset HEADLESS_ARTIFACT_DIR." + ) + } + switch privateDirectoryState(configuration.artifactRootURL) { + case .absent: + return warning( + "storage.artifacts", "The artifact directory has not been created yet.", + "Start Headless once to initialize private artifact storage." + ) + case .safe: + return healthy("storage.artifacts", "The artifact directory is private and owned by the current user.") + case .unsafe: + return failed( + "storage.artifacts", "The artifact path is not a private owned directory.", + "Choose an owned directory and restrict it to mode 0700." + ) + } + } + + private func hostLogCheck() -> DoctorCheck { + let url: URL + let isOverride: Bool + if let override = configuration.environment["HEADLESS_HOST_LOG"] { + guard override.hasPrefix("/"), + let resolved = try? HostLogStore(environment: configuration.environment).url else { + return failed( + "storage.host-log", "The host-log override cannot be resolved safely.", + "Use an absolute path with an existing safe parent, or unset HEADLESS_HOST_LOG." + ) + } + url = resolved + isOverride = true + } else { + url = configuration.runtimeDirectoryURL.appendingPathComponent("host.log") + isOverride = false + } + var parentInfo = stat() + guard lstat(url.deletingLastPathComponent().path, &parentInfo) == 0, + (parentInfo.st_mode & S_IFMT) == S_IFDIR else { + return isOverride + ? failed( + "storage.host-log", "The configured host-log parent is unavailable.", + "Create a safe writable parent or unset HEADLESS_HOST_LOG." + ) + : warning( + "storage.host-log", "The host-log parent has not been created yet.", + "Run `headless start` to initialize host logging." + ) + } + if url.deletingLastPathComponent().standardizedFileURL == configuration.runtimeDirectoryURL, + (parentInfo.st_uid != geteuid() || (parentInfo.st_mode & 0o077) != 0) { + return failed( + "storage.host-log", "The default host-log parent is not private.", + "Secure the runtime directory before starting Headless." + ) + } + var info = stat() + guard lstat(url.path, &info) == 0 else { + if errno == ENOENT { + guard access(url.deletingLastPathComponent().path, W_OK) == 0 else { + return failed( + "storage.host-log", "The host-log destination is not writable.", + "Choose a writable private log destination." + ) + } + return warning( + "storage.host-log", "No host log exists because no detached host has written one yet.", + "Run `headless start` to create the bounded host log." + ) + } + return failed("storage.host-log", "The host log cannot be inspected.", "Check its parent directory.") + } + guard (info.st_mode & S_IFMT) == S_IFREG, info.st_uid == geteuid(), info.st_nlink == 1, + (info.st_mode & 0o077) == 0, info.st_size <= off_t(HostLogStore.maximumFileBytes), + access(url.path, W_OK) == 0 else { + return failed( + "storage.host-log", "The host log violates its private bounded-file contract.", + "Replace it with an owned 0600 regular file with one link." + ) + } + for (candidate, maximumSize) in [ + (URL(fileURLWithPath: url.path + ".1"), HostLogStore.maximumFileBytes), + (URL(fileURLWithPath: url.path + ".lock"), Int.max), + ] { + guard lstat(candidate.path, &info) == 0 else { + if errno == ENOENT { continue } + return failed( + "storage.host-log", "A host-log companion file cannot be inspected.", + "Inspect the host-log archive and lock entries." + ) + } + guard (info.st_mode & S_IFMT) == S_IFREG, info.st_uid == geteuid(), info.st_nlink == 1, + (info.st_mode & 0o077) == 0, info.st_size <= off_t(maximumSize), + access(candidate.path, W_OK) == 0 else { + return failed( + "storage.host-log", "A host-log archive or lock entry is unsafe.", + "Replace companion entries with owned 0600 regular files with one link." + ) + } + } + return healthy("storage.host-log", "The host log is private, regular, and within its size bound.") + } + + private func ffmpegCheck() -> DoctorCheck { + if BrowserRecording.ffmpegExecutable( + environment: configuration.environment, + systemCandidates: configuration.ffmpegCandidates + ) != nil { + return healthy("dependency.ffmpeg", "FFmpeg is available for recording and visual comparison.") + } + return warning( + "dependency.ffmpeg", "FFmpeg is unavailable; recording and visual comparison are disabled.", + "Install FFmpeg or set HEADLESS_FFMPEG_EXECUTABLE to a trusted absolute executable." + ) + } + + private func browserCheck() -> DoctorCheck { + guard configuration.platform == .linux else { + return healthy("browser.runtime", "The system WebKit framework provides the browser engine.") + } + do { + _ = try ChromiumRuntimeResolver( + environment: configuration.environment, + hostExecutablePath: configuration.executableURL.path, + systemCandidates: configuration.chromiumCandidates + ).resolve() + return healthy("browser.runtime", "A supported Chromium executable is available.") + } catch { + return failed( + "browser.runtime", "No supported Chromium executable is available.", + "Install native Chromium or use the bundled Linux runtime." + ) + } + } + + private func settingsCheck() -> DoctorCheck { + guard configuration.settingsConfigurationValid else { + return failed( + "settings.storage", "The settings location is not an absolute path.", + "Set XDG_CONFIG_HOME to an absolute directory or unset it." + ) + } + do { + if configuration.platform == .macOS { + _ = try SettingsStore.production(environment: configuration.environment).snapshots(caller: .user) + return healthy("settings.storage", "Stored settings match the current typed registry.") + } + guard let root = configuration.settingsRootURL else { + return failed("settings.storage", "The settings location cannot be resolved.", "Check XDG_CONFIG_HOME.") + } + let backend = try FileSettingsBackend(rootURL: root) + let exists = try backend.validateReadOnly() + return healthy( + "settings.storage", + exists ? "Stored settings are private and valid." : "No settings file exists; typed defaults will be used." + ) + } catch { + return failed( + "settings.storage", "Settings storage is unsafe or corrupt.", + "Inspect or reset the settings storage before starting Headless." + ) + } + } + + private func sandboxCheck() -> DoctorCheck { + guard configuration.platform == .linux else { + return DoctorCheck( + id: "sandbox.linux", status: .unsupported, severity: .info, + detail: "Linux Chromium sandbox checks do not apply on macOS." + ) + } + guard !configuration.runningAsRoot else { + return failed( + "sandbox.linux", "Headless is running as root, which the Chromium host refuses.", + "Run Headless as a non-root user without disabling Chromium's sandbox." + ) + } + return healthy("sandbox.linux", "The current user is eligible to run sandboxed Chromium.") + } + + private enum DirectoryState { case absent, safe, unsafe } + + private func privateDirectoryState(_ url: URL) -> DirectoryState { + guard url.path.hasPrefix("/") else { return .unsafe } + var info = stat() + guard lstat(url.path, &info) == 0 else { return errno == ENOENT ? .absent : .unsafe } + return (info.st_mode & S_IFMT) == S_IFDIR && info.st_uid == geteuid() + && (info.st_mode & 0o077) == 0 ? .safe : .unsafe + } + + private func healthy(_ id: String, _ detail: String) -> DoctorCheck { + DoctorCheck(id: id, status: .healthy, severity: .info, detail: detail) + } + + private func warning(_ id: String, _ detail: String, _ suggestion: String) -> DoctorCheck { + DoctorCheck(id: id, status: .warning, severity: .warning, detail: detail, suggestion: suggestion) + } + + private func failed(_ id: String, _ detail: String, _ suggestion: String) -> DoctorCheck { + DoctorCheck(id: id, status: .failed, severity: .error, detail: detail, suggestion: suggestion) + } +} diff --git a/apps/headless/Sources/HeadlessProtocol/Settings.swift b/apps/headless/Sources/HeadlessProtocol/Settings.swift index a92d547..8512cc6 100644 --- a/apps/headless/Sources/HeadlessProtocol/Settings.swift +++ b/apps/headless/Sources/HeadlessProtocol/Settings.swift @@ -512,6 +512,10 @@ public final class FileSettingsBackend: @unchecked Sendable, SettingsBackend { public convenience init( environment: [String: String], registry: SettingsRegistry = .shared ) throws { + try self.init(rootURL: Self.resolvedRootURL(environment: environment), registry: registry) + } + + public static func resolvedRootURL(environment: [String: String]) throws -> URL { let base: URL if let configured = environment["XDG_CONFIG_HOME"] { guard configured.hasPrefix("/") else { throw SettingsError.insecureStorage } @@ -519,7 +523,7 @@ public final class FileSettingsBackend: @unchecked Sendable, SettingsBackend { } else { base = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config", isDirectory: true) } - try self.init(rootURL: base.appendingPathComponent("headless", isDirectory: true), registry: registry) + return base.appendingPathComponent("headless", isDirectory: true) } public init(rootURL: URL, registry: SettingsRegistry = .shared) throws { @@ -549,6 +553,29 @@ public final class FileSettingsBackend: @unchecked Sendable, SettingsBackend { } } + public func validateReadOnly() throws -> Bool { + var rootInfo = stat() + guard lstat(rootURL.path, &rootInfo) == 0 else { + if errno == ENOENT { return false } + throw SettingsError.insecureStorage + } + guard (rootInfo.st_mode & S_IFMT) == S_IFDIR, rootInfo.st_uid == geteuid(), + (rootInfo.st_mode & 0o077) == 0 else { throw SettingsError.insecureStorage } + let directory = open(rootURL.path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_DIRECTORY) + guard directory >= 0 else { throw SettingsError.insecureStorage } + defer { close(directory) } + + let lock = openat(directory, Self.lockName, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) + if lock >= 0 { + defer { close(lock) } + try Self.validatePrivateRegularFile(lock, repairPermissions: false) + } else if errno != ENOENT { + throw SettingsError.insecureStorage + } + _ = try read(from: directory, repairPermissions: false) + return true + } + private func withLockedValues( _ body: (inout [String: String], Int32) throws -> T ) throws -> T { @@ -564,14 +591,14 @@ public final class FileSettingsBackend: @unchecked Sendable, SettingsBackend { return try body(&values, directory) } - private func read(from directory: Int32) throws -> [String: String] { + private func read(from directory: Int32, repairPermissions: Bool = true) throws -> [String: String] { let descriptor = openat(directory, Self.fileName, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) if descriptor < 0 { if errno == ENOENT { return [:] } throw SettingsError.insecureStorage } defer { close(descriptor) } - try Self.validatePrivateRegularFile(descriptor) + try Self.validatePrivateRegularFile(descriptor, repairPermissions: repairPermissions) var info = stat() guard fstat(descriptor, &info) == 0, info.st_size >= 0, info.st_size <= Self.maximumFileBytes else { throw SettingsError.corruptStorage } @@ -686,13 +713,17 @@ public final class FileSettingsBackend: @unchecked Sendable, SettingsBackend { return -1 } - private static func validatePrivateRegularFile(_ descriptor: Int32) throws { + private static func validatePrivateRegularFile( + _ descriptor: Int32, repairPermissions: Bool = true + ) throws { var info = stat() guard fstat(descriptor, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG, info.st_uid == geteuid(), info.st_nlink == 1, (info.st_mode & 0o077) == 0 else { throw SettingsError.insecureStorage } - guard fchmod(descriptor, 0o600) == 0 else { throw SettingsError.operationFailed("permissions") } + if repairPermissions { + guard fchmod(descriptor, 0o600) == 0 else { throw SettingsError.operationFailed("permissions") } + } } } diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index 400a5f6..99cfddd 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -190,6 +190,44 @@ private func connectRawUnixSocket(path: String) throws -> Int32 { return descriptor } +private func createStaleUnixSocket(path: String) throws { + #if canImport(Darwin) + let descriptor = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + #else + let descriptor = Glibc.socket(AF_UNIX, Int32(SOCK_STREAM.rawValue), 0) + #endif + guard descriptor >= 0 else { throw TestFailure(description: "stale socket creation failed") } + defer { closeRawSocket(descriptor) } + + var address = sockaddr_un() + let bytes = Array(path.utf8) + let capacity = MemoryLayout.size(ofValue: address.sun_path) + guard bytes.count < capacity else { throw TestFailure(description: "stale socket path was too long") } + address.sun_family = sa_family_t(AF_UNIX) + #if canImport(Darwin) + address.sun_len = UInt8(MemoryLayout.size + bytes.count + 1) + #endif + withUnsafeMutablePointer(to: &address.sun_path) { pointer in + pointer.withMemoryRebound(to: UInt8.self, capacity: capacity) { buffer in + for (index, byte) in bytes.enumerated() { buffer[index] = byte } + buffer[bytes.count] = 0 + } + } + let length = socklen_t(MemoryLayout.size + bytes.count + 1) + let bound = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + #if canImport(Darwin) + Darwin.bind(descriptor, $0, length) + #else + Glibc.bind(descriptor, $0, length) + #endif + } + } + guard bound == 0, chmod(path, 0o600) == 0 else { + throw TestFailure(description: "stale socket bind failed") + } +} + private func closeRawSocket(_ descriptor: Int32) { #if canImport(Darwin) _ = Darwin.close(descriptor) @@ -1421,6 +1459,111 @@ struct ProtocolTests { } } + static func doctorCLIAndDiagnostics() throws { + let invocation = try CLIParser().parse(["doctor"]) + try expect(invocation.local == .doctor, "doctor should be a local command") + try expect(invocation.jsonOutput, "doctor should always produce JSON") + try expectThrows("doctor should reject trailing arguments") { + _ = try CLIParser().parse(["doctor", "repair"]) + } + try expectThrows("doctor should reject a browser session") { + _ = try CLIParser().parse(["--session", "qa", "doctor"]) + } + + let root = URL(fileURLWithPath: "/tmp/headless-doctor-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let runtime = root.appendingPathComponent("runtime", isDirectory: true) + let artifacts = root.appendingPathComponent("artifacts", isDirectory: true) + let settings = root.appendingPathComponent("settings", isDirectory: true) + for directory in [root, runtime, artifacts] { + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: false, + attributes: [.posixPermissions: NSNumber(value: 0o700)] + ) + try expect(chmod(directory.path, 0o700) == 0, "doctor fixture permissions") + } + let log = runtime.appendingPathComponent("host.log") + try Data("safe\n".utf8).write(to: log) + try expect(chmod(log.path, 0o600) == 0, "doctor log permissions") + let testExecutable = URL(fileURLWithPath: "/bin/sh").resolvingSymlinksInPath() + + let healthyConfiguration = try DoctorConfiguration( + environment: ["HEADLESS_HOST_LOG": log.path], + platform: .linux, + executableURL: testExecutable, + runtimeDirectoryURL: runtime, + socketURL: runtime.appendingPathComponent("host.sock"), + artifactRootURL: artifacts, + settingsRootURL: settings, + chromiumCandidates: ["/bin/sh"], + ffmpegCandidates: ["/bin/sh"], + runningAsRoot: false + ) + let healthy = HeadlessDoctor(configuration: healthyConfiguration).run() + try expect(!healthy.hasFailures, "a complete synthetic installation should pass doctor") + try expect(healthy.status == .healthy, "all healthy checks should produce a healthy report") + try expect( + healthy.document == HeadlessDoctor(configuration: healthyConfiguration).run().document, + "doctor output should be deterministic" + ) + let healthyData = try ProtocolCodec.encoder.encode(healthy.document) + try expect(healthyData.count < headlessMaximumMessageBytes, "doctor output should fit the frame budget") + try expect(!String(decoding: healthyData, as: UTF8.self).contains(root.path), "doctor must not expose paths") + + try expect(chmod(runtime.path, 0o755) == 0, "unsafe runtime fixture") + try FileManager.default.createDirectory( + at: settings, withIntermediateDirectories: false, + attributes: [.posixPermissions: NSNumber(value: 0o700)] + ) + try expect(chmod(settings.path, 0o700) == 0, "settings fixture permissions") + let settingsFile = settings.appendingPathComponent("settings.json") + try Data("{}".utf8).write(to: settingsFile) + try expect(chmod(settingsFile.path, 0o600) == 0, "settings file fixture permissions") + let socket = runtime.appendingPathComponent("host.sock") + try createStaleUnixSocket(path: socket.path) + + let failingConfiguration = try DoctorConfiguration( + environment: ["HEADLESS_HOST_LOG": log.path, "SECRET_SENTINEL": "must-not-leak"], + platform: .linux, + executableURL: testExecutable, + runtimeDirectoryURL: runtime, + socketURL: socket, + artifactRootURL: artifacts, + settingsRootURL: settings, + chromiumCandidates: [], + ffmpegCandidates: [], + runningAsRoot: true + ) + let failing = HeadlessDoctor(configuration: failingConfiguration).run() + try expect(failing.hasFailures && failing.status == .failed, "blocking diagnostics should fail doctor") + guard case .object(let report) = failing.document, + case .array(let rawChecks)? = report["checks"] else { + throw TestFailure(description: "doctor report shape") + } + let checks = try rawChecks.reduce(into: [String: [String: JSONValue]]()) { result, value in + guard case .object(let check) = value, let identifier = check["id"]?.stringValue else { + throw TestFailure(description: "doctor check shape") + } + result[identifier] = check + } + for identifier in [ + "browser.runtime", "runtime.directory", "runtime.socket", "sandbox.linux", "settings.storage", + ] { + try expect(checks[identifier]?["status"] == .string("failed"), "\(identifier) should fail") + } + try expect( + checks["dependency.ffmpeg"]?["status"] == .string("warning"), + "missing FFmpeg should remain non-blocking" + ) + let failingData = try ProtocolCodec.encoder.encode(failing.document) + try expect(!String(decoding: failingData, as: UTF8.self).contains("must-not-leak"), "doctor leaked an environment value") + try expect(FileManager.default.fileExists(atPath: socket.path), "doctor must not remove a stale socket") + try expect( + (try FileManager.default.attributesOfItem(atPath: runtime.path)[.posixPermissions] as? NSNumber)?.intValue == 0o755, + "doctor must not repair unsafe permissions" + ) + } + static func settingsRegistryAndAccess() throws { let platforms: Set = [.macOS, .linux] let definitions = [ @@ -4377,6 +4520,7 @@ struct ProtocolTests { ("CLI P2 commands and boundaries", cliP2CommandsAndBoundaries), ("CLI command matrix", cliCommandMatrix), ("config CLI commands and arity", configCLICommandsAndArity), + ("doctor CLI and diagnostics", doctorCLIAndDiagnostics), ("settings registry and access", settingsRegistryAndAccess), ("UserDefaults settings compatibility", userDefaultsSettingsCompatibility), ("file settings backend security and persistence", fileSettingsBackendSecurityAndPersistence), diff --git a/apps/headless/Tests/linux-e2e.sh b/apps/headless/Tests/linux-e2e.sh index c31bc40..3edf357 100755 --- a/apps/headless/Tests/linux-e2e.sh +++ b/apps/headless/Tests/linux-e2e.sh @@ -82,6 +82,10 @@ test ! -e "$HOME/.local/share/headless/credential-vault/credentials-index.json" STEP="runtime-discovery" headless runtime | grep -q '"executable":"/usr/lib/chromium/chromium"' headless runtime | grep -q '"transport":"inherited-devtools-pipe"' +DOCTOR_OUTPUT="$(headless doctor)" +echo "$DOCTOR_OUTPUT" | grep -q '"ok":true' +echo "$DOCTOR_OUTPUT" | grep -q '"id":"browser.runtime"' +echo "$DOCTOR_OUTPUT" | grep -q '"id":"runtime.socket"' if SNAP_RUNTIME="$(HEADLESS_CHROMIUM_EXECUTABLE=/snap/bin/chromium headless runtime 2>&1)"; then echo "Snap Chromium was accepted by runtime selection" >&2 exit 1 diff --git a/apps/headless/docs/COMMANDS.md b/apps/headless/docs/COMMANDS.md index d0efa47..92989e1 100644 --- a/apps/headless/docs/COMMANDS.md +++ b/apps/headless/docs/COMMANDS.md @@ -21,7 +21,7 @@ headless -- --value # stop option parsing; literal values ```sh version | --version -start [--background|--foreground] [--allow PATTERN]... [--supervised] | status | stop | runtime +start [--background|--foreground] [--allow PATTERN]... [--supervised] | status | stop | runtime | doctor profile clear config list | config describe KEY | config get KEY config set KEY VALUE | config reset KEY @@ -38,6 +38,13 @@ schema `start --allow` with the same hosts in any order is a no-op. `stop` controls the host afterwards. `runtime` reports which engine is active and where it came from. +- `doctor` runs a read-only, offline installation check. It reports bounded + JSON with stable check identifiers for the executable, browser engine, + FFmpeg, runtime directory and socket, artifact and settings storage, host + log, and platform sandbox. Warnings identify optional or not-yet-created + facilities; its exit status is nonzero only when a failed check blocks a + supported operation. It never starts a browser, repairs storage, removes a + stale socket, or prints environment values and file contents. - `start --supervised` is for SDK-owned lifecycle management. It refuses to 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 diff --git a/apps/headless/test.sh b/apps/headless/test.sh index 8a1c11f..8435699 100755 --- a/apps/headless/test.sh +++ b/apps/headless/test.sh @@ -81,6 +81,29 @@ fi echo "headless tests: CLI product version does not match $EXPECTED_VERSION" >&2 exit 1 } +set +e +DOCTOR_OUTPUT="$("$BIN_PATH/headless" doctor)" +DOCTOR_STATUS=$? +set -e +case "$DOCTOR_STATUS" in + 0|69) ;; + *) + echo "headless tests: doctor returned unexpected status $DOCTOR_STATUS" >&2 + exit 1 + ;; +esac +grep -q '"schemaVersion":1' <<<"$DOCTOR_OUTPUT" || { + echo "headless tests: doctor did not return its versioned JSON report" >&2 + exit 1 +} +grep -q '"id":"runtime.socket"' <<<"$DOCTOR_OUTPUT" || { + echo "headless tests: doctor omitted the socket check" >&2 + exit 1 +} +if "$BIN_PATH/headless" doctor repair >/dev/null 2>&1; then + echo "headless tests: doctor accepted an unknown argument" >&2 + exit 1 +fi "$BIN_PATH/headless" schema > "$TEST_SCRATCH/protocol-schema.json" cmp "$TEST_SCRATCH/protocol-schema.json" ../../sdk/protocol-schema.json || { echo "headless tests: sdk/protocol-schema.json is stale; regenerate it with headless schema" >&2 diff --git a/docs/roadmap/architecture-decisions.md b/docs/roadmap/architecture-decisions.md index a749462..edd3563 100644 --- a/docs/roadmap/architecture-decisions.md +++ b/docs/roadmap/architecture-decisions.md @@ -903,6 +903,40 @@ protocol. --- +## 30. Doctor is a read-only local readiness report + +**Decision:** `headless doctor` is an offline local command with a versioned, +bounded JSON report. Checks have stable identifiers, one of `healthy`, +`warning`, `unsupported`, or `failed`, a severity, a short controlled detail, +and an optional actionable suggestion. The report includes product, protocol, +and platform versions. Output stays below the protocol frame budget even +though doctor does not use the host protocol. + +Doctor inspects the running CLI, browser runtime, FFmpeg, private runtime and +socket, artifact storage, typed settings, bounded host log, and applicable +Linux sandbox constraints. It may send the existing non-disruptive `ping` to +an owned private socket, but it never launches a browser, contacts the network, +creates or repairs storage, changes configuration, removes stale sockets, or +reads arbitrary file contents. Settings parsing is bounded to the existing +settings-file limit. Reports use controlled messages and never include +environment values, page data, credentials, cookies, storage values, URLs, or +file contents. + +Missing optional tools and storage that has not been initialized are warnings. +Unsafe storage, corrupt settings, stale or unsafe sockets, missing required +Linux Chromium, an unresolved CLI, and running the Linux host as root are +failures. The command exits nonzero only when at least one check failed. + +**Status:** implemented for +[#194](https://github.com/LockInTime/headless/issues/194). + +**Consequences:** users and agents get one deterministic installation report +without changing the machine they are diagnosing. Repair remains an explicit +operator action, and startup keeps its existing validation and failure +behavior rather than trusting doctor's earlier result. + +--- + ## Decision log | # | Decision | Status | Date | @@ -929,5 +963,6 @@ protocol. | 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 | +| 30 | Doctor is a read-only local readiness report | 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 c013bb0..2ede644 100644 --- a/docs/roadmap/improvements-backlog.md +++ b/docs/roadmap/improvements-backlog.md @@ -461,9 +461,12 @@ Owner-decided scope: package managers, no hosted service. Gathered from the audit and product thinking; none are committed until they 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`). +- **G1. `headless doctor`** ([#194](https://github.com/LockInTime/headless/issues/194)) — + ~~one command validating runtime, ffmpeg, socket dir, permissions, and + printing fix hints (pieces exist across `install-linux.sh`, `runtime`, + sandbox `doctor`).~~ **Done:** added a deterministic read-only JSON report + covering runtime, storage, logs, settings, browser, FFmpeg, and sandbox + readiness with stable findings and actionable failure guidance. - **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