import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { createServer, type Server } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Writable } from 'node:stream'; import { pino } from 'pino'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { IBootstrapService, IFileSystemStorageService, IHostRequestHeaders, InMemoryStorageService, IOAuthToolkit, ITelemetryService, noopTelemetryService, } from '@moonshot-ai/agent-core-v2'; import { listLiveServerInstances } from '../src/instanceRegistry'; import { listenWithPortRetry, type RunningServer, startServer } from '../src/start'; import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; import { authedFetch } from './helpers/auth'; describe('server-v2 boot', () => { let server: RunningServer | undefined; let home: string | undefined; afterEach(async () => { if (server !== undefined) { await server.close(); server = undefined; } if (home !== undefined) { await rm(home, { recursive: true, force: true }); home = undefined; } }); it('boots agent-core-v2 and serves the basic /api/v1 routes', async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-')); server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent', }); const base = `http://127.0.0.1:${server.port}`; const healthz = await fetch(`${base}/api/v1/healthz`); expect(healthz.status).toBe(200); const healthBody = await healthz.json() as { code: number; data: { ok: boolean }; request_id: string; }; expect(healthBody.code).toBe(0); expect(healthBody.data.ok).toBe(true); expect(typeof healthBody.request_id).toBe('string'); const meta = await authedFetch(server, base, '/api/v1/meta'); expect(meta.status).toBe(200); const metaBody = await meta.json() as { code: number; data: { server_id: string; server_version: string; capabilities: Record }; }; expect(metaBody.code).toBe(0); expect(typeof metaBody.data.server_id).toBe('string'); expect(typeof metaBody.data.server_version).toBe('string'); expect(metaBody.data.capabilities).toBeDefined(); const auth = await authedFetch(server, base, '/api/v1/auth'); expect(auth.status).toBe(200); const authBody = await auth.json() as { code: number; data: { models_ready: boolean; providers_count: number }; }; expect(authBody.code).toBe(0); expect(typeof authBody.data.models_ready).toBe('boolean'); expect(authBody.data.providers_count).toBeGreaterThanOrEqual(0); const oauthPoll = await authedFetch(server, base, '/api/v1/oauth/login'); expect(oauthPoll.status).toBe(200); const oauthBody = await oauthPoll.json() as { code: number; data: null }; expect(oauthBody.code).toBe(0); expect(oauthBody.data).toBeNull(); }); it('reports opts.serverVersion as server_version instead of the package version', async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-version-')); server = await startServer({ hostIdentity: { productName: 'test-host', version: '9.9.9-host', platform: 'test_platform', }, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent', serverVersion: '9.9.9-host', }); const base = `http://127.0.0.1:${server.port}`; const meta = await authedFetch(server, base, '/api/v1/meta'); const metaBody = await meta.json() as { code: number; data: { server_version: string }; }; expect(metaBody.data.server_version).toBe('9.9.9-host'); const [instance] = await listLiveServerInstances(home); expect(instance?.serverVersion).toBe('9.9.9-host'); const defaults = server.core.accessor.get(IHostRequestHeaders); expect(defaults.headers['User-Agent']).toBe('test-host/9.9.9-host'); expect(server.core.accessor.get(IBootstrapService).clientIdentity).toEqual({ productName: 'test-host', version: '9.9.9-host', platform: 'test_platform', }); }); it('seeds default Kimi identity headers from hostIdentity that opts.seeds can override', async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-ua-')); server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent', }); const defaults = server.core.accessor.get(IHostRequestHeaders); expect(defaults.headers['User-Agent']).toBe('test-host/0.0.0-test'); expect(defaults.headers['X-Msh-Version']).toBe('0.0.0-test'); expect(defaults.headers['X-Msh-Platform']).toBe('test_platform'); await server.close(); server = undefined; server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent', seeds: [[IHostRequestHeaders, { headers: { 'User-Agent': 'custom-host/9.9' } }]], }); const overridden = server.core.accessor.get(IHostRequestHeaders); expect(overridden.headers['User-Agent']).toBe('custom-host/9.9'); }); it('seeds explicit skill dirs into the core scope when skillDirs is provided', async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-skills-')); server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent', skillDirs: ['/skills/explicit'], }); expect(server.core.accessor.get(IBootstrapService).args.skillDirs).toEqual([ '/skills/explicit', ]); await server.close(); server = undefined; server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent', }); expect(server.core.accessor.get(IBootstrapService).args.skillDirs).toBeUndefined(); }); it('does not shut down a host-injected telemetry service when server telemetry is disabled', async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-host-telemetry-')); await writeFile(join(home, 'config.toml'), 'telemetry = false\n', 'utf8'); const shutdown = vi.fn(async () => {}); server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent', seeds: [[ITelemetryService, { ...noopTelemetryService, shutdown }]], }); await server.close(); server = undefined; expect(shutdown).not.toHaveBeenCalled(); }); it('completes server cleanup when owned telemetry shutdown fails', async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-telemetry-failure-')); const storage = new InMemoryStorageService(); const write = storage.write.bind(storage); vi.spyOn(storage, 'write').mockImplementation(async (scope, key, data, options) => { if (scope === 'telemetry') throw new Error('telemetry storage unavailable'); await write(scope, key, data, options); }); const auth = { _serviceBrand: undefined, getCachedAccessToken: async () => { throw new Error('telemetry auth unavailable'); }, } as unknown as IOAuthToolkit; server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent', telemetry: true, seeds: [ [IFileSystemStorageService, storage], [IOAuthToolkit, auth], ], }); const core = server.core; core.accessor.get(ITelemetryService).track2('session_ended', { reason: 'exit' }); await server.close(); server = undefined; expect(() => core.accessor.get(IBootstrapService)).toThrow(); expect(await listLiveServerInstances(home)).toEqual([]); }); it('logs process-level exceptions without exiting and removes the handlers on close', async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-')); const lines: string[] = []; const stream = new Writable({ write(chunk, _encoding, callback) { lines.push(String(chunk)); callback(); }, }); const rejectionBefore = process.listeners('unhandledRejection'); const exceptionBefore = process.listeners('uncaughtException'); server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logger: pino({ level: 'error' }, stream), }); expect(process.listenerCount('unhandledRejection')).toBe(rejectionBefore.length + 1); expect(process.listenerCount('uncaughtException')).toBe(exceptionBefore.length + 1); const onUncaughtException = process .listeners('uncaughtException') .find((listener) => !exceptionBefore.includes(listener)) as | ((error: Error) => void) | undefined; const onUnhandledRejection = process .listeners('unhandledRejection') .find((listener) => !rejectionBefore.includes(listener)) as | ((reason: unknown) => void) | undefined; expect(onUncaughtException).toBeDefined(); expect(onUnhandledRejection).toBeDefined(); onUncaughtException?.(new Error('synthetic uncaught')); onUnhandledRejection?.(new Error('synthetic rejection')); const output = lines.join(''); expect(output).toContain('"msg":"uncaughtException"'); expect(output).toContain('"msg":"unhandledRejection"'); const healthz = await fetch(`http://127.0.0.1:${server.port}/api/v1/healthz`); expect(healthz.status).toBe(200); await server.close(); server = undefined; expect(process.listenerCount('unhandledRejection')).toBe(rejectionBefore.length); expect(process.listenerCount('uncaughtException')).toBe(exceptionBefore.length); }); it('does not leave process handlers installed when startup fails', async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-')); const emptyAssets = await mkdtemp(join(tmpdir(), 'kimi-server-v2-assets-')); const rejectionBefore = process.listenerCount('unhandledRejection'); const exceptionBefore = process.listenerCount('uncaughtException'); try { await expect( startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent', webAssetsDir: emptyAssets, }), ).rejects.toThrow('web assets'); expect(process.listenerCount('unhandledRejection')).toBe(rejectionBefore); expect(process.listenerCount('uncaughtException')).toBe(exceptionBefore); } finally { await rm(emptyAssets, { recursive: true, force: true }); } }); }); function silentLogger() { return pino({ level: 'silent' }); } function addrInUse(): NodeJS.ErrnoException { const err = new Error('listen EADDRINUSE') as NodeJS.ErrnoException; err.code = 'EADDRINUSE'; return err; } function listenOnPort(host: string, port: number): Promise { return new Promise((resolve, reject) => { const server = createServer(); server.once('error', reject); server.listen({ host, port }, () => resolve(server)); }); } function closeNetServer(server: Server): Promise { return new Promise((resolve) => server.close(() => resolve())); } async function allocateAdjacentFreePair( host = '127.0.0.1', ): Promise<{ port: number; next: number }> { for (let i = 0; i < 30; i++) { const a = await listenOnPort(host, 0); const address = a.address(); const port = typeof address === 'object' && address !== null ? address.port : 0; await closeNetServer(a); if (port <= 0 || port >= 65535) continue; const probe = await listenOnPort(host, port + 1).catch(() => null); if (probe === null) continue; await closeNetServer(probe); return { port, next: port + 1 }; } throw new Error('could not allocate an adjacent free port pair'); } describe('listenWithPortRetry', () => { it('returns the requested port when the first listen succeeds', async () => { const attempts: number[] = []; const result = await listenWithPortRetry({ listen: async (_host, port) => { attempts.push(port); return `http://127.0.0.1:${String(port)}`; }, host: '127.0.0.1', port: 5000, logger: silentLogger(), }); expect(result.port).toBe(5000); expect(attempts).toEqual([5000]); }); it('retries with port+1 on EADDRINUSE until a bind succeeds', async () => { const attempts: number[] = []; const result = await listenWithPortRetry({ listen: async (_host, port) => { attempts.push(port); if (port < 5002) throw addrInUse(); return `http://127.0.0.1:${String(port)}`; }, host: '127.0.0.1', port: 5000, logger: silentLogger(), }); expect(result.port).toBe(5002); expect(result.address).toBe('http://127.0.0.1:5002'); expect(attempts).toEqual([5000, 5001, 5002]); }); it('does not retry on non-EADDRINUSE errors', async () => { const attempts: number[] = []; const boom = Object.assign(new Error('listen EACCES'), { code: 'EACCES' }); await expect( listenWithPortRetry({ listen: async (_host, port) => { attempts.push(port); throw boom; }, host: '127.0.0.1', port: 5000, logger: silentLogger(), }), ).rejects.toBe(boom); expect(attempts).toEqual([5000]); }); it('throws after exhausting maxRetries', async () => { const attempts: number[] = []; await expect( listenWithPortRetry({ listen: async (_host, port) => { attempts.push(port); throw addrInUse(); }, host: '127.0.0.1', port: 5000, logger: silentLogger(), maxRetries: 3, }), ).rejects.toMatchObject({ code: 'EADDRINUSE' }); expect(attempts).toEqual([5000, 5001, 5002, 5003]); }); it('does not walk ports when the requested port is 0 (ephemeral)', async () => { const attempts: number[] = []; const result = await listenWithPortRetry({ listen: async (_host, port) => { attempts.push(port); return 'http://127.0.0.1:54321'; }, host: '127.0.0.1', port: 0, logger: silentLogger(), }); expect(result.port).toBe(0); expect(attempts).toEqual([0]); }); }); describe('server-v2 boot — port retry', () => { let server: RunningServer | undefined; let home: string | undefined; afterEach(async () => { if (server !== undefined) { await server.close(); server = undefined; } if (home !== undefined) { await rm(home, { recursive: true, force: true }); home = undefined; } }); it('retries on port+1 and advertises the bound port in the instance registry', async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-port-retry-')); const { port, next } = await allocateAdjacentFreePair(); const occupant = await listenOnPort('127.0.0.1', port); try { server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port, homeDir: home, logLevel: 'silent', }); expect(server.port).toBeGreaterThanOrEqual(next); const [instance] = await listLiveServerInstances(home); expect(instance?.port).toBe(server.port); } finally { await closeNetServer(occupant); } }); });