feat(server): add bearer-token auth and safe host exposure (#1006)

* test(server): add API surface snapshot guardrail

Boot startServer on port 0 and snapshot the documented v1 route table derived from /openapi.json paths, plus the reachability of doc/meta endpoints (/healthz, /openapi.json, /asyncapi.json, /). Gives later auth/--host phases an intentional diff when routes change. M0 makes no production behavior change.

* test(server): add e2e server harness with token support

Add test/helpers/serverHarness.ts: boot() wraps startServer with an isolated lock + home dir and returns a handle (server, address, baseUrl, wsUrl, token, close) plus authedFetch/authedWs that carry Authorization: Bearer <token> (and the kimi-code.bearer.<token> WS subprotocol). serviceOverrides is the generic DI seam later phases use to inject a fixed-token auth service; IAuthTokenService is not referenced yet. closeAll() tears down every booted server and socket. M0 makes no production behavior change; typecheck-only gate.

* feat(server): add privateFiles 0600 atomic write/read utility

* feat(server): add per-start tokenStore

* feat(server): add env-based bcrypt password hash utility

* feat(server): add IAuthTokenService DI seam

* feat(server): add global onRequest auth hook with bypass + redaction

* fix(server): stop reflecting Host header in /asyncapi.json

* feat(server): add WS bearer subprotocol constant and parser

* feat(server): enforce bearer token auth on WS upgrade

* feat(server): add Host header allowlist middleware

* feat(server): add Origin/CORS middleware

* feat(server): wire Host/Origin checks into HTTP and WS

* feat(server): wire token auth, Host/Origin, and WS auth into start.ts

* fix(server): create lock file with 0600 permissions

* fix(server): suppress debug routes on non-loopback binds

* feat(kimi-code): read server token and send Authorization on CLI calls

* feat(kimi-code): inject server token into /web URL fragment

* feat(server): add bindClassify for loopback/lan/public classification

* feat(kimi-code): register --host flag and pass it through the daemon

* feat(server): require password and TLS opt-out on non-loopback binds

* feat(server): rate-limit repeated auth failures on non-loopback binds

* feat(server): disable shutdown and terminals on public binds by default

* feat(server): add security response headers on non-loopback binds

* test(server): cover LAN/public host-exposure hardening end to end

* docs(server): add deployment security and threat-model guide

* changeset: minor kimi-code for server auth and host exposure

* feat(kimi-web): add server bearer-token auth support

* fix: repair CI for server auth and host exposure

- Replace native @node-rs/bcrypt with pure-JS bcryptjs so the ESM CLI
  bundle and the SEA native bundle both build without native-addon
  require issues (node-rs/bcrypt broke the ESM smoke and the SEA
  check-bundle allowlist).
- Remove dead cleanup references (stopSpinner, authLogoBlinkTimer) in
  apps/kimi-web App.vue that failed vue-tsc.
- Fix lint: drop empty spread fallbacks in the e2e auth-header merge,
  void the intentionally-async WS upgrade listener, add missing
  assertions to satisfy jest/expect-expect, and convert a ternary
  statement to if/else.
- Send the bearer token in the snapshot perf/smoke tests so they pass
  under the new global auth hook.
- Refresh the pnpmDeps hash in flake.nix for the updated lockfile.

* feat(server): persist bearer token and add rotate-token command

- persist the server bearer token in <home>/server.token (0600) and reuse it across restarts instead of per-start server-<pid>.token
- add `kimi server rotate-token` to regenerate the token; the token store reloads on mtime/inode change so rotation applies without restart
- print the token and Vite-style Local/Network URLs in the startup banner
- allow non-loopback binds with bearer-token-only auth (password now optional) and update SECURITY.md
- surface daemon boot failures immediately with the exit reason and log tail instead of waiting for the spawn timeout

* feat(server): print full token URLs and re-print links after rotate

- Drop the ready-panel border so token URLs print in full for copying; keep the Kimi sprite beside the title.
- Re-print Local/Network access links after `server rotate-token` (host/port from the lock).
- Extract shared access-URL helpers into access-urls.ts.
- Unify link and token colors between the banner and rotate-token.

* feat(server): dim URL #token= fragment and de-highlight token

- Render the `#token=…` fragment in a dim gray so the host/port stands out in the banner and rotate-token links.
- De-highlight the standalone token; set it off with surrounding whitespace instead of color.
- Add splitTokenFragment helper.

* refactor(cli): polish server ready banner and rotate-token output

- move version onto the ready banner title line; drop the separate
  Ready:/Version: rows and the startup-time metric
- reorder rotate-token output so the new token sits between the
  invalidation note and the access links
- update server CLI tests for the new layout

* feat(server): warn on reuse and refine ready banner

- Warn when `server run` reuses an already-running daemon (its options are not applied) and show the running server's actual URLs.
- Show a `Network: off  use --host 0.0.0.0 to enable` hint on loopback binds.
- Move the version onto the title line and drop the startup-time metric.

* fix(web): relabel auth dialog to token and cover full page

- Relabel the server auth dialog from "password" to "token"; the server accepts the bearer token, with the password only as a fallback.
- Make the auth dialog overlay fully opaque so it covers the whole page instead of revealing the login page underneath.

* fix: resolve CI failures on web auth PR

- Replace chalk.yellow named color with chalk.hex(darkColors.warning)
  in the server reuse notice to satisfy the chalk named color guard.
- Update pnpmDeps hash in flake.nix to match the regenerated
  pnpm-lock.yaml so the Nix build succeeds.
- Retry rmSync in ws-broadcast e2e teardown to ride out EBUSY /
  ENOTEMPTY races while the server flushes files after close().

* test(server): update API surface snapshot for warnings route

The feat/web-auth branch adds GET /api/v1/sessions/{session_id}/warnings
(packages/server/src/routes/sessions.ts), so the API surface guardrail
snapshot needs to record the new documented v1 route.
This commit is contained in:
Haozhe 2026-06-25 17:57:56 +08:00 committed by GitHub
parent d6e524682d
commit 60dfb68a2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
92 changed files with 6631 additions and 265 deletions

View file

@ -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`.

View file

@ -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) }];
}

View file

@ -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';
@ -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). */
@ -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);
@ -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));
}
@ -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);
@ -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;
@ -259,7 +291,12 @@ 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
@ -267,14 +304,33 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise<E
}
// 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) {
@ -282,14 +338,53 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise<E
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 '';
}
}

View file

@ -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 {
@ -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

View file

@ -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;
@ -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>;
@ -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');
@ -126,7 +133,10 @@ 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();
@ -134,6 +144,7 @@ export async function requestShutdownViaApi(origin: string): Promise<void> {
try {
await fetch(`${origin}/api/v1/shutdown`, {
method: 'POST',
headers: token !== undefined ? authHeaders(token) : undefined,
signal: controller.signal,
});
} finally {
@ -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) =>

View file

@ -0,0 +1,84 @@
/**
* Enumerate this machine's non-loopback network interface addresses, used to
* print `Network: http://<addr>:<port>/` hints (à la Vite) when the server
* binds a wildcard host (`0.0.0.0` / `::`).
*/
import { networkInterfaces } from 'node:os';
export interface NetworkAddress {
/** Raw IP address (IPv4 or IPv6); IPv6 is NOT bracket-wrapped here. */
address: string;
family: 'IPv4' | 'IPv6';
}
/**
* List non-internal interface addresses, IPv4 first then IPv6, preserving
* interface order within each family.
*
* Like Vite, this lists the machine's own interface addresses LAN
* (192.168/10/172.16) plus any directly-assigned public address. It does not
* (and cannot, without an external service) discover a NAT-translated WAN IP,
* and we deliberately avoid any network call for a startup hint.
*/
export function listNetworkAddresses(): NetworkAddress[] {
const raw: NetworkAddress[] = [];
for (const entries of Object.values(networkInterfaces())) {
for (const info of entries ?? []) {
if (info.internal) {
continue;
}
if (info.family === 'IPv4') {
raw.push({ address: info.address, family: 'IPv4' });
} else if (info.family === 'IPv6') {
raw.push({ address: info.address, family: 'IPv6' });
}
}
}
return filterDisplayAddresses(raw);
}
/**
* Drop addresses that are not useful as a connect target and de-duplicate.
*
* IPv6 link-local (`fe80::/10`) is filtered out: it is only reachable with a
* zone id (e.g. `fe80::1%en0`), which our bare URL cannot carry, so showing it
* is pure noise and it is the bulk of what `os.networkInterfaces()` reports
* on a typical machine. Duplicates (the same address reported on more than one
* interface) are collapsed. The result is IPv4 first, then IPv6, preserving
* order within each family.
*/
export function filterDisplayAddresses(
addrs: readonly NetworkAddress[],
): NetworkAddress[] {
const seen = new Set<string>();
const kept: NetworkAddress[] = [];
for (const addr of addrs) {
if (addr.family === 'IPv6' && isLinkLocalV6(addr.address)) {
continue;
}
if (seen.has(addr.address)) {
continue;
}
seen.add(addr.address);
kept.push(addr);
}
return [
...kept.filter((a) => a.family === 'IPv4'),
...kept.filter((a) => a.family === 'IPv6'),
];
}
/** True for IPv6 link-local addresses (`fe80::/10`, i.e. `fe80::``febf::`). */
function isLinkLocalV6(address: string): boolean {
const first = Number.parseInt(address.split(':')[0] ?? '', 16);
return first >= 0xfe80 && first <= 0xfebf;
}
/**
* Format an address for use as a URL host: bracket-wrap IPv6 per RFC 3986,
* return IPv4 as-is.
*/
export function formatHostForUrl(address: string, family: NetworkAddress['family']): string {
return family === 'IPv6' ? `[${address}]` : address;
}

View file

@ -11,8 +11,10 @@ import type { Command } from 'commander';
import { getLiveLock } from '@moonshot-ai/server';
import { getDataDir } from '#/utils/paths';
import { lockConnectHost } from './daemon';
import { isServerHealthy, serverOrigin } from './shared';
import { authHeaders, isServerHealthy, resolveServerToken, serverOrigin } from './shared';
/** Wire shape of a single connection returned by `GET /api/v1/connections`. */
interface ConnectionInfo {
@ -62,7 +64,11 @@ async function handlePsCommand(opts: { json?: boolean }): Promise<void> {
throw new Error(`Kimi server at ${origin} is not responding.`);
}
const connections = await fetchConnections(origin);
// The `/api/v1/connections` route is gated by bearer auth (M5.1). Read the
// persistent token; a clear error here means the server has never been
// started (no token file yet) or the token file was removed.
const token = resolveServerToken(getDataDir());
const connections = await fetchConnections(origin, token);
if (opts.json) {
process.stdout.write(`${JSON.stringify(connections, null, 2)}\n`);
@ -71,13 +77,14 @@ async function handlePsCommand(opts: { json?: boolean }): Promise<void> {
process.stdout.write(formatTable(connections));
}
async function fetchConnections(origin: string): Promise<ConnectionInfo[]> {
async function fetchConnections(origin: string, token: string): Promise<ConnectionInfo[]> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, FETCH_TIMEOUT_MS);
try {
const res = await fetch(`${origin}/api/v1/connections`, {
headers: authHeaders(token),
signal: controller.signal,
});
if (!res.ok) {

View file

@ -0,0 +1,57 @@
/**
* `kimi server rotate-token` generate a new persistent server token.
*
* Rewrites `<KIMI_CODE_HOME>/server.token` (0600, atomic). The previous token
* stops working immediately: a running server re-reads the file on its next
* auth check, so rotation takes effect without a restart.
*/
import { getLiveLock, rotateServerToken } from '@moonshot-ai/server';
import chalk from 'chalk';
import type { Command } from 'commander';
import { darkColors } from '#/tui/theme/colors';
import { getDataDir } from '#/utils/paths';
import { accessUrlLines, splitTokenFragment } from './access-urls';
import { DEFAULT_SERVER_HOST } from './shared';
export function registerRotateTokenCommand(server: Command): void {
server
.command('rotate-token')
.description(
'Generate a new persistent server token; the previous token stops working immediately.',
)
.action(async () => {
try {
const token = await rotateServerToken(getDataDir());
process.stdout.write(
'The previous token is now invalid. A running server picks up the new token automatically.\n',
);
// Token in the middle: indented and set off by blank lines (no color
// highlight), so it is easy to spot without dominating the output.
process.stdout.write(`\n ${chalk.bold('New server token:')} ${token}\n\n`);
// Re-print the access links with the new token so the user can
// reconnect immediately. When a server is running its bind host/port
// come from the lock; otherwise there is nothing to connect to yet.
const lock = getLiveLock();
if (lock !== undefined) {
const host = lock.host ?? DEFAULT_SERVER_HOST;
for (const { label, url: href } of accessUrlLines(host, lock.port, token)) {
// De-emphasize the `#token=…` fragment so the host/port stands out.
const [base, frag] = splitTokenFragment(href);
const rendered =
frag === ''
? chalk.hex(darkColors.accent)(base)
: chalk.hex(darkColors.accent)(base) + chalk.hex(darkColors.textDim)(frag);
process.stdout.write(` ${chalk.dim(label)}${rendered}\n`);
}
}
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
}
});
}

View file

@ -13,7 +13,6 @@
import { join } from 'node:path';
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry';
import { startServer, type RunningServer } from '@moonshot-ai/server';
import chalk from 'chalk';
@ -23,21 +22,30 @@ import { CLI_SHUTDOWN_TIMEOUT_MS, WEB_UI_MODE } from '#/constant/app';
import { getNativeWebAssetsDir } from '#/native/web-assets';
import { darkColors } from '#/tui/theme/colors';
import { openUrl as defaultOpenUrl } from '#/utils/open-url';
import { getDataDir } from '#/utils/paths';
import { initializeServerTelemetry } from '../../telemetry';
import { createKimiCodeHostIdentity, getHostPackageRoot, getVersion } from '../../version';
import { ensureDaemon } from './daemon';
import {
accessUrlLines,
buildOpenableUrl,
isLoopbackHost,
splitTokenFragment,
} from './access-urls';
import { ensureDaemon, type EnsureDaemonResult } from './daemon';
import { type NetworkAddress } from './networks';
import {
DEFAULT_FOREGROUND_LOG_LEVEL,
DEFAULT_SERVER_HOST,
DEFAULT_SERVER_PORT,
parseServerOptions,
tryResolveServerToken,
VALID_LOG_LEVELS,
type ParsedServerOptions,
type ServerCliOptions,
} from './shared';
const WEB_ASSETS_DIR = 'dist-web';
const READY_PANEL_WIDTH = 72;
export interface RunCliOptions extends ServerCliOptions {
open?: boolean;
@ -51,17 +59,49 @@ export interface StartForegroundHooks {
}
export interface RunCommandDeps {
startServerBackground(options: ParsedServerOptions): Promise<{ origin: string }>;
startServerBackground(options: ParsedServerOptions): Promise<{
origin: string;
/** True when an already-running daemon was reused (no new server started). */
reused?: boolean;
/** Bind host the running daemon is actually listening on (from the lock). */
host?: string;
/** Port the running daemon is actually listening on (from the lock). */
port?: number;
}>;
/** Foreground runner; defaults to the real in-process runner when omitted. */
startServerForeground?: (
options: ParsedServerOptions,
hooks?: StartForegroundHooks,
) => Promise<never>;
openUrl(url: string): void;
/**
* Best-effort read of the server's persistent bearer token. When it returns
* a token, the ready banner prints it and the opened Web UI URL carries it in
* the `#token=` fragment (M5.5). Optional so callers/tests that don't supply
* it simply print/open the plain origin.
*/
resolveToken?: () => string | undefined;
/**
* Non-loopback interface addresses to display for a wildcard bind. Defaults
* to the machine's own interfaces (`listNetworkAddresses()`); inject a fixed
* list in tests for deterministic output.
*/
networkAddresses?: NetworkAddress[];
stdout: Pick<NodeJS.WriteStream, 'write'>;
stderr: Pick<NodeJS.WriteStream, 'write'>;
}
/**
* Build the Web UI URL, carrying the bearer token in the URL fragment.
*
* The token rides in `#token=<token>` a client-side fragment that is never
* sent to the server (so it never appears in server access logs) and is not
* logged by proxies. The Web UI reads it from `location.hash` after load.
*/
export function buildWebUrl(origin: string, token: string): string {
return buildOpenableUrl(origin, token);
}
/** Build the `run` subcommand, mounted under a parent (`server` or top-level). */
export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }): Command {
return cmd
@ -70,6 +110,26 @@ export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean })
`Bind port (default ${DEFAULT_SERVER_PORT})`,
String(DEFAULT_SERVER_PORT),
)
.option(
'--host <host>',
`Bind host (default ${DEFAULT_SERVER_HOST}). Use 0.0.0.0 to listen on all interfaces (requires --insecure-no-tls unless behind a TLS proxy). The bearer token is printed at startup.`,
String(DEFAULT_SERVER_HOST),
)
.option(
'--insecure-no-tls',
'Allow a non-loopback bind without a TLS-terminating reverse proxy. Required to bind beyond 127.0.0.1; use a tunnel or reverse proxy in production.',
false,
)
.option(
'--allow-remote-shutdown',
'On a non-loopback bind, keep POST /api/v1/shutdown enabled (default: route is disabled → 404).',
false,
)
.option(
'--allow-remote-terminals',
'On a non-loopback bind, keep the PTY /api/v1/terminals/* routes enabled (default: disabled → 404). Remote shell is high risk.',
false,
)
.option(
'--log-level <level>',
`Server log level: ${VALID_LOG_LEVELS.join('|')}. Omit to keep logs off.`,
@ -119,34 +179,53 @@ export async function handleRunCommand(
await startServerDaemon(parsed);
return;
}
const startedAt = Date.now();
// Resolve the persistent token once: it is printed in the ready banner and
// rides in the opened Web UI URL's `#token=` fragment (M5.5). Falls back to
// the plain origin / no token line when unavailable.
const writeReady = (result: { origin: string; reused?: boolean; host?: string }): void => {
const { origin } = result;
const host = result.host ?? parsed.host;
const token = deps.resolveToken?.();
let output = '';
if (result.reused === true) {
// A daemon was already running, so this command's --host/--port/etc. did
// not start a new one. Say so loudly, then print the actual running
// server's URLs (using its real bind host, not the requested one).
output += formatReuseNotice(origin);
}
output +=
parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL
? formatReadyBanner(origin, host, {
token,
networkAddresses: deps.networkAddresses,
})
: formatReadyLine(origin, token);
deps.stdout.write(output);
if (opts.open === true) {
deps.openUrl(token !== undefined ? buildWebUrl(origin, token) : origin);
}
};
if (opts.foreground === true) {
const run = deps.startServerForeground ?? startServerForeground;
await run(parsed, {
onReady: (origin) => {
const readyMs = Date.now() - startedAt;
deps.stdout.write(
parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL
? formatReadyBanner(origin, readyMs)
: `Kimi server: ${origin}\n`,
);
if (opts.open === true) {
deps.openUrl(origin);
}
},
onReady: (origin) => writeReady({ origin, reused: false, host: parsed.host }),
});
return;
}
const { origin } = await deps.startServerBackground(parsed);
const readyMs = Date.now() - startedAt;
deps.stdout.write(
parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL
? formatReadyBanner(origin, readyMs)
: `Kimi server: ${origin}\n`,
const result = await deps.startServerBackground(parsed);
writeReady(result);
}
function formatReuseNotice(origin: string): string {
return (
`${chalk.hex(darkColors.warning)('A server is already running')} at ${origin}` +
`the options from this command were not applied. ` +
`Run ${chalk.bold('kimi server kill')} first to bind a new host/port.\n`
);
if (opts.open === true) {
deps.openUrl(origin);
}
}
function formatReadyLine(origin: string, token: string | undefined): string {
return `Kimi server: ${buildOpenableUrl(origin, token)}\n`;
}
/**
@ -157,14 +236,17 @@ export async function handleRunCommand(
*/
export async function startServerBackground(
options: ParsedServerOptions,
): Promise<{ origin: string }> {
const { origin } = await ensureDaemon({
): Promise<EnsureDaemonResult> {
return ensureDaemon({
host: options.host,
port: options.port,
logLevel: options.logLevel,
debugEndpoints: options.debugEndpoints,
insecureNoTls: options.insecureNoTls,
allowRemoteShutdown: options.allowRemoteShutdown,
allowRemoteTerminals: options.allowRemoteTerminals,
idleGraceMs: options.idleGraceMs,
});
return { origin };
}
/**
@ -238,6 +320,9 @@ async function runServerInProcess(
port: options.port,
logLevel: options.logLevel,
debugEndpoints: options.debugEndpoints,
insecureNoTls: options.insecureNoTls,
allowRemoteShutdown: options.allowRemoteShutdown,
allowRemoteTerminals: options.allowRemoteTerminals,
webAssetsDir: serverWebAssetsDir(),
coreProcessOptions: {
identity: createKimiCodeHostIdentity(version),
@ -323,65 +408,80 @@ export function resolveServerWebAssetsDir(
return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR);
}
function formatReadyBanner(origin: string, readyMs: number): string {
interface FormatReadyBannerOptions {
/** Persistent bearer token to print; omitted when unresolvable. */
token?: string;
/** Non-loopback interface addresses to list for a wildcard bind. */
networkAddresses?: NetworkAddress[];
}
function formatReadyBanner(
origin: string,
host: string,
opts: FormatReadyBannerOptions = {},
): 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);
const muted = (text: string): string => chalk.hex(darkColors.textMuted)(text);
const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text);
const url = chalk.hex(darkColors.accent)(displayOrigin(origin));
const width = READY_PANEL_WIDTH;
const innerWidth = width - 4;
const pad = ' ';
const url = (text: string): string => chalk.hex(darkColors.accent)(text);
// Render the `#token=…` fragment in a de-emphasized gray so the host/port
// stands out while the full URL stays selectable for copying.
const urlWithDimToken = (href: string): string => {
const [base, frag] = splitTokenFragment(href);
return frag === '' ? url(base) : url(base) + dim(frag);
};
const port = Number(new URL(origin).port);
// Borderless header: the Kimi sprite (the little mascot with eyes) sits next
// to the title, keeping the brand without the enclosing box.
const logo = ['▐█▛█▛█▌', '▐█████▌'] as const;
const logoWidth = Math.max(...logo.map((row) => visibleWidth(row)));
const gap = ' ';
const textWidth = innerWidth - logoWidth - gap.length;
const headerLines = [
primary(logo[0].padEnd(logoWidth)) +
gap +
truncateToWidth(title('Kimi server ready'), textWidth, '…'),
primary(logo[1].padEnd(logoWidth)) +
gap +
truncateToWidth(dim('Local web UI is available from this machine.'), textWidth, '…'),
];
const infoLines = [
label('URL: ') + url,
label('Network: ') + muted('local 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`),
label('Version: ') + muted(getVersion()),
];
const contentLines = [...headerLines, '', ...infoLines];
const lines = [
const lines: string[] = [
'',
` ${primary(logo[0])} ${title('Kimi server ready')} ${dim(getVersion())}`,
` ${primary(logo[1])} ${dim('Local web UI is available from this machine.')}`,
'',
primary('╭' + '─'.repeat(width - 2) + '╮'),
primary('│') + ' '.repeat(width - 2) + primary('│'),
];
for (const content of contentLines) {
const truncated = truncateToWidth(content, innerWidth, '…');
const rightPad = Math.max(0, innerWidth - visibleWidth(truncated));
lines.push(primary('│') + pad + truncated + ' '.repeat(rightPad) + primary('│'));
// Access links.
for (const { label: text, url: href } of accessUrlLines(
host,
port,
opts.token,
opts.networkAddresses,
)) {
lines.push(` ${label(text)}${urlWithDimToken(href)}`);
}
// On a loopback bind there is no network URL; show how to enable one.
if (isLoopbackHost(host)) {
lines.push(` ${label('Network: ')}${muted('off')}${dim(' use --host 0.0.0.0 to enable')}`);
}
if (opts.token !== undefined) {
// Set the token off with surrounding whitespace rather than color, so it is
// easy to spot without being highlighted.
lines.push('');
lines.push(` ${label('Token: ')}${opts.token}`);
lines.push('');
}
lines.push(primary('│') + ' '.repeat(width - 2) + primary('│'));
lines.push(primary('╰' + '─'.repeat(width - 2) + '╯'));
// Auxiliary controls last.
lines.push(` ${label('Logs: ')}${muted('off')}${dim(' use --log-level info to enable')}`);
lines.push(` ${label('Stop: ')}${muted('kimi server kill')}`);
lines.push('');
return lines.join('\n');
}
function displayOrigin(origin: string): string {
return origin.endsWith('/') ? origin : `${origin}/`;
}
const DEFAULT_RUN_COMMAND_DEPS: RunCommandDeps = {
startServerBackground,
startServerForeground,
openUrl: defaultOpenUrl,
resolveToken: () => {
// Read the persistent `<homeDir>/server.token` written on first boot
// (M5.1). Best-effort: a missing/older server yields undefined and the
// caller opens the plain origin.
return tryResolveServerToken(getDataDir());
},
stdout: process.stdout,
stderr: process.stderr,
};

View file

@ -5,12 +5,18 @@
* `run`, `web`, and `status` all use.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { ServerLogLevel } from '@moonshot-ai/server';
export const DEFAULT_SERVER_HOST = '127.0.0.1';
export const DEFAULT_SERVER_PORT = 58627;
export const DEFAULT_SERVER_ORIGIN = serverOrigin(DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT);
/** Filename (under KIMI_CODE_HOME) of the persistent server bearer token. */
export const SERVER_TOKEN_FILE = 'server.token';
export const DEFAULT_LOG_LEVEL: ServerLogLevel = 'info';
export const DEFAULT_FOREGROUND_LOG_LEVEL: ServerLogLevel = 'silent';
@ -36,6 +42,12 @@ export interface ParsedServerOptions {
port: number;
logLevel: ServerLogLevel;
debugEndpoints: boolean;
/** Allow a non-loopback bind without a TLS-terminating reverse proxy. */
insecureNoTls: boolean;
/** Allow `POST /api/v1/shutdown` on a non-loopback bind. */
allowRemoteShutdown: boolean;
/** Allow PTY `/api/v1/terminals/*` routes on a non-loopback bind. */
allowRemoteTerminals: boolean;
/** Internal: run as an idle-exiting background daemon instead of foreground. */
daemon: boolean;
/** Internal: idle-shutdown grace in ms (daemon mode only). */
@ -47,6 +59,12 @@ export interface ServerCliOptions {
port?: string;
logLevel?: string;
debugEndpoints?: boolean;
/** Allow a non-loopback bind without TLS (`--insecure-no-tls`). */
insecureNoTls?: boolean;
/** Allow remote shutdown on a non-loopback bind (`--allow-remote-shutdown`). */
allowRemoteShutdown?: boolean;
/** Allow remote terminals on a non-loopback bind (`--allow-remote-terminals`). */
allowRemoteTerminals?: boolean;
/** Internal flag set by the daemon spawner (`kimi web`). */
daemon?: boolean;
/** Internal flag set by the daemon spawner / tests. */
@ -59,6 +77,9 @@ export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions
port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT),
logLevel: parseLogLevel(opts.logLevel ?? DEFAULT_FOREGROUND_LOG_LEVEL),
debugEndpoints: opts.debugEndpoints === true,
insecureNoTls: opts.insecureNoTls === true,
allowRemoteShutdown: opts.allowRemoteShutdown === true,
allowRemoteTerminals: opts.allowRemoteTerminals === true,
daemon: opts.daemon === true,
idleGraceMs: parseIdleGraceMs(opts.idleGraceMs),
};
@ -174,3 +195,41 @@ export async function ensureServerWebReady(origin: string): Promise<void> {
clearTimeout(timeout);
}
}
/**
* Read the persistent bearer token for the server.
*
* The server writes `<homeDir>/server.token` (0600) on first boot and reuses
* it across restarts (ROADMAP M5.1); CLI commands that hit a gated REST route
* read it back here and send it as `Authorization: Bearer <token>`. `homeDir`
* is the CLI's own KIMI_CODE_HOME resolution (`getDataDir()`).
*
* Throws a clear error when the file is missing/unreadable the usual cause
* is a server that has never been started (no token file yet), or an older
* build that predates token auth.
*/
export function resolveServerToken(homeDir: string): string {
const tokenPath = join(homeDir, SERVER_TOKEN_FILE);
try {
return readFileSync(tokenPath, 'utf8').trim();
} catch (error) {
throw new Error(
`unable to read server token at ${tokenPath}; has the server been started at least once?`,
{ cause: error },
);
}
}
/** Best-effort token read: returns `undefined` instead of throwing. */
export function tryResolveServerToken(homeDir: string): string | undefined {
try {
return resolveServerToken(homeDir);
} catch {
return undefined;
}
}
/** An `Authorization: Bearer <token>` header bag for `fetch`. */
export function authHeaders(token: string): { Authorization: string } {
return { Authorization: `Bearer ${token}` };
}

View file

@ -9,7 +9,7 @@
*/
import type { ChildProcess } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { createServer, type Server } from 'node:net';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
@ -45,7 +45,7 @@ describe('kimi server', () => {
const server = program.commands.find((c) => c.name() === 'server');
expect(server).toBeDefined();
const subs = server?.commands.map((c) => c.name()).toSorted();
expect(subs).toEqual(['kill', 'ps', 'run']);
expect(subs).toEqual(['kill', 'ps', 'rotate-token', 'run']);
});
it('`server run` exposes local-only foreground options', () => {
@ -55,10 +55,13 @@ describe('kimi 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');
expect(longs).toContain('--insecure-no-tls');
expect(longs).toContain('--allow-remote-shutdown');
expect(longs).toContain('--allow-remote-terminals');
expect(longs).toContain('--foreground');
// run defaults to NOT opening the browser → option is the positive --open
expect(longs).toContain('--open');
@ -87,7 +90,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');
});
});
@ -333,6 +336,44 @@ describe('`kimi server run` background start', () => {
expect(parsed).toMatchObject({ logLevel: 'debug' });
});
it('warns and uses the running server host when a daemon is reused', async () => {
const { handleRunCommand } = await import('#/cli/sub/server/run');
let stdout = '';
// The user asks for a public bind, but a loopback daemon is already up.
await handleRunCommand(
{ port: '58627', host: '0.0.0.0' },
{
startServerBackground: async () => ({
origin: 'http://127.0.0.1:58627',
reused: true,
host: '127.0.0.1',
port: 58627,
}),
resolveToken: () => 'tok',
openUrl: vi.fn(),
stdout: {
write(chunk: string | Uint8Array) {
stdout += String(chunk);
return true;
},
},
stderr: { write: () => true },
},
);
const plain = stripAnsi(stdout);
// A clear notice that a server was already running and options were ignored.
expect(plain).toContain('A server is already running');
expect(plain).toContain('kimi server kill');
// The banner uses the *actual* host (loopback), not the requested 0.0.0.0 —
// so it shows a Local URL plus the "network disabled" hint, NOT real
// Network addresses (which would be misleading since nothing binds them).
expect(plain).toContain('http://127.0.0.1:58627/#token=tok');
expect(plain).toContain('use --host 0.0.0.0 to enable');
expect(plain).not.toContain('Network: http');
});
it('prints a TUI-style ready panel once the daemon is up', async () => {
const { handleRunCommand } = await import('#/cli/sub/server/run');
let stdout = '';
@ -357,21 +398,33 @@ describe('`kimi server run` background start', () => {
);
const plain = stripAnsi(stdout);
expect(plain).toContain('╭');
expect(plain).toContain('╰');
expect(plain).toContain('▐█▛█▛█▌');
expect(plain).toContain('▐█████▌');
expect(plain).toContain('Kimi server ready');
expect(plain).toContain('URL:');
expect(plain).toContain('Local:');
expect(plain).toContain('http://127.0.0.1:58627/');
// Loopback bind shows a Network hint for enabling network access.
expect(plain).toContain('Network:');
expect(plain).toContain('local only');
expect(plain).toContain('use --host 0.0.0.0 to enable');
expect(plain).toContain('Logs:');
expect(plain).toContain('off');
expect(plain).toContain('Stop:');
expect(plain).toContain('kimi server kill');
// Version sits on the title line; no separate Ready:/Version: rows and no
// startup-time metric.
expect(plain).not.toContain('Ready:');
expect(plain).not.toContain('Version:');
expect(plain).not.toContain(' ms');
// No bordered panel (the token URL must print in full for copying), but
// the Kimi sprite stays next to the title.
expect(plain).not.toContain('╭');
expect(plain).not.toContain('╰');
expect(plain).toContain('▐█▛█▛█▌');
expect(plain).toContain('▐█████▌');
expect(plain).not.toContain('➜');
expect(plain).not.toContain('Kimi server:');
// Title is above the URLs; Logs/Stop are at the bottom.
expect(plain.indexOf('Kimi server ready')).toBeLessThan(plain.indexOf('Local:'));
expect(plain.indexOf('Logs:')).toBeLessThan(plain.indexOf('Stop:'));
});
it('uses the TUI dark palette for the ready banner', async () => {
@ -407,8 +460,8 @@ describe('`kimi server run` background start', () => {
expect(stdout).toContain(color.hex(darkColors.primary)('▐█▛█▛█▌'));
expect(stdout).toContain(color.bold.hex(darkColors.primary)('Kimi server ready'));
expect(stdout).toContain(color.hex(darkColors.accent)('http://127.0.0.1:58627/'));
expect(stdout).toContain(color.bold.hex(darkColors.textDim)('URL: '));
expect(stdout).toContain(color.hex(darkColors.textMuted)('local only'));
expect(stdout).toContain(color.bold.hex(darkColors.textDim)('Local: '));
expect(stdout).toContain(color.hex(darkColors.textMuted)('off'));
});
});
@ -564,6 +617,190 @@ async function allocateAdjacentFreeRun(count: number, host = '127.0.0.1'): Promi
throw new Error('could not allocate a run of adjacent free ports');
}
describe('--host threading (M6.2)', () => {
it('passes --host through to the background daemon', async () => {
const { handleRunCommand } = await import('#/cli/sub/server/run');
let parsed: unknown;
await handleRunCommand(
{ port: '58627', host: '0.0.0.0' },
{
startServerBackground: async (options) => {
parsed = options;
return { origin: 'http://0.0.0.0:58627' };
},
openUrl: vi.fn(),
stdout: { write: () => true },
stderr: { write: () => true },
},
);
expect(parsed).toMatchObject({ host: '0.0.0.0', port: 58627 });
});
it('passes --host through to the foreground runner', async () => {
const { handleRunCommand } = await import('#/cli/sub/server/run');
let foregroundOptions: unknown;
await handleRunCommand(
{ port: '58627', host: '0.0.0.0', foreground: true },
{
startServerBackground: async () => ({ origin: 'http://0.0.0.0:58627' }),
startServerForeground: async (options) => {
foregroundOptions = options;
return undefined as unknown as never;
},
openUrl: vi.fn(),
stdout: { write: () => true },
stderr: { write: () => true },
},
);
expect(foregroundOptions).toMatchObject({ host: '0.0.0.0' });
});
});
describe('lockConnectHost (M6.2 connect side)', () => {
it('maps a 0.0.0.0 bind to 127.0.0.1 so the CLI connects over loopback', async () => {
const { lockConnectHost } = await import('#/cli/sub/server/daemon');
// The daemon binds 0.0.0.0 (all interfaces), but the local CLI must
// connect over loopback — 0.0.0.0 is not a connectable address. The token
// then rides on that loopback connection (covered by the M5.4 kill/ps
// Authorization tests).
expect(lockConnectHost({ pid: 1, started_at: '', port: 58627, host: '0.0.0.0' })).toBe(
'127.0.0.1',
);
});
it('preserves a loopback / concrete bind host', async () => {
const { lockConnectHost } = await import('#/cli/sub/server/daemon');
expect(lockConnectHost({ pid: 1, started_at: '', port: 58627, host: '127.0.0.1' })).toBe(
'127.0.0.1',
);
expect(lockConnectHost({ pid: 1, started_at: '', port: 58627, host: '192.168.1.5' })).toBe(
'192.168.1.5',
);
});
it('falls back to 127.0.0.1 when the lock has no host', async () => {
const { lockConnectHost } = await import('#/cli/sub/server/daemon');
expect(lockConnectHost({ pid: 1, started_at: '', port: 58627 })).toBe('127.0.0.1');
});
});
describe('--insecure-no-tls threading (M6.3)', () => {
it('threads --insecure-no-tls to the foreground runner', async () => {
const { handleRunCommand } = await import('#/cli/sub/server/run');
let foregroundOptions: unknown;
await handleRunCommand(
{ host: '0.0.0.0', insecureNoTls: true, foreground: true },
{
startServerBackground: async () => ({ origin: 'http://0.0.0.0:58627' }),
startServerForeground: async (options) => {
foregroundOptions = options;
return undefined as unknown as never;
},
openUrl: vi.fn(),
stdout: { write: () => true },
stderr: { write: () => true },
},
);
expect(foregroundOptions).toMatchObject({ host: '0.0.0.0', insecureNoTls: true });
});
it('threads --insecure-no-tls to the background daemon', async () => {
const { handleRunCommand } = await import('#/cli/sub/server/run');
let parsed: unknown;
await handleRunCommand(
{ host: '0.0.0.0', insecureNoTls: true },
{
startServerBackground: async (options) => {
parsed = options;
return { origin: 'http://0.0.0.0:58627' };
},
openUrl: vi.fn(),
stdout: { write: () => true },
stderr: { write: () => true },
},
);
expect(parsed).toMatchObject({ insecureNoTls: true });
});
});
describe('ready banner reflects the bind class (M6.3)', () => {
it('lists Local + Network addresses for a 0.0.0.0 bind (Vite-style)', async () => {
const { handleRunCommand } = await import('#/cli/sub/server/run');
let stdout = '';
await handleRunCommand(
{ host: '0.0.0.0', insecureNoTls: true },
{
startServerBackground: async () => ({ origin: 'http://0.0.0.0:58627' }),
resolveToken: () => 'tok-xyz',
networkAddresses: [
{ address: '192.168.98.66', family: 'IPv4' },
{ address: '10.8.12.216', family: 'IPv4' },
],
openUrl: vi.fn(),
stdout: {
write(chunk: string | Uint8Array) {
stdout += String(chunk);
return true;
},
},
stderr: { write: () => true },
},
);
const raw = stripAnsi(stdout);
expect(raw).toContain('Kimi server ready');
expect(raw).toContain('Local:');
expect(raw).toContain('Network:');
// Full token-bearing URLs are printed plainly (no box, no truncation) so
// they are easy to copy.
expect(raw).toContain('http://localhost:58627/#token=tok-xyz');
expect(raw).toContain('http://192.168.98.66:58627/#token=tok-xyz');
expect(raw).toContain('http://10.8.12.216:58627/#token=tok-xyz');
expect(raw).toContain('Token:');
expect(raw).toContain('tok-xyz');
expect(raw).not.toContain('╭');
});
it('prints the Local URL and token for a 127.0.0.1 bind', async () => {
const { handleRunCommand } = await import('#/cli/sub/server/run');
let stdout = '';
await handleRunCommand(
{ host: '127.0.0.1' },
{
startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }),
resolveToken: () => 'tok-loop',
openUrl: vi.fn(),
stdout: {
write(chunk: string | Uint8Array) {
stdout += String(chunk);
return true;
},
},
stderr: { write: () => true },
},
);
const raw = stripAnsi(stdout);
expect(raw).toContain('Kimi server ready');
expect(raw).toContain('Local:');
// Full token-bearing URL, printed plainly for copying.
expect(raw).toContain('http://127.0.0.1:58627/#token=tok-loop');
expect(raw).toContain('Token:');
expect(raw).toContain('tok-loop');
expect(raw).not.toContain('╭');
});
});
describe('resolveDaemonPort', () => {
it('returns the preferred port when it is free', async () => {
const { resolveDaemonPort } = await import('#/cli/sub/server/daemon');
@ -673,6 +910,94 @@ describe('spawnDaemonChild', () => {
expect(options).toMatchObject({ detached: true, cwd: dirname(daemonLogPath()) });
expect(options?.cwd).not.toBe(process.cwd());
});
it('passes --host through to the daemon child args (M6.2)', async () => {
const { spawn } = await import('node:child_process');
const spawnMock = vi.mocked(spawn);
spawnMock.mockClear();
spawnMock.mockReturnValue({ unref: vi.fn(), once: vi.fn() } as unknown as ChildProcess);
const { spawnDaemonChild } = await import('#/cli/sub/server/daemon');
spawnDaemonChild({ host: '0.0.0.0', port: 58627, logLevel: 'info' });
const [, args] = spawnMock.mock.calls[0]!;
expect(args).toEqual(expect.arrayContaining(['--host', '0.0.0.0']));
});
it('passes --insecure-no-tls through to the daemon child args (M6.3)', async () => {
const { spawn } = await import('node:child_process');
const spawnMock = vi.mocked(spawn);
spawnMock.mockClear();
spawnMock.mockReturnValue({ unref: vi.fn(), once: vi.fn() } as unknown as ChildProcess);
const { spawnDaemonChild } = await import('#/cli/sub/server/daemon');
spawnDaemonChild({ host: '0.0.0.0', port: 58627, logLevel: 'info', insecureNoTls: true });
const [, args] = spawnMock.mock.calls[0]!;
expect(args).toEqual(expect.arrayContaining(['--insecure-no-tls']));
});
});
describe('ensureDaemon surfaces boot failures via early exit', () => {
let workDir: string;
let prevHome: string | undefined;
beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), 'kimi-ensure-exit-'));
prevHome = process.env['KIMI_CODE_HOME'];
process.env['KIMI_CODE_HOME'] = workDir;
vi.resetModules();
});
afterEach(() => {
if (prevHome === undefined) {
delete process.env['KIMI_CODE_HOME'];
} else {
process.env['KIMI_CODE_HOME'] = prevHome;
}
rmSync(workDir, { recursive: true, force: true });
});
it('rejects fast with the exit reason and a log tail when the daemon exits early', async () => {
const { spawn } = await import('node:child_process');
const spawnMock = vi.mocked(spawn);
const { mkdirSync, writeFileSync: writeSync } = await import('node:fs');
const { daemonLogPath, ensureDaemon } = await import('#/cli/sub/server/daemon');
// Seed the daemon log with the kind of line a failing boot writes, so we
// can assert it is surfaced to the user instead of a generic timeout.
mkdirSync(dirname(daemonLogPath()), { recursive: true });
writeSync(daemonLogPath(), 'fatal: Refusing to bind a non-loopback host without TLS.\n');
// Fake child that exits with code 1 shortly after the 'exit' listener is
// attached — simulating a daemon that fails during boot.
const fakeChild = {
unref: vi.fn(),
once: vi.fn((event: string, cb: (...a: unknown[]) => void) => {
if (event === 'exit') {
setTimeout(() => cb(1, null), 5);
}
return fakeChild;
}),
};
spawnMock.mockReturnValueOnce(fakeChild as unknown as ChildProcess);
const start = Date.now();
let caught: unknown;
try {
await ensureDaemon({ port: 0 });
} catch (error) {
caught = error;
}
const elapsed = Date.now() - start;
expect(caught).toBeInstanceOf(Error);
const message = (caught as Error).message;
expect(message).toMatch(/exited with code 1/);
expect(message).toContain('Refusing to bind');
// Must fail fast — nowhere near the 20s spawn timeout.
expect(elapsed).toBeLessThan(5000);
});
});
describe('createIdleShutdownHandler', () => {
@ -809,6 +1134,7 @@ function makeKillDeps(overrides: Partial<KillCommandDeps> = {}): {
requestShutdown: async () => {
state.shutdownCalls += 1;
},
resolveToken: () => undefined,
signalPid: (pid, signal) => {
signals.push({ pid, signal });
return true;
@ -883,5 +1209,271 @@ describe('`kimi server kill`', () => {
});
});
describe('resolveServerToken', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'kimi-server-token-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('reads the token from <homeDir>/server.token', async () => {
const { resolveServerToken } = await import('#/cli/sub/server/shared');
writeFileSync(join(dir, 'server.token'), 'secret-token\n');
expect(resolveServerToken(dir)).toBe('secret-token');
});
it('trims surrounding whitespace', async () => {
const { resolveServerToken } = await import('#/cli/sub/server/shared');
writeFileSync(join(dir, 'server.token'), ' tok \n');
expect(resolveServerToken(dir)).toBe('tok');
});
it('throws a clear error when the token file is missing', async () => {
const { resolveServerToken } = await import('#/cli/sub/server/shared');
expect(() => resolveServerToken(dir)).toThrow(/unable to read server token/);
});
});
describe('authHeaders', () => {
it('builds a Bearer Authorization header', async () => {
const { authHeaders } = await import('#/cli/sub/server/shared');
expect(authHeaders('abc')).toEqual({ Authorization: 'Bearer abc' });
});
});
describe('`kimi server kill` carries the bearer token', () => {
const liveLock = { pid: 1234, started_at: '2026-06-17T00:00:00.000Z', port: 58627 };
it('passes the resolved token to requestShutdown', async () => {
const { handleKillCommand } = await import('#/cli/sub/server/kill');
let seenToken: string | undefined = 'unset';
const { deps } = makeKillDeps({
getLiveLock: () => liveLock,
resolveToken: () => 'tok-123',
requestShutdown: async (_origin, token) => {
seenToken = token;
},
pidAlive: () => false,
});
await handleKillCommand(deps);
expect(seenToken).toBe('tok-123');
});
it('passes undefined when the token cannot be read (best-effort)', async () => {
const { handleKillCommand } = await import('#/cli/sub/server/kill');
let seenToken: string | undefined = 'unset';
const { deps } = makeKillDeps({
getLiveLock: () => liveLock,
resolveToken: () => undefined,
requestShutdown: async (_origin, token) => {
seenToken = token;
},
pidAlive: () => false,
});
await handleKillCommand(deps);
expect(seenToken).toBeUndefined();
});
});
describe('buildWebUrl', () => {
it('carries the token in the URL fragment (not path or query)', async () => {
const { buildWebUrl } = await import('#/cli/sub/server/run');
const url = buildWebUrl('http://127.0.0.1:58627', 'abc123');
expect(url).toBe('http://127.0.0.1:58627/#token=abc123');
const parsed = new URL(url);
expect(parsed.hash).toBe('#token=abc123');
// The token is client-side only: it must NOT appear in the path or query
// (which WOULD be sent to the server and logged).
expect(parsed.pathname).not.toContain('abc123');
expect(parsed.search).not.toContain('abc123');
});
it('normalizes a trailing slash', async () => {
const { buildWebUrl } = await import('#/cli/sub/server/run');
expect(buildWebUrl('http://127.0.0.1:58627/', 't')).toBe(
'http://127.0.0.1:58627/#token=t',
);
});
});
describe('accessUrlLines', () => {
it('returns Local + Network lines for a wildcard bind', async () => {
const { accessUrlLines } = await import('#/cli/sub/server/access-urls');
const lines = accessUrlLines('0.0.0.0', 58627, 'tok', [
{ address: '192.168.1.5', family: 'IPv4' },
]);
expect(lines).toEqual([
{ label: 'Local: ', url: 'http://localhost:58627/#token=tok' },
{ label: 'Network: ', url: 'http://192.168.1.5:58627/#token=tok' },
]);
});
it('returns a single Local line for a loopback bind', async () => {
const { accessUrlLines } = await import('#/cli/sub/server/access-urls');
const lines = accessUrlLines('127.0.0.1', 58627, 'tok');
expect(lines).toEqual([
{ label: 'Local: ', url: 'http://127.0.0.1:58627/#token=tok' },
]);
});
it('returns a single URL line for a specific host (no token)', async () => {
const { accessUrlLines } = await import('#/cli/sub/server/access-urls');
const lines = accessUrlLines('192.168.1.5', 58627, undefined);
expect(lines).toEqual([{ label: 'URL: ', url: 'http://192.168.1.5:58627/' }]);
});
it('splitTokenFragment splits off the #token= fragment', async () => {
const { splitTokenFragment } = await import('#/cli/sub/server/access-urls');
expect(splitTokenFragment('http://h:1/#token=abc')).toEqual(['http://h:1/', '#token=abc']);
expect(splitTokenFragment('http://h:1/')).toEqual(['http://h:1/', '']);
});
});
describe('`kimi web` / `server run --open` token fragment (M5.5)', () => {
it('opens the Web UI URL with the token fragment when a token is resolvable', async () => {
const { handleRunCommand } = await import('#/cli/sub/server/run');
const openUrl = vi.fn();
await handleRunCommand(
{ port: '58627', open: true },
{
startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }),
resolveToken: () => 'tok-xyz',
openUrl,
stdout: { write: () => true },
stderr: { write: () => true },
},
);
expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/#token=tok-xyz');
});
it('opens the plain origin when no token is resolvable', async () => {
const { handleRunCommand } = await import('#/cli/sub/server/run');
const openUrl = vi.fn();
await handleRunCommand(
{ port: '58627', open: true },
{
startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }),
resolveToken: () => undefined,
openUrl,
stdout: { write: () => true },
stderr: { write: () => true },
},
);
expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627');
});
});
describe('`kimi server rotate-token`', () => {
let dir: string;
let prevHome: string | undefined;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'kimi-rotate-'));
prevHome = process.env['KIMI_CODE_HOME'];
process.env['KIMI_CODE_HOME'] = dir;
vi.resetModules();
});
afterEach(() => {
if (prevHome === undefined) {
delete process.env['KIMI_CODE_HOME'];
} else {
process.env['KIMI_CODE_HOME'] = prevHome;
}
rmSync(dir, { recursive: true, force: true });
});
it('writes a new token to server.token and prints it', async () => {
const { registerServerCommand } = await import('#/cli/sub/server');
const program = new Command('kimi').exitOverride();
registerServerCommand(program);
let stdout = '';
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
stdout += String(chunk);
return true;
});
await program.parseAsync(['node', 'kimi', 'server', 'rotate-token']);
writeSpy.mockRestore();
const token = readFileSync(join(dir, 'server.token'), 'utf8').trim();
expect(token.length).toBeGreaterThan(20);
expect(stdout).toContain('New server token');
expect(stdout).toContain(token);
});
it('re-prints the access links with the new token when a server is running', async () => {
const { registerServerCommand } = await import('#/cli/sub/server');
const { mkdirSync, writeFileSync: writeSync } = await import('node:fs');
// Fake a live lock pointing at this (alive) process so getLiveLock() finds
// the running server and the command can re-print its links.
mkdirSync(join(dir, 'server'), { recursive: true });
writeSync(
join(dir, 'server', 'lock'),
JSON.stringify({
pid: process.pid,
started_at: new Date().toISOString(),
port: 58627,
host: '127.0.0.1',
}),
);
const program = new Command('kimi').exitOverride();
registerServerCommand(program);
let stdout = '';
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
stdout += String(chunk);
return true;
});
await program.parseAsync(['node', 'kimi', 'server', 'rotate-token']);
writeSpy.mockRestore();
const token = readFileSync(join(dir, 'server.token'), 'utf8').trim();
expect(stdout).toContain('New server token');
expect(stdout).toContain(`http://127.0.0.1:58627/#token=${token}`);
// Token line sits between the note and the links.
expect(stdout.indexOf('picks up the new token')).toBeLessThan(
stdout.indexOf('New server token'),
);
expect(stdout.indexOf('New server token')).toBeLessThan(
stdout.indexOf(`http://127.0.0.1:58627/#token=${token}`),
);
});
});
describe('formatHostForUrl', () => {
it('bracket-wraps IPv6 and leaves IPv4 as-is', async () => {
const { formatHostForUrl } = await import('#/cli/sub/server/networks');
expect(formatHostForUrl('192.168.1.5', 'IPv4')).toBe('192.168.1.5');
expect(formatHostForUrl('fe80::1', 'IPv6')).toBe('[fe80::1]');
});
});
describe('filterDisplayAddresses', () => {
it('drops IPv6 link-local, de-duplicates, and orders IPv4 before IPv6', async () => {
const { filterDisplayAddresses } = await import('#/cli/sub/server/networks');
const out = filterDisplayAddresses([
{ address: 'fe80::ecf3:c2ff:fe9c:11c3', family: 'IPv6' },
{ address: '192.168.1.5', family: 'IPv4' },
{ address: 'fe80::ecf3:c2ff:fe9c:11c3', family: 'IPv6' },
{ address: '10.0.0.1', family: 'IPv4' },
{ address: 'fe80::1', family: 'IPv6' },
{ address: '2001:db8::1', family: 'IPv6' },
]);
expect(out).toEqual([
{ address: '192.168.1.5', family: 'IPv4' },
{ address: '10.0.0.1', family: 'IPv4' },
{ address: '2001:db8::1', family: 'IPv6' },
]);
});
});
// Silence vi import for cases where the file is built before tests reference vi.
void vi;

View file

@ -33,8 +33,16 @@ import { useSidebarLayout } from './composables/useSidebarLayout';
import { useFilePreview, type DetailTarget } from './composables/useFilePreview';
import { useDetailPanel } from './composables/useDetailPanel';
import { useIsMobile } from './composables/useIsMobile';
import ServerAuthDialog from './components/ServerAuthDialog.vue';
import { initServerAuth, onAuthRequired } from './api/daemon/serverAuth';
import type { AppConfig, ThinkingLevel } from './api/types';
// Hydrate the server-transport credential (fragment token or sessionStorage)
// BEFORE the client connects, so the first REST/WS calls already carry it.
const hasServerCredential = initServerAuth();
const showServerAuth = ref(!hasServerCredential);
let offAuthRequired: (() => void) | null = null;
const client = useKimiWebClient();
provide('resolveImage', client.resolveImageUrl);
const { t } = useI18n();
@ -98,10 +106,17 @@ onMounted(() => {
// Capture-phase so Escape closes the side detail layer BEFORE the
// conversation pane's bubble-phase handler interrupts a running prompt.
document.addEventListener('keydown', onGlobalKeydown, true);
offAuthRequired = onAuthRequired(() => {
showServerAuth.value = true;
});
});
onUnmounted(() => {
document.removeEventListener('keydown', onGlobalKeydown, true);
if (offAuthRequired !== null) {
offAuthRequired();
offAuthRequired = null;
}
});
function onGlobalKeydown(e: KeyboardEvent): void {
@ -504,6 +519,7 @@ function openPr(url: string): void {
<template>
<div class="app-shell">
<ServerAuthDialog v-if="showServerAuth" />
<section v-if="showAuthGate" class="auth-page">
<div class="auth-page-inner">
<svg ref="authLogoRef" class="auth-page-logo ch-logo" viewBox="0 0 32 22" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Kimi Code" @mousedown.prevent @click="blinkAuthLogo">

View file

@ -4,6 +4,7 @@
import { buildRestUrl } from '../config';
import { DaemonApiError, DaemonNetworkError } from '../errors';
import { traceRestFailure, traceRestRequest, traceRestResponse } from '../../debug/trace';
import { getCredential, markAuthRequired } from './serverAuth';
import type { WireEnvelope } from './wire';
/** Per-request timeout. Without one, a hung connection (half-open TCP after a
@ -14,6 +15,10 @@ const REQUEST_TIMEOUT_MS = 30_000;
const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
const BODY_PREVIEW_LIMIT = 500;
// Server-transport auth failure envelope code (see packages/server
// middleware/auth.ts AUTH_ERROR_CODE). Distinct from provider-auth 4011040113.
const SERVER_AUTH_UNAUTHORIZED_CODE = 40101;
export interface DaemonHttpClientIdentity {
readonly clientId: string;
readonly clientName: string;
@ -155,6 +160,7 @@ export class DaemonHttpClient {
envelopeRequestId: envelope.request_id,
data: envelope.data,
});
this.checkAuthRequired(response, envelope.code);
if (envelope.code !== 0) {
throw new DaemonApiError({
code: envelope.code,
@ -265,6 +271,8 @@ export class DaemonHttpClient {
data: envelope.data,
});
this.checkAuthRequired(response, envelope.code);
// Unwrap: code 0 = success; allowed non-zero = return data; else throw
if (envelope.code !== 0 && !allowCodes.includes(envelope.code)) {
throw new DaemonApiError({
@ -281,10 +289,23 @@ export class DaemonHttpClient {
}
private addClientHeaders(headers: Record<string, string>): void {
const credential = getCredential();
if (credential !== undefined) {
headers['Authorization'] = `Bearer ${credential}`;
}
if (this.identity === undefined) return;
headers['X-Kimi-Client-Id'] = this.identity.clientId;
headers['X-Kimi-Client-Name'] = this.identity.clientName;
headers['X-Kimi-Client-Version'] = this.identity.clientVersion;
headers['X-Kimi-Client-Ui-Mode'] = this.identity.clientUiMode;
}
private checkAuthRequired(response: Response, envelopeCode: number): void {
if (
response.status === 401 ||
envelopeCode === SERVER_AUTH_UNAUTHORIZED_CODE
) {
markAuthRequired();
}
}
}

View file

@ -0,0 +1,116 @@
// apps/kimi-web/src/api/daemon/serverAuth.ts
// Minimal server-transport credential store for the Web UI.
//
// The local server now requires a bearer credential on every non-bypass API
// and WebSocket call (the persistent server token, or the KIMI_CODE_PASSWORD
// password). The Web UI obtains that credential in one of two ways:
// 1. From the URL fragment (`#token=<...>`) that `kimi web` appends when it
// opens the browser — read once at boot, then scrubbed from the URL so it
// does not linger in history or screenshots.
// 2. From a token the user types into the ServerAuthDialog modal.
//
// The credential is held in memory and mirrored to sessionStorage so a page
// refresh keeps working without re-prompting (sessionStorage is tab-scoped and
// cleared when the tab closes — we deliberately do NOT use localStorage, since
// the credential authenticates as the server).
const STORAGE_KEY = 'kimi-web.server-credential';
const FRAGMENT_PARAM = 'token';
let memory: string | undefined;
type AuthRequiredListener = () => void;
const listeners = new Set<AuthRequiredListener>();
function readFragmentToken(): string | undefined {
if (typeof window === 'undefined') return undefined;
const hash = window.location.hash ?? '';
if (!hash.startsWith('#')) return undefined;
const params = new URLSearchParams(hash.slice(1));
const token = params.get(FRAGMENT_PARAM);
if (!token) return undefined;
// Scrub the fragment (keep path + query) so the token is not left in the
// address bar, browser history, or any screenshot of the window.
const url = new URL(window.location.href);
url.hash = '';
window.history.replaceState(
window.history.state,
'',
`${url.pathname}${url.search}`,
);
return token;
}
function loadStored(): string | undefined {
try {
return globalThis.sessionStorage?.getItem(STORAGE_KEY) ?? undefined;
} catch {
return undefined;
}
}
/**
* Initialize the credential store. Call once at app boot (before the first
* API/WS call). Prefers a fragment token over a stored one. Returns true if a
* credential is available afterwards (so the caller can skip the modal).
*/
export function initServerAuth(): boolean {
const fragment = readFragmentToken();
if (fragment) {
setCredential(fragment);
return true;
}
memory = loadStored();
return memory !== undefined;
}
/** Current credential, or undefined if none has been provided yet. */
export function getCredential(): string | undefined {
return memory;
}
/** Store a credential (memory + sessionStorage) for subsequent requests. */
export function setCredential(value: string): void {
memory = value;
try {
globalThis.sessionStorage?.setItem(STORAGE_KEY, value);
} catch {
// sessionStorage may be unavailable (private mode) — memory still works.
}
}
/** Drop the credential (memory + sessionStorage). */
export function clearCredential(): void {
memory = undefined;
try {
globalThis.sessionStorage?.removeItem(STORAGE_KEY);
} catch {
// ignore
}
}
/**
* Register a listener invoked when the server rejects our credential (HTTP 401
* / envelope code 40101). Returns an unsubscribe function.
*/
export function onAuthRequired(listener: AuthRequiredListener): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/**
* Called by the HTTP/WS transport when the server rejects the current
* credential. Clears it and notifies listeners (the App shows the modal).
*/
export function markAuthRequired(): void {
clearCredential();
for (const listener of listeners) {
try {
listener();
} catch {
// a failing listener must not break transport handling
}
}
}

View file

@ -5,8 +5,14 @@
import { traceWsIn, traceWsLifecycle, traceWsOut } from '../../debug/trace';
import { classifyFrame } from './agentEventProjector';
import { getCredential } from './serverAuth';
import type { WireEvent, WireServerFrame } from './wire';
// Mirrors packages/server WS_BEARER_PROTOCOL_PREFIX. The browser WebSocket API
// cannot set arbitrary headers, so the bearer credential rides in the
// Sec-WebSocket-Protocol subprotocol instead.
const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.';
// ---------------------------------------------------------------------------
// Handler interface
// ---------------------------------------------------------------------------
@ -89,7 +95,10 @@ export class DaemonEventSocket {
if (this.ws !== null || this.closed) return;
traceWsLifecycle('connect', { url: this.wsUrl, attempt: this.reconnectAttempts });
const ws = new WebSocket(this.wsUrl);
const credential = getCredential();
const protocols =
credential !== undefined ? [`${WS_BEARER_PROTOCOL_PREFIX}${credential}`] : undefined;
const ws = new WebSocket(this.wsUrl, protocols);
this.ws = ws;
ws.onopen = () => {

View file

@ -0,0 +1,148 @@
<!-- apps/kimi-web/src/components/ServerAuthDialog.vue -->
<!-- Minimal token prompt shown when the Web UI has no server-transport
credential, or when the server rejects it (HTTP 401). On submit we store
the token as the bearer credential and reload so every REST/WS call picks
it up. The overlay is fully opaque so it covers the whole page (nothing
underneath shows through). Light only, Kimi blue #1565C0, no emoji. -->
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue';
import { setCredential } from '../api/daemon/serverAuth';
const credential = ref('');
const inputRef = ref<HTMLInputElement | null>(null);
const submitting = ref(false);
onMounted(() => {
void nextTick(() => inputRef.value?.focus());
});
function submit(): void {
const value = credential.value;
if (!value || submitting.value) return;
submitting.value = true;
setCredential(value);
// Reload so the HTTP client and WebSocket reconnect with the new credential.
window.location.reload();
}
function onKeydown(e: KeyboardEvent): void {
if (e.key === 'Enter') {
e.preventDefault();
submit();
}
}
</script>
<template>
<div class="server-auth-overlay" role="dialog" aria-modal="true" aria-labelledby="server-auth-title">
<div class="server-auth-card">
<h1 id="server-auth-title" class="server-auth-title">Server token required</h1>
<p class="server-auth-hint">
This server is protected. Enter the bearer token printed when the server
started (or the password set via <code>KIMI_CODE_PASSWORD</code>).
</p>
<input
ref="inputRef"
v-model="credential"
type="password"
class="server-auth-input"
autocomplete="current-password"
placeholder="Token"
:disabled="submitting"
@keydown="onKeydown"
/>
<button
type="button"
class="server-auth-button"
:disabled="!credential || submitting"
@click="submit"
>
{{ submitting ? 'Connecting…' : 'Connect' }}
</button>
</div>
</div>
</template>
<style scoped>
.server-auth-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
/* Fully opaque so the dialog covers the whole page nothing underneath
(e.g. the login page) shows through. */
background: var(--bg, #ffffff);
font-family: 'Inter', system-ui, sans-serif;
}
.server-auth-card {
width: min(360px, calc(100vw - 32px));
padding: 28px 28px 24px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
}
.server-auth-title {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
}
.server-auth-hint {
margin: 0 0 18px;
font-size: 13px;
line-height: 1.5;
color: #555;
}
.server-auth-hint code {
padding: 1px 5px;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 12px;
background: #f0f0f0;
border-radius: 4px;
}
.server-auth-input {
box-sizing: border-box;
width: 100%;
padding: 10px 12px;
margin-bottom: 14px;
font-size: 14px;
color: #1a1a1a;
background: #fff;
border: 1px solid #d0d0d0;
border-radius: 8px;
outline: none;
}
.server-auth-input:focus {
border-color: #1565c0;
box-shadow: 0 0 0 3px rgba(21, 101, 192, 0.15);
}
.server-auth-button {
width: 100%;
padding: 10px 12px;
font-size: 14px;
font-weight: 600;
color: #fff;
background: #1565c0;
border: none;
border-radius: 8px;
cursor: pointer;
}
.server-auth-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.server-auth-button:not(:disabled):hover {
background: #0d47a1;
}
</style>

View file

@ -150,7 +150,7 @@
inherit (finalAttrs) pname version src pnpmWorkspaces;
inherit pnpm;
fetcherVersion = 3;
hash = "sha256-yukns+b5mjDgp/TuFco24FH5pjJm/Ivlw71gMyhSl/Q=";
hash = "sha256-O9xDt/5bCakst2mKTyki3oyUph1g+CuH/BIqA/4fgYE=";
};
nativeBuildInputs = [

192
packages/server/SECURITY.md Normal file
View file

@ -0,0 +1,192 @@
# Server deployment security
Operational guide for exposing the `@moonshot-ai/server` HTTP/WebSocket API beyond
`127.0.0.1`. For reporting vulnerabilities, see the repo root `SECURITY.md`.
## Threat model
The server classifies the bind host into one of three tiers
(`services/auth/bindClassify.ts`):
| Tier | Bind host | Trust boundary | Primary threats | Mitigations |
|---|---|---|---|---|
| loopback | `127.0.0.0/8`, `::1`, `localhost` (default) | This host only; local process isolation | Same-host processes/users connecting to the port; malicious web pages hitting `localhost` (CSRF / DNS rebinding) | Persistent bearer token; Host/Origin checks; token file `0600` (this user only) |
| LAN | RFC1918 + `169.254/16`, `fe80::/10` (`--host 192.168.x.x`) | The local network is untrusted | Network-reachable attackers; brute force; CSRF; remote shell / shutdown | **Full hardening (same as public):** bearer token auth (password optional), TLS or explicit opt-out, auth-failure rate limiting, dangerous endpoints disabled, security headers |
| public | Everything else; `0.0.0.0` / `::` / empty | The whole internet is untrusted | Same as LAN, at internet scale | Same hardening as LAN; a TLS-terminating reverse proxy (or tunnel) is strongly recommended |
**Important:** the hardening gate in `start.ts` is `bindClass !== 'loopback'`. LAN and
public binds therefore receive the **same** hardening stack (TLS opt-out + rate-limit
+ endpoint downgrade + security headers). Authentication is the persistent bearer
token (printed in the startup banner); `KIMI_CODE_PASSWORD` is an optional additional
credential on every tier. The tier label only changes the banner and the automatic
Host allowlist entry. Treat LAN exposure with the same care as public exposure.
Not in scope (see PLAN §6): NAT traversal, untrusted relays, end-to-end encryption.
## Default (loopback) deployment
```
kimi server run # or: kimi web
```
- Binds `127.0.0.1:58627` by default (`--host` / `--port` to override).
- Uses a persistent bearer token (`crypto.randomBytes(32)`, base64url) generated
once on first boot and written to `<KIMI_CODE_HOME>/server.token` with mode
`0600` (parent directory `0700`). The same token is reused across restarts; it
is NOT deleted when the server exits. Rotate it explicitly with
`kimi server rotate-token` (a running server picks up the new token without a
restart).
- The local CLI reads that token file automatically and sends
`Authorization: Bearer <token>` on every REST/WebSocket call — no setup required.
- Only loopback is reachable; nothing is exposed to the network.
## LAN deployment
Bind a specific LAN interface. Because the hardening gate is `bindClass !== 'loopback'`,
a LAN bind gets the same hardening stack as a public bind:
```
kimi server run --host 192.168.1.10 --insecure-no-tls
```
- Authentication is the persistent bearer token printed in the startup banner; send it
as `Authorization: Bearer <token>`. `KIMI_CODE_PASSWORD` is optional and adds a
second credential (it is never required).
- `--insecure-no-tls` is required unless TLS is terminated in front of the server
(reverse proxy or tunnel). Use it only on a trusted network.
- `POST /api/v1/shutdown` and the PTY `/api/v1/terminals/*` routes are **404 by
default** on LAN too. Re-enable only with `--allow-remote-shutdown` /
`--allow-remote-terminals`.
- Auth-failure rate limiting and security response headers are active.
## Public deployment
A public bind is `0.0.0.0`, `::`, an empty host, or any non-RFC1918 address. Put the
server behind a TLS-terminating reverse proxy (or a tunnel); do not terminate TLS in
process.
```
kimi server run --host 0.0.0.0 --insecure-no-tls
```
- Authentication is the persistent bearer token printed in the startup banner;
`KIMI_CODE_PASSWORD` is optional. `--insecure-no-tls` is mandatory here because
the proxy terminates TLS; without it (and without app-level TLS) the server refuses
to bind.
- The reverse proxy must pass through the `Authorization` header and the `Host`
header, and must upgrade WebSocket connections (see examples below).
- `POST /api/v1/shutdown` and `/api/v1/terminals/*` are **404 by default**. Re-enable
only with `--allow-remote-shutdown` / `--allow-remote-terminals`.
- Auth-failure rate limiting (10 failures / 60 s window / 60 s ban per source,
response code `42901`) and security headers are active.
- Prefer a tunnel (`cloudflared`, `ssh -R`) over a raw public bind where possible.
## Reverse-proxy examples
The server listens on `127.0.0.1:58627`; the proxy terminates TLS and forwards to it.
**Caddy** (`Caddyfile`; automatic HTTPS):
```
kimi.example.com {
reverse_proxy 127.0.0.1:58627
}
```
Caddy's `reverse_proxy` passes `Host`, `Authorization`, and the WebSocket upgrade
headers by default.
**nginx** (TLS terminated at nginx):
```
server {
listen 443 ssl;
server_name kimi.example.com;
ssl_certificate /etc/letsencrypt/live/kimi.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/kimi.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:58627;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Authorization $http_authorization;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
**Tunnel alternatives:**
- `cloudflared tunnel --url http://127.0.0.1:58627` (Cloudflare Tunnel; no inbound
firewall rule).
- `ssh -R` (SSH remote port forwarding) to publish the loopback port via a host you
control.
For both tunnels, keep the server on the default loopback bind; the tunnel provides
the remote reachability and TLS.
## Credential management
- **Token** — a persistent bearer token generated once on first boot, held in
memory and written to `<KIMI_CODE_HOME>/server.token` (`0600`, directory
`0700`). It survives restarts and is reused until explicitly rotated with
`kimi server rotate-token`, which rewrites the file (the previous token stops
working immediately, even for a running server). The CLI reads it
automatically; treat the file as a secret.
- **Password** — set `KIMI_CODE_PASSWORD` in the environment. The server hashes it at
boot with bcrypt (cost 12) and keeps only the hash in memory; verification uses
`bcrypt.compare`. Password auth is accepted as `Authorization: Bearer <password>`.
- **Stored password hash** — the current path is env-only. There is **no**
`set-password` subcommand and no config-file hash option yet. If a config-stored
hash is needed later, generate one externally with cost 12, e.g.:
```
node -e "require('bcryptjs').hash(process.env.KIMI_CODE_PASSWORD,12).then(h=>console.log(h))"
```
(This is forward-looking; do not rely on it until config support lands.)
## Host / Origin allowlists
Host and Origin checks run on every request (HTTP and WebSocket upgrade) on all
tiers.
- **Default Host allowlist:** `localhost`, `*.localhost`, `127.0.0.1`, `::1`,
`[::1]`, and the actual bind host/IP. Requests with any other `Host` get
`403 Invalid Host header` (DNS-rebinding protection).
- **`KIMI_CODE_ALLOWED_HOSTS`** — comma-separated extra hosts. A leading dot matches
a subdomain wildcard, e.g. `KIMI_CODE_ALLOWED_HOSTS=.example.com,kimi.local`.
- **`KIMI_CODE_CORS_ORIGINS`** — comma-separated list of allowed cross-origin values
(full `scheme://host[:port]`). No `*` wildcard. Matched origins get
`Access-Control-Allow-Origin` echoed; `OPTIONS` preflight short-circuits to `204`.
The bundled Web UI is same-origin and needs no entry.
Example: `KIMI_CODE_CORS_ORIGINS=https://kimi.example.com`.
- **`KIMI_CODE_DISABLE_HOST_CHECK=1`** — disables the Host check entirely on **all**
tiers (loopback, LAN, and public). Test/controlled environments only; this removes
the DNS-rebinding protection and must not be set in production.
## Authentication reference
- **HTTP:** `Authorization: Bearer <token|password>` on every route except
`GET /api/v1/healthz`, `OPTIONS *`, and the static Web UI assets (`/`, `/*`).
Failure returns `401` with code `40101`.
- **WebSocket:** send either an `Authorization: Bearer <token|password>` header or
the subprotocol `Sec-WebSocket-Protocol: kimi-code.bearer.<token>` (for browsers
that cannot set WS headers). The server echoes the matching subprotocol on accept;
a failed check destroys the socket.
## CLI flags
The only server-exposure flags are:
| Flag | Purpose |
|---|---|
| `--host <host>` | Bind host (default `127.0.0.1`). |
| `--port <port>` | Bind port (default `58627`). |
| `--insecure-no-tls` | Allow a non-loopback bind without app-level TLS (i.e. TLS is terminated by a proxy/tunnel). |
| `--allow-remote-shutdown` | Re-enable `POST /api/v1/shutdown` on a non-loopback bind. |
| `--allow-remote-terminals` | Re-enable the PTY `/api/v1/terminals/*` routes on a non-loopback bind. |
There is no `--bind-class` flag and no `set-password` subcommand.

View file

@ -42,6 +42,7 @@
"@fastify/swagger": "^9.7.0",
"@moonshot-ai/agent-core": "workspace:^",
"@moonshot-ai/protocol": "workspace:^",
"bcryptjs": "^2.4.3",
"fastify": "^5.1.0",
"pino": "^9.5.0",
"pino-pretty": "^13.0.0",
@ -51,6 +52,7 @@
"zod-to-json-schema": "^3.25.2"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/ws": "^8.18.0"
}
}

View file

@ -2,6 +2,9 @@ export { startServer, ServerLockedError } from './start';
export type { ServerStartOptions, RunningServer } from './start';
export { okEnvelope, errEnvelope } from './envelope';
export type { Envelope } from './envelope';
export { classify } from './services/auth/bindClassify';
export type { BindClass } from './services/auth/bindClassify';
export { rotateServerToken, serverTokenPath } from './services/auth/persistentToken';
export { createServerLogger } from './services/pinoLoggerService';
export type {
CreateLoggerOptions,

View file

@ -165,8 +165,10 @@ function tryExclusiveCreate(path: string, contents: LockContents): boolean {
let fd: number | undefined;
try {
// 0o100 (O_CREAT) | 0o200 (O_EXCL) | 0o2 (O_RDWR) — but `openSync` accepts the
// string flag form which is portable.
fd = openSync(path, 'wx');
// string flag form which is portable. Mode 0o600 so the lock file (which
// lives next to the per-pid token file) is not world/group readable
// (ROADMAP M5.2).
fd = openSync(path, 'wx', 0o600);
writeFileSync(fd, JSON.stringify(contents));
return true;
} catch (err) {

View file

@ -0,0 +1,134 @@
/**
* Global HTTP bearer-auth hook (ROADMAP M2.2).
*
* `createAuthHook` builds a Fastify `onRequest` hook that requires a valid
* `Authorization: Bearer <token>` on every non-bypassed route. It is NOT wired
* into `start.ts` in M2 that happens in M5.1. Until then it is exercised in
* isolation via tests on a minimal Fastify app.
*
* 401 responses use the reserved daemon code `40101`
* (`packages/protocol/src/error-codes.ts` intentionally omits it; the protocol
* package is left untouched). `errEnvelope(code: number, …)` accepts a plain
* number, so the literal is passed directly.
*/
import type { FastifyReply, FastifyRequest } from 'fastify';
import { errEnvelope } from '#/envelope';
import {
AUTH_RATE_LIMIT_CODE,
AUTH_RATE_LIMIT_MSG,
type AuthFailureLimiter,
} from '#/middleware/rateLimit';
import type { IAuthTokenService } from '#/services/auth/authTokenService';
/** Daemon-reserved unauthorized code (not in the protocol `ErrorCode` enum). */
const AUTH_ERROR_CODE = 40101;
const AUTH_ERROR_MSG = 'Unauthorized';
const REDACTED = '[redacted]';
const BEARER_PREFIX = 'Bearer ';
export interface AuthHookOptions {
/** Return true to skip auth for this request (bypass whitelist). */
readonly isBypassed?: (req: FastifyRequest) => boolean;
/**
* Optional per-source auth-failure limiter (ROADMAP M6.4). When present, a
* banned source is rejected with `429` before auth runs, and each failed
* attempt is recorded. Wired only on non-loopback binds from `start.ts`.
*/
readonly limiter?: Pick<AuthFailureLimiter, 'recordFailure' | 'isBanned'>;
}
/**
* Default bypass policy the security boundary.
*
* Bypassed (no token required):
* - every `OPTIONS` request (CORS preflight);
* - `GET /api/v1/healthz` (liveness probe for supervisors / load balancers);
* - static web assets, defined as any path that does NOT start with `/api/`
* AND is not one of the meta documents `/openapi.json` / `/asyncapi.json`.
*
* NOT bypassed (token required): all `/api/…` routes plus `/openapi.json` and
* `/asyncapi.json` (the meta documents leak the API shape, so they stay gated).
*/
function defaultIsBypassed(req: FastifyRequest): boolean {
if (req.method === 'OPTIONS') {
return true;
}
const path = req.url.split('?', 1)[0] ?? req.url;
if (req.method === 'GET' && path === '/api/v1/healthz') {
return true;
}
const isApi = path.startsWith('/api/');
const isMeta = path === '/openapi.json' || path === '/asyncapi.json';
return !isApi && !isMeta;
}
/**
* Extract the bearer token from the raw `Authorization` header.
*
* Returns `null` when the header is missing, lacks the case-sensitive
* `Bearer ` prefix, or carries an empty token all treated as 401.
*/
function extractBearer(header: string | undefined): string | null {
if (header === undefined || !header.startsWith(BEARER_PREFIX)) {
return null;
}
const token = header.slice(BEARER_PREFIX.length);
return token.length === 0 ? null : token;
}
/**
* Build the global `onRequest` auth hook.
*
* Order inside the hook matters: the raw token is extracted first, then the
* header view is redacted for downstream request logging (`start.ts` logs
* requests), and only then is the candidate validated so auth still sees the
* real token while logs never do. Returning the `reply` from the async hook
* short-circuits Fastify on 401 (the route handler never runs).
*/
export function createAuthHook(
authTokenService: IAuthTokenService,
opts?: AuthHookOptions,
): (req: FastifyRequest, reply: FastifyReply) => Promise<FastifyReply | void> {
const isBypassed = opts?.isBypassed ?? defaultIsBypassed;
return async (req, reply) => {
// Rate-limit check (ROADMAP M6.4): a banned source is rejected before any
// auth work — even a valid token cannot bypass an active ban. Loopback
// binds pass no limiter, so this branch is a no-op there.
if (opts?.limiter?.isBanned(req.ip) === true) {
return reply
.code(429)
.send(errEnvelope(AUTH_RATE_LIMIT_CODE, AUTH_RATE_LIMIT_MSG, req.id));
}
const header = req.headers.authorization;
const token = extractBearer(header);
// Redact the header view BEFORE the rest of the pipeline logs the request.
// Auth has already consumed the raw value above, so this only affects the
// downstream log view of the request.
if (header !== undefined) {
req.headers.authorization = REDACTED;
}
if (isBypassed(req)) {
return;
}
if (token === null) {
opts?.limiter?.recordFailure(req.ip);
return reply
.code(401)
.send(errEnvelope(AUTH_ERROR_CODE, AUTH_ERROR_MSG, req.id));
}
if (!(await authTokenService.isValid(token))) {
opts?.limiter?.recordFailure(req.ip);
return reply
.code(401)
.send(errEnvelope(AUTH_ERROR_CODE, AUTH_ERROR_MSG, req.id));
}
};
}

View file

@ -0,0 +1,166 @@
/**
* Host-header allowlist middleware (ROADMAP M4.1).
*
* `createHostCheck` builds a Fastify `onRequest` hook that rejects requests
* whose `Host` header is not in the allowlist with a `403 Invalid Host header`
* envelope. This is the primary DNS-rebinding defence once the server is
* reachable beyond localhost (PLAN §3.4).
*
* Default-allow set (no configuration required):
* - `localhost`, `*.localhost` (any subdomain of `localhost`);
* - `127.0.0.1`, `::1`, `[::1]`;
* - any literal IP (`net.isIP(host) !== 0`);
* - the host the server actually bound to (`boundHost`);
* - caller-supplied extras (`extra`), where a leading `.` matches the bare
* domain and any subdomain (e.g. `.example.com` matches `example.com` and
* `a.example.com`).
*
* The default set is intentionally permissive for loopback/IP access so that
* `app.inject` (default `Host: localhost:80`) and real `fetch` to
* `127.0.0.1:<port>` keep working existing HTTP/WS tests rely on this.
*
* 403 responses use the reserved daemon code `40301`
* (`packages/protocol/src/error-codes.ts` intentionally omits it; the protocol
* package is left untouched). `errEnvelope(code: number, …)` accepts a plain
* number, so the literal is passed directly.
*/
import net from 'node:net';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { errEnvelope } from '#/envelope';
/** Daemon-reserved "invalid Host" code (not in the protocol `ErrorCode` enum). */
const HOST_ERROR_CODE = 40301;
const HOST_ERROR_MSG = 'Invalid Host header';
export interface HostCheckOptions {
/** The host the server bound to; always allowed (port stripped both sides). */
readonly boundHost?: string;
/** Extra allowed hosts / domain-suffix patterns (from `KIMI_CODE_ALLOWED_HOSTS`). */
readonly extra?: readonly string[];
/** Disable the check entirely (`KIMI_CODE_DISABLE_HOST_CHECK=1`; test-only). */
readonly disable?: boolean;
}
/** Returned by {@link createHostCheck}: the Fastify hook plus the raw predicate. */
export interface HostCheck {
/** Fastify `onRequest` hook that 403s on a disallowed `Host`. */
readonly onRequest: (req: FastifyRequest, reply: FastifyReply) => Promise<FastifyReply | void>;
/** Reusable predicate (also used by the WS upgrade path in M4.3). */
readonly isAllowed: (host: string | undefined) => boolean;
}
/**
* Parse `KIMI_CODE_ALLOWED_HOSTS` into an `extra` allowlist.
*
* Comma-separated, trimmed, empties dropped. A leading `.` is preserved so the
* caller can express domain-suffix wildcards (`.example.com`).
*/
export function parseAllowedHosts(env: NodeJS.ProcessEnv = process.env): string[] {
const raw = env['KIMI_CODE_ALLOWED_HOSTS'];
if (raw === undefined) {
return [];
}
return raw
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
/** True when `KIMI_CODE_DISABLE_HOST_CHECK=1` (test/controlled env only). */
export function isHostCheckDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
return env['KIMI_CODE_DISABLE_HOST_CHECK'] === '1';
}
/**
* Strip a trailing `:port` from a `Host` value and lowercase it.
*
* Handles:
* - bracketed IPv6 with a port: `[::1]:80` `[::1]`;
* - host/IPv4 with a port: `localhost:80` `localhost`, `1.2.3.4:5678` `1.2.3.4`;
* - bare values (no port): returned lowercased as-is;
* - bare IPv6 without brackets (multiple colons, e.g. `::1`): returned
* lowercased as-is there is no unambiguous port to strip.
*/
export function stripPort(host: string): string {
if (host.startsWith('[')) {
const end = host.indexOf(']');
return (end === -1 ? host : host.slice(0, end + 1)).toLowerCase();
}
const firstColon = host.indexOf(':');
if (firstColon === -1) {
return host.toLowerCase();
}
const lastColon = host.lastIndexOf(':');
if (firstColon === lastColon) {
const after = host.slice(lastColon + 1);
if (after.length > 0 && /^\d+$/.test(after)) {
return host.slice(0, lastColon).toLowerCase();
}
}
// Multiple colons (bare IPv6) or a non-digit suffix — no port to strip.
return host.toLowerCase();
}
/**
* Decide whether a `Host` value is allowed under the given options.
*
* Missing/empty `Host` is rejected (HTTP/1.1 requires it). The check is a no-op
* when `opts.disable` is set.
*/
export function isAllowedHost(host: string | undefined, opts: HostCheckOptions): boolean {
if (opts.disable === true) {
return true;
}
if (host === undefined || host.length === 0) {
return false;
}
const h = stripPort(host);
if (h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]') {
return true;
}
if (h.endsWith('.localhost')) {
return true;
}
if (net.isIP(h) !== 0) {
return true;
}
if (opts.boundHost !== undefined && h === stripPort(opts.boundHost)) {
return true;
}
if (opts.extra !== undefined) {
for (const entry of opts.extra) {
if (entry.startsWith('.')) {
const base = entry.slice(1);
if (h === base || h.endsWith(entry)) {
return true;
}
} else if (h === entry) {
return true;
}
}
}
return false;
}
/**
* Build the Fastify `onRequest` hook and the reusable `isAllowed` predicate.
*
* Returning the `reply` from the hook short-circuits Fastify on 403 so the
* route handler never runs.
*/
export function createHostCheck(opts: HostCheckOptions): HostCheck {
const isAllowed = (host: string | undefined): boolean => isAllowedHost(host, opts);
const onRequest = async (
req: FastifyRequest,
reply: FastifyReply,
): Promise<FastifyReply | void> => {
if (!isAllowed(req.headers.host)) {
return reply.code(403).send(errEnvelope(HOST_ERROR_CODE, HOST_ERROR_MSG, req.id));
}
};
return { onRequest, isAllowed };
}

View file

@ -0,0 +1,122 @@
/**
* Origin / CORS middleware (ROADMAP M4.2).
*
* HTTP `onRequest` hook:
* - no `Origin` header non-CORS / same-origin request proceeds untouched;
* - same-origin (`Origin` host === `Host`, port stripped both sides) allowed;
* - cross-origin allowed only if the full origin (scheme + host) is in the
* explicit whitelist (`KIMI_CODE_CORS_ORIGINS`, no `*` wildcard PLAN
* §3.4). Allowed origins get `Access-Control-Allow-*` echoed; `OPTIONS`
* preflight short-circuits to `204`;
* - cross-origin and NOT whitelisted no CORS headers are emitted, so the
* browser blocks the response. `OPTIONS` still returns `204` (without CORS
* headers) so the preflight fails closed.
*
* `isOriginAllowed` is also exported for the WS upgrade path (M4.3). There,
* absent/malformed `Origin` is treated as allowed (non-browser Node `ws`
* clients send no `Origin`); a present-but-disallowed browser Origin is
* rejected. See M4.3 for the deliberate present-only deviation.
*/
import type { FastifyReply, FastifyRequest } from 'fastify';
import { stripPort } from './hostnames';
const CORS_ALLOW_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
const CORS_ALLOW_HEADERS = 'Content-Type, Authorization';
export interface OriginHookOptions {
/** Explicit cross-origin allowlist (full origin strings, scheme + host). */
readonly allowedOrigins?: readonly string[];
}
/**
* Parse `KIMI_CODE_CORS_ORIGINS` into an allowlist.
*
* Comma-separated, trimmed, empties dropped. No `*` wildcard every entry is
* an explicit origin (PLAN §3.4).
*/
export function parseCorsOrigins(env: NodeJS.ProcessEnv = process.env): string[] {
const raw = env['KIMI_CODE_CORS_ORIGINS'];
if (raw === undefined) {
return [];
}
return raw
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
/**
* Return the `host` (host[:port], default port dropped) of an `Origin` value,
* or `undefined` when the origin is missing or malformed.
*/
export function originHost(origin: string | undefined): string | undefined {
if (origin === undefined) {
return undefined;
}
try {
return new URL(origin).host;
} catch {
return undefined;
}
}
/**
* Decide whether an `Origin` is allowed for a request to `host`.
*
* - missing/malformed `Origin` allowed (non-CORS / non-browser client);
* - same-origin (`Origin` host === `Host`, port stripped both sides) allowed;
* - otherwise allowed only when the full origin string is in `allowed`.
*/
export function isOriginAllowed(
origin: string | undefined,
host: string | undefined,
allowed: readonly string[],
): boolean {
const oh = originHost(origin);
if (oh === undefined) {
return true;
}
if (host !== undefined && stripPort(oh) === stripPort(host)) {
return true;
}
// `origin` is defined here (originHost returned a host), so the whitelist
// match is against the full origin string (scheme + host).
return allowed.includes(origin as string);
}
/**
* Build the Fastify `onRequest` CORS hook.
*
* Allowed origins get `Access-Control-Allow-*` echoed and `OPTIONS` preflights
* short-circuit to `204`. Disallowed origins get no CORS headers (the browser
* blocks the response); their `OPTIONS` preflight still returns `204` so it
* fails closed without leaking headers.
*/
export function createOriginHook(
opts: OriginHookOptions,
): (req: FastifyRequest, reply: FastifyReply) => Promise<FastifyReply | void> {
const allowed = opts.allowedOrigins ?? [];
return async (req, reply) => {
const origin = req.headers.origin;
if (origin === undefined) {
return;
}
if (isOriginAllowed(origin, req.headers.host, allowed)) {
reply.header('Access-Control-Allow-Origin', origin);
reply.header('Access-Control-Allow-Methods', CORS_ALLOW_METHODS);
reply.header('Access-Control-Allow-Headers', CORS_ALLOW_HEADERS);
reply.header('Vary', 'Origin');
if (req.method === 'OPTIONS') {
return reply.code(204).send();
}
return;
}
// Origin present but not allowed: emit no CORS headers so the browser
// blocks the response. Short-circuit the preflight to fail closed.
if (req.method === 'OPTIONS') {
return reply.code(204).send();
}
};
}

View file

@ -0,0 +1,103 @@
/**
* Auth-failure rate limiter (ROADMAP M6.4).
*
* A per-`remoteAddress` failure counter that temporarily bans a source after
* too many failed authentication attempts. Wired into `createAuthHook` only on
* non-loopback binds (`start.ts`), so the loopback default keeps its existing
* "no rate limit" behavior while a public/LAN bind slows brute-force attempts.
*
* Policy: a source is banned when it accumulates `maxFailures` failures within
* a sliding `windowMs` window; the ban lasts `banMs`. Once banned, every
* request from that source is rejected with `429` until the ban expires even
* a request carrying a valid token so a banned attacker cannot keep probing.
*/
/** Reserved daemon code for rate-limited auth (not in the protocol enum). */
export const AUTH_RATE_LIMIT_CODE = 42901;
export const AUTH_RATE_LIMIT_MSG = 'Too many failed auth attempts';
export interface AuthFailureLimiterOptions {
/** Failures within {@link windowMs} that trigger a ban. Default `10`. */
readonly maxFailures?: number;
/** Rolling failure window in ms. Default `60_000`. */
readonly windowMs?: number;
/** Ban duration in ms once the threshold is hit. Default `60_000`. */
readonly banMs?: number;
}
/** Minimal surface consumed by `createAuthHook`. */
export interface AuthFailureLimiter {
/** Record one failed auth attempt for `ip`. */
recordFailure(ip: string): void;
/** True while `ip` is inside an active ban window. */
isBanned(ip: string): boolean;
/** Stop the periodic cleanup timer and drop all state (shutdown / tests). */
dispose(): void;
}
interface Entry {
/** Failures recorded since {@link windowStart}. */
count: number;
/** Start (ms epoch) of the current failure window. */
windowStart: number;
/** ms epoch until which the source is banned; `0` when not banned. */
bannedUntil: number;
}
const DEFAULT_MAX_FAILURES = 10;
const DEFAULT_WINDOW_MS = 60_000;
const DEFAULT_BAN_MS = 60_000;
/**
* Build a per-source auth-failure limiter.
*
* A periodic sweep drops entries that are neither banned nor within an active
* failure window so the map does not grow without bound on a long-lived
* public server. The timer is `unref`-ed so it never keeps the process alive.
*/
export function createAuthFailureLimiter(
opts?: AuthFailureLimiterOptions,
): AuthFailureLimiter {
const maxFailures = opts?.maxFailures ?? DEFAULT_MAX_FAILURES;
const windowMs = opts?.windowMs ?? DEFAULT_WINDOW_MS;
const banMs = opts?.banMs ?? DEFAULT_BAN_MS;
const entries = new Map<string, Entry>();
const sweep = setInterval(() => {
const now = Date.now();
for (const [ip, entry] of entries) {
const banned = entry.bannedUntil > now;
const windowLive = now - entry.windowStart <= windowMs;
if (!banned && !windowLive) {
entries.delete(ip);
}
}
}, windowMs);
if (typeof sweep.unref === 'function') {
sweep.unref();
}
return {
recordFailure(ip: string): void {
const now = Date.now();
let entry = entries.get(ip);
if (entry === undefined || now - entry.windowStart > windowMs) {
entry = { count: 0, windowStart: now, bannedUntil: 0 };
entries.set(ip, entry);
}
entry.count += 1;
if (entry.count >= maxFailures) {
entry.bannedUntil = now + banMs;
}
},
isBanned(ip: string): boolean {
const entry = entries.get(ip);
if (entry === undefined) return false;
return entry.bannedUntil > Date.now();
},
dispose(): void {
clearInterval(sweep);
entries.clear();
},
};
}

View file

@ -46,6 +46,17 @@ interface ApiV1RouteHost {
export interface RegisterApiV1RoutesOptions {
readonly serverVersion: string;
readonly debugEndpoints?: boolean;
/**
* Mount `POST /api/v1/shutdown`. Defaults to true (backward compatible);
* `start.ts` sets it false on a public bind unless `--allow-remote-shutdown`.
*/
readonly enableShutdown?: boolean;
/**
* Mount the PTY `/api/v1/terminals/*` routes. Defaults to true (backward
* compatible); `start.ts` sets it false on a public bind unless
* `--allow-remote-terminals`.
*/
readonly enableTerminals?: boolean;
}
export async function registerApiV1Routes(
@ -76,10 +87,12 @@ export async function registerApiV1Routes(
ix,
);
registerSessionsRoutes(apiV1 as unknown as Parameters<typeof registerSessionsRoutes>[0], ix);
registerShutdownRoutes(
apiV1 as unknown as Parameters<typeof registerShutdownRoutes>[0],
ix,
);
if (opts.enableShutdown !== false) {
registerShutdownRoutes(
apiV1 as unknown as Parameters<typeof registerShutdownRoutes>[0],
ix,
);
}
registerSnapshotRoutes(apiV1 as unknown as Parameters<typeof registerSnapshotRoutes>[0], ix);
registerMessagesRoutes(apiV1 as unknown as Parameters<typeof registerMessagesRoutes>[0], ix);
registerPromptsRoutes(apiV1 as unknown as Parameters<typeof registerPromptsRoutes>[0], ix);
@ -94,10 +107,12 @@ export async function registerApiV1Routes(
registerToolsRoutes(apiV1 as unknown as Parameters<typeof registerToolsRoutes>[0], ix);
registerSkillsRoutes(apiV1 as unknown as Parameters<typeof registerSkillsRoutes>[0], ix);
registerTasksRoutes(apiV1 as unknown as Parameters<typeof registerTasksRoutes>[0], ix);
registerTerminalsRoutes(
apiV1 as unknown as Parameters<typeof registerTerminalsRoutes>[0],
ix,
);
if (opts.enableTerminals !== false) {
registerTerminalsRoutes(
apiV1 as unknown as Parameters<typeof registerTerminalsRoutes>[0],
ix,
);
}
registerFsRoutes(apiV1 as unknown as Parameters<typeof registerFsRoutes>[0], ix);
registerFilesRoutes(apiV1 as unknown as Parameters<typeof registerFilesRoutes>[0], ix);
registerWorkspacesRoutes(

View file

@ -0,0 +1,56 @@
/**
* `IAuthTokenService` DI surface (ROADMAP M2.1).
*
* Exposes the persistent bearer token plus a single validity check that accepts
* EITHER the persistent token (constant-time, via `TokenStore`) OR a verified
* user password (bcrypt, async). The seam exists so tests can inject a
* fixed-token impl via `startServer({ serviceOverrides })`, and so `start.ts`
* (M5.1) can wire the real async-built instance at boot.
*
* `isValid` is async because password verification (`bcrypt.compare`) is
* async the token path is synchronous, but the interface must await both.
*/
import { createDecorator } from '@moonshot-ai/agent-core';
import { verifyPassword } from './password';
import type { TokenStore } from './tokenStore';
export interface IAuthTokenService {
readonly _serviceBrand: undefined;
/** The persistent bearer token (re-read from disk when its mtime changes). */
getToken(): string;
/**
* True when `candidate` matches the persistent token OR verifies against the
* configured password hash. Constant-time on the token path; bcrypt on the
* password path.
*/
isValid(candidate: string): Promise<boolean>;
}
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const IAuthTokenService =
createDecorator<IAuthTokenService>('authTokenService');
/**
* Default `IAuthTokenService` over a `TokenStore` + optional password hash.
*
* Constructed in `start.ts` (M5.1) where the async `TokenStore` /
* `passwordHash` are available, then injected via `serviceOverrides`. NOT built
* inside `createServerServiceCollection`: that path is synchronous and cannot
* await the `TokenStore` file write or the bcrypt hash.
*/
export function createAuthTokenService(deps: {
readonly tokenStore: TokenStore;
readonly passwordHash: string | undefined;
}): IAuthTokenService {
return {
_serviceBrand: undefined,
getToken: () => deps.tokenStore.getToken(),
isValid: async (candidate) =>
deps.tokenStore.isValid(candidate) ||
(await verifyPassword(candidate, deps.passwordHash)),
};
}

View file

@ -0,0 +1,112 @@
/**
* Bind-address classification (ROADMAP M6.1).
*
* `classify(host)` buckets a bind host into the network exposure tier it
* implies, so `start.ts` can decide which hardening to apply:
*
* - `loopback` only this host (`127.0.0.0/8`, `::1`, `localhost`). The
* token-only default; no public hardening required.
* - `lan` RFC1918 private ranges (`10/8`, `172.16/12`, `192.168/16`) plus
* link-local (`169.254/16`, `fe80::/10`). Reachable from the local
* network; hardening is recommended but not all of it is mandatory.
* - `public` everything else. Full D2 hardening (forced password, TLS
* opt-out, auth-failure rate limiting, dangerous-endpoint downgrade,
* security headers).
*
* Wildcard binds (`0.0.0.0`, `::`, empty) are treated as `public` by default
* a wildcard is reachable from anywhere the host is unless the caller
* explicitly relaxes the classification via `opts.bindClass: 'lan'`
* (`--bind-class=lan`).
*/
import net from 'node:net';
export type BindClass = 'loopback' | 'lan' | 'public';
export interface ClassifyOptions {
/** Override classification of wildcard binds (`0.0.0.0` / `::` / empty). */
readonly bindClass?: 'lan' | 'public';
}
/** Convert a dotted-quad IPv4 literal to its unsigned 32-bit integer form. */
function ipv4ToInt(ip: string): number {
const [a, b, c, d] = ip.split('.');
return (
(((Number(a) << 24) >>> 0) +
((Number(b) << 16) >>> 0) +
((Number(c) << 8) >>> 0) +
(Number(d) >>> 0)) >>>
0
);
}
/** True when `ip` falls inside the IPv4 CIDR `base/prefix`. */
function ipv4InCidr(ip: string, base: string, prefix: number): boolean {
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
return ((ipv4ToInt(ip) & mask) >>> 0) === ((ipv4ToInt(base) & mask) >>> 0);
}
/**
* Expand a (possibly `::`-compressed) IPv6 literal into 8 lowercase hextets.
* Returns `null` when the shape is not a plain 8-group IPv6 address.
*/
function expandV6(host: string): readonly string[] | null {
const lower = host.toLowerCase();
if (lower.includes('::')) {
const halves = lower.split('::');
const leftRaw = halves[0] ?? '';
const rightRaw = halves[1] ?? '';
const left = leftRaw.length > 0 ? leftRaw.split(':') : [];
const right = rightRaw.length > 0 ? rightRaw.split(':') : [];
const missing = 8 - (left.length + right.length);
if (missing < 0) return null;
return [...left, ...Array<string>(missing).fill('0'), ...right];
}
const parts = lower.split(':');
return parts.length === 8 ? parts : null;
}
/**
* True when `host` is an IPv6 link-local address (`fe80::/10`).
*
* The first 10 bits are fixed (`1111111010`), so the leading hextet ranges
* `0xfe80``0xfebf`. IPv4-mapped / compressed forms that do not expand to an
* `fe80::/10` leading group return false.
*/
function isLinkLocalV6(host: string): boolean {
const groups = expandV6(host);
if (groups === null) return false;
const first = Number.parseInt(groups[0] ?? '', 16);
return first >= 0xfe80 && first <= 0xfebf;
}
/**
* Classify a bind host by the network exposure it implies.
*
* See the module header for the tier definitions. A non-IP hostname that is
* not `localhost` is treated conservatively as `public` a DNS name could
* resolve to a public address.
*/
export function classify(host: string, opts?: ClassifyOptions): BindClass {
if (host === '' || host === '0.0.0.0' || host === '::') {
return opts?.bindClass ?? 'public';
}
if (host === 'localhost') {
return 'loopback';
}
const family = net.isIP(host);
if (family === 4) {
if (host.startsWith('127.')) return 'loopback';
if (ipv4InCidr(host, '10.0.0.0', 8)) return 'lan';
if (ipv4InCidr(host, '172.16.0.0', 12)) return 'lan';
if (ipv4InCidr(host, '192.168.0.0', 16)) return 'lan';
if (ipv4InCidr(host, '169.254.0.0', 16)) return 'lan';
return 'public';
}
if (family === 6) {
if (host.toLowerCase() === '::1') return 'loopback';
if (isLinkLocalV6(host)) return 'lan';
return 'public';
}
return 'public';
}

View file

@ -0,0 +1,6 @@
export * from './privateFiles';
export * from './tokenStore';
export * from './password';
export * from './authTokenService';
export * from './bindClassify';
export * from './securityHeaders';

View file

@ -0,0 +1,23 @@
import { compare, hash } from 'bcryptjs';
const BCRYPT_COST = 12;
export async function resolvePasswordHash(
env: NodeJS.ProcessEnv = process.env,
): Promise<string | undefined> {
const plaintext = env['KIMI_CODE_PASSWORD'];
if (!plaintext) {
return undefined;
}
return hash(plaintext, BCRYPT_COST);
}
export async function verifyPassword(
candidate: string,
passwordHash: string | undefined,
): Promise<boolean> {
if (passwordHash === undefined) {
return false;
}
return compare(candidate, passwordHash);
}

View file

@ -0,0 +1,79 @@
/**
* Persistent server bearer token.
*
* The token lives at `<KIMI_CODE_HOME>/server.token` (mode 0600) and is reused
* across restarts, so a reboot does NOT rotate it. It is generated once on
* first boot and only changes when the operator explicitly runs
* `kimi server rotate-token` (which calls {@link rotateServerToken}).
*
* All writes go through {@link writePrivateFile} (atomic rename, 0700 dir,
* 0600 file) and reads through {@link readPrivateFile} (refuses files looser
* than 0600), so the on-disk token is never world/group-readable and a
* rotation is never observed half-written.
*/
import { randomBytes } from 'node:crypto';
import { join } from 'node:path';
import { readPrivateFile, writePrivateFile } from './privateFiles';
/** On-disk filename for the persistent token, relative to KIMI_CODE_HOME. */
export const SERVER_TOKEN_FILE = 'server.token';
/** Absolute path of the persistent token file for a given home dir. */
export function serverTokenPath(homeDir: string): string {
return join(homeDir, SERVER_TOKEN_FILE);
}
/** Fresh 256-bit token, base64url-encoded (43 chars, URL-safe). */
export function generateServerToken(): string {
return randomBytes(32).toString('base64url');
}
/** Atomically write `token` to `<homeDir>/server.token` (0600). */
export async function writeServerToken(homeDir: string, token: string): Promise<void> {
await writePrivateFile(serverTokenPath(homeDir), token);
}
/**
* Read the persistent token, or `undefined` when no token file exists yet.
* Throws if the file exists but is too permissive (not 0600).
*/
export async function readServerToken(homeDir: string): Promise<string | undefined> {
try {
const buf = await readPrivateFile(serverTokenPath(homeDir));
return buf.toString('utf8').trim();
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return undefined;
}
throw err;
}
}
/**
* Return the existing persistent token, generating and persisting one on first
* boot. An empty/unreadable file is treated as missing and regenerated.
*/
export async function loadOrCreateServerToken(homeDir: string): Promise<string> {
const existing = await readServerToken(homeDir);
if (existing !== undefined && existing.length > 0) {
return existing;
}
const token = generateServerToken();
await writeServerToken(homeDir, token);
return token;
}
/**
* Generate and persist a brand-new token, invalidating the previous one.
*
* A running server picks the new token up on its next auth check (the token
* store re-reads the file when its mtime changes), so rotation takes effect
* immediately without a restart.
*/
export async function rotateServerToken(homeDir: string): Promise<string> {
const token = generateServerToken();
await writeServerToken(homeDir, token);
return token;
}

View file

@ -0,0 +1,61 @@
import { randomBytes } from 'node:crypto';
import {
chmod,
mkdir,
open,
readFile,
rename,
rm,
stat,
} from 'node:fs/promises';
import { dirname } from 'node:path';
export class PrivateFileTooPermissiveError extends Error {
readonly code = 'EPRIVATE_FILE_TOO_PERMISSIVE';
constructor(
readonly filePath: string,
readonly mode: number,
) {
super(
`private file ${filePath} is too permissive (mode ${mode.toString(8).padStart(3, '0')}); expected 0600`,
);
this.name = 'PrivateFileTooPermissiveError';
}
}
export async function writePrivateFile(
filePath: string,
data: string | Buffer,
): Promise<void> {
const dir = dirname(filePath);
await mkdir(dir, { recursive: true, mode: 0o700 });
await chmod(dir, 0o700);
const tmp = `${filePath}.tmp.${randomBytes(8).toString('hex')}`;
let handle: Awaited<ReturnType<typeof open>> | undefined;
try {
handle = await open(tmp, 'w', 0o600);
await handle.chmod(0o600);
await handle.writeFile(data);
await handle.sync();
await handle.close();
handle = undefined;
await rename(tmp, filePath);
} catch (err) {
if (handle) {
await handle.close().catch(() => {});
}
await rm(tmp, { force: true }).catch(() => {});
throw err;
}
}
export async function readPrivateFile(filePath: string): Promise<Buffer> {
const info = await stat(filePath);
if ((info.mode & 0o077) !== 0) {
throw new PrivateFileTooPermissiveError(filePath, info.mode & 0o777);
}
return readFile(filePath);
}

View file

@ -0,0 +1,45 @@
/**
* Security response headers (ROADMAP M6.6).
*
* `createSecurityHeadersHook` builds a Fastify `onSend` hook that stamps a
* small set of defensive headers on every response once the server is exposed
* beyond loopback. Wired from `start.ts` only on non-loopback binds so the
* loopback default keeps its lean response headers.
*
* Headers:
* - `X-Content-Type-Options: nosniff` stop MIME sniffing.
* - `Referrer-Policy: no-referrer` never leak the URL to third parties.
* - `Content-Security-Policy: default-src 'self'` the bundled Web UI is
* same-origin, so `'self'` covers it; tighten later if needed.
* - `Strict-Transport-Security` ONLY when `opts.tls === true`. In this
* phase TLS is terminated by a reverse proxy (Caddy/nginx), so `start.ts`
* passes `tls: false` and HSTS is omitted here; the proxy is responsible
* for setting HSTS.
*/
import type { FastifyReply, FastifyRequest } from 'fastify';
export interface SecurityHeadersOptions {
/** When true, also emit `Strict-Transport-Security`. */
readonly tls: boolean;
}
const HSTS_VALUE = 'max-age=31536000';
/**
* Build the `onSend` hook. Returns the payload unchanged so Fastify continues
* the response pipeline with the headers applied.
*/
export function createSecurityHeadersHook(
opts: SecurityHeadersOptions,
): (req: FastifyRequest, reply: FastifyReply, payload: unknown) => Promise<unknown> {
return async (_req, reply, payload) => {
reply.header('X-Content-Type-Options', 'nosniff');
reply.header('Referrer-Policy', 'no-referrer');
reply.header('Content-Security-Policy', "default-src 'self'");
if (opts.tls === true) {
reply.header('Strict-Transport-Security', HSTS_VALUE);
}
return payload;
};
}

View file

@ -0,0 +1,80 @@
import { timingSafeEqual } from 'node:crypto';
import { readFileSync, statSync } from 'node:fs';
import { loadOrCreateServerToken, serverTokenPath } from './persistentToken';
export interface TokenStore {
readonly tokenPath: string;
getToken(): string;
isValid(candidate: string): boolean;
dispose(): Promise<void>;
}
/**
* Persistent token store over `<homeDir>/server.token`.
*
* The token is loaded (or generated) once at boot and reused across restarts.
* `getToken()`/`isValid()` re-read the file whenever its mtime changes, so a
* `kimi server rotate-token` (which rewrites the file) takes effect on a
* running server immediately no restart, no extra API. The file is small
* (43 bytes) and the common path is a single `statSync` per check.
*
* `dispose()` is intentionally a no-op: the token must survive shutdown.
*/
export async function createTokenStore(homeDir: string): Promise<TokenStore> {
const tokenPath = serverTokenPath(homeDir);
const initial = await loadOrCreateServerToken(homeDir);
const initialStat = statSync(tokenPath);
let cache: { token: string; mtimeMs: number; ino: number } = {
token: initial,
mtimeMs: initialStat.mtimeMs,
ino: initialStat.ino,
};
const currentToken = (): string => {
let st: ReturnType<typeof statSync>;
try {
st = statSync(tokenPath);
} catch {
// File temporarily unavailable — keep serving the last known token.
return cache.token;
}
// Detect a rewrite by mtime OR inode. `writePrivateFile` does an atomic
// rename, which always yields a new inode (POSIX) and a fresh mtime
// (Windows/NTFS, where `ino` is always 0). Checking both makes the reload
// robust even on filesystems with coarse (1s) mtime resolution.
if (st.mtimeMs === cache.mtimeMs && st.ino === cache.ino) {
return cache.token;
}
// Changed: re-read, but refuse a too-permissive file and never let an
// empty/partial read clobber the last good token.
if ((st.mode & 0o077) !== 0) {
return cache.token;
}
try {
const token = readFileSync(tokenPath, 'utf8').trim();
if (token.length > 0) {
cache = { token, mtimeMs: st.mtimeMs, ino: st.ino };
}
} catch {
// keep last known token
}
return cache.token;
};
return {
tokenPath,
getToken: currentToken,
isValid(candidate: string): boolean {
const tokenBuf = Buffer.from(currentToken());
const candidateBuf = Buffer.from(candidate);
if (candidateBuf.length !== tokenBuf.length) {
return false;
}
return timingSafeEqual(candidateBuf, tokenBuf);
},
async dispose(): Promise<void> {
// Persistent token: intentionally left on disk so it survives restarts.
},
};
}

View file

@ -1,9 +1,19 @@
import { createDecorator, type TelemetryClient } from '@moonshot-ai/agent-core';
import type { HostCheckOptions } from '#/middleware/hostnames';
import type { IAuthTokenService } from '#/services/auth/authTokenService';
import type { AbortHandler, FsWatchHandler, TerminalHandler } from '#/ws/connection';
export const WS_PATH = '/api/v1/ws';
/**
* `Sec-WebSocket-Protocol` subprotocol prefix used by browser clients to carry
* the bearer token during the WS upgrade handshake (browsers cannot set
* arbitrary headers on a WebSocket, so the token rides in a subprotocol). The
* full offered subprotocol is `${WS_BEARER_PROTOCOL_PREFIX}<token>`.
*/
export const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.';
export interface IWSGateway {
readonly _serviceBrand: undefined;
@ -14,11 +24,47 @@ export interface IWSGateway {
setFsWatchHandler(handler: FsWatchHandler): void;
setTerminalHandler(handler: TerminalHandler): void;
/**
* Install the `IAuthTokenService` used to validate bearer tokens on the WS
* upgrade path. Wired by `start.ts` (M5.1) AFTER construction so the
* ix-resolved, override-aware impl (not the constructor options) is what
* enforces auth letting test overrides via `serviceOverrides` take effect
* for WS too. `WSGatewayOptions.authTokenService?` is retained only for the
* M3/M4 unit tests that construct the gateway directly.
*/
setAuthTokenService(service: IAuthTokenService): void;
}
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const IWSGateway = createDecorator<IWSGateway>('wsGateway');
/**
* Extract the bearer token from a `Sec-WebSocket-Protocol` request header.
*
* The header is a comma-separated list of offered subprotocols (e.g.
* `"kimi-code.bearer.abc, other"`). Returns the token portion of the first
* entry whose subprotocol starts with {@link WS_BEARER_PROTOCOL_PREFIX}, or
* `undefined` when the header is missing/empty, no entry matches, or the
* matching entry carries an empty token.
*/
export function extractWsBearerToken(protocolHeader: string | undefined): string | undefined {
if (protocolHeader === undefined || protocolHeader.length === 0) {
return undefined;
}
for (const rawEntry of protocolHeader.split(',')) {
const entry = rawEntry.trim();
if (entry.startsWith(WS_BEARER_PROTOCOL_PREFIX)) {
const token = entry.slice(WS_BEARER_PROTOCOL_PREFIX.length);
if (token.length === 0) {
return undefined;
}
return token;
}
}
return undefined;
}
export interface WSGatewayOptions {
pingIntervalMs?: number;
@ -38,4 +84,28 @@ export interface WSGatewayOptions {
* web-path events share one sink + context.
*/
telemetry?: TelemetryClient;
/**
* When set, the WS upgrade path requires a valid bearer token (via the
* `Authorization` header or the `kimi-code.bearer.<token>` subprotocol).
* When unset (e.g. tests / pre-M5.1 boots), upgrade auth is skipped.
*/
authTokenService?: IAuthTokenService;
/**
* Optional Host-header allowlist enforced on the WS upgrade path (ROADMAP
* M4.3). When set, upgrades whose `Host` is not allowed are rejected with
* `403 Forbidden` before token validation. When unset (tests / pre-M5.1
* boots), the WS Host check is skipped HTTP-level Host enforcement in
* `start.ts` still covers non-upgrade requests.
*/
hostCheck?: HostCheckOptions;
/**
* Optional Origin allowlist enforced on the WS upgrade path (ROADMAP M4.3).
* When set, a present browser `Origin` must be same-origin or listed here;
* an absent `Origin` (Node `ws` clients) is allowed. When unset (tests /
* pre-M5.1 boots), the WS Origin check is skipped.
*/
allowedOrigins?: readonly string[];
}

View file

@ -4,6 +4,9 @@ import type { Socket } from 'node:net';
import { Disposable, ILogService } from '@moonshot-ai/agent-core';
import { WebSocketServer, type WebSocket } from 'ws';
import { isAllowedHost } from '#/middleware/hostnames';
import { isOriginAllowed } from '#/middleware/origin';
import type { IAuthTokenService } from '#/services/auth/authTokenService';
import {
WsConnection,
type AbortHandler,
@ -15,7 +18,13 @@ import { IConnectionRegistry } from './connectionRegistry';
import { IRestGateway } from './restGateway';
import { ISessionClientsService } from './sessionClients';
import { IWSBroadcastService } from './wsBroadcast';
import { IWSGateway, type WSGatewayOptions, WS_PATH } from './wsGateway';
import {
extractWsBearerToken,
IWSGateway,
type WSGatewayOptions,
WS_BEARER_PROTOCOL_PREFIX,
WS_PATH,
} from './wsGateway';
export class WSGateway extends Disposable implements IWSGateway {
readonly _serviceBrand: undefined;
@ -26,6 +35,7 @@ export class WSGateway extends Disposable implements IWSGateway {
private abortHandler: AbortHandler | undefined;
private fsWatchHandler: FsWatchHandler | undefined;
private terminalHandler: TerminalHandler | undefined;
private authTokenService: IAuthTokenService | undefined;
private detached = false;
constructor(
@ -37,9 +47,23 @@ export class WSGateway extends Disposable implements IWSGateway {
@ILogService private readonly logger: ILogService,
) {
super();
this.wss = new WebSocketServer({ noServer: true });
this.authTokenService = options.authTokenService;
this.wss = new WebSocketServer({
noServer: true,
// Browsers require the server to select one of the offered subprotocols;
// echo back the `kimi-code.bearer.<token>` subprotocol when present so
// token-carrying browser clients complete the handshake.
handleProtocols: (protocols: Set<string>) => {
for (const p of protocols) {
if (p.startsWith(WS_BEARER_PROTOCOL_PREFIX)) return p;
}
return false;
},
});
this.server = this.restGateway.app.server;
this.upgradeListener = (req, sock, head) => this.onUpgrade(req, sock, head);
this.upgradeListener = (req, sock, head) => {
void this.onUpgrade(req, sock, head);
};
this.server.on('upgrade', this.upgradeListener);
this.logger.debug({ path: WS_PATH }, 'ws gateway attached upgrade listener');
}
@ -56,7 +80,11 @@ export class WSGateway extends Disposable implements IWSGateway {
this.terminalHandler = handler;
}
private onUpgrade(req: IncomingMessage, socket: Socket, head: Buffer): void {
setAuthTokenService(service: IAuthTokenService): void {
this.authTokenService = service;
}
private async onUpgrade(req: IncomingMessage, socket: Socket, head: Buffer): Promise<void> {
const url = req.url ?? '';
const path = url.split('?', 1)[0];
if (path !== WS_PATH) {
@ -68,6 +96,49 @@ export class WSGateway extends Disposable implements IWSGateway {
// ~40 ms clusters, making the stream look stuttery. Trade a little bandwidth
// for lower latency.
socket.setNoDelay(true);
// Host / Origin checks (ROADMAP M4.3) — enforced BEFORE token validation
// and only when the corresponding option is provided. When unset (tests /
// pre-M5.1 boots) the checks are skipped so existing clients (incl. Node
// `ws`, which sends no `Origin`) keep working. Origin is present-only: a
// missing `Origin` is treated as a non-browser client and allowed.
const hostCheck = this.options.hostCheck;
if (hostCheck !== undefined && !isAllowedHost(req.headers.host, hostCheck)) {
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
const allowedOrigins = this.options.allowedOrigins;
if (
allowedOrigins !== undefined &&
!isOriginAllowed(req.headers.origin, req.headers.host, allowedOrigins)
) {
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
const authTokenService = this.authTokenService;
if (authTokenService !== undefined) {
const authorization = req.headers.authorization;
const token = authorization?.startsWith('Bearer ')
? authorization.slice('Bearer '.length)
: extractWsBearerToken(req.headers['sec-websocket-protocol']);
// `isValid` is the only await on this path; wrap it so a rejection
// destroys the socket instead of escaping as an unhandled rejection.
let ok = false;
try {
ok = token !== undefined && (await authTokenService.isValid(token));
} catch {
ok = false;
}
if (!ok) {
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
}
this.wss.handleUpgrade(req, socket, head, (ws) => this.onConnect(ws, req));
}

View file

@ -64,6 +64,12 @@ export function createServerServiceCollection(
new SyncDescriptor(Services.CoreProcessService, [server.coreProcessOptions ?? {}], false),
);
// `IAuthTokenService` (ROADMAP M2.1) is intentionally NOT registered here:
// its real instance needs an async-built `TokenStore` + `passwordHash` that
// are only available in `start.ts` (M5.1). It is therefore supplied via
// `server.serviceOverrides` (last-wins) — the same seam tests use to inject
// a fixed-token impl. A silent default would be a security hole, so the
// absence is deliberate: an unconfigured server has no auth token service.
for (const [id, override] of server.serviceOverrides ?? []) {
services.set(id, override);
}

View file

@ -10,6 +10,14 @@ import {
import { installErrorHandler } from './error-handler';
import { transformOpenApiDocument } from './openapi/transforms';
import { acquireLock, ServerLockedError } from './lock';
import { createAuthHook } from '#/middleware/auth';
import { createAuthFailureLimiter } from '#/middleware/rateLimit';
import {
createHostCheck,
isHostCheckDisabled,
parseAllowedHosts,
} from '#/middleware/hostnames';
import { createOriginHook, parseCorsOrigins } from '#/middleware/origin';
import {
createServerLogger,
type ServerLogLevel,
@ -28,6 +36,14 @@ import {
} from '#/services/gateway';
import { createServerServiceCollection } from '#/services/serviceCollection';
import { ISnapshotService, loadSnapshotConfig } from '#/services/snapshot';
import {
createAuthTokenService,
IAuthTokenService,
} from '#/services/auth/authTokenService';
import { classify } from '#/services/auth/bindClassify';
import { resolvePasswordHash } from '#/services/auth/password';
import { createSecurityHeadersHook } from '#/services/auth/securityHeaders';
import { createTokenStore } from '#/services/auth/tokenStore';
import { getServerVersion } from './version';
import { registerWebAssetRoutes } from './routes/webAssets';
@ -46,6 +62,36 @@ export interface ServerStartOptions {
debugEndpoints?: boolean;
/**
* Override the classification of a wildcard bind (`0.0.0.0` / `::` / empty).
* Default (unset) treats wildcards as `public` (most strict); set to `lan`
* to relax to LAN-tier hardening. See `services/auth/bindClassify.ts`.
*/
bindClass?: 'lan' | 'public';
/**
* Allow a non-loopback bind WITHOUT a TLS-terminating reverse proxy. Default
* false: binding beyond loopback refuses to start unless this is set, so a
* public/LAN bind is never served over plain HTTP by accident. Pass
* `--insecure-no-tls` (or set this) only when you accept the risk.
*/
insecureNoTls?: boolean;
/**
* Allow `POST /api/v1/shutdown` on a non-loopback bind. Default false: the
* shutdown route is NOT registered (404) on a public/LAN bind unless this is
* set. Loopback always mounts it.
*/
allowRemoteShutdown?: boolean;
/**
* Allow the PTY `/api/v1/terminals/*` routes on a non-loopback bind. Default
* false: terminals routes are NOT registered (404) on a public/LAN bind
* unless this is set (remote shell is the highest-risk surface). Loopback
* always mounts them.
*/
allowRemoteTerminals?: boolean;
webAssetsDir?: string;
serviceOverrides?: ReadonlyArray<readonly [ServiceIdentifier<unknown>, unknown]>;
@ -88,6 +134,21 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
app.setSerializerCompiler(() => (data) => JSON.stringify(data));
installErrorHandler(app);
// Host / Origin checks (ROADMAP M4.3). Registered before any route so they
// run ahead of every handler and ahead of the (future, M5.1) auth hook.
// Host is evaluated before Origin; both are uniform across bindings (PLAN
// D3) — even on loopback — so behavior does not depend on how the server is
// reached. The default-allow set keeps `app.inject` (`Host: localhost:80`)
// and real `fetch` to `127.0.0.1:<port>` working.
const hostCheck = createHostCheck({
boundHost: opts.host,
extra: parseAllowedHosts(process.env),
disable: isHostCheckDisabled(process.env),
});
const originHook = createOriginHook({ allowedOrigins: parseCorsOrigins(process.env) });
app.addHook('onRequest', hostCheck.onRequest);
app.addHook('onRequest', originHook);
const serverVersion = opts.coreProcessOptions?.identity?.version ?? getServerVersion();
async function registerOpenApi(): Promise<void> {
@ -138,26 +199,138 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
}),
};
// Token auth (ROADMAP M5.1). The real `IAuthTokenService` needs an
// async-built `TokenStore` over the persistent `<homeDir>/server.token`
// (0600; generated once on first boot and reused across restarts) and an
// optional bcrypt password hash — both awaited here, then supplied to the
// collection via `serviceOverrides` so tests can inject a fixed-token impl
// that wins (last-wins) over this default. The store re-reads the file when
// its mtime changes, so `kimi server rotate-token` takes effect without a
// restart; the file is intentionally kept on shutdown (dispose is a no-op).
const tokenStore = await createTokenStore(envService.homeDir);
const passwordHash = await resolvePasswordHash(process.env);
const defaultAuth = createAuthTokenService({ tokenStore, passwordHash });
// Public-bind hardening gate (ROADMAP M6.3). Classify the bind host and, for
// any non-loopback tier (LAN or public), refuse to start unless the operator
// explicitly acknowledged that TLS is terminated elsewhere (`insecureNoTls`).
// Auth is bearer-token based: the persistent token is printed in the startup
// banner and reused across restarts, so a password is no longer mandatory for
// a non-loopback bind — `KIMI_CODE_PASSWORD` remains an optional additional
// credential. Failing here (before the container is built and before we
// listen) keeps a public/LAN bind from ever serving plain HTTP by accident.
// On refusal we release the lock so the operator can retry cleanly.
const bindClass = classify(opts.host, { bindClass: opts.bindClass });
if (bindClass !== 'loopback') {
const refusePublicBind = async (message: string): Promise<never> => {
try {
await tokenStore.dispose();
} catch {
// best-effort cleanup of the token file on boot refusal
}
lockHandle.release();
throw new Error(message);
};
if (opts.insecureNoTls !== true) {
await refusePublicBind(
'Refusing to bind a non-loopback host without TLS. ' +
'Put the server behind a TLS-terminating reverse proxy (Caddy/nginx), ' +
'or pass --insecure-no-tls to acknowledge the risk.',
);
}
if (passwordHash === undefined) {
pinoLogger.warn(
{ host: opts.host, bindClass },
'binding non-loopback host with token-only auth (no KIMI_CODE_PASSWORD) — the bearer token printed in the startup banner is the only credential protecting this server',
);
}
pinoLogger.warn(
{ host: opts.host, bindClass },
'binding non-loopback host without TLS — use a reverse proxy or tunnel in production',
);
}
const services = createServerServiceCollection({
server: opts,
server: {
...opts,
// WS Host/Origin defaults (ROADMAP M4.3 / M5.1): mirror the HTTP checks
// on the upgrade path. Caller-supplied values win (used by the
// host-origin e2e tests). `authTokenService` is NOT threaded here — it
// reaches the WS gateway via `setAuthTokenService` below so the
// override-aware impl enforces auth.
wsGatewayOptions: {
...opts.wsGatewayOptions,
hostCheck: opts.wsGatewayOptions?.hostCheck ?? {
boundHost: opts.host,
extra: parseAllowedHosts(process.env),
disable: isHostCheckDisabled(process.env),
},
allowedOrigins:
opts.wsGatewayOptions?.allowedOrigins ?? parseCorsOrigins(process.env),
},
serviceOverrides: [
[IAuthTokenService, defaultAuth],
...(opts.serviceOverrides ?? []),
],
},
app,
pinoLogger,
envService,
});
const ix = new InstantiationService(services);
// Auth hook (ROADMAP M5.1). Registered after Host/Origin (above) and before
// routes, so a rejected request never reaches a handler. Resolved from the
// container so a test-injected fixed-token impl is what enforces auth.
//
// Auth-failure rate limit (ROADMAP M6.4): only on a non-loopback bind, where
// brute-force attempts are reachable from the network. Loopback keeps the
// original "no limiter" behavior so local retries are never throttled.
const authTokenService = ix.invokeFunction((a) => a.get(IAuthTokenService));
const authFailureLimiter =
bindClass !== 'loopback' ? createAuthFailureLimiter() : undefined;
app.addHook('onRequest', createAuthHook(authTokenService, { limiter: authFailureLimiter }));
// Security response headers (ROADMAP M6.6): only on a non-loopback bind.
// TLS is terminated by a reverse proxy in this phase, so HSTS is omitted
// here (`tls: false`) — the proxy is responsible for setting it.
if (bindClass !== 'loopback') {
app.addHook('onSend', createSecurityHeadersHook({ tls: false }));
}
// Bind classification (`bindClass`, computed above next to the password/TLS
// gate) drives every hardening decision from here on: debug routes now;
// rate limit, dangerous endpoints, and security headers in M6.4M6.6.
// Debug routes (ROADMAP M5.3): only mount `/api/v1/debug/*` when bound to a
// loopback interface. On a non-loopback bind these introspection/mutation
// endpoints would be reachable from the network, so suppress them even if
// the caller asked for them, and warn so the operator knows.
if (opts.debugEndpoints === true && bindClass !== 'loopback') {
pinoLogger.warn(
{ host: opts.host, bindClass },
'debug endpoints suppressed: refusing to mount /api/v1/debug/* on a non-loopback bind',
);
}
// Dangerous-endpoint downgrade (ROADMAP M6.5): on a non-loopback bind the
// shutdown + terminals routes are NOT registered (404) unless the operator
// explicitly opts in. Loopback always mounts them (backward compatible).
const allowRemoteShutdown = opts.allowRemoteShutdown === true;
const allowRemoteTerminals = opts.allowRemoteTerminals === true;
await registerApiV1Routes(app, ix, {
serverVersion,
debugEndpoints: opts.debugEndpoints,
debugEndpoints: opts.debugEndpoints === true && bindClass === 'loopback',
enableShutdown: bindClass === 'loopback' || allowRemoteShutdown,
enableTerminals: bindClass === 'loopback' || allowRemoteTerminals,
});
app.get('/asyncapi.json', async (req, reply) => {
const host = typeof req.headers.host === 'string' ? req.headers.host : undefined;
app.get('/asyncapi.json', async (_req, reply) => {
// Reflect the bound host, never the caller-supplied `Host` header (PLAN
// §3.6-3: Host-header reflection is an information-leak / SSRF-adjacent
// hole once the server is reachable beyond localhost).
return reply.type('application/json').send(
createAsyncApiDocument({
version: serverVersion,
serverHost: host,
}),
createAsyncApiDocument({ version: serverVersion, serverHost: opts.host }),
);
});
app.get('/openapi.json', async (_req, reply) => {
@ -172,6 +345,11 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
try {
await app.ready();
} catch (error) {
try {
await tokenStore.dispose();
} catch {
// best-effort cleanup of the token file on boot failure
}
lockHandle.release();
throw error;
}
@ -211,6 +389,10 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
const wsGw = a.get(IWSGateway);
// Hand the override-aware auth impl to the WS gateway so the upgrade
// path enforces the same token the HTTP hook uses (ROADMAP M5.1).
wsGw.setAuthTokenService(authTokenService);
const built = a.get(ICoreProcessService);
const sessionService = a.get(ISessionService);
@ -331,6 +513,11 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
ix.dispose();
} catch {
}
try {
await tokenStore.dispose();
} catch {
// best-effort cleanup of the token file on boot failure
}
lockHandle.release();
throw error;
@ -343,6 +530,11 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
ix.dispose();
} catch {
}
try {
await tokenStore.dispose();
} catch {
// best-effort cleanup of the token file on boot failure
}
lockHandle.release();
throw error;
@ -364,6 +556,11 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
ix.dispose();
} catch {
}
try {
await tokenStore.dispose();
} catch {
// best-effort cleanup of the token file on boot failure
}
lockHandle.release();
throw error;
@ -403,6 +600,20 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
}
// The persistent token is intentionally left on disk so it survives the
// next start (ROADMAP M5.1). dispose() is a no-op for the persistent store;
// the call is kept so the interface is honored uniformly and a test
// override can still observe shutdown.
try {
await tokenStore.dispose();
} catch {
// ignore — token file may already be gone
}
// Stop the auth-failure limiter's cleanup timer (ROADMAP M6.4). Only set
// on non-loopback binds; the `?.` is a no-op on loopback.
authFailureLimiter?.dispose();
lockHandle.release();
};

View file

@ -0,0 +1,286 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`API surface snapshot > matches the documented v1 route table and meta endpoints 1`] = `
{
"meta": [
[
"GET",
"/",
404,
],
[
"GET",
"/asyncapi.json",
200,
],
[
"GET",
"/healthz",
404,
],
[
"GET",
"/openapi.json",
200,
],
],
"routes": [
[
"DELETE",
"/api/v1/files/{file_id}",
],
[
"DELETE",
"/api/v1/oauth/login",
],
[
"DELETE",
"/api/v1/workspaces/{workspace_id}",
],
[
"GET",
"/api/v1/auth",
],
[
"GET",
"/api/v1/config",
],
[
"GET",
"/api/v1/connections",
],
[
"GET",
"/api/v1/files/{file_id}",
],
[
"GET",
"/api/v1/fs:browse",
],
[
"GET",
"/api/v1/fs:home",
],
[
"GET",
"/api/v1/healthz",
],
[
"GET",
"/api/v1/mcp/servers",
],
[
"GET",
"/api/v1/meta",
],
[
"GET",
"/api/v1/models",
],
[
"GET",
"/api/v1/oauth/login",
],
[
"GET",
"/api/v1/providers",
],
[
"GET",
"/api/v1/providers/{provider_id}",
],
[
"GET",
"/api/v1/sessions",
],
[
"GET",
"/api/v1/sessions/{session_id}",
],
[
"GET",
"/api/v1/sessions/{session_id}/approvals",
],
[
"GET",
"/api/v1/sessions/{session_id}/children",
],
[
"GET",
"/api/v1/sessions/{session_id}/fs/{*}",
],
[
"GET",
"/api/v1/sessions/{session_id}/messages",
],
[
"GET",
"/api/v1/sessions/{session_id}/messages/{message_id}",
],
[
"GET",
"/api/v1/sessions/{session_id}/profile",
],
[
"GET",
"/api/v1/sessions/{session_id}/prompts",
],
[
"GET",
"/api/v1/sessions/{session_id}/questions",
],
[
"GET",
"/api/v1/sessions/{session_id}/skills",
],
[
"GET",
"/api/v1/sessions/{session_id}/snapshot",
],
[
"GET",
"/api/v1/sessions/{session_id}/status",
],
[
"GET",
"/api/v1/sessions/{session_id}/tasks",
],
[
"GET",
"/api/v1/sessions/{session_id}/tasks/{task_id}",
],
[
"GET",
"/api/v1/sessions/{session_id}/terminals",
],
[
"GET",
"/api/v1/sessions/{session_id}/terminals/{terminal_id}",
],
[
"GET",
"/api/v1/sessions/{session_id}/warnings",
],
[
"GET",
"/api/v1/tools",
],
[
"GET",
"/api/v1/workspaces",
],
[
"GET",
"/asyncapi.json",
],
[
"GET",
"/openapi.json",
],
[
"PATCH",
"/api/v1/workspaces/{workspace_id}",
],
[
"POST",
"/api/v1/config",
],
[
"POST",
"/api/v1/files",
],
[
"POST",
"/api/v1/mcp/servers/{tail}",
],
[
"POST",
"/api/v1/models/{tail}",
],
[
"POST",
"/api/v1/oauth/login",
],
[
"POST",
"/api/v1/oauth/logout",
],
[
"POST",
"/api/v1/providers{refresh_oauth}",
],
[
"POST",
"/api/v1/sessions",
],
[
"POST",
"/api/v1/sessions/{session_id}:compact",
],
[
"POST",
"/api/v1/sessions/{session_id}:fork",
],
[
"POST",
"/api/v1/sessions/{session_id}:undo",
],
[
"POST",
"/api/v1/sessions/{session_id}/{tail}",
],
[
"POST",
"/api/v1/sessions/{session_id}/approvals/{approval_id}",
],
[
"POST",
"/api/v1/sessions/{session_id}/children",
],
[
"POST",
"/api/v1/sessions/{session_id}/profile",
],
[
"POST",
"/api/v1/sessions/{session_id}/prompts",
],
[
"POST",
"/api/v1/sessions/{session_id}/prompts:steer",
],
[
"POST",
"/api/v1/sessions/{session_id}/prompts/{tail}",
],
[
"POST",
"/api/v1/sessions/{session_id}/questions/{tail}",
],
[
"POST",
"/api/v1/sessions/{session_id}/skills/{tail}",
],
[
"POST",
"/api/v1/sessions/{session_id}/tasks/{tail}",
],
[
"POST",
"/api/v1/sessions/{session_id}/terminals",
],
[
"POST",
"/api/v1/sessions/{session_id}/terminals/{tail}",
],
[
"POST",
"/api/v1/shutdown",
],
[
"POST",
"/api/v1/workspaces",
],
],
}
`;

View file

@ -0,0 +1,113 @@
/**
* API surface snapshot guardrail (ROADMAP M0.1).
*
* Boots `startServer` on port 0 with a tmpdir lock + isolated home dir, then
* records a stable, sorted snapshot of the documented API surface:
*
* - `routes`: every `[METHOD, path]` pair derived from `/openapi.json`
* `paths` (the documented v1 REST surface). This is the guardrail's target:
* later phases that add / remove / hide routes show an intentional diff.
* - `meta`: the `(method, url, status)` of doc/meta endpoints that sit
* outside `paths` (`/healthz`, `/openapi.json`, `/asyncapi.json`, `/`).
* Status codes prove reachability.
*
* `startServer` does not expose the Fastify `app`, so the surface is read
* through the public `/openapi.json` endpoint rather than by inspecting the
* route table directly keeping M0 a no-behavior-change phase.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pino } from 'pino';
import { afterEach, describe, expect, it } from 'vitest';
import { startServer, type RunningServer } from '../src';
import { authHeaders, fixedTokenAuth } from './helpers/serverHarness';
/** OpenAPI path-item keys that are HTTP methods (skip `parameters`, etc.). */
const HTTP_METHODS = new Set([
'get',
'put',
'post',
'delete',
'options',
'head',
'patch',
'trace',
]);
/** Meta endpoints outside the OpenAPI `paths` map to probe for reachability. */
const META_ENDPOINTS = ['/healthz', '/openapi.json', '/asyncapi.json', '/'];
describe('API surface snapshot', () => {
let tmpDir: string | undefined;
let bridgeHome: string | undefined;
let server: RunningServer | undefined;
afterEach(async () => {
if (server !== undefined) {
try {
await server.close();
} catch {
// ignore — best-effort teardown
}
server = undefined;
}
if (tmpDir !== undefined) {
rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
}
if (bridgeHome !== undefined) {
rmSync(bridgeHome, { recursive: true, force: true });
bridgeHome = undefined;
}
});
it('matches the documented v1 route table and meta endpoints', async () => {
tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-api-surface-'));
bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-api-surface-home-'));
const lockPath = join(tmpDir, 'lock');
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
logger: pino({ level: 'silent' }),
coreProcessOptions: { homeDir: bridgeHome },
});
const address = server.address;
// 1) Documented v1 REST surface, derived from /openapi.json `paths`.
const openApiRes = await fetch(`${address}/openapi.json`, { headers: authHeaders() });
expect(openApiRes.status).toBe(200);
const openApi = (await openApiRes.json()) as {
paths?: Record<string, Record<string, unknown>>;
};
const paths = openApi.paths ?? {};
expect(Object.keys(paths).length).toBeGreaterThan(0);
const routes: Array<[string, string]> = [];
for (const [path, item] of Object.entries(paths)) {
for (const key of Object.keys(item)) {
if (HTTP_METHODS.has(key.toLowerCase())) {
routes.push([key.toUpperCase(), path]);
}
}
}
routes.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]));
// 2) Doc/meta endpoints that are not part of the OpenAPI `paths` map.
const meta: Array<[string, string, number]> = [];
for (const endpoint of META_ENDPOINTS) {
const res = await fetch(`${address}${endpoint}`, { headers: authHeaders() });
meta.push(['GET', endpoint, res.status]);
}
meta.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]) || a[2] - b[2]);
expect({ routes, meta }).toMatchSnapshot();
});
});

View file

@ -34,6 +34,7 @@ import {
} from '@moonshot-ai/agent-core';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
import { rawDataToString } from '../src/ws/rawData';
import {
ApprovalService,
@ -63,6 +64,7 @@ afterEach(async () => {
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -76,12 +78,24 @@ async function bootDaemon(): Promise<RunningServer> {
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {
@ -123,7 +137,7 @@ async function openSubscriber(
const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws';
const received: Record<string, unknown>[] = [];
const ws = await new Promise<WebSocket>((resolve, reject) => {
const sock = new WebSocket(wsUrl);
const sock = new WebSocket(wsUrl, ['kimi-code.bearer.test-token']);
sock.on('message', (data) => {
try {
received.push(JSON.parse(rawDataToString(data)) as Record<string, unknown>);

View file

@ -0,0 +1,188 @@
import { request as httpRequest } from 'node:http';
import Fastify, { type FastifyInstance } from 'fastify';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createAuthHook } from '#/middleware/auth';
import type { IAuthTokenService } from '#/services/auth/authTokenService';
import { boot, closeAll } from './helpers/serverHarness';
const TOKEN = 'test-token';
function fixedImpl(): IAuthTokenService {
return {
_serviceBrand: undefined,
getToken: () => TOKEN,
isValid: async (candidate) => candidate === TOKEN,
};
}
describe('createAuthHook (onRequest middleware)', () => {
let app: FastifyInstance;
beforeEach(async () => {
app = Fastify();
app.addHook('onRequest', createAuthHook(fixedImpl()));
app.get('/api/v1/healthz', async () => ({ ok: true }));
app.get('/api/v1/sessions', async () => ({ ok: true }));
app.options('/api/v1/sessions', async (_req, reply) =>
reply.code(204).send(),
);
app.get('/', async () => ({ ok: true }));
app.get('/openapi.json', async () => ({ openapi: '3.1.0' }));
// Probe surfaces the post-hook header view so we can assert redaction.
app.get('/api/v1/probe', async (req) => ({
authorization: req.headers.authorization ?? null,
}));
await app.ready();
});
afterEach(async () => {
await app.close();
});
it('bypasses GET /api/v1/healthz with no token', async () => {
const res = await app.inject({ method: 'GET', url: '/api/v1/healthz' });
expect(res.statusCode).toBe(200);
});
it('rejects GET /api/v1/sessions with no token (40101 envelope)', async () => {
const res = await app.inject({ method: 'GET', url: '/api/v1/sessions' });
expect(res.statusCode).toBe(401);
const body = res.json() as Record<string, unknown>;
expect(body['code']).toBe(40101);
expect(body['msg']).toBe('Unauthorized');
expect(body['data']).toBeNull();
expect(typeof body['request_id']).toBe('string');
});
it('rejects GET /api/v1/sessions with a wrong token', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/v1/sessions',
headers: { authorization: 'Bearer wrong-token' },
});
expect(res.statusCode).toBe(401);
expect((res.json() as Record<string, unknown>)['code']).toBe(40101);
});
it('accepts GET /api/v1/sessions with the correct bearer token', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/v1/sessions',
headers: { authorization: `Bearer ${TOKEN}` },
});
expect(res.statusCode).toBe(200);
});
it('rejects a malformed Authorization header', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/v1/sessions',
headers: { authorization: TOKEN },
});
expect(res.statusCode).toBe(401);
});
it('bypasses OPTIONS /api/v1/sessions with no token', async () => {
const res = await app.inject({
method: 'OPTIONS',
url: '/api/v1/sessions',
});
expect(res.statusCode).toBe(204);
});
it('bypasses GET / (static asset) with no token', async () => {
const res = await app.inject({ method: 'GET', url: '/' });
expect(res.statusCode).toBe(200);
});
it('does NOT bypass GET /openapi.json (meta doc stays gated)', async () => {
const res = await app.inject({ method: 'GET', url: '/openapi.json' });
expect(res.statusCode).toBe(401);
expect((res.json() as Record<string, unknown>)['code']).toBe(40101);
});
it('redacts the Authorization header view before the handler runs', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/v1/probe',
headers: { authorization: `Bearer ${TOKEN}` },
});
expect(res.statusCode).toBe(200);
const body = res.json() as { authorization: string | null };
expect(body.authorization).toBe('[redacted]');
});
});
/**
* Raw HTTP GET that lets us spoof the `Host` header. Node's `http.request`
* honors an explicit `headers.Host`, whereas `fetch` (undici) treats `Host` as
* a forbidden header and drops it.
*/
function rawGet(
port: number,
path: string,
headers: Record<string, string>,
): Promise<{ status: number; body: unknown }> {
return new Promise((resolve, reject) => {
const req = httpRequest(
{ host: '127.0.0.1', port, path, method: 'GET', headers },
(res) => {
let data = '';
res.setEncoding('utf8');
res.on('data', (chunk: string) => {
data += chunk;
});
res.on('end', () => {
try {
resolve({
status: res.statusCode ?? 0,
body: JSON.parse(data) as unknown,
});
} catch (err) {
reject(err);
}
});
},
);
req.on('error', reject);
req.end();
});
}
describe('/asyncapi.json serverHost (M2.3 Host-reflection fix)', () => {
afterEach(async () => {
await closeAll();
});
it('rejects a spoofed Host header (M4.3) and otherwise reflects the bound host', async () => {
const harness = await boot({ host: '127.0.0.1', port: 0 });
const port = Number(new URL(harness.address).port);
// M4.3: a spoofed / disallowed Host is rejected by the global Host check
// before it ever reaches the route — a stronger guarantee than the M2.3
// "reflect bound host" fix, since the spoofed value can no longer leak.
const spoofed = await rawGet(port, '/asyncapi.json', {
Host: 'evil.com',
});
expect(spoofed.status).toBe(403);
// With an allowed Host, the route responds and still reflects the BOUND
// host, never the caller-supplied Host header (the original M2.3 fix).
// /asyncapi.json is gated by the M5.1 auth hook, so carry the fixed token
// the harness injected via `boot()`.
const { status, body } = await rawGet(port, '/asyncapi.json', {
Host: '127.0.0.1',
Authorization: 'Bearer test-token',
});
expect(status).toBe(200);
const servers = (body as { servers: { local: { host: string } } }).servers;
expect(servers.local.host).toBe('127.0.0.1');
expect(servers.local.host).not.toContain('evil.com');
});
});

View file

@ -0,0 +1,208 @@
/**
* Production auth wiring end-to-end (ROADMAP M5.1).
*
* Unlike the override-driven tests (which inject a fixed-token
* `IAuthTokenService`), this file boots `startServer` with NO auth override so
* the REAL `defaultAuth` is built: a persistent token written to
* `<homeDir>/server.token` (0600) plus the HTTP/WS auth hooks. The token
* is read back from disk exactly what the CLI does (M5.4) and exercised
* against a gated HTTP route and the WS upgrade path. This proves the
* production wiring, not just the override seam.
*/
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { WebSocket, type RawData } from 'ws';
import { startServer, type RunningServer } from '../src';
import { rawDataToString } from '../src/ws/rawData';
let tmpDir: string;
let lockPath: string;
let bridgeHome: string;
const running: RunningServer[] = [];
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-auth-wiring-'));
lockPath = join(tmpDir, 'lock');
bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-auth-wiring-home-'));
});
afterEach(async () => {
for (const r of running.splice(0)) {
try {
await r.close();
} catch {
// ignore
}
}
rmSync(tmpDir, { recursive: true, force: true });
rmSync(bridgeHome, { recursive: true, force: true });
});
function tokenPath(): string {
return join(bridgeHome, 'server.token');
}
function readToken(): string {
return readFileSync(tokenPath(), 'utf8').trim();
}
async function bootReal(): Promise<RunningServer> {
const r = await startServer({
host: '127.0.0.1',
port: 0,
lockPath,
logger: pino({ level: 'silent' }),
coreProcessOptions: { homeDir: bridgeHome },
});
running.push(r);
return r;
}
function wsUrl(http: string): string {
return http.replace(/^http:\/\//, 'ws://') + '/api/v1/ws';
}
interface WsFrame {
type: string;
[k: string]: unknown;
}
interface Conn {
ws: WebSocket;
queue: WsFrame[];
waiters: Array<(frame: WsFrame) => void>;
closed: Promise<{ code: number; reason: string }>;
}
function openConn(url: string, protocols?: string[]): Promise<Conn> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url, protocols);
const queue: WsFrame[] = [];
const waiters: Array<(frame: WsFrame) => void> = [];
let closedResolve: (v: { code: number; reason: string }) => void;
const closed = new Promise<{ code: number; reason: string }>((res) => {
closedResolve = res;
});
// Attach the message listener BEFORE 'open' can fire so the immediate
// `server_hello` frame is never dropped.
ws.on('message', (data: RawData) => {
try {
const frame = JSON.parse(rawDataToString(data)) as WsFrame;
if (waiters.length > 0) waiters.shift()?.(frame);
else queue.push(frame);
} catch {
// ignore non-JSON frames
}
});
ws.on('close', (code, reason) => closedResolve({ code, reason: String(reason) }));
ws.once('open', () => resolve({ ws, queue, waiters, closed }));
ws.once('error', reject);
});
}
function receive(conn: Conn, timeoutMs: number): Promise<WsFrame> {
return new Promise((resolve, reject) => {
if (conn.queue.length > 0) {
resolve(conn.queue.shift()!);
return;
}
const t = setTimeout(() => {
const idx = conn.waiters.indexOf(waiter);
if (idx >= 0) conn.waiters.splice(idx, 1);
reject(new Error(`no message within ${timeoutMs}ms`));
}, timeoutMs);
const waiter = (frame: WsFrame): void => {
clearTimeout(t);
resolve(frame);
};
conn.waiters.push(waiter);
});
}
async function receiveType(conn: Conn, type: string, timeoutMs: number): Promise<WsFrame> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const remaining = deadline - Date.now();
if (remaining <= 0) throw new Error(`no message of type ${type} within ${timeoutMs}ms`);
const frame = await receive(conn, remaining);
if (frame.type === type) return frame;
}
}
function expectRejected(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
const t = setTimeout(
() => done(new Error('connection was not rejected within timeout')),
1500,
);
const done = (err?: Error): void => {
clearTimeout(t);
ws.removeAllListeners();
try {
ws.terminate();
} catch {
// ignore
}
if (err === undefined) {
resolve();
} else {
reject(err);
}
};
ws.once('open', () => done(new Error('connection unexpectedly opened')));
ws.once('error', () => done());
ws.once('close', () => done());
});
}
describe('production auth wiring (M5.1)', () => {
it('writes a 0600 token file at boot and keeps it on close (persistent)', async () => {
const r = await bootReal();
const p = tokenPath();
const info = statSync(p);
expect(info.mode & 0o777).toBe(0o600);
const token = readToken();
expect(token.length).toBeGreaterThan(0);
await r.close();
// Persistent token: the file survives shutdown so the next start reuses it.
expect(statSync(p).mode & 0o777).toBe(0o600);
});
it('gates HTTP: 200 with the token, 401 without', async () => {
const r = await bootReal();
const token = readToken();
const ok = await fetch(`${r.address}/openapi.json`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(ok.status).toBe(200);
const bad = await fetch(`${r.address}/openapi.json`);
expect(bad.status).toBe(401);
const body = (await bad.json()) as { code: number };
expect(body.code).toBe(40101);
});
it('gates WS: server_hello with the token, rejected without', async () => {
const r = await bootReal();
const token = readToken();
const conn = await openConn(wsUrl(r.address), [`kimi-code.bearer.${token}`]);
const hello = await receiveType(conn, 'server_hello', 1000);
expect(hello.type).toBe('server_hello');
conn.ws.close();
await conn.closed;
await expectRejected(wsUrl(r.address));
});
});

View file

@ -32,6 +32,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { authSummarySchema, type AuthSummary } from '@moonshot-ai/protocol';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
@ -57,6 +58,7 @@ afterEach(async () => {
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -69,12 +71,24 @@ async function bootDaemon(): Promise<RunningServer> {
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {

View file

@ -0,0 +1,127 @@
import { mkdtempSync, rmSync } from 'node:fs';
import type { Server as HttpServer } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
InstantiationService,
type IEnvironmentService,
} from '@moonshot-ai/agent-core';
import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
createAuthTokenService,
IAuthTokenService,
} from '#/services/auth/authTokenService';
import { resolvePasswordHash } from '#/services/auth/password';
import { createTokenStore, type TokenStore } from '#/services/auth/tokenStore';
import type { FastifyLike } from '#/services/gateway/restGateway';
import { createServerServiceCollection } from '#/services/serviceCollection';
function tmpEnv(): { dir: string; env: IEnvironmentService } {
const dir = mkdtempSync(join(tmpdir(), 'kimi-auth-token-test-'));
return {
dir,
env: {
_serviceBrand: undefined,
homeDir: dir,
configPath: join(dir, 'config.toml'),
},
};
}
/** Minimal Fastify shape — `FastifyRestGateway` only stores it at construction. */
function appStub(): FastifyLike {
return {
server: {} as unknown as HttpServer,
listen: async () => 'http://127.0.0.1:0',
close: async () => {},
};
}
describe('createAuthTokenService', () => {
let homeDir: string;
let store: TokenStore;
beforeEach(async () => {
homeDir = mkdtempSync(join(tmpdir(), 'kimi-auth-token-store-'));
store = await createTokenStore(homeDir);
});
afterEach(async () => {
await store.dispose();
rmSync(homeDir, { recursive: true, force: true });
});
it('getToken() returns the tokenStore token', () => {
const svc = createAuthTokenService({ tokenStore: store, passwordHash: undefined });
expect(svc.getToken()).toBe(store.getToken());
});
it('isValid accepts the token', async () => {
const svc = createAuthTokenService({ tokenStore: store, passwordHash: undefined });
expect(await svc.isValid(store.getToken())).toBe(true);
});
it('isValid accepts the password when a hash is configured', async () => {
const passwordHash = await resolvePasswordHash({
KIMI_CODE_PASSWORD: 'correct horse battery staple',
});
const svc = createAuthTokenService({ tokenStore: store, passwordHash });
expect(await svc.isValid('correct horse battery staple')).toBe(true);
});
it('isValid rejects a wrong candidate', async () => {
const passwordHash = await resolvePasswordHash({
KIMI_CODE_PASSWORD: 'correct horse battery staple',
});
const svc = createAuthTokenService({ tokenStore: store, passwordHash });
expect(await svc.isValid('wrong')).toBe(false);
});
it('isValid accepts only the token when passwordHash is undefined', async () => {
const svc = createAuthTokenService({ tokenStore: store, passwordHash: undefined });
expect(await svc.isValid(store.getToken())).toBe(true);
expect(await svc.isValid('any-password')).toBe(false);
});
});
describe('IAuthTokenService via serviceCollection override', () => {
let env: IEnvironmentService;
let dir: string;
let ix: InstantiationService;
const fixed: IAuthTokenService = {
_serviceBrand: undefined,
getToken: () => 'fixed-token',
isValid: async (candidate) => candidate === 'fixed-token',
};
beforeEach(() => {
({ env, dir } = tmpEnv());
const collection = createServerServiceCollection({
server: {
host: '127.0.0.1',
port: 0,
serviceOverrides: [[IAuthTokenService, fixed] as const],
},
app: appStub(),
pinoLogger: pino({ level: 'silent' }),
envService: env,
});
ix = new InstantiationService(collection);
});
afterEach(() => {
ix.dispose();
rmSync(dir, { recursive: true, force: true });
});
it('retrieves the injected impl and validates its own token', async () => {
const resolved = ix.invokeFunction((a) => a.get(IAuthTokenService));
expect(resolved).toBe(fixed);
expect(await resolved.isValid(resolved.getToken())).toBe(true);
expect(await resolved.isValid('nope')).toBe(false);
});
});

View file

@ -0,0 +1,85 @@
/**
* `classify(host)` tier classification (ROADMAP M6.1).
*
* Pins the loopback / lan / public boundaries that gate every M6 hardening
* decision, including the wildcard defaults and the RFC1918 / link-local
* edges (e.g. `172.31.255.255` inside `172.16/12`, `172.32.0.1` just outside).
*/
import { describe, expect, it } from 'vitest';
import { classify } from '../src/services/auth/bindClassify';
describe('classify', () => {
describe('loopback', () => {
it.each([
['127.0.0.1'],
['127.255.255.255'],
['::1'],
['localhost'],
])('%s → loopback', (host) => {
expect(classify(host)).toBe('loopback');
});
});
describe('lan', () => {
it.each([
['192.168.1.5'],
['10.0.0.1'],
['172.16.0.1'],
['172.31.255.255'],
['169.254.1.1'],
['fe80::1'],
['fe80:0000:0000:0000:0000:0000:0000:0001'],
['febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff'],
])('%s → lan', (host) => {
expect(classify(host)).toBe('lan');
});
});
describe('public', () => {
it.each([
['8.8.8.8'],
['172.32.0.1'],
['203.0.113.5'],
['2001:4860:4860::8888'],
['fec0::1'],
['example.com'],
])('%s → public', (host) => {
expect(classify(host)).toBe('public');
});
});
describe('wildcard binds default to public unless relaxed', () => {
it('0.0.0.0 → public by default', () => {
expect(classify('0.0.0.0')).toBe('public');
});
it('0.0.0.0 → lan when bindClass=lan', () => {
expect(classify('0.0.0.0', { bindClass: 'lan' })).toBe('lan');
});
it('0.0.0.0 → public when bindClass=public (explicit)', () => {
expect(classify('0.0.0.0', { bindClass: 'public' })).toBe('public');
});
it(':: → public by default', () => {
expect(classify('::')).toBe('public');
});
it(':: → lan when bindClass=lan', () => {
expect(classify('::', { bindClass: 'lan' })).toBe('lan');
});
it('empty string → public by default', () => {
expect(classify('')).toBe('public');
});
});
it('bindClass override does not reclassify a concrete loopback/lan host', () => {
// The override applies only to wildcard binds; a real loopback stays
// loopback even if a caller passes bindClass=public by mistake.
expect(classify('127.0.0.1', { bindClass: 'public' })).toBe('loopback');
expect(classify('192.168.1.5', { bindClass: 'public' })).toBe('lan');
});
});

View file

@ -6,6 +6,7 @@ import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
@ -31,6 +32,7 @@ afterEach(async () => {
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -43,12 +45,24 @@ async function bootDaemon(): Promise<RunningServer> {
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {

View file

@ -29,6 +29,7 @@ import {
startServer,
type RunningServer,
} from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
import { rawDataToString } from '../src/ws/rawData';
let tmpDir: string;
@ -56,6 +57,7 @@ afterEach(async () => {
async function spawn(): Promise<RunningServer> {
const r = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -71,12 +73,24 @@ async function spawn(): Promise<RunningServer> {
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function wsUrl(http: string): string {
@ -100,7 +114,7 @@ interface Conn {
function openConn(url: string): Promise<Conn> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
const ws = new WebSocket(url, ['kimi-code.bearer.test-token']);
const queue: WsFrame[] = [];
const waiters: Array<(frame: WsFrame) => void> = [];
let closedResolve: (v: { code: number; reason: string }) => void;

View file

@ -0,0 +1,93 @@
/**
* Debug-route loopback gating (ROADMAP M5.3).
*
* `/api/v1/debug/*` routes are test-only introspection/mutation endpoints.
* They must only be mounted when the server is bound to a loopback interface;
* on a non-loopback bind (e.g. `0.0.0.0`) they are suppressed even when the
* caller passes `debugEndpoints: true`.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { startServer, type RunningServer } from '../src';
import { authHeaders, fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
let bridgeHome: string;
let prevPassword: string | undefined;
const running: RunningServer[] = [];
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-debug-loopback-'));
lockPath = join(tmpDir, 'lock');
bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-debug-loopback-home-'));
// M6.3: a non-loopback bind (0.0.0.0) now refuses to start without a
// password + TLS opt-out. Set a password so the 0.0.0.0 case can boot; the
// fixed-token override still governs auth (password ≠ token).
prevPassword = process.env['KIMI_CODE_PASSWORD'];
process.env['KIMI_CODE_PASSWORD'] = 'test-pw';
});
afterEach(async () => {
for (const r of running.splice(0)) {
try {
await r.close();
} catch {
// ignore
}
}
if (prevPassword === undefined) {
delete process.env['KIMI_CODE_PASSWORD'];
} else {
process.env['KIMI_CODE_PASSWORD'] = prevPassword;
}
rmSync(tmpDir, { recursive: true, force: true });
rmSync(bridgeHome, { recursive: true, force: true });
});
async function boot(host: string): Promise<RunningServer> {
const r = await startServer({
serviceOverrides: [fixedTokenAuth()],
host,
port: 0,
lockPath: host === '0.0.0.0' ? join(tmpDir, `lock-${host}`) : lockPath,
logger: pino({ level: 'silent' }),
coreProcessOptions: { homeDir: bridgeHome },
debugEndpoints: true,
// M6.3: acknowledge the lack of a TLS proxy so the 0.0.0.0 bind is allowed
// (loopback ignores this — the gate only fires for non-loopback).
insecureNoTls: true,
});
running.push(r);
return r;
}
/** Probe a debug route on `127.0.0.1:<port>` regardless of the bound host. */
async function probeDebug(r: RunningServer): Promise<number> {
const port = new URL(r.address).port;
const res = await fetch(`http://127.0.0.1:${port}/api/v1/debug/prompts/some-session/state`, {
headers: authHeaders(),
});
return res.status;
}
describe('debug endpoints are loopback-only (M5.3)', () => {
it('does NOT mount /api/v1/debug/* on a non-loopback bind (0.0.0.0)', async () => {
const r = await boot('0.0.0.0');
// Route suppressed → Fastify 404 (the auth hook would 401 if the route
// existed without a token, but with a token a missing route is 404).
expect(await probeDebug(r)).toBe(404);
});
it('mounts /api/v1/debug/* on a loopback bind (127.0.0.1)', async () => {
const r = await boot('127.0.0.1');
// Route mounted + valid token → 200 (data is null for an unknown session).
expect(await probeDebug(r)).toBe(200);
});
});

View file

@ -28,6 +28,7 @@ import {
startServer,
type RunningServer,
} from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
@ -53,6 +54,7 @@ afterEach(async () => {
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -76,10 +78,22 @@ interface FastifyAppLike {
}
function appOf(r: RunningServer): FastifyAppLike {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as FastifyAppLike;
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
interface Envelope<T = unknown> {

View file

@ -28,6 +28,7 @@ import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
@ -56,6 +57,7 @@ afterEach(async () => {
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -71,15 +73,27 @@ function appOf(r: RunningServer): {
json: () => unknown;
}>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{
statusCode: number;
json: () => unknown;
}>;
};
inject: (req: unknown) => Promise<{
statusCode: number;
json: () => unknown;
}>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {

View file

@ -28,6 +28,7 @@ import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
@ -56,6 +57,7 @@ afterEach(async () => {
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -71,15 +73,27 @@ function appOf(r: RunningServer): {
json: () => unknown;
}>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{
statusCode: number;
json: () => unknown;
}>;
};
inject: (req: unknown) => Promise<{
statusCode: number;
json: () => unknown;
}>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {

View file

@ -18,6 +18,7 @@ import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
import type {
FsBrowseEntry,
FsBrowseResponse,
@ -55,6 +56,7 @@ afterEach(async () => {
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -67,12 +69,24 @@ async function bootDaemon(): Promise<RunningServer> {
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {

View file

@ -30,6 +30,7 @@ import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
@ -58,6 +59,7 @@ afterEach(async () => {
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -79,12 +81,24 @@ interface InjectResponse {
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<InjectResponse>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<InjectResponse>;
};
inject: (req: unknown) => Promise<InjectResponse>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {

View file

@ -14,6 +14,7 @@ import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
import { parsePorcelain, parseNumstat } from '@moonshot-ai/agent-core';
let tmpDir: string;
@ -43,6 +44,7 @@ afterEach(async () => {
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -58,15 +60,27 @@ function appOf(r: RunningServer): {
json: () => unknown;
}>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{
statusCode: number;
json: () => unknown;
}>;
};
inject: (req: unknown) => Promise<{
statusCode: number;
json: () => unknown;
}>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {

View file

@ -19,6 +19,7 @@ import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
@ -47,6 +48,7 @@ afterEach(async () => {
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -62,15 +64,27 @@ function appOf(r: RunningServer): {
json: () => unknown;
}>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{
statusCode: number;
json: () => unknown;
}>;
};
inject: (req: unknown) => Promise<{
statusCode: number;
json: () => unknown;
}>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {

View file

@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { ISessionService, FsSearchService, ILogService } from '@moonshot-ai/agent-core';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
@ -43,6 +44,7 @@ afterEach(async () => {
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -58,15 +60,27 @@ function appOf(r: RunningServer): {
json: () => unknown;
}>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{
statusCode: number;
json: () => unknown;
}>;
};
inject: (req: unknown) => Promise<{
statusCode: number;
json: () => unknown;
}>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {

View file

@ -43,6 +43,7 @@ import {
startServer,
type RunningServer,
} from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
import { rawDataToString } from '../src/ws/rawData';
let tmpDir: string;
@ -74,6 +75,7 @@ afterEach(async () => {
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -90,15 +92,27 @@ function appOf(r: RunningServer): {
json: () => unknown;
}>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{
statusCode: number;
json: () => unknown;
}>;
};
inject: (req: unknown) => Promise<{
statusCode: number;
json: () => unknown;
}>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
async function createSession(r: RunningServer): Promise<string> {
@ -136,7 +150,7 @@ interface Conn {
function openConn(url: string): Promise<Conn> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
const ws = new WebSocket(url, ['kimi-code.bearer.test-token']);
const queue: WsFrame[] = [];
const waiters: Array<(frame: WsFrame) => void> = [];
ws.on('message', (data) => {

View file

@ -0,0 +1,229 @@
/**
* Reusable e2e server harness with token support (ROADMAP M0.2 / M5.1).
*
* Wraps `startServer` with an isolated tmp lock + home dir and exposes helpers
* (`authedFetch` / `authedWs`) that carry an `Authorization: Bearer <token>`.
*
* From M5.1 the server enforces bearer auth, so `boot()` injects a fixed-token
* `IAuthTokenService` (default token `test-token`) via `serviceOverrides` by
* default keeping harness-based tests transparent. A caller-supplied
* `IAuthTokenService` override still wins (serviceOverrides are last-wins), so
* tests that need a custom impl can pass one explicitly. For tests that boot
* `startServer` directly, use the exported {@link fixedTokenAuth} /
* {@link withAuth} / {@link authHeaders} helpers to inject the same fixed token
* and carry it on each request.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ServiceIdentifier } from '@moonshot-ai/agent-core';
import { pino } from 'pino';
import { WebSocket } from 'ws';
import { startServer, type RunningServer, type ServerStartOptions } from '../../src';
import { IAuthTokenService } from '../../src/services/auth/authTokenService';
import type { IAuthTokenService as IAuthTokenServiceType } from '../../src/services/auth/authTokenService';
type ServiceOverride = readonly [ServiceIdentifier<unknown>, unknown];
/** Default deterministic token used when a test does not supply one. */
const DEFAULT_TOKEN = 'test-token';
/**
* Build a `[IAuthTokenService, impl]` override pair that accepts a single fixed
* `token`. Pass it into `startServer({ serviceOverrides: [fixedTokenAuth()] })`
* (or `boot({ serviceOverrides: [fixedTokenAuth()] })`) so a test can
* authenticate with `Authorization: Bearer test-token` or any custom `token`.
*
* `getToken` returns the fixed token so callers that read it back (e.g. WS
* subprotocol helpers) stay consistent; `isValid` accepts only that token.
*/
export function fixedTokenAuth(token: string = DEFAULT_TOKEN): ServiceOverride {
const impl: IAuthTokenServiceType = {
_serviceBrand: undefined,
getToken: () => token,
isValid: async (candidate) => candidate === token,
};
return [IAuthTokenService, impl];
}
/** An `Authorization: Bearer <token>` header bag, for spreading into `headers`. */
export function authHeaders(token: string = DEFAULT_TOKEN): { Authorization: string } {
return { Authorization: `Bearer ${token}` };
}
/**
* Merge `Authorization: Bearer <token>` into a `fetch` `RequestInit`, preserving
* caller-supplied headers. A caller-supplied `Authorization` wins, so tests that
* need to send a wrong/no token can still do so explicitly.
*/
export function withAuth(init: RequestInit = {}, token: string = DEFAULT_TOKEN): RequestInit {
const headers = new Headers(init.headers);
if (!headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`);
}
return { ...init, headers };
}
/**
* WS subprotocol prefix that carries the bearer token during the upgrade.
* Hardcoded here as a literal for M0; `WS_BEARER_PROTOCOL_PREFIX` is introduced
* in M3.1 and may replace this literal when the WS auth seam lands.
*/
const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.';
export interface BootOptions {
/** Bearer token attached by `authedFetch` / `authedWs`. Defaults to `'test-token'`. */
token?: string;
/**
* Generic pass-through to `startServer({ serviceOverrides })`. The auth seam:
* from M2.1 on, callers inject `[IAuthTokenService, fixedTokenImpl]` here.
* Defaults to `[]`.
*/
serviceOverrides?: ServerStartOptions['serviceOverrides'];
/** Bind host. Defaults to `'127.0.0.1'`. */
host?: string;
/** Bind port. Defaults to `0` (ephemeral). */
port?: number;
}
export interface ServerHarness {
/** The underlying `startServer` result. */
readonly server: RunningServer;
/** Raw address returned by `startServer`, e.g. `http://127.0.0.1:51234`. */
readonly address: string;
/** HTTP base URL, e.g. `http://127.0.0.1:51234`. */
readonly baseUrl: string;
/** WebSocket URL for the v1 endpoint, e.g. `ws://127.0.0.1:51234/api/v1/ws`. */
readonly wsUrl: string;
/** The bearer token this harness attaches to requests. */
readonly token: string;
/**
* `fetch` against `baseUrl + path`, merging `Authorization: Bearer <token>`
* into the request headers. Caller-supplied headers win on conflict.
*/
authedFetch(path: string, init?: RequestInit): Promise<Response>;
/**
* Open a `ws` WebSocket to `wsUrl`, offering subprotocol
* `kimi-code.bearer.<token>` and an `Authorization: Bearer <token>` header.
* In M0 the server ignores both; the connection still opens.
*/
authedWs(): WebSocket;
/** Tear down this server and terminate any sockets opened via `authedWs`. */
close(): Promise<void>;
}
/** Every harness produced by `boot()`, for suite-level cleanup via `closeAll()`. */
const opened = new Set<ServerHarness>();
function deriveHttpPort(address: string): string {
const port = new URL(address).port;
if (port === '') {
throw new Error(`cannot derive port from server address: ${address}`);
}
return port;
}
/**
* Boot an isolated `startServer` for e2e use.
*
* Creates a tmp `lockPath` + isolated home dir (mirroring `start.test.ts`),
* binds to `127.0.0.1:0` by default, and tracks the result for `closeAll()`.
*/
export async function boot(opts: BootOptions = {}): Promise<ServerHarness> {
const token = opts.token ?? DEFAULT_TOKEN;
const host = opts.host ?? '127.0.0.1';
const port = opts.port ?? 0;
// Inject a fixed-token `IAuthTokenService` by default so harness-based tests
// stay transparent (`authedFetch` / `authedWs` already carry `test-token`).
// The fixed impl is placed FIRST so an explicit caller-supplied
// `IAuthTokenService` override still wins (serviceOverrides are last-wins).
const serviceOverrides: ServerStartOptions['serviceOverrides'] = [
fixedTokenAuth(token),
...(opts.serviceOverrides ?? []),
];
const tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-harness-'));
const homeDir = mkdtempSync(join(tmpdir(), 'kimi-server-harness-home-'));
const lockPath = join(tmpDir, 'lock');
const server = await startServer({
host,
port,
lockPath,
serviceOverrides,
logger: pino({ level: 'silent' }),
coreProcessOptions: { homeDir },
});
const httpPort = deriveHttpPort(server.address);
const baseUrl = `http://${host}:${httpPort}`;
const wsUrl = `ws://${host}:${httpPort}/api/v1/ws`;
const sockets = new Set<WebSocket>();
let closed = false;
const harness: ServerHarness = {
server,
address: server.address,
baseUrl,
wsUrl,
token,
authedFetch(path: string, init: RequestInit = {}): Promise<Response> {
const headers = new Headers(init.headers);
// Caller-supplied Authorization wins on conflict.
if (!headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`);
}
return fetch(`${baseUrl}${path}`, { ...init, headers });
},
authedWs(): WebSocket {
const ws = new WebSocket(wsUrl, [`${WS_BEARER_PROTOCOL_PREFIX}${token}`], {
headers: { Authorization: `Bearer ${token}` },
});
sockets.add(ws);
const drop = (): void => {
sockets.delete(ws);
};
ws.once('close', drop);
ws.once('error', drop);
return ws;
},
async close(): Promise<void> {
if (closed) return;
closed = true;
opened.delete(harness);
for (const ws of sockets) {
try {
ws.terminate();
} catch {
// ignore — best-effort teardown
}
}
sockets.clear();
try {
await server.close();
} catch {
// ignore — best-effort teardown
}
rmSync(tmpDir, { recursive: true, force: true });
rmSync(homeDir, { recursive: true, force: true });
},
};
opened.add(harness);
return harness;
}
/** Close every harness produced by `boot()`. Intended for `afterEach` cleanup. */
export async function closeAll(): Promise<void> {
const pending = [...opened];
await Promise.all(pending.map((harness) => harness.close()));
}

View file

@ -0,0 +1,345 @@
/**
* Host-exposure hardening (ROADMAP M6.3M6.7).
*
* End-to-end coverage of the §3.5 public-bind hardening stack on a
* `host: '0.0.0.0'` + `KIMI_CODE_PASSWORD` + `insecureNoTls: true` server:
* - M6.3 public-bind gate (no `--insecure-no-tls` refuse; token-only
* (no password) + `insecureNoTls` boot + token-only warning logged;
* password + `insecureNoTls` boot + warn logged).
* - Real password auth path (`Authorization: Bearer <password>` 200 via
* `verifyPassword`; wrong/missing credentials 401).
* - M6.4 auth-failure rate limit (N bad tokens 429 on the (N+1)th).
* - M6.5 dangerous-endpoint downgrade (shutdown/terminals 404 by default;
* 200 with the allow flags; loopback mounts shutdown by default).
* - Host allowlist (spoofed Host 403; bound host 200).
* - M6.6 security response headers present on a non-loopback response.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { request as httpRequest } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Writable } from 'node:stream';
import { pino, type Logger } from 'pino';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { IServerShutdownService, startServer, type RunningServer, type ServerStartOptions } from '../src';
import { authHeaders, fixedTokenAuth } from './helpers/serverHarness';
const createdDirs: string[] = [];
const running: RunningServer[] = [];
let prevPassword: string | undefined;
function tmpPaths(): { lockPath: string; homeDir: string } {
const dir = mkdtempSync(join(tmpdir(), 'kimi-host-exposure-'));
const home = mkdtempSync(join(tmpdir(), 'kimi-host-exposure-home-'));
createdDirs.push(dir, home);
return { lockPath: join(dir, 'lock'), homeDir: home };
}
function capturingLogger(): { logger: Logger; lines: string[] } {
const lines: string[] = [];
const dest = new Writable({
write(chunk, _enc, cb) {
lines.push(String(chunk));
cb();
},
});
return { logger: pino({ level: 'info' }, dest), lines };
}
beforeEach(() => {
prevPassword = process.env['KIMI_CODE_PASSWORD'];
});
afterEach(async () => {
for (const r of running.splice(0)) {
try {
await r.close();
} catch {
// ignore — best-effort teardown
}
}
for (const dir of createdDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
if (prevPassword === undefined) {
delete process.env['KIMI_CODE_PASSWORD'];
} else {
process.env['KIMI_CODE_PASSWORD'] = prevPassword;
}
});
describe('non-loopback bind gate (M6.3)', () => {
it('boots 0.0.0.0 without a password (token-only) and logs the token-only warning', async () => {
delete process.env['KIMI_CODE_PASSWORD'];
const { lockPath, homeDir } = tmpPaths();
const { logger, lines } = capturingLogger();
const server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '0.0.0.0',
port: 0,
lockPath,
insecureNoTls: true,
logger,
coreProcessOptions: { homeDir },
});
running.push(server);
// The server is up with token-only auth: a gated route answers 200.
const res = await fetch(`${server.address}/api/v1/healthz`, { headers: authHeaders() });
expect(res.status).toBe(200);
// The token-only warning was logged so the operator knows the bearer token
// is the only credential protecting the exposed server.
expect(lines.join('')).toContain('token-only auth');
});
it('refuses to bind 0.0.0.0 with a password but without --insecure-no-tls', async () => {
process.env['KIMI_CODE_PASSWORD'] = 'test-pw';
const { lockPath, homeDir } = tmpPaths();
await expect(
startServer({
serviceOverrides: [fixedTokenAuth()],
host: '0.0.0.0',
port: 0,
lockPath,
logger: pino({ level: 'silent' }),
coreProcessOptions: { homeDir },
}),
).rejects.toThrow(/without TLS/);
});
it('boots 0.0.0.0 with a password + insecureNoTls and logs the public warning', async () => {
process.env['KIMI_CODE_PASSWORD'] = 'test-pw';
const { lockPath, homeDir } = tmpPaths();
const { logger, lines } = capturingLogger();
const server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '0.0.0.0',
port: 0,
lockPath,
insecureNoTls: true,
logger,
coreProcessOptions: { homeDir },
});
running.push(server);
// The server is up: a gated route answers 200 with a valid token.
const res = await fetch(`${server.address}/api/v1/healthz`, { headers: authHeaders() });
expect(res.status).toBe(200);
// The public-bind warning was logged so the operator knows TLS is off.
const combined = lines.join('');
expect(combined).toContain('binding non-loopback host without TLS');
});
});
describe('dangerous-endpoint downgrade on a public bind (M6.5)', () => {
interface BootExposureOpts {
host?: string;
allowRemoteShutdown?: boolean;
allowRemoteTerminals?: boolean;
}
async function bootExposure(opts: BootExposureOpts = {}): Promise<{
server: RunningServer;
shutdownCalls: string[];
}> {
process.env['KIMI_CODE_PASSWORD'] = 'test-pw';
const { lockPath, homeDir } = tmpPaths();
const shutdownCalls: string[] = [];
// Capture shutdown requests instead of exiting the process.
const noopShutdown = [
IServerShutdownService,
{
_serviceBrand: undefined,
requestShutdown: async (reason: string) => {
shutdownCalls.push(reason);
},
},
] as const;
const serviceOverrides: ServerStartOptions['serviceOverrides'] = [
fixedTokenAuth(),
noopShutdown,
];
const server = await startServer({
serviceOverrides,
host: opts.host ?? '0.0.0.0',
port: 0,
lockPath,
insecureNoTls: true,
allowRemoteShutdown: opts.allowRemoteShutdown,
allowRemoteTerminals: opts.allowRemoteTerminals,
logger: pino({ level: 'silent' }),
coreProcessOptions: { homeDir },
});
running.push(server);
return { server, shutdownCalls };
}
const terminalsUrl = (server: RunningServer): string =>
`${server.address}/api/v1/sessions/some-session/terminals`;
it('returns 404 for shutdown and terminals on a public bind without the allow flags', async () => {
const { server } = await bootExposure();
const shutdown = await fetch(`${server.address}/api/v1/shutdown`, {
method: 'POST',
headers: authHeaders(),
});
expect(shutdown.status).toBe(404);
const terminals = await fetch(terminalsUrl(server), { headers: authHeaders() });
expect(terminals.status).toBe(404);
});
it('returns 200 for shutdown on a public bind when allowRemoteShutdown is set', async () => {
const { server, shutdownCalls } = await bootExposure({ allowRemoteShutdown: true });
const shutdown = await fetch(`${server.address}/api/v1/shutdown`, {
method: 'POST',
headers: authHeaders(),
});
expect(shutdown.status).toBe(200);
// The handler replies before triggering shutdown (setImmediate); the noop
// override captures it so the process does not exit.
await vi.waitFor(() => expect(shutdownCalls).toContain('api'));
});
it('mounts shutdown on a loopback bind by default', async () => {
const { server, shutdownCalls } = await bootExposure({ host: '127.0.0.1' });
const shutdown = await fetch(`${server.address}/api/v1/shutdown`, {
method: 'POST',
headers: authHeaders(),
});
expect(shutdown.status).toBe(200);
await vi.waitFor(() => expect(shutdownCalls).toContain('api'));
});
});
/**
* Raw HTTP GET that lets us set an arbitrary `Host` header. Node's `fetch`
* (undici) treats `Host` as a forbidden header and silently replaces it with
* the URL host, so the Host-allowlist test drives `node:http` directly.
*/
function rawHttpGet(
url: string,
headers: Record<string, string>,
): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const u = new URL(url);
const req = httpRequest(
{
hostname: u.hostname,
port: u.port,
path: `${u.pathname}${u.search}`,
method: 'GET',
headers,
},
(res) => {
let body = '';
res.on('data', (chunk: Buffer) => {
body += chunk.toString();
});
res.on('end', () => resolve({ status: res.statusCode ?? 0, body }));
},
);
req.on('error', reject);
req.end();
});
}
describe('public-bind §3.5 end-to-end (M6.7)', () => {
/**
* Boot a 0.0.0.0 server using the REAL auth impl (no fixed-token override) so
* the password `verifyPassword` path is exercised. `KIMI_CODE_PASSWORD` is set
* so the M6.3 gate passes and the password is itself a valid bearer.
*/
async function bootPublicReal(): Promise<RunningServer> {
process.env['KIMI_CODE_PASSWORD'] = 'test-pw';
const { lockPath, homeDir } = tmpPaths();
const server = await startServer({
host: '0.0.0.0',
port: 0,
lockPath,
insecureNoTls: true,
logger: pino({ level: 'silent' }),
coreProcessOptions: { homeDir },
});
running.push(server);
return server;
}
/** Boot a 0.0.0.0 server with a deterministic fixed token. */
async function bootPublicFixed(token = 'real-token'): Promise<RunningServer> {
process.env['KIMI_CODE_PASSWORD'] = 'test-pw';
const { lockPath, homeDir } = tmpPaths();
const server = await startServer({
serviceOverrides: [fixedTokenAuth(token)],
host: '0.0.0.0',
port: 0,
lockPath,
insecureNoTls: true,
logger: pino({ level: 'silent' }),
coreProcessOptions: { homeDir },
});
running.push(server);
return server;
}
it('accepts the user password as a bearer token (verifyPassword path)', async () => {
const server = await bootPublicReal();
const ok = await fetch(`${server.address}/api/v1/sessions`, {
headers: { Authorization: 'Bearer test-pw' },
});
expect(ok.status).toBe(200);
// Security headers ride on every non-loopback response (M6.6).
expect(ok.headers.get('x-content-type-options')).toBe('nosniff');
expect(ok.headers.get('content-security-policy')).toBe("default-src 'self'");
});
it('rejects wrong and missing credentials with 401', async () => {
const server = await bootPublicReal();
const wrong = await fetch(`${server.address}/api/v1/sessions`, {
headers: { Authorization: 'Bearer wrong-password' },
});
expect(wrong.status).toBe(401);
const missing = await fetch(`${server.address}/api/v1/sessions`);
expect(missing.status).toBe(401);
});
it('rate-limits repeated auth failures to 429 on a real bind (M6.4)', async () => {
const server = await bootPublicFixed('real-token');
const url = `${server.address}/api/v1/sessions`;
let lastStatus = 0;
// Default threshold is 10 failures; the 11th must be 429.
for (let i = 0; i < 11; i += 1) {
const res = await fetch(url, { headers: { Authorization: 'Bearer wrong' } });
lastStatus = res.status;
if (i < 10) {
expect(res.status).toBe(401);
}
}
expect(lastStatus).toBe(429);
});
it('rejects a spoofed Host with 403 and accepts the bound host', async () => {
const server = await bootPublicFixed();
// Bound host (0.0.0.0) is a literal IP → allowed by the Host allowlist.
const bound = await rawHttpGet(`${server.address}/api/v1/healthz`, {
Host: `0.0.0.0:${new URL(server.address).port}`,
});
expect(bound.status).toBe(200);
// A spoofed Host is rejected before auth (Host check runs first).
const spoofed = await rawHttpGet(`${server.address}/api/v1/healthz`, {
Host: 'evil.example.com',
});
expect(spoofed.status).toBe(403);
});
});

View file

@ -0,0 +1,262 @@
/**
* Host / Origin checks wired into HTTP + WS (ROADMAP M4.3).
*
* HTTP: the global `onRequest` Host hook rejects `Host: evil.com` with 403
* before any route handler (here `/api/v1/healthz`), while the default
* `127.0.0.1:<port>` Host from a real `fetch` is allowed.
*
* WS: `wsGatewayOptions.hostCheck` / `allowedOrigins` gate the upgrade path
* before token validation. `authTokenService` is intentionally left unset so
* these cases isolate Host/Origin from token auth. The Node `ws` client sends
* no `Origin` by default, which is treated as a non-browser client and
* allowed; a spoofed browser `Origin: http://evil.com` is rejected.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { request as httpRequest } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { WebSocket } from 'ws';
import { startServer, type RunningServer } from '../src';
import type { WSGatewayOptions } from '../src/services/gateway/wsGateway';
import { rawDataToString } from '../src/ws/rawData';
import { fixedTokenAuth } from './helpers/serverHarness';
interface WsFrame {
type: string;
payload?: unknown;
[k: string]: unknown;
}
interface Conn {
ws: WebSocket;
queue: WsFrame[];
waiters: Array<(frame: WsFrame) => void>;
closed: Promise<{ code: number; reason: string }>;
}
interface ConnectOptions {
protocols?: string[];
headers?: Record<string, string>;
}
let tmpDir: string;
let lockPath: string;
let bridgeHome: string;
const running: RunningServer[] = [];
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-host-origin-'));
lockPath = join(tmpDir, 'lock');
bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-host-origin-home-'));
});
afterEach(async () => {
for (const r of running.splice(0)) {
try {
await r.close();
} catch {
// ignore
}
}
rmSync(tmpDir, { recursive: true, force: true });
rmSync(bridgeHome, { recursive: true, force: true });
});
async function spawn(wsGatewayOptions?: WSGatewayOptions): Promise<RunningServer> {
const r = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
logger: pino({ level: 'silent' }),
coreProcessOptions: { homeDir: bridgeHome },
...(wsGatewayOptions !== undefined ? { wsGatewayOptions } : {}),
});
running.push(r);
return r;
}
function wsUrl(http: string): string {
return http.replace(/^http:\/\//, 'ws://') + '/api/v1/ws';
}
/**
* Raw HTTP GET that lets us set an arbitrary `Host` header. Node's `fetch`
* (undici) treats `Host` as a forbidden header and silently replaces it with
* the URL host, so we drive `node:http` directly to exercise the Host check.
*/
function rawHttpGet(
url: string,
headers: Record<string, string>,
): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const u = new URL(url);
const req = httpRequest(
{
hostname: u.hostname,
port: u.port,
path: `${u.pathname}${u.search}`,
method: 'GET',
headers,
},
(res) => {
let body = '';
res.on('data', (chunk: Buffer) => {
body += chunk.toString();
});
res.on('end', () => resolve({ status: res.statusCode ?? 0, body }));
},
);
req.on('error', reject);
req.end();
});
}
function openConn(url: string, opts?: ConnectOptions): Promise<Conn> {
return new Promise((resolve, reject) => {
// Offer the fixed bearer token so the M5.1 WS auth passes; the Host/Origin
// cases that expect rejection use `expectRejected` (no token) instead.
const protocols = [...(opts?.protocols ?? []), 'kimi-code.bearer.test-token'];
const ws = new WebSocket(url, protocols, { headers: opts?.headers });
const queue: WsFrame[] = [];
const waiters: Array<(frame: WsFrame) => void> = [];
let closedResolve: (v: { code: number; reason: string }) => void;
const closed = new Promise<{ code: number; reason: string }>((res) => {
closedResolve = res;
});
ws.on('message', (data) => {
let parsed: WsFrame;
try {
parsed = JSON.parse(rawDataToString(data)) as WsFrame;
} catch {
return;
}
if (waiters.length > 0) {
waiters.shift()?.(parsed);
} else {
queue.push(parsed);
}
});
ws.on('close', (code, reason) => {
closedResolve({ code, reason: String(reason) });
});
ws.once('open', () => resolve({ ws, queue, waiters, closed }));
ws.once('error', (err) => reject(err));
});
}
function receive(conn: Conn, timeoutMs: number): Promise<WsFrame> {
return new Promise((resolve, reject) => {
if (conn.queue.length > 0) {
resolve(conn.queue.shift()!);
return;
}
const t = setTimeout(() => {
const idx = conn.waiters.indexOf(waiter);
if (idx >= 0) conn.waiters.splice(idx, 1);
reject(new Error(`no message in ${timeoutMs}ms`));
}, timeoutMs);
const waiter = (frame: WsFrame): void => {
clearTimeout(t);
resolve(frame);
};
conn.waiters.push(waiter);
});
}
async function receiveType(conn: Conn, type: string, timeoutMs: number): Promise<WsFrame> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const remaining = deadline - Date.now();
if (remaining <= 0) throw new Error(`no message of type ${type} within ${timeoutMs}ms`);
const frame = await receive(conn, remaining);
if (frame.type === type) return frame;
}
}
function expectRejected(url: string, opts?: ConnectOptions): Promise<void> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url, opts?.protocols, { headers: opts?.headers });
const done = (err?: Error): void => {
clearTimeout(t);
ws.removeAllListeners();
try {
ws.terminate();
} catch {
// ignore
}
if (err !== undefined) reject(err);
else resolve();
};
const t = setTimeout(
() => done(new Error('connection was not rejected within timeout')),
1500,
);
ws.once('open', () => done(new Error('connection unexpectedly opened')));
ws.once('error', () => done());
ws.once('close', () => done());
});
}
describe('HTTP Host check (start.ts)', () => {
it('rejects Host: evil.com with 403 before the route handler', async () => {
const r = await spawn();
const res = await rawHttpGet(`${r.address}/api/v1/healthz`, { Host: 'evil.com' });
expect(res.status).toBe(403);
const body = JSON.parse(res.body) as Record<string, unknown>;
expect(body['code']).toBe(40301);
expect(body['msg']).toBe('Invalid Host header');
});
it('allows the default 127.0.0.1:<port> Host', async () => {
const r = await spawn();
const res = await rawHttpGet(`${r.address}/api/v1/healthz`, {});
expect(res.status).toBe(200);
});
});
describe('WS Host/Origin checks (wsGatewayService)', () => {
it('rejects a spoofed Host before token validation', async () => {
const r = await spawn({ hostCheck: { boundHost: '127.0.0.1' }, allowedOrigins: [] });
await expectRejected(wsUrl(r.address), { headers: { Host: 'evil.com' } });
});
it('accepts a normal Host and delivers server_hello', async () => {
const r = await spawn({ hostCheck: { boundHost: '127.0.0.1' }, allowedOrigins: [] });
const conn = await openConn(wsUrl(r.address));
const hello = await receiveType(conn, 'server_hello', 1000);
expect(hello.type).toBe('server_hello');
conn.ws.close();
await conn.closed;
});
it('rejects a disallowed browser Origin', async () => {
const r = await spawn({ hostCheck: { boundHost: '127.0.0.1' }, allowedOrigins: [] });
await expectRejected(wsUrl(r.address), {
headers: { origin: 'http://evil.com' },
});
});
it('allows a Node client with no Origin (present-only check)', async () => {
const r = await spawn({ hostCheck: { boundHost: '127.0.0.1' }, allowedOrigins: [] });
const conn = await openConn(wsUrl(r.address));
const hello = await receiveType(conn, 'server_hello', 1000);
expect(hello.type).toBe('server_hello');
conn.ws.close();
await conn.closed;
});
it('skips the checks when the options are unset', async () => {
const r = await spawn();
const conn = await openConn(wsUrl(r.address));
const hello = await receiveType(conn, 'server_hello', 1000);
expect(hello.type).toBe('server_hello');
conn.ws.close();
await conn.closed;
});
});

View file

@ -0,0 +1,150 @@
/**
* Host-header allowlist (ROADMAP M4.1).
*
* Pure unit cases for `stripPort` / `isAllowedHost` / the env parsers, plus a
* minimal Fastify integration test that exercises the `onRequest` hook through
* `app.inject` (which sends `Host: localhost:80` by default).
*/
import Fastify, { type FastifyInstance } from 'fastify';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
createHostCheck,
isAllowedHost,
isHostCheckDisabled,
parseAllowedHosts,
stripPort,
} from '#/middleware/hostnames';
describe('stripPort', () => {
it('strips the port from a hostname', () => {
expect(stripPort('localhost:80')).toBe('localhost');
});
it('strips the port from bracketed IPv6', () => {
expect(stripPort('[::1]:80')).toBe('[::1]');
});
it('strips the port from an IPv4 literal', () => {
expect(stripPort('1.2.3.4:5678')).toBe('1.2.3.4');
});
it('lowercases bare hosts', () => {
expect(stripPort('LOCALHOST')).toBe('localhost');
});
});
describe('isAllowedHost (default allow set)', () => {
const allow = ['localhost', 'localhost:80', 'foo.localhost', '127.0.0.1', '127.0.0.1:58627', '[::1]', '::1', '8.8.8.8'];
for (const host of allow) {
it(`allows ${host}`, () => {
expect(isAllowedHost(host, {})).toBe(true);
});
}
const deny = ['evil.com', 'evil.com:80', '127.0.0.1.evil.com'];
for (const host of deny) {
it(`denies ${host}`, () => {
expect(isAllowedHost(host, {})).toBe(false);
});
}
it('denies a missing Host header', () => {
expect(isAllowedHost(undefined, {})).toBe(false);
});
});
describe('isAllowedHost (boundHost)', () => {
it('allows the bound host', () => {
expect(isAllowedHost('myhost', { boundHost: 'myhost' })).toBe(true);
});
it('strips the port on both sides', () => {
expect(isAllowedHost('myhost:1234', { boundHost: 'myhost:8080' })).toBe(true);
});
it('still denies unrelated hosts', () => {
expect(isAllowedHost('otherhost', { boundHost: 'myhost' })).toBe(false);
});
});
describe('isAllowedHost (extra)', () => {
it('matches a subdomain wildcard', () => {
expect(isAllowedHost('a.example.com', { extra: ['.example.com'] })).toBe(true);
});
it('matches the bare domain of a wildcard', () => {
expect(isAllowedHost('example.com', { extra: ['.example.com'] })).toBe(true);
});
it('does not match a partial suffix', () => {
expect(isAllowedHost('baddexample.com', { extra: ['.example.com'] })).toBe(false);
});
it('matches an exact entry', () => {
expect(isAllowedHost('foo', { extra: ['foo'] })).toBe(true);
});
});
describe('isAllowedHost (disable)', () => {
it('allows everything when disabled', () => {
expect(isAllowedHost('evil.com', { disable: true })).toBe(true);
});
});
describe('parseAllowedHosts', () => {
it('splits, trims, and drops empties', () => {
expect(parseAllowedHosts({ KIMI_CODE_ALLOWED_HOSTS: ' a, .b.com, ' })).toEqual(['a', '.b.com']);
});
it('returns [] when unset', () => {
expect(parseAllowedHosts({})).toEqual([]);
});
});
describe('isHostCheckDisabled', () => {
it('is true when set to "1"', () => {
expect(isHostCheckDisabled({ KIMI_CODE_DISABLE_HOST_CHECK: '1' })).toBe(true);
});
it('is false when unset', () => {
expect(isHostCheckDisabled({})).toBe(false);
});
});
describe('createHostCheck (onRequest hook)', () => {
let app: FastifyInstance;
beforeEach(async () => {
app = Fastify();
app.addHook('onRequest', createHostCheck({}).onRequest);
app.get('/api/v1/probe', async () => ({ ok: true }));
await app.ready();
});
afterEach(async () => {
await app.close();
});
it('rejects a disallowed Host with the 40301 envelope', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/v1/probe',
headers: { host: 'evil.com' },
});
expect(res.statusCode).toBe(403);
const body = res.json() as Record<string, unknown>;
expect(body['code']).toBe(40301);
expect(body['msg']).toBe('Invalid Host header');
expect(body['data']).toBeNull();
expect(typeof body['request_id']).toBe('string');
});
it('allows the default app.inject Host (localhost:80)', async () => {
const res = await app.inject({ method: 'GET', url: '/api/v1/probe' });
expect(res.statusCode).toBe(200);
});
});

View file

@ -8,7 +8,7 @@
* 0)` returns ESRCH for any unallocated pid on Linux/macOS).
*/
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@ -78,6 +78,14 @@ describe('acquireLock — basic acquire / release', () => {
rmSync(lockPath);
expect(() => handle.release()).not.toThrow();
});
it('creates the lock file with 0600 permissions (ROADMAP M5.2)', () => {
const handle = acquireLock({ lockPath, port: 58627 });
// The lock file lives next to the per-pid bearer token; it must not be
// group/world readable.
expect(statSync(lockPath).mode & 0o777).toBe(0o600);
handle.release();
});
});
describe('acquireLock — concurrent-instance protection', () => {

View file

@ -40,6 +40,7 @@ import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
@ -65,6 +66,7 @@ afterEach(async () => {
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -77,12 +79,24 @@ async function bootDaemon(): Promise<RunningServer> {
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {

View file

@ -33,6 +33,7 @@ import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
@ -59,6 +60,7 @@ afterEach(async () => {
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -75,16 +77,24 @@ async function bootDaemon(): Promise<RunningServer> {
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
// We use the same accessor pattern start.ts uses internally. IRestGateway
// is registered with the `FastifyLike` structural type; `.inject()` is the
// Fastify-specific method we need for hermetic tests — it lives on the
// underlying instance, not on FastifyLike. The cast is local to this test.
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
describe('GET /api/v1/meta — envelope + metaResponseSchema', () => {
@ -137,6 +147,7 @@ describe('GET /api/v1/meta — envelope + metaResponseSchema', () => {
const homeA = mkdtempSync(join(tmpdir(), 'kimi-server-meta-home-a-'));
const homeB = mkdtempSync(join(tmpdir(), 'kimi-server-meta-home-b-'));
const r1 = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath: lockA,
@ -144,6 +155,7 @@ describe('GET /api/v1/meta — envelope + metaResponseSchema', () => {
coreProcessOptions: { homeDir: homeA },
});
const r2 = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath: lockB,
@ -168,6 +180,7 @@ describe('GET /api/v1/meta — envelope + metaResponseSchema', () => {
describe('GET /api/v1/meta — server_version precedence', () => {
it('reports the host kimi-code identity version when provided', async () => {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,

View file

@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IModelCatalogService, type IModelCatalogService as ModelCatalogServiceShape } from '@moonshot-ai/agent-core';
import { IRestGateway, startServer, type RunningServer, type ServerStartOptions } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
@ -40,7 +41,7 @@ async function bootDaemon(
lockPath,
logger: pino({ level: 'silent' }),
coreProcessOptions: { homeDir: bridgeHome },
serviceOverrides,
serviceOverrides: [fixedTokenAuth(), ...(serviceOverrides ?? [])],
});
return server;
}
@ -48,12 +49,24 @@ async function bootDaemon(
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {

View file

@ -40,6 +40,7 @@ import type {
} from '@moonshot-ai/protocol';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
@ -128,7 +129,7 @@ async function bootDaemon(stub: StubOAuth): Promise<RunningServer> {
lockPath,
logger: pino({ level: 'silent' }),
coreProcessOptions: { homeDir: bridgeHome },
serviceOverrides: [[IOAuthService, stub]],
serviceOverrides: [fixedTokenAuth(), [IOAuthService, stub]],
});
return server;
}
@ -136,12 +137,24 @@ async function bootDaemon(stub: StubOAuth): Promise<RunningServer> {
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {

View file

@ -0,0 +1,143 @@
/**
* Origin / CORS middleware (ROADMAP M4.2).
*
* Pure unit cases for `originHost` / `isOriginAllowed` / `parseCorsOrigins`,
* plus a minimal Fastify integration test that drives the `onRequest` hook
* through `app.inject` and asserts the emitted (or withheld) CORS headers.
*/
import Fastify, { type FastifyInstance } from 'fastify';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
createOriginHook,
isOriginAllowed,
originHost,
parseCorsOrigins,
} from '#/middleware/origin';
describe('originHost', () => {
it('returns the host for a valid origin', () => {
expect(originHost('https://foo.com')).toBe('foo.com');
});
it('drops the default port', () => {
expect(originHost('http://localhost:80')).toBe('localhost');
});
it('keeps a non-default port', () => {
expect(originHost('http://127.0.0.1:58627')).toBe('127.0.0.1:58627');
});
it('returns undefined for a missing origin', () => {
expect(originHost(undefined)).toBeUndefined();
});
it('returns undefined for a malformed origin', () => {
expect(originHost('not a url')).toBeUndefined();
});
});
describe('isOriginAllowed', () => {
it('allows same-origin', () => {
expect(isOriginAllowed('http://localhost:80', 'localhost:80', [])).toBe(true);
});
it('denies cross-origin that is not whitelisted', () => {
expect(isOriginAllowed('http://evil.com', 'localhost:80', [])).toBe(false);
});
it('allows cross-origin that is whitelisted', () => {
expect(isOriginAllowed('https://foo.com', 'localhost:80', ['https://foo.com'])).toBe(true);
});
it('allows an absent origin', () => {
expect(isOriginAllowed(undefined, 'localhost:80', [])).toBe(true);
});
it('treats a malformed origin as absent (allowed)', () => {
expect(isOriginAllowed('not a url', 'h', [])).toBe(true);
});
});
describe('parseCorsOrigins', () => {
it('splits, trims, and drops empties', () => {
expect(parseCorsOrigins({ KIMI_CODE_CORS_ORIGINS: ' https://a.com, https://b.com, ' })).toEqual([
'https://a.com',
'https://b.com',
]);
});
it('returns [] when unset', () => {
expect(parseCorsOrigins({})).toEqual([]);
});
});
describe('createOriginHook (onRequest hook)', () => {
let app: FastifyInstance;
beforeEach(async () => {
app = Fastify();
app.addHook('onRequest', createOriginHook({ allowedOrigins: ['https://foo.com'] }));
app.get('/api/v1/probe', async () => ({ ok: true }));
app.options('/api/v1/probe', async () => ({ ok: true }));
await app.ready();
});
afterEach(async () => {
await app.close();
});
it('echoes CORS headers for a same-origin request', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/v1/probe',
headers: { origin: 'http://localhost:80', host: 'localhost:80' },
});
expect(res.statusCode).toBe(200);
expect(res.headers['access-control-allow-origin']).toBe('http://localhost:80');
});
it('echoes the whitelisted cross-origin and short-circuits OPTIONS to 204', async () => {
const res = await app.inject({
method: 'OPTIONS',
url: '/api/v1/probe',
headers: { origin: 'https://foo.com', host: 'localhost:80' },
});
expect(res.statusCode).toBe(204);
expect(res.headers['access-control-allow-origin']).toBe('https://foo.com');
expect(res.headers['access-control-allow-methods']).toBe(
'GET, POST, PUT, PATCH, DELETE, OPTIONS',
);
});
it('withholds CORS headers for a non-whitelisted cross-origin', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/v1/probe',
headers: { origin: 'http://evil.com', host: 'localhost:80' },
});
expect(res.statusCode).toBe(200);
expect(res.headers['access-control-allow-origin']).toBeUndefined();
});
it('returns 204 without CORS headers for a non-whitelisted OPTIONS', async () => {
const res = await app.inject({
method: 'OPTIONS',
url: '/api/v1/probe',
headers: { origin: 'http://evil.com', host: 'localhost:80' },
});
expect(res.statusCode).toBe(204);
expect(res.headers['access-control-allow-origin']).toBeUndefined();
});
it('emits no CORS headers when Origin is absent', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/v1/probe',
headers: { host: 'localhost:80' },
});
expect(res.statusCode).toBe(200);
expect(res.headers['access-control-allow-origin']).toBeUndefined();
});
});

View file

@ -33,6 +33,7 @@ import type { Event, PromptSubmission } from '@moonshot-ai/protocol';
import { IEventService, IPromptService, PromptService } from '@moonshot-ai/agent-core';
import { IRestGateway, startServer, type ServerStartOptions, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
@ -66,7 +67,7 @@ async function bootDaemon(
logger: pino({ level: 'silent' }),
coreProcessOptions: { homeDir: bridgeHome },
wsGatewayOptions: { pingIntervalMs: 5_000, pongTimeoutMs: 5_000 },
serviceOverrides,
serviceOverrides: [fixedTokenAuth(), ...(serviceOverrides ?? [])],
});
return server;
}
@ -74,12 +75,24 @@ async function bootDaemon(
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {
@ -204,7 +217,7 @@ async function openSubscriber(
const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws';
const received: Record<string, unknown>[] = [];
const ws = await new Promise<WebSocket>((resolve, reject) => {
const sock = new WebSocket(wsUrl);
const sock = new WebSocket(wsUrl, ['kimi-code.bearer.test-token']);
sock.on('message', (data) => {
try {
received.push(JSON.parse(wsDataToString(data)) as Record<string, unknown>);

View file

@ -25,6 +25,7 @@ import {
} from '@moonshot-ai/agent-core';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
import { rawDataToString } from '../src/ws/rawData';
import { QuestionService } from '#/services/question/questionService';
@ -52,6 +53,7 @@ afterEach(async () => {
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -65,12 +67,24 @@ async function bootDaemon(): Promise<RunningServer> {
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {
@ -112,7 +126,7 @@ async function openSubscriber(
const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws';
const received: Record<string, unknown>[] = [];
const ws = await new Promise<WebSocket>((resolve, reject) => {
const sock = new WebSocket(wsUrl);
const sock = new WebSocket(wsUrl, ['kimi-code.bearer.test-token']);
sock.on('message', (data) => {
try {
received.push(JSON.parse(rawDataToString(data)) as Record<string, unknown>);

View file

@ -0,0 +1,165 @@
/**
* Auth-failure rate limiting (ROADMAP M6.4).
*
* Two layers:
* 1. `createAuthFailureLimiter` unit behavior failure counting, ban on
* threshold, ban expiry (fake timers), and window reset.
* 2. `createAuthHook` integration a banned source gets `429` (even with a
* valid token), a different source still gets `401`, and a hook without a
* limiter (the loopback wiring) never returns `429`.
*
* Distinct source IPs are expressed via `X-Forwarded-For` with Fastify
* `trustProxy: true`, which is what `req.ip` reads behind a reverse proxy.
*/
import Fastify, { type FastifyInstance } from 'fastify';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createAuthHook } from '#/middleware/auth';
import {
createAuthFailureLimiter,
type AuthFailureLimiter,
} from '#/middleware/rateLimit';
import type { IAuthTokenService } from '#/services/auth/authTokenService';
const TOKEN = 'test-token';
const IP_A = '203.0.113.10';
const IP_B = '203.0.113.11';
function fixedImpl(): IAuthTokenService {
return {
_serviceBrand: undefined,
getToken: () => TOKEN,
isValid: async (candidate) => candidate === TOKEN,
};
}
function buildApp(limiter?: AuthFailureLimiter): FastifyInstance {
const app = Fastify({ trustProxy: true });
app.addHook('onRequest', createAuthHook(fixedImpl(), { limiter }));
app.get('/api/v1/sessions', async () => ({ ok: true }));
return app;
}
function badToken(ip: string): { method: 'GET'; url: string; headers: Record<string, string> } {
return {
method: 'GET',
url: '/api/v1/sessions',
headers: { 'x-forwarded-for': ip, authorization: 'Bearer wrong-token' },
};
}
describe('createAuthHook rate limiting (M6.4)', () => {
let app: FastifyInstance;
let limiter: AuthFailureLimiter;
beforeEach(async () => {
limiter = createAuthFailureLimiter({ maxFailures: 3, windowMs: 60_000, banMs: 60_000 });
app = buildApp(limiter);
await app.ready();
});
afterEach(async () => {
await app.close();
limiter.dispose();
});
it('returns 401 for the first N failures, then 429 on the (N+1)th from the same IP', async () => {
expect((await app.inject(badToken(IP_A))).statusCode).toBe(401);
expect((await app.inject(badToken(IP_A))).statusCode).toBe(401);
expect((await app.inject(badToken(IP_A))).statusCode).toBe(401);
const fourth = await app.inject(badToken(IP_A));
expect(fourth.statusCode).toBe(429);
const body = fourth.json() as Record<string, unknown>;
expect(body['code']).toBe(42901);
expect(body['msg']).toBe('Too many failed auth attempts');
});
it('does not ban a different IP that has not hit the threshold', async () => {
// Push IP_A over the threshold.
await app.inject(badToken(IP_A));
await app.inject(badToken(IP_A));
await app.inject(badToken(IP_A));
expect((await app.inject(badToken(IP_A))).statusCode).toBe(429);
// IP_B is a fresh source — still 401, not 429.
expect((await app.inject(badToken(IP_B))).statusCode).toBe(401);
});
it('returns 429 to a banned IP even when it presents a valid token', async () => {
await app.inject(badToken(IP_A));
await app.inject(badToken(IP_A));
await app.inject(badToken(IP_A));
expect((await app.inject(badToken(IP_A))).statusCode).toBe(429);
const valid = await app.inject({
method: 'GET',
url: '/api/v1/sessions',
headers: { 'x-forwarded-for': IP_A, authorization: `Bearer ${TOKEN}` },
});
expect(valid.statusCode).toBe(429);
});
it('never returns 429 when no limiter is wired (loopback behavior)', async () => {
const noLimiterApp = buildApp(undefined);
await noLimiterApp.ready();
try {
for (let i = 0; i < 10; i += 1) {
expect((await noLimiterApp.inject(badToken(IP_A))).statusCode).toBe(401);
}
} finally {
await noLimiterApp.close();
}
});
});
describe('createAuthFailureLimiter (unit)', () => {
afterEach(() => {
vi.useRealTimers();
});
it('bans a source at the threshold and clears the ban after banMs', () => {
vi.useFakeTimers();
const limiter = createAuthFailureLimiter({ maxFailures: 2, windowMs: 1_000, banMs: 500 });
try {
expect(limiter.isBanned('1.2.3.4')).toBe(false);
limiter.recordFailure('1.2.3.4');
expect(limiter.isBanned('1.2.3.4')).toBe(false);
limiter.recordFailure('1.2.3.4');
expect(limiter.isBanned('1.2.3.4')).toBe(true);
vi.advanceTimersByTime(499);
expect(limiter.isBanned('1.2.3.4')).toBe(true);
vi.advanceTimersByTime(1);
expect(limiter.isBanned('1.2.3.4')).toBe(false);
} finally {
limiter.dispose();
}
});
it('resets the failure count once the window elapses', () => {
vi.useFakeTimers();
const limiter = createAuthFailureLimiter({ maxFailures: 2, windowMs: 1_000, banMs: 500 });
try {
limiter.recordFailure('5.5.5.5');
vi.advanceTimersByTime(1_001); // window expired → next failure starts fresh
limiter.recordFailure('5.5.5.5');
// Only one failure in the new window → not banned.
expect(limiter.isBanned('5.5.5.5')).toBe(false);
} finally {
limiter.dispose();
}
});
it('tracks sources independently', () => {
vi.useFakeTimers();
const limiter = createAuthFailureLimiter({ maxFailures: 1, windowMs: 1_000, banMs: 500 });
try {
limiter.recordFailure('9.9.9.9');
expect(limiter.isBanned('9.9.9.9')).toBe(true);
expect(limiter.isBanned('8.8.8.8')).toBe(false);
} finally {
limiter.dispose();
}
});
});

View file

@ -0,0 +1,95 @@
/**
* Security response headers (ROADMAP M6.6).
*
* Verifies the `onSend` hook is registered only on a non-loopback bind and
* that HSTS is omitted while TLS is terminated elsewhere (`tls: false`).
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { startServer, type RunningServer } from '../src';
import { authHeaders, fixedTokenAuth } from './helpers/serverHarness';
const createdDirs: string[] = [];
const running: RunningServer[] = [];
let prevPassword: string | undefined;
function tmpPaths(): { lockPath: string; homeDir: string } {
const dir = mkdtempSync(join(tmpdir(), 'kimi-sec-headers-'));
const home = mkdtempSync(join(tmpdir(), 'kimi-sec-headers-home-'));
createdDirs.push(dir, home);
return { lockPath: join(dir, 'lock'), homeDir: home };
}
beforeEach(() => {
prevPassword = process.env['KIMI_CODE_PASSWORD'];
});
afterEach(async () => {
for (const r of running.splice(0)) {
try {
await r.close();
} catch {
// ignore — best-effort teardown
}
}
for (const dir of createdDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
if (prevPassword === undefined) {
delete process.env['KIMI_CODE_PASSWORD'];
} else {
process.env['KIMI_CODE_PASSWORD'] = prevPassword;
}
});
async function boot(host: string): Promise<RunningServer> {
const { lockPath, homeDir } = tmpPaths();
if (host !== '127.0.0.1') {
// Non-loopback binds require a password + TLS opt-out (M6.3).
process.env['KIMI_CODE_PASSWORD'] = 'test-pw';
}
const server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host,
port: 0,
lockPath,
insecureNoTls: host !== '127.0.0.1',
logger: pino({ level: 'silent' }),
coreProcessOptions: { homeDir },
});
running.push(server);
return server;
}
describe('security response headers (M6.6)', () => {
it('sets nosniff / Referrer-Policy / CSP on a non-loopback bind, without HSTS', async () => {
const server = await boot('0.0.0.0');
const res = await fetch(`${server.address}/api/v1/sessions`, {
headers: authHeaders(),
});
expect(res.status).toBe(200);
expect(res.headers.get('x-content-type-options')).toBe('nosniff');
expect(res.headers.get('referrer-policy')).toBe('no-referrer');
expect(res.headers.get('content-security-policy')).toBe("default-src 'self'");
// TLS is terminated by the reverse proxy in this phase → no HSTS here.
expect(res.headers.get('strict-transport-security')).toBeNull();
});
it('does NOT set the security headers on a loopback bind', async () => {
const server = await boot('127.0.0.1');
const res = await fetch(`${server.address}/api/v1/sessions`, {
headers: authHeaders(),
});
expect(res.status).toBe(200);
expect(res.headers.get('x-content-type-options')).toBeNull();
expect(res.headers.get('referrer-policy')).toBeNull();
expect(res.headers.get('content-security-policy')).toBeNull();
expect(res.headers.get('strict-transport-security')).toBeNull();
});
});

View file

@ -0,0 +1,182 @@
import {
chmodSync,
existsSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
PrivateFileTooPermissiveError,
readPrivateFile,
writePrivateFile,
} from '#/services/auth/privateFiles';
import { loadOrCreateServerToken, rotateServerToken } from '#/services/auth/persistentToken';
import { createTokenStore } from '#/services/auth/tokenStore';
import { resolvePasswordHash, verifyPassword } from '#/services/auth/password';
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'kimi-auth-test-'));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
describe('privateFiles', () => {
it('writes a file with mode 0600', async () => {
const p = join(tmpDir, 'secret');
await writePrivateFile(p, 'hello');
expect(statSync(p).mode & 0o777).toBe(0o600);
});
it('creates an absent parent dir with mode 0700', async () => {
const p = join(tmpDir, 'nested', 'dir', 'secret');
await writePrivateFile(p, 'hello');
expect(statSync(join(tmpDir, 'nested', 'dir')).mode & 0o777).toBe(0o700);
});
it('round-trips string content through readPrivateFile', async () => {
const p = join(tmpDir, 'secret');
await writePrivateFile(p, 's3cr3t-value');
const buf = await readPrivateFile(p);
expect(buf.toString('utf8')).toBe('s3cr3t-value');
});
it('round-trips Buffer content through readPrivateFile', async () => {
const p = join(tmpDir, 'bin');
const data = Buffer.from([0, 1, 2, 254, 255]);
await writePrivateFile(p, data);
const buf = await readPrivateFile(p);
expect(buf.equals(data)).toBe(true);
});
it('readPrivateFile throws on a 0644 file', async () => {
const p = join(tmpDir, 'leaky');
writeFileSync(p, 'x', { mode: 0o644 });
chmodSync(p, 0o644);
await expect(readPrivateFile(p)).rejects.toThrowError(
PrivateFileTooPermissiveError,
);
});
});
describe('tokenStore', () => {
it('returns the same token from repeated getToken() calls', async () => {
const store = await createTokenStore(join(tmpDir, 'home'));
expect(store.getToken()).toBe(store.getToken());
await store.dispose();
});
it('produces different tokens for different home dirs', async () => {
const a = await createTokenStore(join(tmpDir, 'home-a'));
const b = await createTokenStore(join(tmpDir, 'home-b'));
expect(a.getToken()).not.toBe(b.getToken());
await a.dispose();
await b.dispose();
});
it('reuses the same persistent token across stores in one home dir', async () => {
const home = join(tmpDir, 'home');
const a = await createTokenStore(home);
const token = a.getToken();
await a.dispose();
const b = await createTokenStore(home);
expect(b.getToken()).toBe(token);
await b.dispose();
});
it('writes the token file with mode 0600 at server.token', async () => {
const home = join(tmpDir, 'home');
const store = await createTokenStore(home);
expect(store.tokenPath).toBe(join(home, 'server.token'));
expect(statSync(store.tokenPath).mode & 0o777).toBe(0o600);
await store.dispose();
});
it('isValid accepts the token and rejects wrong / empty / same-length candidates', async () => {
const store = await createTokenStore(join(tmpDir, 'home'));
const token = store.getToken();
expect(store.isValid(token)).toBe(true);
expect(store.isValid('wrong')).toBe(false);
expect(store.isValid('')).toBe(false);
const other = await createTokenStore(join(tmpDir, 'home-other'));
expect(other.getToken().length).toBe(token.length);
expect(store.isValid(other.getToken())).toBe(false);
await store.dispose();
await other.dispose();
});
it('dispose() keeps the persistent token file on disk', async () => {
const store = await createTokenStore(join(tmpDir, 'home'));
expect(existsSync(store.tokenPath)).toBe(true);
await store.dispose();
expect(existsSync(store.tokenPath)).toBe(true);
});
it('re-reads the token after the file is rewritten (live rotation)', async () => {
const home = join(tmpDir, 'home');
const store = await createTokenStore(home);
const original = store.getToken();
// Rewrite the same way `rotateServerToken` does (atomic rename → new
// inode/mtime). Use a distinct, same-length value so the length check in
// isValid does not short-circuit.
const rotated = 'r'.repeat(original.length);
await writePrivateFile(store.tokenPath, rotated);
expect(store.getToken()).toBe(rotated);
expect(store.isValid(rotated)).toBe(true);
expect(store.isValid(original)).toBe(false);
await store.dispose();
});
});
describe('persistentToken', () => {
it('loadOrCreateServerToken generates once and reuses thereafter', async () => {
const home = join(tmpDir, 'home');
const a = await loadOrCreateServerToken(home);
const b = await loadOrCreateServerToken(home);
expect(a).toBe(b);
expect(statSync(join(home, 'server.token')).mode & 0o777).toBe(0o600);
});
it('rotateServerToken writes a new, different token to server.token', async () => {
const home = join(tmpDir, 'home');
const original = await loadOrCreateServerToken(home);
const rotated = await rotateServerToken(home);
expect(rotated).not.toBe(original);
expect(readFileSync(join(home, 'server.token'), 'utf8').trim()).toBe(rotated);
});
});
describe('password', () => {
it('resolvePasswordHash returns undefined when env is unset or empty', async () => {
expect(await resolvePasswordHash({})).toBeUndefined();
expect(await resolvePasswordHash({ KIMI_CODE_PASSWORD: '' })).toBeUndefined();
});
it('hashes a set password with bcrypt and verifies correctly', async () => {
const passwordHash = await resolvePasswordHash({
KIMI_CODE_PASSWORD: 'correct-horse-battery-staple',
});
expect(passwordHash?.startsWith('$2')).toBe(true);
expect(await verifyPassword('correct-horse-battery-staple', passwordHash)).toBe(
true,
);
expect(await verifyPassword('wrong-password', passwordHash)).toBe(false);
});
it('verifyPassword returns false when the hash is undefined', async () => {
expect(await verifyPassword('anything', undefined)).toBe(false);
});
});

View file

@ -21,6 +21,7 @@ import type { Session, Workspace } from '@moonshot-ai/protocol';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
@ -46,6 +47,7 @@ afterEach(async () => {
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -58,12 +60,24 @@ async function bootDaemon(): Promise<RunningServer> {
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {

View file

@ -36,6 +36,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { WebSocket } from 'ws';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
@ -67,6 +68,7 @@ afterEach(async () => {
async function bootDaemon(options: { telemetry?: TelemetryClient } = {}): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -92,12 +94,24 @@ function recordingTelemetry(records: TelemetryRecord[]): TelemetryClient {
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): { code: number; msg: string; data: T | null; request_id: string; details?: unknown } {
@ -118,7 +132,7 @@ async function openSessionListListener(r: RunningServer): Promise<{
const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws';
const received: Record<string, unknown>[] = [];
const ws = await new Promise<WebSocket>((resolve, reject) => {
const sock = new WebSocket(wsUrl);
const sock = new WebSocket(wsUrl, ['kimi-code.bearer.test-token']);
sock.on('message', (data) => {
try {
received.push(JSON.parse(wsDataToString(data)) as Record<string, unknown>);

View file

@ -36,6 +36,7 @@ import {
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
@ -66,6 +67,7 @@ afterEach(async () => {
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -78,12 +80,24 @@ async function bootDaemon(): Promise<RunningServer> {
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {

View file

@ -25,6 +25,7 @@ import type { Event, SessionSnapshotResponse } from '@moonshot-ai/protocol';
import { IEventService, IPromptService, PromptService } from '@moonshot-ai/agent-core';
import { IRestGateway, IWSBroadcastService, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
import { WSBroadcastService } from '#/services/gateway/wsBroadcastService';
let tmpDir: string;
@ -51,6 +52,7 @@ afterEach(async () => {
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -63,12 +65,24 @@ async function bootDaemon(): Promise<RunningServer> {
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {

View file

@ -20,6 +20,7 @@ import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { startServer, type RunningServer } from '../src';
import { fixedTokenAuth, withAuth } from './helpers/serverHarness';
let tmpDir: string;
let bridgeHome: string;
@ -42,11 +43,14 @@ afterEach(async () => {
});
async function postSession(baseUrl: string, cwd: string): Promise<string> {
const res = await fetch(`${baseUrl}/api/v1/sessions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ metadata: { cwd } }),
});
const res = await fetch(
`${baseUrl}/api/v1/sessions`,
withAuth({
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ metadata: { cwd } }),
}),
);
const env = (await res.json()) as { code: number; data: { id: string } | null };
if (env.code !== 0 || env.data === null) throw new Error(JSON.stringify(env));
return env.data.id;
@ -54,7 +58,7 @@ async function postSession(baseUrl: string, cwd: string): Promise<string> {
async function timeSnapshot(baseUrl: string, sid: string): Promise<number> {
const t = performance.now();
const res = await fetch(`${baseUrl}/api/v1/sessions/${sid}/snapshot`);
const res = await fetch(`${baseUrl}/api/v1/sessions/${sid}/snapshot`, withAuth());
await res.text();
return performance.now() - t;
}
@ -71,6 +75,7 @@ describe('SnapshotReader perf (real HTTP, 50 sessions)', () => {
host: '127.0.0.1',
port: 0,
lockPath: join(tmpDir, 'lock'),
serviceOverrides: [fixedTokenAuth()],
logger: pino({ level: 'silent' }),
coreProcessOptions: { homeDir: bridgeHome },
});

View file

@ -22,6 +22,7 @@ import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { startServer, type RunningServer } from '../src';
import { fixedTokenAuth, withAuth } from './helpers/serverHarness';
let tmpDir: string;
let bridgeHome: string;
@ -48,6 +49,7 @@ async function boot(): Promise<{ baseUrl: string }> {
host: '127.0.0.1',
port: 0,
lockPath: join(tmpDir, 'lock'),
serviceOverrides: [fixedTokenAuth()],
logger: pino({ level: 'silent' }),
coreProcessOptions: { homeDir: bridgeHome },
});
@ -55,11 +57,14 @@ async function boot(): Promise<{ baseUrl: string }> {
}
async function postSession(baseUrl: string): Promise<string> {
const res = await fetch(`${baseUrl}/api/v1/sessions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ metadata: { cwd: join(tmpDir, 'workspace') } }),
});
const res = await fetch(
`${baseUrl}/api/v1/sessions`,
withAuth({
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ metadata: { cwd: join(tmpDir, 'workspace') } }),
}),
);
const env = (await res.json()) as { code: number; data: { id: string } | null };
if (env.code !== 0 || env.data === null) throw new Error(`createSession failed: ${JSON.stringify(env)}`);
return env.data.id;
@ -71,7 +76,7 @@ async function fetchSnapshot(baseUrl: string, sid: string): Promise<{
ms: number;
}> {
const t0 = performance.now();
const res = await fetch(`${baseUrl}/api/v1/sessions/${sid}/snapshot`);
const res = await fetch(`${baseUrl}/api/v1/sessions/${sid}/snapshot`, withAuth());
const envelope = (await res.json()) as { code: number; data: unknown };
return { status: res.status, envelope, ms: performance.now() - t0 };
}

View file

@ -47,6 +47,7 @@ import {
type LockContents,
type RunningServer,
} from '../src';
import { authHeaders, fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
@ -123,6 +124,7 @@ function addrInUse(): NodeJS.ErrnoException {
async function spawn(): Promise<RunningServer> {
const r = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -171,6 +173,7 @@ describe('startServer — lock + healthz smoke', () => {
const thirdPartyLockPath = join(tmpDir, 'lock-third-party');
try {
const r = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port,
lockPath: thirdPartyLockPath,
@ -303,6 +306,7 @@ describe('startServer — web assets', () => {
writeFileSync(join(assetsDir, 'app.js'), 'console.log("kimi web");', 'utf8');
const r = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -325,7 +329,7 @@ describe('startServer — web assets', () => {
const health = await fetch(`${r.address}/api/v1/healthz`);
await expect(health.json()).resolves.toMatchObject({ code: 0 });
const openApi = await fetch(`${r.address}/openapi.json`);
const openApi = await fetch(`${r.address}/openapi.json`, { headers: authHeaders() });
expect(openApi.status).toBe(200);
expect(openApi.headers.get('content-type')).toContain('application/json');
await expect(openApi.json()).resolves.toMatchObject({
@ -338,7 +342,7 @@ describe('startServer — web assets', () => {
},
});
const asyncApi = await fetch(`${r.address}/asyncapi.json`);
const asyncApi = await fetch(`${r.address}/asyncapi.json`, { headers: authHeaders() });
expect(asyncApi.status).toBe(200);
expect(asyncApi.headers.get('content-type')).toContain('application/json');
await expect(asyncApi.json()).resolves.toMatchObject({
@ -362,6 +366,7 @@ describe('startServer — web assets', () => {
it('does not expose the Swagger UI while keeping /openapi.json available', async () => {
const r = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -370,7 +375,7 @@ describe('startServer — web assets', () => {
});
running.push(r);
const openApi = await fetch(`${r.address}/openapi.json`);
const openApi = await fetch(`${r.address}/openapi.json`, { headers: authHeaders() });
expect(openApi.status).toBe(200);
const res = await fetch(`${r.address}/documentation`);
@ -433,11 +438,14 @@ describe('POST /api/v1/shutdown', () => {
coreProcessOptions: { homeDir: bridgeHome },
// Override the real shutdown service so the route does not exit the
// test runner via `process.exit(0)`.
serviceOverrides: [[IServerShutdownService, fake] as const],
serviceOverrides: [fixedTokenAuth(), [IServerShutdownService, fake] as const],
});
running.push(r);
const res = await fetch(`${r.address}/api/v1/shutdown`, { method: 'POST' });
const res = await fetch(`${r.address}/api/v1/shutdown`, {
method: 'POST',
headers: authHeaders(),
});
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body['code']).toBe(0);

View file

@ -13,6 +13,7 @@ import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
@ -38,6 +39,7 @@ afterEach(async () => {
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -50,12 +52,24 @@ async function bootDaemon(): Promise<RunningServer> {
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown; payload: string }>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown; payload: string }>;
};
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown; payload: string }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function asRecord(value: unknown): Record<string, unknown> {

View file

@ -40,6 +40,7 @@ import {
} from '@moonshot-ai/protocol';
import { IRestGateway, startServer, type ServerStartOptions, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
@ -72,7 +73,7 @@ async function bootDaemon(
lockPath,
logger: pino({ level: 'silent' }),
coreProcessOptions: { homeDir: bridgeHome },
serviceOverrides,
serviceOverrides: [fixedTokenAuth(), ...(serviceOverrides ?? [])],
});
return server;
}
@ -80,12 +81,24 @@ async function bootDaemon(
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {

View file

@ -8,6 +8,7 @@ import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
import { FakeTerminalBackend } from './terminalTestBackend';
let tmpDir: string;
@ -41,6 +42,7 @@ async function bootServer(): Promise<RunningServer> {
logger: pino({ level: 'silent' }),
coreProcessOptions: { homeDir: bridgeHome },
serviceOverrides: [
fixedTokenAuth(),
[ITerminalService, new SyncDescriptor(TerminalService, [{ backend }], false)],
],
});
@ -50,12 +52,24 @@ async function bootServer(): Promise<RunningServer> {
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {

View file

@ -26,6 +26,7 @@ import {
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
@ -51,6 +52,7 @@ afterEach(async () => {
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -63,12 +65,24 @@ async function bootDaemon(): Promise<RunningServer> {
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {

View file

@ -25,6 +25,7 @@ import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
import type { Workspace } from '@moonshot-ai/protocol';
let tmpDir: string;
@ -51,6 +52,7 @@ afterEach(async () => {
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -63,12 +65,24 @@ async function bootDaemon(): Promise<RunningServer> {
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {

View file

@ -32,6 +32,7 @@ import { WebSocket } from 'ws';
import { IPromptService, PromptService } from '@moonshot-ai/agent-core';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
import { rawDataToString } from '../src/ws/rawData';
let tmpDir: string;
@ -58,6 +59,7 @@ afterEach(async () => {
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -71,12 +73,24 @@ async function bootDaemon(): Promise<RunningServer> {
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {
@ -129,7 +143,7 @@ async function openSubscriber(r: RunningServer, sid: string): Promise<Subscriber
const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws';
const received: Record<string, unknown>[] = [];
const ws = await new Promise<WebSocket>((resolve, reject) => {
const sock = new WebSocket(wsUrl);
const sock = new WebSocket(wsUrl, ['kimi-code.bearer.test-token']);
sock.on('message', (data) => {
try {
received.push(JSON.parse(rawDataToString(data)) as Record<string, unknown>);

View file

@ -0,0 +1,252 @@
/**
* WS upgrade auth (ROADMAP M3).
*
* M3.1 adds the `kimi-code.bearer.<token>` subprotocol parser; M3.2 wires it
* into the upgrade path. The parser is exercised as pure unit cases first; the
* upgrade-path cases boot `startServer` with a fixed-token
* `IAuthTokenService` injected through `wsGatewayOptions.authTokenService`.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { WebSocket } from 'ws';
import { startServer, type RunningServer } from '../src';
import { IAuthTokenService } from '../src/services/auth/authTokenService';
import { extractWsBearerToken } from '../src/services/gateway/wsGateway';
import { rawDataToString } from '../src/ws/rawData';
describe('extractWsBearerToken', () => {
it('returns undefined for a missing header', () => {
expect(extractWsBearerToken(undefined)).toBeUndefined();
});
it('returns undefined for an empty header', () => {
expect(extractWsBearerToken('')).toBeUndefined();
});
it('extracts the token from a single bearer subprotocol', () => {
expect(extractWsBearerToken('kimi-code.bearer.TOKEN')).toBe('TOKEN');
});
it('finds the bearer subprotocol among a comma-separated list', () => {
expect(extractWsBearerToken('other, kimi-code.bearer.TOKEN2')).toBe('TOKEN2');
});
it('returns undefined for an empty token', () => {
expect(extractWsBearerToken('kimi-code.bearer.')).toBeUndefined();
});
it('returns undefined when no subprotocol matches', () => {
expect(extractWsBearerToken('unrelated')).toBeUndefined();
});
});
interface WsFrame {
type: string;
payload?: unknown;
[k: string]: unknown;
}
interface Conn {
ws: WebSocket;
queue: WsFrame[];
waiters: Array<(frame: WsFrame) => void>;
closed: Promise<{ code: number; reason: string }>;
}
interface ConnectOptions {
protocols?: string[];
headers?: Record<string, string>;
}
let tmpDir: string;
let lockPath: string;
let bridgeHome: string;
const running: RunningServer[] = [];
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-ws-auth-'));
lockPath = join(tmpDir, 'lock');
bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-ws-auth-home-'));
});
afterEach(async () => {
for (const r of running.splice(0)) {
try {
await r.close();
} catch {
// ignore
}
}
rmSync(tmpDir, { recursive: true, force: true });
rmSync(bridgeHome, { recursive: true, force: true });
});
/** Accepts exactly `'test-token'`; mirrors the fixed-token seam used in M2. */
const fixedTokenAuth: IAuthTokenService = {
_serviceBrand: undefined,
getToken: () => 'test-token',
isValid: async (candidate) => candidate === 'test-token',
};
async function spawn(): Promise<RunningServer> {
const r = await startServer({
host: '127.0.0.1',
port: 0,
lockPath,
logger: pino({ level: 'silent' }),
coreProcessOptions: { homeDir: bridgeHome },
wsGatewayOptions: {
pingIntervalMs: 60,
pongTimeoutMs: 200,
},
// Inject the fixed token via the DI seam (M5.1 reads it through the WS
// gateway's `setAuthTokenService`, no longer via `wsGatewayOptions`).
serviceOverrides: [[IAuthTokenService, fixedTokenAuth]],
});
running.push(r);
return r;
}
function wsUrl(http: string): string {
return http.replace(/^http:\/\//, 'ws://') + '/api/v1/ws';
}
function openConn(url: string, opts?: ConnectOptions): Promise<Conn> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url, opts?.protocols, { headers: opts?.headers });
const queue: WsFrame[] = [];
const waiters: Array<(frame: WsFrame) => void> = [];
let closedResolve: (v: { code: number; reason: string }) => void;
const closed = new Promise<{ code: number; reason: string }>((res) => {
closedResolve = res;
});
ws.on('message', (data) => {
let parsed: WsFrame;
try {
parsed = JSON.parse(rawDataToString(data)) as WsFrame;
} catch {
return;
}
if (waiters.length > 0) {
waiters.shift()?.(parsed);
} else {
queue.push(parsed);
}
});
ws.on('close', (code, reason) => {
closedResolve({ code, reason: String(reason) });
});
ws.once('open', () => resolve({ ws, queue, waiters, closed }));
ws.once('error', (err) => reject(err));
});
}
function receive(conn: Conn, timeoutMs: number): Promise<WsFrame> {
return new Promise((resolve, reject) => {
if (conn.queue.length > 0) {
resolve(conn.queue.shift()!);
return;
}
const t = setTimeout(() => {
const idx = conn.waiters.indexOf(waiter);
if (idx >= 0) conn.waiters.splice(idx, 1);
reject(new Error(`no message in ${timeoutMs}ms`));
}, timeoutMs);
const waiter = (frame: WsFrame): void => {
clearTimeout(t);
resolve(frame);
};
conn.waiters.push(waiter);
});
}
async function receiveType(conn: Conn, type: string, timeoutMs: number): Promise<WsFrame> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const remaining = deadline - Date.now();
if (remaining <= 0) throw new Error(`no message of type ${type} within ${timeoutMs}ms`);
const frame = await receive(conn, remaining);
if (frame.type === type) return frame;
}
}
/**
* Asserts the upgrade is rejected (the socket errors/closes without ever
* opening, so no `server_hello` is received). Resolves on the first `error` or
* `close`; rejects if the socket opens or nothing happens within the timeout.
*/
function expectRejected(url: string, opts?: ConnectOptions): Promise<void> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url, opts?.protocols, { headers: opts?.headers });
const done = (err?: Error): void => {
clearTimeout(t);
ws.removeAllListeners();
try {
ws.terminate();
} catch {
// ignore
}
if (err !== undefined) reject(err);
else resolve();
};
const t = setTimeout(
() => done(new Error('connection was not rejected within timeout')),
1500,
);
ws.once('open', () => done(new Error('connection unexpectedly opened')));
ws.once('error', () => done());
ws.once('close', () => done());
});
}
describe('ws upgrade auth', () => {
it('accepts a valid bearer subprotocol and echoes it', async () => {
const r = await spawn();
const conn = await openConn(wsUrl(r.address), {
protocols: ['kimi-code.bearer.test-token'],
});
await receiveType(conn, 'server_hello', 1000);
expect(conn.ws.protocol).toBe('kimi-code.bearer.test-token');
conn.ws.close();
await conn.closed;
});
it('accepts a valid Authorization bearer header', async () => {
const r = await spawn();
const conn = await openConn(wsUrl(r.address), {
headers: { Authorization: 'Bearer test-token' },
});
const hello = await receiveType(conn, 'server_hello', 1000);
expect(hello.type).toBe('server_hello');
conn.ws.close();
await conn.closed;
});
it('rejects a wrong bearer token without server_hello', async () => {
const r = await spawn();
await expectRejected(wsUrl(r.address), {
protocols: ['kimi-code.bearer.wrong'],
});
});
it('rejects a connection with no token', async () => {
const r = await spawn();
await expectRejected(wsUrl(r.address));
});
it('rejects upgrades to a non-/api/v1/ws path', async () => {
const r = await spawn();
const badUrl = r.address.replace(/^http:\/\//, 'ws://') + '/api/v1/other';
await expectRejected(badUrl, { protocols: ['kimi-code.bearer.test-token'] });
});
});

View file

@ -31,6 +31,7 @@ import {
startServer,
type RunningServer,
} from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
import { rawDataToString } from '../src/ws/rawData';
let tmpDir: string;
@ -52,12 +53,15 @@ afterEach(async () => {
// ignore
}
}
rmSync(tmpDir, { recursive: true, force: true });
rmSync(bridgeHome, { recursive: true, force: true });
// The server's core process may still flush files into the sandboxed home
// briefly after close(), so retry removals to ride out EBUSY/ENOTEMPTY races.
rmSync(tmpDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
rmSync(bridgeHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
});
async function spawn(): Promise<RunningServer> {
const r = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -91,7 +95,7 @@ interface Conn {
function openConn(url: string): Promise<Conn> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
const ws = new WebSocket(url, ['kimi-code.bearer.test-token']);
const queue: WsFrame[] = [];
const waiters: Array<(frame: WsFrame) => void> = [];
ws.on('message', (data) => {

View file

@ -25,6 +25,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { WebSocket } from 'ws';
import { IConnectionRegistry, IWSGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
import { rawDataToString } from '../src/ws/rawData';
interface TelemetryRecord {
@ -84,6 +85,7 @@ afterEach(async () => {
async function spawn(): Promise<RunningServer> {
const r = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -122,7 +124,7 @@ interface Conn {
function openConn(url: string): Promise<Conn> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
const ws = new WebSocket(url, ['kimi-code.bearer.test-token']);
const queue: WsFrame[] = [];
const waiters: Array<(frame: WsFrame) => void> = [];
let closedResolve: (v: { code: number; reason: string }) => void;
@ -284,6 +286,7 @@ describe('WS gateway connection-count observer', () => {
it('reports size 1 after a connect and size 0 after the last disconnect', async () => {
const counts: number[] = [];
const r = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -313,6 +316,7 @@ describe('WS gateway connection-count observer', () => {
it('tracks multiple concurrent connections independently', async () => {
const counts: number[] = [];
const r = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -350,6 +354,7 @@ describe('WS gateway telemetry (ws_connected / ws_disconnected)', () => {
it('emits ws_connected on connect and ws_disconnected on close', async () => {
const records: TelemetryRecord[] = [];
const r = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,

View file

@ -41,6 +41,7 @@ import {
startServer,
type RunningServer,
} from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
import { rawDataToString } from '../src/ws/rawData';
import { WSBroadcastService } from '#/services/gateway/wsBroadcastService';
@ -69,6 +70,7 @@ afterEach(async () => {
async function spawn(): Promise<RunningServer> {
const r = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
@ -104,7 +106,7 @@ interface Conn {
function openConn(url: string): Promise<Conn> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
const ws = new WebSocket(url, ['kimi-code.bearer.test-token']);
const queue: WsFrame[] = [];
const waiters: Array<(frame: WsFrame) => void> = [];
ws.on('message', (data) => {

View file

@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { WebSocket } from 'ws';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
import { rawDataToString } from '../src/ws/rawData';
import { FakeTerminalBackend } from './terminalTestBackend';
@ -44,6 +45,7 @@ async function bootServer(): Promise<RunningServer> {
coreProcessOptions: { homeDir: bridgeHome },
wsGatewayOptions: { pingIntervalMs: 5_000, pongTimeoutMs: 5_000 },
serviceOverrides: [
fixedTokenAuth(),
[ITerminalService, new SyncDescriptor(TerminalService, [{ backend }], false)],
],
});
@ -53,12 +55,24 @@ async function bootServer(): Promise<RunningServer> {
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
return r.services.invokeFunction((a) => {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
// caller-supplied `authorization` header wins, so explicit token tests keep
// working; every other header (Range, content-type, …) is preserved.
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {
@ -112,7 +126,9 @@ async function openSocket(r: RunningServer): Promise<{
}> {
const received: Record<string, unknown>[] = [];
const ws = await new Promise<WebSocket>((resolve, reject) => {
const sock = new WebSocket(r.address.replace('http://', 'ws://') + '/api/v1/ws');
const sock = new WebSocket(r.address.replace('http://', 'ws://') + '/api/v1/ws', [
'kimi-code.bearer.test-token',
]);
sock.on('message', (data) => {
received.push(JSON.parse(rawDataToString(data)) as Record<string, unknown>);
});

16
pnpm-lock.yaml generated
View file

@ -553,6 +553,9 @@ importers:
'@moonshot-ai/protocol':
specifier: workspace:^
version: link:../protocol
bcryptjs:
specifier: ^2.4.3
version: 2.4.3
fastify:
specifier: ^5.1.0
version: 5.8.5
@ -575,6 +578,9 @@ importers:
specifier: ^3.25.2
version: 3.25.2(zod@4.3.6)
devDependencies:
'@types/bcryptjs':
specifier: ^2.4.6
version: 2.4.6
'@types/ws':
specifier: ^8.18.0
version: 8.18.1
@ -2561,6 +2567,9 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/bcryptjs@2.4.6':
resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@ -3070,6 +3079,9 @@ packages:
bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
bcryptjs@2.4.3:
resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==}
better-path-resolve@1.0.0:
resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==}
engines: {node: '>=4'}
@ -8018,6 +8030,8 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
'@types/bcryptjs@2.4.6': {}
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@ -8595,6 +8609,8 @@ snapshots:
dependencies:
tweetnacl: 0.14.5
bcryptjs@2.4.3: {}
better-path-resolve@1.0.0:
dependencies:
is-windows: 1.0.2