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:
Arvin Xu
2026-05-30 20:35:09 +08:00
committed by GitHub
parent c27b62e10c
commit 3caa3efb18
11 changed files with 226 additions and 3 deletions
+1
View File
@@ -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
View File
@@ -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'
+20
View File
@@ -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;
+1
View File
@@ -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(),
+40 -2
View File
@@ -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();
}
+25 -1
View File
@@ -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);
});
});
+29
View File
@@ -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;
+1
View File
@@ -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:*",
+1
View File
@@ -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,