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:
Arvin Xu 2026-03-25 10:43:15 +08:00 committed by GitHub
parent f853537695
commit fed8b39957
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1136 additions and 51 deletions

View file

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

View file

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

View file

@ -3,5 +3,6 @@ packages:
- '../../packages/electron-client-ipc'
- '../../packages/file-loaders'
- '../../packages/desktop-bridge'
- '../../packages/device-gateway-client'
- '../../packages/local-file-shell'
- '.'

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -12,6 +12,8 @@ export interface ElectronMainStore {
lastRefreshAt?: number;
refreshToken?: string;
};
gatewayDeviceId: string;
gatewayUrl: string;
locale: string;
networkProxy: NetworkProxySettings;
shortcuts: Record<string, string>;

View file

@ -523,6 +523,7 @@
},
"pnpm": {
"onlyBuiltDependencies": [
"@lobehub/editor",
"ffmpeg-static"
],
"overrides": {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,10 @@
export type GatewayConnectionStatus =
| 'connected'
| 'connecting'
| 'disconnected'
| 'reconnecting'
| 'authenticating';
export interface GatewayConnectionBroadcastEvents {
gatewayConnectionStatusChanged: (params: { status: GatewayConnectionStatus }) => void;
}

View file

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

View file

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