kimi-code/packages/server/test/helpers/serverHarness.ts
Haozhe 60dfb68a2d
feat(server): add bearer-token auth and safe host exposure (#1006)
* test(server): add API surface snapshot guardrail

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

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

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

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

* feat(server): add per-start tokenStore

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

* feat(server): add IAuthTokenService DI seam

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

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

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

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

* feat(server): add Host header allowlist middleware

* feat(server): add Origin/CORS middleware

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: repair CI for server auth and host exposure

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: resolve CI failures on web auth PR

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

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

The feat/web-auth branch adds GET /api/v1/sessions/{session_id}/warnings
(packages/server/src/routes/sessions.ts), so the API surface guardrail
snapshot needs to record the new documented v1 route.
2026-06-25 17:57:56 +08:00

229 lines
8.1 KiB
TypeScript

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