feat: unify device auth + pairing
This commit is contained in:
84
apps/macos/Sources/Clawdbot/DeviceIdentity.swift
Normal file
84
apps/macos/Sources/Clawdbot/DeviceIdentity.swift
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct DeviceIdentity: Codable, Sendable {
|
||||||
|
var deviceId: String
|
||||||
|
var publicKey: String
|
||||||
|
var privateKey: String
|
||||||
|
var createdAtMs: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DeviceIdentityStore {
|
||||||
|
private static let fileName = "device.json"
|
||||||
|
|
||||||
|
static func loadOrCreate() -> DeviceIdentity {
|
||||||
|
let url = self.fileURL()
|
||||||
|
if let data = try? Data(contentsOf: url),
|
||||||
|
let decoded = try? JSONDecoder().decode(DeviceIdentity.self, from: data),
|
||||||
|
!decoded.deviceId.isEmpty,
|
||||||
|
!decoded.publicKey.isEmpty,
|
||||||
|
!decoded.privateKey.isEmpty {
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
let identity = self.generate()
|
||||||
|
self.save(identity)
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
|
||||||
|
static func signPayload(_ payload: String, identity: DeviceIdentity) -> String? {
|
||||||
|
guard let privateKeyData = Data(base64Encoded: identity.privateKey) else { return nil }
|
||||||
|
do {
|
||||||
|
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyData)
|
||||||
|
let signature = try privateKey.signature(for: Data(payload.utf8))
|
||||||
|
return self.base64UrlEncode(signature)
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func generate() -> DeviceIdentity {
|
||||||
|
let privateKey = Curve25519.Signing.PrivateKey()
|
||||||
|
let publicKey = privateKey.publicKey
|
||||||
|
let publicKeyData = publicKey.rawRepresentation
|
||||||
|
let privateKeyData = privateKey.rawRepresentation
|
||||||
|
let deviceId = SHA256.hash(data: publicKeyData).compactMap { String(format: "%02x", $0) }.joined()
|
||||||
|
return DeviceIdentity(
|
||||||
|
deviceId: deviceId,
|
||||||
|
publicKey: publicKeyData.base64EncodedString(),
|
||||||
|
privateKey: privateKeyData.base64EncodedString(),
|
||||||
|
createdAtMs: Int(Date().timeIntervalSince1970 * 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func base64UrlEncode(_ data: Data) -> String {
|
||||||
|
let base64 = data.base64EncodedString()
|
||||||
|
return base64
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func publicKeyBase64Url(_ identity: DeviceIdentity) -> String? {
|
||||||
|
guard let data = Data(base64Encoded: identity.publicKey) else { return nil }
|
||||||
|
return self.base64UrlEncode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func save(_ identity: DeviceIdentity) {
|
||||||
|
let url = self.fileURL()
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
let data = try JSONEncoder().encode(identity)
|
||||||
|
try data.write(to: url, options: [.atomic])
|
||||||
|
} catch {
|
||||||
|
// best-effort only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fileURL() -> URL {
|
||||||
|
let base = ClawdbotPaths.stateDirURL
|
||||||
|
return base
|
||||||
|
.appendingPathComponent("identity", isDirectory: true)
|
||||||
|
.appendingPathComponent(fileName, isDirectory: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
318
apps/macos/Sources/Clawdbot/DevicePairingApprovalPrompter.swift
Normal file
318
apps/macos/Sources/Clawdbot/DevicePairingApprovalPrompter.swift
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import AppKit
|
||||||
|
import ClawdbotProtocol
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class DevicePairingApprovalPrompter {
|
||||||
|
static let shared = DevicePairingApprovalPrompter()
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.clawdbot", category: "device-pairing")
|
||||||
|
private var task: Task<Void, Never>?
|
||||||
|
private var isStopping = false
|
||||||
|
private var isPresenting = false
|
||||||
|
private var queue: [PendingRequest] = []
|
||||||
|
var pendingCount: Int = 0
|
||||||
|
var pendingRepairCount: Int = 0
|
||||||
|
private var activeAlert: NSAlert?
|
||||||
|
private var activeRequestId: String?
|
||||||
|
private var alertHostWindow: NSWindow?
|
||||||
|
private var resolvedByRequestId: Set<String> = []
|
||||||
|
|
||||||
|
private final class AlertHostWindow: NSWindow {
|
||||||
|
override var canBecomeKey: Bool { true }
|
||||||
|
override var canBecomeMain: Bool { true }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PairingList: Codable {
|
||||||
|
let pending: [PendingRequest]
|
||||||
|
let paired: [PairedDevice]?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PairedDevice: Codable, Equatable {
|
||||||
|
let deviceId: String
|
||||||
|
let approvedAtMs: Double?
|
||||||
|
let displayName: String?
|
||||||
|
let platform: String?
|
||||||
|
let remoteIp: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PendingRequest: Codable, Equatable, Identifiable {
|
||||||
|
let requestId: String
|
||||||
|
let deviceId: String
|
||||||
|
let publicKey: String
|
||||||
|
let displayName: String?
|
||||||
|
let platform: String?
|
||||||
|
let clientId: String?
|
||||||
|
let clientMode: String?
|
||||||
|
let role: String?
|
||||||
|
let scopes: [String]?
|
||||||
|
let remoteIp: String?
|
||||||
|
let silent: Bool?
|
||||||
|
let isRepair: Bool?
|
||||||
|
let ts: Double
|
||||||
|
|
||||||
|
var id: String { self.requestId }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PairingResolvedEvent: Codable {
|
||||||
|
let requestId: String
|
||||||
|
let deviceId: String
|
||||||
|
let decision: String
|
||||||
|
let ts: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum PairingResolution: String {
|
||||||
|
case approved
|
||||||
|
case rejected
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard self.task == nil else { return }
|
||||||
|
self.isStopping = false
|
||||||
|
self.task = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
_ = try? await GatewayConnection.shared.refresh()
|
||||||
|
await self.loadPendingRequestsFromGateway()
|
||||||
|
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
|
||||||
|
for await push in stream {
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
await MainActor.run { [weak self] in self?.handle(push: push) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
self.isStopping = true
|
||||||
|
self.endActiveAlert()
|
||||||
|
self.task?.cancel()
|
||||||
|
self.task = nil
|
||||||
|
self.queue.removeAll(keepingCapacity: false)
|
||||||
|
self.updatePendingCounts()
|
||||||
|
self.isPresenting = false
|
||||||
|
self.activeRequestId = nil
|
||||||
|
self.alertHostWindow?.orderOut(nil)
|
||||||
|
self.alertHostWindow?.close()
|
||||||
|
self.alertHostWindow = nil
|
||||||
|
self.resolvedByRequestId.removeAll(keepingCapacity: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPendingRequestsFromGateway() async {
|
||||||
|
do {
|
||||||
|
let list: PairingList = try await GatewayConnection.shared.requestDecoded(method: .devicePairList)
|
||||||
|
await self.apply(list: list)
|
||||||
|
} catch {
|
||||||
|
self.logger.error("failed to load device pairing requests: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apply(list: PairingList) async {
|
||||||
|
self.queue = list.pending.sorted(by: { $0.ts > $1.ts })
|
||||||
|
self.updatePendingCounts()
|
||||||
|
self.presentNextIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updatePendingCounts() {
|
||||||
|
self.pendingCount = self.queue.count
|
||||||
|
self.pendingRepairCount = self.queue.filter { $0.isRepair == true }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentNextIfNeeded() {
|
||||||
|
guard !self.isStopping else { return }
|
||||||
|
guard !self.isPresenting else { return }
|
||||||
|
guard let next = self.queue.first else { return }
|
||||||
|
self.isPresenting = true
|
||||||
|
self.presentAlert(for: next)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentAlert(for req: PendingRequest) {
|
||||||
|
self.logger.info("presenting device pairing alert requestId=\(req.requestId, privacy: .public)")
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.alertStyle = .warning
|
||||||
|
alert.messageText = "Allow device to connect?"
|
||||||
|
alert.informativeText = Self.describe(req)
|
||||||
|
alert.addButton(withTitle: "Later")
|
||||||
|
alert.addButton(withTitle: "Approve")
|
||||||
|
alert.addButton(withTitle: "Reject")
|
||||||
|
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
|
||||||
|
alert.buttons[2].hasDestructiveAction = true
|
||||||
|
}
|
||||||
|
|
||||||
|
self.activeAlert = alert
|
||||||
|
self.activeRequestId = req.requestId
|
||||||
|
let hostWindow = self.requireAlertHostWindow()
|
||||||
|
|
||||||
|
let sheetSize = alert.window.frame.size
|
||||||
|
if let screen = hostWindow.screen ?? NSScreen.main {
|
||||||
|
let bounds = screen.visibleFrame
|
||||||
|
let x = bounds.midX - (sheetSize.width / 2)
|
||||||
|
let sheetOriginY = bounds.midY - (sheetSize.height / 2)
|
||||||
|
let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height
|
||||||
|
hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY))
|
||||||
|
} else {
|
||||||
|
hostWindow.center()
|
||||||
|
}
|
||||||
|
|
||||||
|
hostWindow.makeKeyAndOrderFront(nil)
|
||||||
|
alert.beginSheetModal(for: hostWindow) { [weak self] response in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.activeRequestId = nil
|
||||||
|
self.activeAlert = nil
|
||||||
|
await self.handleAlertResponse(response, request: req)
|
||||||
|
hostWindow.orderOut(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
|
||||||
|
defer {
|
||||||
|
if self.queue.first == request {
|
||||||
|
self.queue.removeFirst()
|
||||||
|
} else {
|
||||||
|
self.queue.removeAll { $0 == request }
|
||||||
|
}
|
||||||
|
self.updatePendingCounts()
|
||||||
|
self.isPresenting = false
|
||||||
|
self.presentNextIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !self.isStopping else { return }
|
||||||
|
|
||||||
|
if self.resolvedByRequestId.remove(request.requestId) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch response {
|
||||||
|
case .alertFirstButtonReturn:
|
||||||
|
return
|
||||||
|
case .alertSecondButtonReturn:
|
||||||
|
_ = await self.approve(requestId: request.requestId)
|
||||||
|
case .alertThirdButtonReturn:
|
||||||
|
await self.reject(requestId: request.requestId)
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func approve(requestId: String) async -> Bool {
|
||||||
|
do {
|
||||||
|
try await GatewayConnection.shared.devicePairApprove(requestId: requestId)
|
||||||
|
self.logger.info("approved device 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reject(requestId: String) async {
|
||||||
|
do {
|
||||||
|
try await GatewayConnection.shared.devicePairReject(requestId: requestId)
|
||||||
|
self.logger.info("rejected device pairing requestId=\(requestId, privacy: .public)")
|
||||||
|
} catch {
|
||||||
|
self.logger.error("reject failed requestId=\(requestId, privacy: .public)")
|
||||||
|
self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func endActiveAlert() {
|
||||||
|
guard let alert = self.activeAlert else { return }
|
||||||
|
if let parent = alert.window.sheetParent {
|
||||||
|
parent.endSheet(alert.window, returnCode: .abort)
|
||||||
|
}
|
||||||
|
self.activeAlert = nil
|
||||||
|
self.activeRequestId = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requireAlertHostWindow() -> NSWindow {
|
||||||
|
if let alertHostWindow {
|
||||||
|
return alertHostWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
let window = AlertHostWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 520, height: 1),
|
||||||
|
styleMask: [.borderless],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false)
|
||||||
|
window.title = ""
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
window.level = .floating
|
||||||
|
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||||
|
window.isOpaque = false
|
||||||
|
window.hasShadow = false
|
||||||
|
window.backgroundColor = .clear
|
||||||
|
window.ignoresMouseEvents = true
|
||||||
|
|
||||||
|
self.alertHostWindow = window
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(push: GatewayPush) {
|
||||||
|
switch push {
|
||||||
|
case let .event(evt) where evt.event == "device.pair.requested":
|
||||||
|
guard let payload = evt.payload else { return }
|
||||||
|
do {
|
||||||
|
let req = try GatewayPayloadDecoding.decode(payload, as: PendingRequest.self)
|
||||||
|
self.enqueue(req)
|
||||||
|
} catch {
|
||||||
|
self.logger.error("failed to decode device pairing request: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
case let .event(evt) where evt.event == "device.pair.resolved":
|
||||||
|
guard let payload = evt.payload else { return }
|
||||||
|
do {
|
||||||
|
let resolved = try GatewayPayloadDecoding.decode(payload, as: PairingResolvedEvent.self)
|
||||||
|
self.handleResolved(resolved)
|
||||||
|
} catch {
|
||||||
|
self.logger.error("failed to decode device pairing resolution: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enqueue(_ req: PendingRequest) {
|
||||||
|
guard !self.queue.contains(req) else { return }
|
||||||
|
self.queue.append(req)
|
||||||
|
self.updatePendingCounts()
|
||||||
|
self.presentNextIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleResolved(_ resolved: PairingResolvedEvent) {
|
||||||
|
let resolution = resolved.decision == PairingResolution.approved.rawValue ? PairingResolution.approved : .rejected
|
||||||
|
if let activeRequestId, activeRequestId == resolved.requestId {
|
||||||
|
self.resolvedByRequestId.insert(resolved.requestId)
|
||||||
|
self.endActiveAlert()
|
||||||
|
self.logger.info("device pairing resolved while active requestId=\(resolved.requestId, privacy: .public) decision=\(resolution.rawValue, privacy: .public)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.queue.removeAll { $0.requestId == resolved.requestId }
|
||||||
|
self.updatePendingCounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func describe(_ req: PendingRequest) -> String {
|
||||||
|
var lines: [String] = []
|
||||||
|
lines.append("Device: \(req.displayName ?? req.deviceId)")
|
||||||
|
if let platform = req.platform {
|
||||||
|
lines.append("Platform: \(platform)")
|
||||||
|
}
|
||||||
|
if let role = req.role {
|
||||||
|
lines.append("Role: \(role)")
|
||||||
|
}
|
||||||
|
if let scopes = req.scopes, !scopes.isEmpty {
|
||||||
|
lines.append("Scopes: \(scopes.joined(separator: \", \"))")
|
||||||
|
}
|
||||||
|
if let remoteIp = req.remoteIp {
|
||||||
|
lines.append("IP: \(remoteIp)")
|
||||||
|
}
|
||||||
|
if req.isRepair == true {
|
||||||
|
lines.append("Repair: yes")
|
||||||
|
}
|
||||||
|
return lines.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import ClawdbotProtocol
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ExecApprovalsGatewayPrompter {
|
||||||
|
static let shared = ExecApprovalsGatewayPrompter()
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.gateway")
|
||||||
|
private var task: Task<Void, Never>?
|
||||||
|
|
||||||
|
struct GatewayApprovalRequest: Codable, Sendable {
|
||||||
|
var id: String
|
||||||
|
var request: ExecApprovalPromptRequest
|
||||||
|
var createdAtMs: Int
|
||||||
|
var expiresAtMs: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard self.task == nil else { return }
|
||||||
|
self.task = Task { [weak self] in
|
||||||
|
await self?.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
self.task?.cancel()
|
||||||
|
self.task = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func run() async {
|
||||||
|
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
|
||||||
|
for await push in stream {
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
await self.handle(push: push)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(push: GatewayPush) async {
|
||||||
|
guard case let .event(evt) = push else { return }
|
||||||
|
guard evt.event == "exec.approval.requested" else { return }
|
||||||
|
guard let payload = evt.payload else { return }
|
||||||
|
do {
|
||||||
|
let data = try JSONEncoder().encode(payload)
|
||||||
|
let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data)
|
||||||
|
let decision = ExecApprovalsPromptPresenter.prompt(request.request)
|
||||||
|
try await GatewayConnection.shared.requestVoid(
|
||||||
|
method: .execApprovalResolve,
|
||||||
|
params: [
|
||||||
|
"id": AnyCodable(request.id),
|
||||||
|
"decision": AnyCodable(decision.rawValue),
|
||||||
|
],
|
||||||
|
timeoutMs: 10_000)
|
||||||
|
} catch {
|
||||||
|
self.logger.error("exec approval handling failed \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -204,6 +204,7 @@ actor GatewayChannelActor {
|
|||||||
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
|
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
|
||||||
let clientDisplayName = InstanceIdentity.displayName
|
let clientDisplayName = InstanceIdentity.displayName
|
||||||
let clientId = "clawdbot-macos"
|
let clientId = "clawdbot-macos"
|
||||||
|
let clientMode = "ui"
|
||||||
|
|
||||||
let reqId = UUID().uuidString
|
let reqId = UUID().uuidString
|
||||||
var client: [String: ProtoAnyCodable] = [
|
var client: [String: ProtoAnyCodable] = [
|
||||||
@@ -212,7 +213,7 @@ actor GatewayChannelActor {
|
|||||||
"version": ProtoAnyCodable(
|
"version": ProtoAnyCodable(
|
||||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
|
||||||
"platform": ProtoAnyCodable(platform),
|
"platform": ProtoAnyCodable(platform),
|
||||||
"mode": ProtoAnyCodable("ui"),
|
"mode": ProtoAnyCodable(clientMode),
|
||||||
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
|
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
|
||||||
]
|
]
|
||||||
client["deviceFamily"] = ProtoAnyCodable("Mac")
|
client["deviceFamily"] = ProtoAnyCodable("Mac")
|
||||||
@@ -226,12 +227,36 @@ actor GatewayChannelActor {
|
|||||||
"caps": ProtoAnyCodable([] as [String]),
|
"caps": ProtoAnyCodable([] as [String]),
|
||||||
"locale": ProtoAnyCodable(primaryLocale),
|
"locale": ProtoAnyCodable(primaryLocale),
|
||||||
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
||||||
|
"role": ProtoAnyCodable("operator"),
|
||||||
|
"scopes": ProtoAnyCodable(["operator.admin", "operator.approvals", "operator.pairing"]),
|
||||||
]
|
]
|
||||||
if let token = self.token {
|
if let token = self.token {
|
||||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
|
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
|
||||||
} else if let password = self.password {
|
} else if let password = self.password {
|
||||||
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
|
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
|
||||||
}
|
}
|
||||||
|
let identity = DeviceIdentityStore.loadOrCreate()
|
||||||
|
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||||
|
let scopes = "operator.admin,operator.approvals,operator.pairing"
|
||||||
|
let payload = [
|
||||||
|
"v1",
|
||||||
|
identity.deviceId,
|
||||||
|
clientId,
|
||||||
|
clientMode,
|
||||||
|
"operator",
|
||||||
|
scopes,
|
||||||
|
String(signedAtMs),
|
||||||
|
self.token ?? "",
|
||||||
|
].joined(separator: "|")
|
||||||
|
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
||||||
|
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
|
||||||
|
params["device"] = ProtoAnyCodable([
|
||||||
|
"id": ProtoAnyCodable(identity.deviceId),
|
||||||
|
"publicKey": ProtoAnyCodable(publicKey),
|
||||||
|
"signature": ProtoAnyCodable(signature),
|
||||||
|
"signedAt": ProtoAnyCodable(signedAtMs),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
let frame = RequestFrame(
|
let frame = RequestFrame(
|
||||||
type: "req",
|
type: "req",
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ actor GatewayConnection {
|
|||||||
case voicewakeSet = "voicewake.set"
|
case voicewakeSet = "voicewake.set"
|
||||||
case nodePairApprove = "node.pair.approve"
|
case nodePairApprove = "node.pair.approve"
|
||||||
case nodePairReject = "node.pair.reject"
|
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 cronList = "cron.list"
|
||||||
case cronRuns = "cron.runs"
|
case cronRuns = "cron.runs"
|
||||||
case cronRun = "cron.run"
|
case cronRun = "cron.run"
|
||||||
@@ -610,6 +614,22 @@ extension GatewayConnection {
|
|||||||
timeoutMs: 10000)
|
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
|
// MARK: - Cron
|
||||||
|
|
||||||
struct CronSchedulerStatus: Decodable, Sendable {
|
struct CronSchedulerStatus: Decodable, Sendable {
|
||||||
|
|||||||
@@ -256,7 +256,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
TerminationSignalWatcher.shared.start()
|
TerminationSignalWatcher.shared.start()
|
||||||
NodePairingApprovalPrompter.shared.start()
|
NodePairingApprovalPrompter.shared.start()
|
||||||
|
DevicePairingApprovalPrompter.shared.start()
|
||||||
ExecApprovalsPromptServer.shared.start()
|
ExecApprovalsPromptServer.shared.start()
|
||||||
|
ExecApprovalsGatewayPrompter.shared.start()
|
||||||
MacNodeModeCoordinator.shared.start()
|
MacNodeModeCoordinator.shared.start()
|
||||||
VoiceWakeGlobalSettingsSync.shared.start()
|
VoiceWakeGlobalSettingsSync.shared.start()
|
||||||
Task { PresenceReporter.shared.start() }
|
Task { PresenceReporter.shared.start() }
|
||||||
@@ -281,7 +283,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
PresenceReporter.shared.stop()
|
PresenceReporter.shared.stop()
|
||||||
NodePairingApprovalPrompter.shared.stop()
|
NodePairingApprovalPrompter.shared.stop()
|
||||||
|
DevicePairingApprovalPrompter.shared.stop()
|
||||||
ExecApprovalsPromptServer.shared.stop()
|
ExecApprovalsPromptServer.shared.stop()
|
||||||
|
ExecApprovalsGatewayPrompter.shared.stop()
|
||||||
MacNodeModeCoordinator.shared.stop()
|
MacNodeModeCoordinator.shared.stop()
|
||||||
TerminationSignalWatcher.shared.stop()
|
TerminationSignalWatcher.shared.stop()
|
||||||
VoiceWakeGlobalSettingsSync.shared.stop()
|
VoiceWakeGlobalSettingsSync.shared.stop()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ struct MenuContent: View {
|
|||||||
private let controlChannel = ControlChannel.shared
|
private let controlChannel = ControlChannel.shared
|
||||||
private let activityStore = WorkActivityStore.shared
|
private let activityStore = WorkActivityStore.shared
|
||||||
@Bindable private var pairingPrompter = NodePairingApprovalPrompter.shared
|
@Bindable private var pairingPrompter = NodePairingApprovalPrompter.shared
|
||||||
|
@Bindable private var devicePairingPrompter = DevicePairingApprovalPrompter.shared
|
||||||
@Environment(\.openSettings) private var openSettings
|
@Environment(\.openSettings) private var openSettings
|
||||||
@State private var availableMics: [AudioInputDevice] = []
|
@State private var availableMics: [AudioInputDevice] = []
|
||||||
@State private var loadingMics = false
|
@State private var loadingMics = false
|
||||||
@@ -50,6 +51,13 @@ struct MenuContent: View {
|
|||||||
label: "Pairing approval pending (\(self.pairingPrompter.pendingCount))\(repairSuffix)",
|
label: "Pairing approval pending (\(self.pairingPrompter.pendingCount))\(repairSuffix)",
|
||||||
color: .orange)
|
color: .orange)
|
||||||
}
|
}
|
||||||
|
if self.devicePairingPrompter.pendingCount > 0 {
|
||||||
|
let repairCount = self.devicePairingPrompter.pendingRepairCount
|
||||||
|
let repairSuffix = repairCount > 0 ? " · \(repairCount) repair" : ""
|
||||||
|
self.statusLine(
|
||||||
|
label: "Device pairing pending (\(self.devicePairingPrompter.pendingCount))\(repairSuffix)",
|
||||||
|
color: .orange)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(self.state.connectionMode == .unconfigured)
|
.disabled(self.state.connectionMode == .unconfigured)
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ final class TerminationSignalWatcher {
|
|||||||
self.logger.info("received signal \(sig, privacy: .public); terminating")
|
self.logger.info("received signal \(sig, privacy: .public); terminating")
|
||||||
// Ensure any pairing prompt can't accidentally approve during shutdown.
|
// Ensure any pairing prompt can't accidentally approve during shutdown.
|
||||||
NodePairingApprovalPrompter.shared.stop()
|
NodePairingApprovalPrompter.shared.stop()
|
||||||
|
DevicePairingApprovalPrompter.shared.stop()
|
||||||
NSApp.terminate(nil)
|
NSApp.terminate(nil)
|
||||||
|
|
||||||
// Safety net: don't hang forever if something blocks termination.
|
// Safety net: don't hang forever if something blocks termination.
|
||||||
|
|||||||
360
docs/refactor/clawnet.md
Normal file
360
docs/refactor/clawnet.md
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
---
|
||||||
|
summary: "Clawnet refactor: unify network protocol, roles, auth, approvals, identity"
|
||||||
|
read_when:
|
||||||
|
- Planning a unified network protocol for nodes + operator clients
|
||||||
|
- Reworking approvals, pairing, TLS, and presence across devices
|
||||||
|
---
|
||||||
|
# Clawnet refactor (protocol + auth unification)
|
||||||
|
|
||||||
|
## Hi
|
||||||
|
Hi Peter — great direction; this unlocks simpler UX + stronger security.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Single, rigorous document for:
|
||||||
|
- Current state: protocols, flows, trust boundaries.
|
||||||
|
- Pain points: approvals, multi‑hop routing, UI duplication.
|
||||||
|
- Proposed new state: one protocol, scoped roles, unified auth/pairing, TLS pinning.
|
||||||
|
- Identity model: stable IDs + cute slugs.
|
||||||
|
- Migration plan, risks, open questions.
|
||||||
|
|
||||||
|
## Goals (from discussion)
|
||||||
|
- One protocol for all clients (mac app, CLI, iOS, Android, headless node).
|
||||||
|
- Every network participant authenticated + paired.
|
||||||
|
- Role clarity: nodes vs operators.
|
||||||
|
- Central approvals routed to where the user is.
|
||||||
|
- TLS encryption + optional pinning for all remote traffic.
|
||||||
|
- Minimal code duplication.
|
||||||
|
- Single machine should appear once (no UI/node duplicate entry).
|
||||||
|
|
||||||
|
## Non‑goals (explicit)
|
||||||
|
- Remove capability separation (still need least‑privilege).
|
||||||
|
- Expose full gateway control plane without scope checks.
|
||||||
|
- Make auth depend on human labels (slugs remain non‑security).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Current state (as‑is)
|
||||||
|
|
||||||
|
## Two protocols
|
||||||
|
|
||||||
|
### 1) Gateway WebSocket (control plane)
|
||||||
|
- Full API surface: config, channels, models, sessions, agent runs, logs, nodes, etc.
|
||||||
|
- Default bind: loopback. Remote access via SSH/Tailscale.
|
||||||
|
- Auth: token/password via `connect`.
|
||||||
|
- No TLS pinning (relies on loopback/tunnel).
|
||||||
|
- Code:
|
||||||
|
- `src/gateway/server/ws-connection/message-handler.ts`
|
||||||
|
- `src/gateway/client.ts`
|
||||||
|
- `docs/gateway/protocol.md`
|
||||||
|
|
||||||
|
### 2) Bridge (node transport)
|
||||||
|
- Narrow allowlist surface, node identity + pairing.
|
||||||
|
- JSONL over TCP; optional TLS + cert fingerprint pinning.
|
||||||
|
- TLS advertises fingerprint in discovery TXT.
|
||||||
|
- Code:
|
||||||
|
- `src/infra/bridge/server/connection.ts`
|
||||||
|
- `src/gateway/server-bridge.ts`
|
||||||
|
- `src/node-host/bridge-client.ts`
|
||||||
|
- `docs/gateway/bridge-protocol.md`
|
||||||
|
|
||||||
|
## Control plane clients today
|
||||||
|
- CLI → Gateway WS via `callGateway` (`src/gateway/call.ts`).
|
||||||
|
- macOS app UI → Gateway WS (`GatewayConnection`).
|
||||||
|
- Web Control UI → Gateway WS.
|
||||||
|
- ACP → Gateway WS.
|
||||||
|
- Browser control uses its own HTTP control server.
|
||||||
|
|
||||||
|
## Nodes today
|
||||||
|
- macOS app in node mode connects to Gateway bridge (`MacNodeBridgeSession`).
|
||||||
|
- iOS/Android apps connect to Gateway bridge.
|
||||||
|
- Pairing + per‑node token stored on gateway.
|
||||||
|
|
||||||
|
## Current approval flow (exec)
|
||||||
|
- Agent uses `system.run` via Gateway.
|
||||||
|
- Gateway invokes node over bridge.
|
||||||
|
- Node runtime decides approval.
|
||||||
|
- UI prompt shown by mac app (when node == mac app).
|
||||||
|
- Node returns `invoke-res` to Gateway.
|
||||||
|
- Multi‑hop, UI tied to node host.
|
||||||
|
|
||||||
|
## Presence + identity today
|
||||||
|
- Gateway presence entries from WS clients.
|
||||||
|
- Node presence entries from bridge.
|
||||||
|
- mac app can show two entries for same machine (UI + node).
|
||||||
|
- Node identity stored in pairing store; UI identity separate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Problems / pain points
|
||||||
|
|
||||||
|
- Two protocol stacks to maintain (WS + Bridge).
|
||||||
|
- Approvals on remote nodes: prompt appears on node host, not where user is.
|
||||||
|
- TLS pinning only exists for bridge; WS depends on SSH/Tailscale.
|
||||||
|
- Identity duplication: same machine shows as multiple instances.
|
||||||
|
- Ambiguous roles: UI + node + CLI capabilities not clearly separated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Proposed new state (Clawnet)
|
||||||
|
|
||||||
|
## One protocol, two roles
|
||||||
|
Single WS protocol with role + scope.
|
||||||
|
- **Role: node** (capability host)
|
||||||
|
- **Role: operator** (control plane)
|
||||||
|
- Optional **scope** for operator:
|
||||||
|
- `operator.read` (status + viewing)
|
||||||
|
- `operator.write` (agent run, sends)
|
||||||
|
- `operator.admin` (config, channels, models)
|
||||||
|
|
||||||
|
### Role behaviors
|
||||||
|
|
||||||
|
**Node**
|
||||||
|
- Can register capabilities (`caps`, `commands`, permissions).
|
||||||
|
- Can receive `invoke` commands (`system.run`, `camera.*`, `canvas.*`, `screen.record`, etc).
|
||||||
|
- Can send events: `voice.transcript`, `agent.request`, `chat.subscribe`.
|
||||||
|
- Cannot call config/models/channels/sessions/agent control plane APIs.
|
||||||
|
|
||||||
|
**Operator**
|
||||||
|
- Full control plane API, gated by scope.
|
||||||
|
- Receives all approvals.
|
||||||
|
- Does not directly execute OS actions; routes to nodes.
|
||||||
|
|
||||||
|
### Key rule
|
||||||
|
Role is per‑connection, not per device. A device may open both roles, separately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Unified authentication + pairing
|
||||||
|
|
||||||
|
## Client identity
|
||||||
|
Every client provides:
|
||||||
|
- `deviceId` (stable, derived from device key).
|
||||||
|
- `displayName` (human name).
|
||||||
|
- `role` + `scope` + `caps` + `commands`.
|
||||||
|
|
||||||
|
## Pairing flow (unified)
|
||||||
|
- Client connects unauthenticated.
|
||||||
|
- Gateway creates a **pairing request** for that `deviceId`.
|
||||||
|
- Operator receives prompt; approves/denies.
|
||||||
|
- Gateway issues credentials bound to:
|
||||||
|
- device public key
|
||||||
|
- role(s)
|
||||||
|
- scope(s)
|
||||||
|
- capabilities/commands
|
||||||
|
- Client persists token, reconnects authenticated.
|
||||||
|
|
||||||
|
## Device‑bound auth (avoid bearer token replay)
|
||||||
|
Preferred: device keypairs.
|
||||||
|
- Device generates keypair once.
|
||||||
|
- `deviceId = fingerprint(publicKey)`.
|
||||||
|
- Gateway sends nonce; device signs; gateway verifies.
|
||||||
|
- Tokens are issued to a public key (proof‑of‑possession), not a string.
|
||||||
|
|
||||||
|
Alternatives:
|
||||||
|
- mTLS (client certs): strongest, more ops complexity.
|
||||||
|
- Short‑lived bearer tokens only as a temporary phase (rotate + revoke early).
|
||||||
|
|
||||||
|
## Silent approval (SSH heuristic)
|
||||||
|
Define it precisely to avoid a weak link. Prefer one:
|
||||||
|
- **Local‑only**: auto‑pair when client connects via loopback/Unix socket.
|
||||||
|
- **Challenge via SSH**: gateway issues nonce; client proves SSH by fetching it.
|
||||||
|
- **Physical presence window**: after a local approval on gateway host UI, allow auto‑pair for a short window (e.g. 10 minutes).
|
||||||
|
|
||||||
|
Always log + record auto‑approvals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# TLS everywhere (dev + prod)
|
||||||
|
|
||||||
|
## Reuse existing bridge TLS
|
||||||
|
Use current TLS runtime + fingerprint pinning:
|
||||||
|
- `src/infra/bridge/server/tls.ts`
|
||||||
|
- fingerprint verification logic in `src/node-host/bridge-client.ts`
|
||||||
|
|
||||||
|
## Apply to WS
|
||||||
|
- WS server supports TLS with same cert/key + fingerprint.
|
||||||
|
- WS clients can pin fingerprint (optional).
|
||||||
|
- Discovery advertises TLS + fingerprint for all endpoints.
|
||||||
|
- Discovery is locator hints only; never a trust anchor.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
- Reduce reliance on SSH/Tailscale for confidentiality.
|
||||||
|
- Make remote mobile connections safe by default.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Approvals redesign (centralized)
|
||||||
|
|
||||||
|
## Current
|
||||||
|
Approval happens on node host (mac app node runtime). Prompt appears where node runs.
|
||||||
|
|
||||||
|
## Proposed
|
||||||
|
Approval is **gateway‑hosted**, UI delivered to operator clients.
|
||||||
|
|
||||||
|
### New flow
|
||||||
|
1) Gateway receives `system.run` intent (agent).
|
||||||
|
2) Gateway creates approval record: `approval.requested`.
|
||||||
|
3) Operator UI(s) show prompt.
|
||||||
|
4) Approval decision sent to gateway: `approval.resolve`.
|
||||||
|
5) Gateway invokes node command if approved.
|
||||||
|
6) Node executes, returns `invoke-res`.
|
||||||
|
|
||||||
|
### Approval semantics (hardening)
|
||||||
|
- Broadcast to all operators; only the active UI shows a modal (others get a toast).
|
||||||
|
- First resolution wins; gateway rejects subsequent resolves as already settled.
|
||||||
|
- Default timeout: deny after N seconds (e.g. 60s), log reason.
|
||||||
|
- Resolution requires `operator.approvals` scope.
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
- Prompt appears where user is (mac/phone).
|
||||||
|
- Consistent approvals for remote nodes.
|
||||||
|
- Node runtime stays headless; no UI dependency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Role clarity examples
|
||||||
|
|
||||||
|
## iPhone app
|
||||||
|
- **Node role** for: mic, camera, voice chat, location, push‑to‑talk.
|
||||||
|
- Optional **operator.read** for status and chat view.
|
||||||
|
- Optional **operator.write/admin** only when explicitly enabled.
|
||||||
|
|
||||||
|
## macOS app
|
||||||
|
- Operator role by default (control UI).
|
||||||
|
- Node role when “Mac node” enabled (system.run, screen, camera).
|
||||||
|
- Same deviceId for both connections → merged UI entry.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
- Operator role always.
|
||||||
|
- Scope derived by subcommand:
|
||||||
|
- `status`, `logs` → read
|
||||||
|
- `agent`, `message` → write
|
||||||
|
- `config`, `channels` → admin
|
||||||
|
- approvals + pairing → `operator.approvals` / `operator.pairing`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Identity + slugs
|
||||||
|
|
||||||
|
## Stable ID
|
||||||
|
Required for auth; never changes.
|
||||||
|
Preferred:
|
||||||
|
- Keypair fingerprint (public key hash).
|
||||||
|
|
||||||
|
## Cute slug (lobster‑themed)
|
||||||
|
Human label only.
|
||||||
|
- Example: `scarlet-claw`, `saltwave`, `mantis-pinch`.
|
||||||
|
- Stored in gateway registry, editable.
|
||||||
|
- Collision handling: `-2`, `-3`.
|
||||||
|
|
||||||
|
## UI grouping
|
||||||
|
Same `deviceId` across roles → single “Instance” row:
|
||||||
|
- Badge: `operator`, `node`.
|
||||||
|
- Shows capabilities + last seen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Migration strategy
|
||||||
|
|
||||||
|
## Phase 0: Document + align
|
||||||
|
- Publish this doc.
|
||||||
|
- Inventory all protocol calls + approval flows.
|
||||||
|
|
||||||
|
## Phase 1: Add roles/scopes to WS
|
||||||
|
- Extend `connect` params with `role`, `scope`, `deviceId`.
|
||||||
|
- Add allowlist gating for node role.
|
||||||
|
|
||||||
|
## Phase 2: Bridge compatibility
|
||||||
|
- Keep bridge running.
|
||||||
|
- Add WS node support in parallel.
|
||||||
|
- Gate features behind config flag.
|
||||||
|
|
||||||
|
## Phase 3: Central approvals
|
||||||
|
- Add approval request + resolve events in WS.
|
||||||
|
- Update mac app UI to prompt + respond.
|
||||||
|
- Node runtime stops prompting UI.
|
||||||
|
|
||||||
|
## Phase 4: TLS unification
|
||||||
|
- Add TLS config for WS using bridge TLS runtime.
|
||||||
|
- Add pinning to clients.
|
||||||
|
|
||||||
|
## Phase 5: Deprecate bridge
|
||||||
|
- Migrate iOS/Android/mac node to WS.
|
||||||
|
- Keep bridge as fallback; remove once stable.
|
||||||
|
|
||||||
|
## Phase 6: Device‑bound auth
|
||||||
|
- Require key‑based identity for all non‑local connections.
|
||||||
|
- Add revocation + rotation UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Security notes
|
||||||
|
|
||||||
|
- Role/allowlist enforced at gateway boundary.
|
||||||
|
- No client gets “full” API without operator scope.
|
||||||
|
- Pairing required for *all* connections.
|
||||||
|
- TLS + pinning reduces MITM risk for mobile.
|
||||||
|
- SSH silent approval is a convenience; still recorded + revocable.
|
||||||
|
- Discovery is never a trust anchor.
|
||||||
|
- Capability claims are verified against server allowlists by platform/type.
|
||||||
|
|
||||||
|
# Streaming + large payloads (node media)
|
||||||
|
WS control plane is fine for small messages, but nodes also do:
|
||||||
|
- camera clips
|
||||||
|
- screen recordings
|
||||||
|
- audio streams
|
||||||
|
|
||||||
|
Options:
|
||||||
|
1) WS binary frames + chunking + backpressure rules.
|
||||||
|
2) Separate streaming endpoint (still TLS + auth).
|
||||||
|
3) Keep bridge longer for media‑heavy commands, migrate last.
|
||||||
|
|
||||||
|
Pick one before implementation to avoid drift.
|
||||||
|
|
||||||
|
# Capability + command policy
|
||||||
|
- Node‑reported caps/commands are treated as **claims**.
|
||||||
|
- Gateway enforces per‑platform allowlists.
|
||||||
|
- Any new command requires operator approval or explicit allowlist change.
|
||||||
|
- Audit changes with timestamps.
|
||||||
|
|
||||||
|
# Audit + rate limiting
|
||||||
|
- Log: pairing requests, approvals/denials, token issuance/rotation/revocation.
|
||||||
|
- Rate‑limit pairing spam and approval prompts.
|
||||||
|
|
||||||
|
# Protocol hygiene
|
||||||
|
- Explicit protocol version + error codes.
|
||||||
|
- Reconnect rules + heartbeat policy.
|
||||||
|
- Presence TTL and last‑seen semantics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Open questions
|
||||||
|
|
||||||
|
1) Single device running both roles: token model
|
||||||
|
- Recommend separate tokens per role (node vs operator).
|
||||||
|
- Same deviceId; different scopes; clearer revocation.
|
||||||
|
|
||||||
|
2) Operator scope granularity
|
||||||
|
- read/write/admin + approvals + pairing (minimum viable).
|
||||||
|
- Consider per‑feature scopes later.
|
||||||
|
|
||||||
|
3) Token rotation + revocation UX
|
||||||
|
- Auto‑rotate on role change.
|
||||||
|
- UI to revoke by deviceId + role.
|
||||||
|
|
||||||
|
4) Discovery
|
||||||
|
- Extend current Bonjour TXT to include WS TLS fingerprint + role hints.
|
||||||
|
- Treat as locator hints only.
|
||||||
|
|
||||||
|
5) Cross‑network approval
|
||||||
|
- Broadcast to all operator clients; active UI shows modal.
|
||||||
|
- First response wins; gateway enforces atomicity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary (TL;DR)
|
||||||
|
|
||||||
|
- Today: WS control plane + Bridge node transport.
|
||||||
|
- Pain: approvals + duplication + two stacks.
|
||||||
|
- Proposal: one WS protocol with explicit roles + scopes, unified pairing + TLS pinning, gateway‑hosted approvals, stable device IDs + cute slugs.
|
||||||
|
- Outcome: simpler UX, stronger security, less duplication, better mobile routing.
|
||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
maxAsk,
|
maxAsk,
|
||||||
minSecurity,
|
minSecurity,
|
||||||
recordAllowlistUse,
|
recordAllowlistUse,
|
||||||
requestExecApprovalViaSocket,
|
|
||||||
resolveCommandResolution,
|
resolveCommandResolution,
|
||||||
resolveExecApprovals,
|
resolveExecApprovals,
|
||||||
} from "../infra/exec-approvals.js";
|
} from "../infra/exec-approvals.js";
|
||||||
@@ -526,20 +525,21 @@ export function createExecTool(
|
|||||||
|
|
||||||
let approvedByAsk = false;
|
let approvedByAsk = false;
|
||||||
if (requiresAsk) {
|
if (requiresAsk) {
|
||||||
|
const decisionResult = (await callGatewayTool("exec.approval.request", {}, {
|
||||||
|
command: params.command,
|
||||||
|
cwd: workdir,
|
||||||
|
host: "gateway",
|
||||||
|
security: hostSecurity,
|
||||||
|
ask: hostAsk,
|
||||||
|
agentId: defaults?.agentId,
|
||||||
|
resolvedPath: resolution?.resolvedPath ?? null,
|
||||||
|
sessionKey: defaults?.sessionKey ?? null,
|
||||||
|
timeoutMs: 120_000,
|
||||||
|
})) as { decision?: string } | null;
|
||||||
const decision =
|
const decision =
|
||||||
(await requestExecApprovalViaSocket({
|
decisionResult && typeof decisionResult === "object"
|
||||||
socketPath: approvals.socketPath,
|
? decisionResult.decision ?? null
|
||||||
token: approvals.token,
|
: null;
|
||||||
request: {
|
|
||||||
command: params.command,
|
|
||||||
cwd: workdir,
|
|
||||||
host: "gateway",
|
|
||||||
security: hostSecurity,
|
|
||||||
ask: hostAsk,
|
|
||||||
agentId: defaults?.agentId,
|
|
||||||
resolvedPath: resolution?.resolvedPath ?? null,
|
|
||||||
},
|
|
||||||
})) ?? null;
|
|
||||||
|
|
||||||
if (decision === "deny") {
|
if (decision === "deny") {
|
||||||
throw new Error("exec denied: user denied");
|
throw new Error("exec denied: user denied");
|
||||||
@@ -550,14 +550,12 @@ export function createExecTool(
|
|||||||
} else if (askFallback === "allowlist") {
|
} else if (askFallback === "allowlist") {
|
||||||
if (!allowlistMatch) {
|
if (!allowlistMatch) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"exec denied: approval required (companion app approval UI not available)",
|
"exec denied: approval required (approval UI not available)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
approvedByAsk = true;
|
approvedByAsk = true;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error("exec denied: approval required (approval UI not available)");
|
||||||
"exec denied: approval required (companion app approval UI not available)",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (decision === "allow-once") {
|
if (decision === "allow-once") {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
resolveStateDir,
|
resolveStateDir,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
||||||
|
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
||||||
import {
|
import {
|
||||||
GATEWAY_CLIENT_MODES,
|
GATEWAY_CLIENT_MODES,
|
||||||
GATEWAY_CLIENT_NAMES,
|
GATEWAY_CLIENT_NAMES,
|
||||||
@@ -186,6 +187,9 @@ export async function callGateway<T = unknown>(opts: CallGatewayOptions): Promis
|
|||||||
clientVersion: opts.clientVersion ?? "dev",
|
clientVersion: opts.clientVersion ?? "dev",
|
||||||
platform: opts.platform,
|
platform: opts.platform,
|
||||||
mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI,
|
mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI,
|
||||||
|
role: "operator",
|
||||||
|
scopes: ["operator.admin", "operator.approvals", "operator.pairing"],
|
||||||
|
deviceIdentity: loadOrCreateDeviceIdentity(),
|
||||||
minProtocol: opts.minProtocol ?? PROTOCOL_VERSION,
|
minProtocol: opts.minProtocol ?? PROTOCOL_VERSION,
|
||||||
maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION,
|
maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION,
|
||||||
onHelloOk: async () => {
|
onHelloOk: async () => {
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import { rawDataToString } from "../infra/ws.js";
|
import { rawDataToString } from "../infra/ws.js";
|
||||||
import { logDebug, logError } from "../logger.js";
|
import { logDebug, logError } from "../logger.js";
|
||||||
|
import type { DeviceIdentity } from "../infra/device-identity.js";
|
||||||
|
import { publicKeyRawBase64UrlFromPem, signDevicePayload } from "../infra/device-identity.js";
|
||||||
import {
|
import {
|
||||||
GATEWAY_CLIENT_MODES,
|
GATEWAY_CLIENT_MODES,
|
||||||
GATEWAY_CLIENT_NAMES,
|
GATEWAY_CLIENT_NAMES,
|
||||||
type GatewayClientMode,
|
type GatewayClientMode,
|
||||||
type GatewayClientName,
|
type GatewayClientName,
|
||||||
} from "../utils/message-channel.js";
|
} from "../utils/message-channel.js";
|
||||||
|
import { buildDeviceAuthPayload } from "./device-auth.js";
|
||||||
import {
|
import {
|
||||||
type ConnectParams,
|
type ConnectParams,
|
||||||
type EventFrame,
|
type EventFrame,
|
||||||
@@ -35,6 +38,9 @@ export type GatewayClientOptions = {
|
|||||||
clientVersion?: string;
|
clientVersion?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
mode?: GatewayClientMode;
|
mode?: GatewayClientMode;
|
||||||
|
role?: string;
|
||||||
|
scopes?: string[];
|
||||||
|
deviceIdentity?: DeviceIdentity;
|
||||||
minProtocol?: number;
|
minProtocol?: number;
|
||||||
maxProtocol?: number;
|
maxProtocol?: number;
|
||||||
onEvent?: (evt: EventFrame) => void;
|
onEvent?: (evt: EventFrame) => void;
|
||||||
@@ -110,6 +116,28 @@ export class GatewayClient {
|
|||||||
password: this.opts.password,
|
password: this.opts.password,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const signedAtMs = Date.now();
|
||||||
|
const role = this.opts.role ?? "operator";
|
||||||
|
const scopes = this.opts.scopes ?? ["operator.admin"];
|
||||||
|
const device = (() => {
|
||||||
|
if (!this.opts.deviceIdentity) return undefined;
|
||||||
|
const payload = buildDeviceAuthPayload({
|
||||||
|
deviceId: this.opts.deviceIdentity.deviceId,
|
||||||
|
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
||||||
|
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
|
||||||
|
role,
|
||||||
|
scopes,
|
||||||
|
signedAtMs,
|
||||||
|
token: this.opts.token ?? null,
|
||||||
|
});
|
||||||
|
const signature = signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload);
|
||||||
|
return {
|
||||||
|
id: this.opts.deviceIdentity.deviceId,
|
||||||
|
publicKey: publicKeyRawBase64UrlFromPem(this.opts.deviceIdentity.publicKeyPem),
|
||||||
|
signature,
|
||||||
|
signedAt: signedAtMs,
|
||||||
|
};
|
||||||
|
})();
|
||||||
const params: ConnectParams = {
|
const params: ConnectParams = {
|
||||||
minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION,
|
minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION,
|
||||||
maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
|
maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
|
||||||
@@ -123,6 +151,9 @@ export class GatewayClient {
|
|||||||
},
|
},
|
||||||
caps: [],
|
caps: [],
|
||||||
auth,
|
auth,
|
||||||
|
role,
|
||||||
|
scopes,
|
||||||
|
device,
|
||||||
};
|
};
|
||||||
|
|
||||||
void this.request<HelloOk>("connect", params)
|
void this.request<HelloOk>("connect", params)
|
||||||
|
|||||||
24
src/gateway/device-auth.ts
Normal file
24
src/gateway/device-auth.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export type DeviceAuthPayloadParams = {
|
||||||
|
deviceId: string;
|
||||||
|
clientId: string;
|
||||||
|
clientMode: string;
|
||||||
|
role: string;
|
||||||
|
scopes: string[];
|
||||||
|
signedAtMs: number;
|
||||||
|
token?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string {
|
||||||
|
const scopes = params.scopes.join(",");
|
||||||
|
const token = params.token ?? "";
|
||||||
|
return [
|
||||||
|
"v1",
|
||||||
|
params.deviceId,
|
||||||
|
params.clientId,
|
||||||
|
params.clientMode,
|
||||||
|
params.role,
|
||||||
|
scopes,
|
||||||
|
String(params.signedAtMs),
|
||||||
|
token,
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
74
src/gateway/exec-approval-manager.ts
Normal file
74
src/gateway/exec-approval-manager.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
import type { ExecApprovalDecision } from "../infra/exec-approvals.js";
|
||||||
|
|
||||||
|
export type ExecApprovalRequestPayload = {
|
||||||
|
command: string;
|
||||||
|
cwd?: string | null;
|
||||||
|
host?: string | null;
|
||||||
|
security?: string | null;
|
||||||
|
ask?: string | null;
|
||||||
|
agentId?: string | null;
|
||||||
|
resolvedPath?: string | null;
|
||||||
|
sessionKey?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExecApprovalRecord = {
|
||||||
|
id: string;
|
||||||
|
request: ExecApprovalRequestPayload;
|
||||||
|
createdAtMs: number;
|
||||||
|
expiresAtMs: number;
|
||||||
|
resolvedAtMs?: number;
|
||||||
|
decision?: ExecApprovalDecision;
|
||||||
|
resolvedBy?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingEntry = {
|
||||||
|
record: ExecApprovalRecord;
|
||||||
|
resolve: (decision: ExecApprovalDecision) => void;
|
||||||
|
reject: (err: Error) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ExecApprovalManager {
|
||||||
|
private pending = new Map<string, PendingEntry>();
|
||||||
|
|
||||||
|
create(request: ExecApprovalRequestPayload, timeoutMs: number): ExecApprovalRecord {
|
||||||
|
const now = Date.now();
|
||||||
|
const id = randomUUID();
|
||||||
|
const record: ExecApprovalRecord = {
|
||||||
|
id,
|
||||||
|
request,
|
||||||
|
createdAtMs: now,
|
||||||
|
expiresAtMs: now + timeoutMs,
|
||||||
|
};
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForDecision(record: ExecApprovalRecord, timeoutMs: number): Promise<ExecApprovalDecision> {
|
||||||
|
return await new Promise<ExecApprovalDecision>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pending.delete(record.id);
|
||||||
|
resolve("deny");
|
||||||
|
}, timeoutMs);
|
||||||
|
this.pending.set(record.id, { record, resolve, reject, timer });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(recordId: string, decision: ExecApprovalDecision, resolvedBy?: string | null): boolean {
|
||||||
|
const pending = this.pending.get(recordId);
|
||||||
|
if (!pending) return false;
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
pending.record.resolvedAtMs = Date.now();
|
||||||
|
pending.record.decision = decision;
|
||||||
|
pending.record.resolvedBy = resolvedBy ?? null;
|
||||||
|
this.pending.delete(recordId);
|
||||||
|
pending.resolve(decision);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnapshot(recordId: string): ExecApprovalRecord | null {
|
||||||
|
const entry = this.pending.get(recordId);
|
||||||
|
return entry?.record ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,12 @@ import {
|
|||||||
CronStatusParamsSchema,
|
CronStatusParamsSchema,
|
||||||
type CronUpdateParams,
|
type CronUpdateParams,
|
||||||
CronUpdateParamsSchema,
|
CronUpdateParamsSchema,
|
||||||
|
type DevicePairApproveParams,
|
||||||
|
DevicePairApproveParamsSchema,
|
||||||
|
type DevicePairListParams,
|
||||||
|
DevicePairListParamsSchema,
|
||||||
|
type DevicePairRejectParams,
|
||||||
|
DevicePairRejectParamsSchema,
|
||||||
type ExecApprovalsGetParams,
|
type ExecApprovalsGetParams,
|
||||||
ExecApprovalsGetParamsSchema,
|
ExecApprovalsGetParamsSchema,
|
||||||
type ExecApprovalsNodeGetParams,
|
type ExecApprovalsNodeGetParams,
|
||||||
@@ -65,6 +71,10 @@ import {
|
|||||||
type ExecApprovalsSetParams,
|
type ExecApprovalsSetParams,
|
||||||
ExecApprovalsSetParamsSchema,
|
ExecApprovalsSetParamsSchema,
|
||||||
type ExecApprovalsSnapshot,
|
type ExecApprovalsSnapshot,
|
||||||
|
type ExecApprovalRequestParams,
|
||||||
|
ExecApprovalRequestParamsSchema,
|
||||||
|
type ExecApprovalResolveParams,
|
||||||
|
ExecApprovalResolveParamsSchema,
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
type ErrorShape,
|
type ErrorShape,
|
||||||
ErrorShapeSchema,
|
ErrorShapeSchema,
|
||||||
@@ -239,12 +249,27 @@ export const validateCronUpdateParams = ajv.compile<CronUpdateParams>(CronUpdate
|
|||||||
export const validateCronRemoveParams = ajv.compile<CronRemoveParams>(CronRemoveParamsSchema);
|
export const validateCronRemoveParams = ajv.compile<CronRemoveParams>(CronRemoveParamsSchema);
|
||||||
export const validateCronRunParams = ajv.compile<CronRunParams>(CronRunParamsSchema);
|
export const validateCronRunParams = ajv.compile<CronRunParams>(CronRunParamsSchema);
|
||||||
export const validateCronRunsParams = ajv.compile<CronRunsParams>(CronRunsParamsSchema);
|
export const validateCronRunsParams = ajv.compile<CronRunsParams>(CronRunsParamsSchema);
|
||||||
|
export const validateDevicePairListParams = ajv.compile<DevicePairListParams>(
|
||||||
|
DevicePairListParamsSchema,
|
||||||
|
);
|
||||||
|
export const validateDevicePairApproveParams = ajv.compile<DevicePairApproveParams>(
|
||||||
|
DevicePairApproveParamsSchema,
|
||||||
|
);
|
||||||
|
export const validateDevicePairRejectParams = ajv.compile<DevicePairRejectParams>(
|
||||||
|
DevicePairRejectParamsSchema,
|
||||||
|
);
|
||||||
export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams>(
|
export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams>(
|
||||||
ExecApprovalsGetParamsSchema,
|
ExecApprovalsGetParamsSchema,
|
||||||
);
|
);
|
||||||
export const validateExecApprovalsSetParams = ajv.compile<ExecApprovalsSetParams>(
|
export const validateExecApprovalsSetParams = ajv.compile<ExecApprovalsSetParams>(
|
||||||
ExecApprovalsSetParamsSchema,
|
ExecApprovalsSetParamsSchema,
|
||||||
);
|
);
|
||||||
|
export const validateExecApprovalRequestParams = ajv.compile<ExecApprovalRequestParams>(
|
||||||
|
ExecApprovalRequestParamsSchema,
|
||||||
|
);
|
||||||
|
export const validateExecApprovalResolveParams = ajv.compile<ExecApprovalResolveParams>(
|
||||||
|
ExecApprovalResolveParamsSchema,
|
||||||
|
);
|
||||||
export const validateExecApprovalsNodeGetParams = ajv.compile<ExecApprovalsNodeGetParams>(
|
export const validateExecApprovalsNodeGetParams = ajv.compile<ExecApprovalsNodeGetParams>(
|
||||||
ExecApprovalsNodeGetParamsSchema,
|
ExecApprovalsNodeGetParamsSchema,
|
||||||
);
|
);
|
||||||
@@ -364,6 +389,9 @@ export type {
|
|||||||
NodePairRequestParams,
|
NodePairRequestParams,
|
||||||
NodePairListParams,
|
NodePairListParams,
|
||||||
NodePairApproveParams,
|
NodePairApproveParams,
|
||||||
|
DevicePairListParams,
|
||||||
|
DevicePairApproveParams,
|
||||||
|
DevicePairRejectParams,
|
||||||
ConfigGetParams,
|
ConfigGetParams,
|
||||||
ConfigSetParams,
|
ConfigSetParams,
|
||||||
ConfigApplyParams,
|
ConfigApplyParams,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export * from "./schema/config.js";
|
|||||||
export * from "./schema/cron.js";
|
export * from "./schema/cron.js";
|
||||||
export * from "./schema/error-codes.js";
|
export * from "./schema/error-codes.js";
|
||||||
export * from "./schema/exec-approvals.js";
|
export * from "./schema/exec-approvals.js";
|
||||||
|
export * from "./schema/devices.js";
|
||||||
export * from "./schema/frames.js";
|
export * from "./schema/frames.js";
|
||||||
export * from "./schema/logs-chat.js";
|
export * from "./schema/logs-chat.js";
|
||||||
export * from "./schema/nodes.js";
|
export * from "./schema/nodes.js";
|
||||||
|
|||||||
44
src/gateway/protocol/schema/devices.ts
Normal file
44
src/gateway/protocol/schema/devices.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
import { NonEmptyString } from "./primitives.js";
|
||||||
|
|
||||||
|
export const DevicePairListParamsSchema = Type.Object({}, { additionalProperties: false });
|
||||||
|
|
||||||
|
export const DevicePairApproveParamsSchema = Type.Object(
|
||||||
|
{ requestId: NonEmptyString },
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DevicePairRejectParamsSchema = Type.Object(
|
||||||
|
{ requestId: NonEmptyString },
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DevicePairRequestedEventSchema = Type.Object(
|
||||||
|
{
|
||||||
|
requestId: NonEmptyString,
|
||||||
|
deviceId: NonEmptyString,
|
||||||
|
publicKey: NonEmptyString,
|
||||||
|
displayName: Type.Optional(NonEmptyString),
|
||||||
|
platform: Type.Optional(NonEmptyString),
|
||||||
|
clientId: Type.Optional(NonEmptyString),
|
||||||
|
clientMode: Type.Optional(NonEmptyString),
|
||||||
|
role: Type.Optional(NonEmptyString),
|
||||||
|
scopes: Type.Optional(Type.Array(NonEmptyString)),
|
||||||
|
remoteIp: Type.Optional(NonEmptyString),
|
||||||
|
silent: Type.Optional(Type.Boolean()),
|
||||||
|
isRepair: Type.Optional(Type.Boolean()),
|
||||||
|
ts: Type.Integer({ minimum: 0 }),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DevicePairResolvedEventSchema = Type.Object(
|
||||||
|
{
|
||||||
|
requestId: NonEmptyString,
|
||||||
|
deviceId: NonEmptyString,
|
||||||
|
decision: NonEmptyString,
|
||||||
|
ts: Type.Integer({ minimum: 0 }),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
@@ -2,6 +2,7 @@ import type { ErrorShape } from "./types.js";
|
|||||||
|
|
||||||
export const ErrorCodes = {
|
export const ErrorCodes = {
|
||||||
NOT_LINKED: "NOT_LINKED",
|
NOT_LINKED: "NOT_LINKED",
|
||||||
|
NOT_PAIRED: "NOT_PAIRED",
|
||||||
AGENT_TIMEOUT: "AGENT_TIMEOUT",
|
AGENT_TIMEOUT: "AGENT_TIMEOUT",
|
||||||
INVALID_REQUEST: "INVALID_REQUEST",
|
INVALID_REQUEST: "INVALID_REQUEST",
|
||||||
UNAVAILABLE: "UNAVAILABLE",
|
UNAVAILABLE: "UNAVAILABLE",
|
||||||
|
|||||||
@@ -86,3 +86,26 @@ export const ExecApprovalsNodeSetParamsSchema = Type.Object(
|
|||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const ExecApprovalRequestParamsSchema = Type.Object(
|
||||||
|
{
|
||||||
|
command: NonEmptyString,
|
||||||
|
cwd: Type.Optional(Type.String()),
|
||||||
|
host: Type.Optional(Type.String()),
|
||||||
|
security: Type.Optional(Type.String()),
|
||||||
|
ask: Type.Optional(Type.String()),
|
||||||
|
agentId: Type.Optional(Type.String()),
|
||||||
|
resolvedPath: Type.Optional(Type.String()),
|
||||||
|
sessionKey: Type.Optional(Type.String()),
|
||||||
|
timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ExecApprovalResolveParamsSchema = Type.Object(
|
||||||
|
{
|
||||||
|
id: NonEmptyString,
|
||||||
|
decision: NonEmptyString,
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|||||||
@@ -35,6 +35,19 @@ export const ConnectParamsSchema = Type.Object(
|
|||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
),
|
),
|
||||||
caps: Type.Optional(Type.Array(NonEmptyString, { default: [] })),
|
caps: Type.Optional(Type.Array(NonEmptyString, { default: [] })),
|
||||||
|
role: Type.Optional(NonEmptyString),
|
||||||
|
scopes: Type.Optional(Type.Array(NonEmptyString)),
|
||||||
|
device: Type.Optional(
|
||||||
|
Type.Object(
|
||||||
|
{
|
||||||
|
id: NonEmptyString,
|
||||||
|
publicKey: NonEmptyString,
|
||||||
|
signature: NonEmptyString,
|
||||||
|
signedAt: Type.Integer({ minimum: 0 }),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
),
|
||||||
|
),
|
||||||
auth: Type.Optional(
|
auth: Type.Optional(
|
||||||
Type.Object(
|
Type.Object(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -53,7 +53,16 @@ import {
|
|||||||
ExecApprovalsNodeSetParamsSchema,
|
ExecApprovalsNodeSetParamsSchema,
|
||||||
ExecApprovalsSetParamsSchema,
|
ExecApprovalsSetParamsSchema,
|
||||||
ExecApprovalsSnapshotSchema,
|
ExecApprovalsSnapshotSchema,
|
||||||
|
ExecApprovalRequestParamsSchema,
|
||||||
|
ExecApprovalResolveParamsSchema,
|
||||||
} from "./exec-approvals.js";
|
} from "./exec-approvals.js";
|
||||||
|
import {
|
||||||
|
DevicePairApproveParamsSchema,
|
||||||
|
DevicePairListParamsSchema,
|
||||||
|
DevicePairRejectParamsSchema,
|
||||||
|
DevicePairRequestedEventSchema,
|
||||||
|
DevicePairResolvedEventSchema,
|
||||||
|
} from "./devices.js";
|
||||||
import {
|
import {
|
||||||
ConnectParamsSchema,
|
ConnectParamsSchema,
|
||||||
ErrorShapeSchema,
|
ErrorShapeSchema,
|
||||||
@@ -182,6 +191,13 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||||||
ExecApprovalsNodeGetParams: ExecApprovalsNodeGetParamsSchema,
|
ExecApprovalsNodeGetParams: ExecApprovalsNodeGetParamsSchema,
|
||||||
ExecApprovalsNodeSetParams: ExecApprovalsNodeSetParamsSchema,
|
ExecApprovalsNodeSetParams: ExecApprovalsNodeSetParamsSchema,
|
||||||
ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema,
|
ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema,
|
||||||
|
ExecApprovalRequestParams: ExecApprovalRequestParamsSchema,
|
||||||
|
ExecApprovalResolveParams: ExecApprovalResolveParamsSchema,
|
||||||
|
DevicePairListParams: DevicePairListParamsSchema,
|
||||||
|
DevicePairApproveParams: DevicePairApproveParamsSchema,
|
||||||
|
DevicePairRejectParams: DevicePairRejectParamsSchema,
|
||||||
|
DevicePairRequestedEvent: DevicePairRequestedEventSchema,
|
||||||
|
DevicePairResolvedEvent: DevicePairResolvedEventSchema,
|
||||||
ChatHistoryParams: ChatHistoryParamsSchema,
|
ChatHistoryParams: ChatHistoryParamsSchema,
|
||||||
ChatSendParams: ChatSendParamsSchema,
|
ChatSendParams: ChatSendParamsSchema,
|
||||||
ChatAbortParams: ChatAbortParamsSchema,
|
ChatAbortParams: ChatAbortParamsSchema,
|
||||||
|
|||||||
@@ -51,7 +51,14 @@ import type {
|
|||||||
ExecApprovalsNodeSetParamsSchema,
|
ExecApprovalsNodeSetParamsSchema,
|
||||||
ExecApprovalsSetParamsSchema,
|
ExecApprovalsSetParamsSchema,
|
||||||
ExecApprovalsSnapshotSchema,
|
ExecApprovalsSnapshotSchema,
|
||||||
|
ExecApprovalRequestParamsSchema,
|
||||||
|
ExecApprovalResolveParamsSchema,
|
||||||
} from "./exec-approvals.js";
|
} from "./exec-approvals.js";
|
||||||
|
import type {
|
||||||
|
DevicePairApproveParamsSchema,
|
||||||
|
DevicePairListParamsSchema,
|
||||||
|
DevicePairRejectParamsSchema,
|
||||||
|
} from "./devices.js";
|
||||||
import type {
|
import type {
|
||||||
ConnectParamsSchema,
|
ConnectParamsSchema,
|
||||||
ErrorShapeSchema,
|
ErrorShapeSchema,
|
||||||
@@ -175,6 +182,11 @@ export type ExecApprovalsSetParams = Static<typeof ExecApprovalsSetParamsSchema>
|
|||||||
export type ExecApprovalsNodeGetParams = Static<typeof ExecApprovalsNodeGetParamsSchema>;
|
export type ExecApprovalsNodeGetParams = Static<typeof ExecApprovalsNodeGetParamsSchema>;
|
||||||
export type ExecApprovalsNodeSetParams = Static<typeof ExecApprovalsNodeSetParamsSchema>;
|
export type ExecApprovalsNodeSetParams = Static<typeof ExecApprovalsNodeSetParamsSchema>;
|
||||||
export type ExecApprovalsSnapshot = Static<typeof ExecApprovalsSnapshotSchema>;
|
export type ExecApprovalsSnapshot = Static<typeof ExecApprovalsSnapshotSchema>;
|
||||||
|
export type ExecApprovalRequestParams = Static<typeof ExecApprovalRequestParamsSchema>;
|
||||||
|
export type ExecApprovalResolveParams = Static<typeof ExecApprovalResolveParamsSchema>;
|
||||||
|
export type DevicePairListParams = Static<typeof DevicePairListParamsSchema>;
|
||||||
|
export type DevicePairApproveParams = Static<typeof DevicePairApproveParamsSchema>;
|
||||||
|
export type DevicePairRejectParams = Static<typeof DevicePairRejectParamsSchema>;
|
||||||
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
|
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
|
||||||
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
|
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
|
||||||
export type ChatEvent = Static<typeof ChatEventSchema>;
|
export type ChatEvent = Static<typeof ChatEventSchema>;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const BASE_METHODS = [
|
|||||||
"exec.approvals.set",
|
"exec.approvals.set",
|
||||||
"exec.approvals.node.get",
|
"exec.approvals.node.get",
|
||||||
"exec.approvals.node.set",
|
"exec.approvals.node.set",
|
||||||
|
"exec.approval.request",
|
||||||
|
"exec.approval.resolve",
|
||||||
"wizard.start",
|
"wizard.start",
|
||||||
"wizard.next",
|
"wizard.next",
|
||||||
"wizard.cancel",
|
"wizard.cancel",
|
||||||
@@ -43,6 +45,9 @@ const BASE_METHODS = [
|
|||||||
"node.pair.approve",
|
"node.pair.approve",
|
||||||
"node.pair.reject",
|
"node.pair.reject",
|
||||||
"node.pair.verify",
|
"node.pair.verify",
|
||||||
|
"device.pair.list",
|
||||||
|
"device.pair.approve",
|
||||||
|
"device.pair.reject",
|
||||||
"node.rename",
|
"node.rename",
|
||||||
"node.list",
|
"node.list",
|
||||||
"node.describe",
|
"node.describe",
|
||||||
@@ -82,5 +87,9 @@ export const GATEWAY_EVENTS = [
|
|||||||
"cron",
|
"cron",
|
||||||
"node.pair.requested",
|
"node.pair.requested",
|
||||||
"node.pair.resolved",
|
"node.pair.resolved",
|
||||||
|
"device.pair.requested",
|
||||||
|
"device.pair.resolved",
|
||||||
"voicewake.changed",
|
"voicewake.changed",
|
||||||
|
"exec.approval.requested",
|
||||||
|
"exec.approval.resolved",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { chatHandlers } from "./server-methods/chat.js";
|
|||||||
import { configHandlers } from "./server-methods/config.js";
|
import { configHandlers } from "./server-methods/config.js";
|
||||||
import { connectHandlers } from "./server-methods/connect.js";
|
import { connectHandlers } from "./server-methods/connect.js";
|
||||||
import { cronHandlers } from "./server-methods/cron.js";
|
import { cronHandlers } from "./server-methods/cron.js";
|
||||||
|
import { deviceHandlers } from "./server-methods/devices.js";
|
||||||
import { execApprovalsHandlers } from "./server-methods/exec-approvals.js";
|
import { execApprovalsHandlers } from "./server-methods/exec-approvals.js";
|
||||||
import { healthHandlers } from "./server-methods/health.js";
|
import { healthHandlers } from "./server-methods/health.js";
|
||||||
import { logsHandlers } from "./server-methods/logs.js";
|
import { logsHandlers } from "./server-methods/logs.js";
|
||||||
@@ -23,6 +24,43 @@ import { voicewakeHandlers } from "./server-methods/voicewake.js";
|
|||||||
import { webHandlers } from "./server-methods/web.js";
|
import { webHandlers } from "./server-methods/web.js";
|
||||||
import { wizardHandlers } from "./server-methods/wizard.js";
|
import { wizardHandlers } from "./server-methods/wizard.js";
|
||||||
|
|
||||||
|
const ADMIN_SCOPE = "operator.admin";
|
||||||
|
const APPROVALS_SCOPE = "operator.approvals";
|
||||||
|
const PAIRING_SCOPE = "operator.pairing";
|
||||||
|
|
||||||
|
const APPROVAL_METHODS = new Set(["exec.approval.request", "exec.approval.resolve"]);
|
||||||
|
const PAIRING_METHODS = new Set([
|
||||||
|
"node.pair.request",
|
||||||
|
"node.pair.list",
|
||||||
|
"node.pair.approve",
|
||||||
|
"node.pair.reject",
|
||||||
|
"node.pair.verify",
|
||||||
|
"device.pair.list",
|
||||||
|
"device.pair.approve",
|
||||||
|
"device.pair.reject",
|
||||||
|
]);
|
||||||
|
const ADMIN_METHOD_PREFIXES = ["exec.approvals."];
|
||||||
|
|
||||||
|
function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) {
|
||||||
|
if (!client?.connect) return null;
|
||||||
|
const role = client.connect.role ?? "operator";
|
||||||
|
const scopes = client.connect.scopes ?? [];
|
||||||
|
if (role !== "operator") {
|
||||||
|
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
|
||||||
|
}
|
||||||
|
if (scopes.includes(ADMIN_SCOPE)) return null;
|
||||||
|
if (APPROVAL_METHODS.has(method) && !scopes.includes(APPROVALS_SCOPE)) {
|
||||||
|
return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.approvals");
|
||||||
|
}
|
||||||
|
if (PAIRING_METHODS.has(method) && !scopes.includes(PAIRING_SCOPE)) {
|
||||||
|
return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.pairing");
|
||||||
|
}
|
||||||
|
if (ADMIN_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix))) {
|
||||||
|
return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export const coreGatewayHandlers: GatewayRequestHandlers = {
|
export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||||
...connectHandlers,
|
...connectHandlers,
|
||||||
...logsHandlers,
|
...logsHandlers,
|
||||||
@@ -31,6 +69,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
|||||||
...channelsHandlers,
|
...channelsHandlers,
|
||||||
...chatHandlers,
|
...chatHandlers,
|
||||||
...cronHandlers,
|
...cronHandlers,
|
||||||
|
...deviceHandlers,
|
||||||
...execApprovalsHandlers,
|
...execApprovalsHandlers,
|
||||||
...webHandlers,
|
...webHandlers,
|
||||||
...modelsHandlers,
|
...modelsHandlers,
|
||||||
@@ -52,6 +91,11 @@ export async function handleGatewayRequest(
|
|||||||
opts: GatewayRequestOptions & { extraHandlers?: GatewayRequestHandlers },
|
opts: GatewayRequestOptions & { extraHandlers?: GatewayRequestHandlers },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { req, respond, client, isWebchatConnect, context } = opts;
|
const { req, respond, client, isWebchatConnect, context } = opts;
|
||||||
|
const authError = authorizeGatewayMethod(req.method, client);
|
||||||
|
if (authError) {
|
||||||
|
respond(false, undefined, authError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const handler = opts.extraHandlers?.[req.method] ?? coreGatewayHandlers[req.method];
|
const handler = opts.extraHandlers?.[req.method] ?? coreGatewayHandlers[req.method];
|
||||||
if (!handler) {
|
if (!handler) {
|
||||||
respond(
|
respond(
|
||||||
|
|||||||
98
src/gateway/server-methods/devices.ts
Normal file
98
src/gateway/server-methods/devices.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
approveDevicePairing,
|
||||||
|
listDevicePairing,
|
||||||
|
rejectDevicePairing,
|
||||||
|
} from "../../infra/device-pairing.js";
|
||||||
|
import {
|
||||||
|
ErrorCodes,
|
||||||
|
errorShape,
|
||||||
|
formatValidationErrors,
|
||||||
|
validateDevicePairApproveParams,
|
||||||
|
validateDevicePairListParams,
|
||||||
|
validateDevicePairRejectParams,
|
||||||
|
} from "../protocol/index.js";
|
||||||
|
import type { GatewayRequestHandlers } from "./types.js";
|
||||||
|
|
||||||
|
export const deviceHandlers: GatewayRequestHandlers = {
|
||||||
|
"device.pair.list": async ({ params, respond }) => {
|
||||||
|
if (!validateDevicePairListParams(params)) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`invalid device.pair.list params: ${formatValidationErrors(
|
||||||
|
validateDevicePairListParams.errors,
|
||||||
|
)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = await listDevicePairing();
|
||||||
|
respond(true, list, undefined);
|
||||||
|
},
|
||||||
|
"device.pair.approve": async ({ params, respond, context }) => {
|
||||||
|
if (!validateDevicePairApproveParams(params)) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`invalid device.pair.approve params: ${formatValidationErrors(
|
||||||
|
validateDevicePairApproveParams.errors,
|
||||||
|
)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { requestId } = params as { requestId: string };
|
||||||
|
const approved = await approveDevicePairing(requestId);
|
||||||
|
if (!approved) {
|
||||||
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.broadcast(
|
||||||
|
"device.pair.resolved",
|
||||||
|
{
|
||||||
|
requestId,
|
||||||
|
deviceId: approved.device.deviceId,
|
||||||
|
decision: "approved",
|
||||||
|
ts: Date.now(),
|
||||||
|
},
|
||||||
|
{ dropIfSlow: true },
|
||||||
|
);
|
||||||
|
respond(true, approved, undefined);
|
||||||
|
},
|
||||||
|
"device.pair.reject": async ({ params, respond, context }) => {
|
||||||
|
if (!validateDevicePairRejectParams(params)) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`invalid device.pair.reject params: ${formatValidationErrors(
|
||||||
|
validateDevicePairRejectParams.errors,
|
||||||
|
)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { requestId } = params as { requestId: string };
|
||||||
|
const rejected = await rejectDevicePairing(requestId);
|
||||||
|
if (!rejected) {
|
||||||
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.broadcast(
|
||||||
|
"device.pair.resolved",
|
||||||
|
{
|
||||||
|
requestId,
|
||||||
|
deviceId: rejected.deviceId,
|
||||||
|
decision: "rejected",
|
||||||
|
ts: Date.now(),
|
||||||
|
},
|
||||||
|
{ dropIfSlow: true },
|
||||||
|
);
|
||||||
|
respond(true, rejected, undefined);
|
||||||
|
},
|
||||||
|
};
|
||||||
105
src/gateway/server-methods/exec-approval.ts
Normal file
105
src/gateway/server-methods/exec-approval.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import type { ExecApprovalDecision } from "../../infra/exec-approvals.js";
|
||||||
|
import type { ExecApprovalManager } from "../exec-approval-manager.js";
|
||||||
|
import {
|
||||||
|
ErrorCodes,
|
||||||
|
errorShape,
|
||||||
|
formatValidationErrors,
|
||||||
|
validateExecApprovalRequestParams,
|
||||||
|
validateExecApprovalResolveParams,
|
||||||
|
} from "../protocol/index.js";
|
||||||
|
import type { GatewayRequestHandlers } from "./types.js";
|
||||||
|
|
||||||
|
export function createExecApprovalHandlers(
|
||||||
|
manager: ExecApprovalManager,
|
||||||
|
): GatewayRequestHandlers {
|
||||||
|
return {
|
||||||
|
"exec.approval.request": async ({ params, respond, context }) => {
|
||||||
|
if (!validateExecApprovalRequestParams(params)) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`invalid exec.approval.request params: ${formatValidationErrors(
|
||||||
|
validateExecApprovalRequestParams.errors,
|
||||||
|
)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = params as {
|
||||||
|
command: string;
|
||||||
|
cwd?: string;
|
||||||
|
host?: string;
|
||||||
|
security?: string;
|
||||||
|
ask?: string;
|
||||||
|
agentId?: string;
|
||||||
|
resolvedPath?: string;
|
||||||
|
sessionKey?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
const timeoutMs = typeof p.timeoutMs === "number" ? p.timeoutMs : 120_000;
|
||||||
|
const request = {
|
||||||
|
command: p.command,
|
||||||
|
cwd: p.cwd ?? null,
|
||||||
|
host: p.host ?? null,
|
||||||
|
security: p.security ?? null,
|
||||||
|
ask: p.ask ?? null,
|
||||||
|
agentId: p.agentId ?? null,
|
||||||
|
resolvedPath: p.resolvedPath ?? null,
|
||||||
|
sessionKey: p.sessionKey ?? null,
|
||||||
|
};
|
||||||
|
const record = manager.create(request, timeoutMs);
|
||||||
|
context.broadcast(
|
||||||
|
"exec.approval.requested",
|
||||||
|
{
|
||||||
|
id: record.id,
|
||||||
|
request: record.request,
|
||||||
|
createdAtMs: record.createdAtMs,
|
||||||
|
expiresAtMs: record.expiresAtMs,
|
||||||
|
},
|
||||||
|
{ dropIfSlow: true },
|
||||||
|
);
|
||||||
|
const decision = await manager.waitForDecision(record, timeoutMs);
|
||||||
|
respond(true, {
|
||||||
|
id: record.id,
|
||||||
|
decision,
|
||||||
|
createdAtMs: record.createdAtMs,
|
||||||
|
expiresAtMs: record.expiresAtMs,
|
||||||
|
}, undefined);
|
||||||
|
},
|
||||||
|
"exec.approval.resolve": async ({ params, respond, client, context }) => {
|
||||||
|
if (!validateExecApprovalResolveParams(params)) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`invalid exec.approval.resolve params: ${formatValidationErrors(
|
||||||
|
validateExecApprovalResolveParams.errors,
|
||||||
|
)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = params as { id: string; decision: string };
|
||||||
|
const decision = p.decision as ExecApprovalDecision;
|
||||||
|
if (decision !== "allow-once" && decision !== "allow-always" && decision !== "deny") {
|
||||||
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolvedBy = client?.connect?.client?.displayName ?? client?.connect?.client?.id;
|
||||||
|
const ok = manager.resolve(p.id, decision, resolvedBy ?? null);
|
||||||
|
if (!ok) {
|
||||||
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown approval id"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.broadcast(
|
||||||
|
"exec.approval.resolved",
|
||||||
|
{ id: p.id, decision, resolvedBy, ts: Date.now() },
|
||||||
|
{ dropIfSlow: true },
|
||||||
|
);
|
||||||
|
respond(true, { ok: true }, undefined);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -37,6 +37,8 @@ import {
|
|||||||
refreshGatewayHealthSnapshot,
|
refreshGatewayHealthSnapshot,
|
||||||
} from "./server/health-state.js";
|
} from "./server/health-state.js";
|
||||||
import { startGatewayBridgeRuntime } from "./server-bridge-runtime.js";
|
import { startGatewayBridgeRuntime } from "./server-bridge-runtime.js";
|
||||||
|
import { ExecApprovalManager } from "./exec-approval-manager.js";
|
||||||
|
import { createExecApprovalHandlers } from "./server-methods/exec-approval.js";
|
||||||
import type { startBrowserControlServerIfEnabled } from "./server-browser.js";
|
import type { startBrowserControlServerIfEnabled } from "./server-browser.js";
|
||||||
import { createChannelManager } from "./server-channels.js";
|
import { createChannelManager } from "./server-channels.js";
|
||||||
import { createAgentEventHandler } from "./server-chat.js";
|
import { createAgentEventHandler } from "./server-chat.js";
|
||||||
@@ -351,6 +353,9 @@ export async function startGatewayServer(
|
|||||||
|
|
||||||
void cron.start().catch((err) => logCron.error(`failed to start: ${String(err)}`));
|
void cron.start().catch((err) => logCron.error(`failed to start: ${String(err)}`));
|
||||||
|
|
||||||
|
const execApprovalManager = new ExecApprovalManager();
|
||||||
|
const execApprovalHandlers = createExecApprovalHandlers(execApprovalManager);
|
||||||
|
|
||||||
attachGatewayWsHandlers({
|
attachGatewayWsHandlers({
|
||||||
wss,
|
wss,
|
||||||
clients,
|
clients,
|
||||||
@@ -364,7 +369,10 @@ export async function startGatewayServer(
|
|||||||
logGateway: log,
|
logGateway: log,
|
||||||
logHealth,
|
logHealth,
|
||||||
logWsControl,
|
logWsControl,
|
||||||
extraHandlers: pluginRegistry.gatewayHandlers,
|
extraHandlers: {
|
||||||
|
...pluginRegistry.gatewayHandlers,
|
||||||
|
...execApprovalHandlers,
|
||||||
|
},
|
||||||
broadcast,
|
broadcast,
|
||||||
context: {
|
context: {
|
||||||
deps,
|
deps,
|
||||||
|
|||||||
@@ -2,12 +2,24 @@ import type { IncomingMessage } from "node:http";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
|
||||||
import type { WebSocket } from "ws";
|
import type { WebSocket } from "ws";
|
||||||
|
import {
|
||||||
|
deriveDeviceIdFromPublicKey,
|
||||||
|
normalizeDevicePublicKeyBase64Url,
|
||||||
|
verifyDeviceSignature,
|
||||||
|
} from "../../../infra/device-identity.js";
|
||||||
|
import {
|
||||||
|
approveDevicePairing,
|
||||||
|
getPairedDevice,
|
||||||
|
requestDevicePairing,
|
||||||
|
updatePairedDeviceMetadata,
|
||||||
|
} from "../../../infra/device-pairing.js";
|
||||||
import { upsertPresence } from "../../../infra/system-presence.js";
|
import { upsertPresence } from "../../../infra/system-presence.js";
|
||||||
import { rawDataToString } from "../../../infra/ws.js";
|
import { rawDataToString } from "../../../infra/ws.js";
|
||||||
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
|
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
|
||||||
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
|
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
|
||||||
import type { ResolvedGatewayAuth } from "../../auth.js";
|
import type { ResolvedGatewayAuth } from "../../auth.js";
|
||||||
import { authorizeGatewayConnect } from "../../auth.js";
|
import { authorizeGatewayConnect } from "../../auth.js";
|
||||||
|
import { buildDeviceAuthPayload } from "../../device-auth.js";
|
||||||
import { isLoopbackAddress } from "../../net.js";
|
import { isLoopbackAddress } from "../../net.js";
|
||||||
import {
|
import {
|
||||||
type ConnectParams,
|
type ConnectParams,
|
||||||
@@ -38,6 +50,8 @@ import type { GatewayWsClient } from "../ws-types.js";
|
|||||||
|
|
||||||
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||||
|
|
||||||
|
const DEVICE_SIGNATURE_SKEW_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
export function attachGatewayWsMessageHandler(params: {
|
export function attachGatewayWsMessageHandler(params: {
|
||||||
socket: WebSocket;
|
socket: WebSocket;
|
||||||
upgradeReq: IncomingMessage;
|
upgradeReq: IncomingMessage;
|
||||||
@@ -236,6 +250,163 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
}
|
}
|
||||||
const authMethod = authResult.method ?? "none";
|
const authMethod = authResult.method ?? "none";
|
||||||
|
|
||||||
|
const role = connectParams.role ?? "operator";
|
||||||
|
const scopes = Array.isArray(connectParams.scopes)
|
||||||
|
? connectParams.scopes
|
||||||
|
: role === "operator"
|
||||||
|
? ["operator.admin"]
|
||||||
|
: [];
|
||||||
|
connectParams.role = role;
|
||||||
|
connectParams.scopes = scopes;
|
||||||
|
|
||||||
|
const device = connectParams.device;
|
||||||
|
let devicePublicKey: string | null = null;
|
||||||
|
if (device) {
|
||||||
|
const derivedId = deriveDeviceIdFromPublicKey(device.publicKey);
|
||||||
|
if (!derivedId || derivedId !== device.id) {
|
||||||
|
setHandshakeState("failed");
|
||||||
|
setCloseCause("device-auth-invalid", {
|
||||||
|
reason: "device-id-mismatch",
|
||||||
|
client: connectParams.client.id,
|
||||||
|
deviceId: device.id,
|
||||||
|
});
|
||||||
|
send({
|
||||||
|
type: "res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: false,
|
||||||
|
error: errorShape(ErrorCodes.INVALID_REQUEST, "device identity mismatch"),
|
||||||
|
});
|
||||||
|
close(1008, "device identity mismatch");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const signedAt = device.signedAt;
|
||||||
|
if (
|
||||||
|
typeof signedAt !== "number" ||
|
||||||
|
Math.abs(Date.now() - signedAt) > DEVICE_SIGNATURE_SKEW_MS
|
||||||
|
) {
|
||||||
|
setHandshakeState("failed");
|
||||||
|
setCloseCause("device-auth-invalid", {
|
||||||
|
reason: "device-signature-stale",
|
||||||
|
client: connectParams.client.id,
|
||||||
|
deviceId: device.id,
|
||||||
|
});
|
||||||
|
send({
|
||||||
|
type: "res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: false,
|
||||||
|
error: errorShape(ErrorCodes.INVALID_REQUEST, "device signature expired"),
|
||||||
|
});
|
||||||
|
close(1008, "device signature expired");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = buildDeviceAuthPayload({
|
||||||
|
deviceId: device.id,
|
||||||
|
clientId: connectParams.client.id,
|
||||||
|
clientMode: connectParams.client.mode,
|
||||||
|
role,
|
||||||
|
scopes,
|
||||||
|
signedAtMs: signedAt,
|
||||||
|
token: connectParams.auth?.token ?? null,
|
||||||
|
});
|
||||||
|
if (!verifyDeviceSignature(device.publicKey, payload, device.signature)) {
|
||||||
|
setHandshakeState("failed");
|
||||||
|
setCloseCause("device-auth-invalid", {
|
||||||
|
reason: "device-signature",
|
||||||
|
client: connectParams.client.id,
|
||||||
|
deviceId: device.id,
|
||||||
|
});
|
||||||
|
send({
|
||||||
|
type: "res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: false,
|
||||||
|
error: errorShape(ErrorCodes.INVALID_REQUEST, "device signature invalid"),
|
||||||
|
});
|
||||||
|
close(1008, "device signature invalid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
devicePublicKey = normalizeDevicePublicKeyBase64Url(device.publicKey);
|
||||||
|
if (!devicePublicKey) {
|
||||||
|
setHandshakeState("failed");
|
||||||
|
setCloseCause("device-auth-invalid", {
|
||||||
|
reason: "device-public-key",
|
||||||
|
client: connectParams.client.id,
|
||||||
|
deviceId: device.id,
|
||||||
|
});
|
||||||
|
send({
|
||||||
|
type: "res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: false,
|
||||||
|
error: errorShape(ErrorCodes.INVALID_REQUEST, "device public key invalid"),
|
||||||
|
});
|
||||||
|
close(1008, "device public key invalid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (device && devicePublicKey) {
|
||||||
|
const paired = await getPairedDevice(device.id);
|
||||||
|
const isPaired = paired?.publicKey === devicePublicKey;
|
||||||
|
if (!isPaired) {
|
||||||
|
const pairing = await requestDevicePairing({
|
||||||
|
deviceId: device.id,
|
||||||
|
publicKey: devicePublicKey,
|
||||||
|
displayName: connectParams.client.displayName,
|
||||||
|
platform: connectParams.client.platform,
|
||||||
|
clientId: connectParams.client.id,
|
||||||
|
clientMode: connectParams.client.mode,
|
||||||
|
role,
|
||||||
|
scopes,
|
||||||
|
remoteIp: remoteAddr,
|
||||||
|
silent: isLoopbackAddress(remoteAddr) && authMethod !== "none",
|
||||||
|
});
|
||||||
|
const context = buildRequestContext();
|
||||||
|
if (pairing.request.silent === true) {
|
||||||
|
const approved = await approveDevicePairing(pairing.request.requestId);
|
||||||
|
if (approved) {
|
||||||
|
context.broadcast(
|
||||||
|
"device.pair.resolved",
|
||||||
|
{
|
||||||
|
requestId: pairing.request.requestId,
|
||||||
|
deviceId: approved.device.deviceId,
|
||||||
|
decision: "approved",
|
||||||
|
ts: Date.now(),
|
||||||
|
},
|
||||||
|
{ dropIfSlow: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (pairing.created) {
|
||||||
|
context.broadcast("device.pair.requested", pairing.request, { dropIfSlow: true });
|
||||||
|
}
|
||||||
|
if (pairing.request.silent !== true) {
|
||||||
|
setHandshakeState("failed");
|
||||||
|
setCloseCause("pairing-required", {
|
||||||
|
deviceId: device.id,
|
||||||
|
requestId: pairing.request.requestId,
|
||||||
|
});
|
||||||
|
send({
|
||||||
|
type: "res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: false,
|
||||||
|
error: errorShape(ErrorCodes.NOT_PAIRED, "pairing required", {
|
||||||
|
details: { requestId: pairing.request.requestId },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
close(1008, "pairing required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await updatePairedDeviceMetadata(device.id, {
|
||||||
|
displayName: connectParams.client.displayName,
|
||||||
|
platform: connectParams.client.platform,
|
||||||
|
clientId: connectParams.client.id,
|
||||||
|
clientMode: connectParams.client.mode,
|
||||||
|
role,
|
||||||
|
scopes,
|
||||||
|
remoteIp: remoteAddr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const shouldTrackPresence = !isGatewayCliClient(connectParams.client);
|
const shouldTrackPresence = !isGatewayCliClient(connectParams.client);
|
||||||
const clientId = connectParams.client.id;
|
const clientId = connectParams.client.id;
|
||||||
const instanceId = connectParams.client.instanceId;
|
const instanceId = connectParams.client.instanceId;
|
||||||
|
|||||||
183
src/infra/device-identity.ts
Normal file
183
src/infra/device-identity.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export type DeviceIdentity = {
|
||||||
|
deviceId: string;
|
||||||
|
publicKeyPem: string;
|
||||||
|
privateKeyPem: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StoredIdentity = {
|
||||||
|
version: 1;
|
||||||
|
deviceId: string;
|
||||||
|
publicKeyPem: string;
|
||||||
|
privateKeyPem: string;
|
||||||
|
createdAtMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_DIR = path.join(os.homedir(), ".clawdbot", "identity");
|
||||||
|
const DEFAULT_FILE = path.join(DEFAULT_DIR, "device.json");
|
||||||
|
|
||||||
|
function ensureDir(filePath: string) {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
||||||
|
|
||||||
|
function base64UrlEncode(buf: Buffer): string {
|
||||||
|
return buf
|
||||||
|
.toString("base64")
|
||||||
|
.replaceAll("+", "-")
|
||||||
|
.replaceAll("/", "_")
|
||||||
|
.replace(/=+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlDecode(input: string): Buffer {
|
||||||
|
const normalized = input.replaceAll("-", "+").replaceAll("_", "/");
|
||||||
|
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
|
||||||
|
return Buffer.from(padded, "base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
function derivePublicKeyRaw(publicKeyPem: string): Buffer {
|
||||||
|
const key = crypto.createPublicKey(publicKeyPem);
|
||||||
|
const spki = key.export({ type: "spki", format: "der" }) as Buffer;
|
||||||
|
if (
|
||||||
|
spki.length === ED25519_SPKI_PREFIX.length + 32 &&
|
||||||
|
spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
|
||||||
|
) {
|
||||||
|
return spki.subarray(ED25519_SPKI_PREFIX.length);
|
||||||
|
}
|
||||||
|
return spki;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fingerprintPublicKey(publicKeyPem: string): string {
|
||||||
|
const raw = derivePublicKeyRaw(publicKeyPem);
|
||||||
|
return crypto.createHash("sha256").update(raw).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateIdentity(): DeviceIdentity {
|
||||||
|
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||||
|
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
||||||
|
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
||||||
|
const deviceId = fingerprintPublicKey(publicKeyPem);
|
||||||
|
return { deviceId, publicKeyPem, privateKeyPem };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadOrCreateDeviceIdentity(filePath: string = DEFAULT_FILE): DeviceIdentity {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const raw = fs.readFileSync(filePath, "utf8");
|
||||||
|
const parsed = JSON.parse(raw) as StoredIdentity;
|
||||||
|
if (
|
||||||
|
parsed?.version === 1 &&
|
||||||
|
typeof parsed.deviceId === "string" &&
|
||||||
|
typeof parsed.publicKeyPem === "string" &&
|
||||||
|
typeof parsed.privateKeyPem === "string"
|
||||||
|
) {
|
||||||
|
const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
|
||||||
|
if (derivedId && derivedId !== parsed.deviceId) {
|
||||||
|
const updated: StoredIdentity = {
|
||||||
|
...parsed,
|
||||||
|
deviceId: derivedId,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(filePath, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
|
||||||
|
try {
|
||||||
|
fs.chmodSync(filePath, 0o600);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
deviceId: derivedId,
|
||||||
|
publicKeyPem: parsed.publicKeyPem,
|
||||||
|
privateKeyPem: parsed.privateKeyPem,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
deviceId: parsed.deviceId,
|
||||||
|
publicKeyPem: parsed.publicKeyPem,
|
||||||
|
privateKeyPem: parsed.privateKeyPem,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to regenerate
|
||||||
|
}
|
||||||
|
|
||||||
|
const identity = generateIdentity();
|
||||||
|
ensureDir(filePath);
|
||||||
|
const stored: StoredIdentity = {
|
||||||
|
version: 1,
|
||||||
|
deviceId: identity.deviceId,
|
||||||
|
publicKeyPem: identity.publicKeyPem,
|
||||||
|
privateKeyPem: identity.privateKeyPem,
|
||||||
|
createdAtMs: Date.now(),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
|
||||||
|
try {
|
||||||
|
fs.chmodSync(filePath, 0o600);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signDevicePayload(privateKeyPem: string, payload: string): string {
|
||||||
|
const key = crypto.createPrivateKey(privateKeyPem);
|
||||||
|
const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key);
|
||||||
|
return base64UrlEncode(sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDevicePublicKeyBase64Url(publicKey: string): string | null {
|
||||||
|
try {
|
||||||
|
if (publicKey.includes("BEGIN")) {
|
||||||
|
return base64UrlEncode(derivePublicKeyRaw(publicKey));
|
||||||
|
}
|
||||||
|
const raw = base64UrlDecode(publicKey);
|
||||||
|
return base64UrlEncode(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveDeviceIdFromPublicKey(publicKey: string): string | null {
|
||||||
|
try {
|
||||||
|
const raw = publicKey.includes("BEGIN")
|
||||||
|
? derivePublicKeyRaw(publicKey)
|
||||||
|
: base64UrlDecode(publicKey);
|
||||||
|
return crypto.createHash("sha256").update(raw).digest("hex");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publicKeyRawBase64UrlFromPem(publicKeyPem: string): string {
|
||||||
|
return base64UrlEncode(derivePublicKeyRaw(publicKeyPem));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyDeviceSignature(
|
||||||
|
publicKey: string,
|
||||||
|
payload: string,
|
||||||
|
signatureBase64Url: string,
|
||||||
|
): boolean {
|
||||||
|
try {
|
||||||
|
const key = publicKey.includes("BEGIN")
|
||||||
|
? crypto.createPublicKey(publicKey)
|
||||||
|
: crypto.createPublicKey({
|
||||||
|
key: Buffer.concat([ED25519_SPKI_PREFIX, base64UrlDecode(publicKey)]),
|
||||||
|
type: "spki",
|
||||||
|
format: "der",
|
||||||
|
});
|
||||||
|
const sig = (() => {
|
||||||
|
try {
|
||||||
|
return base64UrlDecode(signatureBase64Url);
|
||||||
|
} catch {
|
||||||
|
return Buffer.from(signatureBase64Url, "base64");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return crypto.verify(null, Buffer.from(payload, "utf8"), key, sig);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
256
src/infra/device-pairing.ts
Normal file
256
src/infra/device-pairing.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { resolveStateDir } from "../config/paths.js";
|
||||||
|
|
||||||
|
export type DevicePairingPendingRequest = {
|
||||||
|
requestId: string;
|
||||||
|
deviceId: string;
|
||||||
|
publicKey: string;
|
||||||
|
displayName?: string;
|
||||||
|
platform?: string;
|
||||||
|
clientId?: string;
|
||||||
|
clientMode?: string;
|
||||||
|
role?: string;
|
||||||
|
scopes?: string[];
|
||||||
|
remoteIp?: string;
|
||||||
|
silent?: boolean;
|
||||||
|
isRepair?: boolean;
|
||||||
|
ts: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PairedDevice = {
|
||||||
|
deviceId: string;
|
||||||
|
publicKey: string;
|
||||||
|
displayName?: string;
|
||||||
|
platform?: string;
|
||||||
|
clientId?: string;
|
||||||
|
clientMode?: string;
|
||||||
|
role?: string;
|
||||||
|
scopes?: string[];
|
||||||
|
remoteIp?: string;
|
||||||
|
createdAtMs: number;
|
||||||
|
approvedAtMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DevicePairingList = {
|
||||||
|
pending: DevicePairingPendingRequest[];
|
||||||
|
paired: PairedDevice[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type DevicePairingStateFile = {
|
||||||
|
pendingById: Record<string, DevicePairingPendingRequest>;
|
||||||
|
pairedByDeviceId: Record<string, PairedDevice>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PENDING_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
function resolvePaths(baseDir?: string) {
|
||||||
|
const root = baseDir ?? resolveStateDir();
|
||||||
|
const dir = path.join(root, "devices");
|
||||||
|
return {
|
||||||
|
dir,
|
||||||
|
pendingPath: path.join(dir, "pending.json"),
|
||||||
|
pairedPath: path.join(dir, "paired.json"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJSON<T>(filePath: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(filePath, "utf8");
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJSONAtomic(filePath: string, value: unknown) {
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
const tmp = `${filePath}.${randomUUID()}.tmp`;
|
||||||
|
await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
|
||||||
|
try {
|
||||||
|
await fs.chmod(tmp, 0o600);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
await fs.rename(tmp, filePath);
|
||||||
|
try {
|
||||||
|
await fs.chmod(filePath, 0o600);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneExpiredPending(
|
||||||
|
pendingById: Record<string, DevicePairingPendingRequest>,
|
||||||
|
nowMs: number,
|
||||||
|
) {
|
||||||
|
for (const [id, req] of Object.entries(pendingById)) {
|
||||||
|
if (nowMs - req.ts > PENDING_TTL_MS) {
|
||||||
|
delete pendingById[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lock: Promise<void> = Promise.resolve();
|
||||||
|
async function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
const prev = lock;
|
||||||
|
let release: (() => void) | undefined;
|
||||||
|
lock = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
await prev;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
release?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadState(baseDir?: string): Promise<DevicePairingStateFile> {
|
||||||
|
const { pendingPath, pairedPath } = resolvePaths(baseDir);
|
||||||
|
const [pending, paired] = await Promise.all([
|
||||||
|
readJSON<Record<string, DevicePairingPendingRequest>>(pendingPath),
|
||||||
|
readJSON<Record<string, PairedDevice>>(pairedPath),
|
||||||
|
]);
|
||||||
|
const state: DevicePairingStateFile = {
|
||||||
|
pendingById: pending ?? {},
|
||||||
|
pairedByDeviceId: paired ?? {},
|
||||||
|
};
|
||||||
|
pruneExpiredPending(state.pendingById, Date.now());
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistState(state: DevicePairingStateFile, baseDir?: string) {
|
||||||
|
const { pendingPath, pairedPath } = resolvePaths(baseDir);
|
||||||
|
await Promise.all([
|
||||||
|
writeJSONAtomic(pendingPath, state.pendingById),
|
||||||
|
writeJSONAtomic(pairedPath, state.pairedByDeviceId),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDeviceId(deviceId: string) {
|
||||||
|
return deviceId.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDevicePairing(baseDir?: string): Promise<DevicePairingList> {
|
||||||
|
const state = await loadState(baseDir);
|
||||||
|
const pending = Object.values(state.pendingById).sort((a, b) => b.ts - a.ts);
|
||||||
|
const paired = Object.values(state.pairedByDeviceId).sort(
|
||||||
|
(a, b) => b.approvedAtMs - a.approvedAtMs,
|
||||||
|
);
|
||||||
|
return { pending, paired };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPairedDevice(
|
||||||
|
deviceId: string,
|
||||||
|
baseDir?: string,
|
||||||
|
): Promise<PairedDevice | null> {
|
||||||
|
const state = await loadState(baseDir);
|
||||||
|
return state.pairedByDeviceId[normalizeDeviceId(deviceId)] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestDevicePairing(
|
||||||
|
req: Omit<DevicePairingPendingRequest, "requestId" | "ts" | "isRepair">,
|
||||||
|
baseDir?: string,
|
||||||
|
): Promise<{
|
||||||
|
status: "pending";
|
||||||
|
request: DevicePairingPendingRequest;
|
||||||
|
created: boolean;
|
||||||
|
}> {
|
||||||
|
return await withLock(async () => {
|
||||||
|
const state = await loadState(baseDir);
|
||||||
|
const deviceId = normalizeDeviceId(req.deviceId);
|
||||||
|
if (!deviceId) {
|
||||||
|
throw new Error("deviceId required");
|
||||||
|
}
|
||||||
|
const existing = Object.values(state.pendingById).find((p) => p.deviceId === deviceId);
|
||||||
|
if (existing) {
|
||||||
|
return { status: "pending", request: existing, created: false };
|
||||||
|
}
|
||||||
|
const isRepair = Boolean(state.pairedByDeviceId[deviceId]);
|
||||||
|
const request: DevicePairingPendingRequest = {
|
||||||
|
requestId: randomUUID(),
|
||||||
|
deviceId,
|
||||||
|
publicKey: req.publicKey,
|
||||||
|
displayName: req.displayName,
|
||||||
|
platform: req.platform,
|
||||||
|
clientId: req.clientId,
|
||||||
|
clientMode: req.clientMode,
|
||||||
|
role: req.role,
|
||||||
|
scopes: req.scopes,
|
||||||
|
remoteIp: req.remoteIp,
|
||||||
|
silent: req.silent,
|
||||||
|
isRepair,
|
||||||
|
ts: Date.now(),
|
||||||
|
};
|
||||||
|
state.pendingById[request.requestId] = request;
|
||||||
|
await persistState(state, baseDir);
|
||||||
|
return { status: "pending", request, created: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveDevicePairing(
|
||||||
|
requestId: string,
|
||||||
|
baseDir?: string,
|
||||||
|
): Promise<{ requestId: string; device: PairedDevice } | null> {
|
||||||
|
return await withLock(async () => {
|
||||||
|
const state = await loadState(baseDir);
|
||||||
|
const pending = state.pendingById[requestId];
|
||||||
|
if (!pending) return null;
|
||||||
|
const now = Date.now();
|
||||||
|
const existing = state.pairedByDeviceId[pending.deviceId];
|
||||||
|
const device: PairedDevice = {
|
||||||
|
deviceId: pending.deviceId,
|
||||||
|
publicKey: pending.publicKey,
|
||||||
|
displayName: pending.displayName,
|
||||||
|
platform: pending.platform,
|
||||||
|
clientId: pending.clientId,
|
||||||
|
clientMode: pending.clientMode,
|
||||||
|
role: pending.role,
|
||||||
|
scopes: pending.scopes,
|
||||||
|
remoteIp: pending.remoteIp,
|
||||||
|
createdAtMs: existing?.createdAtMs ?? now,
|
||||||
|
approvedAtMs: now,
|
||||||
|
};
|
||||||
|
delete state.pendingById[requestId];
|
||||||
|
state.pairedByDeviceId[device.deviceId] = device;
|
||||||
|
await persistState(state, baseDir);
|
||||||
|
return { requestId, device };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectDevicePairing(
|
||||||
|
requestId: string,
|
||||||
|
baseDir?: string,
|
||||||
|
): Promise<{ requestId: string; deviceId: string } | null> {
|
||||||
|
return await withLock(async () => {
|
||||||
|
const state = await loadState(baseDir);
|
||||||
|
const pending = state.pendingById[requestId];
|
||||||
|
if (!pending) return null;
|
||||||
|
delete state.pendingById[requestId];
|
||||||
|
await persistState(state, baseDir);
|
||||||
|
return { requestId, deviceId: pending.deviceId };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePairedDeviceMetadata(
|
||||||
|
deviceId: string,
|
||||||
|
patch: Partial<Omit<PairedDevice, "deviceId" | "createdAtMs" | "approvedAtMs">>,
|
||||||
|
baseDir?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return await withLock(async () => {
|
||||||
|
const state = await loadState(baseDir);
|
||||||
|
const existing = state.pairedByDeviceId[normalizeDeviceId(deviceId)];
|
||||||
|
if (!existing) return;
|
||||||
|
state.pairedByDeviceId[deviceId] = {
|
||||||
|
...existing,
|
||||||
|
...patch,
|
||||||
|
deviceId: existing.deviceId,
|
||||||
|
createdAtMs: existing.createdAtMs,
|
||||||
|
approvedAtMs: existing.approvedAtMs,
|
||||||
|
};
|
||||||
|
await persistState(state, baseDir);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user