Skip to content
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions apps/headless/Host/AgentBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
) {
Expand All @@ -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() }

Expand All @@ -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 {
Expand Down
86 changes: 78 additions & 8 deletions apps/headless/LinuxHost/BrowserProcess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -203,26 +203,90 @@ 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
}

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() {
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand Down
6 changes: 6 additions & 0 deletions apps/headless/LinuxHost/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
68 changes: 68 additions & 0 deletions apps/headless/Sources/HeadlessProtocol/Authentication.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}

Expand Down Expand Up @@ -227,6 +292,7 @@ public enum AuthenticationError: Error, Equatable, CustomStringConvertible {
case originChanged
case formChanged
case accountNotFound
case credentialAliasExists
case vaultUnavailable
case vaultLocked
case userPresenceUnavailable
Expand All @@ -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"
Expand All @@ -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:
Expand Down
14 changes: 10 additions & 4 deletions apps/headless/Sources/HeadlessProtocol/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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]
Expand Down
10 changes: 10 additions & 0 deletions apps/headless/Sources/HeadlessProtocol/Capabilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -81,6 +89,7 @@ public struct BrowserEngineCapabilities: Sendable {
"savedCredentialUse": .bool(engine == .webkit),
"userPresencePerSavedUse": .bool(engine == .webkit),
"automaticActionReplay": .bool(false),
"interactiveLogin": .bool(true),
]),
]),
])
Expand Down Expand Up @@ -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),
Expand Down
Loading