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.
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))')
|
|
})
|
|
})
|