feat: wire role-scoped device creds

This commit is contained in:
Peter Steinberger
2026-01-20 11:35:08 +00:00
parent dfbf6ac263
commit d8cc7db5e6
17 changed files with 633 additions and 26 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
View 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);
}

View File

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

View File

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