Skip to content

Commit df92c78

Browse files
authored
Merge pull request #175 from LockInTime/feat/durable-auth-profiles-155
feat: persist authentication profiles across restarts
2 parents 805dbaf + 438c34e commit df92c78

17 files changed

Lines changed: 652 additions & 28 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: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,24 @@ struct ScreenshotArtifactData {
1313

1414
extension BrowserWindowController {
1515
func agentVisit(_ url: URL, timeout: TimeInterval = 20) throws -> JSONValue {
16-
onMain { self.navigate(to: url) }
17-
Thread.sleep(forTimeInterval: 0.05)
18-
return try agentWait(parameters: ["settled": .bool(true), "timeoutMs": .number(timeout * 1_000)])
16+
let deadline = Date().addingTimeInterval(timeout)
17+
guard onMain({ self.beginAgentNavigation(to: url) }) else {
18+
throw HostError(code: .operationFailed, message: "Browser refused to start navigation")
19+
}
20+
while Date() < deadline {
21+
let status = onMain { self.agentNavigationStatus() }
22+
if status.failed {
23+
throw HostError(code: .operationFailed, message: "Browser navigation failed")
24+
}
25+
if !status.pending {
26+
let remainingMs = max(100, deadline.timeIntervalSinceNow * 1_000)
27+
return try agentWait(parameters: [
28+
"settled": .bool(true), "timeoutMs": .number(remainingMs),
29+
])
30+
}
31+
Thread.sleep(forTimeInterval: 0.05)
32+
}
33+
throw HostError(code: .timedOut, message: "Timed out while waiting for navigation")
1934
}
2035

2136
func agentInspect(parameters: [String: JSONValue]) throws -> JSONValue {
@@ -430,6 +445,23 @@ final class WebKitBrowserEngine: BrowserEngine {
430445
func createSession() throws -> BrowserWindowController { try create() }
431446
func closeSession(_ session: BrowserWindowController) { close(session) }
432447
func stop() { stopEngine() }
448+
449+
func clearProfile() throws {
450+
let semaphore = DispatchSemaphore(value: 0)
451+
DispatchQueue.main.async {
452+
normalWebsiteDataStore.removeData(
453+
ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
454+
modifiedSince: .distantPast
455+
) { semaphore.signal() }
456+
}
457+
guard semaphore.wait(timeout: .now() + 30) == .success else {
458+
throw HostError(code: .timedOut, message: "Timed out while clearing browser profile")
459+
}
460+
}
461+
462+
func pingDetails() -> [String: JSONValue] {
463+
["profilePersistence": .string("durable")]
464+
}
433465
}
434466

435467
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)