Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
687f6d8
test(server): add API surface snapshot guardrail
sailist Jun 22, 2026
bdea467
test(server): add e2e server harness with token support
sailist Jun 22, 2026
2f74211
feat(server): add privateFiles 0600 atomic write/read utility
sailist Jun 22, 2026
f4695b1
feat(server): add per-start tokenStore
sailist Jun 22, 2026
4bd529c
feat(server): add env-based bcrypt password hash utility
sailist Jun 22, 2026
e16c437
feat(server): add IAuthTokenService DI seam
sailist Jun 22, 2026
297b772
feat(server): add global onRequest auth hook with bypass + redaction
sailist Jun 22, 2026
8f443c3
fix(server): stop reflecting Host header in /asyncapi.json
sailist Jun 22, 2026
a5e316e
feat(server): add WS bearer subprotocol constant and parser
sailist Jun 22, 2026
c4b9949
feat(server): enforce bearer token auth on WS upgrade
sailist Jun 22, 2026
037d86d
feat(server): add Host header allowlist middleware
sailist Jun 22, 2026
13faf29
feat(server): add Origin/CORS middleware
sailist Jun 22, 2026
2187ec4
feat(server): wire Host/Origin checks into HTTP and WS
sailist Jun 22, 2026
3122f8a
feat(server): wire token auth, Host/Origin, and WS auth into start.ts
sailist Jun 22, 2026
3f95019
fix(server): create lock file with 0600 permissions
sailist Jun 22, 2026
3cf31f9
fix(server): suppress debug routes on non-loopback binds
sailist Jun 22, 2026
b2a5411
feat(kimi-code): read server token and send Authorization on CLI calls
sailist Jun 22, 2026
e389785
feat(kimi-code): inject server token into /web URL fragment
sailist Jun 22, 2026
c694a76
feat(server): add bindClassify for loopback/lan/public classification
sailist Jun 22, 2026
879fa91
feat(kimi-code): register --host flag and pass it through the daemon
sailist Jun 22, 2026
ff18d67
feat(server): require password and TLS opt-out on non-loopback binds
sailist Jun 22, 2026
d2cba5d
feat(server): rate-limit repeated auth failures on non-loopback binds
sailist Jun 22, 2026
46758b6
feat(server): disable shutdown and terminals on public binds by default
sailist Jun 22, 2026
80bd74e
feat(server): add security response headers on non-loopback binds
sailist Jun 22, 2026
6fc7c30
test(server): cover LAN/public host-exposure hardening end to end
sailist Jun 22, 2026
13ea2d4
docs(server): add deployment security and threat-model guide
sailist Jun 22, 2026
4472c22
changeset: minor kimi-code for server auth and host exposure
sailist Jun 22, 2026
e15b10d
feat(kimi-web): add server bearer-token auth support
sailist Jun 23, 2026
454e9c0
fix: repair CI for server auth and host exposure
sailist Jun 23, 2026
bad9446
feat(server): persist bearer token and add rotate-token command
sailist Jun 24, 2026
faab9da
feat(server): print full token URLs and re-print links after rotate
sailist Jun 24, 2026
e3e65e1
feat(server): dim URL #token= fragment and de-highlight token
sailist Jun 24, 2026
9d14d8e
refactor(cli): polish server ready banner and rotate-token output
sailist Jun 24, 2026
acd40fc
feat(server): warn on reuse and refine ready banner
sailist Jun 24, 2026
c7a4216
fix(web): relabel auth dialog to token and cover full page
sailist Jun 24, 2026
d810989
fix: resolve CI failures on web auth PR
sailist Jun 25, 2026
097b17c
test(server): update API surface snapshot for warnings route
sailist Jun 25, 2026
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
10 changes: 10 additions & 0 deletions .changeset/server-auth-and-host-exposure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@moonshot-ai/kimi-code": minor
---

Add server authentication and safe `--host` exposure. The local server now
requires a per-start bearer token on all API and WebSocket calls (the CLI reads
it automatically), enforces Host/Origin checks, and gains `--host` with a
public-binding hardening tier: mandatory `KIMI_CODE_PASSWORD`, TLS (or
`--insecure-no-tls`), auth-failure rate limiting, disabled remote
shutdown/terminals, and security response headers. See `packages/server/SECURITY.md`.
85 changes: 85 additions & 0 deletions apps/kimi-code/src/cli/sub/server/access-urls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Build the clickable/copyable access URLs for the running server.
*
* Shared by the `server run` ready banner and `server rotate-token` so both
* show the same Local/Network links. When a token is known it rides in the
* `#token=` fragment (never sent to the server, so never logged), letting a
* user open the link on another device and be authenticated automatically.
*/

import { formatHostForUrl, listNetworkAddresses, type NetworkAddress } from './networks';

/**
* Build a directly-openable server URL. When the token is known it is appended
* as `#token=<token>`; otherwise the bare origin (with a trailing slash) is
* returned.
*/
export function buildOpenableUrl(bareOrigin: string, token: string | undefined): string {
const base = bareOrigin.endsWith('/') ? bareOrigin.slice(0, -1) : bareOrigin;
return token === undefined ? `${base}/` : `${base}/#token=${token}`;
}

/**
* Split a full URL into the part before `#token=` and the `#token=…` fragment
* itself, so callers can render the fragment in a de-emphasized color. Returns
* `[fullUrl, '']` when there is no token fragment.
*/
export function splitTokenFragment(fullUrl: string): [string, string] {
const marker = '#token=';
const idx = fullUrl.indexOf(marker);
return idx < 0 ? [fullUrl, ''] : [fullUrl.slice(0, idx), fullUrl.slice(idx)];
}

export interface AccessUrlLine {
/** Fixed-width label including trailing padding, e.g. `"Local: "`. */
label: string;
/** Full URL, carrying `#token=` when a token is known. */
url: string;
}

function isWildcard(host: string): boolean {
return host === '' || host === '0.0.0.0' || host === '::';
}

/** True when `host` is a loopback address (this host only). */
export function isLoopbackHost(host: string): boolean {
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
}

function hostOrigin(host: string, port: number): string {
const family = host.includes(':') ? 'IPv6' : 'IPv4';
return `http://${formatHostForUrl(host, family)}:${port}`;
}

/**
* Compute the access-URL lines for a bind host/port.
*
* - wildcard (`0.0.0.0` / `::` / empty): a `Local:` line (localhost) plus one
* `Network:` line per non-loopback interface.
* - loopback: a single `Local:` line.
* - specific host: a single `URL:` line.
*/
export function accessUrlLines(
host: string,
port: number,
token: string | undefined,
networkAddresses?: NetworkAddress[],
): AccessUrlLine[] {
if (isWildcard(host)) {
const lines: AccessUrlLine[] = [
{ label: 'Local: ', url: buildOpenableUrl(`http://localhost:${port}`, token) },
];
const addrs = networkAddresses ?? listNetworkAddresses();
for (const addr of addrs) {
lines.push({
label: 'Network: ',
url: buildOpenableUrl(`http://${formatHostForUrl(addr.address, addr.family)}:${port}`, token),
});
}
return lines;
}
if (isLoopbackHost(host)) {
return [{ label: 'Local: ', url: buildOpenableUrl(hostOrigin(host, port), token) }];
}
return [{ label: 'URL: ', url: buildOpenableUrl(hostOrigin(host, port), token) }];
}
113 changes: 104 additions & 9 deletions apps/kimi-code/src/cli/sub/server/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
* foreground runner so it can share the same bootstrap helpers.
*/

import { spawn } from 'node:child_process';
import { appendFileSync, closeSync, mkdirSync, openSync } from 'node:fs';
import { spawn, type ChildProcess } from 'node:child_process';
import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { createServer } from 'node:net';
import { dirname, isAbsolute, join, resolve } from 'node:path';
Expand All @@ -44,18 +44,32 @@ const POLL_INTERVAL_MS = 200;
const DEFAULT_DAEMON_LOG_LEVEL = 'info';

export interface EnsureDaemonOptions {
/** Bind host for the spawned daemon (default `127.0.0.1`). */
host?: string;
/** Preferred port; on conflict a free port is chosen automatically. */
port?: number;
/** Pino log level for the spawned daemon (defaults to `info`). */
logLevel?: string;
/** Mount `/api/v1/debug/*` routes on the spawned daemon. */
debugEndpoints?: boolean;
/** Allow a non-loopback bind without a TLS-terminating reverse proxy. */
insecureNoTls?: boolean;
/** Keep `POST /api/v1/shutdown` enabled on a non-loopback bind. */
allowRemoteShutdown?: boolean;
/** Keep the PTY `/api/v1/terminals/*` routes enabled on a non-loopback bind. */
allowRemoteTerminals?: boolean;
/** Idle-shutdown grace in ms for the spawned daemon (daemon mode only). */
idleGraceMs?: number;
}

