Files
openclaw/apps/macos/Sources/OpenClaw/GatewayConnection.swift
Peter Steinberger b57e7b74e6 feat: add native Skills management parity (#105814)
* feat(apps): add native skills management parity

* chore(i18n): refresh native skills inventory

* style(apps): apply canonical Swift formatting

* style(ios): wrap skills status message

* chore(i18n): refresh native skill locales

* refactor(apps): trim native skills review surface

* chore(i18n): regenerate native app resources

* chore: keep release changelog owner-only

* fix(ios): remove dead gateway and watch wrappers
2026-07-13 00:11:11 -07:00

1695 lines
64 KiB
Swift

import CryptoKit
import Foundation
import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
import OSLog
import Security
private let gatewayConnectionLogger = Logger(subsystem: "ai.openclaw", category: "gateway.connection")
private enum GatewayActivationBindingKeyStore {
private static let service = "ai.openclaw.onboarding-route-binding"
private static let account = "credential-binding-v1"
private static let byteCount = 32
static func loadOrCreate() -> SymmetricKey? {
if let data = load() {
return SymmetricKey(data: data)
}
var data = Data(count: byteCount)
let randomStatus = data.withUnsafeMutableBytes { bytes in
guard let baseAddress = bytes.baseAddress else { return errSecAllocate }
return SecRandomCopyBytes(kSecRandomDefault, self.byteCount, baseAddress)
}
guard randomStatus == errSecSuccess else { return nil }
var query = self.baseQuery
query[kSecValueData as String] = data
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(query as CFDictionary, nil)
if addStatus == errSecSuccess {
return SymmetricKey(data: data)
}
// Another process can win the first-launch create race. Only accept the
// secret after reading the Keychain item back through normal ACL checks.
if addStatus == errSecDuplicateItem, let existing = load() {
return SymmetricKey(data: existing)
}
return nil
}
private static func load() -> Data? {
var query = self.baseQuery
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data,
data.count == byteCount
else { return nil }
return data
}
private static var baseQuery: [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: false,
]
}
}
/// Single, shared Gateway websocket connection for the whole app.
///
/// This owns exactly one `GatewayChannelActor` and reuses it across all callers
/// (ControlChannel, debug actions, SwiftUI WebChat, etc.).
actor GatewayConnection {
static let shared = GatewayConnection(
endpointProvider: GatewayConnection.defaultEndpointProvider)
typealias Config = (url: URL, token: String?, password: String?)
struct EndpointSnapshot {
let config: Config
let routeAuthority: UInt64?
let deviceAuthGatewayID: String?
let revision: UInt64?
init(
config: Config,
routeAuthority: UInt64?,
deviceAuthGatewayID: String? = nil,
revision: UInt64? = nil)
{
self.config = config
self.routeAuthority = routeAuthority
self.deviceAuthGatewayID = deviceAuthGatewayID
self.revision = revision
}
}
typealias EndpointProvider = @Sendable () async throws -> EndpointSnapshot
struct Route: Equatable {
fileprivate let generation: UInt64
fileprivate let authority: UInt64?
fileprivate let url: URL
fileprivate let token: String?
fileprivate let password: String?
fileprivate let deviceAuthGatewayID: String?
let activationOwnershipFingerprint: String?
fileprivate func matches(
_ config: Config,
authority: UInt64?,
deviceAuthGatewayID: String?) -> Bool
{
self.authority == authority &&
self.url == config.url &&
self.token == config.token &&
self.password == config.password &&
self.deviceAuthGatewayID == deviceAuthGatewayID
}
}
/// One connected Gateway server, not merely an endpoint configuration.
/// A reconnect at the same URL creates a different lease.
struct ServerLease {
fileprivate let route: Route
fileprivate let socketGeneration: UInt64
fileprivate let client: GatewayChannelActor
}
struct SessionRoutingIdentity: Equatable {
let defaultAgentID: String
let contract: String
}
enum Method: String {
case agent
case status
case setHeartbeats = "set-heartbeats"
case systemEvent = "system-event"
case health
case channelsStatus = "channels.status"
case configGet = "config.get"
case configSet = "config.set"
case configPatch = "config.patch"
case configSchema = "config.schema"
case configSchemaLookup = "config.schema.lookup"
case wizardStart = "wizard.start"
case wizardNext = "wizard.next"
case wizardCancel = "wizard.cancel"
case wizardStatus = "wizard.status"
case talkConfig = "talk.config"
case talkMode = "talk.mode"
case talkSpeak = "talk.speak"
case webLoginStart = "web.login.start"
case webLoginWait = "web.login.wait"
case channelsLogout = "channels.logout"
case modelsList = "models.list"
case chatHistory = "chat.history"
case sessionsPreview = "sessions.preview"
case chatSend = "chat.send"
case skillsStatus = "skills.status"
case skillsSearch = "skills.search"
case skillsDetail = "skills.detail"
case skillsInstall = "skills.install"
case skillsUpdate = "skills.update"
case voicewakeGet = "voicewake.get"
case voicewakeSet = "voicewake.set"
case nodePairApprove = "node.pair.approve"
case nodePairReject = "node.pair.reject"
case devicePairList = "device.pair.list"
case devicePairApprove = "device.pair.approve"
case devicePairReject = "device.pair.reject"
case execApprovalResolve = "exec.approval.resolve"
case cronList = "cron.list"
case cronRuns = "cron.runs"
case cronRun = "cron.run"
case cronRemove = "cron.remove"
case cronUpdate = "cron.update"
case cronAdd = "cron.add"
case cronStatus = "cron.status"
}
private let endpointProvider: EndpointProvider
private let activationBindingKeyProvider: @Sendable () -> SymmetricKey?
private let sessionBox: WebSocketSessionBox?
private let clientShutdown: @Sendable (GatewayChannelActor) async -> Void
private let decoder = JSONDecoder()
private var client: GatewayChannelActor?
private var configuredURL: URL?
private var configuredToken: String?
private var configuredPassword: String?
private var configuredDeviceAuthGatewayID: String?
private var configuredRouteAuthority: UInt64?
private var configuredShutdownGeneration: UInt64?
private var configuredActivationBindingKey: SymmetricKey?
private var highestEndpointRevision: UInt64?
private var routeGeneration: UInt64 = 0
/// Unbound operations capture this before their first suspension. Shutdown
/// advances it so delayed config and retry work cannot recreate a route.
private var shutdownGeneration: UInt64 = 0
// Callback work keeps the physical socket epoch that decoded it. Retiring
// that epoch prevents delayed pushes from entering a replacement socket.
private var activeSocketGeneration: UInt64?
private var lastRetiredSocketGeneration: UInt64?
private var subscribers: [UUID: AsyncStream<GatewayPush>.Continuation] = [:]
private var lastSnapshot: HelloOk?
private struct LossyDecodable<Value: Decodable>: Decodable {
let value: Value?
init(from decoder: Decoder) throws {
do {
self.value = try Value(from: decoder)
} catch {
self.value = nil
}
}
}
private struct LossyCronListResponse: Decodable {
let jobs: [LossyDecodable<CronJob>]
enum CodingKeys: String, CodingKey {
case jobs
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.jobs = try container.decodeIfPresent([LossyDecodable<CronJob>].self, forKey: .jobs) ?? []
}
}
private struct LossyCronRunsResponse: Decodable {
let entries: [LossyDecodable<CronRunLogEntry>]
enum CodingKeys: String, CodingKey {
case entries
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.entries = try container.decodeIfPresent([LossyDecodable<CronRunLogEntry>].self, forKey: .entries) ?? []
}
}
init(
endpointProvider: @escaping EndpointProvider = GatewayConnection.defaultEndpointProvider,
activationBindingKeyProvider: @escaping @Sendable () -> SymmetricKey? =
GatewayConnection.defaultActivationBindingKey,
sessionBox: WebSocketSessionBox? = nil,
clientShutdown: @escaping @Sendable (GatewayChannelActor) async -> Void = { client in
await client.shutdown()
})
{
self.endpointProvider = endpointProvider
self.activationBindingKeyProvider = activationBindingKeyProvider
self.sessionBox = sessionBox
self.clientShutdown = clientShutdown
}
#if DEBUG
private static let testingActivationBindingKey = SymmetricKey(size: .bits256)
init(
configProvider: @escaping @Sendable () async throws -> Config,
activationBindingKeyProvider: @escaping @Sendable () -> SymmetricKey? = {
GatewayConnection.testingActivationBindingKey
},
sessionBox: WebSocketSessionBox? = nil,
clientShutdown: @escaping @Sendable (GatewayChannelActor) async -> Void = { client in
await client.shutdown()
})
{
self.endpointProvider = {
try await EndpointSnapshot(config: configProvider(), routeAuthority: nil)
}
self.activationBindingKeyProvider = activationBindingKeyProvider
self.sessionBox = sessionBox
self.clientShutdown = clientShutdown
}
#endif
// MARK: - Low-level request
func request(
method: String,
params: [String: AnyCodable]?,
timeoutMs: Double? = nil,
retryTransportFailures: Bool = true) async throws -> Data
{
let shutdownGeneration = shutdownGeneration
let endpoint = try await currentEndpoint()
let cfg = endpoint.config
let client = try await configure(
url: cfg.url,
token: cfg.token,
password: cfg.password,
deviceAuthGatewayID: endpoint.deviceAuthGatewayID,
routeAuthority: endpoint.routeAuthority,
shutdownGeneration: shutdownGeneration)
do {
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
if !retryTransportFailures || error is GatewayResponseError || error is GatewayDecodingError {
throw error
}
try requireCurrentShutdownGeneration(shutdownGeneration)
// Auto-recover in local mode by spawning/attaching a gateway and retrying a few times.
// Canvas interactions should "just work" even if the local gateway isn't running yet.
let mode = await MainActor.run { AppStateStore.shared.connectionMode }
try requireCurrentShutdownGeneration(shutdownGeneration)
switch mode {
case .local:
await MainActor.run { GatewayProcessManager.shared.setActive(true) }
try requireCurrentShutdownGeneration(shutdownGeneration)
var lastError: Error = error
for delayMs in [150, 400, 900] {
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
try requireCurrentShutdownGeneration(shutdownGeneration)
do {
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
try requireCurrentShutdownGeneration(shutdownGeneration)
lastError = error
}
}
let nsError = lastError as NSError
try requireCurrentShutdownGeneration(shutdownGeneration)
if nsError.domain == URLError.errorDomain,
let fallback = await GatewayEndpointStore.shared.maybeFallbackToTailnet(from: cfg.url)
{
try acceptEndpointRevision(fallback)
let fallbackConfig = fallback.config
let fallbackClient = try await configure(
url: fallbackConfig.url,
token: fallbackConfig.token,
password: fallbackConfig.password,
deviceAuthGatewayID: fallback.deviceAuthGatewayID,
routeAuthority: fallback.routeAuthority,
shutdownGeneration: shutdownGeneration)
for delayMs in [150, 400, 900] {
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
try requireCurrentShutdownGeneration(shutdownGeneration)
do {
return try await fallbackClient.request(
method: method,
params: params,
timeoutMs: timeoutMs)
} catch {
try requireCurrentShutdownGeneration(shutdownGeneration)
lastError = error
}
}
}
try requireCurrentShutdownGeneration(shutdownGeneration)
throw lastError
case .remote:
let nsError = error as NSError
guard nsError.domain == URLError.errorDomain else { throw error }
var lastError: Error = error
await RemoteTunnelManager.shared.stopAll()
try requireCurrentShutdownGeneration(shutdownGeneration)
do {
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
try requireCurrentShutdownGeneration(shutdownGeneration)
} catch {
try requireCurrentShutdownGeneration(shutdownGeneration)
lastError = error
}
for delayMs in [150, 400, 900] {
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
try requireCurrentShutdownGeneration(shutdownGeneration)
do {
let endpoint = try await currentEndpoint()
let cfg = endpoint.config
let client = try await configure(
url: cfg.url,
token: cfg.token,
password: cfg.password,
deviceAuthGatewayID: endpoint.deviceAuthGatewayID,
routeAuthority: endpoint.routeAuthority,
shutdownGeneration: shutdownGeneration)
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
try requireCurrentShutdownGeneration(shutdownGeneration)
lastError = error
}
}
try requireCurrentShutdownGeneration(shutdownGeneration)
throw lastError
case .unconfigured:
throw error
}
}
}
/// Route-bound requests never reconfigure or retry on another endpoint.
/// Durable outbox rows must remain owned by the gateway that created them.
func request(
method: String,
params: [String: AnyCodable]?,
timeoutMs: Double? = nil,
ifCurrentRoute route: Route,
distinguishPreDispatchRouteChange: Bool = false) async throws -> Data
{
let endpoint = try await currentEndpoint()
let cfg = endpoint.config
guard route.generation == self.routeGeneration,
route.matches(
cfg,
authority: endpoint.routeAuthority,
deviceAuthGatewayID: endpoint.deviceAuthGatewayID),
self.configuredURL == route.url,
self.configuredToken == route.token,
self.configuredPassword == route.password,
self.configuredDeviceAuthGatewayID == route.deviceAuthGatewayID,
self.configuredRouteAuthority == route.authority,
let client
else {
if distinguishPreDispatchRouteChange {
throw OpenClawChatTransportSendError.notDispatched
}
throw CancellationError()
}
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
}
/// Server-bound requests never reconfigure, reconnect, or cross onto a
/// replacement socket at the same endpoint.
func request(
method: String,
params: [String: AnyCodable]?,
timeoutMs: Double? = nil,
ifCurrentServerLease lease: ServerLease) async throws -> Data
{
guard await isCurrentServerLease(lease) else {
throw OpenClawChatTransportSendError.notDispatched
}
do {
return try await lease.client.request(
method: method,
params: params,
timeoutMs: timeoutMs,
ifCurrentConnectionGeneration: lease.socketGeneration)
} catch is CancellationError {
if Task.isCancelled {
throw CancellationError()
}
throw OpenClawChatTransportSendError.notDispatched
}
}
}
extension GatewayConnection {
enum WizardCancellationOutcome: Equatable {
case cancelled
case absent
case unresolved
}
/// Cancel on the socket that created the wizard, or a replacement socket on
/// the same route. Absence stays distinct so callers can reconcile a commit.
@discardableResult
func cancelWizardSession(
_ sessionID: String,
on lease: ServerLease) async -> WizardCancellationOutcome
{
let initial = await sendWizardCancellation(sessionID, on: lease)
if initial != .unresolved {
return initial
}
guard let replacement = try? await acquireServerLease(
ifSameRouteAs: lease,
timeoutMs: 5000)
else { return .unresolved }
return await self.sendWizardCancellation(sessionID, on: replacement)
}
private func sendWizardCancellation(
_ sessionID: String,
on lease: ServerLease) async -> WizardCancellationOutcome
{
do {
let data = try await lease.client.request(
method: "wizard.cancel",
params: ["sessionId": AnyCodable(sessionID)],
timeoutMs: 10000,
ifCurrentConnectionGeneration: lease.socketGeneration)
let status = try decoder.decode(WizardCancellationStatus.self, from: data)
return switch status.status {
case "cancelled": .cancelled
case "running": .unresolved
default: .absent
}
} catch {
return Self.wizardCancellationOutcome(after: error)
}
}
static func wizardCancellationOutcome(after error: Error) -> WizardCancellationOutcome {
guard let response = error as? GatewayResponseError else { return .unresolved }
let sessionIsAbsent = response.method == "wizard.cancel" &&
response.code == "INVALID_REQUEST" &&
response.message == "wizard not found"
return sessionIsAbsent ? .absent : .unresolved
}
private struct WizardCancellationStatus: Decodable {
let status: String
}
func requestRaw(
method: Method,
params: [String: AnyCodable]? = nil,
timeoutMs: Double? = nil) async throws -> Data
{
try await self.request(method: method.rawValue, params: params, timeoutMs: timeoutMs)
}
func requestRaw(
method: String,
params: [String: AnyCodable]? = nil,
timeoutMs: Double? = nil) async throws -> Data
{
try await self.request(method: method, params: params, timeoutMs: timeoutMs)
}
func requestDecoded<T: Decodable>(
method: Method,
params: [String: AnyCodable]? = nil,
timeoutMs: Double? = nil) async throws -> T
{
let data = try await requestRaw(method: method, params: params, timeoutMs: timeoutMs)
do {
return try self.decoder.decode(T.self, from: data)
} catch {
throw GatewayDecodingError(method: method.rawValue, message: error.localizedDescription)
}
}
func requestDecoded<T: Decodable>(
method: Method,
params: [String: AnyCodable]? = nil,
timeoutMs: Double? = nil,
ifCurrentRoute route: Route) async throws -> T
{
let data = try await self.request(
method: method.rawValue,
params: params,
timeoutMs: timeoutMs,
ifCurrentRoute: route,
distinguishPreDispatchRouteChange: true)
do {
return try self.decoder.decode(T.self, from: data)
} catch {
throw GatewayDecodingError(method: method.rawValue, message: error.localizedDescription)
}
}
func requestVoid(
method: Method,
params: [String: AnyCodable]? = nil,
timeoutMs: Double? = nil) async throws
{
_ = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs)
}
/// Ensure the underlying socket is configured (and replaced if config changed).
func refresh() async throws {
let shutdownGeneration = shutdownGeneration
let endpoint = try await currentEndpoint()
let cfg = endpoint.config
_ = try await self.configure(
url: cfg.url,
token: cfg.token,
password: cfg.password,
deviceAuthGatewayID: endpoint.deviceAuthGatewayID,
routeAuthority: endpoint.routeAuthority,
shutdownGeneration: shutdownGeneration)
}
func captureRoute() async -> Route? {
let shutdownGeneration = shutdownGeneration
do {
let endpoint = try await currentEndpoint()
let cfg = endpoint.config
_ = try await self.configure(
url: cfg.url,
token: cfg.token,
password: cfg.password,
deviceAuthGatewayID: endpoint.deviceAuthGatewayID,
routeAuthority: endpoint.routeAuthority,
shutdownGeneration: shutdownGeneration)
return Route(
generation: self.routeGeneration,
authority: endpoint.routeAuthority,
url: cfg.url,
token: cfg.token,
password: cfg.password,
deviceAuthGatewayID: endpoint.deviceAuthGatewayID,
activationOwnershipFingerprint: Self.activationOwnershipFingerprint(
config: cfg,
key: self.configuredActivationBindingKey))
} catch {
return nil
}
}
/// Connect and bind subsequent work to the hello snapshot's physical
/// socket. The read-only health preflight intentionally uses the ordinary
/// recovery path so a fresh local Gateway can start and a remote tunnel can
/// recover before onboarding freezes the successful physical connection.
func acquireServerLease() async throws -> ServerLease {
try await self.acquireServerLease(timeoutMs: 15000, retryTransportFailures: true)
}
private func acquireServerLease(
timeoutMs: Double,
retryTransportFailures: Bool) async throws -> ServerLease
{
let shutdownGeneration = self.shutdownGeneration
_ = try await self.request(
method: Method.health.rawValue,
params: nil,
timeoutMs: timeoutMs,
retryTransportFailures: retryTransportFailures)
try self.requireCurrentShutdownGeneration(shutdownGeneration)
let endpoint = try await currentEndpoint()
let cfg = endpoint.config
guard let client = configuredClient(
url: cfg.url,
token: cfg.token,
password: cfg.password,
deviceAuthGatewayID: endpoint.deviceAuthGatewayID,
routeAuthority: endpoint.routeAuthority,
shutdownGeneration: shutdownGeneration)
else {
throw OpenClawChatTransportSendError.notDispatched
}
guard let socketGeneration = await client.currentConnectionGeneration() else {
throw OpenClawChatTransportSendError.notDispatched
}
guard let authBinding = await client.authBinding(
ifCurrentConnectionGeneration: socketGeneration)
else {
throw OpenClawChatTransportSendError.notDispatched
}
let lease = ServerLease(
route: Route(
generation: routeGeneration,
authority: endpoint.routeAuthority,
url: cfg.url,
token: cfg.token,
password: cfg.password,
deviceAuthGatewayID: endpoint.deviceAuthGatewayID,
activationOwnershipFingerprint: Self.activationOwnershipFingerprint(
config: cfg,
authBinding: authBinding,
key: self.configuredActivationBindingKey)),
socketGeneration: socketGeneration,
client: client)
guard await self.isCurrentServerLease(lease) else {
throw OpenClawChatTransportSendError.notDispatched
}
return lease
}
/// Reconnect one physical socket without crossing the route that owned the
/// original request. Endpoint or credential changes must start fresh work.
func acquireServerLease(
ifSameRouteAs previous: ServerLease,
timeoutMs: Double) async throws -> ServerLease
{
guard await self.isCurrentRoute(previous.route) else {
throw OpenClawChatTransportSendError.notDispatched
}
// Restart reconciliation owns its overall deadline. One short, non-retrying
// health request keeps a failed attempt from consuming the whole budget.
let replacement = try await acquireServerLease(
timeoutMs: timeoutMs,
retryTransportFailures: false)
guard replacement.route.generation == previous.route.generation,
replacement.route.url == previous.route.url,
replacement.route.token == previous.route.token,
replacement.route.password == previous.route.password,
replacement.route.deviceAuthGatewayID == previous.route.deviceAuthGatewayID
else {
throw OpenClawChatTransportSendError.notDispatched
}
return replacement
}
func isCurrentRoute(_ route: Route) async -> Bool {
guard let endpoint = try? await currentEndpoint() else { return false }
let cfg = endpoint.config
return route.generation == self.routeGeneration &&
route.matches(
cfg,
authority: endpoint.routeAuthority,
deviceAuthGatewayID: endpoint.deviceAuthGatewayID) &&
self.configuredURL == route.url &&
self.configuredToken == route.token &&
self.configuredPassword == route.password &&
self.configuredDeviceAuthGatewayID == route.deviceAuthGatewayID &&
self.configuredRouteAuthority == route.authority
}
func supportsServerCapability(
_ capability: GatewayServerCapability,
ifCurrentRoute route: Route) async -> Bool?
{
guard let endpoint = try? await currentEndpoint() else { return nil }
let cfg = endpoint.config
guard
route.generation == self.routeGeneration,
route.matches(
cfg,
authority: endpoint.routeAuthority,
deviceAuthGatewayID: endpoint.deviceAuthGatewayID),
self.configuredURL == route.url,
self.configuredToken == route.token,
self.configuredPassword == route.password,
self.configuredDeviceAuthGatewayID == route.deviceAuthGatewayID,
self.configuredRouteAuthority == route.authority,
let snapshot = lastSnapshot
else { return nil }
return snapshot.supportsServerCapability(capability)
}
func supportsServerCapability(
_ capability: GatewayServerCapability,
ifCurrentServerLease lease: ServerLease) async -> Bool?
{
guard await self.isCurrentServerLease(lease),
self.serverLeaseMatchesCurrentState(lease),
let snapshot = lastSnapshot
else { return nil }
return snapshot.supportsServerCapability(capability)
}
func isCurrentServerLease(_ lease: ServerLease) async -> Bool {
guard let endpoint = try? await currentEndpoint(),
serverLeaseMatchesCurrentState(lease),
lease.route.matches(
endpoint.config,
authority: endpoint.routeAuthority,
deviceAuthGatewayID: endpoint.deviceAuthGatewayID),
await lease.client.currentConnectionGeneration() == lease.socketGeneration,
serverLeaseMatchesCurrentState(lease)
else { return false }
return true
}
func activationOwnershipFingerprint(
ifCurrentServerLease lease: ServerLease) async -> String?
{
guard await self.isCurrentServerLease(lease) else { return nil }
return lease.route.activationOwnershipFingerprint
}
private func serverLeaseMatchesCurrentState(_ lease: ServerLease) -> Bool {
lease.route.generation == self.routeGeneration &&
lease.route.url == self.configuredURL &&
lease.route.token == self.configuredToken &&
lease.route.password == self.configuredPassword &&
lease.route.deviceAuthGatewayID == self.configuredDeviceAuthGatewayID &&
lease.route.authority == self.configuredRouteAuthority &&
self.client === lease.client &&
self.activeSocketGeneration == lease.socketGeneration &&
self.lastSnapshot != nil
}
func sessionRoutingIdentity(ifCurrentRoute route: Route) async throws -> SessionRoutingIdentity {
let data = try await request(
method: "agents.list",
params: [:],
timeoutMs: 15000,
ifCurrentRoute: route)
return try Self.decodeSessionRoutingIdentity(data)
}
static func decodeSessionRoutingIdentity(_ data: Data) throws -> SessionRoutingIdentity {
let result = try JSONDecoder().decode(AgentsListResult.self, from: data)
guard let contract = OpenClawChatSessionRoutingContract.make(
scope: result.scope.value as? String,
mainKey: result.mainkey,
defaultAgentID: result.defaultid)
else { throw CancellationError() }
return SessionRoutingIdentity(defaultAgentID: result.defaultid, contract: contract)
}
func configuredInferenceModel(
ifCurrentRoute route: Route,
timeoutMs: Double = 15000) async throws -> String?
{
let data = try await request(
method: "agents.list",
params: [:],
timeoutMs: timeoutMs,
ifCurrentRoute: route)
guard await self.isCurrentRoute(route) else {
throw CancellationError()
}
return try Self.decodeConfiguredInferenceModel(data)
}
static func decodeConfiguredInferenceModel(_ data: Data) throws -> String? {
let result = try JSONDecoder().decode(AgentsListResult.self, from: data)
let primary = result.agents
.first(where: { $0.id == result.defaultid })?
.model?["primary"]?.value as? String
let trimmed = primary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
func authSource() async -> GatewayAuthSource? {
guard let client else { return nil }
return await client.authSource()
}
func shutdown() async {
self.shutdownGeneration &+= 1
self.routeGeneration &+= 1
resetSocketGeneration()
self.lastSnapshot = nil
let client = client
self.client = nil
self.configuredURL = nil
self.configuredToken = nil
self.configuredPassword = nil
self.configuredDeviceAuthGatewayID = nil
self.configuredRouteAuthority = nil
self.configuredShutdownGeneration = nil
self.configuredActivationBindingKey = nil
if let client {
await self.clientShutdown(client)
}
}
private func configure(
url: URL,
token: String?,
password: String?,
deviceAuthGatewayID: String?,
routeAuthority: UInt64?,
shutdownGeneration: UInt64) async throws -> GatewayChannelActor
{
try self.requireCurrentShutdownGeneration(shutdownGeneration)
if let client = configuredClient(
url: url,
token: token,
password: password,
deviceAuthGatewayID: deviceAuthGatewayID,
routeAuthority: routeAuthority,
shutdownGeneration: shutdownGeneration)
{
return client
}
// Invalidate captured routes before suspension so no reentrant caller
// can continue an old-gateway outbox flush during replacement.
self.routeGeneration &+= 1
resetSocketGeneration()
self.lastSnapshot = nil
let configuredRouteGeneration = self.routeGeneration
let previousClient = client
client = nil
self.configuredURL = nil
self.configuredToken = nil
self.configuredPassword = nil
self.configuredDeviceAuthGatewayID = nil
self.configuredRouteAuthority = nil
self.configuredShutdownGeneration = nil
self.configuredActivationBindingKey = nil
if let previousClient {
await self.clientShutdown(previousClient)
}
try self.requireCurrentShutdownGeneration(shutdownGeneration)
if self.routeGeneration != configuredRouteGeneration {
if let client = configuredClient(
url: url,
token: token,
password: password,
deviceAuthGatewayID: deviceAuthGatewayID,
routeAuthority: routeAuthority,
shutdownGeneration: shutdownGeneration)
{
return client
}
throw CancellationError()
}
let activationBindingKey = self.activationBindingKeyProvider()
let client = GatewayChannelActor(
url: url,
token: token,
password: password,
authBindingKey: activationBindingKey,
session: sessionBox,
connectSnapshotAdmissionHandler: { [weak self] snapshot, socketGeneration in
await self?.admitConnectSnapshot(
snapshot,
routeGeneration: configuredRouteGeneration,
socketGeneration: socketGeneration)
},
pushHandler: { [weak self] push, socketGeneration in
await self?.handle(
push: push,
routeGeneration: configuredRouteGeneration,
socketGeneration: socketGeneration)
},
connectOptions: GatewayConnectOptions(
role: "operator",
scopes: GatewayChannelActor.defaultOperatorConnectScopes,
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-macos",
clientMode: "ui",
clientDisplayName: InstanceIdentity.displayName,
allowStoredDeviceAuth: deviceAuthGatewayID != nil,
deviceAuthGatewayID: deviceAuthGatewayID),
disconnectHandler: { [weak self] _, socketGeneration in
await self?.handleDisconnect(
routeGeneration: configuredRouteGeneration,
socketGeneration: socketGeneration)
})
self.client = client
self.configuredURL = url
self.configuredToken = token
self.configuredPassword = password
self.configuredDeviceAuthGatewayID = deviceAuthGatewayID
self.configuredRouteAuthority = routeAuthority
self.configuredShutdownGeneration = shutdownGeneration
self.configuredActivationBindingKey = activationBindingKey
return client
}
private func configuredClient(
url: URL,
token: String?,
password: String?,
deviceAuthGatewayID: String?,
routeAuthority: UInt64?,
shutdownGeneration: UInt64) -> GatewayChannelActor?
{
guard self.configuredShutdownGeneration == shutdownGeneration,
self.configuredURL == url,
self.configuredToken == token,
self.configuredPassword == password,
self.configuredDeviceAuthGatewayID == deviceAuthGatewayID,
self.configuredRouteAuthority == routeAuthority
else { return nil }
return self.client
}
private func requireCurrentShutdownGeneration(_ shutdownGeneration: UInt64) throws {
guard self.shutdownGeneration == shutdownGeneration else {
throw CancellationError()
}
}
private func handle(
push: GatewayPush,
routeGeneration: UInt64,
socketGeneration: UInt64)
{
guard routeGeneration == self.routeGeneration,
admitSocketGeneration(socketGeneration)
else { return }
broadcast(push)
}
/// Short connect-path admission only. Subscriber delivery stays on the
/// ordinary push task so connect never waits on downstream UI work.
private func admitConnectSnapshot(
_ snapshot: HelloOk,
routeGeneration: UInt64,
socketGeneration: UInt64)
{
guard routeGeneration == self.routeGeneration,
admitSocketGeneration(socketGeneration)
else { return }
self.lastSnapshot = snapshot
}
private func handleDisconnect(routeGeneration: UInt64, socketGeneration: UInt64) {
guard routeGeneration == self.routeGeneration,
retireSocketGeneration(socketGeneration)
else { return }
self.lastSnapshot = nil
}
}
extension GatewayConnection {
private func admitSocketGeneration(_ socketGeneration: UInt64) -> Bool {
if let lastRetiredSocketGeneration,
socketGeneration <= lastRetiredSocketGeneration
{
return false
}
if let activeSocketGeneration {
return socketGeneration == activeSocketGeneration
}
activeSocketGeneration = socketGeneration
return true
}
private func retireSocketGeneration(_ socketGeneration: UInt64) -> Bool {
if let lastRetiredSocketGeneration,
socketGeneration <= lastRetiredSocketGeneration
{
return false
}
if let activeSocketGeneration,
socketGeneration != activeSocketGeneration
{
return false
}
activeSocketGeneration = nil
lastRetiredSocketGeneration = socketGeneration
return true
}
private func resetSocketGeneration() {
self.activeSocketGeneration = nil
self.lastRetiredSocketGeneration = nil
}
#if DEBUG
func _test_routeGeneration() -> UInt64 {
self.routeGeneration
}
func _test_configuredURL() -> URL? {
self.configuredURL
}
func _test_handlePush(
_ push: GatewayPush,
routeGeneration: UInt64? = nil,
socketGeneration: UInt64)
{
self.handle(
push: push,
routeGeneration: routeGeneration ?? self.routeGeneration,
socketGeneration: socketGeneration)
}
func _test_handleDisconnect(
routeGeneration: UInt64? = nil,
socketGeneration: UInt64)
{
self.handleDisconnect(
routeGeneration: routeGeneration ?? self.routeGeneration,
socketGeneration: socketGeneration)
}
#endif
private static func defaultEndpointProvider() async throws -> EndpointSnapshot {
try await GatewayEndpointStore.shared.requireEndpoint()
}
private func currentEndpoint() async throws -> EndpointSnapshot {
let endpoint = try await endpointProvider()
try acceptEndpointRevision(endpoint)
return endpoint
}
private func acceptEndpointRevision(_ endpoint: EndpointSnapshot) throws {
guard let revision = endpoint.revision else { return }
if let highestEndpointRevision, revision < highestEndpointRevision {
throw CancellationError()
}
if highestEndpointRevision.map({ revision > $0 }) ?? true {
highestEndpointRevision = revision
}
}
static func defaultActivationBindingKey() -> SymmetricKey? {
GatewayActivationBindingKeyStore.loadOrCreate()
}
private static func activationOwnershipFingerprint(
config: Config,
authBinding: GatewayAuthBinding? = nil,
key: SymmetricKey?) -> String?
{
guard let key else { return nil }
// The durable record is already keyed by the stable Gateway route identity.
// Bind only auth here so an SSH tunnel's ephemeral local URL can rebind safely.
var values = [config.token ?? "", config.password ?? ""]
if authBinding?.source == .deviceToken {
guard let credentialFingerprint = authBinding?.credentialFingerprint else { return nil }
values.append(contentsOf: [GatewayAuthSource.deviceToken.rawValue, credentialFingerprint])
}
let framed = values.map { "\($0.utf8.count):\($0)" }.joined(separator: "|")
let tag = HMAC<SHA256>.authenticationCode(for: Data(framed.utf8), using: key)
return tag.map { String(format: "%02x", $0) }.joined()
}
}
// MARK: - Snapshot cache and subscriptions
extension GatewayConnection {
func canvasPluginSurfaceUrl() async -> String? {
guard let snapshot = lastSnapshot else { return nil }
let raw = snapshot.pluginsurfaceurls?["canvas"]?.value as? String
let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
func controlUiAutoAuthToken(config: Config) async -> String? {
guard let endpoint = try? await currentEndpoint(),
endpoint.config.url == config.url,
endpoint.config.token == config.token,
endpoint.config.password == config.password,
let client,
let socketGeneration = activeSocketGeneration,
controlUiRouteIsLive(
config: config,
routeAuthority: endpoint.routeAuthority,
deviceAuthGatewayID: endpoint.deviceAuthGatewayID,
client: client,
socketGeneration: socketGeneration),
await client.currentConnectionGeneration() == socketGeneration,
controlUiRouteIsLive(
config: config,
routeAuthority: endpoint.routeAuthority,
deviceAuthGatewayID: endpoint.deviceAuthGatewayID,
client: client,
socketGeneration: socketGeneration),
let authBinding = await client.authBinding(
ifCurrentConnectionGeneration: socketGeneration),
controlUiRouteIsLive(
config: config,
routeAuthority: endpoint.routeAuthority,
deviceAuthGatewayID: endpoint.deviceAuthGatewayID,
client: client,
socketGeneration: socketGeneration)
else { return nil }
switch authBinding.source {
case .sharedToken:
return config.token?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
case .deviceToken:
guard let gatewayID = configuredDeviceAuthGatewayID?
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
else { return nil }
if let deviceToken = lastSnapshot?.auth["deviceToken"]?.value as? String,
let token = deviceToken.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
{
return token
}
let identity = DeviceIdentityStore.loadOrCreate()
return DeviceAuthStore.loadToken(
deviceId: identity.deviceId,
role: "operator",
gatewayID: gatewayID)?.token
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
case .bootstrapToken, .password, .none:
return nil
}
}
private func controlUiRouteIsLive(
config: Config,
routeAuthority: UInt64?,
deviceAuthGatewayID: String?,
client: GatewayChannelActor,
socketGeneration: UInt64) -> Bool
{
self.configuredURL == config.url &&
self.configuredToken == config.token &&
self.configuredPassword == config.password &&
self.configuredRouteAuthority == routeAuthority &&
self.configuredDeviceAuthGatewayID == deviceAuthGatewayID &&
self.configuredShutdownGeneration == self.shutdownGeneration &&
self.client === client &&
self.activeSocketGeneration == socketGeneration &&
self.lastSnapshot != nil
}
private func sessionDefaultString(_ defaults: [String: OpenClawProtocol.AnyCodable]?, key: String) -> String {
let raw = defaults?[key]?.value as? String
return (raw ?? "").trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
}
func cachedMainSessionKey() -> String? {
guard let snapshot = lastSnapshot else { return nil }
let trimmed = self.sessionDefaultString(snapshot.snapshot.sessiondefaults, key: "mainSessionKey")
return trimmed.isEmpty ? nil : trimmed
}
func cachedGatewayVersion() -> String? {
guard let snapshot = lastSnapshot else { return nil }
let raw = snapshot.server["version"]?.value as? String
let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
func snapshotPaths() -> (configPath: String?, stateDir: String?) {
guard let snapshot = lastSnapshot else { return (nil, nil) }
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
let stateDir = snapshot.snapshot.statedir?.trimmingCharacters(in: .whitespacesAndNewlines)
return (
configPath?.isEmpty == false ? configPath : nil,
stateDir?.isEmpty == false ? stateDir : nil)
}
func subscribe(bufferingNewest: Int = 100) -> AsyncStream<GatewayPush> {
let id = UUID()
let snapshot = self.lastSnapshot
let connection = self
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
if let snapshot {
continuation.yield(.snapshot(snapshot))
}
self.subscribers[id] = continuation
continuation.onTermination = { @Sendable _ in
Task { await connection.removeSubscriber(id) }
}
}
}
private func removeSubscriber(_ id: UUID) {
self.subscribers[id] = nil
}
private func broadcast(_ push: GatewayPush) {
if case let .snapshot(snapshot) = push {
self.lastSnapshot = snapshot
if let mainSessionKey = cachedMainSessionKey() {
Task { @MainActor in
WorkActivityStore.shared.setMainSessionKey(mainSessionKey)
}
}
}
for (_, continuation) in self.subscribers {
continuation.yield(push)
}
}
private func canonicalizeSessionKey(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
guard !trimmed.isEmpty else { return trimmed }
guard let defaults = lastSnapshot?.snapshot.sessiondefaults else { return trimmed }
let mainSessionKey = self.sessionDefaultString(defaults, key: "mainSessionKey")
guard !mainSessionKey.isEmpty else { return trimmed }
let mainKey = self.sessionDefaultString(defaults, key: "mainKey")
let defaultAgentId = self.sessionDefaultString(defaults, key: "defaultAgentId")
let isMainAlias =
trimmed == "main" ||
(!mainKey.isEmpty && trimmed == mainKey) ||
trimmed == mainSessionKey ||
(!defaultAgentId.isEmpty &&
(trimmed == "agent:\(defaultAgentId):main" ||
(mainKey.isEmpty == false && trimmed == "agent:\(defaultAgentId):\(mainKey)")))
return isMainAlias ? mainSessionKey : trimmed
}
}
// MARK: - Typed gateway API
extension GatewayConnection {
struct ConfigGetSnapshot: Decodable {
struct SnapshotConfig: Decodable {
struct Session: Decodable {
let mainKey: String?
let scope: String?
}
let session: Session?
}
let config: SnapshotConfig?
}
static func mainSessionKey(fromConfigGetData data: Data) throws -> String {
let snapshot = try JSONDecoder().decode(ConfigGetSnapshot.self, from: data)
let scope = snapshot.config?.session?.scope?.trimmingCharacters(in: .whitespacesAndNewlines)
if scope == "global" {
return "global"
}
return "main"
}
func mainSessionKey(timeoutMs: Double = 15000) async -> String {
if let cached = cachedMainSessionKey() {
return cached
}
do {
let data = try await requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs)
return try Self.mainSessionKey(fromConfigGetData: data)
} catch {
return "main"
}
}
func status() async -> (ok: Bool, error: String?) {
do {
_ = try await self.requestRaw(method: .status)
return (true, nil)
} catch {
return (false, error.localizedDescription)
}
}
func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool {
do {
try await self.requestVoid(method: .setHeartbeats, params: ["enabled": AnyCodable(enabled)])
return true
} catch {
gatewayConnectionLogger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)")
return false
}
}
func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) {
let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return (false, "message empty") }
let sessionKey = self.canonicalizeSessionKey(invocation.sessionKey)
var params: [String: AnyCodable] = [
"message": AnyCodable(trimmed),
"sessionKey": AnyCodable(sessionKey),
"deliver": AnyCodable(invocation.deliver),
"to": AnyCodable(invocation.to ?? ""),
"channel": AnyCodable(invocation.channel.rawValue),
"idempotencyKey": AnyCodable(invocation.idempotencyKey),
]
if let thinking = invocation.thinking?.trimmingCharacters(in: .whitespacesAndNewlines),
!thinking.isEmpty
{
params["thinking"] = AnyCodable(thinking)
}
if let timeout = invocation.timeoutSeconds {
params["timeout"] = AnyCodable(timeout)
}
if let trigger = invocation.voiceWakeTrigger {
params["voiceWakeTrigger"] = AnyCodable(
trigger.trimmingCharacters(in: .whitespacesAndNewlines))
}
do {
try await self.requestVoid(method: .agent, params: params)
return (true, nil)
} catch {
return (false, error.localizedDescription)
}
}
func sendAgent(
message: String,
thinking: String?,
sessionKey: String,
deliver: Bool,
to: String?,
channel: GatewayAgentChannel = .last,
timeoutSeconds: Int? = nil,
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
{
await self.sendAgent(GatewayAgentInvocation(
message: message,
sessionKey: sessionKey,
thinking: thinking,
deliver: deliver,
to: to,
channel: channel,
timeoutSeconds: timeoutSeconds,
idempotencyKey: idempotencyKey))
}
// MARK: - Health
func healthSnapshot(timeoutMs: Double? = nil) async throws -> HealthSnapshot {
let data = try await requestRaw(method: .health, timeoutMs: timeoutMs)
if let snap = decodeHealthSnapshot(from: data) {
return snap
}
throw GatewayDecodingError(method: Method.health.rawValue, message: "failed to decode health snapshot")
}
func healthOK(timeoutMs: Int = 8000) async throws -> Bool {
let data = try await requestRaw(method: .health, timeoutMs: Double(timeoutMs))
return (try? self.decoder.decode(OpenClawGatewayHealthOK.self, from: data))?.ok ?? true
}
// MARK: - Skills
func skillsStatus() async throws -> SkillsStatusReport {
try await self.requestDecoded(method: .skillsStatus)
}
func skillsInstall(
name: String,
installId: String,
dangerouslyForceUnsafeInstall: Bool? = nil,
timeoutMs: Int? = nil) async throws -> SkillInstallResult
{
var params: [String: AnyCodable] = [
"name": AnyCodable(name),
"installId": AnyCodable(installId),
]
if let dangerouslyForceUnsafeInstall {
params["dangerouslyForceUnsafeInstall"] = AnyCodable(dangerouslyForceUnsafeInstall)
}
if let timeoutMs {
params["timeoutMs"] = AnyCodable(timeoutMs)
}
return try await self.requestDecoded(method: .skillsInstall, params: params)
}
func skillsUpdate(
skillKey: String,
enabled: Bool? = nil,
apiKey: String? = nil,
env: [String: String]? = nil) async throws -> SkillUpdateResult
{
var params: [String: AnyCodable] = [
"skillKey": AnyCodable(skillKey),
]
if let enabled {
params["enabled"] = AnyCodable(enabled)
}
if let apiKey {
params["apiKey"] = AnyCodable(apiKey)
}
if let env, !env.isEmpty {
params["env"] = AnyCodable(env)
}
return try await self.requestDecoded(method: .skillsUpdate, params: params)
}
// MARK: - Sessions
func sessionsPreview(
keys: [String],
limit: Int? = nil,
maxChars: Int? = nil,
timeoutMs: Int? = nil) async throws -> OpenClawSessionsPreviewPayload
{
let resolvedKeys = keys
.map { self.canonicalizeSessionKey($0) }
.filter { !$0.isEmpty }
if resolvedKeys.isEmpty {
return OpenClawSessionsPreviewPayload(ts: 0, previews: [])
}
var params: [String: AnyCodable] = ["keys": AnyCodable(resolvedKeys)]
if let limit {
params["limit"] = AnyCodable(limit)
}
if let maxChars {
params["maxChars"] = AnyCodable(maxChars)
}
let timeout = timeoutMs.map { Double($0) }
return try await self.requestDecoded(
method: .sessionsPreview,
params: params,
timeoutMs: timeout)
}
// MARK: - Chat
func chatHistory(
sessionKey: String,
agentID: String? = nil,
limit: Int? = nil,
maxChars: Int? = nil,
timeoutMs: Int? = nil,
ifCurrentRoute route: Route? = nil) async throws -> OpenClawChatHistoryPayload
{
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(resolvedKey)]
if let agentID = agentID?.trimmingCharacters(in: .whitespacesAndNewlines), !agentID.isEmpty {
params["agentId"] = AnyCodable(agentID)
}
if let limit {
params["limit"] = AnyCodable(limit)
}
if let maxChars {
params["maxChars"] = AnyCodable(maxChars)
}
let timeout = timeoutMs.map { Double($0) }
if let route {
let data = try await request(
method: Method.chatHistory.rawValue,
params: params,
timeoutMs: timeout,
ifCurrentRoute: route)
return try self.decoder.decode(OpenClawChatHistoryPayload.self, from: data)
}
return try await self.requestDecoded(method: .chatHistory, params: params, timeoutMs: timeout)
}
func chatSend(
sessionKey: String,
agentID: String? = nil,
expectedSessionRoutingContract: String? = nil,
message: String,
thinking: String?,
idempotencyKey: String,
attachments: [OpenClawChatAttachmentPayload],
runTimeoutMs: Int? = nil,
requestTimeoutMs: Int = 30000,
ifCurrentRoute route: Route? = nil,
distinguishPreDispatchRouteChange: Bool = false) async throws -> OpenClawChatSendResponse
{
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
var params: [String: AnyCodable] = [
"sessionKey": AnyCodable(resolvedKey),
"message": AnyCodable(message),
"idempotencyKey": AnyCodable(idempotencyKey),
]
if let runTimeoutMs {
params["timeoutMs"] = AnyCodable(runTimeoutMs)
}
if let agentID = agentID?.trimmingCharacters(in: .whitespacesAndNewlines), !agentID.isEmpty {
params["agentId"] = AnyCodable(agentID)
}
if let expectedSessionRoutingContract = expectedSessionRoutingContract?
.trimmingCharacters(in: .whitespacesAndNewlines),
!expectedSessionRoutingContract.isEmpty
{
params["expectedSessionRoutingContract"] = AnyCodable(expectedSessionRoutingContract)
}
if let thinking = thinking?.trimmingCharacters(in: .whitespacesAndNewlines),
!thinking.isEmpty
{
params["thinking"] = AnyCodable(thinking)
}
if !attachments.isEmpty {
let encoded = attachments.map { att in
[
"type": att.type,
"mimeType": att.mimeType,
"fileName": att.fileName,
"content": att.content,
]
}
params["attachments"] = AnyCodable(encoded)
}
if let route {
let data = try await request(
method: Method.chatSend.rawValue,
params: params,
timeoutMs: Double(requestTimeoutMs),
ifCurrentRoute: route,
distinguishPreDispatchRouteChange: distinguishPreDispatchRouteChange)
return try self.decoder.decode(OpenClawChatSendResponse.self, from: data)
}
return try await self.requestDecoded(
method: .chatSend,
params: params,
timeoutMs: Double(requestTimeoutMs))
}
func talkMode(enabled: Bool, phase: String? = nil) async {
var params: [String: AnyCodable] = ["enabled": AnyCodable(enabled)]
if let phase {
params["phase"] = AnyCodable(phase)
}
try? await self.requestVoid(method: .talkMode, params: params)
}
// MARK: - VoiceWake
func voiceWakeGetTriggers() async throws -> [String] {
struct VoiceWakePayload: Decodable { let triggers: [String] }
let payload: VoiceWakePayload = try await requestDecoded(method: .voicewakeGet)
return payload.triggers
}
func voiceWakeSetTriggers(_ triggers: [String]) async {
do {
try await self.requestVoid(
method: .voicewakeSet,
params: ["triggers": AnyCodable(triggers)],
timeoutMs: 10000)
} catch {
// Best-effort only.
}
}
// MARK: - Node pairing
func nodePairApprove(requestId: String) async throws {
try await self.requestVoid(
method: .nodePairApprove,
params: ["requestId": AnyCodable(requestId)],
timeoutMs: 10000)
}
func nodePairReject(requestId: String) async throws {
try await self.requestVoid(
method: .nodePairReject,
params: ["requestId": AnyCodable(requestId)],
timeoutMs: 10000)
}
// MARK: - Device pairing
func devicePairApprove(requestId: String) async throws {
try await self.requestVoid(
method: .devicePairApprove,
params: ["requestId": AnyCodable(requestId)],
timeoutMs: 10000)
}
func devicePairReject(requestId: String) async throws {
try await self.requestVoid(
method: .devicePairReject,
params: ["requestId": AnyCodable(requestId)],
timeoutMs: 10000)
}
// MARK: - Cron
struct CronSchedulerStatus: Decodable {
let enabled: Bool
let storePath: String
let sqlitePath: String?
let jobs: Int
let nextWakeAtMs: Int?
}
func cronStatus() async throws -> CronSchedulerStatus {
try await self.requestDecoded(method: .cronStatus)
}
func cronList(includeDisabled: Bool = true) async throws -> [CronJob] {
let data = try await requestRaw(
method: .cronList,
params: ["includeDisabled": AnyCodable(includeDisabled)])
return try Self.decodeCronListResponse(data)
}
func cronRuns(jobId: String, limit: Int = 200) async throws -> [CronRunLogEntry] {
let data = try await requestRaw(
method: .cronRuns,
params: ["id": AnyCodable(jobId), "limit": AnyCodable(limit)])
return try Self.decodeCronRunsResponse(data)
}
func cronRun(jobId: String, force: Bool = true) async throws {
try await self.requestVoid(
method: .cronRun,
params: [
"id": AnyCodable(jobId),
"mode": AnyCodable(force ? "force" : "due"),
],
timeoutMs: 20000)
}
func cronRemove(jobId: String) async throws {
try await self.requestVoid(method: .cronRemove, params: ["id": AnyCodable(jobId)])
}
func cronUpdate(jobId: String, patch: [String: AnyCodable]) async throws {
try await self.requestVoid(
method: .cronUpdate,
params: ["id": AnyCodable(jobId), "patch": AnyCodable(patch)])
}
func cronAdd(payload: [String: AnyCodable]) async throws {
try await self.requestVoid(method: .cronAdd, params: payload)
}
nonisolated static func decodeCronListResponse(_ data: Data) throws -> [CronJob] {
let decoded = try JSONDecoder().decode(LossyCronListResponse.self, from: data)
let jobs = decoded.jobs.compactMap(\.value)
let skipped = decoded.jobs.count - jobs.count
if skipped > 0 {
gatewayConnectionLogger.warning("cron.list skipped \(skipped, privacy: .public) malformed jobs")
}
return jobs
}
nonisolated static func decodeCronRunsResponse(_ data: Data) throws -> [CronRunLogEntry] {
let decoded = try JSONDecoder().decode(LossyCronRunsResponse.self, from: data)
let entries = decoded.entries.compactMap(\.value)
let skipped = decoded.entries.count - entries.count
if skipped > 0 {
gatewayConnectionLogger.warning("cron.runs skipped \(skipped, privacy: .public) malformed entries")
}
return entries
}
}