Bugfix/openclaw device identity auth (#62)

* fix(openclaw): support device-auth handshake for gateway 2026.2.14+

* added agents.md for cross-agent compatibility
This commit is contained in:
Jamie Taylor
2026-02-19 13:35:52 -05:00
committed by GitHub
parent 6ca27b1e81
commit ea99ca93fd
6 changed files with 538 additions and 33 deletions
+46
View File
@@ -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.
+165 -14
View File
@@ -9,17 +9,25 @@ import {
type AgentEvent, type AgentEvent,
type SessionInfo, type SessionInfo,
type SessionsListParams, type SessionsListParams,
type ConnectChallengePayload,
createConnectParams, createConnectParams,
} from './protocol' } from './protocol'
import { buildSignedDevice } from './device'
const DEFAULT_GATEWAY_URL = process.env.CLAWDBOT_URL || 'ws://127.0.0.1:18789' 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 type EventCallback = (event: EventFrame) => void
export type GatewayAuthState =
| 'unknown'
| 'authorized'
| 'unpaired'
| 'unauthorized'
| 'degraded'
interface PairingInfo {
requestId?: string
message?: string
}
export class ClawdbotClient { export class ClawdbotClient {
private ws: WebSocket | null = null private ws: WebSocket | null = null
@@ -32,6 +40,11 @@ export class ClawdbotClient {
private reconnectTimer: ReturnType<typeof setTimeout> | null = null private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private _connected = false private _connected = false
private _connecting = 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( constructor(
private url: string = DEFAULT_GATEWAY_URL, private url: string = DEFAULT_GATEWAY_URL,
@@ -42,16 +55,32 @@ export class ClawdbotClient {
return this._connected return this._connected
} }
get authState() {
return this._authState
}
get scopes() {
return [...this._scopes]
}
get pairingInfo() {
return this._pairingInfo
}
async connect(): Promise<HelloOk> { async connect(): Promise<HelloOk> {
if (this._connecting || this._connected) { if (this._connecting || this._connected) {
return { type: 'hello-ok', protocol: 3 } as HelloOk return { type: 'hello-ok', protocol: 3 } as HelloOk
} }
this._connecting = true this._connecting = true
this._connectPromiseSettled = false
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
this._connecting = false this._connecting = false
this.ws?.close() 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) }, 10000)
try { try {
@@ -63,7 +92,7 @@ export class ClawdbotClient {
} }
this.ws.once('open', () => { this.ws.once('open', () => {
// WebSocket connected, waiting for challenge this.debugLog('socket open, waiting for connect.challenge')
}) })
this.ws.on('message', (data) => { this.ws.on('message', (data) => {
@@ -73,7 +102,7 @@ export class ClawdbotClient {
// Handle challenge-response auth // Handle challenge-response auth
if (msg.type === 'event' && msg.event === 'connect.challenge') { if (msg.type === 'event' && msg.event === 'connect.challenge') {
this.handleChallenge(msg.payload as ChallengePayload) this.handleChallenge(msg.payload as ConnectChallengePayload)
return return
} }
@@ -86,14 +115,33 @@ export class ClawdbotClient {
this.ws.on('error', (err) => { this.ws.on('error', (err) => {
clearTimeout(timeout) clearTimeout(timeout)
this._connecting = false 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) clearTimeout(timeout)
const wasConnected = this._connected const wasConnected = this._connected
const wasConnecting = this._connecting
this.debugLog('socket close', {
code,
reason: reason?.toString?.() ?? '',
wasConnected,
wasConnecting,
})
this._connected = false this._connected = false
this._connecting = 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 // Only reconnect if we were previously connected and it wasn't a clean close
if (wasConnected && code !== 1000) { if (wasConnected && code !== 1000) {
this.scheduleReconnect() this.scheduleReconnect()
@@ -102,12 +150,35 @@ export class ClawdbotClient {
}) })
} }
private handleChallenge(_challenge: ChallengePayload) { private handleChallenge(challenge: ConnectChallengePayload) {
if (!this.token || this.ws?.readyState !== WebSocket.OPEN) { if (this.ws?.readyState !== WebSocket.OPEN) {
return 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 = { const response: RequestFrame = {
type: 'req', type: 'req',
id: `connect-${Date.now()}`, id: `connect-${Date.now()}`,
@@ -128,7 +199,10 @@ export class ClawdbotClient {
switch (msg.type) { switch (msg.type) {
case 'hello-ok': case 'hello-ok':
if (connectTimeout) clearTimeout(connectTimeout) if (connectTimeout) clearTimeout(connectTimeout)
this.updateAuthStateFromHello(msg)
this._connected = true this._connected = true
this._connecting = false
this._connectPromiseSettled = true
connectResolve?.(msg) connectResolve?.(msg)
break break
@@ -136,8 +210,10 @@ export class ClawdbotClient {
// Check if this is the hello-ok response to our connect request // Check if this is the hello-ok response to our connect request
if (msg.ok && (msg.payload as HelloOk)?.type === 'hello-ok') { if (msg.ok && (msg.payload as HelloOk)?.type === 'hello-ok') {
if (connectTimeout) clearTimeout(connectTimeout) if (connectTimeout) clearTimeout(connectTimeout)
this.updateAuthStateFromHello(msg.payload as HelloOk)
this._connected = true this._connected = true
this._connecting = false this._connecting = false
this._connectPromiseSettled = true
connectResolve?.(msg.payload as HelloOk) connectResolve?.(msg.payload as HelloOk)
} else { } else {
this.handleResponse(msg) this.handleResponse(msg)
@@ -162,12 +238,25 @@ export class ClawdbotClient {
if (res.ok) { if (res.ok) {
pending.resolve(res.payload) pending.resolve(res.payload)
} else { } 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) { 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) { for (const listener of this.eventListeners) {
try { try {
listener(event) listener(event)
@@ -235,6 +324,68 @@ export class ClawdbotClient {
this.ws = null this.ws = null
} }
this._connected = false 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]
} }
} }
+177
View File
@@ -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,
}
}
+42 -4
View File
@@ -32,7 +32,7 @@ export interface ClientInfo {
displayName: string displayName: string
version: string version: string
platform: string platform: string
mode: 'ui' | 'cli' | 'bot' mode: 'ui' | 'cli' | 'bot' | 'operator' | 'node'
} }
export interface ConnectParams { export interface ConnectParams {
@@ -40,6 +40,25 @@ export interface ConnectParams {
maxProtocol: 3 maxProtocol: 3
client: ClientInfo client: ClientInfo
auth?: { token?: string } 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 { export interface HelloOk {
@@ -51,6 +70,7 @@ export interface HelloOk {
stateVersion: { presence: number; health: number } stateVersion: { presence: number; health: number }
} }
features: { methods: string[]; events: string[] } features: { methods: string[]; events: string[] }
auth?: HelloAuth
} }
export interface PresenceEntry { export interface PresenceEntry {
@@ -231,15 +251,32 @@ export function parseSessionKey(key: string): {
return { agentId, platform, recipient, isGroup } return { agentId, platform, recipient, isGroup }
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any export function createConnectParams(
export function createConnectParams(token?: string): any { token?: string,
device?: ConnectDevice
): ConnectParams & {
role: string
scopes: string[]
caps: unknown[]
commands: unknown[]
permissions: Record<string, unknown>
locale: string
userAgent: string
} {
const platformMap: Record<string, string> = {
win32: 'windows',
darwin: 'macos',
linux: 'linux',
}
const platform = platformMap[process.platform] ?? process.platform
return { return {
minProtocol: 3, minProtocol: 3,
maxProtocol: 3, maxProtocol: 3,
client: { client: {
id: 'cli', id: 'cli',
version: '0.1.0', version: '0.1.0',
platform: 'linux', platform,
mode: 'cli', mode: 'cli',
}, },
role: 'operator', role: 'operator',
@@ -250,5 +287,6 @@ export function createConnectParams(token?: string): any {
locale: 'en-US', locale: 'en-US',
userAgent: 'crabwalk-monitor/0.1.0', userAgent: 'crabwalk-monitor/0.1.0',
auth: token ? { token } : undefined, auth: token ? { token } : undefined,
device,
} }
} }
+27 -3
View File
@@ -43,7 +43,12 @@ const openclawRouter = router({
connect: publicProcedure.mutation(async () => { connect: publicProcedure.mutation(async () => {
const client = getClawdbotClient() const client = getClawdbotClient()
if (client.connected) { 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 { try {
const hello = await client.connect() const hello = await client.connect()
@@ -52,11 +57,17 @@ const openclawRouter = router({
protocol: hello.protocol, protocol: hello.protocol,
features: hello.features, features: hello.features,
presenceCount: hello.snapshot?.presence?.length ?? 0, presenceCount: hello.snapshot?.presence?.length ?? 0,
authState: client.authState,
scopes: client.scopes,
pairing: client.pairingInfo,
} }
} catch (error) { } catch (error) {
return { return {
status: 'error' as const, status: 'error' as const,
message: error instanceof Error ? error.message : 'Connection failed', 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 } 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(() => { gatewayEndpoint: publicProcedure.query(() => {
return { url: getClawdbotEndpoint() } return { url: getClawdbotEndpoint() }
}), }),
@@ -134,7 +155,7 @@ const openclawRouter = router({
const client = getClawdbotClient() const client = getClawdbotClient()
const persistence = getPersistenceService() const persistence = getPersistenceService()
if (!client.connected) { if (!client.connected) {
return { sessions: [], error: 'Not connected' } return { sessions: [], error: 'Not connected', authState: client.authState }
} }
try { try {
const sessions = await client.listSessions(input) const sessions = await client.listSessions(input)
@@ -143,11 +164,14 @@ const openclawRouter = router({
for (const session of monitorSessions) { for (const session of monitorSessions) {
persistence.upsertSession(session) persistence.upsertSession(session)
} }
return { sessions: monitorSessions } return { sessions: monitorSessions, authState: client.authState, scopes: client.scopes }
} catch (error) { } catch (error) {
return { return {
sessions: [], sessions: [],
error: error instanceof Error ? error.message : 'Failed to list sessions', error: error instanceof Error ? error.message : 'Failed to list sessions',
authState: client.authState,
scopes: client.scopes,
pairing: client.pairingInfo,
} }
} }
}), }),
+81 -12
View File
@@ -65,10 +65,19 @@ function MonitorPageWrapper() {
const RETRY_DELAY = 3000 const RETRY_DELAY = 3000
const MAX_RETRIES = 10 const MAX_RETRIES = 10
const DEFAULT_GATEWAY_ENDPOINT = 'ws://127.0.0.1:18789' 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() { function MonitorPage() {
const [connected, setConnected] = useState(false) const [connected, setConnected] = useState(false)
const [connecting, setConnecting] = useState(false) const [connecting, setConnecting] = useState(false)
const [authState, setAuthState] = useState<AuthState>('unknown')
const [scopes, setScopes] = useState<string[]>([])
const [pairing, setPairing] = useState<PairingState | null>(null)
const [retryCount, setRetryCount] = useState(0) const [retryCount, setRetryCount] = useState(0)
const [historicalMode, setHistoricalMode] = useState(false) const [historicalMode, setHistoricalMode] = useState(false)
const [debugMode, setDebugMode] = useState(false) const [debugMode, setDebugMode] = useState(false)
@@ -117,6 +126,7 @@ function MonitorPage() {
// Check connection status and persistence on mount // Check connection status and persistence on mount
useEffect(() => { useEffect(() => {
checkStatus() checkStatus()
checkAuthStatus()
checkPersistenceStatus() checkPersistenceStatus()
loadGatewayEndpoint() 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) => { const handleConnect = async (retry = 0) => {
setConnecting(true) setConnecting(true)
setRetryCount(retry) setRetryCount(retry)
try { try {
const result = await trpc.openclaw.connect.mutate() 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') { if (result.status === 'connected' || result.status === 'already_connected') {
setConnected(true) setConnected(true)
setRetryCount(0) setRetryCount(0)
setConnecting(false) setConnecting(false)
// Hydrate from persistence if enabled if (result.authState === 'authorized' || result.authState === 'unknown') {
await hydrateFromPersistence() // Hydrate from persistence if enabled
await loadSessions() await hydrateFromPersistence()
await loadSessions()
}
return return
} }
} catch { } catch {
@@ -199,17 +234,24 @@ function MonitorPage() {
try { try {
await trpc.openclaw.disconnect.mutate() await trpc.openclaw.disconnect.mutate()
setConnected(false) setConnected(false)
setAuthState('unknown')
setScopes([])
setPairing(null)
clearCollections() clearCollections()
} catch (e) { } catch (e) {
console.error('Disconnect error:', e) console.error('Disconnect error:', e)
} }
} }
const loadSessions = async () => { const loadSessions = useCallback(async () => {
try { try {
const result = await trpc.openclaw.sessions.query( const result = await trpc.openclaw.sessions.query(
historicalMode ? { activeMinutes: 1440 } : { activeMinutes: 60 } 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) { if (result.sessions) {
for (const session of result.sessions) { for (const session of result.sessions) {
upsertSession(session) upsertSession(session)
@@ -218,15 +260,18 @@ function MonitorPage() {
} catch (e) { } catch (e) {
console.error('Failed to load sessions:', e) console.error('Failed to load sessions:', e)
} }
} }, [historicalMode])
const handleRefresh = useCallback(async () => { const handleRefresh = useCallback(async () => {
await loadSessions() await checkAuthStatus()
}, [historicalMode]) if (canPollSessions) {
await loadSessions()
}
}, [checkAuthStatus, canPollSessions, loadSessions])
const handleHistoricalModeChange = (enabled: boolean) => { const handleHistoricalModeChange = (enabled: boolean) => {
setHistoricalMode(enabled) setHistoricalMode(enabled)
if (connected) { if (canPollSessions) {
loadSessions() loadSessions()
} }
} }
@@ -337,6 +382,15 @@ function MonitorPage() {
return () => clearInterval(interval) 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(() => { const handleToggleSidebar = useCallback(() => {
setSidebarCollapsed((prev) => !prev) setSidebarCollapsed((prev) => !prev)
}, []) }, [])
@@ -350,16 +404,16 @@ function MonitorPage() {
// Poll for sessions while connected // Poll for sessions while connected
useEffect(() => { useEffect(() => {
if (!connected) return if (!canPollSessions) return
const interval = setInterval(() => { const interval = setInterval(() => {
loadSessions() loadSessions()
}, 5000) // Poll every 5 seconds }, 5000) // Poll every 5 seconds
return () => clearInterval(interval) return () => clearInterval(interval)
}, [connected, historicalMode]) }, [canPollSessions, loadSessions])
// Subscribe to real-time events // Subscribe to real-time events
useEffect(() => { useEffect(() => {
if (!connected) return if (!canPollSessions) return
const subscription = trpc.openclaw.events.subscribe(undefined, { const subscription = trpc.openclaw.events.subscribe(undefined, {
onData: (data) => { onData: (data) => {
@@ -381,7 +435,11 @@ function MonitorPage() {
return () => { return () => {
subscription.unsubscribe() subscription.unsubscribe()
} }
}, [connected]) }, [canPollSessions])
const pairingHint = pairing?.requestId
? `openclaw devices approve ${pairing.requestId}`
: 'openclaw devices list && openclaw devices approve <requestId>'
return ( return (
<div className="h-screen flex flex-col bg-shell-950 text-white overflow-hidden"> <div className="h-screen flex flex-col bg-shell-950 text-white overflow-hidden">
@@ -498,6 +556,17 @@ function MonitorPage() {
</div> </div>
</header> </header>
{connected && !canPollSessions && (
<div className="px-4 py-2 border-y border-neon-peach/30 bg-neon-peach/10">
<div className="font-console text-xs text-neon-peach">
Authentication pending. Session polling is paused to avoid missing-scope errors.
</div>
<div className="font-console text-[11px] text-shell-300 mt-1">
{pairing?.message ?? `Approve this device in OpenClaw: ${pairingHint}`}
</div>
</div>
)}
{/* Main content */} {/* Main content */}
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex overflow-hidden">
{/* Sidebar - desktop only */} {/* Sidebar - desktop only */}