mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
✨ feat: desktop support connect to gateway (#13234)
* support desktop gateway * support device mode * support desktop * fix tests * improve * fix tests * fix tests * fix case
This commit is contained in:
parent
f853537695
commit
fed8b39957
23 changed files with 1136 additions and 51 deletions
|
|
@ -52,8 +52,9 @@ export default defineConfig({
|
|||
minify: !isDev,
|
||||
outDir: 'dist/main',
|
||||
rollupOptions: {
|
||||
// Native modules must be externalized to work correctly
|
||||
external: getExternalDependencies(),
|
||||
// Native modules must be externalized to work correctly.
|
||||
// bufferutil and utf-8-validate are optional peer deps of ws that may not be installed.
|
||||
external: [...getExternalDependencies(), 'bufferutil', 'utf-8-validate'],
|
||||
output: {
|
||||
// Prevent debug package from being bundled into index.js to avoid side-effect pollution
|
||||
manualChunks(id) {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@
|
|||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@lobechat/desktop-bridge": "workspace:*",
|
||||
"@lobechat/device-gateway-client": "workspace:*",
|
||||
"@lobechat/electron-client-ipc": "workspace:*",
|
||||
"@lobechat/electron-server-ipc": "workspace:*",
|
||||
"@lobechat/file-loaders": "workspace:*",
|
||||
|
|
@ -66,7 +67,7 @@
|
|||
"consola": "^3.4.2",
|
||||
"cookie": "^1.1.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"diff": "^8.0.2",
|
||||
"diff": "^8.0.4",
|
||||
"electron": "41.0.2",
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-devtools-installer": "4.0.0",
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@ packages:
|
|||
- '../../packages/electron-client-ipc'
|
||||
- '../../packages/file-loaders'
|
||||
- '../../packages/desktop-bridge'
|
||||
- '../../packages/device-gateway-client'
|
||||
- '../../packages/local-file-shell'
|
||||
- '.'
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ export const defaultProxySettings: NetworkProxySettings = {
|
|||
export const STORE_DEFAULTS: ElectronMainStore = {
|
||||
dataSyncConfig: { storageMode: 'cloud' },
|
||||
encryptedTokens: {},
|
||||
gatewayDeviceId: '',
|
||||
gatewayUrl: 'https://device-gateway.lobehub.com',
|
||||
locale: 'auto',
|
||||
networkProxy: defaultProxySettings,
|
||||
shortcuts: DEFAULT_SHORTCUTS_CONFIG,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
} from '@lobechat/electron-client-ipc';
|
||||
import { BrowserWindow, shell } from 'electron';
|
||||
|
||||
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
|
||||
import { appendVercelCookie } from '@/utils/http-headers';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
|
|
@ -43,14 +44,14 @@ export default class AuthCtr extends ControllerModule {
|
|||
/**
|
||||
* Polling related parameters
|
||||
*/
|
||||
|
||||
|
||||
private pollingInterval: NodeJS.Timeout | null = null;
|
||||
private cachedRemoteUrl: string | null = null;
|
||||
|
||||
/**
|
||||
* Auto-refresh timer
|
||||
*/
|
||||
|
||||
|
||||
private autoRefreshTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
|
|
@ -531,6 +532,9 @@ export default class AuthCtr extends ControllerModule {
|
|||
// Start auto-refresh timer
|
||||
this.startAutoRefresh();
|
||||
|
||||
// Connect to device gateway after successful login
|
||||
this.connectGateway();
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Exchanging authorization code failed:', error);
|
||||
|
|
@ -538,6 +542,19 @@ export default class AuthCtr extends ControllerModule {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to device gateway (fire-and-forget)
|
||||
*/
|
||||
private connectGateway() {
|
||||
const gatewaySrv = this.app.getService(GatewayConnectionService);
|
||||
if (gatewaySrv) {
|
||||
logger.info('Triggering gateway connection after login');
|
||||
gatewaySrv.connect().catch((error) => {
|
||||
logger.error('Gateway connection after login failed:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast token refreshed event
|
||||
*/
|
||||
|
|
|
|||
120
apps/desktop/src/main/controllers/GatewayConnectionCtr.ts
Normal file
120
apps/desktop/src/main/controllers/GatewayConnectionCtr.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import type { GatewayConnectionStatus } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
import LocalFileCtr from './LocalFileCtr';
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
import ShellCommandCtr from './ShellCommandCtr';
|
||||
|
||||
/**
|
||||
* GatewayConnectionCtr
|
||||
*
|
||||
* Thin IPC layer that delegates to GatewayConnectionService.
|
||||
*/
|
||||
export default class GatewayConnectionCtr extends ControllerModule {
|
||||
static override readonly groupName = 'gatewayConnection';
|
||||
|
||||
// ─── Service Accessor ───
|
||||
|
||||
private get service() {
|
||||
return this.app.getService(GatewayConnectionService);
|
||||
}
|
||||
|
||||
private get remoteServerConfigCtr() {
|
||||
return this.app.getController(RemoteServerConfigCtr);
|
||||
}
|
||||
|
||||
private get localFileCtr() {
|
||||
return this.app.getController(LocalFileCtr);
|
||||
}
|
||||
|
||||
private get shellCommandCtr() {
|
||||
return this.app.getController(ShellCommandCtr);
|
||||
}
|
||||
|
||||
// ─── Lifecycle ───
|
||||
|
||||
afterAppReady() {
|
||||
const srv = this.service;
|
||||
|
||||
srv.loadOrCreateDeviceId();
|
||||
|
||||
// Wire up token provider and refresher
|
||||
srv.setTokenProvider(() => this.remoteServerConfigCtr.getAccessToken());
|
||||
srv.setTokenRefresher(() => this.remoteServerConfigCtr.refreshAccessToken());
|
||||
|
||||
// Wire up tool call handler
|
||||
srv.setToolCallHandler((apiName, args) => this.executeToolCall(apiName, args));
|
||||
|
||||
// Auto-connect if already logged in
|
||||
this.tryAutoConnect();
|
||||
}
|
||||
|
||||
// ─── IPC Methods (Renderer → Main) ───
|
||||
|
||||
@IpcMethod()
|
||||
async connect(): Promise<{ error?: string; success: boolean }> {
|
||||
return this.service.connect();
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async disconnect(): Promise<{ success: boolean }> {
|
||||
return this.service.disconnect();
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async getConnectionStatus(): Promise<{ status: GatewayConnectionStatus }> {
|
||||
return { status: this.service.getStatus() };
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async getDeviceInfo(): Promise<{
|
||||
deviceId: string;
|
||||
hostname: string;
|
||||
platform: string;
|
||||
}> {
|
||||
return this.service.getDeviceInfo();
|
||||
}
|
||||
|
||||
// ─── Auto Connect ───
|
||||
|
||||
private async tryAutoConnect() {
|
||||
const isConfigured = await this.remoteServerConfigCtr.isRemoteServerConfigured();
|
||||
if (!isConfigured) return;
|
||||
|
||||
const token = await this.remoteServerConfigCtr.getAccessToken();
|
||||
if (!token) return;
|
||||
|
||||
await this.service.connect();
|
||||
}
|
||||
|
||||
// ─── Tool Call Routing ───
|
||||
|
||||
private async executeToolCall(apiName: string, args: any): Promise<unknown> {
|
||||
const methodMap: Record<string, () => Promise<unknown>> = {
|
||||
editLocalFile: () => this.localFileCtr.handleEditFile(args),
|
||||
globLocalFiles: () => this.localFileCtr.handleGlobFiles(args),
|
||||
grepContent: () => this.localFileCtr.handleGrepContent(args),
|
||||
listLocalFiles: () => this.localFileCtr.listLocalFiles(args),
|
||||
moveLocalFiles: () => this.localFileCtr.handleMoveFiles(args),
|
||||
readLocalFile: () => this.localFileCtr.readFile(args),
|
||||
renameLocalFile: () => this.localFileCtr.handleRenameFile(args),
|
||||
searchLocalFiles: () => this.localFileCtr.handleLocalFilesSearch(args),
|
||||
writeLocalFile: () => this.localFileCtr.handleWriteFile(args),
|
||||
|
||||
getCommandOutput: () => this.shellCommandCtr.handleGetCommandOutput(args),
|
||||
killCommand: () => this.shellCommandCtr.handleKillCommand(args),
|
||||
runCommand: () => this.shellCommandCtr.handleRunCommand(args),
|
||||
};
|
||||
|
||||
const handler = methodMap[apiName];
|
||||
if (!handler) {
|
||||
throw new Error(
|
||||
`Tool "${apiName}" is not available on this device. It may not be supported in the current desktop version. Please skip this tool and try alternative approaches.`,
|
||||
);
|
||||
}
|
||||
|
||||
return handler();
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import retry from 'async-retry';
|
|||
import { safeStorage, session as electronSession } from 'electron';
|
||||
|
||||
import { OFFICIAL_CLOUD_SERVER } from '@/const/env';
|
||||
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
|
||||
import { appendVercelCookie } from '@/utils/http-headers';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
|
|
@ -319,6 +320,13 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
|||
// Also clear from persistent storage
|
||||
logger.debug(`Deleting tokens from store key: ${this.encryptedTokensKey}`);
|
||||
this.app.storeManager.delete(this.encryptedTokensKey);
|
||||
|
||||
// Disconnect gateway when tokens are cleared (logout / token refresh failure)
|
||||
const gatewaySrv = this.app.getService(GatewayConnectionService);
|
||||
if (gatewaySrv) {
|
||||
logger.debug('Disconnecting gateway due to token clear');
|
||||
await gatewaySrv.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
import type { DataSyncConfig } from '@lobechat/electron-client-ipc';
|
||||
import { BrowserWindow, shell } from 'electron';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
|
@ -100,6 +99,7 @@ const mockApp = {
|
|||
}
|
||||
return null;
|
||||
}),
|
||||
getService: vi.fn(() => null),
|
||||
} as unknown as App;
|
||||
|
||||
describe('AuthCtr', () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,562 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
|
||||
|
||||
import GatewayConnectionCtr from '../GatewayConnectionCtr';
|
||||
import LocalFileCtr from '../LocalFileCtr';
|
||||
import RemoteServerConfigCtr from '../RemoteServerConfigCtr';
|
||||
import ShellCommandCtr from '../ShellCommandCtr';
|
||||
|
||||
// ─── Mocks ───
|
||||
|
||||
const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => {
|
||||
const { EventEmitter } = require('node:events');
|
||||
|
||||
// Must be defined inside vi.hoisted so it's available when vi.mock factories run
|
||||
class _MockGatewayClient extends EventEmitter {
|
||||
static lastInstance: _MockGatewayClient | null = null;
|
||||
static lastOptions: any = null;
|
||||
|
||||
connectionStatus = 'disconnected' as string;
|
||||
currentDeviceId: string;
|
||||
|
||||
connect = vi.fn(async () => {
|
||||
this.connectionStatus = 'connecting';
|
||||
this.emit('status_changed', 'connecting');
|
||||
});
|
||||
|
||||
disconnect = vi.fn(async () => {
|
||||
this.connectionStatus = 'disconnected';
|
||||
});
|
||||
|
||||
sendToolCallResponse = vi.fn();
|
||||
|
||||
constructor(options: any) {
|
||||
super();
|
||||
this.currentDeviceId = options.deviceId || 'mock-device-id';
|
||||
_MockGatewayClient.lastInstance = this;
|
||||
_MockGatewayClient.lastOptions = options;
|
||||
}
|
||||
|
||||
// Test helpers
|
||||
simulateConnected() {
|
||||
this.connectionStatus = 'connected';
|
||||
this.emit('status_changed', 'connected');
|
||||
this.emit('connected');
|
||||
}
|
||||
|
||||
simulateStatusChanged(status: string) {
|
||||
this.connectionStatus = status;
|
||||
this.emit('status_changed', status);
|
||||
}
|
||||
|
||||
simulateToolCallRequest(apiName: string, args: object, requestId = 'req-1') {
|
||||
this.emit('tool_call_request', {
|
||||
requestId,
|
||||
toolCall: {
|
||||
apiName,
|
||||
arguments: JSON.stringify(args),
|
||||
identifier: 'test-tool',
|
||||
},
|
||||
type: 'tool_call_request',
|
||||
});
|
||||
}
|
||||
|
||||
simulateAuthExpired() {
|
||||
this.emit('auth_expired');
|
||||
}
|
||||
|
||||
simulateError(message: string) {
|
||||
this.emit('error', new Error(message));
|
||||
}
|
||||
|
||||
simulateReconnecting(delay: number) {
|
||||
this.connectionStatus = 'reconnecting';
|
||||
this.emit('status_changed', 'reconnecting');
|
||||
this.emit('reconnecting', delay);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
MockGatewayClient: _MockGatewayClient,
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn((name: string) => `/mock/${name}`),
|
||||
},
|
||||
ipcMain: { handle: ipcMainHandleMock },
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
verbose: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('electron-is', () => ({
|
||||
macOS: vi.fn(() => false),
|
||||
windows: vi.fn(() => false),
|
||||
linux: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('@/const/env', () => ({
|
||||
OFFICIAL_CLOUD_SERVER: 'https://lobehub-cloud.com',
|
||||
isMac: false,
|
||||
isWindows: false,
|
||||
isLinux: false,
|
||||
isDev: false,
|
||||
}));
|
||||
|
||||
vi.mock('node:crypto', () => ({
|
||||
randomUUID: vi.fn(() => 'mock-device-uuid'),
|
||||
}));
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
default: { hostname: vi.fn(() => 'mock-hostname') },
|
||||
}));
|
||||
|
||||
vi.mock('@lobechat/device-gateway-client', () => ({
|
||||
GatewayClient: MockGatewayClient,
|
||||
}));
|
||||
|
||||
// ─── Mock Controllers ───
|
||||
|
||||
const mockLocalFileCtr = {
|
||||
handleEditFile: vi.fn().mockResolvedValue({ success: true }),
|
||||
handleGlobFiles: vi.fn().mockResolvedValue({ files: [] }),
|
||||
handleGrepContent: vi.fn().mockResolvedValue({ matches: [] }),
|
||||
handleLocalFilesSearch: vi.fn().mockResolvedValue([]),
|
||||
handleMoveFiles: vi.fn().mockResolvedValue([]),
|
||||
handleRenameFile: vi.fn().mockResolvedValue({ newPath: '/mock/renamed.txt', success: true }),
|
||||
handleWriteFile: vi.fn().mockResolvedValue({ success: true }),
|
||||
listLocalFiles: vi.fn().mockResolvedValue([]),
|
||||
readFile: vi.fn().mockResolvedValue({
|
||||
charCount: 12,
|
||||
content: 'file content',
|
||||
createdTime: new Date('2024-01-01'),
|
||||
filename: 'test.txt',
|
||||
fileType: '.txt',
|
||||
lineCount: 1,
|
||||
loc: [1, 1] as [number, number],
|
||||
modifiedTime: new Date('2024-01-01'),
|
||||
totalCharCount: 12,
|
||||
totalLineCount: 1,
|
||||
}),
|
||||
} as unknown as LocalFileCtr;
|
||||
|
||||
const mockShellCommandCtr = {
|
||||
handleGetCommandOutput: vi.fn().mockResolvedValue({ output: '' }),
|
||||
handleKillCommand: vi.fn().mockResolvedValue({ success: true }),
|
||||
handleRunCommand: vi.fn().mockResolvedValue({ success: true, stdout: '' }),
|
||||
} as unknown as ShellCommandCtr;
|
||||
|
||||
const mockRemoteServerConfigCtr = {
|
||||
getAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
|
||||
isRemoteServerConfigured: vi.fn().mockResolvedValue(true),
|
||||
refreshAccessToken: vi.fn().mockResolvedValue({ success: true }),
|
||||
} as unknown as RemoteServerConfigCtr;
|
||||
|
||||
const mockBroadcast = vi.fn();
|
||||
const mockStoreGet = vi.fn();
|
||||
const mockStoreSet = vi.fn();
|
||||
|
||||
const mockApp = {
|
||||
browserManager: { broadcastToAllWindows: mockBroadcast },
|
||||
getController: vi.fn((Cls) => {
|
||||
if (Cls === RemoteServerConfigCtr) return mockRemoteServerConfigCtr;
|
||||
if (Cls === LocalFileCtr) return mockLocalFileCtr;
|
||||
if (Cls === ShellCommandCtr) return mockShellCommandCtr;
|
||||
return null;
|
||||
}),
|
||||
getService: vi.fn((Cls) => {
|
||||
if (Cls === GatewayConnectionService) return mockGatewayConnectionSrv;
|
||||
return null;
|
||||
}),
|
||||
storeManager: { get: mockStoreGet, set: mockStoreSet },
|
||||
} as unknown as App;
|
||||
|
||||
// Lazily initialized — created in beforeEach so it uses the current mockApp
|
||||
let mockGatewayConnectionSrv: GatewayConnectionService;
|
||||
|
||||
// ─── Test Suite ───
|
||||
|
||||
describe('GatewayConnectionCtr', () => {
|
||||
let ctr: GatewayConnectionCtr;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
MockGatewayClient.lastInstance = null;
|
||||
MockGatewayClient.lastOptions = null;
|
||||
mockStoreGet.mockReturnValue(undefined);
|
||||
|
||||
mockGatewayConnectionSrv = new GatewayConnectionService(mockApp);
|
||||
ctr = new GatewayConnectionCtr(mockApp);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ctr.disconnect();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ─── Connection ───
|
||||
|
||||
describe('connect', () => {
|
||||
it('should create GatewayClient with correct options', async () => {
|
||||
mockStoreGet.mockImplementation((key: string) => {
|
||||
if (key === 'gatewayDeviceId') return 'stored-device-id';
|
||||
if (key === 'gatewayUrl') return undefined;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
ctr = new GatewayConnectionCtr(mockApp);
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const options = MockGatewayClient.lastOptions;
|
||||
expect(options).not.toBeNull();
|
||||
expect(options.token).toBe('mock-access-token');
|
||||
expect(options.deviceId).toBe('stored-device-id');
|
||||
expect(options.gatewayUrl).toBe('https://device-gateway.lobehub.com');
|
||||
expect(options.logger).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use custom gateway URL from store when set', async () => {
|
||||
mockStoreGet.mockImplementation((key: string) => {
|
||||
if (key === 'gatewayUrl') return 'http://localhost:8787';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
ctr = new GatewayConnectionCtr(mockApp);
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(MockGatewayClient.lastOptions.gatewayUrl).toBe('http://localhost:8787');
|
||||
});
|
||||
|
||||
it('should return success:false when no access token', async () => {
|
||||
// Prevent auto-connect, then set up providers manually
|
||||
vi.mocked(mockRemoteServerConfigCtr.isRemoteServerConfigured).mockResolvedValueOnce(false);
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
vi.mocked(mockRemoteServerConfigCtr.getAccessToken).mockResolvedValueOnce(null);
|
||||
|
||||
const result = await ctr.connect();
|
||||
expect(result).toEqual({ error: 'No access token available', success: false });
|
||||
expect(MockGatewayClient.lastInstance).toBeNull();
|
||||
});
|
||||
|
||||
it('should no-op when already connected', async () => {
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const firstClient = MockGatewayClient.lastInstance;
|
||||
firstClient!.simulateConnected();
|
||||
|
||||
const result = await ctr.connect();
|
||||
expect(result).toEqual({ success: true });
|
||||
// No new client created
|
||||
expect(MockGatewayClient.lastInstance).toBe(firstClient);
|
||||
});
|
||||
|
||||
it('should broadcast status changes: disconnected → connecting → connected', async () => {
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('gatewayConnectionStatusChanged', {
|
||||
status: 'connecting',
|
||||
});
|
||||
|
||||
MockGatewayClient.lastInstance!.simulateConnected();
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('gatewayConnectionStatusChanged', {
|
||||
status: 'connected',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Disconnect ───
|
||||
|
||||
describe('disconnect', () => {
|
||||
it('should disconnect client and set status to disconnected', async () => {
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const client = MockGatewayClient.lastInstance!;
|
||||
client.simulateConnected();
|
||||
mockBroadcast.mockClear();
|
||||
|
||||
await ctr.disconnect();
|
||||
|
||||
expect(client.disconnect).toHaveBeenCalled();
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('gatewayConnectionStatusChanged', {
|
||||
status: 'disconnected',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not trigger reconnect after intentional disconnect', async () => {
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const client = MockGatewayClient.lastInstance!;
|
||||
client.simulateConnected();
|
||||
|
||||
await ctr.disconnect();
|
||||
mockBroadcast.mockClear();
|
||||
|
||||
// Advance timers — no reconnect should happen
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(mockBroadcast).not.toHaveBeenCalledWith('gatewayConnectionStatusChanged', {
|
||||
status: 'reconnecting',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Auto-Connect ───
|
||||
|
||||
describe('afterAppReady (auto-connect)', () => {
|
||||
it('should auto-connect when server is configured and token exists', async () => {
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(MockGatewayClient.lastInstance).not.toBeNull();
|
||||
expect(MockGatewayClient.lastInstance!.connect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip auto-connect when remote server not configured', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.isRemoteServerConfigured).mockResolvedValueOnce(false);
|
||||
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(MockGatewayClient.lastInstance).toBeNull();
|
||||
});
|
||||
|
||||
it('should skip auto-connect when no access token', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.getAccessToken).mockResolvedValueOnce(null);
|
||||
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(MockGatewayClient.lastInstance).toBeNull();
|
||||
});
|
||||
|
||||
it('should create device ID on first launch and persist it', () => {
|
||||
mockStoreGet.mockReturnValue(undefined);
|
||||
ctr.afterAppReady();
|
||||
|
||||
expect(mockStoreSet).toHaveBeenCalledWith('gatewayDeviceId', 'mock-device-uuid');
|
||||
});
|
||||
|
||||
it('should reuse persisted device ID', () => {
|
||||
mockStoreGet.mockImplementation((key: string) =>
|
||||
key === 'gatewayDeviceId' ? 'existing-id' : undefined,
|
||||
);
|
||||
ctr = new GatewayConnectionCtr(mockApp);
|
||||
ctr.afterAppReady();
|
||||
|
||||
expect(mockStoreSet).not.toHaveBeenCalledWith('gatewayDeviceId', expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Reconnection ───
|
||||
|
||||
describe('reconnection', () => {
|
||||
it('should broadcast reconnecting status when client emits reconnecting', async () => {
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const client = MockGatewayClient.lastInstance!;
|
||||
client.simulateConnected();
|
||||
mockBroadcast.mockClear();
|
||||
|
||||
client.simulateReconnecting(1000);
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('gatewayConnectionStatusChanged', {
|
||||
status: 'reconnecting',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tool Call Routing ───
|
||||
|
||||
describe('tool call routing', () => {
|
||||
async function connectAndOpen() {
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const client = MockGatewayClient.lastInstance!;
|
||||
client.simulateConnected();
|
||||
return client;
|
||||
}
|
||||
|
||||
it.each([
|
||||
['readLocalFile', 'readFile', mockLocalFileCtr],
|
||||
['listLocalFiles', 'listLocalFiles', mockLocalFileCtr],
|
||||
['moveLocalFiles', 'handleMoveFiles', mockLocalFileCtr],
|
||||
['renameLocalFile', 'handleRenameFile', mockLocalFileCtr],
|
||||
['searchLocalFiles', 'handleLocalFilesSearch', mockLocalFileCtr],
|
||||
['writeLocalFile', 'handleWriteFile', mockLocalFileCtr],
|
||||
['editLocalFile', 'handleEditFile', mockLocalFileCtr],
|
||||
['globLocalFiles', 'handleGlobFiles', mockLocalFileCtr],
|
||||
['grepContent', 'handleGrepContent', mockLocalFileCtr],
|
||||
['runCommand', 'handleRunCommand', mockShellCommandCtr],
|
||||
['getCommandOutput', 'handleGetCommandOutput', mockShellCommandCtr],
|
||||
['killCommand', 'handleKillCommand', mockShellCommandCtr],
|
||||
] as const)('should route %s to %s', async (apiName, methodName, controller) => {
|
||||
const client = await connectAndOpen();
|
||||
const args = { test: 'arg' };
|
||||
|
||||
client.simulateToolCallRequest(apiName, args);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect((controller as any)[methodName]).toHaveBeenCalledWith(args);
|
||||
});
|
||||
|
||||
it('should send tool_call_response with success result', async () => {
|
||||
vi.mocked(mockLocalFileCtr.readFile).mockResolvedValueOnce({
|
||||
charCount: 5,
|
||||
content: 'hello',
|
||||
createdTime: new Date('2024-01-01'),
|
||||
filename: 'a.txt',
|
||||
fileType: '.txt',
|
||||
lineCount: 1,
|
||||
loc: [1, 1] as [number, number],
|
||||
modifiedTime: new Date('2024-01-01'),
|
||||
totalCharCount: 5,
|
||||
totalLineCount: 1,
|
||||
});
|
||||
const client = await connectAndOpen();
|
||||
|
||||
client.simulateToolCallRequest('readLocalFile', { path: '/a.txt' }, 'req-42');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
|
||||
requestId: 'req-42',
|
||||
result: {
|
||||
content: JSON.stringify({
|
||||
charCount: 5,
|
||||
content: 'hello',
|
||||
createdTime: new Date('2024-01-01'),
|
||||
filename: 'a.txt',
|
||||
fileType: '.txt',
|
||||
lineCount: 1,
|
||||
loc: [1, 1],
|
||||
modifiedTime: new Date('2024-01-01'),
|
||||
totalCharCount: 5,
|
||||
totalLineCount: 1,
|
||||
}),
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send tool_call_response with error on failure', async () => {
|
||||
vi.mocked(mockLocalFileCtr.readFile).mockRejectedValueOnce(new Error('File not found'));
|
||||
const client = await connectAndOpen();
|
||||
|
||||
client.simulateToolCallRequest('readLocalFile', { path: '/missing' }, 'req-err');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
|
||||
requestId: 'req-err',
|
||||
result: {
|
||||
content: 'File not found',
|
||||
error: 'File not found',
|
||||
success: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send error for unknown apiName', async () => {
|
||||
const client = await connectAndOpen();
|
||||
|
||||
client.simulateToolCallRequest('unknownApi', {}, 'req-unknown');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const errorMsg =
|
||||
'Tool "unknownApi" is not available on this device. It may not be supported in the current desktop version. Please skip this tool and try alternative approaches.';
|
||||
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
|
||||
requestId: 'req-unknown',
|
||||
result: {
|
||||
content: errorMsg,
|
||||
error: errorMsg,
|
||||
success: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Auth Expired ───
|
||||
|
||||
describe('auth_expired handling', () => {
|
||||
it('should refresh token and reconnect on auth_expired', async () => {
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const client1 = MockGatewayClient.lastInstance!;
|
||||
client1.simulateConnected();
|
||||
|
||||
client1.simulateAuthExpired();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
|
||||
// Should have created a new GatewayClient for reconnection
|
||||
expect(MockGatewayClient.lastInstance).not.toBe(client1);
|
||||
expect(MockGatewayClient.lastInstance!.connect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set status to disconnected when token refresh fails', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValueOnce({
|
||||
error: 'invalid_grant',
|
||||
success: false,
|
||||
});
|
||||
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const client = MockGatewayClient.lastInstance!;
|
||||
client.simulateConnected();
|
||||
mockBroadcast.mockClear();
|
||||
|
||||
client.simulateAuthExpired();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('gatewayConnectionStatusChanged', {
|
||||
status: 'disconnected',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── IPC Methods ───
|
||||
|
||||
describe('getConnectionStatus', () => {
|
||||
it('should return current status', async () => {
|
||||
expect(await ctr.getConnectionStatus()).toEqual({ status: 'disconnected' });
|
||||
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(await ctr.getConnectionStatus()).toEqual({ status: 'connecting' });
|
||||
|
||||
MockGatewayClient.lastInstance!.simulateConnected();
|
||||
expect(await ctr.getConnectionStatus()).toEqual({ status: 'connected' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDeviceInfo', () => {
|
||||
it('should return device information', async () => {
|
||||
mockStoreGet.mockImplementation((key: string) =>
|
||||
key === 'gatewayDeviceId' ? 'my-device' : undefined,
|
||||
);
|
||||
ctr = new GatewayConnectionCtr(mockApp);
|
||||
ctr.afterAppReady();
|
||||
|
||||
const info = await ctr.getDeviceInfo();
|
||||
expect(info).toEqual({
|
||||
deviceId: 'my-device',
|
||||
hostname: 'mock-hostname',
|
||||
platform: process.platform,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -47,8 +47,14 @@ const mockBrowserManager = {
|
|||
broadcastToAllWindows: vi.fn(),
|
||||
};
|
||||
|
||||
const mockGatewayConnectionSrv = {
|
||||
disconnect: vi.fn().mockResolvedValue({ success: true }),
|
||||
};
|
||||
|
||||
const mockApp = {
|
||||
browserManager: mockBrowserManager,
|
||||
getController: vi.fn(),
|
||||
getService: vi.fn().mockReturnValue(mockGatewayConnectionSrv),
|
||||
storeManager: mockStoreManager,
|
||||
} as unknown as App;
|
||||
|
||||
|
|
@ -294,6 +300,13 @@ describe('RemoteServerConfigCtr', () => {
|
|||
const accessToken = await controller.getAccessToken();
|
||||
expect(accessToken).toBeNull();
|
||||
});
|
||||
|
||||
it('should disconnect gateway when tokens are cleared', async () => {
|
||||
await controller.saveTokens('access', 'refresh', 3600);
|
||||
await controller.clearTokens();
|
||||
|
||||
expect(mockGatewayConnectionSrv.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTokenExpiresAt', () => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { CreateServicesResult, IpcServiceConstructor, MergeIpcService } fro
|
|||
import AuthCtr from './AuthCtr';
|
||||
import BrowserWindowsCtr from './BrowserWindowsCtr';
|
||||
import DevtoolsCtr from './DevtoolsCtr';
|
||||
import GatewayConnectionCtr from './GatewayConnectionCtr';
|
||||
import LocalFileCtr from './LocalFileCtr';
|
||||
import McpCtr from './McpCtr';
|
||||
import McpInstallCtr from './McpInstallCtr';
|
||||
|
|
@ -23,6 +24,7 @@ export const controllerIpcConstructors = [
|
|||
AuthCtr,
|
||||
BrowserWindowsCtr,
|
||||
DevtoolsCtr,
|
||||
GatewayConnectionCtr,
|
||||
LocalFileCtr,
|
||||
McpCtr,
|
||||
McpInstallCtr,
|
||||
|
|
|
|||
297
apps/desktop/src/main/services/gatewayConnectionSrv.ts
Normal file
297
apps/desktop/src/main/services/gatewayConnectionSrv.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
|
||||
import type {
|
||||
SystemInfoRequestMessage,
|
||||
ToolCallRequestMessage,
|
||||
} from '@lobechat/device-gateway-client';
|
||||
import { GatewayClient } from '@lobechat/device-gateway-client';
|
||||
import type { GatewayConnectionStatus } from '@lobechat/electron-client-ipc';
|
||||
import { app } from 'electron';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ServiceModule } from './index';
|
||||
|
||||
const logger = createLogger('services:GatewayConnectionSrv');
|
||||
|
||||
const DEFAULT_GATEWAY_URL = 'https://device-gateway.lobehub.com';
|
||||
|
||||
interface ToolCallHandler {
|
||||
(apiName: string, args: any): Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* GatewayConnectionService
|
||||
*
|
||||
* Core business logic for managing WebSocket connection to the cloud device-gateway.
|
||||
* Extracted from GatewayConnectionCtr so other controllers can reuse connect/disconnect.
|
||||
*/
|
||||
export default class GatewayConnectionService extends ServiceModule {
|
||||
private client: GatewayClient | null = null;
|
||||
private status: GatewayConnectionStatus = 'disconnected';
|
||||
private deviceId: string | null = null;
|
||||
|
||||
private tokenProvider: (() => Promise<string | null>) | null = null;
|
||||
private tokenRefresher: (() => Promise<{ error?: string; success: boolean }>) | null = null;
|
||||
private toolCallHandler: ToolCallHandler | null = null;
|
||||
|
||||
// ─── Configuration ───
|
||||
|
||||
/**
|
||||
* Set token provider function (to decouple from RemoteServerConfigCtr)
|
||||
*/
|
||||
setTokenProvider(provider: () => Promise<string | null>) {
|
||||
this.tokenProvider = provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set token refresher function (for auth_expired handling)
|
||||
*/
|
||||
setTokenRefresher(refresher: () => Promise<{ error?: string; success: boolean }>) {
|
||||
this.tokenRefresher = refresher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set tool call handler (to route tool calls to LocalFileCtr/ShellCommandCtr)
|
||||
*/
|
||||
setToolCallHandler(handler: ToolCallHandler) {
|
||||
this.toolCallHandler = handler;
|
||||
}
|
||||
|
||||
// ─── Device ID ───
|
||||
|
||||
loadOrCreateDeviceId() {
|
||||
const stored = this.app.storeManager.get('gatewayDeviceId') as string | undefined;
|
||||
if (stored) {
|
||||
this.deviceId = stored;
|
||||
} else {
|
||||
this.deviceId = randomUUID();
|
||||
this.app.storeManager.set('gatewayDeviceId', this.deviceId);
|
||||
}
|
||||
logger.debug(`Device ID: ${this.deviceId}`);
|
||||
}
|
||||
|
||||
getDeviceId(): string {
|
||||
return this.deviceId || 'unknown';
|
||||
}
|
||||
|
||||
// ─── Connection Status ───
|
||||
|
||||
getStatus(): GatewayConnectionStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
getDeviceInfo() {
|
||||
return {
|
||||
deviceId: this.getDeviceId(),
|
||||
hostname: os.hostname(),
|
||||
platform: process.platform,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Connection Logic ───
|
||||
|
||||
async connect(): Promise<{ error?: string; success: boolean }> {
|
||||
if (this.status === 'connected' || this.status === 'connecting') {
|
||||
return { success: true };
|
||||
}
|
||||
return this.doConnect();
|
||||
}
|
||||
|
||||
async disconnect(): Promise<{ success: boolean }> {
|
||||
if (this.client) {
|
||||
await this.client.disconnect();
|
||||
this.client = null;
|
||||
}
|
||||
this.setStatus('disconnected');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async doConnect(): Promise<{ error?: string; success: boolean }> {
|
||||
// Clean up any existing client
|
||||
if (this.client) {
|
||||
await this.client.disconnect();
|
||||
this.client = null;
|
||||
}
|
||||
|
||||
if (!this.tokenProvider) {
|
||||
logger.warn('Cannot connect: no token provider configured');
|
||||
return { error: 'No token provider configured', success: false };
|
||||
}
|
||||
|
||||
const token = await this.tokenProvider();
|
||||
if (!token) {
|
||||
logger.warn('Cannot connect: no access token');
|
||||
return { error: 'No access token available', success: false };
|
||||
}
|
||||
|
||||
const gatewayUrl = this.getGatewayUrl();
|
||||
const userId = this.extractUserIdFromToken(token);
|
||||
logger.info(`Connecting to device gateway: ${gatewayUrl}, userId: ${userId || 'unknown'}`);
|
||||
|
||||
const client = new GatewayClient({
|
||||
deviceId: this.getDeviceId(),
|
||||
gatewayUrl,
|
||||
logger,
|
||||
token,
|
||||
userId: userId || undefined,
|
||||
});
|
||||
|
||||
this.setupClientEvents(client);
|
||||
this.client = client;
|
||||
|
||||
await client.connect();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private setupClientEvents(client: GatewayClient) {
|
||||
client.on('status_changed', (status) => {
|
||||
this.setStatus(status);
|
||||
});
|
||||
|
||||
client.on('tool_call_request', (request) => {
|
||||
this.handleToolCallRequest(request, client);
|
||||
});
|
||||
|
||||
client.on('system_info_request', (request) => {
|
||||
this.handleSystemInfoRequest(client, request);
|
||||
});
|
||||
|
||||
client.on('auth_expired', () => {
|
||||
logger.warn('Received auth_expired, will reconnect with refreshed token');
|
||||
this.handleAuthExpired();
|
||||
});
|
||||
|
||||
client.on('error', (error) => {
|
||||
logger.error('WebSocket error:', error.message);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Auth Expired Handling ───
|
||||
|
||||
private async handleAuthExpired() {
|
||||
// Disconnect the current client
|
||||
if (this.client) {
|
||||
await this.client.disconnect();
|
||||
this.client = null;
|
||||
}
|
||||
|
||||
if (!this.tokenRefresher) {
|
||||
logger.error('No token refresher configured, cannot handle auth_expired');
|
||||
this.setStatus('disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Attempting token refresh before reconnect');
|
||||
const result = await this.tokenRefresher();
|
||||
|
||||
if (result.success) {
|
||||
logger.info('Token refreshed, reconnecting');
|
||||
await this.doConnect();
|
||||
} else {
|
||||
logger.error('Token refresh failed:', result.error);
|
||||
this.setStatus('disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── System Info ───
|
||||
|
||||
private handleSystemInfoRequest(client: GatewayClient, request: SystemInfoRequestMessage) {
|
||||
logger.info(`Received system_info_request: requestId=${request.requestId}`);
|
||||
client.sendSystemInfoResponse({
|
||||
requestId: request.requestId,
|
||||
result: {
|
||||
success: true,
|
||||
systemInfo: {
|
||||
arch: os.arch(),
|
||||
desktopPath: app.getPath('desktop'),
|
||||
documentsPath: app.getPath('documents'),
|
||||
downloadsPath: app.getPath('downloads'),
|
||||
homePath: app.getPath('home'),
|
||||
musicPath: app.getPath('music'),
|
||||
picturesPath: app.getPath('pictures'),
|
||||
userDataPath: app.getPath('userData'),
|
||||
videosPath: app.getPath('videos'),
|
||||
workingDirectory: process.cwd(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tool Call Routing ───
|
||||
|
||||
private handleToolCallRequest = async (
|
||||
request: ToolCallRequestMessage,
|
||||
client: GatewayClient,
|
||||
) => {
|
||||
const { requestId, toolCall } = request;
|
||||
const { apiName, arguments: argsStr } = toolCall;
|
||||
|
||||
logger.info(`Received tool call: apiName=${apiName}, requestId=${requestId}`);
|
||||
|
||||
try {
|
||||
if (!this.toolCallHandler) {
|
||||
throw new Error('No tool call handler configured');
|
||||
}
|
||||
|
||||
const args = JSON.parse(argsStr);
|
||||
const result = await this.toolCallHandler(apiName, args);
|
||||
|
||||
client.sendToolCallResponse({
|
||||
requestId,
|
||||
result: {
|
||||
content: typeof result === 'string' ? result : JSON.stringify(result),
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Tool call failed: apiName=${apiName}, error=${errorMsg}`);
|
||||
|
||||
client.sendToolCallResponse({
|
||||
requestId,
|
||||
result: {
|
||||
content: errorMsg,
|
||||
error: errorMsg,
|
||||
success: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Status Broadcasting ───
|
||||
|
||||
private setStatus(status: GatewayConnectionStatus) {
|
||||
if (this.status === status) return;
|
||||
|
||||
logger.info(`Connection status: ${this.status} → ${status}`);
|
||||
this.status = status;
|
||||
this.app.browserManager.broadcastToAllWindows('gatewayConnectionStatusChanged', { status });
|
||||
}
|
||||
|
||||
// ─── Gateway URL ───
|
||||
|
||||
private getGatewayUrl(): string {
|
||||
return this.app.storeManager.get('gatewayUrl') || DEFAULT_GATEWAY_URL;
|
||||
}
|
||||
|
||||
// ─── Token Helpers ───
|
||||
|
||||
/**
|
||||
* Extract userId (sub claim) from JWT without verification.
|
||||
* The token will be verified server-side; we just need the userId for routing.
|
||||
*/
|
||||
private extractUserIdFromToken(token: string): string | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
|
||||
return payload.sub || null;
|
||||
} catch {
|
||||
logger.warn('Failed to extract userId from JWT token');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,8 @@ export interface ElectronMainStore {
|
|||
lastRefreshAt?: number;
|
||||
refreshToken?: string;
|
||||
};
|
||||
gatewayDeviceId: string;
|
||||
gatewayUrl: string;
|
||||
locale: string;
|
||||
networkProxy: NetworkProxySettings;
|
||||
shortcuts: Record<string, string>;
|
||||
|
|
|
|||
|
|
@ -523,6 +523,7 @@
|
|||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@lobehub/editor",
|
||||
"ffmpeg-static"
|
||||
],
|
||||
"overrides": {
|
||||
|
|
|
|||
|
|
@ -50,8 +50,12 @@ describe('pathScopeAudit', () => {
|
|||
expect(pathScopeAudit({ path: '../other-project/file.ts' }, metadata)).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow /tmp paths (system temp directory)', () => {
|
||||
expect(pathScopeAudit({ file_path: '/tmp/secret.txt' }, metadata)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when file_path is outside working directory', () => {
|
||||
expect(pathScopeAudit({ file_path: '/tmp/secret.txt' }, metadata)).toBe(true);
|
||||
expect(pathScopeAudit({ file_path: '/var/data/secret.txt' }, metadata)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when directory is outside working directory', () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Alert, Flexbox } from '@lobehub/ui';
|
||||
import path from 'path-browserify-esm';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
|
@ -8,6 +7,8 @@ import { agentSelectors } from '@/store/agent/selectors';
|
|||
import { useChatStore } from '@/store/chat';
|
||||
import { topicSelectors } from '@/store/chat/selectors';
|
||||
|
||||
import { isPathWithinScope } from '../../utils/path';
|
||||
|
||||
interface OutOfScopeWarningProps {
|
||||
/**
|
||||
* The path(s) to check
|
||||
|
|
@ -15,19 +16,6 @@ interface OutOfScopeWarningProps {
|
|||
paths: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is within the working directory
|
||||
*/
|
||||
const isPathWithinWorkingDirectory = (targetPath: string, workingDirectory: string): boolean => {
|
||||
const normalizedTarget = path.resolve(targetPath);
|
||||
const normalizedWorkingDir = path.resolve(workingDirectory);
|
||||
|
||||
return (
|
||||
normalizedTarget === normalizedWorkingDir ||
|
||||
normalizedTarget.startsWith(normalizedWorkingDir + path.sep)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Warning component displayed in Intervention UI when paths are outside the working directory
|
||||
*/
|
||||
|
|
@ -42,7 +30,7 @@ const OutOfScopeWarning = memo<OutOfScopeWarningProps>(({ paths }) => {
|
|||
// Find paths that are outside the working directory
|
||||
const outsidePaths = useMemo(() => {
|
||||
if (!workingDirectory) return [];
|
||||
return paths.filter((p) => p && !isPathWithinWorkingDirectory(p, workingDirectory));
|
||||
return paths.filter((p) => p && !isPathWithinScope(p, workingDirectory));
|
||||
}, [paths, workingDirectory]);
|
||||
|
||||
// Don't render if no working directory set or all paths are within scope
|
||||
|
|
|
|||
|
|
@ -1,24 +1,6 @@
|
|||
import { type DynamicInterventionResolver } from '@lobechat/types';
|
||||
|
||||
import { normalizePathForScope, resolvePathWithScope } from './utils/path';
|
||||
|
||||
/**
|
||||
* Check if a path is within the working directory
|
||||
*/
|
||||
const isPathWithinWorkingDirectory = (
|
||||
targetPath: string,
|
||||
workingDirectory: string,
|
||||
resolveAgainstScope: string,
|
||||
): boolean => {
|
||||
const resolvedTarget = resolvePathWithScope(targetPath, resolveAgainstScope) ?? targetPath;
|
||||
const normalizedTarget = normalizePathForScope(resolvedTarget);
|
||||
const normalizedWorkingDir = normalizePathForScope(workingDirectory);
|
||||
|
||||
return (
|
||||
normalizedTarget === normalizedWorkingDir ||
|
||||
normalizedTarget.startsWith(normalizedWorkingDir + '/')
|
||||
);
|
||||
};
|
||||
import { isPathWithinScope, resolvePathWithScope } from './utils/path';
|
||||
|
||||
/**
|
||||
* Extract all path values from tool arguments
|
||||
|
|
@ -72,7 +54,7 @@ export const pathScopeAudit: DynamicInterventionResolver = (
|
|||
|
||||
// Match runtime behavior: a tool-provided scope is interpreted relative to workingDirectory.
|
||||
// If the resolved scope escapes the workingDirectory, intervention is required.
|
||||
if (toolScope && !isPathWithinWorkingDirectory(toolScope, workingDirectory, workingDirectory)) {
|
||||
if (toolScope && !isPathWithinScope(toolScope, workingDirectory, workingDirectory)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +64,5 @@ export const pathScopeAudit: DynamicInterventionResolver = (
|
|||
const paths = extractPaths(toolArgs);
|
||||
|
||||
// Return true if any path is outside the working directory
|
||||
return paths.some(
|
||||
(path) => !isPathWithinWorkingDirectory(path, workingDirectory, effectiveScope),
|
||||
);
|
||||
return paths.some((path) => !isPathWithinScope(path, workingDirectory, effectiveScope));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -44,6 +44,42 @@ export const resolvePathWithScope = (
|
|||
* Resolve a `scope`-bearing args object, filling the target path field from scope.
|
||||
* Returns a shallow copy only if the path field was actually changed.
|
||||
*/
|
||||
/**
|
||||
* System paths that are always allowed (e.g. /tmp for temporary files)
|
||||
*/
|
||||
const ALWAYS_ALLOWED_PATHS = ['/tmp'];
|
||||
|
||||
/**
|
||||
* Check if a path is within the working directory or an always-allowed path.
|
||||
* When `resolveAgainstScope` is provided, relative `targetPath` is resolved against it.
|
||||
*/
|
||||
export const isPathWithinScope = (
|
||||
targetPath: string,
|
||||
workingDirectory: string,
|
||||
resolveAgainstScope?: string,
|
||||
): boolean => {
|
||||
const resolvedTarget =
|
||||
resolvePathWithScope(targetPath, resolveAgainstScope ?? workingDirectory) ?? targetPath;
|
||||
const normalizedTarget = normalizePathForScope(resolvedTarget);
|
||||
const normalizedWorkingDir = normalizePathForScope(workingDirectory);
|
||||
|
||||
// Allow if within working directory
|
||||
if (
|
||||
normalizedTarget === normalizedWorkingDir ||
|
||||
normalizedTarget.startsWith(normalizedWorkingDir + '/')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allow system temp directories
|
||||
return ALWAYS_ALLOWED_PATHS.some((allowed) => {
|
||||
const normalizedAllowed = normalizePathForScope(allowed);
|
||||
return (
|
||||
normalizedTarget === normalizedAllowed || normalizedTarget.startsWith(normalizedAllowed + '/')
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const resolveArgsWithScope = <T extends { scope?: string }>(
|
||||
args: T,
|
||||
pathField: string,
|
||||
|
|
|
|||
|
|
@ -31,9 +31,10 @@ const mockSearchManifest: LobeToolManifest = {
|
|||
|
||||
describe('buildStepToolDelta', () => {
|
||||
describe('device activation', () => {
|
||||
it('should activate local-system when device is active and not in operation set', () => {
|
||||
it('should activate local-system when device is active and not in enabled tools', () => {
|
||||
const delta = buildStepToolDelta({
|
||||
activeDeviceId: 'device-123',
|
||||
enabledToolIds: ['web-search'],
|
||||
localSystemManifest: mockLocalSystemManifest,
|
||||
operationManifestMap: {},
|
||||
});
|
||||
|
|
@ -46,9 +47,29 @@ describe('buildStepToolDelta', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should not activate local-system when already in operation set', () => {
|
||||
it('should activate local-system even when manifest exists in manifestMap but not enabled', () => {
|
||||
// Regression: manifestMap contains all registered manifests (including inactive ones).
|
||||
// The old check used operationManifestMap to deduplicate, which incorrectly skipped
|
||||
// injection when the tool was registered but not enabled.
|
||||
const delta = buildStepToolDelta({
|
||||
activeDeviceId: 'device-123',
|
||||
enabledToolIds: ['web-search', 'remote-device'],
|
||||
localSystemManifest: mockLocalSystemManifest,
|
||||
operationManifestMap: { 'local-system': mockLocalSystemManifest },
|
||||
});
|
||||
|
||||
expect(delta.activatedTools).toHaveLength(1);
|
||||
expect(delta.activatedTools[0]).toEqual({
|
||||
id: 'local-system',
|
||||
manifest: mockLocalSystemManifest,
|
||||
source: 'device',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not activate local-system when already in enabled tools', () => {
|
||||
const delta = buildStepToolDelta({
|
||||
activeDeviceId: 'device-123',
|
||||
enabledToolIds: ['local-system', 'web-search'],
|
||||
localSystemManifest: mockLocalSystemManifest,
|
||||
operationManifestMap: { 'local-system': mockLocalSystemManifest },
|
||||
});
|
||||
|
|
@ -58,6 +79,7 @@ describe('buildStepToolDelta', () => {
|
|||
|
||||
it('should not activate when no activeDeviceId', () => {
|
||||
const delta = buildStepToolDelta({
|
||||
enabledToolIds: [],
|
||||
localSystemManifest: mockLocalSystemManifest,
|
||||
operationManifestMap: {},
|
||||
});
|
||||
|
|
@ -68,6 +90,7 @@ describe('buildStepToolDelta', () => {
|
|||
it('should not activate when no localSystemManifest', () => {
|
||||
const delta = buildStepToolDelta({
|
||||
activeDeviceId: 'device-123',
|
||||
enabledToolIds: [],
|
||||
operationManifestMap: {},
|
||||
});
|
||||
|
||||
|
|
@ -76,8 +99,9 @@ describe('buildStepToolDelta', () => {
|
|||
});
|
||||
|
||||
describe('mentioned tools', () => {
|
||||
it('should add mentioned tools not in operation set', () => {
|
||||
it('should add mentioned tools not in enabled tools', () => {
|
||||
const delta = buildStepToolDelta({
|
||||
enabledToolIds: [],
|
||||
mentionedToolIds: ['tool-a', 'tool-b'],
|
||||
operationManifestMap: {},
|
||||
});
|
||||
|
|
@ -87,8 +111,9 @@ describe('buildStepToolDelta', () => {
|
|||
expect(delta.activatedTools[1]).toEqual({ id: 'tool-b', source: 'mention' });
|
||||
});
|
||||
|
||||
it('should skip mentioned tools already in operation set', () => {
|
||||
it('should skip mentioned tools already in enabled tools', () => {
|
||||
const delta = buildStepToolDelta({
|
||||
enabledToolIds: ['web-search'],
|
||||
mentionedToolIds: ['web-search', 'tool-a'],
|
||||
operationManifestMap: { 'web-search': mockSearchManifest },
|
||||
});
|
||||
|
|
@ -99,6 +124,7 @@ describe('buildStepToolDelta', () => {
|
|||
|
||||
it('should handle empty mentionedToolIds', () => {
|
||||
const delta = buildStepToolDelta({
|
||||
enabledToolIds: [],
|
||||
mentionedToolIds: [],
|
||||
operationManifestMap: {},
|
||||
});
|
||||
|
|
@ -110,6 +136,7 @@ describe('buildStepToolDelta', () => {
|
|||
describe('forceFinish', () => {
|
||||
it('should set deactivatedToolIds to wildcard when forceFinish is true', () => {
|
||||
const delta = buildStepToolDelta({
|
||||
enabledToolIds: [],
|
||||
forceFinish: true,
|
||||
operationManifestMap: {},
|
||||
});
|
||||
|
|
@ -119,6 +146,7 @@ describe('buildStepToolDelta', () => {
|
|||
|
||||
it('should not set deactivatedToolIds when forceFinish is false', () => {
|
||||
const delta = buildStepToolDelta({
|
||||
enabledToolIds: [],
|
||||
forceFinish: false,
|
||||
operationManifestMap: {},
|
||||
});
|
||||
|
|
@ -131,6 +159,7 @@ describe('buildStepToolDelta', () => {
|
|||
it('should handle device + mentions + forceFinish together', () => {
|
||||
const delta = buildStepToolDelta({
|
||||
activeDeviceId: 'device-123',
|
||||
enabledToolIds: [],
|
||||
forceFinish: true,
|
||||
localSystemManifest: mockLocalSystemManifest,
|
||||
mentionedToolIds: ['tool-a'],
|
||||
|
|
@ -143,6 +172,7 @@ describe('buildStepToolDelta', () => {
|
|||
|
||||
it('should return empty delta when no signals', () => {
|
||||
const delta = buildStepToolDelta({
|
||||
enabledToolIds: [],
|
||||
operationManifestMap: {},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ export interface BuildStepToolDeltaParams {
|
|||
* Currently active device ID (triggers local-system tool injection)
|
||||
*/
|
||||
activeDeviceId?: string;
|
||||
/**
|
||||
* IDs of tools that are already enabled/activated at operation level.
|
||||
* Used to deduplicate — tools already enabled won't be injected again.
|
||||
*/
|
||||
enabledToolIds: string[];
|
||||
/**
|
||||
* Force finish flag — strips all tools for pure text output
|
||||
*/
|
||||
|
|
@ -32,12 +37,13 @@ export interface BuildStepToolDeltaParams {
|
|||
*/
|
||||
export function buildStepToolDelta(params: BuildStepToolDeltaParams): StepToolDelta {
|
||||
const delta: StepToolDelta = { activatedTools: [] };
|
||||
const enabledSet = new Set(params.enabledToolIds);
|
||||
|
||||
// Device activation → inject local-system tools
|
||||
if (
|
||||
params.activeDeviceId &&
|
||||
params.localSystemManifest &&
|
||||
!params.operationManifestMap[params.localSystemManifest.identifier]
|
||||
!enabledSet.has(params.localSystemManifest.identifier)
|
||||
) {
|
||||
delta.activatedTools.push({
|
||||
id: params.localSystemManifest.identifier,
|
||||
|
|
@ -49,7 +55,7 @@ export function buildStepToolDelta(params: BuildStepToolDeltaParams): StepToolDe
|
|||
// @tool mentions
|
||||
if (params.mentionedToolIds?.length) {
|
||||
for (const id of params.mentionedToolIds) {
|
||||
if (!params.operationManifestMap[id]) {
|
||||
if (!enabledSet.has(id)) {
|
||||
delta.activatedTools.push({ id, source: 'mention' });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
packages/electron-client-ipc/src/events/gatewayConnection.ts
Normal file
10
packages/electron-client-ipc/src/events/gatewayConnection.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export type GatewayConnectionStatus =
|
||||
| 'connected'
|
||||
| 'connecting'
|
||||
| 'disconnected'
|
||||
| 'reconnecting'
|
||||
| 'authenticating';
|
||||
|
||||
export interface GatewayConnectionBroadcastEvents {
|
||||
gatewayConnectionStatusChanged: (params: { status: GatewayConnectionStatus }) => void;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import type { GatewayConnectionBroadcastEvents } from './gatewayConnection';
|
||||
import type { NavigationBroadcastEvents } from './navigation';
|
||||
import type { ProtocolBroadcastEvents } from './protocol';
|
||||
import type { RemoteServerBroadcastEvents } from './remoteServer';
|
||||
|
|
@ -11,6 +12,7 @@ import type { AutoUpdateBroadcastEvents } from './update';
|
|||
export interface MainBroadcastEvents
|
||||
extends
|
||||
AutoUpdateBroadcastEvents,
|
||||
GatewayConnectionBroadcastEvents,
|
||||
NavigationBroadcastEvents,
|
||||
RemoteServerBroadcastEvents,
|
||||
SystemBroadcastEvents,
|
||||
|
|
@ -22,6 +24,7 @@ export type MainBroadcastParams<T extends MainBroadcastEventKey> = Parameters<
|
|||
MainBroadcastEvents[T]
|
||||
>[0];
|
||||
|
||||
export type { GatewayConnectionStatus } from './gatewayConnection';
|
||||
export type {
|
||||
AuthorizationPhase,
|
||||
AuthorizationProgress,
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ export const createRuntimeExecutors = (
|
|||
|
||||
const stepDelta = buildStepToolDelta({
|
||||
activeDeviceId,
|
||||
enabledToolIds: operationToolSet.enabledToolIds,
|
||||
forceFinish: state.forceFinish,
|
||||
localSystemManifest: LocalSystemManifest as unknown as LobeToolManifest,
|
||||
operationManifestMap: operationToolSet.manifestMap,
|
||||
|
|
|
|||
Loading…
Reference in a new issue