diff --git a/README.md b/README.md index 9ae3b62..1671e20 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/headless/Host/AgentBridge.swift b/apps/headless/Host/AgentBridge.swift index ca4804d..cd0c025 100644 --- a/apps/headless/Host/AgentBridge.swift +++ b/apps/headless/Host/AgentBridge.swift @@ -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 { @@ -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 { diff --git a/apps/headless/LinuxHost/BrowserProcess.swift b/apps/headless/LinuxHost/BrowserProcess.swift index fe06487..3c6aa15 100644 --- a/apps/headless/LinuxHost/BrowserProcess.swift +++ b/apps/headless/LinuxHost/BrowserProcess.swift @@ -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) @@ -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" @@ -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]) { diff --git a/apps/headless/LinuxHost/main.swift b/apps/headless/LinuxHost/main.swift index db2289f..f6a4c9d 100644 --- a/apps/headless/LinuxHost/main.swift +++ b/apps/headless/LinuxHost/main.swift @@ -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 { @@ -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), ] } diff --git a/apps/headless/Sources/HeadlessProtocol/CLI.swift b/apps/headless/Sources/HeadlessProtocol/CLI.swift index e5df7cd..1ddad2d 100644 --- a/apps/headless/Sources/HeadlessProtocol/CLI.swift +++ b/apps/headless/Sources/HeadlessProtocol/CLI.swift @@ -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": @@ -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 diff --git a/apps/headless/Sources/HeadlessProtocol/Capabilities.swift b/apps/headless/Sources/HeadlessProtocol/Capabilities.swift index 9198117..61be634 100644 --- a/apps/headless/Sources/HeadlessProtocol/Capabilities.swift +++ b/apps/headless/Sources/HeadlessProtocol/Capabilities.swift @@ -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) } @@ -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), + ]), ]), ]) } @@ -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( @@ -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 { diff --git a/apps/headless/Sources/HeadlessProtocol/DurableBrowserProfile.swift b/apps/headless/Sources/HeadlessProtocol/DurableBrowserProfile.swift new file mode 100644 index 0000000..0b75065 --- /dev/null +++ b/apps/headless/Sources/HeadlessProtocol/DurableBrowserProfile.swift @@ -0,0 +1,236 @@ +import Foundation + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +public enum DurableBrowserProfileError: Error, CustomStringConvertible { + case invalidDataDirectory + case invalidProfile + case profileInUse + case operationFailed(String) + + public var description: String { + switch self { + case .invalidDataDirectory: + return "Browser profile data directory must be private and owned by the current user" + case .invalidProfile: + return "Browser profile is unsafe; remove the invalid entry before retrying" + case .profileInUse: + return "Another Headless host is already using the browser profile" + case .operationFailed(let operation): + return "Browser profile \(operation) failed: \(String(cString: strerror(errno)))" + } + } +} + +public enum DurableBrowserProfileMigration: String, Sendable { + case none + case migrated + case skippedUnsafe = "skipped-unsafe" + case recoveredCorruption = "recovered-corruption" +} + +/// Owns the Linux normal-profile directory and its process-wide lease. Paths +/// are selected by the host, never by an agent-facing command. +public final class DurableBrowserProfile: @unchecked Sendable { + public let rootURL: URL + public let directoryURL: URL + public let migration: DurableBrowserProfileMigration + private let lockDescriptor: Int32 + + public convenience init(environment: [String: String] = ProcessInfo.processInfo.environment) throws { + let base: URL + if let xdgDataHome = environment["XDG_DATA_HOME"], xdgDataHome.hasPrefix("/") { + base = URL(fileURLWithPath: xdgDataHome, isDirectory: true).standardizedFileURL + } else { + base = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local/share", isDirectory: true) + } + try self.init( + rootURL: base.appendingPathComponent("headless", isDirectory: true), + legacyProfileURL: LocalRuntime.directoryURL.appendingPathComponent("chromium-profile", isDirectory: true) + ) + } + + public init(rootURL: URL, legacyProfileURL: URL? = nil) throws { + let root = rootURL.standardizedFileURL + guard root.isFileURL, root.path.hasPrefix("/") else { + throw DurableBrowserProfileError.invalidDataDirectory + } + try Self.prepareParentDirectories(for: root) + try Self.preparePrivateDirectory(root, recoverOwnedInvalidEntry: false) + Self.removeStaleMigrationDirectories(from: root) + + let lockURL = root.appendingPathComponent("normal-profile.lock", isDirectory: false) + let descriptor = open(lockURL.path, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0o600) + guard descriptor >= 0 else { throw DurableBrowserProfileError.operationFailed("lock creation") } + do { + try Self.validateLock(descriptor) + guard flock(descriptor, LOCK_EX | LOCK_NB) == 0 else { + if errno == EWOULDBLOCK { throw DurableBrowserProfileError.profileInUse } + throw DurableBrowserProfileError.operationFailed("lock acquisition") + } + } catch { + _ = close(descriptor) + throw error + } + + let profile = root.appendingPathComponent("chromium-profile", isDirectory: true) + let migrationResult: DurableBrowserProfileMigration + do { + migrationResult = try Self.prepareProfile(profile, legacyProfileURL: legacyProfileURL) + } catch { + _ = flock(descriptor, LOCK_UN) + _ = close(descriptor) + throw error + } + self.rootURL = root + self.directoryURL = profile + self.lockDescriptor = descriptor + self.migration = migrationResult + } + + deinit { + _ = flock(lockDescriptor, LOCK_UN) + _ = close(lockDescriptor) + } + + public func clear() throws { + if FileManager.default.fileExists(atPath: directoryURL.path) { + try FileManager.default.removeItem(at: directoryURL) + } + try Self.preparePrivateDirectory(directoryURL, recoverOwnedInvalidEntry: true) + } + + private static func prepareParentDirectories(for root: URL) throws { + let parent = root.deletingLastPathComponent() + do { + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + } catch { + throw DurableBrowserProfileError.operationFailed("parent directory creation") + } + } + + private static func prepareProfile( + _ profile: URL, legacyProfileURL: URL? + ) throws -> DurableBrowserProfileMigration { + var info = stat() + if lstat(profile.path, &info) == 0 { + if isPrivateOwnedDirectory(info) { return .none } + guard info.st_uid == getuid(), (info.st_mode & S_IFMT) != S_IFLNK else { + throw DurableBrowserProfileError.invalidProfile + } + let quarantine = profile.deletingLastPathComponent().appendingPathComponent( + "chromium-profile.corrupt-\(UUID().uuidString)", isDirectory: true + ) + guard rename(profile.path, quarantine.path) == 0 else { + throw DurableBrowserProfileError.operationFailed("corruption recovery") + } + try preparePrivateDirectory(profile, recoverOwnedInvalidEntry: false) + return .recoveredCorruption + } + guard errno == ENOENT else { throw DurableBrowserProfileError.operationFailed("profile check") } + + if let legacyProfileURL, FileManager.default.fileExists(atPath: legacyProfileURL.path) { + guard isSafeLegacyTree(legacyProfileURL) else { + try preparePrivateDirectory(profile, recoverOwnedInvalidEntry: false) + return .skippedUnsafe + } + let staging = profile.deletingLastPathComponent().appendingPathComponent( + ".chromium-profile-migration-\(UUID().uuidString)", isDirectory: true + ) + do { + try FileManager.default.copyItem(at: legacyProfileURL, to: staging) + _ = chmod(staging.path, 0o700) + removeChromiumLeaseArtifacts(from: staging) + guard rename(staging.path, profile.path) == 0 else { + throw DurableBrowserProfileError.operationFailed("migration activation") + } + try? FileManager.default.removeItem(at: legacyProfileURL) + return .migrated + } catch { + try? FileManager.default.removeItem(at: staging) + throw error + } + } + + try preparePrivateDirectory(profile, recoverOwnedInvalidEntry: false) + return .none + } + + private static func preparePrivateDirectory( + _ url: URL, recoverOwnedInvalidEntry: Bool + ) throws { + var info = stat() + if lstat(url.path, &info) == 0 { + guard isPrivateOwnedDirectory(info) else { + if recoverOwnedInvalidEntry, info.st_uid == getuid(), (info.st_mode & S_IFMT) != S_IFLNK { + try FileManager.default.removeItem(at: url) + return try preparePrivateDirectory(url, recoverOwnedInvalidEntry: false) + } + throw DurableBrowserProfileError.invalidDataDirectory + } + return + } + guard errno == ENOENT else { throw DurableBrowserProfileError.operationFailed("directory check") } + guard mkdir(url.path, 0o700) == 0 else { + if errno == EEXIST { return try preparePrivateDirectory(url, recoverOwnedInvalidEntry: false) } + throw DurableBrowserProfileError.operationFailed("directory creation") + } + guard chmod(url.path, 0o700) == 0 else { + throw DurableBrowserProfileError.operationFailed("directory permissions") + } + } + + private static func validateLock(_ descriptor: Int32) throws { + var info = stat() + guard fstat(descriptor, &info) == 0 else { + throw DurableBrowserProfileError.operationFailed("lock validation") + } + guard (info.st_mode & S_IFMT) == S_IFREG, info.st_uid == getuid() else { + throw DurableBrowserProfileError.invalidDataDirectory + } + guard fchmod(descriptor, 0o600) == 0 else { + throw DurableBrowserProfileError.operationFailed("lock permissions") + } + } + + private static func isPrivateOwnedDirectory(_ info: stat) -> Bool { + (info.st_mode & S_IFMT) == S_IFDIR && info.st_uid == getuid() && (info.st_mode & 0o077) == 0 + } + + private static func isSafeLegacyTree(_ root: URL) -> Bool { + var rootInfo = stat() + guard lstat(root.path, &rootInfo) == 0, isPrivateOwnedDirectory(rootInfo) else { return false } + guard let enumerator = FileManager.default.enumerator( + at: root, includingPropertiesForKeys: nil, options: [.skipsPackageDescendants] + ) else { return false } + for case let item as URL in enumerator { + var info = stat() + guard lstat(item.path, &info) == 0, info.st_uid == getuid() else { return false } + let type = info.st_mode & S_IFMT + guard type == S_IFDIR || type == S_IFREG else { return false } + } + return true + } + + private static func removeChromiumLeaseArtifacts(from profile: URL) { + for name in ["SingletonCookie", "SingletonLock", "SingletonSocket"] { + try? FileManager.default.removeItem(at: profile.appendingPathComponent(name)) + } + } + + private static func removeStaleMigrationDirectories(from root: URL) { + guard let names = try? FileManager.default.contentsOfDirectory(atPath: root.path) else { return } + for name in names where name.hasPrefix(".chromium-profile-migration-") { + let candidate = root.appendingPathComponent(name, isDirectory: true) + var info = stat() + guard lstat(candidate.path, &info) == 0, info.st_uid == getuid(), + (info.st_mode & S_IFMT) == S_IFDIR else { continue } + try? FileManager.default.removeItem(at: candidate) + } + } +} diff --git a/apps/headless/Sources/HeadlessProtocol/HostCore.swift b/apps/headless/Sources/HeadlessProtocol/HostCore.swift index 772e937..27b209a 100644 --- a/apps/headless/Sources/HeadlessProtocol/HostCore.swift +++ b/apps/headless/Sources/HeadlessProtocol/HostCore.swift @@ -78,12 +78,16 @@ public protocol BrowserEngine: AnyObject { var capabilities: BrowserEngineCapabilities { get } func createSession() throws -> Session func closeSession(_ session: Session) + func clearProfile() throws func stop() func pingDetails() -> [String: JSONValue] func hostError(for error: Error) -> HostError? } public extension BrowserEngine { + func clearProfile() throws { + throw HostError(code: .unsupportedCapability, message: "Profile clearing is not supported by this engine.") + } func pingDetails() -> [String: JSONValue] { [:] } func hostError(for error: Error) -> HostError? { nil } } @@ -162,6 +166,9 @@ public final class HostCore: @unchecked Sendable { } do { + if request.command == .profileClear { + return try clearProfile(request) + } if request.command == .artifactList { return .success(id: request.id, result: try artifacts.list()) } @@ -228,6 +235,40 @@ public final class HostCore: @unchecked Sendable { } } + private func clearProfile(_ request: CommandRequest) throws -> CommandResponse { + let captured = withState { () -> ([BrowserRecording], [Engine.Session]) in + let activeRecordings = Array(recordings.values) + let openSessions = Array(sessions.values) + recordings.removeAll() + sessions.removeAll() + trace.removeAll() + activeFlows.removeAll() + return (activeRecordings, openSessions) + } + for recording in captured.0 { _ = try? recording.stop(timeout: 5) } + for session in captured.1 { engine.closeSession(session) } + do { + try engine.clearProfile() + let replacement = try engine.createSession() + withState { + sessions["default"] = replacement + trace["default"] = [] + } + return .success(id: request.id, result: .object([ + "cleared": .bool(true), "session": .string("default"), + ])) + } catch { + // Preserve a usable host when clearing fails after sessions close. + if let replacement = try? engine.createSession() { + withState { + sessions["default"] = replacement + trace["default"] = [] + } + } + throw error + } + } + private func ping(_ request: CommandRequest) -> CommandResponse { var details: [String: JSONValue] = [ "ready": .bool(true), @@ -400,7 +441,7 @@ public final class HostCore: @unchecked Sendable { ) case .flowRun: return try runFlow(request, sessionName: name) - case .ping, .shutdown, .sessionCreate, .sessionList, .sessionClose, .artifactList: + case .ping, .shutdown, .profileClear, .sessionCreate, .sessionList, .sessionClose, .artifactList: throw HostError(code: .invalidCommand, message: "Command is not valid in this context.") } } diff --git a/apps/headless/Sources/HeadlessProtocol/Protocol.swift b/apps/headless/Sources/HeadlessProtocol/Protocol.swift index 07d7536..38d32ce 100644 --- a/apps/headless/Sources/HeadlessProtocol/Protocol.swift +++ b/apps/headless/Sources/HeadlessProtocol/Protocol.swift @@ -56,6 +56,7 @@ public enum JSONValue: Codable, Equatable, Sendable { public enum CommandName: String, Codable, CaseIterable, Sendable { case ping case shutdown + case profileClear = "profile.clear" case sessionCreate = "session.create" case sessionList = "session.list" case sessionClose = "session.close" @@ -212,7 +213,7 @@ public struct CommandRequest: Codable, Equatable, Sendable { } switch command { - case .ping, .shutdown, .sessionList, .sessionClose, .back, .reload, + case .ping, .shutdown, .profileClear, .sessionList, .sessionClose, .back, .reload, .captureInfo, .artifactList, .recordStatus, .qaReport, .qaClear: try allow([]) case .sessionCreate: diff --git a/apps/headless/Tests/Fixtures/auth-state.html b/apps/headless/Tests/Fixtures/auth-state.html new file mode 100644 index 0000000..94e99eb --- /dev/null +++ b/apps/headless/Tests/Fixtures/auth-state.html @@ -0,0 +1,30 @@ + + + + + + Authentication State + + +
+

Authentication State

+ +

+
+ + + diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index 443b1f0..c91a948 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -196,6 +196,7 @@ private final class TestBrowserEngine: BrowserEngine { private(set) var createdSessions: [TestBrowserSession] = [] private(set) var closedSessions: [TestBrowserSession] = [] private(set) var stopped = false + private(set) var profileClearCount = 0 func createSession() throws -> TestBrowserSession { let session = TestBrowserSession() @@ -205,6 +206,7 @@ private final class TestBrowserEngine: BrowserEngine { func closeSession(_ session: TestBrowserSession) { closedSessions.append(session) } func stop() { stopped = true } + func clearProfile() throws { profileClearCount += 1 } func pingDetails() -> [String: JSONValue] { ["adapter": .string("test-adapter")] } } @@ -315,7 +317,71 @@ struct ProtocolTests { } } + static func durableBrowserProfileLifecycle() throws { + let base = URL(fileURLWithPath: "/tmp/headless-profile-test-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: base) } + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + + let migratedRoot = base.appendingPathComponent("migrated", isDirectory: true) + let legacy = base.appendingPathComponent("legacy", isDirectory: true) + try FileManager.default.createDirectory(at: legacy, withIntermediateDirectories: false) + try expect(chmod(legacy.path, 0o700) == 0, "legacy profile permissions should be configurable") + try Data("persisted".utf8).write(to: legacy.appendingPathComponent("state")) + let migrated = try DurableBrowserProfile(rootURL: migratedRoot, legacyProfileURL: legacy) + try expect(migrated.migration == .migrated, "a safe legacy profile should migrate") + try expect( + FileManager.default.fileExists(atPath: migrated.directoryURL.appendingPathComponent("state").path), + "migration should retain browser state" + ) + try expect(!FileManager.default.fileExists(atPath: legacy.path), "migration should remove the legacy profile") + try expectThrows("a second owner should not acquire the same profile") { + _ = try DurableBrowserProfile(rootURL: migratedRoot) + } + try Data("discard".utf8).write(to: migrated.directoryURL.appendingPathComponent("temporary")) + try migrated.clear() + try expect( + !FileManager.default.fileExists(atPath: migrated.directoryURL.appendingPathComponent("temporary").path), + "profile clear should remove existing state" + ) + + let unsafeRoot = base.appendingPathComponent("unsafe", isDirectory: true) + let unsafeLegacy = base.appendingPathComponent("unsafe-legacy", isDirectory: true) + try FileManager.default.createDirectory(at: unsafeLegacy, withIntermediateDirectories: false) + try expect(chmod(unsafeLegacy.path, 0o700) == 0, "unsafe legacy permissions should be configurable") + try FileManager.default.createSymbolicLink( + at: unsafeLegacy.appendingPathComponent("external"), + withDestinationURL: URL(fileURLWithPath: "/tmp") + ) + let skipped = try DurableBrowserProfile(rootURL: unsafeRoot, legacyProfileURL: unsafeLegacy) + try expect(skipped.migration == .skippedUnsafe, "a legacy profile with symlinks should not migrate") + try expect(FileManager.default.fileExists(atPath: unsafeLegacy.path), "unsafe legacy state should remain untouched") + + let recoveredRoot = base.appendingPathComponent("recovered", isDirectory: true) + try FileManager.default.createDirectory(at: recoveredRoot, withIntermediateDirectories: false) + try expect(chmod(recoveredRoot.path, 0o700) == 0, "recovery root permissions should be configurable") + let corruptProfile = recoveredRoot.appendingPathComponent("chromium-profile", isDirectory: true) + let staleMigration = recoveredRoot.appendingPathComponent( + ".chromium-profile-migration-stale", isDirectory: true + ) + try FileManager.default.createDirectory(at: staleMigration, withIntermediateDirectories: false) + try FileManager.default.createDirectory(at: corruptProfile, withIntermediateDirectories: false) + try expect(chmod(corruptProfile.path, 0o755) == 0, "corrupt profile permissions should be configurable") + let recovered = try DurableBrowserProfile(rootURL: recoveredRoot) + try expect(recovered.migration == .recoveredCorruption, "owned invalid state should report recovery") + var recoveredInfo = stat() + try expect(lstat(recovered.directoryURL.path, &recoveredInfo) == 0, "recovered profile should exist") + try expect((recoveredInfo.st_mode & 0o077) == 0, "recovered profile should be private") + let quarantines = try FileManager.default.contentsOfDirectory(atPath: recoveredRoot.path) + .filter { $0.hasPrefix("chromium-profile.corrupt-") } + try expect(quarantines.count == 1, "owned invalid profile state should be quarantined") + try expect(!FileManager.default.fileExists(atPath: staleMigration.path), "stale migration state should be removed") + } + static func commandParameterValidation() throws { + try CommandRequest(id: "clear-profile", command: .profileClear).validate() + try expectThrows("profile clear should reject parameters") { + try CommandRequest(command: .profileClear, parameters: ["path": .string("/tmp/profile")]).validate() + } try CommandRequest( id: "valid-scroll", command: .scroll, parameters: ["direction": .string("down"), "amount": .number(500)] @@ -700,6 +766,7 @@ struct ProtocolTests { let remoteCommands: [([String], CommandName)] = [ (["status"], .ping), (["stop"], .shutdown), + (["profile", "clear"], .profileClear), (["session", "create", "qa"], .sessionCreate), (["session", "list"], .sessionList), (["session", "close", "qa"], .sessionClose), @@ -1238,6 +1305,15 @@ struct ProtocolTests { features["tourTimeoutMs"] == .number(65_000), "both engine profiles should declare the shared tour timeout" ) + guard case .object(let features)? = engine["features"], + case .object(let normalProfile)? = features["normalProfile"] else { + throw TestFailure(description: "engine should declare normal-profile behavior") + } + try expect(normalProfile["persistent"] == .bool(true), "normal profile should be durable") + try expect( + normalProfile["clearCommand"] == .string(CommandName.profileClear.rawValue), + "normal profile should declare its explicit clear command" + ) try expect( features["backWithoutHistory"] == .string("operation-failed"), "both engines should fail consistently when back history is empty" @@ -1886,11 +1962,17 @@ struct ProtocolTests { try expect(pingResult["adapter"] == .string("test-adapter"), "engine ping details should be merged") try expect(pingResult["capabilities"] != nil, "ping should publish the active engine profile") + let cleared = core.handle(CommandRequest(command: .profileClear)) + try expect(cleared.ok, "shared profile clear should succeed") + try expect(engine.profileClearCount == 1, "profile clear should delegate to the engine") + try expect(engine.closedSessions.count == 1, "profile clear should close the existing default session") + try expect(engine.createdSessions.count == 1, "profile clear should create a clean default session") + let created = core.handle(CommandRequest( command: .sessionCreate, parameters: ["name": .string("secondary")] )) try expect(created.ok, "shared session creation should succeed") - try expect(engine.createdSessions.count == 1, "session creation should delegate to the engine") + try expect(engine.createdSessions.count == 2, "session creation should delegate to the engine") let inspected = core.handle(CommandRequest( command: .inspect, session: "secondary", parameters: ["interactive": .bool(true)] @@ -1900,7 +1982,7 @@ struct ProtocolTests { } try expect(inspectResult["engineResult"] == .bool(true), "inspect should delegate to the session") try expect( - engine.createdSessions[0].agentControlEnableCount == 2, + engine.createdSessions[1].agentControlEnableCount == 2, "agent control should be enabled at creation and before command execution" ) @@ -1920,7 +2002,7 @@ struct ProtocolTests { let closed = core.handle(CommandRequest(command: .sessionClose, session: "secondary")) try expect(closed.ok, "shared session close should succeed") - try expect(engine.closedSessions.count == 1, "session close should delegate to the engine") + try expect(engine.closedSessions.count == 2, "session close should delegate to the engine") let missing = core.handle(CommandRequest(command: .inspect, session: "secondary")) try expect(missing.error?.code == "SESSION_NOT_FOUND", "closed sessions should be removed from shared state") } @@ -1951,6 +2033,7 @@ struct ProtocolTests { ("page navigation boundary", pageNavigationBoundary), ("message size limit", messageSizeLimit), ("identifier validation", identifierValidation), + ("durable browser profile lifecycle", durableBrowserProfileLifecycle), ("command parameter validation", commandParameterValidation), ("strict request fields", rejectsUnexpectedRequestFields), ("CLI visit", cliVisit), diff --git a/apps/headless/Tests/fixture-server.mjs b/apps/headless/Tests/fixture-server.mjs index 1847275..1ceebfa 100644 --- a/apps/headless/Tests/fixture-server.mjs +++ b/apps/headless/Tests/fixture-server.mjs @@ -9,6 +9,7 @@ const routes = new Map([ ['/next', 'next.html'], ['/hostile', 'hostile.html'], ['/large-document', 'large-document.html'], + ['/auth-state', 'auth-state.html'], ]); const server = createServer(async (request, response) => { diff --git a/apps/headless/Tests/linux-e2e.sh b/apps/headless/Tests/linux-e2e.sh index 37d9c85..e957cc0 100755 --- a/apps/headless/Tests/linux-e2e.sh +++ b/apps/headless/Tests/linux-e2e.sh @@ -2,15 +2,17 @@ set -eu export HEADLESS_ARTIFACT_DIR="/tmp/headless-artifacts-e2e-$$" +export XDG_DATA_HOME="/tmp/headless-data-e2e-$$" FIXTURE_ROOT="$(mktemp -d /tmp/headless-fixture.XXXXXX)" INSTALL_ROOT="$(mktemp -d /tmp/headless-install.XXXXXX)" -mkdir -p "$FIXTURE_ROOT/designers/dashboard" "$FIXTURE_ROOT/next" "$FIXTURE_ROOT/hostile" "$FIXTURE_ROOT/large-document" "$FIXTURE_ROOT/trusted-input" "$FIXTURE_ROOT/api" +mkdir -p "$FIXTURE_ROOT/designers/dashboard" "$FIXTURE_ROOT/next" "$FIXTURE_ROOT/hostile" "$FIXTURE_ROOT/large-document" "$FIXTURE_ROOT/trusted-input" "$FIXTURE_ROOT/auth-state" "$FIXTURE_ROOT/api" cp /opt/headless/fixtures/dashboard.html "$FIXTURE_ROOT/designers/dashboard/index.html" cp /opt/headless/fixtures/next.html "$FIXTURE_ROOT/next/index.html" cp /opt/headless/fixtures/hostile.html "$FIXTURE_ROOT/hostile/index.html" cp /opt/headless/fixtures/large-document.html "$FIXTURE_ROOT/large-document/index.html" cp /opt/headless/fixtures/trusted-input.html "$FIXTURE_ROOT/trusted-input/index.html" +cp /opt/headless/fixtures/auth-state.html "$FIXTURE_ROOT/auth-state/index.html" cp /opt/headless/fixtures/api-diagnostic.json "$FIXTURE_ROOT/api/diagnostic" busybox httpd -f -p 127.0.0.1:41739 -h "$FIXTURE_ROOT" & FIXTURE_PID=$! @@ -21,6 +23,7 @@ cleanup() { rm -rf "$FIXTURE_ROOT" rm -rf "$INSTALL_ROOT" rm -rf "$HEADLESS_ARTIFACT_DIR" + rm -rf "$XDG_DATA_HOME" } trap cleanup EXIT INT TERM @@ -66,12 +69,56 @@ fi echo "$PRESENTATION_START" | grep -q 'UNSUPPORTED_CAPABILITY' headless start | grep -q '"ready":true' +test "$(stat -c %a "$XDG_DATA_HOME/headless")" = "700" +test "$(stat -c %a "$XDG_DATA_HOME/headless/chromium-profile")" = "700" if RUNNING_PRESENTATION_START="$(headless start --foreground 2>&1)"; then echo "macOS startup presentation override was accepted by a running Linux host" >&2 exit 1 fi echo "$RUNNING_PRESENTATION_START" | grep -q 'UNSUPPORTED_CAPABILITY' +headless visit 'http://127.0.0.1:41739/auth-state/?action=login' | grep -q 'Authentication State' +headless inspect --text | grep -q 'Cookie state: signed-in' +headless inspect --text | grep -q 'Storage state: signed-in' +PROFILE_RESTART_PID="$(headless status | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p')" +test -n "$PROFILE_RESTART_PID" +headless stop | grep -q '"stopping":true' +for _ in $(seq 1 100); do + ! kill -0 "$PROFILE_RESTART_PID" >/dev/null 2>&1 && break + sleep 0.05 +done +if kill -0 "$PROFILE_RESTART_PID" >/dev/null 2>&1; then + echo "host did not release the durable profile during restart" >&2 + exit 1 +fi +headless start | grep -q '"ready":true' +headless visit 'http://127.0.0.1:41739/auth-state/?action=check' | grep -q 'Authentication State' +headless inspect --text | grep -q 'Cookie state: signed-in' +headless inspect --text | grep -q 'Storage state: signed-in' +headless visit 'http://127.0.0.1:41739/auth-state/?action=logout' | grep -q 'Authentication State' +headless inspect --text | grep -q 'Cookie state: missing' +headless inspect --text | grep -q 'Storage state: missing' +LOGOUT_RESTART_PID="$(headless status | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p')" +test -n "$LOGOUT_RESTART_PID" +headless stop >/dev/null +for _ in $(seq 1 100); do + ! kill -0 "$LOGOUT_RESTART_PID" >/dev/null 2>&1 && break + sleep 0.05 +done +if kill -0 "$LOGOUT_RESTART_PID" >/dev/null 2>&1; then + echo "host did not exit while verifying durable logout" >&2 + exit 1 +fi +headless start >/dev/null +headless visit 'http://127.0.0.1:41739/auth-state/?action=check' >/dev/null +headless inspect --text | grep -q 'Cookie state: missing' +headless inspect --text | grep -q 'Storage state: missing' +headless visit 'http://127.0.0.1:41739/auth-state/?action=login' >/dev/null +headless profile clear | grep -q '"cleared":true' +headless visit 'http://127.0.0.1:41739/auth-state/?action=check' | grep -q 'Authentication State' +headless inspect --text | grep -q 'Cookie state: missing' +headless inspect --text | grep -q 'Storage state: missing' + # The fixture server is the only TCP listener. Chromium control must stay on # its inherited DevTools pipe rather than exposing a loopback debugging port. UNEXPECTED_TCP="$(awk 'NR > 1 && $4 == "0A" && $2 !~ /:A30B$/ { print $2 }' /proc/net/tcp /proc/net/tcp6)" diff --git a/apps/headless/Tests/macos-e2e.sh b/apps/headless/Tests/macos-e2e.sh index cef244f..71621e2 100755 --- a/apps/headless/Tests/macos-e2e.sh +++ b/apps/headless/Tests/macos-e2e.sh @@ -18,6 +18,7 @@ export HEADLESS_SOCKET="$RUNTIME/macos-e2e-$$.sock" export HEADLESS_ARTIFACT_DIR="$RUNTIME/artifacts-macos-e2e-$$" export HEADLESS_HOST_EXECUTABLE="$HOST" export HEADLESS_FIXTURE_PORT="$PORT" +export HEADLESS_E2E_DATA_STORE_ID="$(uuidgen)" LOG="$(mktemp "${TMPDIR:-/tmp}/headless-macos-e2e.XXXXXX")" HOST_LOG="$(mktemp "${TMPDIR:-/tmp}/headless-macos-host.XXXXXX")" RESTORE_LOG="$(mktemp "${TMPDIR:-/tmp}/headless-macos-restore.XXXXXX")" @@ -75,6 +76,9 @@ node Tests/fixture-server.mjs >"$LOG" 2>&1 & FIXTURE_PID=$! cleanup() { + if "$CLI" status >/dev/null 2>&1; then + "$CLI" profile clear >/dev/null 2>&1 || true + fi "$CLI" stop >/dev/null 2>&1 || true if [[ -n "$RESTORE_PID" ]]; then kill "$RESTORE_PID" >/dev/null 2>&1 || true @@ -415,5 +419,58 @@ if [[ "$(frontmost_pid)" == "$BACKGROUND_PID" ]]; then fail fi "$CLI" stop >/dev/null +for _ in {1..100}; do + ! kill -0 "$BACKGROUND_PID" >/dev/null 2>&1 && break + sleep 0.05 +done +if kill -0 "$BACKGROUND_PID" >/dev/null 2>&1; then + echo "background override host did not stop" >&2 + fail +fi + +STEP="durable-authentication-profile" +"$CLI" start --background | grep -q '"ready":true' +"$CLI" visit "http://127.0.0.1:$PORT/auth-state?action=login" | grep -q 'Authentication State' +"$CLI" inspect --text | grep -q 'Cookie state: signed-in' +"$CLI" inspect --text | grep -q 'Storage state: signed-in' +PROFILE_RESTART_PID="$("$CLI" status | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p')" +test -n "$PROFILE_RESTART_PID" +"$CLI" stop >/dev/null +for _ in {1..100}; do + ! kill -0 "$PROFILE_RESTART_PID" >/dev/null 2>&1 && break + sleep 0.05 +done +if kill -0 "$PROFILE_RESTART_PID" >/dev/null 2>&1; then + echo "host did not exit during durable profile restart" >&2 + fail +fi +"$CLI" start --background | grep -q '"ready":true' +"$CLI" visit "http://127.0.0.1:$PORT/auth-state?action=check" | grep -q 'Authentication State' +"$CLI" inspect --text | grep -q 'Cookie state: signed-in' +"$CLI" inspect --text | grep -q 'Storage state: signed-in' +"$CLI" visit "http://127.0.0.1:$PORT/auth-state?action=logout" >/dev/null +"$CLI" inspect --text | grep -q 'Cookie state: missing' +"$CLI" inspect --text | grep -q 'Storage state: missing' +LOGOUT_RESTART_PID="$("$CLI" status | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p')" +test -n "$LOGOUT_RESTART_PID" +"$CLI" stop >/dev/null +for _ in {1..100}; do + ! kill -0 "$LOGOUT_RESTART_PID" >/dev/null 2>&1 && break + sleep 0.05 +done +if kill -0 "$LOGOUT_RESTART_PID" >/dev/null 2>&1; then + echo "host did not exit while verifying durable logout" >&2 + fail +fi +"$CLI" start --background >/dev/null +"$CLI" visit "http://127.0.0.1:$PORT/auth-state?action=check" >/dev/null +"$CLI" inspect --text | grep -q 'Cookie state: missing' +"$CLI" inspect --text | grep -q 'Storage state: missing' +"$CLI" visit "http://127.0.0.1:$PORT/auth-state?action=login" >/dev/null +"$CLI" profile clear | grep -q '"cleared":true' +"$CLI" visit "http://127.0.0.1:$PORT/auth-state?action=check" | grep -q 'Authentication State' +"$CLI" inspect --text | grep -q 'Cookie state: missing' +"$CLI" inspect --text | grep -q 'Storage state: missing' +"$CLI" stop >/dev/null echo "macOS P2 end-to-end flow passed" diff --git a/apps/headless/docs/COMMANDS.md b/apps/headless/docs/COMMANDS.md index 92bebae..b9a8933 100644 --- a/apps/headless/docs/COMMANDS.md +++ b/apps/headless/docs/COMMANDS.md @@ -22,6 +22,7 @@ headless -- --value # stop option parsing; literal values ```sh 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 @@ -33,8 +34,9 @@ capabilities came from. - `config startup-presentation` is macOS only; other engines reject it. - Sessions are windows (macOS) or tabs (Linux) sharing **one browser profile**. - Cookies and storage are shared across sessions — see P1.md for why this is - not an isolation boundary. + Cookies and local storage are shared across sessions and survive host and + machine restarts. `profile clear` closes every session and permanently + removes normal-profile cookies, storage, caches, and permissions. ## Navigation and interaction diff --git a/apps/headless/docs/P1.md b/apps/headless/docs/P1.md index 1b41ae3..97d0e53 100644 --- a/apps/headless/docs/P1.md +++ b/apps/headless/docs/P1.md @@ -5,10 +5,12 @@ control contract. macOS and Linux use the same commands and JSON responses. ## Sessions share one browser profile -Sessions are windows (macOS) or tabs (Linux) inside one persistent browser. +Sessions are windows (macOS) or tabs (Linux) inside one persistent normal +browser profile. They are a way to keep several pages open at once, not an isolation boundary. -Cookies, localStorage, and sessionStorage are shared across every session on -both engines: sign in under session `qa` and session `audit` is signed in too. +Cookies and localStorage are shared across sessions on both engines and survive +host and machine restarts: sign in under session `qa` and session `audit` is +signed in too. Session storage follows browser semantics and is not durable. This is deliberate — it matches the persistent logged-in-browser product idea (architecture decision §11). Do not use sessions to separate identities or to contain untrusted page state; there is no per-session isolation today. If @@ -16,6 +18,14 @@ isolation is wanted later, Chromium would get `Target.createBrowserContext` behind a `session create --isolated` flag and WebKit would use a non-persistent `WKWebsiteDataStore`; that needs its own decision entry first. +Linux stores the normal Chromium profile under `$XDG_DATA_HOME/headless` or +`~/.local/share/headless`, guarded by a single-owner lock and private `0700` +directories. A safe legacy runtime profile is migrated once; unsafe legacy +state is left untouched. WebKit explicitly uses its persistent default data +store. `headless profile clear` closes all sessions, clears the native profile, +and opens a clean `default` session. Site logout remains the preferred way to +clear one account without removing unrelated login state. + ## Acceptance workflow diff --git a/apps/headless/main.swift b/apps/headless/main.swift index 52c803b..1f8d38a 100644 --- a/apps/headless/main.swift +++ b/apps/headless/main.swift @@ -17,6 +17,21 @@ import HeadlessProtocol import Security import WebKit +let normalWebsiteDataStore: WKWebsiteDataStore = { + if let rawIdentifier = ProcessInfo.processInfo.environment["HEADLESS_E2E_DATA_STORE_ID"] { + guard let identifier = UUID(uuidString: rawIdentifier) else { + fputs("headless: invalid E2E website data store identifier\n", stderr) + exit(64) + } + if #available(macOS 14.0, *) { + return WKWebsiteDataStore(forIdentifier: identifier) + } + fputs("headless: isolated E2E website data stores require macOS 14 or newer\n", stderr) + exit(69) + } + return .default() +}() + // MARK: - Passkey capability // WKWebView performs WebAuthn (passkeys via iCloud Keychain / Touch ID) only for @@ -227,6 +242,8 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate, private var lastProgress: CGFloat = 0 private var onStartPage = false private var pendingRestoredStartupURL: URL? + private var pendingAgentNavigation: WKNavigation? + private var pendingAgentNavigationFailed = false private var agentControlEnabled = false var onClose: (() -> Void)? @@ -236,6 +253,7 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate, let diagnosticsBridge = WebKitQABridge() qaBridge = diagnosticsBridge let conf = WKWebViewConfiguration() + conf.websiteDataStore = normalWebsiteDataStore conf.preferences.isElementFullscreenEnabled = true conf.mediaTypesRequiringUserActionForPlayback = [] conf.allowsAirPlayForMediaPlayback = true @@ -462,6 +480,18 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate, load(url) } + func beginAgentNavigation(to url: URL) -> Bool { + pendingRestoredStartupURL = nil + onStartPage = false + pendingAgentNavigationFailed = false + pendingAgentNavigation = webView.load(URLRequest(url: url)) + return pendingAgentNavigation != nil + } + + func agentNavigationStatus() -> (pending: Bool, failed: Bool) { + (pendingAgentNavigation != nil, pendingAgentNavigationFailed) + } + private func load(_ url: URL) { onStartPage = false if url.isFileURL { @@ -678,6 +708,7 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate, } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + completeAgentNavigation(navigation, failed: false) if let job = snapJob { snapJob = nil runSnapJob(job) @@ -690,13 +721,21 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate, } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + completeAgentNavigation(navigation, failed: true) handleLoadError(error) } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + completeAgentNavigation(navigation, failed: true) handleLoadError(error) } + private func completeAgentNavigation(_ navigation: WKNavigation?, failed: Bool) { + guard let navigation, pendingAgentNavigation === navigation else { return } + pendingAgentNavigation = nil + pendingAgentNavigationFailed = failed + } + private func handleLoadError(_ error: Error) { let e = error as NSError // Ignore cancelled loads and "frame load interrupted" (downloads, redirects).