mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
✨ feat(device): auto-register desktop & CLI devices with stable machine ID (#15300)
✨ feat(device): auto-register desktop & CLI devices; send connectionId + channel App layer — wires desktop and `lh connect` to the device registry and the connection-routing scheme. Depends on @lobechat/device-identity and the gateway-client connectionId/channel options (earlier PRs in this stack), plus the device.register / listDevices endpoints (already on canary). - desktop derives the stable deviceId on gateway connect (old per-install random UUID demoted to the routing `connectionId`), registers via device.register, and tags channel `desktop` / `desktop-dev` - `lh connect` derives + registers before opening the WS (explicit --device-id still pins a VM); channel `cli` (env-overridable); connectionId persisted in `~/.lobehub/connection-id` - CLI api client preserves explicit --token connects during registration Part of LOBE-9572. Closes LOBE-9576 / LOBE-9577. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@
|
||||
"devDependencies": {
|
||||
"@lobechat/agent-gateway-client": "workspace:*",
|
||||
"@lobechat/device-gateway-client": "workspace:*",
|
||||
"@lobechat/device-identity": "workspace:*",
|
||||
"@lobechat/heterogeneous-agents": "workspace:*",
|
||||
"@lobechat/local-file-shell": "workspace:*",
|
||||
"@trpc/client": "^11.8.1",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
packages:
|
||||
- '../../packages/agent-gateway-client'
|
||||
- '../../packages/device-gateway-client'
|
||||
- '../../packages/device-identity'
|
||||
- '../../packages/heterogeneous-agents'
|
||||
- '../../packages/local-file-shell'
|
||||
- '../../packages/types'
|
||||
|
||||
@@ -70,6 +70,26 @@ export async function getTrpcClient(): Promise<TrpcClient> {
|
||||
return _client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Lambda tRPC client from an already-resolved auth context, without
|
||||
* re-running credential discovery. Use this when the caller already holds a
|
||||
* token (e.g. `lh connect --token <jwt>`) — `getTrpcClient` would re-resolve
|
||||
* via env/stored creds and `process.exit(1)` when none exist, which would
|
||||
* abort an otherwise-valid explicit-token session.
|
||||
*/
|
||||
export function createLambdaClient(auth: {
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
tokenType: 'apiKey' | 'jwt' | 'serviceToken';
|
||||
}): TrpcClient {
|
||||
const headers =
|
||||
auth.tokenType === 'apiKey' ? { 'X-API-Key': auth.token } : { 'Oidc-Auth': auth.token };
|
||||
|
||||
return createTRPCClient<LambdaRouter>({
|
||||
links: [httpLink({ headers, transformer: superjson, url: `${auth.serverUrl}/trpc/lambda` })],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getToolsTrpcClient(): Promise<ToolsTrpcClient> {
|
||||
if (_toolsClient) return _toolsClient;
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock('../auth/resolveToken', () => ({
|
||||
}),
|
||||
}));
|
||||
vi.mock('../settings', () => ({
|
||||
loadOrCreateConnectionId: vi.fn().mockReturnValue('test-connection-id'),
|
||||
loadSettings: vi.fn().mockReturnValue(null),
|
||||
normalizeUrl: vi.fn((url?: string) => (url ? url.replace(/\/$/, '') : undefined)),
|
||||
saveSettings: vi.fn(),
|
||||
|
||||
@@ -8,8 +8,11 @@ import type {
|
||||
ToolCallRequestMessage,
|
||||
} from '@lobechat/device-gateway-client';
|
||||
import { GatewayClient } from '@lobechat/device-gateway-client';
|
||||
import type { IdentitySource } from '@lobechat/device-identity';
|
||||
import { deriveDeviceId } from '@lobechat/device-identity';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { createLambdaClient } from '../api/client';
|
||||
import { getValidToken } from '../auth/refresh';
|
||||
import { resolveToken } from '../auth/resolveToken';
|
||||
import { CLI_API_KEY_ENV } from '../constants/auth';
|
||||
@@ -25,7 +28,7 @@ import {
|
||||
stopDaemon,
|
||||
writeStatus,
|
||||
} from '../daemon/manager';
|
||||
import { loadSettings, normalizeUrl, saveSettings } from '../settings';
|
||||
import { loadOrCreateConnectionId, loadSettings, normalizeUrl, saveSettings } from '../settings';
|
||||
import { executeToolCall } from '../tools';
|
||||
import { cleanupAllProcesses } from '../tools/shell';
|
||||
import { log, setVerbose } from '../utils/logger';
|
||||
@@ -192,8 +195,24 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
|
||||
const resolvedGatewayUrl = gatewayUrl || OFFICIAL_GATEWAY_URL;
|
||||
|
||||
// Resolve a stable device identity. An explicit `--device-id` wins (lets a
|
||||
// user pin a VM to a fixed identity); otherwise derive from the machine id so
|
||||
// the same machine + user maps to one device across reconnects.
|
||||
const identity: { deviceId: string; identitySource: IdentitySource } | undefined =
|
||||
options.deviceId
|
||||
? { deviceId: options.deviceId, identitySource: 'fallback' }
|
||||
: auth.userId
|
||||
? deriveDeviceId(auth.userId)
|
||||
: undefined;
|
||||
|
||||
// Freeform channel label (`cli` by default); `LOBEHUB_CLI_CHANNEL` lets a
|
||||
// dev build tag itself `cli-dev` so the gateway can prioritise / display it.
|
||||
const channel = process.env.LOBEHUB_CLI_CHANNEL || 'cli';
|
||||
|
||||
const client = new GatewayClient({
|
||||
deviceId: options.deviceId,
|
||||
channel,
|
||||
connectionId: loadOrCreateConnectionId(),
|
||||
deviceId: identity?.deviceId ?? options.deviceId,
|
||||
gatewayUrl: resolvedGatewayUrl,
|
||||
logger: isDaemonChild ? createDaemonLogger() : log,
|
||||
serverUrl: auth.serverUrl,
|
||||
@@ -386,6 +405,25 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Register this device in the server registry before opening the WS, so the
|
||||
// row exists by the time the gateway reports it online. Best-effort: a
|
||||
// failure must not block the connection.
|
||||
if (identity) {
|
||||
try {
|
||||
// Reuse the already-resolved auth (respects `--token` mode) instead of
|
||||
// getTrpcClient(), which re-discovers creds and exits when none are found.
|
||||
const trpc = createLambdaClient(auth);
|
||||
await trpc.device.register.mutate({
|
||||
deviceId: identity.deviceId,
|
||||
hostname: os.hostname(),
|
||||
identitySource: identity.identitySource,
|
||||
platform: process.platform,
|
||||
});
|
||||
} catch (err) {
|
||||
error(`Device registration failed (non-fatal): ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Connect
|
||||
await client.connect();
|
||||
}
|
||||
|
||||
@@ -5,7 +5,13 @@ import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { log } from '../utils/logger';
|
||||
import { loadSettings, normalizeUrl, resolveServerUrl, saveSettings } from './index';
|
||||
import {
|
||||
loadOrCreateConnectionId,
|
||||
loadSettings,
|
||||
normalizeUrl,
|
||||
resolveServerUrl,
|
||||
saveSettings,
|
||||
} from './index';
|
||||
|
||||
const tmpDir = path.join(os.tmpdir(), 'lobehub-cli-test-settings');
|
||||
const settingsDir = path.join(tmpDir, '.lobehub');
|
||||
@@ -91,4 +97,22 @@ describe('settings', () => {
|
||||
|
||||
expect(resolveServerUrl()).toBe('https://app.lobehub.com');
|
||||
});
|
||||
|
||||
it('should create a connectionId once and reuse it across calls', () => {
|
||||
const first = loadOrCreateConnectionId();
|
||||
expect(first).toMatch(/[\da-f-]{36}/);
|
||||
|
||||
// Persisted in its own file, independent of settings.json.
|
||||
expect(fs.existsSync(path.join(settingsDir, 'connection-id'))).toBe(true);
|
||||
expect(loadOrCreateConnectionId()).toBe(first);
|
||||
});
|
||||
|
||||
it('should keep the connectionId even when settings.json is cleared', () => {
|
||||
const id = loadOrCreateConnectionId();
|
||||
// Clearing official-server settings unlinks settings.json — connectionId must survive.
|
||||
saveSettings({ serverUrl: 'https://app.lobehub.com/' });
|
||||
|
||||
expect(fs.existsSync(settingsFile)).toBe(false);
|
||||
expect(loadOrCreateConnectionId()).toBe(id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -14,6 +15,9 @@ export interface StoredSettings {
|
||||
const LOBEHUB_DIR_NAME = process.env.LOBEHUB_CLI_HOME || '.lobehub';
|
||||
const SETTINGS_DIR = path.join(os.homedir(), LOBEHUB_DIR_NAME);
|
||||
const SETTINGS_FILE = path.join(SETTINGS_DIR, 'settings.json');
|
||||
// Kept in its own file rather than settings.json, which is unlinked whenever
|
||||
// all server/gateway URLs are default — the connectionId must persist regardless.
|
||||
const CONNECTION_ID_FILE = path.join(SETTINGS_DIR, 'connection-id');
|
||||
|
||||
export function normalizeUrl(url: string | undefined): string | undefined {
|
||||
return url ? url.replace(/\/$/, '') : undefined;
|
||||
@@ -54,6 +58,31 @@ export function saveSettings(settings: StoredSettings): void {
|
||||
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(normalized, null, 2), { mode: 0o600 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable per-install connection routing key for `lh connect`. Decoupled from
|
||||
* the (machine-derived, shared-across-clients) deviceId so the gateway only
|
||||
* replaces this install's own stale socket — a co-running desktop app on the
|
||||
* same machine keeps its connection. Persisted under the CLI home dir, so a
|
||||
* separate `LOBEHUB_CLI_HOME` (e.g. a dev build) naturally gets its own id.
|
||||
*/
|
||||
export function loadOrCreateConnectionId(): string {
|
||||
try {
|
||||
const existing = fs.readFileSync(CONNECTION_ID_FILE, 'utf8').trim();
|
||||
if (existing) return existing;
|
||||
} catch {
|
||||
// not yet created
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
try {
|
||||
fs.mkdirSync(SETTINGS_DIR, { mode: 0o700, recursive: true });
|
||||
fs.writeFileSync(CONNECTION_ID_FILE, id, { mode: 0o600 });
|
||||
} catch {
|
||||
// best-effort: an unwritable home dir just means a fresh id per run
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function loadSettings(): StoredSettings | null {
|
||||
if (!fs.existsSync(SETTINGS_FILE)) return null;
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"@lobechat/chat-adapter-imessage": "workspace:*",
|
||||
"@lobechat/desktop-bridge": "workspace:*",
|
||||
"@lobechat/device-gateway-client": "workspace:*",
|
||||
"@lobechat/device-identity": "workspace:*",
|
||||
"@lobechat/electron-client-ipc": "workspace:*",
|
||||
"@lobechat/electron-server-ipc": "workspace:*",
|
||||
"@lobechat/file-loaders": "workspace:*",
|
||||
|
||||
@@ -9,6 +9,7 @@ packages:
|
||||
- '../../packages/file-loaders'
|
||||
- '../../packages/desktop-bridge'
|
||||
- '../../packages/device-gateway-client'
|
||||
- '../../packages/device-identity'
|
||||
- '../../packages/local-file-shell'
|
||||
- './stubs/business-const'
|
||||
- './stubs/types'
|
||||
|
||||
@@ -120,6 +120,9 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
// Wire up agent run handler
|
||||
srv.setAgentRunHandler((request) => this.executeAgentRun(request));
|
||||
|
||||
// Wire up device registrar (persists this device to the server registry)
|
||||
srv.setDeviceRegistrar((info) => this.registerDevice(info));
|
||||
|
||||
// Auto-connect if already logged in
|
||||
this.tryAutoConnect();
|
||||
}
|
||||
@@ -689,6 +692,34 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist this device to the server registry via `device.register`.
|
||||
* Fire-and-forget from the connect path: a failure must not block the WS
|
||||
* connection, the device just won't appear in the offline list until the
|
||||
* next successful connect.
|
||||
*/
|
||||
private async registerDevice(info: {
|
||||
deviceId: string;
|
||||
hostname: string;
|
||||
identitySource: string;
|
||||
platform: string;
|
||||
}): Promise<void> {
|
||||
const [serverUrl, token] = await Promise.all([
|
||||
this.remoteServerConfigCtr.getRemoteServerUrl(),
|
||||
this.remoteServerConfigCtr.getAccessToken(),
|
||||
]);
|
||||
if (!serverUrl || !token) return;
|
||||
|
||||
await fetch(`${serverUrl}/trpc/lambda/device.register`, {
|
||||
body: JSON.stringify({ json: info }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Oidc-Auth': token,
|
||||
},
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Platform Agent Helpers ───
|
||||
|
||||
private resolveLhPath(): string {
|
||||
|
||||
@@ -8,9 +8,12 @@ import type {
|
||||
ToolCallRequestMessage,
|
||||
} from '@lobechat/device-gateway-client';
|
||||
import { GatewayClient } from '@lobechat/device-gateway-client';
|
||||
import type { IdentitySource } from '@lobechat/device-identity';
|
||||
import { deriveDeviceId } from '@lobechat/device-identity';
|
||||
import type { GatewayConnectionStatus } from '@lobechat/electron-client-ipc';
|
||||
import { app, powerSaveBlocker } from 'electron';
|
||||
|
||||
import { isDev } from '@/const/env';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ServiceModule } from './index';
|
||||
@@ -31,6 +34,15 @@ interface AgentRunHandler {
|
||||
(request: AgentRunRequestMessage): Promise<{ reason?: string; status: 'accepted' | 'rejected' }>;
|
||||
}
|
||||
|
||||
interface DeviceRegistrar {
|
||||
(info: {
|
||||
deviceId: string;
|
||||
hostname: string;
|
||||
identitySource: IdentitySource;
|
||||
platform: string;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* GatewayConnectionService
|
||||
*
|
||||
@@ -43,11 +55,14 @@ export default class GatewayConnectionService extends ServiceModule {
|
||||
private deviceId: string | null = null;
|
||||
private powerSaveBlockerId: number | null = null;
|
||||
|
||||
private identitySource: IdentitySource | null = null;
|
||||
|
||||
private tokenProvider: (() => Promise<string | null>) | null = null;
|
||||
private tokenRefresher: (() => Promise<{ error?: string; success: boolean }>) | null = null;
|
||||
private toolCallHandler: ToolCallHandler | null = null;
|
||||
private messageApiHandler: MessageApiHandler | null = null;
|
||||
private agentRunHandler: AgentRunHandler | null = null;
|
||||
private deviceRegistrar: DeviceRegistrar | null = null;
|
||||
|
||||
// ─── Configuration ───
|
||||
|
||||
@@ -80,8 +95,22 @@ export default class GatewayConnectionService extends ServiceModule {
|
||||
this.agentRunHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist this device to the server's device registry. Called on every
|
||||
* connect once the userId is known (deviceId is user-scoped). Injected by the
|
||||
* controller, which owns the authed server URL + token.
|
||||
*/
|
||||
setDeviceRegistrar(registrar: DeviceRegistrar) {
|
||||
this.deviceRegistrar = registrar;
|
||||
}
|
||||
|
||||
// ─── Device ID ───
|
||||
|
||||
/**
|
||||
* Ensure a stored fallback id exists. Pre-login this doubles as the device id
|
||||
* shown by `getDeviceInfo`; once a userId is available `resolveDeviceIdentity`
|
||||
* replaces it with a stable machine-derived id.
|
||||
*/
|
||||
loadOrCreateDeviceId() {
|
||||
const stored = this.app.storeManager.get('gatewayDeviceId') as string | undefined;
|
||||
if (stored) {
|
||||
@@ -93,10 +122,40 @@ export default class GatewayConnectionService extends ServiceModule {
|
||||
logger.debug(`Device ID: ${this.deviceId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the stable, user-scoped device id. Survives LobeHub reinstalls
|
||||
* because it hashes the OS machine id; falls back to the stored random UUID
|
||||
* when the machine id is unavailable. Caches the result for this session.
|
||||
*/
|
||||
resolveDeviceIdentity(userId: string): { deviceId: string; identitySource: IdentitySource } {
|
||||
const fallbackId = this.app.storeManager.get('gatewayDeviceId') as string | undefined;
|
||||
const identity = deriveDeviceId(userId, { fallbackId });
|
||||
this.deviceId = identity.deviceId;
|
||||
this.identitySource = identity.identitySource;
|
||||
return identity;
|
||||
}
|
||||
|
||||
getDeviceId(): string {
|
||||
return this.deviceId || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection routing key — the gateway's stale-socket dedupe key, decoupled
|
||||
* from the stable `deviceId`. Reuses the persisted random UUID (historically
|
||||
* `gatewayDeviceId`, now used purely as the connectionId) so a reconnect of
|
||||
* this install replaces only its own previous socket, while a co-running
|
||||
* `lh connect` on the same machine (same deviceId, different connectionId)
|
||||
* stays connected.
|
||||
*/
|
||||
getConnectionId(): string {
|
||||
let id = this.app.storeManager.get('gatewayDeviceId') as string | undefined;
|
||||
if (!id) {
|
||||
id = randomUUID();
|
||||
this.app.storeManager.set('gatewayDeviceId', id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// ─── Connection Status ───
|
||||
|
||||
getStatus(): GatewayConnectionStatus {
|
||||
@@ -171,7 +230,24 @@ export default class GatewayConnectionService extends ServiceModule {
|
||||
const userId = this.extractUserIdFromToken(token);
|
||||
logger.info(`Connecting to device gateway: ${gatewayUrl}, userId: ${userId || 'unknown'}`);
|
||||
|
||||
// Resolve the stable, user-scoped device id and register with the server
|
||||
// registry before opening the WS, so the device row exists by the time the
|
||||
// gateway reports it online.
|
||||
if (userId) {
|
||||
const identity = this.resolveDeviceIdentity(userId);
|
||||
await this.deviceRegistrar?.({
|
||||
deviceId: identity.deviceId,
|
||||
hostname: os.hostname(),
|
||||
identitySource: identity.identitySource,
|
||||
platform: process.platform,
|
||||
}).catch((err) => {
|
||||
logger.warn(`Device registration failed (non-fatal): ${(err as Error).message}`);
|
||||
});
|
||||
}
|
||||
|
||||
const client = new GatewayClient({
|
||||
channel: isDev ? 'desktop-dev' : 'desktop',
|
||||
connectionId: this.getConnectionId(),
|
||||
deviceId: this.getDeviceId(),
|
||||
gatewayUrl,
|
||||
logger,
|
||||
|
||||
Reference in New Issue
Block a user