fix control ui (#1073)

This commit is contained in:
paisley
2026-05-27 12:57:09 +08:00
committed by GitHub
parent be7f8fc768
commit 1e64cf2230
7 changed files with 433 additions and 15 deletions

View File

@@ -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) });

View File

@@ -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) };

View File

@@ -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: <T>(method: string, params?: unknown, timeoutMs?: number) => Promise<T>;
};
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<string, unknown>) : {};
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<PendingDevicePairingRequest[]> {
const pendingPath = join(getOpenClawConfigDir(), 'devices', 'pending.json');
try {
const raw = await readFile(pendingPath, 'utf8');
const parsed = JSON.parse(raw) as Record<string, PendingDevicePairingRequest>;
if (!parsed || typeof parsed !== 'object') return [];
return Object.values(parsed).filter((entry) => entry && typeof entry === 'object');
} catch {
return [];
}
}
async function listPendingPairingRequests(gateway: GatewayPairingRpcClient): Promise<PendingDevicePairingRequest[]> {
const merged = new Map<string, PendingDevicePairingRequest>();
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<unknown>('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<boolean> {
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<string, string | undefined>, binPath).env
: process.env) as Record<string, string | undefined>;
const uvEnv = await getUvMirrorEnv();
return await new Promise<boolean>((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<boolean> {
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<void> {
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<string> },
): Promise<string[]> {
const port = resolveGatewayPort(gateway);
const approvedRequestIds = options?.approvedRequestIds ?? new Set<string>();
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<void> {
const approvedRequestIds = new Set<string>();
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;
}
});
}

View File

@@ -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<string, unknown>, 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<void> {
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<string, unknown>) }
: {}
) as Record<string, unknown>;
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<void> {
? { ...(gateway.controlUi as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
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;

View File

@@ -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<string>(['req-1']);
const approved = await approvePendingControlUiPairingRequests(
{ isConnected: () => true, getStatus: () => ({ port: 18789 }), rpc },
{ approvedRequestIds },
);
expect(approved).toEqual([]);
expect(rpc).toHaveBeenCalledTimes(1);
});
});

View File

@@ -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<string, string>();
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 () => {

View File

@@ -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<typeof import('@electron/utils/paths')>('@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 });
});