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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,13 @@ unless the host was deliberately started with `HEADLESS_ALLOW_SENSITIVE_DIAGNOST
Run `headless help` for every command or `headless capabilities` for the
JSON capability contract.

Normal sessions share one durable browser profile, so cookies and local
storage survive host restarts. Use the website's logout flow to remove one
account, or `headless profile clear` to close every session and erase the full
normal profile. Linux stores it in a private XDG data directory; macOS uses the
persistent WebKit data store. Headless does not accept imported cookies or a
caller-selected profile path.

## Agent skill

This repository ships a portable browser-computer-use skill at
Expand Down
38 changes: 35 additions & 3 deletions apps/headless/Host/AgentBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,24 @@ struct ScreenshotArtifactData {

extension BrowserWindowController {
func agentVisit(_ url: URL, timeout: TimeInterval = 20) throws -> JSONValue {
onMain { self.navigate(to: url) }
Thread.sleep(forTimeInterval: 0.05)
return try agentWait(parameters: ["settled": .bool(true), "timeoutMs": .number(timeout * 1_000)])
let deadline = Date().addingTimeInterval(timeout)
guard onMain({ self.beginAgentNavigation(to: url) }) else {
throw HostError(code: .operationFailed, message: "Browser refused to start navigation")
}
while Date() < deadline {
let status = onMain { self.agentNavigationStatus() }
if status.failed {
throw HostError(code: .operationFailed, message: "Browser navigation failed")
}
if !status.pending {
let remainingMs = max(100, deadline.timeIntervalSinceNow * 1_000)
return try agentWait(parameters: [
"settled": .bool(true), "timeoutMs": .number(remainingMs),
])
}
Thread.sleep(forTimeInterval: 0.05)
}
throw HostError(code: .timedOut, message: "Timed out while waiting for navigation")
}

func agentInspect(parameters: [String: JSONValue]) throws -> JSONValue {
Expand Down Expand Up @@ -430,6 +445,23 @@ final class WebKitBrowserEngine: BrowserEngine {
func createSession() throws -> BrowserWindowController { try create() }
func closeSession(_ session: BrowserWindowController) { close(session) }
func stop() { stopEngine() }

func clearProfile() throws {
let semaphore = DispatchSemaphore(value: 0)
DispatchQueue.main.async {
normalWebsiteDataStore.removeData(
ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
modifiedSince: .distantPast
) { semaphore.signal() }
}
guard semaphore.wait(timeout: .now() + 30) == .success else {
throw HostError(code: .timedOut, message: "Timed out while clearing browser profile")
}
}

func pingDetails() -> [String: JSONValue] {
["profilePersistence": .string("durable")]
}
}

extension BrowserWindowController: BrowserEngineSession {
Expand Down
28 changes: 18 additions & 10 deletions apps/headless/LinuxHost/BrowserProcess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ private final class ChromiumChildProcess {
return errno != ECHILD && kill(processIdentifier, 0) == 0
}

func waitForExit(timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while isRunning && Date() < deadline { Thread.sleep(forTimeInterval: 0.05) }
return !isRunning
}

func stop() {
guard isRunning else { return }
_ = kill(processIdentifier, SIGTERM)
Expand Down Expand Up @@ -157,21 +163,15 @@ final class ChromiumProcess {
let headless: Bool
let runtime: ChromiumRuntimeSelection
var processIdentifier: Int32 { child.processIdentifier }
private let profileURL: URL
private let sessionsLock = NSLock()
private let stopLock = NSLock()
private var sessionsByProtocolID: [String: LinuxBrowserSession] = [:]
private var stopped = false

init() throws {
init(profileURL: URL) throws {
#if os(Linux)
guard getuid() != 0 else { throw CDPError.rootNotSupported }
#endif
try LocalRuntime.preparePrivateDirectory()
profileURL = LocalRuntime.directoryURL.appendingPathComponent("chromium-profile", isDirectory: true)
try FileManager.default.createDirectory(at: profileURL, withIntermediateDirectories: true)
#if os(Linux)
_ = chmod(profileURL.path, 0o700)
#endif

runtime = try ChromiumRuntimeResolver().resolve()
let executable = runtime.executableURL
headless = ProcessInfo.processInfo.environment["HEADLESS_HEADLESS"] != "0"
Expand Down Expand Up @@ -226,8 +226,16 @@ final class ChromiumProcess {
}

func stop() {
stopLock.lock()
guard !stopped else { stopLock.unlock(); return }
stopped = true
stopLock.unlock()

// Chromium flushes persistent cookies and local storage during its
// normal browser shutdown. Keep SIGTERM as a bounded fallback only.
try? browserConnection.sendWithoutWaiting("Browser.close")
if !child.waitForExit(timeout: 3) { child.stop() }
browserConnection.close()
child.stop()
}

private func routeEvent(_ event: [String: Any]) {
Expand Down
21 changes: 19 additions & 2 deletions apps/headless/LinuxHost/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ final class ChromiumBrowserEngine: BrowserEngine {
let name = "chromium"
let platform = "linux"
let capabilities = BrowserEngineCapabilities.chromium
let browser: ChromiumProcess
private let profile: DurableBrowserProfile
private(set) var browser: ChromiumProcess

init() throws {
browser = try ChromiumProcess()
profile = try DurableBrowserProfile()
browser = try ChromiumProcess(profileURL: profile.directoryURL)
}

func createSession() throws -> ChromiumBrowserEngineSession {
Expand All @@ -26,11 +28,26 @@ final class ChromiumBrowserEngine: BrowserEngine {

func stop() { browser.stop() }

func clearProfile() throws {
browser.stop()
do {
try profile.clear()
browser = try ChromiumProcess(profileURL: profile.directoryURL)
} catch {
if let replacement = try? ChromiumProcess(profileURL: profile.directoryURL) {
browser = replacement
}
throw error
}
}

func pingDetails() -> [String: JSONValue] {
[
"browserExecutable": .string(browser.runtime.executableURL.path),
"browserRuntimeSource": .string(browser.runtime.source.rawValue),
"browserTransport": .string("inherited-devtools-pipe"),
"profilePersistence": .string("durable"),
"profileMigration": .string(profile.migration.rawValue),
]
}

Expand Down
4 changes: 4 additions & 0 deletions apps/headless/Sources/HeadlessProtocol/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ public struct CLIParser {
case "stop":
try requireEmpty(arguments)
return remote(.shutdown, session: session, jsonOutput: jsonOutput)
case "profile":
guard arguments == ["clear"] else { throw CLIParseError.missingArgument("profile clear") }
return remote(.profileClear, session: session, jsonOutput: jsonOutput)
case "session":
return try parseSession(arguments, jsonOutput: jsonOutput)
case "visit":
Expand Down Expand Up @@ -673,6 +676,7 @@ Core workflow:
Commands:
version | --version
start [--background|--foreground] | status | stop | runtime
profile clear
config get startup-presentation
config set startup-presentation background|foreground
session create [NAME] | session list | session close NAME
Expand Down
13 changes: 11 additions & 2 deletions apps/headless/Sources/HeadlessProtocol/Capabilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public struct BrowserEngineCapabilities: Sendable {
public let qaDiagnosticSynchronization: String
public let screenshotClipboard: Bool
public let inputDispatch: String
public let normalProfileStorage: String

public var supportedCommands: [CommandName] {
CommandName.allCases.filter { !unsupportedCommands.contains($0) }
Expand Down Expand Up @@ -66,6 +67,12 @@ public struct BrowserEngineCapabilities: Sendable {
"screenshotClipboard": .bool(screenshotClipboard),
"tourTimeoutMs": .number(65_000),
"inputDispatch": .string(inputDispatch),
"normalProfile": .object([
"persistent": .bool(true),
"sharedAcrossSessions": .bool(true),
"storage": .string(normalProfileStorage),
"clearCommand": .string(CommandName.profileClear.rawValue),
]),
]),
])
}
Expand All @@ -85,7 +92,8 @@ public struct BrowserEngineCapabilities: Sendable {
qaDiagnosticSource: "webkit-page-bridge",
qaDiagnosticSynchronization: "best-effort-page-world-observer",
screenshotClipboard: true,
inputDispatch: "synthetic-dom"
inputDispatch: "synthetic-dom",
normalProfileStorage: "persistent-wkwebsite-data-store"
)

public static let chromium = BrowserEngineCapabilities(
Expand All @@ -106,7 +114,8 @@ public struct BrowserEngineCapabilities: Sendable {
qaDiagnosticSource: "chromium-cdp",
qaDiagnosticSynchronization: "runtime-round-trip-flush",
screenshotClipboard: false,
inputDispatch: "trusted-cdp"
inputDispatch: "trusted-cdp",
normalProfileStorage: "private-xdg-data-directory"
)

public static func profile(for engine: BrowserEngineName) -> BrowserEngineCapabilities {
Expand Down
Loading