hermes/backend/system-access.ts
Nico ccee249618 v0.6.42: Hermes chat UI — Vue3/TS/Vite, audio STT/TTS, sidebar rail, MCP event loop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:35:10 +02:00

111 lines
3.8 KiB
TypeScript

/**
* system-access.ts — Device authorization flow for system MCP tools
*
* Flow: Claude calls system_request_access → backend notifies browser via WS →
* user approves in /dev → Claude polls system_check_access → gets systemToken
*/
import { randomUUID } from 'crypto';
import { pushEvent } from './mcp/events.ts';
const REQUEST_TTL_MS = 5 * 60 * 1000; // 5 min to approve
const TOKEN_TTL_MS = 4 * 60 * 60 * 1000; // 4 hour token lifetime
export interface PendingRequest {
requestId: string;
userCode: string;
description: string;
createdAt: number;
expiresAt: number;
}
interface RequestEntry extends PendingRequest {
systemToken: string | null;
mcpKey?: string;
}
interface SystemTokenEntry {
requestId: string;
user: string;
createdAt: number;
expiresAt: number;
}
const pendingMap = new Map<string, RequestEntry>();
const tokenMap = new Map<string, SystemTokenEntry>();
let notifyFn: ((req: PendingRequest) => void) | null = null;
export function setNotifyFn(fn: (req: PendingRequest) => void) {
notifyFn = fn;
}
function genUserCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const part = (n: number) => Array.from({ length: n }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
return `${part(4)}-${part(4)}`;
}
export function createRequest(description: string, mcpKey?: string): PendingRequest {
const now = Date.now();
// Clean expired
for (const [id, req] of pendingMap)
if (req.expiresAt < now) pendingMap.delete(id);
const entry: RequestEntry = {
requestId: randomUUID(),
userCode: genUserCode(),
description,
createdAt: now,
expiresAt: now + REQUEST_TTL_MS,
systemToken: null,
mcpKey,
};
pendingMap.set(entry.requestId, entry);
const pub: PendingRequest = { requestId: entry.requestId, userCode: entry.userCode, description, createdAt: entry.createdAt, expiresAt: entry.expiresAt };
notifyFn?.(pub);
return pub;
}
export function getPendingRequests(): PendingRequest[] {
const now = Date.now();
const out: PendingRequest[] = [];
for (const req of pendingMap.values())
if (req.expiresAt > now && !req.systemToken)
out.push({ requestId: req.requestId, userCode: req.userCode, description: req.description, createdAt: req.createdAt, expiresAt: req.expiresAt });
return out;
}
export function approveRequest(requestId: string, user: string): string | null {
const req = pendingMap.get(requestId);
if (!req) return null;
if (req.expiresAt < Date.now()) { pendingMap.delete(requestId); return null; }
if (req.systemToken) return req.systemToken; // idempotent
const token = randomUUID();
// Push event to MCP subscriber
if (req.mcpKey) pushEvent(req.mcpKey, { type: "system_access_approved", data: { requestId, systemToken: token } });
req.systemToken = token;
tokenMap.set(token, { requestId, user, createdAt: Date.now(), expiresAt: Date.now() + TOKEN_TTL_MS });
return token;
}
export function denyRequest(requestId: string) {
pendingMap.delete(requestId);
}
export function checkRequest(requestId: string): { status: 'pending' | 'approved' | 'expired' | 'denied'; systemToken?: string } {
const req = pendingMap.get(requestId);
if (!req) return { status: 'denied' };
if (req.expiresAt < Date.now()) { pendingMap.delete(requestId); return { status: 'expired' }; }
if (req.systemToken) return { status: 'approved', systemToken: req.systemToken };
return { status: 'pending' };
}
export function validateSystemToken(token: string): SystemTokenEntry | null {
const entry = tokenMap.get(token);
if (!entry) return null;
if (entry.expiresAt < Date.now()) { tokenMap.delete(token); return null; }
return entry;
}