diff --git a/.agents/skills/agent-core-dev/flags.md b/.agents/skills/agent-core-dev/flags.md index abae89ac3c..e4c33d8b2c 100644 --- a/.agents/skills/agent-core-dev/flags.md +++ b/.agents/skills/agent-core-dev/flags.md @@ -11,7 +11,7 @@ Gate not-yet-public features behind `IFlagService.enabled(id)`, per the reposito - `src/flag/flag.ts` — `IFlagService` token + resolver types (`ExperimentalFlagMap`, `ExperimentalFlagConfig`, `ExperimentalFlagSource`, `ExperimentalFeatureState`) + `ExperimentalConfigSchema` / `ExperimentalConfig` (zod). - `src/flag/flagService.ts` — `FlagService` impl + `MASTER_ENV` (`KIMI_CODE_EXPERIMENTAL_FLAG`) + `EXPERIMENTAL_SECTION` (`experimental`); reads definitions from `IFlagRegistry`; self-registers at App scope. - `src/flag/index.ts` — **removed (no barrel)**; `src/index.ts` imports the `flag` leafs precisely instead (e.g. `import './flag/flagService'`). -- `src//flag.ts` — each domain that owns a flag declares it here and calls `registerFlagDefinition` at the module top level (e.g. `src/multiServer/flag.ts`). The directory already names the domain, so the file is just `flag.ts`. +- `src//flag.ts` — each domain that owns a flag declares it here and calls `registerFlagDefinition` at the module top level (e.g. `src/agent/toolSelect/flag.ts` or `src/agent/faultInjection/flag.ts`). The directory already names the domain, so the file is just `flag.ts`. ## Public surface diff --git a/.changeset/multi-server-default-shared-home.md b/.changeset/multi-server-default-shared-home.md new file mode 100644 index 0000000000..4bb01bbff8 --- /dev/null +++ b/.changeset/multi-server-default-shared-home.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Replace the `kimi server` command tree with `kimi web`: the server runs in the foreground (the background daemon and OS-service lifecycle commands are removed), and multiple servers can now share one home directory, each taking the next free port. Manage instances with `kimi web kill [server-id|all]`, `kimi web ps`, and `kimi web rotate-token`; any `kimi server …` invocation prints a deprecation notice and exits 1. diff --git a/.changeset/web-foreground-default.md b/.changeset/web-foreground-default.md deleted file mode 100644 index ddef27b144..0000000000 --- a/.changeset/web-foreground-default.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": minor ---- - -**Breaking**: `kimi web` and the TUI `/web` command now run the server in the foreground by default instead of backgrounding a daemon — the command stays attached to the terminal until Ctrl+C instead of returning immediately. Pass `--background` in scripts or launchers to keep the previous background behavior; `kimi server run` is unchanged. An already-running server is reused as-is across upgrades (a version mismatch is pointed out in the output); run `kimi server kill` once after upgrading to restart on the new version. diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index e2c207c79b..9e865f8c5d 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -37,8 +37,8 @@ "imports": { "#/tui/theme": "./src/tui/theme/index.ts", "#/tui/commands": "./src/tui/commands/index.ts", - "#/cli/sub/server": "./src/cli/sub/server/index.ts", - "#/cli/sub/server/*": "./src/cli/sub/server/*.ts", + "#/cli/sub/web": "./src/cli/sub/web/index.ts", + "#/cli/sub/web/*": "./src/cli/sub/web/*.ts", "#/generated/vis-web-asset": [ "./src/generated/vis-web-asset.ts", "./src/generated/vis-web-asset.d.ts" @@ -63,9 +63,9 @@ "test:native:smoke": "node scripts/native/smoke.mjs", "dev": "node scripts/dev.mjs", "dev:cli-only": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts", - "dev:server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground --debug-endpoints", - "dev:kap-server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground --debug-endpoints", - "dev:kap-server:multi": "KIMI_CODE_EXPERIMENTAL_MULTI_SERVER=1 tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground --debug-endpoints", + "dev:server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts web --no-open --debug-endpoints", + "dev:kap-server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts web --no-open --debug-endpoints", + "dev:kap-server:multi": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts web --no-open --debug-endpoints", "dev:server:restart": "node scripts/dev-server-restart.mjs", "dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs", "build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs", diff --git a/apps/kimi-code/scripts/dev-server-restart.mjs b/apps/kimi-code/scripts/dev-server-restart.mjs index 8169925cf6..4beb2bc6f3 100644 --- a/apps/kimi-code/scripts/dev-server-restart.mjs +++ b/apps/kimi-code/scripts/dev-server-restart.mjs @@ -1,12 +1,12 @@ #!/usr/bin/env node // Press-Enter-to-restart wrapper for the local server. No file watcher. // -// Spawns `tsx ./src/main.ts server run …extraArgs` once, then on each newline -// read from stdin SIGTERMs the child and respawns after it has cleanly exited. -// SIGTERM triggers the server's own `shutdown()` handler -// (apps/kimi-code/src/cli/sub/server/run.ts) which releases the port lock and -// closes WS conns before exit, so a fresh start can re-acquire 58627 without a -// stale-lock fight. +// Spawns `tsx ./src/main.ts web --no-open …extraArgs` once, then on each +// newline read from stdin SIGTERMs the child and respawns after it has +// cleanly exited. SIGTERM triggers the server's own `shutdown()` handler +// (apps/kimi-code/src/cli/sub/web/run.ts) which releases the instance +// registration and closes WS conns before exit, so a fresh start can +// re-acquire 58627 without a stale-entry fight. // // CLI args after `--` (or any extras) are passed straight through, so: // pnpm dev:server:restart -- --host 0.0.0.0 --port 58627 --log-level debug @@ -31,8 +31,8 @@ const tsxArgs = [ '--import', '../../build/register-raw-text-loader.mjs', './src/main.ts', - 'server', - 'run', + 'web', + '--no-open', ...cliArgs, ]; diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 960c707a6c..bd9eeb87a7 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -8,8 +8,8 @@ import { registerDoctorCommand } from './sub/doctor'; import { registerExportCommand } from './sub/export'; import { registerLoginCommand } from './sub/login'; import { registerProviderCommand } from './sub/provider'; -import { registerServerCommand } from './sub/server'; import { registerVisCommand } from './sub/vis'; +import { registerWebCommand } from './sub/web'; export type MainCommandHandler = (opts: CLIOptions) => void; export type MigrateCommandHandler = () => void; @@ -89,7 +89,7 @@ export function createProgram( registerExportCommand(program); registerProviderCommand(program); registerAcpCommand(program); - registerServerCommand(program); + registerWebCommand(program); registerLoginCommand(program); registerDoctorCommand(program); registerVisCommand(program); diff --git a/apps/kimi-code/src/cli/experimental-v2.ts b/apps/kimi-code/src/cli/experimental-v2.ts index 1553252412..f3b2f10954 100644 --- a/apps/kimi-code/src/cli/experimental-v2.ts +++ b/apps/kimi-code/src/cli/experimental-v2.ts @@ -7,7 +7,7 @@ * `cli/update/rollout.ts`) because the CLI must not depend on the core flag * registry. Unset / any non-truthy value keeps the v1 harness. * - * Note: `kimi server run` always boots kap-server (the agent-core-v2 engine + * Note: `kimi web` always boots kap-server (the agent-core-v2 engine * server) — it no longer consults this switch. */ diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 8726f77506..47d97a828b 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -217,13 +217,6 @@ export async function runShell( } removeCrashHandlers(); restoreStty(); - if (tui.exitForegroundTask !== undefined) { - // `/web` foreground mode: the TUI has shut down cleanly; hand the - // terminal to the foreground server instead of exiting. The task runs - // until the server stops (Ctrl+C), then this process exits. - await tui.exitForegroundTask(exitCode); - return; - } process.exit(exitCode); }; try { diff --git a/apps/kimi-code/src/cli/sub/server/daemon.ts b/apps/kimi-code/src/cli/sub/server/daemon.ts deleted file mode 100644 index 9e8202a084..0000000000 --- a/apps/kimi-code/src/cli/sub/server/daemon.ts +++ /dev/null @@ -1,428 +0,0 @@ -/** - * `kimi web` daemon orchestration — parent (spawner) side. - * - * Ensures a single background server daemon exists for this device, then - * returns its origin so the caller can open the web UI. The flow: - * - * 1. Read `~/.kimi-code/server/lock`. If it names a *live* daemon, reuse it - * (wait for it to be healthy) — never spawn a second one. - * 2. Otherwise pick a free port (preferred port when available, else an - * OS-assigned one) and spawn `kimi server run --daemon` as a detached - * child whose stdio is redirected to the server log. - * 3. Poll the lock until *some* live daemon (ours, or a concurrent racer's - * that won the lock) is healthy, then return its origin. - * - * The child side (`startServerDaemon`) lives in `./run.ts` next to the - * foreground runner so it can share the same bootstrap helpers. - */ - -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'; - -import { DEFAULT_LOCK_DIR, getLiveLock, type LockContents } from '@moonshot-ai/kap-server'; - -import { - DEFAULT_SERVER_HOST, - DEFAULT_SERVER_PORT, - LOCAL_SERVER_HOST, - isServerHealthy, - serverOrigin, - waitForServerHealthy, -} from './shared'; - -const SERVER_LOG_FILENAME = 'server.log'; - -/** How long to wait for an already-running daemon to answer `/healthz`. */ -const REUSE_HEALTH_TIMEOUT_MS = 15_000; -/** How long to wait for a freshly-spawned daemon to come up. */ -const SPAWN_TIMEOUT_MS = 20_000; -/** Poll cadence while waiting for the daemon to appear in the lock + healthz. */ -const POLL_INTERVAL_MS = 200; -/** Default log level for a daemon spawned without an explicit `--log-level`. */ -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; - /** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */ - dangerousBypassAuth?: boolean; - /** Keep the daemon alive instead of idle-killing it (`--keep-alive`). */ - keepAlive?: boolean; - /** Extra `Host` header values to allow through the DNS-rebinding check. */ - allowedHosts?: readonly string[]; - /** 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; - /** - * CLI version that started the reused server, recorded in its lock. Absent - * for freshly-spawned servers (same version as this CLI) and for locks - * written by older builds. - */ - readonly hostVersion?: string; -} - -/** Path of the daemon log file (shared with the OS-service log location). */ -export function daemonLogPath(): string { - return join(DEFAULT_LOCK_DIR, SERVER_LOG_FILENAME); -} - -export function lockConnectHost(lock: LockContents): string { - const host = lock.host ?? LOCAL_SERVER_HOST; - return host === '0.0.0.0' ? LOCAL_SERVER_HOST : host; -} - -/** True when `host:port` is currently free to bind (nothing listening). */ -function canBind(host: string, port: number): Promise { - return new Promise((resolvePromise) => { - const probe = createServer(); - probe.once('error', () => resolvePromise(false)); - probe.listen({ host, port }, () => { - probe.close(() => resolvePromise(true)); - }); - }); -} - -/** Ask the OS for an ephemeral free port on `host`. */ -function getFreePort(host: string): Promise { - return new Promise((resolvePromise, reject) => { - const probe = createServer(); - probe.once('error', reject); - probe.listen({ host, port: 0 }, () => { - const address = probe.address(); - if (address === null || typeof address === 'string') { - probe.close(() => reject(new Error('failed to allocate a free port'))); - return; - } - const { port } = address; - probe.close(() => resolvePromise(port)); - }); - }); -} - -/** - * How many consecutive `preferred + n` ports to probe before giving up and - * asking the OS for any free port. Mirrors `PORT_RETRY_LIMIT` in the server's - * own bind retry so the spawner and the daemon agree on the policy. - */ -export const DAEMON_PORT_SCAN_LIMIT = 100; - -/** - * Pick a port for a new daemon: prefer `preferred` when it is free, otherwise - * walk `preferred + 1`, `+ 2`, … upward and take the first free one. Only when - * the whole scan window is saturated do we fall back to an OS-assigned free - * port. - * - * Reusing an already-live daemon is handled by `ensureDaemon` before this runs, - * so a busy port here is held by a third-party process — bumping by one (rather - * than jumping to a random ephemeral port) keeps the URL predictable, matching - * the server's own "port busy ⇒ +1" bind retry. - */ -export async function resolveDaemonPort( - host: string = DEFAULT_SERVER_HOST, - preferred: number = DEFAULT_SERVER_PORT, -): Promise { - for ( - let candidate = preferred; - candidate < preferred + DAEMON_PORT_SCAN_LIMIT && candidate <= 65535; - candidate++ - ) { - if (await canBind(host, candidate)) return candidate; - } - return getFreePort(host); -} - -interface NodeSeaModule { - isSea(): boolean; -} - -const nodeRequire = createRequire(import.meta.url); -let cachedSea: NodeSeaModule | null | undefined; - -function loadSeaModule(): NodeSeaModule | null { - if (cachedSea !== undefined) return cachedSea; - try { - cachedSea = nodeRequire('node:sea') as NodeSeaModule; - } catch { - cachedSea = null; - } - return cachedSea; -} - -/** True when running as a compiled single-executable (SEA / native) binary. */ -function detectSea(): boolean { - const sea = loadSeaModule(); - if (sea === null) return false; - try { - return sea.isSea(); - } catch { - return false; - } -} - -/** - * Absolute path to the CLI entry that should be re-execed to run the daemon. - * Mirrors `resolveSupervisorProgram` in `packages/kap-server/src/svc/program.ts`: - * when the CLI is a compiled single binary, `argv[1]` is the invoked command - * name (e.g. `kimi`) or the first user argument — never a script path — so we - * must re-exec `process.execPath` itself. - */ -export function resolveDaemonProgram( - argv: readonly string[] = process.argv, - cwd: string = process.cwd(), - execPath: string = process.execPath, - isSea: boolean = detectSea(), -): string { - // In a SEA binary `argv[1]` is not a script path, so resolving it against - // `cwd` would produce a bogus path (e.g. `/kimi`) and crash the spawn - // with ENOENT. Always re-exec the binary itself. - if (isSea) return execPath; - const candidate = argv[1] === 'server' ? execPath : (argv[1] ?? execPath); - return isAbsolute(candidate) ? candidate : resolve(cwd, candidate); -} - -interface SpawnDaemonChildOptions { - host?: string; - port: number; - logLevel: string; - debugEndpoints?: boolean; - insecureNoTls?: boolean; - allowRemoteShutdown?: boolean; - allowRemoteTerminals?: boolean; - dangerousBypassAuth?: boolean; - keepAlive?: boolean; - allowedHosts?: readonly string[]; - idleGraceMs?: number; -} - -export function spawnDaemonChild(options: SpawnDaemonChildOptions): ChildProcess { - const program = resolveDaemonProgram(); - const logPath = daemonLogPath(); - const logDir = dirname(logPath); - mkdirSync(logDir, { recursive: true }); - const args = [ - 'server', - 'run', - '--daemon', - '--port', - String(options.port), - '--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.dangerousBypassAuth === true) { - args.push('--dangerous-bypass-auth'); - } - if (options.keepAlive === true) { - args.push('--keep-alive'); - } - if (options.idleGraceMs !== undefined) { - args.push('--idle-grace-ms', String(options.idleGraceMs)); - } - if (options.allowedHosts !== undefined && options.allowedHosts.length > 0) { - args.push('--allowed-host', ...options.allowedHosts); - } - // On Windows `.mjs` files are not executable PE binaries, so we must run - // the script through the Node binary rather than spawning it directly. In - // SEA mode or when re-spawning from an already-running daemon, `program` is - // `process.execPath` itself, so no script argument is needed. - const execPath = process.execPath; - const spawnArgs = program === execPath ? args : [program, ...args]; - - const logFd = openSync(logPath, 'a'); - try { - const child = spawn(execPath, spawnArgs, { - detached: true, - // Run from the server log directory instead of inheriting the caller's - // cwd, so the long-lived daemon does not pin the directory it was - // launched from (notably blocking its deletion on Windows). - cwd: logDir, - stdio: ['ignore', logFd, logFd], - }); - child.once('error', (error) => { - // A spawn failure (e.g. ENOENT) surfaces asynchronously on the child, - // not as a thrown error. Without a listener Node would crash the parent - // with an unhandled 'error' event; record it instead and let the polling - // loop in `ensureDaemon` report the timeout. - try { - appendFileSync(logPath, `[spawner] failed to launch daemon: ${error.message}\n`); - } catch { - // Best-effort; the log directory may already be gone. - } - }); - child.unref(); - return child; - } finally { - // `spawn` dups the fd into the child; the parent must not keep it open. - closeSync(logFd); - } -} - -function sleep(ms: number): Promise { - return new Promise((resolvePromise) => { - setTimeout(resolvePromise, ms); - }); -} - -/** - * Return an already-live, healthy daemon's connection info, or `undefined` - * when no reusable server holds the lock. Used by `ensureDaemon` (step 1) and - * by foreground-mode `kimi web`, which opens the running server instead of - * failing to bind its port. - */ -export async function findReusableDaemon(): Promise { - const existing = getLiveLock(); - if (!existing) return undefined; - const origin = serverOrigin(lockConnectHost(existing), existing.port); - if (!(await waitForServerHealthy(origin, REUSE_HEALTH_TIMEOUT_MS))) return undefined; - return { - origin, - reused: true, - host: existing.host ?? DEFAULT_SERVER_HOST, - port: existing.port, - hostVersion: existing.host_version, - }; -} - -/** - * Ensure a daemon is running and return its origin. Non-blocking for the - * caller beyond the short health wait — the server itself keeps running in a - * detached process after this returns. - */ -export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise { - const host = options.host ?? DEFAULT_SERVER_HOST; - const preferred = options.port ?? DEFAULT_SERVER_PORT; - const logLevel = options.logLevel ?? DEFAULT_DAEMON_LOG_LEVEL; - - // 1. Reuse an already-live daemon if one holds the lock. - const reusable = await findReusableDaemon(); - if (reusable) return reusable; - // A live lock pid that is not responding (wedged or mid-boot failure) falls - // through to spawn: if it is truly wedged our child loses the lock race and - // we reconnect below; if it died, stale takeover lets our child claim it. - - // 2. No reusable daemon — pick a free port and spawn one detached. - const port = await resolveDaemonPort(host, preferred); - const child = spawnDaemonChild({ - host, - port, - logLevel, - debugEndpoints: options.debugEndpoints, - insecureNoTls: options.insecureNoTls, - allowRemoteShutdown: options.allowRemoteShutdown, - allowRemoteTerminals: options.allowRemoteTerminals, - dangerousBypassAuth: options.dangerousBypassAuth, - keepAlive: options.keepAlive, - allowedHosts: options.allowedHosts, - 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) { - const live = getLiveLock(); - if (live) { - const origin = serverOrigin(lockConnectHost(live), live.port); - if (await isServerHealthy(origin, 500)) { - 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.\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 ''; - } -} diff --git a/apps/kimi-code/src/cli/sub/server/index.ts b/apps/kimi-code/src/cli/sub/server/index.ts deleted file mode 100644 index 0e162fd596..0000000000 --- a/apps/kimi-code/src/cli/sub/server/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * `kimi server` parent command. Mounts: - * - `server run` (background daemon by default; `--foreground` to attach; the - * detached daemon child runs the same command with `--daemon`) - * - * The OS service-manager subcommands (`install/uninstall/start/stop/restart/ - * status`) are temporarily NOT registered — see the commented - * `addLifecycleCommands(server)` below. Their implementation is preserved in - * `./lifecycle.ts` + `packages/kap-server/src/svc/*` for later re-exposure. - * - * The top-level `kimi web` alias is registered separately via - * `registerWebAliasCommand` so it stays at the program root. - */ - -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 { - const server = program - .command('server') - .description('Run the local Kimi server (REST + WebSocket + web UI).'); - - buildRunCommand( - server.command('run').description('Start the Kimi server (background daemon; use --foreground to attach).'), - { defaultOpen: false }, - ); - - registerPsCommand(server); - - 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 - // `./lifecycle.ts` + `packages/kap-server/src/svc/*`; re-import - // `addLifecycleCommands` and call it here to re-expose. - // addLifecycleCommands(server); - - registerWebAliasCommand(program); -} - -export { registerWebAliasCommand }; diff --git a/apps/kimi-code/src/cli/sub/server/lifecycle.ts b/apps/kimi-code/src/cli/sub/server/lifecycle.ts deleted file mode 100644 index 84352cebf0..0000000000 --- a/apps/kimi-code/src/cli/sub/server/lifecycle.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * `kimi server install/uninstall/start/stop/restart/status`. - * - * The lifecycle calls into the platform service manager from - * `@moonshot-ai/kap-server` (`src/svc/*`). - * - * The Commander wiring here mirrors `addGatewayServiceCommands` from - * `../openclaw/src/cli/daemon-cli/register-service-commands.ts:58`. - */ - -import type { Command } from 'commander'; - -import { - ServiceUnavailableError, - ServiceUnsupportedError, - resolveServiceManager, - type InstallArgs, - type ServiceManager, - type ServiceStatus, -} from '@moonshot-ai/kap-server'; - -import { openUrl as defaultOpenUrl } from '#/utils/open-url'; - -import { - DEFAULT_LOG_LEVEL, - DEFAULT_SERVER_HOST, - DEFAULT_SERVER_PORT, - LOCAL_SERVER_HOST, - parseLogLevel, - parsePort, - serverOrigin, - VALID_LOG_LEVELS, -} from './shared'; - -export interface InstallCliOptions { - port?: string; - logLevel?: string; - force?: boolean; - open?: boolean; - json?: boolean; -} - -export interface JsonCliOptions { - json?: boolean; -} - -export interface LifecycleCommandDeps { - resolveManager(): ServiceManager; - openUrl(url: string): void; - stdout: Pick; - stderr: Pick; -} - -const DEFAULT_DEPS: LifecycleCommandDeps = { - resolveManager: resolveServiceManager, - openUrl: defaultOpenUrl, - stdout: process.stdout, - stderr: process.stderr, -}; - -/** Mount install/uninstall/start/stop/restart/status under a parent command. */ -export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps = DEFAULT_DEPS): void { - parent - .command('install') - .description('Install the Kimi server as an OS-managed service (launchd/systemd/schtasks).') - .option('--port ', `Bind port (default ${DEFAULT_SERVER_PORT})`, String(DEFAULT_SERVER_PORT)) - .option( - '--log-level ', - `Log level: ${VALID_LOG_LEVELS.join('|')} (default ${DEFAULT_LOG_LEVEL})`, - DEFAULT_LOG_LEVEL, - ) - .option('--force', 'Reinstall and overwrite if already installed', false) - .option('--no-open', 'Do not open the web UI after install.', true) - .option('--json', 'Output JSON', false) - .action(async (opts: InstallCliOptions) => { - await runLifecycle(deps, opts.json === true, async (mgr) => { - const args: InstallArgs = { - host: DEFAULT_SERVER_HOST, - port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT), - logLevel: parseLogLevel(opts.logLevel), - force: opts.force === true, - }; - const result = await mgr.install(args); - const status = await readStatus(mgr); - const enriched = withStatusDetails({ - ok: true, - action: 'install', - status: result.status, - plistPath: result.plistPath, - unitPath: result.unitPath, - taskName: result.taskName, - message: result.message, - }, status, args); - if (opts.json !== true && opts.open !== false && enriched.running === true && typeof enriched.url === 'string') { - deps.openUrl(enriched.url); - } - return enriched; - }); - }); - - parent - .command('uninstall') - .description('Uninstall the Kimi server service.') - .option('--json', 'Output JSON', false) - .action(async (opts: JsonCliOptions) => { - await runLifecycle(deps, opts.json === true, async (mgr) => { - const result = await mgr.uninstall(); - return { ok: result.ok, action: 'uninstall', message: result.message }; - }); - }); - - parent - .command('start') - .description('Start the Kimi server service.') - .option('--json', 'Output JSON', false) - .action(async (opts: JsonCliOptions) => { - await runLifecycle(deps, opts.json === true, async (mgr) => { - const result = await mgr.start(); - const status = await readStatus(mgr); - return withStatusDetails({ ok: result.ok, action: 'start', message: result.message }, status); - }); - }); - - parent - .command('stop') - .description('Stop the Kimi server service.') - .option('--json', 'Output JSON', false) - .action(async (opts: JsonCliOptions) => { - await runLifecycle(deps, opts.json === true, async (mgr) => { - const result = await mgr.stop(); - return { ok: result.ok, action: 'stop', message: result.message }; - }); - }); - - parent - .command('restart') - .description('Restart the Kimi server service.') - .option('--json', 'Output JSON', false) - .action(async (opts: JsonCliOptions) => { - await runLifecycle(deps, opts.json === true, async (mgr) => { - const result = await mgr.restart(); - const status = await readStatus(mgr); - return withStatusDetails({ ok: result.ok, action: 'restart', message: result.message }, status); - }); - }); - - parent - .command('status') - .description('Show Kimi server service status and connectivity.') - .option('--json', 'Output JSON', false) - .action(async (opts: JsonCliOptions) => { - await runLifecycle(deps, opts.json === true, async (mgr) => { - const status: ServiceStatus = await mgr.status(); - return withStatusDetails({ ok: true, action: 'status', ...status }, status); - }); - }); -} - -async function runLifecycle( - deps: LifecycleCommandDeps, - json: boolean, - body: (mgr: ServiceManager) => Promise>, -): Promise { - try { - const mgr = deps.resolveManager(); - const result = await body(mgr); - if (json) { - deps.stdout.write(`${JSON.stringify(result)}\n`); - return; - } - deps.stdout.write(formatHuman(result)); - } catch (error) { - if (error instanceof ServiceUnavailableError || error instanceof ServiceUnsupportedError) { - const payload = { - ok: false, - action: error instanceof ServiceUnavailableError ? 'unavailable' : 'unsupported', - platform: error.platform, - message: error.message, - }; - if (json) { - deps.stdout.write(`${JSON.stringify(payload)}\n`); - } else { - deps.stderr.write(`${error.message}\n`); - } - process.exit(2); - return; - } - if (json) { - deps.stdout.write( - `${JSON.stringify({ ok: false, message: error instanceof Error ? error.message : String(error) })}\n`, - ); - } else { - deps.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - } - process.exit(1); - } -} - -function formatHuman(result: Record): string { - const rawAction = result['action']; - const action = typeof rawAction === 'string' ? rawAction : 'action'; - const rawMessage = result['message']; - const message = typeof rawMessage === 'string' ? `: ${rawMessage}` : ''; - const lines = [`${action}${message}`]; - - const url = result['url']; - if (typeof url === 'string') lines.push(`URL: ${url}`); - - const running = result['running']; - if (typeof running === 'boolean') lines.push(`Status: ${running ? 'running' : 'not running'}`); - - const logPath = result['logPath']; - if (typeof logPath === 'string') lines.push(`Log: ${logPath}`); - - const notes = result['notes']; - if (Array.isArray(notes)) { - for (const note of notes) { - if (typeof note === 'string' && note.length > 0) lines.push(`Note: ${note}`); - } - } - - return `${lines.join('\n')}\n`; -} - -async function readStatus(mgr: ServiceManager): Promise { - try { - return await mgr.status(); - } catch { - return undefined; - } -} - -function withStatusDetails( - result: Record, - status: ServiceStatus | undefined, - fallback?: { host: string; port: number }, -): Record & { url?: string; running?: boolean } { - const host = status?.host ?? fallback?.host; - const port = status?.port ?? fallback?.port; - const url = host !== undefined && port !== undefined ? formatServiceUrl(host, port) : undefined; - return { - ...result, - url, - running: status?.running, - host, - port, - logPath: status?.logPath, - notes: status?.notes, - }; -} - -function formatServiceUrl(host: string, port: number): string { - return serverOrigin(host === '0.0.0.0' ? LOCAL_SERVER_HOST : host, port); -} diff --git a/apps/kimi-code/src/cli/sub/server/run.ts b/apps/kimi-code/src/cli/sub/server/run.ts deleted file mode 100644 index 1a59a46ad1..0000000000 --- a/apps/kimi-code/src/cli/sub/server/run.ts +++ /dev/null @@ -1,699 +0,0 @@ -/** - * `kimi server run` — starts the local server. - * - * By default this ensures a single background daemon is running (spawning a - * detached `kimi server run --daemon` child when needed) and returns once it is - * healthy. Pass `--foreground` to run the server in-process and keep this - * terminal attached until SIGINT/SIGTERM. OS-managed background operation - * (launchd / systemd / schtasks) lives in `kimi server install` + `kimi server start`. - * - * `kimi web` is an alias of this command (registered in `./web-alias.ts`) with - * two flipped defaults: `--open` defaults to `true`, and it runs in the - * foreground by default — pass `--background` to get the daemon behavior of - * `kimi server run`. - */ - -import { join } from 'node:path'; - -import { hostRequestHeadersSeed } from '@moonshot-ai/agent-core-v2'; -import { createServerLogger, startServer, type ServerLogger } from '@moonshot-ai/kap-server'; -import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry'; -import chalk from 'chalk'; -import { Option, type Command } from 'commander'; - -import { CLI_SHUTDOWN_TIMEOUT_MS } 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 { - buildKimiDefaultHeaders, - getHostPackageRoot, - getVersion, -} from '../../version'; -import { - accessUrlLines, - buildOpenableUrl, - isLoopbackHost, - splitTokenFragment, -} from './access-urls'; -import { ensureDaemon, findReusableDaemon, type EnsureDaemonResult } from './daemon'; -import { type NetworkAddress } from './networks'; -import { - DEFAULT_FOREGROUND_LOG_LEVEL, - DEFAULT_LAN_HOST, - DEFAULT_SERVER_HOST, - DEFAULT_SERVER_PORT, - parseServerOptions, - tryResolveServerToken, - VALID_LOG_LEVELS, - type ParsedServerOptions, - type ServerCliOptions, -} from './shared'; - -const WEB_ASSETS_DIR = 'dist-web'; - -/** - * Minimal surface `runServerInProcess` needs from the server. kap-server's - * `RunningServer` is adapted to it (it returns `{ host, port, close }` - * instead of `{ address, logger, close }`). - */ -interface RoutedServer { - readonly address: string; - readonly logger: ServerLogger; - close(): Promise; -} - -export interface RunCliOptions extends ServerCliOptions { - open?: boolean; - /** Run the server in-process instead of spawning a background daemon. */ - foreground?: boolean; - /** - * Run as a background daemon and return once healthy. Only registered on - * `kimi web` (where foreground is the default); `kimi server run` has no - * such flag because background is already its default. - */ - background?: boolean; -} - -export interface StartForegroundHooks { - /** Fires once the server is listening, before the foreground runner blocks. */ - onReady?: (origin: string) => void; -} - -export interface RunCommandDeps { - 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; - /** CLI version that started the reused server (from its lock), if recorded. */ - hostVersion?: string; - }>; - /** Foreground runner; defaults to the real in-process runner when omitted. */ - startServerForeground?: ( - options: ParsedServerOptions, - hooks?: StartForegroundHooks, - ) => Promise; - /** - * Probe for an already-live, healthy daemon. Used by foreground-mode - * `kimi web` to reuse a running server instead of failing to bind its port. - * Defaults to the real lock-based probe when omitted. - */ - findReusableDaemon?: () => Promise; - 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; - stderr: Pick; -} - -/** - * Build the Web UI URL, carrying the bearer token in the URL fragment. - * - * The token rides in `#token=` — a client-side fragment that is never - * sent to the server (so it never appears in server access logs) and is not - * logged by proxies. The Web UI reads it from `location.hash` after load. - */ -export function buildWebUrl(origin: string, token: string): string { - return buildOpenableUrl(origin, token); -} - -/** Build the `run` subcommand, mounted under a parent (`server` or top-level). */ -export function buildRunCommand( - cmd: Command, - options: { defaultOpen: boolean; defaultForeground?: boolean }, -): Command { - const defaultForeground = options.defaultForeground === true; - cmd - .option( - '--port ', - `Bind port (default ${DEFAULT_SERVER_PORT})`, - String(DEFAULT_SERVER_PORT), - ) - .option( - '--host [host]', - `Bind host. Omit to bind ${DEFAULT_SERVER_HOST} (this machine only); pass --host to bind ${DEFAULT_LAN_HOST} (all interfaces), or --host for a specific host. The bearer token is printed at startup.`, - ) - .option( - '--allowed-host ', - 'Extra Host header value to allow through the DNS-rebinding check. Repeat or comma-separate; a leading dot matches a domain suffix (e.g. .example.com).', - ) - .option( - '--keep-alive', - 'Keep the server running instead of exiting after 60s with no connected clients. Implied automatically by --host / --allowed-host, and always on in --foreground mode.', - false, - ) - .option( - '--insecure-no-tls', - 'Allow a non-loopback bind without a TLS-terminating reverse proxy. Defaults to true; only relevant for non-loopback binds.', - true, - ) - .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( - '--dangerous-bypass-auth', - 'Disable bearer-token auth on every REST and WebSocket route, and advertise it via /api/v1/meta so the web UI connects without a token. Only use on a trusted network or behind your own authenticating proxy.', - false, - ) - .option( - '--log-level ', - `Server log level: ${VALID_LOG_LEVELS.join('|')}. Omit to keep logs off.`, - ) - .option( - '--debug-endpoints', - 'Mount /api/v1/debug/* routes for test introspection. OFF by default; production callers leave this unset.', - false, - ) - .option( - '--foreground', - defaultForeground - ? 'Run the server in the foreground and keep this terminal attached until SIGINT/SIGTERM (default; pass --background to run as a daemon instead).' - : 'Run the server in the foreground and keep this terminal attached until SIGINT/SIGTERM (do not daemonize).', - false, - ); - if (defaultForeground) { - cmd.option( - '--background', - 'Run the server as a background daemon and return once it is healthy, releasing this terminal.', - false, - ); - } - return cmd - .option( - options.defaultOpen ? '--no-open' : '--open', - options.defaultOpen - ? 'Do not open the web UI in the default browser.' - : 'Open the web UI in the default browser once the server is healthy.', - options.defaultOpen, - ) - .addOption( - new Option('--daemon', 'Run as an idle-exiting background daemon (internal).').hideHelp(), - ) - .addOption( - new Option( - '--idle-grace-ms ', - 'Idle-shutdown grace in ms (daemon mode, internal).', - ).hideHelp(), - ) - .action(async (opts: RunCliOptions) => { - try { - await handleRunCommand(opts, undefined, { defaultForeground }); - } catch (error) { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exit(1); - } - }); -} - -export interface RunCommandConfig { - /** - * True for the `kimi web` alias: foreground becomes the default and - * `--background` opts back into the daemon. `kimi server run` leaves this - * false, keeping its background default and plain `--foreground` semantics. - */ - defaultForeground?: boolean; -} - -export async function handleRunCommand( - opts: RunCliOptions, - deps: RunCommandDeps = DEFAULT_RUN_COMMAND_DEPS, - config: RunCommandConfig = {}, -): Promise { - const parsed = parseServerOptions(opts); - if (parsed.daemon) { - await startServerDaemon(parsed); - return; - } - const foreground = - opts.foreground === true || (config.defaultForeground === true && opts.background !== true); - // Foreground is always keep-alive: a server attached to the operator's - // terminal must never idle-kill itself. Background daemons respect the - // derived `--keep-alive` flag. - const runOptions: ParsedServerOptions = foreground ? { ...parsed, keepAlive: true } : parsed; - // 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. When auth is bypassed, - // the token is meaningless and is intentionally NOT shown or carried in the - // opened URL. - const writeReady = (result: { - origin: string; - reused?: boolean; - host?: string; - hostVersion?: string; - }): void => { - const { origin } = result; - const host = result.host ?? parsed.host; - // When a daemon is reused, this command's flags were NOT applied to the - // already-running server. Don't trust the requested `--dangerous-bypass-auth` - // for display/open: treat the server as token-protected so we never hide a - // token the user actually needs, nor claim bypass for a server that is - // authenticating. (Probing the running server's `/meta` would give its real - // mode; we conservatively assume non-bypass on reuse.) - const effectiveBypass = result.reused === true ? false : parsed.dangerousBypassAuth; - const token = effectiveBypass ? undefined : deps.resolveToken?.(); - // True only when this process actually hosts the server: it stops with - // Ctrl+C. A reused daemon lives elsewhere and still needs `server kill`. - const attachedForeground = foreground && result.reused !== true; - 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); - // The reused server may predate an upgrade of this CLI: it keeps - // serving its own bundled web UI / API, so surface the mismatch. - if (result.hostVersion !== undefined && result.hostVersion !== getVersion()) { - output += formatServerUpgradeNotice(result.hostVersion); - } - } - output += - parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL - ? formatReadyBanner(origin, host, { - token, - networkAddresses: deps.networkAddresses, - dangerousBypassAuth: effectiveBypass, - foreground: attachedForeground, - }) - : formatReadyLine(origin, token, effectiveBypass, attachedForeground); - deps.stdout.write(output); - if (opts.open === true) { - deps.openUrl(token !== undefined ? buildWebUrl(origin, token) : origin); - } - }; - if (foreground) { - if (config.defaultForeground === true) { - // `kimi web` defaults to foreground, but binding the port while a server - // is already running would fail — reuse it the way the daemon path does: - // print the reuse notice and open the browser, then let this command exit. - const probe = deps.findReusableDaemon ?? findReusableDaemon; - const existing = await probe(); - if (existing !== undefined) { - writeReady({ - origin: existing.origin, - reused: true, - host: existing.host, - hostVersion: existing.hostVersion, - }); - return; - } - } - const run = deps.startServerForeground ?? startServerForeground; - await run(runOptions, { - onReady: (origin) => { - writeReady({ origin, reused: false, host: parsed.host }); - }, - }); - return; - } - const result = await deps.startServerBackground(runOptions); - 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` - ); -} - -/** - * Shown after the reuse notice when the running server was started by a - * different CLI version: it keeps serving its own bundled web UI/API, so the - * user may want to restart it onto the version they just installed. - */ -function formatServerUpgradeNotice(runningVersion: string): string { - return ( - `${chalk.hex(darkColors.warning)('Server version mismatch')}: the running server is ` + - `${runningVersion}, this CLI is ${getVersion()} — restarting picks up the new version.\n` - ); -} - -function formatReadyLine( - origin: string, - token: string | undefined, - dangerousBypassAuth = false, - foreground = false, -): string { - const notice = dangerousBypassAuth - ? `${formatDangerNoticeLines({ foreground }).join('\n')}\n` - : ''; - return `${notice}Kimi server: ${buildOpenableUrl(origin, token)}\n`; -} - -/** - * Red, impossible-to-miss notice emitted when `--dangerous-bypass-auth` - * disables the bearer-token gate. Shared by the full ready banner and the - * compact one-line output so the warning always shows regardless of log level. - */ -function formatDangerNoticeLines(opts: { foreground?: boolean } = {}): string[] { - const danger = (text: string): string => chalk.hex(darkColors.error)(text); - const dangerBold = (text: string): string => chalk.bold.hex(darkColors.error)(text); - // A foreground server stops with Ctrl+C; only a background daemon needs the - // separate `kimi server kill` command. - const stopAction = opts.foreground === true ? 'press Ctrl+C' : 'run kimi server kill'; - return [ - ` ${dangerBold('⚠ DANGER: authentication is DISABLED (--dangerous-bypass-auth).')}`, - ` ${danger('Anyone who can reach this port gets full access. Only continue if you understand the risk.')}`, - ` ${danger('If you are unsure, ')}${dangerBold(stopAction)}${danger(' now to stop this process.')}`, - ]; -} - -/** - * `kimi server run` (non-daemon) — ensures a background daemon is running - * (spawning a detached `kimi server run --daemon` child if needed), then - * returns its origin so the caller can print the ready banner and exit. The - * server keeps running in the background after this returns. - */ -export async function startServerBackground( - options: ParsedServerOptions, -): Promise { - return ensureDaemon({ - host: options.host, - port: options.port, - logLevel: options.logLevel, - debugEndpoints: options.debugEndpoints, - insecureNoTls: options.insecureNoTls, - allowRemoteShutdown: options.allowRemoteShutdown, - allowRemoteTerminals: options.allowRemoteTerminals, - dangerousBypassAuth: options.dangerousBypassAuth, - keepAlive: options.keepAlive, - allowedHosts: options.allowedHosts, - idleGraceMs: options.idleGraceMs, - }); -} - -/** - * `kimi server run --daemon` — runs the local server as a background daemon. - * - * Spawned as a detached child by {@link startServerBackground}. The process is - * expected to be detached (no controlling terminal) and self-terminates after - * the last web client disconnects and a grace period elapses. The grace timer - * is driven by the WS connection count reported through `wsGatewayOptions`. - * Resolves only via `process.exit`. - */ -export async function startServerDaemon(options: ParsedServerOptions): Promise { - return runServerInProcess(options, { daemon: true }); -} - -/** - * `kimi server run --foreground` — runs the local server in-process, attached - * to the current terminal. Resolves only via `process.exit` (SIGINT/SIGTERM). - */ -export async function startServerForeground( - options: ParsedServerOptions, - hooks: StartForegroundHooks = {}, -): Promise { - return runServerInProcess(options, { daemon: false }, hooks.onReady); -} - -/** - * Start the server in the current process and block until shutdown. Shared by - * the detached daemon (`daemon: true`, with idle-exit) and the foreground - * runner (`daemon: false`). `onReady` fires once the server is listening. - */ -async function runServerInProcess( - options: ParsedServerOptions, - mode: { daemon: boolean }, - onReady?: (origin: string) => void, -): Promise { - const version = getVersion(); - // Registers the telemetry provider for `track` / `shutdownTelemetry`; the - // client itself is not passed into kap-server. - initializeServerTelemetry({ version }); - - let running: RoutedServer | undefined; - let stopping = false; - - // Idle auto-shutdown is only for the on-demand personal daemon. It is skipped - // in foreground mode (`mode.daemon` is false) and whenever `--keep-alive` is - // set — explicitly, or implied by `--host` / `--allowed-host`. - const idle = - mode.daemon && !options.keepAlive - ? createIdleShutdownHandler({ - graceMs: options.idleGraceMs, - onIdle: () => { - void shutdown('idle'); - }, - }) - : undefined; - - async function shutdown(reason: string): Promise { - if (stopping) return; - stopping = true; - idle?.cancel(); - running?.logger.info({ reason }, 'server shutting down'); - try { - await running?.close(); - await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); - } catch (error) { - running?.logger.error( - { err: error instanceof Error ? error : new Error(String(error)) }, - 'server shutdown error', - ); - } - process.exit(0); - } - - // kap-server (the DI × Scope engine server) is the only server flavor. Its - // `startServer` returns `{ host, port, close }` rather than `{ address, - // logger, close }`, so adapt it to the `RoutedServer` surface the rest of - // this runner consumes. - const logger = createServerLogger({ level: options.logLevel }); - const v2 = await startServer({ - host: options.host, - port: options.port, - // Report the CLI's product version as `server_version` (/meta, web UI) - // rather than kap-server's private package version. - version, - logLevel: options.logLevel, - logger, - debugEndpoints: options.debugEndpoints, - insecureNoTls: options.insecureNoTls, - allowRemoteShutdown: options.allowRemoteShutdown, - allowRemoteTerminals: options.allowRemoteTerminals, - allowedHosts: options.allowedHosts, - disableAuth: options.dangerousBypassAuth, - // Seed the CLI's Kimi identity headers so the engine's outbound - // requests (model, WebSearch, FetchURL) carry the same User-Agent + - // X-Msh-* identity as direct CLI runs. - seeds: hostRequestHeadersSeed(buildKimiDefaultHeaders(version)), - webAssetsDir: serverWebAssetsDir(), - }); - // The connection registry exposes no count-change hook, so forward - // add/remove to the daemon's idle-shutdown handler (a no-op when `idle` - // is undefined, e.g. foreground or --keep-alive). - if (idle !== undefined) { - const registry = v2.connectionRegistry; - const add = registry.add.bind(registry); - const remove = registry.remove.bind(registry); - registry.add = (conn) => { - add(conn); - idle.onConnectionCountChange(registry.size()); - }; - registry.remove = (connId) => { - remove(connId); - idle.onConnectionCountChange(registry.size()); - }; - } - logger.info('serving the REST/WS API and the bundled web UI'); - running = { - address: `http://${v2.host}:${v2.port}`, - logger, - close: () => v2.close(), - }; - - track('server_started', { daemon: mode.daemon }); - - process.once('SIGINT', () => { - void shutdown('SIGINT'); - }); - process.once('SIGTERM', () => { - void shutdown('SIGTERM'); - }); - - const readyFields = mode.daemon - ? options.keepAlive - ? { address: running.address, idleShutdown: 'disabled' as const } - : { address: running.address, idleGraceMs: options.idleGraceMs } - : { address: running.address }; - running.logger.info(readyFields, mode.daemon ? 'daemon ready' : 'server ready'); - - onReady?.(running.address); - - return new Promise(() => { - // Keeps the event loop alive; the process ends via shutdown()/process.exit. - }); -} - -/** - * Pure idle-shutdown state machine, exported for tests. - * - * Watches the live WS connection count and fires `onIdle` exactly once, after - * the count has dropped back to zero for `graceMs` ms *and* at least one - * client had connected since startup. A reconnect before the grace elapses - * cancels the pending exit. The initial "no clients yet" state never arms the - * timer (so a freshly-spawned daemon is not killed before anyone connects). - */ -export function createIdleShutdownHandler(opts: { graceMs: number; onIdle: () => void }): { - onConnectionCountChange(size: number): void; - cancel(): void; -} { - let timer: NodeJS.Timeout | undefined; - let seenClient = false; - - const cancel = (): void => { - if (timer !== undefined) { - clearTimeout(timer); - timer = undefined; - } - }; - - return { - onConnectionCountChange(size: number): void { - if (size > 0) { - seenClient = true; - cancel(); - return; - } - if (seenClient) { - cancel(); - timer = setTimeout(opts.onIdle, opts.graceMs); - } - }, - cancel, - }; -} - -function serverWebAssetsDir(): string { - return resolveServerWebAssetsDir(); -} - -export function resolveServerWebAssetsDir( - nativeWebAssetsDir: string | null = getNativeWebAssetsDir(), -): string { - return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR); -} - -interface FormatReadyBannerOptions { - /** Persistent bearer token to print; omitted when unresolvable. */ - token?: string; - /** Non-loopback interface addresses to list for a wildcard bind. */ - networkAddresses?: NetworkAddress[]; - /** When true, render a red danger notice (auth is disabled). */ - dangerousBypassAuth?: boolean; - /** - * True when this process hosts the server attached to the terminal: the - * Stop hint becomes Ctrl+C instead of `kimi server kill`. - */ - foreground?: boolean; -} - -/** - * Render the ready banner shown when a server starts (or is reused) with the - * default log level. Exported for `/web` in the TUI, which prints the same - * banner when it hands the terminal over to a foreground server. - */ -export 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 = (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 lines: string[] = [ - '', - ` ${primary(logo[0])} ${title('Kimi server ready')} ${dim(getVersion())}`, - ` ${primary(logo[1])} ${dim('Local web UI is available from this machine.')}`, - '', - ]; - - if (opts.dangerousBypassAuth === true) { - // Red, impossible-to-miss notice: the bearer-token gate is off, so anyone - // who can reach this port gets full session / filesystem / shell access. - lines.push(...formatDangerNoticeLines({ foreground: opts.foreground === true }), ''); - } - - // 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 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(''); - } - - // Auxiliary controls last. A foreground server is stopped by interrupting - // the terminal; only a detached daemon needs the `kill` subcommand. - const stopHint = opts.foreground === true ? 'Ctrl+C' : 'kimi server kill'; - lines.push(` ${label('Logs: ')}${muted('off')}${dim(' use --log-level info to enable')}`); - lines.push(` ${label('Stop: ')}${muted(stopHint)}`); - lines.push(''); - return lines.join('\n'); -} - -const DEFAULT_RUN_COMMAND_DEPS: RunCommandDeps = { - startServerBackground, - startServerForeground, - openUrl: defaultOpenUrl, - resolveToken: () => { - // Read the persistent `/server.token` written on first boot - // (M5.1). Best-effort: a missing/older server yields undefined and the - // caller opens the plain origin. - return tryResolveServerToken(getDataDir()); - }, - stdout: process.stdout, - stderr: process.stderr, -}; diff --git a/apps/kimi-code/src/cli/sub/server/web-alias.ts b/apps/kimi-code/src/cli/sub/server/web-alias.ts deleted file mode 100644 index fa118bdefc..0000000000 --- a/apps/kimi-code/src/cli/sub/server/web-alias.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * `kimi web` — open the Kimi web UI. - * - * Shares the exact same code path as `kimi server run`: it is registered via - * the same `buildRunCommand` builder (and therefore the same `handleRunCommand` - * handler and the same ready banner) with two flipped defaults — `defaultOpen` - * opens the browser, and `defaultForeground` runs the server in the foreground - * (this terminal stays attached until Ctrl+C) instead of backgrounding a - * daemon. `--background` opts back into the daemon behavior of `server run`. - */ - -import type { Command } from 'commander'; - -import { buildRunCommand } from './run'; - -export function registerWebAliasCommand(program: Command): void { - buildRunCommand( - program - .command('web') - .description('Open the Kimi web UI (runs the server in the foreground by default).'), - { defaultOpen: true, defaultForeground: true }, - ); -} diff --git a/apps/kimi-code/src/cli/sub/server/access-urls.ts b/apps/kimi-code/src/cli/sub/web/access-urls.ts similarity index 97% rename from apps/kimi-code/src/cli/sub/server/access-urls.ts rename to apps/kimi-code/src/cli/sub/web/access-urls.ts index 0c6233fe8d..f0edbf9092 100644 --- a/apps/kimi-code/src/cli/sub/server/access-urls.ts +++ b/apps/kimi-code/src/cli/sub/web/access-urls.ts @@ -1,7 +1,7 @@ /** * Build the clickable/copyable access URLs for the running server. * - * Shared by the `server run` ready banner and `server rotate-token` so both + * Shared by the `kimi web` ready banner and `kimi web 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. diff --git a/apps/kimi-code/src/cli/sub/web/deprecated-server.ts b/apps/kimi-code/src/cli/sub/web/deprecated-server.ts new file mode 100644 index 0000000000..21818d9837 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/web/deprecated-server.ts @@ -0,0 +1,30 @@ +/** + * Deprecated `kimi server` shim. + * + * The `kimi server` command tree was replaced by `kimi web` (foreground + * server plus the kill/ps/rotate-token management subcommands). Any + * `kimi server …` invocation — bare or with any subcommand/flags — lands + * here, prints the deprecation notice, and exits 1. The shim itself is + * scheduled for removal in the next major version of Kimi Code. + */ + +import type { Command } from 'commander'; + +export const DEPRECATED_SERVER_NOTICE = + '`kimi server` has been deprecated and no longer works.\n' + + 'Use `kimi web` instead — it runs the local server in the foreground and opens the web UI (`--no-open` to skip).\n' + + 'This notice will be removed in the next major version of Kimi Code.\n'; + +export function registerDeprecatedServerCommand(program: Command): void { + program + .command('server') + .description('Deprecated — use `kimi web` instead.') + // Swallow every legacy subcommand/flag (`run`, `kill`, `--port`, …) so + // they all land in the same notice instead of a commander parse error. + .allowUnknownOption(true) + .allowExcessArguments(true) + .action(() => { + process.stderr.write(DEPRECATED_SERVER_NOTICE); + process.exit(1); + }); +} diff --git a/apps/kimi-code/src/cli/sub/web/index.ts b/apps/kimi-code/src/cli/sub/web/index.ts new file mode 100644 index 0000000000..9099d72a9e --- /dev/null +++ b/apps/kimi-code/src/cli/sub/web/index.ts @@ -0,0 +1,32 @@ +/** + * `kimi web` — run the local Kimi server (REST + WebSocket + web UI) in the + * foreground and open the web UI in the default browser. + * + * The command itself is the runner (`kimi web` = start the server + open the + * browser; `--no-open` to skip). Management subcommands work off the instance + * registry (`~/.kimi-code/server/instances/`), so they see every instance + * sharing this home directory: + * - `web kill [serverId]` — stop an instance (default: the longest-running) + * - `web ps` — list connected clients per instance + * - `web rotate-token` — rotate the home-wide bearer token + */ + +import type { Command } from 'commander'; + +import { registerDeprecatedServerCommand } from './deprecated-server'; +import { registerKillCommand } from './kill'; +import { registerPsCommand } from './ps'; +import { registerRotateTokenCommand } from './rotate-token'; +import { buildWebCommand } from './run'; + +export function registerWebCommand(program: Command): void { + const web = buildWebCommand( + program + .command('web') + .description('Run the local Kimi server and open the web UI.'), + ); + registerKillCommand(web); + registerPsCommand(web); + registerRotateTokenCommand(web); + registerDeprecatedServerCommand(program); +} diff --git a/apps/kimi-code/src/cli/sub/server/kill.ts b/apps/kimi-code/src/cli/sub/web/kill.ts similarity index 55% rename from apps/kimi-code/src/cli/sub/server/kill.ts rename to apps/kimi-code/src/cli/sub/web/kill.ts index 92e5f00b0b..4b76a31ee0 100644 --- a/apps/kimi-code/src/cli/sub/server/kill.ts +++ b/apps/kimi-code/src/cli/sub/web/kill.ts @@ -1,27 +1,30 @@ /** - * `kimi server kill` — terminate the running server. + * `kimi web kill [serverId|all]` — terminate running servers. * * Combines two independent mechanisms so the server dies even if one path * fails: * * 1. API path — `POST /api/v1/shutdown` for a graceful, in-process shutdown * (best-effort; older builds or a wedged server may not answer). - * 2. PID path — signal the pid recorded in the lock (SIGTERM → wait → - * SIGKILL). SIGKILL / TerminateProcess is the hard guarantee: - * it cannot be caught or ignored. + * 2. PID path — signal the pid recorded in the instance registry (SIGTERM → + * wait → SIGKILL). SIGKILL / TerminateProcess is the hard + * guarantee: it cannot be caught or ignored. * - * The only honest failure mode is insufficient permissions (a process owned by + * With multiple servers sharing the home directory, the optional argument + * picks the target: a `serverId` kills that one instance, the reserved + * keyword `all` kills every live instance (a ULID can never collide with + * it), and no argument kills the longest-running live instance. The only + * honest failure mode is insufficient permissions (a process owned by * another user), which surfaces as an error rather than a silent miss. */ import type { Command } from 'commander'; -import { getLiveLock, type LockContents } from '@moonshot-ai/kap-server'; +import { listLiveServerInstances, type ServerInstanceInfo } from '@moonshot-ai/kap-server'; import { getDataDir } from '#/utils/paths'; -import { lockConnectHost } from './daemon'; -import { authHeaders, serverOrigin, tryResolveServerToken } from './shared'; +import { authHeaders, instanceConnectHost, serverOrigin, tryResolveServerToken } from './shared'; /** How long to wait for the graceful API shutdown request. */ const API_TIMEOUT_MS = 2000; @@ -32,8 +35,14 @@ const KILL_GRACE_MS = 2000; /** Poll cadence while waiting for the pid to exit. */ const POLL_INTERVAL_MS = 100; +/** + * Reserved positional that targets every live instance. Server ids are ULIDs + * (26-char Crockford base32), so the keyword can never shadow a real id. + */ +export const KILL_ALL_KEYWORD = 'all'; + export interface KillCommandDeps { - getLiveLock(): LockContents | undefined; + getLiveInstances(): Promise; requestShutdown(origin: string, token: string | undefined): Promise; /** Best-effort read of the persistent bearer token; undefined on miss. */ resolveToken(): string | undefined; @@ -47,10 +56,14 @@ export interface KillCommandDeps { export function registerKillCommand(server: Command): void { server .command('kill') - .description('Stop the running Kimi server (graceful API + forced PID kill).') - .action(async () => { + .description('Stop a running Kimi server (graceful API + forced PID kill).') + .argument( + '[serverId]', + 'Stop only the instance with this server id, or `all` for every instance (default: the longest-running one).', + ) + .action(async (serverId?: string) => { try { - await handleKillCommand(DEFAULT_KILL_DEPS); + await handleKillCommand(DEFAULT_KILL_DEPS, serverId); } catch (error) { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exit(1); @@ -58,15 +71,64 @@ export function registerKillCommand(server: Command): void { }); } -export async function handleKillCommand(deps: KillCommandDeps): Promise { - const lock = deps.getLiveLock(); - if (!lock) { +export async function handleKillCommand( + deps: KillCommandDeps, + serverId?: string, +): Promise { + const instances = await deps.getLiveInstances(); + // The registry sorts by startedAt ascending — the first entry is the + // longest-running instance, the default target. + const [longestRunning] = instances; + if (longestRunning === undefined) { deps.stdout.write('No running Kimi server.\n'); return; } - const { pid } = lock; - const origin = serverOrigin(lockConnectHost(lock), lock.port); + if (serverId === KILL_ALL_KEYWORD) { + const failures: string[] = []; + for (const instance of instances) { + try { + const outcome = await killInstance(instance, deps); + deps.stdout.write( + `Kimi server ${instance.serverId} (pid ${String(instance.pid)}) ${outcome}.\n`, + ); + } catch (error) { + failures.push( + `server ${instance.serverId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + if (failures.length > 0) { + throw new Error(failures.join('\n')); + } + return; + } + + let instance = longestRunning; + if (serverId !== undefined) { + const found = instances.find((i) => i.serverId === serverId); + if (found === undefined) { + const live = instances.map((i) => i.serverId).join(', '); + throw new Error(`No running Kimi server with id ${serverId}. Live servers: ${live}.`); + } + instance = found; + } + + const outcome = await killInstance(instance, deps); + deps.stdout.write(`Kimi server (pid ${String(instance.pid)}) ${outcome}.\n`); +} + +/** + * Kill one instance via the API path (best-effort graceful shutdown) followed + * by the PID path (SIGTERM → wait → SIGKILL). Resolves with how the process + * went down; throws when the pid survives SIGKILL. + */ +async function killInstance( + instance: ServerInstanceInfo, + deps: KillCommandDeps, +): Promise<'stopped' | 'killed'> { + const { pid } = instance; + const origin = serverOrigin(instanceConnectHost(instance), instance.port); // 1. API path — best-effort graceful shutdown. Ignore every outcome: the // server may be an older build without the route, already wedged, or may @@ -80,15 +142,13 @@ export async function handleKillCommand(deps: KillCommandDeps): Promise { deps.signalPid(pid, 'SIGTERM'); if (await waitForExit(pid, TERM_GRACE_MS, deps)) { - deps.stdout.write(`Kimi server (pid ${String(pid)}) stopped.\n`); - return; + return 'stopped'; } deps.signalPid(pid, 'SIGKILL'); if (await waitForExit(pid, KILL_GRACE_MS, deps)) { - deps.stdout.write(`Kimi server (pid ${String(pid)}) killed.\n`); - return; + return 'killed'; } throw new Error( @@ -153,7 +213,7 @@ export async function requestShutdownViaApi( } const DEFAULT_KILL_DEPS: KillCommandDeps = { - getLiveLock, + getLiveInstances: listLiveServerInstances, requestShutdown: requestShutdownViaApi, resolveToken: () => tryResolveServerToken(getDataDir()), signalPid, diff --git a/apps/kimi-code/src/cli/sub/server/networks.ts b/apps/kimi-code/src/cli/sub/web/networks.ts similarity index 100% rename from apps/kimi-code/src/cli/sub/server/networks.ts rename to apps/kimi-code/src/cli/sub/web/networks.ts diff --git a/apps/kimi-code/src/cli/sub/server/ps.ts b/apps/kimi-code/src/cli/sub/web/ps.ts similarity index 54% rename from apps/kimi-code/src/cli/sub/server/ps.ts rename to apps/kimi-code/src/cli/sub/web/ps.ts index 33933e7cd2..b1a8f70049 100644 --- a/apps/kimi-code/src/cli/sub/server/ps.ts +++ b/apps/kimi-code/src/cli/sub/web/ps.ts @@ -1,20 +1,21 @@ /** - * `kimi server ps` — list clients currently connected to the running server. + * `kimi web ps` — list clients currently connected to the running servers. * - * Talks to the running server over HTTP (`GET /api/v1/connections`) using the - * single-instance lock (`~/.kimi-code/server/lock`) to discover its origin — - * the same way `kimi web` locates the daemon. + * Talks to every live server over HTTP (`GET /api/v1/connections`) using the + * instance registry (`~/.kimi-code/server/instances/`) to discover origins, + * and prints one section per server id. The bearer token is home-wide, so one + * token reaches every instance. An unreachable instance degrades to a + * per-server note instead of failing the whole listing. */ import chalk from 'chalk'; import type { Command } from 'commander'; -import { getLiveLock } from '@moonshot-ai/kap-server'; +import { listLiveServerInstances, type ServerInstanceInfo } from '@moonshot-ai/kap-server'; import { getDataDir } from '#/utils/paths'; -import { lockConnectHost } from './daemon'; -import { authHeaders, isServerHealthy, resolveServerToken, serverOrigin } from './shared'; +import { authHeaders, instanceConnectHost, isServerHealthy, resolveServerToken, serverOrigin } from './shared'; /** Wire shape of a single connection returned by `GET /api/v1/connections`. */ interface ConnectionInfo { @@ -39,8 +40,8 @@ const USER_AGENT_MAX_WIDTH = 40; export function registerPsCommand(server: Command): void { server .command('ps') - .description('List clients currently connected to the running Kimi server.') - .option('--json', 'Print the raw connection list as JSON.') + .description('List clients currently connected to each running Kimi server.') + .option('--json', 'Print the raw per-server connection lists as JSON.') .action(async (opts: { json?: boolean }) => { try { await handlePsCommand(opts); @@ -51,30 +52,79 @@ export function registerPsCommand(server: Command): void { }); } +/** One instance's listing outcome: its connections, or why they could not be fetched. */ +interface ServerConnections { + instance: ServerInstanceInfo; + origin: string; + connections?: ConnectionInfo[]; + error?: string; +} + async function handlePsCommand(opts: { json?: boolean }): Promise { - const lock = getLiveLock(); - if (!lock) { + const instances = await listLiveServerInstances(); + if (instances.length === 0) { throw new Error( - 'No running Kimi server. Start one with `kimi server run` or `kimi web`.', + 'No running Kimi server. Start one with `kimi web`.', ); } - const origin = serverOrigin(lockConnectHost(lock), lock.port); - if (!(await isServerHealthy(origin, HEALTH_TIMEOUT_MS))) { - throw new Error(`Kimi server at ${origin} is not responding.`); - } - - // 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. + // The `/api/v1/connections` route is gated by bearer auth (M5.1); the + // persistent token is home-wide, so one read reaches every instance. 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); + + const sections: ServerConnections[] = []; + for (const instance of instances) { + const origin = serverOrigin(instanceConnectHost(instance), instance.port); + if (!(await isServerHealthy(origin, HEALTH_TIMEOUT_MS))) { + sections.push({ instance, origin, error: 'server is not responding' }); + continue; + } + try { + sections.push({ instance, origin, connections: await fetchConnections(origin, token) }); + } catch (error) { + sections.push({ + instance, + origin, + error: error instanceof Error ? error.message : String(error), + }); + } + } if (opts.json) { - process.stdout.write(`${JSON.stringify(connections, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ servers: sections.map(toJsonSection) }, null, 2)}\n`); return; } - process.stdout.write(formatTable(connections)); + process.stdout.write(formatSections(sections)); +} + +function toJsonSection(section: ServerConnections): Record { + const base: Record = { + server_id: section.instance.serverId, + pid: section.instance.pid, + host: section.instance.host, + port: section.instance.port, + origin: section.origin, + }; + if (section.error !== undefined) { + return { ...base, error: section.error }; + } + return { ...base, connections: section.connections ?? [] }; +} + +function formatSections(sections: ServerConnections[]): string { + return ( + sections + .map((section) => { + const header = `server ${section.instance.serverId} (pid ${String(section.instance.pid)}, ${section.origin})`; + if (section.error !== undefined) { + return `${header}\n${section.error}\n`; + } + return `${header}\n${formatTable(section.connections ?? [])}`; + }) + .join('\n') + ); } async function fetchConnections(origin: string, token: string): Promise { diff --git a/apps/kimi-code/src/cli/sub/server/rotate-token.ts b/apps/kimi-code/src/cli/sub/web/rotate-token.ts similarity index 79% rename from apps/kimi-code/src/cli/sub/server/rotate-token.ts rename to apps/kimi-code/src/cli/sub/web/rotate-token.ts index 0acbdcc7c5..eeaa63016b 100644 --- a/apps/kimi-code/src/cli/sub/server/rotate-token.ts +++ b/apps/kimi-code/src/cli/sub/web/rotate-token.ts @@ -1,12 +1,12 @@ /** - * `kimi server rotate-token` — generate a new persistent server token. + * `kimi web rotate-token` — generate a new persistent server token. * * Rewrites `/server.token` (0600, atomic). The previous token * stops working immediately: a running server re-reads the file on its next * auth check, so rotation takes effect without a restart. */ -import { getLiveLock, rotateServerToken } from '@moonshot-ai/kap-server'; +import { getLiveServerInstance, rotateServerToken } from '@moonshot-ai/kap-server'; import chalk from 'chalk'; import type { Command } from 'commander'; @@ -14,7 +14,6 @@ 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 @@ -35,11 +34,11 @@ export function registerRotateTokenCommand(server: Command): void { // 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)) { + // come from the instance registry; otherwise there is nothing to + // connect to yet. + const instance = await getLiveServerInstance(); + if (instance !== undefined) { + for (const { label, url: href } of accessUrlLines(instance.host, instance.port, token)) { // De-emphasize the `#token=…` fragment so the host/port stands out. const [base, frag] = splitTokenFragment(href); const rendered = diff --git a/apps/kimi-code/src/cli/sub/web/run.ts b/apps/kimi-code/src/cli/sub/web/run.ts new file mode 100644 index 0000000000..8898835ea4 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/web/run.ts @@ -0,0 +1,407 @@ +/** + * `kimi web` — run the local server in the foreground and open the web UI. + * + * The server always runs in the current process, attached to the terminal, + * and shuts down cleanly on SIGINT/SIGTERM. `--no-open` skips the browser. + * Multiple instances can share the home directory: each registers itself in + * the instance registry and takes the next free port (see kap-server's + * `startServer`). + */ + +import { join } from 'node:path'; + +import { hostRequestHeadersSeed } from '@moonshot-ai/agent-core-v2'; +import { createServerLogger, startServer, type ServerLogger } from '@moonshot-ai/kap-server'; +import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry'; +import chalk from 'chalk'; +import { type Command } from 'commander'; + +import { CLI_SHUTDOWN_TIMEOUT_MS } 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 { + buildKimiDefaultHeaders, + getHostPackageRoot, + getVersion, +} from '../../version'; +import { + accessUrlLines, + buildOpenableUrl, + isLoopbackHost, + splitTokenFragment, +} from './access-urls'; +import { type NetworkAddress } from './networks'; +import { + DEFAULT_FOREGROUND_LOG_LEVEL, + DEFAULT_LAN_HOST, + DEFAULT_SERVER_HOST, + DEFAULT_SERVER_PORT, + parseServerOptions, + tryResolveServerToken, + VALID_LOG_LEVELS, + type ParsedServerOptions, + type ServerCliOptions, +} from './shared'; + +const WEB_ASSETS_DIR = 'dist-web'; + +/** + * Minimal surface `runServerInProcess` needs from the server. kap-server's + * `RunningServer` is adapted to it (it returns `{ host, port, close }` + * instead of `{ address, logger, close }`). + */ +interface RoutedServer { + readonly address: string; + readonly logger: ServerLogger; + close(): Promise; +} + +export interface WebCliOptions extends ServerCliOptions { + open?: boolean; +} + +export interface StartForegroundHooks { + /** Fires once the server is listening, before the foreground runner blocks. */ + onReady?: (origin: string) => void; +} + +export interface WebCommandDeps { + /** Foreground runner; defaults to the real in-process runner when omitted. */ + startServerForeground?: ( + options: ParsedServerOptions, + hooks?: StartForegroundHooks, + ) => Promise; + 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; + stderr: Pick; +} + +/** + * Build the Web UI URL, carrying the bearer token in the URL fragment. + * + * The token rides in `#token=` — a client-side fragment that is never + * sent to the server (so it never appears in server access logs) and is not + * logged by proxies. The Web UI reads it from `location.hash` after load. + */ +export function buildWebUrl(origin: string, token: string): string { + return buildOpenableUrl(origin, token); +} + +/** Build the `web` command, mounting the runner action on `cmd` itself. */ +export function buildWebCommand(cmd: Command): Command { + return cmd + .option( + '--port ', + `Bind port (default ${DEFAULT_SERVER_PORT})`, + String(DEFAULT_SERVER_PORT), + ) + .option( + '--host [host]', + `Bind host. Omit to bind ${DEFAULT_SERVER_HOST} (this machine only); pass --host to bind ${DEFAULT_LAN_HOST} (all interfaces), or --host for a specific host. The bearer token is printed at startup.`, + ) + .option( + '--allowed-host ', + 'Extra Host header value to allow through the DNS-rebinding check. Repeat or comma-separate; a leading dot matches a domain suffix (e.g. .example.com).', + ) + .option( + '--insecure-no-tls', + 'Allow a non-loopback bind without a TLS-terminating reverse proxy. Defaults to true; only relevant for non-loopback binds.', + true, + ) + .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( + '--dangerous-bypass-auth', + 'Disable bearer-token auth on every REST and WebSocket route, and advertise it via /api/v1/meta so the web UI connects without a token. Only use on a trusted network or behind your own authenticating proxy.', + false, + ) + .option( + '--log-level ', + `Server log level: ${VALID_LOG_LEVELS.join('|')}. Omit to keep logs off.`, + ) + .option( + '--debug-endpoints', + 'Mount /api/v1/debug/* routes for test introspection. OFF by default; production callers leave this unset.', + false, + ) + .option('--no-open', 'Do not open the web UI in the default browser.', true) + .action(async (opts: WebCliOptions) => { + try { + await handleWebCommand(opts); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} + +export async function handleWebCommand( + opts: WebCliOptions, + deps: WebCommandDeps = DEFAULT_WEB_COMMAND_DEPS, +): Promise { + const parsed = parseServerOptions(opts); + const run = deps.startServerForeground ?? startServerForeground; + await run(parsed, { + onReady: (origin) => { + // Resolve the persistent token only once the server is up: a fresh + // server writes `server.token` on first boot, so reading it beforehand + // would miss first-time starts and the browser would hit the auth gate. + // 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. When auth is bypassed, the token is + // meaningless and is intentionally NOT shown or carried in the URL. + const token = parsed.dangerousBypassAuth ? undefined : deps.resolveToken?.(); + deps.stdout.write( + parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL + ? formatReadyBanner(origin, parsed.host, { + token, + networkAddresses: deps.networkAddresses, + dangerousBypassAuth: parsed.dangerousBypassAuth, + }) + : formatReadyLine(origin, token, parsed.dangerousBypassAuth), + ); + if (opts.open === true) { + deps.openUrl(token !== undefined ? buildWebUrl(origin, token) : origin); + } + }, + }); +} + +function formatReadyLine( + origin: string, + token: string | undefined, + dangerousBypassAuth = false, +): string { + const notice = dangerousBypassAuth + ? `${formatDangerNoticeLines().join('\n')}\n` + : ''; + return `${notice}Kimi server: ${buildOpenableUrl(origin, token)}\n`; +} + +/** + * Red, impossible-to-miss notice emitted when `--dangerous-bypass-auth` + * disables the bearer-token gate. Shared by the full ready banner and the + * compact one-line output so the warning always shows regardless of log level. + */ +function formatDangerNoticeLines(): string[] { + const danger = (text: string): string => chalk.hex(darkColors.error)(text); + const dangerBold = (text: string): string => chalk.bold.hex(darkColors.error)(text); + return [ + ` ${dangerBold('⚠ DANGER: authentication is DISABLED (--dangerous-bypass-auth).')}`, + ` ${danger('Anyone who can reach this port gets full access. Only continue if you understand the risk.')}`, + ` ${danger(`If you are unsure, run `)}${dangerBold('kimi web kill')}${danger(' now to stop this process.')}`, + ]; +} + +/** + * `kimi web` — runs the local server in-process, attached to the current + * terminal. Resolves only via `process.exit` (SIGINT/SIGTERM). + */ +export async function startServerForeground( + options: ParsedServerOptions, + hooks: StartForegroundHooks = {}, +): Promise { + return runServerInProcess(options, hooks.onReady); +} + +/** + * Start the server in the current process and block until shutdown. + * `onReady` fires once the server is listening. + */ +async function runServerInProcess( + options: ParsedServerOptions, + onReady?: (origin: string) => void, +): Promise { + const version = getVersion(); + // Registers the telemetry provider for `track` / `shutdownTelemetry`; the + // client itself is not passed into kap-server. + initializeServerTelemetry({ version }); + + let running: RoutedServer | undefined; + let stopping = false; + + async function shutdown(reason: string): Promise { + if (stopping) return; + stopping = true; + running?.logger.info({ reason }, 'server shutting down'); + try { + await running?.close(); + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + } catch (error) { + running?.logger.error( + { err: error instanceof Error ? error : new Error(String(error)) }, + 'server shutdown error', + ); + } + process.exit(0); + } + + // kap-server (the DI × Scope engine server) is the only server flavor. Its + // `startServer` returns `{ host, port, close }` rather than `{ address, + // logger, close }`, so adapt it to the `RoutedServer` surface the rest of + // this runner consumes. + const logger = createServerLogger({ level: options.logLevel }); + const v2 = await startServer({ + host: options.host, + port: options.port, + // Report the CLI's product version as `server_version` (/meta, web UI) + // rather than kap-server's private package version. + version, + logLevel: options.logLevel, + logger, + debugEndpoints: options.debugEndpoints, + insecureNoTls: options.insecureNoTls, + allowRemoteShutdown: options.allowRemoteShutdown, + allowRemoteTerminals: options.allowRemoteTerminals, + allowedHosts: options.allowedHosts, + disableAuth: options.dangerousBypassAuth, + // Seed the CLI's Kimi identity headers so the engine's outbound + // requests (model, WebSearch, FetchURL) carry the same User-Agent + + // X-Msh-* identity as direct CLI runs. + seeds: hostRequestHeadersSeed(buildKimiDefaultHeaders(version)), + webAssetsDir: serverWebAssetsDir(), + }); + logger.info('serving the REST/WS API and the bundled web UI'); + running = { + address: `http://${v2.host}:${v2.port}`, + logger, + close: () => v2.close(), + }; + + track('server_started', { daemon: false }); + + process.once('SIGINT', () => { + void shutdown('SIGINT'); + }); + process.once('SIGTERM', () => { + void shutdown('SIGTERM'); + }); + + running.logger.info({ address: running.address }, 'server ready'); + + onReady?.(running.address); + + return new Promise(() => { + // Keeps the event loop alive; the process ends via shutdown()/process.exit. + }); +} + +function serverWebAssetsDir(): string { + return resolveServerWebAssetsDir(); +} + +export function resolveServerWebAssetsDir( + nativeWebAssetsDir: string | null = getNativeWebAssetsDir(), +): string { + return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR); +} + +interface FormatReadyBannerOptions { + /** Persistent bearer token to print; omitted when unresolvable. */ + token?: string; + /** Non-loopback interface addresses to list for a wildcard bind. */ + networkAddresses?: NetworkAddress[]; + /** When true, render a red danger notice (auth is disabled). */ + dangerousBypassAuth?: boolean; +} + +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 = (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 lines: string[] = [ + '', + ` ${primary(logo[0])} ${title('Kimi server ready')} ${dim(getVersion())}`, + ` ${primary(logo[1])} ${dim('Local web UI is available from this machine.')}`, + '', + ]; + + if (opts.dangerousBypassAuth === true) { + // Red, impossible-to-miss notice: the bearer-token gate is off, so anyone + // who can reach this port gets full session / filesystem / shell access. + lines.push(...formatDangerNoticeLines(), ''); + } + + // 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 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(''); + } + + // Auxiliary controls last. + lines.push(` ${label('Logs: ')}${muted('off')}${dim(' use --log-level info to enable')}`); + lines.push(` ${label('Stop: ')}${muted('kimi web kill')}`); + lines.push(''); + return lines.join('\n'); +} + +const DEFAULT_WEB_COMMAND_DEPS: WebCommandDeps = { + startServerForeground, + openUrl: defaultOpenUrl, + resolveToken: () => { + // Read the persistent `/server.token` written on first boot + // (M5.1). Best-effort: a missing/older server yields undefined and the + // caller opens the plain origin. + return tryResolveServerToken(getDataDir()); + }, + stdout: process.stdout, + stderr: process.stderr, +}; diff --git a/apps/kimi-code/src/cli/sub/server/shared.ts b/apps/kimi-code/src/cli/sub/web/shared.ts similarity index 66% rename from apps/kimi-code/src/cli/sub/server/shared.ts rename to apps/kimi-code/src/cli/sub/web/shared.ts index bcf96d22c0..6a441cee92 100644 --- a/apps/kimi-code/src/cli/sub/server/shared.ts +++ b/apps/kimi-code/src/cli/sub/web/shared.ts @@ -1,14 +1,13 @@ /** - * Shared helpers for `kimi server …` subcommands. + * Shared helpers for `kimi web` and its subcommands. * - * Owns the default host/port, option parsers, and health/readiness probes that - * `run`, `web`, and `status` all use. + * Owns the default host/port, option parsers, and health/readiness probes. */ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import type { ServerLogLevel } from '@moonshot-ai/kap-server'; +import type { ServerInstanceInfo, ServerLogLevel } from '@moonshot-ai/kap-server'; export const LOCAL_SERVER_HOST = '127.0.0.1'; export const DEFAULT_LAN_HOST = '0.0.0.0'; @@ -22,13 +21,6 @@ export const SERVER_TOKEN_FILE = 'server.token'; export const DEFAULT_LOG_LEVEL: ServerLogLevel = 'info'; export const DEFAULT_FOREGROUND_LOG_LEVEL: ServerLogLevel = 'silent'; -/** - * Default idle-shutdown grace for the background daemon: once the last web - * client disconnects, the daemon waits this long before exiting. Overridable - * via the internal `--idle-grace-ms` flag (used by tests). - */ -export const DEFAULT_IDLE_GRACE_MS = 60_000; - export const VALID_LOG_LEVELS: readonly ServerLogLevel[] = [ 'fatal', 'error', @@ -39,6 +31,14 @@ export const VALID_LOG_LEVELS: readonly ServerLogLevel[] = [ 'silent', ]; +/** + * Browser-reachable host for a registry instance: a wildcard bind + * (`0.0.0.0`) is not a connectable address, so advertise loopback instead. + */ +export function instanceConnectHost(instance: ServerInstanceInfo): string { + return instance.host === '0.0.0.0' ? LOCAL_SERVER_HOST : instance.host; +} + export interface ParsedServerOptions { host: string; port: number; @@ -54,18 +54,6 @@ export interface ParsedServerOptions { dangerousBypassAuth: boolean; /** Extra `Host` header values to allow through the DNS-rebinding check. */ allowedHosts: readonly string[]; - /** - * Keep the server running instead of idle-killing it after 60s with no - * connected clients (`--keep-alive`). Also implied automatically by a - * non-default bind (`--host`) or a proxy/tunnel setup (`--allowed-host`), - * and always on in `--foreground` mode. Only the daemon mode consults this — - * foreground never idle-kills regardless. - */ - keepAlive: boolean; - /** Internal: run as an idle-exiting background daemon instead of foreground. */ - daemon: boolean; - /** Internal: idle-shutdown grace in ms (daemon mode only). */ - idleGraceMs: number; } export interface ServerCliOptions { @@ -83,24 +71,11 @@ export interface ServerCliOptions { dangerousBypassAuth?: boolean; /** Extra `Host` header values to allow (`--allowed-host`). */ allowedHost?: string[]; - /** Keep the server running instead of idle-killing it (`--keep-alive`). */ - keepAlive?: boolean; - /** Internal flag set by the daemon spawner (`kimi web`). */ - daemon?: boolean; - /** Internal flag set by the daemon spawner / tests. */ - idleGraceMs?: string; } export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions { - const host = parseHost(opts.host); - const allowedHosts = parseAllowedHostArgs(opts.allowedHost); - // `--keep-alive` is explicit, but also implied by a non-default bind - // (`--host`) or a proxy/tunnel setup (`--allowed-host`). Foreground mode is - // forced keep-alive later in `handleRunCommand`. - const keepAlive = - opts.keepAlive === true || host !== DEFAULT_SERVER_HOST || allowedHosts.length > 0; return { - host, + host: parseHost(opts.host), port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT), logLevel: parseLogLevel(opts.logLevel ?? DEFAULT_FOREGROUND_LOG_LEVEL), debugEndpoints: opts.debugEndpoints === true, @@ -108,10 +83,7 @@ export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions allowRemoteShutdown: opts.allowRemoteShutdown === true, allowRemoteTerminals: opts.allowRemoteTerminals === true, dangerousBypassAuth: opts.dangerousBypassAuth === true, - allowedHosts, - keepAlive, - daemon: opts.daemon === true, - idleGraceMs: parseIdleGraceMs(opts.idleGraceMs), + allowedHosts: parseAllowedHostArgs(opts.allowedHost), }; } @@ -129,15 +101,6 @@ function parseHost(raw: string | boolean | undefined): string { return raw; } -function parseIdleGraceMs(raw: string | undefined): number { - if (raw === undefined) return DEFAULT_IDLE_GRACE_MS; - const n = Number.parseInt(raw, 10); - if (!Number.isFinite(n) || n < 0) { - throw new Error(`error: invalid --idle-grace-ms value: ${raw}`); - } - return n; -} - export function parsePort(raw: string | undefined, label: string, fallback: number): number { if (raw === undefined) return fallback; const n = Number.parseInt(raw, 10); @@ -204,42 +167,6 @@ export async function waitForServerHealthy(origin: string, timeoutMs: number): P return false; } -/** - * Probe `/` and confirm the bundled web UI is being served. - * - * A different build that runs on the same port serves its own bundle — opening - * a browser at that origin lands on stale code. Catching that here lets the - * caller surface a clear "stop the running server" message instead of silently - * handing the user the wrong UI. - */ -export async function ensureServerWebReady(origin: string): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => { - controller.abort(); - }, 3000); - try { - const response = await fetch(`${origin}/`, { - headers: { accept: 'text/html' }, - signal: controller.signal, - }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - const body = await response.text(); - if (!body.includes('
; setExitOpenUrl(url: string): void; - /** - * Register a task that takes over the process after the TUI has shut down - * (instead of exiting): the runner awaits it and only exits when it returns. - * Used by `/web` foreground mode to keep the server attached to this - * terminal until Ctrl+C. - */ - setExitForegroundTask(task: (exitCode: number) => Promise): void; showHelpPanel(): void; createNewSession(): Promise; showSessionPicker(): Promise; @@ -373,7 +366,7 @@ async function handleBuiltInSlashCommand( await handleUndoCommand(host, args); return; case 'web': - await handleWebCommand(host, args); + await handleWebCommand(host); return; default: host.showError(`Unknown slash command: /${String(name)}`); diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 0b58ec265a..34556f7e62 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -393,8 +393,7 @@ export const BUILTIN_SLASH_COMMANDS = [ { name: 'web', aliases: [], - description: - 'Open the current session in the Web UI (server runs in the foreground; --background to daemonize)', + description: 'Open the current session in the Web UI and exit the terminal', priority: 40, availability: 'always', }, diff --git a/apps/kimi-code/src/tui/commands/web.ts b/apps/kimi-code/src/tui/commands/web.ts index 24e41babe5..f80519d392 100644 --- a/apps/kimi-code/src/tui/commands/web.ts +++ b/apps/kimi-code/src/tui/commands/web.ts @@ -1,42 +1,40 @@ -import chalk from 'chalk'; +import { getLiveServerInstance } from '@moonshot-ai/kap-server'; -import { splitTokenFragment } from '#/cli/sub/server/access-urls'; -import { ensureDaemon, findReusableDaemon } from '#/cli/sub/server/daemon'; -import { formatReadyBanner, startServerForeground } from '#/cli/sub/server/run'; -import { parseServerOptions, tryResolveServerToken } from '#/cli/sub/server/shared'; -import { getVersion } from '#/cli/version'; -import { darkColors } from '#/tui/theme/colors'; +import { + instanceConnectHost, + isServerHealthy, + serverOrigin, + tryResolveServerToken, +} from '#/cli/sub/web/shared'; import { openUrl } from '#/utils/open-url'; import { getDataDir } from '#/utils/paths'; import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; -import { formatErrorMessage } from '../utils/event-payload'; import type { SlashCommandHost } from './dispatch'; const WEB_CONFIRM = 'confirm'; const WEB_CANCEL = 'cancel'; -const WEB_BACKGROUND_FLAG = '--background'; + +/** How long to wait for the running server to answer `/healthz`. */ +const HEALTH_TIMEOUT_MS = 1500; /** * `/web` — hand the current session off to the browser. * - * Default (foreground): the TUI shuts down and the Kimi server takes over this - * terminal in the foreground (`Ctrl+C` stops it), with the browser opened to - * the active session. `/web --background` instead ensures the background - * daemon is up, opens the browser, and releases the terminal — equivalent to - * `kimi web --background`. A server that is already running is reused in both - * modes. A confirmation step spells out the consequences and only proceeds - * when the user presses Enter on Continue. + * Connects to the running Kimi server (the longest-running live instance from + * the registry — servers are foreground-only, so one must already be running, + * e.g. via `kimi web` in another terminal), deep-links the active session, and + * then shuts down this terminal UI. A confirmation step spells out the + * consequences and only proceeds when the user presses Enter on Continue. */ -export async function handleWebCommand(host: SlashCommandHost, args: string): Promise { +export async function handleWebCommand(host: SlashCommandHost): Promise { const session = host.session; if (session === undefined) { host.showError(NO_ACTIVE_SESSION_MESSAGE); return; } const sessionId = session.id; - const background = args.split(/\s+/).includes(WEB_BACKGROUND_FLAG); const confirmed = await new Promise((resolve) => { const picker = new ChoicePickerComponent({ @@ -46,9 +44,8 @@ export async function handleWebCommand(host: SlashCommandHost, args: string): Pr { value: WEB_CONFIRM, label: 'Continue', - description: background - ? 'Start the Kimi server (background daemon if needed), open this session in your default browser, and exit the terminal UI.' - : 'Start the Kimi server in the foreground (this terminal stays attached; Ctrl+C stops it), open this session in your default browser, and exit the terminal UI.', + description: + 'Open this session in your default browser (connects to the running Kimi server) and exit the terminal UI.', }, { value: WEB_CANCEL, @@ -68,104 +65,23 @@ export async function handleWebCommand(host: SlashCommandHost, args: string): Pr host.restoreEditor(); if (!confirmed) return; - host.showStatus('Starting Kimi server and opening web UI…'); - - if (background) { - let origin: string; - let hostVersion: string | undefined; - try { - ({ origin, hostVersion } = await ensureDaemon({})); - } catch (error) { - host.showError(`Failed to start server: ${formatErrorMessage(error)}`); - return; - } - // Resolve the persistent token only after the daemon is up: a fresh server - // writes `server.token` on first boot, so reading it beforehand would miss - // first-time starts and the browser would hit the auth gate. Best-effort: - // fall back to the plain URL (and skip the token line) when unresolvable. - showServerVersionHint(host, hostVersion); - await openAndExit(host, sessionId, origin, tryResolveServerToken(getDataDir())); - return; - } - - // Foreground by default. A server that is already running can serve the web - // UI right away — reuse it instead of failing to bind its port. - let reused: { origin: string; hostVersion?: string } | undefined; - try { - reused = await findReusableDaemon(); - } catch (error) { - host.showError(`Failed to probe the running server: ${formatErrorMessage(error)}`); + const instance = await getLiveServerInstance(); + if (instance === undefined) { + host.showError('No running Kimi server. Start one with `kimi web` in another terminal first.'); return; } - if (reused !== undefined) { - showServerVersionHint(host, reused.hostVersion); - await openAndExit(host, sessionId, reused.origin, tryResolveServerToken(getDataDir())); + const origin = serverOrigin(instanceConnectHost(instance), instance.port); + if (!(await isServerHealthy(origin, HEALTH_TIMEOUT_MS))) { + host.showError(`Kimi server at ${origin} is not responding.`); return; } - // No server is running: shut the TUI down and let the Kimi server take over - // this terminal in the foreground (the registered task runs after teardown, - // where `process.exit` would normally happen). The deep link is opened from - // the ready hook, once the server is actually listening, and the terminal - // shows the same ready banner as `kimi web` plus the session deep link. - host.setExitForegroundTask(async () => { - const runOptions = { ...parseServerOptions({}), keepAlive: true }; - try { - await startServerForeground(runOptions, { - onReady: (origin) => { - // Resolve the token here (after the server is listening) for the - // same first-boot reason as the daemon path above. - const token = tryResolveServerToken(getDataDir()); - const url = webSessionUrl(origin, sessionId, token); - process.stdout.write( - formatReadyBanner(origin, runOptions.host, { token, foreground: true }), - ); - process.stdout.write(`\n ${sessionLine(url)}\n`); - openUrl(url); - }, - }); - } catch (error) { - process.stderr.write(`Failed to start server: ${formatErrorMessage(error)}\n`); - process.exit(1); - } - }); - await host.stop(); -} - -/** Styled `Session:` line for the foreground handoff; the token fragment is - * dimmed like in the ready banner so the host/path stands out. */ -function sessionLine(url: string): string { - const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); - const accent = (text: string): string => chalk.hex(darkColors.accent)(text); - const dim = (text: string): string => chalk.hex(darkColors.textDim)(text); - const [base, frag] = splitTokenFragment(url); - return `${label('Session: ')}${accent(base)}${frag === '' ? '' : dim(frag)}`; -} - -/** - * Warn when the reused server was started by a different CLI version: it keeps - * serving its own bundled web UI/API until restarted. Mirrors the banner hint - * printed by `kimi web`. - */ -function showServerVersionHint(host: SlashCommandHost, hostVersion: string | undefined): void { - if (hostVersion === undefined || hostVersion === getVersion()) return; - host.showStatus( - `Running server is version ${hostVersion}, this CLI is ${getVersion()} — restart with kimi server kill to pick up the new version.`, - 'warning', - ); -} - -/** - * Open the session deep link in the browser, record it for the exit hints, - * and shut the TUI down. Used when the server is already running out of - * process (reused or freshly-spawned daemon), so exit frees the terminal. - */ -function openAndExit( - host: SlashCommandHost, - sessionId: string, - origin: string, - token: string | undefined, -): Promise { + // Resolve the persistent token so the opened browser auto-authenticates via + // the `#token=` fragment — matching the `kimi web` command. Show the URL + // and token in green under the status line so they can be copied before the + // terminal exits. Best-effort: an older/never-started server has no token + // file, so we fall back to the plain URL and skip the token line. + const token = tryResolveServerToken(getDataDir()); const url = webSessionUrl(origin, sessionId, token); host.showStatus(`open ${url}`, 'success'); if (token !== undefined) { @@ -173,7 +89,7 @@ function openAndExit( } openUrl(url); host.setExitOpenUrl(url); - return host.stop(); + await host.stop(); } /** diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index cdd7353f01..a05165ca2b 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -356,13 +356,6 @@ export class KimiTUI { /** URL opened in the browser just before exit (e.g. by `/web`); printed by onExit. */ public exitOpenUrl: string | undefined; - /** - * Task that takes over the process after the TUI shuts down, instead of - * exiting (`/web` foreground mode: the server keeps this terminal attached - * until Ctrl+C). Set via {@link setExitForegroundTask}. - */ - public exitForegroundTask: ((exitCode: number) => Promise) | undefined; - track(event: string, properties?: Parameters[1]): void { this.harness.track(event, properties); } @@ -1441,10 +1434,6 @@ export class KimiTUI { this.exitOpenUrl = url; } - setExitForegroundTask(task: (exitCode: number) => Promise): void { - this.exitForegroundTask = task; - } - async getStartupMcpMs(): Promise { const session = this.session; if (session === undefined) return 0; diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index b15ed315fe..01f044f9af 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -462,8 +462,8 @@ describe('CLI options parsing', () => { 'export', 'provider', 'acp', - 'server', 'web', + 'server', 'login', 'doctor', 'vis', diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts deleted file mode 100644 index bf2019a486..0000000000 --- a/apps/kimi-code/test/cli/server/server.test.ts +++ /dev/null @@ -1,2046 +0,0 @@ -/** - * Tests for `kimi server run` and `kimi web` Commander wiring. - * - * These tests don't actually start the server — they verify the parsed shape - * (option flags, --open default) and that the `web` alias defers to the same - * underlying handler with `defaultOpen` and `defaultForeground` flipped to - * true (foreground by default, `--background` opting back into the daemon). - * - * Foreground startup behavior is exercised end-to-end in `server-e2e/`. - */ - -import type { ChildProcess } from 'node:child_process'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { createServer, type Server } from 'node:net'; -import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; - -import chalk, { Chalk } from 'chalk'; -import { Command } from 'commander'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { registerServerCommand } from '#/cli/sub/server'; -import { addLifecycleCommands } from '#/cli/sub/server/lifecycle'; -import type { KillCommandDeps } from '#/cli/sub/server/kill'; -import { darkColors } from '#/tui/theme/colors'; - -vi.mock('node:child_process', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, spawn: vi.fn() }; -}); - -function stripAnsi(text: string): string { - return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); -} - -function makeProgram(): Command { - // `commander` exitOverride avoids killing the test runner when --help/error fires. - const program = new Command('kimi').exitOverride(); - registerServerCommand(program); - return program; -} - -describe('kimi server', () => { - it('registers the expected `server` subcommands while lifecycle commands are hidden', () => { - const program = makeProgram(); - 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', 'rotate-token', 'run']); - }); - - it('`server run` exposes local-only foreground options', () => { - const program = makeProgram(); - const run = program.commands - .find((c) => c.name() === 'server') - ?.commands.find((c) => c.name() === 'run'); - expect(run).toBeDefined(); - const longs = run!.options.map((o) => o.long).filter(Boolean); - 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'); - // run stays background-by-default → no --background opt-out flag - expect(longs).not.toContain('--background'); - }); - - it('`server install` exposes local-only service options', () => { - // Lifecycle commands are no longer registered via `registerServerCommand`, - // but the builder still lives in `./lifecycle` — exercise it directly. - const server = new Command('server'); - addLifecycleCommands(server); - const install = server.commands.find((c) => c.name() === 'install'); - expect(install).toBeDefined(); - const longs = install!.options.map((o) => o.long).filter(Boolean); - expect(longs).not.toContain('--host'); - expect(longs).toContain('--port'); - expect(longs).toContain('--log-level'); - expect(longs).toContain('--force'); - expect(longs).toContain('--no-open'); - expect(longs).toContain('--json'); - }); - - it('the top-level `kimi web` alias is registered and defaults to opening the browser in the foreground', () => { - const program = makeProgram(); - const web = program.commands.find((c) => c.name() === 'web'); - expect(web).toBeDefined(); - 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).toContain('--host'); - expect(longs).toContain('--port'); - // web defaults to foreground → --background opts back into the daemon - expect(longs).toContain('--foreground'); - expect(longs).toContain('--background'); - }); -}); - -describe('`kimi server` lifecycle exits with ESERVICE_UNSUPPORTED on unsupported platforms', () => { - it('the dispatcher returns a friendly error manager for unknown platforms', async () => { - // darwin / linux / win32 have real backends (launchd / systemd / schtasks). - // The remaining platforms fall through to the stub that throws - // `ServiceUnsupportedError` — pin that contract so a future addition - // (freebsd, etc.) needs a deliberate decision instead of silently working. - const { resolveServiceManager, ServiceUnsupportedError } = await import('@moonshot-ai/kap-server'); - const mgr = resolveServiceManager('freebsd'); - await expect( - mgr.install({ host: '127.0.0.1', port: 58627, logLevel: 'info' }), - ).rejects.toBeInstanceOf(ServiceUnsupportedError); - await expect(mgr.status()).rejects.toBeInstanceOf(ServiceUnsupportedError); - }); -}); - -describe('`kimi server` lifecycle handles unavailable service managers', () => { - it('prints a friendly JSON error and exits 2', async () => { - const { ServiceUnavailableError } = await import('@moonshot-ai/kap-server'); - const program = new Command('kimi').exitOverride(); - const server = program.command('server'); - let stdout = ''; - let stderr = ''; - const exit = vi.spyOn(process, 'exit').mockImplementation(((code?: number | string | null) => { - throw new Error(`process.exit(${String(code)})`); - }) as typeof process.exit); - - addLifecycleCommands(server, { - resolveManager: () => ({ - install: async () => { - throw new ServiceUnavailableError( - 'linux', - 'systemd --user is not available in this environment.', - ); - }, - uninstall: async () => ({ ok: true, message: 'unused' }), - start: async () => ({ ok: true, message: 'unused' }), - stop: async () => ({ ok: true, message: 'unused' }), - restart: async () => ({ ok: true, message: 'unused' }), - status: async () => ({ platform: 'linux', installed: false, running: false }), - }), - openUrl: vi.fn(), - stdout: { - write(chunk: string | Uint8Array) { - stdout += String(chunk); - return true; - }, - }, - stderr: { - write(chunk: string | Uint8Array) { - stderr += String(chunk); - return true; - }, - }, - }); - - await expect( - program.parseAsync(['node', 'kimi', 'server', 'install', '--json']), - ).rejects.toThrow('process.exit(2)'); - - exit.mockRestore(); - expect(stderr).toBe(''); - expect(JSON.parse(stdout)).toMatchObject({ - ok: false, - action: 'unavailable', - platform: 'linux', - message: expect.stringContaining('server run --port '), - }); - }); -}); - -describe('`kimi server` lifecycle output', () => { - it('install passes --force/--port, prints the URL, and opens it when running', async () => { - const program = new Command('kimi').exitOverride(); - const server = program.command('server'); - let stdout = ''; - let stderr = ''; - let installArgs: unknown; - const openUrl = vi.fn(); - - addLifecycleCommands(server, { - resolveManager: () => ({ - install: async (args) => { - installArgs = args; - return { - status: 'replaced', - message: 'Kimi server LaunchAgent replaced at /tmp/kimi.plist (port 9999).', - plistPath: '/tmp/kimi.plist', - }; - }, - uninstall: async () => ({ ok: true, message: 'unused' }), - start: async () => ({ ok: true, message: 'unused' }), - stop: async () => ({ ok: true, message: 'unused' }), - restart: async () => ({ ok: true, message: 'unused' }), - status: async () => ({ - platform: 'darwin', - installed: true, - running: true, - host: '127.0.0.1', - port: 9999, - logPath: '/tmp/server.log', - label: 'ai.moonshot.kimi-server', - }), - }), - openUrl, - stdout: { - write(chunk: string | Uint8Array) { - stdout += String(chunk); - return true; - }, - }, - stderr: { - write(chunk: string | Uint8Array) { - stderr += String(chunk); - return true; - }, - }, - }); - - await program.parseAsync([ - 'node', - 'kimi', - 'server', - 'install', - '--force', - '--port', - '9999', - ]); - - expect(stderr).toBe(''); - expect(installArgs).toMatchObject({ port: 9999, force: true }); - expect(stdout).toContain('URL: http://127.0.0.1:9999'); - expect(stdout).toContain('Status: running'); - expect(stdout).toContain('Log: /tmp/server.log'); - expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:9999'); - }); - - it('start prints URL and diagnostics when launchd did not keep the service running', async () => { - const program = new Command('kimi').exitOverride(); - const server = program.command('server'); - let stdout = ''; - const openUrl = vi.fn(); - - addLifecycleCommands(server, { - resolveManager: () => ({ - install: async () => ({ status: 'installed', message: 'unused' }), - uninstall: async () => ({ ok: true, message: 'unused' }), - start: async () => ({ ok: true, message: 'Kimi server started (ai.moonshot.kimi-server).' }), - stop: async () => ({ ok: true, message: 'unused' }), - restart: async () => ({ ok: true, message: 'unused' }), - status: async () => ({ - platform: 'darwin', - installed: true, - running: false, - host: '127.0.0.1', - port: 58627, - logPath: '/tmp/server.log', - label: 'ai.moonshot.kimi-server', - notes: ['launchd state: spawn scheduled', 'last exit code: 78 EX_CONFIG'], - }), - }), - openUrl, - stdout: { - write(chunk: string | Uint8Array) { - stdout += String(chunk); - return true; - }, - }, - stderr: { - write() { - return true; - }, - }, - }); - - await program.parseAsync(['node', 'kimi', 'server', 'start']); - - expect(stdout).toContain('URL: http://127.0.0.1:58627'); - expect(stdout).toContain('Status: not running'); - expect(stdout).toContain('launchd state: spawn scheduled'); - expect(stdout).toContain('last exit code: 78 EX_CONFIG'); - expect(openUrl).not.toHaveBeenCalled(); - }); -}); - -describe('`kimi server run` background start', () => { - it('defaults the daemon log level to silent', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let parsed: unknown; - - await handleRunCommand( - { port: '58627' }, - { - startServerBackground: async (options) => { - parsed = options; - return { origin: 'http://127.0.0.1:58627' }; - }, - openUrl: vi.fn(), - stdout: { - write() { - return true; - }, - }, - stderr: { - write() { - return true; - }, - }, - }, - ); - - expect(parsed).toMatchObject({ logLevel: 'silent' }); - }); - - it('passes --log-level through to the background daemon', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let parsed: unknown; - - await handleRunCommand( - { port: '58627', logLevel: 'debug' }, - { - startServerBackground: async (options) => { - parsed = options; - return { origin: 'http://127.0.0.1:58627' }; - }, - openUrl: vi.fn(), - stdout: { - write() { - return true; - }, - }, - stderr: { - write() { - return true; - }, - }, - }, - ); - - 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 to enable'); - expect(plain).not.toContain('Network: http'); - }); - - it('keeps the token and skips the bypass notice when a daemon is reused', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let stdout = ''; - const openUrl = vi.fn(); - - // The user requests bypass, but a daemon is already running — so the - // requested flag is NOT applied to the server actually serving requests. - await handleRunCommand( - { port: '58627', host: '127.0.0.1', dangerousBypassAuth: true, open: true }, - { - startServerBackground: async () => ({ - origin: 'http://127.0.0.1:58627', - reused: true, - host: '127.0.0.1', - port: 58627, - }), - resolveToken: () => 'tok', - openUrl, - stdout: { - write(chunk: string | Uint8Array) { - stdout += String(chunk); - return true; - }, - }, - stderr: { write: () => true }, - }, - ); - - const plain = stripAnsi(stdout); - // No false "bypass" claim for a server whose real auth mode is unknown. - expect(plain).not.toContain('DANGER'); - // The token is preserved so the browser can auto-authenticate to the - // reused (token-protected) daemon. - expect(plain).toContain('#token=tok'); - expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/#token=tok'); - }); - - it('prints a TUI-style ready panel once the daemon is up', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let stdout = ''; - - await handleRunCommand( - { port: '58627', host: '127.0.0.1' }, - { - startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), - openUrl: vi.fn(), - stdout: { - write(chunk: string | Uint8Array) { - stdout += String(chunk); - return true; - }, - }, - stderr: { - write() { - return true; - }, - }, - }, - ); - - const plain = stripAnsi(stdout); - expect(plain).toContain('Kimi server ready'); - expect(plain).toContain('Local:'); - expect(plain).toContain('http://127.0.0.1:58627/'); - // Loopback bind shows a Network hint for enabling network access. - expect(plain).toContain('Network:'); - expect(plain).toContain('use --host 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 () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let stdout = ''; - const previousChalkLevel = chalk.level; - chalk.level = 3; - - try { - await handleRunCommand( - { port: '58627', host: '127.0.0.1' }, - { - startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), - openUrl: vi.fn(), - stdout: { - write(chunk: string | Uint8Array) { - stdout += String(chunk); - return true; - }, - }, - stderr: { - write() { - return true; - }, - }, - }, - ); - } finally { - chalk.level = previousChalkLevel; - } - - const color = new Chalk({ level: 3 }); - 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)('Local: ')); - expect(stdout).toContain(color.hex(darkColors.textMuted)('off')); - }); - - it('warns when the reused daemon was started by a different CLI version', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - const { getVersion } = await import('#/cli/version'); - let stdout = ''; - - await handleRunCommand( - { port: '58627' }, - { - startServerBackground: async () => ({ - origin: 'http://127.0.0.1:58627', - reused: true, - host: '127.0.0.1', - port: 58627, - hostVersion: '0.0.0-test-old', - }), - openUrl: vi.fn(), - stdout: { - write(chunk: string | Uint8Array) { - stdout += String(chunk); - return true; - }, - }, - stderr: { write: () => true }, - }, - ); - - const plain = stripAnsi(stdout); - expect(plain).toContain('A server is already running'); - expect(plain).toContain('Server version mismatch'); - expect(plain).toContain('0.0.0-test-old'); - expect(plain).toContain(getVersion()); - }); - - it('stays quiet when the reused daemon runs the same CLI version', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - const { getVersion } = await import('#/cli/version'); - let stdout = ''; - - await handleRunCommand( - { port: '58627' }, - { - startServerBackground: async () => ({ - origin: 'http://127.0.0.1:58627', - reused: true, - host: '127.0.0.1', - port: 58627, - hostVersion: getVersion(), - }), - openUrl: vi.fn(), - stdout: { - write(chunk: string | Uint8Array) { - stdout += String(chunk); - return true; - }, - }, - stderr: { write: () => true }, - }, - ); - - const plain = stripAnsi(stdout); - expect(plain).toContain('A server is already running'); - expect(plain).not.toContain('Server version mismatch'); - }); - - it('prints a red danger notice and suppresses the token when auth is bypassed', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let stdout = ''; - const openUrl = vi.fn(); - - await handleRunCommand( - { port: '58627', host: '127.0.0.1', dangerousBypassAuth: true, open: true }, - { - startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), - resolveToken: () => 'tok', - openUrl, - stdout: { - write(chunk: string | Uint8Array) { - stdout += String(chunk); - return true; - }, - }, - stderr: { - write() { - return true; - }, - }, - }, - ); - - const plain = stripAnsi(stdout); - // Red, impossible-to-miss danger notice. - expect(plain).toContain('DANGER: authentication is DISABLED'); - expect(plain).toContain('--dangerous-bypass-auth'); - expect(plain).toContain('kimi server kill'); - // The token is irrelevant when bypassed — neither printed nor carried in - // any URL (so it cannot leak via copy/paste of the banner). - expect(plain).not.toContain('tok'); - expect(plain).not.toContain('#token='); - // The opened browser URL carries no token fragment either. - expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); - }); - - it('renders the bypass danger notice in the error color', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let stdout = ''; - const previousChalkLevel = chalk.level; - chalk.level = 3; - - try { - await handleRunCommand( - { port: '58627', host: '127.0.0.1', dangerousBypassAuth: true }, - { - startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), - openUrl: vi.fn(), - stdout: { - write(chunk: string | Uint8Array) { - stdout += String(chunk); - return true; - }, - }, - stderr: { - write() { - return true; - }, - }, - }, - ); - } finally { - chalk.level = previousChalkLevel; - } - - const color = new Chalk({ level: 3 }); - expect(stdout).toContain( - color.bold.hex(darkColors.error)('⚠ DANGER: authentication is DISABLED (--dangerous-bypass-auth).'), - ); - }); -}); - -describe('`kimi server run --foreground`', () => { - it('runs the server in-process instead of spawning a background daemon', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let foregroundOptions: unknown; - let backgroundCalled = false; - - await handleRunCommand( - { port: '58627', foreground: true }, - { - startServerBackground: async () => { - backgroundCalled = true; - return { origin: 'http://127.0.0.1:58627' }; - }, - startServerForeground: async (options) => { - foregroundOptions = options; - return undefined as unknown as never; - }, - openUrl: vi.fn(), - stdout: { - write() { - return true; - }, - }, - stderr: { - write() { - return true; - }, - }, - }, - ); - - expect(backgroundCalled).toBe(false); - expect(foregroundOptions).toMatchObject({ port: 58627, logLevel: 'silent' }); - }); - - it('prints the ready banner and opens the browser once listening', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let stdout = ''; - const openUrl = vi.fn(); - - await handleRunCommand( - { port: '58627', host: '127.0.0.1', foreground: true, open: true }, - { - startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), - startServerForeground: async (options, hooks) => { - void options; - hooks?.onReady?.('http://127.0.0.1:58627'); - return undefined as unknown as never; - }, - openUrl, - stdout: { - write(chunk: string | Uint8Array) { - stdout += String(chunk); - return true; - }, - }, - stderr: { - write() { - return true; - }, - }, - }, - ); - - const plain = stripAnsi(stdout); - expect(plain).toContain('Kimi server ready'); - expect(plain).toContain('http://127.0.0.1:58627/'); - expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); - // An attached-foreground server stops with Ctrl+C — `kimi server kill` - // from a second terminal would work, but the banner points at the obvious - // one: the terminal the user is looking at. - expect(plain).toContain('Stop:'); - expect(plain).toContain('Ctrl+C'); - expect(plain).not.toContain('kimi server kill'); - }); - - it('adapts the danger notice stop hint in attached-foreground mode', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let stdout = ''; - - await handleRunCommand( - { port: '58627', foreground: true, dangerousBypassAuth: true }, - { - startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), - startServerForeground: async (options, hooks) => { - void options; - hooks?.onReady?.('http://127.0.0.1:58627'); - return undefined as unknown as never; - }, - openUrl: vi.fn(), - stdout: { - write(chunk: string | Uint8Array) { - stdout += String(chunk); - return true; - }, - }, - stderr: { write: () => true }, - }, - ); - - const plain = stripAnsi(stdout); - expect(plain).toContain('DANGER: authentication is DISABLED'); - expect(plain).toContain('press Ctrl+C'); - expect(plain).not.toContain('kimi server kill'); - }); -}); - -describe('`kimi server` does not register a legacy `daemon` command', () => { - it('hard-deletes the old name', () => { - const program = makeProgram(); - const daemon = program.commands.find((c) => c.name() === 'daemon'); - expect(daemon).toBeUndefined(); - }); -}); - -describe('shared parsers stay strict', () => { - it('rejects out-of-range --port', async () => { - const { parsePort } = await import('#/cli/sub/server/shared'); - expect(() => parsePort('99999', '--port', 58627)).toThrow(/invalid --port/); - expect(() => parsePort('-1', '--port', 58627)).toThrow(/invalid --port/); - expect(parsePort(undefined, '--port', 58627)).toBe(58627); - expect(parsePort('8080', '--port', 58627)).toBe(8080); - }); - - it('rejects unknown --log-level values', async () => { - const { parseLogLevel } = await import('#/cli/sub/server/shared'); - expect(() => parseLogLevel('shout')).toThrow(/invalid --log-level/); - expect(parseLogLevel(undefined)).toBe('info'); - expect(parseLogLevel('debug')).toBe('debug'); - }); -}); - -describe('server web asset directory resolution', () => { - it('uses extracted SEA web assets when available', async () => { - const { resolveServerWebAssetsDir } = await import('#/cli/sub/server/run'); - expect(resolveServerWebAssetsDir('/cache/kimi/dist-web')).toBe('/cache/kimi/dist-web'); - }); - - it('falls back to package dist-web outside SEA mode', async () => { - const { resolveServerWebAssetsDir } = await import('#/cli/sub/server/run'); - expect(resolveServerWebAssetsDir(null)).toMatch(/[/\\]dist-web$/); - }); -}); - -function listenOnce(host: string, port: number): Promise { - return new Promise((resolve, reject) => { - const server = createServer(); - server.once('error', reject); - server.listen({ host, port }, () => { - resolve(server); - }); - }); -} - -function closeServer(server: Server): Promise { - return new Promise((resolve) => { - server.close(() => { - resolve(); - }); - }); -} - -async function allocateFreePort(host = '127.0.0.1'): Promise { - const server = await listenOnce(host, 0); - const address = server.address(); - const port = typeof address === 'object' && address !== null ? address.port : 0; - await closeServer(server); - return port; -} - -/** - * Find the start of a run of `count` consecutive free ports - * (`start`, `start + 1`, …, `start + count - 1` all bindable). - */ -async function allocateAdjacentFreeRun(count: number, host = '127.0.0.1'): Promise { - for (let i = 0; i < 50; i++) { - const start = await allocateFreePort(host); - if (start <= 0 || start + count - 1 > 65535) continue; - const held: Server[] = []; - let ok = true; - for (let offset = 1; offset < count; offset++) { - const probe = await listenOnce(host, start + offset).catch(() => null); - if (probe === null) { - ok = false; - break; - } - held.push(probe); - } - for (const server of held) await closeServer(server); - if (ok) return start; - } - 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('default bind (M6.3)', () => { - it('defaults host to 127.0.0.1 and insecureNoTls to true when no flags are passed', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let parsed: unknown; - - await handleRunCommand( - { port: '58627' }, - { - startServerBackground: async (options) => { - parsed = options; - return { origin: 'http://127.0.0.1:58627' }; - }, - openUrl: vi.fn(), - stdout: { write: () => true }, - stderr: { write: () => true }, - }, - ); - - expect(parsed).toMatchObject({ host: '127.0.0.1', insecureNoTls: true }); - }); - - it('treats a bare --host as the default LAN host', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let parsed: unknown; - - await handleRunCommand( - { port: '58627', host: 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({ host: '0.0.0.0', insecureNoTls: true }); - }); -}); - -describe('--allowed-host threading', () => { - it('parses comma-separated --allowed-host values', async () => { - const { parseAllowedHostArgs } = await import('#/cli/sub/server/shared'); - expect(parseAllowedHostArgs(['.example.com, app.example.com'])).toEqual([ - '.example.com', - 'app.example.com', - ]); - }); - - it('threads --allowed-host to the background daemon options', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let parsed: unknown; - - await handleRunCommand( - { port: '58627', allowedHost: ['.example.com'] }, - { - startServerBackground: async (options) => { - parsed = options; - return { origin: 'http://127.0.0.1:58627' }; - }, - openUrl: vi.fn(), - stdout: { write: () => true }, - stderr: { write: () => true }, - }, - ); - - expect(parsed).toMatchObject({ allowedHosts: ['.example.com'] }); - }); -}); - -describe('--keep-alive (no 60s idle-kill)', () => { - it('defaults to off for the plain loopback daemon', async () => { - const { parseServerOptions } = await import('#/cli/sub/server/shared'); - expect(parseServerOptions({}).keepAlive).toBe(false); - }); - - it('is implied by a bare --host (default LAN host)', async () => { - const { parseServerOptions } = await import('#/cli/sub/server/shared'); - expect(parseServerOptions({ host: true }).keepAlive).toBe(true); - }); - - it('is implied by an explicit --host value', async () => { - const { parseServerOptions } = await import('#/cli/sub/server/shared'); - expect(parseServerOptions({ host: '0.0.0.0' }).keepAlive).toBe(true); - expect(parseServerOptions({ host: '192.168.1.5' }).keepAlive).toBe(true); - }); - - it('stays off for an explicit loopback --host with no allowed-hosts', async () => { - const { parseServerOptions } = await import('#/cli/sub/server/shared'); - expect(parseServerOptions({ host: '127.0.0.1' }).keepAlive).toBe(false); - }); - - it('is implied by --allowed-host (proxy/tunnel)', async () => { - const { parseServerOptions } = await import('#/cli/sub/server/shared'); - expect(parseServerOptions({ allowedHost: ['.example.com'] }).keepAlive).toBe(true); - }); - - it('can be set explicitly on a loopback daemon', async () => { - const { parseServerOptions } = await import('#/cli/sub/server/shared'); - expect(parseServerOptions({ keepAlive: true }).keepAlive).toBe(true); - }); - - it('is forced on in --foreground mode even on the default loopback host', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let foregroundOptions: unknown; - - await handleRunCommand( - { port: '58627', foreground: true }, - { - startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), - startServerForeground: async (options) => { - foregroundOptions = options; - return undefined as unknown as never; - }, - openUrl: vi.fn(), - stdout: { write: () => true }, - stderr: { write: () => true }, - }, - ); - - expect(foregroundOptions).toMatchObject({ keepAlive: true }); - }); - - it('threads keepAlive to the foreground runner when implied by --host', 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({ keepAlive: true }); - }); -}); - -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'); - const free = await allocateFreePort(); - await expect(resolveDaemonPort('127.0.0.1', free)).resolves.toBe(free); - }); - - it('falls back to a different free port when the preferred port is busy', async () => { - const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); - const busy = await allocateFreePort(); - const holder = await listenOnce('127.0.0.1', busy); - try { - const port = await resolveDaemonPort('127.0.0.1', busy); - expect(port).not.toBe(busy); - expect(port).toBeGreaterThan(0); - } finally { - await closeServer(holder); - } - }); - - it('walks to preferred+1 when only the preferred port is busy', async () => { - const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); - const start = await allocateAdjacentFreeRun(2); - const holder = await listenOnce('127.0.0.1', start); - try { - const port = await resolveDaemonPort('127.0.0.1', start); - expect(port).toBe(start + 1); - } finally { - await closeServer(holder); - } - }); - - it('skips past a run of busy ports to the first free one', async () => { - const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); - const start = await allocateAdjacentFreeRun(3); - // Hold both `start` and `start+1`; the resolver should land on `start+2`. - const holderA = await listenOnce('127.0.0.1', start); - const holderB = await listenOnce('127.0.0.1', start + 1); - try { - const port = await resolveDaemonPort('127.0.0.1', start); - expect(port).toBe(start + 2); - } finally { - await closeServer(holderA); - await closeServer(holderB); - } - }); -}); - -describe('resolveDaemonProgram', () => { - it('uses the absolute script path outside SEA mode', async () => { - const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon'); - expect(resolveDaemonProgram(['node', '/opt/kimi/dist/cli.mjs'], '/tmp', '/usr/bin/node', false)).toBe('/opt/kimi/dist/cli.mjs'); - }); - - it('normalizes a relative executable path against cwd outside SEA mode', async () => { - const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon'); - expect(resolveDaemonProgram(['node', './kimi'], '/tmp/kimi-bin', '/usr/bin/node', false)).toBe(resolve('/tmp/kimi-bin', './kimi')); - }); - - it('returns execPath in SEA mode when argv[1] is a bare command name', async () => { - // Reproduces `kimi web` from the shell: argv[1] is the invoked command - // name (`kimi`), not a path. Resolving it against cwd produced `/kimi` - // and crashed the spawn with ENOENT. - const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon'); - expect(resolveDaemonProgram(['/Users/x/.kimi-code/bin/kimi', 'kimi', 'web'], '/Users/x', '/Users/x/.kimi-code/bin/kimi', true)).toBe('/Users/x/.kimi-code/bin/kimi'); - }); - - it('returns execPath in SEA mode for a spawned `server` child', async () => { - const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon'); - expect(resolveDaemonProgram(['/Users/x/.kimi-code/bin/kimi', 'server', 'run'], '/Users/x', '/Users/x/.kimi-code/bin/kimi', true)).toBe('/Users/x/.kimi-code/bin/kimi'); - }); -}); - -describe('spawnDaemonChild', () => { - let workDir: string; - let prevHome: string | undefined; - - beforeEach(() => { - workDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-cwd-')); - 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('spawns the daemon with cwd set to the server log directory', 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, daemonLogPath } = await import('#/cli/sub/server/daemon'); - spawnDaemonChild({ port: 58627, logLevel: 'info' }); - - expect(spawnMock).toHaveBeenCalledOnce(); - const [program, args, options] = spawnMock.mock.calls[0]!; - expect(program).toBeTruthy(); - expect(args).toEqual(expect.arrayContaining(['server', 'run', '--daemon'])); - 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'])); - }); - - it('passes --allowed-host through to the daemon child args', 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({ port: 58627, logLevel: 'info', allowedHosts: ['.example.com'] }); - - const [, args] = spawnMock.mock.calls[0]!; - expect(args).toEqual(expect.arrayContaining(['--allowed-host', '.example.com'])); - }); - - it('passes --keep-alive through to the daemon child args', 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({ port: 58627, logLevel: 'info', keepAlive: true }); - - const [, args] = spawnMock.mock.calls[0]!; - expect(args).toEqual(expect.arrayContaining(['--keep-alive'])); - }); -}); - -describe('ensureDaemon surfaces boot failures via early exit', () => { - let workDir: string; - let prevHome: string | undefined; - - beforeEach(() => { - workDir = mkdtempSync(join(tmpdir(), 'kimi-ensure-exit-')); - prevHome = process.env['KIMI_CODE_HOME']; - process.env['KIMI_CODE_HOME'] = workDir; - vi.resetModules(); - }); - - afterEach(() => { - if (prevHome === undefined) { - delete process.env['KIMI_CODE_HOME']; - } else { - process.env['KIMI_CODE_HOME'] = prevHome; - } - rmSync(workDir, { recursive: true, force: true }); - }); - - it('rejects fast with the exit reason and a log tail when the daemon exits early', async () => { - const { spawn } = await import('node:child_process'); - const spawnMock = vi.mocked(spawn); - const { mkdirSync, writeFileSync: writeSync } = await import('node:fs'); - const { daemonLogPath, ensureDaemon } = await import('#/cli/sub/server/daemon'); - - // Seed the daemon log with the kind of line a failing boot writes, so we - // can assert it is surfaced to the user instead of a generic timeout. - mkdirSync(dirname(daemonLogPath()), { recursive: true }); - writeSync(daemonLogPath(), 'fatal: Refusing to bind a non-loopback host without TLS.\n'); - - // Fake child that exits with code 1 shortly after the 'exit' listener is - // attached — simulating a daemon that fails during boot. - const fakeChild = { - unref: vi.fn(), - once: vi.fn((event: string, cb: (...a: unknown[]) => void) => { - if (event === 'exit') { - setTimeout(() => { - cb(1, null); - }, 5); - } - return fakeChild; - }), - }; - spawnMock.mockReturnValueOnce(fakeChild as unknown as ChildProcess); - - const start = Date.now(); - let caught: unknown; - try { - await ensureDaemon({ port: 0 }); - } catch (error) { - caught = error; - } - const elapsed = Date.now() - start; - - expect(caught).toBeInstanceOf(Error); - const message = (caught as Error).message; - expect(message).toMatch(/exited with code 1/); - expect(message).toContain('Refusing to bind'); - // Must fail fast — nowhere near the 20s spawn timeout. - expect(elapsed).toBeLessThan(5000); - }); -}); - -describe('createIdleShutdownHandler', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - afterEach(() => { - vi.useRealTimers(); - }); - - it('does not arm before any client connects', async () => { - const { createIdleShutdownHandler } = await import('#/cli/sub/server/run'); - const onIdle = vi.fn(); - const handler = createIdleShutdownHandler({ graceMs: 1000, onIdle }); - handler.onConnectionCountChange(0); - vi.advanceTimersByTime(2000); - expect(onIdle).not.toHaveBeenCalled(); - }); - - it('fires onIdle after the grace once the last client leaves', async () => { - const { createIdleShutdownHandler } = await import('#/cli/sub/server/run'); - const onIdle = vi.fn(); - const handler = createIdleShutdownHandler({ graceMs: 1000, onIdle }); - handler.onConnectionCountChange(1); - handler.onConnectionCountChange(0); - vi.advanceTimersByTime(999); - expect(onIdle).not.toHaveBeenCalled(); - vi.advanceTimersByTime(1); - expect(onIdle).toHaveBeenCalledTimes(1); - }); - - it('cancels a pending exit when a client reconnects during the grace', async () => { - const { createIdleShutdownHandler } = await import('#/cli/sub/server/run'); - const onIdle = vi.fn(); - const handler = createIdleShutdownHandler({ graceMs: 1000, onIdle }); - handler.onConnectionCountChange(1); - handler.onConnectionCountChange(0); - vi.advanceTimersByTime(500); - handler.onConnectionCountChange(1); // reconnect - vi.advanceTimersByTime(2000); - expect(onIdle).not.toHaveBeenCalled(); - }); - - it('only the final drop to zero arms the timer with multiple clients', async () => { - const { createIdleShutdownHandler } = await import('#/cli/sub/server/run'); - const onIdle = vi.fn(); - const handler = createIdleShutdownHandler({ graceMs: 500, onIdle }); - handler.onConnectionCountChange(1); - handler.onConnectionCountChange(2); - handler.onConnectionCountChange(1); // still one connected - vi.advanceTimersByTime(1000); - expect(onIdle).not.toHaveBeenCalled(); - handler.onConnectionCountChange(0); // now none - vi.advanceTimersByTime(500); - expect(onIdle).toHaveBeenCalledTimes(1); - }); -}); - -describe('kimi web / `server run` (shares `server run` call stack, background default)', () => { - it('prints the ready banner and opens the browser by default', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let stdout = ''; - const openUrl = vi.fn(); - - await handleRunCommand( - { port: '58627', open: true }, - { - startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), - openUrl, - stdout: { - write(chunk: string | Uint8Array) { - stdout += String(chunk); - return true; - }, - }, - stderr: { - write() { - return true; - }, - }, - }, - ); - - expect(stripAnsi(stdout)).toContain('Kimi server ready'); - expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); - }); - - it('does not open the browser when open is false', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - const openUrl = vi.fn(); - await handleRunCommand( - { port: '58627' }, - { - startServerBackground: async () => ({ origin: 'http://127.0.0.1:9000' }), - openUrl, - stdout: { write: () => true }, - stderr: { write: () => true }, - }, - ); - expect(openUrl).not.toHaveBeenCalled(); - }); - - it('rejects an invalid --log-level before touching the daemon', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - const startServerBackground = vi.fn(); - await expect( - handleRunCommand( - { logLevel: 'shout' }, - { - startServerBackground, - openUrl: vi.fn(), - stdout: { write: () => true }, - stderr: { write: () => true }, - }, - ), - ).rejects.toThrow(/invalid --log-level/); - expect(startServerBackground).not.toHaveBeenCalled(); - }); -}); - -describe('kimi web (foreground default)', () => { - it('runs in the foreground by default instead of spawning a daemon', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let foregroundOptions: unknown; - const startServerBackground = vi.fn(async () => ({ origin: 'http://127.0.0.1:58627' })); - - await handleRunCommand( - { port: '58627' }, - { - startServerBackground, - startServerForeground: async (options) => { - foregroundOptions = options; - return undefined as unknown as never; - }, - findReusableDaemon: async () => undefined, - openUrl: vi.fn(), - stdout: { write: () => true }, - stderr: { write: () => true }, - }, - { defaultForeground: true }, - ); - - expect(startServerBackground).not.toHaveBeenCalled(); - // Foreground is always keep-alive. - expect(foregroundOptions).toMatchObject({ port: 58627, keepAlive: true }); - }); - - it('`--background` restores the daemon behavior', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - const startServerBackground = vi.fn(async () => ({ origin: 'http://127.0.0.1:58627' })); - const startServerForeground = vi.fn(); - const findReusableDaemon = vi.fn(async () => undefined); - - await handleRunCommand( - { port: '58627', background: true }, - { - startServerBackground, - startServerForeground, - findReusableDaemon, - openUrl: vi.fn(), - stdout: { write: () => true }, - stderr: { write: () => true }, - }, - { defaultForeground: true }, - ); - - expect(startServerBackground).toHaveBeenCalledOnce(); - expect(startServerForeground).not.toHaveBeenCalled(); - // Background mode reuses daemons via ensureDaemon, not the foreground probe. - expect(findReusableDaemon).not.toHaveBeenCalled(); - }); - - it('`--foreground` stays foreground even when combined with the default', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let foregroundOptions: unknown; - - await handleRunCommand( - { port: '58627', foreground: true }, - { - startServerBackground: vi.fn(async () => ({ origin: 'http://127.0.0.1:58627' })), - startServerForeground: async (options) => { - foregroundOptions = options; - return undefined as unknown as never; - }, - findReusableDaemon: async () => undefined, - openUrl: vi.fn(), - stdout: { write: () => true }, - stderr: { write: () => true }, - }, - { defaultForeground: true }, - ); - - expect(foregroundOptions).toMatchObject({ port: 58627 }); - }); - - it('reuses an already-running server instead of failing to bind the port', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let stdout = ''; - const openUrl = vi.fn(); - const startServerForeground = vi.fn(); - - await handleRunCommand( - { port: '58627', open: true }, - { - startServerBackground: vi.fn(async () => ({ origin: 'http://127.0.0.1:58627' })), - startServerForeground, - findReusableDaemon: async () => ({ - origin: 'http://127.0.0.1:58627', - reused: true, - host: '127.0.0.1', - port: 58627, - }), - resolveToken: () => 'tok', - openUrl, - stdout: { - write(chunk: string | Uint8Array) { - stdout += String(chunk); - return true; - }, - }, - stderr: { write: () => true }, - }, - { defaultForeground: true }, - ); - - expect(startServerForeground).not.toHaveBeenCalled(); - const plain = stripAnsi(stdout); - expect(plain).toContain('A server is already running'); - expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/#token=tok'); - // The reused server is a daemon living in another process — the Stop hint - // stays `kimi server kill`, not Ctrl+C. - expect(plain).toContain('Stop:'); - expect(plain).toContain('kimi server kill'); - // No hostVersion recorded/attached → no version mismatch line. - expect(plain).not.toContain('Server version mismatch'); - }); - - it('hints at a version mismatch when reusing a server from another CLI version', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let stdout = ''; - - await handleRunCommand( - { port: '58627' }, - { - startServerBackground: vi.fn(async () => ({ origin: 'http://127.0.0.1:58627' })), - startServerForeground: vi.fn(), - findReusableDaemon: async () => ({ - origin: 'http://127.0.0.1:58627', - reused: true, - host: '127.0.0.1', - port: 58627, - hostVersion: '0.0.0-test-old', - }), - openUrl: vi.fn(), - stdout: { - write(chunk: string | Uint8Array) { - stdout += String(chunk); - return true; - }, - }, - stderr: { write: () => true }, - }, - { defaultForeground: true }, - ); - - const plain = stripAnsi(stdout); - expect(plain).toContain('Server version mismatch'); - expect(plain).toContain('0.0.0-test-old'); - }); - - it('`server run --foreground` never probes for a reusable daemon', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - const findReusableDaemon = vi.fn(async () => undefined); - - await handleRunCommand( - { port: '58627', foreground: true }, - { - startServerBackground: vi.fn(async () => ({ origin: 'http://127.0.0.1:58627' })), - startServerForeground: async () => undefined as unknown as never, - findReusableDaemon, - openUrl: vi.fn(), - stdout: { write: () => true }, - stderr: { write: () => true }, - }, - ); - - expect(findReusableDaemon).not.toHaveBeenCalled(); - }); -}); - -function makeKillDeps(overrides: Partial = {}): { - deps: KillCommandDeps; - writes: string[]; - signals: Array<{ pid: number; signal: NodeJS.Signals }>; - state: { shutdownCalls: number }; - clock: { t: number }; -} { - const writes: string[] = []; - const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; - const state = { shutdownCalls: 0 }; - const clock = { t: 0 }; - const deps: KillCommandDeps = { - getLiveLock: () => undefined, - requestShutdown: async () => { - state.shutdownCalls += 1; - }, - resolveToken: () => undefined, - signalPid: (pid, signal) => { - signals.push({ pid, signal }); - return true; - }, - pidAlive: () => false, - sleep: async (ms) => { - clock.t += ms; - }, - stdout: { - write(chunk: string | Uint8Array) { - writes.push(String(chunk)); - return true; - }, - }, - now: () => clock.t, - ...overrides, - }; - return { deps, writes, signals, state, clock }; -} - -describe('`kimi server kill`', () => { - const liveLock = { pid: 1234, started_at: '2026-06-17T00:00:00.000Z', port: 58627 }; - - it('prints "No running Kimi server." and sends no signal when no live lock exists', async () => { - const { handleKillCommand } = await import('#/cli/sub/server/kill'); - const { deps, writes, signals } = makeKillDeps({ getLiveLock: () => undefined }); - - await handleKillCommand(deps); - - expect(writes.join('')).toContain('No running Kimi server.'); - expect(signals).toEqual([]); - }); - - it('attempts the API shutdown, then stops after SIGTERM when the pid exits promptly', async () => { - const { handleKillCommand } = await import('#/cli/sub/server/kill'); - const { deps, writes, signals, state, clock } = makeKillDeps({ - getLiveLock: () => liveLock, - pidAlive: () => clock.t < 50, - }); - - await handleKillCommand(deps); - - expect(state.shutdownCalls).toBe(1); - expect(signals).toEqual([{ pid: 1234, signal: 'SIGTERM' }]); - expect(writes.join('')).toContain('pid 1234'); - expect(writes.join('')).toContain('stopped.'); - }); - - it('escalates to SIGKILL when the pid survives SIGTERM', async () => { - const { handleKillCommand } = await import('#/cli/sub/server/kill'); - const { deps, writes, signals, clock } = makeKillDeps({ - getLiveLock: () => ({ ...liveLock, pid: 5678 }), - // Survives the 3s SIGTERM grace, dies during the 2s SIGKILL grace. - pidAlive: () => clock.t < 3100, - }); - - await handleKillCommand(deps); - - expect(signals.map((s) => s.signal)).toEqual(['SIGTERM', 'SIGKILL']); - expect(writes.join('')).toContain('pid 5678'); - expect(writes.join('')).toContain('killed.'); - }); - - it('throws a permissions error when the pid survives SIGKILL', async () => { - const { handleKillCommand } = await import('#/cli/sub/server/kill'); - const { deps } = makeKillDeps({ - getLiveLock: () => ({ ...liveLock, pid: 9999 }), - pidAlive: () => true, - }); - - await expect(handleKillCommand(deps)).rejects.toThrow(/insufficient permissions/); - }); -}); - -describe('resolveServerToken', () => { - let dir: string; - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'kimi-server-token-')); - }); - afterEach(() => { - rmSync(dir, { recursive: true, force: true }); - }); - - it('reads the token from /server.token', async () => { - const { resolveServerToken } = await import('#/cli/sub/server/shared'); - writeFileSync(join(dir, 'server.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; diff --git a/apps/kimi-code/test/cli/telemetry.test.ts b/apps/kimi-code/test/cli/telemetry.test.ts index 8b06558629..a558b752fe 100644 --- a/apps/kimi-code/test/cli/telemetry.test.ts +++ b/apps/kimi-code/test/cli/telemetry.test.ts @@ -60,7 +60,6 @@ describe('initializeServerTelemetry', () => { it('configures the sink with ui_mode="web" and the CLI product identity', async () => { const { initializeServerTelemetry } = await import('#/cli/telemetry'); const client = initializeServerTelemetry({ version: '1.2.3' }); - expect(mocks.initializeTelemetry).toHaveBeenCalledWith( expect.objectContaining({ appName: 'kimi-code-cli', @@ -81,7 +80,10 @@ describe('initializeServerTelemetry', () => { setContext: expect.any(Function), }), ); - }); + // The first dynamic import pulls in the whole SDK/oauth chain (~3s idle, + // more under full-suite transform contention) — give it headroom past the + // 5s default timeout. + }, 20000); it('disables telemetry when config.toml sets telemetry = false', async () => { mocks.loadRuntimeConfigSafe.mockReturnValue({ diff --git a/apps/kimi-code/test/cli/web/web.test.ts b/apps/kimi-code/test/cli/web/web.test.ts new file mode 100644 index 0000000000..6532d4dbef --- /dev/null +++ b/apps/kimi-code/test/cli/web/web.test.ts @@ -0,0 +1,1130 @@ +/** + * Tests for the `kimi web` Commander wiring and its subcommands. + * + * These tests don't actually start the server — the foreground runner is + * injected, so they verify option parsing, the ready banner / one-line ready + * output, browser opening, and the kill / ps / rotate-token subcommands + * against fake deps. + */ + +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import chalk, { Chalk } from 'chalk'; +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerWebCommand } from '#/cli/sub/web'; +import type { KillCommandDeps } from '#/cli/sub/web/kill'; +import type { WebCommandDeps } from '#/cli/sub/web/run'; +import type { ParsedServerOptions } from '#/cli/sub/web/shared'; +import { darkColors } from '#/tui/theme/colors'; + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawn: vi.fn() }; +}); + +function stripAnsi(text: string): string { + return text.replaceAll(/\[[0-9;]*m/g, ''); +} + +function makeProgram(): Command { + // `commander` exitOverride avoids killing the test runner when --help/error fires. + const program = new Command('kimi').exitOverride(); + registerWebCommand(program); + return program; +} + +type ForegroundRunner = NonNullable; + +/** + * Fake foreground runner: records the parsed options and fires `onReady` with + * a fixed origin, then returns (the real runner blocks until SIGINT/SIGTERM). + */ +function makeRunner(origin = 'http://127.0.0.1:58627'): { + runner: ForegroundRunner; + calls: { options: ParsedServerOptions | undefined }; +} { + const calls: { options: ParsedServerOptions | undefined } = { options: undefined }; + const runner: ForegroundRunner = async (options, hooks) => { + calls.options = options; + hooks?.onReady?.(origin); + return undefined as never; + }; + return { runner, calls }; +} + +/** Capturing stdout/stderr pair for `WebCommandDeps`. */ +function makeIo(): { + stdout: Pick; + stderr: Pick; + readStdout(): string; +} { + let out = ''; + return { + stdout: { + write(chunk: string | Uint8Array) { + out += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + readStdout: () => out, + }; +} + +describe('kimi web', () => { + it('registers the `web` command with the kill/ps/rotate-token subcommands', () => { + const program = makeProgram(); + const web = program.commands.find((c) => c.name() === 'web'); + expect(web).toBeDefined(); + const subs = web?.commands.map((c) => c.name()).toSorted(); + expect(subs).toEqual(['kill', 'ps', 'rotate-token']); + }); + + it('exposes the foreground server options on `web` itself', () => { + const program = makeProgram(); + const web = program.commands.find((c) => c.name() === 'web'); + expect(web).toBeDefined(); + const longs = web!.options.map((o) => o.long).filter(Boolean); + expect(longs).toContain('--port'); + expect(longs).toContain('--host'); + expect(longs).toContain('--allowed-host'); + expect(longs).toContain('--insecure-no-tls'); + expect(longs).toContain('--allow-remote-shutdown'); + expect(longs).toContain('--allow-remote-terminals'); + expect(longs).toContain('--dangerous-bypass-auth'); + expect(longs).toContain('--log-level'); + expect(longs).toContain('--debug-endpoints'); + // web opens the browser by default → the option is the negative --no-open. + expect(longs).toContain('--no-open'); + // The background/daemon era flags are gone: the server always runs in the + // foreground. + expect(longs).not.toContain('--foreground'); + expect(longs).not.toContain('--keep-alive'); + expect(longs).not.toContain('--daemon'); + expect(longs).not.toContain('--idle-grace-ms'); + }); + + it('routes `kimi server` and any legacy subcommand to a deprecation notice', async () => { + for (const argv of [ + ['node', 'kimi', 'server'], + ['node', 'kimi', 'server', 'run', '--port', '1'], + ['node', 'kimi', 'server', 'kill', 'abc'], + ['node', 'kimi', 'server', 'ps', '--json'], + ]) { + const program = makeProgram(); + let stderr = ''; + const exitCalls: number[] = []; + const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + exitCalls.push(code ?? 0); + return undefined; + }) as never); + + await program.parseAsync(argv); + errSpy.mockRestore(); + exitSpy.mockRestore(); + + expect(exitCalls).toEqual([1]); + expect(stderr).toContain('`kimi server` has been deprecated and no longer works.'); + expect(stderr).toContain('kimi web'); + expect(stderr).toContain('next major version'); + } + }); +}); + +describe('`kimi web` ready banner', () => { + it('prints the TUI-style ready panel once listening', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + // The runner reports the actual bound origin — the banner must take the + // port from it, not from the requested --port. + const { runner } = makeRunner('http://127.0.0.1:58628'); + const { stdout, stderr, readStdout } = makeIo(); + + await handleWebCommand( + { port: '58627', open: false }, + { + startServerForeground: runner, + resolveToken: () => 'tok', + openUrl: vi.fn(), + stdout, + stderr, + }, + ); + + const plain = stripAnsi(readStdout()); + expect(plain).toContain('Kimi server ready'); + expect(plain).toContain('Local:'); + expect(plain).toContain('http://127.0.0.1:58628/#token=tok'); + expect(plain).toContain('Token:'); + // Loopback bind shows a Network hint for enabling network access. + expect(plain).toContain('Network:'); + expect(plain).toContain('use --host to enable'); + expect(plain).toContain('Logs:'); + expect(plain).toContain('off'); + expect(plain).toContain('Stop:'); + expect(plain).toContain('kimi web kill'); + // 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('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 () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner(); + const { stdout, stderr, readStdout } = makeIo(); + const previousChalkLevel = chalk.level; + chalk.level = 3; + + try { + await handleWebCommand( + { port: '58627', host: '127.0.0.1', open: false }, + { startServerForeground: runner, openUrl: vi.fn(), stdout, stderr }, + ); + } finally { + chalk.level = previousChalkLevel; + } + + const out = readStdout(); + const color = new Chalk({ level: 3 }); + expect(out).toContain(color.hex(darkColors.primary)('▐█▛█▛█▌')); + expect(out).toContain(color.bold.hex(darkColors.primary)('Kimi server ready')); + expect(out).toContain(color.hex(darkColors.accent)('http://127.0.0.1:58627/')); + expect(out).toContain(color.bold.hex(darkColors.textDim)('Local: ')); + expect(out).toContain(color.hex(darkColors.textMuted)('off')); + }); + + it('renders the bypass danger notice in the error color', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner(); + const { stdout, stderr, readStdout } = makeIo(); + const previousChalkLevel = chalk.level; + chalk.level = 3; + + try { + await handleWebCommand( + { port: '58627', dangerousBypassAuth: true, open: false }, + { startServerForeground: runner, openUrl: vi.fn(), stdout, stderr }, + ); + } finally { + chalk.level = previousChalkLevel; + } + + const color = new Chalk({ level: 3 }); + expect(readStdout()).toContain( + color.bold.hex(darkColors.error)( + '⚠ DANGER: authentication is DISABLED (--dangerous-bypass-auth).', + ), + ); + }); + + it('prints the danger notice and suppresses the token when auth is bypassed', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner(); + const { stdout, stderr, readStdout } = makeIo(); + const openUrl = vi.fn(); + + await handleWebCommand( + { port: '58627', host: '127.0.0.1', dangerousBypassAuth: true, open: true }, + { + startServerForeground: runner, + resolveToken: () => 'tok', + openUrl, + stdout, + stderr, + }, + ); + + const plain = stripAnsi(readStdout()); + // Red, impossible-to-miss danger notice. + expect(plain).toContain('DANGER: authentication is DISABLED'); + expect(plain).toContain('--dangerous-bypass-auth'); + expect(plain).toContain('kimi web kill'); + // The token is irrelevant when bypassed — neither printed nor carried in + // any URL (so it cannot leak via copy/paste of the banner). + expect(plain).not.toContain('tok'); + expect(plain).not.toContain('#token='); + // The opened browser URL carries no token fragment either. + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); + }); +}); + +describe('ready banner reflects the bind class', () => { + it('lists Local + Network addresses for a 0.0.0.0 bind (Vite-style)', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner('http://0.0.0.0:58627'); + const { stdout, stderr, readStdout } = makeIo(); + + await handleWebCommand( + { host: '0.0.0.0', open: false }, + { + startServerForeground: runner, + resolveToken: () => 'tok-xyz', + networkAddresses: [ + { address: '192.168.98.66', family: 'IPv4' }, + { address: '10.8.12.216', family: 'IPv4' }, + ], + openUrl: vi.fn(), + stdout, + stderr, + }, + ); + + const raw = stripAnsi(readStdout()); + 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('lists only the Local URL for a 127.0.0.1 bind', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner('http://127.0.0.1:58627'); + const { stdout, stderr, readStdout } = makeIo(); + + await handleWebCommand( + { host: '127.0.0.1', open: false }, + { + startServerForeground: runner, + resolveToken: () => 'tok-loop', + // Injected interface addresses must NOT leak into a loopback banner. + networkAddresses: [{ address: '192.168.98.66', family: 'IPv4' }], + openUrl: vi.fn(), + stdout, + stderr, + }, + ); + + const raw = stripAnsi(readStdout()); + expect(raw).toContain('Kimi server ready'); + expect(raw).toContain('Local:'); + expect(raw).toContain('http://127.0.0.1:58627/#token=tok-loop'); + expect(raw).toContain('Token:'); + expect(raw).toContain('tok-loop'); + // No network URLs on a loopback bind — just the "off" hint. + expect(raw).toContain('use --host to enable'); + expect(raw).not.toContain('Network: http'); + expect(raw).not.toContain('192.168.98.66'); + expect(raw).not.toContain('╭'); + }); +}); + +describe('`kimi web` opens the browser', () => { + it('opens the Web UI URL with the #token= fragment by default', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner(); + const { stdout, stderr } = makeIo(); + const openUrl = vi.fn(); + + await handleWebCommand( + { port: '58627', open: true }, + { + startServerForeground: runner, + resolveToken: () => 'tok-xyz', + openUrl, + stdout, + stderr, + }, + ); + + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/#token=tok-xyz'); + }); + + it('opens the plain origin when no token is resolvable', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner(); + const { stdout, stderr } = makeIo(); + const openUrl = vi.fn(); + + await handleWebCommand( + { port: '58627', open: true }, + { + startServerForeground: runner, + resolveToken: () => undefined, + openUrl, + stdout, + stderr, + }, + ); + + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); + }); + + it('does not open the browser when open is false', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner('http://127.0.0.1:9000'); + const { stdout, stderr } = makeIo(); + const openUrl = vi.fn(); + + await handleWebCommand( + { port: '58627', open: false }, + { startServerForeground: runner, openUrl, stdout, stderr }, + ); + + expect(openUrl).not.toHaveBeenCalled(); + }); +}); + +describe('`kimi web` option threading', () => { + it('threads the CLI flags into the foreground runner options', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner, calls } = makeRunner(); + const { stdout, stderr } = makeIo(); + + await handleWebCommand( + { + port: '59000', + host: '0.0.0.0', + insecureNoTls: true, + allowedHost: ['.example.com'], + dangerousBypassAuth: true, + debugEndpoints: true, + allowRemoteShutdown: true, + allowRemoteTerminals: true, + open: false, + }, + { startServerForeground: runner, openUrl: vi.fn(), stdout, stderr }, + ); + + expect(calls.options).toEqual({ + host: '0.0.0.0', + port: 59000, + logLevel: 'silent', + debugEndpoints: true, + insecureNoTls: true, + allowRemoteShutdown: true, + allowRemoteTerminals: true, + dangerousBypassAuth: true, + allowedHosts: ['.example.com'], + }); + }); + + it('defaults the host to 127.0.0.1 and insecureNoTls to true', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner, calls } = makeRunner(); + const { stdout, stderr } = makeIo(); + + await handleWebCommand( + { port: '58627', open: false }, + { startServerForeground: runner, openUrl: vi.fn(), stdout, stderr }, + ); + + expect(calls.options).toMatchObject({ + host: '127.0.0.1', + insecureNoTls: true, + logLevel: 'silent', + }); + }); + + it('maps a bare --host to the default LAN host', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner, calls } = makeRunner(); + const { stdout, stderr } = makeIo(); + + await handleWebCommand( + { port: '58627', host: true, open: false }, + { startServerForeground: runner, openUrl: vi.fn(), stdout, stderr }, + ); + + expect(calls.options).toMatchObject({ host: '0.0.0.0', insecureNoTls: true }); + }); + + it('passes --log-level through to the runner', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner, calls } = makeRunner(); + const { stdout, stderr } = makeIo(); + + await handleWebCommand( + { port: '58627', logLevel: 'debug', open: false }, + { startServerForeground: runner, openUrl: vi.fn(), stdout, stderr }, + ); + + expect(calls.options).toMatchObject({ logLevel: 'debug' }); + }); + + it('rejects an invalid --log-level before calling the runner', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const startServerForeground = vi.fn(async () => undefined as never); + const { stdout, stderr } = makeIo(); + + await expect( + handleWebCommand( + { logLevel: 'shout', open: false }, + { startServerForeground, openUrl: vi.fn(), stdout, stderr }, + ), + ).rejects.toThrow(/invalid --log-level/); + expect(startServerForeground).not.toHaveBeenCalled(); + }); + + it('prints the one-line ready line instead of the full banner with a non-default --log-level', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner(); + const { stdout, stderr, readStdout } = makeIo(); + + await handleWebCommand( + { port: '58627', logLevel: 'info', open: false }, + { + startServerForeground: runner, + resolveToken: () => 'tok', + openUrl: vi.fn(), + stdout, + stderr, + }, + ); + + const plain = stripAnsi(readStdout()); + expect(plain).toContain('Kimi server: http://127.0.0.1:58627/#token=tok'); + expect(plain).not.toContain('Kimi server ready'); + expect(plain).not.toContain('Local:'); + }); + + it('parses comma-separated --allowed-host values', async () => { + const { parseAllowedHostArgs } = await import('#/cli/sub/web/shared'); + expect(parseAllowedHostArgs(['.example.com, app.example.com'])).toEqual([ + '.example.com', + 'app.example.com', + ]); + }); +}); + +describe('shared parsers stay strict', () => { + it('rejects out-of-range --port', async () => { + const { parsePort } = await import('#/cli/sub/web/shared'); + expect(() => parsePort('99999', '--port', 58627)).toThrow(/invalid --port/); + expect(() => parsePort('-1', '--port', 58627)).toThrow(/invalid --port/); + expect(parsePort(undefined, '--port', 58627)).toBe(58627); + expect(parsePort('8080', '--port', 58627)).toBe(8080); + }); + + it('rejects unknown --log-level values', async () => { + const { parseLogLevel } = await import('#/cli/sub/web/shared'); + expect(() => parseLogLevel('shout')).toThrow(/invalid --log-level/); + expect(parseLogLevel(undefined)).toBe('info'); + expect(parseLogLevel('debug')).toBe('debug'); + }); +}); + +describe('server web asset directory resolution', () => { + it('uses extracted SEA web assets when available', async () => { + const { resolveServerWebAssetsDir } = await import('#/cli/sub/web/run'); + expect(resolveServerWebAssetsDir('/cache/kimi/dist-web')).toBe('/cache/kimi/dist-web'); + }); + + it('falls back to package dist-web outside SEA mode', async () => { + const { resolveServerWebAssetsDir } = await import('#/cli/sub/web/run'); + expect(resolveServerWebAssetsDir(null)).toMatch(/[/\\]dist-web$/); + }); +}); + +describe('instanceConnectHost (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 { instanceConnectHost } = await import('#/cli/sub/web/shared'); + // The server 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 kill/ps + // Authorization tests). + expect( + instanceConnectHost({ + serverId: 'srv', + pid: 1, + startedAt: 0, + heartbeatAt: 0, + port: 58627, + host: '0.0.0.0', + }), + ).toBe('127.0.0.1'); + }); + + it('preserves a loopback / concrete bind host', async () => { + const { instanceConnectHost } = await import('#/cli/sub/web/shared'); + const base = { serverId: 'srv', pid: 1, startedAt: 0, heartbeatAt: 0, port: 58627 }; + expect(instanceConnectHost({ ...base, host: '127.0.0.1' })).toBe('127.0.0.1'); + expect(instanceConnectHost({ ...base, host: '192.168.1.5' })).toBe('192.168.1.5'); + }); +}); + +function makeKillDeps(overrides: Partial = {}): { + deps: KillCommandDeps; + writes: string[]; + signals: Array<{ pid: number; signal: NodeJS.Signals }>; + state: { shutdownCalls: number }; + clock: { t: number }; +} { + const writes: string[] = []; + const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + const state = { shutdownCalls: 0 }; + const clock = { t: 0 }; + const deps: KillCommandDeps = { + getLiveInstances: async () => [], + requestShutdown: async () => { + state.shutdownCalls += 1; + }, + resolveToken: () => undefined, + signalPid: (pid, signal) => { + signals.push({ pid, signal }); + return true; + }, + pidAlive: () => false, + sleep: async (ms) => { + clock.t += ms; + }, + stdout: { + write(chunk: string | Uint8Array) { + writes.push(String(chunk)); + return true; + }, + }, + now: () => clock.t, + ...overrides, + }; + return { deps, writes, signals, state, clock }; +} + +describe('`kimi web kill`', () => { + const liveInstance = { + serverId: 'srv-1', + pid: 1234, + host: '127.0.0.1', + port: 58627, + startedAt: 1000, + heartbeatAt: 1000, + }; + + it('prints "No running Kimi server." and sends no signal when no live instance exists', async () => { + const { handleKillCommand } = await import('#/cli/sub/web/kill'); + const { deps, writes, signals } = makeKillDeps({ getLiveInstances: async () => [] }); + + await handleKillCommand(deps); + + expect(writes.join('')).toContain('No running Kimi server.'); + expect(signals).toEqual([]); + }); + + it('attempts the API shutdown, then stops after SIGTERM when the pid exits promptly', async () => { + const { handleKillCommand } = await import('#/cli/sub/web/kill'); + const { deps, writes, signals, state, clock } = makeKillDeps({ + getLiveInstances: async () => [liveInstance], + pidAlive: () => clock.t < 50, + }); + + await handleKillCommand(deps); + + expect(state.shutdownCalls).toBe(1); + expect(signals).toEqual([{ pid: 1234, signal: 'SIGTERM' }]); + expect(writes.join('')).toContain('pid 1234'); + expect(writes.join('')).toContain('stopped.'); + }); + + it('escalates to SIGKILL when the pid survives SIGTERM', async () => { + const { handleKillCommand } = await import('#/cli/sub/web/kill'); + const { deps, writes, signals, clock } = makeKillDeps({ + getLiveInstances: async () => [{ ...liveInstance, pid: 5678 }], + // Survives the 3s SIGTERM grace, dies during the 2s SIGKILL grace. + pidAlive: () => clock.t < 3100, + }); + + await handleKillCommand(deps); + + expect(signals.map((s) => s.signal)).toEqual(['SIGTERM', 'SIGKILL']); + expect(writes.join('')).toContain('pid 5678'); + expect(writes.join('')).toContain('killed.'); + }); + + it('throws a permissions error when the pid survives SIGKILL', async () => { + const { handleKillCommand } = await import('#/cli/sub/web/kill'); + const { deps } = makeKillDeps({ + getLiveInstances: async () => [{ ...liveInstance, pid: 9999 }], + pidAlive: () => true, + }); + + await expect(handleKillCommand(deps)).rejects.toThrow(/insufficient permissions/); + }); + + it('targets only the instance matching the given server-id', async () => { + const { handleKillCommand } = await import('#/cli/sub/web/kill'); + const other = { ...liveInstance, serverId: 'srv-2', pid: 5678, port: 58628 }; + const { deps, writes, signals } = makeKillDeps({ + getLiveInstances: async () => [liveInstance, other], + pidAlive: () => false, + }); + + await handleKillCommand(deps, 'srv-2'); + + expect(signals).toEqual([{ pid: 5678, signal: 'SIGTERM' }]); + expect(writes.join('')).toContain('pid 5678'); + }); + + it('throws and lists live server ids when the given server-id matches nothing', async () => { + const { handleKillCommand } = await import('#/cli/sub/web/kill'); + const { deps, signals } = makeKillDeps({ + getLiveInstances: async () => [liveInstance], + }); + + await expect(handleKillCommand(deps, 'srv-nope')).rejects.toThrow( + /No running Kimi server with id srv-nope\. Live servers: srv-1\./, + ); + expect(signals).toEqual([]); + }); + + it('kills every live instance when given the `all` keyword', async () => { + const { handleKillCommand } = await import('#/cli/sub/web/kill'); + const other = { ...liveInstance, serverId: 'srv-2', pid: 5678, port: 58628 }; + const { deps, writes, signals, state } = makeKillDeps({ + getLiveInstances: async () => [liveInstance, other], + pidAlive: () => false, + }); + + await handleKillCommand(deps, 'all'); + + expect(state.shutdownCalls).toBe(2); + expect(signals).toEqual([ + { pid: 1234, signal: 'SIGTERM' }, + { pid: 5678, signal: 'SIGTERM' }, + ]); + const out = writes.join(''); + expect(out).toContain('server srv-1 (pid 1234) stopped.'); + expect(out).toContain('server srv-2 (pid 5678) stopped.'); + }); + + it('continues past a failed instance and reports the failure at the end', async () => { + const { handleKillCommand } = await import('#/cli/sub/web/kill'); + const wedged = { ...liveInstance, serverId: 'srv-2', pid: 9999, port: 58628 }; + const { deps, writes, signals } = makeKillDeps({ + getLiveInstances: async () => [liveInstance, wedged], + // srv-1 dies on SIGTERM; srv-2 survives everything. + pidAlive: (pid) => pid === 9999, + }); + + await expect(handleKillCommand(deps, 'all')).rejects.toThrow( + /server srv-2: Failed to stop Kimi server \(pid 9999\); insufficient permissions\?/, + ); + + // The healthy instance was still stopped before the error surfaced. + expect(signals).toEqual([ + { pid: 1234, signal: 'SIGTERM' }, + { pid: 9999, signal: 'SIGTERM' }, + { pid: 9999, signal: 'SIGKILL' }, + ]); + expect(writes.join('')).toContain('server srv-1 (pid 1234) stopped.'); + }); +}); + +describe('resolveServerToken', () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-server-token-')); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('reads the token from /server.token', async () => { + const { resolveServerToken } = await import('#/cli/sub/web/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/web/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/web/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/web/shared'); + expect(authHeaders('abc')).toEqual({ Authorization: 'Bearer abc' }); + }); +}); + +describe('`kimi web kill` carries the bearer token', () => { + const liveInstance = { + serverId: 'srv-1', + pid: 1234, + host: '127.0.0.1', + port: 58627, + startedAt: 1000, + heartbeatAt: 1000, + }; + + it('passes the resolved token to requestShutdown', async () => { + const { handleKillCommand } = await import('#/cli/sub/web/kill'); + let seenToken: string | undefined = 'unset'; + const { deps } = makeKillDeps({ + getLiveInstances: async () => [liveInstance], + 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/web/kill'); + let seenToken: string | undefined = 'unset'; + const { deps } = makeKillDeps({ + getLiveInstances: async () => [liveInstance], + resolveToken: () => undefined, + requestShutdown: async (_origin, token) => { + seenToken = token; + }, + pidAlive: () => false, + }); + + await handleKillCommand(deps); + + expect(seenToken).toBeUndefined(); + }); +}); + +describe('`kimi web ps`', () => { + let dir: string; + let prevHome: string | undefined; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-ps-')); + prevHome = process.env['KIMI_CODE_HOME']; + process.env['KIMI_CODE_HOME'] = dir; + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + if (prevHome === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = prevHome; + } + rmSync(dir, { recursive: true, force: true }); + }); + + function writeInstance(serverId: string, port: number, startedAt: number): void { + mkdirSync(join(dir, 'server', 'instances'), { recursive: true }); + writeFileSync( + join(dir, 'server', 'instances', `${serverId}.json`), + JSON.stringify({ + server_id: serverId, + pid: process.pid, + host: '127.0.0.1', + port, + started_at: startedAt, + heartbeat_at: startedAt, + }), + ); + } + + function connection(id: string, userAgent: string): Record { + return { + id, + connected_at: new Date().toISOString(), + remote_address: null, + user_agent: userAgent, + has_client_hello: true, + subscriptions: [], + }; + } + + function stubFetchForTwoServers(): void { + vi.stubGlobal('fetch', async (input: unknown) => { + const url = String(input); + if (url.endsWith('/api/v1/healthz')) { + return new Response(JSON.stringify({ code: 0 }), { status: 200 }); + } + if (url === 'http://127.0.0.1:58627/api/v1/connections') { + return new Response( + JSON.stringify({ + code: 0, + msg: 'ok', + data: { connections: [connection('conn-a', 'agent-a')] }, + }), + { status: 200 }, + ); + } + if (url === 'http://127.0.0.1:58628/api/v1/connections') { + return new Response( + JSON.stringify({ + code: 0, + msg: 'ok', + data: { connections: [connection('conn-b', 'agent-b')] }, + }), + { status: 200 }, + ); + } + return new Response('not found', { status: 404 }); + }); + } + + function captureStdout(): { read(): string; restore(): void } { + let stdout = ''; + const spy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + return { + read: () => stdout, + restore: () => spy.mockRestore(), + }; + } + + it('lists connections grouped by server id, oldest instance first', async () => { + writeFileSync(join(dir, 'server.token'), 'tok'); + writeInstance('srv-a', 58627, 1000); + writeInstance('srv-b', 58628, 2000); + stubFetchForTwoServers(); + + const { registerWebCommand } = await import('#/cli/sub/web'); + const program = new Command('kimi').exitOverride(); + registerWebCommand(program); + const out = captureStdout(); + + await program.parseAsync(['node', 'kimi', 'web', 'ps']); + out.restore(); + + const plain = stripAnsi(out.read()); + expect(plain).toContain('server srv-a (pid '); + expect(plain).toContain('server srv-b (pid '); + // Oldest instance first, each connection under its own server section. + expect(plain.indexOf('server srv-a')).toBeLessThan(plain.indexOf('agent-a')); + expect(plain.indexOf('agent-a')).toBeLessThan(plain.indexOf('server srv-b')); + expect(plain.indexOf('server srv-b')).toBeLessThan(plain.indexOf('agent-b')); + }); + + it('prints per-server sections in --json', async () => { + writeFileSync(join(dir, 'server.token'), 'tok'); + writeInstance('srv-a', 58627, 1000); + writeInstance('srv-b', 58628, 2000); + stubFetchForTwoServers(); + + const { registerWebCommand } = await import('#/cli/sub/web'); + const program = new Command('kimi').exitOverride(); + registerWebCommand(program); + const out = captureStdout(); + + await program.parseAsync(['node', 'kimi', 'web', 'ps', '--json']); + out.restore(); + + const parsed = JSON.parse(out.read()) as { + servers: Array<{ server_id: string; connections: Array<{ id: string }> }>; + }; + expect(parsed.servers.map((s) => s.server_id)).toEqual(['srv-a', 'srv-b']); + expect(parsed.servers[0]?.connections.map((c) => c.id)).toEqual(['conn-a']); + expect(parsed.servers[1]?.connections.map((c) => c.id)).toEqual(['conn-b']); + }); + + it('errors when no server is running', async () => { + const { registerWebCommand } = await import('#/cli/sub/web'); + const program = new Command('kimi').exitOverride(); + registerWebCommand(program); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + let stderr = ''; + const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); + + await program.parseAsync(['node', 'kimi', 'web', 'ps']); + errSpy.mockRestore(); + exitSpy.mockRestore(); + + expect(stderr).toContain('No running Kimi server.'); + }); +}); + +describe('buildWebUrl', () => { + it('carries the token in the URL fragment (not path or query)', async () => { + const { buildWebUrl } = await import('#/cli/sub/web/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/web/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/web/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/web/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/web/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/web/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 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 { registerWebCommand } = await import('#/cli/sub/web'); + const program = new Command('kimi').exitOverride(); + registerWebCommand(program); + let stdout = ''; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + + await program.parseAsync(['node', 'kimi', 'web', '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 { registerWebCommand } = await import('#/cli/sub/web'); + const { mkdirSync, writeFileSync: writeSync } = await import('node:fs'); + // Fake a live instance-registry entry pointing at this (alive) process so + // getLiveServerInstance() finds the running server and the command can + // re-print its links. + mkdirSync(join(dir, 'server', 'instances'), { recursive: true }); + writeSync( + join(dir, 'server', 'instances', '01JTEST0000000000000000000.json'), + JSON.stringify({ + server_id: '01JTEST0000000000000000000', + pid: process.pid, + host: '127.0.0.1', + port: 58627, + started_at: Date.now(), + heartbeat_at: Date.now(), + }), + ); + + const program = new Command('kimi').exitOverride(); + registerWebCommand(program); + let stdout = ''; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + + await program.parseAsync(['node', 'kimi', 'web', '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/web/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/web/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' }, + ]); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/web.test.ts b/apps/kimi-code/test/tui/commands/web.test.ts index 7301ce7155..f4a69f5b21 100644 --- a/apps/kimi-code/test/tui/commands/web.test.ts +++ b/apps/kimi-code/test/tui/commands/web.test.ts @@ -1,38 +1,31 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { StartForegroundHooks } from '#/cli/sub/server/run'; import { findBuiltInSlashCommand, resolveSlashCommandAvailability } from '#/tui/commands/index'; import type { SlashCommandHost } from '#/tui/commands/dispatch'; import { handleWebCommand, webSessionUrl } from '#/tui/commands/web'; const mocks = vi.hoisted(() => ({ - ensureDaemon: vi.fn(), - findReusableDaemon: vi.fn(), - startServerForeground: vi.fn(), + getLiveServerInstance: vi.fn(), + isServerHealthy: vi.fn(), tryResolveServerToken: vi.fn(), getDataDir: vi.fn(() => '/tmp/kimi-home'), openUrl: vi.fn(), })); -vi.mock('#/cli/sub/server/daemon', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('@moonshot-ai/kap-server', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getLiveServerInstance: mocks.getLiveServerInstance }; +}); + +vi.mock('#/cli/sub/web/shared', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, - ensureDaemon: mocks.ensureDaemon, - findReusableDaemon: mocks.findReusableDaemon, + isServerHealthy: mocks.isServerHealthy, + tryResolveServerToken: mocks.tryResolveServerToken, }; }); -vi.mock('#/cli/sub/server/run', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, startServerForeground: mocks.startServerForeground }; -}); - -vi.mock('#/cli/sub/server/shared', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, tryResolveServerToken: mocks.tryResolveServerToken }; -}); - vi.mock('#/utils/open-url', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, openUrl: mocks.openUrl }; @@ -48,10 +41,6 @@ type MountedPanel = { render: (width: number) => string[]; }; -function stripAnsi(text: string): string { - return text.replaceAll(/\[([0-9;]*)m/g, ''); -} - function makeHost() { let mountedPanel: MountedPanel | null = null; const host = { @@ -63,7 +52,6 @@ function makeHost() { }), restoreEditor: vi.fn(), setExitOpenUrl: vi.fn(), - setExitForegroundTask: vi.fn(), stop: vi.fn(async () => {}), } as unknown as SlashCommandHost & { showStatus: ReturnType; @@ -71,7 +59,6 @@ function makeHost() { mountEditorReplacement: ReturnType; restoreEditor: ReturnType; setExitOpenUrl: ReturnType; - setExitForegroundTask: ReturnType; stop: ReturnType; }; return { host, getMountedPanel: () => mountedPanel }; @@ -89,247 +76,84 @@ describe('handleWebCommand', () => { beforeEach(() => { vi.clearAllMocks(); mocks.getDataDir.mockReturnValue('/tmp/kimi-home'); - mocks.ensureDaemon.mockResolvedValue({ - origin: 'http://127.0.0.1:58627', - reused: false, + mocks.getLiveServerInstance.mockResolvedValue({ + serverId: 'srv-1', + pid: 1234, host: '127.0.0.1', port: 58627, + startedAt: 1, + heartbeatAt: 1, }); - mocks.findReusableDaemon.mockResolvedValue(undefined); + mocks.isServerHealthy.mockResolvedValue(true); }); - describe('--background', () => { - it('shows the token in green and opens the deep link carrying the token fragment', async () => { - mocks.tryResolveServerToken.mockReturnValue('tok-1'); - const { host, getMountedPanel } = makeHost(); - - const pending = handleWebCommand(host, '--background'); - getMountedPanel()?.handleInput('\r'); - await pending; - - expect(host.showStatus).toHaveBeenCalledWith('Starting Kimi server and opening web UI…'); - expect(host.showStatus).toHaveBeenCalledWith( - 'open http://127.0.0.1:58627/sessions/ses-1#token=tok-1', - 'success', - ); - expect(host.showStatus).toHaveBeenCalledWith('Token: tok-1', 'success'); - expect(mocks.openUrl).toHaveBeenCalledWith( - 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', - ); - expect(host.setExitOpenUrl).toHaveBeenCalledWith( - 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', - ); - expect(mocks.ensureDaemon).toHaveBeenCalledOnce(); - expect(mocks.startServerForeground).not.toHaveBeenCalled(); - expect(host.setExitForegroundTask).not.toHaveBeenCalled(); - expect(host.stop).toHaveBeenCalledOnce(); - }); - - it('resolves the token after the daemon is up so first-time starts carry it', async () => { - // A fresh server writes `server.token` during startup; resolving any - // earlier (e.g. right after the confirm dialog) would miss it. - const callOrder: string[] = []; - mocks.ensureDaemon.mockImplementation(async () => { - callOrder.push('ensureDaemon'); - return { origin: 'http://127.0.0.1:58627', reused: false, host: '127.0.0.1', port: 58627 }; - }); - mocks.tryResolveServerToken.mockImplementation(() => { - callOrder.push('resolveToken'); - return 'tok-1'; - }); - const { host, getMountedPanel } = makeHost(); - - const pending = handleWebCommand(host, '--background'); - getMountedPanel()?.handleInput('\r'); - await pending; - - expect(callOrder).toEqual(['ensureDaemon', 'resolveToken']); - expect(mocks.openUrl).toHaveBeenCalledWith( - 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', - ); - }); - - it('skips the token line and fragment when no token is available', async () => { - mocks.tryResolveServerToken.mockReturnValue(undefined); - const { host, getMountedPanel } = makeHost(); - - const pending = handleWebCommand(host, '--background'); - getMountedPanel()?.handleInput('\r'); - await pending; - - expect(host.showStatus).toHaveBeenCalledWith('Starting Kimi server and opening web UI…'); - expect(host.showStatus).toHaveBeenCalledWith( - 'open http://127.0.0.1:58627/sessions/ses-1', - 'success', - ); - expect(host.showStatus).not.toHaveBeenCalledWith( - expect.stringContaining('Token:'), - 'success', - ); - expect(mocks.openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/sessions/ses-1'); - expect(host.setExitOpenUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/sessions/ses-1'); - }); - - it('warns about a version mismatch when the reused daemon is from another CLI version', async () => { - mocks.tryResolveServerToken.mockReturnValue('tok-1'); - mocks.ensureDaemon.mockResolvedValue({ - origin: 'http://127.0.0.1:58627', - reused: true, - host: '127.0.0.1', - port: 58627, - hostVersion: '9.9.9-test-old', - }); - const { host, getMountedPanel } = makeHost(); - - const pending = handleWebCommand(host, '--background'); - getMountedPanel()?.handleInput('\r'); - await pending; + it('shows the token in green and opens the deep link carrying the token fragment', async () => { + mocks.tryResolveServerToken.mockReturnValue('tok-1'); + const { host, getMountedPanel } = makeHost(); - expect(host.showStatus).toHaveBeenCalledWith( - expect.stringContaining('Running server is version 9.9.9-test-old'), - 'warning', - ); - }); - - it('describes the background daemon in the confirmation step', async () => { - const { host, getMountedPanel } = makeHost(); - - const pending = handleWebCommand(host, '--background'); - const rendered = getMountedPanel()?.render(120).join('\n') ?? ''; - getMountedPanel()?.handleInput('\r'); - await pending; + const pending = handleWebCommand(host); + getMountedPanel()?.handleInput('\r'); + await pending; - expect(rendered).toContain('background daemon'); - }); + expect(host.showStatus).toHaveBeenCalledWith( + 'open http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + 'success', + ); + expect(host.showStatus).toHaveBeenCalledWith('Token: tok-1', 'success'); + expect(mocks.openUrl).toHaveBeenCalledWith( + 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + ); + expect(host.setExitOpenUrl).toHaveBeenCalledWith( + 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + ); + expect(host.stop).toHaveBeenCalledOnce(); }); - describe('default (foreground)', () => { - it('reuses an already-running server instead of starting a foreground one', async () => { - mocks.tryResolveServerToken.mockReturnValue('tok-1'); - mocks.findReusableDaemon.mockResolvedValue({ - origin: 'http://127.0.0.1:58627', - reused: true, - host: '127.0.0.1', - port: 58627, - }); - const { host, getMountedPanel } = makeHost(); - - const pending = handleWebCommand(host, ''); - getMountedPanel()?.handleInput('\r'); - await pending; - - expect(mocks.openUrl).toHaveBeenCalledWith( - 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', - ); - expect(host.setExitOpenUrl).toHaveBeenCalledWith( - 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', - ); - expect(host.stop).toHaveBeenCalledOnce(); - expect(host.setExitForegroundTask).not.toHaveBeenCalled(); - expect(mocks.startServerForeground).not.toHaveBeenCalled(); - expect(mocks.ensureDaemon).not.toHaveBeenCalled(); - }); - - it('warns about a version mismatch when the reused server is from another CLI version', async () => { - mocks.tryResolveServerToken.mockReturnValue('tok-1'); - mocks.findReusableDaemon.mockResolvedValue({ - origin: 'http://127.0.0.1:58627', - reused: true, - host: '127.0.0.1', - port: 58627, - hostVersion: '9.9.9-test-old', - }); - const { host, getMountedPanel } = makeHost(); + it('skips the token line and fragment when no token is available', async () => { + mocks.tryResolveServerToken.mockReturnValue(undefined); + const { host, getMountedPanel } = makeHost(); - const pending = handleWebCommand(host, ''); - getMountedPanel()?.handleInput('\r'); - await pending; + const pending = handleWebCommand(host); + getMountedPanel()?.handleInput('\r'); + await pending; - expect(host.showStatus).toHaveBeenCalledWith( - expect.stringContaining('Running server is version 9.9.9-test-old'), - 'warning', - ); - expect(mocks.openUrl).toHaveBeenCalledWith( - 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', - ); - expect(host.setExitForegroundTask).not.toHaveBeenCalled(); - }); - - it('registers a foreground exit task that starts the server and opens the deep link', async () => { - mocks.tryResolveServerToken.mockReturnValue('tok-1'); - let readyHooks: StartForegroundHooks | undefined; - mocks.startServerForeground.mockImplementation( - (_options: unknown, hooks?: StartForegroundHooks) => { - readyHooks = hooks; - return new Promise(() => {}); - }, - ); - const { host, getMountedPanel } = makeHost(); - - const pending = handleWebCommand(host, ''); - getMountedPanel()?.handleInput('\r'); - await pending; + expect(host.showStatus).toHaveBeenCalledWith( + 'open http://127.0.0.1:58627/sessions/ses-1', + 'success', + ); + expect(host.showStatus).not.toHaveBeenCalledWith(expect.stringContaining('Token:'), 'success'); + expect(mocks.openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/sessions/ses-1'); + expect(host.setExitOpenUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/sessions/ses-1'); + }); - expect(mocks.startServerForeground).not.toHaveBeenCalled(); - expect(host.setExitOpenUrl).not.toHaveBeenCalled(); - expect(mocks.openUrl).not.toHaveBeenCalled(); - expect(mocks.ensureDaemon).not.toHaveBeenCalled(); - expect(mocks.tryResolveServerToken).not.toHaveBeenCalled(); - expect(host.stop).toHaveBeenCalledOnce(); - expect(host.setExitForegroundTask).toHaveBeenCalledOnce(); + it('shows an error and does not exit when no server is running', async () => { + mocks.getLiveServerInstance.mockResolvedValue(undefined); + const { host, getMountedPanel } = makeHost(); - // Run the exit task the way run-shell's onExit would: it starts the - // foreground server; the ready hook prints and opens the deep link. - const task = host.setExitForegroundTask.mock.calls[0]?.[0] as ( - exitCode: number, - ) => Promise; - const taskPending = task(0); - expect(mocks.startServerForeground).toHaveBeenCalledOnce(); - const runOptions = mocks.startServerForeground.mock.calls[0]?.[0] as { - keepAlive: boolean; - host: string; - port: number; - }; - expect(runOptions.keepAlive).toBe(true); - expect(runOptions.host).toBe('127.0.0.1'); - expect(runOptions.port).toBe(58627); + const pending = handleWebCommand(host); + getMountedPanel()?.handleInput('\r'); + await pending; - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); - try { - readyHooks?.onReady?.('http://127.0.0.1:58627'); - // The token is resolved inside the ready hook — after the server has - // written `server.token` on first boot — never during the TUI phase. - expect(mocks.tryResolveServerToken).toHaveBeenCalledOnce(); - expect(mocks.openUrl).toHaveBeenCalledWith( - 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', - ); - const written = stripAnsi(stdoutSpy.mock.calls.map((call) => String(call[0])).join('')); - // Same ready banner as `kimi web`, plus the session deep link. - expect(written).toContain('Kimi server ready'); - expect(written).toContain('http://127.0.0.1:58627/'); - expect(written).toContain('Token: tok-1'); - expect(written).toContain('Session: http://127.0.0.1:58627/sessions/ses-1#token=tok-1'); - // Foreground servers stop with Ctrl+C, not `kimi server kill`. - expect(written).toContain('Stop: Ctrl+C'); - expect(written).not.toContain('kimi server kill'); - } finally { - stdoutSpy.mockRestore(); - } - // Keep the never-resolving task from outliving the test. - void taskPending; - }); + expect(host.showError).toHaveBeenCalledWith( + 'No running Kimi server. Start one with `kimi web` in another terminal first.', + ); + expect(mocks.openUrl).not.toHaveBeenCalled(); + expect(host.stop).not.toHaveBeenCalled(); + }); - it('describes the foreground behavior in the confirmation step', async () => { - const { host, getMountedPanel } = makeHost(); + it('shows an error and does not exit when the running server is unhealthy', async () => { + mocks.isServerHealthy.mockResolvedValue(false); + const { host, getMountedPanel } = makeHost(); - const pending = handleWebCommand(host, ''); - const rendered = getMountedPanel()?.render(120).join('\n') ?? ''; - getMountedPanel()?.handleInput('\r'); - await pending; + const pending = handleWebCommand(host); + getMountedPanel()?.handleInput('\r'); + await pending; - expect(rendered).toContain('foreground'); - expect(rendered).toContain('Ctrl+C'); - }); + expect(host.showError).toHaveBeenCalledWith( + 'Kimi server at http://127.0.0.1:58627 is not responding.', + ); + expect(mocks.openUrl).not.toHaveBeenCalled(); + expect(host.stop).not.toHaveBeenCalled(); }); }); diff --git a/apps/kimi-inspect/vite/serverDiscovery.ts b/apps/kimi-inspect/vite/serverDiscovery.ts index 8fcdf7a88d..efe324c25c 100644 --- a/apps/kimi-inspect/vite/serverDiscovery.ts +++ b/apps/kimi-inspect/vite/serverDiscovery.ts @@ -5,8 +5,8 @@ * * kap-server already self-registers for peer discovery * (`packages/kap-server/src/instanceRegistry.ts`): - * multi_server `/server/instances/.json` - * single-server `/server/lock` + * current builds `/server/instances/.json` + * pre-registry builds `/server/lock` * and persists the bearer token at `/server.token` (one token per * home, shared by every instance). The browser cannot read those files, but * this Vite process can, so `GET /__inspect/servers` answers with the live @@ -57,7 +57,7 @@ interface ServerInstanceDisk { host_version?: string; } -/** Mirror of `LockContents` (`packages/kap-server/src/lock.ts`). */ +/** Mirror of the legacy single-server lock written by pre-registry builds. */ interface ServerLockDisk { pid?: number; host?: string; @@ -146,7 +146,8 @@ export async function readLiveInstances(homeDir: string): Promise entry.info); } -/** The legacy single-server lock (`/server/lock`), when its pid is alive. */ +/** The legacy single-server lock (`/server/lock`) written by pre-registry + * builds, when its pid is alive. Current builds never write it. */ export async function readLiveLock(homeDir: string): Promise { const disk = await readJson(join(homeDir, 'server', 'lock')); if (disk === undefined || typeof disk.pid !== 'number' || typeof disk.port !== 'number') { diff --git a/apps/kimi-web/AGENTS.md b/apps/kimi-web/AGENTS.md index 3e7dc6299d..eb59df3c04 100644 --- a/apps/kimi-web/AGENTS.md +++ b/apps/kimi-web/AGENTS.md @@ -51,7 +51,7 @@ All via `pnpm --filter @moonshot-ai/kimi-web …`: - `check:style` — design-system §06 anti-pattern guard (`scripts/check-style.mjs`). - There is **no `lint` script** in this package; linting runs at the repo root via oxlint. -Debugging against kap-server instances: start one from the repo root with `pnpm dev:server` (port 58627), optionally a second with `pnpm dev:v2` (`KIMI_CODE_EXPERIMENTAL_MULTI_SERVER=1`, port 58628 — both can run at once). The dev server proxies `/api/v1` to the `default` preset; the Sidebar brand row carries a dev-only backend pill (engine generation `v1`/`v2` from `GET /api/v1/meta`'s `backend` field + endpoint) whose menu repoints the proxy at runtime — no Vite restart. Presets default to `http://127.0.0.1:58627` / `:58628`, overridable via `KIMI_BACKEND_DEFAULT_URL` / `KIMI_BACKEND_MULTI_URL`; the switcher endpoints (`GET/POST /__kimi-dev/backend`, dev-only, see `backendSwitcherPlugin` in `vite.config.ts`) drive the menu. +Debugging against kap-server instances: start one from the repo root with `pnpm dev:server` (port 58627), optionally a second with `pnpm dev:v2` (port 58628 — instances share the home dir via the registry, so both can run at once). The dev server proxies `/api/v1` to the `default` preset; the Sidebar brand row carries a dev-only backend pill (engine generation `v1`/`v2` from `GET /api/v1/meta`'s `backend` field + endpoint) whose menu repoints the proxy at runtime — no Vite restart. Presets default to `http://127.0.0.1:58627` / `:58628`, overridable via `KIMI_BACKEND_DEFAULT_URL` / `KIMI_BACKEND_MULTI_URL`; the switcher endpoints (`GET/POST /__kimi-dev/backend`, dev-only, see `backendSwitcherPlugin` in `vite.config.ts`) drive the menu. ## Gotchas / hard rules diff --git a/apps/kimi-web/README.md b/apps/kimi-web/README.md index 0519a441ef..93ae4a4ac0 100644 --- a/apps/kimi-web/README.md +++ b/apps/kimi-web/README.md @@ -96,7 +96,7 @@ web UI of the `kimi` CLI (`apps/kimi-code`). 4. **Publish** — the root `.github/workflows/release.yml` publishes `@moonshot-ai/kimi-code` to npm; `dist-web` is listed in the package `files` array, so the built web assets travel with the CLI package. -5. **Serve** — `kimi server run` / `kimi web` serves `dist-web` from the +5. **Serve** — `kimi web` serves `dist-web` from the installed package. The web UI does not display its own package version or build commit. It is diff --git a/apps/kimi-web/src/api/daemon/serverAuth.ts b/apps/kimi-web/src/api/daemon/serverAuth.ts index e8c01e1b5f..4aa04ed8b8 100644 --- a/apps/kimi-web/src/api/daemon/serverAuth.ts +++ b/apps/kimi-web/src/api/daemon/serverAuth.ts @@ -13,7 +13,7 @@ // days so it survives tab close and browser restarts without becoming a // permanent browser-profile secret. The token is already persisted server-side // at /server.token and handed to the browser in the launch URL. -// `kimi server rotate-token` invalidates a stale copy, and the next 401 clears +// `kimi web rotate-token` invalidates a stale copy, and the next 401 clears // it here. const STORAGE_KEY = 'kimi-web.server-credential'; @@ -241,7 +241,7 @@ export function clearCredential(): void { // Only clear the persisted copy when it still holds the credential this // tab was using. localStorage is shared across tabs, so an unconditional // removal would let a stale tab erase a newer token another tab stored - // (e.g. right after `kimi server rotate-token`). + // (e.g. right after `kimi web rotate-token`). const raw = globalThis.localStorage?.getItem(STORAGE_KEY); const stored = raw === null || raw === undefined ? undefined diff --git a/apps/kimi-web/vite.config.ts b/apps/kimi-web/vite.config.ts index 6e348ae84c..9f907988b6 100644 --- a/apps/kimi-web/vite.config.ts +++ b/apps/kimi-web/vite.config.ts @@ -9,9 +9,9 @@ import { fileURLToPath } from 'node:url'; const webPort = Number(process.env.WEB_PORT) || 5175; // Dev-proxy backend presets: `default` is the kap-server started by the root // `pnpm dev:server` (port 58627); `multi` is a second kap-server instance -// started with `pnpm dev:v2` (KIMI_CODE_EXPERIMENTAL_MULTI_SERVER=1, port -// 58628) for multi-instance debugging. Override with KIMI_BACKEND_DEFAULT_URL -// / KIMI_BACKEND_MULTI_URL. +// started with `pnpm dev:v2` (port 58628 — instances share the home dir, so +// both can run at once) for multi-instance debugging. Override with +// KIMI_BACKEND_DEFAULT_URL / KIMI_BACKEND_MULTI_URL. const backendPresets = { default: process.env.KIMI_BACKEND_DEFAULT_URL || 'http://127.0.0.1:58627', multi: process.env.KIMI_BACKEND_MULTI_URL || 'http://127.0.0.1:58628', diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index dce8bd813d..1d4b9db767 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -120,7 +120,7 @@ In `stream-json` mode, regular replies produce an Assistant message; when the mo ## Subcommands -`kimi` provides the following subcommands: `login` (non-interactive login), `acp` (ACP IDE mode), `server` (run and manage the local REST/WebSocket/web service), `web` (open the web UI; runs the server in the foreground by default), `doctor` (validate configuration files), `export` (export a session), `migrate` (migrate legacy data), `upgrade` (check for updates), and `provider` (manage providers). +`kimi` provides the following subcommands: `login` (non-interactive login), `acp` (ACP IDE mode), `web` (run the local REST/WebSocket/web service in the foreground and open the web UI), `doctor` (validate configuration files), `export` (export a session), `migrate` (migrate legacy data), `upgrade` (check for updates), and `provider` (manage providers). ### `kimi login` @@ -140,80 +140,51 @@ Switch Kimi Code CLI to ACP (Agent Client Protocol) mode, communicating with an kimi acp ``` -### `kimi server` +### `kimi web` -Run, install, and manage the local Kimi server — a single process that exposes the REST + WebSocket API and serves the web UI from the same origin. The parent command is split into an on-demand entrypoint (`run`) and an OS-managed service lifecycle (`install`, `uninstall`, `start`, `stop`, `restart`, `status`). `kimi server run` ensures a single background daemon is running and returns once it is healthy; pass `--foreground` to keep the server attached to the current terminal instead. +Run the local Kimi server in the foreground of the current terminal — a single process that exposes the REST + WebSocket API and serves the web UI from the same origin — and open the web UI in the default browser once it is ready. The command stays attached to the terminal and shuts down cleanly on `SIGINT` / `SIGTERM` (e.g. `Ctrl-C`). When the server is running, `GET /openapi.json` returns the REST OpenAPI document and `GET /asyncapi.json` returns the local WebSocket AsyncAPI document. ```sh -kimi server run # start or reuse a background daemon -kimi server run --foreground # run attached to the current terminal -kimi server install # register with launchd / systemd / schtasks -kimi server start # start the OS-managed service -kimi server status # snapshot of installed/running state +kimi web # run the server in the foreground and open the browser +kimi web --no-open # don't open the browser +kimi web --port 58628 # pick a specific bind port ``` -#### `kimi server run` +Multiple instances can share one home directory: each registers itself under `~/.kimi-code/server/instances/`, and a busy port is retried with `port + 1` (58628, 58629, …). | Option | Description | | --- | --- | -| `--port ` | Bind port; defaults to `58627` | +| `--port ` | Bind port; defaults to `58627`; a busy port is retried with `+1` | +| `--host [host]` | Bind host; omit for `127.0.0.1` (this machine only), pass a bare `--host` for `0.0.0.0` (all interfaces) | +| `--allowed-host ` | Extra Host header values allowed through the DNS-rebinding check; repeatable or comma-separated | | `--log-level ` | Enable server logs at the selected level; omitted by default | | `--debug-endpoints` | Mount `/api/v1/debug/*` routes (off by default) | -| `--keep-alive` | Keep the server running instead of exiting after 60s with no connected clients; implied by `--host` / `--allowed-host` and always on with `--foreground` | | `--dangerous-bypass-auth` | Disable bearer-token auth on all REST and WebSocket routes so the web UI connects without a token; only for trusted networks or behind an authenticating proxy | -| `--foreground` | Run in the foreground instead of spawning a background daemon | -| `--open` | Open the web UI in the default browser once the server is healthy | +| `--no-open` | Do not open the browser once the server is ready | -`kimi server run` binds to local loopback only. By default it spawns a single background daemon (reused across runs) and exits once the daemon is healthy; the daemon shuts itself down after the last web client disconnects. Pass `--keep-alive` to keep it running past the idle timeout, or `--foreground` to run the server in the current process instead — it then stays attached to the terminal and shuts down cleanly on `SIGINT` / `SIGTERM`. +`kimi web` binds to local loopback only by default and prints the bearer token in the startup banner; the web UI authenticates automatically via the `#token=` URL fragment. -::: danger -`--dangerous-bypass-auth` disables authentication entirely. Anyone who can reach the port gets full access to your sessions, filesystem, and shell. Only use it on a trusted network or behind your own authenticating reverse proxy, and run `kimi server kill` to stop the server when you are done. +::: info +The `kimi server` command tree is deprecated: any `kimi server …` invocation (including all legacy subcommands) only prints a deprecation notice and exits with code 1 — use `kimi web` instead. The notice will be removed in the next major version of Kimi Code. ::: -#### `kimi server install` - -Register the server as an OS-managed service so it starts at login and restarts after a crash. The backend picks itself based on the running platform: - -- **macOS**: writes a LaunchAgent plist to `~/Library/LaunchAgents/ai.moonshot.kimi-server.plist` and bootstraps it via `launchctl bootstrap gui/`. -- **Linux**: writes a `--user` systemd unit to `~/.config/systemd/user/kimi-server.service` and runs `systemctl --user enable --now`. -- **Windows**: registers a scheduled task named `KimiServer` via `schtasks /Create /XML`. - -| Option | Description | -| --- | --- | -| `--port ` | Bind port the supervised server uses; defaults to `58627` | -| `--log-level ` | Log level recorded in the generated unit | -| `--force` | Replace an existing install instead of failing | -| `--json` | Output JSON instead of a human-readable line | - -The loopback host, chosen port, and log level are recorded to `~/.kimi-code/server/install.json` so `kimi server status` can report them even when the service is stopped. - -#### Lifecycle subcommands - -| Command | Description | -| --- | --- | -| `kimi server uninstall` | Stop and remove the OS service definition. Idempotent. | -| `kimi server start` | Start the OS-managed service. Errors if not installed. | -| `kimi server stop` | Stop the OS-managed service. | -| `kimi server restart` | Restart the OS-managed service. | -| `kimi server status` | Print installed / running / pid / port / log-path. `--json` for automation. | +::: danger +`--dangerous-bypass-auth` disables authentication entirely. Anyone who can reach the port gets full access to your sessions, filesystem, and shell. Only use it on a trusted network or behind your own authenticating reverse proxy, and run `kimi web kill` to stop the server when you are done. +::: -#### `kimi web` +#### `kimi web kill [server-id|all]` -Opens Kimi's graphical session in the browser as an alternative to the terminal TUI. +Stop a running server instance: first tries `POST /api/v1/shutdown` for a graceful exit, then signals the instance pid with SIGTERM, escalating to SIGKILL when needed. With multiple instances sharing the home directory, `[server-id]` picks the target; without it the longest-running instance is stopped; the special keyword `all` stops every live instance; an unknown id errors with the live instance ids listed. -`kimi web` runs a local Kimi server in the foreground — the command stays attached to the terminal and the server stops when you press `Ctrl-C` — and opens the web UI in the default browser once the server is healthy. If a server is already running, it is reused: the command prints its address, opens the browser, and returns instead of binding a new port. Pass `--background` to start a background daemon and release the terminal immediately; the daemon shuts itself down after the last web client disconnects. +#### `kimi web ps` -The reused server keeps running whatever version started it — after an upgrade, an older server is reused as-is and the output points out the version mismatch. Run `kimi server kill` once after upgrading to restart on the new version. +List the clients currently connected to each instance (from `GET /api/v1/connections`), grouped by server id; `--json` prints the raw data nested per instance. -```sh -kimi web # run the server in the foreground and open the browser (reuses a running one) -kimi web --background # start a background daemon, open the browser, and release the terminal -kimi web --no-open # don't open the browser; keep the server attached to the terminal -``` +#### `kimi web rotate-token` -Stop a foreground server with `Ctrl-C` and a background one with `kimi server kill`, and list active connections with `kimi server ps`. `--port`, `--log-level`, `--foreground`, and the other flags match `kimi server run`; `--background` is only available on `kimi web`. +Generate a new persistent bearer token (written to `~/.kimi-code/server.token`); the previous token stops working immediately. The token is shared by the whole home directory, so every running instance picks the new one up on its next auth check — no restart needed. ### `kimi doctor` diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index b52ae0bea8..38b9174559 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -40,7 +40,6 @@ Some commands are only available in the idle state. Executing these commands whi | `/export-debug-zip` | — | Export the current session as a debug ZIP archive (same behavior as [`kimi export`](./kimi-command.md#kimi-export)) | No | | `/copy` | — | Copy the last assistant message to the clipboard | No | | `/add-dir []` | — | Add an extra workspace directory to the current session. Run without a path (or with `list`) to list configured directories. When adding, choose whether to remember the directory for the project in `.kimi-code/local.toml` | No | -| `/web [--background]` | — | Open the current session in the Web UI. By default the TUI exits and the server keeps running in the foreground on the same terminal (stop it with `Ctrl-C`); `--background` starts or reuses a background daemon and releases the terminal instead. See [`kimi web`](./kimi-command.md#kimi-web) | Yes | ## Modes & Run Control diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 3470eac198..e8d6f36b72 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -120,7 +120,7 @@ kimi -p "List changed files" --output-format stream-json ## 子命令 -`kimi` 提供以下子命令:`login`(非交互式登录)、`acp`(ACP IDE 模式)、`server`(运行并管理本地 REST/WebSocket/web 服务)、`web`(打开 web UI,默认前台运行服务)、`doctor`(校验配置文件)、`export`(导出会话)、`migrate`(迁移旧版数据)、`upgrade`(检查更新)、`provider`(管理供应商)。 +`kimi` 提供以下子命令:`login`(非交互式登录)、`acp`(ACP IDE 模式)、`web`(前台运行本地 REST/WebSocket/web 服务并打开 web UI)、`doctor`(校验配置文件)、`export`(导出会话)、`migrate`(迁移旧版数据)、`upgrade`(检查更新)、`provider`(管理供应商)。 ### `kimi login` @@ -140,80 +140,51 @@ kimi login kimi acp ``` -### `kimi server` +### `kimi web` -运行并管理本地 Kimi 服务 —— 同一个进程同时挂载 REST + WebSocket API 与 web UI。父命令拆成按需入口 (`run`) 与 OS 级生命周期管理 (`install`、`uninstall`、`start`、`stop`、`restart`、`status`)。`kimi server run` 会确保一个后台守护进程在运行、健康后返回;如需把服务挂在当前终端,请加 `--foreground`。 +在当前终端前台运行本地 Kimi 服务 —— 同一个进程同时挂载 REST + WebSocket API 与 web UI —— 并在服务就绪后用默认浏览器打开 web UI。命令会一直挂在终端,直到收到 `SIGINT` / `SIGTERM`(如 `Ctrl-C`)时干净退出。 服务运行时,`GET /openapi.json` 会返回 REST OpenAPI 文档,`GET /asyncapi.json` 会返回本地 WebSocket 协议的 AsyncAPI 文档。 ```sh -kimi server run # 启动或复用一个后台守护进程 -kimi server run --foreground # 挂在当前终端前台运行 -kimi server install # 注册到 launchd / systemd / schtasks -kimi server start # 启动 OS 管理的服务 -kimi server status # 查看安装与运行状态 +kimi web # 前台运行服务并打开浏览器 +kimi web --no-open # 不打开浏览器 +kimi web --port 58628 # 指定绑定端口 ``` -#### `kimi server run` +同一 home 目录下可以同时运行多个实例:每个实例注册到 `~/.kimi-code/server/instances/`,端口被占用时自动 +1 重试(58628、58629……)。 | 选项 | 说明 | | --- | --- | -| `--port ` | 绑定端口;默认 `58627` | +| `--port ` | 绑定端口;默认 `58627`;被占用时自动 +1 重试 | +| `--host [host]` | 绑定地址;缺省 `127.0.0.1`(仅本机),裸 `--host` 绑 `0.0.0.0`(所有网卡) | +| `--allowed-host ` | DNS 重绑定检查额外允许的 Host 头,可重复或逗号分隔 | | `--log-level ` | 按所选级别开启服务日志;默认不输出 | | `--debug-endpoints` | 挂载 `/api/v1/debug/*` 调试路由(默认关闭) | -| `--keep-alive` | 让服务在没有客户端连接 60 秒后继续运行,不会因空闲退出;`--host` / `--allowed-host` 会自动启用,`--foreground` 模式下始终开启 | | `--dangerous-bypass-auth` | 关闭所有 REST 与 WebSocket 路由的 bearer token 鉴权,使 web UI 无需 token 即可连接;仅用于可信网络或自有鉴权代理之后 | -| `--foreground` | 前台运行,不 spawn 后台守护进程 | -| `--open` | 服务健康后用默认浏览器打开 web UI | +| `--no-open` | 就绪后不自动打开浏览器 | -`kimi server run` 只绑定本机 loopback 地址。默认会 spawn 一个后台守护进程(多次运行会复用同一个),健康后即退出;守护进程在最后一个 web 客户端断开后自行关闭。加 `--keep-alive` 可让它在空闲超时后继续运行,或加 `--foreground` 则在当前进程中运行——保持挂在终端,在 `SIGINT` / `SIGTERM` 时干净退出。 +`kimi web` 默认只绑定本机 loopback 地址,并在启动横幅中打印 bearer token;web UI 通过 URL 的 `#token=` 片段自动完成鉴权。 -::: danger 警告 -`--dangerous-bypass-auth` 会彻底关闭鉴权。任何能访问该端口的人都能完全控制你的会话、文件系统和 shell。请仅在可信网络或自有鉴权反向代理之后使用,用完后运行 `kimi server kill` 停止服务。 +::: info 提示 +`kimi server` 命令树已废弃:任何 `kimi server …` 调用(含全部旧子命令)只会打印弃用提示并以退出码 1 结束,请改用 `kimi web`。该提示将在 Kimi Code 下个大版本移除。 ::: -#### `kimi server install` - -把服务注册成 OS 管理的进程,开机自启、崩溃后自动重启。根据当前平台选择对应后端: - -- **macOS**:写 LaunchAgent plist 到 `~/Library/LaunchAgents/ai.moonshot.kimi-server.plist`,并通过 `launchctl bootstrap gui/` 启动。 -- **Linux**:写 `--user` systemd unit 到 `~/.config/systemd/user/kimi-server.service`,并执行 `systemctl --user enable --now`。 -- **Windows**:通过 `schtasks /Create /XML` 注册名为 `KimiServer` 的计划任务。 - -| 选项 | 说明 | -| --- | --- | -| `--port ` | 被托管的服务绑定端口;默认 `58627` | -| `--log-level ` | 写入生成 unit 的日志级别 | -| `--force` | 已安装时强制覆盖 | -| `--json` | 用 JSON 替代人类可读输出 | - -本机地址、选定的端口和日志级别会写入 `~/.kimi-code/server/install.json`,即便服务停掉 `kimi server status` 也能读到。 - -#### 生命周期子命令 - -| 命令 | 说明 | -| --- | --- | -| `kimi server uninstall` | 停止并移除 OS 服务定义。幂等。 | -| `kimi server start` | 启动 OS 管理的服务。未安装时会报错。 | -| `kimi server stop` | 停止 OS 管理的服务。 | -| `kimi server restart` | 重启 OS 管理的服务。 | -| `kimi server status` | 打印 installed / running / pid / port / log-path;`--json` 用于脚本。 | +::: danger 警告 +`--dangerous-bypass-auth` 会彻底关闭鉴权。任何能访问该端口的人都能完全控制你的会话、文件系统和 shell。请仅在可信网络或自有鉴权反向代理之后使用,用完后运行 `kimi web kill` 停止服务。 +::: -#### `kimi web` +#### `kimi web kill [server-id|all]` -在浏览器中打开 Kimi 的图形会话界面,作为终端 TUI 的替代入口。 +停止运行中的服务实例:先请求 `POST /api/v1/shutdown` 优雅退出,再对实例 pid 发 SIGTERM、必要时升级为 SIGKILL。多实例并存时用 `[server-id]` 指定目标;缺省停止存活最久的实例;传入特殊关键字 `all` 停止全部实例;id 不存在时报错并列出所有存活实例 id。 -`kimi web` 在前台运行本地 Kimi 服务——命令保持挂在当前终端,按 `Ctrl-C` 即停止服务——服务健康后用默认浏览器打开 web UI。如果已有服务在运行,则直接复用:打印其地址、打开浏览器并返回,不会再绑定新端口。加 `--background` 则启动后台守护进程并立即释放终端;守护进程在最后一个 web 客户端断开后自行关闭。 +#### `kimi web ps` -被复用的服务保持启动它时的版本:升级后,旧版本的服务会被原样复用,输出中会提示版本不一致。升级后运行一次 `kimi server kill`,下次启动即使用新版本。 +按 server-id 分组列出每个实例当前连接的客户端(来自 `GET /api/v1/connections`);`--json` 输出按实例嵌套的原始数据。 -```sh -kimi web # 前台运行服务并打开浏览器(已运行则复用) -kimi web --background # 启动后台守护进程,打开浏览器后立即释放终端 -kimi web --no-open # 不打开浏览器,服务保持挂在当前终端 -``` +#### `kimi web rotate-token` -前台服务按 `Ctrl-C` 停止,后台守护进程用 `kimi server kill` 停止,查看活动连接用 `kimi server ps`。`--port`、`--log-level`、`--foreground` 等选项与 `kimi server run` 一致;`--background` 仅 `kimi web` 支持。 +生成新的持久化 bearer token(写入 `~/.kimi-code/server.token`),旧 token 立即失效。token 是整个 home 目录共享的,所有运行中的实例会在下一次鉴权校验时自动换用新 token,无需重启。 ### `kimi doctor` diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 2eb94c300b..64449d6572 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -38,7 +38,6 @@ | `/export-debug-zip` | — | 将当前会话导出为调试用 ZIP 压缩包(与 [`kimi export`](./kimi-command.md#kimi-export) 行为一致) | 否 | | `/copy` | — | 将最后一条 AI 回复复制到剪贴板 | 否 | | `/add-dir []` | — | 为当前会话添加额外的工作目录。不带路径(或传入 `list`)运行时列出已配置的目录。添加时可选择是否将目录记入项目的 `.kimi-code/local.toml` | 否 | -| `/web [--background]` | — | 在 Web UI 中打开当前会话。默认退出 TUI 并让服务在同一终端前台运行(按 `Ctrl-C` 停止);`--background` 则启动或复用后台守护进程并释放终端。参见 [`kimi web`](./kimi-command.md#kimi-web) | 是 | ## 模式与运行控制 diff --git a/packages/agent-core-v2/docs/flag.md b/packages/agent-core-v2/docs/flag.md index 0ac9340f0f..54f2cbf784 100644 --- a/packages/agent-core-v2/docs/flag.md +++ b/packages/agent-core-v2/docs/flag.md @@ -11,7 +11,7 @@ Gates not-yet-public features behind `IFlagService.enabled(id)`, per the reposit - `src/flag/flag.ts` — `IFlagService` token + resolver types (`ExperimentalFlagMap`, `ExperimentalFlagConfig`, `ExperimentalFlagSource`, `ExperimentalFeatureState`) + `ExperimentalConfigSchema` / `ExperimentalConfig` (zod). - `src/flag/flagService.ts` — `FlagService` impl + `MASTER_ENV` (`KIMI_CODE_EXPERIMENTAL_FLAG`) + `EXPERIMENTAL_SECTION` (`experimental`); reads definitions from `IFlagRegistry`; self-registers at App scope. - `src/flag/index.ts` — barrel; re-exported by `src/index.ts` at the L3 block. -- `src//flag.ts` — each domain that owns a flag declares it here and calls `registerFlagDefinition` at the module top level (e.g. `src/multiServer/flag.ts`). The directory already names the domain, so the file is just `flag.ts`. +- `src//flag.ts` — each domain that owns a flag declares it here and calls `registerFlagDefinition` at the module top level (e.g. `src/agent/toolSelect/flag.ts`). The directory already names the domain, so the file is just `flag.ts`. ## Public surface @@ -103,7 +103,7 @@ if (!this.flags.enabled('my_feature')) return; ## References - `packages/agent-core-v2/src/flag/` — implementation (`IFlagRegistry` + `IFlagService`). -- `packages/agent-core-v2/src/app/multiServer/flag.ts` — example per-domain flag contribution. +- `packages/agent-core-v2/src/agent/toolSelect/flag.ts` — example per-domain flag contribution. - `packages/agent-core-v2/test/flag/flag.test.ts` — precedence + config subscription tests. - `packages/agent-core/src/flags/` — v1 source this was ported from. - `plan/PLAN.md` §2/§3 — domain placement (`flag` at L3, not `_base/flags`). diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 2f4a57693a..81f1731e43 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -127,7 +127,6 @@ const DOMAIN_LAYER = new Map([ ['permissionPolicy', 3], ['permissionRules', 3], ['plugin', 3], - ['multiServer', 3], ['record', 3], ['modelCatalog', 3], ['agentProfileCatalog', 3], diff --git a/packages/agent-core-v2/src/app/multiServer/flag.ts b/packages/agent-core-v2/src/app/multiServer/flag.ts deleted file mode 100644 index b3bdc70f6d..0000000000 --- a/packages/agent-core-v2/src/app/multiServer/flag.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * `multi_server` experimental flag — gates the multi-server shared-homedir work. - * - * When enabled, a kap-server instance registers itself under - * `/server/instances/.json` instead of taking the legacy - * single-instance `/server/lock`, so multiple servers can share one home - * directory. Off by default; enable via `KIMI_CODE_EXPERIMENTAL_MULTI_SERVER`, - * the master `KIMI_CODE_EXPERIMENTAL_FLAG`, or the `[experimental]` config - * section. Imported for its side effect (registers the definition) from the - * package barrel. - */ - -import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; - -export const MULTI_SERVER_FLAG_ID = 'multi_server'; -export const MULTI_SERVER_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_MULTI_SERVER'; - -export const multiServerFlag: FlagDefinitionInput = { - id: MULTI_SERVER_FLAG_ID, - title: 'multi-server shared home', - description: - 'Allow multiple kap-server instances to share one home directory by registering each instance under server/instances/ instead of taking a single homedir lock.', - env: MULTI_SERVER_FLAG_ENV, - default: false, - surface: 'core', -}; - -registerFlagDefinition(multiServerFlag); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 8ecf108c84..66dbb44c60 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -164,9 +164,6 @@ export * from '#/app/flag/flagRegistryService'; export * from '#/app/flag/flag'; export * from '#/app/flag/flagService'; -import '#/app/multiServer/flag'; -export * from '#/app/multiServer/flag'; - export * from '#/agent/activityView/activityView'; import '#/agent/activityView/activityViewService'; import '#/agent/plan/profile/plan'; diff --git a/packages/kap-server/src/index.ts b/packages/kap-server/src/index.ts index 785c95cece..999cb62a56 100644 --- a/packages/kap-server/src/index.ts +++ b/packages/kap-server/src/index.ts @@ -3,7 +3,7 @@ * DI × Scope agent engine (`@moonshot-ai/agent-core-v2`). */ -export { startServer, ServerLockedError } from './start'; +export { startServer } from './start'; export type { ServerStartOptions, RunningServer } from './start'; export { okEnvelope, errEnvelope } from './envelope'; export type { Envelope } from './envelope'; @@ -16,16 +16,6 @@ export type { ServerLogger, ServerLogLevel, } from './services/pinoLoggerService'; -export { acquireLock, getLiveLock, DEFAULT_LOCK_PATH, DEFAULT_LOCK_DIR } from './lock'; -export type { AcquireLockOptions, AcquireLockResult, LockContents } from './lock'; -export { resolveServiceManager, ServiceUnavailableError, ServiceUnsupportedError } from './svc'; -export type { - InstallArgs, - InstallResult, - LifecycleResult, - ServiceManager, - ServiceStatus, -} from './svc'; export { createInstanceRegistry, listLiveServerInstances, diff --git a/packages/kap-server/src/instanceRegistry.ts b/packages/kap-server/src/instanceRegistry.ts index 912cfc8d5e..96c9305faa 100644 --- a/packages/kap-server/src/instanceRegistry.ts +++ b/packages/kap-server/src/instanceRegistry.ts @@ -1,17 +1,17 @@ /** - * Server instance registry — replaces the single-instance homedir lock when the - * `multi_server` experimental flag is on. + * Server instance registry — the discovery mechanism for kap-server instances + * sharing one home directory. * - * Instead of one exclusive `/server/lock`, every server instance writes a - * self-describing file under `/server/instances/.json`. Multiple - * instances can coexist in the same home directory and discover each other by - * reading the directory. Each file is single-writer (only its owning process - * ever rewrites it), so updates are race-free; stale entries left by a crashed - * peer are swept lazily on `register` / `listLive` via a `kill(pid, 0)` probe. + * Every server instance writes a self-describing file under + * `/server/instances/.json`. Multiple instances can coexist in + * the same home directory and discover each other by reading the directory. + * Each file is single-writer (only its owning process ever rewrites it), so + * updates are race-free; stale entries left by a crashed peer are swept lazily + * on `register` / `listLive` via a `kill(pid, 0)` probe. * - * The `heartbeat_at` field is informational this phase (diagnostics + a hook - * for future cross-machine TTL liveness); same-machine stale detection keys off - * pid liveness only, matching the legacy lock's `pidAlive` semantics. + * The `heartbeat_at` field is informational (diagnostics + a hook for future + * cross-machine TTL liveness); same-machine stale detection keys off pid + * liveness only. */ import { randomBytes } from 'node:crypto'; @@ -331,8 +331,8 @@ export async function listLiveServerInstances( /** * Convenience one-shot read: return the longest-running live instance, or - * `undefined` when none exist. Mirrors `getLiveLock` for callers that only - * need a single daemon to talk to. + * `undefined` when none exist. For callers that only need a single daemon to + * talk to (e.g. the CLI's `server ps/kill` and the `kimi web` spawner). */ export async function getLiveServerInstance( homeDir?: string, diff --git a/packages/kap-server/src/lock.ts b/packages/kap-server/src/lock.ts deleted file mode 100644 index fb3a7629d4..0000000000 --- a/packages/kap-server/src/lock.ts +++ /dev/null @@ -1,279 +0,0 @@ -/** - * Filesystem lock for single-instance server enforcement. - * - * The lock is a small JSON file at `/server/lock` (defaults - * to `~/.kimi-code/server/lock`; overridable via `KIMI_CODE_HOME` env or - * `lockPath` for tests). It records the live server's `pid`, `started_at`, - * and `port`. Acquisition is exclusive (`O_WRONLY | O_CREAT | O_EXCL`) — - * racing servers can't both win. - * - * Stale lock takeover: when a lock file exists, we ping the recorded pid via - * `process.kill(pid, 0)`. Node's `kill` does NOT send a signal when sig is 0 — - * it only probes existence (man kill(2)). If the probe throws `ESRCH` the - * process is gone and we take over by `unlink` + retry. If the probe succeeds - * (or throws `EPERM`, meaning the process exists but is owned by another user), - * we throw `ESERVER_LOCKED` so the caller surfaces the conflict to stderr. - * - * Race vs. takeover: the stale-check sees a dead pid, then unlinks, then - * re-acquires with `O_EXCL`. If a third party slipped in between unlink and - * re-create, `O_EXCL` returns `EEXIST`, which we propagate (don't loop) — the - * operator should see the conflict, not silently overwrite. - * - * Release is best-effort: if the file is missing or its `pid` no longer - * matches ours, we log and continue rather than throw. Crashed servers may - * leave the file dangling; the next start's stale-check cleans it up. - */ - -import { - closeSync, - existsSync, - mkdirSync, - readFileSync, - unlinkSync, - writeFileSync, - openSync, -} from 'node:fs'; -import { dirname, join } from 'node:path'; - -import { resolveKimiHome } from '@moonshot-ai/agent-core-v2'; - -export const DEFAULT_LOCK_DIR = join(resolveKimiHome(), 'server'); -export const DEFAULT_LOCK_PATH = join(DEFAULT_LOCK_DIR, 'lock'); - -/** JSON shape stored in the lock file. snake_case to match operator-facing logs. */ -export interface LockContents { - pid: number; - started_at: string; - host?: string; - port: number; - /** Host CLI version that started this server (e.g. kimi-code package version). - Lets `kimi server status` detect a build-mismatched server. Absent in locks - written by older builds. */ - host_version?: string; - /** Absolute path of the CLI entry that spawned the server. Distinguishes two - installs that share a version string (e.g. two pkg.pr.new builds of the - same base version living in different npx cache dirs). */ - entry?: string; -} - -export interface AcquireLockOptions { - /** Override default `/server/lock` — used in tests. */ - lockPath?: string; - /** Port the server will bind to. Recorded in the lock file for diagnostics. */ - port: number; - /** Host the server will bind to. Recorded in the lock file for diagnostics. */ - host?: string; - /** Host CLI version, recorded as `host_version` for build-mismatch detection. */ - hostVersion?: string; - /** CLI entry path that spawned this server, recorded as `entry`. */ - entry?: string; - /** Override `new Date().toISOString()` — used in tests for deterministic output. */ - nowIso?: string; - /** - * Override `process.pid` — used in tests where we want to simulate a - * different server owning the lock. Production callers should not set this. - */ - pid?: number; -} - -export interface AcquireLockResult { - /** Idempotent release: safe to call multiple times; best-effort on missing/mismatched lock. */ - release(): void; - /** Absolute path of the lock file that was acquired. */ - lockPath: string; - /** - * Rewrite the lock file's recorded `port` to the one actually bound. Used - * when the requested port was busy (held by a third-party process) and the - * server retried on `port + 1`: the lock must advertise the real port so - * `kimi server status` / `kill` / `ps` can find the daemon. Best-effort and - * ownership-guarded — a no-op when the file is missing, owned by another - * pid, or already records `port`. - */ - updatePort(port: number): void; -} - -/** Error thrown when another server is already holding the lock. */ -export class ServerLockedError extends Error { - override readonly name = 'ServerLockedError'; - readonly code = 'ESERVER_LOCKED' as const; - /** - * Process exit code preferred by CLI consumers. `2` is distinct from generic - * failure `1` so operators can scriptly distinguish - * "another server is running" from "server crashed". Commander reads this if - * present; library callers can ignore it. - */ - readonly exitCode = 2 as const; - readonly existing: LockContents; - constructor(message: string, existing: LockContents) { - super(message); - this.existing = existing; - } -} - -/** `process.kill(pid, 0)` probe — true if the pid exists, false on ESRCH. */ -function pidAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'ESRCH') return false; - // EPERM = process exists but we can't signal it (different user). Treat as alive. - if (code === 'EPERM') return true; - // Anything else: be safe, assume alive so we don't clobber. - return true; - } -} - -/** Read + JSON.parse the lock file; returns undefined on any error so callers can fall through. */ -function readLockContents(path: string): LockContents | undefined { - try { - const raw = readFileSync(path, 'utf8'); - const parsed = JSON.parse(raw) as unknown; - if ( - typeof parsed === 'object' && - parsed !== null && - typeof (parsed as LockContents).pid === 'number' && - typeof (parsed as LockContents).started_at === 'string' && - typeof (parsed as LockContents).port === 'number' - ) { - return parsed as LockContents; - } - return undefined; - } catch { - return undefined; - } -} - -/** - * Read the lock file and return its contents only when it describes a *live* - * server (parseable JSON whose recorded pid still exists). Returns `undefined` - * when the file is missing, unparseable, or stale (dead pid) — i.e. when there - * is no daemon to reuse. Read-only: never mutates the lock. - */ -export function getLiveLock(lockPath: string = DEFAULT_LOCK_PATH): LockContents | undefined { - const contents = readLockContents(lockPath); - if (!contents) return undefined; - return pidAlive(contents.pid) ? contents : undefined; -} - -/** - * Try `O_WRONLY | O_CREAT | O_EXCL` to create the lock file with the contents. - * Returns true on success, false on EEXIST. Throws on any other fs error. - */ -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. 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) { - if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false; - throw err; - } finally { - if (fd !== undefined) { - try { - closeSync(fd); - } catch { - // already closed by writeFileSync in some Node versions — ignore. - } - } - } -} - -/** - * Acquire an exclusive lock for this server instance. Throws `ServerLockedError` - * if another live server holds the lock; silently takes over a stale lock whose - * recorded pid is no longer running. - */ -export function acquireLock(opts: AcquireLockOptions): AcquireLockResult { - const lockPath = opts.lockPath ?? DEFAULT_LOCK_PATH; - const pid = opts.pid ?? process.pid; - const startedAt = opts.nowIso ?? new Date().toISOString(); - const contents: LockContents = { - pid, - started_at: startedAt, - host: opts.host, - port: opts.port, - ...(opts.hostVersion !== undefined ? { host_version: opts.hostVersion } : {}), - ...(opts.entry !== undefined ? { entry: opts.entry } : {}), - }; - - mkdirSync(dirname(lockPath), { recursive: true }); - - // First try: clean acquire. - if (tryExclusiveCreate(lockPath, contents)) { - return makeReleaseHandle(lockPath, pid); - } - - // Lock exists — inspect. - const existing = readLockContents(lockPath); - if (existing && pidAlive(existing.pid)) { - // Live owner — refuse to take over. Note that "same pid as ours" still - // counts as live: callers that genuinely want to swap should release the - // existing handle first, not stomp via acquireLock. - throw new ServerLockedError( - `server already running (pid=${existing.pid}, port=${existing.port}, started=${existing.started_at})`, - existing, - ); - } - - // Stale (dead pid) or unparseable — take over. - try { - unlinkSync(lockPath); - } catch (err) { - // EBUSY/ENOENT both acceptable — race with another concurrent acquirer. - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - throw err; - } - } - - if (!tryExclusiveCreate(lockPath, contents)) { - // Someone slipped in. Re-read for diagnostic. - const winner = readLockContents(lockPath); - throw new ServerLockedError( - winner - ? `server already running (pid=${winner.pid}, port=${winner.port}, started=${winner.started_at})` - : 'lock file present but unreadable', - winner ?? { pid: -1, started_at: '', port: opts.port }, - ); - } - return makeReleaseHandle(lockPath, pid); -} - -function makeReleaseHandle(lockPath: string, ownerPid: number): AcquireLockResult { - let released = false; - return { - lockPath, - release(): void { - if (released) return; - released = true; - if (!existsSync(lockPath)) return; - const contents = readLockContents(lockPath); - if (contents && contents.pid !== ownerPid) { - // Someone else owns the lock now — don't touch it. - return; - } - try { - unlinkSync(lockPath); - } catch { - // Best-effort: file may have vanished between existsSync and unlinkSync. - } - }, - updatePort(port: number): void { - if (!existsSync(lockPath)) return; - const contents = readLockContents(lockPath); - // Only rewrite our own lock, and only when the port actually changed. - if (!contents || contents.pid !== ownerPid || contents.port === port) return; - try { - writeFileSync(lockPath, JSON.stringify({ ...contents, port })); - } catch { - // Best-effort: a concurrent release/takeover may have removed the file. - } - }, - }; -} diff --git a/packages/kap-server/src/middleware/hostnames.ts b/packages/kap-server/src/middleware/hostnames.ts index 3c6cd0c39b..745dfa2b3f 100644 --- a/packages/kap-server/src/middleware/hostnames.ts +++ b/packages/kap-server/src/middleware/hostnames.ts @@ -107,7 +107,7 @@ export function formatHostErrorMessage(host: string | undefined): string { const normalizedHost = host === undefined || host.length === 0 ? undefined : stripPort(host); const hostLabel = normalizedHost ?? ''; const hostArg = normalizedHost ?? ''; - return `Invalid Host header: ${hostLabel}; allow this host with KIMI_CODE_ALLOWED_HOSTS=${hostArg} or 'kimi server run --allowed-host ${hostArg}'.`; + return `Invalid Host header: ${hostLabel}; allow this host with KIMI_CODE_ALLOWED_HOSTS=${hostArg} or 'kimi web --allowed-host ${hostArg}'.`; } /** diff --git a/packages/kap-server/src/services/auth/persistentToken.ts b/packages/kap-server/src/services/auth/persistentToken.ts index 2846dbf740..fbc4603a51 100644 --- a/packages/kap-server/src/services/auth/persistentToken.ts +++ b/packages/kap-server/src/services/auth/persistentToken.ts @@ -4,7 +4,7 @@ * The token lives at `/server.token` (mode 0600) and is reused * across restarts, so a reboot does NOT rotate it. It is generated once on * first boot and only changes when the operator explicitly runs - * `kimi server rotate-token` (which calls {@link rotateServerToken}). + * `kimi web 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 diff --git a/packages/kap-server/src/services/auth/tokenStore.ts b/packages/kap-server/src/services/auth/tokenStore.ts index 459c463efe..a36adf733b 100644 --- a/packages/kap-server/src/services/auth/tokenStore.ts +++ b/packages/kap-server/src/services/auth/tokenStore.ts @@ -15,7 +15,7 @@ export interface TokenStore { * * 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 + * `kimi web 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. * diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 31e3c0b8b0..d971d4eea6 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -14,7 +14,6 @@ import { IModelCatalogService, IWorkspaceRegistry, logSeed, - MULTI_SERVER_FLAG_ENV, resolveConfigPath, resolveKimiHome, resolveLoggingConfig, @@ -26,7 +25,6 @@ import { createAsyncApiDocument } from './protocol/asyncapi'; import Fastify, { type FastifyInstance } from 'fastify'; import { installErrorHandler } from './error-handler'; -import { acquireLock, type AcquireLockResult, ServerLockedError } from './lock'; import { createInstanceRegistry, type InstanceRegistration } from './instanceRegistry'; import { transformOpenApiDocument } from './openapi/transforms'; import { registerRequestLogging } from './requestLogging'; @@ -80,8 +78,12 @@ export interface ServerStartOptions { readonly port?: number; readonly homeDir?: string; readonly configPath?: string; - /** Override the single-instance lock path — used in tests. Defaults to `/server/lock`. */ - readonly lockPath?: string; + /** + * Override the instance-registry directory — used in tests that need the + * registry OUTSIDE `homeDir` (e.g. folder-picker fixtures browsing the home + * dir). Defaults to `/server/instances`. + */ + readonly instancesDir?: string; readonly logLevel?: ServerLogLevel; readonly logger?: ServerLogger; readonly debugEndpoints?: boolean; @@ -138,64 +140,30 @@ export interface RunningServer { const DEFAULT_HOST = '127.0.0.1'; const DEFAULT_PORT = 58627; -/** - * Resolve the `multi_server` gate from the environment *before* bootstrap. - * - * The lock-vs-registry decision must be made ahead of `bootstrap()` so the - * legacy single-instance lock is still taken early (fail-fast, and ahead of any - * bootstrap-time writes to shared home-dir files). The decision keys off the - * dedicated `KIMI_CODE_EXPERIMENTAL_MULTI_SERVER` env only — deliberately NOT - * the master `KIMI_CODE_EXPERIMENTAL_FLAG`: that switch already enables the v2 - * engine itself, and coupling the lock contract to it would make every v2 - * server skip the legacy lock before CLI consumers learn to read the instance - * registry. Keeping the gate specific makes multi-server strictly opt-in. - */ -function isMultiServerEnabled(env: NodeJS.ProcessEnv): boolean { - const raw = (env[MULTI_SERVER_FLAG_ENV] ?? '').trim().toLowerCase(); - return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'; -} - -export { ServerLockedError }; - export async function startServer(opts: ServerStartOptions = {}): Promise { const host = opts.host ?? DEFAULT_HOST; const port = opts.port ?? DEFAULT_PORT; const homeDir = resolveKimiHome(opts.homeDir); - // Instance discovery (matches v1 when `multi_server` is off): - // - flag off: take the single-instance `/server/lock` so a second - // server on the same homeDir fails fast with `ServerLockedError` rather - // than racing the port. - // - flag on: register this process under `/server/instances/` so - // multiple servers can share the homeDir; port conflicts are resolved by - // the `port + 1` retry below instead of the lock. - // Either handle is released on close and on any boot refusal below. + // Instance discovery: every server registers itself under + // `/server/instances/.json`, so multiple servers can share + // one homeDir and consumers (the CLI's `server ps/kill`, `kimi web`, dev + // tooling) can discover the live instances. Port conflicts between siblings + // are resolved by the `port + 1` retry below. The registration is released + // on close and on any boot refusal below. const hostVersion = opts.version ?? getServerVersion(); - let lockHandle: AcquireLockResult | undefined; - let registration: InstanceRegistration | undefined; - if (isMultiServerEnabled(process.env)) { - const registry = createInstanceRegistry({ - instancesDir: join(homeDir, 'server', 'instances'), - }); - registration = await registry.register({ - pid: process.pid, - host, - port, - startedAt: Date.now(), - hostVersion, - }); - } else { - lockHandle = acquireLock({ - port, - host, - lockPath: opts.lockPath ?? join(homeDir, 'server', 'lock'), - hostVersion, - entry: process.argv[1], - }); - } + const registry = createInstanceRegistry({ + instancesDir: opts.instancesDir ?? join(homeDir, 'server', 'instances'), + }); + const registration: InstanceRegistration = await registry.register({ + pid: process.pid, + host, + port, + startedAt: Date.now(), + hostVersion, + }); const exposureClass = classify(host, { bindClass: opts.bindClass }); if (exposureClass !== 'loopback' && opts.insecureNoTls !== true) { - await registration?.release(); - lockHandle?.release(); + await registration.release(); throw new Error( `Refusing to bind ${host} (${exposureClass}) without TLS; terminate TLS at a reverse proxy or pass --insecure-no-tls.`, ); @@ -326,8 +294,7 @@ export async function startServer(opts: ServerStartOptions = {}): Promise/server/instances/`), and the `port + 1` walk is exactly how the + // second instance yields to 58628 (and so on) — the retry doubles as the + // multi-instance coexistence mechanism. A busy port held by a third-party + // listener gets the same treatment, matching the v1 policy. try { await listenWithPortRetry({ listen: (h, p) => app.listen({ host: h, port: p }), @@ -552,7 +518,7 @@ export async function startServer(opts: ServerStartOptions = {}): Promise { logger.warn( @@ -604,15 +569,12 @@ export interface ListenWithPortRetryOptions { /** * Bind the listener, retrying on `port + 1` when the port is held. * - * Why this is the right layer: when the `multi_server` flag is off, - * {@link startServer} takes the single-instance lock *before* listening, so by - * the time we reach `listen` a live kimi server would already have thrown - * `ServerLockedError`; any `EADDRINUSE` is then a third-party listener and - * bumping the port is the desired policy ("if the port is taken by something - * other than kimi server itself, +1"). When `multi_server` is on, the lock is - * replaced by the instance registry, so a busy port may be a sibling kimi - * instance — the same `port + 1` walk then serves as the multi-instance - * coexistence mechanism (second instance lands on the next free port). + * Why this is the right layer: there is no single-instance lock — every + * kap-server registers itself under `/server/instances/` instead, so a + * busy port may be a sibling kimi instance. The `port + 1` walk then serves + * as the multi-instance coexistence mechanism (the second instance lands on + * the next free port), and a third-party listener gets the same "port busy ⇒ + * +1" policy as v1. * * Port `0` (OS-assigned ephemeral) is never retried: the kernel already picks a * free port, so `EADDRINUSE` cannot arise from a specific-port conflict. diff --git a/packages/kap-server/src/svc/exec.ts b/packages/kap-server/src/svc/exec.ts deleted file mode 100644 index 42442da147..0000000000 --- a/packages/kap-server/src/svc/exec.ts +++ /dev/null @@ -1,53 +0,0 @@ - - -import { execFile } from 'node:child_process'; - -export interface ExecResult { - stdout: string; - stderr: string; - code: number; -} - -export interface ExecOptions { - - windowsHide?: boolean; - - timeoutMs?: number; - - env?: NodeJS.ProcessEnv; -} - - -export function execFileUtf8( - file: string, - args: readonly string[], - options: ExecOptions = {}, -): Promise { - return new Promise((resolve) => { - execFile( - file, - [...args], - { - encoding: 'utf8', - windowsHide: options.windowsHide === true, - ...(options.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), - env: options.env ?? process.env, - }, - (err, stdout, stderr) => { - if (err === null) { - resolve({ stdout, stderr, code: 0 }); - return; - } - - - const code = typeof (err as { code?: unknown }).code === 'number' ? (err as { code: number }).code : -1; - const message = err instanceof Error ? err.message : JSON.stringify(err); - resolve({ - stdout: typeof stdout === 'string' ? stdout : '', - stderr: typeof stderr === 'string' && stderr.length > 0 ? stderr : message, - code, - }); - }, - ); - }); -} diff --git a/packages/kap-server/src/svc/index.ts b/packages/kap-server/src/svc/index.ts deleted file mode 100644 index 090c510026..0000000000 --- a/packages/kap-server/src/svc/index.ts +++ /dev/null @@ -1,26 +0,0 @@ - - -export { resolveServiceManager } from './service'; -export { createLaunchdManager, parseLaunchctlPrint } from './launchd'; -export { buildLaunchAgentPlist } from './launchd-plist'; -export { createSystemdManager } from './systemd'; -export { buildSystemdUnit, parseSystemctlShow } from './systemd-unit'; -export { createSchtasksManager } from './schtasks'; -export { buildScheduledTaskXml, parseSchtasksQuery } from './schtasks-xml'; -export { buildInstallPlan, readInstallPlan, writeInstallPlan } from './install-plan'; -export type { InstallPlan } from './install-plan'; -export { - KIMI_SERVER_LABEL, - KIMI_SERVER_PLIST_FILENAME, - KIMI_SERVER_SYSTEMD_UNIT, - KIMI_SERVER_TASK_NAME, -} from './paths'; -export { - ServiceUnavailableError, - ServiceUnsupportedError, - type InstallArgs, - type InstallResult, - type LifecycleResult, - type ServiceManager, - type ServiceStatus, -} from './types'; diff --git a/packages/kap-server/src/svc/install-plan.ts b/packages/kap-server/src/svc/install-plan.ts deleted file mode 100644 index b45dcdc7cd..0000000000 --- a/packages/kap-server/src/svc/install-plan.ts +++ /dev/null @@ -1,100 +0,0 @@ - - -import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { dirname } from 'node:path'; - -import { installPlanPath } from './paths'; -import type { InstallArgs } from './types'; - -const SUPERVISED_SERVER_HOST = '127.0.0.1'; - -export interface InstallPlan { - - host: string; - - port: number; - - logLevel: string; - - program: string; - - programArguments: string[]; - - logPath: string; - - installedAt: string; -} - -export interface BuildInstallPlanInput extends InstallArgs { - - program: string; - - logPath: string; - - nowIso?: string; -} - - -export function buildInstallPlan(input: BuildInstallPlanInput): InstallPlan { - return { - host: SUPERVISED_SERVER_HOST, - port: input.port, - logLevel: input.logLevel, - program: input.program, - programArguments: [ - input.program, - 'server', - 'run', - '--port', - String(input.port), - '--log-level', - input.logLevel, - '--host', - SUPERVISED_SERVER_HOST, - ], - logPath: input.logPath, - installedAt: input.nowIso ?? new Date().toISOString(), - }; -} - - -export function writeInstallPlan(plan: InstallPlan, path: string = installPlanPath()): void { - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, `${JSON.stringify(plan, null, 2)}\n`, { mode: 0o600 }); -} - - -export function readInstallPlan(path: string = installPlanPath()): InstallPlan | undefined { - try { - const raw = readFileSync(path, 'utf8'); - const parsed = JSON.parse(raw) as unknown; - if (!isInstallPlan(parsed)) return undefined; - return parsed; - } catch { - return undefined; - } -} - - -export function deleteInstallPlan(path: string = installPlanPath()): void { - try { - rmSync(path, { force: true }); - } catch { - - } -} - -function isInstallPlan(value: unknown): value is InstallPlan { - if (typeof value !== 'object' || value === null) return false; - const v = value as Record; - return ( - typeof v['host'] === 'string' && - typeof v['port'] === 'number' && - typeof v['logLevel'] === 'string' && - typeof v['program'] === 'string' && - Array.isArray(v['programArguments']) && - (v['programArguments'] as unknown[]).every((arg) => typeof arg === 'string') && - typeof v['logPath'] === 'string' && - typeof v['installedAt'] === 'string' - ); -} diff --git a/packages/kap-server/src/svc/launchd-plist.ts b/packages/kap-server/src/svc/launchd-plist.ts deleted file mode 100644 index 51fc1fb802..0000000000 --- a/packages/kap-server/src/svc/launchd-plist.ts +++ /dev/null @@ -1,91 +0,0 @@ - - - - -export const LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS = 10; -export const LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS = 20; - -export const LAUNCH_AGENT_UMASK_DECIMAL = 0o077; -export const LAUNCH_AGENT_PROCESS_TYPE = 'Interactive'; -export const LAUNCH_AGENT_STDIN_PATH = '/dev/null'; - -export interface BuildLaunchAgentPlistInput { - label: string; - - comment?: string; - - programArguments: readonly string[]; - - workingDirectory?: string; - - stdoutPath: string; - - stderrPath: string; - - environment?: Readonly>; -} - -const plistEscape = (value: string): string => - value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); - -function renderEnvDict(env?: Readonly>): string { - if (!env) return ''; - const entries = Object.entries(env); - if (entries.length === 0) return ''; - const inner = entries - .map( - ([k, v]) => - `\n ${plistEscape(k)}\n ${plistEscape(v)}`, - ) - .join(''); - return `\n EnvironmentVariables\n ${inner}\n `; -} - - -export function buildLaunchAgentPlist(input: BuildLaunchAgentPlistInput): string { - const argsXml = input.programArguments - .map((arg) => `\n ${plistEscape(arg)}`) - .join(''); - const workingDirXml = input.workingDirectory - ? `\n WorkingDirectory\n ${plistEscape(input.workingDirectory)}` - : ''; - const commentXml = input.comment?.trim() - ? `\n Comment\n ${plistEscape(input.comment.trim())}` - : ''; - const envXml = renderEnvDict(input.environment); - return ` - - - - Label - ${plistEscape(input.label)}${commentXml} - RunAtLoad - - KeepAlive - - ExitTimeOut - ${LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS} - ProcessType - ${LAUNCH_AGENT_PROCESS_TYPE} - ThrottleInterval - ${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS} - Umask - ${LAUNCH_AGENT_UMASK_DECIMAL} - ProgramArguments - ${argsXml} - ${workingDirXml} - StandardInPath - ${plistEscape(LAUNCH_AGENT_STDIN_PATH)} - StandardOutPath - ${plistEscape(input.stdoutPath)} - StandardErrorPath - ${plistEscape(input.stderrPath)}${envXml} - - -`; -} diff --git a/packages/kap-server/src/svc/launchd.ts b/packages/kap-server/src/svc/launchd.ts deleted file mode 100644 index 7c1149549b..0000000000 --- a/packages/kap-server/src/svc/launchd.ts +++ /dev/null @@ -1,283 +0,0 @@ - - -import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; -import { dirname } from 'node:path'; - -import { execFileUtf8, type ExecOptions, type ExecResult } from './exec'; -import { - buildInstallPlan, - deleteInstallPlan, - readInstallPlan, - writeInstallPlan, - type InstallPlan, -} from './install-plan'; -import { buildLaunchAgentPlist } from './launchd-plist'; -import { - guiDomain as defaultGuiDomain, - KIMI_SERVER_LABEL, - launchAgentPlistPath as defaultLaunchAgentPlistPath, - supervisorLogPath as defaultSupervisorLogPath, -} from './paths'; -import { resolveSupervisorProgram } from './program'; -import type { - InstallArgs, - InstallResult, - LifecycleResult, - ServiceManager, - ServiceStatus, -} from './types'; - -const PLIST_MODE = 0o600; -const LAUNCH_AGENT_DIR_MODE = 0o755; - -export interface LaunchdManagerDeps { - - execLaunchctl(args: readonly string[], options?: ExecOptions): Promise; - - resolveProgram(): string; - - plistPath(): string; - - logPath(): string; - - guiDomain(): string; -} - -const DEFAULT_DEPS: LaunchdManagerDeps = { - execLaunchctl: (args, options) => execFileUtf8('launchctl', args, options), - resolveProgram: () => resolveSupervisorProgram(), - plistPath: defaultLaunchAgentPlistPath, - logPath: defaultSupervisorLogPath, - guiDomain: () => defaultGuiDomain(), -}; - -export { resolveSupervisorProgram }; - - -export function createLaunchdManager( - overrides: Partial = {}, -): ServiceManager { - const deps: LaunchdManagerDeps = { ...DEFAULT_DEPS, ...overrides }; - - async function install(args: InstallArgs): Promise { - const plistPath = deps.plistPath(); - const logPath = deps.logPath(); - const program = deps.resolveProgram(); - const plan = buildInstallPlan({ ...args, program, logPath }); - - const alreadyInstalled = existsSync(plistPath); - if (alreadyInstalled && args.force !== true) { - return { - status: 'already-installed', - message: `LaunchAgent already installed at ${plistPath}. Rerun with --force to replace it.`, - plistPath, - }; - } - - if (alreadyInstalled) { - - await bestEffortBootout(deps); - } - - writePlist(plistPath, plan, logPath); - writeInstallPlan(plan); - - const bootstrap = await deps.execLaunchctl(['bootstrap', deps.guiDomain(), plistPath]); - if (bootstrap.code !== 0 && !isAlreadyLoaded(bootstrap)) { - throw new Error( - `launchctl bootstrap failed (code ${bootstrap.code}): ${detail(bootstrap) ?? 'unknown error'}`, - ); - } - - return { - status: alreadyInstalled ? 'replaced' : 'installed', - message: `Kimi server LaunchAgent ${alreadyInstalled ? 'replaced' : 'installed'} at ${plistPath} (port ${plan.port}).`, - plistPath, - }; - } - - async function uninstall(): Promise { - const plistPath = deps.plistPath(); - const installed = existsSync(plistPath); - if (!installed) { - deleteInstallPlan(); - return { ok: true, message: 'LaunchAgent was not installed; nothing to remove.' }; - } - const bootout = await deps.execLaunchctl([ - 'bootout', - `${deps.guiDomain()}/${KIMI_SERVER_LABEL}`, - ]); - - void bootout; - try { - rmSync(plistPath, { force: true }); - } catch (error) { - throw new Error( - `failed to remove plist ${plistPath}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - deleteInstallPlan(); - return { ok: true, message: `LaunchAgent removed (${plistPath}).` }; - } - - async function start(): Promise { - const plistPath = deps.plistPath(); - if (!existsSync(plistPath)) { - return { - ok: false, - message: 'LaunchAgent is not installed. Run `kimi server install` first.', - }; - } - const target = `${deps.guiDomain()}/${KIMI_SERVER_LABEL}`; - - const result = await deps.execLaunchctl(['kickstart', '-k', target]); - if (result.code !== 0) { - - const bootstrap = await deps.execLaunchctl(['bootstrap', deps.guiDomain(), plistPath]); - if (bootstrap.code !== 0 && !isAlreadyLoaded(bootstrap)) { - return { - ok: false, - message: `launchctl kickstart + bootstrap both failed: ${detail(result) ?? 'unknown'} / ${detail(bootstrap) ?? 'unknown'}`, - }; - } - const retry = await deps.execLaunchctl(['kickstart', target]); - if (retry.code !== 0) { - return { - ok: false, - message: `launchctl kickstart failed after bootstrap: ${detail(retry) ?? 'unknown error'}`, - }; - } - } - return { ok: true, message: `Kimi server started (${KIMI_SERVER_LABEL}).` }; - } - - async function stop(): Promise { - const target = `${deps.guiDomain()}/${KIMI_SERVER_LABEL}`; - const result = await deps.execLaunchctl(['kill', 'SIGTERM', target]); - if (result.code !== 0) { - - const bootout = await deps.execLaunchctl(['bootout', target]); - if (bootout.code !== 0) { - return { - ok: false, - message: `launchctl kill + bootout both failed: ${detail(result) ?? 'unknown'} / ${detail(bootout) ?? 'unknown'}`, - }; - } - } - return { ok: true, message: `Kimi server stopped (${KIMI_SERVER_LABEL}).` }; - } - - async function restart(): Promise { - const target = `${deps.guiDomain()}/${KIMI_SERVER_LABEL}`; - const result = await deps.execLaunchctl(['kickstart', '-k', target]); - if (result.code !== 0) { - return { - ok: false, - message: `launchctl kickstart -k failed: ${detail(result) ?? 'unknown error'}`, - }; - } - return { ok: true, message: `Kimi server restarted (${KIMI_SERVER_LABEL}).` }; - } - - async function status(): Promise { - const plistPath = deps.plistPath(); - const plan = readInstallPlan(); - const installed = existsSync(plistPath); - - const base: ServiceStatus = { - platform: 'darwin', - installed, - running: false, - label: KIMI_SERVER_LABEL, - ...(plan?.host !== undefined ? { host: plan.host } : {}), - ...(plan?.port !== undefined ? { port: plan.port } : {}), - logPath: deps.logPath(), - }; - - if (!installed) { - return { ...base, notes: ['LaunchAgent is not installed.'] }; - } - - const print = await deps.execLaunchctl(['print', `${deps.guiDomain()}/${KIMI_SERVER_LABEL}`]); - if (print.code !== 0) { - return { - ...base, - notes: [ - `launchctl print failed (code ${print.code}): ${detail(print) ?? 'unknown'}.`, - 'The plist is on disk but the service is not loaded.', - ], - }; - } - const info = parseLaunchctlPrint(print.stdout); - const notes = - info.state !== undefined - ? [`launchd state: ${info.state}`] - : ['launchd state: unknown']; - if (info.lastExitCode !== undefined) { - notes.push(`last exit code: ${info.lastExitCode}`); - } - return { - ...base, - running: info.state === 'running' || info.pid !== undefined, - ...(info.pid !== undefined ? { pid: info.pid } : {}), - notes, - }; - } - - return { install, uninstall, start, stop, restart, status }; -} - - -export function parseLaunchctlPrint(output: string): { - state?: string; - pid?: number; - lastExitCode?: string; -} { - const result: { state?: string; pid?: number; lastExitCode?: string } = {}; - for (const rawLine of output.split(/\r?\n/)) { - const line = rawLine.trim(); - const equalsIdx = line.indexOf('='); - if (equalsIdx === -1) continue; - const key = line.slice(0, equalsIdx).trim().toLowerCase(); - const value = line.slice(equalsIdx + 1).trim(); - if (key === 'state' && result.state === undefined) { - result.state = value; - } else if (key === 'pid' && result.pid === undefined) { - const n = Number.parseInt(value, 10); - if (Number.isFinite(n) && n > 0) { - result.pid = n; - } - } else if ((key === 'last exit code' || key === 'last exit status') && result.lastExitCode === undefined) { - result.lastExitCode = value; - } - } - return result; -} - -function writePlist(plistPath: string, plan: InstallPlan, logPath: string): void { - const xml = buildLaunchAgentPlist({ - label: KIMI_SERVER_LABEL, - comment: 'Kimi Code local server (managed by `kimi server install`).', - programArguments: plan.programArguments, - stdoutPath: logPath, - stderrPath: logPath, - }); - mkdirSync(dirname(plistPath), { recursive: true, mode: LAUNCH_AGENT_DIR_MODE }); - writeFileSync(plistPath, xml, { mode: PLIST_MODE }); -} - -async function bestEffortBootout(deps: LaunchdManagerDeps): Promise { - await deps.execLaunchctl(['bootout', `${deps.guiDomain()}/${KIMI_SERVER_LABEL}`]).catch(() => { - - }); -} - -function isAlreadyLoaded(res: ExecResult): boolean { - const message = (res.stderr || res.stdout).toLowerCase(); - return res.code === 130 || message.includes('already loaded') || message.includes('service already loaded'); -} - -function detail(res: ExecResult): string | undefined { - const text = (res.stderr || res.stdout).trim(); - return text.length > 0 ? text : undefined; -} diff --git a/packages/kap-server/src/svc/paths.ts b/packages/kap-server/src/svc/paths.ts deleted file mode 100644 index 15b13fe67c..0000000000 --- a/packages/kap-server/src/svc/paths.ts +++ /dev/null @@ -1,43 +0,0 @@ - - -import { homedir } from 'node:os'; -import { join } from 'node:path'; - -import { resolveKimiHome } from '@moonshot-ai/agent-core-v2'; - - -export const KIMI_SERVER_LABEL = 'ai.moonshot.kimi-server'; - - -export const KIMI_SERVER_PLIST_FILENAME = `${KIMI_SERVER_LABEL}.plist`; - - -export const KIMI_SERVER_SYSTEMD_UNIT = 'kimi-server.service'; - - -export const KIMI_SERVER_TASK_NAME = 'KimiServer'; - - -export function launchAgentPlistPath(): string { - return join(homedir(), 'Library', 'LaunchAgents', KIMI_SERVER_PLIST_FILENAME); -} - - -export function systemdUnitPath(): string { - return join(homedir(), '.config', 'systemd', 'user', KIMI_SERVER_SYSTEMD_UNIT); -} - - -export function supervisorLogPath(): string { - return join(resolveKimiHome(), 'server', 'server.log'); -} - - -export function installPlanPath(): string { - return join(resolveKimiHome(), 'server', 'install.json'); -} - - -export function guiDomain(uid: number = process.getuid?.() ?? 0): string { - return `gui/${uid}`; -} diff --git a/packages/kap-server/src/svc/program.ts b/packages/kap-server/src/svc/program.ts deleted file mode 100644 index 1d55f83a2c..0000000000 --- a/packages/kap-server/src/svc/program.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { createRequire } from 'node:module'; -import { isAbsolute, resolve } from 'node:path'; - -interface NodeSeaModule { - isSea(): boolean; -} - -const nodeRequire = createRequire(import.meta.url); -let cachedSea: NodeSeaModule | null | undefined; - -function loadSeaModule(): NodeSeaModule | null { - if (cachedSea !== undefined) return cachedSea; - try { - cachedSea = nodeRequire('node:sea') as NodeSeaModule; - } catch { - cachedSea = null; - } - return cachedSea; -} - -/** True when running as a compiled single-executable (SEA / native) binary. */ -function detectSea(): boolean { - const sea = loadSeaModule(); - if (sea === null) return false; - try { - return sea.isSea(); - } catch { - return false; - } -} - -export function resolveSupervisorProgram( - argv: readonly string[] = process.argv, - cwd: string = process.cwd(), - execPath: string = process.execPath, - isSea: boolean = detectSea(), -): string { - // In a SEA binary `argv[1]` is the invoked command name (e.g. `kimi`) or the - // first user argument — never a script path — so the re-exec target is always - // the binary itself. Resolving it against `cwd` would produce a bogus path - // (e.g. `/kimi`) and crash the spawn with ENOENT. - if (isSea) return execPath; - const candidate = argv[1] === 'server' ? execPath : (argv[1] ?? execPath); - return isAbsolute(candidate) ? candidate : resolve(cwd, candidate); -} diff --git a/packages/kap-server/src/svc/schtasks-xml.ts b/packages/kap-server/src/svc/schtasks-xml.ts deleted file mode 100644 index b71d73109c..0000000000 --- a/packages/kap-server/src/svc/schtasks-xml.ts +++ /dev/null @@ -1,136 +0,0 @@ - - -const escapeXmlText = (value: string): string => - value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); - -export interface BuildScheduledTaskXmlInput { - description: string; - - command: string; - - arguments?: string; - - taskUser?: string; -} - -export function buildScheduledTaskXml(input: BuildScheduledTaskXmlInput): string { - const description = escapeXmlText(input.description); - const command = escapeXmlText(input.command); - const args = input.arguments ? escapeXmlText(input.arguments) : ''; - - const principalLogon = input.taskUser - ? `\n ${escapeXmlText(input.taskUser)}\n InteractiveToken` - : '\n S-1-5-32-545'; - const triggerUser = input.taskUser - ? `\n ${escapeXmlText(input.taskUser)}` - : ''; - const argumentsXml = args ? `\n ${args}` : ''; - - return ` - - - ${description} - - - - true${triggerUser} - - - - ${principalLogon} - LeastPrivilege - - - - IgnoreNew - false - false - true - false - false - - false - false - - true - true - false - false - false - PT0S - 7 - - - - ${command}${argumentsXml} - - -`; -} - - -export function parseSchtasksQuery(output: string): Record | undefined { - - const lines = output.split(/\r?\n/).filter((line) => line.trim().length > 0); - if (lines.length < 2) return undefined; - - const header = parseCsvRow(lines[0] ?? ''); - const firstRow = parseCsvRow(lines[1] ?? ''); - if (header.length === 0 || firstRow.length === 0) return undefined; - - const out: Record = {}; - for (let i = 0; i < header.length; i += 1) { - const key = header[i] ?? ''; - const value = firstRow[i] ?? ''; - if (key.length > 0) { - out[key] = value; - } - } - return out; -} - - -function parseCsvRow(line: string): string[] { - const cells: string[] = []; - let current = ''; - let inQuotes = false; - let i = 0; - while (i < line.length) { - const ch = line[i]; - if (inQuotes) { - if (ch === '"') { - if (line[i + 1] === '"') { - current += '"'; - i += 2; - continue; - } - inQuotes = false; - i += 1; - continue; - } - current += ch; - i += 1; - } else { - if (ch === '"') { - inQuotes = true; - i += 1; - continue; - } - if (ch === ',') { - cells.push(current); - current = ''; - i += 1; - continue; - } - current += ch; - i += 1; - } - } - cells.push(current); - return cells; -} diff --git a/packages/kap-server/src/svc/schtasks.ts b/packages/kap-server/src/svc/schtasks.ts deleted file mode 100644 index d784993705..0000000000 --- a/packages/kap-server/src/svc/schtasks.ts +++ /dev/null @@ -1,262 +0,0 @@ - - -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { execFileUtf8, type ExecOptions, type ExecResult } from './exec'; -import { - buildInstallPlan, - deleteInstallPlan, - readInstallPlan, - writeInstallPlan, - type InstallPlan, -} from './install-plan'; -import { KIMI_SERVER_TASK_NAME, supervisorLogPath as defaultSupervisorLogPath } from './paths'; -import { resolveSupervisorProgram } from './program'; -import { buildScheduledTaskXml, parseSchtasksQuery } from './schtasks-xml'; -import type { - InstallArgs, - InstallResult, - LifecycleResult, - ServiceManager, - ServiceStatus, -} from './types'; - -export interface SchtasksManagerDeps { - - execSchtasks(args: readonly string[], options?: ExecOptions): Promise; - - resolveProgram(): string; - - logPath(): string; - - writeTaskXml(xml: string): string; - - taskExists(): Promise; -} - -const DEFAULT_DEPS: SchtasksManagerDeps = { - execSchtasks: (args, options) => - execFileUtf8('schtasks', args, { windowsHide: true, ...options }), - resolveProgram: () => resolveSupervisorProgram(process.argv, process.cwd(), 'kimi.exe'), - logPath: defaultSupervisorLogPath, - writeTaskXml: defaultWriteTaskXml, - taskExists: defaultTaskExists, -}; - -export function createSchtasksManager( - overrides: Partial = {}, -): ServiceManager { - const deps: SchtasksManagerDeps = { ...DEFAULT_DEPS, ...overrides }; - - async function install(args: InstallArgs): Promise { - const program = deps.resolveProgram(); - const logPath = deps.logPath(); - const plan = buildInstallPlan({ ...args, program, logPath }); - - const alreadyInstalled = await deps.taskExists(); - if (alreadyInstalled && args.force !== true) { - return { - status: 'already-installed', - message: `Scheduled task "${KIMI_SERVER_TASK_NAME}" already exists. Rerun with --force to replace it.`, - taskName: KIMI_SERVER_TASK_NAME, - }; - } - - const argString = serializeArguments(plan); - const xml = buildScheduledTaskXml({ - description: 'Kimi Code local server (managed by `kimi server install`)', - command: plan.program, - ...(argString.length > 0 ? { arguments: argString } : {}), - }); - const xmlPath = deps.writeTaskXml(xml); - - try { - const createArgs = ['/Create', '/TN', KIMI_SERVER_TASK_NAME, '/XML', xmlPath]; - if (alreadyInstalled) { - createArgs.push('/F'); - } - const create = await deps.execSchtasks(createArgs); - if (create.code !== 0) { - throw new Error( - `schtasks /Create failed (code ${create.code}): ${detail(create) ?? 'unknown error'}`, - ); - } - } finally { - - try { - rmSync(xmlPath, { force: true }); - } catch { - - } - } - - writeInstallPlan(plan); - - - const run = await deps.execSchtasks(['/Run', '/TN', KIMI_SERVER_TASK_NAME]); - if (run.code !== 0) { - - return { - status: alreadyInstalled ? 'replaced' : 'installed', - message: `Task ${alreadyInstalled ? 'replaced' : 'installed'} but /Run failed: ${detail(run) ?? 'unknown error'}.`, - taskName: KIMI_SERVER_TASK_NAME, - }; - } - - return { - status: alreadyInstalled ? 'replaced' : 'installed', - message: `Kimi server scheduled task ${alreadyInstalled ? 'replaced' : 'installed'} (${KIMI_SERVER_TASK_NAME}, port ${plan.port}).`, - taskName: KIMI_SERVER_TASK_NAME, - }; - } - - async function uninstall(): Promise { - if (!(await deps.taskExists())) { - deleteInstallPlan(); - return { ok: true, message: 'Scheduled task was not installed; nothing to remove.' }; - } - - await deps.execSchtasks(['/End', '/TN', KIMI_SERVER_TASK_NAME]).catch(() => undefined); - const del = await deps.execSchtasks(['/Delete', '/TN', KIMI_SERVER_TASK_NAME, '/F']); - if (del.code !== 0) { - return { - ok: false, - message: `schtasks /Delete failed: ${detail(del) ?? 'unknown error'}`, - }; - } - deleteInstallPlan(); - return { ok: true, message: `Scheduled task removed (${KIMI_SERVER_TASK_NAME}).` }; - } - - async function start(): Promise { - if (!(await deps.taskExists())) { - return { - ok: false, - message: 'Scheduled task is not installed. Run `kimi server install` first.', - }; - } - const result = await deps.execSchtasks(['/Run', '/TN', KIMI_SERVER_TASK_NAME]); - if (result.code !== 0) { - return { - ok: false, - message: `schtasks /Run failed: ${detail(result) ?? 'unknown error'}`, - }; - } - return { ok: true, message: `Kimi server started (${KIMI_SERVER_TASK_NAME}).` }; - } - - async function stop(): Promise { - const result = await deps.execSchtasks(['/End', '/TN', KIMI_SERVER_TASK_NAME]); - if (result.code !== 0) { - return { - ok: false, - message: `schtasks /End failed: ${detail(result) ?? 'unknown error'}`, - }; - } - return { ok: true, message: `Kimi server stopped (${KIMI_SERVER_TASK_NAME}).` }; - } - - async function restart(): Promise { - const end = await deps.execSchtasks(['/End', '/TN', KIMI_SERVER_TASK_NAME]); - if (end.code !== 0) { - return { - ok: false, - message: `schtasks /End failed during restart: ${detail(end) ?? 'unknown error'}`, - }; - } - const run = await deps.execSchtasks(['/Run', '/TN', KIMI_SERVER_TASK_NAME]); - if (run.code !== 0) { - return { - ok: false, - message: `schtasks /Run failed during restart: ${detail(run) ?? 'unknown error'}`, - }; - } - return { ok: true, message: `Kimi server restarted (${KIMI_SERVER_TASK_NAME}).` }; - } - - async function status(): Promise { - const plan = readInstallPlan(); - const installed = await deps.taskExists(); - - const base: ServiceStatus = { - platform: 'win32', - installed, - running: false, - taskName: KIMI_SERVER_TASK_NAME, - ...(plan?.host !== undefined ? { host: plan.host } : {}), - ...(plan?.port !== undefined ? { port: plan.port } : {}), - logPath: deps.logPath(), - }; - - if (!installed) { - return { ...base, notes: ['Scheduled task is not installed.'] }; - } - - const query = await deps.execSchtasks([ - '/Query', - '/TN', - KIMI_SERVER_TASK_NAME, - '/FO', - 'CSV', - '/V', - ]); - if (query.code !== 0) { - return { - ...base, - notes: [ - `schtasks /Query failed (code ${query.code}): ${detail(query) ?? 'unknown'}.`, - 'The task is registered but its state could not be read.', - ], - }; - } - const row = parseSchtasksQuery(query.stdout); - const taskStatus = row?.['Status']; - const running = taskStatus === 'Running'; - return { - ...base, - running, - notes: [`schtasks status: ${taskStatus ?? 'unknown'}`], - }; - } - - return { install, uninstall, start, stop, restart, status }; -} - - -function defaultWriteTaskXml(xml: string): string { - const dir = mkdtempSync(join(tmpdir(), 'kimi-server-task-')); - const xmlPath = join(dir, 'task.xml'); - const bom = Buffer.from([0xff, 0xfe]); - const body = Buffer.from(xml, 'utf16le'); - writeFileSync(xmlPath, Buffer.concat([bom, body])); - return xmlPath; -} - - -async function defaultTaskExists(): Promise { - const res = await execFileUtf8('schtasks', ['/Query', '/TN', KIMI_SERVER_TASK_NAME], { - windowsHide: true, - }); - return res.code === 0; -} - - -function serializeArguments(plan: InstallPlan): string { - - return plan.programArguments - .slice(1) - .map((arg) => (/\s/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg)) - .join(' '); -} - -function detail(res: ExecResult): string | undefined { - const text = (res.stderr || res.stdout).trim(); - return text.length > 0 ? text : undefined; -} - - -export function taskXmlExists(path: string): boolean { - return existsSync(path); -} diff --git a/packages/kap-server/src/svc/service.ts b/packages/kap-server/src/svc/service.ts deleted file mode 100644 index aa0e364e18..0000000000 --- a/packages/kap-server/src/svc/service.ts +++ /dev/null @@ -1,34 +0,0 @@ - - -import { createLaunchdManager } from './launchd'; -import { createSchtasksManager } from './schtasks'; -import { createSystemdManager } from './systemd'; -import { ServiceUnsupportedError, type ServiceManager } from './types'; - -export function resolveServiceManager(platform: NodeJS.Platform = process.platform): ServiceManager { - switch (platform) { - case 'darwin': - return createLaunchdManager(); - case 'linux': - return createSystemdManager(); - case 'win32': - return createSchtasksManager(); - default: - return createUnsupportedManager(platform); - } -} - - -function createUnsupportedManager(platform: string): ServiceManager { - const fail = (): never => { - throw new ServiceUnsupportedError(platform); - }; - return { - install: async () => fail(), - uninstall: async () => fail(), - start: async () => fail(), - stop: async () => fail(), - restart: async () => fail(), - status: async () => fail(), - }; -} diff --git a/packages/kap-server/src/svc/systemd-unit.ts b/packages/kap-server/src/svc/systemd-unit.ts deleted file mode 100644 index 47543e3562..0000000000 --- a/packages/kap-server/src/svc/systemd-unit.ts +++ /dev/null @@ -1,84 +0,0 @@ - - -const FORBIDDEN = /[\r\n]/; - -function assertNoLineBreaks(value: string, label: string): void { - if (FORBIDDEN.test(value)) { - throw new Error(`${label} cannot contain CR or LF characters.`); - } -} - - -function systemdEscapeArg(value: string): string { - assertNoLineBreaks(value, 'Systemd unit values'); - if (!/[\s"\\]/.test(value)) { - return value; - } - return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; -} - -export interface BuildSystemdUnitInput { - description?: string; - programArguments: readonly string[]; - workingDirectory?: string; - - environment?: Readonly>; -} - - -export function buildSystemdUnit(input: BuildSystemdUnitInput): string { - const execStart = input.programArguments.map(systemdEscapeArg).join(' '); - const description = (input.description ?? 'Kimi Code local server').trim(); - assertNoLineBreaks(description, 'Systemd Description'); - - const workingDirLine = input.workingDirectory - ? `WorkingDirectory=${systemdEscapeArg(input.workingDirectory)}` - : null; - - const envLines = input.environment - ? Object.entries(input.environment).map(([k, v]) => { - assertNoLineBreaks(k, 'Systemd environment variable names'); - assertNoLineBreaks(v, 'Systemd environment variable values'); - return `Environment=${systemdEscapeArg(`${k}=${v}`)}`; - }) - : []; - - const lines = [ - '[Unit]', - `Description=${description}`, - 'After=network-online.target', - 'Wants=network-online.target', - 'StartLimitBurst=5', - 'StartLimitIntervalSec=60', - '', - '[Service]', - 'Type=simple', - `ExecStart=${execStart}`, - 'Restart=always', - 'RestartSec=5', - 'TimeoutStopSec=30', - 'TimeoutStartSec=30', - 'KillMode=control-group', - workingDirLine, - ...envLines, - '', - '[Install]', - 'WantedBy=default.target', - '', - ]; - return lines.filter((line): line is string => line !== null).join('\n'); -} - -export function parseSystemctlShow(output: string): Record { - const result: Record = {}; - for (const rawLine of output.split(/\r?\n/)) { - const idx = rawLine.indexOf('='); - if (idx === -1) continue; - const key = rawLine.slice(0, idx).trim(); - const value = rawLine.slice(idx + 1); - if (key.length > 0) { - result[key] = value; - } - } - return result; -} diff --git a/packages/kap-server/src/svc/systemd.ts b/packages/kap-server/src/svc/systemd.ts deleted file mode 100644 index a596a53994..0000000000 --- a/packages/kap-server/src/svc/systemd.ts +++ /dev/null @@ -1,226 +0,0 @@ - - -import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; -import { dirname } from 'node:path'; - -import { execFileUtf8, type ExecOptions, type ExecResult } from './exec'; -import { - buildInstallPlan, - deleteInstallPlan, - readInstallPlan, - writeInstallPlan, - type InstallPlan, -} from './install-plan'; -import { - KIMI_SERVER_SYSTEMD_UNIT, - supervisorLogPath as defaultSupervisorLogPath, - systemdUnitPath as defaultSystemdUnitPath, -} from './paths'; -import { resolveSupervisorProgram } from './program'; -import { buildSystemdUnit, parseSystemctlShow } from './systemd-unit'; -import { ServiceUnavailableError } from './types'; -import type { - InstallArgs, - InstallResult, - LifecycleResult, - ServiceManager, - ServiceStatus, -} from './types'; - -const UNIT_MODE = 0o600; -const UNIT_DIR_MODE = 0o755; - -export interface SystemdManagerDeps { - - execSystemctl(args: readonly string[], options?: ExecOptions): Promise; - - resolveProgram(): string; - - unitPath(): string; - - logPath(): string; -} - -const DEFAULT_DEPS: SystemdManagerDeps = { - execSystemctl: (args, options) => execFileUtf8('systemctl', ['--user', ...args], options), - resolveProgram: () => resolveSupervisorProgram(), - unitPath: defaultSystemdUnitPath, - logPath: defaultSupervisorLogPath, -}; - -export function createSystemdManager( - overrides: Partial = {}, -): ServiceManager { - const deps: SystemdManagerDeps = { ...DEFAULT_DEPS, ...overrides }; - - async function install(args: InstallArgs): Promise { - const unitPath = deps.unitPath(); - const logPath = deps.logPath(); - const program = deps.resolveProgram(); - const plan = buildInstallPlan({ ...args, program, logPath }); - - const alreadyInstalled = existsSync(unitPath); - if (alreadyInstalled && args.force !== true) { - return { - status: 'already-installed', - message: `systemd unit already installed at ${unitPath}. Rerun with --force to replace it.`, - unitPath, - }; - } - - await assertUserSystemdAvailable(deps); - - writeUnit(unitPath, plan); - writeInstallPlan(plan); - - const reload = await deps.execSystemctl(['daemon-reload']); - if (reload.code !== 0) { - throw new Error( - `systemctl --user daemon-reload failed (code ${reload.code}): ${detail(reload) ?? 'unknown error'}`, - ); - } - - const enable = await deps.execSystemctl(['enable', '--now', KIMI_SERVER_SYSTEMD_UNIT]); - if (enable.code !== 0) { - throw new Error( - `systemctl --user enable --now failed (code ${enable.code}): ${detail(enable) ?? 'unknown error'}`, - ); - } - - return { - status: alreadyInstalled ? 'replaced' : 'installed', - message: `Kimi server systemd unit ${alreadyInstalled ? 'replaced' : 'installed'} at ${unitPath} (port ${plan.port}).`, - unitPath, - }; - } - - async function uninstall(): Promise { - const unitPath = deps.unitPath(); - if (!existsSync(unitPath)) { - deleteInstallPlan(); - return { ok: true, message: 'systemd unit was not installed; nothing to remove.' }; - } - await deps.execSystemctl(['disable', '--now', KIMI_SERVER_SYSTEMD_UNIT]).catch(() => undefined); - try { - rmSync(unitPath, { force: true }); - } catch (error) { - throw new Error( - `failed to remove unit ${unitPath}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - await deps.execSystemctl(['daemon-reload']).catch(() => undefined); - deleteInstallPlan(); - return { ok: true, message: `systemd unit removed (${unitPath}).` }; - } - - async function start(): Promise { - if (!existsSync(deps.unitPath())) { - return { - ok: false, - message: 'systemd unit is not installed. Run `kimi server install` first.', - }; - } - const result = await deps.execSystemctl(['start', KIMI_SERVER_SYSTEMD_UNIT]); - if (result.code !== 0) { - return { - ok: false, - message: `systemctl --user start failed: ${detail(result) ?? 'unknown error'}`, - }; - } - return { ok: true, message: `Kimi server started (${KIMI_SERVER_SYSTEMD_UNIT}).` }; - } - - async function stop(): Promise { - const result = await deps.execSystemctl(['stop', KIMI_SERVER_SYSTEMD_UNIT]); - if (result.code !== 0) { - return { - ok: false, - message: `systemctl --user stop failed: ${detail(result) ?? 'unknown error'}`, - }; - } - return { ok: true, message: `Kimi server stopped (${KIMI_SERVER_SYSTEMD_UNIT}).` }; - } - - async function restart(): Promise { - const result = await deps.execSystemctl(['restart', KIMI_SERVER_SYSTEMD_UNIT]); - if (result.code !== 0) { - return { - ok: false, - message: `systemctl --user restart failed: ${detail(result) ?? 'unknown error'}`, - }; - } - return { ok: true, message: `Kimi server restarted (${KIMI_SERVER_SYSTEMD_UNIT}).` }; - } - - async function status(): Promise { - const unitPath = deps.unitPath(); - const plan = readInstallPlan(); - const installed = existsSync(unitPath); - - const base: ServiceStatus = { - platform: 'linux', - installed, - running: false, - unitName: KIMI_SERVER_SYSTEMD_UNIT, - ...(plan?.host !== undefined ? { host: plan.host } : {}), - ...(plan?.port !== undefined ? { port: plan.port } : {}), - logPath: deps.logPath(), - }; - - if (!installed) { - return { ...base, notes: ['systemd unit is not installed.'] }; - } - - const show = await deps.execSystemctl([ - 'show', - KIMI_SERVER_SYSTEMD_UNIT, - '--property=ActiveState,SubState,MainPID,ExecStart', - ]); - if (show.code !== 0) { - return { - ...base, - notes: [ - `systemctl --user show failed (code ${show.code}): ${detail(show) ?? 'unknown'}.`, - 'The unit is on disk but systemd could not report its state.', - ], - }; - } - const fields = parseSystemctlShow(show.stdout); - const activeState = fields['ActiveState']; - const subState = fields['SubState']; - const mainPid = Number.parseInt(fields['MainPID'] ?? '', 10); - const running = activeState === 'active' && subState !== 'failed'; - return { - ...base, - running, - ...(Number.isFinite(mainPid) && mainPid > 0 ? { pid: mainPid } : {}), - notes: [`systemd state: ${activeState ?? 'unknown'}/${subState ?? 'unknown'}`], - }; - } - - return { install, uninstall, start, stop, restart, status }; -} - -async function assertUserSystemdAvailable(deps: SystemdManagerDeps): Promise { - const probe = await deps.execSystemctl(['show-environment']); - if (probe.code === 0) return; - - throw new ServiceUnavailableError( - 'linux', - `systemd --user is not available in this environment: ${detail(probe) ?? 'systemctl --user show-environment failed'}.`, - ); -} - -function writeUnit(unitPath: string, plan: InstallPlan): void { - const text = buildSystemdUnit({ - description: 'Kimi Code local server (managed by `kimi server install`)', - programArguments: plan.programArguments, - }); - mkdirSync(dirname(unitPath), { recursive: true, mode: UNIT_DIR_MODE }); - writeFileSync(unitPath, text, { mode: UNIT_MODE }); -} - -function detail(res: ExecResult): string | undefined { - const text = (res.stderr || res.stdout).trim(); - return text.length > 0 ? text : undefined; -} diff --git a/packages/kap-server/src/svc/types.ts b/packages/kap-server/src/svc/types.ts deleted file mode 100644 index 95f970e747..0000000000 --- a/packages/kap-server/src/svc/types.ts +++ /dev/null @@ -1,97 +0,0 @@ - - -import type { ServerLogLevel } from '../services/pinoLoggerService'; - - -export interface InstallArgs { - - host: string; - - port: number; - - logLevel: ServerLogLevel; - - force?: boolean; -} - - -export interface InstallResult { - status: 'installed' | 'replaced' | 'already-installed'; - message: string; - - plistPath?: string; - - unitPath?: string; - - taskName?: string; -} - - -export interface LifecycleResult { - ok: boolean; - - message: string; -} - - -export interface ServiceStatus { - - platform: 'darwin' | 'linux' | 'win32'; - - installed: boolean; - - running: boolean; - - pid?: number; - - port?: number; - - host?: string; - - logPath?: string; - - label?: string; - - unitName?: string; - - taskName?: string; - - notes?: readonly string[]; -} - - -export interface ServiceManager { - install(args: InstallArgs): Promise; - uninstall(): Promise; - start(): Promise; - stop(): Promise; - restart(): Promise; - status(): Promise; -} - - -export class ServiceUnsupportedError extends Error { - override readonly name = 'ServiceUnsupportedError'; - readonly code = 'ESERVICE_UNSUPPORTED' as const; - readonly exitCode = 2 as const; - readonly platform: string; - constructor(platform: string) { - super(`Kimi server service management is not yet supported on ${platform}.`); - this.platform = platform; - } -} - - -export class ServiceUnavailableError extends Error { - override readonly name = 'ServiceUnavailableError'; - readonly code = 'ESERVICE_UNAVAILABLE' as const; - readonly exitCode = 2 as const; - readonly platform: string; - - constructor(platform: string, reason: string) { - super( - `${reason} Run \`kimi server run --port \` directly when running inside Docker or another container supervisor.`, - ); - this.platform = platform; - } -} diff --git a/packages/kap-server/test/boot.test.ts b/packages/kap-server/test/boot.test.ts index f2a65b4334..dba60db6fc 100644 --- a/packages/kap-server/test/boot.test.ts +++ b/packages/kap-server/test/boot.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, rm } from 'node:fs/promises'; import { createServer, type Server } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -8,7 +8,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { hostRequestHeadersSeed, IHostRequestHeaders, ISkillCatalogRuntimeOptions } from '@moonshot-ai/agent-core-v2'; -import type { LockContents } from '../src/lock'; +import { listLiveServerInstances } from '../src/instanceRegistry'; import { listenWithPortRetry, type RunningServer, startServer } from '../src/start'; import { getServerVersion } from '../src/version'; import { authedFetch } from './helpers/auth'; @@ -98,11 +98,10 @@ describe('server-v2 boot', () => { }; expect(metaBody.data.server_version).toBe('9.9.9-host'); - // The host version is also what the lock advertises to status/ps clients. - const stored = JSON.parse( - await readFile(join(home, 'server', 'lock'), 'utf8'), - ) as LockContents; - expect(stored.host_version).toBe('9.9.9-host'); + // The host version is also what the instance registry advertises to + // status/ps clients. + const [instance] = await listLiveServerInstances(home); + expect(instance?.hostVersion).toBe('9.9.9-host'); // ... and it backs the default product User-Agent. const defaults = server.core.accessor.get(IHostRequestHeaders); @@ -303,11 +302,11 @@ describe('server-v2 boot — port retry', () => { } }); - it('retries on port+1 and advertises the bound port in the lock', async () => { + it('retries on port+1 and advertises the bound port in the instance registry', async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-port-retry-')); const { port, next } = await allocateAdjacentFreePair(); // Occupy the requested port with a raw TCP server (a "third-party" process - // from the server's point of view — it does NOT hold the lock). + // from the server's point of view — it is not a registered kimi instance). const occupant = await listenOnPort('127.0.0.1', port); try { server = await startServer({ @@ -317,14 +316,12 @@ describe('server-v2 boot — port retry', () => { logLevel: 'silent', }); - // Bound to the next available port (>= next); the lock advertises it so - // status/kill/ps work. On Windows a recently-closed probe port can linger - // in TIME_WAIT, so the retry may land on port+2 instead of port+1. + // Bound to the next available port (>= next); the registry advertises it + // so status/kill/ps work. On Windows a recently-closed probe port can + // linger in TIME_WAIT, so the retry may land on port+2 instead of port+1. expect(server.port).toBeGreaterThanOrEqual(next); - const stored = JSON.parse( - await readFile(join(home, 'server', 'lock'), 'utf8'), - ) as LockContents; - expect(stored.port).toBe(server.port); + const [instance] = await listLiveServerInstances(home); + expect(instance?.port).toBe(server.port); } finally { await closeNetServer(occupant); } diff --git a/packages/kap-server/test/fs-watch.e2e.test.ts b/packages/kap-server/test/fs-watch.e2e.test.ts index 455d46ea77..e674e625e3 100644 --- a/packages/kap-server/test/fs-watch.e2e.test.ts +++ b/packages/kap-server/test/fs-watch.e2e.test.ts @@ -26,14 +26,12 @@ import { WebSocket, type RawData } from 'ws'; import { startServer, type RunningServer } from '../src/start'; let tmpDir: string; -let lockPath: string; let bridgeHome: string; let workspace: string; let server: RunningServer | undefined; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'kap-fswatch-')); - lockPath = join(tmpDir, 'lock'); bridgeHome = mkdtempSync(join(tmpdir(), 'kap-fswatch-home-')); workspace = join(tmpDir, 'workspace'); mkdirSync(workspace, { recursive: true }); @@ -58,7 +56,6 @@ async function boot(): Promise { host: '127.0.0.1', port: 0, homeDir: bridgeHome, - lockPath, logger: pino({ level: 'silent' }), disableAuth: true, }); diff --git a/packages/kap-server/test/hostnames.test.ts b/packages/kap-server/test/hostnames.test.ts index 1a59f06728..d4a7a79879 100644 --- a/packages/kap-server/test/hostnames.test.ts +++ b/packages/kap-server/test/hostnames.test.ts @@ -36,7 +36,7 @@ describe('stripPort', () => { describe('formatHostErrorMessage', () => { it('includes the rejected host and allow guidance', () => { expect(formatHostErrorMessage('APP.Example.com:443')).toBe( - "Invalid Host header: app.example.com; allow this host with KIMI_CODE_ALLOWED_HOSTS=app.example.com or 'kimi server run --allowed-host app.example.com'.", + "Invalid Host header: app.example.com; allow this host with KIMI_CODE_ALLOWED_HOSTS=app.example.com or 'kimi web --allowed-host app.example.com'.", ); }); }); @@ -145,7 +145,7 @@ describe('createHostCheck (onRequest hook)', () => { const body = res.json() as Record; expect(body['code']).toBe(40301); expect(body['msg']).toBe( - "Invalid Host header: evil.com; allow this host with KIMI_CODE_ALLOWED_HOSTS=evil.com or 'kimi server run --allowed-host evil.com'.", + "Invalid Host header: evil.com; allow this host with KIMI_CODE_ALLOWED_HOSTS=evil.com or 'kimi web --allowed-host evil.com'.", ); expect(body['data']).toBeNull(); expect(typeof body['request_id']).toBe('string'); diff --git a/packages/kap-server/test/instanceRegistry.test.ts b/packages/kap-server/test/instanceRegistry.test.ts index 2e6ef951e3..4bd79f9873 100644 --- a/packages/kap-server/test/instanceRegistry.test.ts +++ b/packages/kap-server/test/instanceRegistry.test.ts @@ -1,9 +1,10 @@ /** - * Server instance registry semantics (plan P0 — multi-server shared homedir). + * Server instance registry semantics — the always-on kap-server discovery + * mechanism (no feature flag; every instance registers itself). * * Hermetic strategy: every test uses a tmpdir instances dir so the real * `~/.kimi-code/server/instances` is never touched. Dead-pid simulation uses - * `0x7fffffff` (guaranteed ESRCH on Linux/macOS), mirroring lock.test.ts. + * `0x7fffffff` (guaranteed ESRCH on Linux/macOS). */ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; @@ -18,6 +19,7 @@ import { listLiveServerInstances, type ServerInstanceInfo, } from '../src/instanceRegistry'; +import { type RunningServer, startServer } from '../src/start'; let tmpDir: string; let instancesDir: string; @@ -258,3 +260,76 @@ describe('convenience readers', () => { await expect(getLiveServerInstance(tmpDir)).resolves.toBeUndefined(); }); }); + + +describe('startServer — instance registry wiring', () => { + let home: string | undefined; + const servers: RunningServer[] = []; + + afterEach(async () => { + while (servers.length > 0) { + await servers.pop()!.close(); + } + if (home !== undefined) { + rmSync(home, { recursive: true, force: true }); + home = undefined; + } + }); + + it('lets two servers share one homeDir, each registering a distinct instance and port', async () => { + home = mkdtempSync(join(tmpdir(), 'kimi-server-multi-server-')); + const a = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + servers.push(a); + const b = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + servers.push(b); + + // Each instance binds its own (ephemeral) port and registers it. + expect(b.port).not.toBe(a.port); + + const live = await listLiveServerInstances(home); + expect(live).toHaveLength(2); + expect(new Set(live.map((i) => i.serverId)).size).toBe(2); + expect(live.map((i) => i.port).sort((x, y) => x - y)).toEqual( + [a.port, b.port].sort((x, y) => x - y), + ); + // The legacy single-instance lock is never created. + expect(existsSync(join(home, 'server', 'lock'))).toBe(false); + }); + + it('removes its instance file on close so peers no longer list it', async () => { + home = mkdtempSync(join(tmpdir(), 'kimi-server-multi-server-')); + const a = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + servers.push(a); + const b = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + servers.push(b); + expect(await listLiveServerInstances(home)).toHaveLength(2); + + await a.close(); + servers.splice(servers.indexOf(a), 1); + + const live = await listLiveServerInstances(home); + expect(live).toHaveLength(1); + expect(live[0]?.port).toBe(b.port); + }); + + it('releases its registration on close so a fresh instance on the same home can start', async () => { + home = mkdtempSync(join(tmpdir(), 'kimi-server-multi-server-')); + const first = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + await first.close(); + + const restarted = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + servers.push(restarted); + expect(await listLiveServerInstances(home)).toHaveLength(1); + expect((await listLiveServerInstances(home))[0]?.port).toBe(restarted.port); + }); +}); diff --git a/packages/kap-server/test/lock.test.ts b/packages/kap-server/test/lock.test.ts deleted file mode 100644 index c087b358eb..0000000000 --- a/packages/kap-server/test/lock.test.ts +++ /dev/null @@ -1,370 +0,0 @@ -/** - * Lock file semantics (ROADMAP P0.12 AC) — ported from v1. - * - * Hermetic strategy: every test uses a tmpdir lock path so production - * `~/.kimi/server/lock` is never touched. We mint pid values that don't - * collide with the real process (we ARE the test process, so use a clearly - * dead high pid like 0x7fffffff for stale-takeover tests; `process.kill(pid, - * 0)` returns ESRCH for any unallocated pid on Linux/macOS). - */ - -import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, existsSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { - DEFAULT_LOCK_PATH, - ServerLockedError, - acquireLock, - getLiveLock, - type LockContents, -} from '../src/lock'; -import { type RunningServer, startServer } from '../src/start'; -import { listLiveServerInstances } from '../src/instanceRegistry'; - -let tmpDir: string; -let lockPath: string; - -beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-lock-test-')); - lockPath = join(tmpDir, 'lock'); -}); - -afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }); -}); - -describe('acquireLock — basic acquire / release', () => { - it('writes pid/started_at/port JSON and release deletes the file', () => { - const handle = acquireLock({ - lockPath, - port: 58627, - nowIso: '2026-06-05T00:00:00.000Z', - }); - expect(handle.lockPath).toBe(lockPath); - expect(existsSync(lockPath)).toBe(true); - - const stored = JSON.parse(readFileSync(lockPath, 'utf8')) as LockContents; - expect(stored).toEqual({ - pid: process.pid, - started_at: '2026-06-05T00:00:00.000Z', - port: 58627, - }); - - handle.release(); - expect(existsSync(lockPath)).toBe(false); - }); - - it('defaults nowIso + pid when not provided', () => { - const handle = acquireLock({ lockPath, port: 1234 }); - const stored = JSON.parse(readFileSync(lockPath, 'utf8')) as LockContents; - expect(stored.pid).toBe(process.pid); - expect(stored.port).toBe(1234); - // ISO 8601 with milliseconds + Z. Loose check — full format coverage lives in protocol/time.test.ts. - expect(stored.started_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); - handle.release(); - }); - - it('release is idempotent — second call is a no-op', () => { - const handle = acquireLock({ lockPath, port: 9 }); - handle.release(); - handle.release(); // would throw if not guarded - expect(existsSync(lockPath)).toBe(false); - }); - - it('release tolerates a missing lock file (best-effort)', () => { - const handle = acquireLock({ lockPath, port: 9 }); - // Operator manually rm'd it between acquire and release. - rmSync(lockPath); - expect(() => handle.release()).not.toThrow(); - }); - - it.skipIf(process.platform === 'win32')('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', () => { - it('throws ServerLockedError when a live owner already holds the lock', () => { - // Simulate "another live server" by writing a lock file with our own pid - // (which is definitely alive — this test runner) but a fake port. - const existing: LockContents = { - pid: process.pid, - started_at: '2026-06-05T00:00:00.000Z', - port: 58627, - }; - writeFileSync(lockPath, JSON.stringify(existing)); - - // Same-pid double-acquire is also a conflict (single-server-per-process - // invariant). Caller must release the previous handle first. - expect(() => acquireLock({ lockPath, port: 58627 })).toThrow(ServerLockedError); - - try { - acquireLock({ lockPath, port: 58627 }); - } catch (err) { - const e = err as ServerLockedError; - expect(e.code).toBe('ESERVER_LOCKED'); - expect(e.exitCode).toBe(2); - expect(e.message).toContain(`pid=${process.pid}`); - expect(e.message).toContain('port=58627'); - expect(e.existing).toEqual(existing); - } - }); - - it('takes over a stale lock whose recorded pid is dead', () => { - // 0x7fffffff (2147483647) is the max signed-32 pid on Linux/macOS; the - // kernel never allocates pids that high in normal operation. ESRCH guaranteed. - const stalePid = 0x7fffffff; - writeFileSync( - lockPath, - JSON.stringify({ - pid: stalePid, - started_at: '2025-01-01T00:00:00.000Z', - port: 58627, - } satisfies LockContents), - ); - - const handle = acquireLock({ lockPath, port: 58627 }); - const stored = JSON.parse(readFileSync(lockPath, 'utf8')) as LockContents; - expect(stored.pid).toBe(process.pid); - expect(stored.port).toBe(58627); - handle.release(); - }); - - it('takes over an unparseable lock file', () => { - writeFileSync(lockPath, '{garbage'); - const handle = acquireLock({ lockPath, port: 4242 }); - const stored = JSON.parse(readFileSync(lockPath, 'utf8')) as LockContents; - expect(stored.pid).toBe(process.pid); - handle.release(); - }); - - it('does NOT delete the lock if a third party stole ownership between acquire and release', () => { - const handle = acquireLock({ lockPath, port: 9999 }); - // Simulate another server clobbering the file with its own pid+port. - const otherPid = 0x7ffffff0; - writeFileSync( - lockPath, - JSON.stringify({ pid: otherPid, started_at: 'x', port: 1234 } satisfies LockContents), - ); - - handle.release(); - expect(existsSync(lockPath)).toBe(true); // mismatched pid → preserved - }); -}); - -describe('acquireLock — updatePort', () => { - it('rewrites the recorded port when our pid owns the lock', () => { - const handle = acquireLock({ lockPath, port: 7878 }); - - handle.updatePort(7879); - - const stored = JSON.parse(readFileSync(lockPath, 'utf8')) as LockContents; - expect(stored.port).toBe(7879); - expect(stored.pid).toBe(process.pid); - handle.release(); - }); - - it('preserves unrelated fields when rewriting the port', () => { - const handle = acquireLock({ - lockPath, - port: 7878, - host: '127.0.0.1', - hostVersion: '1.2.3', - entry: '/usr/local/bin/kimi', - nowIso: '2026-06-05T00:00:00.000Z', - }); - - handle.updatePort(7880); - - const stored = JSON.parse(readFileSync(lockPath, 'utf8')) as LockContents; - expect(stored).toEqual({ - pid: process.pid, - started_at: '2026-06-05T00:00:00.000Z', - host: '127.0.0.1', - port: 7880, - host_version: '1.2.3', - entry: '/usr/local/bin/kimi', - }); - handle.release(); - }); - - it('is a no-op when the port is unchanged', () => { - const handle = acquireLock({ lockPath, port: 7878 }); - const before = readFileSync(lockPath, 'utf8'); - - handle.updatePort(7878); - - expect(readFileSync(lockPath, 'utf8')).toBe(before); - handle.release(); - }); - - it('is a no-op when the lock file is missing', () => { - const handle = acquireLock({ lockPath, port: 7878 }); - rmSync(lockPath); - - expect(() => { - handle.updatePort(7879); - }).not.toThrow(); - expect(existsSync(lockPath)).toBe(false); - handle.release(); - }); - - it('does NOT rewrite the port when a third party owns the lock', () => { - const handle = acquireLock({ lockPath, port: 7878 }); - // Simulate another server clobbering the file with its own pid+port. - writeFileSync( - lockPath, - JSON.stringify({ pid: 0x7ffffff0, started_at: 'x', port: 7878 } satisfies LockContents), - ); - - handle.updatePort(7879); - - const stored = JSON.parse(readFileSync(lockPath, 'utf8')) as LockContents; - expect(stored.port).toBe(7878); // untouched — we don't own it anymore - handle.release(); - }); -}); - -describe('acquireLock — defaults', () => { - it('DEFAULT_LOCK_PATH points under the kimi-code home', () => { - expect(DEFAULT_LOCK_PATH).toMatch(/[/\\]\.kimi-code[/\\]server[/\\]lock$/); - }); -}); - -describe('getLiveLock', () => { - it('returns undefined when the lock file is missing', () => { - expect(getLiveLock(lockPath)).toBeUndefined(); - }); - - it('returns undefined for an unparseable lock file', () => { - writeFileSync(lockPath, '{garbage'); - expect(getLiveLock(lockPath)).toBeUndefined(); - }); - - it('returns undefined for a stale lock whose recorded pid is dead', () => { - writeFileSync( - lockPath, - JSON.stringify({ - pid: 0x7fffffff, - started_at: '2025-01-01T00:00:00.000Z', - port: 58627, - } satisfies LockContents), - ); - expect(getLiveLock(lockPath)).toBeUndefined(); - }); - - it('returns the contents when the recorded pid is alive', () => { - const live: LockContents = { - pid: process.pid, - started_at: '2026-06-05T00:00:00.000Z', - port: 9000, - }; - writeFileSync(lockPath, JSON.stringify(live)); - expect(getLiveLock(lockPath)).toEqual(live); - }); -}); - -describe('startServer — single-instance lock wiring', () => { - let home: string | undefined; - let first: RunningServer | undefined; - - afterEach(async () => { - if (first !== undefined) { - await first.close(); - first = undefined; - } - if (home !== undefined) { - rmSync(home, { recursive: true, force: true }); - home = undefined; - } - }); - - it('refuses a second server on the same homeDir and releases the lock on close', async () => { - home = mkdtempSync(join(tmpdir(), 'kimi-server-v2-lock-wiring-')); - first = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); - - // A second instance on the same home must fail fast with ServerLockedError. - await expect( - startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }), - ).rejects.toThrow(ServerLockedError); - - // After the first server closes, the lock is released so a fresh instance - // on the same home can start (stale-takeover / reuse path). - await first.close(); - first = undefined; - const restarted = await startServer({ - host: '127.0.0.1', - port: 0, - homeDir: home, - logLevel: 'silent', - }); - await restarted.close(); - }); -}); - -describe('startServer — multi_server flag wiring', () => { - const ENV = 'KIMI_CODE_EXPERIMENTAL_MULTI_SERVER'; - let home: string | undefined; - let prevEnv: string | undefined; - const servers: RunningServer[] = []; - - beforeEach(() => { - prevEnv = process.env[ENV]; - process.env[ENV] = '1'; - }); - - afterEach(async () => { - if (prevEnv === undefined) delete process.env[ENV]; - else process.env[ENV] = prevEnv; - while (servers.length > 0) { - await servers.pop()!.close(); - } - if (home !== undefined) { - rmSync(home, { recursive: true, force: true }); - home = undefined; - } - }); - - it('lets two servers share one homeDir, each registering a distinct instance and port', async () => { - home = mkdtempSync(join(tmpdir(), 'kimi-server-v2-multi-server-')); - const a = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); - servers.push(a); - const b = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); - servers.push(b); - - // Each instance binds its own (ephemeral) port and registers it. - expect(b.port).not.toBe(a.port); - - const live = await listLiveServerInstances(home); - expect(live).toHaveLength(2); - expect(new Set(live.map((i) => i.serverId)).size).toBe(2); - expect(live.map((i) => i.port).sort((x, y) => x - y)).toEqual( - [a.port, b.port].sort((x, y) => x - y), - ); - // The legacy single-instance lock is NOT created in multi-server mode. - expect(existsSync(join(home, 'server', 'lock'))).toBe(false); - }); - - it('removes its instance file on close so peers no longer list it', async () => { - home = mkdtempSync(join(tmpdir(), 'kimi-server-v2-multi-server-')); - const a = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); - servers.push(a); - const b = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); - servers.push(b); - expect(await listLiveServerInstances(home)).toHaveLength(2); - - await a.close(); - servers.splice(servers.indexOf(a), 1); - - const live = await listLiveServerInstances(home); - expect(live).toHaveLength(1); - expect(live[0]?.port).toBe(b.port); - }); -}); diff --git a/packages/kap-server/test/svc/launchd.test.ts b/packages/kap-server/test/svc/launchd.test.ts deleted file mode 100644 index 64cd82b40b..0000000000 --- a/packages/kap-server/test/svc/launchd.test.ts +++ /dev/null @@ -1,339 +0,0 @@ -/** - * Pure-function tests for the launchd backend. No real `launchctl` shell-out — - * `execLaunchctl` is stubbed so we can assert the exact argv and force - * pre-canned results. - * - * Mirrors openclaw's pattern from `../openclaw/src/daemon/launchd.test.ts`, - * trimmed to the small ServiceManager surface. - */ - -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { buildLaunchAgentPlist } from '../../src/svc/launchd-plist'; -import { - createLaunchdManager, - resolveSupervisorProgram, - parseLaunchctlPrint, - type LaunchdManagerDeps, -} from '../../src/svc/launchd'; -import { KIMI_SERVER_LABEL } from '../../src/svc/paths'; -import { readInstallPlan, writeInstallPlan } from '../../src/svc/install-plan'; -import type { ExecOptions, ExecResult } from '../../src/svc/exec'; - -interface StubCall { - args: readonly string[]; - options?: ExecOptions; -} - -function makeStubExec(responses: ReadonlyArray) { - const calls: StubCall[] = []; - let i = 0; - const execLaunchctl = async (args: readonly string[], options?: ExecOptions): Promise => { - calls.push({ args, options }); - const next = responses[i] ?? { stdout: '', stderr: '', code: 0 }; - i += 1; - return next; - }; - return { execLaunchctl, calls } as const; -} - -function makeDeps( - responses: ReadonlyArray, - workDir: string, -): { deps: LaunchdManagerDeps; calls: StubCall[]; plistPath: string; logPath: string; planPath: string } { - const { execLaunchctl, calls } = makeStubExec(responses); - const plistPath = join(workDir, 'Library', 'LaunchAgents', `${KIMI_SERVER_LABEL}.plist`); - const logPath = join(workDir, 'server', 'server.log'); - const planPath = join(workDir, 'server', 'install.json'); - // Re-point the install-plan path side-effects by overriding via env. - // Tests below pass `planPath` explicitly to read/writeInstallPlan, so the - // deps only need to point launchctl + filesystem locations. - const deps: LaunchdManagerDeps = { - execLaunchctl, - resolveProgram: () => '/usr/local/bin/kimi', - plistPath: () => plistPath, - logPath: () => logPath, - guiDomain: () => 'gui/501', - }; - return { deps, calls, plistPath, logPath, planPath }; -} - -let workDir: string; -let prevHome: string | undefined; - -beforeEach(() => { - workDir = mkdtempSync(join(tmpdir(), 'kimi-launchd-test-')); - // Pin KIMI_CODE_HOME so the implicit install.json path lands under workDir. - prevHome = process.env['KIMI_CODE_HOME']; - process.env['KIMI_CODE_HOME'] = workDir; -}); - -afterEach(() => { - if (prevHome === undefined) { - delete process.env['KIMI_CODE_HOME']; - } else { - process.env['KIMI_CODE_HOME'] = prevHome; - } - rmSync(workDir, { recursive: true, force: true }); -}); - -describe('buildLaunchAgentPlist', () => { - it('renders a well-formed plist with label, ProgramArguments, and stdio paths', () => { - const xml = buildLaunchAgentPlist({ - label: KIMI_SERVER_LABEL, - programArguments: ['/usr/local/bin/kimi', 'server', 'run', '--port', '58627'], - stdoutPath: '/tmp/x.log', - stderrPath: '/tmp/x.log', - }); - expect(xml).toContain(`Label\n ${KIMI_SERVER_LABEL}`); - expect(xml).toContain('/usr/local/bin/kimi'); - expect(xml).toContain('server'); - expect(xml).toContain('run'); - expect(xml).not.toContain('--host'); - expect(xml).toContain('--port'); - expect(xml).toContain('58627'); - expect(xml).toContain('StandardOutPath\n /tmp/x.log'); - expect(xml).toContain('RunAtLoad\n '); - expect(xml).toContain('KeepAlive\n '); - }); - - it('escapes XML-special characters in the program path', () => { - const xml = buildLaunchAgentPlist({ - label: 'test', - programArguments: ['/path/with & special '], - stdoutPath: '/tmp/a', - stderrPath: '/tmp/a', - }); - expect(xml).toContain('/path/with & special <chars>'); - expect(xml).not.toContain('& special '); - }); -}); - -describe('parseLaunchctlPrint', () => { - it('extracts state + pid from a `launchctl print` block', () => { - const sample = [ - `${'gui/501/' + KIMI_SERVER_LABEL} = {`, - '\tstate = running', - '\tpid = 4711', - '\tlast exit code = 78: EX_CONFIG', - '\tprogram = /usr/local/bin/kimi', - '}', - ].join('\n'); - const parsed = parseLaunchctlPrint(sample); - expect(parsed.state).toBe('running'); - expect(parsed.pid).toBe(4711); - expect(parsed.lastExitCode).toBe('78: EX_CONFIG'); - }); - - it('returns undefined fields when keys are missing', () => { - const parsed = parseLaunchctlPrint('domain = gui/501\nactive count = 1'); - expect(parsed.state).toBeUndefined(); - expect(parsed.pid).toBeUndefined(); - }); -}); - -describe('resolveSupervisorProgram', () => { - it('normalizes a relative executable path to an absolute path', () => { - expect(resolveSupervisorProgram(['node', './kimi'], '/tmp/kimi-bin')).toBe(resolve('/tmp/kimi-bin', './kimi')); - }); - - it('uses the absolute script path outside SEA mode', () => { - expect(resolveSupervisorProgram(['node', '/opt/kimi/dist/cli.mjs'], '/tmp', '/usr/bin/node', false)).toBe('/opt/kimi/dist/cli.mjs'); - }); - - it('returns execPath in SEA mode even when argv[1] is a bare command name', () => { - // Reproduces `kimi web` from the shell: argv[1] is the invoked command - // name, not a path — resolving it against cwd produced `/kimi` (ENOENT). - expect(resolveSupervisorProgram(['/Users/x/.kimi-code/bin/kimi', 'kimi', 'web'], '/Users/x', '/Users/x/.kimi-code/bin/kimi', true)).toBe('/Users/x/.kimi-code/bin/kimi'); - }); - - it('returns execPath in SEA mode for a spawned `server` child', () => { - expect(resolveSupervisorProgram(['/Users/x/.kimi-code/bin/kimi', 'server', 'run'], '/Users/x', '/Users/x/.kimi-code/bin/kimi', true)).toBe('/Users/x/.kimi-code/bin/kimi'); - }); -}); - -describe.skipIf(process.platform === 'win32')('launchd manager — install', () => { - it('writes the plist and bootstraps via launchctl', async () => { - const { deps, calls, plistPath } = makeDeps([{ stdout: '', stderr: '', code: 0 }], workDir); - const mgr = createLaunchdManager(deps); - const result = await mgr.install({ host: '127.0.0.1', port: 58627, logLevel: 'info' }); - - expect(result.status).toBe('installed'); - expect(result.plistPath).toBe(plistPath); - expect(existsSync(plistPath)).toBe(true); - const xml = readFileSync(plistPath, 'utf8'); - expect(xml).toContain(`${KIMI_SERVER_LABEL}`); - expect(xml).toContain('/usr/local/bin/kimi'); - expect(xml).toContain('58627'); - - expect(calls.length).toBe(1); - expect(calls[0]?.args).toEqual(['bootstrap', 'gui/501', plistPath]); - }); - - it('refuses to overwrite an existing install without --force', async () => { - const { deps, calls, plistPath } = makeDeps([], workDir); - require('node:fs').mkdirSync(plistPath.replace(/\/[^/]+$/, ''), { recursive: true }); - writeFileSync(plistPath, ''); - - const mgr = createLaunchdManager(deps); - const result = await mgr.install({ host: '127.0.0.1', port: 58627, logLevel: 'info' }); - expect(result.status).toBe('already-installed'); - expect(calls.length).toBe(0); // never invoked launchctl - }); - - it('overwrites + replaces when force=true', async () => { - const { deps, calls, plistPath } = makeDeps( - [ - { stdout: '', stderr: '', code: 0 }, // bootout - { stdout: '', stderr: '', code: 0 }, // bootstrap - ], - workDir, - ); - require('node:fs').mkdirSync(plistPath.replace(/\/[^/]+$/, ''), { recursive: true }); - writeFileSync(plistPath, ''); - - const mgr = createLaunchdManager(deps); - const result = await mgr.install({ host: '0.0.0.0', port: 9999, logLevel: 'debug', force: true }); - expect(result.status).toBe('replaced'); - expect(calls[0]?.args[0]).toBe('bootout'); - expect(calls[1]?.args[0]).toBe('bootstrap'); - const xml = readFileSync(plistPath, 'utf8'); - expect(xml).not.toContain('0.0.0.0'); - expect(xml).toContain('9999'); - }); - - it('surfaces a launchctl bootstrap failure as a thrown error', async () => { - const { deps } = makeDeps([{ stdout: '', stderr: 'GUI session unavailable', code: 5 }], workDir); - const mgr = createLaunchdManager(deps); - await expect( - mgr.install({ host: '127.0.0.1', port: 58627, logLevel: 'info' }), - ).rejects.toThrow(/launchctl bootstrap failed/); - }); - - it('treats `already loaded` bootstrap output as success', async () => { - const { deps } = makeDeps( - [{ stdout: '', stderr: 'service already loaded', code: 1 }], - workDir, - ); - const mgr = createLaunchdManager(deps); - const result = await mgr.install({ host: '127.0.0.1', port: 58627, logLevel: 'info' }); - expect(result.status).toBe('installed'); - }); -}); - -describe.skipIf(process.platform === 'win32')('launchd manager — lifecycle', () => { - it('start delegates to `launchctl kickstart -k /