Files
openclaw/apps/macos/Sources/OpenClaw/CookieSyncManager.swift
Peter Steinberger 2ef92d0bbe feat: cookie sync from Mac to a remote Gateway browser profile (#123494)
* feat(browser): sync system cookies to a remote gateway profile

Add `openclaw browser cookie-sync`: decrypt allowlisted macOS Chrome-family
cookies locally and push them into a managed profile on a possibly-remote
Gateway over the existing operator channel. --watch re-syncs on cookie-DB
changes with a single Keychain prompt per session.

- New POST /cookies/set-many batch route (mirrors /cookies/set)
- Extract one canonical readSystemProfileCookies reused by import + sync
- Mandatory domain allowlist (never syncs an unrestricted cookie jar)
- Decryption stays host-local (macOS); no cookie values are logged

* feat(macos): cookie sync checkbox and configuration UI

Add an off-by-default 'Cookie sync' section (Settings > General): a toggle, an
editable domain allowlist editor, and a target-profile field, actionable only in
remote-gateway mode. CookieSyncManager supervises `openclaw browser cookie-sync
--watch` against the connected Gateway when enabled, resolving a LOCAL CLI (never
the SSH-redirect path, since decryption is host-local) and injecting gateway
URL + token/password via environment, never argv. A status row surfaces
running/stopped/error and the last sync summary.

* fix(macos): satisfy cookie sync lint gates

* chore(i18n): register cookie sync native source strings

Regenerate apps/.i18n/native-source.json baseline for the new macOS Cookie
sync settings strings (additive only). Satisfies the native:i18n:verify gate;
generated locale artifacts are refreshed separately by the locale-refresh job.
2026-08-13 23:47:26 -07:00

413 lines
16 KiB
Swift

import Darwin
import Foundation
import Observation
import OSLog
@MainActor
@Observable
final class CookieSyncManager: NSObject {
enum State: Equatable {
case stopped
case running
case error(String)
}
private struct Endpoint: Equatable {
let url: URL
let token: String?
let password: String?
}
private struct SyncIntent: Equatable {
let domains: [String]
let profile: String
let endpoint: Endpoint
}
static let shared = CookieSyncManager()
private(set) var state: State = .stopped
private(set) var lastSummary: String?
@ObservationIgnored private let logger = Logger(subsystem: "ai.openclaw", category: "cookie-sync")
@ObservationIgnored private let queue = DispatchQueue(label: "ai.openclaw.cookie-sync")
@ObservationIgnored private weak var appState: AppState?
@ObservationIgnored private var endpointState: GatewayEndpointState?
@ObservationIgnored private var endpointTask: Task<Void, Never>?
@ObservationIgnored private var reconcileTask: Task<Void, Never>?
@ObservationIgnored private var retryTask: Task<Void, Never>?
@ObservationIgnored private var process: Process?
@ObservationIgnored private var stdoutPipe: Pipe?
@ObservationIgnored private var stderrPipe: Pipe?
@ObservationIgnored private var stdoutSource: DispatchSourceRead?
@ObservationIgnored private var stderrSource: DispatchSourceRead?
@ObservationIgnored private var startupWatchdog: DispatchSourceTimer?
@ObservationIgnored private var stdoutBuffer = Data()
@ObservationIgnored private var processGeneration: UUID?
@ObservationIgnored private var runningIntent: SyncIntent?
@ObservationIgnored private var reconcileGeneration: UInt64 = 0
@ObservationIgnored private var retryAttempt = 0
@ObservationIgnored private var isStarted = false
func start(state: AppState) {
self.appState = state
guard !self.isStarted else {
self.scheduleReconcile(resetRetry: true)
return
}
self.isStarted = true
let center = NotificationCenter.default
center.addObserver(
self,
selector: #selector(self.settingsDidChange),
name: UserDefaults.didChangeNotification,
object: AppDefaults.standard)
center.addObserver(
self,
selector: #selector(self.settingsDidChange),
name: .openclawConfigDidChange,
object: nil)
center.addObserver(
self,
selector: #selector(self.cliDidChange),
name: .openclawCLIInstalled,
object: nil)
self.endpointTask = Task { [weak self] in
let states = await GatewayEndpointStore.shared.subscribe()
for await state in states {
guard !Task.isCancelled, let self else { return }
self.endpointState = state
self.scheduleReconcile(resetRetry: true)
}
}
self.scheduleReconcile(resetRetry: true)
}
func stop() {
// This singleton's notification lifetime follows its explicit start/stop lifecycle.
// swiftlint:disable:next notification_center_detachment
NotificationCenter.default.removeObserver(self)
self.endpointTask?.cancel()
self.endpointTask = nil
self.reconcileTask?.cancel()
self.reconcileTask = nil
self.retryTask?.cancel()
self.retryTask = nil
self.isStarted = false
self.stopChild(nextState: .stopped)
}
static func normalizedDomains(_ values: [String]) -> [String] {
var seen = Set<String>()
return values
.flatMap { $0.split(separator: ",", omittingEmptySubsequences: false).map(String.init) }
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty && seen.insert($0.lowercased()).inserted }
}
@objc private nonisolated func settingsDidChange(_: Notification) {
Task { @MainActor [weak self] in
self?.scheduleReconcile(resetRetry: true)
}
}
@objc private nonisolated func cliDidChange(_: Notification) {
Task { @MainActor [weak self] in
guard let self else { return }
self.stopChild(nextState: .stopped)
self.scheduleReconcile(resetRetry: true)
}
}
private func scheduleReconcile(resetRetry: Bool, delay: Duration = .milliseconds(350)) {
if resetRetry {
self.retryAttempt = 0
self.retryTask?.cancel()
self.retryTask = nil
}
self.reconcileGeneration &+= 1
let generation = self.reconcileGeneration
self.reconcileTask?.cancel()
self.reconcileTask = Task { [weak self] in
do {
try await Task.sleep(for: delay)
} catch {
return
}
guard !Task.isCancelled, let self, generation == self.reconcileGeneration else { return }
await self.reconcile(generation: generation)
}
}
private func reconcile(generation: UInt64) async {
guard let appState = self.appState,
appState.cookieSyncEnabled,
appState.connectionMode == .remote
else {
self.stopChild(nextState: .stopped)
return
}
let domains = Self.normalizedDomains(appState.cookieSyncDomains)
guard !domains.isEmpty else {
self.stopChild(nextState: .stopped)
return
}
let profile = appState.cookieSyncIntoProfile
.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty ?? "imported"
guard let endpoint = self.remoteEndpoint else {
self.stopChild(nextState: .error("no remote gateway credentials available"))
return
}
let intent = SyncIntent(domains: domains, profile: profile, endpoint: endpoint)
if self.process?.isRunning == true, self.runningIntent == intent {
return
}
self.stopChild(nextState: .stopped)
guard let executable = await self.resolveLocalOpenClawExecutable(),
!Task.isCancelled,
generation == self.reconcileGeneration
else {
if !Task.isCancelled, generation == self.reconcileGeneration {
self.state = .error("OpenClaw CLI not found locally; cookie sync needs the CLI on this Mac")
}
return
}
self.launch(executable: executable, intent: intent)
}
private var remoteEndpoint: Endpoint? {
guard case let .ready(mode, url, rawToken, rawPassword, _) = self.endpointState,
mode == .remote
else { return nil }
let token = rawToken?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let password = rawPassword?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
guard token != nil || password != nil else { return nil }
return Endpoint(url: url, token: token, password: token == nil ? password : nil)
}
private func resolveLocalOpenClawExecutable() async -> String? {
#if DEBUG
if let executable = CommandResolver.projectOpenClawExecutable() {
return executable
}
#endif
if case let .ready(location, _) = await CLIInstaller.status() {
return location
}
return CommandResolver.findExecutable(
named: "openclaw",
searchPaths: CommandResolver.preferredPaths())
}
private func launch(executable: String, intent: SyncIntent) {
let process = Process()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
let generation = UUID()
let arguments = [
"browser",
"cookie-sync",
"--watch",
"--domains",
intent.domains.joined(separator: ","),
"--into",
intent.profile,
]
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = arguments
process.standardInput = FileHandle.nullDevice
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
var environment = ProcessInfo.processInfo.environment
environment["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
environment["OPENCLAW_GATEWAY_URL"] = intent.endpoint.url.absoluteString
environment.removeValue(forKey: "OPENCLAW_GATEWAY_TOKEN")
environment.removeValue(forKey: "OPENCLAW_GATEWAY_PASSWORD")
if let token = intent.endpoint.token {
environment["OPENCLAW_GATEWAY_TOKEN"] = token
} else if let password = intent.endpoint.password {
environment["OPENCLAW_GATEWAY_PASSWORD"] = password
}
process.environment = environment
process.terminationHandler = { [weak self] process in
let terminationStatus = process.terminationStatus
Task { @MainActor [weak self] in
self?.childTerminated(generation: generation, status: terminationStatus)
}
}
self.process = process
self.stdoutPipe = stdoutPipe
self.stderrPipe = stderrPipe
self.processGeneration = generation
self.runningIntent = intent
self.stdoutBuffer.removeAll(keepingCapacity: true)
do {
try process.run()
try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForWriting.close()
self.installReadSources(stdoutPipe: stdoutPipe, stderrPipe: stderrPipe, generation: generation)
self.installStartupWatchdog(generation: generation)
self.state = .running
self.logger.info("cookie sync started for \(intent.domains.count, privacy: .public) domain(s)")
} catch {
self.logger.error("cookie sync launch failed: \(error.localizedDescription, privacy: .public)")
self.stopChild(nextState: .error("Cookie sync could not start: \(error.localizedDescription)"))
self.scheduleRetry()
}
}
private func installReadSources(stdoutPipe: Pipe, stderrPipe: Pipe, generation: UUID) {
let stdoutDescriptor = stdoutPipe.fileHandleForReading.fileDescriptor
let stdoutSource = DispatchSource.makeReadSource(fileDescriptor: stdoutDescriptor, queue: self.queue)
stdoutSource.setEventHandler { [weak self] in
let data = Self.readAvailable(fileDescriptor: stdoutDescriptor, byteCount: stdoutSource.data)
Task { @MainActor [weak self] in
self?.consumeStdout(data, generation: generation)
}
}
self.stdoutSource = stdoutSource
stdoutSource.resume()
let stderrDescriptor = stderrPipe.fileHandleForReading.fileDescriptor
let stderrSource = DispatchSource.makeReadSource(fileDescriptor: stderrDescriptor, queue: self.queue)
stderrSource.setEventHandler { [weak self] in
let data = Self.readAvailable(fileDescriptor: stderrDescriptor, byteCount: stderrSource.data)
Task { @MainActor [weak self] in
self?.consumeStderr(data, generation: generation)
}
}
self.stderrSource = stderrSource
stderrSource.resume()
}
private func installStartupWatchdog(generation: UUID) {
let timer = DispatchSource.makeTimerSource(queue: self.queue)
timer.schedule(deadline: .now() + 5)
timer.setEventHandler { [weak self] in
Task { @MainActor [weak self] in
guard let self, self.processGeneration == generation else { return }
self.startupWatchdog?.cancel()
self.startupWatchdog = nil
if self.process?.isRunning == true {
self.retryAttempt = 0
self.logger.debug("cookie sync startup watchdog passed")
}
}
}
self.startupWatchdog = timer
timer.resume()
}
private func consumeStdout(_ data: Data, generation: UUID) {
guard self.processGeneration == generation else { return }
guard !data.isEmpty else {
self.stdoutSource?.cancel()
self.stdoutSource = nil
return
}
self.stdoutBuffer.append(data)
if self.stdoutBuffer.count > 64 * 1024 {
self.stdoutBuffer.removeFirst(self.stdoutBuffer.count - 64 * 1024)
}
while let newline = self.stdoutBuffer.firstIndex(of: 0x0A) {
let lineData = self.stdoutBuffer.prefix(upTo: newline)
self.stdoutBuffer.removeSubrange(...newline)
// Preserve lossy decoding so malformed CLI bytes do not hide the rest of a status line.
// swiftlint:disable:next optional_data_string_conversion
let line = String(decoding: lineData, as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !line.isEmpty else { continue }
self.lastSummary = line
self.logger.info("cookie sync: \(line, privacy: .public)")
}
}
private func consumeStderr(_ data: Data, generation: UUID) {
guard self.processGeneration == generation else { return }
guard !data.isEmpty else {
self.stderrSource?.cancel()
self.stderrSource = nil
return
}
// Preserve lossy decoding so malformed CLI bytes do not hide useful diagnostics.
// swiftlint:disable:next optional_data_string_conversion
let message = String(decoding: data, as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
if !message.isEmpty {
self.logger.error("cookie sync stderr: \(message, privacy: .private)")
}
}
private func childTerminated(generation: UUID, status: Int32) {
guard self.processGeneration == generation else { return }
self.stopChild(nextState: .error("Cookie sync exited with status \(status)"))
self.scheduleRetry()
}
private func scheduleRetry() {
guard self.shouldBeActive else { return }
self.retryAttempt += 1
let delaySeconds = min(30, 1 << min(self.retryAttempt - 1, 5))
self.retryTask?.cancel()
self.retryTask = Task { [weak self] in
do {
try await Task.sleep(for: .seconds(delaySeconds))
} catch {
return
}
guard !Task.isCancelled, let self else { return }
self.scheduleReconcile(resetRetry: false, delay: .zero)
}
}
private var shouldBeActive: Bool {
guard let appState = self.appState else { return false }
return appState.cookieSyncEnabled &&
appState.connectionMode == .remote &&
!Self.normalizedDomains(appState.cookieSyncDomains).isEmpty
}
private func stopChild(nextState: State) {
self.startupWatchdog?.cancel()
self.startupWatchdog = nil
self.stdoutSource?.cancel()
self.stdoutSource = nil
self.stderrSource?.cancel()
self.stderrSource = nil
let process = self.process
self.process = nil
self.processGeneration = nil
self.runningIntent = nil
try? self.stdoutPipe?.fileHandleForReading.close()
try? self.stdoutPipe?.fileHandleForWriting.close()
try? self.stderrPipe?.fileHandleForReading.close()
try? self.stderrPipe?.fileHandleForWriting.close()
self.stdoutPipe = nil
self.stderrPipe = nil
self.stdoutBuffer.removeAll(keepingCapacity: false)
if process?.isRunning == true {
process?.terminate()
}
self.state = nextState
}
private nonisolated static func readAvailable(fileDescriptor: Int32, byteCount: UInt) -> Data {
let count = max(1, min(Int(byteCount), 64 * 1024))
var data = Data(count: count)
let bytesRead = data.withUnsafeMutableBytes { buffer in
Darwin.read(fileDescriptor, buffer.baseAddress, count)
}
guard bytesRead > 0 else { return Data() }
data.removeSubrange(bytesRead..<data.count)
return data
}
}