From ea99ca93fd36b4aa2991a0b1401974bb0e881c15 Mon Sep 17 00:00:00 2001 From: Jamie Taylor <97075514+Popidge@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:35:52 +0000 Subject: [PATCH] Bugfix/openclaw device identity auth (#62) * fix(openclaw): support device-auth handshake for gateway 2026.2.14+ * added agents.md for cross-agent compatibility --- AGENTS.md | 46 +++++++ src/integrations/openclaw/client.ts | 179 ++++++++++++++++++++++++-- src/integrations/openclaw/device.ts | 177 +++++++++++++++++++++++++ src/integrations/openclaw/protocol.ts | 46 ++++++- src/integrations/trpc/router.ts | 30 ++++- src/routes/monitor/index.tsx | 93 +++++++++++-- 6 files changed, 538 insertions(+), 33 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/integrations/openclaw/device.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4e04b20 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,46 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `src/routes/`: TanStack Start file-based routes (`/monitor`, `/workspace`, API handlers under `src/routes/api/`). +- `src/components/`: UI by domain (`monitor/`, `workspace/`, `navigation/`, `ani/`). +- `src/integrations/`: external/system integrations (`openclaw/`, `trpc/`, `query/`). +- `src/lib/`: shared utilities (graph layout, workspace FS helpers, demo data). +- `public/`: static assets (images, fonts, skill metadata). +- Runtime and packaging files: `Dockerfile`, `docker-compose.yml`, `bin/crabwalk`. + +## Architecture Overview +- Stack: TanStack Start + Router, tRPC, TanStack Query/DB, ReactFlow, Tailwind v4, React 19. +- Monitor flow: OpenClaw gateway WebSocket -> server integration (`src/integrations/openclaw/`) -> tRPC router (`src/integrations/trpc/router.ts`) -> client collections/graph UI. +- API entrypoint: `src/routes/api/trpc.$.ts`; router setup in `src/router.tsx`. + +## Build, Test, and Development Commands +- `npm run dev`: starts local dev server on `http://localhost:3000`. +- `npm run build`: creates production build with Vite/TanStack Start. +- `npm start`: runs the built server from `.output/server/index.mjs`. +- `docker-compose up -d`: run containerized app (set `CLAWDBOT_API_TOKEN` first). + +## Coding Style & Naming Conventions +- Language: TypeScript + React function components. +- Style in current codebase: 2-space indentation, single quotes, semicolon-light formatting. +- Components/files: `PascalCase` for React components (example: `SessionNode.tsx`). +- Hooks/utilities: `camelCase` exports, hooks prefixed with `use` (example: `useIsMobile.ts`). +- Route files follow TanStack conventions, e.g. `src/routes/monitor/index.tsx`, `src/routes/api/trpc.$.ts`. +- Use path alias `~/` for imports from `src`. + +## Testing Guidelines +- No dedicated automated test script is currently defined in `package.json`. +- Minimum pre-PR validation: run `npm run build`, then verify `/monitor` connectivity and `/workspace` file operations in `npm run dev`. +- If adding tests, colocate as `*.test.ts` / `*.test.tsx` near the feature and prefer fast unit tests for parsing/state logic. + +## Commit & Pull Request Guidelines +- Follow Conventional Commit style used in history: `feat(scope): ...`, `fix(scope): ...`, `docs: ...`, `chore: ...`. +- Keep scopes aligned with feature areas (`monitor`, `workspace`, `nav`, `openclaw`). +- PRs should include: + - clear summary of behavior changes, + - linked issue(s) when applicable, + - screenshots/GIFs for UI changes, + - notes on env/config changes (tokens, gateway URL, workspace mounts). + +## Security & Configuration Tips +- Never commit secrets. Use `.env` or runtime env vars (`CLAWDBOT_API_TOKEN`, `CLAWDBOT_URL`). +- Keep `.env.example` updated when introducing new required configuration. diff --git a/src/integrations/openclaw/client.ts b/src/integrations/openclaw/client.ts index 87fbf6e..a93ee5f 100644 --- a/src/integrations/openclaw/client.ts +++ b/src/integrations/openclaw/client.ts @@ -9,17 +9,25 @@ import { type AgentEvent, type SessionInfo, type SessionsListParams, + type ConnectChallengePayload, createConnectParams, } from './protocol' +import { buildSignedDevice } from './device' const DEFAULT_GATEWAY_URL = process.env.CLAWDBOT_URL || 'ws://127.0.0.1:18789' -interface ChallengePayload { - nonce: string - ts: number -} - type EventCallback = (event: EventFrame) => void +export type GatewayAuthState = + | 'unknown' + | 'authorized' + | 'unpaired' + | 'unauthorized' + | 'degraded' + +interface PairingInfo { + requestId?: string + message?: string +} export class ClawdbotClient { private ws: WebSocket | null = null @@ -32,6 +40,11 @@ export class ClawdbotClient { private reconnectTimer: ReturnType | null = null private _connected = false private _connecting = false + private _authState: GatewayAuthState = 'unknown' + private _scopes: string[] = [] + private _pairingInfo: PairingInfo | null = null + private _connectPromiseSettled = false + private readonly debugEnabled = process.env.CRABWALK_DEBUG_OPENCLAW === '1' constructor( private url: string = DEFAULT_GATEWAY_URL, @@ -42,16 +55,32 @@ export class ClawdbotClient { return this._connected } + get authState() { + return this._authState + } + + get scopes() { + return [...this._scopes] + } + + get pairingInfo() { + return this._pairingInfo + } + async connect(): Promise { if (this._connecting || this._connected) { return { type: 'hello-ok', protocol: 3 } as HelloOk } this._connecting = true + this._connectPromiseSettled = false return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this._connecting = false this.ws?.close() - reject(new Error('Connection timeout - is openclaw gateway running?')) + if (!this._connectPromiseSettled) { + this._connectPromiseSettled = true + reject(new Error('Connection timeout - is openclaw gateway running?')) + } }, 10000) try { @@ -63,7 +92,7 @@ export class ClawdbotClient { } this.ws.once('open', () => { - // WebSocket connected, waiting for challenge + this.debugLog('socket open, waiting for connect.challenge') }) this.ws.on('message', (data) => { @@ -73,7 +102,7 @@ export class ClawdbotClient { // Handle challenge-response auth if (msg.type === 'event' && msg.event === 'connect.challenge') { - this.handleChallenge(msg.payload as ChallengePayload) + this.handleChallenge(msg.payload as ConnectChallengePayload) return } @@ -86,14 +115,33 @@ export class ClawdbotClient { this.ws.on('error', (err) => { clearTimeout(timeout) this._connecting = false - reject(err) + this.debugLog('socket error before connect', err) + if (!this._connectPromiseSettled) { + this._connectPromiseSettled = true + reject(err) + } }) - this.ws.on('close', (code, _reason) => { + this.ws.on('close', (code, reason) => { clearTimeout(timeout) const wasConnected = this._connected + const wasConnecting = this._connecting + this.debugLog('socket close', { + code, + reason: reason?.toString?.() ?? '', + wasConnected, + wasConnecting, + }) this._connected = false this._connecting = false + if (!wasConnected && wasConnecting && !this._connectPromiseSettled) { + this._connectPromiseSettled = true + reject( + new Error( + `Gateway closed before connect (code ${code}${reason ? `, reason: ${reason.toString()}` : ''})` + ) + ) + } // Only reconnect if we were previously connected and it wasn't a clean close if (wasConnected && code !== 1000) { this.scheduleReconnect() @@ -102,12 +150,35 @@ export class ClawdbotClient { }) } - private handleChallenge(_challenge: ChallengePayload) { - if (!this.token || this.ws?.readyState !== WebSocket.OPEN) { + private handleChallenge(challenge: ConnectChallengePayload) { + if (this.ws?.readyState !== WebSocket.OPEN) { return } - const params = createConnectParams(this.token) + let params = createConnectParams(this.token) + try { + const device = buildSignedDevice({ + challenge, + token: this.token ?? null, + role: params.role, + scopes: params.scopes, + clientId: params.client.id, + clientMode: params.client.mode, + }) + params = createConnectParams(this.token, device) + } catch (error) { + console.error('[openclaw] Failed to create signed device identity:', error) + } + + this.debugLog('sending connect', { + hasToken: Boolean(params.auth?.token), + hasDevice: Boolean(params.device), + deviceId: params.device?.id, + clientMode: params.client.mode, + clientPlatform: params.client.platform, + scopes: params.scopes, + }) + const response: RequestFrame = { type: 'req', id: `connect-${Date.now()}`, @@ -128,7 +199,10 @@ export class ClawdbotClient { switch (msg.type) { case 'hello-ok': if (connectTimeout) clearTimeout(connectTimeout) + this.updateAuthStateFromHello(msg) this._connected = true + this._connecting = false + this._connectPromiseSettled = true connectResolve?.(msg) break @@ -136,8 +210,10 @@ export class ClawdbotClient { // Check if this is the hello-ok response to our connect request if (msg.ok && (msg.payload as HelloOk)?.type === 'hello-ok') { if (connectTimeout) clearTimeout(connectTimeout) + this.updateAuthStateFromHello(msg.payload as HelloOk) this._connected = true this._connecting = false + this._connectPromiseSettled = true connectResolve?.(msg.payload as HelloOk) } else { this.handleResponse(msg) @@ -162,12 +238,25 @@ export class ClawdbotClient { if (res.ok) { pending.resolve(res.payload) } else { - pending.reject(new Error(res.error?.message || 'Request failed')) + const message = res.error?.message || 'Request failed' + this.updateAuthStateFromError(message) + pending.reject(new Error(message)) } } } private handleEvent(event: EventFrame) { + if (event.event.includes('pair') || event.event.includes('device')) { + const payload = event.payload as { requestId?: string; message?: string } | undefined + if (payload?.requestId || payload?.message) { + this._authState = 'unpaired' + this._pairingInfo = { + requestId: payload.requestId, + message: payload.message, + } + } + } + for (const listener of this.eventListeners) { try { listener(event) @@ -235,6 +324,68 @@ export class ClawdbotClient { this.ws = null } this._connected = false + this._authState = 'unknown' + this._scopes = [] + this._pairingInfo = null + } + + private updateAuthStateFromHello(hello: HelloOk) { + const scopes = hello.auth?.scopes + this._scopes = scopes ? [...scopes] : [] + + if (!scopes) { + this._authState = 'authorized' + return + } + + if (scopes.includes('operator.read')) { + this._authState = 'authorized' + this._pairingInfo = null + this.debugLog('authorized scopes', scopes) + return + } + + this._authState = scopes.length === 0 ? 'unpaired' : 'degraded' + this.debugLog('non-authorized scopes', scopes) + } + + private updateAuthStateFromError(message: string) { + const lowered = message.toLowerCase() + if (lowered.includes('missing scope') || lowered.includes('operator.read')) { + this._authState = 'unpaired' + const requestId = this.extractRequestId(message) + this._pairingInfo = { + requestId: requestId ?? this._pairingInfo?.requestId, + message, + } + return + } + + if (lowered.includes('unauthorized') || lowered.includes('forbidden')) { + this._authState = 'unauthorized' + this._pairingInfo = { message } + } + } + + private debugLog(message: string, payload?: unknown) { + if (!this.debugEnabled) return + if (payload !== undefined) { + console.log(`[openclaw][debug] ${message}`, payload) + return + } + console.log(`[openclaw][debug] ${message}`) + } + + private extractRequestId(message: string): string | undefined { + const explicitMatch = message.match(/request(?:\s+id)?[:=\s]+([a-zA-Z0-9_-]+)/i) + if (explicitMatch?.[1]) { + return explicitMatch[1] + } + + const uuidMatch = message.match( + /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i + ) + return uuidMatch?.[0] } } diff --git a/src/integrations/openclaw/device.ts b/src/integrations/openclaw/device.ts new file mode 100644 index 0000000..949a435 --- /dev/null +++ b/src/integrations/openclaw/device.ts @@ -0,0 +1,177 @@ +import fs from 'fs' +import path from 'path' +import { + createHash, + createPrivateKey, + generateKeyPairSync, + sign, +} from 'crypto' +import type { ConnectChallengePayload, ConnectDevice } from './protocol' + +const DATA_DIR = path.join(process.cwd(), 'data') +const DEVICE_IDENTITY_FILE = path.join(DATA_DIR, 'device-identity.json') + +interface StoredDeviceIdentity { + id: string + publicKey: string + privateKeyPem: string + createdAt: number + lastUsedAt: number +} + +function base64UrlToBuffer(value: string): Buffer { + const padded = value.padEnd(value.length + ((4 - (value.length % 4)) % 4), '=') + const base64 = padded.replace(/-/g, '+').replace(/_/g, '/') + return Buffer.from(base64, 'base64') +} + +function base64UrlEncode(value: Buffer): string { + return value.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '') +} + +function decodePublicKey(value: string): Buffer { + try { + const raw = base64UrlToBuffer(value) + if (raw.length > 0) return raw + } catch { + // fallback below + } + return Buffer.from(value, 'base64') +} + +function fingerprintFromPublicKey(publicKey: string): string { + const rawPublicKey = decodePublicKey(publicKey) + return createHash('sha256').update(rawPublicKey).digest('hex') +} + +function ensureDataDir() { + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }) + } +} + +function loadStoredIdentity(): StoredDeviceIdentity | null { + try { + if (!fs.existsSync(DEVICE_IDENTITY_FILE)) { + return null + } + const data = JSON.parse(fs.readFileSync(DEVICE_IDENTITY_FILE, 'utf-8')) as StoredDeviceIdentity + if (!data.publicKey || !data.privateKeyPem) { + return null + } + const canonicalId = fingerprintFromPublicKey(data.publicKey) + const normalized: StoredDeviceIdentity = { + ...data, + id: canonicalId, + } + // Auto-migrate legacy id formats to canonical fingerprint. + if (data.id !== canonicalId) { + saveStoredIdentity(normalized) + } + return normalized + } catch { + return null + } +} + +function saveStoredIdentity(identity: StoredDeviceIdentity) { + ensureDataDir() + fs.writeFileSync(DEVICE_IDENTITY_FILE, JSON.stringify(identity, null, 2)) +} + +function generateStoredIdentity(): StoredDeviceIdentity { + const { publicKey, privateKey } = generateKeyPairSync('ed25519') + const publicJwk = publicKey.export({ format: 'jwk' }) + if (!publicJwk.x) { + throw new Error('Failed to export Ed25519 public key') + } + + const rawPublicKey = base64UrlToBuffer(publicJwk.x) + const fingerprint = createHash('sha256').update(rawPublicKey).digest('hex') + const now = Date.now() + + return { + id: fingerprint, + publicKey: base64UrlEncode(rawPublicKey), + privateKeyPem: privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(), + createdAt: now, + lastUsedAt: now, + } +} + +interface BuildSignedDeviceParams { + challenge: ConnectChallengePayload + token: string | null + role: string + scopes: string[] + clientId: string + clientMode: string +} + +function buildDeviceAuthPayload(params: { + deviceId: string + clientId: string + clientMode: string + role: string + scopes: string[] + signedAtMs: number + token: string | null + nonce?: string + version?: 'v1' | 'v2' +}): string { + const version = params.version ?? (params.nonce ? 'v2' : 'v1') + const scopes = params.scopes.join(',') + const token = params.token ?? '' + const parts = [ + version, + params.deviceId, + params.clientId, + params.clientMode, + params.role, + scopes, + String(params.signedAtMs), + token, + ] + if (version === 'v2') { + parts.push(params.nonce ?? '') + } + return parts.join('|') +} + +export function getOrCreateIdentity(): StoredDeviceIdentity { + const existing = loadStoredIdentity() + if (existing) { + return existing + } + const generated = generateStoredIdentity() + saveStoredIdentity(generated) + return generated +} + +export function buildSignedDevice(params: BuildSignedDeviceParams): ConnectDevice { + const identity = getOrCreateIdentity() + const privateKey = createPrivateKey(identity.privateKeyPem) + const signedAt = Date.now() + const payload = buildDeviceAuthPayload({ + deviceId: identity.id, + clientId: params.clientId, + clientMode: params.clientMode, + role: params.role, + scopes: params.scopes, + signedAtMs: signedAt, + token: params.token, + nonce: params.challenge.nonce || undefined, + }) + const signature = base64UrlEncode(sign(null, Buffer.from(payload, 'utf8'), privateKey)) + + identity.lastUsedAt = signedAt + saveStoredIdentity(identity) + + return { + id: identity.id, + publicKey: identity.publicKey, + signature, + signedAt, + nonce: params.challenge.nonce, + } +} diff --git a/src/integrations/openclaw/protocol.ts b/src/integrations/openclaw/protocol.ts index 7fda5e1..76f9534 100644 --- a/src/integrations/openclaw/protocol.ts +++ b/src/integrations/openclaw/protocol.ts @@ -32,7 +32,7 @@ export interface ClientInfo { displayName: string version: string platform: string - mode: 'ui' | 'cli' | 'bot' + mode: 'ui' | 'cli' | 'bot' | 'operator' | 'node' } export interface ConnectParams { @@ -40,6 +40,25 @@ export interface ConnectParams { maxProtocol: 3 client: ClientInfo auth?: { token?: string } + device?: ConnectDevice +} + +export interface ConnectChallengePayload { + nonce: string + ts: number +} + +export interface ConnectDevice { + id: string + publicKey: string + signature: string + signedAt: number + nonce: string +} + +export interface HelloAuth { + role?: string + scopes?: string[] } export interface HelloOk { @@ -51,6 +70,7 @@ export interface HelloOk { stateVersion: { presence: number; health: number } } features: { methods: string[]; events: string[] } + auth?: HelloAuth } export interface PresenceEntry { @@ -231,15 +251,32 @@ export function parseSessionKey(key: string): { return { agentId, platform, recipient, isGroup } } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function createConnectParams(token?: string): any { +export function createConnectParams( + token?: string, + device?: ConnectDevice +): ConnectParams & { + role: string + scopes: string[] + caps: unknown[] + commands: unknown[] + permissions: Record + locale: string + userAgent: string +} { + const platformMap: Record = { + win32: 'windows', + darwin: 'macos', + linux: 'linux', + } + const platform = platformMap[process.platform] ?? process.platform + return { minProtocol: 3, maxProtocol: 3, client: { id: 'cli', version: '0.1.0', - platform: 'linux', + platform, mode: 'cli', }, role: 'operator', @@ -250,5 +287,6 @@ export function createConnectParams(token?: string): any { locale: 'en-US', userAgent: 'crabwalk-monitor/0.1.0', auth: token ? { token } : undefined, + device, } } diff --git a/src/integrations/trpc/router.ts b/src/integrations/trpc/router.ts index 635087e..2cc5798 100644 --- a/src/integrations/trpc/router.ts +++ b/src/integrations/trpc/router.ts @@ -43,7 +43,12 @@ const openclawRouter = router({ connect: publicProcedure.mutation(async () => { const client = getClawdbotClient() if (client.connected) { - return { status: 'already_connected' as const } + return { + status: 'already_connected' as const, + authState: client.authState, + scopes: client.scopes, + pairing: client.pairingInfo, + } } try { const hello = await client.connect() @@ -52,11 +57,17 @@ const openclawRouter = router({ protocol: hello.protocol, features: hello.features, presenceCount: hello.snapshot?.presence?.length ?? 0, + authState: client.authState, + scopes: client.scopes, + pairing: client.pairingInfo, } } catch (error) { return { status: 'error' as const, message: error instanceof Error ? error.message : 'Connection failed', + authState: client.authState, + scopes: client.scopes, + pairing: client.pairingInfo, } } }), @@ -72,6 +83,16 @@ const openclawRouter = router({ return { connected: client.connected } }), + authStatus: publicProcedure.query(() => { + const client = getClawdbotClient() + return { + connected: client.connected, + authState: client.authState, + scopes: client.scopes, + pairing: client.pairingInfo, + } + }), + gatewayEndpoint: publicProcedure.query(() => { return { url: getClawdbotEndpoint() } }), @@ -134,7 +155,7 @@ const openclawRouter = router({ const client = getClawdbotClient() const persistence = getPersistenceService() if (!client.connected) { - return { sessions: [], error: 'Not connected' } + return { sessions: [], error: 'Not connected', authState: client.authState } } try { const sessions = await client.listSessions(input) @@ -143,11 +164,14 @@ const openclawRouter = router({ for (const session of monitorSessions) { persistence.upsertSession(session) } - return { sessions: monitorSessions } + return { sessions: monitorSessions, authState: client.authState, scopes: client.scopes } } catch (error) { return { sessions: [], error: error instanceof Error ? error.message : 'Failed to list sessions', + authState: client.authState, + scopes: client.scopes, + pairing: client.pairingInfo, } } }), diff --git a/src/routes/monitor/index.tsx b/src/routes/monitor/index.tsx index 2040395..fca01b3 100644 --- a/src/routes/monitor/index.tsx +++ b/src/routes/monitor/index.tsx @@ -65,10 +65,19 @@ function MonitorPageWrapper() { const RETRY_DELAY = 3000 const MAX_RETRIES = 10 const DEFAULT_GATEWAY_ENDPOINT = 'ws://127.0.0.1:18789' +type AuthState = 'unknown' | 'authorized' | 'unpaired' | 'unauthorized' | 'degraded' + +interface PairingState { + requestId?: string + message?: string +} function MonitorPage() { const [connected, setConnected] = useState(false) const [connecting, setConnecting] = useState(false) + const [authState, setAuthState] = useState('unknown') + const [scopes, setScopes] = useState([]) + const [pairing, setPairing] = useState(null) const [retryCount, setRetryCount] = useState(0) const [historicalMode, setHistoricalMode] = useState(false) const [debugMode, setDebugMode] = useState(false) @@ -117,6 +126,7 @@ function MonitorPage() { // Check connection status and persistence on mount useEffect(() => { checkStatus() + checkAuthStatus() checkPersistenceStatus() loadGatewayEndpoint() }, []) @@ -151,18 +161,43 @@ function MonitorPage() { } } + const checkAuthStatus = useCallback(async () => { + try { + const status = await trpc.openclaw.authStatus.query() + setConnected(status.connected) + setAuthState(status.authState as AuthState) + setScopes(status.scopes ?? []) + setPairing(status.pairing ?? null) + } catch { + // ignore + } + }, []) + + const canPollSessions = useMemo(() => { + if (!connected) return false + if (authState === 'unpaired' || authState === 'unauthorized' || authState === 'degraded') { + return false + } + return true + }, [connected, authState]) + const handleConnect = async (retry = 0) => { setConnecting(true) setRetryCount(retry) try { const result = await trpc.openclaw.connect.mutate() + setAuthState((result.authState as AuthState) ?? 'unknown') + setScopes(result.scopes ?? []) + setPairing(result.pairing ?? null) if (result.status === 'connected' || result.status === 'already_connected') { setConnected(true) setRetryCount(0) setConnecting(false) - // Hydrate from persistence if enabled - await hydrateFromPersistence() - await loadSessions() + if (result.authState === 'authorized' || result.authState === 'unknown') { + // Hydrate from persistence if enabled + await hydrateFromPersistence() + await loadSessions() + } return } } catch { @@ -199,17 +234,24 @@ function MonitorPage() { try { await trpc.openclaw.disconnect.mutate() setConnected(false) + setAuthState('unknown') + setScopes([]) + setPairing(null) clearCollections() } catch (e) { console.error('Disconnect error:', e) } } - const loadSessions = async () => { + const loadSessions = useCallback(async () => { try { const result = await trpc.openclaw.sessions.query( historicalMode ? { activeMinutes: 1440 } : { activeMinutes: 60 } ) + setAuthState((prev) => (result.authState as AuthState) ?? prev) + setScopes((prev) => result.scopes ?? prev) + setPairing((prev) => result.pairing ?? prev) + if (result.sessions) { for (const session of result.sessions) { upsertSession(session) @@ -218,15 +260,18 @@ function MonitorPage() { } catch (e) { console.error('Failed to load sessions:', e) } - } + }, [historicalMode]) const handleRefresh = useCallback(async () => { - await loadSessions() - }, [historicalMode]) + await checkAuthStatus() + if (canPollSessions) { + await loadSessions() + } + }, [checkAuthStatus, canPollSessions, loadSessions]) const handleHistoricalModeChange = (enabled: boolean) => { setHistoricalMode(enabled) - if (connected) { + if (canPollSessions) { loadSessions() } } @@ -337,6 +382,15 @@ function MonitorPage() { return () => clearInterval(interval) }, []) + // Poll lightweight auth status while connected + useEffect(() => { + if (!connected) return + const interval = setInterval(() => { + checkAuthStatus() + }, 10000) + return () => clearInterval(interval) + }, [connected, checkAuthStatus]) + const handleToggleSidebar = useCallback(() => { setSidebarCollapsed((prev) => !prev) }, []) @@ -350,16 +404,16 @@ function MonitorPage() { // Poll for sessions while connected useEffect(() => { - if (!connected) return + if (!canPollSessions) return const interval = setInterval(() => { loadSessions() }, 5000) // Poll every 5 seconds return () => clearInterval(interval) - }, [connected, historicalMode]) + }, [canPollSessions, loadSessions]) // Subscribe to real-time events useEffect(() => { - if (!connected) return + if (!canPollSessions) return const subscription = trpc.openclaw.events.subscribe(undefined, { onData: (data) => { @@ -381,7 +435,11 @@ function MonitorPage() { return () => { subscription.unsubscribe() } - }, [connected]) + }, [canPollSessions]) + + const pairingHint = pairing?.requestId + ? `openclaw devices approve ${pairing.requestId}` + : 'openclaw devices list && openclaw devices approve ' return (
@@ -498,6 +556,17 @@ function MonitorPage() {
+ {connected && !canPollSessions && ( +
+
+ Authentication pending. Session polling is paused to avoid missing-scope errors. +
+
+ {pairing?.message ?? `Approve this device in OpenClaw: ${pairingHint}`} +
+
+ )} + {/* Main content */}
{/* Sidebar - desktop only */}