From 77a67484ea50518a1ca78bff2ed72de7a58cd930 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 19 Dec 2025 01:04:35 +0100 Subject: [PATCH] feat(pairing): add silent SSH auto-approve --- .../Bridge/BridgeConnectionHandler.swift | 6 +- .../Clawdis/NodePairingApprovalPrompter.swift | 130 +++++++++++++++++- .../Sources/ClawdisKit/BridgeFrames.swift | 5 +- docs/gateway/pairing.md | 3 + src/gateway/protocol/schema.ts | 1 + src/gateway/server.ts | 2 + src/infra/bridge/server.ts | 2 + src/infra/node-pairing.ts | 2 + 8 files changed, 146 insertions(+), 5 deletions(-) diff --git a/apps/macos/Sources/Clawdis/Bridge/BridgeConnectionHandler.swift b/apps/macos/Sources/Clawdis/Bridge/BridgeConnectionHandler.swift index 0c2a1e6f2..b7869597b 100644 --- a/apps/macos/Sources/Clawdis/Bridge/BridgeConnectionHandler.swift +++ b/apps/macos/Sources/Clawdis/Bridge/BridgeConnectionHandler.swift @@ -171,8 +171,12 @@ actor BridgeConnectionHandler { displayName: req.displayName, platform: req.platform, version: req.version, + deviceFamily: req.deviceFamily, + modelIdentifier: req.modelIdentifier, caps: req.caps, - remoteAddress: self.remoteAddressString()) + commands: req.commands, + remoteAddress: self.remoteAddressString(), + silent: req.silent) let result = await context.handlePair(enriched) await self.handlePairResult(result, serverName: context.serverName) if case .ok = result { diff --git a/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift b/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift index 4c724a87a..2043dcb33 100644 --- a/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift +++ b/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift @@ -19,6 +19,7 @@ final class NodePairingApprovalPrompter { private var activeRequestId: String? private var alertHostWindow: NSWindow? private var remoteResolutionsByRequestId: [String: PairingResolution] = [:] + private var autoApproveAttempts: Set = [] private final class AlertHostWindow: NSWindow { override var canBecomeKey: Bool { true } @@ -47,6 +48,7 @@ final class NodePairingApprovalPrompter { let version: String? let remoteIp: String? let isRepair: Bool? + let silent: Bool? let ts: Double var id: String { self.requestId } @@ -90,6 +92,7 @@ final class NodePairingApprovalPrompter { self.alertHostWindow?.close() self.alertHostWindow = nil self.remoteResolutionsByRequestId.removeAll(keepingCapacity: false) + self.autoApproveAttempts.removeAll(keepingCapacity: false) } private func loadPendingRequestsFromGateway() async { @@ -258,7 +261,13 @@ final class NodePairingApprovalPrompter { guard !self.isPresenting else { return } guard let next = self.queue.first else { return } self.isPresenting = true - self.presentAlert(for: next) + Task { @MainActor [weak self] in + guard let self else { return } + if await self.trySilentApproveIfPossible(next) { + return + } + self.presentAlert(for: next) + } } private func presentAlert(for req: PendingRequest) { @@ -330,7 +339,7 @@ final class NodePairingApprovalPrompter { // Later: leave as pending (CLI can approve/reject). Request will expire on the gateway TTL. return case .alertSecondButtonReturn: - await self.approve(requestId: request.requestId) + _ = await self.approve(requestId: request.requestId) await self.notify(resolution: .approved, request: request, via: "local") case .alertThirdButtonReturn: await self.reject(requestId: request.requestId) @@ -340,13 +349,15 @@ final class NodePairingApprovalPrompter { } } - private func approve(requestId: String) async { + private func approve(requestId: String) async -> Bool { do { try await GatewayConnection.shared.nodePairApprove(requestId: requestId) self.logger.info("approved node pairing requestId=\(requestId, privacy: .public)") + return true } catch { self.logger.error("approve failed requestId=\(requestId, privacy: .public)") self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)") + return false } } @@ -410,4 +421,117 @@ final class NodePairingApprovalPrompter { sound: nil, priority: .active) } + + private struct SSHTarget { + let host: String + let port: Int + } + + private func trySilentApproveIfPossible(_ req: PendingRequest) async -> Bool { + guard req.silent == true else { return false } + if self.autoApproveAttempts.contains(req.requestId) { return false } + self.autoApproveAttempts.insert(req.requestId) + + guard let target = await self.resolveSSHTarget() else { + self.logger.info("silent pairing skipped (no ssh target) requestId=\(req.requestId, privacy: .public)") + return false + } + + let user = NSUserName().trimmingCharacters(in: .whitespacesAndNewlines) + guard !user.isEmpty else { + self.logger.info("silent pairing skipped (missing local user) requestId=\(req.requestId, privacy: .public)") + return false + } + + let ok = await Self.probeSSH(user: user, host: target.host, port: target.port) + if !ok { + self.logger.info("silent pairing probe failed requestId=\(req.requestId, privacy: .public)") + return false + } + + guard await self.approve(requestId: req.requestId) else { + self.logger.info("silent pairing approve failed requestId=\(req.requestId, privacy: .public)") + return false + } + + await self.notify(resolution: .approved, request: req, via: "silent-ssh") + if self.queue.first == req { + self.queue.removeFirst() + } else { + self.queue.removeAll { $0 == req } + } + self.isPresenting = false + self.presentNextIfNeeded() + return true + } + + private func resolveSSHTarget() async -> SSHTarget? { + let settings = CommandResolver.connectionSettings() + if !settings.target.isEmpty, let parsed = CommandResolver.parseSSHTarget(settings.target) { + let user = NSUserName().trimmingCharacters(in: .whitespacesAndNewlines) + if let targetUser = parsed.user, + !targetUser.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + targetUser != user + { + self.logger.info("silent pairing skipped (ssh user mismatch)") + return nil + } + let host = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty else { return nil } + let port = parsed.port > 0 ? parsed.port : 22 + return SSHTarget(host: host, port: port) + } + + let model = MasterDiscoveryModel() + model.start() + defer { model.stop() } + + let deadline = Date().addingTimeInterval(5.0) + while model.masters.isEmpty && Date() < deadline { + try? await Task.sleep(nanoseconds: 200_000_000) + } + + guard let master = model.masters.first else { return nil } + let host = (master.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? + master.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty) + guard let host, !host.isEmpty else { return nil } + let port = master.sshPort > 0 ? master.sshPort : 22 + return SSHTarget(host: host, port: port) + } + + private static func probeSSH(user: String, host: String, port: Int) async -> Bool { + await Task.detached(priority: .utility) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + + var args = [ + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=5", + "-o", + "NumberOfPasswordPrompts=0", + "-o", + "PreferredAuthentications=publickey", + "-o", + "StrictHostKeyChecking=accept-new", + ] + if port > 0, port != 22 { + args.append(contentsOf: ["-p", String(port)]) + } + args.append(contentsOf: ["-l", user, host, "/usr/bin/true"]) + process.arguments = args + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + do { + try process.run() + } catch { + return false + } + process.waitUntilExit() + return process.terminationStatus == 0 + }.value + } } diff --git a/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift b/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift index af222ecae..7f7973eff 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift @@ -114,6 +114,7 @@ public struct BridgePairRequest: Codable, Sendable { public let caps: [String]? public let commands: [String]? public let remoteAddress: String? + public let silent: Bool? public init( type: String = "pair-request", @@ -125,7 +126,8 @@ public struct BridgePairRequest: Codable, Sendable { modelIdentifier: String? = nil, caps: [String]? = nil, commands: [String]? = nil, - remoteAddress: String? = nil) + remoteAddress: String? = nil, + silent: Bool? = nil) { self.type = type self.nodeId = nodeId @@ -137,6 +139,7 @@ public struct BridgePairRequest: Codable, Sendable { self.caps = caps self.commands = commands self.remoteAddress = remoteAddress + self.silent = silent } } diff --git a/docs/gateway/pairing.md b/docs/gateway/pairing.md index f5c685fc5..1533d60a1 100644 --- a/docs/gateway/pairing.md +++ b/docs/gateway/pairing.md @@ -32,6 +32,7 @@ These are conceptual method names; wire them into `src/gateway/protocol/schema.t - `platform?` (string) - `version?` (string) - `remoteIp?` (string) + - `silent?` (boolean) — hint that the UI may attempt auto-approval - `ts` (ms since epoch) - `node.pair.resolved` - Emitted when a pending request is approved/rejected. @@ -45,6 +46,7 @@ These are conceptual method names; wire them into `src/gateway/protocol/schema.t - `node.pair.request` - Creates (or returns) a pending request. - Params: node metadata (same shape as `node.pair.requested` payload, minus `requestId`/`ts`). + - Optional `silent` flag hints that the UI can attempt an SSH auto-approve before showing an alert. - Result: - `status` ("pending") - `created` (boolean) — whether this call created the pending request @@ -98,6 +100,7 @@ Target direction: The macOS UI (Swift) can: - Subscribe to `node.pair.requested`, show an alert (including `remoteIp`), and call `node.pair.approve` or `node.pair.reject`. - Or ignore/dismiss (“Later”) and let CLI handle it. +- When `silent` is set, it can try a short SSH probe (same user) and auto-approve if reachable; otherwise fall back to the normal alert. ## Implementation note If the bridge is only provided by the macOS app, then “no Swift app running” cannot work end-to-end. diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index aab7c0371..8ae85a90a 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -227,6 +227,7 @@ export const NodePairRequestParamsSchema = Type.Object( caps: Type.Optional(Type.Array(NonEmptyString)), commands: Type.Optional(Type.Array(NonEmptyString)), remoteIp: Type.Optional(NonEmptyString), + silent: Type.Optional(Type.Boolean()), }, { additionalProperties: false }, ); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index ccc62ca59..2198c31ac 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -3276,6 +3276,7 @@ export async function startGatewayServer( caps?: string[]; commands?: string[]; remoteIp?: string; + silent?: boolean; }; try { const result = await requestNodePairing({ @@ -3288,6 +3289,7 @@ export async function startGatewayServer( caps: p.caps, commands: p.commands, remoteIp: p.remoteIp, + silent: p.silent, }); if (result.status === "pending" && result.created) { broadcast("node.pair.requested", result.request, { diff --git a/src/infra/bridge/server.ts b/src/infra/bridge/server.ts index 304b8f621..21d728096 100644 --- a/src/infra/bridge/server.ts +++ b/src/infra/bridge/server.ts @@ -35,6 +35,7 @@ type BridgePairRequestFrame = { caps?: string[]; commands?: string[]; remoteAddress?: string; + silent?: boolean; }; type BridgeEventFrame = { @@ -396,6 +397,7 @@ export async function startNodeBridgeServer( ? req.commands.map((c) => String(c)).filter(Boolean) : undefined, remoteIp: remoteAddress, + silent: req.silent === true ? true : undefined, }, opts.pairingBaseDir, ); diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index 1b16c710a..81d2887b4 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -14,6 +14,7 @@ export type NodePairingPendingRequest = { caps?: string[]; commands?: string[]; remoteIp?: string; + silent?: boolean; isRepair?: boolean; ts: number; }; @@ -185,6 +186,7 @@ export async function requestNodePairing( caps: req.caps, commands: req.commands, remoteIp: req.remoteIp, + silent: req.silent, isRepair, ts: Date.now(), };