mirror of
https://github.com/crabwise-ai/crabwalk.git
synced 2026-08-14 00:57:52 +00:00
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:
@@ -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.
|
||||||
@@ -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]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user