mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-20 14:16:22 +00:00
* 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.
229 lines
8.1 KiB
TypeScript
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()));
|
|
}
|