This commit is contained in:
Chris Herold 2026-01-29 11:15:38 -08:00 committed by GitHub
commit 77410dc0a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1438 additions and 211 deletions

View File

@ -7,8 +7,13 @@ struct ChatSheet: View {
@State private var viewModel: MoltbotChatViewModel
private let userAccent: Color?
init(gateway: GatewayNodeSession, sessionKey: String, userAccent: Color? = nil) {
let transport = IOSGatewayChatTransport(gateway: gateway)
init(
gateway: GatewayOperatorSession,
nodeSession: GatewayNodeSession,
sessionKey: String,
userAccent: Color? = nil
) {
let transport = IOSGatewayChatTransport(gateway: gateway, nodeSession: nodeSession)
self._viewModel = State(
initialValue: MoltbotChatViewModel(
sessionKey: sessionKey,

View File

@ -4,10 +4,13 @@ import MoltbotProtocol
import Foundation
struct IOSGatewayChatTransport: MoltbotChatTransport, Sendable {
private let gateway: GatewayNodeSession
private let gateway: GatewayOperatorSession
/// Node session is used for sending node.event (e.g., chat.subscribe).
private let nodeSession: GatewayNodeSession
init(gateway: GatewayNodeSession) {
init(gateway: GatewayOperatorSession, nodeSession: GatewayNodeSession) {
self.gateway = gateway
self.nodeSession = nodeSession
}
func abortRun(sessionKey: String, runId: String) async throws {
@ -36,7 +39,8 @@ struct IOSGatewayChatTransport: MoltbotChatTransport, Sendable {
struct Subscribe: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey))
let json = String(data: data, encoding: .utf8)
await self.gateway.sendEvent(event: "chat.subscribe", payloadJSON: json)
// Use node session for chat.subscribe (node.event).
await self.nodeSession.sendEvent(event: "chat.subscribe", payloadJSON: json)
}
func requestHistory(sessionKey: String) async throws -> MoltbotChatHistoryPayload {

View File

@ -205,7 +205,8 @@ final class GatewayConnectionController {
password: String?)
{
guard let appModel else { return }
let connectOptions = self.makeConnectOptions()
let operatorConnectOptions = self.makeOperatorConnectOptions()
let nodeConnectOptions = self.makeNodeConnectOptions()
Task { [weak self] in
guard let self else { return }
@ -218,7 +219,8 @@ final class GatewayConnectionController {
tls: tls,
token: token,
password: password,
connectOptions: connectOptions)
operatorConnectOptions: operatorConnectOptions,
nodeConnectOptions: nodeConnectOptions)
}
}
@ -273,10 +275,28 @@ final class GatewayConnectionController {
"manual|\(host.lowercased())|\(port)"
}
private func makeConnectOptions() -> GatewayConnectOptions {
private func makeOperatorConnectOptions() -> GatewayConnectOptions {
let defaults = UserDefaults.standard
let displayName = self.resolvedDisplayName(defaults: defaults)
return GatewayConnectOptions(
role: "operator",
scopes: self.currentScopes(),
caps: [],
commands: [],
permissions: [:],
clientId: "moltbot-ios",
clientMode: "ui",
clientDisplayName: displayName)
}
private func makeNodeConnectOptions() -> GatewayConnectOptions {
let defaults = UserDefaults.standard
let displayName = self.resolvedDisplayName(defaults: defaults)
// Node role should not request operator scopes. The gateway treats scopes
// as operator-only permissions and each role has its own device token, so
// node connections should have empty scopes.
return GatewayConnectOptions(
role: "node",
scopes: [],
@ -288,6 +308,12 @@ final class GatewayConnectionController {
clientDisplayName: displayName)
}
private func currentScopes() -> [String] {
// operator.read/write for chat operations.
// operator.pairing to receive device.pair.* events for pairing UI.
["operator.read", "operator.write", "operator.pairing"]
}
private func resolvedDisplayName(defaults: UserDefaults) -> String {
let key = "node.displayName"
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@ -324,6 +350,10 @@ final class GatewayConnectionController {
}
private func currentCommands() -> [String] {
// Only declare commands that match the gateway iOS allowlist.
// See: src/gateway/node-command-policy.ts - PLATFORM_DEFAULTS.ios
// iOS allowlist: CANVAS_COMMANDS + CAMERA_COMMANDS + SCREEN_COMMANDS + LOCATION_COMMANDS
// System commands (run, which, notify, execApprovals.*) are NOT on the iOS allowlist.
var commands: [String] = [
MoltbotCanvasCommand.present.rawValue,
MoltbotCanvasCommand.hide.rawValue,
@ -334,11 +364,6 @@ final class GatewayConnectionController {
MoltbotCanvasA2UICommand.pushJSONL.rawValue,
MoltbotCanvasA2UICommand.reset.rawValue,
MoltbotScreenCommand.record.rawValue,
MoltbotSystemCommand.notify.rawValue,
MoltbotSystemCommand.which.rawValue,
MoltbotSystemCommand.run.rawValue,
MoltbotSystemCommand.execApprovalsGet.rawValue,
MoltbotSystemCommand.execApprovalsSet.rawValue,
]
let caps = Set(self.currentCaps())

View File

@ -33,6 +33,9 @@ final class GatewayDiscoveryModel {
private var browsers: [String: NWBrowser] = [:]
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
private var resultsByDomain: [String: Set<NWBrowser.Result>] = [:]
private var resolvedTXTByID: [String: [String: String]] = [:]
private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:]
private var statesByDomain: [String: NWBrowser.State] = [:]
private var debugLoggingEnabled = false
private var lastStableIDs = Set<String>()
@ -71,34 +74,8 @@ final class GatewayDiscoveryModel {
browser.browseResultsChangedHandler = { [weak self] results, _ in
Task { @MainActor in
guard let self else { return }
self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in
switch result.endpoint {
case let .service(name, _, _, _):
let decodedName = BonjourEscapes.decode(name)
let txt = result.endpoint.txtRecord?.dictionary ?? [:]
let advertisedName = txt["displayName"]
let prettyAdvertised = advertisedName
.map(Self.prettifyInstanceName)
.flatMap { $0.isEmpty ? nil : $0 }
let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName)
return DiscoveredGateway(
name: prettyName,
endpoint: result.endpoint,
stableID: GatewayEndpointID.stableID(result.endpoint),
debugID: GatewayEndpointID.prettyDescription(result.endpoint),
lanHost: Self.txtValue(txt, key: "lanHost"),
tailnetDns: Self.txtValue(txt, key: "tailnetDns"),
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
tlsEnabled: Self.txtBoolValue(txt, key: "gatewayTls"),
tlsFingerprintSha256: Self.txtValue(txt, key: "gatewayTlsSha256"),
cliPath: Self.txtValue(txt, key: "cliPath"))
default:
return nil
}
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
self.resultsByDomain[domain] = results
self.updateGatewaysForDomain(domain: domain, results: results)
self.recomputeGateways()
}
}
@ -115,11 +92,106 @@ final class GatewayDiscoveryModel {
}
self.browsers = [:]
self.gatewaysByDomain = [:]
self.resultsByDomain = [:]
self.resolvedTXTByID = [:]
self.pendingTXTResolvers.values.forEach { $0.cancel() }
self.pendingTXTResolvers = [:]
self.statesByDomain = [:]
self.gateways = []
self.statusText = "Stopped"
}
private func updateGatewaysForAllDomains() {
for (domain, results) in self.resultsByDomain {
self.updateGatewaysForDomain(domain: domain, results: results)
}
}
private func updateGatewaysForDomain(domain: String, results: Set<NWBrowser.Result>) {
self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in
switch result.endpoint {
case let .service(name, type, domainName, _):
let decodedName = BonjourEscapes.decode(name)
let stableID = GatewayEndpointID.stableID(result.endpoint)
let txt = self.mergedTXT(for: result, stableID: stableID)
let advertisedName = txt["displayName"]
let prettyAdvertised = advertisedName
.map(Self.prettifyInstanceName)
.flatMap { $0.isEmpty ? nil : $0 }
let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName)
let lanHost = Self.txtValue(txt, key: "lanHost")
let tailnetDns = Self.txtValue(txt, key: "tailnetDns")
if lanHost == nil && tailnetDns == nil {
self.ensureTXTResolution(
stableID: stableID,
serviceName: name,
type: type,
domain: domainName)
}
return DiscoveredGateway(
name: prettyName,
endpoint: result.endpoint,
stableID: stableID,
debugID: GatewayEndpointID.prettyDescription(result.endpoint),
lanHost: lanHost,
tailnetDns: tailnetDns,
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
tlsEnabled: Self.txtBoolValue(txt, key: "gatewayTls"),
tlsFingerprintSha256: Self.txtValue(txt, key: "gatewayTlsSha256"),
cliPath: Self.txtValue(txt, key: "cliPath"))
default:
return nil
}
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
private func mergedTXT(for result: NWBrowser.Result, stableID: String) -> [String: String] {
var merged = self.resolvedTXTByID[stableID] ?? [:]
if case let .bonjour(txt) = result.metadata {
merged.merge(txt.dictionary, uniquingKeysWith: { _, new in new })
}
if let endpointTxt = result.endpoint.txtRecord?.dictionary {
merged.merge(endpointTxt, uniquingKeysWith: { _, new in new })
}
return merged
}
private func ensureTXTResolution(
stableID: String,
serviceName: String,
type: String,
domain: String)
{
guard self.resolvedTXTByID[stableID] == nil else { return }
guard self.pendingTXTResolvers[stableID] == nil else { return }
let resolver = GatewayTXTResolver(
name: serviceName,
type: type,
domain: domain)
{ [weak self] result in
Task { @MainActor in
guard let self else { return }
self.pendingTXTResolvers[stableID] = nil
switch result {
case let .success(txt):
guard !txt.isEmpty else { return }
self.resolvedTXTByID[stableID] = txt
self.appendDebugLog("resolved TXT for \(serviceName)")
self.updateGatewaysForAllDomains()
self.recomputeGateways()
case .failure:
break
}
}
}
self.pendingTXTResolvers[stableID] = resolver
resolver.start()
}
private func recomputeGateways() {
let next = self.gatewaysByDomain.values
.flatMap(\.self)
@ -222,3 +294,65 @@ final class GatewayDiscoveryModel {
return raw == "1" || raw == "true" || raw == "yes"
}
}
final class GatewayTXTResolver: NSObject, NetServiceDelegate {
private let service: NetService
private let completion: (Result<[String: String], Error>) -> Void
private var didFinish = false
init(
name: String,
type: String,
domain: String,
completion: @escaping (Result<[String: String], Error>) -> Void)
{
self.service = NetService(domain: domain, type: type, name: name)
self.completion = completion
super.init()
self.service.delegate = self
}
func start(timeout: TimeInterval = 2.0) {
self.service.schedule(in: .main, forMode: .common)
self.service.resolve(withTimeout: timeout)
}
func cancel() {
self.finish(result: .failure(GatewayTXTResolverError.cancelled))
}
func netServiceDidResolveAddress(_ sender: NetService) {
let txt = Self.decodeTXT(sender.txtRecordData())
self.finish(result: .success(txt))
}
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
self.finish(result: .failure(GatewayTXTResolverError.resolveFailed(errorDict)))
}
private func finish(result: Result<[String: String], Error>) {
guard !self.didFinish else { return }
self.didFinish = true
self.service.stop()
self.service.remove(from: .main, forMode: .common)
self.completion(result)
}
private static func decodeTXT(_ data: Data?) -> [String: String] {
guard let data else { return [:] }
let dict = NetService.dictionary(fromTXTRecord: data)
var out: [String: String] = [:]
out.reserveCapacity(dict.count)
for (key, value) in dict {
if let str = String(data: value, encoding: .utf8) {
out[key] = str
}
}
return out
}
}
enum GatewayTXTResolverError: Error {
case cancelled
case resolveFailed([String: NSNumber])
}

View File

@ -0,0 +1,163 @@
import MoltbotKit
import MoltbotProtocol
import Foundation
/// Operator-role gateway session for iOS.
///
/// Wraps `GatewayChannelActor` to provide a similar interface to `GatewayNodeSession`
/// but without invoke handling (operator role does not receive node.invoke requests).
public actor GatewayOperatorSession {
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private var channel: GatewayChannelActor?
private var activeURL: URL?
private var activeToken: String?
private var activePassword: String?
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
public init() {}
public func connect(
url: URL,
token: String?,
password: String?,
connectOptions: GatewayConnectOptions,
sessionBox: WebSocketSessionBox?,
onConnected: @escaping @Sendable () async -> Void,
onDisconnected: @escaping @Sendable (String) async -> Void
) async throws {
let shouldReconnect = self.activeURL != url ||
self.activeToken != token ||
self.activePassword != password ||
self.channel == nil
if shouldReconnect {
if let existing = self.channel {
await existing.shutdown()
}
let channel = GatewayChannelActor(
url: url,
token: token,
password: password,
session: sessionBox,
pushHandler: { [weak self] push in
await self?.handlePush(push, onConnected: onConnected)
},
connectOptions: connectOptions,
disconnectHandler: { [weak self] reason in
guard self != nil else { return }
await onDisconnected(reason)
})
self.channel = channel
self.activeURL = url
self.activeToken = token
self.activePassword = password
}
guard let channel = self.channel else {
throw NSError(domain: "Gateway", code: 0, userInfo: [
NSLocalizedDescriptionKey: "gateway channel unavailable",
])
}
do {
try await channel.connect()
// onConnected is called via pushHandler when snapshot arrives
} catch {
await onDisconnected(error.localizedDescription)
throw error
}
}
public func disconnect() async {
await self.channel?.shutdown()
self.channel = nil
self.activeURL = nil
self.activeToken = nil
self.activePassword = nil
}
public func currentRemoteAddress() -> String? {
guard let url = self.activeURL else { return nil }
guard let host = url.host else { return url.absoluteString }
let port = url.port ?? (url.scheme == "wss" ? 443 : 80)
if host.contains(":") {
return "[\(host)]:\(port)"
}
return "\(host):\(port)"
}
public func request(method: String, paramsJSON: String?, timeoutSeconds: Int = 15) async throws -> Data {
guard let channel = self.channel else {
throw NSError(domain: "Gateway", code: 11, userInfo: [
NSLocalizedDescriptionKey: "not connected",
])
}
let params = try self.decodeParamsJSON(paramsJSON)
return try await channel.request(
method: method,
params: params,
timeoutMs: Double(timeoutSeconds * 1000))
}
public func subscribeServerEvents(bufferingNewest: Int = 200) -> AsyncStream<EventFrame> {
let id = UUID()
let session = self
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
self.serverEventSubscribers[id] = continuation
continuation.onTermination = { @Sendable _ in
Task { await session.removeServerEventSubscriber(id) }
}
}
}
private func handlePush(
_ push: GatewayPush,
onConnected: @escaping @Sendable () async -> Void
) async {
switch push {
case .snapshot:
await onConnected()
case let .event(evt):
self.broadcastServerEvent(evt)
case let .seqGap(expected, received):
// Broadcast a synthetic event so subscribers can handle gaps
let gapEvent = EventFrame(
type: "evt",
event: "seqGap",
payload: MoltbotProtocol.AnyCodable(["expected": expected, "received": received]),
seq: received,
stateversion: nil)
self.broadcastServerEvent(gapEvent)
}
}
private func broadcastServerEvent(_ evt: EventFrame) {
for (id, continuation) in self.serverEventSubscribers {
if case .terminated = continuation.yield(evt) {
self.serverEventSubscribers.removeValue(forKey: id)
}
}
}
private func removeServerEventSubscriber(_ id: UUID) {
self.serverEventSubscribers.removeValue(forKey: id)
}
private func decodeParamsJSON(_ paramsJSON: String?) throws -> [String: MoltbotKit.AnyCodable]? {
guard let paramsJSON, !paramsJSON.isEmpty else { return nil }
guard let data = paramsJSON.data(using: .utf8) else {
throw NSError(domain: "Gateway", code: 12, userInfo: [
NSLocalizedDescriptionKey: "paramsJSON not UTF-8",
])
}
let raw = try JSONSerialization.jsonObject(with: data)
guard let dict = raw as? [String: Any] else {
return nil
}
return dict.reduce(into: [:]) { acc, entry in
acc[entry.key] = MoltbotKit.AnyCodable(entry.value)
}
}
}

View File

@ -1,4 +1,5 @@
import MoltbotKit
import MoltbotProtocol
import Network
import Observation
import SwiftUI
@ -14,6 +15,18 @@ final class NodeAppModel {
case error
}
enum ConnectionRole: String {
case `operator`
case node
}
enum GatewayPairingState: Equatable {
case none
case operatorPending
case nodePending
case bothPending
}
var isBackgrounded: Bool = false
let screen = ScreenController()
let camera = CameraController()
@ -23,26 +36,52 @@ final class NodeAppModel {
var gatewayRemoteAddress: String?
var connectedGatewayID: String?
var seamColorHex: String?
var mainSessionKey: String = "main"
var mainSessionKey: String = "agent:main:main"
var operatorPairingPending: Bool = false
var nodePairingPending: Bool = false
private let gateway = GatewayNodeSession()
private var gatewayTask: Task<Void, Never>?
var gatewayPairingState: GatewayPairingState {
switch (self.operatorPairingPending, self.nodePairingPending) {
case (true, true):
return .bothPending
case (true, false):
return .operatorPending
case (false, true):
return .nodePending
case (false, false):
return .none
}
}
private let gateway: GatewayOperatorSession
private let nodeSession: GatewayNodeSession
private var operatorTask: Task<Void, Never>?
private var nodeTask: Task<Void, Never>?
private var voiceWakeSyncTask: Task<Void, Never>?
private var pairingEventTask: Task<Void, Never>?
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
let voiceWake = VoiceWakeManager()
let talkMode = TalkModeManager()
private let locationService = LocationService()
private var lastAutoA2uiURL: String?
private var gatewayConnected = false
var gatewaySession: GatewayNodeSession { self.gateway }
var operatorConnected: Bool = false
var nodeConnected: Bool = false
var gatewaySession: GatewayOperatorSession { self.gateway }
var gatewayNodeSession: GatewayNodeSession { self.nodeSession }
var cameraHUDText: String?
var cameraHUDKind: CameraHUDKind?
var cameraFlashNonce: Int = 0
var screenRecordActive: Bool = false
init() {
init(
gatewaySession: GatewayOperatorSession = GatewayOperatorSession(),
nodeSession: GatewayNodeSession = GatewayNodeSession())
{
self.gateway = gatewaySession
self.nodeSession = nodeSession
self.voiceWake.configure { [weak self] cmd in
guard let self else { return }
let sessionKey = await MainActor.run { self.mainSessionKey }
@ -55,7 +94,7 @@ final class NodeAppModel {
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
self.voiceWake.setEnabled(enabled)
self.talkMode.attachGateway(self.gateway)
self.talkMode.attachGateway(self.gateway, nodeSession: self.nodeSession)
let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
self.talkMode.setEnabled(talkEnabled)
@ -151,7 +190,7 @@ final class NodeAppModel {
}
private func resolveA2UIHostURL() async -> String? {
guard let raw = await self.gateway.currentCanvasHostUrl() else { return nil }
guard let raw = await self.nodeSession.currentCanvasHostUrl() else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
return base.appendingPathComponent("__moltbot__/a2ui/").absoluteString + "?platform=ios"
@ -209,121 +248,349 @@ final class NodeAppModel {
tls: GatewayTLSParams?,
token: String?,
password: String?,
connectOptions: GatewayConnectOptions)
operatorConnectOptions: GatewayConnectOptions,
nodeConnectOptions: GatewayConnectOptions,
sessionBox: WebSocketSessionBox? = nil)
{
self.gatewayTask?.cancel()
// Cancel any existing connection tasks
self.operatorTask?.cancel()
self.nodeTask?.cancel()
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
self.pairingEventTask?.cancel()
self.pairingEventTask = nil
// Reset state
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
let id = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
self.connectedGatewayID = id.isEmpty ? url.absoluteString : id
self.gatewayConnected = false
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
let sessionBox = tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
self.operatorConnected = false
self.nodeConnected = false
self.clearPairingPending()
self.gatewayStatusText = "Connecting…"
self.gatewayTask = Task {
var attempt = 0
while !Task.isCancelled {
await MainActor.run {
if attempt == 0 {
self.gatewayStatusText = "Connecting…"
} else {
self.gatewayStatusText = "Reconnecting…"
}
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
}
// Create separate session boxes for operator and node to avoid shared websocket state.
// Each connection needs its own URLSession/TLS session to prevent response cross-talk.
func makeSessionBox() -> WebSocketSessionBox? {
if let sessionBox { return sessionBox }
if let tls { return WebSocketSessionBox(session: GatewayTLSPinningSession(params: tls)) }
return nil
}
do {
try await self.gateway.connect(
url: url,
token: token,
password: password,
connectOptions: connectOptions,
sessionBox: sessionBox,
onConnected: { [weak self] in
guard let self else { return }
// Start independent connection loops for operator and node
self.operatorTask = Task { [weak self] in
await self?.operatorConnectLoop(
url: url,
token: token,
password: password,
connectOptions: operatorConnectOptions,
sessionBox: makeSessionBox())
}
self.nodeTask = Task { [weak self] in
await self?.nodeConnectLoop(
url: url,
token: token,
password: password,
connectOptions: nodeConnectOptions,
sessionBox: makeSessionBox())
}
}
/// Independent connection loop for the operator session.
private func operatorConnectLoop(
url: URL,
token: String?,
password: String?,
connectOptions: GatewayConnectOptions,
sessionBox: WebSocketSessionBox?)
async {
var attempt = 0
while !Task.isCancelled {
do {
try await self.gateway.connect(
url: url,
token: token,
password: password,
connectOptions: connectOptions,
sessionBox: sessionBox,
onConnected: { [weak self] in
guard let self else { return }
await MainActor.run {
self.setPairingPending(for: .operator, pending: false)
self.operatorConnected = true
self.gatewayServerName = url.host ?? "gateway"
self.updateGatewayConnectionStatus()
}
if let addr = await self.gateway.currentRemoteAddress() {
await MainActor.run {
self.gatewayStatusText = "Connected"
self.gatewayServerName = url.host ?? "gateway"
self.gatewayConnected = true
self.gatewayRemoteAddress = addr
}
if let addr = await self.gateway.currentRemoteAddress() {
await MainActor.run {
self.gatewayRemoteAddress = addr
}
}
await self.refreshBrandingFromGateway()
await self.startVoiceWakeSync()
await self.showA2UIOnConnectIfNeeded()
},
onDisconnected: { [weak self] reason in
guard let self else { return }
await MainActor.run {
self.gatewayStatusText = "Disconnected"
self.gatewayRemoteAddress = nil
self.gatewayConnected = false
}
await self.refreshBrandingFromGateway()
await self.startVoiceWakeSync()
await self.startPairingEventSync()
},
onDisconnected: { [weak self] reason in
guard let self else { return }
await MainActor.run {
self.operatorConnected = false
self.gatewayRemoteAddress = nil
if !self.nodeConnected {
self.gatewayServerName = nil
self.showLocalCanvasOnDisconnect()
self.gatewayStatusText = "Disconnected: \(reason)"
}
},
onInvoke: { [weak self] req in
guard let self else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: MoltbotNodeError(
code: .unavailable,
message: "UNAVAILABLE: node not ready"))
}
return await self.handleInvoke(req)
})
self.updateGatewayConnectionStatus(reason: reason)
self.updatePairingPending(for: .operator, reason: reason)
}
})
if Task.isCancelled { break }
attempt = 0
try? await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
if Task.isCancelled { break }
attempt += 1
await MainActor.run {
self.gatewayStatusText = "Gateway error: \(error.localizedDescription)"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.gatewayConnected = false
self.showLocalCanvasOnDisconnect()
}
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
// Connection succeeded - reset attempt counter and wait before checking again
attempt = 0
try? await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
if Task.isCancelled { break }
attempt += 1
await MainActor.run {
self.updatePairingPending(for: .operator, reason: error.localizedDescription)
self.operatorConnected = false
self.updateGatewayConnectionStatus(reason: error.localizedDescription)
}
// Exponential backoff
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
}
}
await MainActor.run {
self.gatewayStatusText = "Offline"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.connectedGatewayID = nil
self.gatewayConnected = false
self.seamColorHex = nil
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = "main"
self.talkMode.updateMainSessionKey(self.mainSessionKey)
// Cleanup on task cancellation
await self.gateway.disconnect()
await MainActor.run {
self.operatorConnected = false
self.setPairingPending(for: .operator, pending: false)
self.updateGatewayConnectionStatus()
}
}
/// Independent connection loop for the node session.
private func nodeConnectLoop(
url: URL,
token: String?,
password: String?,
connectOptions: GatewayConnectOptions,
sessionBox: WebSocketSessionBox?)
async {
var attempt = 0
while !Task.isCancelled {
do {
try await self.nodeSession.connect(
url: url,
token: token,
password: password,
connectOptions: connectOptions,
sessionBox: sessionBox,
onConnected: { [weak self] in
guard let self else { return }
await MainActor.run {
self.setPairingPending(for: .node, pending: false)
self.nodeConnected = true
if self.gatewayServerName == nil {
self.gatewayServerName = url.host ?? "gateway"
}
self.updateGatewayConnectionStatus()
}
await self.showA2UIOnConnectIfNeeded()
},
onDisconnected: { [weak self] reason in
guard let self else { return }
await MainActor.run {
self.nodeConnected = false
if !self.operatorConnected {
self.gatewayServerName = nil
self.showLocalCanvasOnDisconnect()
}
self.updateGatewayConnectionStatus(reason: reason)
}
},
onInvoke: { [weak self] req in
guard let self else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: MoltbotNodeError(
code: .unavailable,
message: "UNAVAILABLE: node not ready"))
}
return await self.handleInvoke(req)
})
// Connection succeeded - reset attempt counter and wait before checking again
attempt = 0
try? await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
if Task.isCancelled { break }
attempt += 1
await MainActor.run {
self.updatePairingPending(for: .node, reason: error.localizedDescription)
self.nodeConnected = false
self.updateGatewayConnectionStatus(reason: error.localizedDescription)
}
self.showLocalCanvasOnDisconnect()
// Exponential backoff
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
}
}
// Cleanup on task cancellation
await self.nodeSession.disconnect()
await MainActor.run {
self.nodeConnected = false
self.setPairingPending(for: .node, pending: false)
self.updateGatewayConnectionStatus()
}
}
private func updateGatewayConnectionStatus(reason: String? = nil) {
let trimmedReason = (reason ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
switch (self.operatorConnected, self.nodeConnected) {
case (true, true):
self.gatewayStatusText = "Connected (operator + node)"
case (true, false):
self.gatewayStatusText = "Connected (operator only)"
case (false, true):
self.gatewayStatusText = "Connected (node only)"
case (false, false):
if trimmedReason.isEmpty {
self.gatewayStatusText = "Disconnected"
} else {
self.gatewayStatusText = "Disconnected: \(trimmedReason)"
}
}
}
private func setPairingPending(for role: ConnectionRole, pending: Bool) {
switch role {
case .operator:
self.operatorPairingPending = pending
case .node:
self.nodePairingPending = pending
}
}
private func clearPairingPending() {
self.operatorPairingPending = false
self.nodePairingPending = false
}
private func updatePairingPending(for role: ConnectionRole, reason: String) {
let trimmed = reason.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
self.setPairingPending(for: role, pending: false)
return
}
let lower = trimmed.lowercased()
let pending = lower.contains("pairing") || lower.contains("approval")
self.setPairingPending(for: role, pending: pending)
}
private func startPairingEventSync() async {
self.pairingEventTask?.cancel()
let myDeviceId = DeviceIdentityStore.loadOrCreate().deviceId
self.pairingEventTask = Task { [weak self] in
guard let self else { return }
let stream = await self.gateway.subscribeServerEvents(bufferingNewest: 200)
for await evt in stream {
if Task.isCancelled { return }
await self.handlePairingEvent(evt, myDeviceId: myDeviceId)
}
}
}
private func handlePairingEvent(_ evt: EventFrame, myDeviceId: String) async {
// Handle device.pair.requested: set pairing pending for the role
// Handle device.pair.resolved: clear pairing pending for the role
switch evt.event {
case "device.pair.requested":
guard let payload = evt.payload else { return }
struct RequestedPayload: Decodable {
var deviceId: String
var role: String?
}
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: RequestedPayload.self) else { return }
guard decoded.deviceId == myDeviceId else { return }
let role = self.parsePairingRole(decoded.role)
await MainActor.run {
self.setPairingPendingFromEvent(role: role, pending: true)
}
case "device.pair.resolved":
guard let payload = evt.payload else { return }
struct ResolvedPayload: Decodable {
var deviceId: String
var decision: String?
}
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: ResolvedPayload.self) else { return }
guard decoded.deviceId == myDeviceId else { return }
// On resolution (approved or rejected), clear the pending state.
// The role isn't always in resolved events, so clear both for this device.
await MainActor.run {
self.operatorPairingPending = false
self.nodePairingPending = false
}
default:
break
}
}
private func parsePairingRole(_ roleString: String?) -> ConnectionRole? {
guard let role = roleString?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else {
return nil
}
switch role {
case "operator": return .operator
case "node": return .node
default: return nil
}
}
private func setPairingPendingFromEvent(role: ConnectionRole?, pending: Bool) {
// If role is known, set just that role. Otherwise set both.
switch role {
case .operator:
self.operatorPairingPending = pending
case .node:
self.nodePairingPending = pending
case nil:
self.operatorPairingPending = pending
self.nodePairingPending = pending
}
}
func disconnectGateway() {
self.gatewayTask?.cancel()
self.gatewayTask = nil
self.operatorTask?.cancel()
self.operatorTask = nil
self.nodeTask?.cancel()
self.nodeTask = nil
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
Task { await self.gateway.disconnect() }
self.pairingEventTask?.cancel()
self.pairingEventTask = nil
Task {
await self.gateway.disconnect()
await self.nodeSession.disconnect()
}
self.gatewayStatusText = "Offline"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.connectedGatewayID = nil
self.gatewayConnected = false
self.operatorConnected = false
self.nodeConnected = false
self.clearPairingPending()
self.seamColorHex = nil
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = "main"
@ -445,7 +712,7 @@ final class NodeAppModel {
NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8",
])
}
await self.gateway.sendEvent(event: "voice.transcript", payloadJSON: json)
await self.nodeSession.sendEvent(event: "voice.transcript", payloadJSON: json)
}
func handleDeepLink(url: URL) async {
@ -494,11 +761,11 @@ final class NodeAppModel {
NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8",
])
}
await self.gateway.sendEvent(event: "agent.request", payloadJSON: json)
await self.nodeSession.sendEvent(event: "agent.request", payloadJSON: json)
}
private func isGatewayConnected() async -> Bool {
self.gatewayConnected
self.operatorConnected
}
private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
@ -705,7 +972,7 @@ final class NodeAppModel {
""")
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
case MoltbotCanvasA2UICommand.push.rawValue, MoltbotCanvasA2UICommand.pushJSONL.rawValue:
let messages: [AnyCodable]
let messages: [MoltbotKit.AnyCodable]
if command == MoltbotCanvasA2UICommand.pushJSONL.rawValue {
let params = try Self.decodeParams(MoltbotCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
messages = try MoltbotCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
@ -955,5 +1222,21 @@ extension NodeAppModel {
func _test_showLocalCanvasOnDisconnect() {
self.showLocalCanvasOnDisconnect()
}
func _test_setPairingPending(role: ConnectionRole, pending: Bool) {
self.setPairingPending(for: role, pending: pending)
}
func _test_clearPairingPending() {
self.clearPairingPending()
}
func _test_updatePairingPending(role: ConnectionRole, reason: String) {
self.updatePairingPending(for: role, reason: reason)
}
func _test_handlePairingEvent(_ evt: EventFrame, myDeviceId: String) async {
await self.handlePairingEvent(evt, myDeviceId: myDeviceId)
}
}
#endif

View File

@ -53,6 +53,7 @@ struct RootCanvas: View {
case .chat:
ChatSheet(
gateway: self.appModel.gatewaySession,
nodeSession: self.appModel.gatewayNodeSession,
sessionKey: self.appModel.mainSessionKey,
userAccent: self.appModel.seamColor)
}
@ -91,8 +92,39 @@ struct RootCanvas: View {
}
}
private var pairingRole: StatusPill.PairingRole? {
switch self.appModel.gatewayPairingState {
case .none:
return nil
case .operatorPending:
return .operator
case .nodePending:
return .node
case .bothPending:
return .both
}
}
private var connectionRole: StatusPill.ConnectionRole? {
switch (self.appModel.operatorConnected, self.appModel.nodeConnected) {
case (true, true):
return .both
case (true, false):
return .operatorOnly
case (false, true):
return .nodeOnly
case (false, false):
return nil
}
}
private var gatewayStatus: StatusPill.GatewayState {
if self.appModel.gatewayServerName != nil { return .connected }
if let pairingRole {
return .pairingPending(pairingRole)
}
if let connectionRole {
return .connected(connectionRole)
}
let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
if text.localizedCaseInsensitiveContains("connecting") ||

View File

@ -64,8 +64,39 @@ struct RootTabs: View {
}
}
private var pairingRole: StatusPill.PairingRole? {
switch self.appModel.gatewayPairingState {
case .none:
return nil
case .operatorPending:
return .operator
case .nodePending:
return .node
case .bothPending:
return .both
}
}
private var connectionRole: StatusPill.ConnectionRole? {
switch (self.appModel.operatorConnected, self.appModel.nodeConnected) {
case (true, true):
return .both
case (true, false):
return .operatorOnly
case (false, true):
return .nodeOnly
case (false, false):
return nil
}
}
private var gatewayStatus: StatusPill.GatewayState {
if self.appModel.gatewayServerName != nil { return .connected }
if let pairingRole {
return .pairingPending(pairingRole)
}
if let connectionRole {
return .connected(connectionRole)
}
let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
if text.localizedCaseInsensitiveContains("connecting") ||
@ -90,6 +121,10 @@ struct RootTabs: View {
tint: .orange)
}
if self.pairingRole != nil {
return nil
}
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayLower = gatewayStatus.lowercased()
if gatewayLower.contains("repair") {

View File

@ -65,8 +65,22 @@ struct SettingsTab: View {
}
Section("Gateway") {
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
LabeledContent("Discovery (Bonjour)", value: self.gatewayController.discoveryStatusText)
Text("Discovery stays active while Moltbot is open to find other gateways.")
.font(.footnote)
.foregroundStyle(.secondary)
LabeledContent("Status", value: self.appModel.gatewayStatusText)
TextField("Gateway Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
Text("Required to pair with a gateway. Provide a token or password from the gateway settings.")
.font(.footnote)
.foregroundStyle(.secondary)
if let serverName = self.appModel.gatewayServerName {
LabeledContent("Server", value: serverName)
if let addr = self.appModel.gatewayRemoteAddress {
@ -120,7 +134,7 @@ struct SettingsTab: View {
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Port", value: self.$manualGatewayPort, format: .number)
TextField("Port", value: self.$manualGatewayPort, formatter: Self.portFormatter)
.keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
@ -158,12 +172,6 @@ struct SettingsTab: View {
}
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
TextField("Gateway Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
}
}
@ -398,6 +406,13 @@ struct SettingsTab: View {
useTLS: self.manualGatewayTLS)
}
private static let portFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .none
formatter.usesGroupingSeparator = false
return formatter
}()
private static func primaryIPv4Address() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
@ -458,7 +473,7 @@ struct SettingsTab: View {
}
if lines.isEmpty {
lines.append(gateway.debugID)
lines.append("Discovery details unavailable yet.")
}
return lines

View File

@ -3,16 +3,52 @@ import SwiftUI
struct StatusPill: View {
@Environment(\.scenePhase) private var scenePhase
enum PairingRole: Equatable {
case `operator`
case node
case both
var title: String {
switch self {
case .operator:
"Pairing pending (operator)"
case .node:
"Pairing pending (node)"
case .both:
"Pairing pending (operator + node)"
}
}
}
enum ConnectionRole: Equatable {
case operatorOnly
case nodeOnly
case both
var title: String {
switch self {
case .operatorOnly:
"Connected (operator only)"
case .nodeOnly:
"Connected (node only)"
case .both:
"Connected (operator + node)"
}
}
}
enum GatewayState: Equatable {
case connected
case connected(ConnectionRole)
case connecting
case pairingPending(PairingRole)
case error
case disconnected
var title: String {
switch self {
case .connected: "Connected"
case let .connected(role): role.title
case .connecting: "Connecting…"
case let .pairingPending(role): role.title
case .error: "Error"
case .disconnected: "Offline"
}
@ -22,10 +58,16 @@ struct StatusPill: View {
switch self {
case .connected: .green
case .connecting: .yellow
case .pairingPending: .orange
case .error: .red
case .disconnected: .gray
}
}
var isConnecting: Bool {
if case .connecting = self { return true }
return false
}
}
struct Activity: Equatable {
@ -49,8 +91,8 @@ struct StatusPill: View {
Circle()
.fill(self.gateway.color)
.frame(width: 9, height: 9)
.scaleEffect(self.gateway == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0)
.opacity(self.gateway == .connecting ? (self.pulse ? 1.0 : 0.6) : 1.0)
.scaleEffect(self.gateway.isConnecting ? (self.pulse ? 1.15 : 0.85) : 1.0)
.opacity(self.gateway.isConnecting ? (self.pulse ? 1.0 : 0.6) : 1.0)
Text(self.gateway.title)
.font(.system(size: 13, weight: .semibold))
@ -114,7 +156,7 @@ struct StatusPill: View {
}
private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase) {
guard gateway == .connecting, scenePhase == .active else {
guard gateway.isConnecting, scenePhase == .active else {
withAnimation(.easeOut(duration: 0.2)) { self.pulse = false }
return
}

View File

@ -43,15 +43,17 @@ final class TalkModeManager: NSObject {
var pcmPlayer: PCMStreamingAudioPlaying = PCMStreamingAudioPlayer.shared
var mp3Player: StreamingAudioPlaying = StreamingAudioPlayer.shared
private var gateway: GatewayNodeSession?
private var gateway: GatewayOperatorSession?
private var nodeSession: GatewayNodeSession?
private let silenceWindow: TimeInterval = 0.7
private var chatSubscribedSessionKeys = Set<String>()
private let logger = Logger(subsystem: "bot.molt", category: "TalkMode")
func attachGateway(_ gateway: GatewayNodeSession) {
func attachGateway(_ gateway: GatewayOperatorSession, nodeSession: GatewayNodeSession? = nil) {
self.gateway = gateway
self.nodeSession = nodeSession
}
func updateMainSessionKey(_ sessionKey: String?) {
@ -298,22 +300,23 @@ final class TalkModeManager: NSObject {
private func subscribeChatIfNeeded(sessionKey: String) async {
let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return }
guard let gateway else { return }
// Use nodeSession for node.event (chat.subscribe).
guard let nodeSession else { return }
guard !self.chatSubscribedSessionKeys.contains(key) else { return }
let payload = "{\"sessionKey\":\"\(key)\"}"
await gateway.sendEvent(event: "chat.subscribe", payloadJSON: payload)
await nodeSession.sendEvent(event: "chat.subscribe", payloadJSON: payload)
self.chatSubscribedSessionKeys.insert(key)
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
}
private func unsubscribeAllChats() async {
guard let gateway else { return }
guard let nodeSession else { return }
let keys = self.chatSubscribedSessionKeys
self.chatSubscribedSessionKeys.removeAll()
for key in keys {
let payload = "{\"sessionKey\":\"\(key)\"}"
await gateway.sendEvent(event: "chat.unsubscribe", payloadJSON: payload)
await nodeSession.sendEvent(event: "chat.unsubscribe", payloadJSON: payload)
}
}
@ -339,7 +342,7 @@ final class TalkModeManager: NSObject {
}
}
private func sendChat(_ message: String, gateway: GatewayNodeSession) async throws -> String {
private func sendChat(_ message: String, gateway: GatewayOperatorSession) async throws -> String {
struct SendResponse: Decodable { let runId: String }
let payload: [String: Any] = [
"sessionKey": self.mainSessionKey,
@ -362,7 +365,7 @@ final class TalkModeManager: NSObject {
private func waitForChatCompletion(
runId: String,
gateway: GatewayNodeSession,
gateway: GatewayOperatorSession,
timeoutSeconds: Int = 120) async -> ChatCompletionState
{
let stream = await gateway.subscribeServerEvents(bufferingNewest: 200)
@ -397,7 +400,7 @@ final class TalkModeManager: NSObject {
}
private func waitForAssistantText(
gateway: GatewayNodeSession,
gateway: GatewayOperatorSession,
since: Double,
timeoutSeconds: Int) async throws -> String?
{
@ -411,7 +414,10 @@ final class TalkModeManager: NSObject {
return nil
}
private func fetchLatestAssistantText(gateway: GatewayNodeSession, since: Double? = nil) async throws -> String? {
private func fetchLatestAssistantText(
gateway: GatewayOperatorSession,
since: Double? = nil) async throws -> String?
{
let res = try await gateway.request(
method: "chat.history",
paramsJSON: "{\"sessionKey\":\"\(self.mainSessionKey)\"}",

View File

@ -76,4 +76,39 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
#expect(commands.contains(MoltbotLocationCommand.get.rawValue))
}
}
@Test @MainActor func currentCommandsMatchGatewayIOSAllowlist() {
// Verify only iOS-allowlisted commands are declared.
// iOS allowlist per node-command-policy.ts: canvas, camera, screen, location
// System commands (run, which, notify, execApprovals.*) are NOT allowed on iOS.
withUserDefaults([
"node.instanceId": "ios-test",
"camera.enabled": true,
"location.enabledMode": MoltbotLocationMode.whileUsing.rawValue,
]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let commands = Set(controller._test_currentCommands())
#expect(commands.contains(MoltbotCanvasCommand.present.rawValue))
#expect(commands.contains(MoltbotCanvasCommand.hide.rawValue))
#expect(commands.contains(MoltbotCanvasCommand.navigate.rawValue))
#expect(commands.contains(MoltbotCanvasCommand.evalJS.rawValue))
#expect(commands.contains(MoltbotCanvasCommand.snapshot.rawValue))
#expect(commands.contains(MoltbotCanvasA2UICommand.push.rawValue))
#expect(commands.contains(MoltbotCanvasA2UICommand.pushJSONL.rawValue))
#expect(commands.contains(MoltbotCanvasA2UICommand.reset.rawValue))
#expect(commands.contains(MoltbotScreenCommand.record.rawValue))
#expect(commands.contains(MoltbotCameraCommand.list.rawValue))
#expect(commands.contains(MoltbotCameraCommand.snap.rawValue))
#expect(commands.contains(MoltbotCameraCommand.clip.rawValue))
#expect(commands.contains(MoltbotLocationCommand.get.rawValue))
#expect(!commands.contains(MoltbotSystemCommand.notify.rawValue))
#expect(!commands.contains(MoltbotSystemCommand.which.rawValue))
#expect(!commands.contains(MoltbotSystemCommand.run.rawValue))
#expect(!commands.contains(MoltbotSystemCommand.execApprovalsGet.rawValue))
#expect(!commands.contains(MoltbotSystemCommand.execApprovalsSet.rawValue))
}
}
}

View File

@ -0,0 +1,52 @@
import MoltbotKit
import Foundation
import Testing
@testable import Moltbot
@Suite(.serialized) struct GatewayDualSessionStateTests {
@Test @MainActor func connectToGatewayStartsOperatorAndNodeSessions() async {
let session = TestGatewayWebSocketSession()
let appModel = NodeAppModel(
gatewaySession: GatewayOperatorSession(),
nodeSession: GatewayNodeSession())
let operatorOptions = GatewayConnectOptions(
role: "operator",
scopes: ["operator.read", "operator.write"],
caps: [],
commands: [],
permissions: [:],
clientId: "moltbot-ios",
clientMode: "ui",
clientDisplayName: "Test")
// Node role should have empty scopes - operator scopes are operator-only
let nodeOptions = GatewayConnectOptions(
role: "node",
scopes: [],
caps: ["camera"],
commands: [],
permissions: [:],
clientId: "moltbot-ios",
clientMode: "node",
clientDisplayName: "Test")
appModel.connectToGateway(
url: URL(string: "ws://example.invalid")!,
gatewayStableID: "test",
tls: nil,
token: nil,
password: nil,
operatorConnectOptions: operatorOptions,
nodeConnectOptions: nodeOptions,
sessionBox: WebSocketSessionBox(session: session))
try? await Task.sleep(nanoseconds: 200_000_000)
let roles = Set(session.snapshotConnectRoles())
#expect(roles == ["operator", "node"])
#expect(appModel.gatewayStatusText == "Connected (operator + node)")
appModel.disconnectGateway()
#expect(appModel.gatewayStatusText == "Offline")
}
}

View File

@ -0,0 +1,146 @@
import MoltbotKit
import MoltbotProtocol
import Foundation
import Testing
@testable import Moltbot
@Suite(.serialized) struct GatewayPairingStateTests {
@Test @MainActor func pairingPendingTransitionsAcrossRoles() {
let appModel = NodeAppModel()
#expect(appModel.gatewayPairingState == .none)
appModel._test_updatePairingPending(role: .operator, reason: "Pairing required")
#expect(appModel.gatewayPairingState == .operatorPending)
appModel._test_updatePairingPending(role: .node, reason: "Awaiting approval")
#expect(appModel.gatewayPairingState == .bothPending)
appModel._test_updatePairingPending(role: .operator, reason: "Disconnected")
#expect(appModel.gatewayPairingState == .nodePending)
appModel._test_clearPairingPending()
#expect(appModel.gatewayPairingState == .none)
}
@Test @MainActor func pairingPendingClearsOnEmptyReason() {
let appModel = NodeAppModel()
appModel._test_setPairingPending(role: .node, pending: true)
#expect(appModel.gatewayPairingState == .nodePending)
appModel._test_updatePairingPending(role: .node, reason: " ")
#expect(appModel.gatewayPairingState == .none)
}
@Test @MainActor func pairingRequestedEventSetsPendingForOperatorRole() async {
let appModel = NodeAppModel()
let myDeviceId = "test-device-123"
let payload: [String: MoltbotProtocol.AnyCodable] = [
"deviceId": MoltbotProtocol.AnyCodable(myDeviceId),
"role": MoltbotProtocol.AnyCodable("operator"),
]
let evt = EventFrame(
type: "event",
event: "device.pair.requested",
payload: MoltbotProtocol.AnyCodable(payload),
seq: nil,
stateversion: nil)
await appModel._test_handlePairingEvent(evt, myDeviceId: myDeviceId)
#expect(appModel.gatewayPairingState == .operatorPending)
}
@Test @MainActor func pairingRequestedEventSetsPendingForNodeRole() async {
let appModel = NodeAppModel()
let myDeviceId = "test-device-456"
let payload: [String: MoltbotProtocol.AnyCodable] = [
"deviceId": MoltbotProtocol.AnyCodable(myDeviceId),
"role": MoltbotProtocol.AnyCodable("node"),
]
let evt = EventFrame(
type: "event",
event: "device.pair.requested",
payload: MoltbotProtocol.AnyCodable(payload),
seq: nil,
stateversion: nil)
await appModel._test_handlePairingEvent(evt, myDeviceId: myDeviceId)
#expect(appModel.gatewayPairingState == .nodePending)
}
@Test @MainActor func pairingRequestedEventIgnoresOtherDevices() async {
let appModel = NodeAppModel()
let myDeviceId = "test-device-mine"
let payload: [String: MoltbotProtocol.AnyCodable] = [
"deviceId": MoltbotProtocol.AnyCodable("different-device"),
"role": MoltbotProtocol.AnyCodable("operator"),
]
let evt = EventFrame(
type: "event",
event: "device.pair.requested",
payload: MoltbotProtocol.AnyCodable(payload),
seq: nil,
stateversion: nil)
await appModel._test_handlePairingEvent(evt, myDeviceId: myDeviceId)
#expect(appModel.gatewayPairingState == .none)
}
@Test @MainActor func pairingResolvedEventClearsPending() async {
let appModel = NodeAppModel()
let myDeviceId = "test-device-789"
// First set pending state
appModel._test_setPairingPending(role: .operator, pending: true)
appModel._test_setPairingPending(role: .node, pending: true)
#expect(appModel.gatewayPairingState == .bothPending)
// Receive resolved event
let payload: [String: MoltbotProtocol.AnyCodable] = [
"deviceId": MoltbotProtocol.AnyCodable(myDeviceId),
"decision": MoltbotProtocol.AnyCodable("approved"),
]
let evt = EventFrame(
type: "event",
event: "device.pair.resolved",
payload: MoltbotProtocol.AnyCodable(payload),
seq: nil,
stateversion: nil)
await appModel._test_handlePairingEvent(evt, myDeviceId: myDeviceId)
#expect(appModel.gatewayPairingState == .none)
}
@Test @MainActor func pairingResolvedEventIgnoresOtherDevices() async {
let appModel = NodeAppModel()
let myDeviceId = "test-device-mine"
// Set pending state
appModel._test_setPairingPending(role: .operator, pending: true)
#expect(appModel.gatewayPairingState == .operatorPending)
// Receive resolved event for different device
let payload: [String: MoltbotProtocol.AnyCodable] = [
"deviceId": MoltbotProtocol.AnyCodable("different-device"),
"decision": MoltbotProtocol.AnyCodable("approved"),
]
let evt = EventFrame(
type: "event",
event: "device.pair.resolved",
payload: MoltbotProtocol.AnyCodable(payload),
seq: nil,
stateversion: nil)
await appModel._test_handlePairingEvent(evt, myDeviceId: myDeviceId)
// State unchanged
#expect(appModel.gatewayPairingState == .operatorPending)
}
}

View File

@ -4,8 +4,9 @@ import Testing
@Suite struct IOSGatewayChatTransportTests {
@Test func requestsFailFastWhenGatewayNotConnected() async {
let gateway = GatewayNodeSession()
let transport = IOSGatewayChatTransport(gateway: gateway)
let gateway = GatewayOperatorSession()
let nodeSession = GatewayNodeSession()
let transport = IOSGatewayChatTransport(gateway: gateway, nodeSession: nodeSession)
do {
_ = try await transport.requestHistory(sessionKey: "node-test")

View File

@ -67,8 +67,10 @@ import UIKit
@Test @MainActor func chatSheetBuildsAViewHierarchy() {
let appModel = NodeAppModel()
let gateway = GatewayNodeSession()
let root = ChatSheet(gateway: gateway, sessionKey: "test")
let root = ChatSheet(
gateway: appModel.gatewaySession,
nodeSession: appModel.gatewayNodeSession,
sessionKey: "test")
.environment(appModel)
.environment(appModel.voiceWake)
_ = Self.host(root)

View File

@ -0,0 +1,220 @@
import MoltbotKit
import Foundation
import os
final class TestGatewayWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
private let connectParamsData = OSAllocatedUnfairLock<Data?>(initialState: nil)
private let requestMethods = OSAllocatedUnfairLock<[String]>(initialState: [])
private let pendingReceiveHandler =
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?>(
initialState: nil)
private let queuedMessages = OSAllocatedUnfairLock<[URLSessionWebSocketTask.Message]>(initialState: [])
private let sentChallenge = OSAllocatedUnfairLock(initialState: false)
var state: URLSessionTask.State = .suspended
func snapshotConnectParams() -> [String: Any]? {
guard let data = self.connectParamsData.withLock({ $0 }) else { return nil }
return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
}
func snapshotRequestMethods() -> [String] {
self.requestMethods.withLock { $0 }
}
func resume() {
self.state = .running
}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
_ = (closeCode, reason)
self.state = .canceling
let handler = self.pendingReceiveHandler.withLock { handler in
defer { handler = nil }
return handler
}
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.cancelled)))
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
let data: Data? = switch message {
case let .data(d): d
case let .string(s): s.data(using: .utf8)
@unknown default: nil
}
guard let data else { return }
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
obj["type"] as? String == "req",
let id = obj["id"] as? String,
let method = obj["method"] as? String
else { return }
if method == "connect" {
self.connectRequestID.withLock { $0 = id }
let paramsData = obj["params"].flatMap { try? JSONSerialization.data(withJSONObject: $0) }
self.connectParamsData.withLock { $0 = paramsData }
return
}
self.requestMethods.withLock { $0.append(method) }
guard let responseData = Self.responseData(for: method, id: id) else { return }
self.deliver(.data(responseData))
}
func receive() async throws -> URLSessionWebSocketTask.Message {
if self.sentChallenge.withLock({ $0 == false }) {
self.sentChallenge.withLock { $0 = true }
return .data(Self.connectChallengeData())
}
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(Self.connectOkData(id: id))
}
func receive(
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
{
let message = self.queuedMessages.withLock { messages -> URLSessionWebSocketTask.Message? in
guard !messages.isEmpty else { return nil }
return messages.removeFirst()
}
if let message {
completionHandler(.success(message))
return
}
self.pendingReceiveHandler.withLock { $0 = completionHandler }
}
private func deliver(_ message: URLSessionWebSocketTask.Message) {
let handler = self.pendingReceiveHandler.withLock { handler -> ((@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void))? in
let h = handler
handler = nil
return h
}
if let handler {
handler(.success(message))
} else {
self.queuedMessages.withLock { $0.append(message) }
}
}
private static func connectChallengeData() -> Data {
let json = """
{
"type": "event",
"event": "connect.challenge",
"payload": { "nonce": "test-nonce" }
}
"""
return Data(json.utf8)
}
private static func connectOkData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 3,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
}
private static func responseData(for method: String, id: String) -> Data? {
switch method {
case "chat.send":
return Data("""
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": { "runId": "run-1", "status": "ok" }
}
""".utf8)
case "chat.history":
return Data("""
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"sessionKey": "main",
"sessionId": null,
"messages": [],
"thinkingLevel": "low"
}
}
""".utf8)
case "health":
return Data("""
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": { "ok": true }
}
""".utf8)
case "config.get":
return Data("""
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"config": {
"ui": { "seamColor": "#ffffff" },
"session": { "mainKey": "main" }
}
}
}
""".utf8)
case "voicewake.get":
return Data("""
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": { "triggers": ["clawd"] }
}
""".utf8)
default:
return nil
}
}
}
final class TestGatewayWebSocketSession: WebSocketSessioning, @unchecked Sendable {
private let taskLock = OSAllocatedUnfairLock<[TestGatewayWebSocketTask]>(initialState: [])
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
_ = url
let task = TestGatewayWebSocketTask()
self.taskLock.withLock { $0.append(task) }
return WebSocketTaskBox(task: task)
}
func snapshotConnectRoles() -> [String] {
self.taskLock.withLock { tasks in
tasks.compactMap { $0.snapshotConnectParams()?["role"] as? String }
}
}
func snapshotRequestMethods() -> [String] {
self.taskLock.withLock { tasks in
tasks.flatMap { $0.snapshotRequestMethods() }
}
}
}

View File

@ -408,7 +408,10 @@ public final class MoltbotChatViewModel {
}
private func handleAgentEvent(_ evt: MoltbotAgentEventPayload) {
if let sessionId, evt.runId != sessionId {
// Agent events use runId (client idempotency key), not sessionId (Pi session UUID).
// Match against our pending runs, not the session ID.
let isOurRun = self.pendingRuns.contains(evt.runId)
if !isOurRun {
return
}

View File

@ -128,7 +128,7 @@ public actor GatewayChannelActor {
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private let connectTimeoutSeconds: Double = 6
private let connectChallengeTimeoutSeconds: Double = 0.75
private let connectChallengeTimeoutSeconds: Double = 2.0
private var watchdogTask: Task<Void, Never>?
private var tickTask: Task<Void, Never>?
private let defaultRequestTimeoutMs: Double = 15000
@ -247,16 +247,11 @@ public actor GatewayChannelActor {
self.logger.error("gateway ws connect failed \(wrapped.localizedDescription, privacy: .public)")
throw wrapped
}
self.listen()
self.connected = true
// Note: listen(), connected, isConnecting, and connectWaiters are now
// handled inside handleConnectResponse() before pushHandler is called,
// so that nested requests from onConnected callbacks can proceed.
self.backoffMs = 500
self.lastSeq = nil
let waiters = self.connectWaiters
self.connectWaiters.removeAll()
for waiter in waiters {
waiter.resume(returning: ())
}
}
private func sendConnect() async throws {
@ -416,6 +411,16 @@ public actor GatewayChannelActor {
guard let self else { return }
await self.watchTicks()
}
// Mark as connected and start listening BEFORE calling pushHandler,
// so that any requests made from onConnected callbacks can proceed.
self.listen()
self.connected = true
self.isConnecting = false
let waiters = self.connectWaiters
self.connectWaiters.removeAll()
for waiter in waiters {
waiter.resume(returning: ())
}
await self.pushHandler?(.snapshot(ok))
}
@ -477,28 +482,23 @@ public actor GatewayChannelActor {
private func waitForConnectChallenge() async throws -> String? {
guard let task = self.task else { return nil }
do {
return try await AsyncTimeout.withTimeout(
seconds: self.connectChallengeTimeoutSeconds,
onTimeout: { ConnectChallengeError.timeout },
operation: { [weak self] in
guard let self else { return nil }
while true {
let msg = try await task.receive()
guard let data = self.decodeMessageData(msg) else { continue }
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue }
if case let .event(evt) = frame, evt.event == "connect.challenge" {
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let nonce = payload["nonce"]?.value as? String {
return nonce
}
return try await AsyncTimeout.withTimeout(
seconds: self.connectChallengeTimeoutSeconds,
onTimeout: { ConnectChallengeError.timeout },
operation: { [weak self] in
guard let self else { return nil }
while true {
let msg = try await task.receive()
guard let data = self.decodeMessageData(msg) else { continue }
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue }
if case let .event(evt) = frame, evt.event == "connect.challenge" {
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let nonce = payload["nonce"]?.value as? String {
return nonce
}
}
})
} catch {
if error is ConnectChallengeError { return nil }
throw error
}
}
})
}
private func waitForConnectResponse(reqId: String) async throws -> ResponseFrame {

View File

@ -191,10 +191,10 @@ public actor GatewayNodeSession {
self.broadcastServerEvent(evt)
guard evt.event == "node.invoke.request" else { return }
guard let payload = evt.payload else { return }
guard let onInvoke = self.onInvoke else { return }
do {
let data = try self.encoder.encode(payload)
let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
guard let onInvoke else { return }
let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON)
let response = await Self.invokeWithTimeout(
request: req,

View File

@ -23,24 +23,47 @@ Availability: internal preview. The iOS app is not publicly distributed yet.
- Tailnet via unicast DNS-SD (`moltbot.internal.`), **or**
- Manual host/port (fallback).
## Quick start (pair + connect)
## Quick start (authenticate + pair + connect)
1) Start the Gateway:
### 1. Start the Gateway
```bash
moltbot gateway --port 18789
```
2) In the iOS app, open Settings and pick a discovered gateway (or enable Manual Host and enter host/port).
### 2. Configure authentication in the iOS app
3) Approve the pairing request on the gateway host:
Open Settings in the Moltbot iOS app and configure **one** of the following:
- **Gateway Token**: Paste the token from `moltbot config get gateway.token` (recommended for secure setups)
- **Gateway Password**: Enter the password from `moltbot config get gateway.password` (simpler alternative)
If neither is set on the gateway, you can set one:
```bash
moltbot nodes pending
moltbot nodes approve <requestId>
moltbot config set gateway.token "your-secret-token"
# or
moltbot config set gateway.password "your-password"
```
4) Verify connection:
### 3. Select the gateway
In the iOS app Settings, pick a discovered gateway from the list, or enable **Manual Host** and enter the host/port manually.
### 4. Approve the pairing request
The iOS app requires device pairing for both operator and node roles. On the gateway host, list and approve pending devices:
```bash
moltbot devices list
moltbot devices approve <id>
```
You may need to approve twice (once for operator role, once for node role).
> **Note**: Use `moltbot devices` for WebSocket pairing, not `moltbot nodes pending/approve`.
### 5. Verify connection
```bash
moltbot nodes status
@ -94,8 +117,9 @@ moltbot nodes invoke --node "iOS Node" --command canvas.snapshot --params '{"max
- `NODE_BACKGROUND_UNAVAILABLE`: bring the iOS app to the foreground (canvas/camera/screen commands require it).
- `A2UI_HOST_NOT_CONFIGURED`: the Gateway did not advertise a canvas host URL; check `canvasHost` in [Gateway configuration](/gateway/configuration).
- Pairing prompt never appears: run `moltbot nodes pending` and approve manually.
- Reconnect fails after reinstall: the Keychain pairing token was cleared; re-pair the node.
- **Authentication failed**: Ensure the gateway token or password in iOS Settings matches what is configured on the gateway (`moltbot config get gateway.token` or `gateway.password`).
- **Pairing prompt never appears**: Run `moltbot devices list` to see pending requests and approve with `moltbot devices approve <id>`.
- **Reconnect fails after reinstall**: The Keychain pairing token was cleared; re-pair the device using `moltbot devices list` and `moltbot devices approve`.
## Related docs