mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-10 02:30:15 +00:00
628232ab1f
Co-authored-by: Codex <codex@openai.com>
210 lines
8.6 KiB
TypeScript
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))')
|
|
})
|
|
})
|