feat: wire role-scoped device creds
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
} from "../protocol/index.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
function redactPairedDevice(device: { tokens?: Record<string, DeviceAuthToken> } & Record<string, unknown>) {
|
||||
function redactPairedDevice(
|
||||
device: { tokens?: Record<string, DeviceAuthToken> } & Record<string, unknown>,
|
||||
) {
|
||||
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() },
|
||||
|
||||
@@ -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<typeof createSubsystemLogger>;
|
||||
|
||||
export type GatewayClient = {
|
||||
connect: ConnectParams;
|
||||
@@ -28,7 +31,7 @@ export type GatewayRequestContext = {
|
||||
getHealthCache: () => HealthSummary | null;
|
||||
refreshHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;
|
||||
logHealth: { error: (message: string) => void };
|
||||
logGateway: { warn: (message: string) => void };
|
||||
logGateway: SubsystemLogger;
|
||||
incrementPresenceVersion: () => number;
|
||||
getHealthVersion: () => number;
|
||||
broadcast: (
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<typeof refreshActiveTab>[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<typeof loadCron>[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) {
|
||||
|
||||
@@ -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<string, unknown> | 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 =
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
devicesLoading: boolean;
|
||||
devicesError: string | null;
|
||||
devicesList: DevicePairingList | null;
|
||||
execApprovalsLoading: boolean;
|
||||
execApprovalsSaving: boolean;
|
||||
execApprovalsDirty: boolean;
|
||||
|
||||
@@ -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<Record<string, unknown>> = [];
|
||||
@state() devicesLoading = false;
|
||||
@state() devicesError: string | null = null;
|
||||
@state() devicesList: DevicePairingList | null = null;
|
||||
@state() execApprovalsLoading = false;
|
||||
@state() execApprovalsSaving = false;
|
||||
@state() execApprovalsDirty = false;
|
||||
|
||||
135
ui/src/ui/controllers/devices.ts
Normal file
135
ui/src/ui/controllers/devices.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
99
ui/src/ui/device-auth.ts
Normal file
99
ui/src/ui/device-auth.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
export type DeviceAuthEntry = {
|
||||
token: string;
|
||||
role: string;
|
||||
scopes: string[];
|
||||
updatedAtMs: number;
|
||||
};
|
||||
|
||||
type DeviceAuthStore = {
|
||||
version: 1;
|
||||
deviceId: string;
|
||||
tokens: Record<string, DeviceAuthEntry>;
|
||||
};
|
||||
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
@@ -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<GatewayHelloOk>("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");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
devicesLoading: boolean;
|
||||
devicesError: string | null;
|
||||
devicesList: DevicePairingList | null;
|
||||
configForm: Record<string, unknown> | 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)}
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div>
|
||||
@@ -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`
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div>
|
||||
<div class="card-title">Devices</div>
|
||||
<div class="card-sub">Pairing requests + role tokens.</div>
|
||||
</div>
|
||||
<button class="btn" ?disabled=${props.devicesLoading} @click=${props.onDevicesRefresh}>
|
||||
${props.devicesLoading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
${props.devicesError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">${props.devicesError}</div>`
|
||||
: nothing}
|
||||
<div class="list" style="margin-top: 16px;">
|
||||
${pending.length > 0
|
||||
? html`
|
||||
<div class="muted" style="margin-bottom: 8px;">Pending</div>
|
||||
${pending.map((req) => renderPendingDevice(req, props))}
|
||||
`
|
||||
: nothing}
|
||||
${paired.length > 0
|
||||
? html`
|
||||
<div class="muted" style="margin-top: 12px; margin-bottom: 8px;">Paired</div>
|
||||
${paired.map((device) => renderPairedDevice(device, props))}
|
||||
`
|
||||
: nothing}
|
||||
${pending.length === 0 && paired.length === 0
|
||||
? html`<div class="muted">No paired devices.</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div class="list-item">
|
||||
<div class="list-main">
|
||||
<div class="list-title">${name}</div>
|
||||
<div class="list-sub">${req.deviceId}${ip}</div>
|
||||
<div class="muted" style="margin-top: 6px;">
|
||||
${role} · requested ${age}${repair}
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-meta">
|
||||
<div class="row" style="justify-content: flex-end; gap: 8px; flex-wrap: wrap;">
|
||||
<button class="btn btn--sm primary" @click=${() => props.onDeviceApprove(req.requestId)}>
|
||||
Approve
|
||||
</button>
|
||||
<button class="btn btn--sm" @click=${() => props.onDeviceReject(req.requestId)}>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div class="list-item">
|
||||
<div class="list-main">
|
||||
<div class="list-title">${name}</div>
|
||||
<div class="list-sub">${device.deviceId}${ip}</div>
|
||||
<div class="muted" style="margin-top: 6px;">${roles} · ${scopes}</div>
|
||||
${tokens.length === 0
|
||||
? html`<div class="muted" style="margin-top: 6px;">Tokens: none</div>`
|
||||
: html`
|
||||
<div class="muted" style="margin-top: 10px;">Tokens</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px; margin-top: 6px;">
|
||||
${tokens.map((token) => renderTokenRow(device.deviceId, token, props))}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div class="row" style="justify-content: space-between; gap: 8px;">
|
||||
<div class="list-sub">${token.role} · ${status} · ${scopes} · ${when}</div>
|
||||
<div class="row" style="justify-content: flex-end; gap: 6px; flex-wrap: wrap;">
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
@click=${() => props.onDeviceRotate(deviceId, token.role, token.scopes)}
|
||||
>
|
||||
Rotate
|
||||
</button>
|
||||
${token.revokedAtMs
|
||||
? nothing
|
||||
: html`
|
||||
<button
|
||||
class="btn btn--sm danger"
|
||||
@click=${() => props.onDeviceRevoke(deviceId, token.role)}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
type BindingAgent = {
|
||||
id: string;
|
||||
name?: string;
|
||||
|
||||
Reference in New Issue
Block a user