mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
23daf0f3c1
commit
7db88b6f0c
19 changed files with 496 additions and 23 deletions
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue