Files
hermes-ui/tests/server/lan-discovery.test.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +08:00

210 lines
8.6 KiB
TypeScript

import { readFileSync } from 'fs'
import { createHash, generateKeyPairSync } from 'crypto'
import { afterEach, describe, expect, it } from 'vitest'
import {
HERMES_DISCOVERY_PORT,
discoveryPortForHttpPort,
getDiscoveryHttpPorts,
getLanEndpointKind,
isPrivateOrLoopbackIPv4,
resetLanDiscoveryState,
scanLanDevices,
startLanDiscoveryResponder,
} from '../../packages/server/src/services/lan-discovery'
import type { PublicSystemInfo } from '../../packages/server/src/services/system-info'
const fakeKeyPair = generateKeyPairSync('ed25519', {
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
})
const fakeDeviceId = `hwui_${createHash('sha256').update(fakeKeyPair.publicKey).digest('base64url').slice(0, 32)}`
const fakeInfo: PublicSystemInfo = {
device_id: fakeDeviceId,
device_public_key: fakeKeyPair.publicKey,
computer_name: 'test-machine',
os: {
type: 'TestOS',
platform: 'linux',
release: '1.0.0',
arch: 'x64',
},
hermes_agent_version: 'v1.2.3',
hermes_web_ui_version: '9.9.9',
}
describe('LAN discovery', () => {
const originalPorts = process.env.HERMES_LAN_DISCOVERY_HTTP_PORTS
afterEach(() => {
resetLanDiscoveryState()
if (originalPorts === undefined) delete process.env.HERMES_LAN_DISCOVERY_HTTP_PORTS
else process.env.HERMES_LAN_DISCOVERY_HTTP_PORTS = originalPorts
})
it('maps HTTP ports to UDP discovery ports', () => {
expect(HERMES_DISCOVERY_PORT).toBe(48640)
expect(discoveryPortForHttpPort(8648)).toBe(48648)
expect(discoveryPortForHttpPort(8748)).toBe(48748)
})
it('starts a fixed-port discovery responder for HTTP ports without a UDP mapping', async () => {
const socket = startLanDiscoveryResponder({
httpPort: 54321,
getSystemInfo: async () => fakeInfo,
})
expect(socket).not.toBeNull()
if (socket) await new Promise<void>(resolve => socket.once('listening', () => resolve()))
})
it('classifies well-known LAN endpoints', () => {
expect(getLanEndpointKind(8648)).toBe('web')
expect(getLanEndpointKind(8748)).toBe('desktop')
expect(getLanEndpointKind(19001)).toBe('custom')
})
it('limits discovery responses to local/private IPv4 senders', () => {
expect(isPrivateOrLoopbackIPv4('127.0.0.1')).toBe(true)
expect(isPrivateOrLoopbackIPv4('192.168.1.20')).toBe(true)
expect(isPrivateOrLoopbackIPv4('10.1.2.3')).toBe(true)
expect(isPrivateOrLoopbackIPv4('172.16.0.1')).toBe(true)
expect(isPrivateOrLoopbackIPv4('8.8.8.8')).toBe(false)
})
it('uses configured scan ports plus the active server port', () => {
process.env.HERMES_LAN_DISCOVERY_HTTP_PORTS = '8648, 8748'
expect(getDiscoveryHttpPorts(9999)).toEqual([8648, 8748, 9999])
})
it('discovers a local responder over UDP', async () => {
const httpPort = 19001
const socket = startLanDiscoveryResponder({
httpPort,
getSystemInfo: async () => fakeInfo,
})
if (!socket) throw new Error('expected discovery responder socket')
await new Promise<void>(resolve => socket.once('listening', () => resolve()))
const result = await scanLanDevices({
httpPorts: [httpPort],
targetAddresses: ['127.0.0.1'],
timeoutMs: 300,
includeSelf: true,
})
expect(result.scanning).toBe(false)
const device = result.devices.find(item => item.device_id === fakeDeviceId && item.http_port === httpPort)
expect(device).toMatchObject({
id: fakeDeviceId,
device_id: fakeDeviceId,
ip: '127.0.0.1',
http_port: httpPort,
url: `http://127.0.0.1:${httpPort}`,
endpoint_kind: 'custom',
computer_name: 'test-machine',
hermes_agent_version: 'v1.2.3',
hermes_web_ui_version: '9.9.9',
})
})
it('builds device URLs from the UDP source address instead of announced URLs', async () => {
const httpPort = 19003
const socket = startLanDiscoveryResponder({
httpPort,
getSystemInfo: async () => fakeInfo,
})
if (!socket) throw new Error('expected discovery responder socket')
await new Promise<void>(resolve => socket.once('listening', () => resolve()))
const originalSend = socket.send.bind(socket)
socket.send = ((message: any, port: any, address: any, callback?: any) => {
const announced = JSON.parse(Buffer.from(message).toString('utf8'))
announced.url = 'http://127.0.0.1:1'
return originalSend(Buffer.from(JSON.stringify(announced)), port, address, callback)
}) as typeof socket.send
const result = await scanLanDevices({
httpPorts: [httpPort],
targetAddresses: ['127.0.0.1'],
timeoutMs: 300,
includeSelf: true,
})
const device = result.devices.find(item => item.device_id === fakeDeviceId && item.http_port === httpPort)
expect(device?.url).toBe(`http://127.0.0.1:${httpPort}`)
})
it('excludes the local machine from scan results by default', async () => {
const httpPort = 19002
const socket = startLanDiscoveryResponder({
httpPort,
getSystemInfo: async () => fakeInfo,
})
if (!socket) throw new Error('expected discovery responder socket')
await new Promise<void>(resolve => socket.once('listening', () => resolve()))
const result = await scanLanDevices({
httpPorts: [httpPort],
targetAddresses: ['127.0.0.1'],
timeoutMs: 300,
})
expect(result.scanning).toBe(false)
expect(result.devices).toEqual([])
})
it('registers device request routes before auth and management routes behind super admin auth', () => {
const source = readFileSync('packages/server/src/routes/index.ts', 'utf8')
const deviceRoutesSource = readFileSync('packages/server/src/routes/devices.ts', 'utf8')
const bootstrapSource = readFileSync('packages/server/src/index.ts', 'utf8')
const authIndex = source.indexOf('authMiddleware.forEach')
const publicDeviceIndex = source.indexOf('app.use(devicePublicRoutes.routes())')
const deviceIndex = source.indexOf('app.use(deviceRoutes.routes())')
expect(authIndex).toBeGreaterThanOrEqual(0)
expect(publicDeviceIndex).toBeGreaterThanOrEqual(0)
expect(deviceIndex).toBeGreaterThanOrEqual(0)
expect(deviceRoutesSource).toContain("devicePublicRoutes.post('/api/devices/link-status'")
expect(deviceRoutesSource).toContain("devicePublicRoutes.get('/api/devices/link-info'")
expect(deviceRoutesSource).toContain("import { requireSuperAdmin }")
expect(deviceRoutesSource).toContain('deviceRoutes.use(requireSuperAdmin)')
expect(deviceRoutesSource).toContain("deviceRoutes.get('/api/devices/pairing-link'")
expect(deviceRoutesSource).toContain("deviceRoutes.post('/api/devices/manual-request'")
expect(deviceRoutesSource).toContain("deviceRoutes.delete('/api/devices/:id/request-history'")
expect(deviceRoutesSource).toContain("deviceRoutes.get('/api/devices/peer-connections'")
expect(deviceRoutesSource).toContain("deviceRoutes.post('/api/devices/:id/connect'")
expect(deviceRoutesSource).toContain("deviceRoutes.get('/api/devices/peer-connections/:connectionId/terminals'")
expect(bootstrapSource).toContain('getLanPeerSocketPath()')
expect(publicDeviceIndex).toBeLessThan(authIndex)
expect(deviceIndex).toBeGreaterThan(authIndex)
})
it('keeps LAN peer terminals bounded and idle-reclaimable', () => {
const peerSocketSource = readFileSync('packages/server/src/services/lan-peer-socket.ts', 'utf8')
expect(peerSocketSource).toContain("boundedEnvInt('HERMES_LAN_PEER_MAX_TERMINALS', 4")
expect(peerSocketSource).toContain("boundedEnvInt('HERMES_LAN_PEER_TERMINAL_IDLE_MS', 10 * 60 * 1000")
expect(peerSocketSource).toContain("boundedEnvInt('HERMES_LAN_PEER_TERMINAL_BUFFER_BYTES', 1024 * 1024")
expect(peerSocketSource).toContain('Terminal limit reached')
expect(peerSocketSource).toContain('[lan-peer] closing idle terminal')
expect(peerSocketSource).toContain('this.disposeTerminalSession(session, { notify: false })')
})
it('exposes an MCP terminal list tool so agents can recover forgotten terminal ids', () => {
const mcpSource = readFileSync('bin/hermes-studio-mcp.mjs', 'utf8')
expect(mcpSource).toContain("name: 'hermes_studio_lan_devices_list'")
expect(mcpSource).toContain('online status')
expect(mcpSource).toContain('temporary profile token')
expect(mcpSource).toContain("'X-Hermes-Profile': profile")
expect(mcpSource).toContain('token: args.token')
expect(mcpSource).toContain('profile: args.profile')
expect(mcpSource).toContain("join(appHome(), 'profiles', segment, '.model-run-token')")
expect(mcpSource).toContain("name: 'hermes_studio_lan_terminal_list'")
expect(mcpSource).toContain('/terminals`, withAuthArgs(args))')
})
})