fix: make desktop identity installation-owned

This commit is contained in:
4pmtong 2026-08-06 01:28:15 +08:00
parent ada9a92aa8
commit fd2740e884
16 changed files with 235 additions and 54 deletions

View file

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

View file

@ -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<BackendStartResult> | 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 () => {

View file

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

View file

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

View file

@ -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

View file

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

View file

@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocked = vi.hoisted(() => ({
electronAPI: null as null | {
getDesktopInstanceId: ReturnType<typeof vi.fn>;
},
}));
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');
});
});

View file

@ -1,32 +1,46 @@
import { createHost } from '@/host/createHost';
const DESKTOP_INSTANCE_STORAGE_KEY = 'eigent_desktop_instance_id';
let memoryInstanceId = '';
let desktopInstanceIdPromise: Promise<string> | 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<string> {
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;
},
};

View file

@ -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 &&

View file

@ -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 = {

View file

@ -126,7 +126,7 @@ function clearCommandRequestIdentity(fingerprint: string): void {
);
}
export function getRemoteControlDesktopInstanceId(): string {
export function getRemoteControlDesktopInstanceId(): Promise<string> {
return getDesktopInstanceId();
}

View file

@ -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)
) {

View file

@ -151,6 +151,7 @@ interface ElectronAPI {
}>;
getBackendPort: () => Promise<number | null>;
getLocalControlCapability: () => Promise<string>;
getDesktopInstanceId: (legacyRendererId?: string) => Promise<string>;
restartBackend: () => Promise<{ success: boolean; error?: string }>;
onInstallDependenciesStart: (callback: () => void) => void;
onInstallDependenciesLog: (

View file

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

View file

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

View file

@ -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(
[]