Skip to content

Commit 3bcac93

Browse files
committed
feat: persist authentication profiles across restarts
1 parent 94b0547 commit 3bcac93

17 files changed

Lines changed: 603 additions & 25 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ unless the host was deliberately started with `HEADLESS_ALLOW_SENSITIVE_DIAGNOST
135135
Run `headless help` for every command or `headless capabilities` for the
136136
JSON capability contract.
137137

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

140147
This repository ships a portable browser-computer-use skill at

apps/headless/Host/AgentBridge.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,23 @@ final class WebKitBrowserEngine: BrowserEngine {
430430
func createSession() throws -> BrowserWindowController { try create() }
431431
func closeSession(_ session: BrowserWindowController) { close(session) }
432432
func stop() { stopEngine() }
433+
434+
func clearProfile() throws {
435+
let semaphore = DispatchSemaphore(value: 0)
436+
DispatchQueue.main.async {
437+
normalWebsiteDataStore.removeData(
438+
ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
439+
modifiedSince: .distantPast
440+
) { semaphore.signal() }
441+
}
442+
guard semaphore.wait(timeout: .now() + 30) == .success else {
443+
throw HostError(code: .timedOut, message: "Timed out while clearing browser profile")
444+
}
445+
}
446+
447+
func pingDetails() -> [String: JSONValue] {
448+
["profilePersistence": .string("durable")]
449+
}
433450
}
434451

435452
extension BrowserWindowController: BrowserEngineSession {

apps/headless/LinuxHost/BrowserProcess.swift

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ private final class ChromiumChildProcess {
2828
return errno != ECHILD && kill(processIdentifier, 0) == 0
2929
}
3030

31+
func waitForExit(timeout: TimeInterval) -> Bool {
32+
let deadline = Date().addingTimeInterval(timeout)
33+
while isRunning && Date() < deadline { Thread.sleep(forTimeInterval: 0.05) }
34+
return !isRunning
35+
}
36+
3137
func stop() {
3238
guard isRunning else { return }
3339
_ = kill(processIdentifier, SIGTERM)
@@ -157,21 +163,15 @@ final class ChromiumProcess {
157163
let headless: Bool
158164
let runtime: ChromiumRuntimeSelection
159165
var processIdentifier: Int32 { child.processIdentifier }
160-
private let profileURL: URL
161166
private let sessionsLock = NSLock()
167+
private let stopLock = NSLock()
162168
private var sessionsByProtocolID: [String: LinuxBrowserSession] = [:]
169+
private var stopped = false
163170

164-
init() throws {
171+
init(profileURL: URL) throws {
165172
#if os(Linux)
166173
guard getuid() != 0 else { throw CDPError.rootNotSupported }
167174
#endif
168-
try LocalRuntime.preparePrivateDirectory()
169-
profileURL = LocalRuntime.directoryURL.appendingPathComponent("chromium-profile", isDirectory: true)
170-
try FileManager.default.createDirectory(at: profileURL, withIntermediateDirectories: true)
171-
#if os(Linux)
172-
_ = chmod(profileURL.path, 0o700)
173-
#endif
174-
175175
runtime = try ChromiumRuntimeResolver().resolve()
176176
let executable = runtime.executableURL
177177
headless = ProcessInfo.processInfo.environment["HEADLESS_HEADLESS"] != "0"
@@ -226,8 +226,16 @@ final class ChromiumProcess {
226226
}
227227

228228
func stop() {
229+
stopLock.lock()
230+
guard !stopped else { stopLock.unlock(); return }
231+
stopped = true
232+
stopLock.unlock()
233+
234+
// Chromium flushes persistent cookies and local storage during its
235+
// normal browser shutdown. Keep SIGTERM as a bounded fallback only.
236+
try? browserConnection.sendWithoutWaiting("Browser.close")
237+
if !child.waitForExit(timeout: 3) { child.stop() }
229238
browserConnection.close()
230-
child.stop()
231239
}
232240

233241
private func routeEvent(_ event: [String: Any]) {

apps/headless/LinuxHost/main.swift

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ final class ChromiumBrowserEngine: BrowserEngine {
1010
let name = "chromium"
1111
let platform = "linux"
1212
let capabilities = BrowserEngineCapabilities.chromium
13-
let browser: ChromiumProcess
13+
private let profile: DurableBrowserProfile
14+
private(set) var browser: ChromiumProcess
1415

1516
init() throws {
16-
browser = try ChromiumProcess()
17+
profile = try DurableBrowserProfile()
18+
browser = try ChromiumProcess(profileURL: profile.directoryURL)
1719
}
1820

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

2729
func stop() { browser.stop() }
2830

31+
func clearProfile() throws {
32+
browser.stop()
33+
do {
34+
try profile.clear()
35+
browser = try ChromiumProcess(profileURL: profile.directoryURL)
36+
} catch {
37+
if let replacement = try? ChromiumProcess(profileURL: profile.directoryURL) {
38+
browser = replacement
39+
}
40+
throw error
41+
}
42+
}
43+
2944
func pingDetails() -> [String: JSONValue] {
3045
[
3146
"browserExecutable": .string(browser.runtime.executableURL.path),
3247
"browserRuntimeSource": .string(browser.runtime.source.rawValue),
3348
"browserTransport": .string("inherited-devtools-pipe"),
49+
"profilePersistence": .string("durable"),
50+
"profileMigration": .string(profile.migration.rawValue),
3451
]
3552
}
3653

apps/headless/Sources/HeadlessProtocol/CLI.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ public struct CLIParser {
122122
case "stop":
123123
try requireEmpty(arguments)
124124
return remote(.shutdown, session: session, jsonOutput: jsonOutput)
125+
case "profile":
126+
guard arguments == ["clear"] else { throw CLIParseError.missingArgument("profile clear") }
127+
return remote(.profileClear, session: session, jsonOutput: jsonOutput)
125128
case "session":
126129
return try parseSession(arguments, jsonOutput: jsonOutput)
127130
case "visit":
@@ -673,6 +676,7 @@ Core workflow:
673676
Commands:
674677
version | --version
675678
start [--background|--foreground] | status | stop | runtime
679+
profile clear
676680
config get startup-presentation
677681
config set startup-presentation background|foreground
678682
session create [NAME] | session list | session close NAME

apps/headless/Sources/HeadlessProtocol/Capabilities.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public struct BrowserEngineCapabilities: Sendable {
2424
public let qaDiagnosticSynchronization: String
2525
public let screenshotClipboard: Bool
2626
public let inputDispatch: String
27+
public let normalProfileStorage: String
2728

2829
public var supportedCommands: [CommandName] {
2930
CommandName.allCases.filter { !unsupportedCommands.contains($0) }
@@ -66,6 +67,12 @@ public struct BrowserEngineCapabilities: Sendable {
6667
"screenshotClipboard": .bool(screenshotClipboard),
6768
"tourTimeoutMs": .number(65_000),
6869
"inputDispatch": .string(inputDispatch),
70+
"normalProfile": .object([
71+
"persistent": .bool(true),
72+
"sharedAcrossSessions": .bool(true),
73+
"storage": .string(normalProfileStorage),
74+
"clearCommand": .string(CommandName.profileClear.rawValue),
75+
]),
6976
]),
7077
])
7178
}
@@ -85,7 +92,8 @@ public struct BrowserEngineCapabilities: Sendable {
8592
qaDiagnosticSource: "webkit-page-bridge",
8693
qaDiagnosticSynchronization: "best-effort-page-world-observer",
8794
screenshotClipboard: true,
88-
inputDispatch: "synthetic-dom"
95+
inputDispatch: "synthetic-dom",
96+
normalProfileStorage: "persistent-wkwebsite-data-store"
8997
)
9098

9199
public static let chromium = BrowserEngineCapabilities(
@@ -106,7 +114,8 @@ public struct BrowserEngineCapabilities: Sendable {
106114
qaDiagnosticSource: "chromium-cdp",
107115
qaDiagnosticSynchronization: "runtime-round-trip-flush",
108116
screenshotClipboard: false,
109-
inputDispatch: "trusted-cdp"
117+
inputDispatch: "trusted-cdp",
118+
normalProfileStorage: "private-xdg-data-directory"
110119
)
111120

112121
public static func profile(for engine: BrowserEngineName) -> BrowserEngineCapabilities {

0 commit comments

Comments
 (0)