From 7db88b6f0c1c59a88e423ae50aca5bab66b75663 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Sat, 4 Jul 2026 18:03:50 +0800 Subject: [PATCH] feat(server): add --dangerous-bypass-auth and --keep-alive flags (#1368) * feat(server): add --dangerous-bypass-auth and --keep-alive flags - --dangerous-bypass-auth disables bearer-token auth on every REST and WebSocket route and advertises it via /api/v1/meta so the web UI skips the token prompt; the startup banner drops the token and shows a red danger notice - --keep-alive keeps the daemon running instead of idle-killing after 60s; implied by --host / --allowed-host and always on in --foreground mode * fix(server): address review feedback on bypass-auth - keep the token and skip the bypass notice when a daemon is reused, since the requested --dangerous-bypass-auth flag is not applied to the already-running server - clear the cached dangerous_bypass_auth web state on HTTP 401 so a stale bypass value cannot hide the token prompt after the server restarts without the flag --- apps/kimi-code/src/cli/sub/server/daemon.ts | 14 ++ apps/kimi-code/src/cli/sub/server/run.ts | 96 +++++++-- apps/kimi-code/src/cli/sub/server/shared.ts | 27 ++- apps/kimi-code/test/cli/server/server.test.ts | 198 ++++++++++++++++++ apps/kimi-web/src/App.vue | 12 +- apps/kimi-web/src/api/daemon/client.ts | 3 + apps/kimi-web/src/api/types.ts | 2 +- .../composables/client/useWorkspaceState.ts | 1 + .../src/composables/useKimiWebClient.ts | 20 ++ .../protocol/src/__tests__/rest-meta.test.ts | 12 ++ packages/protocol/src/rest/meta.ts | 7 + packages/server/src/middleware/auth.ts | 12 ++ packages/server/src/routes/meta.ts | 6 + .../server/src/routes/registerApiV1Routes.ts | 6 + .../server/src/services/gateway/wsGateway.ts | 6 + .../src/services/gateway/wsGatewayService.ts | 3 +- packages/server/src/start.ts | 35 +++- .../server/test/auth-middleware.e2e.test.ts | 26 +++ packages/server/test/meta.e2e.test.ts | 33 +++ 19 files changed, 496 insertions(+), 23 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/server/daemon.ts b/apps/kimi-code/src/cli/sub/server/daemon.ts index 84070a736..cc633934c 100644 --- a/apps/kimi-code/src/cli/sub/server/daemon.ts +++ b/apps/kimi-code/src/cli/sub/server/daemon.ts @@ -59,6 +59,10 @@ export interface EnsureDaemonOptions { 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). */ @@ -202,6 +206,8 @@ interface SpawnDaemonChildOptions { insecureNoTls?: boolean; allowRemoteShutdown?: boolean; allowRemoteTerminals?: boolean; + dangerousBypassAuth?: boolean; + keepAlive?: boolean; allowedHosts?: readonly string[]; idleGraceMs?: number; } @@ -235,6 +241,12 @@ export function spawnDaemonChild(options: SpawnDaemonChildOptions): ChildProcess 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)); } @@ -320,6 +332,8 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise', '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.', @@ -134,6 +139,11 @@ export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }) '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.`, @@ -183,13 +193,27 @@ export async function handleRunCommand( await startServerDaemon(parsed); return; } + // 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 = + opts.foreground === true ? { ...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. + // 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 }): void => { const { origin } = result; const host = result.host ?? parsed.host; - const token = deps.resolveToken?.(); + // 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?.(); let output = ''; if (result.reused === true) { // A daemon was already running, so this command's --host/--port/etc. did @@ -202,8 +226,9 @@ export async function handleRunCommand( ? formatReadyBanner(origin, host, { token, networkAddresses: deps.networkAddresses, + dangerousBypassAuth: effectiveBypass, }) - : formatReadyLine(origin, token); + : formatReadyLine(origin, token, effectiveBypass); deps.stdout.write(output); if (opts.open === true) { deps.openUrl(token !== undefined ? buildWebUrl(origin, token) : origin); @@ -211,14 +236,14 @@ export async function handleRunCommand( }; if (opts.foreground === true) { const run = deps.startServerForeground ?? startServerForeground; - await run(parsed, { + await run(runOptions, { onReady: (origin) => { writeReady({ origin, reused: false, host: parsed.host }); }, }); return; } - const result = await deps.startServerBackground(parsed); + const result = await deps.startServerBackground(runOptions); writeReady(result); } @@ -230,8 +255,30 @@ function formatReuseNotice(origin: string): string { ); } -function formatReadyLine(origin: string, token: string | undefined): string { - return `Kimi server: ${buildOpenableUrl(origin, token)}\n`; +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 server kill')}${danger(' now to stop this process.')}`, + ]; } /** @@ -251,6 +298,8 @@ export async function startServerBackground( insecureNoTls: options.insecureNoTls, allowRemoteShutdown: options.allowRemoteShutdown, allowRemoteTerminals: options.allowRemoteTerminals, + dangerousBypassAuth: options.dangerousBypassAuth, + keepAlive: options.keepAlive, allowedHosts: options.allowedHosts, idleGraceMs: options.idleGraceMs, }); @@ -296,14 +345,18 @@ async function runServerInProcess( let running: RunningServer | undefined; let stopping = false; - const idle = mode.daemon - ? createIdleShutdownHandler({ - graceMs: options.idleGraceMs, - onIdle: () => { - void shutdown('idle'); - }, - }) - : undefined; + // 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; @@ -330,6 +383,7 @@ async function runServerInProcess( insecureNoTls: options.insecureNoTls, allowRemoteShutdown: options.allowRemoteShutdown, allowRemoteTerminals: options.allowRemoteTerminals, + dangerousBypassAuth: options.dangerousBypassAuth, allowedHosts: options.allowedHosts, webAssetsDir: serverWebAssetsDir(), coreProcessOptions: { @@ -356,7 +410,9 @@ async function runServerInProcess( }); const readyFields = mode.daemon - ? { address: running.address, idleGraceMs: options.idleGraceMs } + ? 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'); @@ -421,6 +477,8 @@ interface FormatReadyBannerOptions { 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( @@ -452,6 +510,12 @@ function formatReadyBanner( '', ]; + 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, diff --git a/apps/kimi-code/src/cli/sub/server/shared.ts b/apps/kimi-code/src/cli/sub/server/shared.ts index 09acad29e..aa2b686ce 100644 --- a/apps/kimi-code/src/cli/sub/server/shared.ts +++ b/apps/kimi-code/src/cli/sub/server/shared.ts @@ -50,8 +50,18 @@ export interface ParsedServerOptions { allowRemoteShutdown: boolean; /** Allow PTY `/api/v1/terminals/*` routes on a non-loopback bind. */ allowRemoteTerminals: boolean; + /** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */ + 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). */ @@ -69,8 +79,12 @@ export interface ServerCliOptions { allowRemoteShutdown?: boolean; /** Allow remote terminals on a non-loopback bind (`--allow-remote-terminals`). */ allowRemoteTerminals?: boolean; + /** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */ + 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. */ @@ -78,15 +92,24 @@ export interface ServerCliOptions { } 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: parseHost(opts.host), + host, port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT), logLevel: parseLogLevel(opts.logLevel ?? DEFAULT_FOREGROUND_LOG_LEVEL), debugEndpoints: opts.debugEndpoints === true, insecureNoTls: opts.insecureNoTls !== false, allowRemoteShutdown: opts.allowRemoteShutdown === true, allowRemoteTerminals: opts.allowRemoteTerminals === true, - allowedHosts: parseAllowedHostArgs(opts.allowedHost), + dangerousBypassAuth: opts.dangerousBypassAuth === true, + allowedHosts, + keepAlive, daemon: opts.daemon === true, idleGraceMs: parseIdleGraceMs(opts.idleGraceMs), }; diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts index 782ccbc92..b6ee71f95 100644 --- a/apps/kimi-code/test/cli/server/server.test.ts +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -374,6 +374,43 @@ describe('`kimi server run` background start', () => { 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 = ''; @@ -463,6 +500,79 @@ describe('`kimi server run` background start', () => { expect(stdout).toContain(color.bold.hex(darkColors.textDim)('Local: ')); expect(stdout).toContain(color.hex(darkColors.textMuted)('off')); }); + + 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`', () => { @@ -738,6 +848,81 @@ describe('--allowed-host threading', () => { }); }); +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'); @@ -1027,6 +1212,19 @@ describe('spawnDaemonChild', () => { 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', () => { diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 0cec2807e..6da2ea200 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -45,10 +45,15 @@ import Icon from './components/ui/Icon.vue'; // Hydrate the server-transport credential (fragment token or sessionStorage) // BEFORE the client connects, so the first REST/WS calls already carry it. const hasServerCredential = initServerAuth(); -const showServerAuth = ref(!hasServerCredential); +const authRequired = ref(!hasServerCredential); let offAuthRequired: (() => void) | null = null; const client = useKimiWebClient(); +// When the server runs with `--dangerous-bypass-auth`, `/meta` advertises it +// and we skip the token prompt entirely — there is no credential to enter. +const showServerAuth = computed( + () => !client.dangerousBypassAuth.value && authRequired.value, +); provide('resolveImage', client.resolveImageUrl); const { t } = useI18n(); @@ -124,7 +129,10 @@ onMounted(() => { // conversation pane's bubble-phase handler interrupts a running prompt. document.addEventListener('keydown', onGlobalKeydown, true); offAuthRequired = onAuthRequired(() => { - showServerAuth.value = true; + authRequired.value = true; + // The server now demands a token, so any cached "bypass" state from a + // previous mode is stale — drop it so the token prompt can show. + client.clearDangerousBypassAuth(); }); }); diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index 75ff5af78..315c77982 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -98,6 +98,7 @@ interface WireMeta { started_at: string; capabilities: Record; open_in_apps?: string[]; + dangerous_bypass_auth?: boolean; } interface WireAbortResult { @@ -270,6 +271,7 @@ export class DaemonKimiWebApi implements KimiWebApi { startedAt: string; capabilities: Record; openInApps: string[]; + dangerousBypassAuth: boolean; }> { const data = await this.http.get('/meta'); return { @@ -278,6 +280,7 @@ export class DaemonKimiWebApi implements KimiWebApi { startedAt: data.started_at, capabilities: data.capabilities, openInApps: Array.isArray(data.open_in_apps) ? data.open_in_apps : [], + dangerousBypassAuth: data.dangerous_bypass_auth === true, }; } diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 0983205f9..be559f3d9 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -644,7 +644,7 @@ export interface AppSessionWarning { export interface KimiWebApi { getHealth(): Promise<{ status: 'ok'; uptimeSec: number }>; - getMeta(): Promise<{ serverVersion: string; serverId: string; startedAt: string; capabilities: Record; openInApps: string[] }>; + getMeta(): Promise<{ serverVersion: string; serverId: string; startedAt: string; capabilities: Record; openInApps: string[]; dangerousBypassAuth: boolean }>; listSessions(input?: PageRequest & { status?: AppSessionStatus; workspaceId?: string; includeArchive?: boolean; excludeEmpty?: boolean }): Promise>; createSession(input: { title?: string; cwd?: string; model?: string; workspaceId?: string }): Promise; /** Fetch one session by id (deep links beyond the first listSessions page). */ diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index f44f09bf0..00add8963 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -544,6 +544,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta api.getMeta().then((m) => { rawState.serverVersion = m.serverVersion; rawState.availableOpenInApps = m.openInApps; + rawState.dangerousBypassAuth = m.dangerousBypassAuth; }).catch(() => null), modelProvider.loadModels(), ]); diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index f5a9e4c1b..ed7a0be08 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -280,6 +280,12 @@ interface QueuedPrompt { export interface ExtendedState extends KimiClientState { connected: boolean; serverVersion: string; + /** + * True when the connected server reports `dangerous_bypass_auth` in `/meta`, + * meaning its bearer-token gate is disabled. The UI skips the server-token + * prompt and connects without a credential. + */ + dangerousBypassAuth: boolean; workspaceName: string; connection: ConnectionState; permission: PermissionMode; @@ -351,6 +357,7 @@ const rawState: ExtendedState = reactive({ ...createInitialState(), connected: false, serverVersion: '', + dangerousBypassAuth: false, workspaceName: 'kimi-web', connection: 'disconnected' as ConnectionState, permission: loadPermissionFromStorage(), @@ -1710,6 +1717,17 @@ const loadMoreMessagesError = computed(() => { return sid ? rawState.messagesLoadMoreErrorBySession[sid] ?? false : false; }); const serverVersion = computed(() => rawState.serverVersion); +const dangerousBypassAuth = computed(() => rawState.dangerousBypassAuth); + +/** + * Drop the cached `dangerous_bypass_auth` value read from `/meta`. Called when + * the server demands authentication (HTTP 401) so a stale "bypass" value from + * a previous server mode does not keep hiding the token prompt after the same + * origin is restarted without `--dangerous-bypass-auth`. + */ +function clearDangerousBypassAuth(): void { + rawState.dangerousBypassAuth = false; +} const permission = computed(() => rawState.permission); const thinking = computed(() => rawState.thinking); @@ -2387,6 +2405,8 @@ export function useKimiWebClient() { hasMoreMessages, loadMoreMessagesError, serverVersion, + dangerousBypassAuth, + clearDangerousBypassAuth, initialized, permission, thinking, diff --git a/packages/protocol/src/__tests__/rest-meta.test.ts b/packages/protocol/src/__tests__/rest-meta.test.ts index 45ed8744b..3df4288a4 100644 --- a/packages/protocol/src/__tests__/rest-meta.test.ts +++ b/packages/protocol/src/__tests__/rest-meta.test.ts @@ -16,6 +16,7 @@ describe('metaResponseSchema', () => { server_id: '01HXYZABCDEFGHJKMNPQRSTVWX', started_at: '2026-06-04T10:30:00.000Z', open_in_apps: ['finder', 'vscode'] as const, + dangerous_bypass_auth: false, }; it('round-trips a well-formed payload', () => { @@ -24,6 +25,7 @@ describe('metaResponseSchema', () => { expect(parsed.capabilities.websocket).toBe(true); expect(parsed.server_id).toBe('01HXYZABCDEFGHJKMNPQRSTVWX'); expect(parsed.started_at).toBe('2026-06-04T10:30:00.000Z'); + expect(parsed.dangerous_bypass_auth).toBe(false); }); it('normalizes started_at to UTC Z with millisecond precision', () => { @@ -55,6 +57,16 @@ describe('metaResponseSchema', () => { expect(metaResponseSchema.safeParse(rest).success).toBe(false); }); + it('rejects missing dangerous_bypass_auth', () => { + const { dangerous_bypass_auth: _omit, ...rest } = sample; + expect(metaResponseSchema.safeParse(rest).success).toBe(false); + }); + + it('accepts dangerous_bypass_auth = true', () => { + const parsed = metaResponseSchema.parse({ ...sample, dangerous_bypass_auth: true }); + expect(parsed.dangerous_bypass_auth).toBe(true); + }); + it('rejects a capability set with the wrong boolean literal', () => { const bad = { ...sample, diff --git a/packages/protocol/src/rest/meta.ts b/packages/protocol/src/rest/meta.ts index 025b81a3e..402a67df9 100644 --- a/packages/protocol/src/rest/meta.ts +++ b/packages/protocol/src/rest/meta.ts @@ -29,6 +29,13 @@ export const metaResponseSchema = z.object({ server_id: z.string().min(1), started_at: isoDateTimeSchema, open_in_apps: z.array(fsOpenInAppIdSchema), + /** + * True when the server was started with `--dangerous-bypass-auth`, meaning + * the bearer-token gate is disabled on every REST and WebSocket route. The + * web UI reads this to skip the token prompt and connect without a + * credential. Defaults to false on hardened boots. + */ + dangerous_bypass_auth: z.boolean(), }); export type MetaResponse = z.infer; diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts index 00b07759f..c0fba8caa 100644 --- a/packages/server/src/middleware/auth.ts +++ b/packages/server/src/middleware/auth.ts @@ -31,6 +31,12 @@ const BEARER_PREFIX = 'Bearer '; export interface AuthHookOptions { /** Return true to skip auth for this request (bypass whitelist). */ readonly isBypassed?: (req: FastifyRequest) => boolean; + /** + * Disable auth entirely (`--dangerous-bypass-auth`). When true, the hook is a + * no-op: every request is allowed without a token. Reserved for explicit + * operator opt-in on trusted networks. + */ + readonly disabled?: boolean; /** * Optional per-source auth-failure limiter (ROADMAP M6.4). When present, a * banned source is rejected with `429` before auth runs, and each failed @@ -94,6 +100,12 @@ export function createAuthHook( const isBypassed = opts?.isBypassed ?? defaultIsBypassed; return async (req, reply) => { + // `--dangerous-bypass-auth`: skip every check below. The operator opted in + // explicitly, so no token is required on any route. + if (opts?.disabled === true) { + return; + } + // Rate-limit check (ROADMAP M6.4): a banned source is rejected before any // auth work — even a valid token cannot bypass an active ban. Loopback // binds pass no limiter, so this branch is a no-op there. diff --git a/packages/server/src/routes/meta.ts b/packages/server/src/routes/meta.ts index 4849a76ac..da0b0365d 100644 --- a/packages/server/src/routes/meta.ts +++ b/packages/server/src/routes/meta.ts @@ -46,6 +46,11 @@ export interface MetaRouteOptions { readonly serverId: string; /** ISO 8601 UTC timestamp the server went live at. */ readonly startedAt: string; + /** + * Whether the server was started with `--dangerous-bypass-auth`. Surfaced so + * the web UI can skip the token prompt and connect without a credential. + */ + readonly dangerousBypassAuth: boolean; } export function registerMetaRoute(app: RouteHost, opts: MetaRouteOptions): void { @@ -64,6 +69,7 @@ export function registerMetaRoute(app: RouteHost, opts: MetaRouteOptions): void server_id: opts.serverId, started_at: opts.startedAt, open_in_apps: [...getAvailableOpenInApps()], + dangerous_bypass_auth: opts.dangerousBypassAuth, }); const route = defineRoute( diff --git a/packages/server/src/routes/registerApiV1Routes.ts b/packages/server/src/routes/registerApiV1Routes.ts index 5c3a53c94..45c7da765 100644 --- a/packages/server/src/routes/registerApiV1Routes.ts +++ b/packages/server/src/routes/registerApiV1Routes.ts @@ -58,6 +58,11 @@ export interface RegisterApiV1RoutesOptions { * `--allow-remote-terminals`. */ readonly enableTerminals?: boolean; + /** + * Surface `dangerous_bypass_auth` in the `/meta` payload. Set by `start.ts` + * from the `--dangerous-bypass-auth` CLI flag. + */ + readonly dangerousBypassAuth?: boolean; } export async function registerApiV1Routes( @@ -74,6 +79,7 @@ export async function registerApiV1Routes( serverVersion: opts.serverVersion, serverId: ulid(), startedAt: new Date().toISOString(), + dangerousBypassAuth: opts.dangerousBypassAuth === true, }); registerAuthRoute(apiV1 as unknown as Parameters[0], ix); diff --git a/packages/server/src/services/gateway/wsGateway.ts b/packages/server/src/services/gateway/wsGateway.ts index 7308be6fe..6eae2f0e5 100644 --- a/packages/server/src/services/gateway/wsGateway.ts +++ b/packages/server/src/services/gateway/wsGateway.ts @@ -92,6 +92,12 @@ export interface WSGatewayOptions { */ authTokenService?: IAuthTokenService; + /** + * Disable WS upgrade auth entirely (`--dangerous-bypass-auth`). When true, + * the token check is skipped even if an `authTokenService` is configured. + */ + dangerousBypassAuth?: boolean; + /** * Optional Host-header allowlist enforced on the WS upgrade path (ROADMAP * M4.3). When set, upgrades whose `Host` is not allowed are rejected with diff --git a/packages/server/src/services/gateway/wsGatewayService.ts b/packages/server/src/services/gateway/wsGatewayService.ts index d349081c1..b7d72b743 100644 --- a/packages/server/src/services/gateway/wsGatewayService.ts +++ b/packages/server/src/services/gateway/wsGatewayService.ts @@ -120,7 +120,8 @@ export class WSGateway extends Disposable implements IWSGateway { } const authTokenService = this.authTokenService; - if (authTokenService !== undefined) { + // `--dangerous-bypass-auth`: skip token validation on the upgrade path too. + if (authTokenService !== undefined && this.options.dangerousBypassAuth !== true) { const authorization = req.headers.authorization; const token = authorization?.startsWith('Bearer ') ? authorization.slice('Bearer '.length) diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts index dda05f458..db6a6ea38 100644 --- a/packages/server/src/start.ts +++ b/packages/server/src/start.ts @@ -93,6 +93,16 @@ export interface ServerStartOptions { */ allowRemoteTerminals?: boolean; + /** + * Disable bearer-token auth on EVERY REST and WebSocket route. Default + * false. Pass `--dangerous-bypass-auth` (or set this) only on a trusted + * network / behind your own authenticating proxy: with this set, anyone who + * can reach the server gets full session, filesystem, and shell access with + * no credential. The `/api/v1/meta` payload advertises the state so the web + * UI can connect without a token. + */ + dangerousBypassAuth?: boolean; + webAssetsDir?: string; /** @@ -258,6 +268,19 @@ export async function startServer(opts: ServerStartOptions): Promise a.get(IAuthTokenService)); const authFailureLimiter = bindClass !== 'loopback' ? createAuthFailureLimiter() : undefined; - app.addHook('onRequest', createAuthHook(authTokenService, { limiter: authFailureLimiter })); + app.addHook( + 'onRequest', + createAuthHook(authTokenService, { + limiter: authFailureLimiter, + disabled: opts.dangerousBypassAuth === true, + }), + ); // Security response headers (ROADMAP M6.6): only on a non-loopback bind. // TLS is terminated by a reverse proxy in this phase, so HSTS is omitted @@ -331,6 +363,7 @@ export async function startServer(opts: ServerStartOptions): Promise { diff --git a/packages/server/test/auth-middleware.e2e.test.ts b/packages/server/test/auth-middleware.e2e.test.ts index 15f33dfb0..b740de0fb 100644 --- a/packages/server/test/auth-middleware.e2e.test.ts +++ b/packages/server/test/auth-middleware.e2e.test.ts @@ -118,6 +118,32 @@ describe('createAuthHook (onRequest middleware)', () => { }); }); +describe('createAuthHook — disabled option (--dangerous-bypass-auth)', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = Fastify(); + app.addHook('onRequest', createAuthHook(fixedImpl(), { disabled: true })); + app.get('/api/v1/sessions', async () => ({ ok: true })); + app.get('/openapi.json', async () => ({ openapi: '3.1.0' })); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('admits a token-less request to a normally gated API route', async () => { + const res = await app.inject({ method: 'GET', url: '/api/v1/sessions' }); + expect(res.statusCode).toBe(200); + }); + + it('admits a token-less request to /openapi.json (bypass is global)', async () => { + const res = await app.inject({ method: 'GET', url: '/openapi.json' }); + expect(res.statusCode).toBe(200); + }); +}); + /** * Raw HTTP GET that lets us spoof the `Host` header. Node's `http.request` * honors an explicit `headers.Host`, whereas `fetch` (undici) treats `Host` as diff --git a/packages/server/test/meta.e2e.test.ts b/packages/server/test/meta.e2e.test.ts index c98715bb7..428d0e25b 100644 --- a/packages/server/test/meta.e2e.test.ts +++ b/packages/server/test/meta.e2e.test.ts @@ -196,6 +196,39 @@ describe('GET /api/v1/meta — server_version precedence', () => { }); }); +describe('GET /api/v1/meta — dangerous_bypass_auth flag', () => { + it('reports false on a normal (token-protected) boot', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ method: 'GET', url: '/api/v1/meta' }); + const data = (res.json() as { data: { dangerous_bypass_auth: boolean } }).data; + expect(data.dangerous_bypass_auth).toBe(false); + }); + + it('reports true and admits a token-less request when bypass is enabled', async () => { + server = await startServer({ + serviceOverrides: [fixedTokenAuth()], + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir: bridgeHome }, + dangerousBypassAuth: true, + }); + // Raw inject with NO authorization header — auth is disabled, so the + // request must still succeed and advertise the bypass. + const rawApp = server.services.invokeFunction((a) => { + const gw = a.get(IRestGateway); + return gw.app as unknown as { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; + }; + }); + const res = await rawApp.inject({ method: 'GET', url: '/api/v1/meta' }); + expect(res.statusCode).toBe(200); + const data = (res.json() as { data: { dangerous_bypass_auth: boolean } }).data; + expect(data.dangerous_bypass_auth).toBe(true); + }); +}); + describe('GET /api/v1/meta — request_id propagation (W4.3 contract)', () => { it('echoes a client-supplied valid ULID verbatim', async () => { const r = await bootDaemon();