export interface EnsureDaemonResult {
readonly origin: string;
/** True when an already-running daemon was reused (no new server started). */
readonly reused: boolean;
/** Bind host the running daemon is actually listening on (from the lock). */
readonly host: string;
/** Port the running daemon is actually listening on (from the lock). */
readonly port: number;
}

/** Path of the daemon log file (shared with the OS-service log location). */
Expand Down Expand Up @@ -178,13 +192,17 @@ export function resolveDaemonProgram(
}

interface SpawnDaemonChildOptions {
host?: string;
port: number;
logLevel: string;
debugEndpoints?: boolean;
insecureNoTls?: boolean;
allowRemoteShutdown?: boolean;
allowRemoteTerminals?: boolean;
idleGraceMs?: number;
}

export function spawnDaemonChild(options: SpawnDaemonChildOptions): void {
export function spawnDaemonChild(options: SpawnDaemonChildOptions): ChildProcess {
const program = resolveDaemonProgram();
const logPath = daemonLogPath();
const logDir = dirname(logPath);
Expand All @@ -198,9 +216,21 @@ export function spawnDaemonChild(options: SpawnDaemonChildOptions): void {
'--log-level',
options.logLevel,
];
if (options.host !== undefined) {
args.push('--host', options.host);
}
if (options.debugEndpoints === true) {
args.push('--debug-endpoints');
}
if (options.insecureNoTls === true) {
args.push('--insecure-no-tls');
}
if (options.allowRemoteShutdown === true) {
args.push('--allow-remote-shutdown');
}
if (options.allowRemoteTerminals === true) {
args.push('--allow-remote-terminals');
}
if (options.idleGraceMs !== undefined) {
args.push('--idle-grace-ms', String(options.idleGraceMs));
}
Expand Down Expand Up @@ -233,6 +263,7 @@ export function spawnDaemonChild(options: SpawnDaemonChildOptions): void {
}
});
child.unref();
return child;
} finally {
// `spawn` dups the fd into the child; the parent must not keep it open.
closeSync(logFd);
Expand All @@ -251,6 +282,7 @@ 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;

Expand All @@ -259,37 +291,100 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise<E
if (existing) {
const origin = serverOrigin(lockConnectHost(existing), existing.port);
if (await waitForServerHealthy(origin, REUSE_HEALTH_TIMEOUT_MS)) {
return { origin };
return {
origin,
reused: true,
host: existing.host ?? DEFAULT_SERVER_HOST,
port: existing.port,
};
}
// 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);
spawnDaemonChild({
const port = await resolveDaemonPort(host, preferred);
const child = spawnDaemonChild({
host,
port,
logLevel,
debugEndpoints: options.debugEndpoints,
insecureNoTls: options.insecureNoTls,
allowRemoteShutdown: options.allowRemoteShutdown,
allowRemoteTerminals: options.allowRemoteTerminals,
idleGraceMs: options.idleGraceMs,
});

// Watch for an early exit so a boot failure (e.g. the non-loopback TLS gate,
// a config error, or a lost lock race with no other daemon to fall back to)
// surfaces the real error immediately instead of waiting out the full spawn
// timeout. The exit code/signal plus a tail of the daemon log is what tells
// the operator *why* it failed.
let childExit: { code: number | null; signal: NodeJS.Signals | null } | undefined;
child.once('exit', (code, signal) => {
childExit = { code, signal };
});
child.once('error', () => {
// Spawn failure (ENOENT etc.) is already recorded in the log by
// spawnDaemonChild; treat it as an early exit here.
childExit = { code: -1, signal: null };
});

// 3. Wait until some live daemon (ours, or a racer that won the lock) is up.
const deadline = Date.now() + SPAWN_TIMEOUT_MS;
while (Date.now() < deadline) {
const live = getLiveLock();
if (live) {
const origin = serverOrigin(lockConnectHost(live), live.port);
if (await isServerHealthy(origin, 500)) {
return { origin };
return {
origin,
reused: false,
host: live.host ?? DEFAULT_SERVER_HOST,
port: live.port,
};
}
}
if (childExit !== undefined && !live) {
// Our child exited and no other live daemon holds the lock to fall back
// to — this is a real boot failure, not a lost race.
throw new Error(formatDaemonBootFailure(childExit, daemonLogPath()));
}
await sleep(POLL_INTERVAL_MS);
}

throw new Error(
`Kimi server daemon failed to start within ${String(SPAWN_TIMEOUT_MS)}ms. ` +
`Check the log for details: ${daemonLogPath()}`,
`Kimi server daemon failed to start within ${String(SPAWN_TIMEOUT_MS)}ms.\n\n` +
formatLogTail(daemonLogPath()),
);
}

function formatDaemonBootFailure(
exit: { code: number | null; signal: NodeJS.Signals | null },
logPath: string,
): string {
const reason =
exit.signal === null
? `exited with code ${String(exit.code)}`
: `was terminated by signal ${exit.signal}`;
return `Kimi server daemon ${reason} during startup.\n\n${formatLogTail(logPath)}`;
}

function formatLogTail(logPath: string): string {
const tail = tailFile(logPath, 30);
if (tail.length === 0) {
return `Check the log for details: ${logPath}`;
}
return `Last log lines (${logPath}):\n${tail}`;
}

function tailFile(filePath: string, maxLines: number): string {
try {
const content = readFileSync(filePath, 'utf8');
const lines = content.split('\n').filter((line) => line.length > 0);
return lines.slice(-maxLines).join('\n');
} catch {
return '';
}
}
3 changes: 3 additions & 0 deletions apps/kimi-code/src/cli/sub/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { Command } from 'commander';
import { registerPsCommand } from './ps';
import { registerKillCommand } from './kill';
import { buildRunCommand } from './run';
import { registerRotateTokenCommand } from './rotate-token';
import { registerWebAliasCommand } from './web-alias';

export function registerServerCommand(program: Command): void {
Expand All @@ -33,6 +34,8 @@ export function registerServerCommand(program: Command): void {

registerKillCommand(server);

registerRotateTokenCommand(server);

// OS service-manager commands (`install/uninstall/start/stop/restart/status`)
// are temporarily hidden — the product now favors the on-demand background
// daemon (`kimi web`) over service-ization. The implementation still lives in
Expand Down
22 changes: 17 additions & 5 deletions apps/kimi-code/src/cli/sub/server/kill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ import type { Command } from 'commander';

import { getLiveLock, type LockContents } from '@moonshot-ai/server';

import { getDataDir } from '#/utils/paths';

import { lockConnectHost } from './daemon';
import { serverOrigin } from './shared';
import { authHeaders, serverOrigin, tryResolveServerToken } from './shared';

/** How long to wait for the graceful API shutdown request. */
const API_TIMEOUT_MS = 2000;
Expand All @@ -32,7 +34,9 @@ const POLL_INTERVAL_MS = 100;

export interface KillCommandDeps {
getLiveLock(): LockContents | undefined;
requestShutdown(origin: string): Promise<void>;
requestShutdown(origin: string, token: string | undefined): Promise<void>;
/** Best-effort read of the persistent bearer token; undefined on miss. */
resolveToken(): string | undefined;
signalPid(pid: number, signal: NodeJS.Signals): boolean;
pidAlive(pid: number): boolean;
sleep(ms: number): Promise<void>;
Expand Down Expand Up @@ -66,8 +70,11 @@ export async function handleKillCommand(deps: KillCommandDeps): Promise<void> {

// 1. API path — best-effort graceful shutdown. Ignore every outcome: the
// server may be an older build without the route, already wedged, or may
// drop the connection as it exits.
await deps.requestShutdown(origin).catch(() => {});
// drop the connection as it exits. The bearer token (M5.1) is best-effort
// too: if it can't be read the API call 401s and the PID path below still
// guarantees the kill.
const token = deps.resolveToken();
await deps.requestShutdown(origin, token).catch(() => {});

// 2. PID path — SIGTERM, wait, then SIGKILL.
deps.signalPid(pid, 'SIGTERM');
Expand Down Expand Up @@ -126,14 +133,18 @@ export function signalPid(pid: number, signal: NodeJS.Signals): boolean {
}

/** POST the shutdown endpoint; resolves once the request completes or times out. */
export async function requestShutdownViaApi(origin: string): Promise<void> {
export async function requestShutdownViaApi(
origin: string,
token: string | undefined,
): Promise<void> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, API_TIMEOUT_MS);
try {
await fetch(`${origin}/api/v1/shutdown`, {
method: 'POST',
headers: token !== undefined ? authHeaders(token) : undefined,
signal: controller.signal,
});
} finally {
Expand All @@ -144,6 +155,7 @@ export async function requestShutdownViaApi(origin: string): Promise<void> {
const DEFAULT_KILL_DEPS: KillCommandDeps = {
getLiveLock,
requestShutdown: requestShutdownViaApi,
resolveToken: () => tryResolveServerToken(getDataDir()),
signalPid,
pidAlive,
sleep: (ms) =>
Expand Down
Loading
Loading