mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-25 00:23:54 +00:00
fix: make desktop identity installation-owned
This commit is contained in:
parent
ada9a92aa8
commit
fd2740e884
16 changed files with 235 additions and 54 deletions
39
electron/main/desktopIdentity.ts
Normal file
39
electron/main/desktopIdentity.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
57
src/lib/desktopIdentity.test.ts
Normal file
57
src/lib/desktopIdentity.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ function clearCommandRequestIdentity(fingerprint: string): void {
|
|||
);
|
||||
}
|
||||
|
||||
export function getRemoteControlDesktopInstanceId(): string {
|
||||
export function getRemoteControlDesktopInstanceId(): Promise<string> {
|
||||
return getDesktopInstanceId();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
) {
|
||||
|
|
|
|||
1
src/types/electron.d.ts
vendored
1
src/types/electron.d.ts
vendored
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
49
test/unit/electron/desktopIdentity.test.ts
Normal file
49
test/unit/electron/desktopIdentity.test.ts
Normal 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}$/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
[]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue