From fd2740e8843be8bf11e6276bcee9e2c1ffeb4fb7 Mon Sep 17 00:00:00 2001 From: 4pmtong Date: Thu, 6 Aug 2026 01:28:15 +0800 Subject: [PATCH] fix: make desktop identity installation-owned --- electron/main/desktopIdentity.ts | 39 +++++++++++++ electron/main/index.ts | 14 +++++ electron/preload/index.ts | 2 + src/api/http.ts | 2 - src/components/Dispatch/index.tsx | 2 +- src/hooks/useRemoteControlBridge.ts | 32 ++++++++--- src/lib/desktopIdentity.test.ts | 57 +++++++++++++++++++ src/lib/desktopIdentity.ts | 66 +++++++++++++--------- src/lib/projector/effects.ts | 9 --- src/lib/projector/types.ts | 1 - src/lib/remoteControl.ts | 2 +- src/pages/RemoteControl.tsx | 2 +- src/types/electron.d.ts | 1 + test/unit/api/http.test.ts | 5 ++ test/unit/electron/desktopIdentity.test.ts | 49 ++++++++++++++++ test/unit/lib/projector.test.ts | 6 -- 16 files changed, 235 insertions(+), 54 deletions(-) create mode 100644 electron/main/desktopIdentity.ts create mode 100644 src/lib/desktopIdentity.test.ts create mode 100644 test/unit/electron/desktopIdentity.test.ts diff --git a/electron/main/desktopIdentity.ts b/electron/main/desktopIdentity.ts new file mode 100644 index 000000000..efdf792fd --- /dev/null +++ b/electron/main/desktopIdentity.ts @@ -0,0 +1,39 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +const DESKTOP_INSTANCE_ID_FILE = 'desktop-instance-id'; +const VALID_DESKTOP_INSTANCE_ID = /^desk_[A-Za-z0-9_-]{16,128}$/; + +export function getOrCreateDesktopInstanceId( + userDataPath: string, + legacyRendererId?: string | null +): string { + const identityPath = path.join(userDataPath, DESKTOP_INSTANCE_ID_FILE); + try { + const existing = fs.readFileSync(identityPath, 'utf-8').trim(); + if (VALID_DESKTOP_INSTANCE_ID.test(existing)) return existing; + } catch (error: any) { + if (error?.code !== 'ENOENT') throw error; + } + + const migrated = String(legacyRendererId || '').trim(); + const candidate = VALID_DESKTOP_INSTANCE_ID.test(migrated) + ? migrated + : `desk_${crypto.randomUUID().replaceAll('-', '')}`; + try { + fs.writeFileSync(identityPath, candidate, { + encoding: 'utf-8', + flag: 'wx', + mode: 0o600, + }); + return candidate; + } catch (error: any) { + if (error?.code !== 'EEXIST') throw error; + const winner = fs.readFileSync(identityPath, 'utf-8').trim(); + if (!VALID_DESKTOP_INSTANCE_ID.test(winner)) { + throw new Error('Desktop instance identity file is invalid'); + } + return winner; + } +} diff --git a/electron/main/index.ts b/electron/main/index.ts index 90078c6c7..d0121c174 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -37,6 +37,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import kill from 'tree-kill'; import { copyBrowserData } from './copy'; +import { getOrCreateDesktopInstanceId } from './desktopIdentity'; import { FileReader } from './fileReader'; import { checkToolInstalled, @@ -94,6 +95,7 @@ let python_process: ChildProcessWithoutNullStreams | null = null; let backendPort: number = 5001; let backendStartPromise: Promise | null = null; const localControlCapability = crypto.randomBytes(32).toString('base64url'); +let desktopInstanceId: string | null = null; let browser_port = 9222; let use_external_cdp = false; let proxyUrl: string | null = null; @@ -1068,6 +1070,18 @@ function registerIpcHandlers() { } return localControlCapability; }); + ipcMain.handle('get-desktop-instance-id', (event, legacyRendererId) => { + if (!win || event.sender.id !== win.webContents.id) { + throw new Error('Desktop identity is restricted to the main renderer'); + } + if (!desktopInstanceId) { + desktopInstanceId = getOrCreateDesktopInstanceId( + userData, + typeof legacyRendererId === 'string' ? legacyRendererId : null + ); + } + return desktopInstanceId; + }); // ==================== restart app handler ==================== ipcMain.handle('restart-app', async () => { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 63cf9a67c..e95f3d146 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -120,6 +120,8 @@ contextBridge.exposeInMainWorld('electronAPI', { getBackendPort: () => ipcRenderer.invoke('get-backend-port'), getLocalControlCapability: () => ipcRenderer.invoke('get-local-control-capability'), + getDesktopInstanceId: (legacyRendererId?: string) => + ipcRenderer.invoke('get-desktop-instance-id', legacyRendererId), restartBackend: () => ipcRenderer.invoke('restart-backend'), onInstallDependenciesStart: (callback: () => void) => { ipcRenderer.on('install-dependencies-start', callback); diff --git a/src/api/http.ts b/src/api/http.ts index cb6932e49..057fb0298 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -16,7 +16,6 @@ import { showCreditsToast } from '@/components/Toast/creditsToast'; import { showStorageToast } from '@/components/Toast/storageToast'; import { showTrafficToast } from '@/components/Toast/trafficToast'; import { createHost } from '@/host/createHost'; -import { getDesktopInstanceId } from '@/lib/desktopIdentity'; import { getAuthStore } from '@/store/authStore'; import { getConnectionConfig, @@ -105,7 +104,6 @@ async function buildBrainHeaders( headers['Authorization'] = `Bearer ${token}`; } if (shouldAttachAuthHeader(url)) { - headers['X-Desktop-Instance-ID'] = getDesktopInstanceId(); const localControlCapability = await getLocalControlCapability(); if (localControlCapability) { headers[LOCAL_CONTROL_CAPABILITY_HEADER] = localControlCapability; diff --git a/src/components/Dispatch/index.tsx b/src/components/Dispatch/index.tsx index a1880d69f..ac599fbe9 100644 --- a/src/components/Dispatch/index.tsx +++ b/src/components/Dispatch/index.tsx @@ -446,7 +446,7 @@ export function WorkspaceDispatch() { const title = buildRemoteControlTitle(activeSpace?.name); const res = await createRemoteControlSession({ - desktop_instance_id: getRemoteControlDesktopInstanceId(), + desktop_instance_id: await getRemoteControlDesktopInstanceId(), space_id: activeSpaceId, ...(activeProjectId ? { project_id: activeProjectId } : {}), ...(activeProjectId && brainSessionId diff --git a/src/hooks/useRemoteControlBridge.ts b/src/hooks/useRemoteControlBridge.ts index 45758abda..ebdd72c10 100644 --- a/src/hooks/useRemoteControlBridge.ts +++ b/src/hooks/useRemoteControlBridge.ts @@ -1007,7 +1007,7 @@ export function useRemoteControlBridge(token: string | null | undefined) { let reconnectTimer: number | null = null; let pingTimer: number | null = null; let reconnectAttempt = 0; - const desktopInstanceId = getRemoteControlDesktopInstanceId(); + let desktopInstanceId = ''; const send = (payload: Record) => { if (ws?.readyState === WebSocket.OPEN) { @@ -1307,13 +1307,31 @@ export function useRemoteControlBridge(token: string | null | undefined) { }; const connect = async () => { - const url = await getRemoteControlWebSocketUrl( - '/api/v1/remote-control/bridge/subscribe' - ); - if (stopped) { + let url = ''; + try { + if (!desktopInstanceId) { + desktopInstanceId = await getRemoteControlDesktopInstanceId(); + } + url = await getRemoteControlWebSocketUrl( + '/api/v1/remote-control/bridge/subscribe' + ); + if (stopped) { + return; + } + ws = new WebSocket(url); + } catch (error) { + console.warn( + '[RemoteControlBridge] Bridge identity or URL resolution failed', + error + ); + if (!stopped) { + const base = Math.min(30_000, 3_000 * 2 ** reconnectAttempt); + const delay = base + Math.floor(Math.random() * 1_000); + reconnectAttempt += 1; + reconnectTimer = window.setTimeout(() => void connect(), delay); + } return; } - ws = new WebSocket(url); console.info('[RemoteControlBridge][RC-TRACE] connecting bridge ws', { url, desktop_instance_id: desktopInstanceId, @@ -1392,7 +1410,7 @@ export function useRemoteControlBridge(token: string | null | undefined) { const base = Math.min(30_000, 3_000 * 2 ** reconnectAttempt); const delay = base + Math.floor(Math.random() * 1_000); reconnectAttempt += 1; - reconnectTimer = window.setTimeout(connect, delay); + reconnectTimer = window.setTimeout(() => void connect(), delay); } }; ws.onerror = () => { diff --git a/src/lib/desktopIdentity.test.ts b/src/lib/desktopIdentity.test.ts new file mode 100644 index 000000000..ee4ef9fe2 --- /dev/null +++ b/src/lib/desktopIdentity.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocked = vi.hoisted(() => ({ + electronAPI: null as null | { + getDesktopInstanceId: ReturnType; + }, +})); + +vi.mock('@/host/createHost', () => ({ + createHost: () => ({ electronAPI: mocked.electronAPI }), +})); + +import { + __desktopIdentityTestHooks, + getDesktopInstanceId, +} from './desktopIdentity'; + +describe('desktop identity ownership', () => { + beforeEach(() => { + window.localStorage.clear(); + mocked.electronAPI = null; + __desktopIdentityTestHooks.reset(); + }); + + it('does not mint a device identity in an ordinary browser', async () => { + await expect(getDesktopInstanceId()).resolves.toBe(''); + expect( + window.localStorage.getItem('eigent_desktop_instance_id') + ).toBeNull(); + }); + + it('coalesces concurrent renderer reads through the main process', async () => { + window.localStorage.setItem( + 'eigent_desktop_instance_id', + 'desk_legacyrendereridentity1234' + ); + const getIdentity = vi.fn(async () => { + await Promise.resolve(); + return 'desk_mainprocessidentity123456'; + }); + mocked.electronAPI = { getDesktopInstanceId: getIdentity }; + + const identities = await Promise.all([ + getDesktopInstanceId(), + getDesktopInstanceId(), + getDesktopInstanceId(), + ]); + + expect(identities).toEqual([ + 'desk_mainprocessidentity123456', + 'desk_mainprocessidentity123456', + 'desk_mainprocessidentity123456', + ]); + expect(getIdentity).toHaveBeenCalledOnce(); + expect(getIdentity).toHaveBeenCalledWith('desk_legacyrendereridentity1234'); + }); +}); diff --git a/src/lib/desktopIdentity.ts b/src/lib/desktopIdentity.ts index 35aacf578..0aa50a997 100644 --- a/src/lib/desktopIdentity.ts +++ b/src/lib/desktopIdentity.ts @@ -1,32 +1,46 @@ +import { createHost } from '@/host/createHost'; + const DESKTOP_INSTANCE_STORAGE_KEY = 'eigent_desktop_instance_id'; -let memoryInstanceId = ''; +let desktopInstanceIdPromise: Promise | null = null; -function randomId(prefix: string): string { - const cryptoApi = globalThis.crypto; - if (cryptoApi?.randomUUID) { - return `${prefix}_${cryptoApi.randomUUID().replaceAll('-', '')}`; - } - return `${prefix}_${Date.now().toString(36)}_${Math.random() - .toString(36) - .slice(2)}`; -} - -export function getDesktopInstanceId(): string { - if (memoryInstanceId) { - return memoryInstanceId; - } +function legacyRendererIdentity(): string { try { - const existing = localStorage.getItem(DESKTOP_INSTANCE_STORAGE_KEY); - if (existing) { - memoryInstanceId = existing; - return existing; - } - const next = randomId('desk'); - localStorage.setItem(DESKTOP_INSTANCE_STORAGE_KEY, next); - memoryInstanceId = next; - return next; + return localStorage.getItem(DESKTOP_INSTANCE_STORAGE_KEY) || ''; } catch { - memoryInstanceId = randomId('desk'); - return memoryInstanceId; + return ''; } } + +export async function getDesktopInstanceId(): Promise { + const api = createHost().electronAPI; + if (!api?.getDesktopInstanceId) { + // Remote Web and ordinary browsers are not Desktop devices and must never + // mint an identity that the Cloud could mistake for an installation. + return ''; + } + if (!desktopInstanceIdPromise) { + desktopInstanceIdPromise = Promise.resolve( + api.getDesktopInstanceId(legacyRendererIdentity() || undefined) + ) + .then((identity) => { + if (!identity) throw new Error('Electron returned an empty device id'); + try { + localStorage.setItem(DESKTOP_INSTANCE_STORAGE_KEY, identity); + } catch { + // The main-process file remains authoritative. + } + return identity; + }) + .catch((error) => { + desktopInstanceIdPromise = null; + throw error; + }); + } + return desktopInstanceIdPromise; +} + +export const __desktopIdentityTestHooks = { + reset: () => { + desktopInstanceIdPromise = null; + }, +}; diff --git a/src/lib/projector/effects.ts b/src/lib/projector/effects.ts index 6a379d646..787c5fe0c 100644 --- a/src/lib/projector/effects.ts +++ b/src/lib/projector/effects.ts @@ -17,15 +17,6 @@ export function deriveLiveEffects( const effects: ProjectorEffect[] = []; if (next.seenEventIds[event.eventId]) { effects.push({ type: 'scroll_to_latest', eventId: event.eventId }); - const run = next.runs[event.runId]; - if (run && run.status !== 'running') { - effects.push({ - type: 'notify_terminal', - eventId: event.eventId, - runId: event.runId, - status: run.status, - }); - } } if ( next.needsResync && diff --git a/src/lib/projector/types.ts b/src/lib/projector/types.ts index 7d756040c..4a3b3138b 100644 --- a/src/lib/projector/types.ts +++ b/src/lib/projector/types.ts @@ -54,7 +54,6 @@ export type ProjectViewState = { export type ProjectorEffect = | { type: 'scroll_to_latest'; eventId: string } - | { type: 'notify_terminal'; eventId: string; runId: string; status: string } | { type: 'request_resync'; reason: string }; export type ProjectSnapshotInput = { diff --git a/src/lib/remoteControl.ts b/src/lib/remoteControl.ts index bda7cff75..454dbb0f1 100644 --- a/src/lib/remoteControl.ts +++ b/src/lib/remoteControl.ts @@ -126,7 +126,7 @@ function clearCommandRequestIdentity(fingerprint: string): void { ); } -export function getRemoteControlDesktopInstanceId(): string { +export function getRemoteControlDesktopInstanceId(): Promise { return getDesktopInstanceId(); } diff --git a/src/pages/RemoteControl.tsx b/src/pages/RemoteControl.tsx index 553fb3e36..96934c05d 100644 --- a/src/pages/RemoteControl.tsx +++ b/src/pages/RemoteControl.tsx @@ -375,7 +375,7 @@ export default function RemoteControlPage() { }); } if ( - payload.type === 'pong' && + (payload.type === 'pong' || payload.type === 'watermark') && typeof payload.current_cursor === 'number' && payload.current_cursor > (projectorRef.current?.currentCursor || 0) ) { diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 55d25de84..6ef15304e 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -151,6 +151,7 @@ interface ElectronAPI { }>; getBackendPort: () => Promise; getLocalControlCapability: () => Promise; + getDesktopInstanceId: (legacyRendererId?: string) => Promise; restartBackend: () => Promise<{ success: boolean; error?: string }>; onInstallDependenciesStart: (callback: () => void) => void; onInstallDependenciesLog: ( diff --git a/test/unit/api/http.test.ts b/test/unit/api/http.test.ts index aa1f06f42..f7b6b9bb3 100644 --- a/test/unit/api/http.test.ts +++ b/test/unit/api/http.test.ts @@ -110,6 +110,11 @@ describe('api/http handleResponse', () => { }), }) ); + const headers = request.mock.calls[0]?.[1]?.headers as Record< + string, + string + >; + expect(headers['X-Desktop-Instance-ID']).toBeUndefined(); }); }); diff --git a/test/unit/electron/desktopIdentity.test.ts b/test/unit/electron/desktopIdentity.test.ts new file mode 100644 index 000000000..925e99d31 --- /dev/null +++ b/test/unit/electron/desktopIdentity.test.ts @@ -0,0 +1,49 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { getOrCreateDesktopInstanceId } from '../../../electron/main/desktopIdentity'; + +const temporaryDirectories: string[] = []; + +function temporaryDirectory(): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'eigent-device-')); + temporaryDirectories.push(directory); + return directory; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('main-process desktop identity', () => { + it('atomically persists and reuses the first installation identity', () => { + const directory = temporaryDirectory(); + const first = getOrCreateDesktopInstanceId( + directory, + 'desk_migratedrendereridentity123' + ); + const replay = getOrCreateDesktopInstanceId( + directory, + 'desk_differentrendereridentity45' + ); + + expect(first).toBe('desk_migratedrendereridentity123'); + expect(replay).toBe(first); + expect( + fs.readFileSync(path.join(directory, 'desktop-instance-id'), 'utf-8') + ).toBe(first); + }); + + it('generates a valid identity when no trusted migration value exists', () => { + const identity = getOrCreateDesktopInstanceId( + temporaryDirectory(), + 'browser-forgery' + ); + + expect(identity).toMatch(/^desk_[A-Za-z0-9_-]{16,128}$/); + }); +}); diff --git a/test/unit/lib/projector.test.ts b/test/unit/lib/projector.test.ts index 344f511a8..5676c125a 100644 --- a/test/unit/lib/projector.test.ts +++ b/test/unit/lib/projector.test.ts @@ -179,12 +179,6 @@ describe('projector pipeline', () => { const next = reduceProjectView(initial, normalized); expect(deriveLiveEffects(initial, next, normalized, 'live')).toEqual([ { type: 'scroll_to_latest', eventId: 'event-1' }, - { - type: 'notify_terminal', - eventId: 'event-1', - runId: 'run-1', - status: 'completed', - }, ]); expect(deriveLiveEffects(initial, next, normalized, 'rehydrate')).toEqual( []