mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
feat(server): add bearer-token auth and safe host exposure (#1006)
* test(server): add API surface snapshot guardrail
Boot startServer on port 0 and snapshot the documented v1 route table derived from /openapi.json paths, plus the reachability of doc/meta endpoints (/healthz, /openapi.json, /asyncapi.json, /). Gives later auth/--host phases an intentional diff when routes change. M0 makes no production behavior change.
* test(server): add e2e server harness with token support
Add test/helpers/serverHarness.ts: boot() wraps startServer with an isolated lock + home dir and returns a handle (server, address, baseUrl, wsUrl, token, close) plus authedFetch/authedWs that carry Authorization: Bearer <token> (and the kimi-code.bearer.<token> WS subprotocol). serviceOverrides is the generic DI seam later phases use to inject a fixed-token auth service; IAuthTokenService is not referenced yet. closeAll() tears down every booted server and socket. M0 makes no production behavior change; typecheck-only gate.
* feat(server): add privateFiles 0600 atomic write/read utility
* feat(server): add per-start tokenStore
* feat(server): add env-based bcrypt password hash utility
* feat(server): add IAuthTokenService DI seam
* feat(server): add global onRequest auth hook with bypass + redaction
* fix(server): stop reflecting Host header in /asyncapi.json
* feat(server): add WS bearer subprotocol constant and parser
* feat(server): enforce bearer token auth on WS upgrade
* feat(server): add Host header allowlist middleware
* feat(server): add Origin/CORS middleware
* feat(server): wire Host/Origin checks into HTTP and WS
* feat(server): wire token auth, Host/Origin, and WS auth into start.ts
* fix(server): create lock file with 0600 permissions
* fix(server): suppress debug routes on non-loopback binds
* feat(kimi-code): read server token and send Authorization on CLI calls
* feat(kimi-code): inject server token into /web URL fragment
* feat(server): add bindClassify for loopback/lan/public classification
* feat(kimi-code): register --host flag and pass it through the daemon
* feat(server): require password and TLS opt-out on non-loopback binds
* feat(server): rate-limit repeated auth failures on non-loopback binds
* feat(server): disable shutdown and terminals on public binds by default
* feat(server): add security response headers on non-loopback binds
* test(server): cover LAN/public host-exposure hardening end to end
* docs(server): add deployment security and threat-model guide
* changeset: minor kimi-code for server auth and host exposure
* feat(kimi-web): add server bearer-token auth support
* fix: repair CI for server auth and host exposure
- Replace native @node-rs/bcrypt with pure-JS bcryptjs so the ESM CLI
bundle and the SEA native bundle both build without native-addon
require issues (node-rs/bcrypt broke the ESM smoke and the SEA
check-bundle allowlist).
- Remove dead cleanup references (stopSpinner, authLogoBlinkTimer) in
apps/kimi-web App.vue that failed vue-tsc.
- Fix lint: drop empty spread fallbacks in the e2e auth-header merge,
void the intentionally-async WS upgrade listener, add missing
assertions to satisfy jest/expect-expect, and convert a ternary
statement to if/else.
- Send the bearer token in the snapshot perf/smoke tests so they pass
under the new global auth hook.
- Refresh the pnpmDeps hash in flake.nix for the updated lockfile.
* feat(server): persist bearer token and add rotate-token command
- persist the server bearer token in <home>/server.token (0600) and reuse it across restarts instead of per-start server-<pid>.token
- add `kimi server rotate-token` to regenerate the token; the token store reloads on mtime/inode change so rotation applies without restart
- print the token and Vite-style Local/Network URLs in the startup banner
- allow non-loopback binds with bearer-token-only auth (password now optional) and update SECURITY.md
- surface daemon boot failures immediately with the exit reason and log tail instead of waiting for the spawn timeout
* feat(server): print full token URLs and re-print links after rotate
- Drop the ready-panel border so token URLs print in full for copying; keep the Kimi sprite beside the title.
- Re-print Local/Network access links after `server rotate-token` (host/port from the lock).
- Extract shared access-URL helpers into access-urls.ts.
- Unify link and token colors between the banner and rotate-token.
* feat(server): dim URL #token= fragment and de-highlight token
- Render the `#token=…` fragment in a dim gray so the host/port stands out in the banner and rotate-token links.
- De-highlight the standalone token; set it off with surrounding whitespace instead of color.
- Add splitTokenFragment helper.
* refactor(cli): polish server ready banner and rotate-token output
- move version onto the ready banner title line; drop the separate
Ready:/Version: rows and the startup-time metric
- reorder rotate-token output so the new token sits between the
invalidation note and the access links
- update server CLI tests for the new layout
* feat(server): warn on reuse and refine ready banner
- Warn when `server run` reuses an already-running daemon (its options are not applied) and show the running server's actual URLs.
- Show a `Network: off use --host 0.0.0.0 to enable` hint on loopback binds.
- Move the version onto the title line and drop the startup-time metric.
* fix(web): relabel auth dialog to token and cover full page
- Relabel the server auth dialog from "password" to "token"; the server accepts the bearer token, with the password only as a fallback.
- Make the auth dialog overlay fully opaque so it covers the whole page instead of revealing the login page underneath.
* fix: resolve CI failures on web auth PR
- Replace chalk.yellow named color with chalk.hex(darkColors.warning)
in the server reuse notice to satisfy the chalk named color guard.
- Update pnpmDeps hash in flake.nix to match the regenerated
pnpm-lock.yaml so the Nix build succeeds.
- Retry rmSync in ws-broadcast e2e teardown to ride out EBUSY /
ENOTEMPTY races while the server flushes files after close().
* test(server): update API surface snapshot for warnings route
The feat/web-auth branch adds GET /api/v1/sessions/{session_id}/warnings
(packages/server/src/routes/sessions.ts), so the API surface guardrail
snapshot needs to record the new documented v1 route.
This commit is contained in:
parent
d6e524682d
commit
60dfb68a2d
92 changed files with 6631 additions and 265 deletions
10
.changeset/server-auth-and-host-exposure.md
Normal file
10
.changeset/server-auth-and-host-exposure.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": minor
|
||||
---
|
||||
|
||||
Add server authentication and safe `--host` exposure. The local server now
|
||||
requires a per-start bearer token on all API and WebSocket calls (the CLI reads
|
||||
it automatically), enforces Host/Origin checks, and gains `--host` with a
|
||||
public-binding hardening tier: mandatory `KIMI_CODE_PASSWORD`, TLS (or
|
||||
`--insecure-no-tls`), auth-failure rate limiting, disabled remote
|
||||
shutdown/terminals, and security response headers. See `packages/server/SECURITY.md`.
|
||||
85
apps/kimi-code/src/cli/sub/server/access-urls.ts
Normal file
85
apps/kimi-code/src/cli/sub/server/access-urls.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Build the clickable/copyable access URLs for the running server.
|
||||
*
|
||||
* Shared by the `server run` ready banner and `server rotate-token` so both
|
||||
* show the same Local/Network links. When a token is known it rides in the
|
||||
* `#token=` fragment (never sent to the server, so never logged), letting a
|
||||
* user open the link on another device and be authenticated automatically.
|
||||
*/
|
||||
|
||||
import { formatHostForUrl, listNetworkAddresses, type NetworkAddress } from './networks';
|
||||
|
||||
/**
|
||||
* Build a directly-openable server URL. When the token is known it is appended
|
||||
* as `#token=<token>`; otherwise the bare origin (with a trailing slash) is
|
||||
* returned.
|
||||
*/
|
||||
export function buildOpenableUrl(bareOrigin: string, token: string | undefined): string {
|
||||
const base = bareOrigin.endsWith('/') ? bareOrigin.slice(0, -1) : bareOrigin;
|
||||
return token === undefined ? `${base}/` : `${base}/#token=${token}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a full URL into the part before `#token=` and the `#token=…` fragment
|
||||
* itself, so callers can render the fragment in a de-emphasized color. Returns
|
||||
* `[fullUrl, '']` when there is no token fragment.
|
||||
*/
|
||||
export function splitTokenFragment(fullUrl: string): [string, string] {
|
||||
const marker = '#token=';
|
||||
const idx = fullUrl.indexOf(marker);
|
||||
return idx < 0 ? [fullUrl, ''] : [fullUrl.slice(0, idx), fullUrl.slice(idx)];
|
||||
}
|
||||
|
||||
export interface AccessUrlLine {
|
||||
/** Fixed-width label including trailing padding, e.g. `"Local: "`. */
|
||||
label: string;
|
||||
/** Full URL, carrying `#token=` when a token is known. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
function isWildcard(host: string): boolean {
|
||||
return host === '' || host === '0.0.0.0' || host === '::';
|
||||
}
|
||||
|
||||
/** True when `host` is a loopback address (this host only). */
|
||||
export function isLoopbackHost(host: string): boolean {
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
||||
}
|
||||
|
||||
function hostOrigin(host: string, port: number): string {
|
||||
const family = host.includes(':') ? 'IPv6' : 'IPv4';
|
||||
return `http://${formatHostForUrl(host, family)}:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the access-URL lines for a bind host/port.
|
||||
*
|
||||
* - wildcard (`0.0.0.0` / `::` / empty): a `Local:` line (localhost) plus one
|
||||
* `Network:` line per non-loopback interface.
|
||||
* - loopback: a single `Local:` line.
|
||||
* - specific host: a single `URL:` line.
|
||||
*/
|
||||
export function accessUrlLines(
|
||||
host: string,
|
||||
port: number,
|
||||
token: string | undefined,
|
||||
networkAddresses?: NetworkAddress[],
|
||||
): AccessUrlLine[] {
|
||||
if (isWildcard(host)) {
|
||||
const lines: AccessUrlLine[] = [
|
||||
{ label: 'Local: ', url: buildOpenableUrl(`http://localhost:${port}`, token) },
|
||||
];
|
||||
const addrs = networkAddresses ?? listNetworkAddresses();
|
||||
for (const addr of addrs) {
|
||||
lines.push({
|
||||
label: 'Network: ',
|
||||
url: buildOpenableUrl(`http://${formatHostForUrl(addr.address, addr.family)}:${port}`, token),
|
||||
});
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
if (isLoopbackHost(host)) {
|
||||
return [{ label: 'Local: ', url: buildOpenableUrl(hostOrigin(host, port), token) }];
|
||||
}
|
||||
return [{ label: 'URL: ', url: buildOpenableUrl(hostOrigin(host, port), token) }];
|
||||
}
|
||||
|
|
@ -16,8 +16,8 @@
|
|||
* foreground runner so it can share the same bootstrap helpers.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { appendFileSync, closeSync, mkdirSync, openSync } from 'node:fs';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createServer } from 'node:net';
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
|
|
@ -44,18 +44,32 @@ const POLL_INTERVAL_MS = 200;
|
|||
const DEFAULT_DAEMON_LOG_LEVEL = 'info';
|
||||
|
||||
export interface EnsureDaemonOptions {
|
||||
/** Bind host for the spawned daemon (default `127.0.0.1`). */
|
||||
host?: string;
|
||||
/** Preferred port; on conflict a free port is chosen automatically. */
|
||||
port?: number;
|
||||
/** Pino log level for the spawned daemon (defaults to `info`). */
|
||||
logLevel?: string;
|
||||
/** Mount `/api/v1/debug/*` routes on the spawned daemon. */
|
||||
debugEndpoints?: boolean;
|
||||
/** Allow a non-loopback bind without a TLS-terminating reverse proxy. */
|
||||
insecureNoTls?: boolean;
|
||||
/** Keep `POST /api/v1/shutdown` enabled on a non-loopback bind. */
|
||||
allowRemoteShutdown?: boolean;
|
||||
/** Keep the PTY `/api/v1/terminals/*` routes enabled on a non-loopback bind. */
|
||||
allowRemoteTerminals?: boolean;
|
||||
/** Idle-shutdown grace in ms for the spawned daemon (daemon mode only). */
|
||||
idleGraceMs?: number;
|
||||
}
|
||||
|
||||
export interface EnsureDaemonResult {
|
||||
readonly origin: string;
|
||||
/** True when an already-running daemon was reused (no new server started). */
|
||||
readonly reused: boolean;
|
||||
/** Bind host the running daemon is actually listening on (from the lock). */
|
||||
readonly host: string;
|
||||
/** Port the running daemon is actually listening on (from the lock). */
|
||||
readonly port: number;
|
||||
}
|
||||
|
||||
/** Path of the daemon log file (shared with the OS-service log location). */
|
||||
|
|
@ -178,13 +192,17 @@ export function resolveDaemonProgram(
|
|||
}
|
||||
|
||||
interface SpawnDaemonChildOptions {
|
||||
host?: string;
|
||||
port: number;
|
||||
logLevel: string;
|
||||
debugEndpoints?: boolean;
|
||||
insecureNoTls?: boolean;
|
||||
allowRemoteShutdown?: boolean;
|
||||
allowRemoteTerminals?: boolean;
|
||||
idleGraceMs?: number;
|
||||
}
|
||||
|
||||
export function spawnDaemonChild(options: SpawnDaemonChildOptions): void {
|
||||
export function spawnDaemonChild(options: SpawnDaemonChildOptions): ChildProcess {
|
||||
const program = resolveDaemonProgram();
|
||||
const logPath = daemonLogPath();
|
||||
const logDir = dirname(logPath);
|
||||
|
|
@ -198,9 +216,21 @@ export function spawnDaemonChild(options: SpawnDaemonChildOptions): void {
|
|||
'--log-level',
|
||||
options.logLevel,
|
||||
];
|
||||
if (options.host !== undefined) {
|
||||
args.push('--host', options.host);
|
||||
}
|
||||
if (options.debugEndpoints === true) {
|
||||
args.push('--debug-endpoints');
|
||||
}
|
||||
if (options.insecureNoTls === true) {
|
||||
args.push('--insecure-no-tls');
|
||||
}
|
||||
if (options.allowRemoteShutdown === true) {
|
||||
args.push('--allow-remote-shutdown');
|
||||
}
|
||||
if (options.allowRemoteTerminals === true) {
|
||||
args.push('--allow-remote-terminals');
|
||||
}
|
||||
if (options.idleGraceMs !== undefined) {
|
||||
args.push('--idle-grace-ms', String(options.idleGraceMs));
|
||||
}
|
||||
|
|
@ -233,6 +263,7 @@ export function spawnDaemonChild(options: SpawnDaemonChildOptions): void {
|
|||
}
|
||||
});
|
||||
child.unref();
|
||||
return child;
|
||||
} finally {
|
||||
// `spawn` dups the fd into the child; the parent must not keep it open.
|
||||
closeSync(logFd);
|
||||
|
|
@ -251,6 +282,7 @@ function sleep(ms: number): Promise<void> {
|
|||
* detached process after this returns.
|
||||
*/
|
||||
export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise<EnsureDaemonResult> {
|
||||
const host = options.host ?? DEFAULT_SERVER_HOST;
|
||||
const preferred = options.port ?? DEFAULT_SERVER_PORT;
|
||||
const logLevel = options.logLevel ?? DEFAULT_DAEMON_LOG_LEVEL;
|
||||
|
||||
|
|
@ -259,7 +291,12 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise<E
|
|||
if (existing) {
|
||||
const origin = serverOrigin(lockConnectHost(existing), existing.port);
|
||||
if (await waitForServerHealthy(origin, REUSE_HEALTH_TIMEOUT_MS)) {
|
||||
return { origin };
|
||||
return {
|
||||
origin,
|
||||
reused: true,
|
||||
host: existing.host ?? DEFAULT_SERVER_HOST,
|
||||
port: existing.port,
|
||||
};
|
||||
}
|
||||
// Live pid but not responding (wedged or mid-boot failure). Fall through
|
||||
// and spawn: if it is truly wedged our child loses the lock race and we
|
||||
|
|
@ -267,14 +304,33 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise<E
|
|||
}
|
||||
|
||||
// 2. No reusable daemon — pick a free port and spawn one detached.
|
||||
const port = await resolveDaemonPort(DEFAULT_SERVER_HOST, preferred);
|
||||
spawnDaemonChild({
|
||||
const port = await resolveDaemonPort(host, preferred);
|
||||
const child = spawnDaemonChild({
|
||||
host,
|
||||
port,
|
||||
logLevel,
|
||||
debugEndpoints: options.debugEndpoints,
|
||||
insecureNoTls: options.insecureNoTls,
|
||||
allowRemoteShutdown: options.allowRemoteShutdown,
|
||||
allowRemoteTerminals: options.allowRemoteTerminals,
|
||||
idleGraceMs: options.idleGraceMs,
|
||||
});
|
||||
|
||||
// Watch for an early exit so a boot failure (e.g. the non-loopback TLS gate,
|
||||
// a config error, or a lost lock race with no other daemon to fall back to)
|
||||
// surfaces the real error immediately instead of waiting out the full spawn
|
||||
// timeout. The exit code/signal plus a tail of the daemon log is what tells
|
||||
// the operator *why* it failed.
|
||||
let childExit: { code: number | null; signal: NodeJS.Signals | null } | undefined;
|
||||
child.once('exit', (code, signal) => {
|
||||
childExit = { code, signal };
|
||||
});
|
||||
child.once('error', () => {
|
||||
// Spawn failure (ENOENT etc.) is already recorded in the log by
|
||||
// spawnDaemonChild; treat it as an early exit here.
|
||||
childExit = { code: -1, signal: null };
|
||||
});
|
||||
|
||||
// 3. Wait until some live daemon (ours, or a racer that won the lock) is up.
|
||||
const deadline = Date.now() + SPAWN_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
|
|
@ -282,14 +338,53 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise<E
|
|||
if (live) {
|
||||
const origin = serverOrigin(lockConnectHost(live), live.port);
|
||||
if (await isServerHealthy(origin, 500)) {
|
||||
return { origin };
|
||||
return {
|
||||
origin,
|
||||
reused: false,
|
||||
host: live.host ?? DEFAULT_SERVER_HOST,
|
||||
port: live.port,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (childExit !== undefined && !live) {
|
||||
// Our child exited and no other live daemon holds the lock to fall back
|
||||
// to — this is a real boot failure, not a lost race.
|
||||
throw new Error(formatDaemonBootFailure(childExit, daemonLogPath()));
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Kimi server daemon failed to start within ${String(SPAWN_TIMEOUT_MS)}ms. ` +
|
||||
`Check the log for details: ${daemonLogPath()}`,
|
||||
`Kimi server daemon failed to start within ${String(SPAWN_TIMEOUT_MS)}ms.\n\n` +
|
||||
formatLogTail(daemonLogPath()),
|
||||
);
|
||||
}
|
||||
|
||||
function formatDaemonBootFailure(
|
||||
exit: { code: number | null; signal: NodeJS.Signals | null },
|
||||
logPath: string,
|
||||
): string {
|
||||
const reason =
|
||||
exit.signal === null
|
||||
? `exited with code ${String(exit.code)}`
|
||||
: `was terminated by signal ${exit.signal}`;
|
||||
return `Kimi server daemon ${reason} during startup.\n\n${formatLogTail(logPath)}`;
|
||||
}
|
||||
|
||||
function formatLogTail(logPath: string): string {
|
||||
const tail = tailFile(logPath, 30);
|
||||
if (tail.length === 0) {
|
||||
return `Check the log for details: ${logPath}`;
|
||||
}
|
||||
return `Last log lines (${logPath}):\n${tail}`;
|
||||
}
|
||||
|
||||
function tailFile(filePath: string, maxLines: number): string {
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n').filter((line) => line.length > 0);
|
||||
return lines.slice(-maxLines).join('\n');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,8 +18,10 @@ import type { Command } from 'commander';
|
|||
|
||||
import { getLiveLock, type LockContents } from '@moonshot-ai/server';
|
||||
|
||||
import { getDataDir } from '#/utils/paths';
|
||||
|
||||
import { lockConnectHost } from './daemon';
|
||||
import { serverOrigin } from './shared';
|
||||
import { authHeaders, serverOrigin, tryResolveServerToken } from './shared';
|
||||
|
||||
/** How long to wait for the graceful API shutdown request. */
|
||||
const API_TIMEOUT_MS = 2000;
|
||||
|
|
@ -32,7 +34,9 @@ const POLL_INTERVAL_MS = 100;
|
|||
|
||||
export interface KillCommandDeps {
|
||||
getLiveLock(): LockContents | undefined;
|
||||
requestShutdown(origin: string): Promise<void>;
|
||||
requestShutdown(origin: string, token: string | undefined): Promise<void>;
|
||||
/** Best-effort read of the persistent bearer token; undefined on miss. */
|
||||
resolveToken(): string | undefined;
|
||||
signalPid(pid: number, signal: NodeJS.Signals): boolean;
|
||||
pidAlive(pid: number): boolean;
|
||||
sleep(ms: number): Promise<void>;
|
||||
|
|
@ -66,8 +70,11 @@ export async function handleKillCommand(deps: KillCommandDeps): Promise<void> {
|
|||
|
||||
// 1. API path — best-effort graceful shutdown. Ignore every outcome: the
|
||||
// server may be an older build without the route, already wedged, or may
|
||||
// drop the connection as it exits.
|
||||
await deps.requestShutdown(origin).catch(() => {});
|
||||
// drop the connection as it exits. The bearer token (M5.1) is best-effort
|
||||
// too: if it can't be read the API call 401s and the PID path below still
|
||||
// guarantees the kill.
|
||||
const token = deps.resolveToken();
|
||||
await deps.requestShutdown(origin, token).catch(() => {});
|
||||
|
||||
// 2. PID path — SIGTERM, wait, then SIGKILL.
|
||||
deps.signalPid(pid, 'SIGTERM');
|
||||
|
|
@ -126,7 +133,10 @@ export function signalPid(pid: number, signal: NodeJS.Signals): boolean {
|
|||
}
|
||||
|
||||
/** POST the shutdown endpoint; resolves once the request completes or times out. */
|
||||
export async function requestShutdownViaApi(origin: string): Promise<void> {
|
||||
export async function requestShutdownViaApi(
|
||||
origin: string,
|
||||
token: string | undefined,
|
||||
): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
|
|
@ -134,6 +144,7 @@ export async function requestShutdownViaApi(origin: string): Promise<void> {
|
|||
try {
|
||||
await fetch(`${origin}/api/v1/shutdown`, {
|
||||
method: 'POST',
|
||||
headers: token !== undefined ? authHeaders(token) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -144,6 +155,7 @@ export async function requestShutdownViaApi(origin: string): Promise<void> {
|
|||
const DEFAULT_KILL_DEPS: KillCommandDeps = {
|
||||
getLiveLock,
|
||||
requestShutdown: requestShutdownViaApi,
|
||||
resolveToken: () => tryResolveServerToken(getDataDir()),
|
||||
signalPid,
|
||||
pidAlive,
|
||||
sleep: (ms) =>
|
||||
|
|
|
|||
84
apps/kimi-code/src/cli/sub/server/networks.ts
Normal file
84
apps/kimi-code/src/cli/sub/server/networks.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Enumerate this machine's non-loopback network interface addresses, used to
|
||||
* print `Network: http://<addr>:<port>/` hints (à la Vite) when the server
|
||||
* binds a wildcard host (`0.0.0.0` / `::`).
|
||||
*/
|
||||
|
||||
import { networkInterfaces } from 'node:os';
|
||||
|
||||
export interface NetworkAddress {
|
||||
/** Raw IP address (IPv4 or IPv6); IPv6 is NOT bracket-wrapped here. */
|
||||
address: string;
|
||||
family: 'IPv4' | 'IPv6';
|
||||
}
|
||||
|
||||
/**
|
||||
* List non-internal interface addresses, IPv4 first then IPv6, preserving
|
||||
* interface order within each family.
|
||||
*
|
||||
* Like Vite, this lists the machine's own interface addresses — LAN
|
||||
* (192.168/10/172.16) plus any directly-assigned public address. It does not
|
||||
* (and cannot, without an external service) discover a NAT-translated WAN IP,
|
||||
* and we deliberately avoid any network call for a startup hint.
|
||||
*/
|
||||
export function listNetworkAddresses(): NetworkAddress[] {
|
||||
const raw: NetworkAddress[] = [];
|
||||
for (const entries of Object.values(networkInterfaces())) {
|
||||
for (const info of entries ?? []) {
|
||||
if (info.internal) {
|
||||
continue;
|
||||
}
|
||||
if (info.family === 'IPv4') {
|
||||
raw.push({ address: info.address, family: 'IPv4' });
|
||||
} else if (info.family === 'IPv6') {
|
||||
raw.push({ address: info.address, family: 'IPv6' });
|
||||
}
|
||||
}
|
||||
}
|
||||
return filterDisplayAddresses(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop addresses that are not useful as a connect target and de-duplicate.
|
||||
*
|
||||
* IPv6 link-local (`fe80::/10`) is filtered out: it is only reachable with a
|
||||
* zone id (e.g. `fe80::1%en0`), which our bare URL cannot carry, so showing it
|
||||
* is pure noise — and it is the bulk of what `os.networkInterfaces()` reports
|
||||
* on a typical machine. Duplicates (the same address reported on more than one
|
||||
* interface) are collapsed. The result is IPv4 first, then IPv6, preserving
|
||||
* order within each family.
|
||||
*/
|
||||
export function filterDisplayAddresses(
|
||||
addrs: readonly NetworkAddress[],
|
||||
): NetworkAddress[] {
|
||||
const seen = new Set<string>();
|
||||
const kept: NetworkAddress[] = [];
|
||||
for (const addr of addrs) {
|
||||
if (addr.family === 'IPv6' && isLinkLocalV6(addr.address)) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(addr.address)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(addr.address);
|
||||
kept.push(addr);
|
||||
}
|
||||
return [
|
||||
...kept.filter((a) => a.family === 'IPv4'),
|
||||
...kept.filter((a) => a.family === 'IPv6'),
|
||||
];
|
||||
}
|
||||
|
||||
/** True for IPv6 link-local addresses (`fe80::/10`, i.e. `fe80::`–`febf::`). */
|
||||
function isLinkLocalV6(address: string): boolean {
|
||||
const first = Number.parseInt(address.split(':')[0] ?? '', 16);
|
||||
return first >= 0xfe80 && first <= 0xfebf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an address for use as a URL host: bracket-wrap IPv6 per RFC 3986,
|
||||
* return IPv4 as-is.
|
||||
*/
|
||||
export function formatHostForUrl(address: string, family: NetworkAddress['family']): string {
|
||||
return family === 'IPv6' ? `[${address}]` : address;
|
||||
}
|
||||
|
|
@ -11,8 +11,10 @@ import type { Command } from 'commander';
|
|||
|
||||
import { getLiveLock } from '@moonshot-ai/server';
|
||||
|
||||
import { getDataDir } from '#/utils/paths';
|
||||
|
||||
import { lockConnectHost } from './daemon';
|
||||
import { isServerHealthy, serverOrigin } from './shared';
|
||||
import { authHeaders, isServerHealthy, resolveServerToken, serverOrigin } from './shared';
|
||||
|
||||
/** Wire shape of a single connection returned by `GET /api/v1/connections`. */
|
||||
interface ConnectionInfo {
|
||||
|
|
@ -62,7 +64,11 @@ async function handlePsCommand(opts: { json?: boolean }): Promise<void> {
|
|||
throw new Error(`Kimi server at ${origin} is not responding.`);
|
||||
}
|
||||
|
||||
const connections = await fetchConnections(origin);
|
||||
// The `/api/v1/connections` route is gated by bearer auth (M5.1). Read the
|
||||
// persistent token; a clear error here means the server has never been
|
||||
// started (no token file yet) or the token file was removed.
|
||||
const token = resolveServerToken(getDataDir());
|
||||
const connections = await fetchConnections(origin, token);
|
||||
|
||||
if (opts.json) {
|
||||
process.stdout.write(`${JSON.stringify(connections, null, 2)}\n`);
|
||||
|
|
@ -71,13 +77,14 @@ async function handlePsCommand(opts: { json?: boolean }): Promise<void> {
|
|||
process.stdout.write(formatTable(connections));
|
||||
}
|
||||
|
||||
async function fetchConnections(origin: string): Promise<ConnectionInfo[]> {
|
||||
async function fetchConnections(origin: string, token: string): Promise<ConnectionInfo[]> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(`${origin}/api/v1/connections`, {
|
||||
headers: authHeaders(token),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
|
|
|||
57
apps/kimi-code/src/cli/sub/server/rotate-token.ts
Normal file
57
apps/kimi-code/src/cli/sub/server/rotate-token.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* `kimi server rotate-token` — generate a new persistent server token.
|
||||
*
|
||||
* Rewrites `<KIMI_CODE_HOME>/server.token` (0600, atomic). The previous token
|
||||
* stops working immediately: a running server re-reads the file on its next
|
||||
* auth check, so rotation takes effect without a restart.
|
||||
*/
|
||||
|
||||
import { getLiveLock, rotateServerToken } from '@moonshot-ai/server';
|
||||
import chalk from 'chalk';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { darkColors } from '#/tui/theme/colors';
|
||||
import { getDataDir } from '#/utils/paths';
|
||||
|
||||
import { accessUrlLines, splitTokenFragment } from './access-urls';
|
||||
import { DEFAULT_SERVER_HOST } from './shared';
|
||||
|
||||
export function registerRotateTokenCommand(server: Command): void {
|
||||
server
|
||||
.command('rotate-token')
|
||||
.description(
|
||||
'Generate a new persistent server token; the previous token stops working immediately.',
|
||||
)
|
||||
.action(async () => {
|
||||
try {
|
||||
const token = await rotateServerToken(getDataDir());
|
||||
process.stdout.write(
|
||||
'The previous token is now invalid. A running server picks up the new token automatically.\n',
|
||||
);
|
||||
|
||||
// Token in the middle: indented and set off by blank lines (no color
|
||||
// highlight), so it is easy to spot without dominating the output.
|
||||
process.stdout.write(`\n ${chalk.bold('New server token:')} ${token}\n\n`);
|
||||
|
||||
// Re-print the access links with the new token so the user can
|
||||
// reconnect immediately. When a server is running its bind host/port
|
||||
// come from the lock; otherwise there is nothing to connect to yet.
|
||||
const lock = getLiveLock();
|
||||
if (lock !== undefined) {
|
||||
const host = lock.host ?? DEFAULT_SERVER_HOST;
|
||||
for (const { label, url: href } of accessUrlLines(host, lock.port, token)) {
|
||||
// De-emphasize the `#token=…` fragment so the host/port stands out.
|
||||
const [base, frag] = splitTokenFragment(href);
|
||||
const rendered =
|
||||
frag === ''
|
||||
? chalk.hex(darkColors.accent)(base)
|
||||
: chalk.hex(darkColors.accent)(base) + chalk.hex(darkColors.textDim)(frag);
|
||||
process.stdout.write(` ${chalk.dim(label)}${rendered}\n`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -13,7 +13,6 @@
|
|||
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
||||
import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry';
|
||||
import { startServer, type RunningServer } from '@moonshot-ai/server';
|
||||
import chalk from 'chalk';
|
||||
|
|
@ -23,21 +22,30 @@ import { CLI_SHUTDOWN_TIMEOUT_MS, WEB_UI_MODE } from '#/constant/app';
|
|||
import { getNativeWebAssetsDir } from '#/native/web-assets';
|
||||
import { darkColors } from '#/tui/theme/colors';
|
||||
import { openUrl as defaultOpenUrl } from '#/utils/open-url';
|
||||
import { getDataDir } from '#/utils/paths';
|
||||
|
||||
import { initializeServerTelemetry } from '../../telemetry';
|
||||
import { createKimiCodeHostIdentity, getHostPackageRoot, getVersion } from '../../version';
|
||||
import { ensureDaemon } from './daemon';
|
||||
import {
|
||||
accessUrlLines,
|
||||
buildOpenableUrl,
|
||||
isLoopbackHost,
|
||||
splitTokenFragment,
|
||||
} from './access-urls';
|
||||
import { ensureDaemon, type EnsureDaemonResult } from './daemon';
|
||||
import { type NetworkAddress } from './networks';
|
||||
import {
|
||||
DEFAULT_FOREGROUND_LOG_LEVEL,
|
||||
DEFAULT_SERVER_HOST,
|
||||
DEFAULT_SERVER_PORT,
|
||||
parseServerOptions,
|
||||
tryResolveServerToken,
|
||||
VALID_LOG_LEVELS,
|
||||
type ParsedServerOptions,
|
||||
type ServerCliOptions,
|
||||
} from './shared';
|
||||
|
||||
const WEB_ASSETS_DIR = 'dist-web';
|
||||
const READY_PANEL_WIDTH = 72;
|
||||
|
||||
export interface RunCliOptions extends ServerCliOptions {
|
||||
open?: boolean;
|
||||
|
|
@ -51,17 +59,49 @@ export interface StartForegroundHooks {
|
|||
}
|
||||
|
||||
export interface RunCommandDeps {
|
||||
startServerBackground(options: ParsedServerOptions): Promise<{ origin: string }>;
|
||||
startServerBackground(options: ParsedServerOptions): Promise<{
|
||||
origin: string;
|
||||
/** True when an already-running daemon was reused (no new server started). */
|
||||
reused?: boolean;
|
||||
/** Bind host the running daemon is actually listening on (from the lock). */
|
||||
host?: string;
|
||||
/** Port the running daemon is actually listening on (from the lock). */
|
||||
port?: number;
|
||||
}>;
|
||||
/** Foreground runner; defaults to the real in-process runner when omitted. */
|
||||
startServerForeground?: (
|
||||
options: ParsedServerOptions,
|
||||
hooks?: StartForegroundHooks,
|
||||
) => Promise<never>;
|
||||
openUrl(url: string): void;
|
||||
/**
|
||||
* Best-effort read of the server's persistent bearer token. When it returns
|
||||
* a token, the ready banner prints it and the opened Web UI URL carries it in
|
||||
* the `#token=` fragment (M5.5). Optional so callers/tests that don't supply
|
||||
* it simply print/open the plain origin.
|
||||
*/
|
||||
resolveToken?: () => string | undefined;
|
||||
/**
|
||||
* Non-loopback interface addresses to display for a wildcard bind. Defaults
|
||||
* to the machine's own interfaces (`listNetworkAddresses()`); inject a fixed
|
||||
* list in tests for deterministic output.
|
||||
*/
|
||||
networkAddresses?: NetworkAddress[];
|
||||
stdout: Pick<NodeJS.WriteStream, 'write'>;
|
||||
stderr: Pick<NodeJS.WriteStream, 'write'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Web UI URL, carrying the bearer token in the URL fragment.
|
||||
*
|
||||
* The token rides in `#token=<token>` — a client-side fragment that is never
|
||||
* sent to the server (so it never appears in server access logs) and is not
|
||||
* logged by proxies. The Web UI reads it from `location.hash` after load.
|
||||
*/
|
||||
export function buildWebUrl(origin: string, token: string): string {
|
||||
return buildOpenableUrl(origin, token);
|
||||
}
|
||||
|
||||
/** Build the `run` subcommand, mounted under a parent (`server` or top-level). */
|
||||
export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }): Command {
|
||||
return cmd
|
||||
|
|
@ -70,6 +110,26 @@ export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean })
|
|||
`Bind port (default ${DEFAULT_SERVER_PORT})`,
|
||||
String(DEFAULT_SERVER_PORT),
|
||||
)
|
||||
.option(
|
||||
'--host <host>',
|
||||
`Bind host (default ${DEFAULT_SERVER_HOST}). Use 0.0.0.0 to listen on all interfaces (requires --insecure-no-tls unless behind a TLS proxy). The bearer token is printed at startup.`,
|
||||
String(DEFAULT_SERVER_HOST),
|
||||
)
|
||||
.option(
|
||||
'--insecure-no-tls',
|
||||
'Allow a non-loopback bind without a TLS-terminating reverse proxy. Required to bind beyond 127.0.0.1; use a tunnel or reverse proxy in production.',
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
'--allow-remote-shutdown',
|
||||
'On a non-loopback bind, keep POST /api/v1/shutdown enabled (default: route is disabled → 404).',
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
'--allow-remote-terminals',
|
||||
'On a non-loopback bind, keep the PTY /api/v1/terminals/* routes enabled (default: disabled → 404). Remote shell is high risk.',
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
'--log-level <level>',
|
||||
`Server log level: ${VALID_LOG_LEVELS.join('|')}. Omit to keep logs off.`,
|
||||
|
|
@ -119,34 +179,53 @@ export async function handleRunCommand(
|
|||
await startServerDaemon(parsed);
|
||||
return;
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
// Resolve the persistent token once: it is printed in the ready banner and
|
||||
// rides in the opened Web UI URL's `#token=` fragment (M5.5). Falls back to
|
||||
// the plain origin / no token line when unavailable.
|
||||
const writeReady = (result: { origin: string; reused?: boolean; host?: string }): void => {
|
||||
const { origin } = result;
|
||||
const host = result.host ?? parsed.host;
|
||||
const token = deps.resolveToken?.();
|
||||
let output = '';
|
||||
if (result.reused === true) {
|
||||
// A daemon was already running, so this command's --host/--port/etc. did
|
||||
// not start a new one. Say so loudly, then print the actual running
|
||||
// server's URLs (using its real bind host, not the requested one).
|
||||
output += formatReuseNotice(origin);
|
||||
}
|
||||
output +=
|
||||
parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL
|
||||
? formatReadyBanner(origin, host, {
|
||||
token,
|
||||
networkAddresses: deps.networkAddresses,
|
||||
})
|
||||
: formatReadyLine(origin, token);
|
||||
deps.stdout.write(output);
|
||||
if (opts.open === true) {
|
||||
deps.openUrl(token !== undefined ? buildWebUrl(origin, token) : origin);
|
||||
}
|
||||
};
|
||||
if (opts.foreground === true) {
|
||||
const run = deps.startServerForeground ?? startServerForeground;
|
||||
await run(parsed, {
|
||||
onReady: (origin) => {
|
||||
const readyMs = Date.now() - startedAt;
|
||||
deps.stdout.write(
|
||||
parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL
|
||||
? formatReadyBanner(origin, readyMs)
|
||||
: `Kimi server: ${origin}\n`,
|
||||
);
|
||||
if (opts.open === true) {
|
||||
deps.openUrl(origin);
|
||||
}
|
||||
},
|
||||
onReady: (origin) => writeReady({ origin, reused: false, host: parsed.host }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { origin } = await deps.startServerBackground(parsed);
|
||||
const readyMs = Date.now() - startedAt;
|
||||
deps.stdout.write(
|
||||
parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL
|
||||
? formatReadyBanner(origin, readyMs)
|
||||
: `Kimi server: ${origin}\n`,
|
||||
const result = await deps.startServerBackground(parsed);
|
||||
writeReady(result);
|
||||
}
|
||||
|
||||
function formatReuseNotice(origin: string): string {
|
||||
return (
|
||||
`${chalk.hex(darkColors.warning)('A server is already running')} at ${origin} — ` +
|
||||
`the options from this command were not applied. ` +
|
||||
`Run ${chalk.bold('kimi server kill')} first to bind a new host/port.\n`
|
||||
);
|
||||
if (opts.open === true) {
|
||||
deps.openUrl(origin);
|
||||
}
|
||||
}
|
||||
|
||||
function formatReadyLine(origin: string, token: string | undefined): string {
|
||||
return `Kimi server: ${buildOpenableUrl(origin, token)}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -157,14 +236,17 @@ export async function handleRunCommand(
|
|||
*/
|
||||
export async function startServerBackground(
|
||||
options: ParsedServerOptions,
|
||||
): Promise<{ origin: string }> {
|
||||
const { origin } = await ensureDaemon({
|
||||
): Promise<EnsureDaemonResult> {
|
||||
return ensureDaemon({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
logLevel: options.logLevel,
|
||||
debugEndpoints: options.debugEndpoints,
|
||||
insecureNoTls: options.insecureNoTls,
|
||||
allowRemoteShutdown: options.allowRemoteShutdown,
|
||||
allowRemoteTerminals: options.allowRemoteTerminals,
|
||||
idleGraceMs: options.idleGraceMs,
|
||||
});
|
||||
return { origin };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -238,6 +320,9 @@ async function runServerInProcess(
|
|||
port: options.port,
|
||||
logLevel: options.logLevel,
|
||||
debugEndpoints: options.debugEndpoints,
|
||||
insecureNoTls: options.insecureNoTls,
|
||||
allowRemoteShutdown: options.allowRemoteShutdown,
|
||||
allowRemoteTerminals: options.allowRemoteTerminals,
|
||||
webAssetsDir: serverWebAssetsDir(),
|
||||
coreProcessOptions: {
|
||||
identity: createKimiCodeHostIdentity(version),
|
||||
|
|
@ -323,65 +408,80 @@ export function resolveServerWebAssetsDir(
|
|||
return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR);
|
||||
}
|
||||
|
||||
function formatReadyBanner(origin: string, readyMs: number): string {
|
||||
interface FormatReadyBannerOptions {
|
||||
/** Persistent bearer token to print; omitted when unresolvable. */
|
||||
token?: string;
|
||||
/** Non-loopback interface addresses to list for a wildcard bind. */
|
||||
networkAddresses?: NetworkAddress[];
|
||||
}
|
||||
|
||||
function formatReadyBanner(
|
||||
origin: string,
|
||||
host: string,
|
||||
opts: FormatReadyBannerOptions = {},
|
||||
): string {
|
||||
const primary = (text: string): string => chalk.hex(darkColors.primary)(text);
|
||||
const title = (text: string): string => chalk.bold.hex(darkColors.primary)(text);
|
||||
const dim = (text: string): string => chalk.hex(darkColors.textDim)(text);
|
||||
const muted = (text: string): string => chalk.hex(darkColors.textMuted)(text);
|
||||
const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text);
|
||||
const url = chalk.hex(darkColors.accent)(displayOrigin(origin));
|
||||
const width = READY_PANEL_WIDTH;
|
||||
const innerWidth = width - 4;
|
||||
const pad = ' ';
|
||||
const url = (text: string): string => chalk.hex(darkColors.accent)(text);
|
||||
// Render the `#token=…` fragment in a de-emphasized gray so the host/port
|
||||
// stands out while the full URL stays selectable for copying.
|
||||
const urlWithDimToken = (href: string): string => {
|
||||
const [base, frag] = splitTokenFragment(href);
|
||||
return frag === '' ? url(base) : url(base) + dim(frag);
|
||||
};
|
||||
|
||||
const port = Number(new URL(origin).port);
|
||||
// Borderless header: the Kimi sprite (the little mascot with eyes) sits next
|
||||
// to the title, keeping the brand without the enclosing box.
|
||||
const logo = ['▐█▛█▛█▌', '▐█████▌'] as const;
|
||||
const logoWidth = Math.max(...logo.map((row) => visibleWidth(row)));
|
||||
const gap = ' ';
|
||||
const textWidth = innerWidth - logoWidth - gap.length;
|
||||
const headerLines = [
|
||||
primary(logo[0].padEnd(logoWidth)) +
|
||||
gap +
|
||||
truncateToWidth(title('Kimi server ready'), textWidth, '…'),
|
||||
primary(logo[1].padEnd(logoWidth)) +
|
||||
gap +
|
||||
truncateToWidth(dim('Local web UI is available from this machine.'), textWidth, '…'),
|
||||
];
|
||||
const infoLines = [
|
||||
label('URL: ') + url,
|
||||
label('Network: ') + muted('local only'),
|
||||
label('Logs: ') + muted('off') + dim(' use --log-level info to enable'),
|
||||
label('Stop: ') + muted('kimi server kill'),
|
||||
label('Ready: ') + muted(`${String(Math.max(0, readyMs))} ms`),
|
||||
label('Version: ') + muted(getVersion()),
|
||||
];
|
||||
const contentLines = [...headerLines, '', ...infoLines];
|
||||
|
||||
const lines = [
|
||||
const lines: string[] = [
|
||||
'',
|
||||
` ${primary(logo[0])} ${title('Kimi server ready')} ${dim(getVersion())}`,
|
||||
` ${primary(logo[1])} ${dim('Local web UI is available from this machine.')}`,
|
||||
'',
|
||||
primary('╭' + '─'.repeat(width - 2) + '╮'),
|
||||
primary('│') + ' '.repeat(width - 2) + primary('│'),
|
||||
];
|
||||
|
||||
for (const content of contentLines) {
|
||||
const truncated = truncateToWidth(content, innerWidth, '…');
|
||||
const rightPad = Math.max(0, innerWidth - visibleWidth(truncated));
|
||||
lines.push(primary('│') + pad + truncated + ' '.repeat(rightPad) + primary('│'));
|
||||
// Access links.
|
||||
for (const { label: text, url: href } of accessUrlLines(
|
||||
host,
|
||||
port,
|
||||
opts.token,
|
||||
opts.networkAddresses,
|
||||
)) {
|
||||
lines.push(` ${label(text)}${urlWithDimToken(href)}`);
|
||||
}
|
||||
// On a loopback bind there is no network URL; show how to enable one.
|
||||
if (isLoopbackHost(host)) {
|
||||
lines.push(` ${label('Network: ')}${muted('off')}${dim(' use --host 0.0.0.0 to enable')}`);
|
||||
}
|
||||
if (opts.token !== undefined) {
|
||||
// Set the token off with surrounding whitespace rather than color, so it is
|
||||
// easy to spot without being highlighted.
|
||||
lines.push('');
|
||||
lines.push(` ${label('Token: ')}${opts.token}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push(primary('│') + ' '.repeat(width - 2) + primary('│'));
|
||||
lines.push(primary('╰' + '─'.repeat(width - 2) + '╯'));
|
||||
// Auxiliary controls last.
|
||||
lines.push(` ${label('Logs: ')}${muted('off')}${dim(' use --log-level info to enable')}`);
|
||||
lines.push(` ${label('Stop: ')}${muted('kimi server kill')}`);
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function displayOrigin(origin: string): string {
|
||||
return origin.endsWith('/') ? origin : `${origin}/`;
|
||||
}
|
||||
|
||||
const DEFAULT_RUN_COMMAND_DEPS: RunCommandDeps = {
|
||||
startServerBackground,
|
||||
startServerForeground,
|
||||
openUrl: defaultOpenUrl,
|
||||
resolveToken: () => {
|
||||
// Read the persistent `<homeDir>/server.token` written on first boot
|
||||
// (M5.1). Best-effort: a missing/older server yields undefined and the
|
||||
// caller opens the plain origin.
|
||||
return tryResolveServerToken(getDataDir());
|
||||
},
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,12 +5,18 @@
|
|||
* `run`, `web`, and `status` all use.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { ServerLogLevel } from '@moonshot-ai/server';
|
||||
|
||||
export const DEFAULT_SERVER_HOST = '127.0.0.1';
|
||||
export const DEFAULT_SERVER_PORT = 58627;
|
||||
export const DEFAULT_SERVER_ORIGIN = serverOrigin(DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT);
|
||||
|
||||
/** Filename (under KIMI_CODE_HOME) of the persistent server bearer token. */
|
||||
export const SERVER_TOKEN_FILE = 'server.token';
|
||||
|
||||
export const DEFAULT_LOG_LEVEL: ServerLogLevel = 'info';
|
||||
export const DEFAULT_FOREGROUND_LOG_LEVEL: ServerLogLevel = 'silent';
|
||||
|
||||
|
|
@ -36,6 +42,12 @@ export interface ParsedServerOptions {
|
|||
port: number;
|
||||
logLevel: ServerLogLevel;
|
||||
debugEndpoints: boolean;
|
||||
/** Allow a non-loopback bind without a TLS-terminating reverse proxy. */
|
||||
insecureNoTls: boolean;
|
||||
/** Allow `POST /api/v1/shutdown` on a non-loopback bind. */
|
||||
allowRemoteShutdown: boolean;
|
||||
/** Allow PTY `/api/v1/terminals/*` routes on a non-loopback bind. */
|
||||
allowRemoteTerminals: boolean;
|
||||
/** Internal: run as an idle-exiting background daemon instead of foreground. */
|
||||
daemon: boolean;
|
||||
/** Internal: idle-shutdown grace in ms (daemon mode only). */
|
||||
|
|
@ -47,6 +59,12 @@ export interface ServerCliOptions {
|
|||
port?: string;
|
||||
logLevel?: string;
|
||||
debugEndpoints?: boolean;
|
||||
/** Allow a non-loopback bind without TLS (`--insecure-no-tls`). */
|
||||
insecureNoTls?: boolean;
|
||||
/** Allow remote shutdown on a non-loopback bind (`--allow-remote-shutdown`). */
|
||||
allowRemoteShutdown?: boolean;
|
||||
/** Allow remote terminals on a non-loopback bind (`--allow-remote-terminals`). */
|
||||
allowRemoteTerminals?: boolean;
|
||||
/** Internal flag set by the daemon spawner (`kimi web`). */
|
||||
daemon?: boolean;
|
||||
/** Internal flag set by the daemon spawner / tests. */
|
||||
|
|
@ -59,6 +77,9 @@ export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions
|
|||
port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT),
|
||||
logLevel: parseLogLevel(opts.logLevel ?? DEFAULT_FOREGROUND_LOG_LEVEL),
|
||||
debugEndpoints: opts.debugEndpoints === true,
|
||||
insecureNoTls: opts.insecureNoTls === true,
|
||||
allowRemoteShutdown: opts.allowRemoteShutdown === true,
|
||||
allowRemoteTerminals: opts.allowRemoteTerminals === true,
|
||||
daemon: opts.daemon === true,
|
||||
idleGraceMs: parseIdleGraceMs(opts.idleGraceMs),
|
||||
};
|
||||
|
|
@ -174,3 +195,41 @@ export async function ensureServerWebReady(origin: string): Promise<void> {
|
|||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persistent bearer token for the server.
|
||||
*
|
||||
* The server writes `<homeDir>/server.token` (0600) on first boot and reuses
|
||||
* it across restarts (ROADMAP M5.1); CLI commands that hit a gated REST route
|
||||
* read it back here and send it as `Authorization: Bearer <token>`. `homeDir`
|
||||
* is the CLI's own KIMI_CODE_HOME resolution (`getDataDir()`).
|
||||
*
|
||||
* Throws a clear error when the file is missing/unreadable — the usual cause
|
||||
* is a server that has never been started (no token file yet), or an older
|
||||
* build that predates token auth.
|
||||
*/
|
||||
export function resolveServerToken(homeDir: string): string {
|
||||
const tokenPath = join(homeDir, SERVER_TOKEN_FILE);
|
||||
try {
|
||||
return readFileSync(tokenPath, 'utf8').trim();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`unable to read server token at ${tokenPath}; has the server been started at least once?`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort token read: returns `undefined` instead of throwing. */
|
||||
export function tryResolveServerToken(homeDir: string): string | undefined {
|
||||
try {
|
||||
return resolveServerToken(homeDir);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** An `Authorization: Bearer <token>` header bag for `fetch`. */
|
||||
export function authHeaders(token: string): { Authorization: string } {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*/
|
||||
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { createServer, type Server } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
|
@ -45,7 +45,7 @@ describe('kimi server', () => {
|
|||
const server = program.commands.find((c) => c.name() === 'server');
|
||||
expect(server).toBeDefined();
|
||||
const subs = server?.commands.map((c) => c.name()).toSorted();
|
||||
expect(subs).toEqual(['kill', 'ps', 'run']);
|
||||
expect(subs).toEqual(['kill', 'ps', 'rotate-token', 'run']);
|
||||
});
|
||||
|
||||
it('`server run` exposes local-only foreground options', () => {
|
||||
|
|
@ -55,10 +55,13 @@ describe('kimi server', () => {
|
|||
?.commands.find((c) => c.name() === 'run');
|
||||
expect(run).toBeDefined();
|
||||
const longs = run!.options.map((o) => o.long).filter(Boolean);
|
||||
expect(longs).not.toContain('--host');
|
||||
expect(longs).toContain('--host');
|
||||
expect(longs).toContain('--port');
|
||||
expect(longs).toContain('--log-level');
|
||||
expect(longs).toContain('--debug-endpoints');
|
||||
expect(longs).toContain('--insecure-no-tls');
|
||||
expect(longs).toContain('--allow-remote-shutdown');
|
||||
expect(longs).toContain('--allow-remote-terminals');
|
||||
expect(longs).toContain('--foreground');
|
||||
// run defaults to NOT opening the browser → option is the positive --open
|
||||
expect(longs).toContain('--open');
|
||||
|
|
@ -87,7 +90,7 @@ describe('kimi server', () => {
|
|||
const longs = web!.options.map((o) => o.long).filter(Boolean);
|
||||
// web defaults to opening → the option is the negative form --no-open
|
||||
expect(longs).toContain('--no-open');
|
||||
expect(longs).not.toContain('--host');
|
||||
expect(longs).toContain('--host');
|
||||
expect(longs).toContain('--port');
|
||||
});
|
||||
});
|
||||
|
|
@ -333,6 +336,44 @@ describe('`kimi server run` background start', () => {
|
|||
expect(parsed).toMatchObject({ logLevel: 'debug' });
|
||||
});
|
||||
|
||||
it('warns and uses the running server host when a daemon is reused', async () => {
|
||||
const { handleRunCommand } = await import('#/cli/sub/server/run');
|
||||
let stdout = '';
|
||||
|
||||
// The user asks for a public bind, but a loopback daemon is already up.
|
||||
await handleRunCommand(
|
||||
{ port: '58627', host: '0.0.0.0' },
|
||||
{
|
||||
startServerBackground: async () => ({
|
||||
origin: 'http://127.0.0.1:58627',
|
||||
reused: true,
|
||||
host: '127.0.0.1',
|
||||
port: 58627,
|
||||
}),
|
||||
resolveToken: () => 'tok',
|
||||
openUrl: vi.fn(),
|
||||
stdout: {
|
||||
write(chunk: string | Uint8Array) {
|
||||
stdout += String(chunk);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
stderr: { write: () => true },
|
||||
},
|
||||
);
|
||||
|
||||
const plain = stripAnsi(stdout);
|
||||
// A clear notice that a server was already running and options were ignored.
|
||||
expect(plain).toContain('A server is already running');
|
||||
expect(plain).toContain('kimi server kill');
|
||||
// The banner uses the *actual* host (loopback), not the requested 0.0.0.0 —
|
||||
// so it shows a Local URL plus the "network disabled" hint, NOT real
|
||||
// Network addresses (which would be misleading since nothing binds them).
|
||||
expect(plain).toContain('http://127.0.0.1:58627/#token=tok');
|
||||
expect(plain).toContain('use --host 0.0.0.0 to enable');
|
||||
expect(plain).not.toContain('Network: http');
|
||||
});
|
||||
|
||||
it('prints a TUI-style ready panel once the daemon is up', async () => {
|
||||
const { handleRunCommand } = await import('#/cli/sub/server/run');
|
||||
let stdout = '';
|
||||
|
|
@ -357,21 +398,33 @@ describe('`kimi server run` background start', () => {
|
|||
);
|
||||
|
||||
const plain = stripAnsi(stdout);
|
||||
expect(plain).toContain('╭');
|
||||
expect(plain).toContain('╰');
|
||||
expect(plain).toContain('▐█▛█▛█▌');
|
||||
expect(plain).toContain('▐█████▌');
|
||||
expect(plain).toContain('Kimi server ready');
|
||||
expect(plain).toContain('URL:');
|
||||
expect(plain).toContain('Local:');
|
||||
expect(plain).toContain('http://127.0.0.1:58627/');
|
||||
// Loopback bind shows a Network hint for enabling network access.
|
||||
expect(plain).toContain('Network:');
|
||||
expect(plain).toContain('local only');
|
||||
expect(plain).toContain('use --host 0.0.0.0 to enable');
|
||||
expect(plain).toContain('Logs:');
|
||||
expect(plain).toContain('off');
|
||||
expect(plain).toContain('Stop:');
|
||||
expect(plain).toContain('kimi server kill');
|
||||
// Version sits on the title line; no separate Ready:/Version: rows and no
|
||||
// startup-time metric.
|
||||
expect(plain).not.toContain('Ready:');
|
||||
expect(plain).not.toContain('Version:');
|
||||
expect(plain).not.toContain(' ms');
|
||||
// No bordered panel (the token URL must print in full for copying), but
|
||||
// the Kimi sprite stays next to the title.
|
||||
expect(plain).not.toContain('╭');
|
||||
expect(plain).not.toContain('╰');
|
||||
expect(plain).toContain('▐█▛█▛█▌');
|
||||
expect(plain).toContain('▐█████▌');
|
||||
expect(plain).not.toContain('➜');
|
||||
expect(plain).not.toContain('Kimi server:');
|
||||
|
||||
// Title is above the URLs; Logs/Stop are at the bottom.
|
||||
expect(plain.indexOf('Kimi server ready')).toBeLessThan(plain.indexOf('Local:'));
|
||||
expect(plain.indexOf('Logs:')).toBeLessThan(plain.indexOf('Stop:'));
|
||||
});
|
||||
|
||||
it('uses the TUI dark palette for the ready banner', async () => {
|
||||
|
|
@ -407,8 +460,8 @@ describe('`kimi server run` background start', () => {
|
|||
expect(stdout).toContain(color.hex(darkColors.primary)('▐█▛█▛█▌'));
|
||||
expect(stdout).toContain(color.bold.hex(darkColors.primary)('Kimi server ready'));
|
||||
expect(stdout).toContain(color.hex(darkColors.accent)('http://127.0.0.1:58627/'));
|
||||
expect(stdout).toContain(color.bold.hex(darkColors.textDim)('URL: '));
|
||||
expect(stdout).toContain(color.hex(darkColors.textMuted)('local only'));
|
||||
expect(stdout).toContain(color.bold.hex(darkColors.textDim)('Local: '));
|
||||
expect(stdout).toContain(color.hex(darkColors.textMuted)('off'));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -564,6 +617,190 @@ async function allocateAdjacentFreeRun(count: number, host = '127.0.0.1'): Promi
|
|||
throw new Error('could not allocate a run of adjacent free ports');
|
||||
}
|
||||
|
||||
describe('--host threading (M6.2)', () => {
|
||||
it('passes --host through to the background daemon', async () => {
|
||||
const { handleRunCommand } = await import('#/cli/sub/server/run');
|
||||
let parsed: unknown;
|
||||
|
||||
await handleRunCommand(
|
||||
{ port: '58627', host: '0.0.0.0' },
|
||||
{
|
||||
startServerBackground: async (options) => {
|
||||
parsed = options;
|
||||
return { origin: 'http://0.0.0.0:58627' };
|
||||
},
|
||||
openUrl: vi.fn(),
|
||||
stdout: { write: () => true },
|
||||
stderr: { write: () => true },
|
||||
},
|
||||
);
|
||||
|
||||
expect(parsed).toMatchObject({ host: '0.0.0.0', port: 58627 });
|
||||
});
|
||||
|
||||
it('passes --host through to the foreground runner', async () => {
|
||||
const { handleRunCommand } = await import('#/cli/sub/server/run');
|
||||
let foregroundOptions: unknown;
|
||||
|
||||
await handleRunCommand(
|
||||
{ port: '58627', host: '0.0.0.0', foreground: true },
|
||||
{
|
||||
startServerBackground: async () => ({ origin: 'http://0.0.0.0:58627' }),
|
||||
startServerForeground: async (options) => {
|
||||
foregroundOptions = options;
|
||||
return undefined as unknown as never;
|
||||
},
|
||||
openUrl: vi.fn(),
|
||||
stdout: { write: () => true },
|
||||
stderr: { write: () => true },
|
||||
},
|
||||
);
|
||||
|
||||
expect(foregroundOptions).toMatchObject({ host: '0.0.0.0' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('lockConnectHost (M6.2 connect side)', () => {
|
||||
it('maps a 0.0.0.0 bind to 127.0.0.1 so the CLI connects over loopback', async () => {
|
||||
const { lockConnectHost } = await import('#/cli/sub/server/daemon');
|
||||
// The daemon binds 0.0.0.0 (all interfaces), but the local CLI must
|
||||
// connect over loopback — 0.0.0.0 is not a connectable address. The token
|
||||
// then rides on that loopback connection (covered by the M5.4 kill/ps
|
||||
// Authorization tests).
|
||||
expect(lockConnectHost({ pid: 1, started_at: '', port: 58627, host: '0.0.0.0' })).toBe(
|
||||
'127.0.0.1',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a loopback / concrete bind host', async () => {
|
||||
const { lockConnectHost } = await import('#/cli/sub/server/daemon');
|
||||
expect(lockConnectHost({ pid: 1, started_at: '', port: 58627, host: '127.0.0.1' })).toBe(
|
||||
'127.0.0.1',
|
||||
);
|
||||
expect(lockConnectHost({ pid: 1, started_at: '', port: 58627, host: '192.168.1.5' })).toBe(
|
||||
'192.168.1.5',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to 127.0.0.1 when the lock has no host', async () => {
|
||||
const { lockConnectHost } = await import('#/cli/sub/server/daemon');
|
||||
expect(lockConnectHost({ pid: 1, started_at: '', port: 58627 })).toBe('127.0.0.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('--insecure-no-tls threading (M6.3)', () => {
|
||||
it('threads --insecure-no-tls to the foreground runner', async () => {
|
||||
const { handleRunCommand } = await import('#/cli/sub/server/run');
|
||||
let foregroundOptions: unknown;
|
||||
|
||||
await handleRunCommand(
|
||||
{ host: '0.0.0.0', insecureNoTls: true, foreground: true },
|
||||
{
|
||||
startServerBackground: async () => ({ origin: 'http://0.0.0.0:58627' }),
|
||||
startServerForeground: async (options) => {
|
||||
foregroundOptions = options;
|
||||
return undefined as unknown as never;
|
||||
},
|
||||
openUrl: vi.fn(),
|
||||
stdout: { write: () => true },
|
||||
stderr: { write: () => true },
|
||||
},
|
||||
);
|
||||
|
||||
expect(foregroundOptions).toMatchObject({ host: '0.0.0.0', insecureNoTls: true });
|
||||
});
|
||||
|
||||
it('threads --insecure-no-tls to the background daemon', async () => {
|
||||
const { handleRunCommand } = await import('#/cli/sub/server/run');
|
||||
let parsed: unknown;
|
||||
|
||||
await handleRunCommand(
|
||||
{ host: '0.0.0.0', insecureNoTls: true },
|
||||
{
|
||||
startServerBackground: async (options) => {
|
||||
parsed = options;
|
||||
return { origin: 'http://0.0.0.0:58627' };
|
||||
},
|
||||
openUrl: vi.fn(),
|
||||
stdout: { write: () => true },
|
||||
stderr: { write: () => true },
|
||||
},
|
||||
);
|
||||
|
||||
expect(parsed).toMatchObject({ insecureNoTls: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ready banner reflects the bind class (M6.3)', () => {
|
||||
it('lists Local + Network addresses for a 0.0.0.0 bind (Vite-style)', async () => {
|
||||
const { handleRunCommand } = await import('#/cli/sub/server/run');
|
||||
let stdout = '';
|
||||
|
||||
await handleRunCommand(
|
||||
{ host: '0.0.0.0', insecureNoTls: true },
|
||||
{
|
||||
startServerBackground: async () => ({ origin: 'http://0.0.0.0:58627' }),
|
||||
resolveToken: () => 'tok-xyz',
|
||||
networkAddresses: [
|
||||
{ address: '192.168.98.66', family: 'IPv4' },
|
||||
{ address: '10.8.12.216', family: 'IPv4' },
|
||||
],
|
||||
openUrl: vi.fn(),
|
||||
stdout: {
|
||||
write(chunk: string | Uint8Array) {
|
||||
stdout += String(chunk);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
stderr: { write: () => true },
|
||||
},
|
||||
);
|
||||
|
||||
const raw = stripAnsi(stdout);
|
||||
expect(raw).toContain('Kimi server ready');
|
||||
expect(raw).toContain('Local:');
|
||||
expect(raw).toContain('Network:');
|
||||
// Full token-bearing URLs are printed plainly (no box, no truncation) so
|
||||
// they are easy to copy.
|
||||
expect(raw).toContain('http://localhost:58627/#token=tok-xyz');
|
||||
expect(raw).toContain('http://192.168.98.66:58627/#token=tok-xyz');
|
||||
expect(raw).toContain('http://10.8.12.216:58627/#token=tok-xyz');
|
||||
expect(raw).toContain('Token:');
|
||||
expect(raw).toContain('tok-xyz');
|
||||
expect(raw).not.toContain('╭');
|
||||
});
|
||||
|
||||
it('prints the Local URL and token for a 127.0.0.1 bind', async () => {
|
||||
const { handleRunCommand } = await import('#/cli/sub/server/run');
|
||||
let stdout = '';
|
||||
|
||||
await handleRunCommand(
|
||||
{ host: '127.0.0.1' },
|
||||
{
|
||||
startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }),
|
||||
resolveToken: () => 'tok-loop',
|
||||
openUrl: vi.fn(),
|
||||
stdout: {
|
||||
write(chunk: string | Uint8Array) {
|
||||
stdout += String(chunk);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
stderr: { write: () => true },
|
||||
},
|
||||
);
|
||||
|
||||
const raw = stripAnsi(stdout);
|
||||
expect(raw).toContain('Kimi server ready');
|
||||
expect(raw).toContain('Local:');
|
||||
// Full token-bearing URL, printed plainly for copying.
|
||||
expect(raw).toContain('http://127.0.0.1:58627/#token=tok-loop');
|
||||
expect(raw).toContain('Token:');
|
||||
expect(raw).toContain('tok-loop');
|
||||
expect(raw).not.toContain('╭');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDaemonPort', () => {
|
||||
it('returns the preferred port when it is free', async () => {
|
||||
const { resolveDaemonPort } = await import('#/cli/sub/server/daemon');
|
||||
|
|
@ -673,6 +910,94 @@ describe('spawnDaemonChild', () => {
|
|||
expect(options).toMatchObject({ detached: true, cwd: dirname(daemonLogPath()) });
|
||||
expect(options?.cwd).not.toBe(process.cwd());
|
||||
});
|
||||
|
||||
it('passes --host through to the daemon child args (M6.2)', async () => {
|
||||
const { spawn } = await import('node:child_process');
|
||||
const spawnMock = vi.mocked(spawn);
|
||||
spawnMock.mockClear();
|
||||
spawnMock.mockReturnValue({ unref: vi.fn(), once: vi.fn() } as unknown as ChildProcess);
|
||||
|
||||
const { spawnDaemonChild } = await import('#/cli/sub/server/daemon');
|
||||
spawnDaemonChild({ host: '0.0.0.0', port: 58627, logLevel: 'info' });
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0]!;
|
||||
expect(args).toEqual(expect.arrayContaining(['--host', '0.0.0.0']));
|
||||
});
|
||||
|
||||
it('passes --insecure-no-tls through to the daemon child args (M6.3)', async () => {
|
||||
const { spawn } = await import('node:child_process');
|
||||
const spawnMock = vi.mocked(spawn);
|
||||
spawnMock.mockClear();
|
||||
spawnMock.mockReturnValue({ unref: vi.fn(), once: vi.fn() } as unknown as ChildProcess);
|
||||
|
||||
const { spawnDaemonChild } = await import('#/cli/sub/server/daemon');
|
||||
spawnDaemonChild({ host: '0.0.0.0', port: 58627, logLevel: 'info', insecureNoTls: true });
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0]!;
|
||||
expect(args).toEqual(expect.arrayContaining(['--insecure-no-tls']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureDaemon surfaces boot failures via early exit', () => {
|
||||
let workDir: string;
|
||||
let prevHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
workDir = mkdtempSync(join(tmpdir(), 'kimi-ensure-exit-'));
|
||||
prevHome = process.env['KIMI_CODE_HOME'];
|
||||
process.env['KIMI_CODE_HOME'] = workDir;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (prevHome === undefined) {
|
||||
delete process.env['KIMI_CODE_HOME'];
|
||||
} else {
|
||||
process.env['KIMI_CODE_HOME'] = prevHome;
|
||||
}
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rejects fast with the exit reason and a log tail when the daemon exits early', async () => {
|
||||
const { spawn } = await import('node:child_process');
|
||||
const spawnMock = vi.mocked(spawn);
|
||||
const { mkdirSync, writeFileSync: writeSync } = await import('node:fs');
|
||||
const { daemonLogPath, ensureDaemon } = await import('#/cli/sub/server/daemon');
|
||||
|
||||
// Seed the daemon log with the kind of line a failing boot writes, so we
|
||||
// can assert it is surfaced to the user instead of a generic timeout.
|
||||
mkdirSync(dirname(daemonLogPath()), { recursive: true });
|
||||
writeSync(daemonLogPath(), 'fatal: Refusing to bind a non-loopback host without TLS.\n');
|
||||
|
||||
// Fake child that exits with code 1 shortly after the 'exit' listener is
|
||||
// attached — simulating a daemon that fails during boot.
|
||||
const fakeChild = {
|
||||
unref: vi.fn(),
|
||||
once: vi.fn((event: string, cb: (...a: unknown[]) => void) => {
|
||||
if (event === 'exit') {
|
||||
setTimeout(() => cb(1, null), 5);
|
||||
}
|
||||
return fakeChild;
|
||||
}),
|
||||
};
|
||||
spawnMock.mockReturnValueOnce(fakeChild as unknown as ChildProcess);
|
||||
|
||||
const start = Date.now();
|
||||
let caught: unknown;
|
||||
try {
|
||||
await ensureDaemon({ port: 0 });
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
const message = (caught as Error).message;
|
||||
expect(message).toMatch(/exited with code 1/);
|
||||
expect(message).toContain('Refusing to bind');
|
||||
// Must fail fast — nowhere near the 20s spawn timeout.
|
||||
expect(elapsed).toBeLessThan(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createIdleShutdownHandler', () => {
|
||||
|
|
@ -809,6 +1134,7 @@ function makeKillDeps(overrides: Partial<KillCommandDeps> = {}): {
|
|||
requestShutdown: async () => {
|
||||
state.shutdownCalls += 1;
|
||||
},
|
||||
resolveToken: () => undefined,
|
||||
signalPid: (pid, signal) => {
|
||||
signals.push({ pid, signal });
|
||||
return true;
|
||||
|
|
@ -883,5 +1209,271 @@ describe('`kimi server kill`', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('resolveServerToken', () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'kimi-server-token-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads the token from <homeDir>/server.token', async () => {
|
||||
const { resolveServerToken } = await import('#/cli/sub/server/shared');
|
||||
writeFileSync(join(dir, 'server.token'), 'secret-token\n');
|
||||
expect(resolveServerToken(dir)).toBe('secret-token');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace', async () => {
|
||||
const { resolveServerToken } = await import('#/cli/sub/server/shared');
|
||||
writeFileSync(join(dir, 'server.token'), ' tok \n');
|
||||
expect(resolveServerToken(dir)).toBe('tok');
|
||||
});
|
||||
|
||||
it('throws a clear error when the token file is missing', async () => {
|
||||
const { resolveServerToken } = await import('#/cli/sub/server/shared');
|
||||
expect(() => resolveServerToken(dir)).toThrow(/unable to read server token/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authHeaders', () => {
|
||||
it('builds a Bearer Authorization header', async () => {
|
||||
const { authHeaders } = await import('#/cli/sub/server/shared');
|
||||
expect(authHeaders('abc')).toEqual({ Authorization: 'Bearer abc' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('`kimi server kill` carries the bearer token', () => {
|
||||
const liveLock = { pid: 1234, started_at: '2026-06-17T00:00:00.000Z', port: 58627 };
|
||||
|
||||
it('passes the resolved token to requestShutdown', async () => {
|
||||
const { handleKillCommand } = await import('#/cli/sub/server/kill');
|
||||
let seenToken: string | undefined = 'unset';
|
||||
const { deps } = makeKillDeps({
|
||||
getLiveLock: () => liveLock,
|
||||
resolveToken: () => 'tok-123',
|
||||
requestShutdown: async (_origin, token) => {
|
||||
seenToken = token;
|
||||
},
|
||||
pidAlive: () => false,
|
||||
});
|
||||
|
||||
await handleKillCommand(deps);
|
||||
|
||||
expect(seenToken).toBe('tok-123');
|
||||
});
|
||||
|
||||
it('passes undefined when the token cannot be read (best-effort)', async () => {
|
||||
const { handleKillCommand } = await import('#/cli/sub/server/kill');
|
||||
let seenToken: string | undefined = 'unset';
|
||||
const { deps } = makeKillDeps({
|
||||
getLiveLock: () => liveLock,
|
||||
resolveToken: () => undefined,
|
||||
requestShutdown: async (_origin, token) => {
|
||||
seenToken = token;
|
||||
},
|
||||
pidAlive: () => false,
|
||||
});
|
||||
|
||||
await handleKillCommand(deps);
|
||||
|
||||
expect(seenToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWebUrl', () => {
|
||||
it('carries the token in the URL fragment (not path or query)', async () => {
|
||||
const { buildWebUrl } = await import('#/cli/sub/server/run');
|
||||
const url = buildWebUrl('http://127.0.0.1:58627', 'abc123');
|
||||
expect(url).toBe('http://127.0.0.1:58627/#token=abc123');
|
||||
const parsed = new URL(url);
|
||||
expect(parsed.hash).toBe('#token=abc123');
|
||||
// The token is client-side only: it must NOT appear in the path or query
|
||||
// (which WOULD be sent to the server and logged).
|
||||
expect(parsed.pathname).not.toContain('abc123');
|
||||
expect(parsed.search).not.toContain('abc123');
|
||||
});
|
||||
|
||||
it('normalizes a trailing slash', async () => {
|
||||
const { buildWebUrl } = await import('#/cli/sub/server/run');
|
||||
expect(buildWebUrl('http://127.0.0.1:58627/', 't')).toBe(
|
||||
'http://127.0.0.1:58627/#token=t',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessUrlLines', () => {
|
||||
it('returns Local + Network lines for a wildcard bind', async () => {
|
||||
const { accessUrlLines } = await import('#/cli/sub/server/access-urls');
|
||||
const lines = accessUrlLines('0.0.0.0', 58627, 'tok', [
|
||||
{ address: '192.168.1.5', family: 'IPv4' },
|
||||
]);
|
||||
expect(lines).toEqual([
|
||||
{ label: 'Local: ', url: 'http://localhost:58627/#token=tok' },
|
||||
{ label: 'Network: ', url: 'http://192.168.1.5:58627/#token=tok' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns a single Local line for a loopback bind', async () => {
|
||||
const { accessUrlLines } = await import('#/cli/sub/server/access-urls');
|
||||
const lines = accessUrlLines('127.0.0.1', 58627, 'tok');
|
||||
expect(lines).toEqual([
|
||||
{ label: 'Local: ', url: 'http://127.0.0.1:58627/#token=tok' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns a single URL line for a specific host (no token)', async () => {
|
||||
const { accessUrlLines } = await import('#/cli/sub/server/access-urls');
|
||||
const lines = accessUrlLines('192.168.1.5', 58627, undefined);
|
||||
expect(lines).toEqual([{ label: 'URL: ', url: 'http://192.168.1.5:58627/' }]);
|
||||
});
|
||||
|
||||
it('splitTokenFragment splits off the #token= fragment', async () => {
|
||||
const { splitTokenFragment } = await import('#/cli/sub/server/access-urls');
|
||||
expect(splitTokenFragment('http://h:1/#token=abc')).toEqual(['http://h:1/', '#token=abc']);
|
||||
expect(splitTokenFragment('http://h:1/')).toEqual(['http://h:1/', '']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('`kimi web` / `server run --open` token fragment (M5.5)', () => {
|
||||
it('opens the Web UI URL with the token fragment when a token is resolvable', async () => {
|
||||
const { handleRunCommand } = await import('#/cli/sub/server/run');
|
||||
const openUrl = vi.fn();
|
||||
await handleRunCommand(
|
||||
{ port: '58627', open: true },
|
||||
{
|
||||
startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }),
|
||||
resolveToken: () => 'tok-xyz',
|
||||
openUrl,
|
||||
stdout: { write: () => true },
|
||||
stderr: { write: () => true },
|
||||
},
|
||||
);
|
||||
expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/#token=tok-xyz');
|
||||
});
|
||||
|
||||
it('opens the plain origin when no token is resolvable', async () => {
|
||||
const { handleRunCommand } = await import('#/cli/sub/server/run');
|
||||
const openUrl = vi.fn();
|
||||
await handleRunCommand(
|
||||
{ port: '58627', open: true },
|
||||
{
|
||||
startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }),
|
||||
resolveToken: () => undefined,
|
||||
openUrl,
|
||||
stdout: { write: () => true },
|
||||
stderr: { write: () => true },
|
||||
},
|
||||
);
|
||||
expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627');
|
||||
});
|
||||
});
|
||||
|
||||
describe('`kimi server rotate-token`', () => {
|
||||
let dir: string;
|
||||
let prevHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'kimi-rotate-'));
|
||||
prevHome = process.env['KIMI_CODE_HOME'];
|
||||
process.env['KIMI_CODE_HOME'] = dir;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (prevHome === undefined) {
|
||||
delete process.env['KIMI_CODE_HOME'];
|
||||
} else {
|
||||
process.env['KIMI_CODE_HOME'] = prevHome;
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes a new token to server.token and prints it', async () => {
|
||||
const { registerServerCommand } = await import('#/cli/sub/server');
|
||||
const program = new Command('kimi').exitOverride();
|
||||
registerServerCommand(program);
|
||||
let stdout = '';
|
||||
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
stdout += String(chunk);
|
||||
return true;
|
||||
});
|
||||
|
||||
await program.parseAsync(['node', 'kimi', 'server', 'rotate-token']);
|
||||
writeSpy.mockRestore();
|
||||
|
||||
const token = readFileSync(join(dir, 'server.token'), 'utf8').trim();
|
||||
expect(token.length).toBeGreaterThan(20);
|
||||
expect(stdout).toContain('New server token');
|
||||
expect(stdout).toContain(token);
|
||||
});
|
||||
|
||||
it('re-prints the access links with the new token when a server is running', async () => {
|
||||
const { registerServerCommand } = await import('#/cli/sub/server');
|
||||
const { mkdirSync, writeFileSync: writeSync } = await import('node:fs');
|
||||
// Fake a live lock pointing at this (alive) process so getLiveLock() finds
|
||||
// the running server and the command can re-print its links.
|
||||
mkdirSync(join(dir, 'server'), { recursive: true });
|
||||
writeSync(
|
||||
join(dir, 'server', 'lock'),
|
||||
JSON.stringify({
|
||||
pid: process.pid,
|
||||
started_at: new Date().toISOString(),
|
||||
port: 58627,
|
||||
host: '127.0.0.1',
|
||||
}),
|
||||
);
|
||||
|
||||
const program = new Command('kimi').exitOverride();
|
||||
registerServerCommand(program);
|
||||
let stdout = '';
|
||||
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
stdout += String(chunk);
|
||||
return true;
|
||||
});
|
||||
|
||||
await program.parseAsync(['node', 'kimi', 'server', 'rotate-token']);
|
||||
writeSpy.mockRestore();
|
||||
|
||||
const token = readFileSync(join(dir, 'server.token'), 'utf8').trim();
|
||||
expect(stdout).toContain('New server token');
|
||||
expect(stdout).toContain(`http://127.0.0.1:58627/#token=${token}`);
|
||||
// Token line sits between the note and the links.
|
||||
expect(stdout.indexOf('picks up the new token')).toBeLessThan(
|
||||
stdout.indexOf('New server token'),
|
||||
);
|
||||
expect(stdout.indexOf('New server token')).toBeLessThan(
|
||||
stdout.indexOf(`http://127.0.0.1:58627/#token=${token}`),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatHostForUrl', () => {
|
||||
it('bracket-wraps IPv6 and leaves IPv4 as-is', async () => {
|
||||
const { formatHostForUrl } = await import('#/cli/sub/server/networks');
|
||||
expect(formatHostForUrl('192.168.1.5', 'IPv4')).toBe('192.168.1.5');
|
||||
expect(formatHostForUrl('fe80::1', 'IPv6')).toBe('[fe80::1]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterDisplayAddresses', () => {
|
||||
it('drops IPv6 link-local, de-duplicates, and orders IPv4 before IPv6', async () => {
|
||||
const { filterDisplayAddresses } = await import('#/cli/sub/server/networks');
|
||||
const out = filterDisplayAddresses([
|
||||
{ address: 'fe80::ecf3:c2ff:fe9c:11c3', family: 'IPv6' },
|
||||
{ address: '192.168.1.5', family: 'IPv4' },
|
||||
{ address: 'fe80::ecf3:c2ff:fe9c:11c3', family: 'IPv6' },
|
||||
{ address: '10.0.0.1', family: 'IPv4' },
|
||||
{ address: 'fe80::1', family: 'IPv6' },
|
||||
{ address: '2001:db8::1', family: 'IPv6' },
|
||||
]);
|
||||
expect(out).toEqual([
|
||||
{ address: '192.168.1.5', family: 'IPv4' },
|
||||
{ address: '10.0.0.1', family: 'IPv4' },
|
||||
{ address: '2001:db8::1', family: 'IPv6' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Silence vi import for cases where the file is built before tests reference vi.
|
||||
void vi;
|
||||
|
|
|
|||
|
|
@ -33,8 +33,16 @@ import { useSidebarLayout } from './composables/useSidebarLayout';
|
|||
import { useFilePreview, type DetailTarget } from './composables/useFilePreview';
|
||||
import { useDetailPanel } from './composables/useDetailPanel';
|
||||
import { useIsMobile } from './composables/useIsMobile';
|
||||
import ServerAuthDialog from './components/ServerAuthDialog.vue';
|
||||
import { initServerAuth, onAuthRequired } from './api/daemon/serverAuth';
|
||||
import type { AppConfig, ThinkingLevel } from './api/types';
|
||||
|
||||
// Hydrate the server-transport credential (fragment token or sessionStorage)
|
||||
// BEFORE the client connects, so the first REST/WS calls already carry it.
|
||||
const hasServerCredential = initServerAuth();
|
||||
const showServerAuth = ref(!hasServerCredential);
|
||||
let offAuthRequired: (() => void) | null = null;
|
||||
|
||||
const client = useKimiWebClient();
|
||||
provide('resolveImage', client.resolveImageUrl);
|
||||
const { t } = useI18n();
|
||||
|
|
@ -98,10 +106,17 @@ onMounted(() => {
|
|||
// Capture-phase so Escape closes the side detail layer BEFORE the
|
||||
// conversation pane's bubble-phase handler interrupts a running prompt.
|
||||
document.addEventListener('keydown', onGlobalKeydown, true);
|
||||
offAuthRequired = onAuthRequired(() => {
|
||||
showServerAuth.value = true;
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', onGlobalKeydown, true);
|
||||
if (offAuthRequired !== null) {
|
||||
offAuthRequired();
|
||||
offAuthRequired = null;
|
||||
}
|
||||
});
|
||||
|
||||
function onGlobalKeydown(e: KeyboardEvent): void {
|
||||
|
|
@ -504,6 +519,7 @@ function openPr(url: string): void {
|
|||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<ServerAuthDialog v-if="showServerAuth" />
|
||||
<section v-if="showAuthGate" class="auth-page">
|
||||
<div class="auth-page-inner">
|
||||
<svg ref="authLogoRef" class="auth-page-logo ch-logo" viewBox="0 0 32 22" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Kimi Code" @mousedown.prevent @click="blinkAuthLogo">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { buildRestUrl } from '../config';
|
||||
import { DaemonApiError, DaemonNetworkError } from '../errors';
|
||||
import { traceRestFailure, traceRestRequest, traceRestResponse } from '../../debug/trace';
|
||||
import { getCredential, markAuthRequired } from './serverAuth';
|
||||
import type { WireEnvelope } from './wire';
|
||||
|
||||
/** Per-request timeout. Without one, a hung connection (half-open TCP after a
|
||||
|
|
@ -14,6 +15,10 @@ const REQUEST_TIMEOUT_MS = 30_000;
|
|||
const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||||
const BODY_PREVIEW_LIMIT = 500;
|
||||
|
||||
// Server-transport auth failure envelope code (see packages/server
|
||||
// middleware/auth.ts AUTH_ERROR_CODE). Distinct from provider-auth 40110–40113.
|
||||
const SERVER_AUTH_UNAUTHORIZED_CODE = 40101;
|
||||
|
||||
export interface DaemonHttpClientIdentity {
|
||||
readonly clientId: string;
|
||||
readonly clientName: string;
|
||||
|
|
@ -155,6 +160,7 @@ export class DaemonHttpClient {
|
|||
envelopeRequestId: envelope.request_id,
|
||||
data: envelope.data,
|
||||
});
|
||||
this.checkAuthRequired(response, envelope.code);
|
||||
if (envelope.code !== 0) {
|
||||
throw new DaemonApiError({
|
||||
code: envelope.code,
|
||||
|
|
@ -265,6 +271,8 @@ export class DaemonHttpClient {
|
|||
data: envelope.data,
|
||||
});
|
||||
|
||||
this.checkAuthRequired(response, envelope.code);
|
||||
|
||||
// Unwrap: code 0 = success; allowed non-zero = return data; else throw
|
||||
if (envelope.code !== 0 && !allowCodes.includes(envelope.code)) {
|
||||
throw new DaemonApiError({
|
||||
|
|
@ -281,10 +289,23 @@ export class DaemonHttpClient {
|
|||
}
|
||||
|
||||
private addClientHeaders(headers: Record<string, string>): void {
|
||||
const credential = getCredential();
|
||||
if (credential !== undefined) {
|
||||
headers['Authorization'] = `Bearer ${credential}`;
|
||||
}
|
||||
if (this.identity === undefined) return;
|
||||
headers['X-Kimi-Client-Id'] = this.identity.clientId;
|
||||
headers['X-Kimi-Client-Name'] = this.identity.clientName;
|
||||
headers['X-Kimi-Client-Version'] = this.identity.clientVersion;
|
||||
headers['X-Kimi-Client-Ui-Mode'] = this.identity.clientUiMode;
|
||||
}
|
||||
|
||||
private checkAuthRequired(response: Response, envelopeCode: number): void {
|
||||
if (
|
||||
response.status === 401 ||
|
||||
envelopeCode === SERVER_AUTH_UNAUTHORIZED_CODE
|
||||
) {
|
||||
markAuthRequired();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
116
apps/kimi-web/src/api/daemon/serverAuth.ts
Normal file
116
apps/kimi-web/src/api/daemon/serverAuth.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// apps/kimi-web/src/api/daemon/serverAuth.ts
|
||||
// Minimal server-transport credential store for the Web UI.
|
||||
//
|
||||
// The local server now requires a bearer credential on every non-bypass API
|
||||
// and WebSocket call (the persistent server token, or the KIMI_CODE_PASSWORD
|
||||
// password). The Web UI obtains that credential in one of two ways:
|
||||
// 1. From the URL fragment (`#token=<...>`) that `kimi web` appends when it
|
||||
// opens the browser — read once at boot, then scrubbed from the URL so it
|
||||
// does not linger in history or screenshots.
|
||||
// 2. From a token the user types into the ServerAuthDialog modal.
|
||||
//
|
||||
// The credential is held in memory and mirrored to sessionStorage so a page
|
||||
// refresh keeps working without re-prompting (sessionStorage is tab-scoped and
|
||||
// cleared when the tab closes — we deliberately do NOT use localStorage, since
|
||||
// the credential authenticates as the server).
|
||||
|
||||
const STORAGE_KEY = 'kimi-web.server-credential';
|
||||
const FRAGMENT_PARAM = 'token';
|
||||
|
||||
let memory: string | undefined;
|
||||
|
||||
type AuthRequiredListener = () => void;
|
||||
const listeners = new Set<AuthRequiredListener>();
|
||||
|
||||
function readFragmentToken(): string | undefined {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
const hash = window.location.hash ?? '';
|
||||
if (!hash.startsWith('#')) return undefined;
|
||||
const params = new URLSearchParams(hash.slice(1));
|
||||
const token = params.get(FRAGMENT_PARAM);
|
||||
if (!token) return undefined;
|
||||
// Scrub the fragment (keep path + query) so the token is not left in the
|
||||
// address bar, browser history, or any screenshot of the window.
|
||||
const url = new URL(window.location.href);
|
||||
url.hash = '';
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
`${url.pathname}${url.search}`,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
function loadStored(): string | undefined {
|
||||
try {
|
||||
return globalThis.sessionStorage?.getItem(STORAGE_KEY) ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the credential store. Call once at app boot (before the first
|
||||
* API/WS call). Prefers a fragment token over a stored one. Returns true if a
|
||||
* credential is available afterwards (so the caller can skip the modal).
|
||||
*/
|
||||
export function initServerAuth(): boolean {
|
||||
const fragment = readFragmentToken();
|
||||
if (fragment) {
|
||||
setCredential(fragment);
|
||||
return true;
|
||||
}
|
||||
memory = loadStored();
|
||||
return memory !== undefined;
|
||||
}
|
||||
|
||||
/** Current credential, or undefined if none has been provided yet. */
|
||||
export function getCredential(): string | undefined {
|
||||
return memory;
|
||||
}
|
||||
|
||||
/** Store a credential (memory + sessionStorage) for subsequent requests. */
|
||||
export function setCredential(value: string): void {
|
||||
memory = value;
|
||||
try {
|
||||
globalThis.sessionStorage?.setItem(STORAGE_KEY, value);
|
||||
} catch {
|
||||
// sessionStorage may be unavailable (private mode) — memory still works.
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the credential (memory + sessionStorage). */
|
||||
export function clearCredential(): void {
|
||||
memory = undefined;
|
||||
try {
|
||||
globalThis.sessionStorage?.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a listener invoked when the server rejects our credential (HTTP 401
|
||||
* / envelope code 40101). Returns an unsubscribe function.
|
||||
*/
|
||||
export function onAuthRequired(listener: AuthRequiredListener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the HTTP/WS transport when the server rejects the current
|
||||
* credential. Clears it and notifies listeners (the App shows the modal).
|
||||
*/
|
||||
export function markAuthRequired(): void {
|
||||
clearCredential();
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch {
|
||||
// a failing listener must not break transport handling
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,8 +5,14 @@
|
|||
|
||||
import { traceWsIn, traceWsLifecycle, traceWsOut } from '../../debug/trace';
|
||||
import { classifyFrame } from './agentEventProjector';
|
||||
import { getCredential } from './serverAuth';
|
||||
import type { WireEvent, WireServerFrame } from './wire';
|
||||
|
||||
// Mirrors packages/server WS_BEARER_PROTOCOL_PREFIX. The browser WebSocket API
|
||||
// cannot set arbitrary headers, so the bearer credential rides in the
|
||||
// Sec-WebSocket-Protocol subprotocol instead.
|
||||
const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -89,7 +95,10 @@ export class DaemonEventSocket {
|
|||
if (this.ws !== null || this.closed) return;
|
||||
|
||||
traceWsLifecycle('connect', { url: this.wsUrl, attempt: this.reconnectAttempts });
|
||||
const ws = new WebSocket(this.wsUrl);
|
||||
const credential = getCredential();
|
||||
const protocols =
|
||||
credential !== undefined ? [`${WS_BEARER_PROTOCOL_PREFIX}${credential}`] : undefined;
|
||||
const ws = new WebSocket(this.wsUrl, protocols);
|
||||
this.ws = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
|
|
|
|||
148
apps/kimi-web/src/components/ServerAuthDialog.vue
Normal file
148
apps/kimi-web/src/components/ServerAuthDialog.vue
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<!-- apps/kimi-web/src/components/ServerAuthDialog.vue -->
|
||||
<!-- Minimal token prompt shown when the Web UI has no server-transport
|
||||
credential, or when the server rejects it (HTTP 401). On submit we store
|
||||
the token as the bearer credential and reload so every REST/WS call picks
|
||||
it up. The overlay is fully opaque so it covers the whole page (nothing
|
||||
underneath shows through). Light only, Kimi blue #1565C0, no emoji. -->
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref } from 'vue';
|
||||
import { setCredential } from '../api/daemon/serverAuth';
|
||||
|
||||
const credential = ref('');
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
const submitting = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
void nextTick(() => inputRef.value?.focus());
|
||||
});
|
||||
|
||||
function submit(): void {
|
||||
const value = credential.value;
|
||||
if (!value || submitting.value) return;
|
||||
submitting.value = true;
|
||||
setCredential(value);
|
||||
// Reload so the HTTP client and WebSocket reconnect with the new credential.
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="server-auth-overlay" role="dialog" aria-modal="true" aria-labelledby="server-auth-title">
|
||||
<div class="server-auth-card">
|
||||
<h1 id="server-auth-title" class="server-auth-title">Server token required</h1>
|
||||
<p class="server-auth-hint">
|
||||
This server is protected. Enter the bearer token printed when the server
|
||||
started (or the password set via <code>KIMI_CODE_PASSWORD</code>).
|
||||
</p>
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="credential"
|
||||
type="password"
|
||||
class="server-auth-input"
|
||||
autocomplete="current-password"
|
||||
placeholder="Token"
|
||||
:disabled="submitting"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="server-auth-button"
|
||||
:disabled="!credential || submitting"
|
||||
@click="submit"
|
||||
>
|
||||
{{ submitting ? 'Connecting…' : 'Connect' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.server-auth-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* Fully opaque so the dialog covers the whole page — nothing underneath
|
||||
(e.g. the login page) shows through. */
|
||||
background: var(--bg, #ffffff);
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.server-auth-card {
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
padding: 28px 28px 24px;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.server-auth-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.server-auth-hint {
|
||||
margin: 0 0 18px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.server-auth-hint code {
|
||||
padding: 1px 5px;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.server-auth-input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
background: #fff;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.server-auth-input:focus {
|
||||
border-color: #1565c0;
|
||||
box-shadow: 0 0 0 3px rgba(21, 101, 192, 0.15);
|
||||
}
|
||||
|
||||
.server-auth-button {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: #1565c0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.server-auth-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.server-auth-button:not(:disabled):hover {
|
||||
background: #0d47a1;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -150,7 +150,7 @@
|
|||
inherit (finalAttrs) pname version src pnpmWorkspaces;
|
||||
inherit pnpm;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-yukns+b5mjDgp/TuFco24FH5pjJm/Ivlw71gMyhSl/Q=";
|
||||
hash = "sha256-O9xDt/5bCakst2mKTyki3oyUph1g+CuH/BIqA/4fgYE=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = [
|
||||
|
|
|
|||
192
packages/server/SECURITY.md
Normal file
192
packages/server/SECURITY.md
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
# Server deployment security
|
||||
|
||||
Operational guide for exposing the `@moonshot-ai/server` HTTP/WebSocket API beyond
|
||||
`127.0.0.1`. For reporting vulnerabilities, see the repo root `SECURITY.md`.
|
||||
|
||||
## Threat model
|
||||
|
||||
The server classifies the bind host into one of three tiers
|
||||
(`services/auth/bindClassify.ts`):
|
||||
|
||||
| Tier | Bind host | Trust boundary | Primary threats | Mitigations |
|
||||
|---|---|---|---|---|
|
||||
| loopback | `127.0.0.0/8`, `::1`, `localhost` (default) | This host only; local process isolation | Same-host processes/users connecting to the port; malicious web pages hitting `localhost` (CSRF / DNS rebinding) | Persistent bearer token; Host/Origin checks; token file `0600` (this user only) |
|
||||
| LAN | RFC1918 + `169.254/16`, `fe80::/10` (`--host 192.168.x.x`) | The local network is untrusted | Network-reachable attackers; brute force; CSRF; remote shell / shutdown | **Full hardening (same as public):** bearer token auth (password optional), TLS or explicit opt-out, auth-failure rate limiting, dangerous endpoints disabled, security headers |
|
||||
| public | Everything else; `0.0.0.0` / `::` / empty | The whole internet is untrusted | Same as LAN, at internet scale | Same hardening as LAN; a TLS-terminating reverse proxy (or tunnel) is strongly recommended |
|
||||
|
||||
**Important:** the hardening gate in `start.ts` is `bindClass !== 'loopback'`. LAN and
|
||||
public binds therefore receive the **same** hardening stack (TLS opt-out + rate-limit
|
||||
+ endpoint downgrade + security headers). Authentication is the persistent bearer
|
||||
token (printed in the startup banner); `KIMI_CODE_PASSWORD` is an optional additional
|
||||
credential on every tier. The tier label only changes the banner and the automatic
|
||||
Host allowlist entry. Treat LAN exposure with the same care as public exposure.
|
||||
|
||||
Not in scope (see PLAN §6): NAT traversal, untrusted relays, end-to-end encryption.
|
||||
|
||||
## Default (loopback) deployment
|
||||
|
||||
```
|
||||
kimi server run # or: kimi web
|
||||
```
|
||||
|
||||
- Binds `127.0.0.1:58627` by default (`--host` / `--port` to override).
|
||||
- Uses a persistent bearer token (`crypto.randomBytes(32)`, base64url) generated
|
||||
once on first boot and written to `<KIMI_CODE_HOME>/server.token` with mode
|
||||
`0600` (parent directory `0700`). The same token is reused across restarts; it
|
||||
is NOT deleted when the server exits. Rotate it explicitly with
|
||||
`kimi server rotate-token` (a running server picks up the new token without a
|
||||
restart).
|
||||
- The local CLI reads that token file automatically and sends
|
||||
`Authorization: Bearer <token>` on every REST/WebSocket call — no setup required.
|
||||
- Only loopback is reachable; nothing is exposed to the network.
|
||||
|
||||
## LAN deployment
|
||||
|
||||
Bind a specific LAN interface. Because the hardening gate is `bindClass !== 'loopback'`,
|
||||
a LAN bind gets the same hardening stack as a public bind:
|
||||
|
||||
```
|
||||
kimi server run --host 192.168.1.10 --insecure-no-tls
|
||||
```
|
||||
|
||||
- Authentication is the persistent bearer token printed in the startup banner; send it
|
||||
as `Authorization: Bearer <token>`. `KIMI_CODE_PASSWORD` is optional and adds a
|
||||
second credential (it is never required).
|
||||
- `--insecure-no-tls` is required unless TLS is terminated in front of the server
|
||||
(reverse proxy or tunnel). Use it only on a trusted network.
|
||||
- `POST /api/v1/shutdown` and the PTY `/api/v1/terminals/*` routes are **404 by
|
||||
default** on LAN too. Re-enable only with `--allow-remote-shutdown` /
|
||||
`--allow-remote-terminals`.
|
||||
- Auth-failure rate limiting and security response headers are active.
|
||||
|
||||
## Public deployment
|
||||
|
||||
A public bind is `0.0.0.0`, `::`, an empty host, or any non-RFC1918 address. Put the
|
||||
server behind a TLS-terminating reverse proxy (or a tunnel); do not terminate TLS in
|
||||
process.
|
||||
|
||||
```
|
||||
kimi server run --host 0.0.0.0 --insecure-no-tls
|
||||
```
|
||||
|
||||
- Authentication is the persistent bearer token printed in the startup banner;
|
||||
`KIMI_CODE_PASSWORD` is optional. `--insecure-no-tls` is mandatory here because
|
||||
the proxy terminates TLS; without it (and without app-level TLS) the server refuses
|
||||
to bind.
|
||||
- The reverse proxy must pass through the `Authorization` header and the `Host`
|
||||
header, and must upgrade WebSocket connections (see examples below).
|
||||
- `POST /api/v1/shutdown` and `/api/v1/terminals/*` are **404 by default**. Re-enable
|
||||
only with `--allow-remote-shutdown` / `--allow-remote-terminals`.
|
||||
- Auth-failure rate limiting (10 failures / 60 s window / 60 s ban per source,
|
||||
response code `42901`) and security headers are active.
|
||||
- Prefer a tunnel (`cloudflared`, `ssh -R`) over a raw public bind where possible.
|
||||
|
||||
## Reverse-proxy examples
|
||||
|
||||
The server listens on `127.0.0.1:58627`; the proxy terminates TLS and forwards to it.
|
||||
|
||||
**Caddy** (`Caddyfile`; automatic HTTPS):
|
||||
|
||||
```
|
||||
kimi.example.com {
|
||||
reverse_proxy 127.0.0.1:58627
|
||||
}
|
||||
```
|
||||
|
||||
Caddy's `reverse_proxy` passes `Host`, `Authorization`, and the WebSocket upgrade
|
||||
headers by default.
|
||||
|
||||
**nginx** (TLS terminated at nginx):
|
||||
|
||||
```
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name kimi.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/kimi.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/kimi.example.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:58627;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Tunnel alternatives:**
|
||||
|
||||
- `cloudflared tunnel --url http://127.0.0.1:58627` (Cloudflare Tunnel; no inbound
|
||||
firewall rule).
|
||||
- `ssh -R` (SSH remote port forwarding) to publish the loopback port via a host you
|
||||
control.
|
||||
|
||||
For both tunnels, keep the server on the default loopback bind; the tunnel provides
|
||||
the remote reachability and TLS.
|
||||
|
||||
## Credential management
|
||||
|
||||
- **Token** — a persistent bearer token generated once on first boot, held in
|
||||
memory and written to `<KIMI_CODE_HOME>/server.token` (`0600`, directory
|
||||
`0700`). It survives restarts and is reused until explicitly rotated with
|
||||
`kimi server rotate-token`, which rewrites the file (the previous token stops
|
||||
working immediately, even for a running server). The CLI reads it
|
||||
automatically; treat the file as a secret.
|
||||
- **Password** — set `KIMI_CODE_PASSWORD` in the environment. The server hashes it at
|
||||
boot with bcrypt (cost 12) and keeps only the hash in memory; verification uses
|
||||
`bcrypt.compare`. Password auth is accepted as `Authorization: Bearer <password>`.
|
||||
- **Stored password hash** — the current path is env-only. There is **no**
|
||||
`set-password` subcommand and no config-file hash option yet. If a config-stored
|
||||
hash is needed later, generate one externally with cost 12, e.g.:
|
||||
|
||||
```
|
||||
node -e "require('bcryptjs').hash(process.env.KIMI_CODE_PASSWORD,12).then(h=>console.log(h))"
|
||||
```
|
||||
|
||||
(This is forward-looking; do not rely on it until config support lands.)
|
||||
|
||||
## Host / Origin allowlists
|
||||
|
||||
Host and Origin checks run on every request (HTTP and WebSocket upgrade) on all
|
||||
tiers.
|
||||
|
||||
- **Default Host allowlist:** `localhost`, `*.localhost`, `127.0.0.1`, `::1`,
|
||||
`[::1]`, and the actual bind host/IP. Requests with any other `Host` get
|
||||
`403 Invalid Host header` (DNS-rebinding protection).
|
||||
- **`KIMI_CODE_ALLOWED_HOSTS`** — comma-separated extra hosts. A leading dot matches
|
||||
a subdomain wildcard, e.g. `KIMI_CODE_ALLOWED_HOSTS=.example.com,kimi.local`.
|
||||
- **`KIMI_CODE_CORS_ORIGINS`** — comma-separated list of allowed cross-origin values
|
||||
(full `scheme://host[:port]`). No `*` wildcard. Matched origins get
|
||||
`Access-Control-Allow-Origin` echoed; `OPTIONS` preflight short-circuits to `204`.
|
||||
The bundled Web UI is same-origin and needs no entry.
|
||||
Example: `KIMI_CODE_CORS_ORIGINS=https://kimi.example.com`.
|
||||
- **`KIMI_CODE_DISABLE_HOST_CHECK=1`** — disables the Host check entirely on **all**
|
||||
tiers (loopback, LAN, and public). Test/controlled environments only; this removes
|
||||
the DNS-rebinding protection and must not be set in production.
|
||||
|
||||
## Authentication reference
|
||||
|
||||
- **HTTP:** `Authorization: Bearer <token|password>` on every route except
|
||||
`GET /api/v1/healthz`, `OPTIONS *`, and the static Web UI assets (`/`, `/*`).
|
||||
Failure returns `401` with code `40101`.
|
||||
- **WebSocket:** send either an `Authorization: Bearer <token|password>` header or
|
||||
the subprotocol `Sec-WebSocket-Protocol: kimi-code.bearer.<token>` (for browsers
|
||||
that cannot set WS headers). The server echoes the matching subprotocol on accept;
|
||||
a failed check destroys the socket.
|
||||
|
||||
## CLI flags
|
||||
|
||||
The only server-exposure flags are:
|
||||
|
||||
| Flag | Purpose |
|
||||
|---|---|
|
||||
| `--host <host>` | Bind host (default `127.0.0.1`). |
|
||||
| `--port <port>` | Bind port (default `58627`). |
|
||||
| `--insecure-no-tls` | Allow a non-loopback bind without app-level TLS (i.e. TLS is terminated by a proxy/tunnel). |
|
||||
| `--allow-remote-shutdown` | Re-enable `POST /api/v1/shutdown` on a non-loopback bind. |
|
||||
| `--allow-remote-terminals` | Re-enable the PTY `/api/v1/terminals/*` routes on a non-loopback bind. |
|
||||
|
||||
There is no `--bind-class` flag and no `set-password` subcommand.
|
||||
|
|
@ -42,6 +42,7 @@
|
|||
"@fastify/swagger": "^9.7.0",
|
||||
"@moonshot-ai/agent-core": "workspace:^",
|
||||
"@moonshot-ai/protocol": "workspace:^",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"fastify": "^5.1.0",
|
||||
"pino": "^9.5.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
|
|
@ -51,6 +52,7 @@
|
|||
"zod-to-json-schema": "^3.25.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ export { startServer, ServerLockedError } from './start';
|
|||
export type { ServerStartOptions, RunningServer } from './start';
|
||||
export { okEnvelope, errEnvelope } from './envelope';
|
||||
export type { Envelope } from './envelope';
|
||||
export { classify } from './services/auth/bindClassify';
|
||||
export type { BindClass } from './services/auth/bindClassify';
|
||||
export { rotateServerToken, serverTokenPath } from './services/auth/persistentToken';
|
||||
export { createServerLogger } from './services/pinoLoggerService';
|
||||
export type {
|
||||
CreateLoggerOptions,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
134
packages/server/src/middleware/auth.ts
Normal file
134
packages/server/src/middleware/auth.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* Global HTTP bearer-auth hook (ROADMAP M2.2).
|
||||
*
|
||||
* `createAuthHook` builds a Fastify `onRequest` hook that requires a valid
|
||||
* `Authorization: Bearer <token>` on every non-bypassed route. It is NOT wired
|
||||
* into `start.ts` in M2 — that happens in M5.1. Until then it is exercised in
|
||||
* isolation via tests on a minimal Fastify app.
|
||||
*
|
||||
* 401 responses use the reserved daemon code `40101`
|
||||
* (`packages/protocol/src/error-codes.ts` intentionally omits it; the protocol
|
||||
* package is left untouched). `errEnvelope(code: number, …)` accepts a plain
|
||||
* number, so the literal is passed directly.
|
||||
*/
|
||||
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import { errEnvelope } from '#/envelope';
|
||||
import {
|
||||
AUTH_RATE_LIMIT_CODE,
|
||||
AUTH_RATE_LIMIT_MSG,
|
||||
type AuthFailureLimiter,
|
||||
} from '#/middleware/rateLimit';
|
||||
import type { IAuthTokenService } from '#/services/auth/authTokenService';
|
||||
|
||||
/** Daemon-reserved unauthorized code (not in the protocol `ErrorCode` enum). */
|
||||
const AUTH_ERROR_CODE = 40101;
|
||||
const AUTH_ERROR_MSG = 'Unauthorized';
|
||||
const REDACTED = '[redacted]';
|
||||
const BEARER_PREFIX = 'Bearer ';
|
||||
|
||||
export interface AuthHookOptions {
|
||||
/** Return true to skip auth for this request (bypass whitelist). */
|
||||
readonly isBypassed?: (req: FastifyRequest) => boolean;
|
||||
/**
|
||||
* Optional per-source auth-failure limiter (ROADMAP M6.4). When present, a
|
||||
* banned source is rejected with `429` before auth runs, and each failed
|
||||
* attempt is recorded. Wired only on non-loopback binds from `start.ts`.
|
||||
*/
|
||||
readonly limiter?: Pick<AuthFailureLimiter, 'recordFailure' | 'isBanned'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default bypass policy — the security boundary.
|
||||
*
|
||||
* Bypassed (no token required):
|
||||
* - every `OPTIONS` request (CORS preflight);
|
||||
* - `GET /api/v1/healthz` (liveness probe for supervisors / load balancers);
|
||||
* - static web assets, defined as any path that does NOT start with `/api/`
|
||||
* AND is not one of the meta documents `/openapi.json` / `/asyncapi.json`.
|
||||
*
|
||||
* NOT bypassed (token required): all `/api/…` routes plus `/openapi.json` and
|
||||
* `/asyncapi.json` (the meta documents leak the API shape, so they stay gated).
|
||||
*/
|
||||
function defaultIsBypassed(req: FastifyRequest): boolean {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return true;
|
||||
}
|
||||
const path = req.url.split('?', 1)[0] ?? req.url;
|
||||
if (req.method === 'GET' && path === '/api/v1/healthz') {
|
||||
return true;
|
||||
}
|
||||
const isApi = path.startsWith('/api/');
|
||||
const isMeta = path === '/openapi.json' || path === '/asyncapi.json';
|
||||
return !isApi && !isMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the bearer token from the raw `Authorization` header.
|
||||
*
|
||||
* Returns `null` when the header is missing, lacks the case-sensitive
|
||||
* `Bearer ` prefix, or carries an empty token — all treated as 401.
|
||||
*/
|
||||
function extractBearer(header: string | undefined): string | null {
|
||||
if (header === undefined || !header.startsWith(BEARER_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
const token = header.slice(BEARER_PREFIX.length);
|
||||
return token.length === 0 ? null : token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the global `onRequest` auth hook.
|
||||
*
|
||||
* Order inside the hook matters: the raw token is extracted first, then the
|
||||
* header view is redacted for downstream request logging (`start.ts` logs
|
||||
* requests), and only then is the candidate validated — so auth still sees the
|
||||
* real token while logs never do. Returning the `reply` from the async hook
|
||||
* short-circuits Fastify on 401 (the route handler never runs).
|
||||
*/
|
||||
export function createAuthHook(
|
||||
authTokenService: IAuthTokenService,
|
||||
opts?: AuthHookOptions,
|
||||
): (req: FastifyRequest, reply: FastifyReply) => Promise<FastifyReply | void> {
|
||||
const isBypassed = opts?.isBypassed ?? defaultIsBypassed;
|
||||
|
||||
return async (req, reply) => {
|
||||
// Rate-limit check (ROADMAP M6.4): a banned source is rejected before any
|
||||
// auth work — even a valid token cannot bypass an active ban. Loopback
|
||||
// binds pass no limiter, so this branch is a no-op there.
|
||||
if (opts?.limiter?.isBanned(req.ip) === true) {
|
||||
return reply
|
||||
.code(429)
|
||||
.send(errEnvelope(AUTH_RATE_LIMIT_CODE, AUTH_RATE_LIMIT_MSG, req.id));
|
||||
}
|
||||
|
||||
const header = req.headers.authorization;
|
||||
const token = extractBearer(header);
|
||||
|
||||
// Redact the header view BEFORE the rest of the pipeline logs the request.
|
||||
// Auth has already consumed the raw value above, so this only affects the
|
||||
// downstream log view of the request.
|
||||
if (header !== undefined) {
|
||||
req.headers.authorization = REDACTED;
|
||||
}
|
||||
|
||||
if (isBypassed(req)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (token === null) {
|
||||
opts?.limiter?.recordFailure(req.ip);
|
||||
return reply
|
||||
.code(401)
|
||||
.send(errEnvelope(AUTH_ERROR_CODE, AUTH_ERROR_MSG, req.id));
|
||||
}
|
||||
|
||||
if (!(await authTokenService.isValid(token))) {
|
||||
opts?.limiter?.recordFailure(req.ip);
|
||||
return reply
|
||||
.code(401)
|
||||
.send(errEnvelope(AUTH_ERROR_CODE, AUTH_ERROR_MSG, req.id));
|
||||
}
|
||||
};
|
||||
}
|
||||
166
packages/server/src/middleware/hostnames.ts
Normal file
166
packages/server/src/middleware/hostnames.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
/**
|
||||
* Host-header allowlist middleware (ROADMAP M4.1).
|
||||
*
|
||||
* `createHostCheck` builds a Fastify `onRequest` hook that rejects requests
|
||||
* whose `Host` header is not in the allowlist with a `403 Invalid Host header`
|
||||
* envelope. This is the primary DNS-rebinding defence once the server is
|
||||
* reachable beyond localhost (PLAN §3.4).
|
||||
*
|
||||
* Default-allow set (no configuration required):
|
||||
* - `localhost`, `*.localhost` (any subdomain of `localhost`);
|
||||
* - `127.0.0.1`, `::1`, `[::1]`;
|
||||
* - any literal IP (`net.isIP(host) !== 0`);
|
||||
* - the host the server actually bound to (`boundHost`);
|
||||
* - caller-supplied extras (`extra`), where a leading `.` matches the bare
|
||||
* domain and any subdomain (e.g. `.example.com` matches `example.com` and
|
||||
* `a.example.com`).
|
||||
*
|
||||
* The default set is intentionally permissive for loopback/IP access so that
|
||||
* `app.inject` (default `Host: localhost:80`) and real `fetch` to
|
||||
* `127.0.0.1:<port>` keep working — existing HTTP/WS tests rely on this.
|
||||
*
|
||||
* 403 responses use the reserved daemon code `40301`
|
||||
* (`packages/protocol/src/error-codes.ts` intentionally omits it; the protocol
|
||||
* package is left untouched). `errEnvelope(code: number, …)` accepts a plain
|
||||
* number, so the literal is passed directly.
|
||||
*/
|
||||
|
||||
import net from 'node:net';
|
||||
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import { errEnvelope } from '#/envelope';
|
||||
|
||||
/** Daemon-reserved "invalid Host" code (not in the protocol `ErrorCode` enum). */
|
||||
const HOST_ERROR_CODE = 40301;
|
||||
const HOST_ERROR_MSG = 'Invalid Host header';
|
||||
|
||||
export interface HostCheckOptions {
|
||||
/** The host the server bound to; always allowed (port stripped both sides). */
|
||||
readonly boundHost?: string;
|
||||
/** Extra allowed hosts / domain-suffix patterns (from `KIMI_CODE_ALLOWED_HOSTS`). */
|
||||
readonly extra?: readonly string[];
|
||||
/** Disable the check entirely (`KIMI_CODE_DISABLE_HOST_CHECK=1`; test-only). */
|
||||
readonly disable?: boolean;
|
||||
}
|
||||
|
||||
/** Returned by {@link createHostCheck}: the Fastify hook plus the raw predicate. */
|
||||
export interface HostCheck {
|
||||
/** Fastify `onRequest` hook that 403s on a disallowed `Host`. */
|
||||
readonly onRequest: (req: FastifyRequest, reply: FastifyReply) => Promise<FastifyReply | void>;
|
||||
/** Reusable predicate (also used by the WS upgrade path in M4.3). */
|
||||
readonly isAllowed: (host: string | undefined) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `KIMI_CODE_ALLOWED_HOSTS` into an `extra` allowlist.
|
||||
*
|
||||
* Comma-separated, trimmed, empties dropped. A leading `.` is preserved so the
|
||||
* caller can express domain-suffix wildcards (`.example.com`).
|
||||
*/
|
||||
export function parseAllowedHosts(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
const raw = env['KIMI_CODE_ALLOWED_HOSTS'];
|
||||
if (raw === undefined) {
|
||||
return [];
|
||||
}
|
||||
return raw
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
/** True when `KIMI_CODE_DISABLE_HOST_CHECK=1` (test/controlled env only). */
|
||||
export function isHostCheckDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return env['KIMI_CODE_DISABLE_HOST_CHECK'] === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a trailing `:port` from a `Host` value and lowercase it.
|
||||
*
|
||||
* Handles:
|
||||
* - bracketed IPv6 with a port: `[::1]:80` → `[::1]`;
|
||||
* - host/IPv4 with a port: `localhost:80` → `localhost`, `1.2.3.4:5678` → `1.2.3.4`;
|
||||
* - bare values (no port): returned lowercased as-is;
|
||||
* - bare IPv6 without brackets (multiple colons, e.g. `::1`): returned
|
||||
* lowercased as-is — there is no unambiguous port to strip.
|
||||
*/
|
||||
export function stripPort(host: string): string {
|
||||
if (host.startsWith('[')) {
|
||||
const end = host.indexOf(']');
|
||||
return (end === -1 ? host : host.slice(0, end + 1)).toLowerCase();
|
||||
}
|
||||
const firstColon = host.indexOf(':');
|
||||
if (firstColon === -1) {
|
||||
return host.toLowerCase();
|
||||
}
|
||||
const lastColon = host.lastIndexOf(':');
|
||||
if (firstColon === lastColon) {
|
||||
const after = host.slice(lastColon + 1);
|
||||
if (after.length > 0 && /^\d+$/.test(after)) {
|
||||
return host.slice(0, lastColon).toLowerCase();
|
||||
}
|
||||
}
|
||||
// Multiple colons (bare IPv6) or a non-digit suffix — no port to strip.
|
||||
return host.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a `Host` value is allowed under the given options.
|
||||
*
|
||||
* Missing/empty `Host` is rejected (HTTP/1.1 requires it). The check is a no-op
|
||||
* when `opts.disable` is set.
|
||||
*/
|
||||
export function isAllowedHost(host: string | undefined, opts: HostCheckOptions): boolean {
|
||||
if (opts.disable === true) {
|
||||
return true;
|
||||
}
|
||||
if (host === undefined || host.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const h = stripPort(host);
|
||||
|
||||
if (h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]') {
|
||||
return true;
|
||||
}
|
||||
if (h.endsWith('.localhost')) {
|
||||
return true;
|
||||
}
|
||||
if (net.isIP(h) !== 0) {
|
||||
return true;
|
||||
}
|
||||
if (opts.boundHost !== undefined && h === stripPort(opts.boundHost)) {
|
||||
return true;
|
||||
}
|
||||
if (opts.extra !== undefined) {
|
||||
for (const entry of opts.extra) {
|
||||
if (entry.startsWith('.')) {
|
||||
const base = entry.slice(1);
|
||||
if (h === base || h.endsWith(entry)) {
|
||||
return true;
|
||||
}
|
||||
} else if (h === entry) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Fastify `onRequest` hook and the reusable `isAllowed` predicate.
|
||||
*
|
||||
* Returning the `reply` from the hook short-circuits Fastify on 403 so the
|
||||
* route handler never runs.
|
||||
*/
|
||||
export function createHostCheck(opts: HostCheckOptions): HostCheck {
|
||||
const isAllowed = (host: string | undefined): boolean => isAllowedHost(host, opts);
|
||||
const onRequest = async (
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<FastifyReply | void> => {
|
||||
if (!isAllowed(req.headers.host)) {
|
||||
return reply.code(403).send(errEnvelope(HOST_ERROR_CODE, HOST_ERROR_MSG, req.id));
|
||||
}
|
||||
};
|
||||
return { onRequest, isAllowed };
|
||||
}
|
||||
122
packages/server/src/middleware/origin.ts
Normal file
122
packages/server/src/middleware/origin.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* Origin / CORS middleware (ROADMAP M4.2).
|
||||
*
|
||||
* HTTP `onRequest` hook:
|
||||
* - no `Origin` header → non-CORS / same-origin request → proceeds untouched;
|
||||
* - same-origin (`Origin` host === `Host`, port stripped both sides) → allowed;
|
||||
* - cross-origin → allowed only if the full origin (scheme + host) is in the
|
||||
* explicit whitelist (`KIMI_CODE_CORS_ORIGINS`, no `*` wildcard — PLAN
|
||||
* §3.4). Allowed origins get `Access-Control-Allow-*` echoed; `OPTIONS`
|
||||
* preflight short-circuits to `204`;
|
||||
* - cross-origin and NOT whitelisted → no CORS headers are emitted, so the
|
||||
* browser blocks the response. `OPTIONS` still returns `204` (without CORS
|
||||
* headers) so the preflight fails closed.
|
||||
*
|
||||
* `isOriginAllowed` is also exported for the WS upgrade path (M4.3). There,
|
||||
* absent/malformed `Origin` is treated as allowed (non-browser Node `ws`
|
||||
* clients send no `Origin`); a present-but-disallowed browser Origin is
|
||||
* rejected. See M4.3 for the deliberate present-only deviation.
|
||||
*/
|
||||
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import { stripPort } from './hostnames';
|
||||
|
||||
const CORS_ALLOW_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
|
||||
const CORS_ALLOW_HEADERS = 'Content-Type, Authorization';
|
||||
|
||||
export interface OriginHookOptions {
|
||||
/** Explicit cross-origin allowlist (full origin strings, scheme + host). */
|
||||
readonly allowedOrigins?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `KIMI_CODE_CORS_ORIGINS` into an allowlist.
|
||||
*
|
||||
* Comma-separated, trimmed, empties dropped. No `*` wildcard — every entry is
|
||||
* an explicit origin (PLAN §3.4).
|
||||
*/
|
||||
export function parseCorsOrigins(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
const raw = env['KIMI_CODE_CORS_ORIGINS'];
|
||||
if (raw === undefined) {
|
||||
return [];
|
||||
}
|
||||
return raw
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the `host` (host[:port], default port dropped) of an `Origin` value,
|
||||
* or `undefined` when the origin is missing or malformed.
|
||||
*/
|
||||
export function originHost(origin: string | undefined): string | undefined {
|
||||
if (origin === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return new URL(origin).host;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether an `Origin` is allowed for a request to `host`.
|
||||
*
|
||||
* - missing/malformed `Origin` → allowed (non-CORS / non-browser client);
|
||||
* - same-origin (`Origin` host === `Host`, port stripped both sides) → allowed;
|
||||
* - otherwise → allowed only when the full origin string is in `allowed`.
|
||||
*/
|
||||
export function isOriginAllowed(
|
||||
origin: string | undefined,
|
||||
host: string | undefined,
|
||||
allowed: readonly string[],
|
||||
): boolean {
|
||||
const oh = originHost(origin);
|
||||
if (oh === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (host !== undefined && stripPort(oh) === stripPort(host)) {
|
||||
return true;
|
||||
}
|
||||
// `origin` is defined here (originHost returned a host), so the whitelist
|
||||
// match is against the full origin string (scheme + host).
|
||||
return allowed.includes(origin as string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Fastify `onRequest` CORS hook.
|
||||
*
|
||||
* Allowed origins get `Access-Control-Allow-*` echoed and `OPTIONS` preflights
|
||||
* short-circuit to `204`. Disallowed origins get no CORS headers (the browser
|
||||
* blocks the response); their `OPTIONS` preflight still returns `204` so it
|
||||
* fails closed without leaking headers.
|
||||
*/
|
||||
export function createOriginHook(
|
||||
opts: OriginHookOptions,
|
||||
): (req: FastifyRequest, reply: FastifyReply) => Promise<FastifyReply | void> {
|
||||
const allowed = opts.allowedOrigins ?? [];
|
||||
return async (req, reply) => {
|
||||
const origin = req.headers.origin;
|
||||
if (origin === undefined) {
|
||||
return;
|
||||
}
|
||||
if (isOriginAllowed(origin, req.headers.host, allowed)) {
|
||||
reply.header('Access-Control-Allow-Origin', origin);
|
||||
reply.header('Access-Control-Allow-Methods', CORS_ALLOW_METHODS);
|
||||
reply.header('Access-Control-Allow-Headers', CORS_ALLOW_HEADERS);
|
||||
reply.header('Vary', 'Origin');
|
||||
if (req.method === 'OPTIONS') {
|
||||
return reply.code(204).send();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Origin present but not allowed: emit no CORS headers so the browser
|
||||
// blocks the response. Short-circuit the preflight to fail closed.
|
||||
if (req.method === 'OPTIONS') {
|
||||
return reply.code(204).send();
|
||||
}
|
||||
};
|
||||
}
|
||||
103
packages/server/src/middleware/rateLimit.ts
Normal file
103
packages/server/src/middleware/rateLimit.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* Auth-failure rate limiter (ROADMAP M6.4).
|
||||
*
|
||||
* A per-`remoteAddress` failure counter that temporarily bans a source after
|
||||
* too many failed authentication attempts. Wired into `createAuthHook` only on
|
||||
* non-loopback binds (`start.ts`), so the loopback default keeps its existing
|
||||
* "no rate limit" behavior while a public/LAN bind slows brute-force attempts.
|
||||
*
|
||||
* Policy: a source is banned when it accumulates `maxFailures` failures within
|
||||
* a sliding `windowMs` window; the ban lasts `banMs`. Once banned, every
|
||||
* request from that source is rejected with `429` until the ban expires — even
|
||||
* a request carrying a valid token — so a banned attacker cannot keep probing.
|
||||
*/
|
||||
|
||||
/** Reserved daemon code for rate-limited auth (not in the protocol enum). */
|
||||
export const AUTH_RATE_LIMIT_CODE = 42901;
|
||||
export const AUTH_RATE_LIMIT_MSG = 'Too many failed auth attempts';
|
||||
|
||||
export interface AuthFailureLimiterOptions {
|
||||
/** Failures within {@link windowMs} that trigger a ban. Default `10`. */
|
||||
readonly maxFailures?: number;
|
||||
/** Rolling failure window in ms. Default `60_000`. */
|
||||
readonly windowMs?: number;
|
||||
/** Ban duration in ms once the threshold is hit. Default `60_000`. */
|
||||
readonly banMs?: number;
|
||||
}
|
||||
|
||||
/** Minimal surface consumed by `createAuthHook`. */
|
||||
export interface AuthFailureLimiter {
|
||||
/** Record one failed auth attempt for `ip`. */
|
||||
recordFailure(ip: string): void;
|
||||
/** True while `ip` is inside an active ban window. */
|
||||
isBanned(ip: string): boolean;
|
||||
/** Stop the periodic cleanup timer and drop all state (shutdown / tests). */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
/** Failures recorded since {@link windowStart}. */
|
||||
count: number;
|
||||
/** Start (ms epoch) of the current failure window. */
|
||||
windowStart: number;
|
||||
/** ms epoch until which the source is banned; `0` when not banned. */
|
||||
bannedUntil: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_FAILURES = 10;
|
||||
const DEFAULT_WINDOW_MS = 60_000;
|
||||
const DEFAULT_BAN_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Build a per-source auth-failure limiter.
|
||||
*
|
||||
* A periodic sweep drops entries that are neither banned nor within an active
|
||||
* failure window so the map does not grow without bound on a long-lived
|
||||
* public server. The timer is `unref`-ed so it never keeps the process alive.
|
||||
*/
|
||||
export function createAuthFailureLimiter(
|
||||
opts?: AuthFailureLimiterOptions,
|
||||
): AuthFailureLimiter {
|
||||
const maxFailures = opts?.maxFailures ?? DEFAULT_MAX_FAILURES;
|
||||
const windowMs = opts?.windowMs ?? DEFAULT_WINDOW_MS;
|
||||
const banMs = opts?.banMs ?? DEFAULT_BAN_MS;
|
||||
const entries = new Map<string, Entry>();
|
||||
|
||||
const sweep = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of entries) {
|
||||
const banned = entry.bannedUntil > now;
|
||||
const windowLive = now - entry.windowStart <= windowMs;
|
||||
if (!banned && !windowLive) {
|
||||
entries.delete(ip);
|
||||
}
|
||||
}
|
||||
}, windowMs);
|
||||
if (typeof sweep.unref === 'function') {
|
||||
sweep.unref();
|
||||
}
|
||||
|
||||
return {
|
||||
recordFailure(ip: string): void {
|
||||
const now = Date.now();
|
||||
let entry = entries.get(ip);
|
||||
if (entry === undefined || now - entry.windowStart > windowMs) {
|
||||
entry = { count: 0, windowStart: now, bannedUntil: 0 };
|
||||
entries.set(ip, entry);
|
||||
}
|
||||
entry.count += 1;
|
||||
if (entry.count >= maxFailures) {
|
||||
entry.bannedUntil = now + banMs;
|
||||
}
|
||||
},
|
||||
isBanned(ip: string): boolean {
|
||||
const entry = entries.get(ip);
|
||||
if (entry === undefined) return false;
|
||||
return entry.bannedUntil > Date.now();
|
||||
},
|
||||
dispose(): void {
|
||||
clearInterval(sweep);
|
||||
entries.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -46,6 +46,17 @@ interface ApiV1RouteHost {
|
|||
export interface RegisterApiV1RoutesOptions {
|
||||
readonly serverVersion: string;
|
||||
readonly debugEndpoints?: boolean;
|
||||
/**
|
||||
* Mount `POST /api/v1/shutdown`. Defaults to true (backward compatible);
|
||||
* `start.ts` sets it false on a public bind unless `--allow-remote-shutdown`.
|
||||
*/
|
||||
readonly enableShutdown?: boolean;
|
||||
/**
|
||||
* Mount the PTY `/api/v1/terminals/*` routes. Defaults to true (backward
|
||||
* compatible); `start.ts` sets it false on a public bind unless
|
||||
* `--allow-remote-terminals`.
|
||||
*/
|
||||
readonly enableTerminals?: boolean;
|
||||
}
|
||||
|
||||
export async function registerApiV1Routes(
|
||||
|
|
@ -76,10 +87,12 @@ export async function registerApiV1Routes(
|
|||
ix,
|
||||
);
|
||||
registerSessionsRoutes(apiV1 as unknown as Parameters<typeof registerSessionsRoutes>[0], ix);
|
||||
registerShutdownRoutes(
|
||||
apiV1 as unknown as Parameters<typeof registerShutdownRoutes>[0],
|
||||
ix,
|
||||
);
|
||||
if (opts.enableShutdown !== false) {
|
||||
registerShutdownRoutes(
|
||||
apiV1 as unknown as Parameters<typeof registerShutdownRoutes>[0],
|
||||
ix,
|
||||
);
|
||||
}
|
||||
registerSnapshotRoutes(apiV1 as unknown as Parameters<typeof registerSnapshotRoutes>[0], ix);
|
||||
registerMessagesRoutes(apiV1 as unknown as Parameters<typeof registerMessagesRoutes>[0], ix);
|
||||
registerPromptsRoutes(apiV1 as unknown as Parameters<typeof registerPromptsRoutes>[0], ix);
|
||||
|
|
@ -94,10 +107,12 @@ export async function registerApiV1Routes(
|
|||
registerToolsRoutes(apiV1 as unknown as Parameters<typeof registerToolsRoutes>[0], ix);
|
||||
registerSkillsRoutes(apiV1 as unknown as Parameters<typeof registerSkillsRoutes>[0], ix);
|
||||
registerTasksRoutes(apiV1 as unknown as Parameters<typeof registerTasksRoutes>[0], ix);
|
||||
registerTerminalsRoutes(
|
||||
apiV1 as unknown as Parameters<typeof registerTerminalsRoutes>[0],
|
||||
ix,
|
||||
);
|
||||
if (opts.enableTerminals !== false) {
|
||||
registerTerminalsRoutes(
|
||||
apiV1 as unknown as Parameters<typeof registerTerminalsRoutes>[0],
|
||||
ix,
|
||||
);
|
||||
}
|
||||
registerFsRoutes(apiV1 as unknown as Parameters<typeof registerFsRoutes>[0], ix);
|
||||
registerFilesRoutes(apiV1 as unknown as Parameters<typeof registerFilesRoutes>[0], ix);
|
||||
registerWorkspacesRoutes(
|
||||
|
|
|
|||
56
packages/server/src/services/auth/authTokenService.ts
Normal file
56
packages/server/src/services/auth/authTokenService.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* `IAuthTokenService` DI surface (ROADMAP M2.1).
|
||||
*
|
||||
* Exposes the persistent bearer token plus a single validity check that accepts
|
||||
* EITHER the persistent token (constant-time, via `TokenStore`) OR a verified
|
||||
* user password (bcrypt, async). The seam exists so tests can inject a
|
||||
* fixed-token impl via `startServer({ serviceOverrides })`, and so `start.ts`
|
||||
* (M5.1) can wire the real async-built instance at boot.
|
||||
*
|
||||
* `isValid` is async because password verification (`bcrypt.compare`) is
|
||||
* async — the token path is synchronous, but the interface must await both.
|
||||
*/
|
||||
|
||||
import { createDecorator } from '@moonshot-ai/agent-core';
|
||||
|
||||
import { verifyPassword } from './password';
|
||||
import type { TokenStore } from './tokenStore';
|
||||
|
||||
export interface IAuthTokenService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/** The persistent bearer token (re-read from disk when its mtime changes). */
|
||||
getToken(): string;
|
||||
|
||||
/**
|
||||
* True when `candidate` matches the persistent token OR verifies against the
|
||||
* configured password hash. Constant-time on the token path; bcrypt on the
|
||||
* password path.
|
||||
*/
|
||||
isValid(candidate: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
export const IAuthTokenService =
|
||||
createDecorator<IAuthTokenService>('authTokenService');
|
||||
|
||||
/**
|
||||
* Default `IAuthTokenService` over a `TokenStore` + optional password hash.
|
||||
*
|
||||
* Constructed in `start.ts` (M5.1) where the async `TokenStore` /
|
||||
* `passwordHash` are available, then injected via `serviceOverrides`. NOT built
|
||||
* inside `createServerServiceCollection`: that path is synchronous and cannot
|
||||
* await the `TokenStore` file write or the bcrypt hash.
|
||||
*/
|
||||
export function createAuthTokenService(deps: {
|
||||
readonly tokenStore: TokenStore;
|
||||
readonly passwordHash: string | undefined;
|
||||
}): IAuthTokenService {
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
getToken: () => deps.tokenStore.getToken(),
|
||||
isValid: async (candidate) =>
|
||||
deps.tokenStore.isValid(candidate) ||
|
||||
(await verifyPassword(candidate, deps.passwordHash)),
|
||||
};
|
||||
}
|
||||
112
packages/server/src/services/auth/bindClassify.ts
Normal file
112
packages/server/src/services/auth/bindClassify.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* Bind-address classification (ROADMAP M6.1).
|
||||
*
|
||||
* `classify(host)` buckets a bind host into the network exposure tier it
|
||||
* implies, so `start.ts` can decide which hardening to apply:
|
||||
*
|
||||
* - `loopback` — only this host (`127.0.0.0/8`, `::1`, `localhost`). The
|
||||
* token-only default; no public hardening required.
|
||||
* - `lan` — RFC1918 private ranges (`10/8`, `172.16/12`, `192.168/16`) plus
|
||||
* link-local (`169.254/16`, `fe80::/10`). Reachable from the local
|
||||
* network; hardening is recommended but not all of it is mandatory.
|
||||
* - `public` — everything else. Full D2 hardening (forced password, TLS
|
||||
* opt-out, auth-failure rate limiting, dangerous-endpoint downgrade,
|
||||
* security headers).
|
||||
*
|
||||
* Wildcard binds (`0.0.0.0`, `::`, empty) are treated as `public` by default —
|
||||
* a wildcard is reachable from anywhere the host is — unless the caller
|
||||
* explicitly relaxes the classification via `opts.bindClass: 'lan'`
|
||||
* (`--bind-class=lan`).
|
||||
*/
|
||||
|
||||
import net from 'node:net';
|
||||
|
||||
export type BindClass = 'loopback' | 'lan' | 'public';
|
||||
|
||||
export interface ClassifyOptions {
|
||||
/** Override classification of wildcard binds (`0.0.0.0` / `::` / empty). */
|
||||
readonly bindClass?: 'lan' | 'public';
|
||||
}
|
||||
|
||||
/** Convert a dotted-quad IPv4 literal to its unsigned 32-bit integer form. */
|
||||
function ipv4ToInt(ip: string): number {
|
||||
const [a, b, c, d] = ip.split('.');
|
||||
return (
|
||||
(((Number(a) << 24) >>> 0) +
|
||||
((Number(b) << 16) >>> 0) +
|
||||
((Number(c) << 8) >>> 0) +
|
||||
(Number(d) >>> 0)) >>>
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/** True when `ip` falls inside the IPv4 CIDR `base/prefix`. */
|
||||
function ipv4InCidr(ip: string, base: string, prefix: number): boolean {
|
||||
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
|
||||
return ((ipv4ToInt(ip) & mask) >>> 0) === ((ipv4ToInt(base) & mask) >>> 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a (possibly `::`-compressed) IPv6 literal into 8 lowercase hextets.
|
||||
* Returns `null` when the shape is not a plain 8-group IPv6 address.
|
||||
*/
|
||||
function expandV6(host: string): readonly string[] | null {
|
||||
const lower = host.toLowerCase();
|
||||
if (lower.includes('::')) {
|
||||
const halves = lower.split('::');
|
||||
const leftRaw = halves[0] ?? '';
|
||||
const rightRaw = halves[1] ?? '';
|
||||
const left = leftRaw.length > 0 ? leftRaw.split(':') : [];
|
||||
const right = rightRaw.length > 0 ? rightRaw.split(':') : [];
|
||||
const missing = 8 - (left.length + right.length);
|
||||
if (missing < 0) return null;
|
||||
return [...left, ...Array<string>(missing).fill('0'), ...right];
|
||||
}
|
||||
const parts = lower.split(':');
|
||||
return parts.length === 8 ? parts : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `host` is an IPv6 link-local address (`fe80::/10`).
|
||||
*
|
||||
* The first 10 bits are fixed (`1111111010`), so the leading hextet ranges
|
||||
* `0xfe80`–`0xfebf`. IPv4-mapped / compressed forms that do not expand to an
|
||||
* `fe80::/10` leading group return false.
|
||||
*/
|
||||
function isLinkLocalV6(host: string): boolean {
|
||||
const groups = expandV6(host);
|
||||
if (groups === null) return false;
|
||||
const first = Number.parseInt(groups[0] ?? '', 16);
|
||||
return first >= 0xfe80 && first <= 0xfebf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a bind host by the network exposure it implies.
|
||||
*
|
||||
* See the module header for the tier definitions. A non-IP hostname that is
|
||||
* not `localhost` is treated conservatively as `public` — a DNS name could
|
||||
* resolve to a public address.
|
||||
*/
|
||||
export function classify(host: string, opts?: ClassifyOptions): BindClass {
|
||||
if (host === '' || host === '0.0.0.0' || host === '::') {
|
||||
return opts?.bindClass ?? 'public';
|
||||
}
|
||||
if (host === 'localhost') {
|
||||
return 'loopback';
|
||||
}
|
||||
const family = net.isIP(host);
|
||||
if (family === 4) {
|
||||
if (host.startsWith('127.')) return 'loopback';
|
||||
if (ipv4InCidr(host, '10.0.0.0', 8)) return 'lan';
|
||||
if (ipv4InCidr(host, '172.16.0.0', 12)) return 'lan';
|
||||
if (ipv4InCidr(host, '192.168.0.0', 16)) return 'lan';
|
||||
if (ipv4InCidr(host, '169.254.0.0', 16)) return 'lan';
|
||||
return 'public';
|
||||
}
|
||||
if (family === 6) {
|
||||
if (host.toLowerCase() === '::1') return 'loopback';
|
||||
if (isLinkLocalV6(host)) return 'lan';
|
||||
return 'public';
|
||||
}
|
||||
return 'public';
|
||||
}
|
||||
6
packages/server/src/services/auth/index.ts
Normal file
6
packages/server/src/services/auth/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export * from './privateFiles';
|
||||
export * from './tokenStore';
|
||||
export * from './password';
|
||||
export * from './authTokenService';
|
||||
export * from './bindClassify';
|
||||
export * from './securityHeaders';
|
||||
23
packages/server/src/services/auth/password.ts
Normal file
23
packages/server/src/services/auth/password.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { compare, hash } from 'bcryptjs';
|
||||
|
||||
const BCRYPT_COST = 12;
|
||||
|
||||
export async function resolvePasswordHash(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<string | undefined> {
|
||||
const plaintext = env['KIMI_CODE_PASSWORD'];
|
||||
if (!plaintext) {
|
||||
return undefined;
|
||||
}
|
||||
return hash(plaintext, BCRYPT_COST);
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
candidate: string,
|
||||
passwordHash: string | undefined,
|
||||
): Promise<boolean> {
|
||||
if (passwordHash === undefined) {
|
||||
return false;
|
||||
}
|
||||
return compare(candidate, passwordHash);
|
||||
}
|
||||
79
packages/server/src/services/auth/persistentToken.ts
Normal file
79
packages/server/src/services/auth/persistentToken.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Persistent server bearer token.
|
||||
*
|
||||
* The token lives at `<KIMI_CODE_HOME>/server.token` (mode 0600) and is reused
|
||||
* across restarts, so a reboot does NOT rotate it. It is generated once on
|
||||
* first boot and only changes when the operator explicitly runs
|
||||
* `kimi server rotate-token` (which calls {@link rotateServerToken}).
|
||||
*
|
||||
* All writes go through {@link writePrivateFile} (atomic rename, 0700 dir,
|
||||
* 0600 file) and reads through {@link readPrivateFile} (refuses files looser
|
||||
* than 0600), so the on-disk token is never world/group-readable and a
|
||||
* rotation is never observed half-written.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { readPrivateFile, writePrivateFile } from './privateFiles';
|
||||
|
||||
/** On-disk filename for the persistent token, relative to KIMI_CODE_HOME. */
|
||||
export const SERVER_TOKEN_FILE = 'server.token';
|
||||
|
||||
/** Absolute path of the persistent token file for a given home dir. */
|
||||
export function serverTokenPath(homeDir: string): string {
|
||||
return join(homeDir, SERVER_TOKEN_FILE);
|
||||
}
|
||||
|
||||
/** Fresh 256-bit token, base64url-encoded (43 chars, URL-safe). */
|
||||
export function generateServerToken(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
/** Atomically write `token` to `<homeDir>/server.token` (0600). */
|
||||
export async function writeServerToken(homeDir: string, token: string): Promise<void> {
|
||||
await writePrivateFile(serverTokenPath(homeDir), token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persistent token, or `undefined` when no token file exists yet.
|
||||
* Throws if the file exists but is too permissive (not 0600).
|
||||
*/
|
||||
export async function readServerToken(homeDir: string): Promise<string | undefined> {
|
||||
try {
|
||||
const buf = await readPrivateFile(serverTokenPath(homeDir));
|
||||
return buf.toString('utf8').trim();
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the existing persistent token, generating and persisting one on first
|
||||
* boot. An empty/unreadable file is treated as missing and regenerated.
|
||||
*/
|
||||
export async function loadOrCreateServerToken(homeDir: string): Promise<string> {
|
||||
const existing = await readServerToken(homeDir);
|
||||
if (existing !== undefined && existing.length > 0) {
|
||||
return existing;
|
||||
}
|
||||
const token = generateServerToken();
|
||||
await writeServerToken(homeDir, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and persist a brand-new token, invalidating the previous one.
|
||||
*
|
||||
* A running server picks the new token up on its next auth check (the token
|
||||
* store re-reads the file when its mtime changes), so rotation takes effect
|
||||
* immediately without a restart.
|
||||
*/
|
||||
export async function rotateServerToken(homeDir: string): Promise<string> {
|
||||
const token = generateServerToken();
|
||||
await writeServerToken(homeDir, token);
|
||||
return token;
|
||||
}
|
||||
61
packages/server/src/services/auth/privateFiles.ts
Normal file
61
packages/server/src/services/auth/privateFiles.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { randomBytes } from 'node:crypto';
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
open,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
} from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
export class PrivateFileTooPermissiveError extends Error {
|
||||
readonly code = 'EPRIVATE_FILE_TOO_PERMISSIVE';
|
||||
|
||||
constructor(
|
||||
readonly filePath: string,
|
||||
readonly mode: number,
|
||||
) {
|
||||
super(
|
||||
`private file ${filePath} is too permissive (mode ${mode.toString(8).padStart(3, '0')}); expected 0600`,
|
||||
);
|
||||
this.name = 'PrivateFileTooPermissiveError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function writePrivateFile(
|
||||
filePath: string,
|
||||
data: string | Buffer,
|
||||
): Promise<void> {
|
||||
const dir = dirname(filePath);
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
await chmod(dir, 0o700);
|
||||
|
||||
const tmp = `${filePath}.tmp.${randomBytes(8).toString('hex')}`;
|
||||
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
handle = await open(tmp, 'w', 0o600);
|
||||
await handle.chmod(0o600);
|
||||
await handle.writeFile(data);
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
await rename(tmp, filePath);
|
||||
} catch (err) {
|
||||
if (handle) {
|
||||
await handle.close().catch(() => {});
|
||||
}
|
||||
await rm(tmp, { force: true }).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readPrivateFile(filePath: string): Promise<Buffer> {
|
||||
const info = await stat(filePath);
|
||||
if ((info.mode & 0o077) !== 0) {
|
||||
throw new PrivateFileTooPermissiveError(filePath, info.mode & 0o777);
|
||||
}
|
||||
return readFile(filePath);
|
||||
}
|
||||
45
packages/server/src/services/auth/securityHeaders.ts
Normal file
45
packages/server/src/services/auth/securityHeaders.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Security response headers (ROADMAP M6.6).
|
||||
*
|
||||
* `createSecurityHeadersHook` builds a Fastify `onSend` hook that stamps a
|
||||
* small set of defensive headers on every response once the server is exposed
|
||||
* beyond loopback. Wired from `start.ts` only on non-loopback binds so the
|
||||
* loopback default keeps its lean response headers.
|
||||
*
|
||||
* Headers:
|
||||
* - `X-Content-Type-Options: nosniff` — stop MIME sniffing.
|
||||
* - `Referrer-Policy: no-referrer` — never leak the URL to third parties.
|
||||
* - `Content-Security-Policy: default-src 'self'` — the bundled Web UI is
|
||||
* same-origin, so `'self'` covers it; tighten later if needed.
|
||||
* - `Strict-Transport-Security` — ONLY when `opts.tls === true`. In this
|
||||
* phase TLS is terminated by a reverse proxy (Caddy/nginx), so `start.ts`
|
||||
* passes `tls: false` and HSTS is omitted here; the proxy is responsible
|
||||
* for setting HSTS.
|
||||
*/
|
||||
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
export interface SecurityHeadersOptions {
|
||||
/** When true, also emit `Strict-Transport-Security`. */
|
||||
readonly tls: boolean;
|
||||
}
|
||||
|
||||
const HSTS_VALUE = 'max-age=31536000';
|
||||
|
||||
/**
|
||||
* Build the `onSend` hook. Returns the payload unchanged so Fastify continues
|
||||
* the response pipeline with the headers applied.
|
||||
*/
|
||||
export function createSecurityHeadersHook(
|
||||
opts: SecurityHeadersOptions,
|
||||
): (req: FastifyRequest, reply: FastifyReply, payload: unknown) => Promise<unknown> {
|
||||
return async (_req, reply, payload) => {
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
reply.header('Referrer-Policy', 'no-referrer');
|
||||
reply.header('Content-Security-Policy', "default-src 'self'");
|
||||
if (opts.tls === true) {
|
||||
reply.header('Strict-Transport-Security', HSTS_VALUE);
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
}
|
||||
80
packages/server/src/services/auth/tokenStore.ts
Normal file
80
packages/server/src/services/auth/tokenStore.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
|
||||
import { loadOrCreateServerToken, serverTokenPath } from './persistentToken';
|
||||
|
||||
export interface TokenStore {
|
||||
readonly tokenPath: string;
|
||||
getToken(): string;
|
||||
isValid(candidate: string): boolean;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent token store over `<homeDir>/server.token`.
|
||||
*
|
||||
* The token is loaded (or generated) once at boot and reused across restarts.
|
||||
* `getToken()`/`isValid()` re-read the file whenever its mtime changes, so a
|
||||
* `kimi server rotate-token` (which rewrites the file) takes effect on a
|
||||
* running server immediately — no restart, no extra API. The file is small
|
||||
* (43 bytes) and the common path is a single `statSync` per check.
|
||||
*
|
||||
* `dispose()` is intentionally a no-op: the token must survive shutdown.
|
||||
*/
|
||||
export async function createTokenStore(homeDir: string): Promise<TokenStore> {
|
||||
const tokenPath = serverTokenPath(homeDir);
|
||||
const initial = await loadOrCreateServerToken(homeDir);
|
||||
const initialStat = statSync(tokenPath);
|
||||
let cache: { token: string; mtimeMs: number; ino: number } = {
|
||||
token: initial,
|
||||
mtimeMs: initialStat.mtimeMs,
|
||||
ino: initialStat.ino,
|
||||
};
|
||||
|
||||
const currentToken = (): string => {
|
||||
let st: ReturnType<typeof statSync>;
|
||||
try {
|
||||
st = statSync(tokenPath);
|
||||
} catch {
|
||||
// File temporarily unavailable — keep serving the last known token.
|
||||
return cache.token;
|
||||
}
|
||||
// Detect a rewrite by mtime OR inode. `writePrivateFile` does an atomic
|
||||
// rename, which always yields a new inode (POSIX) and a fresh mtime
|
||||
// (Windows/NTFS, where `ino` is always 0). Checking both makes the reload
|
||||
// robust even on filesystems with coarse (1s) mtime resolution.
|
||||
if (st.mtimeMs === cache.mtimeMs && st.ino === cache.ino) {
|
||||
return cache.token;
|
||||
}
|
||||
// Changed: re-read, but refuse a too-permissive file and never let an
|
||||
// empty/partial read clobber the last good token.
|
||||
if ((st.mode & 0o077) !== 0) {
|
||||
return cache.token;
|
||||
}
|
||||
try {
|
||||
const token = readFileSync(tokenPath, 'utf8').trim();
|
||||
if (token.length > 0) {
|
||||
cache = { token, mtimeMs: st.mtimeMs, ino: st.ino };
|
||||
}
|
||||
} catch {
|
||||
// keep last known token
|
||||
}
|
||||
return cache.token;
|
||||
};
|
||||
|
||||
return {
|
||||
tokenPath,
|
||||
getToken: currentToken,
|
||||
isValid(candidate: string): boolean {
|
||||
const tokenBuf = Buffer.from(currentToken());
|
||||
const candidateBuf = Buffer.from(candidate);
|
||||
if (candidateBuf.length !== tokenBuf.length) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(candidateBuf, tokenBuf);
|
||||
},
|
||||
async dispose(): Promise<void> {
|
||||
// Persistent token: intentionally left on disk so it survives restarts.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,9 +1,19 @@
|
|||
import { createDecorator, type TelemetryClient } from '@moonshot-ai/agent-core';
|
||||
|
||||
import type { HostCheckOptions } from '#/middleware/hostnames';
|
||||
import type { IAuthTokenService } from '#/services/auth/authTokenService';
|
||||
import type { AbortHandler, FsWatchHandler, TerminalHandler } from '#/ws/connection';
|
||||
|
||||
export const WS_PATH = '/api/v1/ws';
|
||||
|
||||
/**
|
||||
* `Sec-WebSocket-Protocol` subprotocol prefix used by browser clients to carry
|
||||
* the bearer token during the WS upgrade handshake (browsers cannot set
|
||||
* arbitrary headers on a WebSocket, so the token rides in a subprotocol). The
|
||||
* full offered subprotocol is `${WS_BEARER_PROTOCOL_PREFIX}<token>`.
|
||||
*/
|
||||
export const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.';
|
||||
|
||||
export interface IWSGateway {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
|
|
@ -14,11 +24,47 @@ export interface IWSGateway {
|
|||
setFsWatchHandler(handler: FsWatchHandler): void;
|
||||
|
||||
setTerminalHandler(handler: TerminalHandler): void;
|
||||
|
||||
/**
|
||||
* Install the `IAuthTokenService` used to validate bearer tokens on the WS
|
||||
* upgrade path. Wired by `start.ts` (M5.1) AFTER construction so the
|
||||
* ix-resolved, override-aware impl (not the constructor options) is what
|
||||
* enforces auth — letting test overrides via `serviceOverrides` take effect
|
||||
* for WS too. `WSGatewayOptions.authTokenService?` is retained only for the
|
||||
* M3/M4 unit tests that construct the gateway directly.
|
||||
*/
|
||||
setAuthTokenService(service: IAuthTokenService): void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
export const IWSGateway = createDecorator<IWSGateway>('wsGateway');
|
||||
|
||||
/**
|
||||
* Extract the bearer token from a `Sec-WebSocket-Protocol` request header.
|
||||
*
|
||||
* The header is a comma-separated list of offered subprotocols (e.g.
|
||||
* `"kimi-code.bearer.abc, other"`). Returns the token portion of the first
|
||||
* entry whose subprotocol starts with {@link WS_BEARER_PROTOCOL_PREFIX}, or
|
||||
* `undefined` when the header is missing/empty, no entry matches, or the
|
||||
* matching entry carries an empty token.
|
||||
*/
|
||||
export function extractWsBearerToken(protocolHeader: string | undefined): string | undefined {
|
||||
if (protocolHeader === undefined || protocolHeader.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
for (const rawEntry of protocolHeader.split(',')) {
|
||||
const entry = rawEntry.trim();
|
||||
if (entry.startsWith(WS_BEARER_PROTOCOL_PREFIX)) {
|
||||
const token = entry.slice(WS_BEARER_PROTOCOL_PREFIX.length);
|
||||
if (token.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface WSGatewayOptions {
|
||||
pingIntervalMs?: number;
|
||||
|
||||
|
|
@ -38,4 +84,28 @@ export interface WSGatewayOptions {
|
|||
* web-path events share one sink + context.
|
||||
*/
|
||||
telemetry?: TelemetryClient;
|
||||
|
||||
/**
|
||||
* When set, the WS upgrade path requires a valid bearer token (via the
|
||||
* `Authorization` header or the `kimi-code.bearer.<token>` subprotocol).
|
||||
* When unset (e.g. tests / pre-M5.1 boots), upgrade auth is skipped.
|
||||
*/
|
||||
authTokenService?: IAuthTokenService;
|
||||
|
||||
/**
|
||||
* Optional Host-header allowlist enforced on the WS upgrade path (ROADMAP
|
||||
* M4.3). When set, upgrades whose `Host` is not allowed are rejected with
|
||||
* `403 Forbidden` before token validation. When unset (tests / pre-M5.1
|
||||
* boots), the WS Host check is skipped — HTTP-level Host enforcement in
|
||||
* `start.ts` still covers non-upgrade requests.
|
||||
*/
|
||||
hostCheck?: HostCheckOptions;
|
||||
|
||||
/**
|
||||
* Optional Origin allowlist enforced on the WS upgrade path (ROADMAP M4.3).
|
||||
* When set, a present browser `Origin` must be same-origin or listed here;
|
||||
* an absent `Origin` (Node `ws` clients) is allowed. When unset (tests /
|
||||
* pre-M5.1 boots), the WS Origin check is skipped.
|
||||
*/
|
||||
allowedOrigins?: readonly string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import type { Socket } from 'node:net';
|
|||
import { Disposable, ILogService } from '@moonshot-ai/agent-core';
|
||||
import { WebSocketServer, type WebSocket } from 'ws';
|
||||
|
||||
import { isAllowedHost } from '#/middleware/hostnames';
|
||||
import { isOriginAllowed } from '#/middleware/origin';
|
||||
import type { IAuthTokenService } from '#/services/auth/authTokenService';
|
||||
import {
|
||||
WsConnection,
|
||||
type AbortHandler,
|
||||
|
|
@ -15,7 +18,13 @@ import { IConnectionRegistry } from './connectionRegistry';
|
|||
import { IRestGateway } from './restGateway';
|
||||
import { ISessionClientsService } from './sessionClients';
|
||||
import { IWSBroadcastService } from './wsBroadcast';
|
||||
import { IWSGateway, type WSGatewayOptions, WS_PATH } from './wsGateway';
|
||||
import {
|
||||
extractWsBearerToken,
|
||||
IWSGateway,
|
||||
type WSGatewayOptions,
|
||||
WS_BEARER_PROTOCOL_PREFIX,
|
||||
WS_PATH,
|
||||
} from './wsGateway';
|
||||
|
||||
export class WSGateway extends Disposable implements IWSGateway {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
|
@ -26,6 +35,7 @@ export class WSGateway extends Disposable implements IWSGateway {
|
|||
private abortHandler: AbortHandler | undefined;
|
||||
private fsWatchHandler: FsWatchHandler | undefined;
|
||||
private terminalHandler: TerminalHandler | undefined;
|
||||
private authTokenService: IAuthTokenService | undefined;
|
||||
private detached = false;
|
||||
|
||||
constructor(
|
||||
|
|
@ -37,9 +47,23 @@ export class WSGateway extends Disposable implements IWSGateway {
|
|||
@ILogService private readonly logger: ILogService,
|
||||
) {
|
||||
super();
|
||||
this.wss = new WebSocketServer({ noServer: true });
|
||||
this.authTokenService = options.authTokenService;
|
||||
this.wss = new WebSocketServer({
|
||||
noServer: true,
|
||||
// Browsers require the server to select one of the offered subprotocols;
|
||||
// echo back the `kimi-code.bearer.<token>` subprotocol when present so
|
||||
// token-carrying browser clients complete the handshake.
|
||||
handleProtocols: (protocols: Set<string>) => {
|
||||
for (const p of protocols) {
|
||||
if (p.startsWith(WS_BEARER_PROTOCOL_PREFIX)) return p;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
this.server = this.restGateway.app.server;
|
||||
this.upgradeListener = (req, sock, head) => this.onUpgrade(req, sock, head);
|
||||
this.upgradeListener = (req, sock, head) => {
|
||||
void this.onUpgrade(req, sock, head);
|
||||
};
|
||||
this.server.on('upgrade', this.upgradeListener);
|
||||
this.logger.debug({ path: WS_PATH }, 'ws gateway attached upgrade listener');
|
||||
}
|
||||
|
|
@ -56,7 +80,11 @@ export class WSGateway extends Disposable implements IWSGateway {
|
|||
this.terminalHandler = handler;
|
||||
}
|
||||
|
||||
private onUpgrade(req: IncomingMessage, socket: Socket, head: Buffer): void {
|
||||
setAuthTokenService(service: IAuthTokenService): void {
|
||||
this.authTokenService = service;
|
||||
}
|
||||
|
||||
private async onUpgrade(req: IncomingMessage, socket: Socket, head: Buffer): Promise<void> {
|
||||
const url = req.url ?? '';
|
||||
const path = url.split('?', 1)[0];
|
||||
if (path !== WS_PATH) {
|
||||
|
|
@ -68,6 +96,49 @@ export class WSGateway extends Disposable implements IWSGateway {
|
|||
// ~40 ms clusters, making the stream look stuttery. Trade a little bandwidth
|
||||
// for lower latency.
|
||||
socket.setNoDelay(true);
|
||||
|
||||
// Host / Origin checks (ROADMAP M4.3) — enforced BEFORE token validation
|
||||
// and only when the corresponding option is provided. When unset (tests /
|
||||
// pre-M5.1 boots) the checks are skipped so existing clients (incl. Node
|
||||
// `ws`, which sends no `Origin`) keep working. Origin is present-only: a
|
||||
// missing `Origin` is treated as a non-browser client and allowed.
|
||||
const hostCheck = this.options.hostCheck;
|
||||
if (hostCheck !== undefined && !isAllowedHost(req.headers.host, hostCheck)) {
|
||||
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedOrigins = this.options.allowedOrigins;
|
||||
if (
|
||||
allowedOrigins !== undefined &&
|
||||
!isOriginAllowed(req.headers.origin, req.headers.host, allowedOrigins)
|
||||
) {
|
||||
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const authTokenService = this.authTokenService;
|
||||
if (authTokenService !== undefined) {
|
||||
const authorization = req.headers.authorization;
|
||||
const token = authorization?.startsWith('Bearer ')
|
||||
? authorization.slice('Bearer '.length)
|
||||
: extractWsBearerToken(req.headers['sec-websocket-protocol']);
|
||||
// `isValid` is the only await on this path; wrap it so a rejection
|
||||
// destroys the socket instead of escaping as an unhandled rejection.
|
||||
let ok = false;
|
||||
try {
|
||||
ok = token !== undefined && (await authTokenService.isValid(token));
|
||||
} catch {
|
||||
ok = false;
|
||||
}
|
||||
if (!ok) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.wss.handleUpgrade(req, socket, head, (ws) => this.onConnect(ws, req));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,14 @@ import {
|
|||
import { installErrorHandler } from './error-handler';
|
||||
import { transformOpenApiDocument } from './openapi/transforms';
|
||||
import { acquireLock, ServerLockedError } from './lock';
|
||||
import { createAuthHook } from '#/middleware/auth';
|
||||
import { createAuthFailureLimiter } from '#/middleware/rateLimit';
|
||||
import {
|
||||
createHostCheck,
|
||||
isHostCheckDisabled,
|
||||
parseAllowedHosts,
|
||||
} from '#/middleware/hostnames';
|
||||
import { createOriginHook, parseCorsOrigins } from '#/middleware/origin';
|
||||
import {
|
||||
createServerLogger,
|
||||
type ServerLogLevel,
|
||||
|
|
@ -28,6 +36,14 @@ import {
|
|||
} from '#/services/gateway';
|
||||
import { createServerServiceCollection } from '#/services/serviceCollection';
|
||||
import { ISnapshotService, loadSnapshotConfig } from '#/services/snapshot';
|
||||
import {
|
||||
createAuthTokenService,
|
||||
IAuthTokenService,
|
||||
} from '#/services/auth/authTokenService';
|
||||
import { classify } from '#/services/auth/bindClassify';
|
||||
import { resolvePasswordHash } from '#/services/auth/password';
|
||||
import { createSecurityHeadersHook } from '#/services/auth/securityHeaders';
|
||||
import { createTokenStore } from '#/services/auth/tokenStore';
|
||||
import { getServerVersion } from './version';
|
||||
import { registerWebAssetRoutes } from './routes/webAssets';
|
||||
|
||||
|
|
@ -46,6 +62,36 @@ export interface ServerStartOptions {
|
|||
|
||||
debugEndpoints?: boolean;
|
||||
|
||||
/**
|
||||
* Override the classification of a wildcard bind (`0.0.0.0` / `::` / empty).
|
||||
* Default (unset) treats wildcards as `public` (most strict); set to `lan`
|
||||
* to relax to LAN-tier hardening. See `services/auth/bindClassify.ts`.
|
||||
*/
|
||||
bindClass?: 'lan' | 'public';
|
||||
|
||||
/**
|
||||
* Allow a non-loopback bind WITHOUT a TLS-terminating reverse proxy. Default
|
||||
* false: binding beyond loopback refuses to start unless this is set, so a
|
||||
* public/LAN bind is never served over plain HTTP by accident. Pass
|
||||
* `--insecure-no-tls` (or set this) only when you accept the risk.
|
||||
*/
|
||||
insecureNoTls?: boolean;
|
||||
|
||||
/**
|
||||
* Allow `POST /api/v1/shutdown` on a non-loopback bind. Default false: the
|
||||
* shutdown route is NOT registered (404) on a public/LAN bind unless this is
|
||||
* set. Loopback always mounts it.
|
||||
*/
|
||||
allowRemoteShutdown?: boolean;
|
||||
|
||||
/**
|
||||
* Allow the PTY `/api/v1/terminals/*` routes on a non-loopback bind. Default
|
||||
* false: terminals routes are NOT registered (404) on a public/LAN bind
|
||||
* unless this is set (remote shell is the highest-risk surface). Loopback
|
||||
* always mounts them.
|
||||
*/
|
||||
allowRemoteTerminals?: boolean;
|
||||
|
||||
webAssetsDir?: string;
|
||||
|
||||
serviceOverrides?: ReadonlyArray<readonly [ServiceIdentifier<unknown>, unknown]>;
|
||||
|
|
@ -88,6 +134,21 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
|
|||
app.setSerializerCompiler(() => (data) => JSON.stringify(data));
|
||||
installErrorHandler(app);
|
||||
|
||||
// Host / Origin checks (ROADMAP M4.3). Registered before any route so they
|
||||
// run ahead of every handler and ahead of the (future, M5.1) auth hook.
|
||||
// Host is evaluated before Origin; both are uniform across bindings (PLAN
|
||||
// D3) — even on loopback — so behavior does not depend on how the server is
|
||||
// reached. The default-allow set keeps `app.inject` (`Host: localhost:80`)
|
||||
// and real `fetch` to `127.0.0.1:<port>` working.
|
||||
const hostCheck = createHostCheck({
|
||||
boundHost: opts.host,
|
||||
extra: parseAllowedHosts(process.env),
|
||||
disable: isHostCheckDisabled(process.env),
|
||||
});
|
||||
const originHook = createOriginHook({ allowedOrigins: parseCorsOrigins(process.env) });
|
||||
app.addHook('onRequest', hostCheck.onRequest);
|
||||
app.addHook('onRequest', originHook);
|
||||
|
||||
const serverVersion = opts.coreProcessOptions?.identity?.version ?? getServerVersion();
|
||||
|
||||
async function registerOpenApi(): Promise<void> {
|
||||
|
|
@ -138,26 +199,138 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
|
|||
}),
|
||||
};
|
||||
|
||||
// Token auth (ROADMAP M5.1). The real `IAuthTokenService` needs an
|
||||
// async-built `TokenStore` over the persistent `<homeDir>/server.token`
|
||||
// (0600; generated once on first boot and reused across restarts) and an
|
||||
// optional bcrypt password hash — both awaited here, then supplied to the
|
||||
// collection via `serviceOverrides` so tests can inject a fixed-token impl
|
||||
// that wins (last-wins) over this default. The store re-reads the file when
|
||||
// its mtime changes, so `kimi server rotate-token` takes effect without a
|
||||
// restart; the file is intentionally kept on shutdown (dispose is a no-op).
|
||||
const tokenStore = await createTokenStore(envService.homeDir);
|
||||
const passwordHash = await resolvePasswordHash(process.env);
|
||||
const defaultAuth = createAuthTokenService({ tokenStore, passwordHash });
|
||||
|
||||
// Public-bind hardening gate (ROADMAP M6.3). Classify the bind host and, for
|
||||
// any non-loopback tier (LAN or public), refuse to start unless the operator
|
||||
// explicitly acknowledged that TLS is terminated elsewhere (`insecureNoTls`).
|
||||
// Auth is bearer-token based: the persistent token is printed in the startup
|
||||
// banner and reused across restarts, so a password is no longer mandatory for
|
||||
// a non-loopback bind — `KIMI_CODE_PASSWORD` remains an optional additional
|
||||
// credential. Failing here (before the container is built and before we
|
||||
// listen) keeps a public/LAN bind from ever serving plain HTTP by accident.
|
||||
// On refusal we release the lock so the operator can retry cleanly.
|
||||
const bindClass = classify(opts.host, { bindClass: opts.bindClass });
|
||||
if (bindClass !== 'loopback') {
|
||||
const refusePublicBind = async (message: string): Promise<never> => {
|
||||
try {
|
||||
await tokenStore.dispose();
|
||||
} catch {
|
||||
// best-effort cleanup of the token file on boot refusal
|
||||
}
|
||||
lockHandle.release();
|
||||
throw new Error(message);
|
||||
};
|
||||
if (opts.insecureNoTls !== true) {
|
||||
await refusePublicBind(
|
||||
'Refusing to bind a non-loopback host without TLS. ' +
|
||||
'Put the server behind a TLS-terminating reverse proxy (Caddy/nginx), ' +
|
||||
'or pass --insecure-no-tls to acknowledge the risk.',
|
||||
);
|
||||
}
|
||||
if (passwordHash === undefined) {
|
||||
pinoLogger.warn(
|
||||
{ host: opts.host, bindClass },
|
||||
'binding non-loopback host with token-only auth (no KIMI_CODE_PASSWORD) — the bearer token printed in the startup banner is the only credential protecting this server',
|
||||
);
|
||||
}
|
||||
pinoLogger.warn(
|
||||
{ host: opts.host, bindClass },
|
||||
'binding non-loopback host without TLS — use a reverse proxy or tunnel in production',
|
||||
);
|
||||
}
|
||||
|
||||
const services = createServerServiceCollection({
|
||||
server: opts,
|
||||
server: {
|
||||
...opts,
|
||||
// WS Host/Origin defaults (ROADMAP M4.3 / M5.1): mirror the HTTP checks
|
||||
// on the upgrade path. Caller-supplied values win (used by the
|
||||
// host-origin e2e tests). `authTokenService` is NOT threaded here — it
|
||||
// reaches the WS gateway via `setAuthTokenService` below so the
|
||||
// override-aware impl enforces auth.
|
||||
wsGatewayOptions: {
|
||||
...opts.wsGatewayOptions,
|
||||
hostCheck: opts.wsGatewayOptions?.hostCheck ?? {
|
||||
boundHost: opts.host,
|
||||
extra: parseAllowedHosts(process.env),
|
||||
disable: isHostCheckDisabled(process.env),
|
||||
},
|
||||
allowedOrigins:
|
||||
opts.wsGatewayOptions?.allowedOrigins ?? parseCorsOrigins(process.env),
|
||||
},
|
||||
serviceOverrides: [
|
||||
[IAuthTokenService, defaultAuth],
|
||||
...(opts.serviceOverrides ?? []),
|
||||
],
|
||||
},
|
||||
app,
|
||||
pinoLogger,
|
||||
envService,
|
||||
});
|
||||
const ix = new InstantiationService(services);
|
||||
|
||||
// Auth hook (ROADMAP M5.1). Registered after Host/Origin (above) and before
|
||||
// routes, so a rejected request never reaches a handler. Resolved from the
|
||||
// container so a test-injected fixed-token impl is what enforces auth.
|
||||
//
|
||||
// Auth-failure rate limit (ROADMAP M6.4): only on a non-loopback bind, where
|
||||
// brute-force attempts are reachable from the network. Loopback keeps the
|
||||
// original "no limiter" behavior so local retries are never throttled.
|
||||
const authTokenService = ix.invokeFunction((a) => a.get(IAuthTokenService));
|
||||
const authFailureLimiter =
|
||||
bindClass !== 'loopback' ? createAuthFailureLimiter() : undefined;
|
||||
app.addHook('onRequest', createAuthHook(authTokenService, { limiter: authFailureLimiter }));
|
||||
|
||||
// Security response headers (ROADMAP M6.6): only on a non-loopback bind.
|
||||
// TLS is terminated by a reverse proxy in this phase, so HSTS is omitted
|
||||
// here (`tls: false`) — the proxy is responsible for setting it.
|
||||
if (bindClass !== 'loopback') {
|
||||
app.addHook('onSend', createSecurityHeadersHook({ tls: false }));
|
||||
}
|
||||
|
||||
// Bind classification (`bindClass`, computed above next to the password/TLS
|
||||
// gate) drives every hardening decision from here on: debug routes now;
|
||||
// rate limit, dangerous endpoints, and security headers in M6.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
|
||||
// endpoints would be reachable from the network, so suppress them even if
|
||||
// the caller asked for them, and warn so the operator knows.
|
||||
if (opts.debugEndpoints === true && bindClass !== 'loopback') {
|
||||
pinoLogger.warn(
|
||||
{ host: opts.host, bindClass },
|
||||
'debug endpoints suppressed: refusing to mount /api/v1/debug/* on a non-loopback bind',
|
||||
);
|
||||
}
|
||||
|
||||
// Dangerous-endpoint downgrade (ROADMAP M6.5): on a non-loopback bind the
|
||||
// shutdown + terminals routes are NOT registered (404) unless the operator
|
||||
// explicitly opts in. Loopback always mounts them (backward compatible).
|
||||
const allowRemoteShutdown = opts.allowRemoteShutdown === true;
|
||||
const allowRemoteTerminals = opts.allowRemoteTerminals === true;
|
||||
await registerApiV1Routes(app, ix, {
|
||||
serverVersion,
|
||||
debugEndpoints: opts.debugEndpoints,
|
||||
debugEndpoints: opts.debugEndpoints === true && bindClass === 'loopback',
|
||||
enableShutdown: bindClass === 'loopback' || allowRemoteShutdown,
|
||||
enableTerminals: bindClass === 'loopback' || allowRemoteTerminals,
|
||||
});
|
||||
|
||||
app.get('/asyncapi.json', async (req, reply) => {
|
||||
const host = typeof req.headers.host === 'string' ? req.headers.host : undefined;
|
||||
app.get('/asyncapi.json', async (_req, reply) => {
|
||||
// Reflect the bound host, never the caller-supplied `Host` header (PLAN
|
||||
// §3.6-3: Host-header reflection is an information-leak / SSRF-adjacent
|
||||
// hole once the server is reachable beyond localhost).
|
||||
return reply.type('application/json').send(
|
||||
createAsyncApiDocument({
|
||||
version: serverVersion,
|
||||
serverHost: host,
|
||||
}),
|
||||
createAsyncApiDocument({ version: serverVersion, serverHost: opts.host }),
|
||||
);
|
||||
});
|
||||
app.get('/openapi.json', async (_req, reply) => {
|
||||
|
|
@ -172,6 +345,11 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
|
|||
try {
|
||||
await app.ready();
|
||||
} catch (error) {
|
||||
try {
|
||||
await tokenStore.dispose();
|
||||
} catch {
|
||||
// best-effort cleanup of the token file on boot failure
|
||||
}
|
||||
lockHandle.release();
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -211,6 +389,10 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
|
|||
|
||||
const wsGw = a.get(IWSGateway);
|
||||
|
||||
// Hand the override-aware auth impl to the WS gateway so the upgrade
|
||||
// path enforces the same token the HTTP hook uses (ROADMAP M5.1).
|
||||
wsGw.setAuthTokenService(authTokenService);
|
||||
|
||||
const built = a.get(ICoreProcessService);
|
||||
|
||||
const sessionService = a.get(ISessionService);
|
||||
|
|
@ -331,6 +513,11 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
|
|||
ix.dispose();
|
||||
} catch {
|
||||
|
||||
}
|
||||
try {
|
||||
await tokenStore.dispose();
|
||||
} catch {
|
||||
// best-effort cleanup of the token file on boot failure
|
||||
}
|
||||
lockHandle.release();
|
||||
throw error;
|
||||
|
|
@ -343,6 +530,11 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
|
|||
ix.dispose();
|
||||
} catch {
|
||||
|
||||
}
|
||||
try {
|
||||
await tokenStore.dispose();
|
||||
} catch {
|
||||
// best-effort cleanup of the token file on boot failure
|
||||
}
|
||||
lockHandle.release();
|
||||
throw error;
|
||||
|
|
@ -364,6 +556,11 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
|
|||
ix.dispose();
|
||||
} catch {
|
||||
|
||||
}
|
||||
try {
|
||||
await tokenStore.dispose();
|
||||
} catch {
|
||||
// best-effort cleanup of the token file on boot failure
|
||||
}
|
||||
lockHandle.release();
|
||||
throw error;
|
||||
|
|
@ -403,6 +600,20 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
|
|||
|
||||
}
|
||||
|
||||
// The persistent token is intentionally left on disk so it survives the
|
||||
// next start (ROADMAP M5.1). dispose() is a no-op for the persistent store;
|
||||
// the call is kept so the interface is honored uniformly and a test
|
||||
// override can still observe shutdown.
|
||||
try {
|
||||
await tokenStore.dispose();
|
||||
} catch {
|
||||
// ignore — token file may already be gone
|
||||
}
|
||||
|
||||
// Stop the auth-failure limiter's cleanup timer (ROADMAP M6.4). Only set
|
||||
// on non-loopback binds; the `?.` is a no-op on loopback.
|
||||
authFailureLimiter?.dispose();
|
||||
|
||||
lockHandle.release();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,286 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`API surface snapshot > matches the documented v1 route table and meta endpoints 1`] = `
|
||||
{
|
||||
"meta": [
|
||||
[
|
||||
"GET",
|
||||
"/",
|
||||
404,
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/asyncapi.json",
|
||||
200,
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/healthz",
|
||||
404,
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/openapi.json",
|
||||
200,
|
||||
],
|
||||
],
|
||||
"routes": [
|
||||
[
|
||||
"DELETE",
|
||||
"/api/v1/files/{file_id}",
|
||||
],
|
||||
[
|
||||
"DELETE",
|
||||
"/api/v1/oauth/login",
|
||||
],
|
||||
[
|
||||
"DELETE",
|
||||
"/api/v1/workspaces/{workspace_id}",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/auth",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/config",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/connections",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/files/{file_id}",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/fs:browse",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/fs:home",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/healthz",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/mcp/servers",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/meta",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/models",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/oauth/login",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/providers",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/providers/{provider_id}",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/sessions",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/sessions/{session_id}",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/sessions/{session_id}/approvals",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/sessions/{session_id}/children",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/sessions/{session_id}/fs/{*}",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/sessions/{session_id}/messages",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/sessions/{session_id}/messages/{message_id}",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/sessions/{session_id}/profile",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/sessions/{session_id}/prompts",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/sessions/{session_id}/questions",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/sessions/{session_id}/skills",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/sessions/{session_id}/snapshot",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/sessions/{session_id}/status",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/sessions/{session_id}/tasks",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/sessions/{session_id}/tasks/{task_id}",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/sessions/{session_id}/terminals",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/sessions/{session_id}/terminals/{terminal_id}",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/sessions/{session_id}/warnings",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/tools",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/api/v1/workspaces",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/asyncapi.json",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
"/openapi.json",
|
||||
],
|
||||
[
|
||||
"PATCH",
|
||||
"/api/v1/workspaces/{workspace_id}",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/config",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/files",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/mcp/servers/{tail}",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/models/{tail}",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/oauth/login",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/oauth/logout",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/providers{refresh_oauth}",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/sessions",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/sessions/{session_id}:compact",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/sessions/{session_id}:fork",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/sessions/{session_id}:undo",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/sessions/{session_id}/{tail}",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/sessions/{session_id}/approvals/{approval_id}",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/sessions/{session_id}/children",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/sessions/{session_id}/profile",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/sessions/{session_id}/prompts",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/sessions/{session_id}/prompts:steer",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/sessions/{session_id}/prompts/{tail}",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/sessions/{session_id}/questions/{tail}",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/sessions/{session_id}/skills/{tail}",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/sessions/{session_id}/tasks/{tail}",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/sessions/{session_id}/terminals",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/sessions/{session_id}/terminals/{tail}",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/shutdown",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/workspaces",
|
||||
],
|
||||
],
|
||||
}
|
||||
`;
|
||||
113
packages/server/test/api-surface.snapshot.test.ts
Normal file
113
packages/server/test/api-surface.snapshot.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* API surface snapshot guardrail (ROADMAP M0.1).
|
||||
*
|
||||
* Boots `startServer` on port 0 with a tmpdir lock + isolated home dir, then
|
||||
* records a stable, sorted snapshot of the documented API surface:
|
||||
*
|
||||
* - `routes`: every `[METHOD, path]` pair derived from `/openapi.json`
|
||||
* `paths` (the documented v1 REST surface). This is the guardrail's target:
|
||||
* later phases that add / remove / hide routes show an intentional diff.
|
||||
* - `meta`: the `(method, url, status)` of doc/meta endpoints that sit
|
||||
* outside `paths` (`/healthz`, `/openapi.json`, `/asyncapi.json`, `/`).
|
||||
* Status codes prove reachability.
|
||||
*
|
||||
* `startServer` does not expose the Fastify `app`, so the surface is read
|
||||
* through the public `/openapi.json` endpoint rather than by inspecting the
|
||||
* route table directly — keeping M0 a no-behavior-change phase.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { pino } from 'pino';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { startServer, type RunningServer } from '../src';
|
||||
import { authHeaders, fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
/** OpenAPI path-item keys that are HTTP methods (skip `parameters`, etc.). */
|
||||
const HTTP_METHODS = new Set([
|
||||
'get',
|
||||
'put',
|
||||
'post',
|
||||
'delete',
|
||||
'options',
|
||||
'head',
|
||||
'patch',
|
||||
'trace',
|
||||
]);
|
||||
|
||||
/** Meta endpoints outside the OpenAPI `paths` map to probe for reachability. */
|
||||
const META_ENDPOINTS = ['/healthz', '/openapi.json', '/asyncapi.json', '/'];
|
||||
|
||||
describe('API surface snapshot', () => {
|
||||
let tmpDir: string | undefined;
|
||||
let bridgeHome: string | undefined;
|
||||
let server: RunningServer | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (server !== undefined) {
|
||||
try {
|
||||
await server.close();
|
||||
} catch {
|
||||
// ignore — best-effort teardown
|
||||
}
|
||||
server = undefined;
|
||||
}
|
||||
if (tmpDir !== undefined) {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
tmpDir = undefined;
|
||||
}
|
||||
if (bridgeHome !== undefined) {
|
||||
rmSync(bridgeHome, { recursive: true, force: true });
|
||||
bridgeHome = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('matches the documented v1 route table and meta endpoints', async () => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-api-surface-'));
|
||||
bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-api-surface-home-'));
|
||||
const lockPath = join(tmpDir, 'lock');
|
||||
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
logger: pino({ level: 'silent' }),
|
||||
coreProcessOptions: { homeDir: bridgeHome },
|
||||
});
|
||||
|
||||
const address = server.address;
|
||||
|
||||
// 1) Documented v1 REST surface, derived from /openapi.json `paths`.
|
||||
const openApiRes = await fetch(`${address}/openapi.json`, { headers: authHeaders() });
|
||||
expect(openApiRes.status).toBe(200);
|
||||
const openApi = (await openApiRes.json()) as {
|
||||
paths?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
const paths = openApi.paths ?? {};
|
||||
expect(Object.keys(paths).length).toBeGreaterThan(0);
|
||||
|
||||
const routes: Array<[string, string]> = [];
|
||||
for (const [path, item] of Object.entries(paths)) {
|
||||
for (const key of Object.keys(item)) {
|
||||
if (HTTP_METHODS.has(key.toLowerCase())) {
|
||||
routes.push([key.toUpperCase(), path]);
|
||||
}
|
||||
}
|
||||
}
|
||||
routes.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]));
|
||||
|
||||
// 2) Doc/meta endpoints that are not part of the OpenAPI `paths` map.
|
||||
const meta: Array<[string, string, number]> = [];
|
||||
for (const endpoint of META_ENDPOINTS) {
|
||||
const res = await fetch(`${address}${endpoint}`, { headers: authHeaders() });
|
||||
meta.push(['GET', endpoint, res.status]);
|
||||
}
|
||||
meta.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]) || a[2] - b[2]);
|
||||
|
||||
expect({ routes, meta }).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -34,6 +34,7 @@ import {
|
|||
} from '@moonshot-ai/agent-core';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
import { rawDataToString } from '../src/ws/rawData';
|
||||
import {
|
||||
ApprovalService,
|
||||
|
|
@ -63,6 +64,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -76,12 +78,24 @@ async function bootDaemon(): Promise<RunningServer> {
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
@ -123,7 +137,7 @@ async function openSubscriber(
|
|||
const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws';
|
||||
const received: Record<string, unknown>[] = [];
|
||||
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
||||
const sock = new WebSocket(wsUrl);
|
||||
const sock = new WebSocket(wsUrl, ['kimi-code.bearer.test-token']);
|
||||
sock.on('message', (data) => {
|
||||
try {
|
||||
received.push(JSON.parse(rawDataToString(data)) as Record<string, unknown>);
|
||||
|
|
|
|||
188
packages/server/test/auth-middleware.e2e.test.ts
Normal file
188
packages/server/test/auth-middleware.e2e.test.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { request as httpRequest } from 'node:http';
|
||||
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createAuthHook } from '#/middleware/auth';
|
||||
import type { IAuthTokenService } from '#/services/auth/authTokenService';
|
||||
|
||||
import { boot, closeAll } from './helpers/serverHarness';
|
||||
|
||||
const TOKEN = 'test-token';
|
||||
|
||||
function fixedImpl(): IAuthTokenService {
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
getToken: () => TOKEN,
|
||||
isValid: async (candidate) => candidate === TOKEN,
|
||||
};
|
||||
}
|
||||
|
||||
describe('createAuthHook (onRequest middleware)', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = Fastify();
|
||||
app.addHook('onRequest', createAuthHook(fixedImpl()));
|
||||
|
||||
app.get('/api/v1/healthz', async () => ({ ok: true }));
|
||||
app.get('/api/v1/sessions', async () => ({ ok: true }));
|
||||
app.options('/api/v1/sessions', async (_req, reply) =>
|
||||
reply.code(204).send(),
|
||||
);
|
||||
app.get('/', async () => ({ ok: true }));
|
||||
app.get('/openapi.json', async () => ({ openapi: '3.1.0' }));
|
||||
// Probe surfaces the post-hook header view so we can assert redaction.
|
||||
app.get('/api/v1/probe', async (req) => ({
|
||||
authorization: req.headers.authorization ?? null,
|
||||
}));
|
||||
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('bypasses GET /api/v1/healthz with no token', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/healthz' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects GET /api/v1/sessions with no token (40101 envelope)', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/sessions' });
|
||||
expect(res.statusCode).toBe(401);
|
||||
const body = res.json() as Record<string, unknown>;
|
||||
expect(body['code']).toBe(40101);
|
||||
expect(body['msg']).toBe('Unauthorized');
|
||||
expect(body['data']).toBeNull();
|
||||
expect(typeof body['request_id']).toBe('string');
|
||||
});
|
||||
|
||||
it('rejects GET /api/v1/sessions with a wrong token', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/sessions',
|
||||
headers: { authorization: 'Bearer wrong-token' },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect((res.json() as Record<string, unknown>)['code']).toBe(40101);
|
||||
});
|
||||
|
||||
it('accepts GET /api/v1/sessions with the correct bearer token', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/sessions',
|
||||
headers: { authorization: `Bearer ${TOKEN}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects a malformed Authorization header', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/sessions',
|
||||
headers: { authorization: TOKEN },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('bypasses OPTIONS /api/v1/sessions with no token', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'OPTIONS',
|
||||
url: '/api/v1/sessions',
|
||||
});
|
||||
expect(res.statusCode).toBe(204);
|
||||
});
|
||||
|
||||
it('bypasses GET / (static asset) with no token', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('does NOT bypass GET /openapi.json (meta doc stays gated)', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/openapi.json' });
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect((res.json() as Record<string, unknown>)['code']).toBe(40101);
|
||||
});
|
||||
|
||||
it('redacts the Authorization header view before the handler runs', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/probe',
|
||||
headers: { authorization: `Bearer ${TOKEN}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as { authorization: string | null };
|
||||
expect(body.authorization).toBe('[redacted]');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Raw HTTP GET that lets us spoof the `Host` header. Node's `http.request`
|
||||
* honors an explicit `headers.Host`, whereas `fetch` (undici) treats `Host` as
|
||||
* a forbidden header and drops it.
|
||||
*/
|
||||
function rawGet(
|
||||
port: number,
|
||||
path: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = httpRequest(
|
||||
{ host: '127.0.0.1', port, path, method: 'GET', headers },
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk: string) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve({
|
||||
status: res.statusCode ?? 0,
|
||||
body: JSON.parse(data) as unknown,
|
||||
});
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('/asyncapi.json serverHost (M2.3 Host-reflection fix)', () => {
|
||||
afterEach(async () => {
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('rejects a spoofed Host header (M4.3) and otherwise reflects the bound host', async () => {
|
||||
const harness = await boot({ host: '127.0.0.1', port: 0 });
|
||||
const port = Number(new URL(harness.address).port);
|
||||
|
||||
// M4.3: a spoofed / disallowed Host is rejected by the global Host check
|
||||
// before it ever reaches the route — a stronger guarantee than the M2.3
|
||||
// "reflect bound host" fix, since the spoofed value can no longer leak.
|
||||
const spoofed = await rawGet(port, '/asyncapi.json', {
|
||||
Host: 'evil.com',
|
||||
});
|
||||
expect(spoofed.status).toBe(403);
|
||||
|
||||
// With an allowed Host, the route responds and still reflects the BOUND
|
||||
// host, never the caller-supplied Host header (the original M2.3 fix).
|
||||
// /asyncapi.json is gated by the M5.1 auth hook, so carry the fixed token
|
||||
// the harness injected via `boot()`.
|
||||
const { status, body } = await rawGet(port, '/asyncapi.json', {
|
||||
Host: '127.0.0.1',
|
||||
Authorization: 'Bearer test-token',
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
|
||||
const servers = (body as { servers: { local: { host: string } } }).servers;
|
||||
expect(servers.local.host).toBe('127.0.0.1');
|
||||
expect(servers.local.host).not.toContain('evil.com');
|
||||
});
|
||||
});
|
||||
208
packages/server/test/auth-wiring.e2e.test.ts
Normal file
208
packages/server/test/auth-wiring.e2e.test.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
/**
|
||||
* Production auth wiring end-to-end (ROADMAP M5.1).
|
||||
*
|
||||
* Unlike the override-driven tests (which inject a fixed-token
|
||||
* `IAuthTokenService`), this file boots `startServer` with NO auth override so
|
||||
* the REAL `defaultAuth` is built: a persistent token written to
|
||||
* `<homeDir>/server.token` (0600) plus the HTTP/WS auth hooks. The token
|
||||
* is read back from disk — exactly what the CLI does (M5.4) — and exercised
|
||||
* against a gated HTTP route and the WS upgrade path. This proves the
|
||||
* production wiring, not just the override seam.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { pino } from 'pino';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { WebSocket, type RawData } from 'ws';
|
||||
|
||||
import { startServer, type RunningServer } from '../src';
|
||||
import { rawDataToString } from '../src/ws/rawData';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
let bridgeHome: string;
|
||||
const running: RunningServer[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-auth-wiring-'));
|
||||
lockPath = join(tmpDir, 'lock');
|
||||
bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-auth-wiring-home-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const r of running.splice(0)) {
|
||||
try {
|
||||
await r.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
rmSync(bridgeHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function tokenPath(): string {
|
||||
return join(bridgeHome, 'server.token');
|
||||
}
|
||||
|
||||
function readToken(): string {
|
||||
return readFileSync(tokenPath(), 'utf8').trim();
|
||||
}
|
||||
|
||||
async function bootReal(): Promise<RunningServer> {
|
||||
const r = await startServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
logger: pino({ level: 'silent' }),
|
||||
coreProcessOptions: { homeDir: bridgeHome },
|
||||
});
|
||||
running.push(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
function wsUrl(http: string): string {
|
||||
return http.replace(/^http:\/\//, 'ws://') + '/api/v1/ws';
|
||||
}
|
||||
|
||||
interface WsFrame {
|
||||
type: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
interface Conn {
|
||||
ws: WebSocket;
|
||||
queue: WsFrame[];
|
||||
waiters: Array<(frame: WsFrame) => void>;
|
||||
closed: Promise<{ code: number; reason: string }>;
|
||||
}
|
||||
|
||||
function openConn(url: string, protocols?: string[]): Promise<Conn> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url, protocols);
|
||||
const queue: WsFrame[] = [];
|
||||
const waiters: Array<(frame: WsFrame) => void> = [];
|
||||
let closedResolve: (v: { code: number; reason: string }) => void;
|
||||
const closed = new Promise<{ code: number; reason: string }>((res) => {
|
||||
closedResolve = res;
|
||||
});
|
||||
// Attach the message listener BEFORE 'open' can fire so the immediate
|
||||
// `server_hello` frame is never dropped.
|
||||
ws.on('message', (data: RawData) => {
|
||||
try {
|
||||
const frame = JSON.parse(rawDataToString(data)) as WsFrame;
|
||||
if (waiters.length > 0) waiters.shift()?.(frame);
|
||||
else queue.push(frame);
|
||||
} catch {
|
||||
// ignore non-JSON frames
|
||||
}
|
||||
});
|
||||
ws.on('close', (code, reason) => closedResolve({ code, reason: String(reason) }));
|
||||
ws.once('open', () => resolve({ ws, queue, waiters, closed }));
|
||||
ws.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function receive(conn: Conn, timeoutMs: number): Promise<WsFrame> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (conn.queue.length > 0) {
|
||||
resolve(conn.queue.shift()!);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
const idx = conn.waiters.indexOf(waiter);
|
||||
if (idx >= 0) conn.waiters.splice(idx, 1);
|
||||
reject(new Error(`no message within ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
const waiter = (frame: WsFrame): void => {
|
||||
clearTimeout(t);
|
||||
resolve(frame);
|
||||
};
|
||||
conn.waiters.push(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
async function receiveType(conn: Conn, type: string, timeoutMs: number): Promise<WsFrame> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) throw new Error(`no message of type ${type} within ${timeoutMs}ms`);
|
||||
const frame = await receive(conn, remaining);
|
||||
if (frame.type === type) return frame;
|
||||
}
|
||||
}
|
||||
|
||||
function expectRejected(url: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
const t = setTimeout(
|
||||
() => done(new Error('connection was not rejected within timeout')),
|
||||
1500,
|
||||
);
|
||||
const done = (err?: Error): void => {
|
||||
clearTimeout(t);
|
||||
ws.removeAllListeners();
|
||||
try {
|
||||
ws.terminate();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (err === undefined) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
ws.once('open', () => done(new Error('connection unexpectedly opened')));
|
||||
ws.once('error', () => done());
|
||||
ws.once('close', () => done());
|
||||
});
|
||||
}
|
||||
|
||||
describe('production auth wiring (M5.1)', () => {
|
||||
it('writes a 0600 token file at boot and keeps it on close (persistent)', async () => {
|
||||
const r = await bootReal();
|
||||
const p = tokenPath();
|
||||
|
||||
const info = statSync(p);
|
||||
expect(info.mode & 0o777).toBe(0o600);
|
||||
|
||||
const token = readToken();
|
||||
expect(token.length).toBeGreaterThan(0);
|
||||
|
||||
await r.close();
|
||||
// Persistent token: the file survives shutdown so the next start reuses it.
|
||||
expect(statSync(p).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('gates HTTP: 200 with the token, 401 without', async () => {
|
||||
const r = await bootReal();
|
||||
const token = readToken();
|
||||
|
||||
const ok = await fetch(`${r.address}/openapi.json`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
|
||||
const bad = await fetch(`${r.address}/openapi.json`);
|
||||
expect(bad.status).toBe(401);
|
||||
const body = (await bad.json()) as { code: number };
|
||||
expect(body.code).toBe(40101);
|
||||
});
|
||||
|
||||
it('gates WS: server_hello with the token, rejected without', async () => {
|
||||
const r = await bootReal();
|
||||
const token = readToken();
|
||||
|
||||
const conn = await openConn(wsUrl(r.address), [`kimi-code.bearer.${token}`]);
|
||||
const hello = await receiveType(conn, 'server_hello', 1000);
|
||||
expect(hello.type).toBe('server_hello');
|
||||
conn.ws.close();
|
||||
await conn.closed;
|
||||
|
||||
await expectRejected(wsUrl(r.address));
|
||||
});
|
||||
});
|
||||
|
|
@ -32,6 +32,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||
import { authSummarySchema, type AuthSummary } from '@moonshot-ai/protocol';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -57,6 +58,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -69,12 +71,24 @@ async function bootDaemon(): Promise<RunningServer> {
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
|
|||
127
packages/server/test/authTokenService.test.ts
Normal file
127
packages/server/test/authTokenService.test.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import type { Server as HttpServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
InstantiationService,
|
||||
type IEnvironmentService,
|
||||
} from '@moonshot-ai/agent-core';
|
||||
import { pino } from 'pino';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createAuthTokenService,
|
||||
IAuthTokenService,
|
||||
} from '#/services/auth/authTokenService';
|
||||
import { resolvePasswordHash } from '#/services/auth/password';
|
||||
import { createTokenStore, type TokenStore } from '#/services/auth/tokenStore';
|
||||
import type { FastifyLike } from '#/services/gateway/restGateway';
|
||||
import { createServerServiceCollection } from '#/services/serviceCollection';
|
||||
|
||||
function tmpEnv(): { dir: string; env: IEnvironmentService } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'kimi-auth-token-test-'));
|
||||
return {
|
||||
dir,
|
||||
env: {
|
||||
_serviceBrand: undefined,
|
||||
homeDir: dir,
|
||||
configPath: join(dir, 'config.toml'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal Fastify shape — `FastifyRestGateway` only stores it at construction. */
|
||||
function appStub(): FastifyLike {
|
||||
return {
|
||||
server: {} as unknown as HttpServer,
|
||||
listen: async () => 'http://127.0.0.1:0',
|
||||
close: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('createAuthTokenService', () => {
|
||||
let homeDir: string;
|
||||
let store: TokenStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
homeDir = mkdtempSync(join(tmpdir(), 'kimi-auth-token-store-'));
|
||||
store = await createTokenStore(homeDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await store.dispose();
|
||||
rmSync(homeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('getToken() returns the tokenStore token', () => {
|
||||
const svc = createAuthTokenService({ tokenStore: store, passwordHash: undefined });
|
||||
expect(svc.getToken()).toBe(store.getToken());
|
||||
});
|
||||
|
||||
it('isValid accepts the token', async () => {
|
||||
const svc = createAuthTokenService({ tokenStore: store, passwordHash: undefined });
|
||||
expect(await svc.isValid(store.getToken())).toBe(true);
|
||||
});
|
||||
|
||||
it('isValid accepts the password when a hash is configured', async () => {
|
||||
const passwordHash = await resolvePasswordHash({
|
||||
KIMI_CODE_PASSWORD: 'correct horse battery staple',
|
||||
});
|
||||
const svc = createAuthTokenService({ tokenStore: store, passwordHash });
|
||||
expect(await svc.isValid('correct horse battery staple')).toBe(true);
|
||||
});
|
||||
|
||||
it('isValid rejects a wrong candidate', async () => {
|
||||
const passwordHash = await resolvePasswordHash({
|
||||
KIMI_CODE_PASSWORD: 'correct horse battery staple',
|
||||
});
|
||||
const svc = createAuthTokenService({ tokenStore: store, passwordHash });
|
||||
expect(await svc.isValid('wrong')).toBe(false);
|
||||
});
|
||||
|
||||
it('isValid accepts only the token when passwordHash is undefined', async () => {
|
||||
const svc = createAuthTokenService({ tokenStore: store, passwordHash: undefined });
|
||||
expect(await svc.isValid(store.getToken())).toBe(true);
|
||||
expect(await svc.isValid('any-password')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IAuthTokenService via serviceCollection override', () => {
|
||||
let env: IEnvironmentService;
|
||||
let dir: string;
|
||||
let ix: InstantiationService;
|
||||
|
||||
const fixed: IAuthTokenService = {
|
||||
_serviceBrand: undefined,
|
||||
getToken: () => 'fixed-token',
|
||||
isValid: async (candidate) => candidate === 'fixed-token',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
({ env, dir } = tmpEnv());
|
||||
const collection = createServerServiceCollection({
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
serviceOverrides: [[IAuthTokenService, fixed] as const],
|
||||
},
|
||||
app: appStub(),
|
||||
pinoLogger: pino({ level: 'silent' }),
|
||||
envService: env,
|
||||
});
|
||||
ix = new InstantiationService(collection);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ix.dispose();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('retrieves the injected impl and validates its own token', async () => {
|
||||
const resolved = ix.invokeFunction((a) => a.get(IAuthTokenService));
|
||||
expect(resolved).toBe(fixed);
|
||||
expect(await resolved.isValid(resolved.getToken())).toBe(true);
|
||||
expect(await resolved.isValid('nope')).toBe(false);
|
||||
});
|
||||
});
|
||||
85
packages/server/test/bindClassify.test.ts
Normal file
85
packages/server/test/bindClassify.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* `classify(host)` tier classification (ROADMAP M6.1).
|
||||
*
|
||||
* Pins the loopback / lan / public boundaries that gate every M6 hardening
|
||||
* decision, including the wildcard defaults and the RFC1918 / link-local
|
||||
* edges (e.g. `172.31.255.255` inside `172.16/12`, `172.32.0.1` just outside).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { classify } from '../src/services/auth/bindClassify';
|
||||
|
||||
describe('classify', () => {
|
||||
describe('loopback', () => {
|
||||
it.each([
|
||||
['127.0.0.1'],
|
||||
['127.255.255.255'],
|
||||
['::1'],
|
||||
['localhost'],
|
||||
])('%s → loopback', (host) => {
|
||||
expect(classify(host)).toBe('loopback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lan', () => {
|
||||
it.each([
|
||||
['192.168.1.5'],
|
||||
['10.0.0.1'],
|
||||
['172.16.0.1'],
|
||||
['172.31.255.255'],
|
||||
['169.254.1.1'],
|
||||
['fe80::1'],
|
||||
['fe80:0000:0000:0000:0000:0000:0000:0001'],
|
||||
['febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff'],
|
||||
])('%s → lan', (host) => {
|
||||
expect(classify(host)).toBe('lan');
|
||||
});
|
||||
});
|
||||
|
||||
describe('public', () => {
|
||||
it.each([
|
||||
['8.8.8.8'],
|
||||
['172.32.0.1'],
|
||||
['203.0.113.5'],
|
||||
['2001:4860:4860::8888'],
|
||||
['fec0::1'],
|
||||
['example.com'],
|
||||
])('%s → public', (host) => {
|
||||
expect(classify(host)).toBe('public');
|
||||
});
|
||||
});
|
||||
|
||||
describe('wildcard binds default to public unless relaxed', () => {
|
||||
it('0.0.0.0 → public by default', () => {
|
||||
expect(classify('0.0.0.0')).toBe('public');
|
||||
});
|
||||
|
||||
it('0.0.0.0 → lan when bindClass=lan', () => {
|
||||
expect(classify('0.0.0.0', { bindClass: 'lan' })).toBe('lan');
|
||||
});
|
||||
|
||||
it('0.0.0.0 → public when bindClass=public (explicit)', () => {
|
||||
expect(classify('0.0.0.0', { bindClass: 'public' })).toBe('public');
|
||||
});
|
||||
|
||||
it(':: → public by default', () => {
|
||||
expect(classify('::')).toBe('public');
|
||||
});
|
||||
|
||||
it(':: → lan when bindClass=lan', () => {
|
||||
expect(classify('::', { bindClass: 'lan' })).toBe('lan');
|
||||
});
|
||||
|
||||
it('empty string → public by default', () => {
|
||||
expect(classify('')).toBe('public');
|
||||
});
|
||||
});
|
||||
|
||||
it('bindClass override does not reclassify a concrete loopback/lan host', () => {
|
||||
// The override applies only to wildcard binds; a real loopback stays
|
||||
// loopback even if a caller passes bindClass=public by mistake.
|
||||
expect(classify('127.0.0.1', { bindClass: 'public' })).toBe('loopback');
|
||||
expect(classify('192.168.1.5', { bindClass: 'public' })).toBe('lan');
|
||||
});
|
||||
});
|
||||
|
|
@ -6,6 +6,7 @@ import { pino } from 'pino';
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -31,6 +32,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -43,12 +45,24 @@ async function bootDaemon(): Promise<RunningServer> {
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
startServer,
|
||||
type RunningServer,
|
||||
} from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
import { rawDataToString } from '../src/ws/rawData';
|
||||
|
||||
let tmpDir: string;
|
||||
|
|
@ -56,6 +57,7 @@ afterEach(async () => {
|
|||
|
||||
async function spawn(): Promise<RunningServer> {
|
||||
const r = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -71,12 +73,24 @@ async function spawn(): Promise<RunningServer> {
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function wsUrl(http: string): string {
|
||||
|
|
@ -100,7 +114,7 @@ interface Conn {
|
|||
|
||||
function openConn(url: string): Promise<Conn> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
const ws = new WebSocket(url, ['kimi-code.bearer.test-token']);
|
||||
const queue: WsFrame[] = [];
|
||||
const waiters: Array<(frame: WsFrame) => void> = [];
|
||||
let closedResolve: (v: { code: number; reason: string }) => void;
|
||||
|
|
|
|||
93
packages/server/test/debug-nonloopback.e2e.test.ts
Normal file
93
packages/server/test/debug-nonloopback.e2e.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* Debug-route loopback gating (ROADMAP M5.3).
|
||||
*
|
||||
* `/api/v1/debug/*` routes are test-only introspection/mutation endpoints.
|
||||
* They must only be mounted when the server is bound to a loopback interface;
|
||||
* on a non-loopback bind (e.g. `0.0.0.0`) they are suppressed even when the
|
||||
* caller passes `debugEndpoints: true`.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { pino } from 'pino';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { startServer, type RunningServer } from '../src';
|
||||
import { authHeaders, fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
let bridgeHome: string;
|
||||
let prevPassword: string | undefined;
|
||||
const running: RunningServer[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-debug-loopback-'));
|
||||
lockPath = join(tmpDir, 'lock');
|
||||
bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-debug-loopback-home-'));
|
||||
// M6.3: a non-loopback bind (0.0.0.0) now refuses to start without a
|
||||
// password + TLS opt-out. Set a password so the 0.0.0.0 case can boot; the
|
||||
// fixed-token override still governs auth (password ≠ token).
|
||||
prevPassword = process.env['KIMI_CODE_PASSWORD'];
|
||||
process.env['KIMI_CODE_PASSWORD'] = 'test-pw';
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const r of running.splice(0)) {
|
||||
try {
|
||||
await r.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (prevPassword === undefined) {
|
||||
delete process.env['KIMI_CODE_PASSWORD'];
|
||||
} else {
|
||||
process.env['KIMI_CODE_PASSWORD'] = prevPassword;
|
||||
}
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
rmSync(bridgeHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function boot(host: string): Promise<RunningServer> {
|
||||
const r = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host,
|
||||
port: 0,
|
||||
lockPath: host === '0.0.0.0' ? join(tmpDir, `lock-${host}`) : lockPath,
|
||||
logger: pino({ level: 'silent' }),
|
||||
coreProcessOptions: { homeDir: bridgeHome },
|
||||
debugEndpoints: true,
|
||||
// M6.3: acknowledge the lack of a TLS proxy so the 0.0.0.0 bind is allowed
|
||||
// (loopback ignores this — the gate only fires for non-loopback).
|
||||
insecureNoTls: true,
|
||||
});
|
||||
running.push(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Probe a debug route on `127.0.0.1:<port>` regardless of the bound host. */
|
||||
async function probeDebug(r: RunningServer): Promise<number> {
|
||||
const port = new URL(r.address).port;
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/v1/debug/prompts/some-session/state`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
return res.status;
|
||||
}
|
||||
|
||||
describe('debug endpoints are loopback-only (M5.3)', () => {
|
||||
it('does NOT mount /api/v1/debug/* on a non-loopback bind (0.0.0.0)', async () => {
|
||||
const r = await boot('0.0.0.0');
|
||||
// Route suppressed → Fastify 404 (the auth hook would 401 if the route
|
||||
// existed without a token, but with a token a missing route is 404).
|
||||
expect(await probeDebug(r)).toBe(404);
|
||||
});
|
||||
|
||||
it('mounts /api/v1/debug/* on a loopback bind (127.0.0.1)', async () => {
|
||||
const r = await boot('127.0.0.1');
|
||||
// Route mounted + valid token → 200 (data is null for an unknown session).
|
||||
expect(await probeDebug(r)).toBe(200);
|
||||
});
|
||||
});
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
startServer,
|
||||
type RunningServer,
|
||||
} from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -53,6 +54,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -76,10 +78,22 @@ interface FastifyAppLike {
|
|||
}
|
||||
|
||||
function appOf(r: RunningServer): FastifyAppLike {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as FastifyAppLike;
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface Envelope<T = unknown> {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { pino } from 'pino';
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -56,6 +57,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -71,15 +73,27 @@ function appOf(r: RunningServer): {
|
|||
json: () => unknown;
|
||||
}>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{
|
||||
statusCode: number;
|
||||
json: () => unknown;
|
||||
}>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{
|
||||
statusCode: number;
|
||||
json: () => unknown;
|
||||
}>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { pino } from 'pino';
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -56,6 +57,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -71,15 +73,27 @@ function appOf(r: RunningServer): {
|
|||
json: () => unknown;
|
||||
}>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{
|
||||
statusCode: number;
|
||||
json: () => unknown;
|
||||
}>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{
|
||||
statusCode: number;
|
||||
json: () => unknown;
|
||||
}>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { pino } from 'pino';
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
import type {
|
||||
FsBrowseEntry,
|
||||
FsBrowseResponse,
|
||||
|
|
@ -55,6 +56,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -67,12 +69,24 @@ async function bootDaemon(): Promise<RunningServer> {
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { pino } from 'pino';
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -58,6 +59,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -79,12 +81,24 @@ interface InjectResponse {
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<InjectResponse>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<InjectResponse>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<InjectResponse>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { pino } from 'pino';
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
import { parsePorcelain, parseNumstat } from '@moonshot-ai/agent-core';
|
||||
|
||||
let tmpDir: string;
|
||||
|
|
@ -43,6 +44,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -58,15 +60,27 @@ function appOf(r: RunningServer): {
|
|||
json: () => unknown;
|
||||
}>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{
|
||||
statusCode: number;
|
||||
json: () => unknown;
|
||||
}>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{
|
||||
statusCode: number;
|
||||
json: () => unknown;
|
||||
}>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { pino } from 'pino';
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -47,6 +48,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -62,15 +64,27 @@ function appOf(r: RunningServer): {
|
|||
json: () => unknown;
|
||||
}>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{
|
||||
statusCode: number;
|
||||
json: () => unknown;
|
||||
}>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{
|
||||
statusCode: number;
|
||||
json: () => unknown;
|
||||
}>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||
import { ISessionService, FsSearchService, ILogService } from '@moonshot-ai/agent-core';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -43,6 +44,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -58,15 +60,27 @@ function appOf(r: RunningServer): {
|
|||
json: () => unknown;
|
||||
}>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{
|
||||
statusCode: number;
|
||||
json: () => unknown;
|
||||
}>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{
|
||||
statusCode: number;
|
||||
json: () => unknown;
|
||||
}>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import {
|
|||
startServer,
|
||||
type RunningServer,
|
||||
} from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
import { rawDataToString } from '../src/ws/rawData';
|
||||
|
||||
let tmpDir: string;
|
||||
|
|
@ -74,6 +75,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -90,15 +92,27 @@ function appOf(r: RunningServer): {
|
|||
json: () => unknown;
|
||||
}>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{
|
||||
statusCode: number;
|
||||
json: () => unknown;
|
||||
}>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{
|
||||
statusCode: number;
|
||||
json: () => unknown;
|
||||
}>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createSession(r: RunningServer): Promise<string> {
|
||||
|
|
@ -136,7 +150,7 @@ interface Conn {
|
|||
|
||||
function openConn(url: string): Promise<Conn> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
const ws = new WebSocket(url, ['kimi-code.bearer.test-token']);
|
||||
const queue: WsFrame[] = [];
|
||||
const waiters: Array<(frame: WsFrame) => void> = [];
|
||||
ws.on('message', (data) => {
|
||||
|
|
|
|||
229
packages/server/test/helpers/serverHarness.ts
Normal file
229
packages/server/test/helpers/serverHarness.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* Reusable e2e server harness with token support (ROADMAP M0.2 / M5.1).
|
||||
*
|
||||
* Wraps `startServer` with an isolated tmp lock + home dir and exposes helpers
|
||||
* (`authedFetch` / `authedWs`) that carry an `Authorization: Bearer <token>`.
|
||||
*
|
||||
* From M5.1 the server enforces bearer auth, so `boot()` injects a fixed-token
|
||||
* `IAuthTokenService` (default token `test-token`) via `serviceOverrides` by
|
||||
* default — keeping harness-based tests transparent. A caller-supplied
|
||||
* `IAuthTokenService` override still wins (serviceOverrides are last-wins), so
|
||||
* tests that need a custom impl can pass one explicitly. For tests that boot
|
||||
* `startServer` directly, use the exported {@link fixedTokenAuth} /
|
||||
* {@link withAuth} / {@link authHeaders} helpers to inject the same fixed token
|
||||
* and carry it on each request.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { ServiceIdentifier } from '@moonshot-ai/agent-core';
|
||||
import { pino } from 'pino';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
import { startServer, type RunningServer, type ServerStartOptions } from '../../src';
|
||||
import { IAuthTokenService } from '../../src/services/auth/authTokenService';
|
||||
import type { IAuthTokenService as IAuthTokenServiceType } from '../../src/services/auth/authTokenService';
|
||||
|
||||
type ServiceOverride = readonly [ServiceIdentifier<unknown>, unknown];
|
||||
|
||||
/** Default deterministic token used when a test does not supply one. */
|
||||
const DEFAULT_TOKEN = 'test-token';
|
||||
|
||||
/**
|
||||
* Build a `[IAuthTokenService, impl]` override pair that accepts a single fixed
|
||||
* `token`. Pass it into `startServer({ serviceOverrides: [fixedTokenAuth()] })`
|
||||
* (or `boot({ serviceOverrides: [fixedTokenAuth()] })`) so a test can
|
||||
* authenticate with `Authorization: Bearer test-token` — or any custom `token`.
|
||||
*
|
||||
* `getToken` returns the fixed token so callers that read it back (e.g. WS
|
||||
* subprotocol helpers) stay consistent; `isValid` accepts only that token.
|
||||
*/
|
||||
export function fixedTokenAuth(token: string = DEFAULT_TOKEN): ServiceOverride {
|
||||
const impl: IAuthTokenServiceType = {
|
||||
_serviceBrand: undefined,
|
||||
getToken: () => token,
|
||||
isValid: async (candidate) => candidate === token,
|
||||
};
|
||||
return [IAuthTokenService, impl];
|
||||
}
|
||||
|
||||
/** An `Authorization: Bearer <token>` header bag, for spreading into `headers`. */
|
||||
export function authHeaders(token: string = DEFAULT_TOKEN): { Authorization: string } {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `Authorization: Bearer <token>` into a `fetch` `RequestInit`, preserving
|
||||
* caller-supplied headers. A caller-supplied `Authorization` wins, so tests that
|
||||
* need to send a wrong/no token can still do so explicitly.
|
||||
*/
|
||||
export function withAuth(init: RequestInit = {}, token: string = DEFAULT_TOKEN): RequestInit {
|
||||
const headers = new Headers(init.headers);
|
||||
if (!headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
return { ...init, headers };
|
||||
}
|
||||
|
||||
/**
|
||||
* WS subprotocol prefix that carries the bearer token during the upgrade.
|
||||
* Hardcoded here as a literal for M0; `WS_BEARER_PROTOCOL_PREFIX` is introduced
|
||||
* in M3.1 and may replace this literal when the WS auth seam lands.
|
||||
*/
|
||||
const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.';
|
||||
|
||||
export interface BootOptions {
|
||||
/** Bearer token attached by `authedFetch` / `authedWs`. Defaults to `'test-token'`. */
|
||||
token?: string;
|
||||
/**
|
||||
* Generic pass-through to `startServer({ serviceOverrides })`. The auth seam:
|
||||
* from M2.1 on, callers inject `[IAuthTokenService, fixedTokenImpl]` here.
|
||||
* Defaults to `[]`.
|
||||
*/
|
||||
serviceOverrides?: ServerStartOptions['serviceOverrides'];
|
||||
/** Bind host. Defaults to `'127.0.0.1'`. */
|
||||
host?: string;
|
||||
/** Bind port. Defaults to `0` (ephemeral). */
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export interface ServerHarness {
|
||||
/** The underlying `startServer` result. */
|
||||
readonly server: RunningServer;
|
||||
/** Raw address returned by `startServer`, e.g. `http://127.0.0.1:51234`. */
|
||||
readonly address: string;
|
||||
/** HTTP base URL, e.g. `http://127.0.0.1:51234`. */
|
||||
readonly baseUrl: string;
|
||||
/** WebSocket URL for the v1 endpoint, e.g. `ws://127.0.0.1:51234/api/v1/ws`. */
|
||||
readonly wsUrl: string;
|
||||
/** The bearer token this harness attaches to requests. */
|
||||
readonly token: string;
|
||||
/**
|
||||
* `fetch` against `baseUrl + path`, merging `Authorization: Bearer <token>`
|
||||
* into the request headers. Caller-supplied headers win on conflict.
|
||||
*/
|
||||
authedFetch(path: string, init?: RequestInit): Promise<Response>;
|
||||
/**
|
||||
* Open a `ws` WebSocket to `wsUrl`, offering subprotocol
|
||||
* `kimi-code.bearer.<token>` and an `Authorization: Bearer <token>` header.
|
||||
* In M0 the server ignores both; the connection still opens.
|
||||
*/
|
||||
authedWs(): WebSocket;
|
||||
/** Tear down this server and terminate any sockets opened via `authedWs`. */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Every harness produced by `boot()`, for suite-level cleanup via `closeAll()`. */
|
||||
const opened = new Set<ServerHarness>();
|
||||
|
||||
function deriveHttpPort(address: string): string {
|
||||
const port = new URL(address).port;
|
||||
if (port === '') {
|
||||
throw new Error(`cannot derive port from server address: ${address}`);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot an isolated `startServer` for e2e use.
|
||||
*
|
||||
* Creates a tmp `lockPath` + isolated home dir (mirroring `start.test.ts`),
|
||||
* binds to `127.0.0.1:0` by default, and tracks the result for `closeAll()`.
|
||||
*/
|
||||
export async function boot(opts: BootOptions = {}): Promise<ServerHarness> {
|
||||
const token = opts.token ?? DEFAULT_TOKEN;
|
||||
const host = opts.host ?? '127.0.0.1';
|
||||
const port = opts.port ?? 0;
|
||||
// Inject a fixed-token `IAuthTokenService` by default so harness-based tests
|
||||
// stay transparent (`authedFetch` / `authedWs` already carry `test-token`).
|
||||
// The fixed impl is placed FIRST so an explicit caller-supplied
|
||||
// `IAuthTokenService` override still wins (serviceOverrides are last-wins).
|
||||
const serviceOverrides: ServerStartOptions['serviceOverrides'] = [
|
||||
fixedTokenAuth(token),
|
||||
...(opts.serviceOverrides ?? []),
|
||||
];
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-harness-'));
|
||||
const homeDir = mkdtempSync(join(tmpdir(), 'kimi-server-harness-home-'));
|
||||
const lockPath = join(tmpDir, 'lock');
|
||||
|
||||
const server = await startServer({
|
||||
host,
|
||||
port,
|
||||
lockPath,
|
||||
serviceOverrides,
|
||||
logger: pino({ level: 'silent' }),
|
||||
coreProcessOptions: { homeDir },
|
||||
});
|
||||
|
||||
const httpPort = deriveHttpPort(server.address);
|
||||
const baseUrl = `http://${host}:${httpPort}`;
|
||||
const wsUrl = `ws://${host}:${httpPort}/api/v1/ws`;
|
||||
|
||||
const sockets = new Set<WebSocket>();
|
||||
let closed = false;
|
||||
|
||||
const harness: ServerHarness = {
|
||||
server,
|
||||
address: server.address,
|
||||
baseUrl,
|
||||
wsUrl,
|
||||
token,
|
||||
|
||||
authedFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
const headers = new Headers(init.headers);
|
||||
// Caller-supplied Authorization wins on conflict.
|
||||
if (!headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
return fetch(`${baseUrl}${path}`, { ...init, headers });
|
||||
},
|
||||
|
||||
authedWs(): WebSocket {
|
||||
const ws = new WebSocket(wsUrl, [`${WS_BEARER_PROTOCOL_PREFIX}${token}`], {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
sockets.add(ws);
|
||||
const drop = (): void => {
|
||||
sockets.delete(ws);
|
||||
};
|
||||
ws.once('close', drop);
|
||||
ws.once('error', drop);
|
||||
return ws;
|
||||
},
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
opened.delete(harness);
|
||||
|
||||
for (const ws of sockets) {
|
||||
try {
|
||||
ws.terminate();
|
||||
} catch {
|
||||
// ignore — best-effort teardown
|
||||
}
|
||||
}
|
||||
sockets.clear();
|
||||
|
||||
try {
|
||||
await server.close();
|
||||
} catch {
|
||||
// ignore — best-effort teardown
|
||||
}
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
rmSync(homeDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
|
||||
opened.add(harness);
|
||||
return harness;
|
||||
}
|
||||
|
||||
/** Close every harness produced by `boot()`. Intended for `afterEach` cleanup. */
|
||||
export async function closeAll(): Promise<void> {
|
||||
const pending = [...opened];
|
||||
await Promise.all(pending.map((harness) => harness.close()));
|
||||
}
|
||||
345
packages/server/test/host-exposure.e2e.test.ts
Normal file
345
packages/server/test/host-exposure.e2e.test.ts
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
/**
|
||||
* Host-exposure hardening (ROADMAP M6.3–M6.7).
|
||||
*
|
||||
* End-to-end coverage of the §3.5 public-bind hardening stack on a
|
||||
* `host: '0.0.0.0'` + `KIMI_CODE_PASSWORD` + `insecureNoTls: true` server:
|
||||
* - M6.3 public-bind gate (no `--insecure-no-tls` → refuse; token-only
|
||||
* (no password) + `insecureNoTls` → boot + token-only warning logged;
|
||||
* password + `insecureNoTls` → boot + warn logged).
|
||||
* - Real password auth path (`Authorization: Bearer <password>` → 200 via
|
||||
* `verifyPassword`; wrong/missing credentials → 401).
|
||||
* - M6.4 auth-failure rate limit (N bad tokens → 429 on the (N+1)th).
|
||||
* - M6.5 dangerous-endpoint downgrade (shutdown/terminals 404 by default;
|
||||
* 200 with the allow flags; loopback mounts shutdown by default).
|
||||
* - Host allowlist (spoofed Host → 403; bound host → 200).
|
||||
* - M6.6 security response headers present on a non-loopback response.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Writable } from 'node:stream';
|
||||
|
||||
import { pino, type Logger } from 'pino';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { IServerShutdownService, startServer, type RunningServer, type ServerStartOptions } from '../src';
|
||||
import { authHeaders, fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
const createdDirs: string[] = [];
|
||||
const running: RunningServer[] = [];
|
||||
let prevPassword: string | undefined;
|
||||
|
||||
function tmpPaths(): { lockPath: string; homeDir: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'kimi-host-exposure-'));
|
||||
const home = mkdtempSync(join(tmpdir(), 'kimi-host-exposure-home-'));
|
||||
createdDirs.push(dir, home);
|
||||
return { lockPath: join(dir, 'lock'), homeDir: home };
|
||||
}
|
||||
|
||||
function capturingLogger(): { logger: Logger; lines: string[] } {
|
||||
const lines: string[] = [];
|
||||
const dest = new Writable({
|
||||
write(chunk, _enc, cb) {
|
||||
lines.push(String(chunk));
|
||||
cb();
|
||||
},
|
||||
});
|
||||
return { logger: pino({ level: 'info' }, dest), lines };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
prevPassword = process.env['KIMI_CODE_PASSWORD'];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const r of running.splice(0)) {
|
||||
try {
|
||||
await r.close();
|
||||
} catch {
|
||||
// ignore — best-effort teardown
|
||||
}
|
||||
}
|
||||
for (const dir of createdDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
if (prevPassword === undefined) {
|
||||
delete process.env['KIMI_CODE_PASSWORD'];
|
||||
} else {
|
||||
process.env['KIMI_CODE_PASSWORD'] = prevPassword;
|
||||
}
|
||||
});
|
||||
|
||||
describe('non-loopback bind gate (M6.3)', () => {
|
||||
it('boots 0.0.0.0 without a password (token-only) and logs the token-only warning', async () => {
|
||||
delete process.env['KIMI_CODE_PASSWORD'];
|
||||
const { lockPath, homeDir } = tmpPaths();
|
||||
const { logger, lines } = capturingLogger();
|
||||
|
||||
const server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '0.0.0.0',
|
||||
port: 0,
|
||||
lockPath,
|
||||
insecureNoTls: true,
|
||||
logger,
|
||||
coreProcessOptions: { homeDir },
|
||||
});
|
||||
running.push(server);
|
||||
|
||||
// The server is up with token-only auth: a gated route answers 200.
|
||||
const res = await fetch(`${server.address}/api/v1/healthz`, { headers: authHeaders() });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// The token-only warning was logged so the operator knows the bearer token
|
||||
// is the only credential protecting the exposed server.
|
||||
expect(lines.join('')).toContain('token-only auth');
|
||||
});
|
||||
|
||||
it('refuses to bind 0.0.0.0 with a password but without --insecure-no-tls', async () => {
|
||||
process.env['KIMI_CODE_PASSWORD'] = 'test-pw';
|
||||
const { lockPath, homeDir } = tmpPaths();
|
||||
|
||||
await expect(
|
||||
startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '0.0.0.0',
|
||||
port: 0,
|
||||
lockPath,
|
||||
logger: pino({ level: 'silent' }),
|
||||
coreProcessOptions: { homeDir },
|
||||
}),
|
||||
).rejects.toThrow(/without TLS/);
|
||||
});
|
||||
|
||||
it('boots 0.0.0.0 with a password + insecureNoTls and logs the public warning', async () => {
|
||||
process.env['KIMI_CODE_PASSWORD'] = 'test-pw';
|
||||
const { lockPath, homeDir } = tmpPaths();
|
||||
const { logger, lines } = capturingLogger();
|
||||
|
||||
const server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '0.0.0.0',
|
||||
port: 0,
|
||||
lockPath,
|
||||
insecureNoTls: true,
|
||||
logger,
|
||||
coreProcessOptions: { homeDir },
|
||||
});
|
||||
running.push(server);
|
||||
|
||||
// The server is up: a gated route answers 200 with a valid token.
|
||||
const res = await fetch(`${server.address}/api/v1/healthz`, { headers: authHeaders() });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// The public-bind warning was logged so the operator knows TLS is off.
|
||||
const combined = lines.join('');
|
||||
expect(combined).toContain('binding non-loopback host without TLS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dangerous-endpoint downgrade on a public bind (M6.5)', () => {
|
||||
interface BootExposureOpts {
|
||||
host?: string;
|
||||
allowRemoteShutdown?: boolean;
|
||||
allowRemoteTerminals?: boolean;
|
||||
}
|
||||
|
||||
async function bootExposure(opts: BootExposureOpts = {}): Promise<{
|
||||
server: RunningServer;
|
||||
shutdownCalls: string[];
|
||||
}> {
|
||||
process.env['KIMI_CODE_PASSWORD'] = 'test-pw';
|
||||
const { lockPath, homeDir } = tmpPaths();
|
||||
const shutdownCalls: string[] = [];
|
||||
// Capture shutdown requests instead of exiting the process.
|
||||
const noopShutdown = [
|
||||
IServerShutdownService,
|
||||
{
|
||||
_serviceBrand: undefined,
|
||||
requestShutdown: async (reason: string) => {
|
||||
shutdownCalls.push(reason);
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
const serviceOverrides: ServerStartOptions['serviceOverrides'] = [
|
||||
fixedTokenAuth(),
|
||||
noopShutdown,
|
||||
];
|
||||
const server = await startServer({
|
||||
serviceOverrides,
|
||||
host: opts.host ?? '0.0.0.0',
|
||||
port: 0,
|
||||
lockPath,
|
||||
insecureNoTls: true,
|
||||
allowRemoteShutdown: opts.allowRemoteShutdown,
|
||||
allowRemoteTerminals: opts.allowRemoteTerminals,
|
||||
logger: pino({ level: 'silent' }),
|
||||
coreProcessOptions: { homeDir },
|
||||
});
|
||||
running.push(server);
|
||||
return { server, shutdownCalls };
|
||||
}
|
||||
|
||||
const terminalsUrl = (server: RunningServer): string =>
|
||||
`${server.address}/api/v1/sessions/some-session/terminals`;
|
||||
|
||||
it('returns 404 for shutdown and terminals on a public bind without the allow flags', async () => {
|
||||
const { server } = await bootExposure();
|
||||
|
||||
const shutdown = await fetch(`${server.address}/api/v1/shutdown`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
});
|
||||
expect(shutdown.status).toBe(404);
|
||||
|
||||
const terminals = await fetch(terminalsUrl(server), { headers: authHeaders() });
|
||||
expect(terminals.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 200 for shutdown on a public bind when allowRemoteShutdown is set', async () => {
|
||||
const { server, shutdownCalls } = await bootExposure({ allowRemoteShutdown: true });
|
||||
|
||||
const shutdown = await fetch(`${server.address}/api/v1/shutdown`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
});
|
||||
expect(shutdown.status).toBe(200);
|
||||
// The handler replies before triggering shutdown (setImmediate); the noop
|
||||
// override captures it so the process does not exit.
|
||||
await vi.waitFor(() => expect(shutdownCalls).toContain('api'));
|
||||
});
|
||||
|
||||
it('mounts shutdown on a loopback bind by default', async () => {
|
||||
const { server, shutdownCalls } = await bootExposure({ host: '127.0.0.1' });
|
||||
|
||||
const shutdown = await fetch(`${server.address}/api/v1/shutdown`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
});
|
||||
expect(shutdown.status).toBe(200);
|
||||
await vi.waitFor(() => expect(shutdownCalls).toContain('api'));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Raw HTTP GET that lets us set an arbitrary `Host` header. Node's `fetch`
|
||||
* (undici) treats `Host` as a forbidden header and silently replaces it with
|
||||
* the URL host, so the Host-allowlist test drives `node:http` directly.
|
||||
*/
|
||||
function rawHttpGet(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<{ status: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const u = new URL(url);
|
||||
const req = httpRequest(
|
||||
{
|
||||
hostname: u.hostname,
|
||||
port: u.port,
|
||||
path: `${u.pathname}${u.search}`,
|
||||
method: 'GET',
|
||||
headers,
|
||||
},
|
||||
(res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
res.on('end', () => resolve({ status: res.statusCode ?? 0, body }));
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('public-bind §3.5 end-to-end (M6.7)', () => {
|
||||
/**
|
||||
* Boot a 0.0.0.0 server using the REAL auth impl (no fixed-token override) so
|
||||
* the password `verifyPassword` path is exercised. `KIMI_CODE_PASSWORD` is set
|
||||
* so the M6.3 gate passes and the password is itself a valid bearer.
|
||||
*/
|
||||
async function bootPublicReal(): Promise<RunningServer> {
|
||||
process.env['KIMI_CODE_PASSWORD'] = 'test-pw';
|
||||
const { lockPath, homeDir } = tmpPaths();
|
||||
const server = await startServer({
|
||||
host: '0.0.0.0',
|
||||
port: 0,
|
||||
lockPath,
|
||||
insecureNoTls: true,
|
||||
logger: pino({ level: 'silent' }),
|
||||
coreProcessOptions: { homeDir },
|
||||
});
|
||||
running.push(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
/** Boot a 0.0.0.0 server with a deterministic fixed token. */
|
||||
async function bootPublicFixed(token = 'real-token'): Promise<RunningServer> {
|
||||
process.env['KIMI_CODE_PASSWORD'] = 'test-pw';
|
||||
const { lockPath, homeDir } = tmpPaths();
|
||||
const server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth(token)],
|
||||
host: '0.0.0.0',
|
||||
port: 0,
|
||||
lockPath,
|
||||
insecureNoTls: true,
|
||||
logger: pino({ level: 'silent' }),
|
||||
coreProcessOptions: { homeDir },
|
||||
});
|
||||
running.push(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
it('accepts the user password as a bearer token (verifyPassword path)', async () => {
|
||||
const server = await bootPublicReal();
|
||||
const ok = await fetch(`${server.address}/api/v1/sessions`, {
|
||||
headers: { Authorization: 'Bearer test-pw' },
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
// Security headers ride on every non-loopback response (M6.6).
|
||||
expect(ok.headers.get('x-content-type-options')).toBe('nosniff');
|
||||
expect(ok.headers.get('content-security-policy')).toBe("default-src 'self'");
|
||||
});
|
||||
|
||||
it('rejects wrong and missing credentials with 401', async () => {
|
||||
const server = await bootPublicReal();
|
||||
const wrong = await fetch(`${server.address}/api/v1/sessions`, {
|
||||
headers: { Authorization: 'Bearer wrong-password' },
|
||||
});
|
||||
expect(wrong.status).toBe(401);
|
||||
const missing = await fetch(`${server.address}/api/v1/sessions`);
|
||||
expect(missing.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rate-limits repeated auth failures to 429 on a real bind (M6.4)', async () => {
|
||||
const server = await bootPublicFixed('real-token');
|
||||
const url = `${server.address}/api/v1/sessions`;
|
||||
let lastStatus = 0;
|
||||
// Default threshold is 10 failures; the 11th must be 429.
|
||||
for (let i = 0; i < 11; i += 1) {
|
||||
const res = await fetch(url, { headers: { Authorization: 'Bearer wrong' } });
|
||||
lastStatus = res.status;
|
||||
if (i < 10) {
|
||||
expect(res.status).toBe(401);
|
||||
}
|
||||
}
|
||||
expect(lastStatus).toBe(429);
|
||||
});
|
||||
|
||||
it('rejects a spoofed Host with 403 and accepts the bound host', async () => {
|
||||
const server = await bootPublicFixed();
|
||||
// Bound host (0.0.0.0) is a literal IP → allowed by the Host allowlist.
|
||||
const bound = await rawHttpGet(`${server.address}/api/v1/healthz`, {
|
||||
Host: `0.0.0.0:${new URL(server.address).port}`,
|
||||
});
|
||||
expect(bound.status).toBe(200);
|
||||
// A spoofed Host is rejected before auth (Host check runs first).
|
||||
const spoofed = await rawHttpGet(`${server.address}/api/v1/healthz`, {
|
||||
Host: 'evil.example.com',
|
||||
});
|
||||
expect(spoofed.status).toBe(403);
|
||||
});
|
||||
});
|
||||
262
packages/server/test/host-origin.e2e.test.ts
Normal file
262
packages/server/test/host-origin.e2e.test.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
/**
|
||||
* Host / Origin checks wired into HTTP + WS (ROADMAP M4.3).
|
||||
*
|
||||
* HTTP: the global `onRequest` Host hook rejects `Host: evil.com` with 403
|
||||
* before any route handler (here `/api/v1/healthz`), while the default
|
||||
* `127.0.0.1:<port>` Host from a real `fetch` is allowed.
|
||||
*
|
||||
* WS: `wsGatewayOptions.hostCheck` / `allowedOrigins` gate the upgrade path
|
||||
* before token validation. `authTokenService` is intentionally left unset so
|
||||
* these cases isolate Host/Origin from token auth. The Node `ws` client sends
|
||||
* no `Origin` by default, which is treated as a non-browser client and
|
||||
* allowed; a spoofed browser `Origin: http://evil.com` is rejected.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { pino } from 'pino';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
import { startServer, type RunningServer } from '../src';
|
||||
import type { WSGatewayOptions } from '../src/services/gateway/wsGateway';
|
||||
import { rawDataToString } from '../src/ws/rawData';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
interface WsFrame {
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
interface Conn {
|
||||
ws: WebSocket;
|
||||
queue: WsFrame[];
|
||||
waiters: Array<(frame: WsFrame) => void>;
|
||||
closed: Promise<{ code: number; reason: string }>;
|
||||
}
|
||||
|
||||
interface ConnectOptions {
|
||||
protocols?: string[];
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
let bridgeHome: string;
|
||||
const running: RunningServer[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-host-origin-'));
|
||||
lockPath = join(tmpDir, 'lock');
|
||||
bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-host-origin-home-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const r of running.splice(0)) {
|
||||
try {
|
||||
await r.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
rmSync(bridgeHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function spawn(wsGatewayOptions?: WSGatewayOptions): Promise<RunningServer> {
|
||||
const r = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
logger: pino({ level: 'silent' }),
|
||||
coreProcessOptions: { homeDir: bridgeHome },
|
||||
...(wsGatewayOptions !== undefined ? { wsGatewayOptions } : {}),
|
||||
});
|
||||
running.push(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
function wsUrl(http: string): string {
|
||||
return http.replace(/^http:\/\//, 'ws://') + '/api/v1/ws';
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw HTTP GET that lets us set an arbitrary `Host` header. Node's `fetch`
|
||||
* (undici) treats `Host` as a forbidden header and silently replaces it with
|
||||
* the URL host, so we drive `node:http` directly to exercise the Host check.
|
||||
*/
|
||||
function rawHttpGet(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<{ status: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const u = new URL(url);
|
||||
const req = httpRequest(
|
||||
{
|
||||
hostname: u.hostname,
|
||||
port: u.port,
|
||||
path: `${u.pathname}${u.search}`,
|
||||
method: 'GET',
|
||||
headers,
|
||||
},
|
||||
(res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
res.on('end', () => resolve({ status: res.statusCode ?? 0, body }));
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function openConn(url: string, opts?: ConnectOptions): Promise<Conn> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Offer the fixed bearer token so the M5.1 WS auth passes; the Host/Origin
|
||||
// cases that expect rejection use `expectRejected` (no token) instead.
|
||||
const protocols = [...(opts?.protocols ?? []), 'kimi-code.bearer.test-token'];
|
||||
const ws = new WebSocket(url, protocols, { headers: opts?.headers });
|
||||
const queue: WsFrame[] = [];
|
||||
const waiters: Array<(frame: WsFrame) => void> = [];
|
||||
let closedResolve: (v: { code: number; reason: string }) => void;
|
||||
const closed = new Promise<{ code: number; reason: string }>((res) => {
|
||||
closedResolve = res;
|
||||
});
|
||||
ws.on('message', (data) => {
|
||||
let parsed: WsFrame;
|
||||
try {
|
||||
parsed = JSON.parse(rawDataToString(data)) as WsFrame;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (waiters.length > 0) {
|
||||
waiters.shift()?.(parsed);
|
||||
} else {
|
||||
queue.push(parsed);
|
||||
}
|
||||
});
|
||||
ws.on('close', (code, reason) => {
|
||||
closedResolve({ code, reason: String(reason) });
|
||||
});
|
||||
ws.once('open', () => resolve({ ws, queue, waiters, closed }));
|
||||
ws.once('error', (err) => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
function receive(conn: Conn, timeoutMs: number): Promise<WsFrame> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (conn.queue.length > 0) {
|
||||
resolve(conn.queue.shift()!);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
const idx = conn.waiters.indexOf(waiter);
|
||||
if (idx >= 0) conn.waiters.splice(idx, 1);
|
||||
reject(new Error(`no message in ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
const waiter = (frame: WsFrame): void => {
|
||||
clearTimeout(t);
|
||||
resolve(frame);
|
||||
};
|
||||
conn.waiters.push(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
async function receiveType(conn: Conn, type: string, timeoutMs: number): Promise<WsFrame> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) throw new Error(`no message of type ${type} within ${timeoutMs}ms`);
|
||||
const frame = await receive(conn, remaining);
|
||||
if (frame.type === type) return frame;
|
||||
}
|
||||
}
|
||||
|
||||
function expectRejected(url: string, opts?: ConnectOptions): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url, opts?.protocols, { headers: opts?.headers });
|
||||
const done = (err?: Error): void => {
|
||||
clearTimeout(t);
|
||||
ws.removeAllListeners();
|
||||
try {
|
||||
ws.terminate();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (err !== undefined) reject(err);
|
||||
else resolve();
|
||||
};
|
||||
const t = setTimeout(
|
||||
() => done(new Error('connection was not rejected within timeout')),
|
||||
1500,
|
||||
);
|
||||
ws.once('open', () => done(new Error('connection unexpectedly opened')));
|
||||
ws.once('error', () => done());
|
||||
ws.once('close', () => done());
|
||||
});
|
||||
}
|
||||
|
||||
describe('HTTP Host check (start.ts)', () => {
|
||||
it('rejects Host: evil.com with 403 before the route handler', async () => {
|
||||
const r = await spawn();
|
||||
const res = await rawHttpGet(`${r.address}/api/v1/healthz`, { Host: 'evil.com' });
|
||||
expect(res.status).toBe(403);
|
||||
const body = JSON.parse(res.body) as Record<string, unknown>;
|
||||
expect(body['code']).toBe(40301);
|
||||
expect(body['msg']).toBe('Invalid Host header');
|
||||
});
|
||||
|
||||
it('allows the default 127.0.0.1:<port> Host', async () => {
|
||||
const r = await spawn();
|
||||
const res = await rawHttpGet(`${r.address}/api/v1/healthz`, {});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WS Host/Origin checks (wsGatewayService)', () => {
|
||||
it('rejects a spoofed Host before token validation', async () => {
|
||||
const r = await spawn({ hostCheck: { boundHost: '127.0.0.1' }, allowedOrigins: [] });
|
||||
await expectRejected(wsUrl(r.address), { headers: { Host: 'evil.com' } });
|
||||
});
|
||||
|
||||
it('accepts a normal Host and delivers server_hello', async () => {
|
||||
const r = await spawn({ hostCheck: { boundHost: '127.0.0.1' }, allowedOrigins: [] });
|
||||
const conn = await openConn(wsUrl(r.address));
|
||||
const hello = await receiveType(conn, 'server_hello', 1000);
|
||||
expect(hello.type).toBe('server_hello');
|
||||
conn.ws.close();
|
||||
await conn.closed;
|
||||
});
|
||||
|
||||
it('rejects a disallowed browser Origin', async () => {
|
||||
const r = await spawn({ hostCheck: { boundHost: '127.0.0.1' }, allowedOrigins: [] });
|
||||
await expectRejected(wsUrl(r.address), {
|
||||
headers: { origin: 'http://evil.com' },
|
||||
});
|
||||
});
|
||||
|
||||
it('allows a Node client with no Origin (present-only check)', async () => {
|
||||
const r = await spawn({ hostCheck: { boundHost: '127.0.0.1' }, allowedOrigins: [] });
|
||||
const conn = await openConn(wsUrl(r.address));
|
||||
const hello = await receiveType(conn, 'server_hello', 1000);
|
||||
expect(hello.type).toBe('server_hello');
|
||||
conn.ws.close();
|
||||
await conn.closed;
|
||||
});
|
||||
|
||||
it('skips the checks when the options are unset', async () => {
|
||||
const r = await spawn();
|
||||
const conn = await openConn(wsUrl(r.address));
|
||||
const hello = await receiveType(conn, 'server_hello', 1000);
|
||||
expect(hello.type).toBe('server_hello');
|
||||
conn.ws.close();
|
||||
await conn.closed;
|
||||
});
|
||||
});
|
||||
150
packages/server/test/hostnames.test.ts
Normal file
150
packages/server/test/hostnames.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* Host-header allowlist (ROADMAP M4.1).
|
||||
*
|
||||
* Pure unit cases for `stripPort` / `isAllowedHost` / the env parsers, plus a
|
||||
* minimal Fastify integration test that exercises the `onRequest` hook through
|
||||
* `app.inject` (which sends `Host: localhost:80` by default).
|
||||
*/
|
||||
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createHostCheck,
|
||||
isAllowedHost,
|
||||
isHostCheckDisabled,
|
||||
parseAllowedHosts,
|
||||
stripPort,
|
||||
} from '#/middleware/hostnames';
|
||||
|
||||
describe('stripPort', () => {
|
||||
it('strips the port from a hostname', () => {
|
||||
expect(stripPort('localhost:80')).toBe('localhost');
|
||||
});
|
||||
|
||||
it('strips the port from bracketed IPv6', () => {
|
||||
expect(stripPort('[::1]:80')).toBe('[::1]');
|
||||
});
|
||||
|
||||
it('strips the port from an IPv4 literal', () => {
|
||||
expect(stripPort('1.2.3.4:5678')).toBe('1.2.3.4');
|
||||
});
|
||||
|
||||
it('lowercases bare hosts', () => {
|
||||
expect(stripPort('LOCALHOST')).toBe('localhost');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowedHost (default allow set)', () => {
|
||||
const allow = ['localhost', 'localhost:80', 'foo.localhost', '127.0.0.1', '127.0.0.1:58627', '[::1]', '::1', '8.8.8.8'];
|
||||
|
||||
for (const host of allow) {
|
||||
it(`allows ${host}`, () => {
|
||||
expect(isAllowedHost(host, {})).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
const deny = ['evil.com', 'evil.com:80', '127.0.0.1.evil.com'];
|
||||
|
||||
for (const host of deny) {
|
||||
it(`denies ${host}`, () => {
|
||||
expect(isAllowedHost(host, {})).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
it('denies a missing Host header', () => {
|
||||
expect(isAllowedHost(undefined, {})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowedHost (boundHost)', () => {
|
||||
it('allows the bound host', () => {
|
||||
expect(isAllowedHost('myhost', { boundHost: 'myhost' })).toBe(true);
|
||||
});
|
||||
|
||||
it('strips the port on both sides', () => {
|
||||
expect(isAllowedHost('myhost:1234', { boundHost: 'myhost:8080' })).toBe(true);
|
||||
});
|
||||
|
||||
it('still denies unrelated hosts', () => {
|
||||
expect(isAllowedHost('otherhost', { boundHost: 'myhost' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowedHost (extra)', () => {
|
||||
it('matches a subdomain wildcard', () => {
|
||||
expect(isAllowedHost('a.example.com', { extra: ['.example.com'] })).toBe(true);
|
||||
});
|
||||
|
||||
it('matches the bare domain of a wildcard', () => {
|
||||
expect(isAllowedHost('example.com', { extra: ['.example.com'] })).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match a partial suffix', () => {
|
||||
expect(isAllowedHost('baddexample.com', { extra: ['.example.com'] })).toBe(false);
|
||||
});
|
||||
|
||||
it('matches an exact entry', () => {
|
||||
expect(isAllowedHost('foo', { extra: ['foo'] })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowedHost (disable)', () => {
|
||||
it('allows everything when disabled', () => {
|
||||
expect(isAllowedHost('evil.com', { disable: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAllowedHosts', () => {
|
||||
it('splits, trims, and drops empties', () => {
|
||||
expect(parseAllowedHosts({ KIMI_CODE_ALLOWED_HOSTS: ' a, .b.com, ' })).toEqual(['a', '.b.com']);
|
||||
});
|
||||
|
||||
it('returns [] when unset', () => {
|
||||
expect(parseAllowedHosts({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHostCheckDisabled', () => {
|
||||
it('is true when set to "1"', () => {
|
||||
expect(isHostCheckDisabled({ KIMI_CODE_DISABLE_HOST_CHECK: '1' })).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when unset', () => {
|
||||
expect(isHostCheckDisabled({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createHostCheck (onRequest hook)', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = Fastify();
|
||||
app.addHook('onRequest', createHostCheck({}).onRequest);
|
||||
app.get('/api/v1/probe', async () => ({ ok: true }));
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects a disallowed Host with the 40301 envelope', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/probe',
|
||||
headers: { host: 'evil.com' },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
const body = res.json() as Record<string, unknown>;
|
||||
expect(body['code']).toBe(40301);
|
||||
expect(body['msg']).toBe('Invalid Host header');
|
||||
expect(body['data']).toBeNull();
|
||||
expect(typeof body['request_id']).toBe('string');
|
||||
});
|
||||
|
||||
it('allows the default app.inject Host (localhost:80)', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/probe' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import { pino } from 'pino';
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -65,6 +66,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -77,12 +79,24 @@ async function bootDaemon(): Promise<RunningServer> {
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { pino } from 'pino';
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -59,6 +60,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -75,16 +77,24 @@ async function bootDaemon(): Promise<RunningServer> {
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
} {
|
||||
// We use the same accessor pattern start.ts uses internally. IRestGateway
|
||||
// is registered with the `FastifyLike` structural type; `.inject()` is the
|
||||
// Fastify-specific method we need for hermetic tests — it lives on the
|
||||
// underlying instance, not on FastifyLike. The cast is local to this test.
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('GET /api/v1/meta — envelope + metaResponseSchema', () => {
|
||||
|
|
@ -137,6 +147,7 @@ describe('GET /api/v1/meta — envelope + metaResponseSchema', () => {
|
|||
const homeA = mkdtempSync(join(tmpdir(), 'kimi-server-meta-home-a-'));
|
||||
const homeB = mkdtempSync(join(tmpdir(), 'kimi-server-meta-home-b-'));
|
||||
const r1 = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath: lockA,
|
||||
|
|
@ -144,6 +155,7 @@ describe('GET /api/v1/meta — envelope + metaResponseSchema', () => {
|
|||
coreProcessOptions: { homeDir: homeA },
|
||||
});
|
||||
const r2 = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath: lockB,
|
||||
|
|
@ -168,6 +180,7 @@ describe('GET /api/v1/meta — envelope + metaResponseSchema', () => {
|
|||
describe('GET /api/v1/meta — server_version precedence', () => {
|
||||
it('reports the host kimi-code identity version when provided', async () => {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||
import { IModelCatalogService, type IModelCatalogService as ModelCatalogServiceShape } from '@moonshot-ai/agent-core';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer, type ServerStartOptions } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -40,7 +41,7 @@ async function bootDaemon(
|
|||
lockPath,
|
||||
logger: pino({ level: 'silent' }),
|
||||
coreProcessOptions: { homeDir: bridgeHome },
|
||||
serviceOverrides,
|
||||
serviceOverrides: [fixedTokenAuth(), ...(serviceOverrides ?? [])],
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
|
@ -48,12 +49,24 @@ async function bootDaemon(
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import type {
|
|||
} from '@moonshot-ai/protocol';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -128,7 +129,7 @@ async function bootDaemon(stub: StubOAuth): Promise<RunningServer> {
|
|||
lockPath,
|
||||
logger: pino({ level: 'silent' }),
|
||||
coreProcessOptions: { homeDir: bridgeHome },
|
||||
serviceOverrides: [[IOAuthService, stub]],
|
||||
serviceOverrides: [fixedTokenAuth(), [IOAuthService, stub]],
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
|
@ -136,12 +137,24 @@ async function bootDaemon(stub: StubOAuth): Promise<RunningServer> {
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
|
|||
143
packages/server/test/origin.test.ts
Normal file
143
packages/server/test/origin.test.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
/**
|
||||
* Origin / CORS middleware (ROADMAP M4.2).
|
||||
*
|
||||
* Pure unit cases for `originHost` / `isOriginAllowed` / `parseCorsOrigins`,
|
||||
* plus a minimal Fastify integration test that drives the `onRequest` hook
|
||||
* through `app.inject` and asserts the emitted (or withheld) CORS headers.
|
||||
*/
|
||||
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createOriginHook,
|
||||
isOriginAllowed,
|
||||
originHost,
|
||||
parseCorsOrigins,
|
||||
} from '#/middleware/origin';
|
||||
|
||||
describe('originHost', () => {
|
||||
it('returns the host for a valid origin', () => {
|
||||
expect(originHost('https://foo.com')).toBe('foo.com');
|
||||
});
|
||||
|
||||
it('drops the default port', () => {
|
||||
expect(originHost('http://localhost:80')).toBe('localhost');
|
||||
});
|
||||
|
||||
it('keeps a non-default port', () => {
|
||||
expect(originHost('http://127.0.0.1:58627')).toBe('127.0.0.1:58627');
|
||||
});
|
||||
|
||||
it('returns undefined for a missing origin', () => {
|
||||
expect(originHost(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a malformed origin', () => {
|
||||
expect(originHost('not a url')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOriginAllowed', () => {
|
||||
it('allows same-origin', () => {
|
||||
expect(isOriginAllowed('http://localhost:80', 'localhost:80', [])).toBe(true);
|
||||
});
|
||||
|
||||
it('denies cross-origin that is not whitelisted', () => {
|
||||
expect(isOriginAllowed('http://evil.com', 'localhost:80', [])).toBe(false);
|
||||
});
|
||||
|
||||
it('allows cross-origin that is whitelisted', () => {
|
||||
expect(isOriginAllowed('https://foo.com', 'localhost:80', ['https://foo.com'])).toBe(true);
|
||||
});
|
||||
|
||||
it('allows an absent origin', () => {
|
||||
expect(isOriginAllowed(undefined, 'localhost:80', [])).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a malformed origin as absent (allowed)', () => {
|
||||
expect(isOriginAllowed('not a url', 'h', [])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCorsOrigins', () => {
|
||||
it('splits, trims, and drops empties', () => {
|
||||
expect(parseCorsOrigins({ KIMI_CODE_CORS_ORIGINS: ' https://a.com, https://b.com, ' })).toEqual([
|
||||
'https://a.com',
|
||||
'https://b.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns [] when unset', () => {
|
||||
expect(parseCorsOrigins({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createOriginHook (onRequest hook)', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = Fastify();
|
||||
app.addHook('onRequest', createOriginHook({ allowedOrigins: ['https://foo.com'] }));
|
||||
app.get('/api/v1/probe', async () => ({ ok: true }));
|
||||
app.options('/api/v1/probe', async () => ({ ok: true }));
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('echoes CORS headers for a same-origin request', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/probe',
|
||||
headers: { origin: 'http://localhost:80', host: 'localhost:80' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['access-control-allow-origin']).toBe('http://localhost:80');
|
||||
});
|
||||
|
||||
it('echoes the whitelisted cross-origin and short-circuits OPTIONS to 204', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'OPTIONS',
|
||||
url: '/api/v1/probe',
|
||||
headers: { origin: 'https://foo.com', host: 'localhost:80' },
|
||||
});
|
||||
expect(res.statusCode).toBe(204);
|
||||
expect(res.headers['access-control-allow-origin']).toBe('https://foo.com');
|
||||
expect(res.headers['access-control-allow-methods']).toBe(
|
||||
'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
||||
);
|
||||
});
|
||||
|
||||
it('withholds CORS headers for a non-whitelisted cross-origin', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/probe',
|
||||
headers: { origin: 'http://evil.com', host: 'localhost:80' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['access-control-allow-origin']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns 204 without CORS headers for a non-whitelisted OPTIONS', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'OPTIONS',
|
||||
url: '/api/v1/probe',
|
||||
headers: { origin: 'http://evil.com', host: 'localhost:80' },
|
||||
});
|
||||
expect(res.statusCode).toBe(204);
|
||||
expect(res.headers['access-control-allow-origin']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits no CORS headers when Origin is absent', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/probe',
|
||||
headers: { host: 'localhost:80' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['access-control-allow-origin']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -33,6 +33,7 @@ import type { Event, PromptSubmission } from '@moonshot-ai/protocol';
|
|||
import { IEventService, IPromptService, PromptService } from '@moonshot-ai/agent-core';
|
||||
|
||||
import { IRestGateway, startServer, type ServerStartOptions, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -66,7 +67,7 @@ async function bootDaemon(
|
|||
logger: pino({ level: 'silent' }),
|
||||
coreProcessOptions: { homeDir: bridgeHome },
|
||||
wsGatewayOptions: { pingIntervalMs: 5_000, pongTimeoutMs: 5_000 },
|
||||
serviceOverrides,
|
||||
serviceOverrides: [fixedTokenAuth(), ...(serviceOverrides ?? [])],
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
|
@ -74,12 +75,24 @@ async function bootDaemon(
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
@ -204,7 +217,7 @@ async function openSubscriber(
|
|||
const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws';
|
||||
const received: Record<string, unknown>[] = [];
|
||||
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
||||
const sock = new WebSocket(wsUrl);
|
||||
const sock = new WebSocket(wsUrl, ['kimi-code.bearer.test-token']);
|
||||
sock.on('message', (data) => {
|
||||
try {
|
||||
received.push(JSON.parse(wsDataToString(data)) as Record<string, unknown>);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
} from '@moonshot-ai/agent-core';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
import { rawDataToString } from '../src/ws/rawData';
|
||||
import { QuestionService } from '#/services/question/questionService';
|
||||
|
||||
|
|
@ -52,6 +53,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -65,12 +67,24 @@ async function bootDaemon(): Promise<RunningServer> {
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
@ -112,7 +126,7 @@ async function openSubscriber(
|
|||
const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws';
|
||||
const received: Record<string, unknown>[] = [];
|
||||
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
||||
const sock = new WebSocket(wsUrl);
|
||||
const sock = new WebSocket(wsUrl, ['kimi-code.bearer.test-token']);
|
||||
sock.on('message', (data) => {
|
||||
try {
|
||||
received.push(JSON.parse(rawDataToString(data)) as Record<string, unknown>);
|
||||
|
|
|
|||
165
packages/server/test/rate-limit.test.ts
Normal file
165
packages/server/test/rate-limit.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* Auth-failure rate limiting (ROADMAP M6.4).
|
||||
*
|
||||
* Two layers:
|
||||
* 1. `createAuthFailureLimiter` unit behavior — failure counting, ban on
|
||||
* threshold, ban expiry (fake timers), and window reset.
|
||||
* 2. `createAuthHook` integration — a banned source gets `429` (even with a
|
||||
* valid token), a different source still gets `401`, and a hook without a
|
||||
* limiter (the loopback wiring) never returns `429`.
|
||||
*
|
||||
* Distinct source IPs are expressed via `X-Forwarded-For` with Fastify
|
||||
* `trustProxy: true`, which is what `req.ip` reads behind a reverse proxy.
|
||||
*/
|
||||
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createAuthHook } from '#/middleware/auth';
|
||||
import {
|
||||
createAuthFailureLimiter,
|
||||
type AuthFailureLimiter,
|
||||
} from '#/middleware/rateLimit';
|
||||
import type { IAuthTokenService } from '#/services/auth/authTokenService';
|
||||
|
||||
const TOKEN = 'test-token';
|
||||
const IP_A = '203.0.113.10';
|
||||
const IP_B = '203.0.113.11';
|
||||
|
||||
function fixedImpl(): IAuthTokenService {
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
getToken: () => TOKEN,
|
||||
isValid: async (candidate) => candidate === TOKEN,
|
||||
};
|
||||
}
|
||||
|
||||
function buildApp(limiter?: AuthFailureLimiter): FastifyInstance {
|
||||
const app = Fastify({ trustProxy: true });
|
||||
app.addHook('onRequest', createAuthHook(fixedImpl(), { limiter }));
|
||||
app.get('/api/v1/sessions', async () => ({ ok: true }));
|
||||
return app;
|
||||
}
|
||||
|
||||
function badToken(ip: string): { method: 'GET'; url: string; headers: Record<string, string> } {
|
||||
return {
|
||||
method: 'GET',
|
||||
url: '/api/v1/sessions',
|
||||
headers: { 'x-forwarded-for': ip, authorization: 'Bearer wrong-token' },
|
||||
};
|
||||
}
|
||||
|
||||
describe('createAuthHook rate limiting (M6.4)', () => {
|
||||
let app: FastifyInstance;
|
||||
let limiter: AuthFailureLimiter;
|
||||
|
||||
beforeEach(async () => {
|
||||
limiter = createAuthFailureLimiter({ maxFailures: 3, windowMs: 60_000, banMs: 60_000 });
|
||||
app = buildApp(limiter);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
limiter.dispose();
|
||||
});
|
||||
|
||||
it('returns 401 for the first N failures, then 429 on the (N+1)th from the same IP', async () => {
|
||||
expect((await app.inject(badToken(IP_A))).statusCode).toBe(401);
|
||||
expect((await app.inject(badToken(IP_A))).statusCode).toBe(401);
|
||||
expect((await app.inject(badToken(IP_A))).statusCode).toBe(401);
|
||||
const fourth = await app.inject(badToken(IP_A));
|
||||
expect(fourth.statusCode).toBe(429);
|
||||
const body = fourth.json() as Record<string, unknown>;
|
||||
expect(body['code']).toBe(42901);
|
||||
expect(body['msg']).toBe('Too many failed auth attempts');
|
||||
});
|
||||
|
||||
it('does not ban a different IP that has not hit the threshold', async () => {
|
||||
// Push IP_A over the threshold.
|
||||
await app.inject(badToken(IP_A));
|
||||
await app.inject(badToken(IP_A));
|
||||
await app.inject(badToken(IP_A));
|
||||
expect((await app.inject(badToken(IP_A))).statusCode).toBe(429);
|
||||
|
||||
// IP_B is a fresh source — still 401, not 429.
|
||||
expect((await app.inject(badToken(IP_B))).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 429 to a banned IP even when it presents a valid token', async () => {
|
||||
await app.inject(badToken(IP_A));
|
||||
await app.inject(badToken(IP_A));
|
||||
await app.inject(badToken(IP_A));
|
||||
expect((await app.inject(badToken(IP_A))).statusCode).toBe(429);
|
||||
|
||||
const valid = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/sessions',
|
||||
headers: { 'x-forwarded-for': IP_A, authorization: `Bearer ${TOKEN}` },
|
||||
});
|
||||
expect(valid.statusCode).toBe(429);
|
||||
});
|
||||
|
||||
it('never returns 429 when no limiter is wired (loopback behavior)', async () => {
|
||||
const noLimiterApp = buildApp(undefined);
|
||||
await noLimiterApp.ready();
|
||||
try {
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
expect((await noLimiterApp.inject(badToken(IP_A))).statusCode).toBe(401);
|
||||
}
|
||||
} finally {
|
||||
await noLimiterApp.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAuthFailureLimiter (unit)', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('bans a source at the threshold and clears the ban after banMs', () => {
|
||||
vi.useFakeTimers();
|
||||
const limiter = createAuthFailureLimiter({ maxFailures: 2, windowMs: 1_000, banMs: 500 });
|
||||
try {
|
||||
expect(limiter.isBanned('1.2.3.4')).toBe(false);
|
||||
limiter.recordFailure('1.2.3.4');
|
||||
expect(limiter.isBanned('1.2.3.4')).toBe(false);
|
||||
limiter.recordFailure('1.2.3.4');
|
||||
expect(limiter.isBanned('1.2.3.4')).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(499);
|
||||
expect(limiter.isBanned('1.2.3.4')).toBe(true);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(limiter.isBanned('1.2.3.4')).toBe(false);
|
||||
} finally {
|
||||
limiter.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('resets the failure count once the window elapses', () => {
|
||||
vi.useFakeTimers();
|
||||
const limiter = createAuthFailureLimiter({ maxFailures: 2, windowMs: 1_000, banMs: 500 });
|
||||
try {
|
||||
limiter.recordFailure('5.5.5.5');
|
||||
vi.advanceTimersByTime(1_001); // window expired → next failure starts fresh
|
||||
limiter.recordFailure('5.5.5.5');
|
||||
// Only one failure in the new window → not banned.
|
||||
expect(limiter.isBanned('5.5.5.5')).toBe(false);
|
||||
} finally {
|
||||
limiter.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('tracks sources independently', () => {
|
||||
vi.useFakeTimers();
|
||||
const limiter = createAuthFailureLimiter({ maxFailures: 1, windowMs: 1_000, banMs: 500 });
|
||||
try {
|
||||
limiter.recordFailure('9.9.9.9');
|
||||
expect(limiter.isBanned('9.9.9.9')).toBe(true);
|
||||
expect(limiter.isBanned('8.8.8.8')).toBe(false);
|
||||
} finally {
|
||||
limiter.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
95
packages/server/test/security-headers.test.ts
Normal file
95
packages/server/test/security-headers.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* Security response headers (ROADMAP M6.6).
|
||||
*
|
||||
* Verifies the `onSend` hook is registered only on a non-loopback bind and
|
||||
* that HSTS is omitted while TLS is terminated elsewhere (`tls: false`).
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { pino } from 'pino';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { startServer, type RunningServer } from '../src';
|
||||
import { authHeaders, fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
const createdDirs: string[] = [];
|
||||
const running: RunningServer[] = [];
|
||||
let prevPassword: string | undefined;
|
||||
|
||||
function tmpPaths(): { lockPath: string; homeDir: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'kimi-sec-headers-'));
|
||||
const home = mkdtempSync(join(tmpdir(), 'kimi-sec-headers-home-'));
|
||||
createdDirs.push(dir, home);
|
||||
return { lockPath: join(dir, 'lock'), homeDir: home };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
prevPassword = process.env['KIMI_CODE_PASSWORD'];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const r of running.splice(0)) {
|
||||
try {
|
||||
await r.close();
|
||||
} catch {
|
||||
// ignore — best-effort teardown
|
||||
}
|
||||
}
|
||||
for (const dir of createdDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
if (prevPassword === undefined) {
|
||||
delete process.env['KIMI_CODE_PASSWORD'];
|
||||
} else {
|
||||
process.env['KIMI_CODE_PASSWORD'] = prevPassword;
|
||||
}
|
||||
});
|
||||
|
||||
async function boot(host: string): Promise<RunningServer> {
|
||||
const { lockPath, homeDir } = tmpPaths();
|
||||
if (host !== '127.0.0.1') {
|
||||
// Non-loopback binds require a password + TLS opt-out (M6.3).
|
||||
process.env['KIMI_CODE_PASSWORD'] = 'test-pw';
|
||||
}
|
||||
const server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host,
|
||||
port: 0,
|
||||
lockPath,
|
||||
insecureNoTls: host !== '127.0.0.1',
|
||||
logger: pino({ level: 'silent' }),
|
||||
coreProcessOptions: { homeDir },
|
||||
});
|
||||
running.push(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
describe('security response headers (M6.6)', () => {
|
||||
it('sets nosniff / Referrer-Policy / CSP on a non-loopback bind, without HSTS', async () => {
|
||||
const server = await boot('0.0.0.0');
|
||||
const res = await fetch(`${server.address}/api/v1/sessions`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('x-content-type-options')).toBe('nosniff');
|
||||
expect(res.headers.get('referrer-policy')).toBe('no-referrer');
|
||||
expect(res.headers.get('content-security-policy')).toBe("default-src 'self'");
|
||||
// TLS is terminated by the reverse proxy in this phase → no HSTS here.
|
||||
expect(res.headers.get('strict-transport-security')).toBeNull();
|
||||
});
|
||||
|
||||
it('does NOT set the security headers on a loopback bind', async () => {
|
||||
const server = await boot('127.0.0.1');
|
||||
const res = await fetch(`${server.address}/api/v1/sessions`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('x-content-type-options')).toBeNull();
|
||||
expect(res.headers.get('referrer-policy')).toBeNull();
|
||||
expect(res.headers.get('content-security-policy')).toBeNull();
|
||||
expect(res.headers.get('strict-transport-security')).toBeNull();
|
||||
});
|
||||
});
|
||||
182
packages/server/test/services-auth.test.ts
Normal file
182
packages/server/test/services-auth.test.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
PrivateFileTooPermissiveError,
|
||||
readPrivateFile,
|
||||
writePrivateFile,
|
||||
} from '#/services/auth/privateFiles';
|
||||
import { loadOrCreateServerToken, rotateServerToken } from '#/services/auth/persistentToken';
|
||||
import { createTokenStore } from '#/services/auth/tokenStore';
|
||||
import { resolvePasswordHash, verifyPassword } from '#/services/auth/password';
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'kimi-auth-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('privateFiles', () => {
|
||||
it('writes a file with mode 0600', async () => {
|
||||
const p = join(tmpDir, 'secret');
|
||||
await writePrivateFile(p, 'hello');
|
||||
expect(statSync(p).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('creates an absent parent dir with mode 0700', async () => {
|
||||
const p = join(tmpDir, 'nested', 'dir', 'secret');
|
||||
await writePrivateFile(p, 'hello');
|
||||
expect(statSync(join(tmpDir, 'nested', 'dir')).mode & 0o777).toBe(0o700);
|
||||
});
|
||||
|
||||
it('round-trips string content through readPrivateFile', async () => {
|
||||
const p = join(tmpDir, 'secret');
|
||||
await writePrivateFile(p, 's3cr3t-value');
|
||||
const buf = await readPrivateFile(p);
|
||||
expect(buf.toString('utf8')).toBe('s3cr3t-value');
|
||||
});
|
||||
|
||||
it('round-trips Buffer content through readPrivateFile', async () => {
|
||||
const p = join(tmpDir, 'bin');
|
||||
const data = Buffer.from([0, 1, 2, 254, 255]);
|
||||
await writePrivateFile(p, data);
|
||||
const buf = await readPrivateFile(p);
|
||||
expect(buf.equals(data)).toBe(true);
|
||||
});
|
||||
|
||||
it('readPrivateFile throws on a 0644 file', async () => {
|
||||
const p = join(tmpDir, 'leaky');
|
||||
writeFileSync(p, 'x', { mode: 0o644 });
|
||||
chmodSync(p, 0o644);
|
||||
await expect(readPrivateFile(p)).rejects.toThrowError(
|
||||
PrivateFileTooPermissiveError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenStore', () => {
|
||||
it('returns the same token from repeated getToken() calls', async () => {
|
||||
const store = await createTokenStore(join(tmpDir, 'home'));
|
||||
expect(store.getToken()).toBe(store.getToken());
|
||||
await store.dispose();
|
||||
});
|
||||
|
||||
it('produces different tokens for different home dirs', async () => {
|
||||
const a = await createTokenStore(join(tmpDir, 'home-a'));
|
||||
const b = await createTokenStore(join(tmpDir, 'home-b'));
|
||||
expect(a.getToken()).not.toBe(b.getToken());
|
||||
await a.dispose();
|
||||
await b.dispose();
|
||||
});
|
||||
|
||||
it('reuses the same persistent token across stores in one home dir', async () => {
|
||||
const home = join(tmpDir, 'home');
|
||||
const a = await createTokenStore(home);
|
||||
const token = a.getToken();
|
||||
await a.dispose();
|
||||
const b = await createTokenStore(home);
|
||||
expect(b.getToken()).toBe(token);
|
||||
await b.dispose();
|
||||
});
|
||||
|
||||
it('writes the token file with mode 0600 at server.token', async () => {
|
||||
const home = join(tmpDir, 'home');
|
||||
const store = await createTokenStore(home);
|
||||
expect(store.tokenPath).toBe(join(home, 'server.token'));
|
||||
expect(statSync(store.tokenPath).mode & 0o777).toBe(0o600);
|
||||
await store.dispose();
|
||||
});
|
||||
|
||||
it('isValid accepts the token and rejects wrong / empty / same-length candidates', async () => {
|
||||
const store = await createTokenStore(join(tmpDir, 'home'));
|
||||
const token = store.getToken();
|
||||
expect(store.isValid(token)).toBe(true);
|
||||
expect(store.isValid('wrong')).toBe(false);
|
||||
expect(store.isValid('')).toBe(false);
|
||||
|
||||
const other = await createTokenStore(join(tmpDir, 'home-other'));
|
||||
expect(other.getToken().length).toBe(token.length);
|
||||
expect(store.isValid(other.getToken())).toBe(false);
|
||||
await store.dispose();
|
||||
await other.dispose();
|
||||
});
|
||||
|
||||
it('dispose() keeps the persistent token file on disk', async () => {
|
||||
const store = await createTokenStore(join(tmpDir, 'home'));
|
||||
expect(existsSync(store.tokenPath)).toBe(true);
|
||||
await store.dispose();
|
||||
expect(existsSync(store.tokenPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('re-reads the token after the file is rewritten (live rotation)', async () => {
|
||||
const home = join(tmpDir, 'home');
|
||||
const store = await createTokenStore(home);
|
||||
const original = store.getToken();
|
||||
|
||||
// Rewrite the same way `rotateServerToken` does (atomic rename → new
|
||||
// inode/mtime). Use a distinct, same-length value so the length check in
|
||||
// isValid does not short-circuit.
|
||||
const rotated = 'r'.repeat(original.length);
|
||||
await writePrivateFile(store.tokenPath, rotated);
|
||||
|
||||
expect(store.getToken()).toBe(rotated);
|
||||
expect(store.isValid(rotated)).toBe(true);
|
||||
expect(store.isValid(original)).toBe(false);
|
||||
await store.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistentToken', () => {
|
||||
it('loadOrCreateServerToken generates once and reuses thereafter', async () => {
|
||||
const home = join(tmpDir, 'home');
|
||||
const a = await loadOrCreateServerToken(home);
|
||||
const b = await loadOrCreateServerToken(home);
|
||||
expect(a).toBe(b);
|
||||
expect(statSync(join(home, 'server.token')).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('rotateServerToken writes a new, different token to server.token', async () => {
|
||||
const home = join(tmpDir, 'home');
|
||||
const original = await loadOrCreateServerToken(home);
|
||||
const rotated = await rotateServerToken(home);
|
||||
expect(rotated).not.toBe(original);
|
||||
expect(readFileSync(join(home, 'server.token'), 'utf8').trim()).toBe(rotated);
|
||||
});
|
||||
});
|
||||
|
||||
describe('password', () => {
|
||||
it('resolvePasswordHash returns undefined when env is unset or empty', async () => {
|
||||
expect(await resolvePasswordHash({})).toBeUndefined();
|
||||
expect(await resolvePasswordHash({ KIMI_CODE_PASSWORD: '' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('hashes a set password with bcrypt and verifies correctly', async () => {
|
||||
const passwordHash = await resolvePasswordHash({
|
||||
KIMI_CODE_PASSWORD: 'correct-horse-battery-staple',
|
||||
});
|
||||
expect(passwordHash?.startsWith('$2')).toBe(true);
|
||||
expect(await verifyPassword('correct-horse-battery-staple', passwordHash)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(await verifyPassword('wrong-password', passwordHash)).toBe(false);
|
||||
});
|
||||
|
||||
it('verifyPassword returns false when the hash is undefined', async () => {
|
||||
expect(await verifyPassword('anything', undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -21,6 +21,7 @@ import type { Session, Workspace } from '@moonshot-ai/protocol';
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -46,6 +47,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -58,12 +60,24 @@ async function bootDaemon(): Promise<RunningServer> {
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||
import { WebSocket } from 'ws';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -67,6 +68,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(options: { telemetry?: TelemetryClient } = {}): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -92,12 +94,24 @@ function recordingTelemetry(records: TelemetryRecord[]): TelemetryClient {
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): { code: number; msg: string; data: T | null; request_id: string; details?: unknown } {
|
||||
|
|
@ -118,7 +132,7 @@ async function openSessionListListener(r: RunningServer): Promise<{
|
|||
const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws';
|
||||
const received: Record<string, unknown>[] = [];
|
||||
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
||||
const sock = new WebSocket(wsUrl);
|
||||
const sock = new WebSocket(wsUrl, ['kimi-code.bearer.test-token']);
|
||||
sock.on('message', (data) => {
|
||||
try {
|
||||
received.push(JSON.parse(wsDataToString(data)) as Record<string, unknown>);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -66,6 +67,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -78,12 +80,24 @@ async function bootDaemon(): Promise<RunningServer> {
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import type { Event, SessionSnapshotResponse } from '@moonshot-ai/protocol';
|
|||
import { IEventService, IPromptService, PromptService } from '@moonshot-ai/agent-core';
|
||||
|
||||
import { IRestGateway, IWSBroadcastService, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
import { WSBroadcastService } from '#/services/gateway/wsBroadcastService';
|
||||
|
||||
let tmpDir: string;
|
||||
|
|
@ -51,6 +52,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -63,12 +65,24 @@ async function bootDaemon(): Promise<RunningServer> {
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { pino } from 'pino';
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth, withAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let bridgeHome: string;
|
||||
|
|
@ -42,11 +43,14 @@ afterEach(async () => {
|
|||
});
|
||||
|
||||
async function postSession(baseUrl: string, cwd: string): Promise<string> {
|
||||
const res = await fetch(`${baseUrl}/api/v1/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ metadata: { cwd } }),
|
||||
});
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/v1/sessions`,
|
||||
withAuth({
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ metadata: { cwd } }),
|
||||
}),
|
||||
);
|
||||
const env = (await res.json()) as { code: number; data: { id: string } | null };
|
||||
if (env.code !== 0 || env.data === null) throw new Error(JSON.stringify(env));
|
||||
return env.data.id;
|
||||
|
|
@ -54,7 +58,7 @@ async function postSession(baseUrl: string, cwd: string): Promise<string> {
|
|||
|
||||
async function timeSnapshot(baseUrl: string, sid: string): Promise<number> {
|
||||
const t = performance.now();
|
||||
const res = await fetch(`${baseUrl}/api/v1/sessions/${sid}/snapshot`);
|
||||
const res = await fetch(`${baseUrl}/api/v1/sessions/${sid}/snapshot`, withAuth());
|
||||
await res.text();
|
||||
return performance.now() - t;
|
||||
}
|
||||
|
|
@ -71,6 +75,7 @@ describe('SnapshotReader perf (real HTTP, 50 sessions)', () => {
|
|||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath: join(tmpDir, 'lock'),
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
logger: pino({ level: 'silent' }),
|
||||
coreProcessOptions: { homeDir: bridgeHome },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { pino } from 'pino';
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth, withAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let bridgeHome: string;
|
||||
|
|
@ -48,6 +49,7 @@ async function boot(): Promise<{ baseUrl: string }> {
|
|||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath: join(tmpDir, 'lock'),
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
logger: pino({ level: 'silent' }),
|
||||
coreProcessOptions: { homeDir: bridgeHome },
|
||||
});
|
||||
|
|
@ -55,11 +57,14 @@ async function boot(): Promise<{ baseUrl: string }> {
|
|||
}
|
||||
|
||||
async function postSession(baseUrl: string): Promise<string> {
|
||||
const res = await fetch(`${baseUrl}/api/v1/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ metadata: { cwd: join(tmpDir, 'workspace') } }),
|
||||
});
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/v1/sessions`,
|
||||
withAuth({
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ metadata: { cwd: join(tmpDir, 'workspace') } }),
|
||||
}),
|
||||
);
|
||||
const env = (await res.json()) as { code: number; data: { id: string } | null };
|
||||
if (env.code !== 0 || env.data === null) throw new Error(`createSession failed: ${JSON.stringify(env)}`);
|
||||
return env.data.id;
|
||||
|
|
@ -71,7 +76,7 @@ async function fetchSnapshot(baseUrl: string, sid: string): Promise<{
|
|||
ms: number;
|
||||
}> {
|
||||
const t0 = performance.now();
|
||||
const res = await fetch(`${baseUrl}/api/v1/sessions/${sid}/snapshot`);
|
||||
const res = await fetch(`${baseUrl}/api/v1/sessions/${sid}/snapshot`, withAuth());
|
||||
const envelope = (await res.json()) as { code: number; data: unknown };
|
||||
return { status: res.status, envelope, ms: performance.now() - t0 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import {
|
|||
type LockContents,
|
||||
type RunningServer,
|
||||
} from '../src';
|
||||
import { authHeaders, fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -123,6 +124,7 @@ function addrInUse(): NodeJS.ErrnoException {
|
|||
|
||||
async function spawn(): Promise<RunningServer> {
|
||||
const r = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -171,6 +173,7 @@ describe('startServer — lock + healthz smoke', () => {
|
|||
const thirdPartyLockPath = join(tmpDir, 'lock-third-party');
|
||||
try {
|
||||
const r = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
lockPath: thirdPartyLockPath,
|
||||
|
|
@ -303,6 +306,7 @@ describe('startServer — web assets', () => {
|
|||
writeFileSync(join(assetsDir, 'app.js'), 'console.log("kimi web");', 'utf8');
|
||||
|
||||
const r = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -325,7 +329,7 @@ describe('startServer — web assets', () => {
|
|||
const health = await fetch(`${r.address}/api/v1/healthz`);
|
||||
await expect(health.json()).resolves.toMatchObject({ code: 0 });
|
||||
|
||||
const openApi = await fetch(`${r.address}/openapi.json`);
|
||||
const openApi = await fetch(`${r.address}/openapi.json`, { headers: authHeaders() });
|
||||
expect(openApi.status).toBe(200);
|
||||
expect(openApi.headers.get('content-type')).toContain('application/json');
|
||||
await expect(openApi.json()).resolves.toMatchObject({
|
||||
|
|
@ -338,7 +342,7 @@ describe('startServer — web assets', () => {
|
|||
},
|
||||
});
|
||||
|
||||
const asyncApi = await fetch(`${r.address}/asyncapi.json`);
|
||||
const asyncApi = await fetch(`${r.address}/asyncapi.json`, { headers: authHeaders() });
|
||||
expect(asyncApi.status).toBe(200);
|
||||
expect(asyncApi.headers.get('content-type')).toContain('application/json');
|
||||
await expect(asyncApi.json()).resolves.toMatchObject({
|
||||
|
|
@ -362,6 +366,7 @@ describe('startServer — web assets', () => {
|
|||
|
||||
it('does not expose the Swagger UI while keeping /openapi.json available', async () => {
|
||||
const r = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -370,7 +375,7 @@ describe('startServer — web assets', () => {
|
|||
});
|
||||
running.push(r);
|
||||
|
||||
const openApi = await fetch(`${r.address}/openapi.json`);
|
||||
const openApi = await fetch(`${r.address}/openapi.json`, { headers: authHeaders() });
|
||||
expect(openApi.status).toBe(200);
|
||||
|
||||
const res = await fetch(`${r.address}/documentation`);
|
||||
|
|
@ -433,11 +438,14 @@ describe('POST /api/v1/shutdown', () => {
|
|||
coreProcessOptions: { homeDir: bridgeHome },
|
||||
// Override the real shutdown service so the route does not exit the
|
||||
// test runner via `process.exit(0)`.
|
||||
serviceOverrides: [[IServerShutdownService, fake] as const],
|
||||
serviceOverrides: [fixedTokenAuth(), [IServerShutdownService, fake] as const],
|
||||
});
|
||||
running.push(r);
|
||||
|
||||
const res = await fetch(`${r.address}/api/v1/shutdown`, { method: 'POST' });
|
||||
const res = await fetch(`${r.address}/api/v1/shutdown`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body['code']).toBe(0);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { pino } from 'pino';
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -38,6 +39,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -50,12 +52,24 @@ async function bootDaemon(): Promise<RunningServer> {
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown; payload: string }>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown; payload: string }>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown; payload: string }>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {
|
|||
} from '@moonshot-ai/protocol';
|
||||
|
||||
import { IRestGateway, startServer, type ServerStartOptions, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -72,7 +73,7 @@ async function bootDaemon(
|
|||
lockPath,
|
||||
logger: pino({ level: 'silent' }),
|
||||
coreProcessOptions: { homeDir: bridgeHome },
|
||||
serviceOverrides,
|
||||
serviceOverrides: [fixedTokenAuth(), ...(serviceOverrides ?? [])],
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
|
@ -80,12 +81,24 @@ async function bootDaemon(
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { pino } from 'pino';
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
import { FakeTerminalBackend } from './terminalTestBackend';
|
||||
|
||||
let tmpDir: string;
|
||||
|
|
@ -41,6 +42,7 @@ async function bootServer(): Promise<RunningServer> {
|
|||
logger: pino({ level: 'silent' }),
|
||||
coreProcessOptions: { homeDir: bridgeHome },
|
||||
serviceOverrides: [
|
||||
fixedTokenAuth(),
|
||||
[ITerminalService, new SyncDescriptor(TerminalService, [{ backend }], false)],
|
||||
],
|
||||
});
|
||||
|
|
@ -50,12 +52,24 @@ async function bootServer(): Promise<RunningServer> {
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
|
|
@ -51,6 +52,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -63,12 +65,24 @@ async function bootDaemon(): Promise<RunningServer> {
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { pino } from 'pino';
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
import type { Workspace } from '@moonshot-ai/protocol';
|
||||
|
||||
let tmpDir: string;
|
||||
|
|
@ -51,6 +52,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -63,12 +65,24 @@ async function bootDaemon(): Promise<RunningServer> {
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { WebSocket } from 'ws';
|
|||
import { IPromptService, PromptService } from '@moonshot-ai/agent-core';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
import { rawDataToString } from '../src/ws/rawData';
|
||||
|
||||
let tmpDir: string;
|
||||
|
|
@ -58,6 +59,7 @@ afterEach(async () => {
|
|||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -71,12 +73,24 @@ async function bootDaemon(): Promise<RunningServer> {
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
@ -129,7 +143,7 @@ async function openSubscriber(r: RunningServer, sid: string): Promise<Subscriber
|
|||
const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws';
|
||||
const received: Record<string, unknown>[] = [];
|
||||
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
||||
const sock = new WebSocket(wsUrl);
|
||||
const sock = new WebSocket(wsUrl, ['kimi-code.bearer.test-token']);
|
||||
sock.on('message', (data) => {
|
||||
try {
|
||||
received.push(JSON.parse(rawDataToString(data)) as Record<string, unknown>);
|
||||
|
|
|
|||
252
packages/server/test/ws-auth.e2e.test.ts
Normal file
252
packages/server/test/ws-auth.e2e.test.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
/**
|
||||
* WS upgrade auth (ROADMAP M3).
|
||||
*
|
||||
* M3.1 adds the `kimi-code.bearer.<token>` subprotocol parser; M3.2 wires it
|
||||
* into the upgrade path. The parser is exercised as pure unit cases first; the
|
||||
* upgrade-path cases boot `startServer` with a fixed-token
|
||||
* `IAuthTokenService` injected through `wsGatewayOptions.authTokenService`.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { pino } from 'pino';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
import { startServer, type RunningServer } from '../src';
|
||||
import { IAuthTokenService } from '../src/services/auth/authTokenService';
|
||||
import { extractWsBearerToken } from '../src/services/gateway/wsGateway';
|
||||
import { rawDataToString } from '../src/ws/rawData';
|
||||
|
||||
describe('extractWsBearerToken', () => {
|
||||
it('returns undefined for a missing header', () => {
|
||||
expect(extractWsBearerToken(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for an empty header', () => {
|
||||
expect(extractWsBearerToken('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('extracts the token from a single bearer subprotocol', () => {
|
||||
expect(extractWsBearerToken('kimi-code.bearer.TOKEN')).toBe('TOKEN');
|
||||
});
|
||||
|
||||
it('finds the bearer subprotocol among a comma-separated list', () => {
|
||||
expect(extractWsBearerToken('other, kimi-code.bearer.TOKEN2')).toBe('TOKEN2');
|
||||
});
|
||||
|
||||
it('returns undefined for an empty token', () => {
|
||||
expect(extractWsBearerToken('kimi-code.bearer.')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when no subprotocol matches', () => {
|
||||
expect(extractWsBearerToken('unrelated')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
interface WsFrame {
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
interface Conn {
|
||||
ws: WebSocket;
|
||||
queue: WsFrame[];
|
||||
waiters: Array<(frame: WsFrame) => void>;
|
||||
closed: Promise<{ code: number; reason: string }>;
|
||||
}
|
||||
|
||||
interface ConnectOptions {
|
||||
protocols?: string[];
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
let tmpDir: string;
|
||||
let lockPath: string;
|
||||
let bridgeHome: string;
|
||||
const running: RunningServer[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-ws-auth-'));
|
||||
lockPath = join(tmpDir, 'lock');
|
||||
bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-ws-auth-home-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const r of running.splice(0)) {
|
||||
try {
|
||||
await r.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
rmSync(bridgeHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Accepts exactly `'test-token'`; mirrors the fixed-token seam used in M2. */
|
||||
const fixedTokenAuth: IAuthTokenService = {
|
||||
_serviceBrand: undefined,
|
||||
getToken: () => 'test-token',
|
||||
isValid: async (candidate) => candidate === 'test-token',
|
||||
};
|
||||
|
||||
async function spawn(): Promise<RunningServer> {
|
||||
const r = await startServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
logger: pino({ level: 'silent' }),
|
||||
coreProcessOptions: { homeDir: bridgeHome },
|
||||
wsGatewayOptions: {
|
||||
pingIntervalMs: 60,
|
||||
pongTimeoutMs: 200,
|
||||
},
|
||||
// Inject the fixed token via the DI seam (M5.1 reads it through the WS
|
||||
// gateway's `setAuthTokenService`, no longer via `wsGatewayOptions`).
|
||||
serviceOverrides: [[IAuthTokenService, fixedTokenAuth]],
|
||||
});
|
||||
running.push(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
function wsUrl(http: string): string {
|
||||
return http.replace(/^http:\/\//, 'ws://') + '/api/v1/ws';
|
||||
}
|
||||
|
||||
function openConn(url: string, opts?: ConnectOptions): Promise<Conn> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url, opts?.protocols, { headers: opts?.headers });
|
||||
const queue: WsFrame[] = [];
|
||||
const waiters: Array<(frame: WsFrame) => void> = [];
|
||||
let closedResolve: (v: { code: number; reason: string }) => void;
|
||||
const closed = new Promise<{ code: number; reason: string }>((res) => {
|
||||
closedResolve = res;
|
||||
});
|
||||
ws.on('message', (data) => {
|
||||
let parsed: WsFrame;
|
||||
try {
|
||||
parsed = JSON.parse(rawDataToString(data)) as WsFrame;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (waiters.length > 0) {
|
||||
waiters.shift()?.(parsed);
|
||||
} else {
|
||||
queue.push(parsed);
|
||||
}
|
||||
});
|
||||
ws.on('close', (code, reason) => {
|
||||
closedResolve({ code, reason: String(reason) });
|
||||
});
|
||||
ws.once('open', () => resolve({ ws, queue, waiters, closed }));
|
||||
ws.once('error', (err) => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
function receive(conn: Conn, timeoutMs: number): Promise<WsFrame> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (conn.queue.length > 0) {
|
||||
resolve(conn.queue.shift()!);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
const idx = conn.waiters.indexOf(waiter);
|
||||
if (idx >= 0) conn.waiters.splice(idx, 1);
|
||||
reject(new Error(`no message in ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
const waiter = (frame: WsFrame): void => {
|
||||
clearTimeout(t);
|
||||
resolve(frame);
|
||||
};
|
||||
conn.waiters.push(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
async function receiveType(conn: Conn, type: string, timeoutMs: number): Promise<WsFrame> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) throw new Error(`no message of type ${type} within ${timeoutMs}ms`);
|
||||
const frame = await receive(conn, remaining);
|
||||
if (frame.type === type) return frame;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the upgrade is rejected (the socket errors/closes without ever
|
||||
* opening, so no `server_hello` is received). Resolves on the first `error` or
|
||||
* `close`; rejects if the socket opens or nothing happens within the timeout.
|
||||
*/
|
||||
function expectRejected(url: string, opts?: ConnectOptions): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url, opts?.protocols, { headers: opts?.headers });
|
||||
const done = (err?: Error): void => {
|
||||
clearTimeout(t);
|
||||
ws.removeAllListeners();
|
||||
try {
|
||||
ws.terminate();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (err !== undefined) reject(err);
|
||||
else resolve();
|
||||
};
|
||||
const t = setTimeout(
|
||||
() => done(new Error('connection was not rejected within timeout')),
|
||||
1500,
|
||||
);
|
||||
ws.once('open', () => done(new Error('connection unexpectedly opened')));
|
||||
ws.once('error', () => done());
|
||||
ws.once('close', () => done());
|
||||
});
|
||||
}
|
||||
|
||||
describe('ws upgrade auth', () => {
|
||||
it('accepts a valid bearer subprotocol and echoes it', async () => {
|
||||
const r = await spawn();
|
||||
const conn = await openConn(wsUrl(r.address), {
|
||||
protocols: ['kimi-code.bearer.test-token'],
|
||||
});
|
||||
|
||||
await receiveType(conn, 'server_hello', 1000);
|
||||
expect(conn.ws.protocol).toBe('kimi-code.bearer.test-token');
|
||||
|
||||
conn.ws.close();
|
||||
await conn.closed;
|
||||
});
|
||||
|
||||
it('accepts a valid Authorization bearer header', async () => {
|
||||
const r = await spawn();
|
||||
const conn = await openConn(wsUrl(r.address), {
|
||||
headers: { Authorization: 'Bearer test-token' },
|
||||
});
|
||||
|
||||
const hello = await receiveType(conn, 'server_hello', 1000);
|
||||
expect(hello.type).toBe('server_hello');
|
||||
|
||||
conn.ws.close();
|
||||
await conn.closed;
|
||||
});
|
||||
|
||||
it('rejects a wrong bearer token without server_hello', async () => {
|
||||
const r = await spawn();
|
||||
await expectRejected(wsUrl(r.address), {
|
||||
protocols: ['kimi-code.bearer.wrong'],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a connection with no token', async () => {
|
||||
const r = await spawn();
|
||||
await expectRejected(wsUrl(r.address));
|
||||
});
|
||||
|
||||
it('rejects upgrades to a non-/api/v1/ws path', async () => {
|
||||
const r = await spawn();
|
||||
const badUrl = r.address.replace(/^http:\/\//, 'ws://') + '/api/v1/other';
|
||||
await expectRejected(badUrl, { protocols: ['kimi-code.bearer.test-token'] });
|
||||
});
|
||||
});
|
||||
|
|
@ -31,6 +31,7 @@ import {
|
|||
startServer,
|
||||
type RunningServer,
|
||||
} from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
import { rawDataToString } from '../src/ws/rawData';
|
||||
|
||||
let tmpDir: string;
|
||||
|
|
@ -52,12 +53,15 @@ afterEach(async () => {
|
|||
// ignore
|
||||
}
|
||||
}
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
rmSync(bridgeHome, { recursive: true, force: true });
|
||||
// The server's core process may still flush files into the sandboxed home
|
||||
// briefly after close(), so retry removals to ride out EBUSY/ENOTEMPTY races.
|
||||
rmSync(tmpDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
||||
rmSync(bridgeHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
||||
});
|
||||
|
||||
async function spawn(): Promise<RunningServer> {
|
||||
const r = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -91,7 +95,7 @@ interface Conn {
|
|||
|
||||
function openConn(url: string): Promise<Conn> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
const ws = new WebSocket(url, ['kimi-code.bearer.test-token']);
|
||||
const queue: WsFrame[] = [];
|
||||
const waiters: Array<(frame: WsFrame) => void> = [];
|
||||
ws.on('message', (data) => {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||
import { WebSocket } from 'ws';
|
||||
|
||||
import { IConnectionRegistry, IWSGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
import { rawDataToString } from '../src/ws/rawData';
|
||||
|
||||
interface TelemetryRecord {
|
||||
|
|
@ -84,6 +85,7 @@ afterEach(async () => {
|
|||
|
||||
async function spawn(): Promise<RunningServer> {
|
||||
const r = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -122,7 +124,7 @@ interface Conn {
|
|||
|
||||
function openConn(url: string): Promise<Conn> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
const ws = new WebSocket(url, ['kimi-code.bearer.test-token']);
|
||||
const queue: WsFrame[] = [];
|
||||
const waiters: Array<(frame: WsFrame) => void> = [];
|
||||
let closedResolve: (v: { code: number; reason: string }) => void;
|
||||
|
|
@ -284,6 +286,7 @@ describe('WS gateway connection-count observer', () => {
|
|||
it('reports size 1 after a connect and size 0 after the last disconnect', async () => {
|
||||
const counts: number[] = [];
|
||||
const r = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -313,6 +316,7 @@ describe('WS gateway connection-count observer', () => {
|
|||
it('tracks multiple concurrent connections independently', async () => {
|
||||
const counts: number[] = [];
|
||||
const r = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -350,6 +354,7 @@ describe('WS gateway telemetry (ws_connected / ws_disconnected)', () => {
|
|||
it('emits ws_connected on connect and ws_disconnected on close', async () => {
|
||||
const records: TelemetryRecord[] = [];
|
||||
const r = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
startServer,
|
||||
type RunningServer,
|
||||
} from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
import { rawDataToString } from '../src/ws/rawData';
|
||||
import { WSBroadcastService } from '#/services/gateway/wsBroadcastService';
|
||||
|
||||
|
|
@ -69,6 +70,7 @@ afterEach(async () => {
|
|||
|
||||
async function spawn(): Promise<RunningServer> {
|
||||
const r = await startServer({
|
||||
serviceOverrides: [fixedTokenAuth()],
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
|
|
@ -104,7 +106,7 @@ interface Conn {
|
|||
|
||||
function openConn(url: string): Promise<Conn> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
const ws = new WebSocket(url, ['kimi-code.bearer.test-token']);
|
||||
const queue: WsFrame[] = [];
|
||||
const waiters: Array<(frame: WsFrame) => void> = [];
|
||||
ws.on('message', (data) => {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||
import { WebSocket } from 'ws';
|
||||
|
||||
import { IRestGateway, startServer, type RunningServer } from '../src';
|
||||
import { fixedTokenAuth } from './helpers/serverHarness';
|
||||
import { rawDataToString } from '../src/ws/rawData';
|
||||
import { FakeTerminalBackend } from './terminalTestBackend';
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ async function bootServer(): Promise<RunningServer> {
|
|||
coreProcessOptions: { homeDir: bridgeHome },
|
||||
wsGatewayOptions: { pingIntervalMs: 5_000, pongTimeoutMs: 5_000 },
|
||||
serviceOverrides: [
|
||||
fixedTokenAuth(),
|
||||
[ITerminalService, new SyncDescriptor(TerminalService, [{ backend }], false)],
|
||||
],
|
||||
});
|
||||
|
|
@ -53,12 +55,24 @@ async function bootServer(): Promise<RunningServer> {
|
|||
function appOf(r: RunningServer): {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
} {
|
||||
return r.services.invokeFunction((a) => {
|
||||
const app = r.services.invokeFunction((a) => {
|
||||
const gw = a.get(IRestGateway);
|
||||
return gw.app as unknown as {
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
||||
};
|
||||
});
|
||||
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
||||
// caller-supplied `authorization` header wins, so explicit token tests keep
|
||||
// working; every other header (Range, content-type, …) is preserved.
|
||||
return {
|
||||
inject(req: unknown) {
|
||||
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
||||
return app.inject({
|
||||
...q,
|
||||
headers: { authorization: 'Bearer test-token', ...q.headers },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeOf<T>(body: unknown): {
|
||||
|
|
@ -112,7 +126,9 @@ async function openSocket(r: RunningServer): Promise<{
|
|||
}> {
|
||||
const received: Record<string, unknown>[] = [];
|
||||
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
||||
const sock = new WebSocket(r.address.replace('http://', 'ws://') + '/api/v1/ws');
|
||||
const sock = new WebSocket(r.address.replace('http://', 'ws://') + '/api/v1/ws', [
|
||||
'kimi-code.bearer.test-token',
|
||||
]);
|
||||
sock.on('message', (data) => {
|
||||
received.push(JSON.parse(rawDataToString(data)) as Record<string, unknown>);
|
||||
});
|
||||
|
|
|
|||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
|
|
@ -553,6 +553,9 @@ importers:
|
|||
'@moonshot-ai/protocol':
|
||||
specifier: workspace:^
|
||||
version: link:../protocol
|
||||
bcryptjs:
|
||||
specifier: ^2.4.3
|
||||
version: 2.4.3
|
||||
fastify:
|
||||
specifier: ^5.1.0
|
||||
version: 5.8.5
|
||||
|
|
@ -575,6 +578,9 @@ importers:
|
|||
specifier: ^3.25.2
|
||||
version: 3.25.2(zod@4.3.6)
|
||||
devDependencies:
|
||||
'@types/bcryptjs':
|
||||
specifier: ^2.4.6
|
||||
version: 2.4.6
|
||||
'@types/ws':
|
||||
specifier: ^8.18.0
|
||||
version: 8.18.1
|
||||
|
|
@ -2561,6 +2567,9 @@ packages:
|
|||
'@types/babel__traverse@7.28.0':
|
||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||
|
||||
'@types/bcryptjs@2.4.6':
|
||||
resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
|
|
@ -3070,6 +3079,9 @@ packages:
|
|||
bcrypt-pbkdf@1.0.2:
|
||||
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
|
||||
|
||||
bcryptjs@2.4.3:
|
||||
resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==}
|
||||
|
||||
better-path-resolve@1.0.0:
|
||||
resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -8018,6 +8030,8 @@ snapshots:
|
|||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@types/bcryptjs@2.4.6': {}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
|
|
@ -8595,6 +8609,8 @@ snapshots:
|
|||
dependencies:
|
||||
tweetnacl: 0.14.5
|
||||
|
||||
bcryptjs@2.4.3: {}
|
||||
|
||||
better-path-resolve@1.0.0:
|
||||
dependencies:
|
||||
is-windows: 1.0.2
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue