diff --git a/electron/api/routes/gateway.ts b/electron/api/routes/gateway.ts index d914081..9f385be 100644 --- a/electron/api/routes/gateway.ts +++ b/electron/api/routes/gateway.ts @@ -1,5 +1,6 @@ import type { IncomingMessage, ServerResponse } from 'http'; import { PORTS } from '../../utils/config'; +import { scheduleControlUiDeviceAutoApproval } from '../../utils/control-ui-device-pairing'; import { buildOpenClawControlUiUrl } from '../../utils/openclaw-control-ui'; import { getSetting } from '../../utils/store'; import type { HostApiContext } from '../context'; @@ -73,6 +74,7 @@ export async function handleGatewayRoutes( const port = status.port || PORTS.OPENCLAW_GATEWAY; const view = url.searchParams.get('view') === 'dreams' ? 'dreams' : undefined; const urlValue = buildOpenClawControlUiUrl(port, token, { view }); + scheduleControlUiDeviceAutoApproval(ctx.gatewayManager); sendJson(res, 200, { success: true, url: urlValue, token, port }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 88f6aba..d72bc51 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -20,6 +20,7 @@ import { removeProviderFromOpenClaw, } from '../utils/openclaw-auth'; import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy'; +import { scheduleControlUiDeviceAutoApproval } from '../utils/control-ui-device-pairing'; import { buildOpenClawControlUiUrl } from '../utils/openclaw-control-ui'; import { logger } from '../utils/logger'; import { resolveAgentIdFromChannel } from '../utils/agent-config'; @@ -1395,6 +1396,7 @@ function registerGatewayHandlers( const token = await getSetting('gatewayToken'); const port = status.port || 18789; const url = buildOpenClawControlUiUrl(port, token); + scheduleControlUiDeviceAutoApproval(gatewayManager); return { success: true, url, port, token }; } catch (error) { return { success: false, error: String(error) }; diff --git a/electron/utils/control-ui-device-pairing.ts b/electron/utils/control-ui-device-pairing.ts new file mode 100644 index 0000000..900e96c --- /dev/null +++ b/electron/utils/control-ui-device-pairing.ts @@ -0,0 +1,296 @@ +import { app, utilityProcess } from 'electron'; +import { existsSync } from 'fs'; +import { readFile } from 'fs/promises'; +import { join } from 'path'; +import { PORTS } from './config'; +import { prependPathEntry } from './env-path'; +import { logger } from './logger'; +import { getOpenClawConfigDir, getOpenClawDir, getOpenClawEntryPath } from './paths'; +import { getSetting } from './store'; +import { getUvMirrorEnv } from './uv-env'; + +/** Browser Control UI client id used in OpenClaw 2026.5.x connect frames. */ +export const CONTROL_UI_BROWSER_CLIENT_ID = 'openclaw-control-ui'; + +export type PendingDevicePairingRequest = { + requestId?: string; + clientId?: string; + clientMode?: string; + role?: string; + roles?: string[]; + scopes?: string[]; + platform?: string; +}; + +export type DevicePairingList = { + pending?: PendingDevicePairingRequest[]; + paired?: unknown[]; +}; + +export type GatewayPairingRpcClient = { + isConnected: () => boolean; + getStatus?: () => { port?: number }; + rpc: (method: string, params?: unknown, timeoutMs?: number) => Promise; +}; + +const DEFAULT_POLL_INTERVAL_MS = 800; +const DEFAULT_WATCH_TIMEOUT_MS = 90_000; +const LIST_RPC_TIMEOUT_MS = 10_000; +const APPROVE_RPC_TIMEOUT_MS = 15_000; +const CLI_APPROVE_TIMEOUT_MS = 20_000; + +let activeWatcher: { cancel: () => void } | null = null; + +export function isControlUiBrowserPairingRequest(request: PendingDevicePairingRequest): boolean { + const clientId = typeof request.clientId === 'string' ? request.clientId.trim() : ''; + return clientId === CONTROL_UI_BROWSER_CLIENT_ID; +} + +function parseDevicePairingList(value: unknown): DevicePairingList { + const record = typeof value === 'object' && value !== null ? (value as Record) : {}; + return { + pending: Array.isArray(record.pending) + ? (record.pending as PendingDevicePairingRequest[]) + : [], + paired: Array.isArray(record.paired) ? record.paired : [], + }; +} + +function resolveGatewayPort(gateway: GatewayPairingRpcClient): number { + return gateway.getStatus?.()?.port ?? PORTS.OPENCLAW_GATEWAY; +} + +function resolveWatchTimeoutMs(explicit?: number): number { + return typeof explicit === 'number' ? explicit : DEFAULT_WATCH_TIMEOUT_MS; +} + +/** Read ~/.openclaw/devices/pending.json (same store the Gateway uses on loopback). */ +export async function readLocalPendingPairingRequests(): Promise { + const pendingPath = join(getOpenClawConfigDir(), 'devices', 'pending.json'); + try { + const raw = await readFile(pendingPath, 'utf8'); + const parsed = JSON.parse(raw) as Record; + if (!parsed || typeof parsed !== 'object') return []; + return Object.values(parsed).filter((entry) => entry && typeof entry === 'object'); + } catch { + return []; + } +} + +async function listPendingPairingRequests(gateway: GatewayPairingRpcClient): Promise { + const merged = new Map(); + + for (const request of await readLocalPendingPairingRequests()) { + const requestId = typeof request.requestId === 'string' ? request.requestId.trim() : ''; + if (requestId) merged.set(requestId, request); + } + + if (gateway.isConnected()) { + try { + const list = parseDevicePairingList( + await gateway.rpc('device.pair.list', {}, LIST_RPC_TIMEOUT_MS), + ); + for (const request of list.pending ?? []) { + const requestId = typeof request.requestId === 'string' ? request.requestId.trim() : ''; + if (requestId) merged.set(requestId, request); + } + } catch (error) { + logger.debug(`[control-ui] device.pair.list RPC failed, using local pending file: ${String(error)}`); + } + } + + return [...merged.values()]; +} + +function getBundledBinPath(): string { + const target = `${process.platform}-${process.arch}`; + return app.isPackaged + ? join(process.resourcesPath, 'bin') + : join(process.cwd(), 'resources', 'bin', target); +} + +/** + * Run `openclaw devices approve` in-process (not shown to the user). + * OpenClaw falls back to local pending.json on loopback when RPC is unavailable. + */ +async function approveViaOpenClawCli(requestId: string, _port: number): Promise { + const entryScript = getOpenClawEntryPath(); + const openclawDir = getOpenClawDir(); + if (!existsSync(entryScript)) { + logger.warn('[control-ui] Cannot run devices approve: OpenClaw entry missing'); + return false; + } + + const token = await getSetting('gatewayToken'); + const args = ['devices', 'approve', requestId, '--token', token, '--timeout', String(CLI_APPROVE_TIMEOUT_MS)]; + + const binPath = getBundledBinPath(); + const binPathExists = existsSync(binPath); + const baseEnv = (binPathExists + ? prependPathEntry(process.env as Record, binPath).env + : process.env) as Record; + const uvEnv = await getUvMirrorEnv(); + + return await new Promise((resolve) => { + const child = utilityProcess.fork(entryScript, args, { + cwd: openclawDir, + stdio: 'pipe', + env: { + ...baseEnv, + ...uvEnv, + OPENCLAW_NO_RESPAWN: '1', + OPENCLAW_EMBEDDED_IN: 'ClawX', + } as NodeJS.ProcessEnv, + }); + + let settled = false; + const finish = (ok: boolean) => { + if (settled) return; + settled = true; + resolve(ok); + }; + + const timeout = setTimeout(() => { + logger.warn(`[control-ui] devices approve timed out for ${requestId}`); + try { + child.kill(); + } catch { + // ignore + } + finish(false); + }, CLI_APPROVE_TIMEOUT_MS + 5_000); + + child.on('error', (error) => { + clearTimeout(timeout); + logger.warn(`[control-ui] devices approve spawn failed: ${String(error)}`); + finish(false); + }); + + child.on('exit', (code: number) => { + clearTimeout(timeout); + finish(code === 0); + }); + }); +} + +async function approvePairingRequest( + gateway: GatewayPairingRpcClient, + requestId: string, + port: number, +): Promise { + if (gateway.isConnected()) { + try { + await gateway.rpc('device.pair.approve', { requestId }, APPROVE_RPC_TIMEOUT_MS); + return true; + } catch (error) { + logger.debug( + `[control-ui] device.pair.approve RPC failed for ${requestId}, trying CLI fallback: ${String(error)}`, + ); + } + } + + return approveViaOpenClawCli(requestId, port); +} + +function sleep(ms: number, signal: { cancelled: boolean }): Promise { + return new Promise((resolve) => { + if (signal.cancelled) { + resolve(); + return; + } + const timer = setTimeout(() => { + clearTimeout(timer); + resolve(); + }, ms); + }); +} + +/** + * Approve pending Control UI browser pairing requests. + * Uses Gateway RPC when available; falls back to local pending.json + embedded CLI on Windows packaged builds. + */ +export async function approvePendingControlUiPairingRequests( + gateway: GatewayPairingRpcClient, + options?: { approvedRequestIds?: Set }, +): Promise { + const port = resolveGatewayPort(gateway); + const approvedRequestIds = options?.approvedRequestIds ?? new Set(); + const pending = await listPendingPairingRequests(gateway); + + const approved: string[] = []; + for (const request of pending) { + if (!isControlUiBrowserPairingRequest(request)) continue; + + const requestId = typeof request.requestId === 'string' ? request.requestId.trim() : ''; + if (!requestId || approvedRequestIds.has(requestId)) continue; + + try { + const ok = await approvePairingRequest(gateway, requestId, port); + if (!ok) continue; + approvedRequestIds.add(requestId); + approved.push(requestId); + logger.info( + `[control-ui] Auto-approved browser device pairing (requestId=${requestId}, mode=${request.clientMode ?? 'unknown'})`, + ); + } catch (error) { + logger.warn( + `[control-ui] Failed to auto-approve pairing request ${requestId}: ${String(error)}`, + ); + } + } + + return approved; +} + +async function watchControlUiPairingApprovals( + gateway: GatewayPairingRpcClient, + signal: { cancelled: boolean }, + timeoutMs: number, + pollIntervalMs: number, +): Promise { + const approvedRequestIds = new Set(); + const deadline = Date.now() + timeoutMs; + + while (!signal.cancelled && Date.now() < deadline) { + try { + await approvePendingControlUiPairingRequests(gateway, { approvedRequestIds }); + } catch (error) { + logger.debug(`[control-ui] Pairing poll error: ${String(error)}`); + } + + await sleep(pollIntervalMs, signal); + } +} + +/** + * Poll for Control UI browser pairing requests and approve them locally. + * Safe to call repeatedly; only one watcher runs at a time. + */ +export function scheduleControlUiDeviceAutoApproval( + gateway: GatewayPairingRpcClient, + options?: { + timeoutMs?: number; + pollIntervalMs?: number; + }, +): void { + activeWatcher?.cancel(); + + const signal = { cancelled: false }; + const cancel = () => { + signal.cancelled = true; + }; + activeWatcher = { cancel }; + + const timeoutMs = resolveWatchTimeoutMs(options?.timeoutMs); + const pollIntervalMs = options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + + void watchControlUiPairingApprovals(gateway, signal, timeoutMs, pollIntervalMs) + .catch((error) => { + logger.warn(`[control-ui] Auto-approval watcher failed: ${String(error)}`); + }) + .finally(() => { + if (activeWatcher?.cancel === cancel) { + activeWatcher = null; + } + }); +} diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index 1815f27..5bafcf8 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -28,6 +28,8 @@ import { } from './provider-keys'; import { normalizePiAiModelCost, type PiAiModelCostRates } from '../shared/pi-ai-model-cost'; import { withConfigLock } from './config-mutex'; +import { PORTS } from './config'; +import { getSetting } from './store'; import { OPENCLAW_API_PROTOCOLS, assertValidApiProtocol, @@ -1997,6 +1999,17 @@ export async function getOpenClawProvidersConfig(): Promise<{ } } +function applyControlUiAllowedOrigins(controlUi: Record, port: number): void { + const allowedOrigins = Array.isArray(controlUi.allowedOrigins) + ? (controlUi.allowedOrigins as unknown[]).filter((value): value is string => typeof value === 'string') + : []; + const next = new Set(allowedOrigins); + next.add('file://'); + next.add(`http://127.0.0.1:${port}`); + next.add(`http://localhost:${port}`); + controlUi.allowedOrigins = [...next]; +} + /** * Write the ClawX gateway token into ~/.openclaw/openclaw.json. */ @@ -2020,19 +2033,13 @@ export async function syncGatewayTokenToConfig(token: string): Promise { auth.token = token; gateway.auth = auth; - // Packaged ClawX loads the renderer from file://, so the gateway must allow - // that origin for the chat WebSocket handshake. const controlUi = ( gateway.controlUi && typeof gateway.controlUi === 'object' ? { ...(gateway.controlUi as Record) } : {} ) as Record; - const allowedOrigins = Array.isArray(controlUi.allowedOrigins) - ? (controlUi.allowedOrigins as unknown[]).filter((value): value is string => typeof value === 'string') - : []; - if (!allowedOrigins.includes('file://')) { - controlUi.allowedOrigins = [...allowedOrigins, 'file://']; - } + const gatewayPort = (await getSetting('gatewayPort')) || PORTS.OPENCLAW_GATEWAY; + applyControlUiAllowedOrigins(controlUi, gatewayPort); gateway.controlUi = controlUi; if (!gateway.mode) gateway.mode = 'local'; @@ -2209,12 +2216,8 @@ export async function batchSyncConfigFields(token: string): Promise { ? { ...(gateway.controlUi as Record) } : {} ) as Record; - const allowedOrigins = Array.isArray(controlUi.allowedOrigins) - ? (controlUi.allowedOrigins as unknown[]).filter((v): v is string => typeof v === 'string') - : []; - if (!allowedOrigins.includes('file://')) { - controlUi.allowedOrigins = [...allowedOrigins, 'file://']; - } + const gatewayPort = (await getSetting('gatewayPort')) || PORTS.OPENCLAW_GATEWAY; + applyControlUiAllowedOrigins(controlUi, gatewayPort); gateway.controlUi = controlUi; if (!gateway.mode) gateway.mode = 'local'; config.gateway = gateway; diff --git a/tests/unit/control-ui-device-pairing.test.ts b/tests/unit/control-ui-device-pairing.test.ts new file mode 100644 index 0000000..554dc37 --- /dev/null +++ b/tests/unit/control-ui-device-pairing.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('electron', () => ({ + app: { isPackaged: false }, + utilityProcess: { fork: vi.fn() }, +})); + +vi.mock('@electron/utils/store', () => ({ + getSetting: vi.fn(async () => 'test-gateway-token'), +})); + +vi.mock('@electron/utils/paths', () => ({ + getOpenClawConfigDir: () => '/tmp/openclaw', + getOpenClawDir: () => '/tmp/openclaw/pkg', + getOpenClawEntryPath: () => '/tmp/openclaw/pkg/openclaw.mjs', +})); + +import { + approvePendingControlUiPairingRequests, + CONTROL_UI_BROWSER_CLIENT_ID, + isControlUiBrowserPairingRequest, +} from '@electron/utils/control-ui-device-pairing'; + +describe('control-ui-device-pairing', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('detects Control UI browser pairing requests', () => { + expect( + isControlUiBrowserPairingRequest({ + clientId: CONTROL_UI_BROWSER_CLIENT_ID, + clientMode: 'webchat', + }), + ).toBe(true); + expect(isControlUiBrowserPairingRequest({ clientId: 'gateway-client' })).toBe(false); + expect(isControlUiBrowserPairingRequest({ clientId: 'cli' })).toBe(false); + }); + + it('approves pending Control UI requests via gateway RPC', async () => { + const rpc = vi.fn(async (method: string, params?: unknown) => { + if (method === 'device.pair.list') { + return { + pending: [ + { + requestId: 'req-control-ui', + clientId: CONTROL_UI_BROWSER_CLIENT_ID, + clientMode: 'webchat', + }, + { + requestId: 'req-cli', + clientId: 'cli', + clientMode: 'cli', + }, + ], + }; + } + if (method === 'device.pair.approve') { + expect(params).toEqual({ requestId: 'req-control-ui' }); + return { requestId: 'req-control-ui' }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const approved = await approvePendingControlUiPairingRequests({ + isConnected: () => true, + getStatus: () => ({ port: 18789 }), + rpc, + }); + + expect(approved).toEqual(['req-control-ui']); + expect(rpc).toHaveBeenCalledTimes(2); + expect(rpc.mock.calls[1]?.[0]).toBe('device.pair.approve'); + }); + + it('does not approve the same request twice in one pass', async () => { + const rpc = vi.fn(async (method: string) => { + if (method === 'device.pair.list') { + return { + pending: [ + { + requestId: 'req-1', + clientId: CONTROL_UI_BROWSER_CLIENT_ID, + }, + ], + }; + } + return {}; + }); + + const approvedRequestIds = new Set(['req-1']); + const approved = await approvePendingControlUiPairingRequests( + { isConnected: () => true, getStatus: () => ({ port: 18789 }), rpc }, + { approvedRequestIds }, + ); + + expect(approved).toEqual([]); + expect(rpc).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/gateway-control-ui-route.test.ts b/tests/unit/gateway-control-ui-route.test.ts index 6a79517..9ffec90 100644 --- a/tests/unit/gateway-control-ui-route.test.ts +++ b/tests/unit/gateway-control-ui-route.test.ts @@ -3,11 +3,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { HostApiContext } from '@electron/api/context'; import { handleGatewayRoutes } from '@electron/api/routes/gateway'; +import { scheduleControlUiDeviceAutoApproval } from '@electron/utils/control-ui-device-pairing'; vi.mock('@electron/utils/store', () => ({ getSetting: vi.fn(async () => 'clawx-route-token'), })); +vi.mock('@electron/utils/control-ui-device-pairing', () => ({ + scheduleControlUiDeviceAutoApproval: vi.fn(), +})); + function createResponse() { const headers = new Map(); let body = ''; @@ -66,6 +71,7 @@ describe('GET /api/gateway/control-ui', () => { token: 'clawx-route-token', port: 19001, }); + expect(scheduleControlUiDeviceAutoApproval).toHaveBeenCalledOnce(); }); it('returns the Dreams Control UI URL', async () => { diff --git a/tests/unit/openclaw-auth.test.ts b/tests/unit/openclaw-auth.test.ts index 7515b1e..e7d0a31 100644 --- a/tests/unit/openclaw-auth.test.ts +++ b/tests/unit/openclaw-auth.test.ts @@ -2,11 +2,12 @@ import { mkdir, readFile, rm, writeFile } from 'fs/promises'; import { join } from 'path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { testHome, testUserData } = vi.hoisted(() => { +const { testHome, testUserData, getSettingMock } = vi.hoisted(() => { const suffix = Math.random().toString(36).slice(2); return { testHome: `/tmp/clawx-openclaw-auth-${suffix}`, testUserData: `/tmp/clawx-openclaw-auth-user-data-${suffix}`, + getSettingMock: vi.fn(), }; }); @@ -30,6 +31,10 @@ vi.mock('electron', () => ({ }, })); +vi.mock('@electron/utils/store', () => ({ + getSetting: getSettingMock, +})); + vi.mock('@electron/utils/paths', async () => { const actual = await vi.importActual('@electron/utils/paths'); const resolvedDir = join(testHome, '.openclaw-test-openclaw'); @@ -1901,6 +1906,10 @@ describe('batchSyncConfigFields', () => { beforeEach(async () => { vi.resetModules(); vi.restoreAllMocks(); + getSettingMock.mockImplementation(async (key: string) => { + if (key === 'gatewayPort') return 18789; + return undefined; + }); await rm(testHome, { recursive: true, force: true }); await rm(testUserData, { recursive: true, force: true }); });