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
This commit is contained in:
Haozhe 2026-07-04 18:03:50 +08:00 committed by GitHub
parent 23daf0f3c1
commit 7db88b6f0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 496 additions and 23 deletions

View file

@ -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<E
insecureNoTls: options.insecureNoTls,
allowRemoteShutdown: options.allowRemoteShutdown,
allowRemoteTerminals: options.allowRemoteTerminals,
dangerousBypassAuth: options.dangerousBypassAuth,
keepAlive: options.keepAlive,
allowedHosts: options.allowedHosts,
idleGraceMs: options.idleGraceMs,
});

View file

@ -119,6 +119,11 @@ export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean })
'--allowed-host <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.',
@ -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 <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<void> {
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,

View file

@ -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),
};

View file

@ -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', () => {

View file

@ -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();
});
});

View file

@ -98,6 +98,7 @@ interface WireMeta {
started_at: string;
capabilities: Record<string, boolean>;
open_in_apps?: string[];
dangerous_bypass_auth?: boolean;
}
interface WireAbortResult {
@ -270,6 +271,7 @@ export class DaemonKimiWebApi implements KimiWebApi {
startedAt: string;
capabilities: Record<string, boolean>;
openInApps: string[];
dangerousBypassAuth: boolean;
}> {
const data = await this.http.get<WireMeta>('/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,
};
}

View file

@ -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<string, boolean>; openInApps: string[] }>;
getMeta(): Promise<{ serverVersion: string; serverId: string; startedAt: string; capabilities: Record<string, boolean>; openInApps: string[]; dangerousBypassAuth: boolean }>;
listSessions(input?: PageRequest & { status?: AppSessionStatus; workspaceId?: string; includeArchive?: boolean; excludeEmpty?: boolean }): Promise<Page<AppSession>>;
createSession(input: { title?: string; cwd?: string; model?: string; workspaceId?: string }): Promise<AppSession>;
/** Fetch one session by id (deep links beyond the first listSessions page). */

View file

@ -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(),
]);

View file

@ -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<boolean>(() => {
return sid ? rawState.messagesLoadMoreErrorBySession[sid] ?? false : false;
});
const serverVersion = computed<string>(() => rawState.serverVersion);
const dangerousBypassAuth = computed<boolean>(() => 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<PermissionMode>(() => rawState.permission);
const thinking = computed<ThinkingLevel>(() => rawState.thinking);
@ -2387,6 +2405,8 @@ export function useKimiWebClient() {
hasMoreMessages,
loadMoreMessagesError,
serverVersion,
dangerousBypassAuth,
clearDangerousBypassAuth,
initialized,
permission,
thinking,

View file

@ -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,

View file

@ -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<typeof metaResponseSchema>;

View file

@ -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.

View file

@ -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(

View file

@ -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<typeof registerAuthRoute>[0], ix);

View file

@ -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

View file

@ -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)

View file

@ -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<RunningServ
);
}
// `--dangerous-bypass-auth` (ROADMAP M5.1 escape hatch): the operator
// explicitly disabled the bearer-token gate on every REST and WebSocket
// route. Warn loudly — especially on a non-loopback bind, where this grants
// unauthenticated remote session / filesystem / shell access to anyone who
// can reach the port. The `/api/v1/meta` payload advertises the state so the
// web UI can connect without a token.
if (opts.dangerousBypassAuth === true) {
pinoLogger.warn(
{ host: opts.host, bindClass },
'DANGEROUS: bearer-token auth is DISABLED (--dangerous-bypass-auth) — every REST and WebSocket route accepts unauthenticated requests',
);
}
const services = createServerServiceCollection({
server: {
...opts,
@ -275,6 +298,9 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
},
allowedOrigins:
opts.wsGatewayOptions?.allowedOrigins ?? parseCorsOrigins(process.env),
// Mirror the HTTP bypass on the WS upgrade path so a token-less web
// client can open a socket when `--dangerous-bypass-auth` is set.
dangerousBypassAuth: opts.dangerousBypassAuth === true,
},
serviceOverrides: [
[IAuthTokenService, defaultAuth],
@ -297,7 +323,13 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
const authTokenService = ix.invokeFunction((a) => 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<RunningServ
debugEndpoints: opts.debugEndpoints === true && bindClass === 'loopback',
enableShutdown: bindClass === 'loopback' || allowRemoteShutdown,
enableTerminals: bindClass === 'loopback' || allowRemoteTerminals,
dangerousBypassAuth: opts.dangerousBypassAuth === true,
});
app.get('/asyncapi.json', async (_req, reply) => {

View file

@ -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

View file

@ -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();