diff --git a/.changeset/server-host-option.md b/.changeset/server-host-option.md new file mode 100644 index 000000000..ca273c2af --- /dev/null +++ b/.changeset/server-host-option.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add a `--host` option to `kimi web` and `kimi server run` for binding the web UI to non-loopback interfaces. diff --git a/apps/kimi-code/src/cli/sub/server/daemon.ts b/apps/kimi-code/src/cli/sub/server/daemon.ts index 1c840d418..b3e21bfe4 100644 --- a/apps/kimi-code/src/cli/sub/server/daemon.ts +++ b/apps/kimi-code/src/cli/sub/server/daemon.ts @@ -25,6 +25,7 @@ import { dirname, isAbsolute, join, resolve } from 'node:path'; import { DEFAULT_LOCK_DIR, getLiveLock, type LockContents } from '@moonshot-ai/server'; import { + connectableServerHost, DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT, isServerHealthy, @@ -44,6 +45,8 @@ const POLL_INTERVAL_MS = 200; const DEFAULT_DAEMON_LOG_LEVEL = 'info'; export interface EnsureDaemonOptions { + /** Host/interface the daemon should bind. */ + host?: string; /** Preferred port; on conflict a free port is chosen automatically. */ port?: number; /** Pino log level for the spawned daemon (defaults to `info`). */ @@ -56,6 +59,7 @@ export interface EnsureDaemonOptions { export interface EnsureDaemonResult { readonly origin: string; + readonly host: string; } /** Path of the daemon log file (shared with the OS-service log location). */ @@ -65,7 +69,7 @@ export function daemonLogPath(): string { export function lockConnectHost(lock: LockContents): string { const host = lock.host ?? DEFAULT_SERVER_HOST; - return host === '0.0.0.0' ? DEFAULT_SERVER_HOST : host; + return connectableServerHost(host); } /** True when `host:port` is currently free to bind (nothing listening). */ @@ -178,6 +182,7 @@ export function resolveDaemonProgram( } interface SpawnDaemonChildOptions { + host: string; port: number; logLevel: string; debugEndpoints?: boolean; @@ -193,6 +198,8 @@ export function spawnDaemonChild(options: SpawnDaemonChildOptions): void { 'server', 'run', '--daemon', + '--host', + options.host, '--port', String(options.port), '--log-level', @@ -244,15 +251,17 @@ function sleep(ms: number): Promise { * detached process after this returns. */ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise { + const host = options.host ?? DEFAULT_SERVER_HOST; const preferred = options.port ?? DEFAULT_SERVER_PORT; const logLevel = options.logLevel ?? DEFAULT_DAEMON_LOG_LEVEL; // 1. Reuse an already-live daemon if one holds the lock. const existing = getLiveLock(); if (existing) { + const existingHost = existing.host ?? DEFAULT_SERVER_HOST; const origin = serverOrigin(lockConnectHost(existing), existing.port); if (await waitForServerHealthy(origin, REUSE_HEALTH_TIMEOUT_MS)) { - return { origin }; + return { origin, host: existingHost }; } // Live pid but not responding (wedged or mid-boot failure). Fall through // and spawn: if it is truly wedged our child loses the lock race and we @@ -260,8 +269,9 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise; + startServerBackground(options: ParsedServerOptions): Promise<{ origin: string; host?: string }>; /** Foreground runner; defaults to the real in-process runner when omitted. */ startServerForeground?: ( options: ParsedServerOptions, @@ -65,6 +67,11 @@ export interface RunCommandDeps { /** Build the `run` subcommand, mounted under a parent (`server` or top-level). */ export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }): Command { return cmd + .option( + '--host ', + `Host to bind (default ${DEFAULT_SERVER_HOST})`, + DEFAULT_SERVER_HOST, + ) .option( '--port ', `Bind port (default ${DEFAULT_SERVER_PORT})`, @@ -127,7 +134,7 @@ export async function handleRunCommand( const readyMs = Date.now() - startedAt; deps.stdout.write( parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL - ? formatReadyBanner(origin, readyMs) + ? formatReadyBanner(origin, readyMs, parsed.host) : `Kimi server: ${origin}\n`, ); if (opts.open === true) { @@ -137,11 +144,12 @@ export async function handleRunCommand( }); return; } - const { origin } = await deps.startServerBackground(parsed); + const background = await deps.startServerBackground(parsed); + const { origin } = background; const readyMs = Date.now() - startedAt; deps.stdout.write( parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL - ? formatReadyBanner(origin, readyMs) + ? formatReadyBanner(origin, readyMs, background.host ?? parsed.host) : `Kimi server: ${origin}\n`, ); if (opts.open === true) { @@ -157,14 +165,15 @@ export async function handleRunCommand( */ export async function startServerBackground( options: ParsedServerOptions, -): Promise<{ origin: string }> { - const { origin } = await ensureDaemon({ +): Promise<{ origin: string; host: string }> { + const { origin, host } = await ensureDaemon({ + host: options.host, port: options.port, logLevel: options.logLevel, debugEndpoints: options.debugEndpoints, idleGraceMs: options.idleGraceMs, }); - return { origin }; + return { origin, host }; } /** @@ -267,7 +276,7 @@ async function runServerInProcess( : { address: running.address }; running.logger.info(readyFields, mode.daemon ? 'daemon ready' : 'server ready'); - onReady?.(running.address); + onReady?.(serverOriginFromAddress(options.host, running.address, options.port)); return new Promise(() => { // Keeps the event loop alive; the process ends via shutdown()/process.exit. @@ -323,7 +332,7 @@ export function resolveServerWebAssetsDir( return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR); } -function formatReadyBanner(origin: string, readyMs: number): string { +function formatReadyBanner(origin: string, readyMs: number, host: string): string { const primary = (text: string): string => chalk.hex(darkColors.primary)(text); const title = (text: string): string => chalk.bold.hex(darkColors.primary)(text); const dim = (text: string): string => chalk.hex(darkColors.textDim)(text); @@ -348,7 +357,8 @@ function formatReadyBanner(origin: string, readyMs: number): string { ]; const infoLines = [ label('URL: ') + url, - label('Network: ') + muted('local only'), + label('Network: ') + muted(networkLabel(host)), + ...(isLocalHost(host) ? [] : [label('Access: ') + muted('trusted network only')]), label('Logs: ') + muted('off') + dim(' use --log-level info to enable'), label('Stop: ') + muted('kimi server kill'), label('Ready: ') + muted(`${String(Math.max(0, readyMs))} ms`), @@ -378,6 +388,16 @@ function displayOrigin(origin: string): string { return origin.endsWith('/') ? origin : `${origin}/`; } +function networkLabel(host: string): string { + if (isLocalHost(host)) return 'local only'; + return `listening on ${host}`; +} + +function isLocalHost(host: string): boolean { + const bareHost = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host; + return bareHost === DEFAULT_SERVER_HOST || bareHost === 'localhost' || bareHost === '::1'; +} + const DEFAULT_RUN_COMMAND_DEPS: RunCommandDeps = { startServerBackground, startServerForeground, diff --git a/apps/kimi-code/src/cli/sub/server/shared.ts b/apps/kimi-code/src/cli/sub/server/shared.ts index b0550c65b..aee930684 100644 --- a/apps/kimi-code/src/cli/sub/server/shared.ts +++ b/apps/kimi-code/src/cli/sub/server/shared.ts @@ -92,8 +92,38 @@ export function parseLogLevel(raw: string | undefined): ServerLogLevel { ); } +export function connectableServerHost(host: string): string { + const bareHost = unbracketHost(host); + if (bareHost === '0.0.0.0') return DEFAULT_SERVER_HOST; + if (bareHost === '::') return '::1'; + return bareHost; +} + export function serverOrigin(host: string, port: number): string { - return `http://${host}:${port}`; + return `http://${urlHost(connectableServerHost(host))}:${port}`; +} + +export function serverOriginFromAddress(host: string, address: string, fallbackPort: number): string { + try { + const port = Number.parseInt(new URL(address).port, 10); + if (Number.isFinite(port)) { + return serverOrigin(host, port); + } + } catch { + // Fall back below if the listening address is unexpectedly malformed. + } + return serverOrigin(host, fallbackPort); +} + +function urlHost(host: string): string { + return host.includes(':') ? `[${host}]` : host; +} + +function unbracketHost(host: string): string { + if (host.startsWith('[') && host.endsWith(']')) { + return host.slice(1, -1); + } + return host; } /** Strip `/api/v1` and trailing slashes so user-supplied origins are uniform. */ diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts index 80304ef37..083daff91 100644 --- a/apps/kimi-code/test/cli/server/server.test.ts +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -56,14 +56,14 @@ describe('kimi server', () => { expect(subs).toEqual(['kill', 'ps', 'run']); }); - it('`server run` exposes local-only foreground options', () => { + it('`server run` exposes network-bind foreground options', () => { const program = makeProgram(); const run = program.commands .find((c) => c.name() === 'server') ?.commands.find((c) => c.name() === 'run'); expect(run).toBeDefined(); const longs = run!.options.map((o) => o.long).filter(Boolean); - expect(longs).not.toContain('--host'); + expect(longs).toContain('--host'); expect(longs).toContain('--port'); expect(longs).toContain('--log-level'); expect(longs).toContain('--debug-endpoints'); @@ -95,7 +95,7 @@ describe('kimi server', () => { const longs = web!.options.map((o) => o.long).filter(Boolean); // web defaults to opening → the option is the negative form --no-open expect(longs).toContain('--no-open'); - expect(longs).not.toContain('--host'); + expect(longs).toContain('--host'); expect(longs).toContain('--port'); }); }); @@ -341,6 +341,34 @@ describe('`kimi server run` background start', () => { expect(parsed).toMatchObject({ logLevel: 'debug' }); }); + it('passes --host through to the background daemon', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { host: '0.0.0.0', port: '58627' }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://127.0.0.1:58627' }; + }, + openUrl: vi.fn(), + stdout: { + write() { + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + expect(parsed).toMatchObject({ host: '0.0.0.0', port: 58627 }); + }); + it('prints a TUI-style ready panel once the daemon is up', async () => { const { handleRunCommand } = await import('#/cli/sub/server/run'); let stdout = ''; @@ -418,6 +446,39 @@ describe('`kimi server run` background start', () => { expect(stdout).toContain(color.bold.hex(darkColors.textDim)('URL: ')); expect(stdout).toContain(color.hex(darkColors.textMuted)('local only')); }); + + it('shows the bound host in the ready panel for non-loopback binds', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + + await handleRunCommand( + { host: '0.0.0.0', port: '58627' }, + { + startServerBackground: async () => ({ + origin: 'http://127.0.0.1:58627', + host: '0.0.0.0', + }), + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + const plain = stripAnsi(stdout); + expect(plain).toContain('Network:'); + expect(plain).toContain('listening on 0.0.0.0'); + expect(plain).toContain('Access:'); + expect(plain).toContain('trusted network only'); + }); }); describe('`kimi server run --foreground`', () => { @@ -452,7 +513,40 @@ describe('`kimi server run --foreground`', () => { ); expect(backgroundCalled).toBe(false); - expect(foregroundOptions).toMatchObject({ port: 58627, logLevel: 'silent' }); + expect(foregroundOptions).toMatchObject({ + host: '127.0.0.1', + port: 58627, + logLevel: 'silent', + }); + }); + + it('passes --host through to the foreground server', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let foregroundOptions: unknown; + + await handleRunCommand( + { host: '0.0.0.0', port: '58627', foreground: true }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + startServerForeground: async (options) => { + foregroundOptions = options; + return undefined as unknown as never; + }, + openUrl: vi.fn(), + stdout: { + write() { + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + }, + ); + + expect(foregroundOptions).toMatchObject({ host: '0.0.0.0', port: 58627 }); }); it('prints the ready banner and opens the browser once listening', async () => { @@ -514,6 +608,36 @@ describe('shared parsers stay strict', () => { expect(parseLogLevel(undefined)).toBe('info'); expect(parseLogLevel('debug')).toBe('debug'); }); + + it('formats connectable server origins for bind-any and IPv6 hosts', async () => { + const { connectableServerHost, serverOrigin, serverOriginFromAddress } = await import( + '#/cli/sub/server/shared' + ); + + expect(connectableServerHost('0.0.0.0')).toBe('127.0.0.1'); + expect(connectableServerHost('::')).toBe('::1'); + expect(serverOrigin('127.0.0.1', 58627)).toBe('http://127.0.0.1:58627'); + expect(serverOrigin('0.0.0.0', 58627)).toBe('http://127.0.0.1:58627'); + expect(serverOrigin('localhost', 58627)).toBe('http://localhost:58627'); + expect(serverOrigin('::1', 58627)).toBe('http://[::1]:58627'); + expect(serverOrigin('::', 58627)).toBe('http://[::1]:58627'); + expect(serverOrigin('[::1]', 58627)).toBe('http://[::1]:58627'); + expect(serverOriginFromAddress('::', 'http://[::]:58628', 58627)).toBe( + 'http://[::1]:58628', + ); + }); + + it('normalizes daemon lock hosts for client probes', async () => { + const { lockConnectHost } = await import('#/cli/sub/server/daemon'); + + expect(lockConnectHost({ pid: 1, started_at: 'now', port: 58627, host: '0.0.0.0' })).toBe( + '127.0.0.1', + ); + expect(lockConnectHost({ pid: 1, started_at: 'now', port: 58627, host: '::' })).toBe('::1'); + expect(lockConnectHost({ pid: 1, started_at: 'now', port: 58627, host: '::1' })).toBe( + '::1', + ); + }); }); describe('server web asset directory resolution', () => { @@ -672,12 +796,13 @@ describe('spawnDaemonChild', () => { spawnMock.mockReturnValue({ unref: vi.fn(), once: vi.fn() } as unknown as ChildProcess); const { spawnDaemonChild, daemonLogPath } = await import('#/cli/sub/server/daemon'); - spawnDaemonChild({ port: 58627, logLevel: 'info' }); + spawnDaemonChild({ host: '0.0.0.0', port: 58627, logLevel: 'info' }); expect(spawnMock).toHaveBeenCalledOnce(); const [program, args, options] = spawnMock.mock.calls[0]!; expect(program).toBeTruthy(); expect(args).toEqual(expect.arrayContaining(['server', 'run', '--daemon'])); + expect(args).toEqual(expect.arrayContaining(['--host', '0.0.0.0'])); expect(options).toMatchObject({ detached: true, cwd: dirname(daemonLogPath()) }); expect(options?.cwd).not.toBe(process.cwd()); });