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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,12 @@ npx @lockintime/headless help
npx -p @lockintime/headless headless-mcp
```

SDK generators consume the Swift-owned contract printed by `headless schema`.
The checked-in `sdk/protocol-schema.json` is verified against that output in
the protocol suite. Wire compatibility is exact by protocol version and is
independent of the product release version. Clients must negotiate engine
capabilities and fail explicitly rather than emulate missing behavior.

The launcher selects the matching macOS or Linux release, verifies it against
the release `SHA256SUMS`, validates its archive and embedded product version,
and caches it privately for subsequent commands. Its download origin is fixed
Expand Down
4 changes: 4 additions & 0 deletions apps/headless/LinuxHost/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,9 @@ do {
)
let server = LocalSocketServer()
try server.start { request in core.handle(request) }
let ownerMonitor = SupervisedHostOwnerMonitor.startIfRequested {
stopped.signal()
}

#if canImport(Glibc)
signal(SIGTERM, SIG_IGN)
Expand All @@ -203,6 +206,7 @@ do {
#endif

stopped.wait()
ownerMonitor?.stop()
server.stop()
core.stop()
} catch {
Expand Down
6 changes: 5 additions & 1 deletion apps/headless/MCP/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@ while let line = readLine() {
write(["jsonrpc": "2.0", "id": id ?? NSNull(), "result": [
"protocolVersion": "2025-06-18",
"capabilities": ["tools": ["listChanged": false]],
"serverInfo": ["name": "headless", "version": headlessProductVersion],
"serverInfo": [
"name": "headless", "version": headlessProductVersion,
"headlessProtocolVersion": headlessProtocolVersion,
"headlessSchemaVersion": headlessProtocolSchemaVersion,
],
]])
case "notifications/initialized":
continue
Expand Down
116 changes: 107 additions & 9 deletions apps/headless/Sources/HeadlessCLI/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ private func printResponse(_ response: CommandResponse) throws {
}

private struct HostLauncher {
struct Launch {
let response: CommandResponse
let process: Process?
let ownerHandle: FileHandle?
}

let client = LocalSocketClient()

func ping() -> CommandResponse? {
Expand All @@ -30,14 +36,16 @@ private struct HostLauncher {

func start(
presentation: AgentStartupPresentation? = nil,
allowlist: NavigationAllowlist = .unrestricted
) throws -> CommandResponse {
allowlist: NavigationAllowlist = .unrestricted,
supervised: Bool = false
) throws -> Launch {
#if !os(macOS)
if presentation != nil { throw SettingsError.unsupportedPlatform("startup-presentation") }
#endif
if let response = ping(), response.ok {
if supervised { throw HostLaunchError.alreadyRunning }
try validateRunningAllowlist(response, requested: allowlist)
return response
return Launch(response: response, process: nil, ownerHandle: nil)
}
#if os(Linux)
// Report an unsupported browser directly to the operator instead of
Expand All @@ -60,13 +68,15 @@ private struct HostLauncher {
let effectivePresentation = AgentStartupPresentation.background
#endif
environment["HEADLESS_START_FOREGROUND"] = effectivePresentation == .foreground ? "1" : "0"
environment["HEADLESS_SUPERVISED"] = supervised ? "1" : "0"
if allowlist.isRestricted {
environment[headlessNavigationAllowlistEnvironmentKey] = allowlist.environmentValue
} else {
environment.removeValue(forKey: headlessNavigationAllowlistEnvironmentKey)
}
process.environment = environment
process.standardInput = FileHandle.nullDevice
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)
Expand All @@ -77,27 +87,92 @@ private struct HostLauncher {
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
}
try process.run()
do {
try process.run()
try ownerPipe?.fileHandleForReading.close()
} catch {
try? ownerPipe?.fileHandleForReading.close()
try? ownerPipe?.fileHandleForWriting.close()
throw error
}

let deadline = Date().addingTimeInterval(8)
repeat {
if let response = ping(), response.ok {
do {
try validateRunningAllowlist(response, requested: allowlist)
return response
if supervised, runningHostProcessIdentifier(response) != process.processIdentifier {
throw HostLaunchError.ownershipMismatch
}
return Launch(
response: response, process: supervised ? process : nil,
ownerHandle: ownerPipe?.fileHandleForWriting
)
} catch {
process.terminate()
try? ownerPipe?.fileHandleForWriting.close()
terminateAndReap(process)
throw error
}
}
if !process.isRunning {
try? ownerPipe?.fileHandleForWriting.close()
process.waitUntilExit()
throw HostLaunchError.exited(process.terminationStatus)
}
Thread.sleep(forTimeInterval: 0.05)
} while Date() < deadline
try? ownerPipe?.fileHandleForWriting.close()
terminateAndReap(process)
throw HostLaunchError.timedOut
}

func waitForSupervisedHost(_ launch: Launch) -> Int32 {
guard let process = launch.process, let ownerHandle = launch.ownerHandle else {
return 0
}
while process.isRunning {
var descriptor = pollfd(
fd: STDIN_FILENO,
events: Int16(POLLIN | POLLHUP | POLLERR),
revents: 0
)
let status = poll(&descriptor, 1, 100)
if status > 0 {
var byte: UInt8 = 0
let count = withUnsafeMutableBytes(of: &byte) { buffer in
read(STDIN_FILENO, buffer.baseAddress, 1)
}
if count >= 0 || errno != EINTR { break }
} else if status < 0, errno != EINTR {
break
}
}
try? ownerHandle.close()
let gracefulDeadline = Date().addingTimeInterval(3)
while process.isRunning, Date() < gracefulDeadline {
Thread.sleep(forTimeInterval: 0.05)
}
if process.isRunning { terminateAndReap(process) }
else { process.waitUntilExit() }
return process.terminationStatus
}

private func terminateAndReap(_ process: Process) {
guard process.isRunning else {
process.waitUntilExit()
return
}
process.terminate()
let deadline = Date().addingTimeInterval(3)
while process.isRunning, Date() < deadline {
Thread.sleep(forTimeInterval: 0.05)
}
if process.isRunning {
_ = kill(process.processIdentifier, SIGKILL)
}
process.waitUntilExit()
}

private func validateRunningAllowlist(
_ response: CommandResponse, requested allowlist: NavigationAllowlist
) throws {
Expand All @@ -116,6 +191,17 @@ private struct HostLauncher {
return values.compactMap(\.stringValue)
}

private func runningHostProcessIdentifier(_ response: CommandResponse) -> Int32? {
guard case .object(let result) = response.result,
let value = result["pid"]?.numberValue,
value.rounded() == value,
value >= 1,
value <= Double(Int32.max) else {
return nil
}
return Int32(value)
}

private func resolveHostExecutable() throws -> URL {
let fileManager = FileManager.default
var candidates: [URL] = []
Expand Down Expand Up @@ -144,13 +230,19 @@ private struct HostLauncher {

private enum HostLaunchError: Error, CustomStringConvertible {
case notFound
case alreadyRunning
case ownershipMismatch
case timedOut
case exited(Int32)
case allowlistMismatch(running: [String], requested: [String])

var description: String {
switch self {
case .notFound: return "Could not find headless-host. Run the Headless build first."
case .alreadyRunning:
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 .allowlistMismatch(let running, let requested):
Expand Down Expand Up @@ -239,6 +331,8 @@ do {
print("headless \(headlessProductVersion)")
case .capabilities:
printJSON(capabilitiesDocument)
case .schema:
printJSON(protocolSchemaDocument)
case .runtime:
#if os(Linux)
printJSON(try ChromiumRuntimeResolver().resolve().diagnostic)
Expand All @@ -248,8 +342,12 @@ do {
"supported": .bool(true), "transport": .string("native-webkit"),
]))
#endif
case .start(let presentation, let allowlist):
try printResponse(try HostLauncher().start(presentation: presentation, allowlist: allowlist))
case .start(let presentation, let allowlist, let supervised):
let launch = try HostLauncher().start(
presentation: presentation, allowlist: allowlist, supervised: supervised
)
try printResponse(launch.response)
if launch.process != nil { exit(HostLauncher().waitForSupervisedHost(launch)) }
case .config(let command):
let settings = try SettingsStore.production()
switch command {
Expand Down
26 changes: 13 additions & 13 deletions apps/headless/Sources/HeadlessProtocol/Authentication.swift
Original file line number Diff line number Diff line change
Expand Up @@ -302,19 +302,19 @@ public enum AuthenticationError: Error, Equatable, CustomStringConvertible {

public var code: String {
switch self {
case .challengeNotFound: return "AUTH_CHALLENGE_NOT_FOUND"
case .challengeExpired: return "AUTH_CHALLENGE_EXPIRED"
case .challengeConsumed: return "AUTH_CHALLENGE_CONSUMED"
case .originChanged: return "AUTH_ORIGIN_CHANGED"
case .formChanged: return "AUTH_FORM_CHANGED"
case .accountNotFound: return "AUTH_ACCOUNT_NOT_FOUND"
case .credentialAliasExists: return "CREDENTIAL_ALIAS_EXISTS"
case .vaultUnavailable: return "VAULT_UNAVAILABLE"
case .vaultLocked: return "VAULT_LOCKED"
case .userPresenceUnavailable: return "USER_PRESENCE_UNAVAILABLE"
case .userPresenceDenied: return "USER_PRESENCE_DENIED"
case .invalidBrokerResponse: return "VAULT_RESPONSE_INVALID"
case .brokerFailed: return "VAULT_OPERATION_FAILED"
case .challengeNotFound: return AuthenticationProtocolErrorCode.challengeNotFound.rawValue
case .challengeExpired: return AuthenticationProtocolErrorCode.challengeExpired.rawValue
case .challengeConsumed: return AuthenticationProtocolErrorCode.challengeConsumed.rawValue
case .originChanged: return AuthenticationProtocolErrorCode.originChanged.rawValue
case .formChanged: return AuthenticationProtocolErrorCode.formChanged.rawValue
case .accountNotFound: return AuthenticationProtocolErrorCode.accountNotFound.rawValue
case .credentialAliasExists: return AuthenticationProtocolErrorCode.credentialAliasExists.rawValue
case .vaultUnavailable: return AuthenticationProtocolErrorCode.vaultUnavailable.rawValue
case .vaultLocked: return AuthenticationProtocolErrorCode.vaultLocked.rawValue
case .userPresenceUnavailable: return AuthenticationProtocolErrorCode.userPresenceUnavailable.rawValue
case .userPresenceDenied: return AuthenticationProtocolErrorCode.userPresenceDenied.rawValue
case .invalidBrokerResponse: return AuthenticationProtocolErrorCode.invalidBrokerResponse.rawValue
case .brokerFailed: return AuthenticationProtocolErrorCode.brokerFailed.rawValue
}
}

Expand Down
31 changes: 19 additions & 12 deletions apps/headless/Sources/HeadlessProtocol/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ public enum LocalCommand: Equatable, Sendable {
case help
case version
case capabilities
case schema
case runtime
case start(presentation: AgentStartupPresentation?, allowlist: NavigationAllowlist)
case start(
presentation: AgentStartupPresentation?, allowlist: NavigationAllowlist,
supervised: Bool
)
case config(ConfigCLICommand)
case credentials(CredentialCLICommand)
}
Expand All @@ -36,15 +40,11 @@ public struct CLIInvocation: Equatable, Sendable {
}

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 || request.command == .flowRun { return 125 }
if request.command == .recordStop { return 30 }
if request.command == .screenshot {
return request.parameters["series"]?.stringValue == nil ? 30 : 125
}
return 15
TimeInterval(
protocolCommandDefinition(for: request.command).timeout.milliseconds(
for: request.parameters
)
) / 1_000
}

public enum CLIParseError: Error, Equatable, CustomStringConvertible {
Expand Down Expand Up @@ -96,6 +96,9 @@ public struct CLIParser {
case "capabilities":
try requireEmpty(arguments)
return CLIInvocation(local: .capabilities, jsonOutput: true)
case "schema":
try requireEmpty(arguments)
return CLIInvocation(local: .schema, jsonOutput: true)
case "runtime":
try requireEmpty(arguments)
return CLIInvocation(local: .runtime, jsonOutput: true)
Expand Down Expand Up @@ -528,6 +531,7 @@ public struct CLIParser {

private func parseStart(_ arguments: [String], jsonOutput: Bool) throws -> CLIInvocation {
var args = arguments
let supervised = removeFlag("--supervised", from: &args)
var presentation: AgentStartupPresentation?
if removeFlag("--background", from: &args) {
presentation = .background
Expand All @@ -547,7 +551,9 @@ public struct CLIParser {
allowlist = try NavigationAllowlist.parse(rawAllows)
}
return CLIInvocation(
local: .start(presentation: presentation, allowlist: allowlist),
local: .start(
presentation: presentation, allowlist: allowlist, supervised: supervised
),
jsonOutput: jsonOutput
)
}
Expand Down Expand Up @@ -810,7 +816,7 @@ Core workflow:

Commands:
version | --version
start [--background|--foreground] [--allow PATTERN]... | status | stop | runtime
start [--background|--foreground] [--allow PATTERN]... [--supervised] | status | stop | runtime
profile clear
config list | config describe KEY | config get KEY
config set KEY VALUE | config reset KEY
Expand Down Expand Up @@ -852,6 +858,7 @@ Commands:
flow start | flow stop [--output FLOW.json] | flow run FLOW.json
report create [--output REPORT.json]
capabilities
schema

Global options:
--session NAME target a named browser session
Expand Down
5 changes: 5 additions & 0 deletions apps/headless/Sources/HeadlessProtocol/Capabilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ public let capabilitiesDocument: JSONValue = {
.sorted()
return .object([
"protocolVersion": .string(headlessProtocolVersion),
"protocolSchema": .object([
"command": .string("schema"),
"schemaVersion": .number(Double(headlessProtocolSchemaVersion)),
]),
"transport": stringArray(["local-unix-socket"]),
"currentEngine": .string(currentBrowserEngineCapabilities.engine.rawValue),
"commands": .array(CommandName.allCases.map { .string($0.rawValue) }),
Expand All @@ -207,6 +211,7 @@ public let capabilitiesDocument: JSONValue = {
"localCommands": stringArray([
"config.describe", "config.get", "config.list", "config.reset", "config.set",
"credentials.add", "credentials.list", "credentials.remove", "credentials.rename",
"schema",
]),
"settings": .object([
"definitions": .array(SettingsRegistry.shared.definitions.compactMap { definition in
Expand Down
2 changes: 1 addition & 1 deletion apps/headless/Sources/HeadlessProtocol/HostError.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Foundation

public enum HostErrorCode: String, Sendable {
public enum HostErrorCode: String, CaseIterable, Sendable {
case timedOut = "TIMEOUT"
case elementNotFound = "ELEMENT_NOT_FOUND"
case regionNotFound = "REGION_NOT_FOUND"
Expand Down
Loading