feat: device identity for OpenClaw 2026.2.14+ pairing (#6)
- Generate Ed25519 keypair via Web Crypto API - Persist keypair in IndexedDB (survives page reloads) - Sign connect payload with device private key - Include device object in connect params (id, publicKey, signature, signedAt) - Handle NOT_PAIRED error with 'pairing' connection status - Show pairing-pending banner with instructions to run openclaw devices approve - Extract nonce from connect.challenge for v2 payload signing - Add i18n translations for pairing banner in all 8 languages Closes #6
This commit is contained in:
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pinchchat",
|
||||
"version": "1.64.1",
|
||||
"version": "1.64.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pinchchat",
|
||||
"version": "1.64.1",
|
||||
"version": "1.64.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pinchchat",
|
||||
"version": "1.64.1",
|
||||
"version": "1.64.2",
|
||||
"description": "A sleek, dark-themed webchat UI for OpenClaw — monitor sessions, stream responses, and inspect tool calls in real-time.",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Wifi, Loader2 } from 'lucide-react';
|
||||
import { Wifi, Loader2, ShieldAlert } from 'lucide-react';
|
||||
import type { ConnectionStatus } from '../types';
|
||||
import { useT } from '../hooks/useLocale';
|
||||
|
||||
@@ -7,7 +7,7 @@ interface Props {
|
||||
status: ConnectionStatus;
|
||||
}
|
||||
|
||||
type BannerState = 'hidden' | 'reconnecting' | 'reconnected';
|
||||
type BannerState = 'hidden' | 'reconnecting' | 'reconnected' | 'pairing';
|
||||
|
||||
export function ConnectionBanner({ status }: Props) {
|
||||
const t = useT();
|
||||
@@ -21,8 +21,10 @@ export function ConnectionBanner({ status }: Props) {
|
||||
dismissTimer.current = null;
|
||||
}
|
||||
|
||||
if (current === 'disconnected' || current === 'connecting') {
|
||||
if (prev === 'connected') {
|
||||
if (current === 'pairing') {
|
||||
setBanner('pairing');
|
||||
} else if (current === 'disconnected' || current === 'connecting') {
|
||||
if (prev === 'connected' || prev === 'pairing') {
|
||||
setBanner('reconnecting');
|
||||
}
|
||||
} else if (current === 'connected' && prev !== null && prev !== 'connected') {
|
||||
@@ -44,18 +46,26 @@ export function ConnectionBanner({ status }: Props) {
|
||||
if (banner === 'hidden') return null;
|
||||
|
||||
const isReconnecting = banner === 'reconnecting';
|
||||
const isPairing = banner === 'pairing';
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className={`flex items-center justify-center gap-2 px-4 py-2 text-xs font-medium transition-all duration-500 animate-in slide-in-from-top ${
|
||||
isReconnecting
|
||||
? 'bg-amber-500/10 text-amber-300 border-b border-amber-500/20'
|
||||
: 'bg-emerald-500/10 text-emerald-300 border-b border-emerald-500/20'
|
||||
isPairing
|
||||
? 'bg-blue-500/10 text-blue-300 border-b border-blue-500/20'
|
||||
: isReconnecting
|
||||
? 'bg-amber-500/10 text-amber-300 border-b border-amber-500/20'
|
||||
: 'bg-emerald-500/10 text-emerald-300 border-b border-emerald-500/20'
|
||||
}`}
|
||||
>
|
||||
{isReconnecting ? (
|
||||
{isPairing ? (
|
||||
<>
|
||||
<ShieldAlert size={14} />
|
||||
<span>{t('connection.pairing')}</span>
|
||||
</>
|
||||
) : isReconnecting ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<span>{t('connection.reconnecting')}</span>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { GatewayClient, type JsonPayload } from '../lib/gateway';
|
||||
import { genIdempotencyKey } from '../lib/utils';
|
||||
import { getStoredCredentials, storeCredentials, clearCredentials } from '../lib/credentials';
|
||||
import { getOrCreateDeviceIdentity } from '../lib/deviceIdentity';
|
||||
import { isSystemEvent } from '../lib/systemEvent';
|
||||
import { getCachedMessages, setCachedMessages, mergeWithCache } from '../lib/messageCache';
|
||||
import type { ChatMessage, MessageBlock, ConnectionStatus, Session, AgentIdentity } from '../types';
|
||||
@@ -271,7 +272,7 @@ export function useGateway() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setupClient = useCallback((wsUrl: string, token: string) => {
|
||||
const setupClient = useCallback(async (wsUrl: string, token: string) => {
|
||||
// Tear down existing client
|
||||
if (clientRef.current) {
|
||||
clientRef.current.disconnect();
|
||||
@@ -280,6 +281,14 @@ export function useGateway() {
|
||||
const client = new GatewayClient(wsUrl, token);
|
||||
clientRef.current = client;
|
||||
|
||||
// Load device identity for signed connect handshake
|
||||
try {
|
||||
const identity = await getOrCreateDeviceIdentity();
|
||||
client.setDeviceIdentity(identity);
|
||||
} catch (err) {
|
||||
console.warn('[PinchChat] Failed to load device identity, connecting without it:', err);
|
||||
}
|
||||
|
||||
client.onStatus((s) => {
|
||||
setStatus(s);
|
||||
if (s === 'connected') {
|
||||
@@ -291,6 +300,11 @@ export function useGateway() {
|
||||
loadSessions();
|
||||
loadAgentIdentity();
|
||||
loadHistory(activeSessionRef.current);
|
||||
} else if (s === 'pairing') {
|
||||
setAuthenticated(true);
|
||||
setConnectError(null);
|
||||
setIsConnecting(false);
|
||||
isConnectingRef.current = false;
|
||||
} else if (s === 'disconnected' && !client.isConnected) {
|
||||
// If we never connected successfully, this is an auth/connection error
|
||||
if (isConnectingRef.current) {
|
||||
|
||||
@@ -96,6 +96,9 @@ describe('GatewayClient', () => {
|
||||
// Server sends challenge
|
||||
ws._receive({ type: 'event', event: 'connect.challenge' });
|
||||
|
||||
// handleChallenge is async — flush microtasks
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// Client should have sent a connect request
|
||||
expect(ws.sent.length).toBe(1);
|
||||
const req = JSON.parse(ws.sent[0]!);
|
||||
@@ -165,6 +168,7 @@ describe('GatewayClient', () => {
|
||||
// Complete the challenge first
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
ws._receive({ type: 'event', event: 'connect.challenge' });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const connectReq = JSON.parse(ws.sent[0]!);
|
||||
ws._receive({ type: 'res', id: connectReq.id, ok: true, payload: {} });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
@@ -184,6 +188,7 @@ describe('GatewayClient', () => {
|
||||
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
ws._receive({ type: 'event', event: 'connect.challenge' });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const connectReq = JSON.parse(ws.sent[0]!);
|
||||
ws._receive({ type: 'res', id: connectReq.id, ok: true, payload: {} });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
@@ -207,6 +212,7 @@ describe('GatewayClient', () => {
|
||||
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
ws._receive({ type: 'event', event: 'connect.challenge' });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const connectReq = JSON.parse(ws.sent[0]!);
|
||||
ws._receive({ type: 'res', id: connectReq.id, ok: true, payload: {} });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
@@ -271,4 +277,46 @@ describe('GatewayClient', () => {
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
expect(ws.url).toBe('ws://new:5678');
|
||||
});
|
||||
|
||||
it('extracts nonce from challenge payload', async () => {
|
||||
const gw = new GatewayClient('ws://test:1234', 'tok');
|
||||
gw.connect();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
// Server sends challenge with nonce
|
||||
ws._receive({ type: 'event', event: 'connect.challenge', payload: { nonce: 'test-nonce-123' } });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const req = JSON.parse(ws.sent[0]!);
|
||||
expect(req.method).toBe('connect');
|
||||
// Device object won't be set (no identity), but the connect should still work
|
||||
expect(req.params.auth.token).toBe('tok');
|
||||
|
||||
// Clean up
|
||||
gw.disconnect();
|
||||
});
|
||||
|
||||
it('emits pairing status on NOT_PAIRED error', async () => {
|
||||
const gw = new GatewayClient('ws://test:1234', 'tok');
|
||||
const statuses: string[] = [];
|
||||
gw.onStatus(s => statuses.push(s));
|
||||
|
||||
gw.connect();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
ws._receive({ type: 'event', event: 'connect.challenge' });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const req = JSON.parse(ws.sent[0]!);
|
||||
// Server rejects with NOT_PAIRED
|
||||
ws._receive({ type: 'res', id: req.id, ok: false, payload: { code: 'NOT_PAIRED', message: 'Device not paired' } });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(statuses).toContain('pairing');
|
||||
|
||||
// Clean up
|
||||
gw.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
197
src/lib/deviceIdentity.ts
Normal file
197
src/lib/deviceIdentity.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Device identity management for OpenClaw 2026.2.14+ device pairing.
|
||||
*
|
||||
* Generates an Ed25519 keypair via Web Crypto API, persists it in IndexedDB,
|
||||
* and provides signing utilities for the gateway connect handshake.
|
||||
*/
|
||||
|
||||
const DB_NAME = 'pinchchat_device';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'identity';
|
||||
const IDENTITY_KEY = 'device';
|
||||
|
||||
export interface DeviceIdentity {
|
||||
id: string; // SHA-256 hex fingerprint of the raw public key
|
||||
publicKeyRaw: string; // base64url-encoded raw 32-byte public key
|
||||
keyPair: CryptoKeyPair;
|
||||
}
|
||||
|
||||
// ── IndexedDB helpers ──────────────────────────────────────────────
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
function idbGet<T>(db: IDBDatabase, key: string): Promise<T | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.get(key);
|
||||
req.onsuccess = () => resolve(req.result as T | undefined);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
function idbPut(db: IDBDatabase, key: string, value: unknown): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.put(value, key);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Encoding helpers ───────────────────────────────────────────────
|
||||
|
||||
function bufToBase64Url(buf: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let binary = '';
|
||||
for (const b of bytes) binary += String.fromCharCode(b);
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function bufToHex(buf: ArrayBuffer): string {
|
||||
return Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
// ── Key generation & fingerprinting ────────────────────────────────
|
||||
|
||||
async function generateKeyPair(): Promise<CryptoKeyPair> {
|
||||
return crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']) as Promise<CryptoKeyPair>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the raw 32-byte public key from a CryptoKey.
|
||||
* SPKI for Ed25519 is a fixed 12-byte prefix + 32 bytes of key material.
|
||||
*/
|
||||
async function exportPublicKeyRaw(key: CryptoKey): Promise<ArrayBuffer> {
|
||||
const spki = await crypto.subtle.exportKey('spki', key);
|
||||
// Ed25519 SPKI = 12-byte prefix + 32-byte raw key
|
||||
return spki.slice(12);
|
||||
}
|
||||
|
||||
async function fingerprintKey(key: CryptoKey): Promise<string> {
|
||||
const raw = await exportPublicKeyRaw(key);
|
||||
const hash = await crypto.subtle.digest('SHA-256', raw);
|
||||
return bufToHex(hash);
|
||||
}
|
||||
|
||||
// ── Persisted format (serialisable for IndexedDB) ──────────────────
|
||||
|
||||
interface StoredIdentity {
|
||||
version: 1;
|
||||
deviceId: string;
|
||||
publicKeyRaw: string; // base64url
|
||||
jwkPublic: JsonWebKey;
|
||||
jwkPrivate: JsonWebKey;
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load or create the device identity.
|
||||
* The keypair is persisted in IndexedDB so it survives page reloads.
|
||||
*/
|
||||
export async function getOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
|
||||
const db = await openDB();
|
||||
|
||||
// Try loading existing identity
|
||||
const stored = await idbGet<StoredIdentity>(db, IDENTITY_KEY);
|
||||
if (stored?.version === 1) {
|
||||
try {
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
'jwk', stored.jwkPrivate, { name: 'Ed25519' }, true, ['sign'],
|
||||
);
|
||||
const publicKey = await crypto.subtle.importKey(
|
||||
'jwk', stored.jwkPublic, { name: 'Ed25519' }, true, ['verify'],
|
||||
);
|
||||
db.close();
|
||||
return {
|
||||
id: stored.deviceId,
|
||||
publicKeyRaw: stored.publicKeyRaw,
|
||||
keyPair: { privateKey, publicKey },
|
||||
};
|
||||
} catch {
|
||||
// Corrupted — regenerate below
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new identity
|
||||
const keyPair = await generateKeyPair();
|
||||
const raw = await exportPublicKeyRaw(keyPair.publicKey);
|
||||
const publicKeyRaw = bufToBase64Url(raw);
|
||||
const deviceId = await fingerprintKey(keyPair.publicKey);
|
||||
const jwkPublic = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
|
||||
const jwkPrivate = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
||||
|
||||
const record: StoredIdentity = {
|
||||
version: 1,
|
||||
deviceId,
|
||||
publicKeyRaw,
|
||||
jwkPublic,
|
||||
jwkPrivate,
|
||||
};
|
||||
await idbPut(db, IDENTITY_KEY, record);
|
||||
db.close();
|
||||
|
||||
return { id: deviceId, publicKeyRaw, keyPair };
|
||||
}
|
||||
|
||||
// ── Signing ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the canonical payload string that the gateway expects to verify.
|
||||
* Must match OpenClaw's `buildDeviceAuthPayload` exactly.
|
||||
*/
|
||||
export function buildDeviceAuthPayload(params: {
|
||||
deviceId: string;
|
||||
clientId: string;
|
||||
clientMode: string;
|
||||
role: string;
|
||||
scopes: string[];
|
||||
signedAtMs: number;
|
||||
token: string | null;
|
||||
nonce?: string | null;
|
||||
}): string {
|
||||
const version = params.nonce ? 'v2' : 'v1';
|
||||
const scopes = params.scopes.join(',');
|
||||
const token = params.token ?? '';
|
||||
const base = [
|
||||
version,
|
||||
params.deviceId,
|
||||
params.clientId,
|
||||
params.clientMode,
|
||||
params.role,
|
||||
scopes,
|
||||
String(params.signedAtMs),
|
||||
token,
|
||||
];
|
||||
if (version === 'v2') base.push(params.nonce ?? '');
|
||||
return base.join('|');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a payload string with the device's Ed25519 private key.
|
||||
* Returns a base64url-encoded signature.
|
||||
*/
|
||||
export async function signPayload(
|
||||
privateKey: CryptoKey,
|
||||
payload: string,
|
||||
): Promise<string> {
|
||||
const data = new TextEncoder().encode(payload);
|
||||
const sig = await crypto.subtle.sign('Ed25519', privateKey, data);
|
||||
return bufToBase64Url(sig);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { genId } from './utils';
|
||||
import type { DeviceIdentity } from './deviceIdentity';
|
||||
import { buildDeviceAuthPayload, signPayload } from './deviceIdentity';
|
||||
|
||||
/** Debug logger — enable with localStorage.setItem('pinchchat:debug', '1') */
|
||||
const isDebug = () => {
|
||||
@@ -24,18 +26,22 @@ interface GatewayMessage {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type GatewayStatus = 'disconnected' | 'connecting' | 'connected' | 'pairing';
|
||||
|
||||
export class GatewayClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private pendingRequests = new Map<string, { resolve: (v: JsonPayload) => void; reject: (e: unknown) => void }>();
|
||||
private eventHandlers: GatewayEventHandler[] = [];
|
||||
private _onStatus: (s: 'disconnected' | 'connecting' | 'connected') => void = () => {};
|
||||
private _onStatus: (s: GatewayStatus) => void = () => {};
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private connected = false;
|
||||
private autoReconnect = true;
|
||||
private connectNonce: string | null = null;
|
||||
|
||||
private wsUrl: string;
|
||||
private authToken: string;
|
||||
private deviceIdentity: DeviceIdentity | null = null;
|
||||
|
||||
constructor(wsUrl?: string, authToken?: string) {
|
||||
this.wsUrl = wsUrl || `ws://${window.location.hostname}:18789`;
|
||||
@@ -48,7 +54,12 @@ export class GatewayClient {
|
||||
this.authToken = authToken;
|
||||
}
|
||||
|
||||
onStatus(fn: (s: 'disconnected' | 'connecting' | 'connected') => void) {
|
||||
/** Set the device identity for signed connect handshakes. */
|
||||
setDeviceIdentity(identity: DeviceIdentity) {
|
||||
this.deviceIdentity = identity;
|
||||
}
|
||||
|
||||
onStatus(fn: (s: GatewayStatus) => void) {
|
||||
this._onStatus = fn;
|
||||
}
|
||||
|
||||
@@ -60,6 +71,7 @@ export class GatewayClient {
|
||||
connect() {
|
||||
if (this.ws) return;
|
||||
this.autoReconnect = true;
|
||||
this.connectNonce = null;
|
||||
this._onStatus('connecting');
|
||||
this.ws = new WebSocket(this.wsUrl);
|
||||
|
||||
@@ -72,6 +84,9 @@ export class GatewayClient {
|
||||
|
||||
if (msg.type === 'event') {
|
||||
if (msg.event === 'connect.challenge') {
|
||||
// Extract nonce from challenge payload if present
|
||||
const payload = msg.payload as Record<string, unknown> | undefined;
|
||||
this.connectNonce = (payload && typeof payload.nonce === 'string') ? payload.nonce : null;
|
||||
this.handleChallenge();
|
||||
} else {
|
||||
for (const h of this.eventHandlers) h(msg.event ?? '', msg.payload ?? {});
|
||||
@@ -99,30 +114,68 @@ export class GatewayClient {
|
||||
this.ws.onerror = (e) => { log('WS error', e); };
|
||||
}
|
||||
|
||||
private handleChallenge() {
|
||||
private async handleChallenge() {
|
||||
const id = genId('connect');
|
||||
this.request(id, 'connect', {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: { id: 'webchat', version: __APP_VERSION__, platform: 'web', mode: 'webchat' },
|
||||
role: 'operator',
|
||||
scopes: ['operator.read', 'operator.write', 'operator.admin'],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: {},
|
||||
auth: { token: this.authToken },
|
||||
locale: (typeof navigator !== 'undefined' ? navigator.language : undefined) || 'en',
|
||||
userAgent: `pinchchat/${__APP_VERSION__}`,
|
||||
}).then((res) => {
|
||||
const role = 'operator';
|
||||
const scopes = ['operator.read', 'operator.write', 'operator.admin'];
|
||||
const signedAtMs = Date.now();
|
||||
const nonce = this.connectNonce ?? undefined;
|
||||
|
||||
// Build device object if we have an identity
|
||||
let device: Record<string, unknown> | undefined;
|
||||
if (this.deviceIdentity) {
|
||||
const payload = buildDeviceAuthPayload({
|
||||
deviceId: this.deviceIdentity.id,
|
||||
clientId: 'webchat',
|
||||
clientMode: 'webchat',
|
||||
role,
|
||||
scopes,
|
||||
signedAtMs,
|
||||
token: this.authToken || null,
|
||||
nonce,
|
||||
});
|
||||
const signature = await signPayload(this.deviceIdentity.keyPair.privateKey, payload);
|
||||
device = {
|
||||
id: this.deviceIdentity.id,
|
||||
publicKey: this.deviceIdentity.publicKeyRaw,
|
||||
signature,
|
||||
signedAt: signedAtMs,
|
||||
nonce,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await this.request(id, 'connect', {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: { id: 'webchat', version: __APP_VERSION__, platform: 'web', mode: 'webchat' },
|
||||
role,
|
||||
scopes,
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: {},
|
||||
auth: { token: this.authToken },
|
||||
device,
|
||||
locale: (typeof navigator !== 'undefined' ? navigator.language : undefined) || 'en',
|
||||
userAgent: `pinchchat/${__APP_VERSION__}`,
|
||||
});
|
||||
log('connected!', res);
|
||||
this.connected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this._onStatus('connected');
|
||||
}).catch((err) => {
|
||||
} catch (err) {
|
||||
log('connect failed:', err);
|
||||
// Check if this is a NOT_PAIRED error
|
||||
const errObj = err as Record<string, unknown> | undefined;
|
||||
if (errObj && (errObj.code === 'NOT_PAIRED' || (typeof errObj.message === 'string' && errObj.message.includes('NOT_PAIRED')))) {
|
||||
log('device not paired — awaiting approval');
|
||||
this._onStatus('pairing');
|
||||
// Keep connection open and auto-reconnect; gateway may close us
|
||||
return;
|
||||
}
|
||||
this.autoReconnect = false;
|
||||
this.disconnect();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
|
||||
@@ -87,6 +87,7 @@ const en = {
|
||||
// Connection banner
|
||||
'connection.reconnecting': 'Connection lost — reconnecting…',
|
||||
'connection.reconnected': 'Reconnected!',
|
||||
'connection.pairing': 'Device pairing pending — run `openclaw devices approve` on your gateway',
|
||||
|
||||
// Message actions
|
||||
'message.copy': 'Copy message',
|
||||
@@ -257,6 +258,7 @@ const fr: Record<keyof typeof en, string> = {
|
||||
|
||||
'connection.reconnecting': 'Connexion perdue — reconnexion…',
|
||||
'connection.reconnected': 'Reconnecté !',
|
||||
'connection.pairing': 'Appairage en attente — exécutez `openclaw devices approve` sur votre gateway',
|
||||
|
||||
'message.copy': 'Copier le message',
|
||||
'message.copied': 'Copié !',
|
||||
@@ -418,6 +420,7 @@ const es: Record<keyof typeof en, string> = {
|
||||
|
||||
'connection.reconnecting': 'Conexión perdida — reconectando…',
|
||||
'connection.reconnected': '¡Reconectado!',
|
||||
'connection.pairing': 'Emparejamiento pendiente — ejecute `openclaw devices approve` en su gateway',
|
||||
|
||||
'message.copy': 'Copiar mensaje',
|
||||
'message.copied': '¡Copiado!',
|
||||
@@ -581,6 +584,7 @@ const de: Record<keyof typeof en, string> = {
|
||||
|
||||
'connection.reconnecting': 'Verbindung verloren — wird wiederhergestellt…',
|
||||
'connection.reconnected': 'Wieder verbunden!',
|
||||
'connection.pairing': 'Gerätekopplung ausstehend — führen Sie `openclaw devices approve` auf Ihrem Gateway aus',
|
||||
|
||||
'message.copy': 'Nachricht kopieren',
|
||||
'message.copied': 'Kopiert!',
|
||||
@@ -742,6 +746,7 @@ const ja: Record<keyof typeof en, string> = {
|
||||
|
||||
'connection.reconnecting': '接続が切断されました — 再接続中…',
|
||||
'connection.reconnected': '再接続しました!',
|
||||
'connection.pairing': 'デバイスペアリング保留中 — ゲートウェイで `openclaw devices approve` を実行してください',
|
||||
|
||||
'message.copy': 'メッセージをコピー',
|
||||
'message.copied': 'コピーしました!',
|
||||
@@ -903,6 +908,7 @@ const pt: Record<keyof typeof en, string> = {
|
||||
|
||||
'connection.reconnecting': 'Conexão perdida — reconectando…',
|
||||
'connection.reconnected': 'Reconectado!',
|
||||
'connection.pairing': 'Emparelhamento pendente — execute `openclaw devices approve` no seu gateway',
|
||||
|
||||
'message.copy': 'Copiar mensagem',
|
||||
'message.copied': 'Copiado!',
|
||||
@@ -1064,6 +1070,7 @@ const zh: Record<keyof typeof en, string> = {
|
||||
|
||||
'connection.reconnecting': '连接中断 — 正在重连…',
|
||||
'connection.reconnected': '已重新连接!',
|
||||
'connection.pairing': '设备配对待处理 — 在网关上运行 `openclaw devices approve`',
|
||||
|
||||
'message.copy': '复制消息',
|
||||
'message.copied': '已复制!',
|
||||
@@ -1225,6 +1232,7 @@ const it: Record<keyof typeof en, string> = {
|
||||
|
||||
'connection.reconnecting': 'Connessione persa — riconnessione…',
|
||||
'connection.reconnected': 'Riconnesso!',
|
||||
'connection.pairing': 'Accoppiamento dispositivo in attesa — esegui `openclaw devices approve` sul tuo gateway',
|
||||
|
||||
'message.copy': 'Copia messaggio',
|
||||
'message.copied': 'Copiato!',
|
||||
|
||||
@@ -53,7 +53,7 @@ export interface AgentIdentity {
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected';
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'pairing';
|
||||
|
||||
export interface GatewayState {
|
||||
status: ConnectionStatus;
|
||||
|
||||
Reference in New Issue
Block a user