Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/server-host-option.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 15 additions & 4 deletions apps/kimi-code/src/cli/sub/server/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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`). */
Expand All @@ -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). */
Expand All @@ -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). */
Expand Down Expand Up @@ -178,6 +182,7 @@ export function resolveDaemonProgram(
}

interface SpawnDaemonChildOptions {
host: string;
port: number;
logLevel: string;
debugEndpoints?: boolean;
Expand All @@ -193,6 +198,8 @@ export function spawnDaemonChild(options: SpawnDaemonChildOptions): void {
'server',
'run',
'--daemon',
'--host',
options.host,
'--port',
String(options.port),
'--log-level',
Expand Down Expand Up @@ -244,24 +251,27 @@ function sleep(ms: number): Promise<void> {
* detached process after this returns.
*/
export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise<EnsureDaemonResult> {
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
// reconnect below; if it died, stale takeover lets our child claim it.
}

// 2. No reusable daemon — pick a free port and spawn one detached.
const port = await resolveDaemonPort(DEFAULT_SERVER_HOST, preferred);
const port = await resolveDaemonPort(host, preferred);
spawnDaemonChild({
host,
port,
logLevel,
debugEndpoints: options.debugEndpoints,
Expand All @@ -273,9 +283,10 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise<E
while (Date.now() < deadline) {
const live = getLiveLock();
if (live) {
const liveHost = live.host ?? DEFAULT_SERVER_HOST;
const origin = serverOrigin(lockConnectHost(live), live.port);
Comment on lines +286 to 287

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bracket IPv6 hosts before probing daemon health

When --host is an IPv6 bind such as :: or ::1, the daemon can write that value into the lock, but this new host propagation then feeds the raw lock host into serverOrigin, which formats it as http://${host}:${port}. That produces invalid URLs like http://:::58627, so isServerHealthy/waitForServerHealthy never succeed and kimi web --host :: times out even though the detached server is running; IPv6 hosts need to be bracketed (and :: mapped to a connectable loopback like [::1]) before probing/opening.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed, thanks. server origins now normalize bind-any hosts to a connectable loopback and bracket IPv6 literals, so :: probes/open URLs go through http://[::1]:port. also covered the shared origin helper and daemon lock-host path in tests.

if (await isServerHealthy(origin, 500)) {
return { origin };
return { origin, host: liveHost };
}
}
await sleep(POLL_INTERVAL_MS);
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/src/cli/sub/server/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,5 +249,5 @@ function withStatusDetails(
}

function formatServiceUrl(host: string, port: number): string {
return serverOrigin(host === '0.0.0.0' ? DEFAULT_SERVER_HOST : host, port);
return serverOrigin(host, port);
}
40 changes: 30 additions & 10 deletions apps/kimi-code/src/cli/sub/server/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ import { createKimiCodeHostIdentity, getHostPackageRoot, getVersion } from '../.
import { ensureDaemon } from './daemon';
import {
DEFAULT_FOREGROUND_LOG_LEVEL,
DEFAULT_SERVER_HOST,
DEFAULT_SERVER_PORT,
parseServerOptions,
serverOriginFromAddress,
VALID_LOG_LEVELS,
type ParsedServerOptions,
type ServerCliOptions,
Expand All @@ -51,7 +53,7 @@ export interface StartForegroundHooks {
}

export interface RunCommandDeps {
startServerBackground(options: ParsedServerOptions): Promise<{ origin: string }>;
startServerBackground(options: ParsedServerOptions): Promise<{ origin: string; host?: string }>;
/** Foreground runner; defaults to the real in-process runner when omitted. */
startServerForeground?: (
options: ParsedServerOptions,
Expand All @@ -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>',
`Host to bind (default ${DEFAULT_SERVER_HOST})`,
DEFAULT_SERVER_HOST,
)
.option(
'--port <port>',
`Bind port (default ${DEFAULT_SERVER_PORT})`,
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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 };
}

/**
Expand Down Expand Up @@ -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<never>(() => {
// Keeps the event loop alive; the process ends via shutdown()/process.exit.
Expand Down Expand Up @@ -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);
Expand All @@ -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`),
Expand Down Expand Up @@ -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,
Expand Down
32 changes: 31 additions & 1 deletion apps/kimi-code/src/cli/sub/server/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading