From 687f6d841f0df49255e634c92f78bfd162a01ba3 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 16:48:13 +0800 Subject: [PATCH 01/37] 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. --- .../api-surface.snapshot.test.ts.snap | 282 ++++++++++++++++++ .../server/test/api-surface.snapshot.test.ts | 111 +++++++ 2 files changed, 393 insertions(+) create mode 100644 packages/server/test/__snapshots__/api-surface.snapshot.test.ts.snap create mode 100644 packages/server/test/api-surface.snapshot.test.ts diff --git a/packages/server/test/__snapshots__/api-surface.snapshot.test.ts.snap b/packages/server/test/__snapshots__/api-surface.snapshot.test.ts.snap new file mode 100644 index 0000000000..d12ec3a290 --- /dev/null +++ b/packages/server/test/__snapshots__/api-surface.snapshot.test.ts.snap @@ -0,0 +1,282 @@ +// 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/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", + ], + ], +} +`; diff --git a/packages/server/test/api-surface.snapshot.test.ts b/packages/server/test/api-surface.snapshot.test.ts new file mode 100644 index 0000000000..a94dc3fdc5 --- /dev/null +++ b/packages/server/test/api-surface.snapshot.test.ts @@ -0,0 +1,111 @@ +/** + * 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'; + +/** 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({ + 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`); + expect(openApiRes.status).toBe(200); + const openApi = (await openApiRes.json()) as { + paths?: Record>; + }; + 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}`); + 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(); + }); +}); From bdea467c730568662ab0a5a2080312e4da89aa7c Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 16:53:22 +0800 Subject: [PATCH 02/37] 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 (and the kimi-code.bearer. 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. --- packages/server/test/helpers/serverHarness.ts | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 packages/server/test/helpers/serverHarness.ts diff --git a/packages/server/test/helpers/serverHarness.ts b/packages/server/test/helpers/serverHarness.ts new file mode 100644 index 0000000000..ac135267b8 --- /dev/null +++ b/packages/server/test/helpers/serverHarness.ts @@ -0,0 +1,181 @@ +/** + * Reusable e2e server harness with token support (ROADMAP M0.2). + * + * Wraps `startServer` with an isolated tmp lock + home dir and exposes helpers + * (`authedFetch` / `authedWs`) that carry an `Authorization: Bearer ` + * so later phases (M2.2 onward) can exercise authenticated endpoints without + * re-deriving URLs or threading tokens by hand. + * + * In M0 there is no server-side auth yet, so the token is simply attached to + * requests and ignored by the server; the harness still typechecks and behaves + * correctly against the current auth-less server. The auth seam is + * `BootOptions.serviceOverrides`: from M2.1 on, callers inject a fixed-token + * `IAuthTokenService` there. `IAuthTokenService` does not exist in M0, so this + * module intentionally stays generic and does not import it. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { WebSocket } from 'ws'; + +import { startServer, type RunningServer, type ServerStartOptions } from '../../src'; + +/** Default deterministic token used when a test does not supply one. */ +const DEFAULT_TOKEN = 'test-token'; + +/** + * 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 ` + * into the request headers. Caller-supplied headers win on conflict. + */ + authedFetch(path: string, init?: RequestInit): Promise; + /** + * Open a `ws` WebSocket to `wsUrl`, offering subprotocol + * `kimi-code.bearer.` and an `Authorization: Bearer ` 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; +} + +/** Every harness produced by `boot()`, for suite-level cleanup via `closeAll()`. */ +const opened = new Set(); + +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 { + const token = opts.token ?? DEFAULT_TOKEN; + const host = opts.host ?? '127.0.0.1'; + const port = opts.port ?? 0; + const serviceOverrides = 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(); + let closed = false; + + const harness: ServerHarness = { + server, + address: server.address, + baseUrl, + wsUrl, + token, + + authedFetch(path: string, init: RequestInit = {}): Promise { + 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 { + 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 { + const pending = [...opened]; + await Promise.all(pending.map((harness) => harness.close())); +} From 2f74211822f943e3e19d5186cb379c0be873e16c Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 17:11:20 +0800 Subject: [PATCH 03/37] feat(server): add privateFiles 0600 atomic write/read utility --- .../server/src/services/auth/privateFiles.ts | 61 +++++++++++++++++++ packages/server/test/services-auth.test.ts | 59 ++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 packages/server/src/services/auth/privateFiles.ts create mode 100644 packages/server/test/services-auth.test.ts diff --git a/packages/server/src/services/auth/privateFiles.ts b/packages/server/src/services/auth/privateFiles.ts new file mode 100644 index 0000000000..2197ef72f3 --- /dev/null +++ b/packages/server/src/services/auth/privateFiles.ts @@ -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 { + 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> | 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 { + const info = await stat(filePath); + if ((info.mode & 0o077) !== 0) { + throw new PrivateFileTooPermissiveError(filePath, info.mode & 0o777); + } + return readFile(filePath); +} diff --git a/packages/server/test/services-auth.test.ts b/packages/server/test/services-auth.test.ts new file mode 100644 index 0000000000..30085960d0 --- /dev/null +++ b/packages/server/test/services-auth.test.ts @@ -0,0 +1,59 @@ +import { chmodSync, mkdtempSync, 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'; + +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, + ); + }); +}); From f4695b151d661c0cf5dba68602ca0aeedfad50bc Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 17:14:45 +0800 Subject: [PATCH 04/37] feat(server): add per-start tokenStore --- packages/server/src/services/auth/index.ts | 2 + .../server/src/services/auth/tokenStore.ts | 40 ++++++++++++++ packages/server/test/services-auth.test.ts | 55 ++++++++++++++++++- 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/services/auth/index.ts create mode 100644 packages/server/src/services/auth/tokenStore.ts diff --git a/packages/server/src/services/auth/index.ts b/packages/server/src/services/auth/index.ts new file mode 100644 index 0000000000..dd271aa1fd --- /dev/null +++ b/packages/server/src/services/auth/index.ts @@ -0,0 +1,2 @@ +export * from './privateFiles'; +export * from './tokenStore'; diff --git a/packages/server/src/services/auth/tokenStore.ts b/packages/server/src/services/auth/tokenStore.ts new file mode 100644 index 0000000000..9ebe8a419b --- /dev/null +++ b/packages/server/src/services/auth/tokenStore.ts @@ -0,0 +1,40 @@ +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { writePrivateFile } from './privateFiles'; + +export interface TokenStore { + readonly tokenPath: string; + getToken(): string; + isValid(candidate: string): boolean; + dispose(): Promise; +} + +export async function createTokenStore( + homeDir: string, + pid: number, +): Promise { + const token = randomBytes(32).toString('base64url'); + const tokenPath = join(homeDir, `server-${pid}.token`); + const tokenBuf = Buffer.from(token); + + await writePrivateFile(tokenPath, token); + + return { + tokenPath, + getToken(): string { + return token; + }, + isValid(candidate: string): boolean { + const candidateBuf = Buffer.from(candidate); + if (candidateBuf.length !== tokenBuf.length) { + return false; + } + return timingSafeEqual(candidateBuf, tokenBuf); + }, + async dispose(): Promise { + await rm(tokenPath, { force: true }); + }, + }; +} diff --git a/packages/server/test/services-auth.test.ts b/packages/server/test/services-auth.test.ts index 30085960d0..1e965f5c83 100644 --- a/packages/server/test/services-auth.test.ts +++ b/packages/server/test/services-auth.test.ts @@ -1,4 +1,11 @@ -import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { + chmodSync, + existsSync, + mkdtempSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -9,6 +16,7 @@ import { readPrivateFile, writePrivateFile, } from '#/services/auth/privateFiles'; +import { createTokenStore } from '#/services/auth/tokenStore'; let tmpDir: string; @@ -57,3 +65,48 @@ describe('privateFiles', () => { ); }); }); + +describe('tokenStore', () => { + it('returns the same token from repeated getToken() calls', async () => { + const store = await createTokenStore(join(tmpDir, 'home'), 111); + expect(store.getToken()).toBe(store.getToken()); + await store.dispose(); + }); + + it('produces different tokens for different stores', async () => { + const a = await createTokenStore(join(tmpDir, 'home-a'), 111); + const b = await createTokenStore(join(tmpDir, 'home-b'), 222); + expect(a.getToken()).not.toBe(b.getToken()); + await a.dispose(); + 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, 4242); + expect(store.tokenPath).toBe(join(home, 'server-4242.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'), 333); + 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'), 334); + expect(other.getToken().length).toBe(token.length); + expect(store.isValid(other.getToken())).toBe(false); + await store.dispose(); + await other.dispose(); + }); + + it('dispose() removes the token file', async () => { + const store = await createTokenStore(join(tmpDir, 'home'), 555); + expect(existsSync(store.tokenPath)).toBe(true); + await store.dispose(); + expect(existsSync(store.tokenPath)).toBe(false); + }); +}); From 4bd529c37f65e35745e2be8d9d4f1c2d57db17bb Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 18:02:03 +0800 Subject: [PATCH 05/37] feat(server): add env-based bcrypt password hash utility --- packages/server/package.json | 1 + packages/server/src/services/auth/index.ts | 1 + packages/server/src/services/auth/password.ts | 23 +++ packages/server/test/services-auth.test.ts | 23 +++ pnpm-lock.yaml | 165 ++++++++++++++++++ 5 files changed, 213 insertions(+) create mode 100644 packages/server/src/services/auth/password.ts diff --git a/packages/server/package.json b/packages/server/package.json index c8346872ef..8bc3349b85 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -42,6 +42,7 @@ "@fastify/swagger": "^9.7.0", "@moonshot-ai/agent-core": "workspace:^", "@moonshot-ai/protocol": "workspace:^", + "@node-rs/bcrypt": "^1.10.7", "fastify": "^5.1.0", "pino": "^9.5.0", "pino-pretty": "^13.0.0", diff --git a/packages/server/src/services/auth/index.ts b/packages/server/src/services/auth/index.ts index dd271aa1fd..7fc0c7f683 100644 --- a/packages/server/src/services/auth/index.ts +++ b/packages/server/src/services/auth/index.ts @@ -1,2 +1,3 @@ export * from './privateFiles'; export * from './tokenStore'; +export * from './password'; diff --git a/packages/server/src/services/auth/password.ts b/packages/server/src/services/auth/password.ts new file mode 100644 index 0000000000..1576f322c6 --- /dev/null +++ b/packages/server/src/services/auth/password.ts @@ -0,0 +1,23 @@ +import { compare, hash } from '@node-rs/bcrypt'; + +const BCRYPT_COST = 12; + +export async function resolvePasswordHash( + env: NodeJS.ProcessEnv = process.env, +): Promise { + 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 { + if (passwordHash === undefined) { + return false; + } + return compare(candidate, passwordHash); +} diff --git a/packages/server/test/services-auth.test.ts b/packages/server/test/services-auth.test.ts index 1e965f5c83..3fba8ffb5d 100644 --- a/packages/server/test/services-auth.test.ts +++ b/packages/server/test/services-auth.test.ts @@ -17,6 +17,7 @@ import { writePrivateFile, } from '#/services/auth/privateFiles'; import { createTokenStore } from '#/services/auth/tokenStore'; +import { resolvePasswordHash, verifyPassword } from '#/services/auth/password'; let tmpDir: string; @@ -110,3 +111,25 @@ describe('tokenStore', () => { expect(existsSync(store.tokenPath)).toBe(false); }); }); + +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); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8af62e766f..841a7de60f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -553,6 +553,9 @@ importers: '@moonshot-ai/protocol': specifier: workspace:^ version: link:../protocol + '@node-rs/bcrypt': + specifier: ^1.10.7 + version: 1.10.7 fastify: specifier: ^5.1.0 version: 5.8.5 @@ -1671,12 +1674,106 @@ packages: resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==} engines: {node: '>=14.0.0'} + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@node-rs/bcrypt-android-arm-eabi@1.10.7': + resolution: {integrity: sha512-8dO6/PcbeMZXS3VXGEtct9pDYdShp2WBOWlDvSbcRwVqyB580aCBh0BEFmKYtXLzLvUK8Wf+CG3U6sCdILW1lA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@node-rs/bcrypt-android-arm64@1.10.7': + resolution: {integrity: sha512-UASFBS/CucEMHiCtL/2YYsAY01ZqVR1N7vSb94EOvG5iwW7BQO06kXXCTgj+Xbek9azxixrCUmo3WJnkJZ0hTQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@node-rs/bcrypt-darwin-arm64@1.10.7': + resolution: {integrity: sha512-DgzFdAt455KTuiJ/zYIyJcKFobjNDR/hnf9OS7pK5NRS13Nq4gLcSIIyzsgHwZHxsJWbLpHmFc1H23Y7IQoQBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@node-rs/bcrypt-darwin-x64@1.10.7': + resolution: {integrity: sha512-SPWVfQ6sxSokoUWAKWD0EJauvPHqOGQTd7CxmYatcsUgJ/bruvEHxZ4bIwX1iDceC3FkOtmeHO0cPwR480n/xA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@node-rs/bcrypt-freebsd-x64@1.10.7': + resolution: {integrity: sha512-gpa+Ixs6GwEx6U6ehBpsQetzUpuAGuAFbOiuLB2oo4N58yU4AZz1VIcWyWAHrSWRs92O0SHtmo2YPrMrwfBbSw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@node-rs/bcrypt-linux-arm-gnueabihf@1.10.7': + resolution: {integrity: sha512-kYgJnTnpxrzl9sxYqzflobvMp90qoAlaX1oDL7nhNTj8OYJVDIk0jQgblj0bIkjmoPbBed53OJY/iu4uTS+wig==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@node-rs/bcrypt-linux-arm64-gnu@1.10.7': + resolution: {integrity: sha512-7cEkK2RA+gBCj2tCVEI1rDSJV40oLbSq7bQ+PNMHNI6jCoXGmj9Uzo7mg7ZRbNZ7piIyNH5zlJqutjo8hh/tmA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@node-rs/bcrypt-linux-arm64-musl@1.10.7': + resolution: {integrity: sha512-X7DRVjshhwxUqzdUKDlF55cwzh+wqWJ2E/tILvZPboO3xaNO07Um568Vf+8cmKcz+tiZCGP7CBmKbBqjvKN/Pw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@node-rs/bcrypt-linux-x64-gnu@1.10.7': + resolution: {integrity: sha512-LXRZsvG65NggPD12hn6YxVgH0W3VR5fsE/o1/o2D5X0nxKcNQGeLWnRzs5cP8KpoFOuk1ilctXQJn8/wq+Gn/Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@node-rs/bcrypt-linux-x64-musl@1.10.7': + resolution: {integrity: sha512-tCjHmct79OfcO3g5q21ME7CNzLzpw1MAsUXCLHLGWH+V6pp/xTvMbIcLwzkDj6TI3mxK6kehTn40SEjBkZ3Rog==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@node-rs/bcrypt-wasm32-wasi@1.10.7': + resolution: {integrity: sha512-4qXSihIKeVXYglfXZEq/QPtYtBUvR8d3S85k15Lilv3z5B6NSGQ9mYiNleZ7QHVLN2gEc5gmi7jM353DMH9GkA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@node-rs/bcrypt-win32-arm64-msvc@1.10.7': + resolution: {integrity: sha512-FdfUQrqmDfvC5jFhntMBkk8EI+fCJTx/I1v7Rj+Ezlr9rez1j1FmuUnywbBj2Cg15/0BDhwYdbyZ5GCMFli2aQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@node-rs/bcrypt-win32-ia32-msvc@1.10.7': + resolution: {integrity: sha512-lZLf4Cx+bShIhU071p5BZft4OvP4PGhyp542EEsb3zk34U5GLsGIyCjOafcF/2DGewZL6u8/aqoxbSuROkgFXg==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@node-rs/bcrypt-win32-x64-msvc@1.10.7': + resolution: {integrity: sha512-hdw7tGmN1DxVAMTzICLdaHpXjy+4rxaxnBMgI8seG1JL5e3VcRGsd1/1vVDogVp2cbsmgq+6d6yAY+D9lW/DCg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@node-rs/bcrypt@1.10.7': + resolution: {integrity: sha512-1wk0gHsUQC/ap0j6SJa2K34qNhomxXRcEe3T8cI5s+g6fgHBgLTN7U9LzWTG/HE6G4+2tWWLeCabk1wiYGEQSA==} + engines: {node: '>= 10'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -7388,6 +7485,13 @@ snapshots: '@mozilla/readability@0.6.0': {} + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.1 + optional: true + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -7402,6 +7506,67 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@node-rs/bcrypt-android-arm-eabi@1.10.7': + optional: true + + '@node-rs/bcrypt-android-arm64@1.10.7': + optional: true + + '@node-rs/bcrypt-darwin-arm64@1.10.7': + optional: true + + '@node-rs/bcrypt-darwin-x64@1.10.7': + optional: true + + '@node-rs/bcrypt-freebsd-x64@1.10.7': + optional: true + + '@node-rs/bcrypt-linux-arm-gnueabihf@1.10.7': + optional: true + + '@node-rs/bcrypt-linux-arm64-gnu@1.10.7': + optional: true + + '@node-rs/bcrypt-linux-arm64-musl@1.10.7': + optional: true + + '@node-rs/bcrypt-linux-x64-gnu@1.10.7': + optional: true + + '@node-rs/bcrypt-linux-x64-musl@1.10.7': + optional: true + + '@node-rs/bcrypt-wasm32-wasi@1.10.7': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@node-rs/bcrypt-win32-arm64-msvc@1.10.7': + optional: true + + '@node-rs/bcrypt-win32-ia32-msvc@1.10.7': + optional: true + + '@node-rs/bcrypt-win32-x64-msvc@1.10.7': + optional: true + + '@node-rs/bcrypt@1.10.7': + optionalDependencies: + '@node-rs/bcrypt-android-arm-eabi': 1.10.7 + '@node-rs/bcrypt-android-arm64': 1.10.7 + '@node-rs/bcrypt-darwin-arm64': 1.10.7 + '@node-rs/bcrypt-darwin-x64': 1.10.7 + '@node-rs/bcrypt-freebsd-x64': 1.10.7 + '@node-rs/bcrypt-linux-arm-gnueabihf': 1.10.7 + '@node-rs/bcrypt-linux-arm64-gnu': 1.10.7 + '@node-rs/bcrypt-linux-arm64-musl': 1.10.7 + '@node-rs/bcrypt-linux-x64-gnu': 1.10.7 + '@node-rs/bcrypt-linux-x64-musl': 1.10.7 + '@node-rs/bcrypt-wasm32-wasi': 1.10.7 + '@node-rs/bcrypt-win32-arm64-msvc': 1.10.7 + '@node-rs/bcrypt-win32-ia32-msvc': 1.10.7 + '@node-rs/bcrypt-win32-x64-msvc': 1.10.7 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 From e16c4370e9b09447f1fce6c0e50ba70217b3d838 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 18:16:51 +0800 Subject: [PATCH 06/37] feat(server): add IAuthTokenService DI seam --- .../src/services/auth/authTokenService.ts | 56 ++++++++ packages/server/src/services/auth/index.ts | 1 + .../server/src/services/serviceCollection.ts | 6 + packages/server/test/authTokenService.test.ts | 127 ++++++++++++++++++ 4 files changed, 190 insertions(+) create mode 100644 packages/server/src/services/auth/authTokenService.ts create mode 100644 packages/server/test/authTokenService.test.ts diff --git a/packages/server/src/services/auth/authTokenService.ts b/packages/server/src/services/auth/authTokenService.ts new file mode 100644 index 0000000000..27da5b4831 --- /dev/null +++ b/packages/server/src/services/auth/authTokenService.ts @@ -0,0 +1,56 @@ +/** + * `IAuthTokenService` DI surface (ROADMAP M2.1). + * + * Exposes the per-start bearer token plus a single validity check that accepts + * EITHER the per-start 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 per-start bearer token (held in memory; never re-read from disk). */ + getToken(): string; + + /** + * True when `candidate` matches the per-start token OR verifies against the + * configured password hash. Constant-time on the token path; bcrypt on the + * password path. + */ + isValid(candidate: string): Promise; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IAuthTokenService = + createDecorator('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)), + }; +} diff --git a/packages/server/src/services/auth/index.ts b/packages/server/src/services/auth/index.ts index 7fc0c7f683..2e3a822b28 100644 --- a/packages/server/src/services/auth/index.ts +++ b/packages/server/src/services/auth/index.ts @@ -1,3 +1,4 @@ export * from './privateFiles'; export * from './tokenStore'; export * from './password'; +export * from './authTokenService'; diff --git a/packages/server/src/services/serviceCollection.ts b/packages/server/src/services/serviceCollection.ts index 0857211141..203641d3c1 100644 --- a/packages/server/src/services/serviceCollection.ts +++ b/packages/server/src/services/serviceCollection.ts @@ -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); } diff --git a/packages/server/test/authTokenService.test.ts b/packages/server/test/authTokenService.test.ts new file mode 100644 index 0000000000..8577584c57 --- /dev/null +++ b/packages/server/test/authTokenService.test.ts @@ -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, process.pid); + }); + + 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); + }); +}); From 297b7729640fbb3b62092271d74815dfccc1a4cb Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 18:21:09 +0800 Subject: [PATCH 07/37] feat(server): add global onRequest auth hook with bypass + redaction --- packages/server/src/middleware/auth.ts | 112 +++++++++++++++++ .../server/test/auth-middleware.e2e.test.ts | 115 ++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 packages/server/src/middleware/auth.ts create mode 100644 packages/server/test/auth-middleware.e2e.test.ts diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts new file mode 100644 index 0000000000..2079c963da --- /dev/null +++ b/packages/server/src/middleware/auth.ts @@ -0,0 +1,112 @@ +/** + * Global HTTP bearer-auth hook (ROADMAP M2.2). + * + * `createAuthHook` builds a Fastify `onRequest` hook that requires a valid + * `Authorization: Bearer ` 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 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; +} + +/** + * 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 { + const isBypassed = opts?.isBypassed ?? defaultIsBypassed; + + return async (req, reply) => { + 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) { + return reply + .code(401) + .send(errEnvelope(AUTH_ERROR_CODE, AUTH_ERROR_MSG, req.id)); + } + + if (!(await authTokenService.isValid(token))) { + return reply + .code(401) + .send(errEnvelope(AUTH_ERROR_CODE, AUTH_ERROR_MSG, req.id)); + } + }; +} diff --git a/packages/server/test/auth-middleware.e2e.test.ts b/packages/server/test/auth-middleware.e2e.test.ts new file mode 100644 index 0000000000..7810143e42 --- /dev/null +++ b/packages/server/test/auth-middleware.e2e.test.ts @@ -0,0 +1,115 @@ +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'; + +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; + 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)['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)['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]'); + }); +}); From 8f443c36e2ebf392bdcf8b87d2f206bf0d236c63 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 18:24:00 +0800 Subject: [PATCH 08/37] fix(server): stop reflecting Host header in /asyncapi.json --- packages/server/src/start.ts | 11 ++-- .../server/test/auth-middleware.e2e.test.ts | 60 +++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts index 17df42d4af..761bdba33a 100644 --- a/packages/server/src/start.ts +++ b/packages/server/src/start.ts @@ -151,13 +151,12 @@ export async function startServer(opts: ServerStartOptions): Promise { - 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) => { diff --git a/packages/server/test/auth-middleware.e2e.test.ts b/packages/server/test/auth-middleware.e2e.test.ts index 7810143e42..4c064d855e 100644 --- a/packages/server/test/auth-middleware.e2e.test.ts +++ b/packages/server/test/auth-middleware.e2e.test.ts @@ -1,9 +1,13 @@ +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 { @@ -113,3 +117,59 @@ describe('createAuthHook (onRequest middleware)', () => { 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, +): 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('reflects the bound host, not a spoofed Host header', async () => { + const harness = await boot({ host: '127.0.0.1', port: 0 }); + const port = Number(new URL(harness.address).port); + + const { status, body } = await rawGet(port, '/asyncapi.json', { + Host: 'evil.com', + }); + 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'); + }); +}); From a5e316e7fb92032ee3c1ed9ef7ab27d78feb6277 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 18:34:54 +0800 Subject: [PATCH 09/37] feat(server): add WS bearer subprotocol constant and parser --- .../server/src/services/gateway/wsGateway.ts | 34 +++++++++++++++++ packages/server/test/ws-auth.e2e.test.ts | 37 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 packages/server/test/ws-auth.e2e.test.ts diff --git a/packages/server/src/services/gateway/wsGateway.ts b/packages/server/src/services/gateway/wsGateway.ts index 11d7b4b8f7..00b7b7fbdd 100644 --- a/packages/server/src/services/gateway/wsGateway.ts +++ b/packages/server/src/services/gateway/wsGateway.ts @@ -4,6 +4,14 @@ import type { AbortHandler, FsWatchHandler, TerminalHandler } from '#/ws/connect 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}`. + */ +export const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.'; + export interface IWSGateway { readonly _serviceBrand: undefined; @@ -19,6 +27,32 @@ export interface IWSGateway { // eslint-disable-next-line @typescript-eslint/no-redeclare export const IWSGateway = createDecorator('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; diff --git a/packages/server/test/ws-auth.e2e.test.ts b/packages/server/test/ws-auth.e2e.test.ts new file mode 100644 index 0000000000..f2b23028e5 --- /dev/null +++ b/packages/server/test/ws-auth.e2e.test.ts @@ -0,0 +1,37 @@ +/** + * WS upgrade auth (ROADMAP M3). + * + * M3.1 adds the `kimi-code.bearer.` subprotocol parser; M3.2 wires it + * into the upgrade path. The parser is exercised as pure unit cases here; the + * upgrade-path cases are added in the M3.2 block below. + */ + +import { describe, expect, it } from 'vitest'; + +import { extractWsBearerToken } from '../src/services/gateway/wsGateway'; + +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(); + }); +}); From c4b994912fe801e125dd449c4636ec7d3be1a8b5 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 18:41:42 +0800 Subject: [PATCH 10/37] feat(server): enforce bearer token auth on WS upgrade --- .../server/src/services/gateway/wsGateway.ts | 8 + .../src/services/gateway/wsGatewayService.ts | 44 +++- packages/server/test/ws-auth.e2e.test.ts | 218 +++++++++++++++++- 3 files changed, 264 insertions(+), 6 deletions(-) diff --git a/packages/server/src/services/gateway/wsGateway.ts b/packages/server/src/services/gateway/wsGateway.ts index 00b7b7fbdd..7db19110c8 100644 --- a/packages/server/src/services/gateway/wsGateway.ts +++ b/packages/server/src/services/gateway/wsGateway.ts @@ -1,5 +1,6 @@ import { createDecorator, type TelemetryClient } from '@moonshot-ai/agent-core'; +import type { IAuthTokenService } from '#/services/auth/authTokenService'; import type { AbortHandler, FsWatchHandler, TerminalHandler } from '#/ws/connection'; export const WS_PATH = '/api/v1/ws'; @@ -72,4 +73,11 @@ 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.` subprotocol). + * When unset (e.g. tests / pre-M5.1 boots), upgrade auth is skipped. + */ + authTokenService?: IAuthTokenService; } diff --git a/packages/server/src/services/gateway/wsGatewayService.ts b/packages/server/src/services/gateway/wsGatewayService.ts index 7c06b98f00..161d855f23 100644 --- a/packages/server/src/services/gateway/wsGatewayService.ts +++ b/packages/server/src/services/gateway/wsGatewayService.ts @@ -15,7 +15,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; @@ -37,7 +43,18 @@ export class WSGateway extends Disposable implements IWSGateway { @ILogService private readonly logger: ILogService, ) { super(); - this.wss = new WebSocketServer({ noServer: true }); + this.wss = new WebSocketServer({ + noServer: true, + // Browsers require the server to select one of the offered subprotocols; + // echo back the `kimi-code.bearer.` subprotocol when present so + // token-carrying browser clients complete the handshake. + handleProtocols: (protocols: Set) => { + 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.server.on('upgrade', this.upgradeListener); @@ -56,7 +73,7 @@ export class WSGateway extends Disposable implements IWSGateway { this.terminalHandler = handler; } - private onUpgrade(req: IncomingMessage, socket: Socket, head: Buffer): void { + private async onUpgrade(req: IncomingMessage, socket: Socket, head: Buffer): Promise { const url = req.url ?? ''; const path = url.split('?', 1)[0]; if (path !== WS_PATH) { @@ -68,6 +85,27 @@ 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); + + const authTokenService = this.options.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)); } diff --git a/packages/server/test/ws-auth.e2e.test.ts b/packages/server/test/ws-auth.e2e.test.ts index f2b23028e5..c07a881ce4 100644 --- a/packages/server/test/ws-auth.e2e.test.ts +++ b/packages/server/test/ws-auth.e2e.test.ts @@ -2,13 +2,23 @@ * WS upgrade auth (ROADMAP M3). * * M3.1 adds the `kimi-code.bearer.` subprotocol parser; M3.2 wires it - * into the upgrade path. The parser is exercised as pure unit cases here; the - * upgrade-path cases are added in the M3.2 block below. + * 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 { describe, expect, it } from 'vitest'; +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 type { 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', () => { @@ -35,3 +45,205 @@ describe('extractWsBearerToken', () => { 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; +} + +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 { + const r = await startServer({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir: bridgeHome }, + wsGatewayOptions: { + pingIntervalMs: 60, + pongTimeoutMs: 200, + authTokenService: 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 { + 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 { + 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 { + 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 { + 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' }, + }); + + await receiveType(conn, 'server_hello', 1000); + + 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'] }); + }); +}); From 037d86db78ab6cdb07b0c28cda5a9bce37417ee4 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 18:54:03 +0800 Subject: [PATCH 11/37] feat(server): add Host header allowlist middleware --- packages/server/src/middleware/hostnames.ts | 166 ++++++++++++++++++++ packages/server/test/hostnames.test.ts | 150 ++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 packages/server/src/middleware/hostnames.ts create mode 100644 packages/server/test/hostnames.test.ts diff --git a/packages/server/src/middleware/hostnames.ts b/packages/server/src/middleware/hostnames.ts new file mode 100644 index 0000000000..7b620b9e3f --- /dev/null +++ b/packages/server/src/middleware/hostnames.ts @@ -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:` 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; + /** 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 => { + if (!isAllowed(req.headers.host)) { + return reply.code(403).send(errEnvelope(HOST_ERROR_CODE, HOST_ERROR_MSG, req.id)); + } + }; + return { onRequest, isAllowed }; +} diff --git a/packages/server/test/hostnames.test.ts b/packages/server/test/hostnames.test.ts new file mode 100644 index 0000000000..e5cc1a5c61 --- /dev/null +++ b/packages/server/test/hostnames.test.ts @@ -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; + 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); + }); +}); From 13faf2961e416e23cdb9ad650ebf61ae80d41847 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 18:57:15 +0800 Subject: [PATCH 12/37] feat(server): add Origin/CORS middleware --- packages/server/src/middleware/origin.ts | 122 +++++++++++++++++++ packages/server/test/origin.test.ts | 143 +++++++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 packages/server/src/middleware/origin.ts create mode 100644 packages/server/test/origin.test.ts diff --git a/packages/server/src/middleware/origin.ts b/packages/server/src/middleware/origin.ts new file mode 100644 index 0000000000..7820010f9d --- /dev/null +++ b/packages/server/src/middleware/origin.ts @@ -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 { + 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(); + } + }; +} diff --git a/packages/server/test/origin.test.ts b/packages/server/test/origin.test.ts new file mode 100644 index 0000000000..af5228fcc8 --- /dev/null +++ b/packages/server/test/origin.test.ts @@ -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(); + }); +}); From 2187ec46e3144a460020996c9603abfcccc0fa5e Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 19:08:09 +0800 Subject: [PATCH 13/37] feat(server): wire Host/Origin checks into HTTP and WS --- .../server/src/services/gateway/wsGateway.ts | 18 ++ .../src/services/gateway/wsGatewayService.ts | 24 ++ packages/server/src/start.ts | 21 ++ .../server/test/auth-middleware.e2e.test.ts | 14 +- packages/server/test/host-origin.e2e.test.ts | 254 ++++++++++++++++++ 5 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 packages/server/test/host-origin.e2e.test.ts diff --git a/packages/server/src/services/gateway/wsGateway.ts b/packages/server/src/services/gateway/wsGateway.ts index 7db19110c8..08157da5f2 100644 --- a/packages/server/src/services/gateway/wsGateway.ts +++ b/packages/server/src/services/gateway/wsGateway.ts @@ -1,5 +1,6 @@ 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'; @@ -80,4 +81,21 @@ export interface WSGatewayOptions { * 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[]; } diff --git a/packages/server/src/services/gateway/wsGatewayService.ts b/packages/server/src/services/gateway/wsGatewayService.ts index 161d855f23..9b2e11a02e 100644 --- a/packages/server/src/services/gateway/wsGatewayService.ts +++ b/packages/server/src/services/gateway/wsGatewayService.ts @@ -4,6 +4,8 @@ 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 { WsConnection, type AbortHandler, @@ -86,6 +88,28 @@ export class WSGateway extends Disposable implements IWSGateway { // 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.options.authTokenService; if (authTokenService !== undefined) { const authorization = req.headers.authorization; diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts index 761bdba33a..ba9eee6080 100644 --- a/packages/server/src/start.ts +++ b/packages/server/src/start.ts @@ -10,6 +10,12 @@ import { import { installErrorHandler } from './error-handler'; import { transformOpenApiDocument } from './openapi/transforms'; import { acquireLock, ServerLockedError } from './lock'; +import { + createHostCheck, + isHostCheckDisabled, + parseAllowedHosts, +} from '#/middleware/hostnames'; +import { createOriginHook, parseCorsOrigins } from '#/middleware/origin'; import { createServerLogger, type ServerLogLevel, @@ -88,6 +94,21 @@ export async function startServer(opts: ServerStartOptions): Promise (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:` 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 { diff --git a/packages/server/test/auth-middleware.e2e.test.ts b/packages/server/test/auth-middleware.e2e.test.ts index 4c064d855e..d21f1a1eed 100644 --- a/packages/server/test/auth-middleware.e2e.test.ts +++ b/packages/server/test/auth-middleware.e2e.test.ts @@ -159,13 +159,23 @@ describe('/asyncapi.json serverHost (M2.3 Host-reflection fix)', () => { await closeAll(); }); - it('reflects the bound host, not a spoofed Host header', async () => { + 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); - const { status, body } = await rawGet(port, '/asyncapi.json', { + // 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). + const { status, body } = await rawGet(port, '/asyncapi.json', { + Host: '127.0.0.1', + }); expect(status).toBe(200); const servers = (body as { servers: { local: { host: string } } }).servers; diff --git a/packages/server/test/host-origin.e2e.test.ts b/packages/server/test/host-origin.e2e.test.ts new file mode 100644 index 0000000000..e387b09368 --- /dev/null +++ b/packages/server/test/host-origin.e2e.test.ts @@ -0,0 +1,254 @@ +/** + * 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:` 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'; + +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; +} + +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 { + const r = await startServer({ + 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, +): 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 { + 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 { + 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 { + 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 { + 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; + expect(body['code']).toBe(40301); + expect(body['msg']).toBe('Invalid Host header'); + }); + + it('allows the default 127.0.0.1: 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)); + await receiveType(conn, 'server_hello', 1000); + 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)); + await receiveType(conn, 'server_hello', 1000); + 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)); + await receiveType(conn, 'server_hello', 1000); + conn.ws.close(); + await conn.closed; + }); +}); From 3122f8aa938d9a76bc79d873a62794927825a63b Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 19:56:59 +0800 Subject: [PATCH 14/37] feat(server): wire token auth, Host/Origin, and WS auth into start.ts --- .../server/src/services/gateway/wsGateway.ts | 10 + .../src/services/gateway/wsGatewayService.ts | 9 +- packages/server/src/start.ts | 79 ++++++- .../server/test/api-surface.snapshot.test.ts | 6 +- packages/server/test/approval.e2e.test.ts | 22 +- .../server/test/auth-middleware.e2e.test.ts | 3 + packages/server/test/auth-wiring.e2e.test.ts | 203 ++++++++++++++++++ packages/server/test/auth.e2e.test.ts | 20 +- packages/server/test/config.e2e.test.ts | 20 +- packages/server/test/connections.e2e.test.ts | 22 +- packages/server/test/files.e2e.test.ts | 16 +- packages/server/test/fs-basic.e2e.test.ts | 26 ++- packages/server/test/fs-batch.e2e.test.ts | 26 ++- packages/server/test/fs-browse.e2e.test.ts | 20 +- packages/server/test/fs-download.e2e.test.ts | 20 +- packages/server/test/fs-git.e2e.test.ts | 28 ++- packages/server/test/fs-mkdir.e2e.test.ts | 26 ++- packages/server/test/fs-search.e2e.test.ts | 26 ++- packages/server/test/fs-watch.e2e.test.ts | 28 ++- packages/server/test/helpers/serverHarness.ts | 70 +++++- packages/server/test/host-origin.e2e.test.ts | 7 +- packages/server/test/messages.e2e.test.ts | 20 +- packages/server/test/meta.e2e.test.ts | 27 ++- .../server/test/model-catalog.e2e.test.ts | 21 +- packages/server/test/oauth.e2e.test.ts | 21 +- packages/server/test/prompt.e2e.test.ts | 23 +- packages/server/test/question.e2e.test.ts | 22 +- .../test/sessions-workspace.e2e.test.ts | 20 +- packages/server/test/sessions.e2e.test.ts | 22 +- packages/server/test/skills.e2e.test.ts | 20 +- packages/server/test/snapshot.e2e.test.ts | 20 +- packages/server/test/start.test.ts | 18 +- packages/server/test/swagger.e2e.test.ts | 20 +- packages/server/test/tasks.e2e.test.ts | 21 +- packages/server/test/terminals.e2e.test.ts | 20 +- packages/server/test/tools.e2e.test.ts | 20 +- packages/server/test/workspaces.e2e.test.ts | 20 +- packages/server/test/ws-abort.e2e.test.ts | 22 +- packages/server/test/ws-auth.e2e.test.ts | 6 +- packages/server/test/ws-broadcast.e2e.test.ts | 4 +- packages/server/test/ws-handshake.e2e.test.ts | 7 +- packages/server/test/ws-resync.e2e.test.ts | 4 +- packages/server/test/ws-terminal.e2e.test.ts | 24 ++- 43 files changed, 940 insertions(+), 149 deletions(-) create mode 100644 packages/server/test/auth-wiring.e2e.test.ts diff --git a/packages/server/src/services/gateway/wsGateway.ts b/packages/server/src/services/gateway/wsGateway.ts index 08157da5f2..7308be6fe6 100644 --- a/packages/server/src/services/gateway/wsGateway.ts +++ b/packages/server/src/services/gateway/wsGateway.ts @@ -24,6 +24,16 @@ 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 diff --git a/packages/server/src/services/gateway/wsGatewayService.ts b/packages/server/src/services/gateway/wsGatewayService.ts index 9b2e11a02e..569c46e84f 100644 --- a/packages/server/src/services/gateway/wsGatewayService.ts +++ b/packages/server/src/services/gateway/wsGatewayService.ts @@ -6,6 +6,7 @@ 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, @@ -34,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( @@ -45,6 +47,7 @@ export class WSGateway extends Disposable implements IWSGateway { @ILogService private readonly logger: ILogService, ) { super(); + this.authTokenService = options.authTokenService; this.wss = new WebSocketServer({ noServer: true, // Browsers require the server to select one of the offered subprotocols; @@ -75,6 +78,10 @@ export class WSGateway extends Disposable implements IWSGateway { this.terminalHandler = handler; } + setAuthTokenService(service: IAuthTokenService): void { + this.authTokenService = service; + } + private async onUpgrade(req: IncomingMessage, socket: Socket, head: Buffer): Promise { const url = req.url ?? ''; const path = url.split('?', 1)[0]; @@ -110,7 +117,7 @@ export class WSGateway extends Disposable implements IWSGateway { return; } - const authTokenService = this.options.authTokenService; + const authTokenService = this.authTokenService; if (authTokenService !== undefined) { const authorization = req.headers.authorization; const token = authorization?.startsWith('Bearer ') diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts index ba9eee6080..46fdcae384 100644 --- a/packages/server/src/start.ts +++ b/packages/server/src/start.ts @@ -10,6 +10,7 @@ import { import { installErrorHandler } from './error-handler'; import { transformOpenApiDocument } from './openapi/transforms'; import { acquireLock, ServerLockedError } from './lock'; +import { createAuthHook } from '#/middleware/auth'; import { createHostCheck, isHostCheckDisabled, @@ -34,6 +35,12 @@ import { } from '#/services/gateway'; import { createServerServiceCollection } from '#/services/serviceCollection'; import { ISnapshotService, loadSnapshotConfig } from '#/services/snapshot'; +import { + createAuthTokenService, + IAuthTokenService, +} from '#/services/auth/authTokenService'; +import { resolvePasswordHash } from '#/services/auth/password'; +import { createTokenStore } from '#/services/auth/tokenStore'; import { getServerVersion } from './version'; import { registerWebAssetRoutes } from './routes/webAssets'; @@ -159,14 +166,51 @@ export async function startServer(opts: ServerStartOptions): Promise/server-.token` at 0600) + // 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 token file is disposed + // (best-effort) on shutdown and on every boot-error path below. + const tokenStore = await createTokenStore(envService.homeDir, process.pid); + const passwordHash = await resolvePasswordHash(process.env); + const defaultAuth = createAuthTokenService({ tokenStore, passwordHash }); + 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. + const authTokenService = ix.invokeFunction((a) => a.get(IAuthTokenService)); + app.addHook('onRequest', createAuthHook(authTokenService)); + await registerApiV1Routes(app, ix, { serverVersion, debugEndpoints: opts.debugEndpoints, @@ -192,6 +236,11 @@ export async function startServer(opts: ServerStartOptions): Promise { const lockPath = join(tmpDir, 'lock'); server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -80,7 +82,7 @@ describe('API surface snapshot', () => { const address = server.address; // 1) Documented v1 REST surface, derived from /openapi.json `paths`. - const openApiRes = await fetch(`${address}/openapi.json`); + const openApiRes = await fetch(`${address}/openapi.json`, { headers: authHeaders() }); expect(openApiRes.status).toBe(200); const openApi = (await openApiRes.json()) as { paths?: Record>; @@ -101,7 +103,7 @@ describe('API surface snapshot', () => { // 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}`); + 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]); diff --git a/packages/server/test/approval.e2e.test.ts b/packages/server/test/approval.e2e.test.ts index cf9bb8f062..97736df348 100644 --- a/packages/server/test/approval.e2e.test.ts +++ b/packages/server/test/approval.e2e.test.ts @@ -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 { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -76,12 +78,24 @@ async function bootDaemon(): Promise { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { @@ -123,7 +137,7 @@ async function openSubscriber( const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws'; const received: Record[] = []; const ws = await new Promise((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); diff --git a/packages/server/test/auth-middleware.e2e.test.ts b/packages/server/test/auth-middleware.e2e.test.ts index d21f1a1eed..15f33dfb0c 100644 --- a/packages/server/test/auth-middleware.e2e.test.ts +++ b/packages/server/test/auth-middleware.e2e.test.ts @@ -173,8 +173,11 @@ describe('/asyncapi.json serverHost (M2.3 Host-reflection fix)', () => { // 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); diff --git a/packages/server/test/auth-wiring.e2e.test.ts b/packages/server/test/auth-wiring.e2e.test.ts new file mode 100644 index 0000000000..7ee35f5e1c --- /dev/null +++ b/packages/server/test/auth-wiring.e2e.test.ts @@ -0,0 +1,203 @@ +/** + * 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 random token written to + * `/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-${String(process.pid)}.token`); +} + +function readToken(): string { + return readFileSync(tokenPath(), 'utf8').trim(); +} + +async function bootReal(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 + } + err === undefined ? resolve() : 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 removes it on close', 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(); + expect(() => statSync(p)).toThrow(); + }); + + 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)); + }); +}); diff --git a/packages/server/test/auth.e2e.test.ts b/packages/server/test/auth.e2e.test.ts index 93d5a1d41f..cebf599388 100644 --- a/packages/server/test/auth.e2e.test.ts +++ b/packages/server/test/auth.e2e.test.ts @@ -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 { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -69,12 +71,24 @@ async function bootDaemon(): Promise { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/config.e2e.test.ts b/packages/server/test/config.e2e.test.ts index 6a8c24e011..d7cae0e3ef 100644 --- a/packages/server/test/config.e2e.test.ts +++ b/packages/server/test/config.e2e.test.ts @@ -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 { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -43,12 +45,24 @@ async function bootDaemon(): Promise { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/connections.e2e.test.ts b/packages/server/test/connections.e2e.test.ts index 7856a5638b..a88fb58cd6 100644 --- a/packages/server/test/connections.e2e.test.ts +++ b/packages/server/test/connections.e2e.test.ts @@ -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 { const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -71,12 +73,24 @@ async function spawn(): Promise { 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 }; + 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 { 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; diff --git a/packages/server/test/files.e2e.test.ts b/packages/server/test/files.e2e.test.ts index 7291e60c8e..905608dd95 100644 --- a/packages/server/test/files.e2e.test.ts +++ b/packages/server/test/files.e2e.test.ts @@ -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 { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } interface Envelope { diff --git a/packages/server/test/fs-basic.e2e.test.ts b/packages/server/test/fs-basic.e2e.test.ts index 70323a2c0d..debeff023f 100644 --- a/packages/server/test/fs-basic.e2e.test.ts +++ b/packages/server/test/fs-basic.e2e.test.ts @@ -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 { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/fs-batch.e2e.test.ts b/packages/server/test/fs-batch.e2e.test.ts index 588a59bc8e..70879f3045 100644 --- a/packages/server/test/fs-batch.e2e.test.ts +++ b/packages/server/test/fs-batch.e2e.test.ts @@ -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 { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/fs-browse.e2e.test.ts b/packages/server/test/fs-browse.e2e.test.ts index 471cc40b84..9e736cf56d 100644 --- a/packages/server/test/fs-browse.e2e.test.ts +++ b/packages/server/test/fs-browse.e2e.test.ts @@ -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 { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -67,12 +69,24 @@ async function bootDaemon(): Promise { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/fs-download.e2e.test.ts b/packages/server/test/fs-download.e2e.test.ts index 444afcce63..3846c6a8dd 100644 --- a/packages/server/test/fs-download.e2e.test.ts +++ b/packages/server/test/fs-download.e2e.test.ts @@ -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 { 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; } { - 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; - }; + inject: (req: unknown) => Promise; +}; }); + // 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/fs-git.e2e.test.ts b/packages/server/test/fs-git.e2e.test.ts index 70b98b30c7..5e4f49daa9 100644 --- a/packages/server/test/fs-git.e2e.test.ts +++ b/packages/server/test/fs-git.e2e.test.ts @@ -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 { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/fs-mkdir.e2e.test.ts b/packages/server/test/fs-mkdir.e2e.test.ts index 8be7a6297f..4e70855a0e 100644 --- a/packages/server/test/fs-mkdir.e2e.test.ts +++ b/packages/server/test/fs-mkdir.e2e.test.ts @@ -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 { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/fs-search.e2e.test.ts b/packages/server/test/fs-search.e2e.test.ts index 12471b6dba..afce1075ae 100644 --- a/packages/server/test/fs-search.e2e.test.ts +++ b/packages/server/test/fs-search.e2e.test.ts @@ -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 { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/fs-watch.e2e.test.ts b/packages/server/test/fs-watch.e2e.test.ts index 6535443bef..7f00e4c97b 100644 --- a/packages/server/test/fs-watch.e2e.test.ts +++ b/packages/server/test/fs-watch.e2e.test.ts @@ -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 { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } async function createSession(r: RunningServer): Promise { @@ -136,7 +150,7 @@ interface Conn { function openConn(url: string): Promise { 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) => { diff --git a/packages/server/test/helpers/serverHarness.ts b/packages/server/test/helpers/serverHarness.ts index ac135267b8..ddc6cbe036 100644 --- a/packages/server/test/helpers/serverHarness.ts +++ b/packages/server/test/helpers/serverHarness.ts @@ -1,31 +1,72 @@ /** - * Reusable e2e server harness with token support (ROADMAP M0.2). + * 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 ` - * so later phases (M2.2 onward) can exercise authenticated endpoints without - * re-deriving URLs or threading tokens by hand. + * (`authedFetch` / `authedWs`) that carry an `Authorization: Bearer `. * - * In M0 there is no server-side auth yet, so the token is simply attached to - * requests and ignored by the server; the harness still typechecks and behaves - * correctly against the current auth-less server. The auth seam is - * `BootOptions.serviceOverrides`: from M2.1 on, callers inject a fixed-token - * `IAuthTokenService` there. `IAuthTokenService` does not exist in M0, so this - * module intentionally stays generic and does not import it. + * 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]; /** 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 ` header bag, for spreading into `headers`. */ +export function authHeaders(token: string = DEFAULT_TOKEN): { Authorization: string } { + return { Authorization: `Bearer ${token}` }; +} + +/** + * Merge `Authorization: Bearer ` 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 @@ -95,7 +136,14 @@ export async function boot(opts: BootOptions = {}): Promise { const token = opts.token ?? DEFAULT_TOKEN; const host = opts.host ?? '127.0.0.1'; const port = opts.port ?? 0; - const serviceOverrides = opts.serviceOverrides ?? []; + // 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-')); diff --git a/packages/server/test/host-origin.e2e.test.ts b/packages/server/test/host-origin.e2e.test.ts index e387b09368..0b7281338d 100644 --- a/packages/server/test/host-origin.e2e.test.ts +++ b/packages/server/test/host-origin.e2e.test.ts @@ -24,6 +24,7 @@ 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; @@ -68,6 +69,7 @@ afterEach(async () => { async function spawn(wsGatewayOptions?: WSGatewayOptions): Promise { const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -117,7 +119,10 @@ function rawHttpGet( function openConn(url: string, opts?: ConnectOptions): Promise { return new Promise((resolve, reject) => { - const ws = new WebSocket(url, opts?.protocols, { headers: opts?.headers }); + // 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; diff --git a/packages/server/test/messages.e2e.test.ts b/packages/server/test/messages.e2e.test.ts index 0db71cc751..b6968851b5 100644 --- a/packages/server/test/messages.e2e.test.ts +++ b/packages/server/test/messages.e2e.test.ts @@ -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 { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -77,12 +79,24 @@ async function bootDaemon(): Promise { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/meta.e2e.test.ts b/packages/server/test/meta.e2e.test.ts index 6a4039be51..8bd6463b63 100644 --- a/packages/server/test/meta.e2e.test.ts +++ b/packages/server/test/meta.e2e.test.ts @@ -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 { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -75,16 +77,24 @@ async function bootDaemon(): Promise { 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 }; + 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, diff --git a/packages/server/test/model-catalog.e2e.test.ts b/packages/server/test/model-catalog.e2e.test.ts index 3492bd40d6..9a440eb04d 100644 --- a/packages/server/test/model-catalog.e2e.test.ts +++ b/packages/server/test/model-catalog.e2e.test.ts @@ -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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/oauth.e2e.test.ts b/packages/server/test/oauth.e2e.test.ts index f0d5b9d709..21972c36ed 100644 --- a/packages/server/test/oauth.e2e.test.ts +++ b/packages/server/test/oauth.e2e.test.ts @@ -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 { 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 { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/prompt.e2e.test.ts b/packages/server/test/prompt.e2e.test.ts index 8cfe3f4a0e..dddbf1d2d6 100644 --- a/packages/server/test/prompt.e2e.test.ts +++ b/packages/server/test/prompt.e2e.test.ts @@ -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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { @@ -204,7 +217,7 @@ async function openSubscriber( const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws'; const received: Record[] = []; const ws = await new Promise((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); diff --git a/packages/server/test/question.e2e.test.ts b/packages/server/test/question.e2e.test.ts index 833daf0f64..577a8a7dc5 100644 --- a/packages/server/test/question.e2e.test.ts +++ b/packages/server/test/question.e2e.test.ts @@ -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 { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -65,12 +67,24 @@ async function bootDaemon(): Promise { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { @@ -112,7 +126,7 @@ async function openSubscriber( const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws'; const received: Record[] = []; const ws = await new Promise((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); diff --git a/packages/server/test/sessions-workspace.e2e.test.ts b/packages/server/test/sessions-workspace.e2e.test.ts index 8911b32a4c..8f0c634630 100644 --- a/packages/server/test/sessions-workspace.e2e.test.ts +++ b/packages/server/test/sessions-workspace.e2e.test.ts @@ -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 { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -58,12 +60,24 @@ async function bootDaemon(): Promise { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/sessions.e2e.test.ts b/packages/server/test/sessions.e2e.test.ts index 52231b3d6a..414a248af3 100644 --- a/packages/server/test/sessions.e2e.test.ts +++ b/packages/server/test/sessions.e2e.test.ts @@ -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 { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(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[] = []; const ws = await new Promise((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); diff --git a/packages/server/test/skills.e2e.test.ts b/packages/server/test/skills.e2e.test.ts index 619e772d47..c59d675aba 100644 --- a/packages/server/test/skills.e2e.test.ts +++ b/packages/server/test/skills.e2e.test.ts @@ -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 { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -78,12 +80,24 @@ async function bootDaemon(): Promise { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/snapshot.e2e.test.ts b/packages/server/test/snapshot.e2e.test.ts index e12d657b17..cfec350502 100644 --- a/packages/server/test/snapshot.e2e.test.ts +++ b/packages/server/test/snapshot.e2e.test.ts @@ -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 { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -63,12 +65,24 @@ async function bootDaemon(): Promise { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/start.test.ts b/packages/server/test/start.test.ts index cd25d5750f..3ed2fa39ff 100644 --- a/packages/server/test/start.test.ts +++ b/packages/server/test/start.test.ts @@ -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 { 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; expect(body['code']).toBe(0); diff --git a/packages/server/test/swagger.e2e.test.ts b/packages/server/test/swagger.e2e.test.ts index 2327bb9df8..e7a90f5f2c 100644 --- a/packages/server/test/swagger.e2e.test.ts +++ b/packages/server/test/swagger.e2e.test.ts @@ -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 { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -50,12 +52,24 @@ async function bootDaemon(): Promise { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function asRecord(value: unknown): Record { diff --git a/packages/server/test/tasks.e2e.test.ts b/packages/server/test/tasks.e2e.test.ts index 68c45bf5dd..8be10f0885 100644 --- a/packages/server/test/tasks.e2e.test.ts +++ b/packages/server/test/tasks.e2e.test.ts @@ -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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/terminals.e2e.test.ts b/packages/server/test/terminals.e2e.test.ts index 5b2be145f6..f7ca999418 100644 --- a/packages/server/test/terminals.e2e.test.ts +++ b/packages/server/test/terminals.e2e.test.ts @@ -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 { logger: pino({ level: 'silent' }), coreProcessOptions: { homeDir: bridgeHome }, serviceOverrides: [ + fixedTokenAuth(), [ITerminalService, new SyncDescriptor(TerminalService, [{ backend }], false)], ], }); @@ -50,12 +52,24 @@ async function bootServer(): Promise { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/tools.e2e.test.ts b/packages/server/test/tools.e2e.test.ts index ead6967f07..634cc699a3 100644 --- a/packages/server/test/tools.e2e.test.ts +++ b/packages/server/test/tools.e2e.test.ts @@ -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 { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -63,12 +65,24 @@ async function bootDaemon(): Promise { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/workspaces.e2e.test.ts b/packages/server/test/workspaces.e2e.test.ts index da2c269f21..70ee723460 100644 --- a/packages/server/test/workspaces.e2e.test.ts +++ b/packages/server/test/workspaces.e2e.test.ts @@ -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 { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -63,12 +65,24 @@ async function bootDaemon(): Promise { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/ws-abort.e2e.test.ts b/packages/server/test/ws-abort.e2e.test.ts index 6a59e00006..0a7d008a25 100644 --- a/packages/server/test/ws-abort.e2e.test.ts +++ b/packages/server/test/ws-abort.e2e.test.ts @@ -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 { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -71,12 +73,24 @@ async function bootDaemon(): Promise { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { @@ -129,7 +143,7 @@ async function openSubscriber(r: RunningServer, sid: string): Promise[] = []; const ws = await new Promise((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); diff --git a/packages/server/test/ws-auth.e2e.test.ts b/packages/server/test/ws-auth.e2e.test.ts index c07a881ce4..4a7d841295 100644 --- a/packages/server/test/ws-auth.e2e.test.ts +++ b/packages/server/test/ws-auth.e2e.test.ts @@ -16,7 +16,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WebSocket } from 'ws'; import { startServer, type RunningServer } from '../src'; -import type { IAuthTokenService } from '../src/services/auth/authTokenService'; +import { IAuthTokenService } from '../src/services/auth/authTokenService'; import { extractWsBearerToken } from '../src/services/gateway/wsGateway'; import { rawDataToString } from '../src/ws/rawData'; @@ -104,8 +104,10 @@ async function spawn(): Promise { wsGatewayOptions: { pingIntervalMs: 60, pongTimeoutMs: 200, - authTokenService: fixedTokenAuth, }, + // 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; diff --git a/packages/server/test/ws-broadcast.e2e.test.ts b/packages/server/test/ws-broadcast.e2e.test.ts index 92680884e0..adb97a4cb1 100644 --- a/packages/server/test/ws-broadcast.e2e.test.ts +++ b/packages/server/test/ws-broadcast.e2e.test.ts @@ -31,6 +31,7 @@ import { 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 spawn(): Promise { const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -91,7 +93,7 @@ interface Conn { function openConn(url: string): Promise { 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) => { diff --git a/packages/server/test/ws-handshake.e2e.test.ts b/packages/server/test/ws-handshake.e2e.test.ts index 10b5d7aa1f..49e3c60ea8 100644 --- a/packages/server/test/ws-handshake.e2e.test.ts +++ b/packages/server/test/ws-handshake.e2e.test.ts @@ -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 { 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 { 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, diff --git a/packages/server/test/ws-resync.e2e.test.ts b/packages/server/test/ws-resync.e2e.test.ts index 5d21f2fed0..d5ddb6361b 100644 --- a/packages/server/test/ws-resync.e2e.test.ts +++ b/packages/server/test/ws-resync.e2e.test.ts @@ -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 { 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 { 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) => { diff --git a/packages/server/test/ws-terminal.e2e.test.ts b/packages/server/test/ws-terminal.e2e.test.ts index 8b41f5871b..2cf89a29dd 100644 --- a/packages/server/test/ws-terminal.e2e.test.ts +++ b/packages/server/test/ws-terminal.e2e.test.ts @@ -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 { 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 { 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + }); + }, + }; } function envelopeOf(body: unknown): { @@ -112,7 +126,9 @@ async function openSocket(r: RunningServer): Promise<{ }> { const received: Record[] = []; const ws = await new Promise((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); }); From 3f950196d60cef3493b1748c62c6e85c51341f79 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 19:58:57 +0800 Subject: [PATCH 15/37] fix(server): create lock file with 0600 permissions --- packages/server/src/lock.ts | 6 ++++-- packages/server/test/lock.test.ts | 10 +++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/server/src/lock.ts b/packages/server/src/lock.ts index db516532fb..521f413d4e 100644 --- a/packages/server/src/lock.ts +++ b/packages/server/src/lock.ts @@ -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) { diff --git a/packages/server/test/lock.test.ts b/packages/server/test/lock.test.ts index ed7bea3b04..29ff1bb71c 100644 --- a/packages/server/test/lock.test.ts +++ b/packages/server/test/lock.test.ts @@ -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', () => { From 3cf31f90dc5930143084dac2dea9231115f46769 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 20:02:42 +0800 Subject: [PATCH 16/37] fix(server): suppress debug routes on non-loopback binds --- packages/server/src/start.ts | 16 +++- .../server/test/debug-nonloopback.e2e.test.ts | 79 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 packages/server/test/debug-nonloopback.e2e.test.ts diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts index 46fdcae384..36107c55b4 100644 --- a/packages/server/src/start.ts +++ b/packages/server/src/start.ts @@ -211,9 +211,23 @@ export async function startServer(opts: ServerStartOptions): Promise a.get(IAuthTokenService)); app.addHook('onRequest', createAuthHook(authTokenService)); + // 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. M6 will replace + // this inline check with `bindClassify`. + const isLoopback = + opts.host === '127.0.0.1' || opts.host === '::1' || opts.host === 'localhost'; + if (opts.debugEndpoints === true && !isLoopback) { + pinoLogger.warn( + { host: opts.host }, + 'debug endpoints suppressed: refusing to mount /api/v1/debug/* on a non-loopback bind', + ); + } + await registerApiV1Routes(app, ix, { serverVersion, - debugEndpoints: opts.debugEndpoints, + debugEndpoints: opts.debugEndpoints === true && isLoopback, }); app.get('/asyncapi.json', async (_req, reply) => { diff --git a/packages/server/test/debug-nonloopback.e2e.test.ts b/packages/server/test/debug-nonloopback.e2e.test.ts new file mode 100644 index 0000000000..75e448ffda --- /dev/null +++ b/packages/server/test/debug-nonloopback.e2e.test.ts @@ -0,0 +1,79 @@ +/** + * 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; +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-')); +}); + +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 boot(host: string): Promise { + 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, + }); + running.push(r); + return r; +} + +/** Probe a debug route on `127.0.0.1:` regardless of the bound host. */ +async function probeDebug(r: RunningServer): Promise { + 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); + }); +}); From b2a541107ceade1e2c3844f679161846337e239e Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 20:10:46 +0800 Subject: [PATCH 17/37] feat(kimi-code): read server token and send Authorization on CLI calls --- apps/kimi-code/src/cli/sub/server/kill.ts | 22 ++++-- apps/kimi-code/src/cli/sub/server/ps.ts | 13 +++- apps/kimi-code/src/cli/sub/server/shared.ts | 41 ++++++++++ apps/kimi-code/test/cli/server/server.test.ts | 75 ++++++++++++++++++- 4 files changed, 142 insertions(+), 9 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/server/kill.ts b/apps/kimi-code/src/cli/sub/server/kill.ts index 7275991e53..16fbbedf75 100644 --- a/apps/kimi-code/src/cli/sub/server/kill.ts +++ b/apps/kimi-code/src/cli/sub/server/kill.ts @@ -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; + requestShutdown(origin: string, token: string | undefined): Promise; + /** Best-effort read of the per-start bearer token for `pid`; undefined on miss. */ + resolveToken(pid: number): string | undefined; signalPid(pid: number, signal: NodeJS.Signals): boolean; pidAlive(pid: number): boolean; sleep(ms: number): Promise; @@ -66,8 +70,11 @@ export async function handleKillCommand(deps: KillCommandDeps): Promise { // 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(pid); + 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 { +export async function requestShutdownViaApi( + origin: string, + token: string | undefined, +): Promise { const controller = new AbortController(); const timeout = setTimeout(() => { controller.abort(); @@ -134,6 +144,7 @@ export async function requestShutdownViaApi(origin: string): Promise { 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 { const DEFAULT_KILL_DEPS: KillCommandDeps = { getLiveLock, requestShutdown: requestShutdownViaApi, + resolveToken: (pid) => tryResolveServerToken(getDataDir(), pid), signalPid, pidAlive, sleep: (ms) => diff --git a/apps/kimi-code/src/cli/sub/server/ps.ts b/apps/kimi-code/src/cli/sub/server/ps.ts index 7db750545c..853fde5f90 100644 --- a/apps/kimi-code/src/cli/sub/server/ps.ts +++ b/apps/kimi-code/src/cli/sub/server/ps.ts @@ -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 { 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 + // per-start token the server wrote at boot; a clear error here means the + // server is an older build or the token file was removed. + const token = resolveServerToken(getDataDir(), lock.pid); + 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 { process.stdout.write(formatTable(connections)); } -async function fetchConnections(origin: string): Promise { +async function fetchConnections(origin: string, token: string): Promise { 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) { diff --git a/apps/kimi-code/src/cli/sub/server/shared.ts b/apps/kimi-code/src/cli/sub/server/shared.ts index b0550c65b9..3afa78a532 100644 --- a/apps/kimi-code/src/cli/sub/server/shared.ts +++ b/apps/kimi-code/src/cli/sub/server/shared.ts @@ -5,6 +5,9 @@ * `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'; @@ -174,3 +177,41 @@ export async function ensureServerWebReady(origin: string): Promise { clearTimeout(timeout); } } + +/** + * Read the per-start bearer token for a running server. + * + * The server writes `/server-.token` (0600) at boot (ROADMAP + * M5.1); CLI commands that hit a gated REST route read it back here and send + * it as `Authorization: Bearer `. `homeDir` is the CLI's own + * KIMI_CODE_HOME resolution (`getDataDir()`); `pid` comes from the lock file + * (`LockContents.pid`). + * + * Throws a clear error when the file is missing/unreadable — the usual cause + * is a server that is not running, or an older build that predates token auth. + */ +export function resolveServerToken(homeDir: string, pid: number): string { + const tokenPath = join(homeDir, `server-${String(pid)}.token`); + try { + return readFileSync(tokenPath, 'utf8').trim(); + } catch (error) { + throw new Error( + `unable to read server token at ${tokenPath}; is the server running and on a compatible version?`, + { cause: error }, + ); + } +} + +/** Best-effort token read: returns `undefined` instead of throwing. */ +export function tryResolveServerToken(homeDir: string, pid: number): string | undefined { + try { + return resolveServerToken(homeDir, pid); + } catch { + return undefined; + } +} + +/** An `Authorization: Bearer ` header bag for `fetch`. */ +export function authHeaders(token: string): { Authorization: string } { + return { Authorization: `Bearer ${token}` }; +} diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts index b465d4e4fb..29e61ab257 100644 --- a/apps/kimi-code/test/cli/server/server.test.ts +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -9,7 +9,7 @@ */ import type { ChildProcess } from 'node:child_process'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { createServer, type Server } from 'node:net'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -809,6 +809,7 @@ function makeKillDeps(overrides: Partial = {}): { requestShutdown: async () => { state.shutdownCalls += 1; }, + resolveToken: () => undefined, signalPid: (pid, signal) => { signals.push({ pid, signal }); return true; @@ -883,5 +884,77 @@ 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 /server-.token', async () => { + const { resolveServerToken } = await import('#/cli/sub/server/shared'); + writeFileSync(join(dir, 'server-42.token'), 'secret-token\n'); + expect(resolveServerToken(dir, 42)).toBe('secret-token'); + }); + + it('trims surrounding whitespace', async () => { + const { resolveServerToken } = await import('#/cli/sub/server/shared'); + writeFileSync(join(dir, 'server-7.token'), ' tok \n'); + expect(resolveServerToken(dir, 7)).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, 99)).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(); + }); +}); + // Silence vi import for cases where the file is built before tests reference vi. void vi; From e3897853a90650b8d89fc5ba7126ad91cc1e2659 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 20:15:37 +0800 Subject: [PATCH 18/37] feat(kimi-code): inject server token into /web URL fragment --- apps/kimi-code/src/cli/sub/server/run.ts | 43 ++++++++++++++- apps/kimi-code/test/cli/server/server.test.ts | 55 +++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/server/run.ts b/apps/kimi-code/src/cli/sub/server/run.ts index c556f9f5ad..30e7a8d7a4 100644 --- a/apps/kimi-code/src/cli/sub/server/run.ts +++ b/apps/kimi-code/src/cli/sub/server/run.ts @@ -15,7 +15,7 @@ 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 { getLiveLock, startServer, type RunningServer } from '@moonshot-ai/server'; import chalk from 'chalk'; import { Option, type Command } from 'commander'; @@ -23,6 +23,7 @@ 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'; @@ -31,6 +32,7 @@ import { DEFAULT_FOREGROUND_LOG_LEVEL, DEFAULT_SERVER_PORT, parseServerOptions, + tryResolveServerToken, VALID_LOG_LEVELS, type ParsedServerOptions, type ServerCliOptions, @@ -58,10 +60,29 @@ export interface RunCommandDeps { hooks?: StartForegroundHooks, ) => Promise; openUrl(url: string): void; + /** + * Best-effort read of the running server's per-start bearer token. When it + * returns a token, the opened Web UI URL carries it in the `#token=` fragment + * (M5.5). Optional so callers/tests that don't supply it simply open the + * plain origin. + */ + resolveToken?: () => string | undefined; stdout: Pick; stderr: Pick; } +/** + * Build the Web UI URL, carrying the bearer token in the URL fragment. + * + * The token rides in `#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 { + const base = origin.endsWith('/') ? origin : `${origin}/`; + return `${base}#token=${token}`; +} + /** Build the `run` subcommand, mounted under a parent (`server` or top-level). */ export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }): Command { return cmd @@ -120,6 +141,13 @@ export async function handleRunCommand( return; } const startedAt = Date.now(); + // Open the Web UI, appending the bearer token in the URL fragment when one + // can be resolved (M5.5). Falls back to the plain origin when the token is + // unavailable (older build, missing lock, etc.). + const openWebUrl = (origin: string): void => { + const token = deps.resolveToken?.(); + deps.openUrl(token !== undefined ? buildWebUrl(origin, token) : origin); + }; if (opts.foreground === true) { const run = deps.startServerForeground ?? startServerForeground; await run(parsed, { @@ -131,7 +159,7 @@ export async function handleRunCommand( : `Kimi server: ${origin}\n`, ); if (opts.open === true) { - deps.openUrl(origin); + openWebUrl(origin); } }, }); @@ -145,7 +173,7 @@ export async function handleRunCommand( : `Kimi server: ${origin}\n`, ); if (opts.open === true) { - deps.openUrl(origin); + openWebUrl(origin); } } @@ -382,6 +410,15 @@ const DEFAULT_RUN_COMMAND_DEPS: RunCommandDeps = { startServerBackground, startServerForeground, openUrl: defaultOpenUrl, + resolveToken: () => { + // The background daemon's pid lives in the lock; the foreground server is + // this process. Either way, `/server-.token` is the file the + // server wrote at boot (M5.1). Best-effort: a missing/older server yields + // undefined and the caller opens the plain origin. + const lock = getLiveLock(); + const pid = lock?.pid ?? process.pid; + return tryResolveServerToken(getDataDir(), pid); + }, stdout: process.stdout, stderr: process.stderr, }; diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts index 29e61ab257..a5636ce7e8 100644 --- a/apps/kimi-code/test/cli/server/server.test.ts +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -956,5 +956,60 @@ describe('`kimi server kill` carries the bearer token', () => { }); }); +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('`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'); + }); +}); + // Silence vi import for cases where the file is built before tests reference vi. void vi; From c694a76360313f55d37aa6f4bff357cf092a7231 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 20:35:46 +0800 Subject: [PATCH 19/37] feat(server): add bindClassify for loopback/lan/public classification --- .../server/src/services/auth/bindClassify.ts | 112 ++++++++++++++++++ packages/server/src/services/auth/index.ts | 1 + packages/server/src/start.ts | 25 ++-- packages/server/test/bindClassify.test.ts | 85 +++++++++++++ 4 files changed, 216 insertions(+), 7 deletions(-) create mode 100644 packages/server/src/services/auth/bindClassify.ts create mode 100644 packages/server/test/bindClassify.test.ts diff --git a/packages/server/src/services/auth/bindClassify.ts b/packages/server/src/services/auth/bindClassify.ts new file mode 100644 index 0000000000..9e476884cf --- /dev/null +++ b/packages/server/src/services/auth/bindClassify.ts @@ -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(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'; +} diff --git a/packages/server/src/services/auth/index.ts b/packages/server/src/services/auth/index.ts index 2e3a822b28..aaaacec6ef 100644 --- a/packages/server/src/services/auth/index.ts +++ b/packages/server/src/services/auth/index.ts @@ -2,3 +2,4 @@ export * from './privateFiles'; export * from './tokenStore'; export * from './password'; export * from './authTokenService'; +export * from './bindClassify'; diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts index 36107c55b4..e201e1f303 100644 --- a/packages/server/src/start.ts +++ b/packages/server/src/start.ts @@ -39,6 +39,7 @@ import { createAuthTokenService, IAuthTokenService, } from '#/services/auth/authTokenService'; +import { classify } from '#/services/auth/bindClassify'; import { resolvePasswordHash } from '#/services/auth/password'; import { createTokenStore } from '#/services/auth/tokenStore'; import { getServerVersion } from './version'; @@ -59,6 +60,13 @@ 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'; + webAssetsDir?: string; serviceOverrides?: ReadonlyArray, unknown]>; @@ -211,23 +219,26 @@ export async function startServer(opts: ServerStartOptions): Promise a.get(IAuthTokenService)); app.addHook('onRequest', createAuthHook(authTokenService)); + // Bind classification (ROADMAP M6.1). Determines which hardening applies. + // Computed once here so every later gate (debug routes now; password/TLS, + // rate limit, dangerous endpoints, security headers in M6.3–M6.6) agrees on + // the exposure tier. Wildcard binds default to `public` (most strict). + const bindClass = classify(opts.host, { bindClass: opts.bindClass }); + // 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. M6 will replace - // this inline check with `bindClassify`. - const isLoopback = - opts.host === '127.0.0.1' || opts.host === '::1' || opts.host === 'localhost'; - if (opts.debugEndpoints === true && !isLoopback) { + // the caller asked for them, and warn so the operator knows. + if (opts.debugEndpoints === true && bindClass !== 'loopback') { pinoLogger.warn( - { host: opts.host }, + { host: opts.host, bindClass }, 'debug endpoints suppressed: refusing to mount /api/v1/debug/* on a non-loopback bind', ); } await registerApiV1Routes(app, ix, { serverVersion, - debugEndpoints: opts.debugEndpoints === true && isLoopback, + debugEndpoints: opts.debugEndpoints === true && bindClass === 'loopback', }); app.get('/asyncapi.json', async (_req, reply) => { diff --git a/packages/server/test/bindClassify.test.ts b/packages/server/test/bindClassify.test.ts new file mode 100644 index 0000000000..a9e91544c1 --- /dev/null +++ b/packages/server/test/bindClassify.test.ts @@ -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'); + }); +}); From 879fa91064b6c41cd68746c24917dd1e4038f704 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 20:46:09 +0800 Subject: [PATCH 20/37] feat(kimi-code): register --host flag and pass it through the daemon --- apps/kimi-code/src/cli/sub/server/daemon.ts | 10 ++- apps/kimi-code/src/cli/sub/server/run.ts | 7 ++ apps/kimi-code/test/cli/server/server.test.ts | 88 ++++++++++++++++++- 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/server/daemon.ts b/apps/kimi-code/src/cli/sub/server/daemon.ts index f8ef8ffa8b..bf2ac236f4 100644 --- a/apps/kimi-code/src/cli/sub/server/daemon.ts +++ b/apps/kimi-code/src/cli/sub/server/daemon.ts @@ -44,6 +44,8 @@ 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`). */ @@ -178,6 +180,7 @@ export function resolveDaemonProgram( } interface SpawnDaemonChildOptions { + host?: string; port: number; logLevel: string; debugEndpoints?: boolean; @@ -198,6 +201,9 @@ 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'); } @@ -251,6 +257,7 @@ function sleep(ms: number): Promise { * detached process after this returns. */ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise { + const host = options.host ?? DEFAULT_SERVER_HOST; const preferred = options.port ?? DEFAULT_SERVER_PORT; const logLevel = options.logLevel ?? DEFAULT_DAEMON_LOG_LEVEL; @@ -267,8 +274,9 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise', + `Bind host (default ${DEFAULT_SERVER_HOST}). Use 0.0.0.0 to listen on all interfaces (requires a password + --insecure-no-tls unless behind a TLS proxy).`, + String(DEFAULT_SERVER_HOST), + ) .option( '--log-level ', `Server log level: ${VALID_LOG_LEVELS.join('|')}. Omit to keep logs off.`, @@ -187,6 +193,7 @@ export async function startServerBackground( options: ParsedServerOptions, ): Promise<{ origin: string }> { const { origin } = await ensureDaemon({ + host: options.host, port: options.port, logLevel: options.logLevel, debugEndpoints: options.debugEndpoints, diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts index a5636ce7e8..2495699a8f 100644 --- a/apps/kimi-code/test/cli/server/server.test.ts +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -55,7 +55,7 @@ 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'); @@ -87,7 +87,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'); }); }); @@ -564,6 +564,77 @@ 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('resolveDaemonPort', () => { it('returns the preferred port when it is free', async () => { const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); @@ -673,6 +744,19 @@ 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'])); + }); }); describe('createIdleShutdownHandler', () => { From ff18d679a3e2f0887160f91ecc9ac54ba68ca541 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 21:09:09 +0800 Subject: [PATCH 21/37] feat(server): require password and TLS opt-out on non-loopback binds --- apps/kimi-code/src/cli/sub/server/daemon.ts | 7 + apps/kimi-code/src/cli/sub/server/run.ts | 27 +++- apps/kimi-code/src/cli/sub/server/shared.ts | 5 + apps/kimi-code/test/cli/server/server.test.ts | 106 +++++++++++++++ packages/server/src/index.ts | 2 + packages/server/src/start.ts | 55 +++++++- .../server/test/debug-nonloopback.e2e.test.ts | 14 ++ .../server/test/host-exposure.e2e.test.ts | 127 ++++++++++++++++++ 8 files changed, 333 insertions(+), 10 deletions(-) create mode 100644 packages/server/test/host-exposure.e2e.test.ts diff --git a/apps/kimi-code/src/cli/sub/server/daemon.ts b/apps/kimi-code/src/cli/sub/server/daemon.ts index bf2ac236f4..a74dcda51b 100644 --- a/apps/kimi-code/src/cli/sub/server/daemon.ts +++ b/apps/kimi-code/src/cli/sub/server/daemon.ts @@ -52,6 +52,8 @@ export interface EnsureDaemonOptions { 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; /** Idle-shutdown grace in ms for the spawned daemon (daemon mode only). */ idleGraceMs?: number; } @@ -184,6 +186,7 @@ interface SpawnDaemonChildOptions { port: number; logLevel: string; debugEndpoints?: boolean; + insecureNoTls?: boolean; idleGraceMs?: number; } @@ -207,6 +210,9 @@ export function spawnDaemonChild(options: SpawnDaemonChildOptions): void { if (options.debugEndpoints === true) { args.push('--debug-endpoints'); } + if (options.insecureNoTls === true) { + args.push('--insecure-no-tls'); + } if (options.idleGraceMs !== undefined) { args.push('--idle-grace-ms', String(options.idleGraceMs)); } @@ -280,6 +286,7 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise', `Server log level: ${VALID_LOG_LEVELS.join('|')}. Omit to keep logs off.`, @@ -161,7 +166,7 @@ export async function handleRunCommand( const readyMs = Date.now() - startedAt; deps.stdout.write( parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL - ? formatReadyBanner(origin, readyMs) + ? formatReadyBanner(origin, readyMs, parsed.host) : `Kimi server: ${origin}\n`, ); if (opts.open === true) { @@ -175,7 +180,7 @@ export async function handleRunCommand( const readyMs = Date.now() - startedAt; deps.stdout.write( parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL - ? formatReadyBanner(origin, readyMs) + ? formatReadyBanner(origin, readyMs, parsed.host) : `Kimi server: ${origin}\n`, ); if (opts.open === true) { @@ -197,6 +202,7 @@ export async function startServerBackground( port: options.port, logLevel: options.logLevel, debugEndpoints: options.debugEndpoints, + insecureNoTls: options.insecureNoTls, idleGraceMs: options.idleGraceMs, }); return { origin }; @@ -273,6 +279,7 @@ async function runServerInProcess( port: options.port, logLevel: options.logLevel, debugEndpoints: options.debugEndpoints, + insecureNoTls: options.insecureNoTls, webAssetsDir: serverWebAssetsDir(), coreProcessOptions: { identity: createKimiCodeHostIdentity(version), @@ -358,7 +365,7 @@ export function resolveServerWebAssetsDir( return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR); } -function formatReadyBanner(origin: string, readyMs: number): string { +function formatReadyBanner(origin: string, readyMs: number, host: string): string { const primary = (text: string): string => chalk.hex(darkColors.primary)(text); const title = (text: string): string => chalk.bold.hex(darkColors.primary)(text); const dim = (text: string): string => chalk.hex(darkColors.textDim)(text); @@ -373,6 +380,16 @@ function formatReadyBanner(origin: string, readyMs: number): string { const logoWidth = Math.max(...logo.map((row) => visibleWidth(row))); const gap = ' '; const textWidth = innerWidth - logoWidth - gap.length; + // Bind class drives the Network line: loopback stays "local only"; a + // non-loopback bind prints the tier plus a reachability / hardening hint so + // the operator knows the server is exposed beyond this host. + const bindClass = classify(host); + const networkText = + bindClass === 'loopback' + ? 'local only' + : bindClass === 'lan' + ? 'LAN — reachable from your local network' + : 'public — reachable from the internet; use a tunnel or TLS proxy'; const headerLines = [ primary(logo[0].padEnd(logoWidth)) + gap + @@ -383,7 +400,7 @@ function formatReadyBanner(origin: string, readyMs: number): string { ]; const infoLines = [ label('URL: ') + url, - label('Network: ') + muted('local only'), + label('Network: ') + muted(networkText), 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`), diff --git a/apps/kimi-code/src/cli/sub/server/shared.ts b/apps/kimi-code/src/cli/sub/server/shared.ts index 3afa78a532..89df71f8f2 100644 --- a/apps/kimi-code/src/cli/sub/server/shared.ts +++ b/apps/kimi-code/src/cli/sub/server/shared.ts @@ -39,6 +39,8 @@ export interface ParsedServerOptions { port: number; logLevel: ServerLogLevel; debugEndpoints: boolean; + /** Allow a non-loopback bind without a TLS-terminating reverse proxy. */ + insecureNoTls: boolean; /** Internal: run as an idle-exiting background daemon instead of foreground. */ daemon: boolean; /** Internal: idle-shutdown grace in ms (daemon mode only). */ @@ -50,6 +52,8 @@ export interface ServerCliOptions { port?: string; logLevel?: string; debugEndpoints?: boolean; + /** Allow a non-loopback bind without TLS (`--insecure-no-tls`). */ + insecureNoTls?: boolean; /** Internal flag set by the daemon spawner (`kimi web`). */ daemon?: boolean; /** Internal flag set by the daemon spawner / tests. */ @@ -62,6 +66,7 @@ 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, daemon: opts.daemon === true, idleGraceMs: parseIdleGraceMs(opts.idleGraceMs), }; diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts index 2495699a8f..316d95e83b 100644 --- a/apps/kimi-code/test/cli/server/server.test.ts +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -59,6 +59,7 @@ describe('kimi server', () => { expect(longs).toContain('--port'); expect(longs).toContain('--log-level'); expect(longs).toContain('--debug-endpoints'); + expect(longs).toContain('--insecure-no-tls'); expect(longs).toContain('--foreground'); // run defaults to NOT opening the browser → option is the positive --open expect(longs).toContain('--open'); @@ -635,6 +636,98 @@ describe('lockConnectHost (M6.2 connect side)', () => { }); }); +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('labels a 0.0.0.0 bind as public with a reachability hint', 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' }), + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { write: () => true }, + }, + ); + + const plain = stripAnsi(stdout); + expect(plain).toContain('public'); + expect(plain).toContain('reachable from the internet'); + expect(plain).not.toContain('local only'); + }); + + it('labels a 127.0.0.1 bind as local only', 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' }), + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { write: () => true }, + }, + ); + + expect(stripAnsi(stdout)).toContain('local only'); + }); +}); + describe('resolveDaemonPort', () => { it('returns the preferred port when it is free', async () => { const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); @@ -757,6 +850,19 @@ describe('spawnDaemonChild', () => { 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('createIdleShutdownHandler', () => { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 8a043c88fc..fb7405752a 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -2,6 +2,8 @@ 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 { createServerLogger } from './services/pinoLoggerService'; export type { CreateLoggerOptions, diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts index e201e1f303..a7d8087c3f 100644 --- a/packages/server/src/start.ts +++ b/packages/server/src/start.ts @@ -67,6 +67,14 @@ export interface ServerStartOptions { */ 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; + webAssetsDir?: string; serviceOverrides?: ReadonlyArray, unknown]>; @@ -184,6 +192,45 @@ export async function startServer(opts: ServerStartOptions): Promise => { + try { + await tokenStore.dispose(); + } catch { + // best-effort cleanup of the token file on boot refusal + } + lockHandle.release(); + throw new Error(message); + }; + if (passwordHash === undefined) { + await refusePublicBind( + 'Refusing to bind a non-loopback host without a password. ' + + 'Set KIMI_CODE_PASSWORD (or configure a bcrypt password hash) before using --host ' + + opts.host + + '.', + ); + } + 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.', + ); + } + 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, @@ -219,11 +266,9 @@ export async function startServer(opts: ServerStartOptions): Promise a.get(IAuthTokenService)); app.addHook('onRequest', createAuthHook(authTokenService)); - // Bind classification (ROADMAP M6.1). Determines which hardening applies. - // Computed once here so every later gate (debug routes now; password/TLS, - // rate limit, dangerous endpoints, security headers in M6.3–M6.6) agrees on - // the exposure tier. Wildcard binds default to `public` (most strict). - const bindClass = classify(opts.host, { bindClass: opts.bindClass }); + // 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.4–M6.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 diff --git a/packages/server/test/debug-nonloopback.e2e.test.ts b/packages/server/test/debug-nonloopback.e2e.test.ts index 75e448ffda..418ffd2d77 100644 --- a/packages/server/test/debug-nonloopback.e2e.test.ts +++ b/packages/server/test/debug-nonloopback.e2e.test.ts @@ -20,12 +20,18 @@ 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 () => { @@ -36,6 +42,11 @@ afterEach(async () => { // 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 }); }); @@ -49,6 +60,9 @@ async function boot(host: string): Promise { 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; diff --git a/packages/server/test/host-exposure.e2e.test.ts b/packages/server/test/host-exposure.e2e.test.ts new file mode 100644 index 0000000000..3e4efa389a --- /dev/null +++ b/packages/server/test/host-exposure.e2e.test.ts @@ -0,0 +1,127 @@ +/** + * Host-exposure hardening (ROADMAP M6.3–M6.7). + * + * Covers the public-bind gate added in M6.3 (force password + TLS opt-out) + * and, as later steps land, the full §3.5 public hardening stack: rate limit, + * dangerous-endpoint downgrade, security headers, and Host allowlist. + * + * M6.3 scope here: the three gate outcomes on a `0.0.0.0` bind: + * 1. no password → refuse (password message); + * 2. password but no `--insecure-no-tls` → refuse (TLS message); + * 3. password + `insecureNoTls: true` → boot and log the public-bind warning. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +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 } 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-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('refuses to bind 0.0.0.0 without a password', async () => { + delete process.env['KIMI_CODE_PASSWORD']; + const { lockPath, homeDir } = tmpPaths(); + + await expect( + startServer({ + serviceOverrides: [fixedTokenAuth()], + host: '0.0.0.0', + port: 0, + lockPath, + insecureNoTls: true, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir }, + }), + ).rejects.toThrow(/without a password/); + }); + + 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'); + }); +}); From d2cba5db68ad13b09cb57ebebd0e954ce408dc61 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 21:16:06 +0800 Subject: [PATCH 22/37] feat(server): rate-limit repeated auth failures on non-loopback binds --- packages/server/src/middleware/auth.ts | 22 +++ packages/server/src/middleware/rateLimit.ts | 103 ++++++++++++ packages/server/src/start.ts | 13 +- packages/server/test/rate-limit.test.ts | 165 ++++++++++++++++++++ 4 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/middleware/rateLimit.ts create mode 100644 packages/server/test/rate-limit.test.ts diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts index 2079c963da..00b07759f4 100644 --- a/packages/server/src/middleware/auth.ts +++ b/packages/server/src/middleware/auth.ts @@ -15,6 +15,11 @@ 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). */ @@ -26,6 +31,12 @@ 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; } /** @@ -83,6 +94,15 @@ export function createAuthHook( 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); @@ -98,12 +118,14 @@ export function createAuthHook( } 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)); diff --git a/packages/server/src/middleware/rateLimit.ts b/packages/server/src/middleware/rateLimit.ts new file mode 100644 index 0000000000..ba400b8de8 --- /dev/null +++ b/packages/server/src/middleware/rateLimit.ts @@ -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(); + + 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(); + }, + }; +} diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts index a7d8087c3f..92f8af9e73 100644 --- a/packages/server/src/start.ts +++ b/packages/server/src/start.ts @@ -11,6 +11,7 @@ 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, @@ -263,8 +264,14 @@ export async function startServer(opts: ServerStartOptions): Promise a.get(IAuthTokenService)); - app.addHook('onRequest', createAuthHook(authTokenService)); + const authFailureLimiter = + bindClass !== 'loopback' ? createAuthFailureLimiter() : undefined; + app.addHook('onRequest', createAuthHook(authTokenService, { limiter: authFailureLimiter })); // Bind classification (`bindClass`, computed above next to the password/TLS // gate) drives every hardening decision from here on: debug routes now; @@ -570,6 +577,10 @@ export async function startServer(opts: ServerStartOptions): Promise 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 } { + 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; + 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(); + } + }); +}); From 46758b627719ece5b811f787b2366ca9c6e8ea25 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 21:26:38 +0800 Subject: [PATCH 23/37] feat(server): disable shutdown and terminals on public binds by default --- apps/kimi-code/src/cli/sub/server/daemon.ts | 14 +++ apps/kimi-code/src/cli/sub/server/run.ts | 14 +++ apps/kimi-code/src/cli/sub/server/shared.ts | 10 +++ apps/kimi-code/test/cli/server/server.test.ts | 2 + .../server/src/routes/registerApiV1Routes.ts | 31 +++++-- packages/server/src/start.ts | 22 +++++ .../server/test/host-exposure.e2e.test.ts | 88 ++++++++++++++++++- 7 files changed, 171 insertions(+), 10 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/server/daemon.ts b/apps/kimi-code/src/cli/sub/server/daemon.ts index a74dcda51b..6cd46d21c4 100644 --- a/apps/kimi-code/src/cli/sub/server/daemon.ts +++ b/apps/kimi-code/src/cli/sub/server/daemon.ts @@ -54,6 +54,10 @@ export interface EnsureDaemonOptions { 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; } @@ -187,6 +191,8 @@ interface SpawnDaemonChildOptions { logLevel: string; debugEndpoints?: boolean; insecureNoTls?: boolean; + allowRemoteShutdown?: boolean; + allowRemoteTerminals?: boolean; idleGraceMs?: number; } @@ -213,6 +219,12 @@ export function spawnDaemonChild(options: SpawnDaemonChildOptions): void { 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)); } @@ -287,6 +299,8 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise', `Server log level: ${VALID_LOG_LEVELS.join('|')}. Omit to keep logs off.`, @@ -203,6 +213,8 @@ export async function startServerBackground( logLevel: options.logLevel, debugEndpoints: options.debugEndpoints, insecureNoTls: options.insecureNoTls, + allowRemoteShutdown: options.allowRemoteShutdown, + allowRemoteTerminals: options.allowRemoteTerminals, idleGraceMs: options.idleGraceMs, }); return { origin }; @@ -280,6 +292,8 @@ async function runServerInProcess( logLevel: options.logLevel, debugEndpoints: options.debugEndpoints, insecureNoTls: options.insecureNoTls, + allowRemoteShutdown: options.allowRemoteShutdown, + allowRemoteTerminals: options.allowRemoteTerminals, webAssetsDir: serverWebAssetsDir(), coreProcessOptions: { identity: createKimiCodeHostIdentity(version), diff --git a/apps/kimi-code/src/cli/sub/server/shared.ts b/apps/kimi-code/src/cli/sub/server/shared.ts index 89df71f8f2..ba3ee119f6 100644 --- a/apps/kimi-code/src/cli/sub/server/shared.ts +++ b/apps/kimi-code/src/cli/sub/server/shared.ts @@ -41,6 +41,10 @@ export interface ParsedServerOptions { 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). */ @@ -54,6 +58,10 @@ export interface ServerCliOptions { 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. */ @@ -67,6 +75,8 @@ export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions 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), }; diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts index 316d95e83b..39e698bedd 100644 --- a/apps/kimi-code/test/cli/server/server.test.ts +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -60,6 +60,8 @@ describe('kimi server', () => { 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'); diff --git a/packages/server/src/routes/registerApiV1Routes.ts b/packages/server/src/routes/registerApiV1Routes.ts index c710641ea1..38297e5f08 100644 --- a/packages/server/src/routes/registerApiV1Routes.ts +++ b/packages/server/src/routes/registerApiV1Routes.ts @@ -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[0], ix); - registerShutdownRoutes( - apiV1 as unknown as Parameters[0], - ix, - ); + if (opts.enableShutdown !== false) { + registerShutdownRoutes( + apiV1 as unknown as Parameters[0], + ix, + ); + } registerSnapshotRoutes(apiV1 as unknown as Parameters[0], ix); registerMessagesRoutes(apiV1 as unknown as Parameters[0], ix); registerPromptsRoutes(apiV1 as unknown as Parameters[0], ix); @@ -94,10 +107,12 @@ export async function registerApiV1Routes( registerToolsRoutes(apiV1 as unknown as Parameters[0], ix); registerSkillsRoutes(apiV1 as unknown as Parameters[0], ix); registerTasksRoutes(apiV1 as unknown as Parameters[0], ix); - registerTerminalsRoutes( - apiV1 as unknown as Parameters[0], - ix, - ); + if (opts.enableTerminals !== false) { + registerTerminalsRoutes( + apiV1 as unknown as Parameters[0], + ix, + ); + } registerFsRoutes(apiV1 as unknown as Parameters[0], ix); registerFilesRoutes(apiV1 as unknown as Parameters[0], ix); registerWorkspacesRoutes( diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts index 92f8af9e73..cb30ce289a 100644 --- a/packages/server/src/start.ts +++ b/packages/server/src/start.ts @@ -76,6 +76,21 @@ export interface ServerStartOptions { */ 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, unknown]>; @@ -288,9 +303,16 @@ export async function startServer(opts: ServerStartOptions): Promise { diff --git a/packages/server/test/host-exposure.e2e.test.ts b/packages/server/test/host-exposure.e2e.test.ts index 3e4efa389a..b9732c6b0e 100644 --- a/packages/server/test/host-exposure.e2e.test.ts +++ b/packages/server/test/host-exposure.e2e.test.ts @@ -17,9 +17,9 @@ import { join } from 'node:path'; import { Writable } from 'node:stream'; import { pino, type Logger } from 'pino'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { startServer, type RunningServer } from '../src'; +import { IServerShutdownService, startServer, type RunningServer, type ServerStartOptions } from '../src'; import { authHeaders, fixedTokenAuth } from './helpers/serverHarness'; const createdDirs: string[] = []; @@ -125,3 +125,87 @@ describe('non-loopback bind gate (M6.3)', () => { 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')); + }); +}); From 80bd74e4e8419646d8cc56386d90ee37120c81ed Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 21:29:47 +0800 Subject: [PATCH 24/37] feat(server): add security response headers on non-loopback binds --- packages/server/src/services/auth/index.ts | 1 + .../src/services/auth/securityHeaders.ts | 45 +++++++++ packages/server/src/start.ts | 8 ++ packages/server/test/security-headers.test.ts | 95 +++++++++++++++++++ 4 files changed, 149 insertions(+) create mode 100644 packages/server/src/services/auth/securityHeaders.ts create mode 100644 packages/server/test/security-headers.test.ts diff --git a/packages/server/src/services/auth/index.ts b/packages/server/src/services/auth/index.ts index aaaacec6ef..95ad8d8265 100644 --- a/packages/server/src/services/auth/index.ts +++ b/packages/server/src/services/auth/index.ts @@ -3,3 +3,4 @@ export * from './tokenStore'; export * from './password'; export * from './authTokenService'; export * from './bindClassify'; +export * from './securityHeaders'; diff --git a/packages/server/src/services/auth/securityHeaders.ts b/packages/server/src/services/auth/securityHeaders.ts new file mode 100644 index 0000000000..21f27767dd --- /dev/null +++ b/packages/server/src/services/auth/securityHeaders.ts @@ -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 { + 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; + }; +} diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts index cb30ce289a..d009435208 100644 --- a/packages/server/src/start.ts +++ b/packages/server/src/start.ts @@ -42,6 +42,7 @@ import { } 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'; @@ -288,6 +289,13 @@ export async function startServer(opts: ServerStartOptions): Promise { + 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 { + 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(); + }); +}); From 6fc7c30d23e7d7c36b7e03c6c0ea85e048a586d9 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 21:36:40 +0800 Subject: [PATCH 25/37] test(server): cover LAN/public host-exposure hardening end to end --- .../server/test/host-exposure.e2e.test.ts | 141 +++++++++++++++++- 1 file changed, 133 insertions(+), 8 deletions(-) diff --git a/packages/server/test/host-exposure.e2e.test.ts b/packages/server/test/host-exposure.e2e.test.ts index b9732c6b0e..02d19702f6 100644 --- a/packages/server/test/host-exposure.e2e.test.ts +++ b/packages/server/test/host-exposure.e2e.test.ts @@ -1,17 +1,21 @@ /** * Host-exposure hardening (ROADMAP M6.3–M6.7). * - * Covers the public-bind gate added in M6.3 (force password + TLS opt-out) - * and, as later steps land, the full §3.5 public hardening stack: rate limit, - * dangerous-endpoint downgrade, security headers, and Host allowlist. - * - * M6.3 scope here: the three gate outcomes on a `0.0.0.0` bind: - * 1. no password → refuse (password message); - * 2. password but no `--insecure-no-tls` → refuse (TLS message); - * 3. password + `insecureNoTls: true` → boot and log the public-bind warning. + * 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 password → refuse; password-but-no-TLS → + * refuse; password + `insecureNoTls` → boot + warn logged). + * - Real password auth path (`Authorization: Bearer ` → 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'; @@ -209,3 +213,124 @@ describe('dangerous-endpoint downgrade on a public bind (M6.5)', () => { 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, +): 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 { + 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 { + 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); + }); +}); From 13ea2d4e8f56d7582107d222fde8e3c615f46443 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 21:57:22 +0800 Subject: [PATCH 26/37] docs(server): add deployment security and threat-model guide --- packages/server/SECURITY.md | 185 ++++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 packages/server/SECURITY.md diff --git a/packages/server/SECURITY.md b/packages/server/SECURITY.md new file mode 100644 index 0000000000..6b87f42c67 --- /dev/null +++ b/packages/server/SECURITY.md @@ -0,0 +1,185 @@ +# 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) | Per-start 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):** mandatory password, 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 (password + TLS + +rate-limit + endpoint downgrade + security headers). 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). +- Generates a per-start bearer token (`crypto.randomBytes(32)`, base64url) kept only + in memory and written to `/server-.token` with mode `0600` + (parent directory `0700`). The file is deleted when the server exits. +- The local CLI reads that token file automatically and sends + `Authorization: Bearer ` 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 requires the same credentials as a public bind: + +``` +export KIMI_CODE_PASSWORD='a long random password' +kimi server run --host 192.168.1.10 --insecure-no-tls +``` + +- `KIMI_CODE_PASSWORD` is **required**; the server refuses to start without it on any + non-loopback bind. +- `--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. + +``` +export KIMI_CODE_PASSWORD='a long random password' +kimi server run --host 0.0.0.0 --insecure-no-tls +``` + +- `KIMI_CODE_PASSWORD` is mandatory. `--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** — generated fresh per start, held in memory, written to + `/server-.token` (`0600`, directory `0700`), deleted on exit. + 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 `. +- **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('@node-rs/bcrypt').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 ` 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 ` header or + the subprotocol `Sec-WebSocket-Protocol: kimi-code.bearer.` (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 ` | Bind host (default `127.0.0.1`). | +| `--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. From 4472c2276585e0b5dc7605590f230aaf91ec4761 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 22 Jun 2026 22:01:22 +0800 Subject: [PATCH 27/37] changeset: minor kimi-code for server auth and host exposure --- .changeset/server-auth-and-host-exposure.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/server-auth-and-host-exposure.md diff --git a/.changeset/server-auth-and-host-exposure.md b/.changeset/server-auth-and-host-exposure.md new file mode 100644 index 0000000000..d66a418ad4 --- /dev/null +++ b/.changeset/server-auth-and-host-exposure.md @@ -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`. From e15b10deadd40d982d591aa82784da65a49f49ad Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 23 Jun 2026 11:49:24 +0800 Subject: [PATCH 28/37] feat(kimi-web): add server bearer-token auth support --- apps/kimi-web/src/App.vue | 18 +++ apps/kimi-web/src/api/daemon/http.ts | 21 +++ apps/kimi-web/src/api/daemon/serverAuth.ts | 116 ++++++++++++++ apps/kimi-web/src/api/daemon/ws.ts | 11 +- .../src/components/ServerAuthDialog.vue | 145 ++++++++++++++++++ 5 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 apps/kimi-web/src/api/daemon/serverAuth.ts create mode 100644 apps/kimi-web/src/components/ServerAuthDialog.vue diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 176910afaf..138a45456d 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -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,19 @@ 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); + stopSpinner(); + if (authLogoBlinkTimer !== null) clearTimeout(authLogoBlinkTimer); + if (offAuthRequired !== null) { + offAuthRequired(); + offAuthRequired = null; + } }); function onGlobalKeydown(e: KeyboardEvent): void { @@ -504,6 +521,7 @@ function openPr(url: string): void { + + From 454e9c003a56b567a67b38e46b4c954cb6a24e0a Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 23 Jun 2026 15:39:16 +0800 Subject: [PATCH 29/37] 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. --- apps/kimi-web/src/App.vue | 2 - packages/server/SECURITY.md | 2 +- packages/server/package.json | 3 +- packages/server/src/services/auth/password.ts | 2 +- .../src/services/gateway/wsGatewayService.ts | 4 +- packages/server/test/approval.e2e.test.ts | 2 +- packages/server/test/auth-wiring.e2e.test.ts | 6 +- packages/server/test/auth.e2e.test.ts | 2 +- packages/server/test/config.e2e.test.ts | 2 +- packages/server/test/connections.e2e.test.ts | 2 +- packages/server/test/files.e2e.test.ts | 2 +- packages/server/test/fs-basic.e2e.test.ts | 2 +- packages/server/test/fs-batch.e2e.test.ts | 2 +- packages/server/test/fs-browse.e2e.test.ts | 2 +- packages/server/test/fs-download.e2e.test.ts | 2 +- packages/server/test/fs-git.e2e.test.ts | 2 +- packages/server/test/fs-mkdir.e2e.test.ts | 2 +- packages/server/test/fs-search.e2e.test.ts | 2 +- packages/server/test/fs-watch.e2e.test.ts | 2 +- packages/server/test/host-origin.e2e.test.ts | 9 +- packages/server/test/messages.e2e.test.ts | 2 +- packages/server/test/meta.e2e.test.ts | 2 +- .../server/test/model-catalog.e2e.test.ts | 2 +- packages/server/test/oauth.e2e.test.ts | 2 +- packages/server/test/prompt.e2e.test.ts | 2 +- packages/server/test/question.e2e.test.ts | 2 +- .../test/sessions-workspace.e2e.test.ts | 2 +- packages/server/test/sessions.e2e.test.ts | 2 +- packages/server/test/skills.e2e.test.ts | 2 +- packages/server/test/snapshot.e2e.test.ts | 2 +- packages/server/test/snapshot.perf.test.ts | 17 +- packages/server/test/snapshot.smoke.test.ts | 17 +- packages/server/test/swagger.e2e.test.ts | 2 +- packages/server/test/tasks.e2e.test.ts | 2 +- packages/server/test/terminals.e2e.test.ts | 2 +- packages/server/test/tools.e2e.test.ts | 2 +- packages/server/test/workspaces.e2e.test.ts | 2 +- packages/server/test/ws-abort.e2e.test.ts | 2 +- packages/server/test/ws-auth.e2e.test.ts | 3 +- packages/server/test/ws-terminal.e2e.test.ts | 2 +- pnpm-lock.yaml | 181 ++---------------- 41 files changed, 88 insertions(+), 218 deletions(-) diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 138a45456d..26aa59aff7 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -113,8 +113,6 @@ onMounted(() => { onUnmounted(() => { document.removeEventListener('keydown', onGlobalKeydown, true); - stopSpinner(); - if (authLogoBlinkTimer !== null) clearTimeout(authLogoBlinkTimer); if (offAuthRequired !== null) { offAuthRequired(); offAuthRequired = null; diff --git a/packages/server/SECURITY.md b/packages/server/SECURITY.md index 6b87f42c67..b82adba432 100644 --- a/packages/server/SECURITY.md +++ b/packages/server/SECURITY.md @@ -136,7 +136,7 @@ the remote reachability and TLS. hash is needed later, generate one externally with cost 12, e.g.: ``` - node -e "require('@node-rs/bcrypt').hash(process.env.KIMI_CODE_PASSWORD,12).then(h=>console.log(h))" + 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.) diff --git a/packages/server/package.json b/packages/server/package.json index 8bc3349b85..b5de0a6910 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -42,7 +42,7 @@ "@fastify/swagger": "^9.7.0", "@moonshot-ai/agent-core": "workspace:^", "@moonshot-ai/protocol": "workspace:^", - "@node-rs/bcrypt": "^1.10.7", + "bcryptjs": "^2.4.3", "fastify": "^5.1.0", "pino": "^9.5.0", "pino-pretty": "^13.0.0", @@ -52,6 +52,7 @@ "zod-to-json-schema": "^3.25.2" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/ws": "^8.18.0" } } diff --git a/packages/server/src/services/auth/password.ts b/packages/server/src/services/auth/password.ts index 1576f322c6..0a60f8bec6 100644 --- a/packages/server/src/services/auth/password.ts +++ b/packages/server/src/services/auth/password.ts @@ -1,4 +1,4 @@ -import { compare, hash } from '@node-rs/bcrypt'; +import { compare, hash } from 'bcryptjs'; const BCRYPT_COST = 12; diff --git a/packages/server/src/services/gateway/wsGatewayService.ts b/packages/server/src/services/gateway/wsGatewayService.ts index 569c46e84f..d349081c19 100644 --- a/packages/server/src/services/gateway/wsGatewayService.ts +++ b/packages/server/src/services/gateway/wsGatewayService.ts @@ -61,7 +61,9 @@ export class WSGateway extends Disposable implements IWSGateway { }, }); 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'); } diff --git a/packages/server/test/approval.e2e.test.ts b/packages/server/test/approval.e2e.test.ts index 97736df348..011075eaec 100644 --- a/packages/server/test/approval.e2e.test.ts +++ b/packages/server/test/approval.e2e.test.ts @@ -92,7 +92,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/auth-wiring.e2e.test.ts b/packages/server/test/auth-wiring.e2e.test.ts index 7ee35f5e1c..aa27ab833e 100644 --- a/packages/server/test/auth-wiring.e2e.test.ts +++ b/packages/server/test/auth-wiring.e2e.test.ts @@ -150,7 +150,11 @@ function expectRejected(url: string): Promise { } catch { // ignore } - err === undefined ? resolve() : reject(err); + if (err === undefined) { + resolve(); + } else { + reject(err); + } }; ws.once('open', () => done(new Error('connection unexpectedly opened'))); ws.once('error', () => done()); diff --git a/packages/server/test/auth.e2e.test.ts b/packages/server/test/auth.e2e.test.ts index cebf599388..8c99f69154 100644 --- a/packages/server/test/auth.e2e.test.ts +++ b/packages/server/test/auth.e2e.test.ts @@ -85,7 +85,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/config.e2e.test.ts b/packages/server/test/config.e2e.test.ts index d7cae0e3ef..dab1713ab1 100644 --- a/packages/server/test/config.e2e.test.ts +++ b/packages/server/test/config.e2e.test.ts @@ -59,7 +59,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/connections.e2e.test.ts b/packages/server/test/connections.e2e.test.ts index a88fb58cd6..d5c7c3644d 100644 --- a/packages/server/test/connections.e2e.test.ts +++ b/packages/server/test/connections.e2e.test.ts @@ -87,7 +87,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/files.e2e.test.ts b/packages/server/test/files.e2e.test.ts index 905608dd95..2f6f51fa44 100644 --- a/packages/server/test/files.e2e.test.ts +++ b/packages/server/test/files.e2e.test.ts @@ -90,7 +90,7 @@ function appOf(r: RunningServer): FastifyAppLike { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/fs-basic.e2e.test.ts b/packages/server/test/fs-basic.e2e.test.ts index debeff023f..5ae5908211 100644 --- a/packages/server/test/fs-basic.e2e.test.ts +++ b/packages/server/test/fs-basic.e2e.test.ts @@ -90,7 +90,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/fs-batch.e2e.test.ts b/packages/server/test/fs-batch.e2e.test.ts index 70879f3045..ab081be68a 100644 --- a/packages/server/test/fs-batch.e2e.test.ts +++ b/packages/server/test/fs-batch.e2e.test.ts @@ -90,7 +90,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/fs-browse.e2e.test.ts b/packages/server/test/fs-browse.e2e.test.ts index 9e736cf56d..d217fecd1b 100644 --- a/packages/server/test/fs-browse.e2e.test.ts +++ b/packages/server/test/fs-browse.e2e.test.ts @@ -83,7 +83,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/fs-download.e2e.test.ts b/packages/server/test/fs-download.e2e.test.ts index 3846c6a8dd..88d658d568 100644 --- a/packages/server/test/fs-download.e2e.test.ts +++ b/packages/server/test/fs-download.e2e.test.ts @@ -95,7 +95,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/fs-git.e2e.test.ts b/packages/server/test/fs-git.e2e.test.ts index 5e4f49daa9..9f63efdde9 100644 --- a/packages/server/test/fs-git.e2e.test.ts +++ b/packages/server/test/fs-git.e2e.test.ts @@ -77,7 +77,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/fs-mkdir.e2e.test.ts b/packages/server/test/fs-mkdir.e2e.test.ts index 4e70855a0e..6624bcc4d9 100644 --- a/packages/server/test/fs-mkdir.e2e.test.ts +++ b/packages/server/test/fs-mkdir.e2e.test.ts @@ -81,7 +81,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/fs-search.e2e.test.ts b/packages/server/test/fs-search.e2e.test.ts index afce1075ae..4a887dfb22 100644 --- a/packages/server/test/fs-search.e2e.test.ts +++ b/packages/server/test/fs-search.e2e.test.ts @@ -77,7 +77,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/fs-watch.e2e.test.ts b/packages/server/test/fs-watch.e2e.test.ts index 7f00e4c97b..ab34d5cbfa 100644 --- a/packages/server/test/fs-watch.e2e.test.ts +++ b/packages/server/test/fs-watch.e2e.test.ts @@ -109,7 +109,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/host-origin.e2e.test.ts b/packages/server/test/host-origin.e2e.test.ts index 0b7281338d..b8788518a2 100644 --- a/packages/server/test/host-origin.e2e.test.ts +++ b/packages/server/test/host-origin.e2e.test.ts @@ -229,7 +229,8 @@ describe('WS Host/Origin checks (wsGatewayService)', () => { 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)); - await receiveType(conn, 'server_hello', 1000); + const hello = await receiveType(conn, 'server_hello', 1000); + expect(hello.type).toBe('server_hello'); conn.ws.close(); await conn.closed; }); @@ -244,7 +245,8 @@ describe('WS Host/Origin checks (wsGatewayService)', () => { 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)); - await receiveType(conn, 'server_hello', 1000); + const hello = await receiveType(conn, 'server_hello', 1000); + expect(hello.type).toBe('server_hello'); conn.ws.close(); await conn.closed; }); @@ -252,7 +254,8 @@ describe('WS Host/Origin checks (wsGatewayService)', () => { it('skips the checks when the options are unset', async () => { const r = await spawn(); const conn = await openConn(wsUrl(r.address)); - await receiveType(conn, 'server_hello', 1000); + const hello = await receiveType(conn, 'server_hello', 1000); + expect(hello.type).toBe('server_hello'); conn.ws.close(); await conn.closed; }); diff --git a/packages/server/test/messages.e2e.test.ts b/packages/server/test/messages.e2e.test.ts index b6968851b5..cc3d3248ed 100644 --- a/packages/server/test/messages.e2e.test.ts +++ b/packages/server/test/messages.e2e.test.ts @@ -93,7 +93,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/meta.e2e.test.ts b/packages/server/test/meta.e2e.test.ts index 8bd6463b63..c98715bb78 100644 --- a/packages/server/test/meta.e2e.test.ts +++ b/packages/server/test/meta.e2e.test.ts @@ -91,7 +91,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/model-catalog.e2e.test.ts b/packages/server/test/model-catalog.e2e.test.ts index 9a440eb04d..95c89ab3f9 100644 --- a/packages/server/test/model-catalog.e2e.test.ts +++ b/packages/server/test/model-catalog.e2e.test.ts @@ -63,7 +63,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/oauth.e2e.test.ts b/packages/server/test/oauth.e2e.test.ts index 21972c36ed..560c57c921 100644 --- a/packages/server/test/oauth.e2e.test.ts +++ b/packages/server/test/oauth.e2e.test.ts @@ -151,7 +151,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/prompt.e2e.test.ts b/packages/server/test/prompt.e2e.test.ts index dddbf1d2d6..c99fb9e2f1 100644 --- a/packages/server/test/prompt.e2e.test.ts +++ b/packages/server/test/prompt.e2e.test.ts @@ -89,7 +89,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/question.e2e.test.ts b/packages/server/test/question.e2e.test.ts index 577a8a7dc5..669682ce18 100644 --- a/packages/server/test/question.e2e.test.ts +++ b/packages/server/test/question.e2e.test.ts @@ -81,7 +81,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/sessions-workspace.e2e.test.ts b/packages/server/test/sessions-workspace.e2e.test.ts index 8f0c634630..f1b211dfec 100644 --- a/packages/server/test/sessions-workspace.e2e.test.ts +++ b/packages/server/test/sessions-workspace.e2e.test.ts @@ -74,7 +74,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/sessions.e2e.test.ts b/packages/server/test/sessions.e2e.test.ts index 414a248af3..48f29cadb4 100644 --- a/packages/server/test/sessions.e2e.test.ts +++ b/packages/server/test/sessions.e2e.test.ts @@ -108,7 +108,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/skills.e2e.test.ts b/packages/server/test/skills.e2e.test.ts index c59d675aba..0db5903209 100644 --- a/packages/server/test/skills.e2e.test.ts +++ b/packages/server/test/skills.e2e.test.ts @@ -94,7 +94,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/snapshot.e2e.test.ts b/packages/server/test/snapshot.e2e.test.ts index cfec350502..b9f107d2cd 100644 --- a/packages/server/test/snapshot.e2e.test.ts +++ b/packages/server/test/snapshot.e2e.test.ts @@ -79,7 +79,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/snapshot.perf.test.ts b/packages/server/test/snapshot.perf.test.ts index d403812378..709cf46f3d 100644 --- a/packages/server/test/snapshot.perf.test.ts +++ b/packages/server/test/snapshot.perf.test.ts @@ -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 { - 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 { async function timeSnapshot(baseUrl: string, sid: string): Promise { 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 }, }); diff --git a/packages/server/test/snapshot.smoke.test.ts b/packages/server/test/snapshot.smoke.test.ts index 5d29e95024..5020b47627 100644 --- a/packages/server/test/snapshot.smoke.test.ts +++ b/packages/server/test/snapshot.smoke.test.ts @@ -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 { - 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 }; } diff --git a/packages/server/test/swagger.e2e.test.ts b/packages/server/test/swagger.e2e.test.ts index e7a90f5f2c..a68a2da7ea 100644 --- a/packages/server/test/swagger.e2e.test.ts +++ b/packages/server/test/swagger.e2e.test.ts @@ -66,7 +66,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/tasks.e2e.test.ts b/packages/server/test/tasks.e2e.test.ts index 8be10f0885..864ad7c7b1 100644 --- a/packages/server/test/tasks.e2e.test.ts +++ b/packages/server/test/tasks.e2e.test.ts @@ -95,7 +95,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/terminals.e2e.test.ts b/packages/server/test/terminals.e2e.test.ts index f7ca999418..6fd012ead3 100644 --- a/packages/server/test/terminals.e2e.test.ts +++ b/packages/server/test/terminals.e2e.test.ts @@ -66,7 +66,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/tools.e2e.test.ts b/packages/server/test/tools.e2e.test.ts index 634cc699a3..ef946498c6 100644 --- a/packages/server/test/tools.e2e.test.ts +++ b/packages/server/test/tools.e2e.test.ts @@ -79,7 +79,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/workspaces.e2e.test.ts b/packages/server/test/workspaces.e2e.test.ts index 70ee723460..08a348856e 100644 --- a/packages/server/test/workspaces.e2e.test.ts +++ b/packages/server/test/workspaces.e2e.test.ts @@ -79,7 +79,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/ws-abort.e2e.test.ts b/packages/server/test/ws-abort.e2e.test.ts index 0a7d008a25..a7e5cbaefa 100644 --- a/packages/server/test/ws-abort.e2e.test.ts +++ b/packages/server/test/ws-abort.e2e.test.ts @@ -87,7 +87,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/packages/server/test/ws-auth.e2e.test.ts b/packages/server/test/ws-auth.e2e.test.ts index 4a7d841295..01853cf6b1 100644 --- a/packages/server/test/ws-auth.e2e.test.ts +++ b/packages/server/test/ws-auth.e2e.test.ts @@ -225,7 +225,8 @@ describe('ws upgrade auth', () => { headers: { Authorization: 'Bearer test-token' }, }); - await receiveType(conn, 'server_hello', 1000); + const hello = await receiveType(conn, 'server_hello', 1000); + expect(hello.type).toBe('server_hello'); conn.ws.close(); await conn.closed; diff --git a/packages/server/test/ws-terminal.e2e.test.ts b/packages/server/test/ws-terminal.e2e.test.ts index 2cf89a29dd..353793cc00 100644 --- a/packages/server/test/ws-terminal.e2e.test.ts +++ b/packages/server/test/ws-terminal.e2e.test.ts @@ -69,7 +69,7 @@ function appOf(r: RunningServer): { const q = req as { headers?: Record }; return app.inject({ ...q, - headers: { authorization: 'Bearer test-token', ...(q.headers ?? {}) }, + headers: { authorization: 'Bearer test-token', ...q.headers }, }); }, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 841a7de60f..bd02084336 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -553,9 +553,9 @@ importers: '@moonshot-ai/protocol': specifier: workspace:^ version: link:../protocol - '@node-rs/bcrypt': - specifier: ^1.10.7 - version: 1.10.7 + bcryptjs: + specifier: ^2.4.3 + version: 2.4.3 fastify: specifier: ^5.1.0 version: 5.8.5 @@ -578,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 @@ -1674,106 +1677,12 @@ packages: resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==} engines: {node: '>=14.0.0'} - '@napi-rs/wasm-runtime@0.2.12': - resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@node-rs/bcrypt-android-arm-eabi@1.10.7': - resolution: {integrity: sha512-8dO6/PcbeMZXS3VXGEtct9pDYdShp2WBOWlDvSbcRwVqyB580aCBh0BEFmKYtXLzLvUK8Wf+CG3U6sCdILW1lA==} - engines: {node: '>= 10'} - cpu: [arm] - os: [android] - - '@node-rs/bcrypt-android-arm64@1.10.7': - resolution: {integrity: sha512-UASFBS/CucEMHiCtL/2YYsAY01ZqVR1N7vSb94EOvG5iwW7BQO06kXXCTgj+Xbek9azxixrCUmo3WJnkJZ0hTQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [android] - - '@node-rs/bcrypt-darwin-arm64@1.10.7': - resolution: {integrity: sha512-DgzFdAt455KTuiJ/zYIyJcKFobjNDR/hnf9OS7pK5NRS13Nq4gLcSIIyzsgHwZHxsJWbLpHmFc1H23Y7IQoQBw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@node-rs/bcrypt-darwin-x64@1.10.7': - resolution: {integrity: sha512-SPWVfQ6sxSokoUWAKWD0EJauvPHqOGQTd7CxmYatcsUgJ/bruvEHxZ4bIwX1iDceC3FkOtmeHO0cPwR480n/xA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@node-rs/bcrypt-freebsd-x64@1.10.7': - resolution: {integrity: sha512-gpa+Ixs6GwEx6U6ehBpsQetzUpuAGuAFbOiuLB2oo4N58yU4AZz1VIcWyWAHrSWRs92O0SHtmo2YPrMrwfBbSw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [freebsd] - - '@node-rs/bcrypt-linux-arm-gnueabihf@1.10.7': - resolution: {integrity: sha512-kYgJnTnpxrzl9sxYqzflobvMp90qoAlaX1oDL7nhNTj8OYJVDIk0jQgblj0bIkjmoPbBed53OJY/iu4uTS+wig==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - - '@node-rs/bcrypt-linux-arm64-gnu@1.10.7': - resolution: {integrity: sha512-7cEkK2RA+gBCj2tCVEI1rDSJV40oLbSq7bQ+PNMHNI6jCoXGmj9Uzo7mg7ZRbNZ7piIyNH5zlJqutjo8hh/tmA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@node-rs/bcrypt-linux-arm64-musl@1.10.7': - resolution: {integrity: sha512-X7DRVjshhwxUqzdUKDlF55cwzh+wqWJ2E/tILvZPboO3xaNO07Um568Vf+8cmKcz+tiZCGP7CBmKbBqjvKN/Pw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@node-rs/bcrypt-linux-x64-gnu@1.10.7': - resolution: {integrity: sha512-LXRZsvG65NggPD12hn6YxVgH0W3VR5fsE/o1/o2D5X0nxKcNQGeLWnRzs5cP8KpoFOuk1ilctXQJn8/wq+Gn/Q==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@node-rs/bcrypt-linux-x64-musl@1.10.7': - resolution: {integrity: sha512-tCjHmct79OfcO3g5q21ME7CNzLzpw1MAsUXCLHLGWH+V6pp/xTvMbIcLwzkDj6TI3mxK6kehTn40SEjBkZ3Rog==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@node-rs/bcrypt-wasm32-wasi@1.10.7': - resolution: {integrity: sha512-4qXSihIKeVXYglfXZEq/QPtYtBUvR8d3S85k15Lilv3z5B6NSGQ9mYiNleZ7QHVLN2gEc5gmi7jM353DMH9GkA==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@node-rs/bcrypt-win32-arm64-msvc@1.10.7': - resolution: {integrity: sha512-FdfUQrqmDfvC5jFhntMBkk8EI+fCJTx/I1v7Rj+Ezlr9rez1j1FmuUnywbBj2Cg15/0BDhwYdbyZ5GCMFli2aQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@node-rs/bcrypt-win32-ia32-msvc@1.10.7': - resolution: {integrity: sha512-lZLf4Cx+bShIhU071p5BZft4OvP4PGhyp542EEsb3zk34U5GLsGIyCjOafcF/2DGewZL6u8/aqoxbSuROkgFXg==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - - '@node-rs/bcrypt-win32-x64-msvc@1.10.7': - resolution: {integrity: sha512-hdw7tGmN1DxVAMTzICLdaHpXjy+4rxaxnBMgI8seG1JL5e3VcRGsd1/1vVDogVp2cbsmgq+6d6yAY+D9lW/DCg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@node-rs/bcrypt@1.10.7': - resolution: {integrity: sha512-1wk0gHsUQC/ap0j6SJa2K34qNhomxXRcEe3T8cI5s+g6fgHBgLTN7U9LzWTG/HE6G4+2tWWLeCabk1wiYGEQSA==} - engines: {node: '>= 10'} - '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2658,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==} @@ -3167,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'} @@ -7485,13 +7400,6 @@ snapshots: '@mozilla/readability@0.6.0': {} - '@napi-rs/wasm-runtime@0.2.12': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.1 - optional: true - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -7506,67 +7414,6 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@node-rs/bcrypt-android-arm-eabi@1.10.7': - optional: true - - '@node-rs/bcrypt-android-arm64@1.10.7': - optional: true - - '@node-rs/bcrypt-darwin-arm64@1.10.7': - optional: true - - '@node-rs/bcrypt-darwin-x64@1.10.7': - optional: true - - '@node-rs/bcrypt-freebsd-x64@1.10.7': - optional: true - - '@node-rs/bcrypt-linux-arm-gnueabihf@1.10.7': - optional: true - - '@node-rs/bcrypt-linux-arm64-gnu@1.10.7': - optional: true - - '@node-rs/bcrypt-linux-arm64-musl@1.10.7': - optional: true - - '@node-rs/bcrypt-linux-x64-gnu@1.10.7': - optional: true - - '@node-rs/bcrypt-linux-x64-musl@1.10.7': - optional: true - - '@node-rs/bcrypt-wasm32-wasi@1.10.7': - dependencies: - '@napi-rs/wasm-runtime': 0.2.12 - optional: true - - '@node-rs/bcrypt-win32-arm64-msvc@1.10.7': - optional: true - - '@node-rs/bcrypt-win32-ia32-msvc@1.10.7': - optional: true - - '@node-rs/bcrypt-win32-x64-msvc@1.10.7': - optional: true - - '@node-rs/bcrypt@1.10.7': - optionalDependencies: - '@node-rs/bcrypt-android-arm-eabi': 1.10.7 - '@node-rs/bcrypt-android-arm64': 1.10.7 - '@node-rs/bcrypt-darwin-arm64': 1.10.7 - '@node-rs/bcrypt-darwin-x64': 1.10.7 - '@node-rs/bcrypt-freebsd-x64': 1.10.7 - '@node-rs/bcrypt-linux-arm-gnueabihf': 1.10.7 - '@node-rs/bcrypt-linux-arm64-gnu': 1.10.7 - '@node-rs/bcrypt-linux-arm64-musl': 1.10.7 - '@node-rs/bcrypt-linux-x64-gnu': 1.10.7 - '@node-rs/bcrypt-linux-x64-musl': 1.10.7 - '@node-rs/bcrypt-wasm32-wasi': 1.10.7 - '@node-rs/bcrypt-win32-arm64-msvc': 1.10.7 - '@node-rs/bcrypt-win32-ia32-msvc': 1.10.7 - '@node-rs/bcrypt-win32-x64-msvc': 1.10.7 - '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -8183,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 @@ -8760,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 From bad9446d80b27a54507fa168f38a26ae474d98ea Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 24 Jun 2026 11:27:59 +0800 Subject: [PATCH 30/37] feat(server): persist bearer token and add rotate-token command - persist the server bearer token in /server.token (0600) and reuse it across restarts instead of per-start server-.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 --- apps/kimi-code/src/cli/sub/server/daemon.ts | 62 ++++++- apps/kimi-code/src/cli/sub/server/index.ts | 3 + apps/kimi-code/src/cli/sub/server/kill.ts | 8 +- apps/kimi-code/src/cli/sub/server/networks.ts | 84 +++++++++ apps/kimi-code/src/cli/sub/server/ps.ts | 6 +- .../src/cli/sub/server/rotate-token.ts | 32 ++++ apps/kimi-code/src/cli/sub/server/run.ts | 146 +++++++++------ apps/kimi-code/src/cli/sub/server/shared.ts | 27 +-- apps/kimi-code/test/cli/server/server.test.ts | 171 ++++++++++++++++-- packages/server/SECURITY.md | 43 +++-- packages/server/src/index.ts | 1 + .../src/services/auth/authTokenService.ts | 8 +- .../src/services/auth/persistentToken.ts | 79 ++++++++ .../server/src/services/auth/tokenStore.ts | 72 ++++++-- packages/server/src/start.ts | 49 ++--- packages/server/test/auth-wiring.e2e.test.ts | 11 +- packages/server/test/authTokenService.test.ts | 2 +- .../server/test/host-exposure.e2e.test.ts | 37 ++-- packages/server/test/services-auth.test.ts | 71 ++++++-- 19 files changed, 726 insertions(+), 186 deletions(-) create mode 100644 apps/kimi-code/src/cli/sub/server/networks.ts create mode 100644 apps/kimi-code/src/cli/sub/server/rotate-token.ts create mode 100644 packages/server/src/services/auth/persistentToken.ts diff --git a/apps/kimi-code/src/cli/sub/server/daemon.ts b/apps/kimi-code/src/cli/sub/server/daemon.ts index 6cd46d21c4..ad60ce7853 100644 --- a/apps/kimi-code/src/cli/sub/server/daemon.ts +++ b/apps/kimi-code/src/cli/sub/server/daemon.ts @@ -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'; @@ -196,7 +196,7 @@ interface SpawnDaemonChildOptions { idleGraceMs?: number; } -export function spawnDaemonChild(options: SpawnDaemonChildOptions): void { +export function spawnDaemonChild(options: SpawnDaemonChildOptions): ChildProcess { const program = resolveDaemonProgram(); const logPath = daemonLogPath(); const logDir = dirname(logPath); @@ -257,6 +257,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); @@ -293,7 +294,7 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise { + 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) { @@ -314,11 +330,45 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise line.length > 0); + return lines.slice(-maxLines).join('\n'); + } catch { + return ''; + } +} diff --git a/apps/kimi-code/src/cli/sub/server/index.ts b/apps/kimi-code/src/cli/sub/server/index.ts index c17a61d18d..b8e9a5ffa7 100644 --- a/apps/kimi-code/src/cli/sub/server/index.ts +++ b/apps/kimi-code/src/cli/sub/server/index.ts @@ -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 diff --git a/apps/kimi-code/src/cli/sub/server/kill.ts b/apps/kimi-code/src/cli/sub/server/kill.ts index 16fbbedf75..71b4f2e448 100644 --- a/apps/kimi-code/src/cli/sub/server/kill.ts +++ b/apps/kimi-code/src/cli/sub/server/kill.ts @@ -35,8 +35,8 @@ const POLL_INTERVAL_MS = 100; export interface KillCommandDeps { getLiveLock(): LockContents | undefined; requestShutdown(origin: string, token: string | undefined): Promise; - /** Best-effort read of the per-start bearer token for `pid`; undefined on miss. */ - resolveToken(pid: number): string | undefined; + /** 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; @@ -73,7 +73,7 @@ export async function handleKillCommand(deps: KillCommandDeps): Promise { // 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(pid); + const token = deps.resolveToken(); await deps.requestShutdown(origin, token).catch(() => {}); // 2. PID path — SIGTERM, wait, then SIGKILL. @@ -155,7 +155,7 @@ export async function requestShutdownViaApi( const DEFAULT_KILL_DEPS: KillCommandDeps = { getLiveLock, requestShutdown: requestShutdownViaApi, - resolveToken: (pid) => tryResolveServerToken(getDataDir(), pid), + resolveToken: () => tryResolveServerToken(getDataDir()), signalPid, pidAlive, sleep: (ms) => diff --git a/apps/kimi-code/src/cli/sub/server/networks.ts b/apps/kimi-code/src/cli/sub/server/networks.ts new file mode 100644 index 0000000000..39ca7d084f --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/networks.ts @@ -0,0 +1,84 @@ +/** + * Enumerate this machine's non-loopback network interface addresses, used to + * print `Network: http://:/` 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(); + 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; +} diff --git a/apps/kimi-code/src/cli/sub/server/ps.ts b/apps/kimi-code/src/cli/sub/server/ps.ts index 853fde5f90..6e17d71c05 100644 --- a/apps/kimi-code/src/cli/sub/server/ps.ts +++ b/apps/kimi-code/src/cli/sub/server/ps.ts @@ -65,9 +65,9 @@ async function handlePsCommand(opts: { json?: boolean }): Promise { } // The `/api/v1/connections` route is gated by bearer auth (M5.1). Read the - // per-start token the server wrote at boot; a clear error here means the - // server is an older build or the token file was removed. - const token = resolveServerToken(getDataDir(), lock.pid); + // 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) { diff --git a/apps/kimi-code/src/cli/sub/server/rotate-token.ts b/apps/kimi-code/src/cli/sub/server/rotate-token.ts new file mode 100644 index 0000000000..b9ce831d47 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/rotate-token.ts @@ -0,0 +1,32 @@ +/** + * `kimi server rotate-token` — generate a new persistent server token. + * + * Rewrites `/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 { rotateServerToken } from '@moonshot-ai/server'; +import type { Command } from 'commander'; + +import { getDataDir } from '#/utils/paths'; + +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(`New server token: ${token}\n`); + process.stdout.write( + 'The previous token is now invalid. A running server picks up the new token automatically.\n', + ); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} diff --git a/apps/kimi-code/src/cli/sub/server/run.ts b/apps/kimi-code/src/cli/sub/server/run.ts index 2bc96dee83..b6215f7d81 100644 --- a/apps/kimi-code/src/cli/sub/server/run.ts +++ b/apps/kimi-code/src/cli/sub/server/run.ts @@ -15,7 +15,7 @@ import { join } from 'node:path'; import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry'; -import { classify, getLiveLock, startServer, type RunningServer } from '@moonshot-ai/server'; +import { classify, startServer, type RunningServer } from '@moonshot-ai/server'; import chalk from 'chalk'; import { Option, type Command } from 'commander'; @@ -28,6 +28,7 @@ import { getDataDir } from '#/utils/paths'; import { initializeServerTelemetry } from '../../telemetry'; import { createKimiCodeHostIdentity, getHostPackageRoot, getVersion } from '../../version'; import { ensureDaemon } from './daemon'; +import { formatHostForUrl, listNetworkAddresses, type NetworkAddress } from './networks'; import { DEFAULT_FOREGROUND_LOG_LEVEL, DEFAULT_SERVER_HOST, @@ -62,12 +63,18 @@ export interface RunCommandDeps { ) => Promise; openUrl(url: string): void; /** - * Best-effort read of the running server's per-start bearer token. When it - * returns a token, the opened Web UI URL carries it in the `#token=` fragment - * (M5.5). Optional so callers/tests that don't supply it simply open the - * plain origin. + * 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; stderr: Pick; } @@ -94,12 +101,12 @@ export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }) ) .option( '--host ', - `Bind host (default ${DEFAULT_SERVER_HOST}). Use 0.0.0.0 to listen on all interfaces (requires a password + --insecure-no-tls unless behind a TLS proxy).`, + `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 (with a password) to bind beyond 127.0.0.1; use a tunnel or reverse proxy in production.', + '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( @@ -162,40 +169,37 @@ export async function handleRunCommand( return; } const startedAt = Date.now(); - // Open the Web UI, appending the bearer token in the URL fragment when one - // can be resolved (M5.5). Falls back to the plain origin when the token is - // unavailable (older build, missing lock, etc.). - const openWebUrl = (origin: string): void => { + // 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 = (origin: string): void => { + const readyMs = Date.now() - startedAt; const token = deps.resolveToken?.(); - deps.openUrl(token !== undefined ? buildWebUrl(origin, token) : origin); + deps.stdout.write( + parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL + ? formatReadyBanner(origin, readyMs, parsed.host, { + token, + networkAddresses: deps.networkAddresses, + }) + : formatReadyLine(origin, token), + ); + 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, parsed.host) - : `Kimi server: ${origin}\n`, - ); - if (opts.open === true) { - openWebUrl(origin); - } - }, - }); + await run(parsed, { onReady: writeReady }); return; } const { origin } = await deps.startServerBackground(parsed); - const readyMs = Date.now() - startedAt; - deps.stdout.write( - parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL - ? formatReadyBanner(origin, readyMs, parsed.host) - : `Kimi server: ${origin}\n`, - ); - if (opts.open === true) { - openWebUrl(origin); - } + writeReady(origin); +} + +function formatReadyLine(origin: string, token: string | undefined): string { + return token === undefined + ? `Kimi server: ${origin}\n` + : `Kimi server: ${origin}\nToken: ${token}\n`; } /** @@ -379,13 +383,26 @@ export function resolveServerWebAssetsDir( return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR); } -function formatReadyBanner(origin: string, readyMs: number, host: string): 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, + readyMs: number, + 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 url = (text: string): string => chalk.hex(darkColors.accent)(text); + const tokenColor = (text: string): string => chalk.bold.hex(darkColors.warning)(text); const width = READY_PANEL_WIDTH; const innerWidth = width - 4; const pad = ' '; @@ -394,16 +411,38 @@ function formatReadyBanner(origin: string, readyMs: number, host: string): strin const logoWidth = Math.max(...logo.map((row) => visibleWidth(row))); const gap = ' '; const textWidth = innerWidth - logoWidth - gap.length; - // Bind class drives the Network line: loopback stays "local only"; a - // non-loopback bind prints the tier plus a reachability / hardening hint so - // the operator knows the server is exposed beyond this host. - const bindClass = classify(host); - const networkText = - bindClass === 'loopback' - ? 'local only' - : bindClass === 'lan' - ? 'LAN — reachable from your local network' - : 'public — reachable from the internet; use a tunnel or TLS proxy'; + + const port = new URL(origin).port; + const isWildcard = host === '' || host === '0.0.0.0' || host === '::'; + + // URL/Network lines. A wildcard bind gets a Vite-style listing (Local + one + // Network line per interface); loopback stays "local only"; a specific + // non-loopback bind shows its tier plus a reachability / hardening hint. + const networkLines: string[] = []; + if (isWildcard) { + networkLines.push(label('Local: ') + url(`http://localhost:${port}/`)); + const addrs = opts.networkAddresses ?? listNetworkAddresses(); + for (const addr of addrs) { + networkLines.push( + label('Network: ') + + url(`http://${formatHostForUrl(addr.address, addr.family)}:${port}/`), + ); + } + if (addrs.length === 0) { + networkLines.push(label('Network: ') + muted('no non-loopback interfaces found')); + } + } else { + const bindClass = classify(host); + networkLines.push(label('URL: ') + url(displayOrigin(origin))); + const networkText = + bindClass === 'loopback' + ? 'local only' + : bindClass === 'lan' + ? 'LAN — reachable from your local network' + : 'public — reachable from the internet; use a tunnel or TLS proxy'; + networkLines.push(label('Network: ') + muted(networkText)); + } + const headerLines = [ primary(logo[0].padEnd(logoWidth)) + gap + @@ -413,8 +452,8 @@ function formatReadyBanner(origin: string, readyMs: number, host: string): strin truncateToWidth(dim('Local web UI is available from this machine.'), textWidth, '…'), ]; const infoLines = [ - label('URL: ') + url, - label('Network: ') + muted(networkText), + ...networkLines, + ...(opts.token !== undefined ? [label('Token: ') + tokenColor(opts.token)] : []), 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`), @@ -449,13 +488,10 @@ const DEFAULT_RUN_COMMAND_DEPS: RunCommandDeps = { startServerForeground, openUrl: defaultOpenUrl, resolveToken: () => { - // The background daemon's pid lives in the lock; the foreground server is - // this process. Either way, `/server-.token` is the file the - // server wrote at boot (M5.1). Best-effort: a missing/older server yields - // undefined and the caller opens the plain origin. - const lock = getLiveLock(); - const pid = lock?.pid ?? process.pid; - return tryResolveServerToken(getDataDir(), pid); + // Read the persistent `/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, diff --git a/apps/kimi-code/src/cli/sub/server/shared.ts b/apps/kimi-code/src/cli/sub/server/shared.ts index ba3ee119f6..76287d57c4 100644 --- a/apps/kimi-code/src/cli/sub/server/shared.ts +++ b/apps/kimi-code/src/cli/sub/server/shared.ts @@ -14,6 +14,9 @@ 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'; @@ -194,33 +197,33 @@ export async function ensureServerWebReady(origin: string): Promise { } /** - * Read the per-start bearer token for a running server. + * Read the persistent bearer token for the server. * - * The server writes `/server-.token` (0600) at boot (ROADMAP - * M5.1); CLI commands that hit a gated REST route read it back here and send - * it as `Authorization: Bearer `. `homeDir` is the CLI's own - * KIMI_CODE_HOME resolution (`getDataDir()`); `pid` comes from the lock file - * (`LockContents.pid`). + * The server writes `/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 `. `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 is not running, or an older build that predates token auth. + * 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, pid: number): string { - const tokenPath = join(homeDir, `server-${String(pid)}.token`); +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}; is the server running and on a compatible version?`, + `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, pid: number): string | undefined { +export function tryResolveServerToken(homeDir: string): string | undefined { try { - return resolveServerToken(homeDir, pid); + return resolveServerToken(homeDir); } catch { return undefined; } diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts index 39e698bedd..8927cab766 100644 --- a/apps/kimi-code/test/cli/server/server.test.ts +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -9,7 +9,7 @@ */ import type { ChildProcess } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync } 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', () => { @@ -682,7 +682,7 @@ describe('--insecure-no-tls threading (M6.3)', () => { }); describe('ready banner reflects the bind class (M6.3)', () => { - it('labels a 0.0.0.0 bind as public with a reachability hint', async () => { + 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 = ''; @@ -690,6 +690,11 @@ describe('ready banner reflects the bind class (M6.3)', () => { { 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) { @@ -702,12 +707,17 @@ describe('ready banner reflects the bind class (M6.3)', () => { ); const plain = stripAnsi(stdout); - expect(plain).toContain('public'); - expect(plain).toContain('reachable from the internet'); + expect(plain).toContain('Local:'); + expect(plain).toContain('http://localhost:58627/'); + expect(plain).toContain('Network:'); + expect(plain).toContain('http://192.168.98.66:58627/'); + expect(plain).toContain('http://10.8.12.216:58627/'); + expect(plain).toContain('Token:'); + expect(plain).toContain('tok-xyz'); expect(plain).not.toContain('local only'); }); - it('labels a 127.0.0.1 bind as local only', async () => { + it('labels a 127.0.0.1 bind as local only and prints the token', async () => { const { handleRunCommand } = await import('#/cli/sub/server/run'); let stdout = ''; @@ -715,6 +725,7 @@ describe('ready banner reflects the bind class (M6.3)', () => { { 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) { @@ -726,7 +737,12 @@ describe('ready banner reflects the bind class (M6.3)', () => { }, ); - expect(stripAnsi(stdout)).toContain('local only'); + const plain = stripAnsi(stdout); + expect(plain).toContain('local only'); + expect(plain).toContain('URL:'); + expect(plain).toContain('http://127.0.0.1:58627/'); + expect(plain).toContain('Token:'); + expect(plain).toContain('tok-loop'); }); }); @@ -867,6 +883,68 @@ describe('spawnDaemonChild', () => { }); }); +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', () => { beforeEach(() => { vi.useFakeTimers(); @@ -1085,21 +1163,21 @@ describe('resolveServerToken', () => { rmSync(dir, { recursive: true, force: true }); }); - it('reads the token from /server-.token', async () => { + it('reads the token from /server.token', async () => { const { resolveServerToken } = await import('#/cli/sub/server/shared'); - writeFileSync(join(dir, 'server-42.token'), 'secret-token\n'); - expect(resolveServerToken(dir, 42)).toBe('secret-token'); + 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-7.token'), ' tok \n'); - expect(resolveServerToken(dir, 7)).toBe('tok'); + 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, 99)).toThrow(/unable to read server token/); + expect(() => resolveServerToken(dir)).toThrow(/unable to read server token/); }); }); @@ -1203,5 +1281,72 @@ describe('`kimi web` / `server run --open` token fragment (M5.5)', () => { }); }); +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); + }); +}); + +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; diff --git a/packages/server/SECURITY.md b/packages/server/SECURITY.md index b82adba432..77e135db52 100644 --- a/packages/server/SECURITY.md +++ b/packages/server/SECURITY.md @@ -10,15 +10,16 @@ The server classifies the bind host into one of three tiers | 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) | Per-start 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):** mandatory password, TLS or explicit opt-out, auth-failure rate limiting, dangerous endpoints disabled, security headers | +| 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 (password + TLS + -rate-limit + endpoint downgrade + security headers). The tier label only changes the -banner and the automatic Host allowlist entry. Treat LAN exposure with the same care -as public exposure. +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. @@ -29,9 +30,12 @@ kimi server run # or: kimi web ``` - Binds `127.0.0.1:58627` by default (`--host` / `--port` to override). -- Generates a per-start bearer token (`crypto.randomBytes(32)`, base64url) kept only - in memory and written to `/server-.token` with mode `0600` - (parent directory `0700`). The file is deleted when the server exits. +- Uses a persistent bearer token (`crypto.randomBytes(32)`, base64url) generated + once on first boot and written to `/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 ` on every REST/WebSocket call — no setup required. - Only loopback is reachable; nothing is exposed to the network. @@ -39,15 +43,15 @@ kimi server run # or: kimi web ## LAN deployment Bind a specific LAN interface. Because the hardening gate is `bindClass !== 'loopback'`, -a LAN bind requires the same credentials as a public bind: +a LAN bind gets the same hardening stack as a public bind: ``` -export KIMI_CODE_PASSWORD='a long random password' kimi server run --host 192.168.1.10 --insecure-no-tls ``` -- `KIMI_CODE_PASSWORD` is **required**; the server refuses to start without it on any - non-loopback bind. +- Authentication is the persistent bearer token printed in the startup banner; send it + as `Authorization: Bearer `. `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 @@ -62,11 +66,11 @@ server behind a TLS-terminating reverse proxy (or a tunnel); do not terminate TL process. ``` -export KIMI_CODE_PASSWORD='a long random password' kimi server run --host 0.0.0.0 --insecure-no-tls ``` -- `KIMI_CODE_PASSWORD` is mandatory. `--insecure-no-tls` is mandatory here because +- 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` @@ -125,9 +129,12 @@ the remote reachability and TLS. ## Credential management -- **Token** — generated fresh per start, held in memory, written to - `/server-.token` (`0600`, directory `0700`), deleted on exit. - The CLI reads it automatically; treat the file as a secret. +- **Token** — a persistent bearer token generated once on first boot, held in + memory and written to `/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 `. diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index fb7405752a..f312ac6572 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -4,6 +4,7 @@ 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, diff --git a/packages/server/src/services/auth/authTokenService.ts b/packages/server/src/services/auth/authTokenService.ts index 27da5b4831..0eea599845 100644 --- a/packages/server/src/services/auth/authTokenService.ts +++ b/packages/server/src/services/auth/authTokenService.ts @@ -1,8 +1,8 @@ /** * `IAuthTokenService` DI surface (ROADMAP M2.1). * - * Exposes the per-start bearer token plus a single validity check that accepts - * EITHER the per-start token (constant-time, via `TokenStore`) OR a verified + * 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. @@ -19,11 +19,11 @@ import type { TokenStore } from './tokenStore'; export interface IAuthTokenService { readonly _serviceBrand: undefined; - /** The per-start bearer token (held in memory; never re-read from disk). */ + /** The persistent bearer token (re-read from disk when its mtime changes). */ getToken(): string; /** - * True when `candidate` matches the per-start token OR verifies against the + * 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. */ diff --git a/packages/server/src/services/auth/persistentToken.ts b/packages/server/src/services/auth/persistentToken.ts new file mode 100644 index 0000000000..2846dbf740 --- /dev/null +++ b/packages/server/src/services/auth/persistentToken.ts @@ -0,0 +1,79 @@ +/** + * Persistent server bearer token. + * + * The token lives at `/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 `/server.token` (0600). */ +export async function writeServerToken(homeDir: string, token: string): Promise { + 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 { + 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 { + 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 { + const token = generateServerToken(); + await writeServerToken(homeDir, token); + return token; +} diff --git a/packages/server/src/services/auth/tokenStore.ts b/packages/server/src/services/auth/tokenStore.ts index 9ebe8a419b..e12d860b02 100644 --- a/packages/server/src/services/auth/tokenStore.ts +++ b/packages/server/src/services/auth/tokenStore.ts @@ -1,8 +1,7 @@ -import { randomBytes, timingSafeEqual } from 'node:crypto'; -import { rm } from 'node:fs/promises'; -import { join } from 'node:path'; +import { timingSafeEqual } from 'node:crypto'; +import { readFileSync, statSync } from 'node:fs'; -import { writePrivateFile } from './privateFiles'; +import { loadOrCreateServerToken, serverTokenPath } from './persistentToken'; export interface TokenStore { readonly tokenPath: string; @@ -11,22 +10,63 @@ export interface TokenStore { dispose(): Promise; } -export async function createTokenStore( - homeDir: string, - pid: number, -): Promise { - const token = randomBytes(32).toString('base64url'); - const tokenPath = join(homeDir, `server-${pid}.token`); - const tokenBuf = Buffer.from(token); +/** + * Persistent token store over `/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 { + 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, + }; - await writePrivateFile(tokenPath, token); + const currentToken = (): string => { + let st: ReturnType; + 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(): string { - return token; - }, + getToken: currentToken, isValid(candidate: string): boolean { + const tokenBuf = Buffer.from(currentToken()); const candidateBuf = Buffer.from(candidate); if (candidateBuf.length !== tokenBuf.length) { return false; @@ -34,7 +74,7 @@ export async function createTokenStore( return timingSafeEqual(candidateBuf, tokenBuf); }, async dispose(): Promise { - await rm(tokenPath, { force: true }); + // Persistent token: intentionally left on disk so it survives restarts. }, }; } diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts index d009435208..9adc10d836 100644 --- a/packages/server/src/start.ts +++ b/packages/server/src/start.ts @@ -200,22 +200,26 @@ export async function startServer(opts: ServerStartOptions): Promise/server-.token` at 0600) - // 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 token file is disposed - // (best-effort) on shutdown and on every boot-error path below. - const tokenStore = await createTokenStore(envService.homeDir, process.pid); + // async-built `TokenStore` over the persistent `/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 (a) a user - // password is configured and (b) the operator explicitly acknowledged that - // TLS is terminated elsewhere (`insecureNoTls`). 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 tear down the token - // file and release the lock so the operator can retry cleanly. + // 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 => { @@ -227,14 +231,6 @@ export async function startServer(opts: ServerStartOptions): Promise/server-.token` (0600) plus the HTTP/WS auth hooks. The token + * the REAL `defaultAuth` is built: a persistent token written to + * `/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. @@ -45,7 +45,7 @@ afterEach(async () => { }); function tokenPath(): string { - return join(bridgeHome, `server-${String(process.pid)}.token`); + return join(bridgeHome, 'server.token'); } function readToken(): string { @@ -163,7 +163,7 @@ function expectRejected(url: string): Promise { } describe('production auth wiring (M5.1)', () => { - it('writes a 0600 token file at boot and removes it on close', async () => { + it('writes a 0600 token file at boot and keeps it on close (persistent)', async () => { const r = await bootReal(); const p = tokenPath(); @@ -174,7 +174,8 @@ describe('production auth wiring (M5.1)', () => { expect(token.length).toBeGreaterThan(0); await r.close(); - expect(() => statSync(p)).toThrow(); + // 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 () => { diff --git a/packages/server/test/authTokenService.test.ts b/packages/server/test/authTokenService.test.ts index 8577584c57..e7a7ca1ffb 100644 --- a/packages/server/test/authTokenService.test.ts +++ b/packages/server/test/authTokenService.test.ts @@ -46,7 +46,7 @@ describe('createAuthTokenService', () => { beforeEach(async () => { homeDir = mkdtempSync(join(tmpdir(), 'kimi-auth-token-store-')); - store = await createTokenStore(homeDir, process.pid); + store = await createTokenStore(homeDir); }); afterEach(async () => { diff --git a/packages/server/test/host-exposure.e2e.test.ts b/packages/server/test/host-exposure.e2e.test.ts index 02d19702f6..0acc2ca1c7 100644 --- a/packages/server/test/host-exposure.e2e.test.ts +++ b/packages/server/test/host-exposure.e2e.test.ts @@ -3,8 +3,9 @@ * * 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 password → refuse; password-but-no-TLS → - * refuse; password + `insecureNoTls` → boot + warn logged). + * - 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 ` → 200 via * `verifyPassword`; wrong/missing credentials → 401). * - M6.4 auth-failure rate limit (N bad tokens → 429 on the (N+1)th). @@ -71,21 +72,29 @@ afterEach(async () => { }); describe('non-loopback bind gate (M6.3)', () => { - it('refuses to bind 0.0.0.0 without a password', async () => { + 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(); - await expect( - startServer({ - serviceOverrides: [fixedTokenAuth()], - host: '0.0.0.0', - port: 0, - lockPath, - insecureNoTls: true, - logger: pino({ level: 'silent' }), - coreProcessOptions: { homeDir }, - }), - ).rejects.toThrow(/without a password/); + 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 () => { diff --git a/packages/server/test/services-auth.test.ts b/packages/server/test/services-auth.test.ts index 3fba8ffb5d..1221a2fa2f 100644 --- a/packages/server/test/services-auth.test.ts +++ b/packages/server/test/services-auth.test.ts @@ -2,6 +2,7 @@ import { chmodSync, existsSync, mkdtempSync, + readFileSync, rmSync, statSync, writeFileSync, @@ -16,6 +17,7 @@ import { 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'; @@ -69,46 +71,91 @@ describe('privateFiles', () => { describe('tokenStore', () => { it('returns the same token from repeated getToken() calls', async () => { - const store = await createTokenStore(join(tmpDir, 'home'), 111); + const store = await createTokenStore(join(tmpDir, 'home')); expect(store.getToken()).toBe(store.getToken()); await store.dispose(); }); - it('produces different tokens for different stores', async () => { - const a = await createTokenStore(join(tmpDir, 'home-a'), 111); - const b = await createTokenStore(join(tmpDir, 'home-b'), 222); + 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('writes the token file with mode 0600 at server-.token', async () => { + it('reuses the same persistent token across stores in one home dir', async () => { const home = join(tmpDir, 'home'); - const store = await createTokenStore(home, 4242); - expect(store.tokenPath).toBe(join(home, 'server-4242.token')); + 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'), 333); + 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'), 334); + 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() removes the token file', async () => { - const store = await createTokenStore(join(tmpDir, 'home'), 555); + 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(false); + 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); }); }); From faab9da8016a0fc3fe7f693194a1233aa10b9b46 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 24 Jun 2026 14:48:56 +0800 Subject: [PATCH 31/37] 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. --- .../src/cli/sub/server/access-urls.ts | 73 +++++++++++ .../src/cli/sub/server/rotate-token.ts | 23 +++- apps/kimi-code/src/cli/sub/server/run.ts | 105 ++++----------- apps/kimi-code/test/cli/server/server.test.ts | 120 ++++++++++++++---- 4 files changed, 213 insertions(+), 108 deletions(-) create mode 100644 apps/kimi-code/src/cli/sub/server/access-urls.ts diff --git a/apps/kimi-code/src/cli/sub/server/access-urls.ts b/apps/kimi-code/src/cli/sub/server/access-urls.ts new file mode 100644 index 0000000000..61897b425e --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/access-urls.ts @@ -0,0 +1,73 @@ +/** + * 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=`; 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}`; +} + +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 === '::'; +} + +function isLoopback(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 (isLoopback(host)) { + return [{ label: 'Local: ', url: buildOpenableUrl(hostOrigin(host, port), token) }]; + } + return [{ label: 'URL: ', url: buildOpenableUrl(hostOrigin(host, port), token) }]; +} diff --git a/apps/kimi-code/src/cli/sub/server/rotate-token.ts b/apps/kimi-code/src/cli/sub/server/rotate-token.ts index b9ce831d47..58f6765ee6 100644 --- a/apps/kimi-code/src/cli/sub/server/rotate-token.ts +++ b/apps/kimi-code/src/cli/sub/server/rotate-token.ts @@ -6,11 +6,16 @@ * auth check, so rotation takes effect without a restart. */ -import { rotateServerToken } from '@moonshot-ai/server'; +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 } from './access-urls'; +import { DEFAULT_SERVER_HOST } from './shared'; + export function registerRotateTokenCommand(server: Command): void { server .command('rotate-token') @@ -20,10 +25,24 @@ export function registerRotateTokenCommand(server: Command): void { .action(async () => { try { const token = await rotateServerToken(getDataDir()); - process.stdout.write(`New server token: ${token}\n`); + process.stdout.write( + `${chalk.bold('New server token:')} ${chalk.bold.hex(darkColors.warning)(token)}\n`, + ); process.stdout.write( 'The previous token is now invalid. A running server picks up the new token automatically.\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; + process.stdout.write('\n'); + for (const { label, url } of accessUrlLines(host, lock.port, token)) { + process.stdout.write(` ${chalk.dim(label)}${chalk.hex(darkColors.accent)(url)}\n`); + } + } } catch (error) { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exit(1); diff --git a/apps/kimi-code/src/cli/sub/server/run.ts b/apps/kimi-code/src/cli/sub/server/run.ts index b6215f7d81..fbe8d05561 100644 --- a/apps/kimi-code/src/cli/sub/server/run.ts +++ b/apps/kimi-code/src/cli/sub/server/run.ts @@ -13,9 +13,8 @@ import { join } from 'node:path'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry'; -import { classify, startServer, type RunningServer } from '@moonshot-ai/server'; +import { startServer, type RunningServer } from '@moonshot-ai/server'; import chalk from 'chalk'; import { Option, type Command } from 'commander'; @@ -27,8 +26,9 @@ import { getDataDir } from '#/utils/paths'; import { initializeServerTelemetry } from '../../telemetry'; import { createKimiCodeHostIdentity, getHostPackageRoot, getVersion } from '../../version'; +import { accessUrlLines, buildOpenableUrl } from './access-urls'; import { ensureDaemon } from './daemon'; -import { formatHostForUrl, listNetworkAddresses, type NetworkAddress } from './networks'; +import { type NetworkAddress } from './networks'; import { DEFAULT_FOREGROUND_LOG_LEVEL, DEFAULT_SERVER_HOST, @@ -41,7 +41,6 @@ import { } from './shared'; const WEB_ASSETS_DIR = 'dist-web'; -const READY_PANEL_WIDTH = 72; export interface RunCliOptions extends ServerCliOptions { open?: boolean; @@ -87,8 +86,7 @@ export interface RunCommandDeps { * logged by proxies. The Web UI reads it from `location.hash` after load. */ export function buildWebUrl(origin: string, token: string): string { - const base = origin.endsWith('/') ? origin : `${origin}/`; - return `${base}#token=${token}`; + return buildOpenableUrl(origin, token); } /** Build the `run` subcommand, mounted under a parent (`server` or top-level). */ @@ -197,9 +195,7 @@ export async function handleRunCommand( } function formatReadyLine(origin: string, token: string | undefined): string { - return token === undefined - ? `Kimi server: ${origin}\n` - : `Kimi server: ${origin}\nToken: ${token}\n`; + return `Kimi server: ${buildOpenableUrl(origin, token)}\n`; } /** @@ -403,86 +399,37 @@ function formatReadyBanner( const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); const url = (text: string): string => chalk.hex(darkColors.accent)(text); const tokenColor = (text: string): string => chalk.bold.hex(darkColors.warning)(text); - const width = READY_PANEL_WIDTH; - const innerWidth = width - 4; - const pad = ' '; + 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 port = new URL(origin).port; - const isWildcard = host === '' || host === '0.0.0.0' || host === '::'; - - // URL/Network lines. A wildcard bind gets a Vite-style listing (Local + one - // Network line per interface); loopback stays "local only"; a specific - // non-loopback bind shows its tier plus a reachability / hardening hint. - const networkLines: string[] = []; - if (isWildcard) { - networkLines.push(label('Local: ') + url(`http://localhost:${port}/`)); - const addrs = opts.networkAddresses ?? listNetworkAddresses(); - for (const addr of addrs) { - networkLines.push( - label('Network: ') + - url(`http://${formatHostForUrl(addr.address, addr.family)}:${port}/`), - ); - } - if (addrs.length === 0) { - networkLines.push(label('Network: ') + muted('no non-loopback interfaces found')); - } - } else { - const bindClass = classify(host); - networkLines.push(label('URL: ') + url(displayOrigin(origin))); - const networkText = - bindClass === 'loopback' - ? 'local only' - : bindClass === 'lan' - ? 'LAN — reachable from your local network' - : 'public — reachable from the internet; use a tunnel or TLS proxy'; - networkLines.push(label('Network: ') + muted(networkText)); - } - - 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 = [ - ...networkLines, - ...(opts.token !== undefined ? [label('Token: ') + tokenColor(opts.token)] : []), - 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')}`, + ` ${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('│')); + for (const { label: text, url: href } of accessUrlLines( + host, + port, + opts.token, + opts.networkAddresses, + )) { + lines.push(` ${label(text)}${url(href)}`); } - - lines.push(primary('│') + ' '.repeat(width - 2) + primary('│')); - lines.push(primary('╰' + '─'.repeat(width - 2) + '╯')); + if (opts.token !== undefined) { + lines.push(` ${label('Token: ')}${tokenColor(opts.token)}`); + } + lines.push(` ${label('Logs: ')}${muted('off')}${dim(' use --log-level info to enable')}`); + lines.push(` ${label('Stop: ')}${muted('kimi server kill')}`); + lines.push(` ${label('Ready: ')}${muted(`${String(Math.max(0, readyMs))} ms`)}`); + lines.push(` ${label('Version: ')}${muted(getVersion())}`); lines.push(''); return lines.join('\n'); } -function displayOrigin(origin: string): string { - return origin.endsWith('/') ? origin : `${origin}/`; -} - const DEFAULT_RUN_COMMAND_DEPS: RunCommandDeps = { startServerBackground, startServerForeground, diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts index 8927cab766..f404845b0c 100644 --- a/apps/kimi-code/test/cli/server/server.test.ts +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -360,19 +360,21 @@ 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/'); - expect(plain).toContain('Network:'); - expect(plain).toContain('local only'); expect(plain).toContain('Logs:'); expect(plain).toContain('off'); expect(plain).toContain('Stop:'); expect(plain).toContain('kimi server kill'); + expect(plain).toContain('Ready:'); + expect(plain).toContain('Version:'); + // 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:'); }); @@ -410,8 +412,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')); }); }); @@ -706,18 +708,21 @@ describe('ready banner reflects the bind class (M6.3)', () => { }, ); - const plain = stripAnsi(stdout); - expect(plain).toContain('Local:'); - expect(plain).toContain('http://localhost:58627/'); - expect(plain).toContain('Network:'); - expect(plain).toContain('http://192.168.98.66:58627/'); - expect(plain).toContain('http://10.8.12.216:58627/'); - expect(plain).toContain('Token:'); - expect(plain).toContain('tok-xyz'); - expect(plain).not.toContain('local only'); - }); - - it('labels a 127.0.0.1 bind as local only and prints the token', async () => { + 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 = ''; @@ -737,12 +742,14 @@ describe('ready banner reflects the bind class (M6.3)', () => { }, ); - const plain = stripAnsi(stdout); - expect(plain).toContain('local only'); - expect(plain).toContain('URL:'); - expect(plain).toContain('http://127.0.0.1:58627/'); - expect(plain).toContain('Token:'); - expect(plain).toContain('tok-loop'); + 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('╭'); }); }); @@ -1247,6 +1254,33 @@ describe('buildWebUrl', () => { }); }); +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/' }]); + }); +}); + 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'); @@ -1319,6 +1353,38 @@ describe('`kimi server rotate-token`', () => { 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}`); + }); }); describe('formatHostForUrl', () => { From e3e65e16d300f24d90e1dc01da7844c17b784ee2 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 24 Jun 2026 17:58:04 +0800 Subject: [PATCH 32/37] feat(server): dim URL #token= fragment and de-highlight token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../src/cli/sub/server/access-urls.ts | 11 +++++++++++ .../src/cli/sub/server/rotate-token.ts | 18 ++++++++++++------ apps/kimi-code/src/cli/sub/server/run.ts | 17 +++++++++++++---- apps/kimi-code/test/cli/server/server.test.ts | 6 ++++++ 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/server/access-urls.ts b/apps/kimi-code/src/cli/sub/server/access-urls.ts index 61897b425e..55734c73c6 100644 --- a/apps/kimi-code/src/cli/sub/server/access-urls.ts +++ b/apps/kimi-code/src/cli/sub/server/access-urls.ts @@ -19,6 +19,17 @@ export function buildOpenableUrl(bareOrigin: string, token: string | undefined): 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; diff --git a/apps/kimi-code/src/cli/sub/server/rotate-token.ts b/apps/kimi-code/src/cli/sub/server/rotate-token.ts index 58f6765ee6..942eea0b73 100644 --- a/apps/kimi-code/src/cli/sub/server/rotate-token.ts +++ b/apps/kimi-code/src/cli/sub/server/rotate-token.ts @@ -13,7 +13,7 @@ import type { Command } from 'commander'; import { darkColors } from '#/tui/theme/colors'; import { getDataDir } from '#/utils/paths'; -import { accessUrlLines } from './access-urls'; +import { accessUrlLines, splitTokenFragment } from './access-urls'; import { DEFAULT_SERVER_HOST } from './shared'; export function registerRotateTokenCommand(server: Command): void { @@ -25,9 +25,9 @@ export function registerRotateTokenCommand(server: Command): void { .action(async () => { try { const token = await rotateServerToken(getDataDir()); - process.stdout.write( - `${chalk.bold('New server token:')} ${chalk.bold.hex(darkColors.warning)(token)}\n`, - ); + // Set the token off with whitespace rather than color so it is easy to + // spot without being highlighted. + process.stdout.write(`${chalk.bold('New server token:')} ${token}\n\n`); process.stdout.write( 'The previous token is now invalid. A running server picks up the new token automatically.\n', ); @@ -39,8 +39,14 @@ export function registerRotateTokenCommand(server: Command): void { if (lock !== undefined) { const host = lock.host ?? DEFAULT_SERVER_HOST; process.stdout.write('\n'); - for (const { label, url } of accessUrlLines(host, lock.port, token)) { - process.stdout.write(` ${chalk.dim(label)}${chalk.hex(darkColors.accent)(url)}\n`); + 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) { diff --git a/apps/kimi-code/src/cli/sub/server/run.ts b/apps/kimi-code/src/cli/sub/server/run.ts index fbe8d05561..9d64784f96 100644 --- a/apps/kimi-code/src/cli/sub/server/run.ts +++ b/apps/kimi-code/src/cli/sub/server/run.ts @@ -26,7 +26,7 @@ import { getDataDir } from '#/utils/paths'; import { initializeServerTelemetry } from '../../telemetry'; import { createKimiCodeHostIdentity, getHostPackageRoot, getVersion } from '../../version'; -import { accessUrlLines, buildOpenableUrl } from './access-urls'; +import { accessUrlLines, buildOpenableUrl, splitTokenFragment } from './access-urls'; import { ensureDaemon } from './daemon'; import { type NetworkAddress } from './networks'; import { @@ -398,7 +398,12 @@ function formatReadyBanner( const muted = (text: string): string => chalk.hex(darkColors.textMuted)(text); const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); const url = (text: string): string => chalk.hex(darkColors.accent)(text); - const tokenColor = (text: string): string => chalk.bold.hex(darkColors.warning)(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 @@ -417,10 +422,14 @@ function formatReadyBanner( opts.token, opts.networkAddresses, )) { - lines.push(` ${label(text)}${url(href)}`); + lines.push(` ${label(text)}${urlWithDimToken(href)}`); } if (opts.token !== undefined) { - lines.push(` ${label('Token: ')}${tokenColor(opts.token)}`); + // 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(` ${label('Logs: ')}${muted('off')}${dim(' use --log-level info to enable')}`); lines.push(` ${label('Stop: ')}${muted('kimi server kill')}`); diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts index f404845b0c..b796e6b86f 100644 --- a/apps/kimi-code/test/cli/server/server.test.ts +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -1279,6 +1279,12 @@ describe('accessUrlLines', () => { 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)', () => { From 9d14d8e27a0fd1a089443c0aa78ddb1080fc79ef Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 24 Jun 2026 20:27:49 +0800 Subject: [PATCH 33/37] 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 --- .../src/cli/sub/server/rotate-token.ts | 8 ++++---- apps/kimi-code/src/cli/sub/server/run.ts | 12 +++++------- apps/kimi-code/test/cli/server/server.test.ts | 18 ++++++++++++++++-- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/server/rotate-token.ts b/apps/kimi-code/src/cli/sub/server/rotate-token.ts index 942eea0b73..8f9a479701 100644 --- a/apps/kimi-code/src/cli/sub/server/rotate-token.ts +++ b/apps/kimi-code/src/cli/sub/server/rotate-token.ts @@ -25,20 +25,20 @@ export function registerRotateTokenCommand(server: Command): void { .action(async () => { try { const token = await rotateServerToken(getDataDir()); - // Set the token off with whitespace rather than color so it is easy to - // spot without being highlighted. - process.stdout.write(`${chalk.bold('New server token:')} ${token}\n\n`); 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; - process.stdout.write('\n'); 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); diff --git a/apps/kimi-code/src/cli/sub/server/run.ts b/apps/kimi-code/src/cli/sub/server/run.ts index 9d64784f96..684a664f13 100644 --- a/apps/kimi-code/src/cli/sub/server/run.ts +++ b/apps/kimi-code/src/cli/sub/server/run.ts @@ -166,16 +166,14 @@ 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 = (origin: string): void => { - const readyMs = Date.now() - startedAt; const token = deps.resolveToken?.(); deps.stdout.write( parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL - ? formatReadyBanner(origin, readyMs, parsed.host, { + ? formatReadyBanner(origin, parsed.host, { token, networkAddresses: deps.networkAddresses, }) @@ -388,7 +386,6 @@ interface FormatReadyBannerOptions { function formatReadyBanner( origin: string, - readyMs: number, host: string, opts: FormatReadyBannerOptions = {}, ): string { @@ -411,11 +408,12 @@ function formatReadyBanner( const logo = ['▐█▛█▛█▌', '▐█████▌'] as const; const lines: string[] = [ '', - ` ${primary(logo[0])} ${title('Kimi server ready')}`, + ` ${primary(logo[0])} ${title('Kimi server ready')} ${dim(getVersion())}`, ` ${primary(logo[1])} ${dim('Local web UI is available from this machine.')}`, '', ]; + // Access links. for (const { label: text, url: href } of accessUrlLines( host, port, @@ -431,10 +429,10 @@ function formatReadyBanner( lines.push(` ${label('Token: ')}${opts.token}`); lines.push(''); } + + // 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(` ${label('Ready: ')}${muted(`${String(Math.max(0, readyMs))} ms`)}`); - lines.push(` ${label('Version: ')}${muted(getVersion())}`); lines.push(''); return lines.join('\n'); } diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts index b796e6b86f..70be00955e 100644 --- a/apps/kimi-code/test/cli/server/server.test.ts +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -367,8 +367,11 @@ describe('`kimi server run` background start', () => { expect(plain).toContain('off'); expect(plain).toContain('Stop:'); expect(plain).toContain('kimi server kill'); - expect(plain).toContain('Ready:'); - expect(plain).toContain('Version:'); + // 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('╭'); @@ -377,6 +380,10 @@ describe('`kimi server run` background start', () => { 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 () => { @@ -1390,6 +1397,13 @@ describe('`kimi server rotate-token`', () => { 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}`), + ); }); }); From acd40fc0d54961127b70d14598a2dfc48e765709 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 24 Jun 2026 21:02:53 +0800 Subject: [PATCH 34/37] 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. --- .../src/cli/sub/server/access-urls.ts | 5 +- apps/kimi-code/src/cli/sub/server/daemon.ts | 20 +++++- apps/kimi-code/src/cli/sub/server/run.ts | 63 ++++++++++++++----- apps/kimi-code/test/cli/server/server.test.ts | 41 ++++++++++++ 4 files changed, 111 insertions(+), 18 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/server/access-urls.ts b/apps/kimi-code/src/cli/sub/server/access-urls.ts index 55734c73c6..0c6233fe8d 100644 --- a/apps/kimi-code/src/cli/sub/server/access-urls.ts +++ b/apps/kimi-code/src/cli/sub/server/access-urls.ts @@ -41,7 +41,8 @@ function isWildcard(host: string): boolean { return host === '' || host === '0.0.0.0' || host === '::'; } -function isLoopback(host: string): boolean { +/** 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'; } @@ -77,7 +78,7 @@ export function accessUrlLines( } return lines; } - if (isLoopback(host)) { + if (isLoopbackHost(host)) { return [{ label: 'Local: ', url: buildOpenableUrl(hostOrigin(host, port), token) }]; } return [{ label: 'URL: ', url: buildOpenableUrl(hostOrigin(host, port), token) }]; diff --git a/apps/kimi-code/src/cli/sub/server/daemon.ts b/apps/kimi-code/src/cli/sub/server/daemon.ts index ad60ce7853..fadf7adb9f 100644 --- a/apps/kimi-code/src/cli/sub/server/daemon.ts +++ b/apps/kimi-code/src/cli/sub/server/daemon.ts @@ -64,6 +64,12 @@ export interface EnsureDaemonOptions { 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). */ @@ -285,7 +291,12 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise; + 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, @@ -169,27 +182,46 @@ export async function handleRunCommand( // 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 = (origin: string): void => { + const writeReady = (result: { origin: string; reused?: boolean; host?: string }): void => { + const { origin } = result; + const host = result.host ?? parsed.host; const token = deps.resolveToken?.(); - deps.stdout.write( + 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, parsed.host, { + ? formatReadyBanner(origin, host, { token, networkAddresses: deps.networkAddresses, }) - : formatReadyLine(origin, token), - ); + : 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: writeReady }); + await run(parsed, { + onReady: (origin) => writeReady({ origin, reused: false, host: parsed.host }), + }); return; } - const { origin } = await deps.startServerBackground(parsed); - writeReady(origin); + const result = await deps.startServerBackground(parsed); + writeReady(result); +} + +function formatReuseNotice(origin: string): string { + return ( + `${chalk.yellow('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` + ); } function formatReadyLine(origin: string, token: string | undefined): string { @@ -204,8 +236,8 @@ function formatReadyLine(origin: string, token: string | undefined): string { */ export async function startServerBackground( options: ParsedServerOptions, -): Promise<{ origin: string }> { - const { origin } = await ensureDaemon({ +): Promise { + return ensureDaemon({ host: options.host, port: options.port, logLevel: options.logLevel, @@ -215,7 +247,6 @@ export async function startServerBackground( allowRemoteTerminals: options.allowRemoteTerminals, idleGraceMs: options.idleGraceMs, }); - return { origin }; } /** @@ -422,6 +453,10 @@ function formatReadyBanner( )) { 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. diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts index 70be00955e..0b9afb6713 100644 --- a/apps/kimi-code/test/cli/server/server.test.ts +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -336,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 = ''; @@ -363,6 +401,9 @@ describe('`kimi server run` background start', () => { expect(plain).toContain('Kimi server ready'); 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('use --host 0.0.0.0 to enable'); expect(plain).toContain('Logs:'); expect(plain).toContain('off'); expect(plain).toContain('Stop:'); From c7a4216add40a35b5a501e8eb8588208b6f68be8 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 24 Jun 2026 21:41:30 +0800 Subject: [PATCH 35/37] 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. --- apps/kimi-web/src/api/daemon/serverAuth.ts | 6 ++--- .../src/components/ServerAuthDialog.vue | 27 ++++++++++--------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/apps/kimi-web/src/api/daemon/serverAuth.ts b/apps/kimi-web/src/api/daemon/serverAuth.ts index b44ade8627..d4ef44e4f2 100644 --- a/apps/kimi-web/src/api/daemon/serverAuth.ts +++ b/apps/kimi-web/src/api/daemon/serverAuth.ts @@ -2,12 +2,12 @@ // 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 (per-start token, or the KIMI_CODE_PASSWORD password). -// The Web UI obtains that credential in one of two ways: +// 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 password the user types into the ServerAuthDialog modal. +// 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 diff --git a/apps/kimi-web/src/components/ServerAuthDialog.vue b/apps/kimi-web/src/components/ServerAuthDialog.vue index eeace69610..94cebac634 100644 --- a/apps/kimi-web/src/components/ServerAuthDialog.vue +++ b/apps/kimi-web/src/components/ServerAuthDialog.vue @@ -1,13 +1,14 @@ - + 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. -->