feat(pairing): add silent SSH auto-approve

This commit is contained in:
Peter Steinberger
2025-12-19 01:04:35 +01:00
parent 0b4e70e38b
commit 77a67484ea
8 changed files with 146 additions and 5 deletions

View File

@@ -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 {

View File

@@ -19,6 +19,7 @@ final class NodePairingApprovalPrompter {
private var activeRequestId: String?
private var alertHostWindow: NSWindow?
private var remoteResolutionsByRequestId: [String: PairingResolution] = [:]
private var autoApproveAttempts: Set<String> = []
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
}
}

View File

@@ -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
}
}

View File

@@ -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.

View File

@@ -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 },
);

View File

@@ -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, {

View File

@@ -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,
);

View File

@@ -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(),
};