diff --git a/README.md b/README.md index 27e6ec3..f0bedf3 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ Puppeteer **499**. Full method and limits: ```sh headless start headless session create qa +headless session create private-audit --isolated headless --session qa visit localhost:3000/designers/dashboard headless --session qa inspect --context summary --task "finish onboarding" headless --session qa inspect --context outline --limit 20 @@ -184,6 +185,11 @@ No, and requires a user-entered alias. Linux vault management is available, but saved alias use fails closed until a trusted per-use confirmation surface exists. +With `--session NAME` for an isolated session, interactive saves go only to an +in-memory vault owned by that session. Private challenges list only those +ephemeral aliases. Closing the session or terminating the host erases them; +the durable normal vault is never queried. + ## 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 e00cc0c..ffb0264 100644 --- a/apps/headless/Host/AgentBridge.swift +++ b/apps/headless/Host/AgentBridge.swift @@ -476,12 +476,12 @@ final class WebKitBrowserEngine: BrowserEngine { let name = "webkit" let platform = "macos" let capabilities = BrowserEngineCapabilities.webkit - private let create: () throws -> BrowserWindowController + private let create: (Bool) throws -> BrowserWindowController private let close: (BrowserWindowController) -> Void private let stopEngine: () -> Void init( - create: @escaping () throws -> BrowserWindowController, + create: @escaping (Bool) throws -> BrowserWindowController, close: @escaping (BrowserWindowController) -> Void, stop: @escaping () -> Void = {} ) { @@ -490,7 +490,8 @@ final class WebKitBrowserEngine: BrowserEngine { self.stopEngine = stop } - func createSession() throws -> BrowserWindowController { try create() } + func createSession() throws -> BrowserWindowController { try create(false) } + func createIsolatedSession() throws -> BrowserWindowController { try create(true) } func closeSession(_ session: BrowserWindowController) { close(session) } func stop() { stopEngine() } @@ -513,6 +514,8 @@ final class WebKitBrowserEngine: BrowserEngine { } extension BrowserWindowController: BrowserEngineSession { + var hostIsolated: Bool { isIsolatedSession } + func hostEnableAgentControl() { onMain { self.enableAgentControl() } } func hostVisit(_ url: URL) throws -> JSONValue { try agentVisit(url) } func hostInspect(parameters: [String: JSONValue]) throws -> JSONValue { diff --git a/apps/headless/LinuxHost/BrowserProcess.swift b/apps/headless/LinuxHost/BrowserProcess.swift index a4fd6b3..aaeae89 100644 --- a/apps/headless/LinuxHost/BrowserProcess.swift +++ b/apps/headless/LinuxHost/BrowserProcess.swift @@ -203,19 +203,78 @@ final class ChromiumProcess { deinit { stop() } - func createSession() throws -> LinuxBrowserSession { - let response = try browserConnection.command("Target.createTarget", parameters: ["url": "about:blank"]) + func createSession(isolated: Bool = false) throws -> LinuxBrowserSession { + let browserContextID: String? + if isolated { + let context = try browserConnection.command("Target.createBrowserContext") + guard let identifier = context["browserContextId"] as? String, + !identifier.isEmpty, identifier.utf8.count <= 256 else { + throw CDPError.invalidResponse("Target.createBrowserContext did not return browserContextId") + } + browserContextID = identifier + } else { + browserContextID = nil + } + var targetParameters: [String: Any] = ["url": "about:blank"] + if let browserContextID { targetParameters["browserContextId"] = browserContextID } + let response: [String: Any] + do { + response = try browserConnection.command("Target.createTarget", parameters: targetParameters) + } catch { + if let browserContextID { + _ = try? browserConnection.command( + "Target.disposeBrowserContext", parameters: ["browserContextId": browserContextID] + ) + } + throw error + } guard let targetID = response["targetId"] as? String else { + if let browserContextID { + _ = try? browserConnection.command( + "Target.disposeBrowserContext", parameters: ["browserContextId": browserContextID] + ) + } throw CDPError.invalidResponse("Target.createTarget did not return targetId") } - let attached = try browserConnection.command("Target.attachToTarget", parameters: [ - "targetId": targetID, - "flatten": true, - ]) + let attached: [String: Any] + do { + attached = try browserConnection.command("Target.attachToTarget", parameters: [ + "targetId": targetID, + "flatten": true, + ]) + } catch { + _ = try? browserConnection.command("Target.closeTarget", parameters: ["targetId": targetID]) + if let browserContextID { + _ = try? browserConnection.command( + "Target.disposeBrowserContext", parameters: ["browserContextId": browserContextID] + ) + } + throw error + } guard let sessionID = attached["sessionId"] as? String else { + _ = try? browserConnection.command("Target.closeTarget", parameters: ["targetId": targetID]) + if let browserContextID { + _ = try? browserConnection.command( + "Target.disposeBrowserContext", parameters: ["browserContextId": browserContextID] + ) + } throw CDPError.invalidResponse("Target.attachToTarget did not return sessionId") } - let session = try LinuxBrowserSession(targetID: targetID, sessionID: sessionID, connection: browserConnection) + let session: LinuxBrowserSession + do { + session = try LinuxBrowserSession( + targetID: targetID, sessionID: sessionID, browserContextID: browserContextID, + connection: browserConnection + ) + } catch { + _ = try? browserConnection.command("Target.closeTarget", parameters: ["targetId": targetID]) + if let browserContextID { + _ = try? browserConnection.command( + "Target.disposeBrowserContext", parameters: ["browserContextId": browserContextID] + ) + } + throw error + } sessionsLock.lock(); sessionsByProtocolID[sessionID] = session; sessionsLock.unlock() return session } @@ -223,6 +282,11 @@ final class ChromiumProcess { func closeSession(_ session: LinuxBrowserSession) { sessionsLock.lock(); sessionsByProtocolID.removeValue(forKey: session.protocolSessionID); sessionsLock.unlock() _ = try? browserConnection.command("Target.closeTarget", parameters: ["targetId": session.targetID]) + if let browserContextID = session.browserContextID { + _ = try? browserConnection.command( + "Target.disposeBrowserContext", parameters: ["browserContextId": browserContextID] + ) + } } func stop() { @@ -347,6 +411,8 @@ final class LinuxBrowserSession: @unchecked Sendable { let contentType: String } let targetID: String + let browserContextID: String? + var isIsolated: Bool { browserContextID != nil } private let sessionID: String var protocolSessionID: String { sessionID } private let connection: CDPConnection @@ -363,9 +429,13 @@ final class LinuxBrowserSession: @unchecked Sendable { private let mockLock = NSLock() private var networkMocks: [NetworkMock] = [] - init(targetID: String, sessionID: String, connection: CDPConnection) throws { + init( + targetID: String, sessionID: String, browserContextID: String? = nil, + connection: CDPConnection + ) throws { self.targetID = targetID self.sessionID = sessionID + self.browserContextID = browserContextID self.connection = connection _ = try command("Page.enable") _ = try command("Runtime.enable") diff --git a/apps/headless/LinuxHost/main.swift b/apps/headless/LinuxHost/main.swift index c434faa..0b28a66 100644 --- a/apps/headless/LinuxHost/main.swift +++ b/apps/headless/LinuxHost/main.swift @@ -22,6 +22,10 @@ final class ChromiumBrowserEngine: BrowserEngine { ChromiumBrowserEngineSession(engine: self, browserSession: try browser.createSession()) } + func createIsolatedSession() throws -> ChromiumBrowserEngineSession { + ChromiumBrowserEngineSession(engine: self, browserSession: try browser.createSession(isolated: true)) + } + func closeSession(_ session: ChromiumBrowserEngineSession) { browser.closeSession(session.browserSession) } @@ -71,6 +75,8 @@ final class ChromiumBrowserEngineSession: BrowserEngineSession { self.browserSession = browserSession } + var hostIsolated: Bool { browserSession.isIsolated } + func hostVisit(_ url: URL) throws -> JSONValue { try browserSession.visit(url) } func hostInspect(parameters: [String: JSONValue]) throws -> JSONValue { try browserSession.inspect(parameters: parameters) diff --git a/apps/headless/Sources/HeadlessProtocol/Authentication.swift b/apps/headless/Sources/HeadlessProtocol/Authentication.swift index 04113a0..a369f1a 100644 --- a/apps/headless/Sources/HeadlessProtocol/Authentication.swift +++ b/apps/headless/Sources/HeadlessProtocol/Authentication.swift @@ -178,6 +178,71 @@ public struct UnavailableAuthenticationBroker: AuthenticationBroker { } } +public final class EphemeralAuthenticationBroker: @unchecked Sendable, AuthenticationBroker { + public static let maximumRecords = 100 + + private struct Record { + let origin: CredentialOrigin + let alias: CredentialAlias + let account: String + let password: AuthenticationSecret + } + + private let lock = NSLock() + private var records: [Record] = [] + + public init() {} + deinit { removeAll() } + + public func aliases(for origin: CredentialOrigin) throws -> [AuthenticationAlias] { + try lock.withLock { + try records.filter { $0.origin == origin } + .sorted { $0.alias.rawValue < $1.alias.rawValue } + .map { try AuthenticationAlias(alias: $0.alias, account: $0.account) } + } + } + + public func credential( + for origin: CredentialOrigin, alias: CredentialAlias + ) throws -> AuthenticationCredential { + try lock.withLock { + guard let record = records.first(where: { + $0.origin == origin + && $0.alias.rawValue.caseInsensitiveCompare(alias.rawValue) == .orderedSame + }) else { throw AuthenticationError.accountNotFound } + return try AuthenticationCredential( + account: record.account, + password: AuthenticationSecret(record.password.withUnsafeBytes { Array($0) }) + ) + } + } + + public func store( + _ credential: AuthenticationCredential, for origin: CredentialOrigin, alias: CredentialAlias + ) throws { + try lock.withLock { + guard records.count < Self.maximumRecords else { + throw AuthenticationError.brokerFailed("private credential limit") + } + guard !records.contains(where: { + $0.origin == origin + && $0.alias.rawValue.caseInsensitiveCompare(alias.rawValue) == .orderedSame + }) else { throw AuthenticationError.credentialAliasExists } + records.append(Record( + origin: origin, alias: alias, account: credential.account, + password: AuthenticationSecret(credential.password.withUnsafeBytes { Array($0) }) + )) + } + } + + public func removeAll() { + lock.withLock { + records.forEach { $0.password.clear() } + records.removeAll(keepingCapacity: false) + } + } +} + public struct SecureTerminalAuthenticationPrompt { public init() {} @@ -227,6 +292,7 @@ public enum AuthenticationError: Error, Equatable, CustomStringConvertible { case originChanged case formChanged case accountNotFound + case credentialAliasExists case vaultUnavailable case vaultLocked case userPresenceUnavailable @@ -242,6 +308,7 @@ public enum AuthenticationError: Error, Equatable, CustomStringConvertible { 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" @@ -259,6 +326,7 @@ public enum AuthenticationError: Error, Equatable, CustomStringConvertible { case .originChanged: return "The top-level authentication origin changed. Inspect the page again." case .formChanged: return "The authentication form changed. Inspect the page again." case .accountNotFound: return "No saved account matches that alias for this origin." + case .credentialAliasExists: return "That credential alias already exists for this origin." case .vaultUnavailable: return "An approved operating-system credential vault is unavailable." case .vaultLocked: return "The operating-system credential vault is locked." case .userPresenceUnavailable: diff --git a/apps/headless/Sources/HeadlessProtocol/CLI.swift b/apps/headless/Sources/HeadlessProtocol/CLI.swift index 69b53d6..6c16de5 100644 --- a/apps/headless/Sources/HeadlessProtocol/CLI.swift +++ b/apps/headless/Sources/HeadlessProtocol/CLI.swift @@ -227,10 +227,16 @@ public struct CLIParser { let rest = Array(arguments.dropFirst()) switch subcommand { case "create": - let name = rest.first ?? "default" - guard rest.count <= 1 else { throw CLIParseError.invalidOption(rest[1]) } + var args = rest + let isolated = removeFlag("--isolated", from: &args) + let name = args.first ?? "default" + guard args.count <= 1 else { throw CLIParseError.invalidOption(args[1]) } try validateIdentifier(name, field: "session") - return remote(.sessionCreate, parameters: ["name": .string(name)], jsonOutput: jsonOutput) + var parameters: [String: JSONValue] = ["name": .string(name)] + if isolated { parameters["isolated"] = .bool(true) } + return remote( + .sessionCreate, parameters: parameters, jsonOutput: jsonOutput + ) case "list": try requireEmpty(rest) return remote(.sessionList, jsonOutput: jsonOutput) @@ -779,7 +785,7 @@ Commands: credentials rename --origin URL --alias OLD --to NEW credentials remove --origin URL --alias NAME auth login --challenge ID --account ALIAS | auth login --interactive - session create [NAME] | session list | session close NAME + session create [NAME] [--isolated] | session list | session close NAME visit URL inspect [--context summary|outline|text|actions|full] [--task TEXT] [--within @rN] [--limit N] [--budget TOKENS] [--depth N] [--text] diff --git a/apps/headless/Sources/HeadlessProtocol/Capabilities.swift b/apps/headless/Sources/HeadlessProtocol/Capabilities.swift index 39e2b4e..c0b0b33 100644 --- a/apps/headless/Sources/HeadlessProtocol/Capabilities.swift +++ b/apps/headless/Sources/HeadlessProtocol/Capabilities.swift @@ -73,6 +73,14 @@ public struct BrowserEngineCapabilities: Sendable { "storage": .string(normalProfileStorage), "clearCommand": .string(CommandName.profileClear.rawValue), ]), + "isolatedSessions": .object([ + "supported": .bool(true), + "storage": .string("engine-native-ephemeral-context"), + "sharedAcrossSessions": .bool(false), + "normalVaultAvailable": .bool(false), + "ephemeralCredentials": .bool(true), + "destroyedOnClose": .bool(true), + ]), "authentication": .object([ "challengeCommand": .string(CommandName.authLogin.rawValue), "exactOriginAliases": .bool(true), @@ -81,6 +89,7 @@ public struct BrowserEngineCapabilities: Sendable { "savedCredentialUse": .bool(engine == .webkit), "userPresencePerSavedUse": .bool(engine == .webkit), "automaticActionReplay": .bool(false), + "interactiveLogin": .bool(true), ]), ]), ]) @@ -214,6 +223,7 @@ public let capabilitiesDocument: JSONValue = { "silentUse": .bool(false), "agentReceivesPasswords": .bool(false), "privateContextAccess": .bool(false), + "privateEphemeralCredentials": .bool(true), ]), "security": .object([ "tcpListener": .bool(false), diff --git a/apps/headless/Sources/HeadlessProtocol/HostCore.swift b/apps/headless/Sources/HeadlessProtocol/HostCore.swift index 548bec8..65774c2 100644 --- a/apps/headless/Sources/HeadlessProtocol/HostCore.swift +++ b/apps/headless/Sources/HeadlessProtocol/HostCore.swift @@ -13,6 +13,7 @@ public struct BrowserScreenshot: Sendable { /// The portable browser surface used by `HostCore`. Platform adapters keep /// WKWebView and CDP details out of the command dispatcher. public protocol BrowserEngineSession: AnyObject { + var hostIsolated: Bool { get } func hostEnableAgentControl() func hostVisit(_ url: URL) throws -> JSONValue func hostInspect(parameters: [String: JSONValue]) throws -> JSONValue @@ -52,6 +53,8 @@ public protocol BrowserEngineSession: AnyObject { } public extension BrowserEngineSession { + var hostIsolated: Bool { false } + func hostEnableAgentControl() {} func hostAuthenticationState() throws -> JSONValue { @@ -106,6 +109,7 @@ public protocol BrowserEngine: AnyObject { var platform: String { get } var capabilities: BrowserEngineCapabilities { get } func createSession() throws -> Session + func createIsolatedSession() throws -> Session func closeSession(_ session: Session) func clearProfile() throws func stop() @@ -114,6 +118,13 @@ public protocol BrowserEngine: AnyObject { } public extension BrowserEngine { + func createIsolatedSession() throws -> Session { + throw HostError( + code: .unsupportedCapability, + message: "Isolated sessions are not supported by this engine." + ) + } + func clearProfile() throws { throw HostError(code: .unsupportedCapability, message: "Profile clearing is not supported by this engine.") } @@ -134,6 +145,7 @@ public final class HostCore: @unchecked Sendable { private var trace: [String: [JSONValue]] = ["default": []] private var activeFlows: [String: [RecordedFlowStep]] = [:] private var recordings: [String: BrowserRecording] = [:] + private var privateAuthenticationBrokers: [String: EphemeralAuthenticationBroker] private var stopping = false private let traceStartedAt = ProcessInfo.processInfo.systemUptime @@ -150,6 +162,8 @@ public final class HostCore: @unchecked Sendable { self.authenticationBroker = authenticationBroker self.authenticationChallenges = authenticationChallenges self.sessions = ["default": defaultSession] + self.privateAuthenticationBrokers = defaultSession.hostIsolated + ? ["default": EphemeralAuthenticationBroker()] : [:] self.shutdownHandler = shutdownHandler } @@ -168,6 +182,7 @@ public final class HostCore: @unchecked Sendable { trace.removeValue(forKey: name) activeFlows.removeValue(forKey: name) if let recording = recordings.removeValue(forKey: name) { stopped.append(recording) } + privateAuthenticationBrokers.removeValue(forKey: name)?.removeAll() authenticationChallenges.invalidate(session: name) } return stopped @@ -187,6 +202,8 @@ public final class HostCore: @unchecked Sendable { sessions.removeAll() trace.removeAll() activeFlows.removeAll() + privateAuthenticationBrokers.values.forEach { $0.removeAll() } + privateAuthenticationBrokers.removeAll() authenticationChallenges.removeAll() return (activeRecordings, openSessions) } @@ -213,10 +230,22 @@ public final class HostCore: @unchecked Sendable { case .sessionCreate: return try createSession(request) case .sessionList: - let names = withState { sessions.keys.sorted() } + let listing = withState { () -> ([JSONValue], [JSONValue]) in + let ordered = sessions.sorted(by: { $0.key < $1.key }) + return ( + ordered.map { .string($0.key) }, + ordered.map { name, session in + .object([ + "name": .string(name), "isolated": .bool(session.hostIsolated), + ]) + } + ) + } return .success( id: request.id, - result: .object(["sessions": .array(names.map(JSONValue.string))]) + result: .object([ + "sessions": .array(listing.0), "details": .array(listing.1), + ]) ) case .sessionClose: return closeSession(request) @@ -288,6 +317,8 @@ public final class HostCore: @unchecked Sendable { sessions.removeAll() trace.removeAll() activeFlows.removeAll() + privateAuthenticationBrokers.values.forEach { $0.removeAll() } + privateAuthenticationBrokers.removeAll() authenticationChallenges.removeAll() return (activeRecordings, openSessions) } @@ -335,6 +366,7 @@ public final class HostCore: @unchecked Sendable { guard let name = request.parameters["name"]?.stringValue else { return failure(request, "MISSING_PARAMETER", "Session name is required.") } + let isolated = request.parameters["isolated"]?.boolValue ?? false do { try validateIdentifier(name, field: "session") } catch { return failure(request, "INVALID_SESSION", String(describing: error)) } let preflightRejection = withState { () -> String? in @@ -349,12 +381,15 @@ public final class HostCore: @unchecked Sendable { ? "Host is shutting down." : "Session already exists: \(name)" ) } - let created = try engine.createSession() + let created = try isolated ? engine.createIsolatedSession() : engine.createSession() let rejection = withState { () -> String? in if stopping { return "HOST_UNAVAILABLE" } if sessions[name] != nil { return "SESSION_EXISTS" } sessions[name] = created trace[name] = [] + if created.hostIsolated { + privateAuthenticationBrokers[name] = EphemeralAuthenticationBroker() + } return nil } if let rejection { @@ -366,7 +401,9 @@ public final class HostCore: @unchecked Sendable { } created.hostEnableAgentControl() record(.sessionCreate, session: name) - return .success(id: request.id, result: .object(["session": .string(name)])) + return .success(id: request.id, result: .object([ + "session": .string(name), "isolated": .bool(created.hostIsolated), + ])) } private func closeSession(_ request: CommandRequest) -> CommandResponse { @@ -377,6 +414,7 @@ public final class HostCore: @unchecked Sendable { let recording = recordings.removeValue(forKey: name) trace.removeValue(forKey: name) activeFlows.removeValue(forKey: name) + privateAuthenticationBrokers.removeValue(forKey: name)?.removeAll() authenticationChallenges.invalidate(session: name) return (session, recording) } @@ -528,10 +566,11 @@ public final class HostCore: @unchecked Sendable { let aliases: [AuthenticationAlias] let vaultAvailable: Bool let vaultStatus: String + let broker = broker(for: sessionName, session: session) do { - aliases = try authenticationBroker.aliases(for: credentialOrigin) + aliases = try broker.aliases(for: credentialOrigin) vaultAvailable = true - vaultStatus = "available" + vaultStatus = session.hostIsolated ? "private-ephemeral" : "available" } catch let error as AuthenticationError { aliases = [] vaultAvailable = false @@ -541,20 +580,29 @@ public final class HostCore: @unchecked Sendable { vaultAvailable = false vaultStatus = "VAULT_OPERATION_FAILED" } - #if os(macOS) - let credentialUseAvailable = true - let suggestion = "Ask the user to choose an account alias, then run `headless auth login --challenge ID --account ALIAS`." - #else - let credentialUseAvailable = false - let suggestion = "Saved credential use needs a trusted per-use confirmation surface on this platform." - #endif + let credentialUseAvailable: Bool + let suggestion: String + if session.hostIsolated { + credentialUseAvailable = true + suggestion = aliases.isEmpty + ? "Run `headless auth login --interactive --session \(sessionName)` to enroll an ephemeral account." + : "Choose a private account alias or log in interactively." + } else { + #if os(macOS) + credentialUseAvailable = true + suggestion = "Ask the user to choose an account alias, then run `headless auth login --challenge ID --account ALIAS`." + #else + credentialUseAvailable = false + suggestion = "Saved credential use needs a trusted per-use confirmation surface on this platform." + #endif + } let details: JSONValue = .object([ "challenge": .string(challenge.id), "origin": .string(form.origin), "detection": .string("confirmed"), "accounts": .array(aliases.map(\.publicValue)), "expiresInSeconds": .number(AuthenticationChallengeStore.lifetime), - "userPresenceRequired": .bool(true), + "userPresenceRequired": .bool(!session.hostIsolated), "credentialUseAvailable": .bool(credentialUseAvailable), "vaultAvailable": .bool(vaultAvailable), "vaultStatus": .string(vaultStatus), @@ -601,7 +649,9 @@ public final class HostCore: @unchecked Sendable { if interactive { credential = try session.hostPromptCredential(origin: credentialOrigin) } else if let alias { - credential = try authenticationBroker.credential(for: credentialOrigin, alias: alias) + credential = try broker(for: sessionName, session: session).credential( + for: credentialOrigin, alias: alias + ) } else { throw AuthenticationError.accountNotFound } @@ -669,13 +719,25 @@ public final class HostCore: @unchecked Sendable { let saveAlias = try session.hostPromptCredentialSave( origin: credentialOrigin, account: saveCandidate.account ) { - try authenticationBroker.store(saveCandidate, for: credentialOrigin, alias: saveAlias) + try broker(for: sessionName, session: session).store( + saveCandidate, for: credentialOrigin, alias: saveAlias + ) response["account"] = .string(saveAlias.rawValue) response["saved"] = .bool(true) } return .object(response) } + private func broker( + for sessionName: String, session: Engine.Session + ) -> any AuthenticationBroker { + if session.hostIsolated { + return withState({ privateAuthenticationBrokers[sessionName] }) + ?? UnavailableAuthenticationBroker() + } + return authenticationBroker + } + private func captureInfo(_ session: Engine.Session, name: String) throws -> JSONValue { let base = try session.hostCaptureInfo() guard case .object(var object) = base else { return base } diff --git a/apps/headless/Sources/HeadlessProtocol/Protocol.swift b/apps/headless/Sources/HeadlessProtocol/Protocol.swift index 3b687a7..0cf83fb 100644 --- a/apps/headless/Sources/HeadlessProtocol/Protocol.swift +++ b/apps/headless/Sources/HeadlessProtocol/Protocol.swift @@ -237,10 +237,11 @@ public struct CommandRequest: Codable, Equatable, Sendable { ) } case .sessionCreate: - try allow(["name"]) + try allow(["name", "isolated"]) if let name = try string("name", required: true, maximumBytes: 64) { try validateIdentifier(name, field: "session") } + try boolean("isolated") case .visit: try allow(["url"]) if let value = try string("url", required: true) { _ = try normalizedWebURL(value) } diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index 0db0181..c43a00e 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -229,6 +229,7 @@ private func readRawSocketLine(descriptor: Int32) throws -> Data { } private final class TestBrowserSession: BrowserEngineSession { + let hostIsolated: Bool private(set) var agentControlEnableCount = 0 var authenticationState: JSONValue = .object([ "origin": .string("http://localhost"), "detection": .string("none"), @@ -241,6 +242,10 @@ private final class TestBrowserSession: BrowserEngineSession { private(set) var credentialPromptCount = 0 private(set) var savePromptCount = 0 + init(isolated: Bool = false) { + hostIsolated = isolated + } + func hostEnableAgentControl() { agentControlEnableCount += 1 } func hostVisit(_ url: URL) throws -> JSONValue { .object(["url": .string(url.absoluteString)]) } func hostInspect(parameters: [String: JSONValue]) throws -> JSONValue { @@ -310,6 +315,8 @@ private final class TestAuthenticationBroker: @unchecked Sendable, Authenticatio let account: String let password: [UInt8] var credentialError: AuthenticationError? + private(set) var aliasLookupCount = 0 + private(set) var credentialLookupCount = 0 private(set) var storedAlias: CredentialAlias? private(set) var storedAccount: String? private(set) var storedPassword: [UInt8]? @@ -322,6 +329,7 @@ private final class TestAuthenticationBroker: @unchecked Sendable, Authenticatio } func aliases(for origin: CredentialOrigin) throws -> [AuthenticationAlias] { + aliasLookupCount += 1 guard origin == self.origin else { return [] } return [try AuthenticationAlias(alias: alias, account: account)] } @@ -329,6 +337,7 @@ private final class TestAuthenticationBroker: @unchecked Sendable, Authenticatio func credential( for origin: CredentialOrigin, alias: CredentialAlias ) throws -> AuthenticationCredential { + credentialLookupCount += 1 if let credentialError { throw credentialError } guard origin == self.origin, alias == self.alias else { throw AuthenticationError.accountNotFound @@ -414,6 +423,12 @@ private final class TestBrowserEngine: BrowserEngine { return session } + func createIsolatedSession() throws -> TestBrowserSession { + let session = TestBrowserSession(isolated: true) + createdSessions.append(session) + return session + } + func closeSession(_ session: TestBrowserSession) { closedSessions.append(session) } func stop() { stopped = true } func clearProfile() throws { profileClearCount += 1 } @@ -592,6 +607,16 @@ struct ProtocolTests { try expectThrows("profile clear should reject parameters") { try CommandRequest(command: .profileClear, parameters: ["path": .string("/tmp/profile")]).validate() } + try CommandRequest( + id: "isolated-session", command: .sessionCreate, + parameters: ["name": .string("private"), "isolated": .bool(true)] + ).validate() + try expectThrows("isolated session flag must be boolean") { + try CommandRequest( + id: "invalid-isolated-session", command: .sessionCreate, + parameters: ["name": .string("private"), "isolated": .string("true")] + ).validate() + } try CommandRequest( id: "valid-scroll", command: .scroll, parameters: ["direction": .string("down"), "amount": .number(500)] @@ -1043,6 +1068,18 @@ struct ProtocolTests { let sessionCreate = try CLIParser().parse(["session", "create", "qa"]) try expect(sessionCreate.request?.parameters["name"] == .string("qa"), "session create name should parse") + try expect( + sessionCreate.request?.parameters["isolated"] == nil, + "normal session creation should remain compatible with older hosts" + ) + let isolatedSession = try CLIParser().parse(["session", "create", "private", "--isolated"]) + try expect( + isolatedSession.request?.parameters["isolated"] == .bool(true), + "isolated session flag should parse" + ) + try expectThrows("duplicate isolated flags should be rejected") { + _ = try CLIParser().parse(["session", "create", "private", "--isolated", "--isolated"]) + } let sessionClose = try CLIParser().parse(["session", "close", "qa"]) try expect(sessionClose.request?.session == "qa", "session close target should parse") let tour = try CLIParser().parse(["tour", "--pace", "750"]) @@ -2883,6 +2920,47 @@ struct ProtocolTests { } } + static func ephemeralAuthenticationBrokerLifecycle() throws { + let broker = EphemeralAuthenticationBroker() + let origin = try CredentialOrigin(rawValue: "https://accounts.example.test") + let otherOrigin = try CredentialOrigin(rawValue: "https://other.example.test") + let alias = try CredentialAlias(rawValue: "private") + let credential = try AuthenticationCredential( + account: "private@example.test", + password: AuthenticationSecret(Array("ephemeral-secret".utf8)) + ) + try broker.store(credential, for: origin, alias: alias) + credential.password.clear() + + let aliases = try broker.aliases(for: origin) + try expect(aliases.count == 1, "ephemeral broker should list its exact-origin alias") + try expect(try broker.aliases(for: otherOrigin).isEmpty, "aliases must not cross origins") + let resolved = try broker.credential(for: origin, alias: alias) + defer { resolved.password.clear() } + try expect( + resolved.password.withUnsafeBytes { Array($0) } == Array("ephemeral-secret".utf8), + "ephemeral broker should return a copied secret" + ) + let duplicate = try AuthenticationCredential( + account: "other@example.test", + password: AuthenticationSecret(Array("other-secret".utf8)) + ) + defer { duplicate.password.clear() } + try expectSettingsErrorForAuthentication( + .credentialAliasExists, "ephemeral aliases must be case-insensitively unique" + ) { + try broker.store( + duplicate, for: origin, alias: CredentialAlias(rawValue: "PRIVATE") + ) + } + broker.removeAll() + try expectSettingsErrorForAuthentication( + .accountNotFound, "clearing an ephemeral broker must destroy its records" + ) { + _ = try broker.credential(for: origin, alias: alias) + } + } + static func hostAuthenticationOrchestration() throws { let root = "/tmp/headless-auth-core-test-\(UUID().uuidString)" defer { try? FileManager.default.removeItem(atPath: root) } @@ -3003,6 +3081,96 @@ struct ProtocolTests { try expect(broker.storedAlias?.rawValue == "interactive", "save should retain the chosen alias") try expect(broker.storedAccount == session.promptedAccount, "save should retain the entered account") try expect(broker.storedPassword == Array(session.promptedPassword.utf8), "save should retain the entered secret") + + let privateSession = TestBrowserSession(isolated: true) + privateSession.authenticationState = .object([ + "origin": .string(origin.rawValue), "detection": .string("confirmed"), + "document": .string("0123456789abcdef0123456789abcdef"), + "accountTarget": .string("@e1"), "passwordTarget": .string("@e2"), + "submitTarget": .string("@e3"), + ]) + let privateBroker = TestAuthenticationBroker( + origin: origin, alias: alias, account: "private@example.test", password: "never-read" + ) + let privateCore = HostCore( + engine: TestBrowserEngine(), + artifacts: try ArtifactStore(environment: [ + "HEADLESS_ARTIFACT_DIR": root + "-private", + ]), + defaultSession: privateSession, + authenticationBroker: privateBroker, + shutdownHandler: {} + ) + defer { + privateCore.stop() + try? FileManager.default.removeItem(atPath: root + "-private") + } + let privateBlocked = privateCore.handle(CommandRequest( + command: .inspect, parameters: ["interactive": .bool(true)] + )) + guard case .object(let privateDetails)? = privateBlocked.error?.details, + let privateChallenge = privateDetails["challenge"]?.stringValue, + case .array(let privateAccounts)? = privateDetails["accounts"] else { + throw TestFailure(description: "isolated AUTH_REQUIRED should include structured details") + } + try expect(privateAccounts.isEmpty, "isolated challenges must not list normal-vault aliases") + try expect( + privateDetails["vaultStatus"] == .string("private-ephemeral"), + "isolated challenges should disclose the ephemeral vault" + ) + try expect(privateBroker.aliasLookupCount == 0, "isolated challenges must not query the normal broker") + let privateLogin = privateCore.handle(CommandRequest( + command: .authLogin, + parameters: [ + "challenge": .string(privateChallenge), "account": .string(alias.rawValue), + ] + )) + try expect( + privateLogin.error?.code == "AUTH_ACCOUNT_NOT_FOUND", + "unknown private aliases should fail closed" + ) + try expect(privateBroker.credentialLookupCount == 0, "isolated login must not retrieve a normal secret") + try expect(privateSession.filledCredentialAccount == nil, "isolated login must not fill a credential") + + privateSession.authenticationState = .object([ + "origin": .string(origin.rawValue), "detection": .string("confirmed"), + "document": .string("abcdef0123456789abcdef0123456789"), + "accountTarget": .string("@e1"), "passwordTarget": .string("@e2"), + "submitTarget": .string("@e3"), + ]) + privateSession.authenticationStateAfterCredentialFill = .object([ + "origin": .string(origin.rawValue), "detection": .string("none"), + ]) + privateSession.saveAlias = try CredentialAlias(rawValue: "private") + let privateEnrollment = privateCore.handle(CommandRequest( + command: .authLogin, parameters: ["interactive": .bool(true)] + )) + try expect(privateEnrollment.ok, "private interactive enrollment should succeed") + try expect(privateBroker.storedAlias == nil, "private save must not reach the normal broker") + + privateSession.authenticationState = .object([ + "origin": .string(origin.rawValue), "detection": .string("confirmed"), + "document": .string("11111111111111111111111111111111"), + "accountTarget": .string("@e1"), "passwordTarget": .string("@e2"), + "submitTarget": .string("@e3"), + ]) + let privateListed = privateCore.handle(CommandRequest( + command: .inspect, parameters: ["interactive": .bool(true)] + )) + guard case .object(let listedDetails)? = privateListed.error?.details, + let listedChallenge = listedDetails["challenge"]?.stringValue, + case .array(let listedAccounts)? = listedDetails["accounts"] else { + throw TestFailure(description: "private aliases should be listed in a new challenge") + } + try expect(listedAccounts.count == 1, "private challenge should list only its ephemeral alias") + let privateAliasLogin = privateCore.handle(CommandRequest( + command: .authLogin, + parameters: [ + "challenge": .string(listedChallenge), "account": .string("private"), + ] + )) + try expect(privateAliasLogin.ok, "private alias should remain usable in its context") + try expect(privateBroker.credentialLookupCount == 0, "private alias use must not reach the normal broker") } static func sharedHostCoreDispatch() throws { @@ -3040,6 +3208,26 @@ struct ProtocolTests { try expect(created.ok, "shared session creation should succeed") try expect(engine.createdSessions.count == 2, "session creation should delegate to the engine") + let isolated = core.handle(CommandRequest( + command: .sessionCreate, + parameters: ["name": .string("private"), "isolated": .bool(true)] + )) + guard isolated.ok, case .object(let isolatedResult) = isolated.result else { + throw TestFailure(description: "isolated session creation should succeed") + } + try expect(isolatedResult["isolated"] == .bool(true), "session result should report isolation") + try expect(engine.createdSessions.count == 3, "isolated creation should delegate to the engine") + try expect(engine.createdSessions[2].hostIsolated, "engine should create an isolated session") + let listed = core.handle(CommandRequest(command: .sessionList)) + guard listed.ok, case .object(let listedResult) = listed.result, + case .array(let details)? = listedResult["details"] else { + throw TestFailure(description: "session list should include typed details") + } + try expect( + details.contains(.object(["name": .string("private"), "isolated": .bool(true)])), + "session list should identify isolated sessions" + ) + let inspected = core.handle(CommandRequest( command: .inspect, session: "secondary", parameters: ["interactive": .bool(true)] )) @@ -3069,6 +3257,9 @@ 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 == 2, "session close should delegate to the engine") + let privateClosed = core.handle(CommandRequest(command: .sessionClose, session: "private")) + try expect(privateClosed.ok, "isolated session close should succeed") + try expect(engine.closedSessions.count == 3, "isolated 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") } @@ -3149,6 +3340,7 @@ struct ProtocolTests { ("single-source contract constants", singleSourceContractConstants), ("shared host core dispatch", sharedHostCoreDispatch), ("authentication protocol and challenge lifecycle", authenticationProtocolAndChallengeLifecycle), + ("ephemeral authentication broker lifecycle", ephemeralAuthenticationBrokerLifecycle), ("host authentication orchestration", hostAuthenticationOrchestration), ("docs command reference matches help", docsCommandReferenceMatchesHelp), ] diff --git a/apps/headless/Tests/linux-e2e.sh b/apps/headless/Tests/linux-e2e.sh index 459b4b3..352c200 100755 --- a/apps/headless/Tests/linux-e2e.sh +++ b/apps/headless/Tests/linux-e2e.sh @@ -190,6 +190,49 @@ headless visit 'http://127.0.0.1:41739/auth-state/?action=check' | grep -q 'Auth headless inspect --text | grep -q 'Cookie state: missing' headless inspect --text | grep -q 'Storage state: missing' +# Isolated sessions neither inherit nor leak browser state, and closing the +# owning session destroys its context. +headless visit 'http://127.0.0.1:41739/auth-state/?action=login' >/dev/null +headless session create private-a --isolated | grep -q '"isolated":true' +headless --session private-a visit 'http://127.0.0.1:41739/auth-state/?action=check' >/dev/null +headless --session private-a inspect --text | grep -q 'Cookie state: missing' +headless --session private-a inspect --text | grep -q 'Storage state: missing' +headless --session private-a visit 'http://127.0.0.1:41739/auth-state/?action=login' >/dev/null +headless session create private-b --isolated | grep -q '"isolated":true' +headless --session private-b visit 'http://127.0.0.1:41739/auth-state/?action=check' >/dev/null +headless --session private-b inspect --text | grep -q 'Cookie state: missing' +headless --session private-b inspect --text | grep -q 'Storage state: missing' +headless session close private-a | grep -q '"closed":"private-a"' +headless session create private-a --isolated | grep -q '"isolated":true' +headless --session private-a visit 'http://127.0.0.1:41739/auth-state/?action=check' >/dev/null +headless --session private-a inspect --text | grep -q 'Cookie state: missing' +headless --session private-a inspect --text | grep -q 'Storage state: missing' +headless visit 'http://127.0.0.1:41739/auth-state/?action=check' >/dev/null +headless inspect --text | grep -q 'Cookie state: signed-in' +headless inspect --text | grep -q 'Storage state: signed-in' +headless session close private-a >/dev/null +headless session close private-b >/dev/null +headless visit 'http://127.0.0.1:41739/auth-state/?action=logout' >/dev/null +headless session create private-crash --isolated >/dev/null +headless --session private-crash visit 'http://127.0.0.1:41739/auth-state/?action=login' >/dev/null +CRASHED_HOST_PID="$(headless status | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p')" +test -n "$CRASHED_HOST_PID" +kill -9 "$CRASHED_HOST_PID" +for _ in $(seq 1 100); do + ! kill -0 "$CRASHED_HOST_PID" >/dev/null 2>&1 && break + sleep 0.05 +done +if kill -0 "$CRASHED_HOST_PID" >/dev/null 2>&1; then + echo "host did not terminate during isolated crash recovery" >&2 + exit 1 +fi +headless start >/dev/null +headless session create private-crash --isolated >/dev/null +headless --session private-crash visit 'http://127.0.0.1:41739/auth-state/?action=check' >/dev/null +headless --session private-crash inspect --text | grep -q 'Cookie state: missing' +headless --session private-crash inspect --text | grep -q 'Storage state: missing' +headless session close private-crash >/dev/null + # 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 a7454d7..9624d39 100755 --- a/apps/headless/Tests/macos-e2e.sh +++ b/apps/headless/Tests/macos-e2e.sh @@ -507,6 +507,48 @@ fi "$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' + +STEP="isolated-session-lifecycle" +"$CLI" visit "http://127.0.0.1:$PORT/auth-state?action=login" >/dev/null +"$CLI" session create private-a --isolated | grep -q '"isolated":true' +"$CLI" --session private-a visit "http://127.0.0.1:$PORT/auth-state?action=check" >/dev/null +"$CLI" --session private-a inspect --text | grep -q 'Cookie state: missing' +"$CLI" --session private-a inspect --text | grep -q 'Storage state: missing' +"$CLI" --session private-a visit "http://127.0.0.1:$PORT/auth-state?action=login" >/dev/null +"$CLI" session create private-b --isolated | grep -q '"isolated":true' +"$CLI" --session private-b visit "http://127.0.0.1:$PORT/auth-state?action=check" >/dev/null +"$CLI" --session private-b inspect --text | grep -q 'Cookie state: missing' +"$CLI" --session private-b inspect --text | grep -q 'Storage state: missing' +"$CLI" session close private-a | grep -q '"closed":"private-a"' +"$CLI" session create private-a --isolated | grep -q '"isolated":true' +"$CLI" --session private-a visit "http://127.0.0.1:$PORT/auth-state?action=check" >/dev/null +"$CLI" --session private-a inspect --text | grep -q 'Cookie state: missing' +"$CLI" --session private-a inspect --text | grep -q 'Storage state: missing' +"$CLI" visit "http://127.0.0.1:$PORT/auth-state?action=check" >/dev/null +"$CLI" inspect --text | grep -q 'Cookie state: signed-in' +"$CLI" inspect --text | grep -q 'Storage state: signed-in' +"$CLI" session close private-a >/dev/null +"$CLI" session close private-b >/dev/null +"$CLI" visit "http://127.0.0.1:$PORT/auth-state?action=logout" >/dev/null +"$CLI" session create private-crash --isolated >/dev/null +"$CLI" --session private-crash visit "http://127.0.0.1:$PORT/auth-state?action=login" >/dev/null +CRASHED_HOST_PID="$("$CLI" status | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p')" +test -n "$CRASHED_HOST_PID" +kill -9 "$CRASHED_HOST_PID" +for _ in {1..100}; do + ! kill -0 "$CRASHED_HOST_PID" >/dev/null 2>&1 && break + sleep 0.05 +done +if kill -0 "$CRASHED_HOST_PID" >/dev/null 2>&1; then + echo "host did not terminate during isolated crash recovery" >&2 + fail +fi +"$CLI" start --background >/dev/null +"$CLI" session create private-crash --isolated >/dev/null +"$CLI" --session private-crash visit "http://127.0.0.1:$PORT/auth-state?action=check" >/dev/null +"$CLI" --session private-crash inspect --text | grep -q 'Cookie state: missing' +"$CLI" --session private-crash inspect --text | grep -q 'Storage state: missing' +"$CLI" session close private-crash >/dev/null "$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 27530f1..0fc073c 100644 --- a/apps/headless/docs/COMMANDS.md +++ b/apps/headless/docs/COMMANDS.md @@ -25,7 +25,7 @@ start [--background|--foreground] | status | stop | runtime profile clear config list | config describe KEY | config get KEY config set KEY VALUE | config reset KEY -session create [NAME] | session list | session close NAME +session create [NAME] [--isolated] | session list | session close NAME capabilities ``` @@ -40,9 +40,17 @@ capabilities and `foreground` values. It takes effect on the next host start. Linux lists and describes it as unsupported, then rejects `get`, `set`, and `reset` with `UNSUPPORTED_CAPABILITY`. -- Sessions are windows (macOS) or tabs (Linux) sharing **one browser profile**. - Cookies and local storage are shared across sessions and survive host and - machine restarts. `profile clear` closes every session and permanently +- Normal sessions are windows (macOS) or tabs (Linux) sharing **one browser + profile**. Cookies and local storage are shared across normal sessions and + survive host and machine restarts. `session create NAME --isolated` instead + creates a fresh engine-native ephemeral context that shares no cookies, + storage, cache, permissions, or authentication state with the normal profile + or another isolated session. Closing it destroys that context. Isolated + sessions cannot list or use normal-vault credentials. They can enroll aliases + through `auth login --interactive`; those credentials + stay only in that isolated session's memory and are erased on close or host + termination. `profile clear` closes every session, including isolated + sessions, and permanently removes normal-profile cookies, storage, caches, and permissions. ## Settings diff --git a/apps/headless/docs/P1.md b/apps/headless/docs/P1.md index 549791a..daab986 100644 --- a/apps/headless/docs/P1.md +++ b/apps/headless/docs/P1.md @@ -3,20 +3,22 @@ P1 adds agent-owned evidence capture and page diagnostics to the P0 browser 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 normal -browser profile. -They are a way to keep several pages open at once, not an isolation boundary. -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 -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. +## Normal and isolated sessions + +Normal sessions are windows (macOS) or tabs (Linux) inside one persistent +browser profile. Cookies and localStorage are shared across normal 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 matches architecture decision §11. + +`session create NAME --isolated` creates a separate ephemeral context under +architecture decision §26. It inherits no cookies, storage, cache, permissions, +authentication state, credential aliases, or approvals from the normal profile +or another isolated session. macOS gives each one a fresh non-persistent +`WKWebsiteDataStore`; Linux gives each one an owned Chromium browser context. +Closing the session destroys the context. There is no context reuse, caller- +selected profile path, or persistence. Interactive in-memory private credential +enrollment remains deferred; normal-vault access fails explicitly. Linux stores the normal Chromium profile under `$XDG_DATA_HOME/headless` or `~/.local/share/headless`, guarded by a single-owner lock and private `0700` @@ -63,6 +65,9 @@ defaults to No, and requires a user-entered alias. The secret reaches the broker only through a bounded private pipe. Linux saved use fails with `USER_PRESENCE_UNAVAILABLE` until the host has a trusted confirmation surface; an already-unlocked Secret Service is not treated as current user presence. +In an isolated session, an approved save goes to a session-owned in-memory +vault instead of the broker. Its challenges list only ephemeral aliases, and +closing the session or host clears every secret. ## Acceptance workflow diff --git a/apps/headless/main.swift b/apps/headless/main.swift index 69fc81d..902b04a 100644 --- a/apps/headless/main.swift +++ b/apps/headless/main.swift @@ -230,6 +230,7 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate, let webView: BrowserWebView let qaBridge: WebKitQABridge + let isIsolatedSession: Bool private let progressBar = NSView() private let hud = NSVisualEffectView() private let hudField = NSTextField() @@ -248,12 +249,14 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate, var onClose: (() -> Void)? init( - url: URL?, restoredStartupURL: URL? = nil, size: NSSize?, snap: SnapJob?, isPrimary: Bool + url: URL?, restoredStartupURL: URL? = nil, size: NSSize?, snap: SnapJob?, isPrimary: Bool, + isIsolated: Bool = false ) { + isIsolatedSession = isIsolated let diagnosticsBridge = WebKitQABridge() qaBridge = diagnosticsBridge let conf = WKWebViewConfiguration() - conf.websiteDataStore = normalWebsiteDataStore + conf.websiteDataStore = isIsolated ? .nonPersistent() : normalWebsiteDataStore conf.preferences.isElementFullscreenEnabled = true conf.mediaTypesRequiringUserActionForPlayback = [] conf.allowsAirPlayForMediaPlayback = true @@ -949,12 +952,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { presentation: agentWindowPresentation ) let engine = WebKitBrowserEngine( - create: { [weak self] in + create: { [weak self] isolated in guard let self else { throw HostError(code: .operationFailed, message: "Headless host is stopping.") } return onAgentMain { - self.openWindow(url: nil, presentation: agentWindowPresentation) + self.openWindow( + url: nil, presentation: agentWindowPresentation, isIsolated: isolated + ) } }, close: { controller in onAgentMain { controller.close() } } @@ -997,14 +1002,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { size: NSSize? = nil, snap: SnapJob? = nil, isPrimary: Bool = false, - presentation: WindowPresentation = .foreground + presentation: WindowPresentation = .foreground, + isIsolated: Bool = false ) -> BrowserWindowController { let controller = BrowserWindowController( url: url, restoredStartupURL: restoredStartupURL, size: size, snap: snap, - isPrimary: isPrimary + isPrimary: isPrimary, + isIsolated: isIsolated ) controller.onClose = { [weak self, weak controller] in guard let self, let controller else { return } diff --git a/docs/roadmap/architecture-decisions.md b/docs/roadmap/architecture-decisions.md index 2cc057e..1fa0793 100644 --- a/docs/roadmap/architecture-decisions.md +++ b/docs/roadmap/architecture-decisions.md @@ -652,6 +652,42 @@ wire-protocol version bump is unnecessary because config remains local-only. --- +## 26. Isolated sessions own one ephemeral browser context + +**Decision:** `session create NAME --isolated` creates a session whose cookies, +storage, cache, permissions, and authentication state are separate from the +durable normal profile and every other isolated session. Each isolated session +owns exactly one engine context. Closing that session destroys the context; +there is no caller-selected profile path, context reuse, import, or persistence. + +macOS uses a fresh non-persistent `WKWebsiteDataStore`. Linux creates a +Chromium browser context through the existing private DevTools pipe and +disposes it after closing its only target. `profile clear` closes all sessions, +including isolated sessions, then clears only the durable normal profile and +recreates the normal `default` session. + +Private sessions cannot enumerate or retrieve normal-vault aliases, +credentials, or approvals. Interactive enrollment writes only to an in-memory +credential store owned by the isolated session. The store is exact-origin +bound and bounded, and erases every secret when the session closes, the host +stops, the profile is cleared, or crash recovery replaces the process. It does +not use Keychain, Secret Service, or the normal nonsecret index. + +**Status:** implemented 2026-09-12 as the isolation slice of +[#35](https://github.com/LockInTime/headless/issues/35). + +**Rationale:** a named session currently means another view into one durable +profile, not a privacy boundary. Engine-native ephemeral contexts provide a +clear, testable boundary without adding profile-path escape hatches or a second +host. One session per context keeps ownership and cleanup deterministic. + +**Consequences:** session creation gains one optional compatible parameter. +Capabilities report the isolation contract. Normal sessions continue sharing +the durable profile. Hover, drag, select, scoped evaluation, and response-body +inspection are not part of this decision. + +--- + ## 27. Interactive authentication keeps consent in the trusted host **Decision:** `auth login --interactive` obtains the username and password only @@ -700,6 +736,7 @@ rule that durable saved-credential retrieval needs trusted per-use presence. | 21 | Rust port of shared core, protocol layer first | In progress | 2026-08-22 | | 24 | Credential broker on the unsigned local tier | Decided | 2026-09-10 | | 25 | Typed local settings registry; security policy stays fixed | Implemented | 2026-09-12 | +| 26 | Isolated sessions own one ephemeral browser context | Implemented | 2026-09-12 | | 27 | Interactive authentication keeps consent in trusted host | Implemented | 2026-09-12 | New decisions append here with the same format. 22 and 23 are claimed by open PRs #170 and #169. diff --git a/docs/roadmap/improvements-backlog.md b/docs/roadmap/improvements-backlog.md index fa9aceb..1f6f3be 100644 --- a/docs/roadmap/improvements-backlog.md +++ b/docs/roadmap/improvements-backlog.md @@ -292,10 +292,12 @@ uses trusted CDP mouse, key, and text input. Linux E2E proves page handlers see `isTrusted`; WebKit keeps its synthetic path and the capability matrix declares the difference. -**C7. Considered-and-worth-designing (not committed):** ([#35](https://github.com/LockInTime/headless/issues/35)) hover/drag verbs; +**C7. Considered-and-worth-designing (partially committed):** ([#35](https://github.com/LockInTime/headless/issues/35)) hover/drag verbs; `select` for dropdowns; scoped `evaluate` never (see what-is-excellent §3); -per-session isolated profiles (`session create --isolated`, architecture §11); -response-body inspection stays denied (P1.md:134) unless a gated design lands. +~~per-session isolated profiles (`session create --isolated`, architecture +§26)~~ **Done:** each session owns one ephemeral native browser context and +normal-profile credentials fail closed; response-body inspection stays denied +(P1.md:134) unless a gated design lands. ## §D — CI & testing (Phase 1)