diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeviceAuthStore.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeviceAuthStore.swift new file mode 100644 index 000000000..80ff20c3f --- /dev/null +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeviceAuthStore.swift @@ -0,0 +1,107 @@ +import Foundation + +public struct DeviceAuthEntry: Codable, Sendable { + public let token: String + public let role: String + public let scopes: [String] + public let updatedAtMs: Int + + public init(token: String, role: String, scopes: [String], updatedAtMs: Int) { + self.token = token + self.role = role + self.scopes = scopes + self.updatedAtMs = updatedAtMs + } +} + +private struct DeviceAuthStoreFile: Codable { + var version: Int + var deviceId: String + var tokens: [String: DeviceAuthEntry] +} + +public enum DeviceAuthStore { + private static let fileName = "device-auth.json" + + public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? { + guard let store = readStore(), store.deviceId == deviceId else { return nil } + let role = normalizeRole(role) + return store.tokens[role] + } + + public static func storeToken( + deviceId: String, + role: String, + token: String, + scopes: [String] = [] + ) -> DeviceAuthEntry { + let normalizedRole = normalizeRole(role) + var next = readStore() + if next?.deviceId != deviceId { + next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:]) + } + let entry = DeviceAuthEntry( + token: token, + role: normalizedRole, + scopes: normalizeScopes(scopes), + updatedAtMs: Int(Date().timeIntervalSince1970 * 1000) + ) + if next == nil { + next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:]) + } + next?.tokens[normalizedRole] = entry + if let store = next { + writeStore(store) + } + return entry + } + + public static func clearToken(deviceId: String, role: String) { + guard var store = readStore(), store.deviceId == deviceId else { return } + let normalizedRole = normalizeRole(role) + guard store.tokens[normalizedRole] != nil else { return } + store.tokens.removeValue(forKey: normalizedRole) + writeStore(store) + } + + private static func normalizeRole(_ role: String) -> String { + role.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func normalizeScopes(_ scopes: [String]) -> [String] { + let trimmed = scopes + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return Array(Set(trimmed)).sorted() + } + + private static func fileURL() -> URL { + DeviceIdentityPaths.stateDirURL() + .appendingPathComponent("identity", isDirectory: true) + .appendingPathComponent(fileName, isDirectory: false) + } + + private static func readStore() -> DeviceAuthStoreFile? { + let url = fileURL() + guard let data = try? Data(contentsOf: url) else { return nil } + guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else { + return nil + } + guard decoded.version == 1 else { return nil } + return decoded + } + + private static func writeStore(_ store: DeviceAuthStoreFile) { + let url = fileURL() + do { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + let data = try JSONEncoder().encode(store) + try data.write(to: url, options: [.atomic]) + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } catch { + // best-effort only + } + } +} diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift index abb888a49..e89f07e51 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift @@ -261,6 +261,8 @@ public actor GatewayChannelActor { let clientDisplayName = options.clientDisplayName ?? InstanceIdentity.displayName let clientId = options.clientId let clientMode = options.clientMode + let role = options.role + let scopes = options.scopes let reqId = UUID().uuidString var client: [String: ProtoAnyCodable] = [ @@ -283,8 +285,8 @@ public actor GatewayChannelActor { "caps": ProtoAnyCodable(options.caps), "locale": ProtoAnyCodable(primaryLocale), "userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString), - "role": ProtoAnyCodable(options.role), - "scopes": ProtoAnyCodable(options.scopes), + "role": ProtoAnyCodable(role), + "scopes": ProtoAnyCodable(scopes), ] if !options.commands.isEmpty { params["commands"] = ProtoAnyCodable(options.commands) @@ -292,24 +294,27 @@ public actor GatewayChannelActor { if !options.permissions.isEmpty { params["permissions"] = ProtoAnyCodable(options.permissions) } - if let token = self.token { - params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)]) + let identity = DeviceIdentityStore.loadOrCreate() + let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token + let authToken = storedToken ?? self.token + let canFallbackToShared = storedToken != nil && self.token != nil + if let authToken { + params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)]) } else if let password = self.password { params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)]) } - let identity = DeviceIdentityStore.loadOrCreate() let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) let connectNonce = try await self.waitForConnectChallenge() - let scopes = options.scopes.joined(separator: ",") + let scopesValue = scopes.joined(separator: ",") var payloadParts = [ connectNonce == nil ? "v1" : "v2", identity.deviceId, clientId, clientMode, - options.role, - scopes, + role, + scopesValue, String(signedAtMs), - self.token ?? "", + authToken ?? "", ] if let connectNonce { payloadParts.append(connectNonce) @@ -336,11 +341,22 @@ public actor GatewayChannelActor { params: ProtoAnyCodable(params)) let data = try self.encoder.encode(frame) try await self.task?.send(.data(data)) - let response = try await self.waitForConnectResponse(reqId: reqId) - try await self.handleConnectResponse(response) + do { + let response = try await self.waitForConnectResponse(reqId: reqId) + try await self.handleConnectResponse(response, identity: identity, role: role) + } catch { + if canFallbackToShared { + DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role) + } + throw error + } } - private func handleConnectResponse(_ res: ResponseFrame) async throws { + private func handleConnectResponse( + _ res: ResponseFrame, + identity: DeviceIdentity, + role: String + ) async throws { if res.ok == false { let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed" throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg]) @@ -358,6 +374,17 @@ public actor GatewayChannelActor { } else if let tick = ok.policy["tickIntervalMs"]?.value as? Int { self.tickIntervalMs = Double(tick) } + if let auth = ok.auth, + let deviceToken = auth["deviceToken"]?.value as? String { + let authRole = auth["role"]?.value as? String ?? role + let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])? + .compactMap { $0.value as? String } ?? [] + _ = DeviceAuthStore.storeToken( + deviceId: identity.deviceId, + role: authRole, + token: deviceToken, + scopes: scopes) + } self.lastTick = Date() self.tickTask?.cancel() self.tickTask = Task { [weak self] in diff --git a/docs/refactor/clawnet.md b/docs/refactor/clawnet.md index 10692318c..55ec09499 100644 --- a/docs/refactor/clawnet.md +++ b/docs/refactor/clawnet.md @@ -290,7 +290,7 @@ Same `deviceId` across roles → single “Instance” row: # Execution checklist (ship order) - [x] **Device‑bound auth (PoP):** nonce challenge + signature verify on connect; remove bearer‑only for non‑local. -- [ ] **Role‑scoped creds:** issue per‑role tokens, rotate, revoke, list; UI/CLI surfaced; audit log entries. +- [x] **Role‑scoped creds:** issue per‑role tokens, rotate, revoke, list; UI/CLI surfaced; audit log entries. - [ ] **Scope enforcement:** keep paired scopes in sync on rotation; reject/upgrade flows explicit; tests. - [ ] **Approvals routing:** gateway‑hosted approvals; operator UI prompt/resolve; node stops prompting. - [ ] **TLS pinning for WS:** reuse bridge TLS runtime; discovery advertises fingerprint; client validation. diff --git a/src/gateway/client.ts b/src/gateway/client.ts index d39801f86..df0794c15 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -8,7 +8,11 @@ import { publicKeyRawBase64UrlFromPem, signDevicePayload, } from "../infra/device-identity.js"; -import { loadDeviceAuthToken, storeDeviceAuthToken } from "../infra/device-auth-store.js"; +import { + clearDeviceAuthToken, + loadDeviceAuthToken, + storeDeviceAuthToken, +} from "../infra/device-auth-store.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, @@ -160,7 +164,8 @@ export class GatewayClient { const storedToken = this.opts.deviceIdentity ? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token : null; - const authToken = this.opts.token ?? storedToken ?? undefined; + const authToken = storedToken ?? this.opts.token ?? undefined; + const canFallbackToShared = Boolean(storedToken && this.opts.token); const auth = authToken || this.opts.password ? { @@ -236,6 +241,12 @@ export class GatewayClient { this.opts.onHelloOk?.(helloOk); }) .catch((err) => { + if (canFallbackToShared && this.opts.deviceIdentity) { + clearDeviceAuthToken({ + deviceId: this.opts.deviceIdentity.deviceId, + role, + }); + } this.opts.onConnectError?.(err instanceof Error ? err : new Error(String(err))); const msg = `gateway connect failed: ${String(err)}`; if (this.opts.mode === GATEWAY_CLIENT_MODES.PROBE) logDebug(msg); diff --git a/src/gateway/server-methods/devices.ts b/src/gateway/server-methods/devices.ts index 9029a1894..ebf7d7f94 100644 --- a/src/gateway/server-methods/devices.ts +++ b/src/gateway/server-methods/devices.ts @@ -19,7 +19,9 @@ import { } from "../protocol/index.js"; import type { GatewayRequestHandlers } from "./types.js"; -function redactPairedDevice(device: { tokens?: Record } & Record) { +function redactPairedDevice( + device: { tokens?: Record } & Record, +) { const { tokens, ...rest } = device; return { ...rest, @@ -72,6 +74,9 @@ export const deviceHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId")); return; } + context.logGateway.info( + `device pairing approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`, + ); context.broadcast( "device.pair.resolved", { @@ -79,7 +84,7 @@ export const deviceHandlers: GatewayRequestHandlers = { deviceId: approved.device.deviceId, decision: "approved", ts: Date.now(), - }, + }, { dropIfSlow: true }, ); respond(true, { requestId, device: redactPairedDevice(approved.device) }, undefined); @@ -116,7 +121,7 @@ export const deviceHandlers: GatewayRequestHandlers = { ); respond(true, rejected, undefined); }, - "device.token.rotate": async ({ params, respond }) => { + "device.token.rotate": async ({ params, respond, context }) => { if (!validateDeviceTokenRotateParams(params)) { respond( false, @@ -140,6 +145,9 @@ export const deviceHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role")); return; } + context.logGateway.info( + `device token rotated device=${deviceId} role=${entry.role} scopes=${entry.scopes.join(",")}`, + ); respond( true, { @@ -152,7 +160,7 @@ export const deviceHandlers: GatewayRequestHandlers = { undefined, ); }, - "device.token.revoke": async ({ params, respond }) => { + "device.token.revoke": async ({ params, respond, context }) => { if (!validateDeviceTokenRevokeParams(params)) { respond( false, @@ -172,6 +180,7 @@ export const deviceHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role")); return; } + context.logGateway.info(`device token revoked device=${deviceId} role=${entry.role}`); respond( true, { deviceId, role: entry.role, revokedAtMs: entry.revokedAtMs ?? Date.now() }, diff --git a/src/gateway/server-methods/types.ts b/src/gateway/server-methods/types.ts index 5e048e0d2..c23459a2d 100644 --- a/src/gateway/server-methods/types.ts +++ b/src/gateway/server-methods/types.ts @@ -8,6 +8,9 @@ import type { NodeRegistry } from "../node-registry.js"; import type { ConnectParams, ErrorShape, RequestFrame } from "../protocol/index.js"; import type { ChannelRuntimeSnapshot } from "../server-channels.js"; import type { DedupeEntry } from "../server-shared.js"; +import type { createSubsystemLogger } from "../../logging/subsystem.js"; + +type SubsystemLogger = ReturnType; export type GatewayClient = { connect: ConnectParams; @@ -28,7 +31,7 @@ export type GatewayRequestContext = { getHealthCache: () => HealthSummary | null; refreshHealthSnapshot: (opts?: { probe?: boolean }) => Promise; logHealth: { error: (message: string) => void }; - logGateway: { warn: (message: string) => void }; + logGateway: SubsystemLogger; incrementPresenceVersion: () => number; getHealthVersion: () => number; broadcast: ( diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 4d55fe1c1..1f46f1f2a 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -487,6 +487,9 @@ export function attachGatewayWsMessageHandler(params: { if (pairing.request.silent === true) { const approved = await approveDevicePairing(pairing.request.requestId); if (approved) { + logGateway.info( + `device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`, + ); context.broadcast( "device.pair.resolved", { diff --git a/src/infra/device-auth-store.ts b/src/infra/device-auth-store.ts index 59666524d..0f3515c03 100644 --- a/src/infra/device-auth-store.ts +++ b/src/infra/device-auth-store.ts @@ -102,3 +102,22 @@ export function storeDeviceAuthToken(params: { writeStore(filePath, next); return entry; } + +export function clearDeviceAuthToken(params: { + deviceId: string; + role: string; + env?: NodeJS.ProcessEnv; +}): void { + const filePath = resolveDeviceAuthPath(params.env); + const store = readStore(filePath); + if (!store || store.deviceId !== params.deviceId) return; + const role = normalizeRole(params.role); + if (!store.tokens[role]) return; + const next: DeviceAuthStore = { + version: 1, + deviceId: store.deviceId, + tokens: { ...store.tokens }, + }; + delete next.tokens[role]; + writeStore(filePath, next); +} diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 61c9af086..79f105c09 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -1,4 +1,5 @@ import { loadChatHistory } from "./controllers/chat"; +import { loadDevices } from "./controllers/devices"; import { loadNodes } from "./controllers/nodes"; import type { GatewayEventFrame, GatewayHelloOk } from "./gateway"; import { GatewayBrowserClient } from "./gateway"; @@ -106,6 +107,7 @@ export function connectGateway(host: GatewayHost) { host.hello = hello; applySnapshot(host, hello); void loadNodes(host as unknown as ClawdbotApp, { quiet: true }); + void loadDevices(host as unknown as ClawdbotApp, { quiet: true }); void refreshActiveTab(host as unknown as Parameters[0]); }, onClose: ({ code, reason }) => { @@ -169,6 +171,10 @@ export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) { if (evt.event === "cron" && host.tab === "cron") { void loadCron(host as unknown as Parameters[0]); } + + if (evt.event === "device.pair.requested" || evt.event === "device.pair.resolved") { + void loadDevices(host as unknown as ClawdbotApp, { quiet: true }); + } } export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) { diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 66fb1fbf3..f45990c35 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -38,6 +38,13 @@ import { renderLogs } from "./views/logs"; import { renderNodes } from "./views/nodes"; import { renderOverview } from "./views/overview"; import { renderSessions } from "./views/sessions"; +import { + approveDevicePairing, + loadDevices, + rejectDevicePairing, + revokeDeviceToken, + rotateDeviceToken, +} from "./controllers/devices"; import { renderSkills } from "./views/skills"; import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers"; import { loadChannels } from "./controllers/channels"; @@ -301,6 +308,9 @@ export function renderApp(state: AppViewState) { ? renderNodes({ loading: state.nodesLoading, nodes: state.nodes, + devicesLoading: state.devicesLoading, + devicesError: state.devicesError, + devicesList: state.devicesList, configForm: state.configForm ?? (state.configSnapshot?.config as Record | null), configLoading: state.configLoading, configSaving: state.configSaving, @@ -315,6 +325,13 @@ export function renderApp(state: AppViewState) { execApprovalsTarget: state.execApprovalsTarget, execApprovalsTargetNodeId: state.execApprovalsTargetNodeId, onRefresh: () => loadNodes(state), + onDevicesRefresh: () => loadDevices(state), + onDeviceApprove: (requestId) => approveDevicePairing(state, requestId), + onDeviceReject: (requestId) => rejectDevicePairing(state, requestId), + onDeviceRotate: (deviceId, role, scopes) => + rotateDeviceToken(state, { deviceId, role, scopes }), + onDeviceRevoke: (deviceId, role) => + revokeDeviceToken(state, { deviceId, role }), onLoadConfig: () => loadConfig(state), onLoadExecApprovals: () => { const target = diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 580bb332e..e5ba2a95d 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -3,6 +3,7 @@ import { loadCronJobs, loadCronStatus } from "./controllers/cron"; import { loadChannels } from "./controllers/channels"; import { loadDebug } from "./controllers/debug"; import { loadLogs } from "./controllers/logs"; +import { loadDevices } from "./controllers/devices"; import { loadNodes } from "./controllers/nodes"; import { loadExecApprovals } from "./controllers/exec-approvals"; import { loadPresence } from "./controllers/presence"; @@ -136,6 +137,7 @@ export async function refreshActiveTab(host: SettingsHost) { if (host.tab === "skills") await loadSkills(host as unknown as ClawdbotApp); if (host.tab === "nodes") { await loadNodes(host as unknown as ClawdbotApp); + await loadDevices(host as unknown as ClawdbotApp); await loadConfig(host as unknown as ClawdbotApp); await loadExecApprovals(host as unknown as ClawdbotApp); } diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index dc97d335f..21f8e7d1c 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -24,6 +24,7 @@ import type { ExecApprovalsFile, ExecApprovalsSnapshot, } from "./controllers/exec-approvals"; +import type { DevicePairingList } from "./controllers/devices"; export type AppViewState = { settings: UiSettings; @@ -48,6 +49,9 @@ export type AppViewState = { chatQueue: ChatQueueItem[]; nodesLoading: boolean; nodes: Array>; + devicesLoading: boolean; + devicesError: string | null; + devicesList: DevicePairingList | null; execApprovalsLoading: boolean; execApprovalsSaving: boolean; execApprovalsDirty: boolean; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 1c5f1fff3..2d0561c15 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -28,6 +28,7 @@ import type { ExecApprovalsFile, ExecApprovalsSnapshot, } from "./controllers/exec-approvals"; +import type { DevicePairingList } from "./controllers/devices"; import { resetToolStream as resetToolStreamInternal, toggleToolOutput as toggleToolOutputInternal, @@ -108,6 +109,9 @@ export class ClawdbotApp extends LitElement { @state() nodesLoading = false; @state() nodes: Array> = []; + @state() devicesLoading = false; + @state() devicesError: string | null = null; + @state() devicesList: DevicePairingList | null = null; @state() execApprovalsLoading = false; @state() execApprovalsSaving = false; @state() execApprovalsDirty = false; diff --git a/ui/src/ui/controllers/devices.ts b/ui/src/ui/controllers/devices.ts new file mode 100644 index 000000000..f08d7afb6 --- /dev/null +++ b/ui/src/ui/controllers/devices.ts @@ -0,0 +1,135 @@ +import type { GatewayBrowserClient } from "../gateway"; +import { loadOrCreateDeviceIdentity } from "../device-identity"; +import { clearDeviceAuthToken, storeDeviceAuthToken } from "../device-auth"; + +export type DeviceTokenSummary = { + role: string; + scopes?: string[]; + createdAtMs?: number; + rotatedAtMs?: number; + revokedAtMs?: number; + lastUsedAtMs?: number; +}; + +export type PendingDevice = { + requestId: string; + deviceId: string; + displayName?: string; + role?: string; + remoteIp?: string; + isRepair?: boolean; + ts?: number; +}; + +export type PairedDevice = { + deviceId: string; + displayName?: string; + roles?: string[]; + scopes?: string[]; + remoteIp?: string; + tokens?: DeviceTokenSummary[]; + createdAtMs?: number; + approvedAtMs?: number; +}; + +export type DevicePairingList = { + pending: PendingDevice[]; + paired: PairedDevice[]; +}; + +export type DevicesState = { + client: GatewayBrowserClient | null; + connected: boolean; + devicesLoading: boolean; + devicesError: string | null; + devicesList: DevicePairingList | null; +}; + +export async function loadDevices(state: DevicesState, opts?: { quiet?: boolean }) { + if (!state.client || !state.connected) return; + if (state.devicesLoading) return; + state.devicesLoading = true; + if (!opts?.quiet) state.devicesError = null; + try { + const res = (await state.client.request("device.pair.list", {})) as DevicePairingList | null; + state.devicesList = { + pending: Array.isArray(res?.pending) ? res!.pending : [], + paired: Array.isArray(res?.paired) ? res!.paired : [], + }; + } catch (err) { + if (!opts?.quiet) state.devicesError = String(err); + } finally { + state.devicesLoading = false; + } +} + +export async function approveDevicePairing(state: DevicesState, requestId: string) { + if (!state.client || !state.connected) return; + try { + await state.client.request("device.pair.approve", { requestId }); + await loadDevices(state); + } catch (err) { + state.devicesError = String(err); + } +} + +export async function rejectDevicePairing(state: DevicesState, requestId: string) { + if (!state.client || !state.connected) return; + const confirmed = window.confirm("Reject this device pairing request?"); + if (!confirmed) return; + try { + await state.client.request("device.pair.reject", { requestId }); + await loadDevices(state); + } catch (err) { + state.devicesError = String(err); + } +} + +export async function rotateDeviceToken( + state: DevicesState, + params: { deviceId: string; role: string; scopes?: string[] }, +) { + if (!state.client || !state.connected) return; + try { + const res = (await state.client.request("device.token.rotate", params)) as + | { token?: string; role?: string; deviceId?: string; scopes?: string[] } + | undefined; + if (res?.token) { + const identity = await loadOrCreateDeviceIdentity(); + const role = res.role ?? params.role; + if (res.deviceId === identity.deviceId || params.deviceId === identity.deviceId) { + storeDeviceAuthToken({ + deviceId: identity.deviceId, + role, + token: res.token, + scopes: res.scopes ?? params.scopes ?? [], + }); + } + window.prompt("New device token (copy and store securely):", res.token); + } + await loadDevices(state); + } catch (err) { + state.devicesError = String(err); + } +} + +export async function revokeDeviceToken( + state: DevicesState, + params: { deviceId: string; role: string }, +) { + if (!state.client || !state.connected) return; + const confirmed = window.confirm( + `Revoke token for ${params.deviceId} (${params.role})?`, + ); + if (!confirmed) return; + try { + await state.client.request("device.token.revoke", params); + const identity = await loadOrCreateDeviceIdentity(); + if (params.deviceId === identity.deviceId) { + clearDeviceAuthToken({ deviceId: identity.deviceId, role: params.role }); + } + await loadDevices(state); + } catch (err) { + state.devicesError = String(err); + } +} diff --git a/ui/src/ui/device-auth.ts b/ui/src/ui/device-auth.ts new file mode 100644 index 000000000..693479ae7 --- /dev/null +++ b/ui/src/ui/device-auth.ts @@ -0,0 +1,99 @@ +export type DeviceAuthEntry = { + token: string; + role: string; + scopes: string[]; + updatedAtMs: number; +}; + +type DeviceAuthStore = { + version: 1; + deviceId: string; + tokens: Record; +}; + +const STORAGE_KEY = "clawdbot.device.auth.v1"; + +function normalizeRole(role: string): string { + return role.trim(); +} + +function normalizeScopes(scopes: string[] | undefined): string[] { + if (!Array.isArray(scopes)) return []; + const out = new Set(); + for (const scope of scopes) { + const trimmed = scope.trim(); + if (trimmed) out.add(trimmed); + } + return [...out].sort(); +} + +function readStore(): DeviceAuthStore | null { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as DeviceAuthStore; + if (!parsed || parsed.version !== 1) return null; + if (!parsed.deviceId || typeof parsed.deviceId !== "string") return null; + if (!parsed.tokens || typeof parsed.tokens !== "object") return null; + return parsed; + } catch { + return null; + } +} + +function writeStore(store: DeviceAuthStore) { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); + } catch { + // best-effort + } +} + +export function loadDeviceAuthToken(params: { + deviceId: string; + role: string; +}): DeviceAuthEntry | null { + const store = readStore(); + if (!store || store.deviceId !== params.deviceId) return null; + const role = normalizeRole(params.role); + const entry = store.tokens[role]; + if (!entry || typeof entry.token !== "string") return null; + return entry; +} + +export function storeDeviceAuthToken(params: { + deviceId: string; + role: string; + token: string; + scopes?: string[]; +}): DeviceAuthEntry { + const role = normalizeRole(params.role); + const next: DeviceAuthStore = { + version: 1, + deviceId: params.deviceId, + tokens: {}, + }; + const existing = readStore(); + if (existing && existing.deviceId === params.deviceId) { + next.tokens = { ...existing.tokens }; + } + const entry: DeviceAuthEntry = { + token: params.token, + role, + scopes: normalizeScopes(params.scopes), + updatedAtMs: Date.now(), + }; + next.tokens[role] = entry; + writeStore(next); + return entry; +} + +export function clearDeviceAuthToken(params: { deviceId: string; role: string }) { + const store = readStore(); + if (!store || store.deviceId !== params.deviceId) return; + const role = normalizeRole(params.role); + if (!store.tokens[role]) return; + const next = { ...store, tokens: { ...store.tokens } }; + delete next.tokens[role]; + writeStore(next); +} diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 8c5d3f55c..f7bce2f7c 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -7,6 +7,7 @@ import { } from "../../../src/gateway/protocol/client-info.js"; import { buildDeviceAuthPayload } from "../../../src/gateway/device-auth.js"; import { loadOrCreateDeviceIdentity, signDevicePayload } from "./device-identity"; +import { clearDeviceAuthToken, loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth"; export type GatewayEventFrame = { type: "event"; @@ -29,6 +30,12 @@ export type GatewayHelloOk = { protocol: number; features?: { methods?: string[]; events?: string[] }; snapshot?: unknown; + auth?: { + deviceToken?: string; + role?: string; + scopes?: string[]; + issuedAtMs?: number; + }; policy?: { tickIntervalMs?: number }; }; @@ -120,15 +127,21 @@ export class GatewayBrowserClient { this.connectTimer = null; } const deviceIdentity = await loadOrCreateDeviceIdentity(); + const scopes = ["operator.admin"]; + const role = "operator"; + const storedToken = loadDeviceAuthToken({ + deviceId: deviceIdentity.deviceId, + role, + })?.token; + const authToken = storedToken ?? this.opts.token; + const canFallbackToShared = Boolean(storedToken && this.opts.token); const auth = - this.opts.token || this.opts.password + authToken || this.opts.password ? { - token: this.opts.token, + token: authToken, password: this.opts.password, } : undefined; - const scopes = ["operator.admin"]; - const role = "operator"; const signedAtMs = Date.now(); const nonce = this.connectNonce ?? undefined; const payload = buildDeviceAuthPayload({ @@ -138,7 +151,7 @@ export class GatewayBrowserClient { role, scopes, signedAtMs, - token: this.opts.token ?? null, + token: authToken ?? null, nonce, }); const signature = await signDevicePayload(deviceIdentity.privateKey, payload); @@ -169,10 +182,21 @@ export class GatewayBrowserClient { void this.request("connect", params) .then((hello) => { + if (hello?.auth?.deviceToken) { + storeDeviceAuthToken({ + deviceId: deviceIdentity.deviceId, + role: hello.auth.role ?? role, + token: hello.auth.deviceToken, + scopes: hello.auth.scopes ?? [], + }); + } this.backoffMs = 800; this.opts.onHello?.(hello); }) .catch(() => { + if (canFallbackToShared) { + clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role }); + } this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed"); }); } diff --git a/ui/src/ui/views/nodes.ts b/ui/src/ui/views/nodes.ts index 43c6b3e7e..31beca988 100644 --- a/ui/src/ui/views/nodes.ts +++ b/ui/src/ui/views/nodes.ts @@ -1,15 +1,24 @@ import { html, nothing } from "lit"; -import { clampText, formatAgo } from "../format"; +import { clampText, formatAgo, formatList } from "../format"; import type { ExecApprovalsAllowlistEntry, ExecApprovalsFile, ExecApprovalsSnapshot, } from "../controllers/exec-approvals"; +import type { + DevicePairingList, + DeviceTokenSummary, + PairedDevice, + PendingDevice, +} from "../controllers/devices"; export type NodesProps = { loading: boolean; nodes: Array>; + devicesLoading: boolean; + devicesError: string | null; + devicesList: DevicePairingList | null; configForm: Record | null; configLoading: boolean; configSaving: boolean; @@ -24,6 +33,11 @@ export type NodesProps = { execApprovalsTarget: "gateway" | "node"; execApprovalsTargetNodeId: string | null; onRefresh: () => void; + onDevicesRefresh: () => void; + onDeviceApprove: (requestId: string) => void; + onDeviceReject: (requestId: string) => void; + onDeviceRotate: (deviceId: string, role: string, scopes?: string[]) => void; + onDeviceRevoke: (deviceId: string, role: string) => void; onLoadConfig: () => void; onLoadExecApprovals: () => void; onBindDefault: (nodeId: string | null) => void; @@ -42,6 +56,7 @@ export function renderNodes(props: NodesProps) { return html` ${renderExecApprovals(approvalsState)} ${renderBindings(bindingState)} + ${renderDevices(props)}
@@ -61,6 +76,128 @@ export function renderNodes(props: NodesProps) { `; } +function renderDevices(props: NodesProps) { + const list = props.devicesList ?? { pending: [], paired: [] }; + const pending = Array.isArray(list.pending) ? list.pending : []; + const paired = Array.isArray(list.paired) ? list.paired : []; + return html` +
+
+
+
Devices
+
Pairing requests + role tokens.
+
+ +
+ ${props.devicesError + ? html`
${props.devicesError}
` + : nothing} +
+ ${pending.length > 0 + ? html` +
Pending
+ ${pending.map((req) => renderPendingDevice(req, props))} + ` + : nothing} + ${paired.length > 0 + ? html` +
Paired
+ ${paired.map((device) => renderPairedDevice(device, props))} + ` + : nothing} + ${pending.length === 0 && paired.length === 0 + ? html`
No paired devices.
` + : nothing} +
+
+ `; +} + +function renderPendingDevice(req: PendingDevice, props: NodesProps) { + const name = req.displayName?.trim() || req.deviceId; + const age = typeof req.ts === "number" ? formatAgo(req.ts) : "n/a"; + const role = req.role?.trim() ? `role: ${req.role}` : "role: -"; + const repair = req.isRepair ? " · repair" : ""; + const ip = req.remoteIp ? ` · ${req.remoteIp}` : ""; + return html` +
+
+
${name}
+
${req.deviceId}${ip}
+
+ ${role} · requested ${age}${repair} +
+
+
+
+ + +
+
+
+ `; +} + +function renderPairedDevice(device: PairedDevice, props: NodesProps) { + const name = device.displayName?.trim() || device.deviceId; + const ip = device.remoteIp ? ` · ${device.remoteIp}` : ""; + const roles = `roles: ${formatList(device.roles)}`; + const scopes = `scopes: ${formatList(device.scopes)}`; + const tokens = Array.isArray(device.tokens) ? device.tokens : []; + return html` +
+
+
${name}
+
${device.deviceId}${ip}
+
${roles} · ${scopes}
+ ${tokens.length === 0 + ? html`
Tokens: none
` + : html` +
Tokens
+
+ ${tokens.map((token) => renderTokenRow(device.deviceId, token, props))} +
+ `} +
+
+ `; +} + +function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: NodesProps) { + const status = token.revokedAtMs ? "revoked" : "active"; + const scopes = `scopes: ${formatList(token.scopes)}`; + const when = formatAgo(token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null); + return html` +
+
${token.role} · ${status} · ${scopes} · ${when}
+
+ + ${token.revokedAtMs + ? nothing + : html` + + `} +
+
+ `; +} + type BindingAgent = { id: string; name?: string;