From ea6a4bfe6ef8914f67f254f24b0c5c543c48a341 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 24 Jun 2026 14:42:11 +0800 Subject: [PATCH 01/93] fix: preserve long tool output (#1062) * fix: persist truncated foreground bash output * fix: persist oversized tool results * fix: link background task notifications to saved output * fix: avoid lossy tool result budgeting * fix * fix: include fallback task output previews * fix * fix --- .changeset/preserve-truncated-tool-output.md | 5 + docs/en/reference/tools.md | 2 +- docs/zh/reference/tools.md | 2 +- .../agent-core/src/agent/background/index.ts | 49 +++++++- .../src/agent/context/notification-xml.ts | 42 ++----- packages/agent-core/src/agent/turn/index.ts | 8 +- .../src/agent/turn/tool-result-budget.ts | 91 ++++++++++++++ packages/agent-core/src/loop/tool-call.ts | 7 +- packages/agent-core/src/loop/types.ts | 8 ++ packages/agent-core/src/mcp/output.ts | 21 +++- .../src/tools/builtin/shell/bash.ts | 67 ++++++---- .../src/tools/support/result-builder.ts | 4 + .../test/agent/background/rpc-events.test.ts | 37 +++++- .../agent/bg-idle-notification-repro.test.ts | 14 ++- .../agent-core/test/agent/context.test.ts | 26 ++-- packages/agent-core/test/agent/tool.test.ts | 117 ++++++++++++++++++ packages/agent-core/test/mcp/output.test.ts | 3 + packages/agent-core/test/tools/bash.test.ts | 30 +++++ 18 files changed, 450 insertions(+), 83 deletions(-) create mode 100644 .changeset/preserve-truncated-tool-output.md create mode 100644 packages/agent-core/src/agent/turn/tool-result-budget.ts diff --git a/.changeset/preserve-truncated-tool-output.md b/.changeset/preserve-truncated-tool-output.md new file mode 100644 index 000000000..6e5d51d46 --- /dev/null +++ b/.changeset/preserve-truncated-tool-output.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Preserve full tool output logs when previews are truncated and link background task completion notifications to saved output. diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index 91d43f406..37ed1018f 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -99,7 +99,7 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill ## Background Tasks -Background task tools manage tasks started via `Bash`, `Agent`, or `AskUserQuestion`. When a task reaches a terminal state, its status and trailing output are automatically delivered back to the Agent; use `TaskOutput` to check progress early. +Background task tools manage tasks started via `Bash`, `Agent`, or `AskUserQuestion`. When a task reaches a terminal state, its status and saved output path are automatically delivered back to the Agent; use `TaskOutput` to check progress early. | Tool | Default Approval | Description | | --- | --- | --- | diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index bf4527862..9584bcf6e 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -99,7 +99,7 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只 ## 后台任务 -后台任务工具用于管理通过 `Bash`、`Agent` 或 `AskUserQuestion` 启动的后台任务。任务进入终止状态时会自动把状态和末尾输出送回 Agent;如需提前检查进度,使用 `TaskOutput`。 +后台任务工具用于管理通过 `Bash`、`Agent` 或 `AskUserQuestion` 启动的后台任务。任务进入终止状态时会自动把状态和已保存的输出路径送回 Agent;如需提前检查进度,使用 `TaskOutput`。 | 工具 | 默认审批 | 说明 | | --- | --- | --- | diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index 7d04be4eb..a61a4c3b9 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -18,6 +18,7 @@ import type { ContentPart } from '@moonshot-ai/kosong'; import type { Agent } from '../..'; import { errorMessage } from '../../loop/errors'; import { timeoutOutcome } from '../../utils/promise'; +import { escapeXml, escapeXmlAttr } from '../../utils/xml-escape'; import type { BackgroundTaskOrigin } from '../context'; import { renderNotificationXml } from '../context/notification-xml'; import { type BackgroundTaskPersistence } from './persist'; @@ -105,6 +106,7 @@ interface ManagedTask { * reads the persisted log when available. */ const MAX_OUTPUT_BYTES = 1024 * 1024; // 1 MiB +const NOTIFICATION_FALLBACK_PREVIEW_BYTES = 3_000; const SIGTERM_GRACE_MS = 5_000; const USER_INTERRUPT_REASON = 'Interrupted by user'; @@ -157,7 +159,7 @@ type BackgroundTaskNotification = Record & { readonly title: string; readonly severity: 'info' | 'warning'; readonly body: string; - readonly tail_output: string; + readonly children?: readonly string[] | undefined; }; interface BackgroundTaskNotificationContext { @@ -166,8 +168,6 @@ interface BackgroundTaskNotificationContext { readonly notification: BackgroundTaskNotification; } -const NOTIFICATION_TAIL_BYTES = 3_000; - export interface RegisterBackgroundTaskOptions { /** * When false, the task is tracked by the manager but a foreground tool call @@ -427,6 +427,12 @@ export class BackgroundManager { return this.toInfo(entry); } + persistOutput(taskId: string): void { + const entry = this.tasks.get(taskId); + if (entry === undefined) return; + this.startOutputPersist(entry); + } + /** Stop a running task. SIGTERM → 5s grace → SIGKILL. */ async stop(taskId: string, reason?: string): Promise { const entry = this.tasks.get(taskId); @@ -661,8 +667,10 @@ export class BackgroundManager { if (this.deliveredNotificationKeys.has(key)) return; this.scheduledNotificationKeys.add(key); - const tailOutput = (await this.getOutputSnapshot(info.taskId, NOTIFICATION_TAIL_BYTES)) - .preview; + let output = await this.getOutputSnapshot(info.taskId, 0); + if (!output.fullOutputAvailable) { + output = await this.getOutputSnapshot(info.taskId, NOTIFICATION_FALLBACK_PREVIEW_BYTES); + } if (this.isTerminalNotificationSuppressed(info.taskId)) return undefined; const notification: BackgroundTaskNotification = { id: origin.notificationId, @@ -674,7 +682,7 @@ export class BackgroundManager { title: `Background ${info.kind} ${info.status}`, severity: info.status === 'completed' ? 'info' : 'warning', body: buildBackgroundTaskNotificationBody(info), - tail_output: tailOutput, + children: backgroundTaskNotificationChildren(output), }; const content = [ { @@ -853,6 +861,35 @@ export class BackgroundManager { } } +function backgroundTaskNotificationChildren( + output: BackgroundTaskOutputSnapshot, +): readonly string[] | undefined { + if (output.fullOutputAvailable && output.outputPath !== undefined) { + return [renderOutputFileBlock(output.outputPath, output.outputSizeBytes)]; + } + if (output.preview.length === 0) return undefined; + return [renderOutputPreviewBlock(output)]; +} + +function renderOutputFileBlock(outputPath: string, outputSizeBytes: number): string { + return [ + ``, + `Read the output file to retrieve the result: ${escapeXml(outputPath)}`, + '', + ].join('\n'); +} + +function renderOutputPreviewBlock(output: BackgroundTaskOutputSnapshot): string { + return [ + ``, + output.truncated + ? `Showing the last ${String(output.previewBytes)} bytes. No persisted full output is available.` + : 'No persisted full output is available; this preview is the currently buffered task output.', + escapeXml(output.preview), + '', + ].join('\n'); +} + function notificationKey(origin: BackgroundTaskOrigin): string { return `${origin.taskId}\0${origin.status}\0${origin.notificationId}`; } diff --git a/packages/agent-core/src/agent/context/notification-xml.ts b/packages/agent-core/src/agent/context/notification-xml.ts index c1da4f12b..2d12a1d14 100644 --- a/packages/agent-core/src/agent/context/notification-xml.ts +++ b/packages/agent-core/src/agent/context/notification-xml.ts @@ -7,14 +7,11 @@ * Title: ... * Severity: ... * - * (only when source_kind === 'background_task' and tail_output is non-empty) - * - * + * * * - * The opening-tag names (``) are - * load-bearing for the projector's `mergeAdjacentUserMessages` detector - * — rename requires updating the detector too. + * The opening tag name (`): string { const title = typeof data['title'] === 'string' ? data['title'] : ''; const severity = typeof data['severity'] === 'string' ? data['severity'] : ''; const body = typeof data['body'] === 'string' ? data['body'] : ''; + const children = childBlocks(data['children'] ?? data['extraBlocks']); const agentIdAttr = agentId === undefined ? '' : ` agent_id="${agentId}"`; const lines: string[] = [ @@ -44,36 +42,12 @@ export function renderNotificationXml(data: Record): string { if (title.length > 0) lines.push(`Title: ${title}`); if (severity.length > 0) lines.push(`Severity: ${severity}`); if (body.length > 0) lines.push(body); - - if (data['source_kind'] === 'background_task') { - const tailRaw = typeof data['tail_output'] === 'string' ? data['tail_output'] : ''; - if (tailRaw.length > 0) { - const truncated = truncateTailOutput(tailRaw, 20, 3000); - lines.push(''); - lines.push(truncated); - lines.push(''); - } - } + lines.push(...children); lines.push(''); return lines.join('\n'); } -/** - * Truncate tail output to at most `maxLines` lines and `maxChars` - * characters. Takes the *last* N lines, then trims from the front if - * the character budget is exceeded. - */ -function truncateTailOutput(raw: string, maxLines: number, maxChars: number): string { - const allLines = raw.split('\n'); - const tailLines = allLines.length > maxLines ? allLines.slice(-maxLines) : allLines; - let result = tailLines.join('\n'); - if (result.length > maxChars) { - result = result.slice(-maxChars); - } - return result; -} - function stringAttr(value: unknown, fallback: string): string { if (typeof value !== 'string' || value.length === 0) return fallback; return escapeXmlAttr(value); @@ -85,3 +59,9 @@ function optionalStringAttr(value: unknown): string | undefined { if (typeof value !== 'string' || value.length === 0) return undefined; return value.replaceAll('&', '&').replaceAll('"', '"'); } + +function childBlocks(value: unknown): string[] { + if (typeof value === 'string' && value.length > 0) return [value]; + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === 'string' && item.length > 0); +} diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index b7052b396..6f8f30298 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -40,6 +40,7 @@ import { USER_PROMPT_ORIGIN, type PromptOrigin } from '../context'; import { renderUserPromptHookBlockResult, renderUserPromptHookResult } from '../../session/hooks'; import { canonicalTelemetryArgs, isPlainRecord } from './canonical-args'; import { ToolCallDeduplicator } from './tool-dedup'; +import { budgetToolResultForModel } from './tool-result-budget'; interface ActiveTurn { readonly turnId: number; @@ -747,7 +748,12 @@ export class TurnFlow { toolOutput: isError === true ? undefined : toolOutputText(output).slice(0, 2000), }, }); - return finalResult; + return budgetToolResultForModel({ + homedir: this.agent.homedir, + toolName: ctx.toolCall.name, + toolCallId: ctx.toolCall.id, + result: finalResult, + }); }, }, }); diff --git a/packages/agent-core/src/agent/turn/tool-result-budget.ts b/packages/agent-core/src/agent/turn/tool-result-budget.ts new file mode 100644 index 000000000..2808544b7 --- /dev/null +++ b/packages/agent-core/src/agent/turn/tool-result-budget.ts @@ -0,0 +1,91 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, writeFile } from 'node:fs/promises'; + +import type { ContentPart } from '@moonshot-ai/kosong'; +import { join } from 'pathe'; + +import type { ExecutableToolResult } from '../../loop'; + +const TOOL_RESULT_MAX_CHARS = 50_000; +const TOOL_RESULT_PREVIEW_CHARS = 2_000; + +interface BudgetToolResultOptions { + readonly homedir?: string; + readonly toolName: string; + readonly toolCallId: string; + readonly result: ExecutableToolResult; +} + +export async function budgetToolResultForModel( + options: BudgetToolResultOptions, +): Promise { + const text = persistableToolResultText(options.result.output); + if (text === undefined || text.length <= TOOL_RESULT_MAX_CHARS) return options.result; + if (options.result.truncated === true) return options.result; + if (options.homedir === undefined) return options.result; + + const outputPath = await saveToolResult( + { homedir: options.homedir, toolName: options.toolName, toolCallId: options.toolCallId }, + text, + ); + if (outputPath === undefined) return options.result; + const output = renderPersistedToolResult(options.toolName, options.toolCallId, text, outputPath); + return options.result.isError === true + ? { ...options.result, output, isError: true } + : { ...options.result, output }; +} + +function persistableToolResultText(output: ExecutableToolResult['output']): string | undefined { + if (typeof output === 'string') return output; + if ( + !output.every((part): part is Extract => part.type === 'text') + ) { + return undefined; + } + return output.map((part) => part.text).join(''); +} + +async function saveToolResult( + options: { readonly homedir: string; readonly toolName: string; readonly toolCallId: string }, + text: string, +): Promise { + try { + const dir = join(options.homedir, 'tool-results'); + await mkdir(dir, { recursive: true, mode: 0o700 }); + const outputPath = join( + dir, + `${safeToolResultFileStem(options.toolName, options.toolCallId)}-${randomUUID()}.txt`, + ); + await writeFile(outputPath, text, { encoding: 'utf8', flag: 'wx' }); + return outputPath; + } catch { + return undefined; + } +} + +function renderPersistedToolResult( + toolName: string, + toolCallId: string, + text: string, + outputPath: string, +): string { + const lines = [ + `Tool output exceeded ${String(TOOL_RESULT_MAX_CHARS)} characters; showing a preview only.`, + `tool_name: ${toolName}`, + `tool_call_id: ${toolCallId}`, + `output_size_chars: ${String(text.length)}`, + `output_size_bytes: ${String(Buffer.byteLength(text, 'utf8'))}`, + `output_path: ${outputPath}`, + 'next_step: Use Read with output_path to page through the full output.', + ]; + lines.push('', '[preview]', text.slice(0, TOOL_RESULT_PREVIEW_CHARS)); + return lines.join('\n'); +} + +function safeToolResultFileStem(toolName: string, toolCallId: string): string { + const label = `${toolName}-${toolCallId}` + .replace(/[^a-zA-Z0-9._-]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 80); + return label || 'tool-result'; +} diff --git a/packages/agent-core/src/loop/tool-call.ts b/packages/agent-core/src/loop/tool-call.ts index 1468f9cd2..7379406ce 100644 --- a/packages/agent-core/src/loop/tool-call.ts +++ b/packages/agent-core/src/loop/tool-call.ts @@ -671,7 +671,12 @@ function normalizeToolResult(r: ExecutableToolResult): ExecutableToolResult { output = textJoined.length > 0 ? textJoined : TOOL_OUTPUT_EMPTY; } } - return r.isError === true ? { output, isError: true } : { output }; + if (r.isError === true) { + return r.truncated === true + ? { output, isError: true, truncated: true } + : { output, isError: true }; + } + return r.truncated === true ? { output, truncated: true } : { output }; } function makeToolResult( diff --git a/packages/agent-core/src/loop/types.ts b/packages/agent-core/src/loop/types.ts index 8459450ef..4d954c076 100644 --- a/packages/agent-core/src/loop/types.ts +++ b/packages/agent-core/src/loop/types.ts @@ -78,6 +78,12 @@ export interface ExecutableToolSuccessResult { * this to the user. */ readonly message?: string | undefined; + /** + * True when the tool has already returned a partial result because it + * truncated, paged, or otherwise dropped original output. Later generic + * budgeting must not treat the visible output as complete source text. + */ + readonly truncated?: boolean | undefined; } export interface ExecutableToolErrorResult { @@ -87,6 +93,8 @@ export interface ExecutableToolErrorResult { readonly message?: string | undefined; /** See {@link ExecutableToolSuccessResult.stopTurn}. */ readonly stopTurn?: boolean | undefined; + /** See {@link ExecutableToolSuccessResult.truncated}. */ + readonly truncated?: boolean | undefined; } export type ExecutableToolResult = ExecutableToolSuccessResult | ExecutableToolErrorResult; diff --git a/packages/agent-core/src/mcp/output.ts b/packages/agent-core/src/mcp/output.ts index 9832b04d2..464218cef 100644 --- a/packages/agent-core/src/mcp/output.ts +++ b/packages/agent-core/src/mcp/output.ts @@ -133,7 +133,7 @@ export function convertMCPContentBlock(block: MCPContentBlock): ContentPart | nu export function mcpResultToExecutableOutput( result: MCPToolResult, qualifiedToolName: string, -): { output: string | ContentPart[]; isError: boolean } { +): { output: string | ContentPart[]; isError: boolean; truncated?: true } { const converted: ContentPart[] = []; for (const block of result.content) { const part = convertMCPContentBlock(block); @@ -144,8 +144,10 @@ export function mcpResultToExecutableOutput( const wrapped = wrapMediaOnly(converted, qualifiedToolName); const limited = applyOutputLimits(wrapped); - const output = collapseSingleText(limited); - return { output, isError: result.isError }; + const output = collapseSingleText(limited.parts); + return limited.truncated + ? { output, isError: result.isError, truncated: true } + : { output, isError: result.isError }; } /** @@ -173,20 +175,26 @@ function wrapMediaOnly(parts: readonly ContentPart[], qualifiedToolName: string) * the last surviving text part — this keeps the single-text-part collapse * working when the entire (oversized) input is a single text block. */ -function applyOutputLimits(parts: readonly ContentPart[]): ContentPart[] { +function applyOutputLimits(parts: readonly ContentPart[]): { + readonly parts: ContentPart[]; + readonly truncated: boolean; +} { let remaining = MCP_MAX_OUTPUT_CHARS; + let truncated = false; let textTruncated = false; const out: ContentPart[] = []; for (const part of parts) { if (part.type === 'text') { if (remaining <= 0) { + truncated = true; textTruncated = true; continue; } if (part.text.length > remaining) { out.push({ type: 'text', text: part.text.slice(0, remaining) }); remaining = 0; + truncated = true; textTruncated = true; } else { out.push(part); @@ -198,12 +206,14 @@ function applyOutputLimits(parts: readonly ContentPart[]): ContentPart[] { if (part.type === 'think') { const size = part.think.length + (part.encrypted?.length ?? 0); if (remaining <= 0) { + truncated = true; textTruncated = true; continue; } if (size > remaining) { out.push({ type: 'think', think: part.think.slice(0, remaining) }); remaining = 0; + truncated = true; textTruncated = true; } else { out.push(part); @@ -225,6 +235,7 @@ function applyOutputLimits(parts: readonly ContentPart[]): ContentPart[] { const kind = part.type === 'image_url' ? 'image' : part.type === 'audio_url' ? 'audio' : 'video'; out.push({ type: 'text', text: binaryPartTooLargeNotice(kind, url.length) }); + truncated = true; continue; } out.push(part); @@ -233,7 +244,7 @@ function applyOutputLimits(parts: readonly ContentPart[]): ContentPart[] { if (textTruncated) { appendTruncationNotice(out); } - return out; + return { parts: out, truncated }; } function appendTruncationNotice(out: ContentPart[]): void { diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index 3e124c2b5..df6c4deb8 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -31,7 +31,10 @@ import type { ExecutableToolResult, ToolExecution, ToolUpdate } from '../../../l import { renderPrompt } from '../../../utils/render-prompt'; import { toInputJsonSchema } from '../../support/input-schema'; import { literalRulePattern, matchesGlobRuleSubject } from '../../support/rule-match'; -import { ToolResultBuilder } from '../../support/result-builder'; +import { + type ExecutableToolResultBuilderResult, + ToolResultBuilder, +} from '../../support/result-builder'; import bashDescriptionTemplate from './bash.md?raw'; const MS_PER_SECOND = 1000; @@ -248,12 +251,18 @@ export class BashTool implements BuiltinTool { closeProcessStdin(proc); let collectForegroundOutput = !startsInBackground; + let foregroundOutputPersisted = false; + let foregroundTaskId: string | undefined; const onProcessOutput = startsInBackground ? undefined : (kind: 'stdout' | 'stderr', text: string): void => { if (!collectForegroundOutput) return; onUpdate?.({ kind, text }); builder.write(text); + if (!foregroundOutputPersisted && builder.truncated && foregroundTaskId !== undefined) { + this.backgroundManager.persistOutput(foregroundTaskId); + foregroundOutputPersisted = true; + } }; let taskId: string; @@ -266,6 +275,7 @@ export class BashTool implements BuiltinTool { signal: startsInBackground ? undefined : signal, }, ); + foregroundTaskId = startsInBackground ? undefined : taskId; } catch (error) { collectForegroundOutput = false; await killSpawnedProcess(proc); @@ -299,7 +309,7 @@ export class BashTool implements BuiltinTool { ); } - return this.foregroundCompletionResult(taskId, proc, builder, foregroundTimeoutMs); + return await this.foregroundCompletionResult(taskId, proc, builder, foregroundTimeoutMs); } finally { collectForegroundOutput = false; } @@ -328,41 +338,56 @@ export class BashTool implements BuiltinTool { return undefined; } - private foregroundCompletionResult( + private async foregroundCompletionResult( taskId: string, proc: KaosProcess, builder: ToolResultBuilder, foregroundTimeoutMs: number, - ): ExecutableToolResult { + ): Promise { const current = this.backgroundManager.getTask(taskId); const exitCode = current?.kind === 'process' ? current.exitCode : proc.exitCode; + let result: ExecutableToolResultBuilderResult; if (current?.status === 'timed_out') { const timeoutLabel = formatTimeoutLabel(foregroundTimeoutMs); - return builder.error(`Command killed by timeout (${timeoutLabel})`, { + result = builder.error(`Command killed by timeout (${timeoutLabel})`, { brief: `Killed by timeout (${timeoutLabel})`, }); - } - if (current?.status === 'killed' && current.stopReason === USER_INTERRUPT_REASON) { - return builder.error(USER_INTERRUPT_REASON, { brief: USER_INTERRUPT_REASON }); - } - if ( + } else if (current?.status === 'killed' && current.stopReason === USER_INTERRUPT_REASON) { + result = builder.error(USER_INTERRUPT_REASON, { brief: USER_INTERRUPT_REASON }); + } else if ( (current?.status === 'failed' || current?.status === 'killed') && current.stopReason !== undefined ) { - return builder.error(current.stopReason, { brief: current.stopReason }); + result = builder.error(current.stopReason, { brief: current.stopReason }); + } else if (exitCode === 0) { + result = builder.ok('Command executed successfully.'); + } else { + if (builder.nChars === 0) builder.write(`Process exited with code ${String(exitCode)}`); + result = builder.error(`Command failed with exit code: ${String(exitCode)}.`, { + brief: `Failed with exit code: ${String(exitCode)}`, + }); } + return this.addForegroundOutputReference(taskId, result); + } - const isError = exitCode !== 0; - if (isError && builder.nChars === 0) { - builder.write(`Process exited with code ${String(exitCode)}`); - } + private async addForegroundOutputReference( + taskId: string, + result: ExecutableToolResultBuilderResult, + ): Promise { + if (!result.truncated) return result; + const output = await this.backgroundManager.getOutputSnapshot(taskId, 0); + if (!output.fullOutputAvailable || output.outputPath === undefined) return result; - if (!isError) { - return builder.ok('Command executed successfully.'); - } - return builder.error(`Command failed with exit code: ${String(exitCode)}.`, { - brief: `Failed with exit code: ${String(exitCode)}`, - }); + const taskOutputHint = this.allowBackground + ? `, or TaskOutput(task_id="${taskId}", block=false)` + : ''; + const reference = + `\n\n[Full output saved]\n` + + `task_id: ${taskId}\n` + + `output_path: ${output.outputPath}\n` + + `output_size_bytes: ${String(output.outputSizeBytes)}\n` + + `next_step: Use Read with output_path to page through the full log${taskOutputHint}.`; + return { ...result, output: `${result.output}${reference}` }; } private backgroundStartedResult( diff --git a/packages/agent-core/src/tools/support/result-builder.ts b/packages/agent-core/src/tools/support/result-builder.ts index 8618d5671..80254403f 100644 --- a/packages/agent-core/src/tools/support/result-builder.ts +++ b/packages/agent-core/src/tools/support/result-builder.ts @@ -45,6 +45,10 @@ export class ToolResultBuilder { return this.nCharsValue; } + get truncated(): boolean { + return this.truncationHappened; + } + write(text: string): number { if (this.nCharsValue >= this.maxChars) { if (text.length > 0 && !this.truncationHappened) { diff --git a/packages/agent-core/test/agent/background/rpc-events.test.ts b/packages/agent-core/test/agent/background/rpc-events.test.ts index de31e7657..466ddfa88 100644 --- a/packages/agent-core/test/agent/background/rpc-events.test.ts +++ b/packages/agent-core/test/agent/background/rpc-events.test.ts @@ -257,6 +257,8 @@ describe('BackgroundManager — notification delivery', () => { const text = (content as Array<{ text: string }>)[0]!.text; expect(text).toContain('Background agent completed'); expect(text).toContain('final subagent summary'); + expect(text).toContain(' { @@ -280,6 +282,25 @@ describe('BackgroundManager — notification delivery', () => { expect(text).toContain('shell task completed.'); }); + it('uses a bounded output preview when no persisted task output exists', async () => { + const { agent, manager } = createBackgroundManager(); + const output = `early-output-marker\n${'x'.repeat(4_000)}\nfinal subagent line`; + const taskId = manager.registerTask(agentTask(Promise.resolve({ result: output }), 'agent task')); + + await manager.wait(taskId); + + await vi.waitFor(() => { + expect(agent.turn.steer).toHaveBeenCalledTimes(1); + }); + const [content] = agent.turn.steer.mock.calls[0]!; + const text = (content as Array<{ text: string }>)[0]!.text; + expect(text).toContain(' { const { agent, manager } = createBackgroundManager(); const taskId = registerProcess(manager, pendingProcess(), 'sleep 60', 'long shell task'); @@ -325,7 +346,9 @@ describe('BackgroundManager — notification delivery', () => { }); const text = (content as Array<{ text: string }>)[0]!.text; expect(text).toContain('Background agent completed'); - expect(text).toContain('restored subagent summary'); + expect(text).not.toContain('restored subagent summary'); + expect(text).toContain(' { }); const text = (content as Array<{ text: string }>)[0]!.text; expect(text).toContain('Background process completed'); - expect(text).toContain('restored shell output'); + expect(text).not.toContain('restored shell output'); + expect(text).toContain(' { + it('references persisted output without reading a tail for restored process notifications', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-bash-tail-')); try { const taskId = 'bash-large000'; @@ -381,10 +406,12 @@ describe('BackgroundManager — notification delivery', () => { }); expect(readOutputSpy).not.toHaveBeenCalled(); expect(snapshotSpy).toHaveBeenCalledWith(taskId, expect.any(Number)); - expect(snapshotSpy.mock.calls[0]![1]).toBeLessThan(largeOutput.length); + expect(snapshotSpy.mock.calls[0]![1]).toBe(0); const [content] = agent.context.appendUserMessage.mock.calls[0]!; const text = (content as Array<{ text: string }>)[0]!.text; - expect(text).toContain('final output line'); + expect(text).toContain(' { expect(flatHistoryText).toContain(' { @@ -121,7 +123,9 @@ describe('background notification → main agent (real Agent instance)', () => { expect(flatContext).toContain(' { @@ -166,9 +170,13 @@ describe('background notification → main agent (real Agent instance)', () => { for (const id of taskIds) { expect(flatHistoryText).toContain(id); } + expect(flatHistoryText).toContain('group-1 completed'); + expect(flatHistoryText).toContain('group-2 completed'); + expect(flatHistoryText).toContain('group-3 completed'); expect(flatHistoryText).toContain('bg #1 result'); expect(flatHistoryText).toContain('bg #2 result'); expect(flatHistoryText).toContain('bg #3 result'); + expect(flatHistoryText).toContain(' { @@ -225,7 +233,9 @@ describe('background notification → main agent (real Agent instance)', () => { const flatHistoryText = JSON.stringify(lastCall.history); expect(flatHistoryText).toContain(' { @@ -298,7 +308,9 @@ describe('background notification → main agent (real Agent instance)', () => { // Both notifications are in context, waiting for the user. const flatContext = JSON.stringify(ctx.agent.context.data()); - expect(flatContext).toContain('previous bash output'); + expect(flatContext).toContain(' { }); describe('Agent context notification projection', () => { - it('renders task notifications with escaped attributes and a bounded output tail', () => { - const tail = Array.from({ length: 25 }, (_, index) => `line ${String(index + 1)}`).join('\n'); - + it('renders task notifications with escaped attributes and generic children', () => { const text = renderNotificationXml({ id: 'n_"1&2', category: 'task', @@ -816,17 +814,24 @@ describe('Agent context notification projection', () => { title: 'Task finished', severity: 'info', body: 'The task completed.', - tail_output: tail, + children: [ + [ + '', + 'Read the output file to retrieve the result: /tmp/logs/a&b/output.log', + '', + ].join('\n'), + ], }); expect(text).toContain('id="n_"1&2"'); expect(text).toContain('source_id="bg&1"'); expect(text).toContain('Title: Task finished'); expect(text).toContain('Severity: info'); - expect(text).toContain(''); - expect(text).not.toContain('line 5'); - expect(text).toContain('line 6'); - expect(text).toContain('line 25'); + expect(text).toContain(''); + expect(text).toContain( + 'Read the output file to retrieve the result: /tmp/logs/a&b/output.log', + ); + expect(text).not.toContain(''); expect(text.trimEnd()).toMatch(/<\/notification>$/); }); @@ -870,13 +875,14 @@ describe('Agent context notification projection', () => { const text = renderNotificationXml({ id: '', source_kind: 'host', - tail_output: 'should stay out of the XML', + output_path: '/tmp/output.log', }); expect(text).toContain('id="unknown"'); expect(text).toContain('category="unknown"'); expect(text).not.toContain(''); - expect(text).not.toContain('should stay out of the XML'); + expect(text).not.toContain(' { diff --git a/packages/agent-core/test/agent/tool.test.ts b/packages/agent-core/test/agent/tool.test.ts index 56a74a42c..861a2680e 100644 --- a/packages/agent-core/test/agent/tool.test.ts +++ b/packages/agent-core/test/agent/tool.test.ts @@ -1,6 +1,11 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import type { ToolCall } from '@moonshot-ai/kosong'; import { describe, expect, it, vi } from 'vitest'; +import { budgetToolResultForModel } from '../../src/agent/turn/tool-result-budget'; import { HookEngine } from '../../src/session/hooks'; import type { SessionSubagentHost } from '../../src/session/subagent-host'; import { FLAG_DEFINITIONS, FlagResolver } from '../../src/flags'; @@ -372,6 +377,111 @@ describe('Agent tools', () => { `); await ctx.expectResumeMatches(); }); + + it('persists oversized registered tool results before adding them to model context', async () => { + const sessionDir = mkdtempSync(join(tmpdir(), 'tool-result-overflow-')); + try { + const lookupCall: ToolCall = { + type: 'function', + id: 'call_lookup', + name: 'Lookup', + arguments: '{"query":"moon"}', + }; + const largeOutput = `${'x'.repeat(60_000)}tail survives`; + const ctx = testAgent({ homedir: sessionDir }); + ctx.configure(); + await ctx.rpc.setPermission({ mode: 'auto' }); + await ctx.rpc.registerTool({ + name: 'Lookup', + description: 'Look up a short test value.', + parameters: { type: 'object', properties: {} }, + }); + + ctx.mockNextResponse({ type: 'text', text: 'I will look it up.' }, lookupCall); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Look up moon' }] }); + await ctx.untilToolCall({ output: largeOutput }); + + ctx.mockNextResponse({ type: 'text', text: 'done' }); + await ctx.untilTurnEnd(); + + const toolText = ctx.compactHistory().find((message) => message.role === 'tool')?.text ?? ''; + const outputPath = /^output_path: (.+)$/m.exec(toolText)?.[1]; + expect(toolText).toContain('Tool output exceeded 50000 characters'); + expect(toolText).not.toContain('tail survives'); + expect(outputPath).toBeTruthy(); + expect(readFileSync(outputPath!, 'utf8')).toBe(largeOutput); + } finally { + rmSync(sessionDir, { recursive: true, force: true }); + } + }); + + it('does not overwrite saved oversized tool results with repeated call IDs', async () => { + const sessionDir = mkdtempSync(join(tmpdir(), 'tool-result-overflow-')); + try { + const firstOutput = `${'a'.repeat(60_000)}first tail`; + const secondOutput = `${'b'.repeat(60_000)}second tail`; + + const first = await budgetToolResultForModel({ + homedir: sessionDir, + toolName: 'Lookup', + toolCallId: 'call_lookup', + result: { output: firstOutput }, + }); + const second = await budgetToolResultForModel({ + homedir: sessionDir, + toolName: 'Lookup', + toolCallId: 'call_lookup', + result: { output: secondOutput }, + }); + + const firstPath = savedOutputPath(first.output); + const secondPath = savedOutputPath(second.output); + expect(firstPath).not.toBe(secondPath); + expect(readFileSync(firstPath, 'utf8')).toBe(firstOutput); + expect(readFileSync(secondPath, 'utf8')).toBe(secondOutput); + } finally { + rmSync(sessionDir, { recursive: true, force: true }); + } + }); + + it('keeps oversized tool results intact when no session directory is available', async () => { + const largeOutput = `${'x'.repeat(60_000)}tail survives`; + const result = { output: largeOutput }; + + const budgeted = await budgetToolResultForModel({ + toolName: 'Lookup', + toolCallId: 'call_lookup', + result, + }); + + expect(budgeted).toBe(result); + expect(budgeted.output).toBe(largeOutput); + }); + + it('does not save already-truncated tool result previews as full output', async () => { + const sessionDir = mkdtempSync(join(tmpdir(), 'tool-result-overflow-')); + try { + const largeOutput = `${'x'.repeat(60_000)}[...truncated]`; + const result = { + output: largeOutput, + truncated: true, + }; + + const budgeted = await budgetToolResultForModel({ + homedir: sessionDir, + toolName: 'Lookup', + toolCallId: 'call_lookup', + result, + }); + + expect(budgeted).toBe(result); + expect(budgeted.output).toBe(largeOutput); + expect(budgeted.output).not.toContain('output_path:'); + expect(existsSync(join(sessionDir, 'tool-results'))).toBe(false); + } finally { + rmSync(sessionDir, { recursive: true, force: true }); + } + }); }); function bashCall(): ToolCall { @@ -396,6 +506,13 @@ function agentCall(): ToolCall { }; } +function savedOutputPath(output: unknown): string { + expect(typeof output).toBe('string'); + const outputPath = /^output_path: (.+)$/m.exec(output as string)?.[1]; + expect(outputPath).toBeTruthy(); + return outputPath!; +} + function hookErrorMessageAssertCommand(expected: string): string { const script = [ "let input = '';", diff --git a/packages/agent-core/test/mcp/output.test.ts b/packages/agent-core/test/mcp/output.test.ts index 3fece7048..496ab45dc 100644 --- a/packages/agent-core/test/mcp/output.test.ts +++ b/packages/agent-core/test/mcp/output.test.ts @@ -285,6 +285,7 @@ describe('mcpResultToExecutableOutput', () => { // The notice merges into the single text part so collapseSingleText still // emits a plain string — the very common "single oversized text" case. expect(out.output).toBe('x'.repeat(100_000) + MCP_OUTPUT_TRUNCATED_TEXT); + expect(out.truncated).toBe(true); }); test('drops oversized binary parts in favor of a per-part notice without touching the text budget', () => { @@ -304,6 +305,7 @@ describe('mcpResultToExecutableOutput', () => { // The text-budget marker must NOT appear — only the binary part was dropped. const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join(''); expect(joined).not.toContain('Output truncated'); + expect(out.truncated).toBe(true); }); test('binary part within the per-part cap survives intact alongside oversized text', () => { @@ -320,5 +322,6 @@ describe('mcpResultToExecutableOutput', () => { { type: 'text', text: 'A'.repeat(100_000) }, { type: 'image_url', imageUrl: { url: 'data:image/png;base64,' + 'B'.repeat(500_000) } }, ]); + expect(out).not.toHaveProperty('truncated'); }); }); diff --git a/packages/agent-core/test/tools/bash.test.ts b/packages/agent-core/test/tools/bash.test.ts index 32e0a306a..65eca4d58 100644 --- a/packages/agent-core/test/tools/bash.test.ts +++ b/packages/agent-core/test/tools/bash.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { PassThrough, Readable, type Writable } from 'node:stream'; import type { Environment, KaosProcess } from '@moonshot-ai/kaos'; @@ -993,6 +996,33 @@ describe('BashTool', () => { expect((result as { message?: string }).message).toContain('Output is truncated'); }); + it('saves full foreground output when the inline result is truncated', async () => { + const sessionDir = mkdtempSync(join(tmpdir(), 'bash-truncated-')); + try { + const fullOutput = `${'short line\n'.repeat(6_000)}tail survives\n`; + const { manager } = createBackgroundManager({ sessionDir }); + const tool = bashTool( + createFakeKaos({ + execWithEnv: vi.fn().mockResolvedValue(processWithOutput({ stdout: fullOutput })), + osEnv: posixEnv, + }), + '/workspace', + manager, + ); + + const result = await executeTool(tool, context({ command: 'flood', timeout: 60 })); + const output = result.output as string; + const outputPath = /^output_path: (.+)$/m.exec(output)?.[1]; + + expect(output).toContain('[...truncated]'); + expect(output).toContain('task_id: bash-'); + expect(outputPath).toBeTruthy(); + expect(readFileSync(outputPath!, 'utf8')).toBe(fullOutput); + } finally { + rmSync(sessionDir, { recursive: true, force: true }); + } + }); + it('marks the truncated output buffer with a "[...truncated]" sentinel at the cut point', async () => { const huge = Buffer.alloc(10 * 1024 * 1024 + 1, 'x'); const tool = bashTool( From bbd8a1a947ba26c0e59f98819cab9e20898ff0b7 Mon Sep 17 00:00:00 2001 From: ForgottenR <454906468@qq.com> Date: Wed, 24 Jun 2026 15:27:00 +0800 Subject: [PATCH 02/93] fix(cli): resolve spawn EFTYPE on Windows for kimi web and /web (#903) * fix(cli): resolve spawn EFTYPE on Windows for kimi web and /web * chore(changeset): clarify affected Windows installation methods --------- Co-authored-by: qer Co-authored-by: liruifengv --- .changeset/fix-web-daemon-spawn-windows.md | 5 +++++ apps/kimi-code/src/cli/sub/server/daemon.ts | 9 ++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-web-daemon-spawn-windows.md diff --git a/.changeset/fix-web-daemon-spawn-windows.md b/.changeset/fix-web-daemon-spawn-windows.md new file mode 100644 index 000000000..9a2a11184 --- /dev/null +++ b/.changeset/fix-web-daemon-spawn-windows.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix `kimi web` and `/web` failing to start the background server daemon on Windows with `spawn EFTYPE` when the CLI is installed via npm/pnpm or run from source. The official single-binary install script was not affected. diff --git a/apps/kimi-code/src/cli/sub/server/daemon.ts b/apps/kimi-code/src/cli/sub/server/daemon.ts index 1c840d418..f8ef8ffa8 100644 --- a/apps/kimi-code/src/cli/sub/server/daemon.ts +++ b/apps/kimi-code/src/cli/sub/server/daemon.ts @@ -204,9 +204,16 @@ export function spawnDaemonChild(options: SpawnDaemonChildOptions): void { if (options.idleGraceMs !== undefined) { args.push('--idle-grace-ms', String(options.idleGraceMs)); } + // On Windows `.mjs` files are not executable PE binaries, so we must run + // the script through the Node binary rather than spawning it directly. In + // SEA mode or when re-spawning from an already-running daemon, `program` is + // `process.execPath` itself, so no script argument is needed. + const execPath = process.execPath; + const spawnArgs = program === execPath ? args : [program, ...args]; + const logFd = openSync(logPath, 'a'); try { - const child = spawn(program, args, { + const child = spawn(execPath, spawnArgs, { detached: true, // Run from the server log directory instead of inheriting the caller's // cwd, so the long-lived daemon does not pin the directory it was From d18aa1666a09b038d5a107e9a37fff1031b2e847 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 24 Jun 2026 16:08:39 +0800 Subject: [PATCH 03/93] perf(tui): reuse streaming markdown instances (#1069) --- .changeset/tui-streaming-markdown-reuse.md | 5 ++ .../components/messages/assistant-message.ts | 49 ++++++++++--- .../src/tui/controllers/streaming-ui.ts | 6 +- apps/kimi-code/src/tui/theme/pi-tui-theme.ts | 5 +- .../messages/assistant-message.test.ts | 69 ++++++++++++++++++- .../test/tui/kimi-tui-message-flow.test.ts | 2 +- 6 files changed, 122 insertions(+), 14 deletions(-) create mode 100644 .changeset/tui-streaming-markdown-reuse.md diff --git a/.changeset/tui-streaming-markdown-reuse.md b/.changeset/tui-streaming-markdown-reuse.md new file mode 100644 index 000000000..8e81ef957 --- /dev/null +++ b/.changeset/tui-streaming-markdown-reuse.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Reduce streaming redraw cost for long assistant messages with code blocks. diff --git a/apps/kimi-code/src/tui/components/messages/assistant-message.ts b/apps/kimi-code/src/tui/components/messages/assistant-message.ts index 7b002ef66..e73e51bee 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -12,9 +12,16 @@ import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; +type AssistantMarkdownOptions = { + transient?: boolean; +}; + export class AssistantMessageComponent implements Component { private contentContainer: Container; + private markdown: Markdown | undefined; + private markdownTransient = false; private lastText = ''; + private lastTransient = false; private showBullet: boolean; constructor(showBullet: boolean = true) { @@ -26,25 +33,49 @@ export class AssistantMessageComponent implements Component { this.showBullet = show; } - updateContent(text: string): void { - const displayText = text; - if (displayText === this.lastText) return; + updateContent(text: string, opts?: AssistantMarkdownOptions): void { + const displayText = text.trim(); + const transient = opts?.transient === true; + + if (displayText === this.lastText && transient === this.lastTransient) return; + this.lastText = displayText; - this.contentContainer.clear(); - if (displayText.trim().length > 0) { - this.contentContainer.addChild(new Markdown(displayText.trim(), 0, 0, createMarkdownTheme())); + this.lastTransient = transient; + + if (displayText.length === 0) { + this.contentContainer.clear(); + this.markdown = undefined; + this.markdownTransient = false; + return; } + + if (this.markdown === undefined || this.markdownTransient !== transient) { + this.contentContainer.clear(); + this.markdown = new Markdown(displayText, 0, 0, createMarkdownTheme({ transient })); + this.markdownTransient = transient; + this.contentContainer.addChild(this.markdown); + return; + } + + this.markdown.setText(displayText); } invalidate(): void { // Markdown caches ANSI colour codes keyed on (text, width). When the // theme changes the cached strings contain stale colours, so we rebuild - // the Markdown child with the new theme. + // the Markdown child with the new theme while preserving transient mode. this.contentContainer.clear(); + this.markdown = undefined; + if (this.lastText.trim().length > 0) { - this.contentContainer.addChild( - new Markdown(this.lastText.trim(), 0, 0, createMarkdownTheme()), + this.markdown = new Markdown( + this.lastText.trim(), + 0, + 0, + createMarkdownTheme({ transient: this.lastTransient }), ); + this.markdownTransient = this.lastTransient; + this.contentContainer.addChild(this.markdown); } } diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 4581fc326..872c3bccc 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -600,12 +600,16 @@ export class StreamingUIController { const block = this._streamingBlock; if (block !== null) { block.entry.content = fullText; - block.component.updateContent(fullText); + block.component.updateContent(fullText, { transient: true }); this.host.state.ui.requestRender(); } } onStreamingTextEnd(): void { + const block = this._streamingBlock; + if (block !== null) { + block.component.updateContent(block.entry.content, { transient: false }); + } this._streamingBlock = null; } diff --git a/apps/kimi-code/src/tui/theme/pi-tui-theme.ts b/apps/kimi-code/src/tui/theme/pi-tui-theme.ts index dec6ab253..d03f309fa 100644 --- a/apps/kimi-code/src/tui/theme/pi-tui-theme.ts +++ b/apps/kimi-code/src/tui/theme/pi-tui-theme.ts @@ -22,7 +22,8 @@ import { currentTheme } from './theme'; // eslint-disable-next-line no-control-regex -- intentionally matches the ESC byte that opens ANSI SGR sequences. const HEADING_HASH_PREFIX = /^((?:\u001B\[[0-9;]*m)*)#{1,6}[ \t]+/; -export function createMarkdownTheme(): MarkdownTheme { +export function createMarkdownTheme(options?: { transient?: boolean }): MarkdownTheme { + const transient = options?.transient === true; const stripHash = (text: string): string => text.replace(HEADING_HASH_PREFIX, '$1'); return { @@ -44,6 +45,8 @@ export function createMarkdownTheme(): MarkdownTheme { strikethrough: (text) => chalk.strikethrough(text), underline: (text) => chalk.underline(text), highlightCode: (code: string, lang?: string) => { + if (transient) return code.split('\n'); + const normalizedLang = lang?.trim().toLowerCase(); const language = normalizedLang !== undefined && supportsLanguage(normalizedLang) ? normalizedLang : 'text'; diff --git a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts index 308c3aa21..8d42ca5e0 100644 --- a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts @@ -1,5 +1,6 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; -import { describe, expect, it } from 'vitest'; +import { Markdown, visibleWidth } from '@earendil-works/pi-tui'; +import * as cliHighlight from 'cli-highlight'; +import { describe, expect, it, vi } from 'vitest'; import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; import { STATUS_BULLET } from '#/tui/constant/symbols'; @@ -7,6 +8,14 @@ import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; import { captureProcessWrite } from '../../../helpers/process'; +vi.mock('cli-highlight', async () => { + const actual = await vi.importActual('cli-highlight'); + return { + ...actual, + highlight: vi.fn(actual.highlight), + }; +}); + function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } @@ -60,4 +69,60 @@ describe('AssistantMessageComponent', () => { expect(text).toContain(''); expect(text).not.toContain('UserPromptSubmit hook'); }); + + it('reuses the same Markdown child across streaming text updates', () => { + const component = new AssistantMessageComponent(); + + component.updateContent('hello'); + const first = (component as any).contentContainer.children[0]; + expect(first).toBeInstanceOf(Markdown); + + component.updateContent('hello world'); + const second = (component as any).contentContainer.children[0]; + + expect(second).toBe(first); + expect(strip(component.render(80).join('\n'))).toContain('hello world'); + }); + + it('does not recreate the Markdown child when the text is unchanged', () => { + const component = new AssistantMessageComponent(); + + component.updateContent('hello'); + const first = (component as any).contentContainer.children[0]; + expect(first).toBeInstanceOf(Markdown); + + component.updateContent('hello'); + const second = (component as any).contentContainer.children[0]; + + expect(second).toBe(first); + }); + + it('rebuilds the Markdown child when transient changes so final render can highlight code', () => { + const component = new AssistantMessageComponent(); + const code = '```ts\nconst x = 1\n```'; + + component.updateContent(code, { transient: true }); + const streaming = (component as any).contentContainer.children[0]; + expect(streaming).toBeInstanceOf(Markdown); + + component.updateContent(code, { transient: false }); + const finalized = (component as any).contentContainer.children[0]; + expect(finalized).toBeInstanceOf(Markdown); + + expect(finalized).not.toBe(streaming); + }); + + it('skips synchronous syntax highlighting in transient markdown themes', () => { + const highlightSpy = vi.mocked(cliHighlight.highlight); + highlightSpy.mockClear(); + const streamingTheme = createMarkdownTheme({ transient: true }); + const finalTheme = createMarkdownTheme(); + const code = 'const x = 1'; + + expect(streamingTheme.highlightCode?.(code, 'typescript')).toEqual([code]); + expect(highlightSpy).not.toHaveBeenCalled(); + + finalTheme.highlightCode?.(code, 'typescript'); + expect(highlightSpy).toHaveBeenCalled(); + }); }); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index a8a019b69..0d6c498dc 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -1326,7 +1326,7 @@ command = "vim" await vi.runOnlyPendingTimersAsync(); expect(updateSpy).toHaveBeenCalledTimes(1); - expect(updateSpy).toHaveBeenLastCalledWith('abc'); + expect(updateSpy).toHaveBeenLastCalledWith('abc', { transient: true }); } finally { vi.useRealTimers(); } From ff177155ca630248bcd692421faab21e7b5be069 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Wed, 24 Jun 2026 19:15:06 +0800 Subject: [PATCH 04/93] fix(web): stop auto-dismissing pending questions and approvals on a timeout (#1070) * fix(web): stop dismissing questions after a 60 second timeout The server's question broker auto-expired AskUserQuestion requests after 60s, which dismissed the question even when the user simply needed more time. Remove the timeout, and the now-unused expires_at field, so a question stays pending until the user answers or explicitly dismisses it. --- .changeset/fix-web-question-timeout.md | 5 + .../src/api/daemon/agentEventProjector.ts | 1 - apps/kimi-web/src/api/daemon/eventReducer.ts | 3 +- apps/kimi-web/src/api/daemon/mappers.ts | 8 -- apps/kimi-web/src/api/daemon/wire.ts | 4 - apps/kimi-web/src/api/types.ts | 2 - packages/agent-core/src/rpc/client.ts | 4 +- .../services/coreProcess/coreProcessClient.ts | 3 +- .../src/services/question/question.ts | 8 +- .../src/services/session/sessionService.ts | 3 +- .../test/services/question-adapter.test.ts | 2 - .../protocol/src/__tests__/question.test.ts | 2 - packages/protocol/src/question.ts | 1 - .../scenarios/08-pending-recovery.ts | 1 - .../src/services/approval/approvalService.ts | 76 ++++++------ .../server/src/services/approval/index.ts | 1 - .../server/src/services/question/index.ts | 2 - .../src/services/question/questionService.ts | 85 +++++-------- packages/server/test/approval.e2e.test.ts | 112 ++++++++++-------- packages/server/test/question.e2e.test.ts | 92 +++++++------- packages/server/test/services.test.ts | 48 -------- 21 files changed, 197 insertions(+), 266 deletions(-) create mode 100644 .changeset/fix-web-question-timeout.md diff --git a/.changeset/fix-web-question-timeout.md b/.changeset/fix-web-question-timeout.md new file mode 100644 index 000000000..962ada6fe --- /dev/null +++ b/.changeset/fix-web-question-timeout.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Stop auto-dismissing questions in the web UI after 60 seconds so they wait for the user's answer. diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts index 86d9e7224..31a01c1c6 100644 --- a/apps/kimi-web/src/api/daemon/agentEventProjector.ts +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -1280,7 +1280,6 @@ const PROTOCOL_EVENT_NAMES = new Set([ 'question.requested', 'question.answered', 'question.dismissed', - 'question.expired', // Background tasks (projected) 'task.created', 'task.progress', diff --git a/apps/kimi-web/src/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts index 44f43e709..77d8831f2 100644 --- a/apps/kimi-web/src/api/daemon/eventReducer.ts +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -480,8 +480,7 @@ export function reduceAppEvent( // ------------------------------------------------------------------------- case 'questionAnswered': - case 'questionDismissed': - case 'questionExpired': { + case 'questionDismissed': { const sid = event.sessionId; const qid = event.questionId; const list = next.questionsBySession[sid] ?? []; diff --git a/apps/kimi-web/src/api/daemon/mappers.ts b/apps/kimi-web/src/api/daemon/mappers.ts index 2170bef43..a32697922 100644 --- a/apps/kimi-web/src/api/daemon/mappers.ts +++ b/apps/kimi-web/src/api/daemon/mappers.ts @@ -329,7 +329,6 @@ export function toAppQuestionRequest(wire: WireQuestionRequest): AppQuestionRequ turnId: wire.turn_id, toolCallId: wire.tool_call_id, questions: wire.questions.map(toAppQuestionItem), - expiresAt: wire.expires_at, createdAt: wire.created_at, }; } @@ -645,13 +644,6 @@ export function toAppEvent(wire: WireEvent): AppEvent { dismissedAt: w.payload.dismissed_at, }; - case 'event.question.expired': - return { - type: 'questionExpired', - sessionId: w.session_id, - questionId: w.payload.question_id, - }; - // ----- Background tasks ----- case 'event.task.created': return { diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts index 314cb96cf..3433018d7 100644 --- a/apps/kimi-web/src/api/daemon/wire.ts +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -270,7 +270,6 @@ export interface WireQuestionRequest { turn_id?: number; tool_call_id?: string; questions: WireQuestionItem[]; - expires_at: string; created_at: string; } @@ -739,8 +738,6 @@ type WireEventQuestionDismissed = WireEventBase<'event.question.dismissed', { dismissed_by: string; dismissed_at: string; }>; -type WireEventQuestionExpired = WireEventBase<'event.question.expired', { question_id: string }>; - // Background tasks type WireEventTaskCreated = WireEventBase<'event.task.created', { task: WireBackgroundTask }>; type WireEventTaskProgress = WireEventBase<'event.task.progress', { @@ -802,7 +799,6 @@ export type WireEvent = | WireEventQuestionRequested | WireEventQuestionAnswered | WireEventQuestionDismissed - | WireEventQuestionExpired // Background tasks | WireEventTaskCreated | WireEventTaskProgress diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index ad52b6e0d..84cdd9ae4 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -272,7 +272,6 @@ export interface AppQuestionRequest { turnId?: number; toolCallId?: string; questions: QuestionItem[]; - expiresAt: string; createdAt: string; } @@ -415,7 +414,6 @@ export type AppEvent = | { type: 'questionRequested'; sessionId: string; question: AppQuestionRequest } | { type: 'questionAnswered'; sessionId: string; questionId: string; resolvedAt: string } | { type: 'questionDismissed'; sessionId: string; questionId: string; dismissedAt: string } - | { type: 'questionExpired'; sessionId: string; questionId: string } | { type: 'taskCreated'; sessionId: string; task: AppTask } | { type: 'taskProgress'; sessionId: string; taskId: string; outputChunk: string; stream: 'stdout' | 'stderr' } | { type: 'taskCompleted'; sessionId: string; taskId: string; status: AppTaskStatus; outputPreview?: string; outputBytes?: number } diff --git a/packages/agent-core/src/rpc/client.ts b/packages/agent-core/src/rpc/client.ts index d50cb5f3f..a4a0b0b28 100644 --- a/packages/agent-core/src/rpc/client.ts +++ b/packages/agent-core/src/rpc/client.ts @@ -55,7 +55,9 @@ export function createRPC, Right extends Record signal?.throwIfAborted(); let response: RpcResponse; try { - const value = await abortableRpc(Promise.resolve(fn(rpcPayload)), signal); + const handlerResult = + signal === undefined ? fn(rpcPayload) : fn(rpcPayload, { signal }); + const value = await abortableRpc(Promise.resolve(handlerResult), signal); response = { ok: true, value }; } catch (error) { signal?.throwIfAborted(); diff --git a/packages/agent-core/src/services/coreProcess/coreProcessClient.ts b/packages/agent-core/src/services/coreProcess/coreProcessClient.ts index 24e78e15c..11014b73f 100644 --- a/packages/agent-core/src/services/coreProcess/coreProcessClient.ts +++ b/packages/agent-core/src/services/coreProcess/coreProcessClient.ts @@ -53,8 +53,9 @@ export class BridgeClientAPI implements SDKAPI { async requestQuestion( request: QuestionRequest & { sessionId: string; agentId: string }, + options?: { signal?: AbortSignal }, ): Promise { - return this.deps.questionService.request(request); + return this.deps.questionService.request(request, options); } async toolCall( diff --git a/packages/agent-core/src/services/question/question.ts b/packages/agent-core/src/services/question/question.ts index 23fbc9185..da936ace4 100644 --- a/packages/agent-core/src/services/question/question.ts +++ b/packages/agent-core/src/services/question/question.ts @@ -66,7 +66,10 @@ export interface IQuestionService { * Resolves with the in-process `QuestionResult` (null = no handler / fully * dismissed). Concrete impls own timeout policy. */ - request(req: InProcessQuestionRequest & { sessionId: string; agentId: string }): Promise; + request( + req: InProcessQuestionRequest & { sessionId: string; agentId: string }, + options?: { signal?: AbortSignal }, + ): Promise; /** * Called by the answer-side (REST handler / TUI / mock) to settle a pending @@ -104,8 +107,6 @@ export interface QuestionToBrokerRequestParams { readonly sessionId: string; /** `createdAt` ISO string; broker passes `new Date().toISOString()`. */ readonly createdAt: string; - /** `expiresAt` ISO string; broker computes `createdAt + 60s`. */ - readonly expiresAt: string; } /** @@ -161,7 +162,6 @@ export function toBrokerRequest( session_id: params.sessionId, questions: req.questions.map((q, i) => buildItem(q, i)), created_at: params.createdAt, - expires_at: params.expiresAt, }; if (req.turnId !== undefined) out.turn_id = req.turnId; if (req.toolCallId !== undefined) out.tool_call_id = req.toolCallId; diff --git a/packages/agent-core/src/services/session/sessionService.ts b/packages/agent-core/src/services/session/sessionService.ts index cf98d3ee4..3af85e326 100644 --- a/packages/agent-core/src/services/session/sessionService.ts +++ b/packages/agent-core/src/services/session/sessionService.ts @@ -223,8 +223,7 @@ export class SessionService extends Disposable implements ISessionService { case 'event.approval.expired': case 'event.question.requested': case 'event.question.answered': - case 'event.question.dismissed': - case 'event.question.expired': { + case 'event.question.dismissed': { this._emitStatusChanged(sessionId); break; } diff --git a/packages/agent-core/test/services/question-adapter.test.ts b/packages/agent-core/test/services/question-adapter.test.ts index e5a6b5d4f..b74ceb883 100644 --- a/packages/agent-core/test/services/question-adapter.test.ts +++ b/packages/agent-core/test/services/question-adapter.test.ts @@ -48,7 +48,6 @@ describe('question-adapter · toBrokerRequest (in-process → protocol)', () => questionId: '01J_QUESTION', sessionId: 'sess_x', createdAt: '2026-06-04T10:30:00.000Z', - expiresAt: '2026-06-04T10:31:00.000Z', }); expect(protoReq.question_id).toBe('01J_QUESTION'); @@ -91,7 +90,6 @@ describe('question-adapter · toBrokerRequest (in-process → protocol)', () => questionId: 'q', sessionId: 's', createdAt: '2026-06-04T10:30:00.000Z', - expiresAt: '2026-06-04T10:31:00.000Z', }); expect(protoReq.turn_id).toBeUndefined(); expect(protoReq.tool_call_id).toBeUndefined(); diff --git a/packages/protocol/src/__tests__/question.test.ts b/packages/protocol/src/__tests__/question.test.ts index e4353e99a..95e8ab932 100644 --- a/packages/protocol/src/__tests__/question.test.ts +++ b/packages/protocol/src/__tests__/question.test.ts @@ -96,7 +96,6 @@ describe('questionRequestSchema (SCHEMAS §6.2)', () => { }, ], created_at: '2026-06-04T10:30:00Z', - expires_at: '2026-06-04T10:31:00Z', }; it('accepts a 1-question request', () => { @@ -262,7 +261,6 @@ describe('listPendingQuestionsResponseSchema (REST pending recovery)', () => { }, ], created_at: '2026-06-04T10:30:00Z', - expires_at: '2026-06-04T10:31:00Z', }; it('accepts status=pending query', () => { diff --git a/packages/protocol/src/question.ts b/packages/protocol/src/question.ts index 3bfdf8c9f..66afbd013 100644 --- a/packages/protocol/src/question.ts +++ b/packages/protocol/src/question.ts @@ -29,7 +29,6 @@ export const questionRequestSchema = z.object({ tool_call_id: z.string().min(1).optional(), questions: z.array(questionItemSchema).min(1).max(4), created_at: isoDateTimeSchema, - expires_at: isoDateTimeSchema, }); export type QuestionRequest = z.infer; diff --git a/packages/server-e2e/scenarios/08-pending-recovery.ts b/packages/server-e2e/scenarios/08-pending-recovery.ts index 584cb58b1..f69a860cc 100644 --- a/packages/server-e2e/scenarios/08-pending-recovery.ts +++ b/packages/server-e2e/scenarios/08-pending-recovery.ts @@ -37,7 +37,6 @@ interface QuestionRequestedPayload { options: Array<{ id: string; label: string }>; }>; created_at: string; - expires_at: string; } async function main() { diff --git a/packages/server/src/services/approval/approvalService.ts b/packages/server/src/services/approval/approvalService.ts index 29577acbd..92986e31e 100644 --- a/packages/server/src/services/approval/approvalService.ts +++ b/packages/server/src/services/approval/approvalService.ts @@ -16,13 +16,6 @@ export const APPROVAL_DEFAULT_TIMEOUT_MS = 60_000; export const APPROVAL_RECENTLY_RESOLVED_CAP = 1024; -export class ApprovalExpiredError extends Error { - constructor(public readonly approvalId: string, timeoutMs: number) { - super(`approval ${approvalId} expired after ${timeoutMs}ms`); - this.name = 'ApprovalExpiredError'; - } -} - class PendingApproval implements IDisposable { private _settled = false; @@ -35,13 +28,11 @@ class PendingApproval implements IDisposable { readonly protocolRequest: ProtocolApprovalRequest, private readonly _resolveFn: (r: ApprovalResponse) => void, private readonly _rejectFn: (e: Error) => void, - private readonly _timer: NodeJS.Timeout, ) {} markSettled(): void { if (this._settled) return; this._settled = true; - clearTimeout(this._timer); } resolve(r: ApprovalResponse): void { @@ -55,7 +46,6 @@ class PendingApproval implements IDisposable { dispose(): void { if (this._settled) return; this._settled = true; - clearTimeout(this._timer); try { this._rejectFn(new Error('server shutting down')); } catch { @@ -72,7 +62,6 @@ export class ApprovalService extends Disposable implements IApprovalService { private readonly _byToolCallId = new Map(); private readonly _recentlyResolved = new Set(); - private _timeoutMs = APPROVAL_DEFAULT_TIMEOUT_MS; private readonly _recentlyResolvedCap = APPROVAL_RECENTLY_RESOLVED_CAP; constructor( @@ -81,6 +70,39 @@ export class ApprovalService extends Disposable implements IApprovalService { ) { super(); this._pending = this._register(new DisposableMap()); + + // The turn's abort signal never reaches this broker: agent-core's + // `BridgeClientAPI.requestApproval` drops the `{ signal }` option, so an + // aborted turn would otherwise leave the approval in `_pending` forever + // (pinning the session in `awaiting_approval` and keeping the web panel + // open). Settle stale approvals when the in-process bus reports the turn + // ended for a cancellation reason. This is intentionally session-scoped: a + // turn has at most one pending approval, and on normal completion the + // approval is already resolved so this is a no-op. + this._register( + this.eventService.onDidPublish((event) => { + if ((event as { type?: string }).type !== 'turn.ended') return; + const reason = (event as { reason?: string }).reason; + if (reason !== 'cancelled' && reason !== 'failed' && reason !== 'filtered') return; + const sessionId = (event as { sessionId?: string }).sessionId; + if (sessionId === undefined || sessionId === '') return; + this.dismissForSession(sessionId); + }), + ); + } + + private dismissForSession(sessionId: string): void { + const ids: string[] = []; + for (const p of this._pending.values()) { + if (p.sessionId === sessionId) ids.push(p.approvalId); + } + for (const id of ids) { + // Reuse resolve(): clears `_pending` / `_byToolCallId` and publishes + // `event.approval.resolved` (decision: 'cancelled') so the web panel + // closes. The agent-core caller's promise is already rejected by the + // abort, so the resolved value is only observed by tests. + this.resolve(id, { decision: 'cancelled' }); + } } async request( @@ -92,7 +114,10 @@ export class ApprovalService extends Disposable implements IApprovalService { const approvalId = ulid(); const createdAt = new Date().toISOString(); - const expiresAt = new Date(Date.now() + this._timeoutMs).toISOString(); + // `expires_at` is still populated for the protocol/web contract, but the + // broker no longer enforces it — approvals wait until the user resolves + // them or the server shuts down. + const expiresAt = new Date(Date.now() + APPROVAL_DEFAULT_TIMEOUT_MS).toISOString(); const protocolRequest = approvalToBrokerRequest(req, { approvalId, @@ -121,8 +146,6 @@ export class ApprovalService extends Disposable implements IApprovalService { ); return new Promise((resolve, reject) => { - const timer = setTimeout(() => this._expire(approvalId), this._timeoutMs); - timer.unref?.(); this._pending.set( approvalId, new PendingApproval( @@ -134,7 +157,6 @@ export class ApprovalService extends Disposable implements IApprovalService { protocolRequest, resolve, reject, - timer, ), ); this._byToolCallId.set(req.toolCallId, approvalId); @@ -199,30 +221,6 @@ export class ApprovalService extends Disposable implements IApprovalService { return { sessionId: p.sessionId, toolCallId: p.toolCallId }; } - _setTimeoutMsForTests(ms: number): void { - this._timeoutMs = ms; - } - - private _expire(approvalId: string): void { - const p = this._pending.get(approvalId); - if (!p) return; - p.markSettled(); - this._pending.deleteAndLeak(approvalId); - this._byToolCallId.delete(p.toolCallId); - - this.markResolved(p.approvalId); - - const expiredEvent: Event = { - type: 'event.approval.expired', - sessionId: p.sessionId, - agentId: 'main', - approval_id: p.approvalId, - } as unknown as Event; - this.eventService.publish(expiredEvent); - - p.reject(new ApprovalExpiredError(p.approvalId, this._timeoutMs)); - } - override dispose(): void { if (this._store.isDisposed) return; this._byToolCallId.clear(); diff --git a/packages/server/src/services/approval/index.ts b/packages/server/src/services/approval/index.ts index 1978d7181..49ff011d8 100644 --- a/packages/server/src/services/approval/index.ts +++ b/packages/server/src/services/approval/index.ts @@ -1,6 +1,5 @@ export { ApprovalService, - ApprovalExpiredError, APPROVAL_DEFAULT_TIMEOUT_MS, APPROVAL_RECENTLY_RESOLVED_CAP, } from './approvalService'; diff --git a/packages/server/src/services/question/index.ts b/packages/server/src/services/question/index.ts index 95450a252..78b26f4a1 100644 --- a/packages/server/src/services/question/index.ts +++ b/packages/server/src/services/question/index.ts @@ -1,6 +1,4 @@ export { QuestionService, - QuestionExpiredError, - QUESTION_DEFAULT_TIMEOUT_MS, QUESTION_RECENTLY_RESOLVED_CAP, } from './questionService'; diff --git a/packages/server/src/services/question/questionService.ts b/packages/server/src/services/question/questionService.ts index 6b9b2d142..569f1e5d2 100644 --- a/packages/server/src/services/question/questionService.ts +++ b/packages/server/src/services/question/questionService.ts @@ -12,36 +12,31 @@ import type { // eslint-disable-next-line @typescript-eslint/no-unused-vars const _typeAnchor: typeof IQuestionService = IQuestionService; -export const QUESTION_DEFAULT_TIMEOUT_MS = 60_000; - export const QUESTION_RECENTLY_RESOLVED_CAP = 1024; -export class QuestionExpiredError extends Error { - constructor(public readonly questionId: string, timeoutMs: number) { - super(`question ${questionId} expired after ${timeoutMs}ms`); - this.name = 'QuestionExpiredError'; - } -} - class PendingQuestion implements IDisposable { private _settled = false; + private _abortCleanup: (() => void) | undefined; constructor( readonly questionId: string, readonly sessionId: string, readonly toolCallId: string | undefined, readonly createdAt: string, - readonly expiresAt: string, readonly protocolRequest: ProtocolQuestionRequest, private readonly _resolveFn: (r: QuestionResult) => void, private readonly _rejectFn: (e: Error) => void, - private readonly _timer: NodeJS.Timeout, ) {} + setAbortCleanup(cleanup: () => void): void { + this._abortCleanup = cleanup; + } + markSettled(): void { if (this._settled) return; this._settled = true; - clearTimeout(this._timer); + this._abortCleanup?.(); + this._abortCleanup = undefined; } resolve(r: QuestionResult): void { @@ -55,9 +50,10 @@ class PendingQuestion implements IDisposable { dispose(): void { if (this._settled) return; this._settled = true; - clearTimeout(this._timer); + this._abortCleanup?.(); + this._abortCleanup = undefined; try { - this._rejectFn(new Error('server shutting down')); + this.reject(new Error('server shutting down')); } catch { } @@ -70,7 +66,6 @@ export class QuestionService extends Disposable implements IQuestionService { private readonly _pending: DisposableMap; private readonly _recentlyResolved = new Set(); - private _timeoutMs = QUESTION_DEFAULT_TIMEOUT_MS; private readonly _recentlyResolvedCap = QUESTION_RECENTLY_RESOLVED_CAP; constructor( @@ -83,6 +78,7 @@ export class QuestionService extends Disposable implements IQuestionService { async request( req: QuestionRequest & { sessionId: string; agentId: string }, + options?: { signal?: AbortSignal }, ): Promise { if (this._store.isDisposed) { throw new Error('question service disposed'); @@ -90,13 +86,11 @@ export class QuestionService extends Disposable implements IQuestionService { const questionId = ulid(); const createdAt = new Date().toISOString(); - const expiresAt = new Date(Date.now() + this._timeoutMs).toISOString(); const protocolRequest = questionToBrokerRequest(req, { questionId, sessionId: req.sessionId, createdAt, - expiresAt, }); const event: Event = { @@ -119,22 +113,29 @@ export class QuestionService extends Disposable implements IQuestionService { ); return new Promise((resolve, reject) => { - const timer = setTimeout(() => this._expire(questionId), this._timeoutMs); - timer.unref?.(); - this._pending.set( + const pending = new PendingQuestion( questionId, - new PendingQuestion( - questionId, - req.sessionId, - req.toolCallId, - createdAt, - expiresAt, - protocolRequest, - resolve, - reject, - timer, - ), + req.sessionId, + req.toolCallId, + createdAt, + protocolRequest, + resolve, + reject, ); + this._pending.set(questionId, pending); + + // When the agent's turn is aborted, the broker entry must be settled so + // listPending()/session status don't stay stuck in awaiting_question. + const signal = options?.signal; + if (signal !== undefined) { + if (signal.aborted) { + this.dismiss(questionId); + } else { + const onAbort = () => this.dismiss(questionId); + signal.addEventListener('abort', onAbort, { once: true }); + pending.setAbortCleanup(() => signal.removeEventListener('abort', onAbort)); + } + } }); } @@ -214,28 +215,6 @@ export class QuestionService extends Disposable implements IQuestionService { return { sessionId: p.sessionId, toolCallId: p.toolCallId }; } - _setTimeoutMsForTests(ms: number): void { - this._timeoutMs = ms; - } - - private _expire(questionId: string): void { - const p = this._pending.get(questionId); - if (!p) return; - p.markSettled(); - this._pending.deleteAndLeak(questionId); - this.markResolved(p.questionId); - - const expiredEvent: Event = { - type: 'event.question.expired', - sessionId: p.sessionId, - agentId: 'main', - question_id: p.questionId, - } as unknown as Event; - this.eventService.publish(expiredEvent); - - p.reject(new QuestionExpiredError(p.questionId, this._timeoutMs)); - } - override dispose(): void { if (this._store.isDisposed) return; this._recentlyResolved.clear(); diff --git a/packages/server/test/approval.e2e.test.ts b/packages/server/test/approval.e2e.test.ts index bc347df66..cf9bb8f06 100644 --- a/packages/server/test/approval.e2e.test.ts +++ b/packages/server/test/approval.e2e.test.ts @@ -15,8 +15,6 @@ * - REST `POST` resolves → broker Promise settles → response converts back * to in-process SDK shape * - Idempotency (40902) + not-found (40404) - * - 60s timeout (override to 30ms) broadcasts `event.approval.expired` AND - * rejects the Promise with `ApprovalExpiredError`. */ import { mkdtempSync, rmSync } from 'node:fs'; @@ -29,14 +27,15 @@ import { WebSocket } from 'ws'; import { IApprovalService, + IEventService, type ApprovalRequest, type ApprovalResponse, + type Event, } from '@moonshot-ai/agent-core'; import { IRestGateway, startServer, type RunningServer } from '../src'; import { rawDataToString } from '../src/ws/rawData'; import { - ApprovalExpiredError, ApprovalService, } from '#/services/approval/approvalService'; @@ -267,6 +266,67 @@ describe('Approval reverse-RPC: WS broadcast → REST resolve → Promise settle ws.close(); }); + it('aborts the pending approval when the turn ends for a cancellation reason', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const { ws, received } = await openSubscriber(r, sid); + + const broker = r.services.invokeFunction( + (a) => a.get(IApprovalService) as ApprovalService, + ); + const eventService = r.services.invokeFunction((a) => a.get(IEventService)); + + const pending = broker.request({ + sessionId: sid, + agentId: 'main', + turnId: 21, + toolCallId: 'tc_approval_abort', + toolName: 'shell.run', + action: 'Run `ls`', + display: { kind: 'command', command: 'ls', summary: 'ls' } as never, + }); + + const requested = await waitFor( + received, + (f) => f['type'] === 'event.approval.requested', + 2000, + ); + const payload = requested['payload'] as { approval_id: string }; + expect(broker.isPending(payload.approval_id)).toBe(true); + + // Simulate the agent's turn being aborted before the user resolves the + // approval. The broker subscribes to the in-process bus because the turn + // abort signal never reaches it through the RPC boundary. + eventService.publish({ + type: 'turn.ended', + sessionId: sid, + agentId: 'main', + turnId: 21, + reason: 'cancelled', + } as unknown as Event); + + const resolvedFrame = await waitFor( + received, + (f) => f['type'] === 'event.approval.resolved', + 2000, + ); + const resolvedPayload = resolvedFrame['payload'] as { + approval_id: string; + decision: string; + }; + expect(resolvedPayload.approval_id).toBe(payload.approval_id); + expect(resolvedPayload.decision).toBe('cancelled'); + + // Broker entry is cleaned up so listPending()/session status don't stick + // in awaiting_approval for a dead turn. + expect(broker.isPending(payload.approval_id)).toBe(false); + + const inProcResp: ApprovalResponse = await pending; + expect(inProcResp.decision).toBe('cancelled'); + + ws.close(); + }); + it('GET pending approvals lists recoverable requests and omits resolved ones', async () => { const r = await bootDaemon(); const sid = await createSession(r); @@ -359,52 +419,6 @@ describe('Approval reverse-RPC: WS broadcast → REST resolve → Promise settle expect(env.code).toBe(40001); }); - it('60s timeout broadcasts event.approval.expired + rejects with ApprovalExpiredError', async () => { - const r = await bootDaemon(); - const sid = await createSession(r); - const { ws, received } = await openSubscriber(r, sid); - - // Swap in a short-timeout broker for this session — clean by reaching into - // the container, since startServer doesn't expose a broker-options - // override yet. - const broker = r.services.invokeFunction( - (a) => a.get(IApprovalService) as ApprovalService, - ); - // Stamp the timeout via a private field hack — the test already - // co-owns the impl. (In a fuller world we'd thread a `brokerOptions` - // option through ServerStartOptions.) - (broker as unknown as { _timeoutMs: number })._timeoutMs = 40; - - const pending = broker.request({ - sessionId: sid, - agentId: 'main', - toolCallId: 'tc_timeout', - toolName: 'shell.run', - action: 'Run', - display: { kind: 'generic', summary: 'test' } as never, - turnId: 1, - }); - - // Expect a rejection AND an event.approval.expired frame. - let rejection: unknown; - try { - await pending; - } catch (err) { - rejection = err; - } - expect(rejection).toBeInstanceOf(ApprovalExpiredError); - - const expiredFrame = await waitFor( - received, - (f) => f['type'] === 'event.approval.expired', - 2000, - ); - const payload = expiredFrame['payload'] as { approval_id: string }; - expect(payload.approval_id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/); - - ws.close(); - }); - it('REST resolve on unknown approval_id returns 40404', async () => { const r = await bootDaemon(); const sid = await createSession(r); diff --git a/packages/server/test/question.e2e.test.ts b/packages/server/test/question.e2e.test.ts index 2c56760ed..833daf0f6 100644 --- a/packages/server/test/question.e2e.test.ts +++ b/packages/server/test/question.e2e.test.ts @@ -26,10 +26,7 @@ import { import { IRestGateway, startServer, type RunningServer } from '../src'; import { rawDataToString } from '../src/ws/rawData'; -import { - QuestionService, - QuestionExpiredError, -} from '#/services/question/questionService'; +import { QuestionService } from '#/services/question/questionService'; let tmpDir: string; let lockPath: string; @@ -297,7 +294,6 @@ describe('Question reverse-RPC: WS broadcast → REST resolve → Promise settle other_label?: string; }>; created_at: string; - expires_at: string; }>; }>(res.json()); expect(env.code).toBe(0); @@ -320,7 +316,6 @@ describe('Question reverse-RPC: WS broadcast → REST resolve → Promise settle { id: 'opt_0_1', label: 'No' }, ]); expect(item?.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); - expect(item?.expires_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); const dismissed = await appOf(r).inject({ method: 'POST', @@ -485,6 +480,52 @@ describe('Question reverse-RPC: WS broadcast → REST resolve → Promise settle ws.close(); }); + it('aborts the pending question when the request signal aborts', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const { ws, received } = await openSubscriber(r, sid); + + const broker = r.services.invokeFunction( + (a) => a.get(IQuestionService) as QuestionService, + ); + + const controller = new AbortController(); + const pending = broker.request( + { + sessionId: sid, + agentId: 'main', + questions: [{ question: '?', options: [{ label: 'A' }, { label: 'B' }] }], + }, + { signal: controller.signal }, + ); + + const requested = await waitFor( + received, + (f) => f['type'] === 'event.question.requested', + 2000, + ); + const payload = requested['payload'] as { question_id: string }; + + // Simulate the turn being aborted before the user answers. + controller.abort(); + + const dismissedFrame = await waitFor( + received, + (f) => f['type'] === 'event.question.dismissed', + 2000, + ); + const dPayload = dismissedFrame['payload'] as { question_id: string }; + expect(dPayload.question_id).toBe(payload.question_id); + + // Broker entry is cleaned up so listPending/session status don't stick. + expect(broker.isPending(payload.question_id)).toBe(false); + + const result: QuestionResult = await pending; + expect(result).toBeNull(); + + ws.close(); + }); + it('REST resolve on unknown question_id returns 40405', async () => { const r = await bootDaemon(); const sid = await createSession(r); @@ -510,6 +551,8 @@ describe('Question reverse-RPC: WS broadcast → REST resolve → Promise settle }); it('REST re-resolve on already-resolved question returns 40902 with data:{resolved:false}', async () => { + + const r = await bootDaemon(); const sid = await createSession(r); @@ -586,41 +629,4 @@ describe('Question reverse-RPC: WS broadcast → REST resolve → Promise settle // Cleanup so the test doesn't leave a hanging Promise. broker.dismiss(questionId!); }); - - it('60s timeout broadcasts event.question.expired + rejects with QuestionExpiredError', async () => { - const r = await bootDaemon(); - const sid = await createSession(r); - const { ws, received } = await openSubscriber(r, sid); - - const broker = r.services.invokeFunction( - (a) => a.get(IQuestionService) as QuestionService, - ); - (broker as unknown as { _timeoutMs: number })._timeoutMs = 40; - - const pending = broker.request({ - sessionId: sid, - agentId: 'main', - questions: [ - { question: '?', options: [{ label: 'A' }, { label: 'B' }] }, - ], - }); - - let rejection: unknown; - try { - await pending; - } catch (err) { - rejection = err; - } - expect(rejection).toBeInstanceOf(QuestionExpiredError); - - const expiredFrame = await waitFor( - received, - (f) => f['type'] === 'event.question.expired', - 2000, - ); - const payload = expiredFrame['payload'] as { question_id: string }; - expect(payload.question_id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/); - - ws.close(); - }); }); diff --git a/packages/server/test/services.test.ts b/packages/server/test/services.test.ts index 5444f7667..f72e7cf6d 100644 --- a/packages/server/test/services.test.ts +++ b/packages/server/test/services.test.ts @@ -578,31 +578,6 @@ describe('ApprovalService (broadcasts + resolve-by-approval_id)', () => { bus.dispose(); }); - it('rejects with ApprovalExpiredError + broadcasts event.approval.expired after timeoutMs', async () => { - const { broker, bus, broadcast, conn } = makeBrokerWithBus(); - broker._setTimeoutMsForTests(30); - const pending = broker.request({ - sessionId: 'sess_1', - agentId: 'agent_1', - toolCallId: 'tc_timeout', - toolName: 'shell.run', - action: 'Run', - display: { kind: 'generic', summary: 'test' }, - } as Parameters[0]); - - await expect(pending).rejects.toMatchObject({ - name: 'ApprovalExpiredError', - }); - await broadcast._drainForTest('sess_1'); - const expiredFrame = conn.sent.find( - (f) => (f as { type: string }).type === 'event.approval.expired', - ); - expect(expiredFrame).toBeDefined(); - broker.dispose(); - broadcast.dispose(); - bus.dispose(); - }); - it('dispose rejects all pending requests with "server shutting down"', async () => { const { broker, bus, broadcast } = makeBrokerWithBus(); const p1 = broker.request({ @@ -733,29 +708,6 @@ describe('QuestionService (broadcasts + dismiss)', () => { bus.dispose(); }); - it('60s timeout broadcasts event.question.expired + rejects QuestionExpiredError', async () => { - const { broker, bus, broadcast, conn } = makeQuestionBroker(); - broker._setTimeoutMsForTests(30); - const pending = broker.request({ - sessionId: 's', - agentId: 'a', - questions: [ - { question: '?', options: [{ label: 'A' }, { label: 'B' }] }, - ], - } as Parameters[0]); - - await expect(pending).rejects.toMatchObject({ name: 'QuestionExpiredError' }); - await broadcast._drainForTest('s'); - const expiredFrame = conn.sent.find( - (f) => (f as { type: string }).type === 'event.question.expired', - ); - expect(expiredFrame).toBeDefined(); - - broker.dispose(); - broadcast.dispose(); - bus.dispose(); - }); - it('dispose rejects pending question Promises', async () => { const { broker, bus, broadcast } = makeQuestionBroker(); const pending = broker.request({ From b62b3a147f025a2a9cfddcc914920ecbb752d8ec Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 24 Jun 2026 19:39:42 +0800 Subject: [PATCH 05/93] feat(kimi-code): show cache read details in debug timing (#1074) * feat(kimi-code): show cache read details in debug timing * chore: remove changeset --- .../kimi-code/src/utils/usage/debug-timing.ts | 39 +++++++++++++++++-- .../test/utils/usage/debug-timing.test.ts | 32 +++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/utils/usage/debug-timing.ts b/apps/kimi-code/src/utils/usage/debug-timing.ts index 457b686a3..ab87ebdd8 100644 --- a/apps/kimi-code/src/utils/usage/debug-timing.ts +++ b/apps/kimi-code/src/utils/usage/debug-timing.ts @@ -1,7 +1,16 @@ +import { formatTokenCount } from './usage-format'; + +interface DebugTokenUsage { + readonly inputOther?: number; + readonly inputCacheRead?: number; + readonly inputCacheCreation?: number; + readonly output?: number; +} + export interface StepTimingInput { - readonly llmFirstTokenLatencyMs?: number | undefined; - readonly llmStreamDurationMs?: number | undefined; - readonly usage?: { readonly output: number } | undefined; + readonly llmFirstTokenLatencyMs?: number; + readonly llmStreamDurationMs?: number; + readonly usage?: DebugTokenUsage; } // Decode TPS is only meaningful when the output actually streamed over a @@ -29,9 +38,33 @@ export function formatStepDebugTiming(input: StepTimingInput): string | undefine ); } } + + const inputTokens = usageInputTotal(input.usage); + const hasInputUsage = + input.usage !== undefined && + (input.usage.inputOther !== undefined || + input.usage.inputCacheRead !== undefined || + input.usage.inputCacheCreation !== undefined); + if (hasInputUsage && (inputTokens > 0 || (outputTokens ?? 0) > 0)) { + const cacheReadTokens = input.usage.inputCacheRead ?? 0; + const cacheCreationTokens = input.usage.inputCacheCreation ?? 0; + const cacheHitRate = inputTokens > 0 ? Math.round((cacheReadTokens / inputTokens) * 100) : 0; + const cacheParts = [`cache read ${formatTokenCount(cacheReadTokens)} (${cacheHitRate}%)`]; + if (cacheCreationTokens > 0) { + cacheParts.push(`write ${formatTokenCount(cacheCreationTokens)}`); + } + parts.push(`tokens in ${formatTokenCount(inputTokens)}`); + parts.push(cacheParts.join(' / ')); + } + return `[Debug] ${parts.join(' | ')}`; } +function usageInputTotal(usage: DebugTokenUsage | undefined): number { + if (usage === undefined) return 0; + return (usage.inputOther ?? 0) + (usage.inputCacheRead ?? 0) + (usage.inputCacheCreation ?? 0); +} + function formatDuration(ms: number): string { if (ms < 1000) return `${Math.round(ms)}ms`; return `${(ms / 1000).toFixed(1)}s`; diff --git a/apps/kimi-code/test/utils/usage/debug-timing.test.ts b/apps/kimi-code/test/utils/usage/debug-timing.test.ts index be871f3c1..feb857204 100644 --- a/apps/kimi-code/test/utils/usage/debug-timing.test.ts +++ b/apps/kimi-code/test/utils/usage/debug-timing.test.ts @@ -27,6 +27,38 @@ describe('formatStepDebugTiming', () => { expect(result).toBe('[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s)'); }); + it('formats input tokens and cache read/write counts', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + usage: { + inputOther: 700, + inputCacheRead: 1200, + inputCacheCreation: 100, + output: 200, + }, + }); + expect(result).toBe( + '[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s) | tokens in 2.0k | cache read 1.2k (60%) / write 100', + ); + }); + + it('omits cache write count when it is zero', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + usage: { + inputOther: 1000, + inputCacheRead: 0, + inputCacheCreation: 0, + output: 200, + }, + }); + expect(result).toContain('tokens in 1.0k'); + expect(result).toContain('cache read 0 (0%)'); + expect(result).not.toContain('/ write 0'); + }); + it('omits TPS when the streamed window is too short to measure', () => { const result = formatStepDebugTiming({ llmFirstTokenLatencyMs: 1200, From 0e227ba18aec793aa4c233be7c578068ae91e604 Mon Sep 17 00:00:00 2001 From: Kai Date: Wed, 24 Jun 2026 19:59:30 +0800 Subject: [PATCH 06/93] fix(agent-core): surface git context failures for explore subagents (#1067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agent-core): surface git context failures for explore subagents collectGitContext collapsed every git failure (spawn error, non-zero exit, timeout) into null, so explore subagents silently lost git context with no signal. Now a definitive 'not a git repository' injects an explicit unavailable signal so the subagent does not waste turns probing git history, while other failures are logged and surface as an empty block. The block is all-or-nothing so a partial snapshot (e.g. a timed-out status making a dirty tree look clean) is never shown. * fix(agent-core): use rev-parse for branch to support git < 2.22 `git branch --show-current` was added in Git 2.22 and fails (exit 129) on older Git even in a valid repository. Because the branch probe is fatal, this dropped the whole git-context block for older-Git users. Switch to `git rev-parse --abbrev-ref HEAD`, which is supported across Git versions, and filter the `HEAD` output produced in detached-HEAD state. * fix(agent-core): show whatever git info is available in explore context Git probes fail in perfectly normal states — no `origin` remote, no commits yet (unborn branch), detached HEAD, older Git — so a failed probe no longer aborts the whole collection. Each probe is now best-effort: failures are logged and their section is omitted, and the block is dropped only when nothing useful was collected. Branch is read via `symbolic-ref --short HEAD`, which works in unborn repositories and on older Git; it fails in detached-HEAD state, where the Branch section is just omitted. --- .changeset/fix-git-context-silent-failure.md | 5 + .../agent-core/src/session/git-context.ts | 161 ++++++++++++++---- .../test/session/git-context.test.ts | 116 +++++++++++-- 3 files changed, 237 insertions(+), 45 deletions(-) create mode 100644 .changeset/fix-git-context-silent-failure.md diff --git a/.changeset/fix-git-context-silent-failure.md b/.changeset/fix-git-context-silent-failure.md new file mode 100644 index 000000000..416fe9763 --- /dev/null +++ b/.changeset/fix-git-context-silent-failure.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix explore subagents silently losing git context when git commands time out or the directory is not a repository. diff --git a/packages/agent-core/src/session/git-context.ts b/packages/agent-core/src/session/git-context.ts index ada2b1ad2..7ba1c968b 100644 --- a/packages/agent-core/src/session/git-context.ts +++ b/packages/agent-core/src/session/git-context.ts @@ -3,15 +3,22 @@ * * `collectGitContext` produces a `` block that is prepended to a * fresh explore subagent's prompt so it can orient itself in the repository - * before searching. Every git command is individually guarded — a single - * failure never aborts the whole collection — and remote URLs are sanitized - * so internal infrastructure is not surfaced to the model. + * before searching. Every git probe is best-effort: probes fail in perfectly + * normal states (no `origin` remote, no commits yet, detached HEAD, older + * Git), so a failed probe is logged and its section omitted rather than + * dropping the whole block. The block is omitted entirely only when nothing + * useful was collected. The one explicit state surfaced to the subagent is + * `reason="not-a-repo"`, so it doesn't waste turns probing git history in a + * non-repo directory. Remote URLs are sanitized so internal infrastructure + * is not surfaced to the model. */ import type { Readable } from 'node:stream'; import type { Kaos, KaosProcess } from '@moonshot-ai/kaos'; +import { log } from '../logging/logger'; + const GIT_TIMEOUT_MS = 5_000; const MAX_DIRTY_FILES = 20; const MAX_COMMIT_LINE_LENGTH = 200; @@ -42,17 +49,50 @@ async function disposeProcess(proc: KaosProcess): Promise { * directory is not a git repository or no useful information was collected. */ export async function collectGitContext(kaos: Kaos, cwd: string): Promise { - // Quick check: is this a git repo? - if ((await runGit(kaos, cwd, ['rev-parse', '--is-inside-work-tree'])) === null) { + // Step 1: is this a git repo? `rev-parse` is the authoritative probe — it + // handles `.git` files (worktrees/submodules), subdirectories, bare repos, + // and `$GIT_DIR` redirection, none of which a plain FS check covers. + const revParseArgs = ['rev-parse', '--is-inside-work-tree'] as const; + const revParse = await runGit(kaos, cwd, revParseArgs); + if (!revParse.ok) { + if (revParse.kind === 'command-failed' && isNotARepo(revParse.stderr)) { + // Definitive "not a repo" — tell the subagent so it doesn't waste turns + // probing git history. All other failures are logged but surface as an + // empty block (the subagent works without git context, same as before): + // a transient `git status` hang shouldn't read as "git is broken". + return ``; + } + logGitFailure(cwd, revParseArgs, revParse); return ''; } - const [remoteUrl, branch, dirtyRaw, logRaw] = await Promise.all([ - runGit(kaos, cwd, ['remote', 'get-url', 'origin']), - runGit(kaos, cwd, ['branch', '--show-current']), - runGit(kaos, cwd, ['status', '--porcelain']), - runGit(kaos, cwd, ['log', '-3', '--format=%h %s']), - ]); + // Step 2: collect context in parallel. Every probe is optional — git + // probes fail in perfectly normal states (no `origin` remote, no commits + // yet, detached HEAD, older Git), so a failed probe never aborts the + // collection. Each failure is logged and its section is simply omitted; if + // nothing useful is collected, the block is dropped entirely below. + // + // Branch is read via `symbolic-ref --short HEAD`, which works in unborn + // repositories and on older Git; it fails in detached-HEAD state, in which + // case the Branch section is just omitted. + const commandArgs = [ + ['remote', 'get-url', 'origin'], + ['symbolic-ref', '--short', 'HEAD'], + ['status', '--porcelain'], + ['log', '-3', '--format=%h %s'], + ] as const; + const [remote, branch, status, gitLog] = (await Promise.all( + commandArgs.map(async (args) => ({ args, result: await runGit(kaos, cwd, args) })), + )) as unknown as [TaggedGitResult, TaggedGitResult, TaggedGitResult, TaggedGitResult]; + + for (const { args, result } of [remote, branch, status, gitLog]) { + if (!result.ok) logGitFailure(cwd, args, result); + } + + const remoteUrl = stdoutOf(remote.result); + const branchName = stdoutOf(branch.result); + const dirtyRaw = stdoutOf(status.result); + const logRaw = stdoutOf(gitLog.result); const sections: string[] = [`Working directory: ${cwd}`]; @@ -67,19 +107,17 @@ export async function collectGitContext(kaos: Kaos, cwd: string): Promise line.trim().length > 0); - if (dirtyLines.length > 0) { - const total = dirtyLines.length; - const shown = dirtyLines.slice(0, MAX_DIRTY_FILES); - let body = shown.map((line) => ` ${line}`).join('\n'); - if (total > MAX_DIRTY_FILES) { - body += `\n ... and ${String(total - MAX_DIRTY_FILES)} more`; - } - sections.push(`Dirty files (${String(total)}):\n${body}`); + const dirtyLines = dirtyRaw.split('\n').filter((line) => line.trim().length > 0); + if (dirtyLines.length > 0) { + const total = dirtyLines.length; + const shown = dirtyLines.slice(0, MAX_DIRTY_FILES); + let body = shown.map((line) => ` ${line}`).join('\n'); + if (total > MAX_DIRTY_FILES) { + body += `\n ... and ${String(total - MAX_DIRTY_FILES)} more`; } + sections.push(`Dirty files (${String(total)}):\n${body}`); } if (logRaw) { @@ -135,7 +173,10 @@ export function parseProjectName(remoteUrl: string): string | null { const scp = /^[^/]+@[^/:]+:(.+)$/.exec(remoteUrl); const rawPath = scp?.[1] ?? tryUrlPath(remoteUrl); if (rawPath === null) return null; - const project = rawPath.replace(/^\/+/, '').replace(/\/+$/, '').replace(/\.git$/, ''); + const project = rawPath + .replace(/^\/+/, '') + .replace(/\/+$/, '') + .replace(/\.git$/, ''); return project.length > 0 ? project : null; } @@ -148,16 +189,61 @@ function tryUrlPath(remoteUrl: string): string | null { } /** - * Run a single `git -C ` command and return its trimmed stdout, - * or `null` on any failure (spawn error, non-zero exit, or timeout). The - * `git -C` form runs in the target directory regardless of the Kaos backend. + * Outcome of a single `git` invocation. + * + * - `ok: true` — exited 0; `stdout` is trimmed. + * - `timeout` — exceeded `GIT_TIMEOUT_MS`; process was SIGKILLed. + * - `spawn-error` — `kaos.exec` itself rejected (git missing / backend error). + * - `command-failed` — git ran but exited non-zero, or its streams errored. + * `exitCode`/`stderr` are populated for the non-zero-exit case. */ -async function runGit(kaos: Kaos, cwd: string, args: readonly string[]): Promise { +type GitFailure = + | { readonly kind: 'timeout' } + | { readonly kind: 'spawn-error' } + | { readonly kind: 'command-failed'; readonly exitCode?: number; readonly stderr?: string }; + +type GitResult = + | { readonly ok: true; readonly stdout: string } + | ({ readonly ok: false } & GitFailure); + +type TaggedGitResult = { readonly args: readonly string[]; readonly result: GitResult }; + +function stdoutOf(result: GitResult): string { + return result.ok ? result.stdout : ''; +} + +function isNotARepo(stderr: string | undefined): boolean { + return stderr !== undefined && stderr.includes('not a git repository'); +} + +function logGitFailure(cwd: string, args: readonly string[], failure: GitFailure): void { + const command = `git ${args.join(' ')}`; + if (failure.kind === 'timeout') { + log.debug('git context command timed out', { cwd, command }); + } else if (failure.kind === 'spawn-error') { + log.warn('git context command failed to spawn', { cwd, command }); + } else { + log.debug('git context command failed', { + cwd, + command, + exitCode: failure.exitCode, + stderr: failure.stderr, + }); + } +} + +/** + * Run a single `git -C ` command and return a structured result. + * The `git -C` form runs in the target directory regardless of the Kaos + * backend. Both stdout and stderr are captured so callers can tell "not a + * git repository" (exit 128 + telltale stderr) apart from other failures. + */ +async function runGit(kaos: Kaos, cwd: string, args: readonly string[]): Promise { let proc: KaosProcess | undefined; try { proc = await kaos.exec('git', '-C', cwd, ...args); } catch { - return null; + return { ok: false, kind: 'spawn-error' }; } try { @@ -166,31 +252,36 @@ async function runGit(kaos: Kaos, cwd: string, args: readonly string[]): Promise /* stdin already closed */ } - const work = Promise.all([collectStream(proc.stdout), proc.wait()]); + const work = Promise.all([collectStream(proc.stdout), collectStream(proc.stderr), proc.wait()]); // Attach a rejection handler up front: if `work` rejects during the // timeout-handling window (before the catch block re-awaits it), Node must // not flag it as an unhandled rejection. work.catch(() => {}); let timer: ReturnType | undefined; + let timedOut = false; try { const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => { + timedOut = true; reject(new Error(`git ${args.join(' ')} timed out`)); }, GIT_TIMEOUT_MS); }); - const [stdout, exitCode] = await Promise.race([work, timeout]); - if (exitCode !== 0) return null; - return stdout.trim(); + const [stdout, stderr, exitCode] = await Promise.race([work, timeout]); + if (exitCode !== 0) { + return { ok: false, kind: 'command-failed', exitCode, stderr: stderr.trim() }; + } + return { ok: true, stdout: stdout.trim() }; } catch { try { await proc.kill('SIGKILL'); } catch { /* process already gone */ } - // Let the stdout drain settle so the process resources are released, - // even though the timed-out output is discarded. + // Let the streams drain so process resources are released, even though + // the timed-out/errored output is discarded. await work.catch(() => {}); - return null; + if (timedOut) return { ok: false, kind: 'timeout' }; + return { ok: false, kind: 'command-failed' }; } finally { if (timer !== undefined) clearTimeout(timer); if (proc !== undefined) await disposeProcess(proc); diff --git a/packages/agent-core/test/session/git-context.test.ts b/packages/agent-core/test/session/git-context.test.ts index 3bf9c2344..cb135daa8 100644 --- a/packages/agent-core/test/session/git-context.test.ts +++ b/packages/agent-core/test/session/git-context.test.ts @@ -3,14 +3,18 @@ import { Readable } from 'node:stream'; import type { Kaos, KaosProcess } from '@moonshot-ai/kaos'; import { describe, expect, it, vi } from 'vitest'; -import { collectGitContext, parseProjectName, sanitizeRemoteUrl } from '../../src/session/git-context'; +import { + collectGitContext, + parseProjectName, + sanitizeRemoteUrl, +} from '../../src/session/git-context'; import { createFakeKaos } from '../tools/fixtures/fake-kaos'; -function fakeProcess(stdout: string, exitCode = 0): KaosProcess { +function fakeProcess(stdout: string, exitCode = 0, stderr = ''): KaosProcess { return { stdin: { write: () => true, end: () => {} } as never, stdout: Readable.from([stdout]), - stderr: Readable.from(['']), + stderr: Readable.from([stderr]), pid: 1, exitCode, wait: async () => exitCode, @@ -20,22 +24,50 @@ function fakeProcess(stdout: string, exitCode = 0): KaosProcess { } /** Scripted git output keyed by the git subcommand (`args[3]`). */ -type GitScript = Record; +type GitScript = Record; function gitKaos(script: GitScript): Kaos { return createFakeKaos({ exec: async (...args: string[]): Promise => { const subcommand = args[3] ?? ''; - const scripted = script[subcommand]; + // Match the full git invocation first (e.g. `rev-parse --abbrev-ref + // HEAD`) so two commands sharing a subcommand (both `rev-parse`) can be + // scripted distinctly; fall back to the bare subcommand. + const full = args.slice(3).join(' '); + const scripted = script[full] ?? script[subcommand]; if (scripted === undefined) return fakeProcess('', 1); - return fakeProcess(scripted.stdout, scripted.exitCode ?? 0); + return fakeProcess(scripted.stdout, scripted.exitCode ?? 0, scripted.stderr ?? ''); }, }); } describe('collectGitContext', () => { - it('returns an empty string when the directory is not a git repository', async () => { - const kaos = gitKaos({ 'rev-parse': { stdout: '', exitCode: 1 } }); + it('returns an unavailable block when the directory is not a git repository', async () => { + const kaos = gitKaos({ + 'rev-parse': { + stdout: '', + exitCode: 128, + stderr: 'fatal: not a git repository (or any of the parent directories): .git', + }, + }); + expect(await collectGitContext(kaos, '/project')).toBe( + ``, + ); + }); + + it('returns an empty string when rev-parse fails for a reason other than not-a-repo', async () => { + const kaos = gitKaos({ + 'rev-parse': { stdout: '', exitCode: 1, stderr: 'fatal: some other git error' }, + }); + expect(await collectGitContext(kaos, '/project')).toBe(''); + }); + + it('returns an empty string when git fails to spawn', async () => { + const kaos = createFakeKaos({ + exec: async (): Promise => { + throw new Error('spawn failed'); + }, + }); expect(await collectGitContext(kaos, '/project')).toBe(''); }); @@ -43,7 +75,7 @@ describe('collectGitContext', () => { const kaos = gitKaos({ 'rev-parse': { stdout: 'true' }, remote: { stdout: 'https://github.com/acme/widgets.git' }, - branch: { stdout: 'main' }, + 'symbolic-ref --short HEAD': { stdout: 'main' }, status: { stdout: ' M src/a.ts\n?? src/b.ts' }, log: { stdout: 'abc123 first commit\ndef456 second commit' }, }); @@ -66,7 +98,10 @@ describe('collectGitContext', () => { const dirty = Array.from({ length: 25 }, (_, i) => ` M src/f${String(i)}.ts`).join('\n'); const kaos = gitKaos({ 'rev-parse': { stdout: 'true' }, + remote: { stdout: '' }, + 'symbolic-ref --short HEAD': { stdout: '' }, status: { stdout: dirty }, + log: { stdout: '' }, }); const block = await collectGitContext(kaos, '/project'); @@ -84,7 +119,9 @@ describe('collectGitContext', () => { const kaos = gitKaos({ 'rev-parse': { stdout: 'true' }, remote: { stdout: 'git@internal.corp:secret/repo.git' }, - branch: { stdout: 'main' }, + 'symbolic-ref --short HEAD': { stdout: 'main' }, + status: { stdout: '' }, + log: { stdout: '' }, }); const block = await collectGitContext(kaos, '/project'); @@ -95,6 +132,65 @@ describe('collectGitContext', () => { expect(block).toContain('Branch: main'); }); + it('keeps branch and status when the origin remote is absent', async () => { + const kaos = gitKaos({ + 'rev-parse': { stdout: 'true' }, + remote: { stdout: '', exitCode: 2, stderr: "error: No such remote 'origin'" }, + 'symbolic-ref --short HEAD': { stdout: 'main' }, + status: { stdout: ' M src/a.ts' }, + log: { stdout: 'abc123 first commit' }, + }); + + const block = await collectGitContext(kaos, '/project'); + + expect(block).toContain('Branch: main'); + expect(block).toContain('Dirty files (1):'); + expect(block).toContain('Recent commits:'); + expect(block).not.toContain('Remote:'); + expect(block).not.toContain('Project:'); + }); + + it('keeps branch and status when the repository has no commits yet', async () => { + const kaos = gitKaos({ + 'rev-parse': { stdout: 'true' }, + remote: { stdout: 'https://github.com/acme/widgets.git' }, + 'symbolic-ref --short HEAD': { stdout: 'main' }, + status: { stdout: '' }, + log: { + stdout: '', + exitCode: 128, + stderr: "fatal: your current branch 'main' does not have any commits yet", + }, + }); + + const block = await collectGitContext(kaos, '/project'); + + expect(block).toContain('Branch: main'); + expect(block).toContain('Remote: https://github.com/acme/widgets.git'); + expect(block).toContain('Project: acme/widgets'); + expect(block).not.toContain('Recent commits:'); + }); + + it('omits the Branch section in detached HEAD state', async () => { + const kaos = gitKaos({ + 'rev-parse': { stdout: 'true' }, + 'symbolic-ref --short HEAD': { + stdout: '', + exitCode: 128, + stderr: 'fatal: ref HEAD is not a symbolic ref', + }, + remote: { stdout: 'https://github.com/acme/widgets.git' }, + status: { stdout: '' }, + log: { stdout: 'abc123 first commit' }, + }); + + const block = await collectGitContext(kaos, '/project'); + + expect(block).not.toContain('Branch:'); + expect(block).toContain('Remote: https://github.com/acme/widgets.git'); + expect(block).toContain('Recent commits:'); + }); + it('treats a hanging git command as a failure (timeout)', async () => { vi.useFakeTimers(); try { From 500677ab8baf9081b73a35df5fbbcfc49cb2f9b7 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 24 Jun 2026 20:30:20 +0800 Subject: [PATCH 07/93] fix(tui): clear editor draft on Ctrl-C during compaction (#1076) When compaction is in progress and the editor has a draft, Ctrl-C now clears the draft first instead of cancelling compaction, matching the streaming behavior. The clear-text logic is shared between the compaction and streaming branches. --- .changeset/fix-ctrl-c-compaction.md | 5 ++++ .../src/tui/controllers/editor-keyboard.ts | 15 ++++++++---- .../test/tui/kimi-tui-message-flow.test.ts | 24 +++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-ctrl-c-compaction.md diff --git a/.changeset/fix-ctrl-c-compaction.md b/.changeset/fix-ctrl-c-compaction.md new file mode 100644 index 000000000..58fd93250 --- /dev/null +++ b/.changeset/fix-ctrl-c-compaction.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix Ctrl-C during compaction so it clears a pending editor draft first instead of cancelling immediately. diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index bc5b922b0..42871d5dc 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -72,6 +72,9 @@ export class EditorKeyboardController { if (host.state.appState.isCompacting) { this.clearPendingExit(); + + if (this.clearEditorTextIfPresent()) return; + this.cancelCurrentCompaction(); return; } @@ -88,10 +91,7 @@ export class EditorKeyboardController { if (host.state.appState.streamingPhase !== 'idle') { this.clearPendingExit(); - if (editor.getText().length > 0) { - editor.setText(''); - return; - } + if (this.clearEditorTextIfPresent()) return; this.cancelCurrentStream(); return; @@ -254,6 +254,13 @@ export class EditorKeyboardController { this.host.state.ui.requestRender(); } + private clearEditorTextIfPresent(): boolean { + const editor = this.host.state.editor; + if (editor.getText().length === 0) return false; + editor.setText(''); + return true; + } + private cancelCurrentStream(): void { void this.host.session?.cancel(); } diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 0d6c498dc..621f11653 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -1423,6 +1423,30 @@ command = "vim" expect(session.cancelCompaction).toHaveBeenCalledTimes(1); }); + it('clears editor text before cancelling compaction on Ctrl-C', async () => { + const { driver, session } = await makeDriver(); + driver.sessionEventHandler.handleEvent( + { + type: 'compaction.started', + agentId: 'main', + sessionId: 'ses-1', + trigger: 'manual', + } as Event, + vi.fn(), + ); + driver.state.editor.setText('draft while compacting'); + + driver.state.editor.onCtrlC?.(); + + expect(driver.state.editor.getText()).toBe(''); + expect(session.cancelCompaction).not.toHaveBeenCalled(); + expect(driver.state.appState.isCompacting).toBe(true); + + driver.state.editor.onCtrlC?.(); + + expect(session.cancelCompaction).toHaveBeenCalledTimes(1); + }); + it('dispatches the next queued message after compaction is cancelled', async () => { vi.useFakeTimers(); try { From 75ca3b21609d7197bb2c9b4389901595840ac7e3 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 24 Jun 2026 21:00:40 +0800 Subject: [PATCH 08/93] feat(tui): add Ctrl+U/Ctrl+D paging in the task output viewer (#1078) PgUp/PgDn are often captured by terminal or tmux scrollback, so add Ctrl+U and Ctrl+D as full-page up and down alternatives, matching the existing PgUp/PgDn behavior. --- .changeset/task-output-ctrl-ud-paging.md | 5 +++++ .../components/dialogs/task-output-viewer.ts | 15 ++++++++++++--- .../test/tui/task-output-viewer.test.ts | 18 ++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 .changeset/task-output-ctrl-ud-paging.md diff --git a/.changeset/task-output-ctrl-ud-paging.md b/.changeset/task-output-ctrl-ud-paging.md new file mode 100644 index 000000000..436dd21ca --- /dev/null +++ b/.changeset/task-output-ctrl-ud-paging.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add Ctrl+U and Ctrl+D as page up and page down shortcuts in the task output viewer. diff --git a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts index a1718a5eb..fdcd81aac 100644 --- a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts +++ b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts @@ -125,11 +125,20 @@ export class TaskOutputViewer extends Container implements Focusable { this.scrollBy(1); return; } - if (matchesKey(data, Key.pageUp) || k === ' ' || data === '\u0002' /* C-b */) { + if ( + matchesKey(data, Key.pageUp) || + matchesKey(data, Key.ctrl('u')) || + k === ' ' || + data === '\u0002' /* C-b */ + ) { this.scrollBy(-Math.max(1, visible - 1)); return; } - if (matchesKey(data, Key.pageDown) || data === '\u0006' /* C-f */) { + if ( + matchesKey(data, Key.pageDown) || + matchesKey(data, Key.ctrl('d')) || + data === '\u0006' /* C-f */ + ) { this.scrollBy(Math.max(1, visible - 1)); return; } @@ -240,7 +249,7 @@ export class TaskOutputViewer extends Container implements Focusable { ); const keys = `${key('↑↓')} ${dim('line')} ` + - `${key('PgUp/PgDn')} ${dim('page')} ` + + `${key('PgUp/PgDn/Ctrl+U/D')} ${dim('page')} ` + `${key('g/G')} ${dim('top/bot')} ` + `${key('Q/Esc')} ${dim('cancel')}`; const left = ` ${keys}`; diff --git a/apps/kimi-code/test/tui/task-output-viewer.test.ts b/apps/kimi-code/test/tui/task-output-viewer.test.ts index a7cbfe0df..2a3880e3d 100644 --- a/apps/kimi-code/test/tui/task-output-viewer.test.ts +++ b/apps/kimi-code/test/tui/task-output-viewer.test.ts @@ -144,6 +144,24 @@ describe('TaskOutputViewer — scrolling', () => { expect(out).not.toContain('line-001'); }); + it('Ctrl+D scrolls a page down', () => { + const viewer = makeViewer({ output: bigOutput(50), rows: 12 }); + viewer.handleInput('\u0004'); // Ctrl+D + const out = strip(viewer.render(120).join('\n')); + // Same page size as PageDown: body has 8 viewable rows, page = 7 lines. + expect(out).toContain('line-008'); + expect(out).not.toContain('line-001'); + }); + + it('Ctrl+U scrolls a page up', () => { + const viewer = makeViewer({ output: bigOutput(50), rows: 12 }); + viewer.handleInput('G'); // jump to bottom first + viewer.handleInput('\u0015'); // Ctrl+U + const out = strip(viewer.render(120).join('\n')); + expect(out).toContain('line-036'); + expect(out).not.toContain('line-050'); + }); + it('G jumps to the bottom', () => { const viewer = makeViewer({ output: bigOutput(100), rows: 14 }); viewer.handleInput('G'); From 3aaf1e58037c4045aaa3b9fbabaffa158c60d2ca Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 24 Jun 2026 21:07:02 +0800 Subject: [PATCH 09/93] fix(kimi-code): bump native clipboard dependency to fix Linux startup crash (#1075) * fix(kimi-code): bump native clipboard dependency to fix Linux startup crash * chore(nix): update pnpmDeps hash --- .changeset/fix-linux-clipboard-crash.md | 5 ++ apps/kimi-code/package.json | 2 +- flake.nix | 2 +- pnpm-lock.yaml | 90 ++++++++++++------------- 4 files changed, 52 insertions(+), 47 deletions(-) create mode 100644 .changeset/fix-linux-clipboard-crash.md diff --git a/.changeset/fix-linux-clipboard-crash.md b/.changeset/fix-linux-clipboard-crash.md new file mode 100644 index 000000000..aa329c184 --- /dev/null +++ b/.changeset/fix-linux-clipboard-crash.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix a startup crash on Linux caused by an unhandled native clipboard error. diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index d60576277..3485c3b38 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -74,7 +74,7 @@ "postinstall": "node scripts/postinstall.mjs" }, "optionalDependencies": { - "@mariozechner/clipboard": "^0.3.2", + "@mariozechner/clipboard": "^0.3.9", "koffi": "^2.16.0", "node-pty": "^1.1.0" }, diff --git a/flake.nix b/flake.nix index 895b7ffb2..e3e04414d 100644 --- a/flake.nix +++ b/flake.nix @@ -150,7 +150,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-l8RvHgEGwS1XX0VGWCNtF1EmE1SRQFyCCYGPCoPL3JY="; + hash = "sha256-yukns+b5mjDgp/TuFco24FH5pjJm/Ivlw71gMyhSl/Q="; }; nativeBuildInputs = [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9d2dff47..e162aced4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,8 +140,8 @@ importers: version: 4.3.6 optionalDependencies: '@mariozechner/clipboard': - specifier: ^0.3.2 - version: 0.3.2 + specifier: ^0.3.9 + version: 0.3.9 koffi: specifier: ^2.16.0 version: 2.16.0 @@ -1567,72 +1567,72 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@mariozechner/clipboard-darwin-arm64@0.3.2': - resolution: {integrity: sha512-uBf6K7Je1ihsgvmWxA8UCGCeI+nbRVRXoarZdLjl6slz94Zs1tNKFZqx7aCI5O1i3e0B6ja82zZ06BWrl0MCVw==} + '@mariozechner/clipboard-darwin-arm64@0.3.9': + resolution: {integrity: sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@mariozechner/clipboard-darwin-universal@0.3.2': - resolution: {integrity: sha512-mxSheKTW2U9LsBdXy0SdmdCAE5HqNS9QUmpNHLnfJ+SsbFKALjEZc5oRrVMXxGQSirDvYf5bjmRyT0QYYonnlg==} + '@mariozechner/clipboard-darwin-universal@0.3.9': + resolution: {integrity: sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==} engines: {node: '>= 10'} os: [darwin] - '@mariozechner/clipboard-darwin-x64@0.3.2': - resolution: {integrity: sha512-U1BcVEoidvwIp95+HJswSW+xr28EQiHR7rZjH6pn8Sja5yO4Yoe3yCN0Zm8Lo72BbSOK/fTSq0je7CJpaPCspg==} + '@mariozechner/clipboard-darwin-x64@0.3.9': + resolution: {integrity: sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@mariozechner/clipboard-linux-arm64-gnu@0.3.2': - resolution: {integrity: sha512-BsinwG3yWTIjdgNCxsFlip7LkfwPk+ruw/aFCXHUg/fb5XC/Ksp+YMQ7u0LUtiKzIv/7LMXgZInJQH6gxbAaqQ==} + '@mariozechner/clipboard-linux-arm64-gnu@0.3.9': + resolution: {integrity: sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@mariozechner/clipboard-linux-arm64-musl@0.3.2': - resolution: {integrity: sha512-0/Gi5Xq2V6goXBop19ePoHvXsmJD9SzFlO3S+d6+T2b+BlPcpOu3Oa0wTjl+cZrLAAEzA86aPNBI+VVAFDFPKw==} + '@mariozechner/clipboard-linux-arm64-musl@0.3.9': + resolution: {integrity: sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@mariozechner/clipboard-linux-riscv64-gnu@0.3.2': - resolution: {integrity: sha512-2AFFiXB24qf0zOZsxI1GJGb9wQGlOJyN6UwoXqmKS3dpQi/l6ix30IzDDA4c4ZcCcx4D+9HLYXhC1w7Sov8pXA==} + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': + resolution: {integrity: sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] libc: [glibc] - '@mariozechner/clipboard-linux-x64-gnu@0.3.2': - resolution: {integrity: sha512-v6fVnsn7WMGg73Dab8QMwyFce7tzGfgEixKgzLP8f1GJqkJZi5zO4k4FOHzSgUufgLil63gnxvMpjWkgfeQN7A==} + '@mariozechner/clipboard-linux-x64-gnu@0.3.9': + resolution: {integrity: sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@mariozechner/clipboard-linux-x64-musl@0.3.2': - resolution: {integrity: sha512-xVUtnoMQ8v2JVyfJLKKXACA6avdnchdbBkTsZs8BgJQo29qwCp5NIHAUO8gbJ40iaEGToW5RlmVk2M9V0HsHEw==} + '@mariozechner/clipboard-linux-x64-musl@0.3.9': + resolution: {integrity: sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@mariozechner/clipboard-win32-arm64-msvc@0.3.2': - resolution: {integrity: sha512-AEgg95TNi8TGgak2wSXZkXKCvAUTjWoU1Pqb0ON7JHrX78p616XUFNTJohtIon3e0w6k0pYPZeCuqRCza/Tqeg==} + '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': + resolution: {integrity: sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@mariozechner/clipboard-win32-x64-msvc@0.3.2': - resolution: {integrity: sha512-tGRuYpZwDOD7HBrCpyRuhGnHHSCknELvqwKKUG4JSfSB7JIU7LKRh6zx6fMUOQd8uISK35TjFg5UcNih+vJhFA==} + '@mariozechner/clipboard-win32-x64-msvc@0.3.9': + resolution: {integrity: sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@mariozechner/clipboard@0.3.2': - resolution: {integrity: sha512-IHQpksNjo7EAtGuHFU+tbWDp5LarH3HU/8WiB9O70ZEoBPHOg0/6afwSLK0QyNMMmx4Bpi/zl6+DcBXe95nWYA==} + '@mariozechner/clipboard@0.3.9': + resolution: {integrity: sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==} engines: {node: '>= 10'} '@mermaid-js/mermaid-mindmap@9.3.0': @@ -7267,48 +7267,48 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@mariozechner/clipboard-darwin-arm64@0.3.2': + '@mariozechner/clipboard-darwin-arm64@0.3.9': optional: true - '@mariozechner/clipboard-darwin-universal@0.3.2': + '@mariozechner/clipboard-darwin-universal@0.3.9': optional: true - '@mariozechner/clipboard-darwin-x64@0.3.2': + '@mariozechner/clipboard-darwin-x64@0.3.9': optional: true - '@mariozechner/clipboard-linux-arm64-gnu@0.3.2': + '@mariozechner/clipboard-linux-arm64-gnu@0.3.9': optional: true - '@mariozechner/clipboard-linux-arm64-musl@0.3.2': + '@mariozechner/clipboard-linux-arm64-musl@0.3.9': optional: true - '@mariozechner/clipboard-linux-riscv64-gnu@0.3.2': + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': optional: true - '@mariozechner/clipboard-linux-x64-gnu@0.3.2': + '@mariozechner/clipboard-linux-x64-gnu@0.3.9': optional: true - '@mariozechner/clipboard-linux-x64-musl@0.3.2': + '@mariozechner/clipboard-linux-x64-musl@0.3.9': optional: true - '@mariozechner/clipboard-win32-arm64-msvc@0.3.2': + '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': optional: true - '@mariozechner/clipboard-win32-x64-msvc@0.3.2': + '@mariozechner/clipboard-win32-x64-msvc@0.3.9': optional: true - '@mariozechner/clipboard@0.3.2': + '@mariozechner/clipboard@0.3.9': optionalDependencies: - '@mariozechner/clipboard-darwin-arm64': 0.3.2 - '@mariozechner/clipboard-darwin-universal': 0.3.2 - '@mariozechner/clipboard-darwin-x64': 0.3.2 - '@mariozechner/clipboard-linux-arm64-gnu': 0.3.2 - '@mariozechner/clipboard-linux-arm64-musl': 0.3.2 - '@mariozechner/clipboard-linux-riscv64-gnu': 0.3.2 - '@mariozechner/clipboard-linux-x64-gnu': 0.3.2 - '@mariozechner/clipboard-linux-x64-musl': 0.3.2 - '@mariozechner/clipboard-win32-arm64-msvc': 0.3.2 - '@mariozechner/clipboard-win32-x64-msvc': 0.3.2 + '@mariozechner/clipboard-darwin-arm64': 0.3.9 + '@mariozechner/clipboard-darwin-universal': 0.3.9 + '@mariozechner/clipboard-darwin-x64': 0.3.9 + '@mariozechner/clipboard-linux-arm64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-arm64-musl': 0.3.9 + '@mariozechner/clipboard-linux-riscv64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-x64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-x64-musl': 0.3.9 + '@mariozechner/clipboard-win32-arm64-msvc': 0.3.9 + '@mariozechner/clipboard-win32-x64-msvc': 0.3.9 optional: true '@mermaid-js/mermaid-mindmap@9.3.0': From a86bb9757d99f32983e82a6a82fd3ccaab691b1a Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 24 Jun 2026 21:07:14 +0800 Subject: [PATCH 10/93] fix(kimi-code): show clipboard image paste hint only for newly copied images (#1072) * fix(kimi-code): show clipboard image paste hint only once per image The footer hint repeated on every terminal focus whenever an image remained in the clipboard, which became noisy. Replace the 30s time-based cooldown with a per-image gate: the hint shows once for a given image and stays quiet until the clipboard is observed empty and a new image appears. * fix(kimi-code): suppress clipboard image hint for images present at startup The footer hint fired during initialization whenever an image was already in the clipboard, treating it as new. The first clipboard observation after start now only establishes a baseline, so only images copied during the session trigger the hint. * fix(kimi-code): show hint for first image copied after startup * fix(kimi-code): make clipboard image probe non-blocking The startup baseline probe in ClipboardImageHintController calls clipboardHasImage(), which on Linux/WSL ran wl-paste/xclip/powershell via spawnSync. The probe only reaches its first await after those synchronous calls, so a slow or wedged helper could freeze the TUI launch for up to the 1s-2s tool timeouts even when the user never focuses with an image. Add an async runCommandAsync built on spawn with timeout-based kill, and route the Linux/WSL image detection through it so the event loop is never blocked. Keep the synchronous runCommand for the explicit paste-read path. * chore: add changeset for non-blocking clipboard probe * chore: simplify clipboard image hint changeset --- .changeset/clipboard-image-hint-once.md | 5 + .../src/tui/constant/clipboard-image-hint.ts | 1 - .../tui/controllers/clipboard-image-hint.ts | 63 ++++- .../src/utils/clipboard/clipboard-common.ts | 75 +++++- .../utils/clipboard/clipboard-has-image.ts | 32 +-- .../controllers/clipboard-image-hint.test.ts | 245 +++++++++++++----- .../utils/clipboard/clipboard-common.test.ts | 30 +++ .../clipboard/clipboard-has-image.test.ts | 28 +- 8 files changed, 374 insertions(+), 105 deletions(-) create mode 100644 .changeset/clipboard-image-hint-once.md create mode 100644 apps/kimi-code/test/utils/clipboard/clipboard-common.test.ts diff --git a/.changeset/clipboard-image-hint-once.md b/.changeset/clipboard-image-hint-once.md new file mode 100644 index 000000000..2e14713e4 --- /dev/null +++ b/.changeset/clipboard-image-hint-once.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Improve the image paste hint. diff --git a/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts b/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts index 922e30fdd..eccb3f3fb 100644 --- a/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts +++ b/apps/kimi-code/src/tui/constant/clipboard-image-hint.ts @@ -1,4 +1,3 @@ // Timing constants for the clipboard-image hint controller. export const FOCUS_DEBOUNCE_MS = 1_000; -export const HINT_COOLDOWN_MS = 30_000; export const HINT_DISPLAY_MS = 4_000; diff --git a/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts b/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts index 2999845d6..ac1fd8c11 100644 --- a/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts +++ b/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts @@ -2,11 +2,7 @@ import type { TUI } from '@earendil-works/pi-tui'; import { clipboardHasImage } from '#/utils/clipboard/clipboard-has-image'; -import { - FOCUS_DEBOUNCE_MS, - HINT_COOLDOWN_MS, - HINT_DISPLAY_MS, -} from '../constant/clipboard-image-hint'; +import { FOCUS_DEBOUNCE_MS, HINT_DISPLAY_MS } from '../constant/clipboard-image-hint'; import { TERMINAL_FOCUS_IN, TERMINAL_FOCUS_OUT } from '../utils/terminal-focus'; import type { FooterComponent } from '../components/chrome/footer'; @@ -26,10 +22,19 @@ export class ClipboardImageHintController { private disposeInputListener: (() => void) | undefined; private debounceTimer: ReturnType | undefined; private clearHintTimer: ReturnType | undefined; - private lastHintAtMs = 0; private lastHintText: string | undefined; private checkGeneration = 0; private focused = true; + // Whether the controller has completed its first clipboard observation since + // start. The first observation only establishes a baseline: an image already + // in the clipboard when the session starts is not "new", so it must not + // trigger a hint during initialization. + private initialized = false; + // Whether a detected clipboard image is allowed to trigger a hint. After + // showing a hint for an image it disarms so the same lingering image does + // not nag on every focus. A focus check that finds the clipboard empty + // re-arms it, so the next genuinely new image notifies again. + private armed = true; constructor(host: ClipboardImageHintHost) { this.host = host; @@ -39,6 +44,7 @@ export class ClipboardImageHintController { this.disposeInputListener = this.host.ui.addInputListener((data) => { this.handleInput(data); }); + void this.establishInitialBaseline(); } stop(): void { @@ -49,7 +55,8 @@ export class ClipboardImageHintController { this.checkGeneration += 1; this.clearOwnedHint(); - this.lastHintAtMs = 0; + this.initialized = false; + this.armed = true; } private handleInput(data: string): void { @@ -94,10 +101,28 @@ export class ClipboardImageHintController { this.lastHintText = undefined; } + private async establishInitialBaseline(): Promise { + if (!this.host.getModelSupportsImage()) return; + + this.checkGeneration += 1; + const generation = this.checkGeneration; + + let hasImage = false; + try { + hasImage = await clipboardHasImage(); + } catch { + return; + } + + if (generation !== this.checkGeneration) return; + + this.initialized = true; + this.armed = !hasImage; + } + private async runCheck(generation: number): Promise { if (!this.focused) return; if (!this.host.getModelSupportsImage()) return; - if (Date.now() - this.lastHintAtMs < HINT_COOLDOWN_MS) return; let hasImage = false; try { @@ -108,14 +133,32 @@ export class ClipboardImageHintController { if (generation !== this.checkGeneration) return; if (!this.focused) return; - if (!hasImage) return; + + // First observation after start only establishes the baseline. An image + // already in the clipboard when the session began is not "new", so we + // record the state and stay quiet instead of nagging during initialization. + if (!this.initialized) { + this.initialized = true; + this.armed = !hasImage; + return; + } + + if (!hasImage) { + // Clipboard holds no image, so the next image that appears is a new one + // worth notifying about. Re-arm and bail out. + this.armed = true; + return; + } + + // Same image we already notified about — stay quiet until it changes. + if (!this.armed) return; const hintText = `Image in clipboard · ${getPasteImageShortcut()} to paste`; this.clearClearHintTimer(); this.lastHintText = hintText; + this.armed = false; this.host.footer.setTransientHint(hintText); this.host.requestRender(); - this.lastHintAtMs = Date.now(); this.clearHintTimer = setTimeout(() => { this.clearOwnedHint(); diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-common.ts b/apps/kimi-code/src/utils/clipboard/clipboard-common.ts index 9844dc4fd..dc0f60886 100644 --- a/apps/kimi-code/src/utils/clipboard/clipboard-common.ts +++ b/apps/kimi-code/src/utils/clipboard/clipboard-common.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'node:fs'; -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import type { ClipboardModule } from './clipboard-native'; @@ -9,6 +9,11 @@ export type RunCommand = ( args: string[], options?: RunCommandOptions, ) => { stdout: Buffer; ok: boolean }; +export type RunCommandAsync = ( + command: string, + args: string[], + options?: RunCommandOptions, +) => Promise<{ stdout: Buffer; ok: boolean }>; export const SUPPORTED_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const; @@ -49,6 +54,74 @@ export function runCommand( return { ok: true, stdout }; } +/** + * Non-blocking counterpart of `runCommand`. Used by the clipboard image probe + * on the startup path so a slow or wedged helper (notably `powershell.exe` on + * WSL, or a stuck `wl-paste`/`xclip`) cannot freeze the event loop. The child + * is killed and the promise resolves with `ok: false` once `timeoutMs` elapses + * or the captured stdout exceeds `DEFAULT_MAX_BUFFER_BYTES`. + */ +export function runCommandAsync( + command: string, + args: string[], + options?: RunCommandOptions, +): Promise<{ stdout: Buffer; ok: boolean }> { + const timeoutMs = options?.timeoutMs ?? DEFAULT_LIST_TIMEOUT_MS; + return new Promise((resolve) => { + let child; + try { + child = spawn(command, args, { + env: options?.env, + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + resolve({ ok: false, stdout: Buffer.alloc(0) }); + return; + } + + const chunks: Buffer[] = []; + let totalBytes = 0; + let settled = false; + let timer: ReturnType; + + // Marks the promise as settled and clears the timeout. Returns true only for + // the first caller, so each event handler below resolves at most once. + const claim = (): boolean => { + if (settled) return false; + settled = true; + clearTimeout(timer); + return true; + }; + + timer = setTimeout(() => { + child.kill(); + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + }, timeoutMs); + + child.stdout?.on('data', (chunk: Buffer) => { + totalBytes += chunk.length; + if (totalBytes > DEFAULT_MAX_BUFFER_BYTES) { + child.kill(); + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + return; + } + chunks.push(chunk); + }); + + child.on('error', () => { + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + }); + + child.on('close', (code) => { + if (code !== 0) { + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + return; + } + if (claim()) resolve({ ok: true, stdout: Buffer.concat(chunks) }); + }); + }); +} + export function isWaylandSession(env: NodeJS.ProcessEnv): boolean { return Boolean(env['WAYLAND_DISPLAY']) || env['XDG_SESSION_TYPE'] === 'wayland'; } diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts b/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts index 54f5f3be1..dc696d31c 100644 --- a/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts +++ b/apps/kimi-code/src/utils/clipboard/clipboard-has-image.ts @@ -5,32 +5,32 @@ import { isWaylandSession, isWSL, parseTargetList, - runCommand, + runCommandAsync, safeAvailableFormats, - type RunCommand, + type RunCommandAsync, } from './clipboard-common'; import { clipboard, type ClipboardModule } from './clipboard-native'; const DEFAULT_POWERSHELL_TIMEOUT_MS = 2000; -function hasImageViaWlPaste(run: RunCommand): boolean { - const list = run('wl-paste', ['--list-types'], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS }); +async function hasImageViaWlPaste(run: RunCommandAsync): Promise { + const list = await run('wl-paste', ['--list-types'], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS }); if (!list.ok) return false; return parseTargetList(list.stdout).some((t) => isSupportedImageMimeType(t)); } -function hasImageViaXclip(run: RunCommand): boolean { - const targets = run('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], { +async function hasImageViaXclip(run: RunCommandAsync): Promise { + const targets = await run('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS, }); if (!targets.ok) return false; return parseTargetList(targets.stdout).some((t) => isSupportedImageMimeType(t)); } -function hasImageViaPowerShell(run: RunCommand): boolean { +async function hasImageViaPowerShell(run: RunCommandAsync): Promise { const script = "Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); ($img -ne $null)"; - const result = run('powershell.exe', ['-NoProfile', '-Command', script], { + const result = await run('powershell.exe', ['-NoProfile', '-Command', script], { timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS, }); if (!result.ok) return false; @@ -58,12 +58,12 @@ export async function clipboardHasImage(options?: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; clipboard?: ClipboardModule | null; - runCommand?: RunCommand; + runCommand?: RunCommandAsync; }): Promise { const env = options?.env ?? process.env; const platform = options?.platform ?? process.platform; const clip = options?.clipboard ?? clipboard; - const run = options?.runCommand ?? runCommand; + const run = options?.runCommand ?? runCommandAsync; if (env['TERMUX_VERSION'] !== undefined) return false; @@ -71,19 +71,19 @@ export async function clipboardHasImage(options?: { const wayland = isWaylandSession(env); const wsl = isWSL(env); - let xclipResult: boolean | undefined; - const xclipHasImage = (): boolean => { + let xclipResult: Promise | undefined; + const xclipHasImage = (): Promise => { xclipResult ??= hasImageViaXclip(run); return xclipResult; }; if (wayland || wsl) { - if (hasImageViaWlPaste(run)) return true; - if (xclipHasImage()) return true; + if (await hasImageViaWlPaste(run)) return true; + if (await xclipHasImage()) return true; } - if (wsl && hasImageViaPowerShell(run)) return true; + if (wsl && (await hasImageViaPowerShell(run))) return true; if (!wayland) { - if (xclipHasImage()) return true; + if (await xclipHasImage()) return true; if (await hasImageViaNative(clip)) return true; } return false; diff --git a/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts b/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts index 435d1263b..0c69ebac9 100644 --- a/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts +++ b/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts @@ -71,6 +71,22 @@ function createFakeTUIWithConsumingFocusTracker(): FakeTUI { } as unknown as FakeTUI; } +// Drive the controller through its first clipboard observation with an empty +// clipboard. The first observation only establishes a baseline and never shows +// a hint, leaving the controller armed and ready for the next new image. +async function primeEmptyBaseline(ui: FakeTUI): Promise { + vi.mocked(clipboardHasImage).mockResolvedValue(false); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); +} + +// Simulate the user returning to the terminal and let the debounced check fire. +async function focusReturnAndFlush(ui: FakeTUI): Promise { + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); +} + describe('ClipboardImageHintController', () => { let platformSpy: ReturnType | undefined; @@ -86,7 +102,7 @@ describe('ClipboardImageHintController', () => { vi.useRealTimers(); }); - it('shows hint when focus returns and clipboard has image', async () => { + it('does not show a hint for an image already in the clipboard at startup', async () => { vi.mocked(clipboardHasImage).mockResolvedValue(true); const footer = createFakeFooter(); @@ -101,11 +117,62 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); - ui.emitInput(TERMINAL_FOCUS_OUT); + // Startup baseline observes the image already present; the focus check + // runs too but must stay quiet for that same image. ui.emitInput(TERMINAL_FOCUS_IN); - await vi.advanceTimersByTimeAsync(1000); + // One call is the startup baseline; the second is the focus check. + expect(clipboardHasImage).toHaveBeenCalledTimes(2); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('shows hint when a new image is copied during the session', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + expect(footer.getTransientHint()).toMatch(/Ctrl\+V/); + + controller.stop(); + }); + + it('shows hint for the first image copied after startup when startup baseline was empty', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await vi.advanceTimersByTimeAsync(0); + expect(clipboardHasImage).toHaveBeenCalledTimes(1); + expect(footer.getTransientHint()).toBeNull(); + + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); expect(footer.getTransientHint()).toMatch(/Ctrl\+V/); @@ -135,9 +202,7 @@ describe('ClipboardImageHintController', () => { controller.stop(); }); - it('respects cooldown between hints', async () => { - vi.mocked(clipboardHasImage).mockResolvedValue(true); - + it('does not repeat the hint for the same lingering image', async () => { const footer = createFakeFooter(); const ui = createFakeTUI(); const host: ClipboardImageHintHost = { @@ -150,22 +215,23 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); - ui.emitInput(TERMINAL_FOCUS_IN); - await vi.advanceTimersByTimeAsync(1000); - expect(footer.getTransientHint()).not.toBeNull(); + // Establish the baseline and show a hint for the first image. + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + // The same image is still in the clipboard: focusing again must not nag. + vi.mocked(clipboardHasImage).mockClear(); footer.setTransientHint(null); - ui.emitInput(TERMINAL_FOCUS_OUT); - ui.emitInput(TERMINAL_FOCUS_IN); - await vi.advanceTimersByTimeAsync(1000); + await focusReturnAndFlush(ui); expect(footer.getTransientHint()).toBeNull(); + expect(clipboardHasImage).toHaveBeenCalledTimes(1); controller.stop(); }); - it('clears hint after 2 seconds', async () => { - vi.mocked(clipboardHasImage).mockResolvedValue(true); - + it('shows the hint again for a new image after the clipboard is cleared', async () => { const footer = createFakeFooter(); const ui = createFakeTUI(); const host: ClipboardImageHintHost = { @@ -178,8 +244,42 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); - ui.emitInput(TERMINAL_FOCUS_IN); - await vi.advanceTimersByTimeAsync(1000); + // Establish the baseline, then show a hint for the first image. + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + // Clipboard cleared: the empty check re-arms the controller. + vi.mocked(clipboardHasImage).mockResolvedValue(false); + footer.setTransientHint(null); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toBeNull(); + + // A genuinely new image: hint shows again. + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + controller.stop(); + }); + + it('clears the hint after the display duration', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); expect(footer.getTransientHint()).not.toBeNull(); await vi.advanceTimersByTimeAsync(4000); @@ -203,6 +303,9 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); + await vi.advanceTimersByTimeAsync(0); + vi.mocked(clipboardHasImage).mockClear(); + ui.emitInput(TERMINAL_FOCUS_IN); ui.emitInput(TERMINAL_FOCUS_OUT); await vi.advanceTimersByTimeAsync(1000); @@ -214,8 +317,6 @@ describe('ClipboardImageHintController', () => { }); it('handles rapid focus churn without duplicate checks or hints', async () => { - vi.mocked(clipboardHasImage).mockResolvedValue(true); - const footer = createFakeFooter(); const ui = createFakeTUI(); const host: ClipboardImageHintHost = { @@ -228,6 +329,10 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + vi.mocked(clipboardHasImage).mockClear(); + for (let i = 0; i < 5; i++) { ui.emitInput(TERMINAL_FOCUS_OUT); ui.emitInput(TERMINAL_FOCUS_IN); @@ -260,7 +365,8 @@ describe('ClipboardImageHintController', () => { ui.emitInput(TERMINAL_FOCUS_IN); await vi.advanceTimersByTimeAsync(1000); - expect(clipboardHasImage).toHaveBeenCalledTimes(1); + // One call is the startup baseline; the second is the debounced focus check. + expect(clipboardHasImage).toHaveBeenCalledTimes(2); ui.emitInput(TERMINAL_FOCUS_OUT); await vi.advanceTimersByTimeAsync(1500); @@ -291,7 +397,8 @@ describe('ClipboardImageHintController', () => { ui.emitInput(TERMINAL_FOCUS_IN); await vi.advanceTimersByTimeAsync(1000); - expect(clipboardHasImage).toHaveBeenCalledTimes(1); + // One call is the startup baseline; the second is the debounced focus check. + expect(clipboardHasImage).toHaveBeenCalledTimes(2); controller.stop(); resolveDeferred(true); @@ -300,8 +407,6 @@ describe('ClipboardImageHintController', () => { }); it('clears a displayed hint when stopped', async () => { - vi.mocked(clipboardHasImage).mockResolvedValue(true); - const footer = createFakeFooter(); const ui = createFakeTUI(); const host: ClipboardImageHintHost = { @@ -314,8 +419,9 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); - ui.emitInput(TERMINAL_FOCUS_IN); - await vi.advanceTimersByTimeAsync(1000); + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); expect(footer.getTransientHint()).not.toBeNull(); controller.stop(); @@ -339,6 +445,7 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); + // First observation only establishes the baseline and sets no hint. ui.emitInput(TERMINAL_FOCUS_IN); await vi.advanceTimersByTimeAsync(1000); const otherHint = 'Other hint'; @@ -351,16 +458,6 @@ describe('ClipboardImageHintController', () => { }); it('uses only the latest clipboard read result after focus churn', async () => { - const deferreds: Array<{ resolve: (value: boolean) => void; promise: Promise }> = []; - vi.mocked(clipboardHasImage).mockImplementation(() => { - let resolve: (value: boolean) => void = () => {}; - const promise = new Promise((res) => { - resolve = res; - }); - deferreds.push({ resolve, promise }); - return promise; - }); - const footer = createFakeFooter(); const ui = createFakeTUI(); const host: ClipboardImageHintHost = { @@ -373,14 +470,28 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); - ui.emitInput(TERMINAL_FOCUS_IN); - await vi.advanceTimersByTimeAsync(1000); - expect(clipboardHasImage).toHaveBeenCalledTimes(1); + // Establish an empty baseline with a normal resolved read first. + await primeEmptyBaseline(ui); + + const deferreds: Array<{ resolve: (value: boolean) => void; promise: Promise }> = []; + vi.mocked(clipboardHasImage).mockImplementation(() => { + let resolve: (value: boolean) => void = () => {}; + const promise = new Promise((res) => { + resolve = res; + }); + deferreds.push({ resolve, promise }); + return promise; + }); ui.emitInput(TERMINAL_FOCUS_OUT); ui.emitInput(TERMINAL_FOCUS_IN); await vi.advanceTimersByTimeAsync(1000); - expect(clipboardHasImage).toHaveBeenCalledTimes(2); + expect(deferreds).toHaveLength(1); + + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + expect(deferreds).toHaveLength(2); deferreds[0]!.resolve(true); await vi.advanceTimersByTimeAsync(0); @@ -394,8 +505,6 @@ describe('ClipboardImageHintController', () => { }); it('keeps the existing auto-clear timer when a re-check exits early', async () => { - vi.mocked(clipboardHasImage).mockResolvedValue(true); - const footer = createFakeFooter(); const ui = createFakeTUI(); const host: ClipboardImageHintHost = { @@ -408,15 +517,14 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); - ui.emitInput(TERMINAL_FOCUS_IN); - await vi.advanceTimersByTimeAsync(1000); + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); expect(footer.getTransientHint()).not.toBeNull(); // Trigger a re-check that exits early because the clipboard is now empty. vi.mocked(clipboardHasImage).mockResolvedValue(false); - ui.emitInput(TERMINAL_FOCUS_OUT); - ui.emitInput(TERMINAL_FOCUS_IN); - await vi.advanceTimersByTimeAsync(1000); + await focusReturnAndFlush(ui); // The previous hint should still be visible because its auto-clear timer // was preserved through the re-check. @@ -430,8 +538,6 @@ describe('ClipboardImageHintController', () => { }); it('does not clear a matching hint owned by another caller after auto-clear', async () => { - vi.mocked(clipboardHasImage).mockResolvedValue(true); - const footer = createFakeFooter(); const ui = createFakeTUI(); const host: ClipboardImageHintHost = { @@ -444,8 +550,9 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); - ui.emitInput(TERMINAL_FOCUS_IN); - await vi.advanceTimersByTimeAsync(1000); + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); const hintText = footer.getTransientHint(); expect(hintText).not.toBeNull(); @@ -459,7 +566,7 @@ describe('ClipboardImageHintController', () => { expect(footer.getTransientHint()).toBe(hintText); }); - it('does not inherit cooldown after stop and restart', async () => { + it('re-establishes the baseline after stop and restart', async () => { vi.mocked(clipboardHasImage).mockResolvedValue(true); const footer = createFakeFooter(); @@ -474,26 +581,32 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); + // Image already present at start: baseline only, no hint. ui.emitInput(TERMINAL_FOCUS_IN); await vi.advanceTimersByTimeAsync(1000); - expect(footer.getTransientHint()).not.toBeNull(); + expect(footer.getTransientHint()).toBeNull(); controller.stop(); controller.start(); - footer.setTransientHint(null); - ui.emitInput(TERMINAL_FOCUS_OUT); - ui.emitInput(TERMINAL_FOCUS_IN); - await vi.advanceTimersByTimeAsync(1000); + // After restart the image is still present: baseline again, no hint. + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toBeNull(); - expect(footer.getTransientHint()).not.toBeNull(); + // Clipboard cleared: re-arms the controller. + vi.mocked(clipboardHasImage).mockResolvedValue(false); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toBeNull(); + + // A genuinely new image: hint shows. + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); controller.stop(); }); it('observes focus events even when another listener consumes them', async () => { - vi.mocked(clipboardHasImage).mockResolvedValue(true); - const footer = createFakeFooter(); const ui = createFakeTUIWithConsumingFocusTracker(); const host: ClipboardImageHintHost = { @@ -516,11 +629,17 @@ describe('ClipboardImageHintController', () => { return undefined; }); + // Baseline observation (consumed), then a new image on the next focus. + vi.mocked(clipboardHasImage).mockResolvedValue(false); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + + vi.mocked(clipboardHasImage).mockResolvedValue(true); ui.emitInput(TERMINAL_FOCUS_OUT); ui.emitInput(TERMINAL_FOCUS_IN); await vi.advanceTimersByTimeAsync(1000); - expect(consumedEvents).toEqual([TERMINAL_FOCUS_OUT, TERMINAL_FOCUS_IN]); + expect(consumedEvents).toEqual([TERMINAL_FOCUS_IN, TERMINAL_FOCUS_OUT, TERMINAL_FOCUS_IN]); expect(footer.getTransientHint()).toMatch(/Image in clipboard/); controller.stop(); @@ -529,7 +648,6 @@ describe('ClipboardImageHintController', () => { it('shows Alt+V shortcut on Windows', async () => { platformSpy?.mockRestore(); vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); - vi.mocked(clipboardHasImage).mockResolvedValue(true); const footer = createFakeFooter(); const ui = createFakeTUI(); @@ -543,8 +661,9 @@ describe('ClipboardImageHintController', () => { const controller = new ClipboardImageHintController(host); controller.start(); - ui.emitInput(TERMINAL_FOCUS_IN); - await vi.advanceTimersByTimeAsync(1000); + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); expect(footer.getTransientHint()).toMatch(/Alt\+V/); diff --git a/apps/kimi-code/test/utils/clipboard/clipboard-common.test.ts b/apps/kimi-code/test/utils/clipboard/clipboard-common.test.ts new file mode 100644 index 000000000..c6aba57a4 --- /dev/null +++ b/apps/kimi-code/test/utils/clipboard/clipboard-common.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { runCommandAsync } from '#/utils/clipboard/clipboard-common'; + +describe('runCommandAsync', () => { + it('resolves with stdout for a successful command', async () => { + const result = await runCommandAsync(process.execPath, ['-e', 'process.stdout.write("hello")']); + expect(result.ok).toBe(true); + expect(result.stdout.toString('utf-8')).toBe('hello'); + }); + + it('resolves ok:false for a non-zero exit', async () => { + const result = await runCommandAsync(process.execPath, ['-e', 'process.exit(3)']); + expect(result.ok).toBe(false); + }); + + it('does not block when the command exceeds the timeout', async () => { + const timeoutMs = 100; + const start = Date.now(); + // The child would idle for 30s if left running; runCommandAsync must kill + // it and resolve well before that so a wedged helper cannot freeze launch. + const result = await runCommandAsync(process.execPath, ['-e', 'setTimeout(() => {}, 30000)'], { + timeoutMs, + }); + const elapsed = Date.now() - start; + + expect(result.ok).toBe(false); + expect(elapsed).toBeLessThan(5000); + }); +}); diff --git a/apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts b/apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts index 5aeb0f137..16edebb2f 100644 --- a/apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts +++ b/apps/kimi-code/test/utils/clipboard/clipboard-has-image.test.ts @@ -25,7 +25,7 @@ describe('clipboardHasImage', () => { it('returns false on macOS when native clipboard reports no image', async () => { const clip = fakeClipboard({ hasImage: vi.fn(() => false) }); - const runCommand = vi.fn(() => ({ stdout: Buffer.alloc(0), ok: false })); + const runCommand = vi.fn(async () => ({ stdout: Buffer.alloc(0), ok: false })); const result = await clipboardHasImage({ platform: 'darwin', clipboard: clip, runCommand }); expect(result).toBe(false); expect(runCommand).not.toHaveBeenCalledWith('osascript', expect.anything(), expect.anything()); @@ -52,7 +52,7 @@ describe('clipboardHasImage', () => { }); it('detects image on Wayland via wl-paste list-types', async () => { - const runCommand = vi.fn((command: string, args: string[]) => { + const runCommand = vi.fn(async (command: string, args: string[]) => { if (command === 'wl-paste' && args[0] === '--list-types') { return { stdout: Buffer.from('text/plain\nimage/png\n'), ok: true }; } @@ -63,7 +63,7 @@ describe('clipboardHasImage', () => { }); it('returns false on Wayland when target list contains unsupported MIME types', async () => { - const runCommand = vi.fn((command: string, args: string[]) => { + const runCommand = vi.fn(async (command: string, args: string[]) => { if (command === 'wl-paste' && args[0] === '--list-types') { return { stdout: Buffer.from('text/plain\nimage/bmp\n'), ok: true }; } @@ -80,7 +80,7 @@ describe('clipboardHasImage', () => { }); it('falls back to xclip on Wayland when wl-paste reports no image', async () => { - const runCommand = vi.fn((command: string, args: string[]) => { + const runCommand = vi.fn(async (command: string, args: string[]) => { if (command === 'wl-paste' && args[0] === '--list-types') { return { stdout: Buffer.from('text/plain\n'), ok: true }; } @@ -99,7 +99,7 @@ describe('clipboardHasImage', () => { }); it('detects image on X11 via xclip TARGETS', async () => { - const runCommand = vi.fn((command: string, args: string[]) => { + const runCommand = vi.fn(async (command: string, args: string[]) => { if (command === 'xclip' && args.includes('TARGETS')) { return { stdout: Buffer.from('TARGETS\nimage/jpeg\n'), ok: true }; } @@ -110,7 +110,7 @@ describe('clipboardHasImage', () => { }); it('returns false on X11 when target list contains unsupported MIME types', async () => { - const runCommand = vi.fn((command: string, args: string[]) => { + const runCommand = vi.fn(async (command: string, args: string[]) => { if (command === 'xclip' && args.includes('TARGETS')) { return { stdout: Buffer.from('TARGETS\nimage/tiff\n'), ok: true }; } @@ -122,7 +122,7 @@ describe('clipboardHasImage', () => { }); it('returns false on X11 when target list is empty', async () => { - const runCommand = vi.fn((command: string, args: string[]) => { + const runCommand = vi.fn(async (command: string, args: string[]) => { if (command === 'xclip' && args.includes('TARGETS')) { return { stdout: Buffer.from('TARGETS\n'), ok: true }; } @@ -135,20 +135,20 @@ describe('clipboardHasImage', () => { it('falls back to native hasImage on Linux X11 when xclip fails', async () => { const clip = fakeClipboard({ hasImage: vi.fn(() => true) }); - const runCommand = vi.fn(() => ({ stdout: Buffer.alloc(0), ok: false })); + const runCommand = vi.fn(async () => ({ stdout: Buffer.alloc(0), ok: false })); const result = await clipboardHasImage({ platform: 'linux', env: {}, clipboard: clip, runCommand }); expect(result).toBe(true); }); it('returns false on Linux X11 when xclip and native both fail', async () => { const clip = fakeClipboard({ hasImage: vi.fn(() => false) }); - const runCommand = vi.fn(() => ({ stdout: Buffer.alloc(0), ok: false })); + const runCommand = vi.fn(async () => ({ stdout: Buffer.alloc(0), ok: false })); const result = await clipboardHasImage({ platform: 'linux', env: {}, clipboard: clip, runCommand }); expect(result).toBe(false); }); it('detects WSL via WSL_DISTRO_NAME and checks PowerShell', async () => { - const runCommand = vi.fn((command: string) => { + const runCommand = vi.fn(async (command: string) => { if (command === 'powershell.exe') { return { stdout: Buffer.from('True\n'), ok: true }; } @@ -163,7 +163,7 @@ describe('clipboardHasImage', () => { }); it('detects WSL via WSLENV and checks PowerShell', async () => { - const runCommand = vi.fn((command: string) => { + const runCommand = vi.fn(async (command: string) => { if (command === 'powershell.exe') { return { stdout: Buffer.from('True\n'), ok: true }; } @@ -178,7 +178,7 @@ describe('clipboardHasImage', () => { }); it('returns false on Linux when runCommand fails for all fallbacks', async () => { - const runCommand = vi.fn(() => ({ stdout: Buffer.alloc(0), ok: false })); + const runCommand = vi.fn(async () => ({ stdout: Buffer.alloc(0), ok: false })); const clip = fakeClipboard({ hasImage: vi.fn(() => false) }); const result = await clipboardHasImage({ platform: 'linux', env: {}, runCommand, clipboard: clip }); expect(result).toBe(false); @@ -186,7 +186,7 @@ describe('clipboardHasImage', () => { it('detects image on Windows via native clipboard', async () => { const clip = fakeClipboard({ hasImage: vi.fn(() => true) }); - const runCommand = vi.fn(() => ({ stdout: Buffer.alloc(0), ok: false })); + const runCommand = vi.fn(async () => ({ stdout: Buffer.alloc(0), ok: false })); const result = await clipboardHasImage({ platform: 'win32', clipboard: clip, runCommand }); expect(result).toBe(true); expect(runCommand).not.toHaveBeenCalledWith('powershell.exe', expect.anything(), expect.anything()); @@ -194,7 +194,7 @@ describe('clipboardHasImage', () => { it('returns false on Windows when native clipboard reports no image', async () => { const clip = fakeClipboard({ hasImage: vi.fn(() => false) }); - const runCommand = vi.fn((command: string) => { + const runCommand = vi.fn(async (command: string) => { if (command === 'powershell.exe') { return { stdout: Buffer.from('True\n'), ok: true }; } From 3554f7e7d6e472413aa7a9873d7a2eef5f2b819c Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 24 Jun 2026 21:58:13 +0800 Subject: [PATCH 11/93] feat(plugins): source Superpowers from GitHub and show update badges (#1066) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plugins): source Superpowers from GitHub and show update badges Source the Superpowers plugin from its GitHub release (v6.0.3) instead of a vendored copy, and drop the explicit version field. Derive marketplace entry versions from GitHub source URLs when the version field is omitted, keeping the source URL the single source of truth. Show update badges for installed plugins on the /plugins Installed tab. * docs(plugins): document Installed tab update badges * fix(plugins): stamp GitHub source version in CDN catalog Older CLIs only read the explicit marketplace version and cannot derive it from a GitHub source URL. When publishing the CDN catalog, stamp the version derived from a pinned GitHub source so those clients still surface update badges. The source plugins/marketplace.json keeps no explicit version; the version is derived at build time instead. * feat(plugins): resolve latest version for bare GitHub sources at runtime Point the Superpowers marketplace entry at the bare GitHub repo URL so it tracks the latest release instead of a pinned tag. When a marketplace entry omits version and its source is a bare GitHub repo URL, resolve the latest release tag at load time (via the /releases/latest redirect) to fill the version for update detection. Revert the build-time version stamping; it is no longer needed. Older CLIs that only read the explicit catalog version will no longer see update badges for Superpowers, since the catalog no longer carries one. * feat(plugins): make Enter update and add I for details on Installed tab On the Installed tab, Enter now installs the available update when one is present, and falls back to opening plugin details otherwise. Add the I key to always open plugin details, so details remain reachable when Enter is occupied by an update. Update the installed hint, docs and changeset accordingly. * feat(plugins): show installing state inside the plugins panel Move the "Installing … from marketplace" notice from a transient status message into the plugins panel itself, so the user sees progress in the interactive card while an install or update is in flight. * feat(plugins): highlight reload hint and add dev:cli:marketplace Highlight "Run /new or /reload to apply plugin changes." in warning color after plugin install and remove, and make the two notices symmetric. Add a root dev:cli:marketplace script that points the dev CLI at the production marketplace instead of the local dev server. * fix(plugins): dedupe install success notice Drop the redundant showNotice on marketplace installs so the success message is shown only once, symmetric with remove. * fix(plugins): reset installing state on install failure When a marketplace or Custom-tab install rejects, clear the installing state and return to the list so the user can retry, instead of leaving the panel stuck on the one-way "Installing…" view. --- .changeset/installed-plugin-update-badges.md | 5 + .gitignore | 1 - .oxlintrc.json | 1 - apps/kimi-code/src/tui/commands/plugins.ts | 68 +++++--- .../components/dialogs/plugins-selector.ts | 65 ++++++- .../kimi-code/src/utils/plugin-marketplace.ts | 127 +++++++++++++- .../dialogs/plugins-selector.test.ts | 87 +++++++++ .../test/tui/kimi-tui-message-flow.test.ts | 48 ++++- .../test/utils/plugin-marketplace.test.ts | 165 +++++++++++++++++- docs/en/customization/plugins.md | 5 +- docs/zh/customization/plugins.md | 5 +- package.json | 1 + plugins/marketplace.json | 3 +- 13 files changed, 534 insertions(+), 47 deletions(-) create mode 100644 .changeset/installed-plugin-update-badges.md diff --git a/.changeset/installed-plugin-update-badges.md b/.changeset/installed-plugin-update-badges.md new file mode 100644 index 000000000..2d9c77508 --- /dev/null +++ b/.changeset/installed-plugin-update-badges.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Show update badges on the /plugins Installed tab, where Enter now installs the available update and I opens plugin details. diff --git a/.gitignore b/.gitignore index 8d331c7ca..ae25ce57b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,6 @@ coverage/ .conductor .kimi-stash-dir plugins/cdn/ -superpowers .worktrees/ .kimi-code/local.toml diff --git a/.oxlintrc.json b/.oxlintrc.json index 77a86ac9a..877553f49 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -150,7 +150,6 @@ "node_modules/", "apps/*/scripts/", "docs/smoke-archive/", - "plugins/curated/superpowers/", "*.generated.ts" ] } diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index f7fcaa0b8..b220b4817 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -167,22 +167,28 @@ async function showPluginsPicker( // Each branch of the handler either mounts the next view or restores the // editor itself, so do not pre-restore here — that would flash the editor // for in-place actions like toggling a plugin. - void handlePluginsPanelSelection(host, selection).catch((error: unknown) => { + void handlePluginsPanelSelection(host, panel, selection).catch((error: unknown) => { host.showError(`/plugins failed: ${formatErrorMessage(error)}`); }); }, onCancel: () => { host.restoreEditor(); }, - // The Official/Third-party tabs fetch their catalog lazily so /plugins - // opens instantly and Installed/Custom keep working even when the - // marketplace is unreachable. + // Every tab except Custom needs the catalog: Official/Third-party list it, + // and Installed uses it to show update badges. The Installed/Custom tabs + // keep working even when the marketplace is unreachable (badges simply stay + // hidden until data arrives). onRequestMarketplace: () => { void loadMarketplaceCatalog(host, panel, options?.marketplaceSource); }, }); host.mountEditorReplacement(panel); - if (options?.initialTab === 'official' || options?.initialTab === 'third-party') { + // Kick off the catalog fetch for any tab that needs it: Installed uses it for + // update badges, Official/Third-party list it. Custom never reads the catalog, + // so skip the fetch there. Done here (after `panel` is initialized) rather + // than inside the component constructor, because the callback above closes + // over `panel`. + if (options?.initialTab !== 'custom') { panel.setMarketplaceLoading(); void loadMarketplaceCatalog(host, panel, options?.marketplaceSource); } @@ -287,6 +293,7 @@ async function applyPluginEnabled( async function handlePluginsPanelSelection( host: SlashCommandHost, + panel: PluginsPanelComponent, selection: PluginsPanelSelection, ): Promise { switch (selection.kind) { @@ -320,18 +327,35 @@ async function handlePluginsPanelSelection( await showPluginsPicker(host, { initialTab: 'installed' }); return; case 'install': { - host.showStatus(`Installing or updating ${selection.entry.displayName} from marketplace...`); - await installPluginFromSource(host, selection.entry.source, { successNotice: 'marketplace' }); + panel.setInstalling(selection.entry.displayName); + host.state.ui.requestRender(); + try { + await installPluginFromSource(host, selection.entry.source); + } catch (error) { + panel.clearInstalling(); + host.state.ui.requestRender(); + host.showError(`Failed to install ${selection.entry.displayName}: ${formatErrorMessage(error)}`); + return; + } // Close the panel after installing so the success notice and the // "/reload or /new" / post-install tip are visible in the transcript. host.restoreEditor(); return; } - case 'install-source': - host.showStatus(`Installing plugin from ${truncateForStatus(selection.source)}…`); - await installPluginFromSource(host, selection.source, { successNotice: 'marketplace' }); + case 'install-source': { + panel.setInstalling(truncateForStatus(selection.source)); + host.state.ui.requestRender(); + try { + await installPluginFromSource(host, selection.source); + } catch (error) { + panel.clearInstalling(); + host.state.ui.requestRender(); + host.showError(`Failed to install from ${truncateForStatus(selection.source)}: ${formatErrorMessage(error)}`); + return; + } host.restoreEditor(); return; + } } } @@ -362,7 +386,8 @@ async function handlePluginMcpSelection( async function removePlugin(host: SlashCommandHost, id: string): Promise { await host.requireSession().removePlugin(id); - host.showStatus(`Removed ${id}. Run /reload or /new to apply plugin changes.`); + host.showStatus(`Removed ${id}.`); + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); } async function renderPluginsList( @@ -394,25 +419,21 @@ async function renderPluginInfo(host: SlashCommandHost, id: string): Promise { const session = host.requireSession(); const beforeList = await session.listPlugins(); const summary = await session.installPlugin( resolvePluginInstallSource(source, host.state.appState.workDir), ); - showPluginInstallResult(host, beforeList, summary, options); + showPluginInstallResult(host, beforeList, summary); } +const PLUGIN_RELOAD_HINT = 'Run /new or /reload to apply plugin changes.'; + function showPluginInstallResult( host: SlashCommandHost, beforeList: readonly PluginSummary[], summary: PluginSummary, - options?: { - readonly successNotice?: 'marketplace'; - }, ): void { const previous = beforeList.find((entry) => entry.id === summary.id); const serverWord = summary.mcpServerCount === 1 ? 'server' : 'servers'; @@ -421,15 +442,8 @@ function showPluginInstallResult( ? ` Declares ${summary.mcpServerCount} MCP ${serverWord}; enabled by default and configurable from /plugins.` : ''; const action = describeInstallAction(previous, summary); - host.showStatus( - `${action} (${summary.id}).${mcpHint} Run /new or /reload to apply plugin changes.`, - ); - if (options?.successNotice === 'marketplace') { - host.showNotice( - `Installed or updated ${summary.displayName}`, - `Marketplace install or update succeeded for ${summary.id}. Run /new or /reload to apply plugin changes.`, - ); - } + host.showStatus(`${action} (${summary.id}).${mcpHint}`); + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); } function describeInstallAction( diff --git a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts index 4a51e4979..fbab65d1b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -289,6 +289,7 @@ export class PluginsPanelComponent extends Container implements Focusable { private activeTabIndex: number; private selectedIndex = 0; private market: MarketState = { status: 'idle' }; + private installing: string | undefined; constructor(opts: PluginsPanelOptions) { super(); @@ -323,6 +324,16 @@ export class PluginsPanelComponent extends Container implements Focusable { this.market = { status: 'error', message }; } + setInstalling(label: string): void { + this.installing = label; + this.invalidate(); + } + + clearInstalling(): void { + this.installing = undefined; + this.invalidate(); + } + private get activeTab(): (typeof PLUGINS_PANEL_TABS)[number] { return PLUGINS_PANEL_TABS[this.activeTabIndex]!; } @@ -351,7 +362,9 @@ export class PluginsPanelComponent extends Container implements Focusable { } private requestMarketplaceIfNeeded(): void { - if (this.market.status === 'idle' && this.activeTab.id !== 'installed' && this.activeTab.id !== 'custom') { + // The Installed tab also needs the catalog to render update badges; only the + // Custom tab (manual URL entry) can skip the fetch entirely. + if (this.market.status === 'idle' && this.activeTab.id !== 'custom') { this.market = { status: 'loading' }; this.opts.onRequestMarketplace?.(); } @@ -423,6 +436,16 @@ export class PluginsPanelComponent extends Container implements Focusable { return; } if (matchesKey(data, Key.enter)) { + if (plugin === undefined) return; + const update = this.installedUpdateStatus(plugin); + if (update !== undefined) { + this.opts.onSelect({ kind: 'install', entry: update.entry }); + } else { + this.opts.onSelect({ kind: 'details', id: plugin.id }); + } + return; + } + if (ch === 'i' || ch === 'I') { if (plugin !== undefined) this.opts.onSelect({ kind: 'details', id: plugin.id }); } } @@ -452,11 +475,14 @@ export class PluginsPanelComponent extends Container implements Focusable { } override render(width: number): string[] { + if (this.installing !== undefined) { + return this.renderInstalling(width); + } const colors = currentTheme.palette; const tab = this.activeTab.id; const hint = tab === 'installed' - ? ' Tab switch · Space toggle · D remove · M MCP · Enter details · R reload · Esc cancel' + ? this.installedHint() : tab === 'custom' ? ' Tab switch · Enter install · Esc cancel' : ' Tab switch · ↑↓ navigate · Enter open/install · Esc cancel'; @@ -497,6 +523,23 @@ export class PluginsPanelComponent extends Container implements Focusable { lines.push(mutedHintLine(` ${installed.length} installed`, colors)); } + private installedHint(): string { + const plugin = this.opts.installed[this.selectedIndex]; + const hasUpdate = plugin !== undefined && this.installedUpdateStatus(plugin) !== undefined; + const enter = hasUpdate ? 'Enter update' : 'Enter details'; + return ` Tab switch · Space toggle · D remove · M MCP · ${enter} · I details · R reload · Esc cancel`; + } + + private installedUpdateStatus( + plugin: PluginSummary, + ): { entry: PluginMarketplaceEntry; local: string; latest: string } | undefined { + if (this.market.status !== 'loaded') return undefined; + const entry = this.market.entries.find((e) => e.id === plugin.id); + if (entry === undefined) return undefined; + const status = computeUpdateStatus(entry.version, plugin.version, true); + return status.kind === 'update' ? { entry, local: status.local, latest: status.latest } : undefined; + } + private renderInstalledRow(plugin: PluginSummary, index: number, width: number): string[] { const colors = currentTheme.palette; const selected = index === this.selectedIndex; @@ -504,10 +547,15 @@ export class PluginsPanelComponent extends Container implements Focusable { const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); const status = pluginStatus(plugin); + const update = this.installedUpdateStatus(plugin); let line = prefix + labelStyle(plugin.displayName); if (status !== undefined) { line += ' ' + statusStyle({ kind: 'plugin', value: '', label: '', description: '', status }, colors)(status); } + if (update !== undefined) { + const badge = `update ${update.local} → ${update.latest}`; + line += ' ' + marketplaceStatusStyle(badge, colors)(badge); + } if (this.opts.pluginHint?.id === plugin.id) { line += ' ' + chalk.hex(colors.warning)(this.opts.pluginHint.text); } @@ -580,6 +628,19 @@ export class PluginsPanelComponent extends Container implements Focusable { lines.push(''); lines.push(...renderUrlInputBox(this.customInput, this.focused, width, colors)); } + + private renderInstalling(width: number): string[] { + const colors = currentTheme.palette; + const lines = [ + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(' Plugins'), + '', + chalk.hex(colors.textMuted)(` Installing ${this.installing} from marketplace…`), + '', + chalk.hex(colors.primary)('─'.repeat(width)), + ]; + return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); + } } function buildMcpItems(info: PluginInfo): PluginsOverviewItem[] { diff --git a/apps/kimi-code/src/utils/plugin-marketplace.ts b/apps/kimi-code/src/utils/plugin-marketplace.ts index e1c862899..be55e798e 100644 --- a/apps/kimi-code/src/utils/plugin-marketplace.ts +++ b/apps/kimi-code/src/utils/plugin-marketplace.ts @@ -81,17 +81,32 @@ export async function loadPluginMarketplace( configuredSource ?? KIMI_CODE_PLUGIN_MARKETPLACE_URL, options.workDir, ); + const fetchImpl = options.fetchImpl ?? fetch; let raw: string; try { - raw = await readMarketplaceText(location, options.fetchImpl ?? fetch); + raw = await readMarketplaceText(location, fetchImpl); } catch (error) { const fallback = configuredSource === undefined ? await getSourceCheckoutMarketplaceLocation() : undefined; if (fallback === undefined) throw error; - raw = await readMarketplaceText(fallback, options.fetchImpl ?? fetch); - return parsePluginMarketplace(raw, fallback); + raw = await readMarketplaceText(fallback, fetchImpl); + return withLatestVersions(parsePluginMarketplace(raw, fallback), fetchImpl); } - return parsePluginMarketplace(raw, location); + return withLatestVersions(parsePluginMarketplace(raw, location), fetchImpl); +} + +async function withLatestVersions( + marketplace: PluginMarketplace, + fetchImpl: typeof fetch, +): Promise { + const plugins = await Promise.all( + marketplace.plugins.map(async (entry) => { + if (entry.version !== undefined) return entry; + const latest = await resolveLatestGithubRelease(entry.source, fetchImpl); + return latest === undefined ? entry : { ...entry, version: latest }; + }), + ); + return { ...marketplace, plugins }; } export function parsePluginMarketplace(raw: string, location: MarketplaceLocation): PluginMarketplace { @@ -172,12 +187,13 @@ function parseMarketplaceEntry( if (source === undefined) { throw new Error(`Plugin marketplace entry ${id} must define "source".`); } + const resolvedSource = resolveEntrySource(source, location); return { id, displayName: stringField(value, 'displayName') ?? stringField(value, 'name') ?? id, - source: resolveEntrySource(source, location), + source: resolvedSource, tier: parseMarketplaceTier(value, id), - version: stringField(value, 'version'), + version: stringField(value, 'version') ?? deriveVersionFromGithubSource(resolvedSource), description: stringField(value, 'description') ?? stringField(value, 'shortDescription'), homepage: stringField(value, 'homepage') ?? stringField(value, 'websiteURL'), keywords: stringArrayField(value, 'keywords'), @@ -234,6 +250,105 @@ function resolveEntrySource(source: string, location: MarketplaceLocation): stri return resolve(dirname(location.resolved), trimmed); } +/** + * Best-effort derivation of a semver version from a GitHub source URL that pins + * a specific ref. Lets a marketplace entry omit `version` when the source + * already encodes the release (for example `/releases/tag/v6.0.3`), keeping the + * source URL the single source of truth and avoiding drift between the two. + * + * Only refs shaped like semver (`v6.0.3`, `6.0.3`, `6.0.3-rc.1`) are accepted; + * bare repo URLs, branch names and commit SHAs yield `undefined`, so update + * detection degrades to "unknown" instead of comparing meaningless values. + */ +function deriveVersionFromGithubSource(source: string): string | undefined { + let url: URL; + try { + url = new URL(source); + } catch { + return undefined; + } + if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') { + return undefined; + } + // Pathname shape: ///. Recognized tails: + // releases/tag/ + // tree/ + // commit/ + const [, , kind, a, b] = url.pathname.split('/').filter(Boolean); + const ref = + kind === 'releases' && a === 'tag' ? b : kind === 'tree' || kind === 'commit' ? a : undefined; + if (ref === undefined) return undefined; + let decoded: string; + try { + decoded = decodeURIComponent(ref); + } catch { + decoded = ref; + } + const candidate = decoded.replace(/^v/i, ''); + return valid(candidate) !== null ? candidate : undefined; +} + +async function resolveLatestGithubRelease( + source: string, + fetchImpl: typeof fetch, +): Promise { + const repo = parseGithubRepo(source); + if (repo === undefined) return undefined; + try { + const tag = await fetchLatestReleaseTag(repo.owner, repo.repo, fetchImpl); + if (tag === undefined) return undefined; + const candidate = tag.replace(/^v/i, ''); + return valid(candidate) !== null ? candidate : undefined; + } catch { + return undefined; + } +} + +function parseGithubRepo(source: string): { owner: string; repo: string } | undefined { + let url: URL; + try { + url = new URL(source); + } catch { + return undefined; + } + if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') return undefined; + // Only bare repo URLs (//) qualify — URLs with a ref tail are + // already handled by deriveVersionFromGithubSource. + const segments = url.pathname.split('/').filter(Boolean); + if (segments.length !== 2) return undefined; + const [owner, repo] = segments; + return { owner: owner!, repo: repo! }; +} + +async function fetchLatestReleaseTag( + owner: string, + repo: string, + fetchImpl: typeof fetch, +): Promise { + // Avoid api.github.com: its anonymous quota is shared with the user's browser + // and other tools, and a first-time lookup failing because something else + // burned the budget is unacceptable. The /releases/latest UI route 302s to + // the tag and is not part of the API quota. + const url = `https://github.com/${owner}/${repo}/releases/latest`; + const resp = await fetchImpl(url, { redirect: 'manual' }); + if (resp.status === 404) return undefined; + if (resp.status !== 301 && resp.status !== 302) { + throw new Error( + `Could not look up latest release of ${owner}/${repo}: HTTP ${resp.status} (${url}).`, + ); + } + const location = resp.headers.get('location'); + if (location === null) return undefined; + const match = /\/releases\/tag\/([^/?#]+)/.exec(location); + const tag = match?.[1]; + if (tag === undefined) return undefined; + try { + return decodeURIComponent(tag); + } catch { + return tag; + } +} + function resolveLocalPath(input: string, workDir: string): string { if (input === '~') return homedir(); if (input.startsWith('~/')) return join(homedir(), input.slice(2)); diff --git a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts index c652c28a1..694e8a227 100644 --- a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts @@ -180,6 +180,60 @@ describe('plugins selector dialogs', () => { expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'superpowers' }); }); + it('Enter on an installed plugin with an available update installs it', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel, onSelect } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ + kind: 'install', + entry: expect.objectContaining({ id: 'superpowers' }), + }); + }); + + it('Enter on an up-to-date installed plugin opens details', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '5.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel, onSelect } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'superpowers' }); + }); + + it('I on an installed plugin opens details even when an update is available', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel, onSelect } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + panel.handleInput('i'); + expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'superpowers' }); + }); + it('renders the inline plugin hint on the installed row', () => { const datasource = { ...superpowers, id: 'kimi-datasource', displayName: 'Kimi Datasource', skillCount: 1 }; const { panel } = makePanel({ @@ -213,6 +267,13 @@ describe('plugins selector dialogs', () => { }); }); + it('renders an installing state while an install is in progress', () => { + const { panel } = makePanel({ installed: [superpowers] }); + panel.setInstalling('Superpowers'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Installing Superpowers from marketplace'); + }); + it('keeps a valid selection if ↓ is pressed while the catalog is loading', () => { const { panel, onSelect } = makePanel({ initialTab: 'third-party' }); // Catalog still loading (entries empty); pressing ↓ must not drive the @@ -253,6 +314,32 @@ describe('plugins selector dialogs', () => { expect(out).toContain('Superpowers update 4.0.0 → 5.0.0'); }); + it('shows an update badge on the Installed tab when the marketplace version is newer', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Superpowers enabled update 4.0.0 → 5.0.0'); + }); + + it('does not show an update badge on the Installed tab before the marketplace loads', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const { panel } = makePanel({ installed }); + // The marketplace has not been loaded yet, so the badge stays hidden rather + // than guessing. + const out = strip(renderRaw(panel)); + expect(out).not.toContain('update'); + }); + it('shows installed · v when the installed plugin is up to date', () => { const installed = [{ ...superpowers, id: 'superpowers', version: '5.0.0' }]; const entries = [ diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 621f11653..6c3dc484e 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -3142,8 +3142,8 @@ command = "vim" }); await vi.waitFor(() => { const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Installing or updating Kimi Datasource from marketplace...'); - expect(transcript).toContain('Installed or updated Demo'); + expect(transcript).toContain('Installed Demo'); + expect(transcript).toContain('Run /new or /reload to apply plugin changes.'); }); // Installing closes the panel so the success notice / reload tip is visible. await vi.waitFor(() => { @@ -3151,6 +3151,50 @@ command = "vim" }); }); + it('returns to the plugin list when a marketplace install fails', async () => { + const marketplaceDir = await makeTempHome(); + const marketplacePath = join(marketplaceDir, 'marketplace.json'); + await writeFile( + marketplacePath, + JSON.stringify({ + plugins: [ + { + id: 'kimi-datasource', + tier: 'official', + displayName: 'Kimi Datasource', + source: './kimi-datasource', + }, + ], + }), + 'utf8', + ); + process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL'] = marketplacePath; + const installPlugin = vi.fn(async () => { + throw new Error('install failed'); + }); + const session = makeSession({ installPlugin }); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/plugins marketplace'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); + }); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Kimi Datasource'); + }); + panel.handleInput('\r'); + + // The panel must not get stuck on the one-way "Installing…" view; it should + // return to the list so the user can retry. + await vi.waitFor(() => { + const rendered = stripSgr(panel.render(120).join('\n')); + expect(rendered).toContain('Kimi Datasource'); + expect(rendered).not.toContain('Installing'); + }); + }); + it('removes a plugin record without auto-running any cleanup skill', async () => { const session = makeSession(); const { driver } = await makeDriver(session); diff --git a/apps/kimi-code/test/utils/plugin-marketplace.test.ts b/apps/kimi-code/test/utils/plugin-marketplace.test.ts index d3577bc12..9c220b868 100644 --- a/apps/kimi-code/test/utils/plugin-marketplace.test.ts +++ b/apps/kimi-code/test/utils/plugin-marketplace.test.ts @@ -125,9 +125,22 @@ describe('loadPluginMarketplace', () => { }); it('includes Superpowers in the repository marketplace fixture', async () => { + const fetchImpl = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.endsWith('/releases/latest')) { + return { + status: 302, + headers: new Headers({ + location: 'https://github.com/obra/superpowers/releases/tag/v6.0.3', + }), + } as Response; + } + return { status: 404, headers: new Headers() } as Response; + }) as unknown as typeof fetch; const marketplace = await loadPluginMarketplace({ workDir: REPO_ROOT, source: join(REPO_ROOT, 'plugins/marketplace.json'), + fetchImpl, }); expect(marketplace.plugins).toContainEqual( @@ -135,7 +148,8 @@ describe('loadPluginMarketplace', () => { id: 'superpowers', displayName: 'Superpowers', tier: 'curated', - source: join(REPO_ROOT, 'plugins/curated/superpowers'), + source: 'https://github.com/obra/superpowers', + version: '6.0.3', }), ); expect(marketplace.plugins).toContainEqual( @@ -197,7 +211,7 @@ describe('loadPluginMarketplace', () => { expect(marketplace.plugins).toContainEqual( expect.objectContaining({ id: 'superpowers', - source: join(REPO_ROOT, 'plugins/curated/superpowers'), + source: 'https://github.com/obra/superpowers', }), ); } finally { @@ -221,6 +235,153 @@ describe('loadPluginMarketplace', () => { })).rejects.toThrow(/fetch failed/); }); + describe('version derivation from a GitHub source', () => { + async function loadEntry(source: string, version?: string) { + const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ + plugins: [ + { + id: 'demo', + displayName: 'Demo', + source, + version, + }, + ], + }), + 'utf8', + ); + const marketplace = await loadPluginMarketplace({ workDir: dir, source: file }); + return marketplace.plugins[0]!; + } + + it('derives a version from a /releases/tag/ source', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/releases/tag/v6.0.3'); + expect(entry.version).toBe('6.0.3'); + }); + + it('derives a version from a /tree/ source', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/tree/v6.0.3'); + expect(entry.version).toBe('6.0.3'); + }); + + it('accepts a tag without a leading v', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/releases/tag/6.0.3'); + expect(entry.version).toBe('6.0.3'); + }); + + it('does not derive a version from a commit SHA', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/commit/abc1234'); + expect(entry.version).toBeUndefined(); + }); + + it('does not derive a version from a non-GitHub URL', async () => { + const entry = await loadEntry('https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip'); + expect(entry.version).toBeUndefined(); + }); + + it('lets an explicit version override the derived one', async () => { + const entry = await loadEntry( + 'https://github.com/obra/superpowers/releases/tag/v6.0.3', + '9.9.9', + ); + expect(entry.version).toBe('9.9.9'); + }); + }); + + describe('latest release resolution for bare GitHub sources', () => { + async function loadWithLatest(source: string, fetchImpl: typeof fetch) { + const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ plugins: [{ id: 'demo', displayName: 'Demo', source }] }), + 'utf8', + ); + const marketplace = await loadPluginMarketplace({ workDir: dir, source: file, fetchImpl }); + return marketplace.plugins[0]!; + } + + function redirectFetch(location: string): typeof fetch { + return vi.fn(async () => ({ + status: 302, + headers: new Headers({ location }), + })) as unknown as typeof fetch; + } + + it('fills the version from /releases/latest for a bare repo URL', async () => { + const entry = await loadWithLatest( + 'https://github.com/owner/repo', + redirectFetch('https://github.com/owner/repo/releases/tag/v6.0.3'), + ); + expect(entry.version).toBe('6.0.3'); + }); + + it('strips a leading v from the resolved latest tag', async () => { + const entry = await loadWithLatest( + 'https://github.com/owner/repo', + redirectFetch('https://github.com/owner/repo/releases/tag/6.0.3'), + ); + expect(entry.version).toBe('6.0.3'); + }); + + it('leaves version undefined when the repo has no release', async () => { + const fetchImpl = vi.fn(async () => ({ + status: 404, + headers: new Headers(), + })) as unknown as typeof fetch; + const entry = await loadWithLatest('https://github.com/owner/repo', fetchImpl); + expect(entry.version).toBeUndefined(); + }); + + it('degrades gracefully when the latest lookup throws', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('network down'); + }) as unknown as typeof fetch; + const entry = await loadWithLatest('https://github.com/owner/repo', fetchImpl); + expect(entry.version).toBeUndefined(); + }); + + it('does not query latest when the source already pins a ref', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('should not be called'); + }) as unknown as typeof fetch; + const entry = await loadWithLatest( + 'https://github.com/owner/repo/releases/tag/v6.0.3', + fetchImpl, + ); + expect(entry.version).toBe('6.0.3'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('keeps an explicit version without querying latest', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('should not be called'); + }) as unknown as typeof fetch; + const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ + plugins: [ + { + id: 'demo', + displayName: 'Demo', + version: '9.9.9', + source: 'https://github.com/owner/repo', + }, + ], + }), + 'utf8', + ); + const marketplace = await loadPluginMarketplace({ workDir: dir, source: file, fetchImpl }); + expect(marketplace.plugins[0]?.version).toBe('9.9.9'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + }); + it('accepts legacy marketplace type aliases as normal plugins', async () => { const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); const file = join(dir, 'marketplace.json'); diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index 3be6cb441..09476f607 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -15,7 +15,8 @@ Run `/plugins` in the TUI to open the plugin manager. It is a single panel with | `D` | Remove the selected installed plugin (Installed tab) | | `M` | Manage MCP servers for the selected plugin (Installed tab) | | `R` | Reload `installed.json` and all manifests (Installed tab) | -| `Enter` | Installed tab: view plugin details · Official/Third-party tab: install or update · Custom tab: install | +| `Enter` | Installed tab: install the available update, or view details if up to date · Official/Third-party tab: install or update · Custom tab: install | +| `I` | View plugin details (Installed tab) | | `Esc` | Go back or cancel | You can also use slash commands directly: @@ -34,7 +35,7 @@ You can also use slash commands directly: | `/plugins mcp enable ` | Enable an MCP server declared by a plugin | | `/plugins mcp disable ` | Disable an MCP server declared by a plugin | -The **Official** and **Third-party** tabs list marketplace plugins by tier; the **Custom** tab installs from a URL. Marketplace catalogs load when you switch to those tabs. Each install shows a trust badge: `kimi-official` (from an official address), `curated` (from a curated address), or `third-party` (everything else). +The **Installed** tab lists your installed plugins and shows an update badge when a newer version is available in the marketplace. The **Official** and **Third-party** tabs list marketplace plugins by tier; the **Custom** tab installs from a URL. Marketplace catalogs load automatically when needed. Each install shows a trust badge: `kimi-official` (from an official address), `curated` (from a curated address), or `third-party` (everything else). ### Installing from GitHub diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index 092d17f35..244685489 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -15,7 +15,8 @@ Kimi Code CLI 对 plugin 采用保守的加载策略:安装 plugin 时不会 | `D` | 移除选中的已安装 plugin(Installed tab) | | `M` | 管理选中 plugin 的 MCP servers(Installed tab) | | `R` | 重新加载 `installed.json` 和所有 manifest(Installed tab) | -| `Enter` | Installed tab:查看 plugin 详情 · Official/Third-party tab:安装或更新 · Custom tab:安装 | +| `Enter` | Installed tab:有更新时安装更新,否则查看 plugin 详情 · Official/Third-party tab:安装或更新 · Custom tab:安装 | +| `I` | 查看 plugin 详情(Installed tab) | | `Esc` | 返回或取消 | 也可以直接使用斜杠命令: @@ -34,7 +35,7 @@ Kimi Code CLI 对 plugin 采用保守的加载策略:安装 plugin 时不会 | `/plugins mcp enable ` | 启用 plugin 声明的 MCP server | | `/plugins mcp disable ` | 禁用 plugin 声明的 MCP server | -**Official** 和 **Third-party** tab 按 tier 列出 marketplace plugin;**Custom** tab 从 URL 安装。切到对应 tab 时才会加载 marketplace 目录。每个安装会显示信任徽章:`kimi-official`(来自官方地址)、`curated`(来自精选地址)、`third-party`(其他所有情况)。 +**Installed** tab 列出已安装的 plugin,并在 marketplace 有更新版本时显示更新徽章。**Official** 和 **Third-party** tab 按 tier 列出 marketplace plugin;**Custom** tab 从 URL 安装。marketplace 目录会在需要时自动加载。每个安装会显示信任徽章:`kimi-official`(来自官方地址)、`curated`(来自精选地址)、`third-party`(其他所有情况)。 ### 从 GitHub 安装 diff --git a/package.json b/package.json index d28b86060..825b6f67f 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build": "pnpm -r run build", "build:packages": "pnpm -r --filter './packages/*' run build", "dev:cli": "pnpm -C apps/kimi-code run dev", + "dev:cli:marketplace": "KIMI_CODE_DEV_MARKETPLACE_URL=https://code.kimi.com/kimi-code/plugins/marketplace.json pnpm -C apps/kimi-code run dev", "dev:web": "pnpm -C apps/kimi-web run dev", "dev:server": "pnpm -C apps/kimi-code run dev:server", "build:plugin-marketplace": "pnpm -C apps/kimi-code run build:plugin-marketplace", diff --git a/plugins/marketplace.json b/plugins/marketplace.json index 70544f9d4..1e514a33a 100644 --- a/plugins/marketplace.json +++ b/plugins/marketplace.json @@ -14,11 +14,10 @@ "id": "superpowers", "tier": "curated", "displayName": "Superpowers", - "version": "5.1.0", "description": "Planning, TDD, debugging, and delivery workflows for coding agents.", "homepage": "https://github.com/obra/superpowers", "keywords": ["skills", "planning", "tdd", "debugging", "code-review"], - "source": "./curated/superpowers" + "source": "https://github.com/obra/superpowers" } ] } From 884b65a04014be8d68ffd406f89fc2d26af6e62c Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 25 Jun 2026 11:49:05 +0800 Subject: [PATCH 12/93] fix(web): coalesce snapshot reloads on resync (#1087) Avoid concurrent session snapshot requests when resync_required fires repeatedly, while still allowing one queued rerun after the in-flight reload settles. --- .changeset/web-snapshot-sync-guard.md | 5 ++ .../src/composables/useKimiWebClient.ts | 5 +- apps/kimi-web/src/lib/snapshotSync.ts | 35 +++++++++++++ apps/kimi-web/test/lib-logic.test.ts | 49 +++++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 .changeset/web-snapshot-sync-guard.md create mode 100644 apps/kimi-web/src/lib/snapshotSync.ts diff --git a/.changeset/web-snapshot-sync-guard.md b/.changeset/web-snapshot-sync-guard.md new file mode 100644 index 000000000..16a6a0b3a --- /dev/null +++ b/.changeset/web-snapshot-sync-guard.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix duplicate session snapshot reloads in the bundled web UI during resync. diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 9f519dc65..11b6b6f6a 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -7,6 +7,7 @@ import { i18n } from '../i18n'; import { getKimiWebApi } from '../api'; import { isDaemonApiError, isDaemonNetworkError } from '../api/errors'; import { reconcileWorkspaceOrder, sortByWorkspaceOrder } from '../lib/workspaceOrder'; +import { createCoalescedAsyncRunner } from '../lib/snapshotSync'; import { loadUnread, loadWorkspaceOrder, @@ -747,7 +748,7 @@ function connectEventsIfNeeded(): void { // returns the authoritative {asOfSeq, epoch} and re-subscribes. if (epoch !== undefined) epochBySession[sessionId] = epoch; void currentSeq; - void syncSessionFromSnapshot(sessionId); + snapshotSyncRunner.request(sessionId); }, onError(_code: number, msg: string, _fatal: boolean) { @@ -1009,6 +1010,8 @@ async function syncSessionFromSnapshot(sessionId: string): Promise { + run(key: string): Promise; + request(key: string): void; +} + +export function createCoalescedAsyncRunner( + fn: (key: string) => Promise, +): CoalescedAsyncRunner { + const inFlight = new Map>(); + const queued = new Set(); + + function run(key: string): Promise { + const existing = inFlight.get(key); + if (existing !== undefined) return existing; + + const promise = (async () => fn(key))().finally(() => { + inFlight.delete(key); + if (queued.delete(key)) { + void run(key); + } + }); + inFlight.set(key, promise); + return promise; + } + + function request(key: string): void { + if (inFlight.has(key)) { + queued.add(key); + return; + } + void run(key); + } + + return { run, request }; +} diff --git a/apps/kimi-web/test/lib-logic.test.ts b/apps/kimi-web/test/lib-logic.test.ts index 02a2392b8..e182d164a 100644 --- a/apps/kimi-web/test/lib-logic.test.ts +++ b/apps/kimi-web/test/lib-logic.test.ts @@ -5,6 +5,7 @@ import { parseFilePathLinkCandidate, } from '../src/lib/filePathLinks'; import { parseDiff } from '../src/lib/parseDiff'; +import { createCoalescedAsyncRunner } from '../src/lib/snapshotSync'; import { normalizeToolName, toolSummary } from '../src/lib/toolMeta'; describe('parseDiff', () => { @@ -76,3 +77,51 @@ describe('toolMeta', () => { ).toBe('example.com/path'); }); }); + +describe('createCoalescedAsyncRunner', () => { + it('reuses the in-flight promise for the same key', async () => { + let runs = 0; + let resolveRun!: () => void; + const runner = createCoalescedAsyncRunner(async (_key: string) => { + runs += 1; + await new Promise((resolve) => { + resolveRun = resolve; + }); + return runs; + }); + + const first = runner.run('session-a'); + const second = runner.run('session-a'); + + expect(runs).toBe(1); + resolveRun(); + await expect(Promise.all([first, second])).resolves.toEqual([1, 1]); + expect(runs).toBe(1); + }); + + it('queues at most one rerun requested while a run is in flight', async () => { + let runs = 0; + const resolvers: Array<() => void> = []; + const runner = createCoalescedAsyncRunner(async (_key: string) => { + runs += 1; + await new Promise((resolve) => { + resolvers.push(resolve); + }); + return runs; + }); + + const first = runner.run('session-a'); + runner.request('session-a'); + runner.request('session-a'); + expect(runs).toBe(1); + + resolvers[0]!(); + await first; + await Promise.resolve(); + + expect(runs).toBe(2); + resolvers[1]!(); + await Promise.resolve(); + expect(runs).toBe(2); + }); +}); From ea03f30e5174825049ed4dfedebf8e43fbe751a4 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 25 Jun 2026 12:47:40 +0800 Subject: [PATCH 13/93] feat(web): render LaTeX math in chat via KaTeX (#1035) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): render LaTeX math in chat via KaTeX * fix(web): keep literal prose dollars out of KaTeX inline math Enabling KaTeX turned plain prose with two dollar-prefixed tokens (`Check $PATH before $HOME`, `costs $5 and $10`) into a single inline formula, since markstream's $…$ tokenizer has no "no whitespace inside the delimiters" rule. Add a postTransformTokens guard that turns a single-$ inline span back into literal text when its content starts or ends with whitespace. Real inline math is written tight (`$E=mc^2$`, `$\frac{1}{2}$`), while the prose false-positives always have whitespace inside the delimiters, so this keeps inline/block math working while leaving prices, env vars, and ranges as readable text. Code spans are already excluded by the tokenizer, and running on the flat token stream also covers dollars nested inside lists and blockquotes. Addresses the Codex review comment on PR #1035. * fix(web): reject compact currency ranges before rendering math The literal-dollar guard only caught prose whose content had whitespace inside the delimiters, so a compact range like `costs $5/$10` still rendered `5/` as a formula and dropped the second dollar. (markstream's own currency check rejects `-`/`~` ranges but not `/`.) Extend the guard to also reject a single-$ span whose content is a numeric amount with a trailing range connector (`/`, `-`, `~`, en/em dash) -- a complete formula never ends in a dangling operator. Scoped to digit-led content so symbolic math is left alone, and numeric math that is not a range (`$5/2$`, `$5-2$`, `$0.5$`) still renders. Added tests for the range cases and the non-range math. Addresses the follow-up Codex review comment on PR #1035. * fix(web): treat shell/path dollar pairs as literal text Adjacent shell variables and PATH-like values (`Use \$HOME/bin:\$PATH`, `\$PATH:\$HOME`) were still rendered as math, because the prose-dollar guard only looked at the span's own content (whitespace inside the delimiters, or a trailing numeric range connector) and never at what touches the delimiters from the outside. Replace the two bespoke heuristics with the two industry-standard rules, now driven by the surrounding text tokens: - Pandoc (tex_math_dollars): no whitespace immediately inside the delimiters. - GitHub: each \$ must be bounded on its outer side by whitespace, a line boundary, or structural punctuation. A letter or digit there means a second prose token, so the span is literal text. The GitHub outer-boundary rule subsumes the old numeric-range check (a closing \$ in \$5/\$10 is followed by a digit) and also catches shell/path cases Pandoc's inner rule misses. Normal math -- including bare \$x\$, \$x^2\$., and (\$x^2\$) -- still renders. Added tests for shell/path values and punctuation-wrapped math. Addresses the third Codex review comment on PR #1035. * fix(web): render math next to CJK punctuation and quotes The outer-boundary guard only accepted ASCII punctuation, so a formula followed by full-width punctuation or wrapped in typographic quotes was misclassified as prose: `公式为 \$E=mc^2\$,其中` and `“\$x\$”` showed raw dollars instead of rendering. Invert the boundary check from an allow-list of ASCII punctuation to a deny-list of ASCII letters/digits. A \$ glued to an ASCII letter/digit still means a second prose token (\$PATH:\$HOME, \$5/\$10), but whitespace, line boundaries, and every other character -- full-width punctuation, CJK ideographs, curly quotes -- is now a valid math boundary, which is the correct behavior for localized prose. Addresses the fourth Codex review comment on PR #1035. * fix(web): preserve later math after literal-dollar spans A prose dollar in front of a real formula in the same inline run (`costs $5 and formula $x$`, `Use \$HOME before $E=mc^2$`) exposed the core limit of the token-level guard: markstream's tokenizer greedily pairs the first literal \$ with the formula's opening \$ before any hook runs, so converting that span back to text could only blank it -- the later formula's opening \$ was already consumed and the formula rendered as raw text. Move the guard from postTransformTokens to a source-level preprocessor that runs before tokenization. escapeProseDollars protects code spans, fenced code blocks, and \$\$…\$\$ display math, then pairs single \$ delimiters using the Pandoc (tight delimiters) and GitHub-style outer-boundary rules: any \$ without a valid partner is escaped as \\\$, so the tokenizer leaves it literal while real formulas -- including ones that come after a prose dollar -- still parse as math. The component now preprocesses each markdown segment's text and the postTransformTokens hook is gone. Rewrote the tests around the string-in/string-out helper, including the prose-before-formula case, code spans, fenced code, and block math. Addresses the fifth Codex review comment on PR #1035. * fix(web): protect indented code blocks before escaping dollars The dollar-escaping preprocessor stashed fenced code blocks, inline code, and display math, but not 4-space / tab indented code blocks. So a snippet like ` echo \$HOME` had its dollar rewritten to `\\$HOME`, and because Markdown renders backslashes literally inside code, the web chat corrupted the code to show a stray backslash. Add an indented-code regex and protect those lines too. Also make the placeholder restore iterative, so nested protected regions (e.g. inline code that looks like display math) restore correctly instead of leaving a placeholder behind. Addresses the sixth Codex review comment on PR #1035. * fix(web): do not treat list-continuation lines as indented code The indented-code regex protected every 4-space line, but inside a list item a 4-space indent is a normal continuation paragraph, not a code block (code under a list marker needs deeper indentation). So a message like `- total\n costs \$5 and \$10` had that nested line stashed as "code", leaving its dollars un-escaped -- and the KaTeX parser then rendered the price range as math. Narrow the indented-code rule to a run of 4-space / tab lines that is preceded by a blank line (or the start of the text). That still protects real top-level indented code blocks and deeper-indented code inside lists, while letting 4-space list-continuation lines get their dollars escaped. Addresses the seventh Codex review comment on PR #1035. * refactor(web): render only $$…$$ display math, drop single-$ inline Enable KaTeX for display math only: disable markstream's inline math rule (`md.inline.ruler.disable('math')`) via customMarkdownIt, leaving the `math_block` rule for $$…$$. Single $ now stays literal everywhere, so prices, env vars, shell paths, and code are never mis-rendered as math -- with no escaping, no code detection, and no preprocessor. This removes the escapeProseDollars normalization layer and all of its code-protection machinery (the 8 review comments it attracted were symptoms of trying to make a lax single-$ tokenizer behave). Display $$…$$ math continues to render via KaTeX. Changeset updated to describe display-math-only support. --- .changeset/web-katex-math.md | 5 +++ apps/kimi-web/package.json | 1 + .../kimi-web/src/components/chat/Markdown.vue | 34 ++++++++++++++++++- pnpm-lock.yaml | 3 ++ 4 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 .changeset/web-katex-math.md diff --git a/.changeset/web-katex-math.md b/.changeset/web-katex-math.md new file mode 100644 index 000000000..b09dfe676 --- /dev/null +++ b/.changeset/web-katex-math.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Render LaTeX display math (`$$…$$`) in the web chat via KaTeX. Single `$` is intentionally left as literal text, so prices, env vars, and shell paths (e.g. `$PATH`, `$5/$10`, `$HOME/bin`) are never swallowed as a formula. diff --git a/apps/kimi-web/package.json b/apps/kimi-web/package.json index c3a262ada..0f3c60f43 100644 --- a/apps/kimi-web/package.json +++ b/apps/kimi-web/package.json @@ -16,6 +16,7 @@ "@fontsource-variable/jetbrains-mono": "^5.2.8", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", + "katex": "^0.16.22", "markstream-vue": "1.0.3", "shiki": "^4.2.0", "stream-markdown": "^0.0.16", diff --git a/apps/kimi-web/src/components/chat/Markdown.vue b/apps/kimi-web/src/components/chat/Markdown.vue index b90f2afba..dcafb4790 100644 --- a/apps/kimi-web/src/components/chat/Markdown.vue +++ b/apps/kimi-web/src/components/chat/Markdown.vue @@ -2,7 +2,8 @@ + + + + diff --git a/flake.nix b/flake.nix index e3e04414d..037283001 100644 --- a/flake.nix +++ b/flake.nix @@ -150,7 +150,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-yukns+b5mjDgp/TuFco24FH5pjJm/Ivlw71gMyhSl/Q="; + hash = "sha256-O9xDt/5bCakst2mKTyki3oyUph1g+CuH/BIqA/4fgYE="; }; nativeBuildInputs = [ diff --git a/packages/server/SECURITY.md b/packages/server/SECURITY.md new file mode 100644 index 000000000..77e135db5 --- /dev/null +++ b/packages/server/SECURITY.md @@ -0,0 +1,192 @@ +# Server deployment security + +Operational guide for exposing the `@moonshot-ai/server` HTTP/WebSocket API beyond +`127.0.0.1`. For reporting vulnerabilities, see the repo root `SECURITY.md`. + +## Threat model + +The server classifies the bind host into one of three tiers +(`services/auth/bindClassify.ts`): + +| Tier | Bind host | Trust boundary | Primary threats | Mitigations | +|---|---|---|---|---| +| loopback | `127.0.0.0/8`, `::1`, `localhost` (default) | This host only; local process isolation | Same-host processes/users connecting to the port; malicious web pages hitting `localhost` (CSRF / DNS rebinding) | Persistent bearer token; Host/Origin checks; token file `0600` (this user only) | +| LAN | RFC1918 + `169.254/16`, `fe80::/10` (`--host 192.168.x.x`) | The local network is untrusted | Network-reachable attackers; brute force; CSRF; remote shell / shutdown | **Full hardening (same as public):** bearer token auth (password optional), TLS or explicit opt-out, auth-failure rate limiting, dangerous endpoints disabled, security headers | +| public | Everything else; `0.0.0.0` / `::` / empty | The whole internet is untrusted | Same as LAN, at internet scale | Same hardening as LAN; a TLS-terminating reverse proxy (or tunnel) is strongly recommended | + +**Important:** the hardening gate in `start.ts` is `bindClass !== 'loopback'`. LAN and +public binds therefore receive the **same** hardening stack (TLS opt-out + rate-limit ++ endpoint downgrade + security headers). Authentication is the persistent bearer +token (printed in the startup banner); `KIMI_CODE_PASSWORD` is an optional additional +credential on every tier. The tier label only changes the banner and the automatic +Host allowlist entry. Treat LAN exposure with the same care as public exposure. + +Not in scope (see PLAN §6): NAT traversal, untrusted relays, end-to-end encryption. + +## Default (loopback) deployment + +``` +kimi server run # or: kimi web +``` + +- Binds `127.0.0.1:58627` by default (`--host` / `--port` to override). +- Uses a persistent bearer token (`crypto.randomBytes(32)`, base64url) generated + once on first boot and written to `/server.token` with mode + `0600` (parent directory `0700`). The same token is reused across restarts; it + is NOT deleted when the server exits. Rotate it explicitly with + `kimi server rotate-token` (a running server picks up the new token without a + restart). +- The local CLI reads that token file automatically and sends + `Authorization: Bearer ` on every REST/WebSocket call — no setup required. +- Only loopback is reachable; nothing is exposed to the network. + +## LAN deployment + +Bind a specific LAN interface. Because the hardening gate is `bindClass !== 'loopback'`, +a LAN bind gets the same hardening stack as a public bind: + +``` +kimi server run --host 192.168.1.10 --insecure-no-tls +``` + +- Authentication is the persistent bearer token printed in the startup banner; send it + as `Authorization: Bearer `. `KIMI_CODE_PASSWORD` is optional and adds a + second credential (it is never required). +- `--insecure-no-tls` is required unless TLS is terminated in front of the server + (reverse proxy or tunnel). Use it only on a trusted network. +- `POST /api/v1/shutdown` and the PTY `/api/v1/terminals/*` routes are **404 by + default** on LAN too. Re-enable only with `--allow-remote-shutdown` / + `--allow-remote-terminals`. +- Auth-failure rate limiting and security response headers are active. + +## Public deployment + +A public bind is `0.0.0.0`, `::`, an empty host, or any non-RFC1918 address. Put the +server behind a TLS-terminating reverse proxy (or a tunnel); do not terminate TLS in +process. + +``` +kimi server run --host 0.0.0.0 --insecure-no-tls +``` + +- Authentication is the persistent bearer token printed in the startup banner; + `KIMI_CODE_PASSWORD` is optional. `--insecure-no-tls` is mandatory here because + the proxy terminates TLS; without it (and without app-level TLS) the server refuses + to bind. +- The reverse proxy must pass through the `Authorization` header and the `Host` + header, and must upgrade WebSocket connections (see examples below). +- `POST /api/v1/shutdown` and `/api/v1/terminals/*` are **404 by default**. Re-enable + only with `--allow-remote-shutdown` / `--allow-remote-terminals`. +- Auth-failure rate limiting (10 failures / 60 s window / 60 s ban per source, + response code `42901`) and security headers are active. +- Prefer a tunnel (`cloudflared`, `ssh -R`) over a raw public bind where possible. + +## Reverse-proxy examples + +The server listens on `127.0.0.1:58627`; the proxy terminates TLS and forwards to it. + +**Caddy** (`Caddyfile`; automatic HTTPS): + +``` +kimi.example.com { + reverse_proxy 127.0.0.1:58627 +} +``` + +Caddy's `reverse_proxy` passes `Host`, `Authorization`, and the WebSocket upgrade +headers by default. + +**nginx** (TLS terminated at nginx): + +``` +server { + listen 443 ssl; + server_name kimi.example.com; + + ssl_certificate /etc/letsencrypt/live/kimi.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/kimi.example.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:58627; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Authorization $http_authorization; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} +``` + +**Tunnel alternatives:** + +- `cloudflared tunnel --url http://127.0.0.1:58627` (Cloudflare Tunnel; no inbound + firewall rule). +- `ssh -R` (SSH remote port forwarding) to publish the loopback port via a host you + control. + +For both tunnels, keep the server on the default loopback bind; the tunnel provides +the remote reachability and TLS. + +## Credential management + +- **Token** — a persistent bearer token generated once on first boot, held in + memory and written to `/server.token` (`0600`, directory + `0700`). It survives restarts and is reused until explicitly rotated with + `kimi server rotate-token`, which rewrites the file (the previous token stops + working immediately, even for a running server). The CLI reads it + automatically; treat the file as a secret. +- **Password** — set `KIMI_CODE_PASSWORD` in the environment. The server hashes it at + boot with bcrypt (cost 12) and keeps only the hash in memory; verification uses + `bcrypt.compare`. Password auth is accepted as `Authorization: Bearer `. +- **Stored password hash** — the current path is env-only. There is **no** + `set-password` subcommand and no config-file hash option yet. If a config-stored + hash is needed later, generate one externally with cost 12, e.g.: + + ``` + node -e "require('bcryptjs').hash(process.env.KIMI_CODE_PASSWORD,12).then(h=>console.log(h))" + ``` + + (This is forward-looking; do not rely on it until config support lands.) + +## Host / Origin allowlists + +Host and Origin checks run on every request (HTTP and WebSocket upgrade) on all +tiers. + +- **Default Host allowlist:** `localhost`, `*.localhost`, `127.0.0.1`, `::1`, + `[::1]`, and the actual bind host/IP. Requests with any other `Host` get + `403 Invalid Host header` (DNS-rebinding protection). +- **`KIMI_CODE_ALLOWED_HOSTS`** — comma-separated extra hosts. A leading dot matches + a subdomain wildcard, e.g. `KIMI_CODE_ALLOWED_HOSTS=.example.com,kimi.local`. +- **`KIMI_CODE_CORS_ORIGINS`** — comma-separated list of allowed cross-origin values + (full `scheme://host[:port]`). No `*` wildcard. Matched origins get + `Access-Control-Allow-Origin` echoed; `OPTIONS` preflight short-circuits to `204`. + The bundled Web UI is same-origin and needs no entry. + Example: `KIMI_CODE_CORS_ORIGINS=https://kimi.example.com`. +- **`KIMI_CODE_DISABLE_HOST_CHECK=1`** — disables the Host check entirely on **all** + tiers (loopback, LAN, and public). Test/controlled environments only; this removes + the DNS-rebinding protection and must not be set in production. + +## Authentication reference + +- **HTTP:** `Authorization: Bearer ` on every route except + `GET /api/v1/healthz`, `OPTIONS *`, and the static Web UI assets (`/`, `/*`). + Failure returns `401` with code `40101`. +- **WebSocket:** send either an `Authorization: Bearer ` header or + the subprotocol `Sec-WebSocket-Protocol: kimi-code.bearer.` (for browsers + that cannot set WS headers). The server echoes the matching subprotocol on accept; + a failed check destroys the socket. + +## CLI flags + +The only server-exposure flags are: + +| Flag | Purpose | +|---|---| +| `--host ` | Bind host (default `127.0.0.1`). | +| `--port ` | Bind port (default `58627`). | +| `--insecure-no-tls` | Allow a non-loopback bind without app-level TLS (i.e. TLS is terminated by a proxy/tunnel). | +| `--allow-remote-shutdown` | Re-enable `POST /api/v1/shutdown` on a non-loopback bind. | +| `--allow-remote-terminals` | Re-enable the PTY `/api/v1/terminals/*` routes on a non-loopback bind. | + +There is no `--bind-class` flag and no `set-password` subcommand. diff --git a/packages/server/package.json b/packages/server/package.json index c8346872e..b5de0a691 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -42,6 +42,7 @@ "@fastify/swagger": "^9.7.0", "@moonshot-ai/agent-core": "workspace:^", "@moonshot-ai/protocol": "workspace:^", + "bcryptjs": "^2.4.3", "fastify": "^5.1.0", "pino": "^9.5.0", "pino-pretty": "^13.0.0", @@ -51,6 +52,7 @@ "zod-to-json-schema": "^3.25.2" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/ws": "^8.18.0" } } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 8a043c88f..f312ac657 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -2,6 +2,9 @@ export { startServer, ServerLockedError } from './start'; export type { ServerStartOptions, RunningServer } from './start'; export { okEnvelope, errEnvelope } from './envelope'; export type { Envelope } from './envelope'; +export { classify } from './services/auth/bindClassify'; +export type { BindClass } from './services/auth/bindClassify'; +export { rotateServerToken, serverTokenPath } from './services/auth/persistentToken'; export { createServerLogger } from './services/pinoLoggerService'; export type { CreateLoggerOptions, diff --git a/packages/server/src/lock.ts b/packages/server/src/lock.ts index db516532f..521f413d4 100644 --- a/packages/server/src/lock.ts +++ b/packages/server/src/lock.ts @@ -165,8 +165,10 @@ function tryExclusiveCreate(path: string, contents: LockContents): boolean { let fd: number | undefined; try { // 0o100 (O_CREAT) | 0o200 (O_EXCL) | 0o2 (O_RDWR) — but `openSync` accepts the - // string flag form which is portable. - fd = openSync(path, 'wx'); + // string flag form which is portable. Mode 0o600 so the lock file (which + // lives next to the per-pid token file) is not world/group readable + // (ROADMAP M5.2). + fd = openSync(path, 'wx', 0o600); writeFileSync(fd, JSON.stringify(contents)); return true; } catch (err) { diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts new file mode 100644 index 000000000..00b07759f --- /dev/null +++ b/packages/server/src/middleware/auth.ts @@ -0,0 +1,134 @@ +/** + * Global HTTP bearer-auth hook (ROADMAP M2.2). + * + * `createAuthHook` builds a Fastify `onRequest` hook that requires a valid + * `Authorization: Bearer ` on every non-bypassed route. It is NOT wired + * into `start.ts` in M2 — that happens in M5.1. Until then it is exercised in + * isolation via tests on a minimal Fastify app. + * + * 401 responses use the reserved daemon code `40101` + * (`packages/protocol/src/error-codes.ts` intentionally omits it; the protocol + * package is left untouched). `errEnvelope(code: number, …)` accepts a plain + * number, so the literal is passed directly. + */ + +import type { FastifyReply, FastifyRequest } from 'fastify'; + +import { errEnvelope } from '#/envelope'; +import { + AUTH_RATE_LIMIT_CODE, + AUTH_RATE_LIMIT_MSG, + type AuthFailureLimiter, +} from '#/middleware/rateLimit'; +import type { IAuthTokenService } from '#/services/auth/authTokenService'; + +/** Daemon-reserved unauthorized code (not in the protocol `ErrorCode` enum). */ +const AUTH_ERROR_CODE = 40101; +const AUTH_ERROR_MSG = 'Unauthorized'; +const REDACTED = '[redacted]'; +const BEARER_PREFIX = 'Bearer '; + +export interface AuthHookOptions { + /** Return true to skip auth for this request (bypass whitelist). */ + readonly isBypassed?: (req: FastifyRequest) => boolean; + /** + * Optional per-source auth-failure limiter (ROADMAP M6.4). When present, a + * banned source is rejected with `429` before auth runs, and each failed + * attempt is recorded. Wired only on non-loopback binds from `start.ts`. + */ + readonly limiter?: Pick; +} + +/** + * Default bypass policy — the security boundary. + * + * Bypassed (no token required): + * - every `OPTIONS` request (CORS preflight); + * - `GET /api/v1/healthz` (liveness probe for supervisors / load balancers); + * - static web assets, defined as any path that does NOT start with `/api/` + * AND is not one of the meta documents `/openapi.json` / `/asyncapi.json`. + * + * NOT bypassed (token required): all `/api/…` routes plus `/openapi.json` and + * `/asyncapi.json` (the meta documents leak the API shape, so they stay gated). + */ +function defaultIsBypassed(req: FastifyRequest): boolean { + if (req.method === 'OPTIONS') { + return true; + } + const path = req.url.split('?', 1)[0] ?? req.url; + if (req.method === 'GET' && path === '/api/v1/healthz') { + return true; + } + const isApi = path.startsWith('/api/'); + const isMeta = path === '/openapi.json' || path === '/asyncapi.json'; + return !isApi && !isMeta; +} + +/** + * Extract the bearer token from the raw `Authorization` header. + * + * Returns `null` when the header is missing, lacks the case-sensitive + * `Bearer ` prefix, or carries an empty token — all treated as 401. + */ +function extractBearer(header: string | undefined): string | null { + if (header === undefined || !header.startsWith(BEARER_PREFIX)) { + return null; + } + const token = header.slice(BEARER_PREFIX.length); + return token.length === 0 ? null : token; +} + +/** + * Build the global `onRequest` auth hook. + * + * Order inside the hook matters: the raw token is extracted first, then the + * header view is redacted for downstream request logging (`start.ts` logs + * requests), and only then is the candidate validated — so auth still sees the + * real token while logs never do. Returning the `reply` from the async hook + * short-circuits Fastify on 401 (the route handler never runs). + */ +export function createAuthHook( + authTokenService: IAuthTokenService, + opts?: AuthHookOptions, +): (req: FastifyRequest, reply: FastifyReply) => Promise { + const isBypassed = opts?.isBypassed ?? defaultIsBypassed; + + return async (req, reply) => { + // Rate-limit check (ROADMAP M6.4): a banned source is rejected before any + // auth work — even a valid token cannot bypass an active ban. Loopback + // binds pass no limiter, so this branch is a no-op there. + if (opts?.limiter?.isBanned(req.ip) === true) { + return reply + .code(429) + .send(errEnvelope(AUTH_RATE_LIMIT_CODE, AUTH_RATE_LIMIT_MSG, req.id)); + } + + const header = req.headers.authorization; + const token = extractBearer(header); + + // Redact the header view BEFORE the rest of the pipeline logs the request. + // Auth has already consumed the raw value above, so this only affects the + // downstream log view of the request. + if (header !== undefined) { + req.headers.authorization = REDACTED; + } + + if (isBypassed(req)) { + return; + } + + if (token === null) { + opts?.limiter?.recordFailure(req.ip); + return reply + .code(401) + .send(errEnvelope(AUTH_ERROR_CODE, AUTH_ERROR_MSG, req.id)); + } + + if (!(await authTokenService.isValid(token))) { + opts?.limiter?.recordFailure(req.ip); + return reply + .code(401) + .send(errEnvelope(AUTH_ERROR_CODE, AUTH_ERROR_MSG, req.id)); + } + }; +} diff --git a/packages/server/src/middleware/hostnames.ts b/packages/server/src/middleware/hostnames.ts new file mode 100644 index 000000000..7b620b9e3 --- /dev/null +++ b/packages/server/src/middleware/hostnames.ts @@ -0,0 +1,166 @@ +/** + * Host-header allowlist middleware (ROADMAP M4.1). + * + * `createHostCheck` builds a Fastify `onRequest` hook that rejects requests + * whose `Host` header is not in the allowlist with a `403 Invalid Host header` + * envelope. This is the primary DNS-rebinding defence once the server is + * reachable beyond localhost (PLAN §3.4). + * + * Default-allow set (no configuration required): + * - `localhost`, `*.localhost` (any subdomain of `localhost`); + * - `127.0.0.1`, `::1`, `[::1]`; + * - any literal IP (`net.isIP(host) !== 0`); + * - the host the server actually bound to (`boundHost`); + * - caller-supplied extras (`extra`), where a leading `.` matches the bare + * domain and any subdomain (e.g. `.example.com` matches `example.com` and + * `a.example.com`). + * + * The default set is intentionally permissive for loopback/IP access so that + * `app.inject` (default `Host: localhost:80`) and real `fetch` to + * `127.0.0.1:` keep working — existing HTTP/WS tests rely on this. + * + * 403 responses use the reserved daemon code `40301` + * (`packages/protocol/src/error-codes.ts` intentionally omits it; the protocol + * package is left untouched). `errEnvelope(code: number, …)` accepts a plain + * number, so the literal is passed directly. + */ + +import net from 'node:net'; + +import type { FastifyReply, FastifyRequest } from 'fastify'; + +import { errEnvelope } from '#/envelope'; + +/** Daemon-reserved "invalid Host" code (not in the protocol `ErrorCode` enum). */ +const HOST_ERROR_CODE = 40301; +const HOST_ERROR_MSG = 'Invalid Host header'; + +export interface HostCheckOptions { + /** The host the server bound to; always allowed (port stripped both sides). */ + readonly boundHost?: string; + /** Extra allowed hosts / domain-suffix patterns (from `KIMI_CODE_ALLOWED_HOSTS`). */ + readonly extra?: readonly string[]; + /** Disable the check entirely (`KIMI_CODE_DISABLE_HOST_CHECK=1`; test-only). */ + readonly disable?: boolean; +} + +/** Returned by {@link createHostCheck}: the Fastify hook plus the raw predicate. */ +export interface HostCheck { + /** Fastify `onRequest` hook that 403s on a disallowed `Host`. */ + readonly onRequest: (req: FastifyRequest, reply: FastifyReply) => Promise; + /** Reusable predicate (also used by the WS upgrade path in M4.3). */ + readonly isAllowed: (host: string | undefined) => boolean; +} + +/** + * Parse `KIMI_CODE_ALLOWED_HOSTS` into an `extra` allowlist. + * + * Comma-separated, trimmed, empties dropped. A leading `.` is preserved so the + * caller can express domain-suffix wildcards (`.example.com`). + */ +export function parseAllowedHosts(env: NodeJS.ProcessEnv = process.env): string[] { + const raw = env['KIMI_CODE_ALLOWED_HOSTS']; + if (raw === undefined) { + return []; + } + return raw + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +/** True when `KIMI_CODE_DISABLE_HOST_CHECK=1` (test/controlled env only). */ +export function isHostCheckDisabled(env: NodeJS.ProcessEnv = process.env): boolean { + return env['KIMI_CODE_DISABLE_HOST_CHECK'] === '1'; +} + +/** + * Strip a trailing `:port` from a `Host` value and lowercase it. + * + * Handles: + * - bracketed IPv6 with a port: `[::1]:80` → `[::1]`; + * - host/IPv4 with a port: `localhost:80` → `localhost`, `1.2.3.4:5678` → `1.2.3.4`; + * - bare values (no port): returned lowercased as-is; + * - bare IPv6 without brackets (multiple colons, e.g. `::1`): returned + * lowercased as-is — there is no unambiguous port to strip. + */ +export function stripPort(host: string): string { + if (host.startsWith('[')) { + const end = host.indexOf(']'); + return (end === -1 ? host : host.slice(0, end + 1)).toLowerCase(); + } + const firstColon = host.indexOf(':'); + if (firstColon === -1) { + return host.toLowerCase(); + } + const lastColon = host.lastIndexOf(':'); + if (firstColon === lastColon) { + const after = host.slice(lastColon + 1); + if (after.length > 0 && /^\d+$/.test(after)) { + return host.slice(0, lastColon).toLowerCase(); + } + } + // Multiple colons (bare IPv6) or a non-digit suffix — no port to strip. + return host.toLowerCase(); +} + +/** + * Decide whether a `Host` value is allowed under the given options. + * + * Missing/empty `Host` is rejected (HTTP/1.1 requires it). The check is a no-op + * when `opts.disable` is set. + */ +export function isAllowedHost(host: string | undefined, opts: HostCheckOptions): boolean { + if (opts.disable === true) { + return true; + } + if (host === undefined || host.length === 0) { + return false; + } + const h = stripPort(host); + + if (h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]') { + return true; + } + if (h.endsWith('.localhost')) { + return true; + } + if (net.isIP(h) !== 0) { + return true; + } + if (opts.boundHost !== undefined && h === stripPort(opts.boundHost)) { + return true; + } + if (opts.extra !== undefined) { + for (const entry of opts.extra) { + if (entry.startsWith('.')) { + const base = entry.slice(1); + if (h === base || h.endsWith(entry)) { + return true; + } + } else if (h === entry) { + return true; + } + } + } + return false; +} + +/** + * Build the Fastify `onRequest` hook and the reusable `isAllowed` predicate. + * + * Returning the `reply` from the hook short-circuits Fastify on 403 so the + * route handler never runs. + */ +export function createHostCheck(opts: HostCheckOptions): HostCheck { + const isAllowed = (host: string | undefined): boolean => isAllowedHost(host, opts); + const onRequest = async ( + req: FastifyRequest, + reply: FastifyReply, + ): Promise => { + if (!isAllowed(req.headers.host)) { + return reply.code(403).send(errEnvelope(HOST_ERROR_CODE, HOST_ERROR_MSG, req.id)); + } + }; + return { onRequest, isAllowed }; +} diff --git a/packages/server/src/middleware/origin.ts b/packages/server/src/middleware/origin.ts new file mode 100644 index 000000000..7820010f9 --- /dev/null +++ b/packages/server/src/middleware/origin.ts @@ -0,0 +1,122 @@ +/** + * Origin / CORS middleware (ROADMAP M4.2). + * + * HTTP `onRequest` hook: + * - no `Origin` header → non-CORS / same-origin request → proceeds untouched; + * - same-origin (`Origin` host === `Host`, port stripped both sides) → allowed; + * - cross-origin → allowed only if the full origin (scheme + host) is in the + * explicit whitelist (`KIMI_CODE_CORS_ORIGINS`, no `*` wildcard — PLAN + * §3.4). Allowed origins get `Access-Control-Allow-*` echoed; `OPTIONS` + * preflight short-circuits to `204`; + * - cross-origin and NOT whitelisted → no CORS headers are emitted, so the + * browser blocks the response. `OPTIONS` still returns `204` (without CORS + * headers) so the preflight fails closed. + * + * `isOriginAllowed` is also exported for the WS upgrade path (M4.3). There, + * absent/malformed `Origin` is treated as allowed (non-browser Node `ws` + * clients send no `Origin`); a present-but-disallowed browser Origin is + * rejected. See M4.3 for the deliberate present-only deviation. + */ + +import type { FastifyReply, FastifyRequest } from 'fastify'; + +import { stripPort } from './hostnames'; + +const CORS_ALLOW_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'; +const CORS_ALLOW_HEADERS = 'Content-Type, Authorization'; + +export interface OriginHookOptions { + /** Explicit cross-origin allowlist (full origin strings, scheme + host). */ + readonly allowedOrigins?: readonly string[]; +} + +/** + * Parse `KIMI_CODE_CORS_ORIGINS` into an allowlist. + * + * Comma-separated, trimmed, empties dropped. No `*` wildcard — every entry is + * an explicit origin (PLAN §3.4). + */ +export function parseCorsOrigins(env: NodeJS.ProcessEnv = process.env): string[] { + const raw = env['KIMI_CODE_CORS_ORIGINS']; + if (raw === undefined) { + return []; + } + return raw + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +/** + * Return the `host` (host[:port], default port dropped) of an `Origin` value, + * or `undefined` when the origin is missing or malformed. + */ +export function originHost(origin: string | undefined): string | undefined { + if (origin === undefined) { + return undefined; + } + try { + return new URL(origin).host; + } catch { + return undefined; + } +} + +/** + * Decide whether an `Origin` is allowed for a request to `host`. + * + * - missing/malformed `Origin` → allowed (non-CORS / non-browser client); + * - same-origin (`Origin` host === `Host`, port stripped both sides) → allowed; + * - otherwise → allowed only when the full origin string is in `allowed`. + */ +export function isOriginAllowed( + origin: string | undefined, + host: string | undefined, + allowed: readonly string[], +): boolean { + const oh = originHost(origin); + if (oh === undefined) { + return true; + } + if (host !== undefined && stripPort(oh) === stripPort(host)) { + return true; + } + // `origin` is defined here (originHost returned a host), so the whitelist + // match is against the full origin string (scheme + host). + return allowed.includes(origin as string); +} + +/** + * Build the Fastify `onRequest` CORS hook. + * + * Allowed origins get `Access-Control-Allow-*` echoed and `OPTIONS` preflights + * short-circuit to `204`. Disallowed origins get no CORS headers (the browser + * blocks the response); their `OPTIONS` preflight still returns `204` so it + * fails closed without leaking headers. + */ +export function createOriginHook( + opts: OriginHookOptions, +): (req: FastifyRequest, reply: FastifyReply) => Promise { + const allowed = opts.allowedOrigins ?? []; + return async (req, reply) => { + const origin = req.headers.origin; + if (origin === undefined) { + return; + } + if (isOriginAllowed(origin, req.headers.host, allowed)) { + reply.header('Access-Control-Allow-Origin', origin); + reply.header('Access-Control-Allow-Methods', CORS_ALLOW_METHODS); + reply.header('Access-Control-Allow-Headers', CORS_ALLOW_HEADERS); + reply.header('Vary', 'Origin'); + if (req.method === 'OPTIONS') { + return reply.code(204).send(); + } + return; + } + // Origin present but not allowed: emit no CORS headers so the browser + // blocks the response. Short-circuit the preflight to fail closed. + if (req.method === 'OPTIONS') { + return reply.code(204).send(); + } + }; +} diff --git a/packages/server/src/middleware/rateLimit.ts b/packages/server/src/middleware/rateLimit.ts new file mode 100644 index 000000000..ba400b8de --- /dev/null +++ b/packages/server/src/middleware/rateLimit.ts @@ -0,0 +1,103 @@ +/** + * Auth-failure rate limiter (ROADMAP M6.4). + * + * A per-`remoteAddress` failure counter that temporarily bans a source after + * too many failed authentication attempts. Wired into `createAuthHook` only on + * non-loopback binds (`start.ts`), so the loopback default keeps its existing + * "no rate limit" behavior while a public/LAN bind slows brute-force attempts. + * + * Policy: a source is banned when it accumulates `maxFailures` failures within + * a sliding `windowMs` window; the ban lasts `banMs`. Once banned, every + * request from that source is rejected with `429` until the ban expires — even + * a request carrying a valid token — so a banned attacker cannot keep probing. + */ + +/** Reserved daemon code for rate-limited auth (not in the protocol enum). */ +export const AUTH_RATE_LIMIT_CODE = 42901; +export const AUTH_RATE_LIMIT_MSG = 'Too many failed auth attempts'; + +export interface AuthFailureLimiterOptions { + /** Failures within {@link windowMs} that trigger a ban. Default `10`. */ + readonly maxFailures?: number; + /** Rolling failure window in ms. Default `60_000`. */ + readonly windowMs?: number; + /** Ban duration in ms once the threshold is hit. Default `60_000`. */ + readonly banMs?: number; +} + +/** Minimal surface consumed by `createAuthHook`. */ +export interface AuthFailureLimiter { + /** Record one failed auth attempt for `ip`. */ + recordFailure(ip: string): void; + /** True while `ip` is inside an active ban window. */ + isBanned(ip: string): boolean; + /** Stop the periodic cleanup timer and drop all state (shutdown / tests). */ + dispose(): void; +} + +interface Entry { + /** Failures recorded since {@link windowStart}. */ + count: number; + /** Start (ms epoch) of the current failure window. */ + windowStart: number; + /** ms epoch until which the source is banned; `0` when not banned. */ + bannedUntil: number; +} + +const DEFAULT_MAX_FAILURES = 10; +const DEFAULT_WINDOW_MS = 60_000; +const DEFAULT_BAN_MS = 60_000; + +/** + * Build a per-source auth-failure limiter. + * + * A periodic sweep drops entries that are neither banned nor within an active + * failure window so the map does not grow without bound on a long-lived + * public server. The timer is `unref`-ed so it never keeps the process alive. + */ +export function createAuthFailureLimiter( + opts?: AuthFailureLimiterOptions, +): AuthFailureLimiter { + const maxFailures = opts?.maxFailures ?? DEFAULT_MAX_FAILURES; + const windowMs = opts?.windowMs ?? DEFAULT_WINDOW_MS; + const banMs = opts?.banMs ?? DEFAULT_BAN_MS; + const entries = new Map(); + + const sweep = setInterval(() => { + const now = Date.now(); + for (const [ip, entry] of entries) { + const banned = entry.bannedUntil > now; + const windowLive = now - entry.windowStart <= windowMs; + if (!banned && !windowLive) { + entries.delete(ip); + } + } + }, windowMs); + if (typeof sweep.unref === 'function') { + sweep.unref(); + } + + return { + recordFailure(ip: string): void { + const now = Date.now(); + let entry = entries.get(ip); + if (entry === undefined || now - entry.windowStart > windowMs) { + entry = { count: 0, windowStart: now, bannedUntil: 0 }; + entries.set(ip, entry); + } + entry.count += 1; + if (entry.count >= maxFailures) { + entry.bannedUntil = now + banMs; + } + }, + isBanned(ip: string): boolean { + const entry = entries.get(ip); + if (entry === undefined) return false; + return entry.bannedUntil > Date.now(); + }, + dispose(): void { + clearInterval(sweep); + entries.clear(); + }, + }; +} diff --git a/packages/server/src/routes/registerApiV1Routes.ts b/packages/server/src/routes/registerApiV1Routes.ts index c710641ea..38297e5f0 100644 --- a/packages/server/src/routes/registerApiV1Routes.ts +++ b/packages/server/src/routes/registerApiV1Routes.ts @@ -46,6 +46,17 @@ interface ApiV1RouteHost { export interface RegisterApiV1RoutesOptions { readonly serverVersion: string; readonly debugEndpoints?: boolean; + /** + * Mount `POST /api/v1/shutdown`. Defaults to true (backward compatible); + * `start.ts` sets it false on a public bind unless `--allow-remote-shutdown`. + */ + readonly enableShutdown?: boolean; + /** + * Mount the PTY `/api/v1/terminals/*` routes. Defaults to true (backward + * compatible); `start.ts` sets it false on a public bind unless + * `--allow-remote-terminals`. + */ + readonly enableTerminals?: boolean; } export async function registerApiV1Routes( @@ -76,10 +87,12 @@ export async function registerApiV1Routes( ix, ); registerSessionsRoutes(apiV1 as unknown as Parameters[0], ix); - registerShutdownRoutes( - apiV1 as unknown as Parameters[0], - ix, - ); + if (opts.enableShutdown !== false) { + registerShutdownRoutes( + apiV1 as unknown as Parameters[0], + ix, + ); + } registerSnapshotRoutes(apiV1 as unknown as Parameters[0], ix); registerMessagesRoutes(apiV1 as unknown as Parameters[0], ix); registerPromptsRoutes(apiV1 as unknown as Parameters[0], ix); @@ -94,10 +107,12 @@ export async function registerApiV1Routes( registerToolsRoutes(apiV1 as unknown as Parameters[0], ix); registerSkillsRoutes(apiV1 as unknown as Parameters[0], ix); registerTasksRoutes(apiV1 as unknown as Parameters[0], ix); - registerTerminalsRoutes( - apiV1 as unknown as Parameters[0], - ix, - ); + if (opts.enableTerminals !== false) { + registerTerminalsRoutes( + apiV1 as unknown as Parameters[0], + ix, + ); + } registerFsRoutes(apiV1 as unknown as Parameters[0], ix); registerFilesRoutes(apiV1 as unknown as Parameters[0], ix); registerWorkspacesRoutes( diff --git a/packages/server/src/services/auth/authTokenService.ts b/packages/server/src/services/auth/authTokenService.ts new file mode 100644 index 000000000..0eea59984 --- /dev/null +++ b/packages/server/src/services/auth/authTokenService.ts @@ -0,0 +1,56 @@ +/** + * `IAuthTokenService` DI surface (ROADMAP M2.1). + * + * Exposes the persistent bearer token plus a single validity check that accepts + * EITHER the persistent token (constant-time, via `TokenStore`) OR a verified + * user password (bcrypt, async). The seam exists so tests can inject a + * fixed-token impl via `startServer({ serviceOverrides })`, and so `start.ts` + * (M5.1) can wire the real async-built instance at boot. + * + * `isValid` is async because password verification (`bcrypt.compare`) is + * async — the token path is synchronous, but the interface must await both. + */ + +import { createDecorator } from '@moonshot-ai/agent-core'; + +import { verifyPassword } from './password'; +import type { TokenStore } from './tokenStore'; + +export interface IAuthTokenService { + readonly _serviceBrand: undefined; + + /** The persistent bearer token (re-read from disk when its mtime changes). */ + getToken(): string; + + /** + * True when `candidate` matches the persistent token OR verifies against the + * configured password hash. Constant-time on the token path; bcrypt on the + * password path. + */ + isValid(candidate: string): Promise; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IAuthTokenService = + createDecorator('authTokenService'); + +/** + * Default `IAuthTokenService` over a `TokenStore` + optional password hash. + * + * Constructed in `start.ts` (M5.1) where the async `TokenStore` / + * `passwordHash` are available, then injected via `serviceOverrides`. NOT built + * inside `createServerServiceCollection`: that path is synchronous and cannot + * await the `TokenStore` file write or the bcrypt hash. + */ +export function createAuthTokenService(deps: { + readonly tokenStore: TokenStore; + readonly passwordHash: string | undefined; +}): IAuthTokenService { + return { + _serviceBrand: undefined, + getToken: () => deps.tokenStore.getToken(), + isValid: async (candidate) => + deps.tokenStore.isValid(candidate) || + (await verifyPassword(candidate, deps.passwordHash)), + }; +} diff --git a/packages/server/src/services/auth/bindClassify.ts b/packages/server/src/services/auth/bindClassify.ts new file mode 100644 index 000000000..9e476884c --- /dev/null +++ b/packages/server/src/services/auth/bindClassify.ts @@ -0,0 +1,112 @@ +/** + * Bind-address classification (ROADMAP M6.1). + * + * `classify(host)` buckets a bind host into the network exposure tier it + * implies, so `start.ts` can decide which hardening to apply: + * + * - `loopback` — only this host (`127.0.0.0/8`, `::1`, `localhost`). The + * token-only default; no public hardening required. + * - `lan` — RFC1918 private ranges (`10/8`, `172.16/12`, `192.168/16`) plus + * link-local (`169.254/16`, `fe80::/10`). Reachable from the local + * network; hardening is recommended but not all of it is mandatory. + * - `public` — everything else. Full D2 hardening (forced password, TLS + * opt-out, auth-failure rate limiting, dangerous-endpoint downgrade, + * security headers). + * + * Wildcard binds (`0.0.0.0`, `::`, empty) are treated as `public` by default — + * a wildcard is reachable from anywhere the host is — unless the caller + * explicitly relaxes the classification via `opts.bindClass: 'lan'` + * (`--bind-class=lan`). + */ + +import net from 'node:net'; + +export type BindClass = 'loopback' | 'lan' | 'public'; + +export interface ClassifyOptions { + /** Override classification of wildcard binds (`0.0.0.0` / `::` / empty). */ + readonly bindClass?: 'lan' | 'public'; +} + +/** Convert a dotted-quad IPv4 literal to its unsigned 32-bit integer form. */ +function ipv4ToInt(ip: string): number { + const [a, b, c, d] = ip.split('.'); + return ( + (((Number(a) << 24) >>> 0) + + ((Number(b) << 16) >>> 0) + + ((Number(c) << 8) >>> 0) + + (Number(d) >>> 0)) >>> + 0 + ); +} + +/** True when `ip` falls inside the IPv4 CIDR `base/prefix`. */ +function ipv4InCidr(ip: string, base: string, prefix: number): boolean { + const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; + return ((ipv4ToInt(ip) & mask) >>> 0) === ((ipv4ToInt(base) & mask) >>> 0); +} + +/** + * Expand a (possibly `::`-compressed) IPv6 literal into 8 lowercase hextets. + * Returns `null` when the shape is not a plain 8-group IPv6 address. + */ +function expandV6(host: string): readonly string[] | null { + const lower = host.toLowerCase(); + if (lower.includes('::')) { + const halves = lower.split('::'); + const leftRaw = halves[0] ?? ''; + const rightRaw = halves[1] ?? ''; + const left = leftRaw.length > 0 ? leftRaw.split(':') : []; + const right = rightRaw.length > 0 ? rightRaw.split(':') : []; + const missing = 8 - (left.length + right.length); + if (missing < 0) return null; + return [...left, ...Array(missing).fill('0'), ...right]; + } + const parts = lower.split(':'); + return parts.length === 8 ? parts : null; +} + +/** + * True when `host` is an IPv6 link-local address (`fe80::/10`). + * + * The first 10 bits are fixed (`1111111010`), so the leading hextet ranges + * `0xfe80`–`0xfebf`. IPv4-mapped / compressed forms that do not expand to an + * `fe80::/10` leading group return false. + */ +function isLinkLocalV6(host: string): boolean { + const groups = expandV6(host); + if (groups === null) return false; + const first = Number.parseInt(groups[0] ?? '', 16); + return first >= 0xfe80 && first <= 0xfebf; +} + +/** + * Classify a bind host by the network exposure it implies. + * + * See the module header for the tier definitions. A non-IP hostname that is + * not `localhost` is treated conservatively as `public` — a DNS name could + * resolve to a public address. + */ +export function classify(host: string, opts?: ClassifyOptions): BindClass { + if (host === '' || host === '0.0.0.0' || host === '::') { + return opts?.bindClass ?? 'public'; + } + if (host === 'localhost') { + return 'loopback'; + } + const family = net.isIP(host); + if (family === 4) { + if (host.startsWith('127.')) return 'loopback'; + if (ipv4InCidr(host, '10.0.0.0', 8)) return 'lan'; + if (ipv4InCidr(host, '172.16.0.0', 12)) return 'lan'; + if (ipv4InCidr(host, '192.168.0.0', 16)) return 'lan'; + if (ipv4InCidr(host, '169.254.0.0', 16)) return 'lan'; + return 'public'; + } + if (family === 6) { + if (host.toLowerCase() === '::1') return 'loopback'; + if (isLinkLocalV6(host)) return 'lan'; + return 'public'; + } + return 'public'; +} diff --git a/packages/server/src/services/auth/index.ts b/packages/server/src/services/auth/index.ts new file mode 100644 index 000000000..95ad8d826 --- /dev/null +++ b/packages/server/src/services/auth/index.ts @@ -0,0 +1,6 @@ +export * from './privateFiles'; +export * from './tokenStore'; +export * from './password'; +export * from './authTokenService'; +export * from './bindClassify'; +export * from './securityHeaders'; diff --git a/packages/server/src/services/auth/password.ts b/packages/server/src/services/auth/password.ts new file mode 100644 index 000000000..0a60f8bec --- /dev/null +++ b/packages/server/src/services/auth/password.ts @@ -0,0 +1,23 @@ +import { compare, hash } from 'bcryptjs'; + +const BCRYPT_COST = 12; + +export async function resolvePasswordHash( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const plaintext = env['KIMI_CODE_PASSWORD']; + if (!plaintext) { + return undefined; + } + return hash(plaintext, BCRYPT_COST); +} + +export async function verifyPassword( + candidate: string, + passwordHash: string | undefined, +): Promise { + if (passwordHash === undefined) { + return false; + } + return compare(candidate, passwordHash); +} diff --git a/packages/server/src/services/auth/persistentToken.ts b/packages/server/src/services/auth/persistentToken.ts new file mode 100644 index 000000000..2846dbf74 --- /dev/null +++ b/packages/server/src/services/auth/persistentToken.ts @@ -0,0 +1,79 @@ +/** + * Persistent server bearer token. + * + * The token lives at `/server.token` (mode 0600) and is reused + * across restarts, so a reboot does NOT rotate it. It is generated once on + * first boot and only changes when the operator explicitly runs + * `kimi server rotate-token` (which calls {@link rotateServerToken}). + * + * All writes go through {@link writePrivateFile} (atomic rename, 0700 dir, + * 0600 file) and reads through {@link readPrivateFile} (refuses files looser + * than 0600), so the on-disk token is never world/group-readable and a + * rotation is never observed half-written. + */ + +import { randomBytes } from 'node:crypto'; +import { join } from 'node:path'; + +import { readPrivateFile, writePrivateFile } from './privateFiles'; + +/** On-disk filename for the persistent token, relative to KIMI_CODE_HOME. */ +export const SERVER_TOKEN_FILE = 'server.token'; + +/** Absolute path of the persistent token file for a given home dir. */ +export function serverTokenPath(homeDir: string): string { + return join(homeDir, SERVER_TOKEN_FILE); +} + +/** Fresh 256-bit token, base64url-encoded (43 chars, URL-safe). */ +export function generateServerToken(): string { + return randomBytes(32).toString('base64url'); +} + +/** Atomically write `token` to `/server.token` (0600). */ +export async function writeServerToken(homeDir: string, token: string): Promise { + await writePrivateFile(serverTokenPath(homeDir), token); +} + +/** + * Read the persistent token, or `undefined` when no token file exists yet. + * Throws if the file exists but is too permissive (not 0600). + */ +export async function readServerToken(homeDir: string): Promise { + try { + const buf = await readPrivateFile(serverTokenPath(homeDir)); + return buf.toString('utf8').trim(); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + throw err; + } +} + +/** + * Return the existing persistent token, generating and persisting one on first + * boot. An empty/unreadable file is treated as missing and regenerated. + */ +export async function loadOrCreateServerToken(homeDir: string): Promise { + const existing = await readServerToken(homeDir); + if (existing !== undefined && existing.length > 0) { + return existing; + } + const token = generateServerToken(); + await writeServerToken(homeDir, token); + return token; +} + +/** + * Generate and persist a brand-new token, invalidating the previous one. + * + * A running server picks the new token up on its next auth check (the token + * store re-reads the file when its mtime changes), so rotation takes effect + * immediately without a restart. + */ +export async function rotateServerToken(homeDir: string): Promise { + const token = generateServerToken(); + await writeServerToken(homeDir, token); + return token; +} diff --git a/packages/server/src/services/auth/privateFiles.ts b/packages/server/src/services/auth/privateFiles.ts new file mode 100644 index 000000000..2197ef72f --- /dev/null +++ b/packages/server/src/services/auth/privateFiles.ts @@ -0,0 +1,61 @@ +import { randomBytes } from 'node:crypto'; +import { + chmod, + mkdir, + open, + readFile, + rename, + rm, + stat, +} from 'node:fs/promises'; +import { dirname } from 'node:path'; + +export class PrivateFileTooPermissiveError extends Error { + readonly code = 'EPRIVATE_FILE_TOO_PERMISSIVE'; + + constructor( + readonly filePath: string, + readonly mode: number, + ) { + super( + `private file ${filePath} is too permissive (mode ${mode.toString(8).padStart(3, '0')}); expected 0600`, + ); + this.name = 'PrivateFileTooPermissiveError'; + } +} + +export async function writePrivateFile( + filePath: string, + data: string | Buffer, +): Promise { + const dir = dirname(filePath); + await mkdir(dir, { recursive: true, mode: 0o700 }); + await chmod(dir, 0o700); + + const tmp = `${filePath}.tmp.${randomBytes(8).toString('hex')}`; + + let handle: Awaited> | undefined; + try { + handle = await open(tmp, 'w', 0o600); + await handle.chmod(0o600); + await handle.writeFile(data); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(tmp, filePath); + } catch (err) { + if (handle) { + await handle.close().catch(() => {}); + } + await rm(tmp, { force: true }).catch(() => {}); + throw err; + } +} + +export async function readPrivateFile(filePath: string): Promise { + const info = await stat(filePath); + if ((info.mode & 0o077) !== 0) { + throw new PrivateFileTooPermissiveError(filePath, info.mode & 0o777); + } + return readFile(filePath); +} diff --git a/packages/server/src/services/auth/securityHeaders.ts b/packages/server/src/services/auth/securityHeaders.ts new file mode 100644 index 000000000..21f27767d --- /dev/null +++ b/packages/server/src/services/auth/securityHeaders.ts @@ -0,0 +1,45 @@ +/** + * Security response headers (ROADMAP M6.6). + * + * `createSecurityHeadersHook` builds a Fastify `onSend` hook that stamps a + * small set of defensive headers on every response once the server is exposed + * beyond loopback. Wired from `start.ts` only on non-loopback binds so the + * loopback default keeps its lean response headers. + * + * Headers: + * - `X-Content-Type-Options: nosniff` — stop MIME sniffing. + * - `Referrer-Policy: no-referrer` — never leak the URL to third parties. + * - `Content-Security-Policy: default-src 'self'` — the bundled Web UI is + * same-origin, so `'self'` covers it; tighten later if needed. + * - `Strict-Transport-Security` — ONLY when `opts.tls === true`. In this + * phase TLS is terminated by a reverse proxy (Caddy/nginx), so `start.ts` + * passes `tls: false` and HSTS is omitted here; the proxy is responsible + * for setting HSTS. + */ + +import type { FastifyReply, FastifyRequest } from 'fastify'; + +export interface SecurityHeadersOptions { + /** When true, also emit `Strict-Transport-Security`. */ + readonly tls: boolean; +} + +const HSTS_VALUE = 'max-age=31536000'; + +/** + * Build the `onSend` hook. Returns the payload unchanged so Fastify continues + * the response pipeline with the headers applied. + */ +export function createSecurityHeadersHook( + opts: SecurityHeadersOptions, +): (req: FastifyRequest, reply: FastifyReply, payload: unknown) => Promise { + return async (_req, reply, payload) => { + reply.header('X-Content-Type-Options', 'nosniff'); + reply.header('Referrer-Policy', 'no-referrer'); + reply.header('Content-Security-Policy', "default-src 'self'"); + if (opts.tls === true) { + reply.header('Strict-Transport-Security', HSTS_VALUE); + } + return payload; + }; +} diff --git a/packages/server/src/services/auth/tokenStore.ts b/packages/server/src/services/auth/tokenStore.ts new file mode 100644 index 000000000..e12d860b0 --- /dev/null +++ b/packages/server/src/services/auth/tokenStore.ts @@ -0,0 +1,80 @@ +import { timingSafeEqual } from 'node:crypto'; +import { readFileSync, statSync } from 'node:fs'; + +import { loadOrCreateServerToken, serverTokenPath } from './persistentToken'; + +export interface TokenStore { + readonly tokenPath: string; + getToken(): string; + isValid(candidate: string): boolean; + dispose(): Promise; +} + +/** + * Persistent token store over `/server.token`. + * + * The token is loaded (or generated) once at boot and reused across restarts. + * `getToken()`/`isValid()` re-read the file whenever its mtime changes, so a + * `kimi server rotate-token` (which rewrites the file) takes effect on a + * running server immediately — no restart, no extra API. The file is small + * (43 bytes) and the common path is a single `statSync` per check. + * + * `dispose()` is intentionally a no-op: the token must survive shutdown. + */ +export async function createTokenStore(homeDir: string): Promise { + const tokenPath = serverTokenPath(homeDir); + const initial = await loadOrCreateServerToken(homeDir); + const initialStat = statSync(tokenPath); + let cache: { token: string; mtimeMs: number; ino: number } = { + token: initial, + mtimeMs: initialStat.mtimeMs, + ino: initialStat.ino, + }; + + const currentToken = (): string => { + let st: ReturnType; + try { + st = statSync(tokenPath); + } catch { + // File temporarily unavailable — keep serving the last known token. + return cache.token; + } + // Detect a rewrite by mtime OR inode. `writePrivateFile` does an atomic + // rename, which always yields a new inode (POSIX) and a fresh mtime + // (Windows/NTFS, where `ino` is always 0). Checking both makes the reload + // robust even on filesystems with coarse (1s) mtime resolution. + if (st.mtimeMs === cache.mtimeMs && st.ino === cache.ino) { + return cache.token; + } + // Changed: re-read, but refuse a too-permissive file and never let an + // empty/partial read clobber the last good token. + if ((st.mode & 0o077) !== 0) { + return cache.token; + } + try { + const token = readFileSync(tokenPath, 'utf8').trim(); + if (token.length > 0) { + cache = { token, mtimeMs: st.mtimeMs, ino: st.ino }; + } + } catch { + // keep last known token + } + return cache.token; + }; + + return { + tokenPath, + getToken: currentToken, + isValid(candidate: string): boolean { + const tokenBuf = Buffer.from(currentToken()); + const candidateBuf = Buffer.from(candidate); + if (candidateBuf.length !== tokenBuf.length) { + return false; + } + return timingSafeEqual(candidateBuf, tokenBuf); + }, + async dispose(): Promise { + // Persistent token: intentionally left on disk so it survives restarts. + }, + }; +} diff --git a/packages/server/src/services/gateway/wsGateway.ts b/packages/server/src/services/gateway/wsGateway.ts index 11d7b4b8f..7308be6fe 100644 --- a/packages/server/src/services/gateway/wsGateway.ts +++ b/packages/server/src/services/gateway/wsGateway.ts @@ -1,9 +1,19 @@ import { createDecorator, type TelemetryClient } from '@moonshot-ai/agent-core'; +import type { HostCheckOptions } from '#/middleware/hostnames'; +import type { IAuthTokenService } from '#/services/auth/authTokenService'; import type { AbortHandler, FsWatchHandler, TerminalHandler } from '#/ws/connection'; export const WS_PATH = '/api/v1/ws'; +/** + * `Sec-WebSocket-Protocol` subprotocol prefix used by browser clients to carry + * the bearer token during the WS upgrade handshake (browsers cannot set + * arbitrary headers on a WebSocket, so the token rides in a subprotocol). The + * full offered subprotocol is `${WS_BEARER_PROTOCOL_PREFIX}`. + */ +export const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.'; + export interface IWSGateway { readonly _serviceBrand: undefined; @@ -14,11 +24,47 @@ export interface IWSGateway { setFsWatchHandler(handler: FsWatchHandler): void; setTerminalHandler(handler: TerminalHandler): void; + + /** + * Install the `IAuthTokenService` used to validate bearer tokens on the WS + * upgrade path. Wired by `start.ts` (M5.1) AFTER construction so the + * ix-resolved, override-aware impl (not the constructor options) is what + * enforces auth — letting test overrides via `serviceOverrides` take effect + * for WS too. `WSGatewayOptions.authTokenService?` is retained only for the + * M3/M4 unit tests that construct the gateway directly. + */ + setAuthTokenService(service: IAuthTokenService): void; } // eslint-disable-next-line @typescript-eslint/no-redeclare export const IWSGateway = createDecorator('wsGateway'); +/** + * Extract the bearer token from a `Sec-WebSocket-Protocol` request header. + * + * The header is a comma-separated list of offered subprotocols (e.g. + * `"kimi-code.bearer.abc, other"`). Returns the token portion of the first + * entry whose subprotocol starts with {@link WS_BEARER_PROTOCOL_PREFIX}, or + * `undefined` when the header is missing/empty, no entry matches, or the + * matching entry carries an empty token. + */ +export function extractWsBearerToken(protocolHeader: string | undefined): string | undefined { + if (protocolHeader === undefined || protocolHeader.length === 0) { + return undefined; + } + for (const rawEntry of protocolHeader.split(',')) { + const entry = rawEntry.trim(); + if (entry.startsWith(WS_BEARER_PROTOCOL_PREFIX)) { + const token = entry.slice(WS_BEARER_PROTOCOL_PREFIX.length); + if (token.length === 0) { + return undefined; + } + return token; + } + } + return undefined; +} + export interface WSGatewayOptions { pingIntervalMs?: number; @@ -38,4 +84,28 @@ export interface WSGatewayOptions { * web-path events share one sink + context. */ telemetry?: TelemetryClient; + + /** + * When set, the WS upgrade path requires a valid bearer token (via the + * `Authorization` header or the `kimi-code.bearer.` subprotocol). + * When unset (e.g. tests / pre-M5.1 boots), upgrade auth is skipped. + */ + authTokenService?: IAuthTokenService; + + /** + * Optional Host-header allowlist enforced on the WS upgrade path (ROADMAP + * M4.3). When set, upgrades whose `Host` is not allowed are rejected with + * `403 Forbidden` before token validation. When unset (tests / pre-M5.1 + * boots), the WS Host check is skipped — HTTP-level Host enforcement in + * `start.ts` still covers non-upgrade requests. + */ + hostCheck?: HostCheckOptions; + + /** + * Optional Origin allowlist enforced on the WS upgrade path (ROADMAP M4.3). + * When set, a present browser `Origin` must be same-origin or listed here; + * an absent `Origin` (Node `ws` clients) is allowed. When unset (tests / + * pre-M5.1 boots), the WS Origin check is skipped. + */ + allowedOrigins?: readonly string[]; } diff --git a/packages/server/src/services/gateway/wsGatewayService.ts b/packages/server/src/services/gateway/wsGatewayService.ts index 7c06b98f0..d349081c1 100644 --- a/packages/server/src/services/gateway/wsGatewayService.ts +++ b/packages/server/src/services/gateway/wsGatewayService.ts @@ -4,6 +4,9 @@ import type { Socket } from 'node:net'; import { Disposable, ILogService } from '@moonshot-ai/agent-core'; import { WebSocketServer, type WebSocket } from 'ws'; +import { isAllowedHost } from '#/middleware/hostnames'; +import { isOriginAllowed } from '#/middleware/origin'; +import type { IAuthTokenService } from '#/services/auth/authTokenService'; import { WsConnection, type AbortHandler, @@ -15,7 +18,13 @@ import { IConnectionRegistry } from './connectionRegistry'; import { IRestGateway } from './restGateway'; import { ISessionClientsService } from './sessionClients'; import { IWSBroadcastService } from './wsBroadcast'; -import { IWSGateway, type WSGatewayOptions, WS_PATH } from './wsGateway'; +import { + extractWsBearerToken, + IWSGateway, + type WSGatewayOptions, + WS_BEARER_PROTOCOL_PREFIX, + WS_PATH, +} from './wsGateway'; export class WSGateway extends Disposable implements IWSGateway { readonly _serviceBrand: undefined; @@ -26,6 +35,7 @@ export class WSGateway extends Disposable implements IWSGateway { private abortHandler: AbortHandler | undefined; private fsWatchHandler: FsWatchHandler | undefined; private terminalHandler: TerminalHandler | undefined; + private authTokenService: IAuthTokenService | undefined; private detached = false; constructor( @@ -37,9 +47,23 @@ export class WSGateway extends Disposable implements IWSGateway { @ILogService private readonly logger: ILogService, ) { super(); - this.wss = new WebSocketServer({ noServer: true }); + this.authTokenService = options.authTokenService; + this.wss = new WebSocketServer({ + noServer: true, + // Browsers require the server to select one of the offered subprotocols; + // echo back the `kimi-code.bearer.` subprotocol when present so + // token-carrying browser clients complete the handshake. + handleProtocols: (protocols: Set) => { + for (const p of protocols) { + if (p.startsWith(WS_BEARER_PROTOCOL_PREFIX)) return p; + } + return false; + }, + }); this.server = this.restGateway.app.server; - this.upgradeListener = (req, sock, head) => this.onUpgrade(req, sock, head); + this.upgradeListener = (req, sock, head) => { + void this.onUpgrade(req, sock, head); + }; this.server.on('upgrade', this.upgradeListener); this.logger.debug({ path: WS_PATH }, 'ws gateway attached upgrade listener'); } @@ -56,7 +80,11 @@ export class WSGateway extends Disposable implements IWSGateway { this.terminalHandler = handler; } - private onUpgrade(req: IncomingMessage, socket: Socket, head: Buffer): void { + setAuthTokenService(service: IAuthTokenService): void { + this.authTokenService = service; + } + + private async onUpgrade(req: IncomingMessage, socket: Socket, head: Buffer): Promise { const url = req.url ?? ''; const path = url.split('?', 1)[0]; if (path !== WS_PATH) { @@ -68,6 +96,49 @@ export class WSGateway extends Disposable implements IWSGateway { // ~40 ms clusters, making the stream look stuttery. Trade a little bandwidth // for lower latency. socket.setNoDelay(true); + + // Host / Origin checks (ROADMAP M4.3) — enforced BEFORE token validation + // and only when the corresponding option is provided. When unset (tests / + // pre-M5.1 boots) the checks are skipped so existing clients (incl. Node + // `ws`, which sends no `Origin`) keep working. Origin is present-only: a + // missing `Origin` is treated as a non-browser client and allowed. + const hostCheck = this.options.hostCheck; + if (hostCheck !== undefined && !isAllowedHost(req.headers.host, hostCheck)) { + socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); + socket.destroy(); + return; + } + + const allowedOrigins = this.options.allowedOrigins; + if ( + allowedOrigins !== undefined && + !isOriginAllowed(req.headers.origin, req.headers.host, allowedOrigins) + ) { + socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); + socket.destroy(); + return; + } + + const authTokenService = this.authTokenService; + if (authTokenService !== undefined) { + const authorization = req.headers.authorization; + const token = authorization?.startsWith('Bearer ') + ? authorization.slice('Bearer '.length) + : extractWsBearerToken(req.headers['sec-websocket-protocol']); + // `isValid` is the only await on this path; wrap it so a rejection + // destroys the socket instead of escaping as an unhandled rejection. + let ok = false; + try { + ok = token !== undefined && (await authTokenService.isValid(token)); + } catch { + ok = false; + } + if (!ok) { + socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); + socket.destroy(); + return; + } + } this.wss.handleUpgrade(req, socket, head, (ws) => this.onConnect(ws, req)); } diff --git a/packages/server/src/services/serviceCollection.ts b/packages/server/src/services/serviceCollection.ts index 085721114..203641d3c 100644 --- a/packages/server/src/services/serviceCollection.ts +++ b/packages/server/src/services/serviceCollection.ts @@ -64,6 +64,12 @@ export function createServerServiceCollection( new SyncDescriptor(Services.CoreProcessService, [server.coreProcessOptions ?? {}], false), ); + // `IAuthTokenService` (ROADMAP M2.1) is intentionally NOT registered here: + // its real instance needs an async-built `TokenStore` + `passwordHash` that + // are only available in `start.ts` (M5.1). It is therefore supplied via + // `server.serviceOverrides` (last-wins) — the same seam tests use to inject + // a fixed-token impl. A silent default would be a security hole, so the + // absence is deliberate: an unconfigured server has no auth token service. for (const [id, override] of server.serviceOverrides ?? []) { services.set(id, override); } diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts index 17df42d4a..9adc10d83 100644 --- a/packages/server/src/start.ts +++ b/packages/server/src/start.ts @@ -10,6 +10,14 @@ import { import { installErrorHandler } from './error-handler'; import { transformOpenApiDocument } from './openapi/transforms'; import { acquireLock, ServerLockedError } from './lock'; +import { createAuthHook } from '#/middleware/auth'; +import { createAuthFailureLimiter } from '#/middleware/rateLimit'; +import { + createHostCheck, + isHostCheckDisabled, + parseAllowedHosts, +} from '#/middleware/hostnames'; +import { createOriginHook, parseCorsOrigins } from '#/middleware/origin'; import { createServerLogger, type ServerLogLevel, @@ -28,6 +36,14 @@ import { } from '#/services/gateway'; import { createServerServiceCollection } from '#/services/serviceCollection'; import { ISnapshotService, loadSnapshotConfig } from '#/services/snapshot'; +import { + createAuthTokenService, + IAuthTokenService, +} from '#/services/auth/authTokenService'; +import { classify } from '#/services/auth/bindClassify'; +import { resolvePasswordHash } from '#/services/auth/password'; +import { createSecurityHeadersHook } from '#/services/auth/securityHeaders'; +import { createTokenStore } from '#/services/auth/tokenStore'; import { getServerVersion } from './version'; import { registerWebAssetRoutes } from './routes/webAssets'; @@ -46,6 +62,36 @@ export interface ServerStartOptions { debugEndpoints?: boolean; + /** + * Override the classification of a wildcard bind (`0.0.0.0` / `::` / empty). + * Default (unset) treats wildcards as `public` (most strict); set to `lan` + * to relax to LAN-tier hardening. See `services/auth/bindClassify.ts`. + */ + bindClass?: 'lan' | 'public'; + + /** + * Allow a non-loopback bind WITHOUT a TLS-terminating reverse proxy. Default + * false: binding beyond loopback refuses to start unless this is set, so a + * public/LAN bind is never served over plain HTTP by accident. Pass + * `--insecure-no-tls` (or set this) only when you accept the risk. + */ + insecureNoTls?: boolean; + + /** + * Allow `POST /api/v1/shutdown` on a non-loopback bind. Default false: the + * shutdown route is NOT registered (404) on a public/LAN bind unless this is + * set. Loopback always mounts it. + */ + allowRemoteShutdown?: boolean; + + /** + * Allow the PTY `/api/v1/terminals/*` routes on a non-loopback bind. Default + * false: terminals routes are NOT registered (404) on a public/LAN bind + * unless this is set (remote shell is the highest-risk surface). Loopback + * always mounts them. + */ + allowRemoteTerminals?: boolean; + webAssetsDir?: string; serviceOverrides?: ReadonlyArray, unknown]>; @@ -88,6 +134,21 @@ export async function startServer(opts: ServerStartOptions): Promise (data) => JSON.stringify(data)); installErrorHandler(app); + // Host / Origin checks (ROADMAP M4.3). Registered before any route so they + // run ahead of every handler and ahead of the (future, M5.1) auth hook. + // Host is evaluated before Origin; both are uniform across bindings (PLAN + // D3) — even on loopback — so behavior does not depend on how the server is + // reached. The default-allow set keeps `app.inject` (`Host: localhost:80`) + // and real `fetch` to `127.0.0.1:` working. + const hostCheck = createHostCheck({ + boundHost: opts.host, + extra: parseAllowedHosts(process.env), + disable: isHostCheckDisabled(process.env), + }); + const originHook = createOriginHook({ allowedOrigins: parseCorsOrigins(process.env) }); + app.addHook('onRequest', hostCheck.onRequest); + app.addHook('onRequest', originHook); + const serverVersion = opts.coreProcessOptions?.identity?.version ?? getServerVersion(); async function registerOpenApi(): Promise { @@ -138,26 +199,138 @@ export async function startServer(opts: ServerStartOptions): Promise/server.token` + // (0600; generated once on first boot and reused across restarts) and an + // optional bcrypt password hash — both awaited here, then supplied to the + // collection via `serviceOverrides` so tests can inject a fixed-token impl + // that wins (last-wins) over this default. The store re-reads the file when + // its mtime changes, so `kimi server rotate-token` takes effect without a + // restart; the file is intentionally kept on shutdown (dispose is a no-op). + const tokenStore = await createTokenStore(envService.homeDir); + const passwordHash = await resolvePasswordHash(process.env); + const defaultAuth = createAuthTokenService({ tokenStore, passwordHash }); + + // Public-bind hardening gate (ROADMAP M6.3). Classify the bind host and, for + // any non-loopback tier (LAN or public), refuse to start unless the operator + // explicitly acknowledged that TLS is terminated elsewhere (`insecureNoTls`). + // Auth is bearer-token based: the persistent token is printed in the startup + // banner and reused across restarts, so a password is no longer mandatory for + // a non-loopback bind — `KIMI_CODE_PASSWORD` remains an optional additional + // credential. Failing here (before the container is built and before we + // listen) keeps a public/LAN bind from ever serving plain HTTP by accident. + // On refusal we release the lock so the operator can retry cleanly. + const bindClass = classify(opts.host, { bindClass: opts.bindClass }); + if (bindClass !== 'loopback') { + const refusePublicBind = async (message: string): Promise => { + try { + await tokenStore.dispose(); + } catch { + // best-effort cleanup of the token file on boot refusal + } + lockHandle.release(); + throw new Error(message); + }; + if (opts.insecureNoTls !== true) { + await refusePublicBind( + 'Refusing to bind a non-loopback host without TLS. ' + + 'Put the server behind a TLS-terminating reverse proxy (Caddy/nginx), ' + + 'or pass --insecure-no-tls to acknowledge the risk.', + ); + } + if (passwordHash === undefined) { + pinoLogger.warn( + { host: opts.host, bindClass }, + 'binding non-loopback host with token-only auth (no KIMI_CODE_PASSWORD) — the bearer token printed in the startup banner is the only credential protecting this server', + ); + } + pinoLogger.warn( + { host: opts.host, bindClass }, + 'binding non-loopback host without TLS — use a reverse proxy or tunnel in production', + ); + } + const services = createServerServiceCollection({ - server: opts, + server: { + ...opts, + // WS Host/Origin defaults (ROADMAP M4.3 / M5.1): mirror the HTTP checks + // on the upgrade path. Caller-supplied values win (used by the + // host-origin e2e tests). `authTokenService` is NOT threaded here — it + // reaches the WS gateway via `setAuthTokenService` below so the + // override-aware impl enforces auth. + wsGatewayOptions: { + ...opts.wsGatewayOptions, + hostCheck: opts.wsGatewayOptions?.hostCheck ?? { + boundHost: opts.host, + extra: parseAllowedHosts(process.env), + disable: isHostCheckDisabled(process.env), + }, + allowedOrigins: + opts.wsGatewayOptions?.allowedOrigins ?? parseCorsOrigins(process.env), + }, + serviceOverrides: [ + [IAuthTokenService, defaultAuth], + ...(opts.serviceOverrides ?? []), + ], + }, app, pinoLogger, envService, }); const ix = new InstantiationService(services); + // Auth hook (ROADMAP M5.1). Registered after Host/Origin (above) and before + // routes, so a rejected request never reaches a handler. Resolved from the + // container so a test-injected fixed-token impl is what enforces auth. + // + // Auth-failure rate limit (ROADMAP M6.4): only on a non-loopback bind, where + // brute-force attempts are reachable from the network. Loopback keeps the + // original "no limiter" behavior so local retries are never throttled. + const authTokenService = ix.invokeFunction((a) => a.get(IAuthTokenService)); + const authFailureLimiter = + bindClass !== 'loopback' ? createAuthFailureLimiter() : undefined; + app.addHook('onRequest', createAuthHook(authTokenService, { limiter: authFailureLimiter })); + + // Security response headers (ROADMAP M6.6): only on a non-loopback bind. + // TLS is terminated by a reverse proxy in this phase, so HSTS is omitted + // here (`tls: false`) — the proxy is responsible for setting it. + if (bindClass !== 'loopback') { + app.addHook('onSend', createSecurityHeadersHook({ tls: false })); + } + + // Bind classification (`bindClass`, computed above next to the password/TLS + // gate) drives every hardening decision from here on: debug routes now; + // rate limit, dangerous endpoints, and security headers in M6.4–M6.6. + + // Debug routes (ROADMAP M5.3): only mount `/api/v1/debug/*` when bound to a + // loopback interface. On a non-loopback bind these introspection/mutation + // endpoints would be reachable from the network, so suppress them even if + // the caller asked for them, and warn so the operator knows. + if (opts.debugEndpoints === true && bindClass !== 'loopback') { + pinoLogger.warn( + { host: opts.host, bindClass }, + 'debug endpoints suppressed: refusing to mount /api/v1/debug/* on a non-loopback bind', + ); + } + + // Dangerous-endpoint downgrade (ROADMAP M6.5): on a non-loopback bind the + // shutdown + terminals routes are NOT registered (404) unless the operator + // explicitly opts in. Loopback always mounts them (backward compatible). + const allowRemoteShutdown = opts.allowRemoteShutdown === true; + const allowRemoteTerminals = opts.allowRemoteTerminals === true; await registerApiV1Routes(app, ix, { serverVersion, - debugEndpoints: opts.debugEndpoints, + debugEndpoints: opts.debugEndpoints === true && bindClass === 'loopback', + enableShutdown: bindClass === 'loopback' || allowRemoteShutdown, + enableTerminals: bindClass === 'loopback' || allowRemoteTerminals, }); - app.get('/asyncapi.json', async (req, reply) => { - const host = typeof req.headers.host === 'string' ? req.headers.host : undefined; + app.get('/asyncapi.json', async (_req, reply) => { + // Reflect the bound host, never the caller-supplied `Host` header (PLAN + // §3.6-3: Host-header reflection is an information-leak / SSRF-adjacent + // hole once the server is reachable beyond localhost). return reply.type('application/json').send( - createAsyncApiDocument({ - version: serverVersion, - serverHost: host, - }), + createAsyncApiDocument({ version: serverVersion, serverHost: opts.host }), ); }); app.get('/openapi.json', async (_req, reply) => { @@ -172,6 +345,11 @@ export async function startServer(opts: ServerStartOptions): Promise matches the documented v1 route table and meta endpoints 1`] = ` +{ + "meta": [ + [ + "GET", + "/", + 404, + ], + [ + "GET", + "/asyncapi.json", + 200, + ], + [ + "GET", + "/healthz", + 404, + ], + [ + "GET", + "/openapi.json", + 200, + ], + ], + "routes": [ + [ + "DELETE", + "/api/v1/files/{file_id}", + ], + [ + "DELETE", + "/api/v1/oauth/login", + ], + [ + "DELETE", + "/api/v1/workspaces/{workspace_id}", + ], + [ + "GET", + "/api/v1/auth", + ], + [ + "GET", + "/api/v1/config", + ], + [ + "GET", + "/api/v1/connections", + ], + [ + "GET", + "/api/v1/files/{file_id}", + ], + [ + "GET", + "/api/v1/fs:browse", + ], + [ + "GET", + "/api/v1/fs:home", + ], + [ + "GET", + "/api/v1/healthz", + ], + [ + "GET", + "/api/v1/mcp/servers", + ], + [ + "GET", + "/api/v1/meta", + ], + [ + "GET", + "/api/v1/models", + ], + [ + "GET", + "/api/v1/oauth/login", + ], + [ + "GET", + "/api/v1/providers", + ], + [ + "GET", + "/api/v1/providers/{provider_id}", + ], + [ + "GET", + "/api/v1/sessions", + ], + [ + "GET", + "/api/v1/sessions/{session_id}", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/approvals", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/children", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/fs/{*}", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/messages", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/messages/{message_id}", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/profile", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/prompts", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/questions", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/skills", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/snapshot", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/status", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/tasks", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/tasks/{task_id}", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/terminals", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/terminals/{terminal_id}", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/warnings", + ], + [ + "GET", + "/api/v1/tools", + ], + [ + "GET", + "/api/v1/workspaces", + ], + [ + "GET", + "/asyncapi.json", + ], + [ + "GET", + "/openapi.json", + ], + [ + "PATCH", + "/api/v1/workspaces/{workspace_id}", + ], + [ + "POST", + "/api/v1/config", + ], + [ + "POST", + "/api/v1/files", + ], + [ + "POST", + "/api/v1/mcp/servers/{tail}", + ], + [ + "POST", + "/api/v1/models/{tail}", + ], + [ + "POST", + "/api/v1/oauth/login", + ], + [ + "POST", + "/api/v1/oauth/logout", + ], + [ + "POST", + "/api/v1/providers{refresh_oauth}", + ], + [ + "POST", + "/api/v1/sessions", + ], + [ + "POST", + "/api/v1/sessions/{session_id}:compact", + ], + [ + "POST", + "/api/v1/sessions/{session_id}:fork", + ], + [ + "POST", + "/api/v1/sessions/{session_id}:undo", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/{tail}", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/approvals/{approval_id}", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/children", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/profile", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/prompts", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/prompts:steer", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/prompts/{tail}", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/questions/{tail}", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/skills/{tail}", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/tasks/{tail}", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/terminals", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/terminals/{tail}", + ], + [ + "POST", + "/api/v1/shutdown", + ], + [ + "POST", + "/api/v1/workspaces", + ], + ], +} +`; diff --git a/packages/server/test/api-surface.snapshot.test.ts b/packages/server/test/api-surface.snapshot.test.ts new file mode 100644 index 000000000..ecf971da6 --- /dev/null +++ b/packages/server/test/api-surface.snapshot.test.ts @@ -0,0 +1,113 @@ +/** + * API surface snapshot guardrail (ROADMAP M0.1). + * + * Boots `startServer` on port 0 with a tmpdir lock + isolated home dir, then + * records a stable, sorted snapshot of the documented API surface: + * + * - `routes`: every `[METHOD, path]` pair derived from `/openapi.json` + * `paths` (the documented v1 REST surface). This is the guardrail's target: + * later phases that add / remove / hide routes show an intentional diff. + * - `meta`: the `(method, url, status)` of doc/meta endpoints that sit + * outside `paths` (`/healthz`, `/openapi.json`, `/asyncapi.json`, `/`). + * Status codes prove reachability. + * + * `startServer` does not expose the Fastify `app`, so the surface is read + * through the public `/openapi.json` endpoint rather than by inspecting the + * route table directly — keeping M0 a no-behavior-change phase. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { startServer, type RunningServer } from '../src'; +import { authHeaders, fixedTokenAuth } from './helpers/serverHarness'; + +/** OpenAPI path-item keys that are HTTP methods (skip `parameters`, etc.). */ +const HTTP_METHODS = new Set([ + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'patch', + 'trace', +]); + +/** Meta endpoints outside the OpenAPI `paths` map to probe for reachability. */ +const META_ENDPOINTS = ['/healthz', '/openapi.json', '/asyncapi.json', '/']; + +describe('API surface snapshot', () => { + let tmpDir: string | undefined; + let bridgeHome: string | undefined; + let server: RunningServer | undefined; + + afterEach(async () => { + if (server !== undefined) { + try { + await server.close(); + } catch { + // ignore — best-effort teardown + } + server = undefined; + } + if (tmpDir !== undefined) { + rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + if (bridgeHome !== undefined) { + rmSync(bridgeHome, { recursive: true, force: true }); + bridgeHome = undefined; + } + }); + + it('matches the documented v1 route table and meta endpoints', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-api-surface-')); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-api-surface-home-')); + const lockPath = join(tmpDir, 'lock'); + + server = await startServer({ + serviceOverrides: [fixedTokenAuth()], + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir: bridgeHome }, + }); + + const address = server.address; + + // 1) Documented v1 REST surface, derived from /openapi.json `paths`. + const openApiRes = await fetch(`${address}/openapi.json`, { headers: authHeaders() }); + expect(openApiRes.status).toBe(200); + const openApi = (await openApiRes.json()) as { + paths?: Record>; + }; + const paths = openApi.paths ?? {}; + expect(Object.keys(paths).length).toBeGreaterThan(0); + + const routes: Array<[string, string]> = []; + for (const [path, item] of Object.entries(paths)) { + for (const key of Object.keys(item)) { + if (HTTP_METHODS.has(key.toLowerCase())) { + routes.push([key.toUpperCase(), path]); + } + } + } + routes.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1])); + + // 2) Doc/meta endpoints that are not part of the OpenAPI `paths` map. + const meta: Array<[string, string, number]> = []; + for (const endpoint of META_ENDPOINTS) { + const res = await fetch(`${address}${endpoint}`, { headers: authHeaders() }); + meta.push(['GET', endpoint, res.status]); + } + meta.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]) || a[2] - b[2]); + + expect({ routes, meta }).toMatchSnapshot(); + }); +}); diff --git a/packages/server/test/approval.e2e.test.ts b/packages/server/test/approval.e2e.test.ts index cf9bb8f06..011075eae 100644 --- a/packages/server/test/approval.e2e.test.ts +++ b/packages/server/test/approval.e2e.test.ts @@ -34,6 +34,7 @@ import { } from '@moonshot-ai/agent-core'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { rawDataToString } from '../src/ws/rawData'; import { ApprovalService, @@ -63,6 +64,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -76,12 +78,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { @@ -123,7 +137,7 @@ async function openSubscriber( const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws'; const received: Record[] = []; const ws = await new Promise((resolve, reject) => { - const sock = new WebSocket(wsUrl); + const sock = new WebSocket(wsUrl, ['kimi-code.bearer.test-token']); sock.on('message', (data) => { try { received.push(JSON.parse(rawDataToString(data)) as Record); diff --git a/packages/server/test/auth-middleware.e2e.test.ts b/packages/server/test/auth-middleware.e2e.test.ts new file mode 100644 index 000000000..15f33dfb0 --- /dev/null +++ b/packages/server/test/auth-middleware.e2e.test.ts @@ -0,0 +1,188 @@ +import { request as httpRequest } from 'node:http'; + +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { createAuthHook } from '#/middleware/auth'; +import type { IAuthTokenService } from '#/services/auth/authTokenService'; + +import { boot, closeAll } from './helpers/serverHarness'; + +const TOKEN = 'test-token'; + +function fixedImpl(): IAuthTokenService { + return { + _serviceBrand: undefined, + getToken: () => TOKEN, + isValid: async (candidate) => candidate === TOKEN, + }; +} + +describe('createAuthHook (onRequest middleware)', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = Fastify(); + app.addHook('onRequest', createAuthHook(fixedImpl())); + + app.get('/api/v1/healthz', async () => ({ ok: true })); + app.get('/api/v1/sessions', async () => ({ ok: true })); + app.options('/api/v1/sessions', async (_req, reply) => + reply.code(204).send(), + ); + app.get('/', async () => ({ ok: true })); + app.get('/openapi.json', async () => ({ openapi: '3.1.0' })); + // Probe surfaces the post-hook header view so we can assert redaction. + app.get('/api/v1/probe', async (req) => ({ + authorization: req.headers.authorization ?? null, + })); + + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('bypasses GET /api/v1/healthz with no token', async () => { + const res = await app.inject({ method: 'GET', url: '/api/v1/healthz' }); + expect(res.statusCode).toBe(200); + }); + + it('rejects GET /api/v1/sessions with no token (40101 envelope)', async () => { + const res = await app.inject({ method: 'GET', url: '/api/v1/sessions' }); + expect(res.statusCode).toBe(401); + const body = res.json() as Record; + expect(body['code']).toBe(40101); + expect(body['msg']).toBe('Unauthorized'); + expect(body['data']).toBeNull(); + expect(typeof body['request_id']).toBe('string'); + }); + + it('rejects GET /api/v1/sessions with a wrong token', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/sessions', + headers: { authorization: 'Bearer wrong-token' }, + }); + expect(res.statusCode).toBe(401); + expect((res.json() as Record)['code']).toBe(40101); + }); + + it('accepts GET /api/v1/sessions with the correct bearer token', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/sessions', + headers: { authorization: `Bearer ${TOKEN}` }, + }); + expect(res.statusCode).toBe(200); + }); + + it('rejects a malformed Authorization header', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/sessions', + headers: { authorization: TOKEN }, + }); + expect(res.statusCode).toBe(401); + }); + + it('bypasses OPTIONS /api/v1/sessions with no token', async () => { + const res = await app.inject({ + method: 'OPTIONS', + url: '/api/v1/sessions', + }); + expect(res.statusCode).toBe(204); + }); + + it('bypasses GET / (static asset) with no token', async () => { + const res = await app.inject({ method: 'GET', url: '/' }); + expect(res.statusCode).toBe(200); + }); + + it('does NOT bypass GET /openapi.json (meta doc stays gated)', async () => { + const res = await app.inject({ method: 'GET', url: '/openapi.json' }); + expect(res.statusCode).toBe(401); + expect((res.json() as Record)['code']).toBe(40101); + }); + + it('redacts the Authorization header view before the handler runs', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/probe', + headers: { authorization: `Bearer ${TOKEN}` }, + }); + expect(res.statusCode).toBe(200); + const body = res.json() as { authorization: string | null }; + expect(body.authorization).toBe('[redacted]'); + }); +}); + +/** + * Raw HTTP GET that lets us spoof the `Host` header. Node's `http.request` + * honors an explicit `headers.Host`, whereas `fetch` (undici) treats `Host` as + * a forbidden header and drops it. + */ +function rawGet( + port: number, + path: string, + headers: Record, +): Promise<{ status: number; body: unknown }> { + return new Promise((resolve, reject) => { + const req = httpRequest( + { host: '127.0.0.1', port, path, method: 'GET', headers }, + (res) => { + let data = ''; + res.setEncoding('utf8'); + res.on('data', (chunk: string) => { + data += chunk; + }); + res.on('end', () => { + try { + resolve({ + status: res.statusCode ?? 0, + body: JSON.parse(data) as unknown, + }); + } catch (err) { + reject(err); + } + }); + }, + ); + req.on('error', reject); + req.end(); + }); +} + +describe('/asyncapi.json serverHost (M2.3 Host-reflection fix)', () => { + afterEach(async () => { + await closeAll(); + }); + + it('rejects a spoofed Host header (M4.3) and otherwise reflects the bound host', async () => { + const harness = await boot({ host: '127.0.0.1', port: 0 }); + const port = Number(new URL(harness.address).port); + + // M4.3: a spoofed / disallowed Host is rejected by the global Host check + // before it ever reaches the route — a stronger guarantee than the M2.3 + // "reflect bound host" fix, since the spoofed value can no longer leak. + const spoofed = await rawGet(port, '/asyncapi.json', { + Host: 'evil.com', + }); + expect(spoofed.status).toBe(403); + + // With an allowed Host, the route responds and still reflects the BOUND + // host, never the caller-supplied Host header (the original M2.3 fix). + // /asyncapi.json is gated by the M5.1 auth hook, so carry the fixed token + // the harness injected via `boot()`. + const { status, body } = await rawGet(port, '/asyncapi.json', { + Host: '127.0.0.1', + Authorization: 'Bearer test-token', + }); + expect(status).toBe(200); + + const servers = (body as { servers: { local: { host: string } } }).servers; + expect(servers.local.host).toBe('127.0.0.1'); + expect(servers.local.host).not.toContain('evil.com'); + }); +}); diff --git a/packages/server/test/auth-wiring.e2e.test.ts b/packages/server/test/auth-wiring.e2e.test.ts new file mode 100644 index 000000000..249fd58c4 --- /dev/null +++ b/packages/server/test/auth-wiring.e2e.test.ts @@ -0,0 +1,208 @@ +/** + * Production auth wiring end-to-end (ROADMAP M5.1). + * + * Unlike the override-driven tests (which inject a fixed-token + * `IAuthTokenService`), this file boots `startServer` with NO auth override so + * the REAL `defaultAuth` is built: a persistent token written to + * `/server.token` (0600) plus the HTTP/WS auth hooks. The token + * is read back from disk — exactly what the CLI does (M5.4) — and exercised + * against a gated HTTP route and the WS upgrade path. This proves the + * production wiring, not just the override seam. + */ + +import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { WebSocket, type RawData } from 'ws'; + +import { startServer, type RunningServer } from '../src'; +import { rawDataToString } from '../src/ws/rawData'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +const running: RunningServer[] = []; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-auth-wiring-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-auth-wiring-home-')); +}); + +afterEach(async () => { + for (const r of running.splice(0)) { + try { + await r.close(); + } catch { + // ignore + } + } + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +function tokenPath(): string { + return join(bridgeHome, 'server.token'); +} + +function readToken(): string { + return readFileSync(tokenPath(), 'utf8').trim(); +} + +async function bootReal(): Promise { + const r = await startServer({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir: bridgeHome }, + }); + running.push(r); + return r; +} + +function wsUrl(http: string): string { + return http.replace(/^http:\/\//, 'ws://') + '/api/v1/ws'; +} + +interface WsFrame { + type: string; + [k: string]: unknown; +} + +interface Conn { + ws: WebSocket; + queue: WsFrame[]; + waiters: Array<(frame: WsFrame) => void>; + closed: Promise<{ code: number; reason: string }>; +} + +function openConn(url: string, protocols?: string[]): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, protocols); + const queue: WsFrame[] = []; + const waiters: Array<(frame: WsFrame) => void> = []; + let closedResolve: (v: { code: number; reason: string }) => void; + const closed = new Promise<{ code: number; reason: string }>((res) => { + closedResolve = res; + }); + // Attach the message listener BEFORE 'open' can fire so the immediate + // `server_hello` frame is never dropped. + ws.on('message', (data: RawData) => { + try { + const frame = JSON.parse(rawDataToString(data)) as WsFrame; + if (waiters.length > 0) waiters.shift()?.(frame); + else queue.push(frame); + } catch { + // ignore non-JSON frames + } + }); + ws.on('close', (code, reason) => closedResolve({ code, reason: String(reason) })); + ws.once('open', () => resolve({ ws, queue, waiters, closed })); + ws.once('error', reject); + }); +} + +function receive(conn: Conn, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + if (conn.queue.length > 0) { + resolve(conn.queue.shift()!); + return; + } + const t = setTimeout(() => { + const idx = conn.waiters.indexOf(waiter); + if (idx >= 0) conn.waiters.splice(idx, 1); + reject(new Error(`no message within ${timeoutMs}ms`)); + }, timeoutMs); + const waiter = (frame: WsFrame): void => { + clearTimeout(t); + resolve(frame); + }; + conn.waiters.push(waiter); + }); +} + +async function receiveType(conn: Conn, type: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error(`no message of type ${type} within ${timeoutMs}ms`); + const frame = await receive(conn, remaining); + if (frame.type === type) return frame; + } +} + +function expectRejected(url: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + const t = setTimeout( + () => done(new Error('connection was not rejected within timeout')), + 1500, + ); + const done = (err?: Error): void => { + clearTimeout(t); + ws.removeAllListeners(); + try { + ws.terminate(); + } catch { + // ignore + } + if (err === undefined) { + resolve(); + } else { + reject(err); + } + }; + ws.once('open', () => done(new Error('connection unexpectedly opened'))); + ws.once('error', () => done()); + ws.once('close', () => done()); + }); +} + +describe('production auth wiring (M5.1)', () => { + it('writes a 0600 token file at boot and keeps it on close (persistent)', async () => { + const r = await bootReal(); + const p = tokenPath(); + + const info = statSync(p); + expect(info.mode & 0o777).toBe(0o600); + + const token = readToken(); + expect(token.length).toBeGreaterThan(0); + + await r.close(); + // Persistent token: the file survives shutdown so the next start reuses it. + expect(statSync(p).mode & 0o777).toBe(0o600); + }); + + it('gates HTTP: 200 with the token, 401 without', async () => { + const r = await bootReal(); + const token = readToken(); + + const ok = await fetch(`${r.address}/openapi.json`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(ok.status).toBe(200); + + const bad = await fetch(`${r.address}/openapi.json`); + expect(bad.status).toBe(401); + const body = (await bad.json()) as { code: number }; + expect(body.code).toBe(40101); + }); + + it('gates WS: server_hello with the token, rejected without', async () => { + const r = await bootReal(); + const token = readToken(); + + const conn = await openConn(wsUrl(r.address), [`kimi-code.bearer.${token}`]); + const hello = await receiveType(conn, 'server_hello', 1000); + expect(hello.type).toBe('server_hello'); + conn.ws.close(); + await conn.closed; + + await expectRejected(wsUrl(r.address)); + }); +}); diff --git a/packages/server/test/auth.e2e.test.ts b/packages/server/test/auth.e2e.test.ts index 93d5a1d41..8c99f6915 100644 --- a/packages/server/test/auth.e2e.test.ts +++ b/packages/server/test/auth.e2e.test.ts @@ -32,6 +32,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { authSummarySchema, type AuthSummary } from '@moonshot-ai/protocol'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -57,6 +58,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -69,12 +71,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/authTokenService.test.ts b/packages/server/test/authTokenService.test.ts new file mode 100644 index 000000000..e7a7ca1ff --- /dev/null +++ b/packages/server/test/authTokenService.test.ts @@ -0,0 +1,127 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import type { Server as HttpServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + InstantiationService, + type IEnvironmentService, +} from '@moonshot-ai/agent-core'; +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + createAuthTokenService, + IAuthTokenService, +} from '#/services/auth/authTokenService'; +import { resolvePasswordHash } from '#/services/auth/password'; +import { createTokenStore, type TokenStore } from '#/services/auth/tokenStore'; +import type { FastifyLike } from '#/services/gateway/restGateway'; +import { createServerServiceCollection } from '#/services/serviceCollection'; + +function tmpEnv(): { dir: string; env: IEnvironmentService } { + const dir = mkdtempSync(join(tmpdir(), 'kimi-auth-token-test-')); + return { + dir, + env: { + _serviceBrand: undefined, + homeDir: dir, + configPath: join(dir, 'config.toml'), + }, + }; +} + +/** Minimal Fastify shape — `FastifyRestGateway` only stores it at construction. */ +function appStub(): FastifyLike { + return { + server: {} as unknown as HttpServer, + listen: async () => 'http://127.0.0.1:0', + close: async () => {}, + }; +} + +describe('createAuthTokenService', () => { + let homeDir: string; + let store: TokenStore; + + beforeEach(async () => { + homeDir = mkdtempSync(join(tmpdir(), 'kimi-auth-token-store-')); + store = await createTokenStore(homeDir); + }); + + afterEach(async () => { + await store.dispose(); + rmSync(homeDir, { recursive: true, force: true }); + }); + + it('getToken() returns the tokenStore token', () => { + const svc = createAuthTokenService({ tokenStore: store, passwordHash: undefined }); + expect(svc.getToken()).toBe(store.getToken()); + }); + + it('isValid accepts the token', async () => { + const svc = createAuthTokenService({ tokenStore: store, passwordHash: undefined }); + expect(await svc.isValid(store.getToken())).toBe(true); + }); + + it('isValid accepts the password when a hash is configured', async () => { + const passwordHash = await resolvePasswordHash({ + KIMI_CODE_PASSWORD: 'correct horse battery staple', + }); + const svc = createAuthTokenService({ tokenStore: store, passwordHash }); + expect(await svc.isValid('correct horse battery staple')).toBe(true); + }); + + it('isValid rejects a wrong candidate', async () => { + const passwordHash = await resolvePasswordHash({ + KIMI_CODE_PASSWORD: 'correct horse battery staple', + }); + const svc = createAuthTokenService({ tokenStore: store, passwordHash }); + expect(await svc.isValid('wrong')).toBe(false); + }); + + it('isValid accepts only the token when passwordHash is undefined', async () => { + const svc = createAuthTokenService({ tokenStore: store, passwordHash: undefined }); + expect(await svc.isValid(store.getToken())).toBe(true); + expect(await svc.isValid('any-password')).toBe(false); + }); +}); + +describe('IAuthTokenService via serviceCollection override', () => { + let env: IEnvironmentService; + let dir: string; + let ix: InstantiationService; + + const fixed: IAuthTokenService = { + _serviceBrand: undefined, + getToken: () => 'fixed-token', + isValid: async (candidate) => candidate === 'fixed-token', + }; + + beforeEach(() => { + ({ env, dir } = tmpEnv()); + const collection = createServerServiceCollection({ + server: { + host: '127.0.0.1', + port: 0, + serviceOverrides: [[IAuthTokenService, fixed] as const], + }, + app: appStub(), + pinoLogger: pino({ level: 'silent' }), + envService: env, + }); + ix = new InstantiationService(collection); + }); + + afterEach(() => { + ix.dispose(); + rmSync(dir, { recursive: true, force: true }); + }); + + it('retrieves the injected impl and validates its own token', async () => { + const resolved = ix.invokeFunction((a) => a.get(IAuthTokenService)); + expect(resolved).toBe(fixed); + expect(await resolved.isValid(resolved.getToken())).toBe(true); + expect(await resolved.isValid('nope')).toBe(false); + }); +}); diff --git a/packages/server/test/bindClassify.test.ts b/packages/server/test/bindClassify.test.ts new file mode 100644 index 000000000..a9e91544c --- /dev/null +++ b/packages/server/test/bindClassify.test.ts @@ -0,0 +1,85 @@ +/** + * `classify(host)` tier classification (ROADMAP M6.1). + * + * Pins the loopback / lan / public boundaries that gate every M6 hardening + * decision, including the wildcard defaults and the RFC1918 / link-local + * edges (e.g. `172.31.255.255` inside `172.16/12`, `172.32.0.1` just outside). + */ + +import { describe, expect, it } from 'vitest'; + +import { classify } from '../src/services/auth/bindClassify'; + +describe('classify', () => { + describe('loopback', () => { + it.each([ + ['127.0.0.1'], + ['127.255.255.255'], + ['::1'], + ['localhost'], + ])('%s → loopback', (host) => { + expect(classify(host)).toBe('loopback'); + }); + }); + + describe('lan', () => { + it.each([ + ['192.168.1.5'], + ['10.0.0.1'], + ['172.16.0.1'], + ['172.31.255.255'], + ['169.254.1.1'], + ['fe80::1'], + ['fe80:0000:0000:0000:0000:0000:0000:0001'], + ['febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff'], + ])('%s → lan', (host) => { + expect(classify(host)).toBe('lan'); + }); + }); + + describe('public', () => { + it.each([ + ['8.8.8.8'], + ['172.32.0.1'], + ['203.0.113.5'], + ['2001:4860:4860::8888'], + ['fec0::1'], + ['example.com'], + ])('%s → public', (host) => { + expect(classify(host)).toBe('public'); + }); + }); + + describe('wildcard binds default to public unless relaxed', () => { + it('0.0.0.0 → public by default', () => { + expect(classify('0.0.0.0')).toBe('public'); + }); + + it('0.0.0.0 → lan when bindClass=lan', () => { + expect(classify('0.0.0.0', { bindClass: 'lan' })).toBe('lan'); + }); + + it('0.0.0.0 → public when bindClass=public (explicit)', () => { + expect(classify('0.0.0.0', { bindClass: 'public' })).toBe('public'); + }); + + it(':: → public by default', () => { + expect(classify('::')).toBe('public'); + }); + + it(':: → lan when bindClass=lan', () => { + expect(classify('::', { bindClass: 'lan' })).toBe('lan'); + }); + + it('empty string → public by default', () => { + expect(classify('')).toBe('public'); + }); + }); + + it('bindClass override does not reclassify a concrete loopback/lan host', () => { + // The override applies only to wildcard binds; a real loopback stays + // loopback even if a caller passes bindClass=public by mistake. + expect(classify('127.0.0.1', { bindClass: 'public' })).toBe('loopback'); + expect(classify('192.168.1.5', { bindClass: 'public' })).toBe('lan'); + }); +}); diff --git a/packages/server/test/config.e2e.test.ts b/packages/server/test/config.e2e.test.ts index 6a8c24e01..dab1713ab 100644 --- a/packages/server/test/config.e2e.test.ts +++ b/packages/server/test/config.e2e.test.ts @@ -6,6 +6,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -31,6 +32,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -43,12 +45,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/connections.e2e.test.ts b/packages/server/test/connections.e2e.test.ts index 7856a5638..d5c7c3644 100644 --- a/packages/server/test/connections.e2e.test.ts +++ b/packages/server/test/connections.e2e.test.ts @@ -29,6 +29,7 @@ import { startServer, type RunningServer, } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { rawDataToString } from '../src/ws/rawData'; let tmpDir: string; @@ -56,6 +57,7 @@ afterEach(async () => { async function spawn(): Promise { const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -71,12 +73,24 @@ async function spawn(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function wsUrl(http: string): string { @@ -100,7 +114,7 @@ interface Conn { function openConn(url: string): Promise { return new Promise((resolve, reject) => { - const ws = new WebSocket(url); + const ws = new WebSocket(url, ['kimi-code.bearer.test-token']); const queue: WsFrame[] = []; const waiters: Array<(frame: WsFrame) => void> = []; let closedResolve: (v: { code: number; reason: string }) => void; diff --git a/packages/server/test/debug-nonloopback.e2e.test.ts b/packages/server/test/debug-nonloopback.e2e.test.ts new file mode 100644 index 000000000..418ffd2d7 --- /dev/null +++ b/packages/server/test/debug-nonloopback.e2e.test.ts @@ -0,0 +1,93 @@ +/** + * Debug-route loopback gating (ROADMAP M5.3). + * + * `/api/v1/debug/*` routes are test-only introspection/mutation endpoints. + * They must only be mounted when the server is bound to a loopback interface; + * on a non-loopback bind (e.g. `0.0.0.0`) they are suppressed even when the + * caller passes `debugEndpoints: true`. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { startServer, type RunningServer } from '../src'; +import { authHeaders, fixedTokenAuth } from './helpers/serverHarness'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +let prevPassword: string | undefined; +const running: RunningServer[] = []; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-debug-loopback-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-debug-loopback-home-')); + // M6.3: a non-loopback bind (0.0.0.0) now refuses to start without a + // password + TLS opt-out. Set a password so the 0.0.0.0 case can boot; the + // fixed-token override still governs auth (password ≠ token). + prevPassword = process.env['KIMI_CODE_PASSWORD']; + process.env['KIMI_CODE_PASSWORD'] = 'test-pw'; +}); + +afterEach(async () => { + for (const r of running.splice(0)) { + try { + await r.close(); + } catch { + // ignore + } + } + if (prevPassword === undefined) { + delete process.env['KIMI_CODE_PASSWORD']; + } else { + process.env['KIMI_CODE_PASSWORD'] = prevPassword; + } + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function boot(host: string): Promise { + const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], + host, + port: 0, + lockPath: host === '0.0.0.0' ? join(tmpDir, `lock-${host}`) : lockPath, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir: bridgeHome }, + debugEndpoints: true, + // M6.3: acknowledge the lack of a TLS proxy so the 0.0.0.0 bind is allowed + // (loopback ignores this — the gate only fires for non-loopback). + insecureNoTls: true, + }); + running.push(r); + return r; +} + +/** Probe a debug route on `127.0.0.1:` regardless of the bound host. */ +async function probeDebug(r: RunningServer): Promise { + const port = new URL(r.address).port; + const res = await fetch(`http://127.0.0.1:${port}/api/v1/debug/prompts/some-session/state`, { + headers: authHeaders(), + }); + return res.status; +} + +describe('debug endpoints are loopback-only (M5.3)', () => { + it('does NOT mount /api/v1/debug/* on a non-loopback bind (0.0.0.0)', async () => { + const r = await boot('0.0.0.0'); + // Route suppressed → Fastify 404 (the auth hook would 401 if the route + // existed without a token, but with a token a missing route is 404). + expect(await probeDebug(r)).toBe(404); + }); + + it('mounts /api/v1/debug/* on a loopback bind (127.0.0.1)', async () => { + const r = await boot('127.0.0.1'); + // Route mounted + valid token → 200 (data is null for an unknown session). + expect(await probeDebug(r)).toBe(200); + }); +}); diff --git a/packages/server/test/files.e2e.test.ts b/packages/server/test/files.e2e.test.ts index 7291e60c8..2f6f51fa4 100644 --- a/packages/server/test/files.e2e.test.ts +++ b/packages/server/test/files.e2e.test.ts @@ -28,6 +28,7 @@ import { startServer, type RunningServer, } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -53,6 +54,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -76,10 +78,22 @@ interface FastifyAppLike { } function appOf(r: RunningServer): FastifyAppLike { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as FastifyAppLike; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } interface Envelope { diff --git a/packages/server/test/fs-basic.e2e.test.ts b/packages/server/test/fs-basic.e2e.test.ts index 70323a2c0..5ae590821 100644 --- a/packages/server/test/fs-basic.e2e.test.ts +++ b/packages/server/test/fs-basic.e2e.test.ts @@ -28,6 +28,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -56,6 +57,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -71,15 +73,27 @@ function appOf(r: RunningServer): { json: () => unknown; }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ - statusCode: number; - json: () => unknown; - }>; - }; + inject: (req: unknown) => Promise<{ + statusCode: number; + json: () => unknown; + }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/fs-batch.e2e.test.ts b/packages/server/test/fs-batch.e2e.test.ts index 588a59bc8..ab081be68 100644 --- a/packages/server/test/fs-batch.e2e.test.ts +++ b/packages/server/test/fs-batch.e2e.test.ts @@ -28,6 +28,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -56,6 +57,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -71,15 +73,27 @@ function appOf(r: RunningServer): { json: () => unknown; }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ - statusCode: number; - json: () => unknown; - }>; - }; + inject: (req: unknown) => Promise<{ + statusCode: number; + json: () => unknown; + }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/fs-browse.e2e.test.ts b/packages/server/test/fs-browse.e2e.test.ts index 471cc40b8..d217fecd1 100644 --- a/packages/server/test/fs-browse.e2e.test.ts +++ b/packages/server/test/fs-browse.e2e.test.ts @@ -18,6 +18,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import type { FsBrowseEntry, FsBrowseResponse, @@ -55,6 +56,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -67,12 +69,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/fs-download.e2e.test.ts b/packages/server/test/fs-download.e2e.test.ts index 444afcce6..88d658d56 100644 --- a/packages/server/test/fs-download.e2e.test.ts +++ b/packages/server/test/fs-download.e2e.test.ts @@ -30,6 +30,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -58,6 +59,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -79,12 +81,24 @@ interface InjectResponse { function appOf(r: RunningServer): { inject: (req: unknown) => Promise; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise; - }; + inject: (req: unknown) => Promise; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/fs-git.e2e.test.ts b/packages/server/test/fs-git.e2e.test.ts index 70b98b30c..9f63efdde 100644 --- a/packages/server/test/fs-git.e2e.test.ts +++ b/packages/server/test/fs-git.e2e.test.ts @@ -14,6 +14,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { parsePorcelain, parseNumstat } from '@moonshot-ai/agent-core'; let tmpDir: string; @@ -43,6 +44,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -58,15 +60,27 @@ function appOf(r: RunningServer): { json: () => unknown; }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ - statusCode: number; - json: () => unknown; - }>; - }; + inject: (req: unknown) => Promise<{ + statusCode: number; + json: () => unknown; + }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/fs-mkdir.e2e.test.ts b/packages/server/test/fs-mkdir.e2e.test.ts index 8be7a6297..6624bcc4d 100644 --- a/packages/server/test/fs-mkdir.e2e.test.ts +++ b/packages/server/test/fs-mkdir.e2e.test.ts @@ -19,6 +19,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -47,6 +48,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -62,15 +64,27 @@ function appOf(r: RunningServer): { json: () => unknown; }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ - statusCode: number; - json: () => unknown; - }>; - }; + inject: (req: unknown) => Promise<{ + statusCode: number; + json: () => unknown; + }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/fs-search.e2e.test.ts b/packages/server/test/fs-search.e2e.test.ts index 12471b6db..4a887dfb2 100644 --- a/packages/server/test/fs-search.e2e.test.ts +++ b/packages/server/test/fs-search.e2e.test.ts @@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { ISessionService, FsSearchService, ILogService } from '@moonshot-ai/agent-core'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -43,6 +44,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -58,15 +60,27 @@ function appOf(r: RunningServer): { json: () => unknown; }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ - statusCode: number; - json: () => unknown; - }>; - }; + inject: (req: unknown) => Promise<{ + statusCode: number; + json: () => unknown; + }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/fs-watch.e2e.test.ts b/packages/server/test/fs-watch.e2e.test.ts index 6535443be..ab34d5cbf 100644 --- a/packages/server/test/fs-watch.e2e.test.ts +++ b/packages/server/test/fs-watch.e2e.test.ts @@ -43,6 +43,7 @@ import { startServer, type RunningServer, } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { rawDataToString } from '../src/ws/rawData'; let tmpDir: string; @@ -74,6 +75,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -90,15 +92,27 @@ function appOf(r: RunningServer): { json: () => unknown; }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ - statusCode: number; - json: () => unknown; - }>; - }; + inject: (req: unknown) => Promise<{ + statusCode: number; + json: () => unknown; + }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } async function createSession(r: RunningServer): Promise { @@ -136,7 +150,7 @@ interface Conn { function openConn(url: string): Promise { return new Promise((resolve, reject) => { - const ws = new WebSocket(url); + const ws = new WebSocket(url, ['kimi-code.bearer.test-token']); const queue: WsFrame[] = []; const waiters: Array<(frame: WsFrame) => void> = []; ws.on('message', (data) => { diff --git a/packages/server/test/helpers/serverHarness.ts b/packages/server/test/helpers/serverHarness.ts new file mode 100644 index 000000000..ddc6cbe03 --- /dev/null +++ b/packages/server/test/helpers/serverHarness.ts @@ -0,0 +1,229 @@ +/** + * Reusable e2e server harness with token support (ROADMAP M0.2 / M5.1). + * + * Wraps `startServer` with an isolated tmp lock + home dir and exposes helpers + * (`authedFetch` / `authedWs`) that carry an `Authorization: Bearer `. + * + * From M5.1 the server enforces bearer auth, so `boot()` injects a fixed-token + * `IAuthTokenService` (default token `test-token`) via `serviceOverrides` by + * default — keeping harness-based tests transparent. A caller-supplied + * `IAuthTokenService` override still wins (serviceOverrides are last-wins), so + * tests that need a custom impl can pass one explicitly. For tests that boot + * `startServer` directly, use the exported {@link fixedTokenAuth} / + * {@link withAuth} / {@link authHeaders} helpers to inject the same fixed token + * and carry it on each request. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { ServiceIdentifier } from '@moonshot-ai/agent-core'; +import { pino } from 'pino'; +import { WebSocket } from 'ws'; + +import { startServer, type RunningServer, type ServerStartOptions } from '../../src'; +import { IAuthTokenService } from '../../src/services/auth/authTokenService'; +import type { IAuthTokenService as IAuthTokenServiceType } from '../../src/services/auth/authTokenService'; + +type ServiceOverride = readonly [ServiceIdentifier, unknown]; + +/** Default deterministic token used when a test does not supply one. */ +const DEFAULT_TOKEN = 'test-token'; + +/** + * Build a `[IAuthTokenService, impl]` override pair that accepts a single fixed + * `token`. Pass it into `startServer({ serviceOverrides: [fixedTokenAuth()] })` + * (or `boot({ serviceOverrides: [fixedTokenAuth()] })`) so a test can + * authenticate with `Authorization: Bearer test-token` — or any custom `token`. + * + * `getToken` returns the fixed token so callers that read it back (e.g. WS + * subprotocol helpers) stay consistent; `isValid` accepts only that token. + */ +export function fixedTokenAuth(token: string = DEFAULT_TOKEN): ServiceOverride { + const impl: IAuthTokenServiceType = { + _serviceBrand: undefined, + getToken: () => token, + isValid: async (candidate) => candidate === token, + }; + return [IAuthTokenService, impl]; +} + +/** An `Authorization: Bearer ` header bag, for spreading into `headers`. */ +export function authHeaders(token: string = DEFAULT_TOKEN): { Authorization: string } { + return { Authorization: `Bearer ${token}` }; +} + +/** + * Merge `Authorization: Bearer ` into a `fetch` `RequestInit`, preserving + * caller-supplied headers. A caller-supplied `Authorization` wins, so tests that + * need to send a wrong/no token can still do so explicitly. + */ +export function withAuth(init: RequestInit = {}, token: string = DEFAULT_TOKEN): RequestInit { + const headers = new Headers(init.headers); + if (!headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${token}`); + } + return { ...init, headers }; +} + +/** + * WS subprotocol prefix that carries the bearer token during the upgrade. + * Hardcoded here as a literal for M0; `WS_BEARER_PROTOCOL_PREFIX` is introduced + * in M3.1 and may replace this literal when the WS auth seam lands. + */ +const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.'; + +export interface BootOptions { + /** Bearer token attached by `authedFetch` / `authedWs`. Defaults to `'test-token'`. */ + token?: string; + /** + * Generic pass-through to `startServer({ serviceOverrides })`. The auth seam: + * from M2.1 on, callers inject `[IAuthTokenService, fixedTokenImpl]` here. + * Defaults to `[]`. + */ + serviceOverrides?: ServerStartOptions['serviceOverrides']; + /** Bind host. Defaults to `'127.0.0.1'`. */ + host?: string; + /** Bind port. Defaults to `0` (ephemeral). */ + port?: number; +} + +export interface ServerHarness { + /** The underlying `startServer` result. */ + readonly server: RunningServer; + /** Raw address returned by `startServer`, e.g. `http://127.0.0.1:51234`. */ + readonly address: string; + /** HTTP base URL, e.g. `http://127.0.0.1:51234`. */ + readonly baseUrl: string; + /** WebSocket URL for the v1 endpoint, e.g. `ws://127.0.0.1:51234/api/v1/ws`. */ + readonly wsUrl: string; + /** The bearer token this harness attaches to requests. */ + readonly token: string; + /** + * `fetch` against `baseUrl + path`, merging `Authorization: Bearer ` + * into the request headers. Caller-supplied headers win on conflict. + */ + authedFetch(path: string, init?: RequestInit): Promise; + /** + * Open a `ws` WebSocket to `wsUrl`, offering subprotocol + * `kimi-code.bearer.` and an `Authorization: Bearer ` header. + * In M0 the server ignores both; the connection still opens. + */ + authedWs(): WebSocket; + /** Tear down this server and terminate any sockets opened via `authedWs`. */ + close(): Promise; +} + +/** Every harness produced by `boot()`, for suite-level cleanup via `closeAll()`. */ +const opened = new Set(); + +function deriveHttpPort(address: string): string { + const port = new URL(address).port; + if (port === '') { + throw new Error(`cannot derive port from server address: ${address}`); + } + return port; +} + +/** + * Boot an isolated `startServer` for e2e use. + * + * Creates a tmp `lockPath` + isolated home dir (mirroring `start.test.ts`), + * binds to `127.0.0.1:0` by default, and tracks the result for `closeAll()`. + */ +export async function boot(opts: BootOptions = {}): Promise { + const token = opts.token ?? DEFAULT_TOKEN; + const host = opts.host ?? '127.0.0.1'; + const port = opts.port ?? 0; + // Inject a fixed-token `IAuthTokenService` by default so harness-based tests + // stay transparent (`authedFetch` / `authedWs` already carry `test-token`). + // The fixed impl is placed FIRST so an explicit caller-supplied + // `IAuthTokenService` override still wins (serviceOverrides are last-wins). + const serviceOverrides: ServerStartOptions['serviceOverrides'] = [ + fixedTokenAuth(token), + ...(opts.serviceOverrides ?? []), + ]; + + const tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-harness-')); + const homeDir = mkdtempSync(join(tmpdir(), 'kimi-server-harness-home-')); + const lockPath = join(tmpDir, 'lock'); + + const server = await startServer({ + host, + port, + lockPath, + serviceOverrides, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir }, + }); + + const httpPort = deriveHttpPort(server.address); + const baseUrl = `http://${host}:${httpPort}`; + const wsUrl = `ws://${host}:${httpPort}/api/v1/ws`; + + const sockets = new Set(); + let closed = false; + + const harness: ServerHarness = { + server, + address: server.address, + baseUrl, + wsUrl, + token, + + authedFetch(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + // Caller-supplied Authorization wins on conflict. + if (!headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${token}`); + } + return fetch(`${baseUrl}${path}`, { ...init, headers }); + }, + + authedWs(): WebSocket { + const ws = new WebSocket(wsUrl, [`${WS_BEARER_PROTOCOL_PREFIX}${token}`], { + headers: { Authorization: `Bearer ${token}` }, + }); + sockets.add(ws); + const drop = (): void => { + sockets.delete(ws); + }; + ws.once('close', drop); + ws.once('error', drop); + return ws; + }, + + async close(): Promise { + if (closed) return; + closed = true; + opened.delete(harness); + + for (const ws of sockets) { + try { + ws.terminate(); + } catch { + // ignore — best-effort teardown + } + } + sockets.clear(); + + try { + await server.close(); + } catch { + // ignore — best-effort teardown + } + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(homeDir, { recursive: true, force: true }); + }, + }; + + opened.add(harness); + return harness; +} + +/** Close every harness produced by `boot()`. Intended for `afterEach` cleanup. */ +export async function closeAll(): Promise { + const pending = [...opened]; + await Promise.all(pending.map((harness) => harness.close())); +} diff --git a/packages/server/test/host-exposure.e2e.test.ts b/packages/server/test/host-exposure.e2e.test.ts new file mode 100644 index 000000000..0acc2ca1c --- /dev/null +++ b/packages/server/test/host-exposure.e2e.test.ts @@ -0,0 +1,345 @@ +/** + * Host-exposure hardening (ROADMAP M6.3–M6.7). + * + * End-to-end coverage of the §3.5 public-bind hardening stack on a + * `host: '0.0.0.0'` + `KIMI_CODE_PASSWORD` + `insecureNoTls: true` server: + * - M6.3 public-bind gate (no `--insecure-no-tls` → refuse; token-only + * (no password) + `insecureNoTls` → boot + token-only warning logged; + * password + `insecureNoTls` → boot + warn logged). + * - Real password auth path (`Authorization: Bearer ` → 200 via + * `verifyPassword`; wrong/missing credentials → 401). + * - M6.4 auth-failure rate limit (N bad tokens → 429 on the (N+1)th). + * - M6.5 dangerous-endpoint downgrade (shutdown/terminals 404 by default; + * 200 with the allow flags; loopback mounts shutdown by default). + * - Host allowlist (spoofed Host → 403; bound host → 200). + * - M6.6 security response headers present on a non-loopback response. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { request as httpRequest } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Writable } from 'node:stream'; + +import { pino, type Logger } from 'pino'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { IServerShutdownService, startServer, type RunningServer, type ServerStartOptions } from '../src'; +import { authHeaders, fixedTokenAuth } from './helpers/serverHarness'; + +const createdDirs: string[] = []; +const running: RunningServer[] = []; +let prevPassword: string | undefined; + +function tmpPaths(): { lockPath: string; homeDir: string } { + const dir = mkdtempSync(join(tmpdir(), 'kimi-host-exposure-')); + const home = mkdtempSync(join(tmpdir(), 'kimi-host-exposure-home-')); + createdDirs.push(dir, home); + return { lockPath: join(dir, 'lock'), homeDir: home }; +} + +function capturingLogger(): { logger: Logger; lines: string[] } { + const lines: string[] = []; + const dest = new Writable({ + write(chunk, _enc, cb) { + lines.push(String(chunk)); + cb(); + }, + }); + return { logger: pino({ level: 'info' }, dest), lines }; +} + +beforeEach(() => { + prevPassword = process.env['KIMI_CODE_PASSWORD']; +}); + +afterEach(async () => { + for (const r of running.splice(0)) { + try { + await r.close(); + } catch { + // ignore — best-effort teardown + } + } + for (const dir of createdDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + if (prevPassword === undefined) { + delete process.env['KIMI_CODE_PASSWORD']; + } else { + process.env['KIMI_CODE_PASSWORD'] = prevPassword; + } +}); + +describe('non-loopback bind gate (M6.3)', () => { + it('boots 0.0.0.0 without a password (token-only) and logs the token-only warning', async () => { + delete process.env['KIMI_CODE_PASSWORD']; + const { lockPath, homeDir } = tmpPaths(); + const { logger, lines } = capturingLogger(); + + const server = await startServer({ + serviceOverrides: [fixedTokenAuth()], + host: '0.0.0.0', + port: 0, + lockPath, + insecureNoTls: true, + logger, + coreProcessOptions: { homeDir }, + }); + running.push(server); + + // The server is up with token-only auth: a gated route answers 200. + const res = await fetch(`${server.address}/api/v1/healthz`, { headers: authHeaders() }); + expect(res.status).toBe(200); + + // The token-only warning was logged so the operator knows the bearer token + // is the only credential protecting the exposed server. + expect(lines.join('')).toContain('token-only auth'); + }); + + it('refuses to bind 0.0.0.0 with a password but without --insecure-no-tls', async () => { + process.env['KIMI_CODE_PASSWORD'] = 'test-pw'; + const { lockPath, homeDir } = tmpPaths(); + + await expect( + startServer({ + serviceOverrides: [fixedTokenAuth()], + host: '0.0.0.0', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir }, + }), + ).rejects.toThrow(/without TLS/); + }); + + it('boots 0.0.0.0 with a password + insecureNoTls and logs the public warning', async () => { + process.env['KIMI_CODE_PASSWORD'] = 'test-pw'; + const { lockPath, homeDir } = tmpPaths(); + const { logger, lines } = capturingLogger(); + + const server = await startServer({ + serviceOverrides: [fixedTokenAuth()], + host: '0.0.0.0', + port: 0, + lockPath, + insecureNoTls: true, + logger, + coreProcessOptions: { homeDir }, + }); + running.push(server); + + // The server is up: a gated route answers 200 with a valid token. + const res = await fetch(`${server.address}/api/v1/healthz`, { headers: authHeaders() }); + expect(res.status).toBe(200); + + // The public-bind warning was logged so the operator knows TLS is off. + const combined = lines.join(''); + expect(combined).toContain('binding non-loopback host without TLS'); + }); +}); + +describe('dangerous-endpoint downgrade on a public bind (M6.5)', () => { + interface BootExposureOpts { + host?: string; + allowRemoteShutdown?: boolean; + allowRemoteTerminals?: boolean; + } + + async function bootExposure(opts: BootExposureOpts = {}): Promise<{ + server: RunningServer; + shutdownCalls: string[]; + }> { + process.env['KIMI_CODE_PASSWORD'] = 'test-pw'; + const { lockPath, homeDir } = tmpPaths(); + const shutdownCalls: string[] = []; + // Capture shutdown requests instead of exiting the process. + const noopShutdown = [ + IServerShutdownService, + { + _serviceBrand: undefined, + requestShutdown: async (reason: string) => { + shutdownCalls.push(reason); + }, + }, + ] as const; + const serviceOverrides: ServerStartOptions['serviceOverrides'] = [ + fixedTokenAuth(), + noopShutdown, + ]; + const server = await startServer({ + serviceOverrides, + host: opts.host ?? '0.0.0.0', + port: 0, + lockPath, + insecureNoTls: true, + allowRemoteShutdown: opts.allowRemoteShutdown, + allowRemoteTerminals: opts.allowRemoteTerminals, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir }, + }); + running.push(server); + return { server, shutdownCalls }; + } + + const terminalsUrl = (server: RunningServer): string => + `${server.address}/api/v1/sessions/some-session/terminals`; + + it('returns 404 for shutdown and terminals on a public bind without the allow flags', async () => { + const { server } = await bootExposure(); + + const shutdown = await fetch(`${server.address}/api/v1/shutdown`, { + method: 'POST', + headers: authHeaders(), + }); + expect(shutdown.status).toBe(404); + + const terminals = await fetch(terminalsUrl(server), { headers: authHeaders() }); + expect(terminals.status).toBe(404); + }); + + it('returns 200 for shutdown on a public bind when allowRemoteShutdown is set', async () => { + const { server, shutdownCalls } = await bootExposure({ allowRemoteShutdown: true }); + + const shutdown = await fetch(`${server.address}/api/v1/shutdown`, { + method: 'POST', + headers: authHeaders(), + }); + expect(shutdown.status).toBe(200); + // The handler replies before triggering shutdown (setImmediate); the noop + // override captures it so the process does not exit. + await vi.waitFor(() => expect(shutdownCalls).toContain('api')); + }); + + it('mounts shutdown on a loopback bind by default', async () => { + const { server, shutdownCalls } = await bootExposure({ host: '127.0.0.1' }); + + const shutdown = await fetch(`${server.address}/api/v1/shutdown`, { + method: 'POST', + headers: authHeaders(), + }); + expect(shutdown.status).toBe(200); + await vi.waitFor(() => expect(shutdownCalls).toContain('api')); + }); +}); + +/** + * Raw HTTP GET that lets us set an arbitrary `Host` header. Node's `fetch` + * (undici) treats `Host` as a forbidden header and silently replaces it with + * the URL host, so the Host-allowlist test drives `node:http` directly. + */ +function rawHttpGet( + url: string, + headers: Record, +): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const u = new URL(url); + const req = httpRequest( + { + hostname: u.hostname, + port: u.port, + path: `${u.pathname}${u.search}`, + method: 'GET', + headers, + }, + (res) => { + let body = ''; + res.on('data', (chunk: Buffer) => { + body += chunk.toString(); + }); + res.on('end', () => resolve({ status: res.statusCode ?? 0, body })); + }, + ); + req.on('error', reject); + req.end(); + }); +} + +describe('public-bind §3.5 end-to-end (M6.7)', () => { + /** + * Boot a 0.0.0.0 server using the REAL auth impl (no fixed-token override) so + * the password `verifyPassword` path is exercised. `KIMI_CODE_PASSWORD` is set + * so the M6.3 gate passes and the password is itself a valid bearer. + */ + async function bootPublicReal(): Promise { + process.env['KIMI_CODE_PASSWORD'] = 'test-pw'; + const { lockPath, homeDir } = tmpPaths(); + const server = await startServer({ + host: '0.0.0.0', + port: 0, + lockPath, + insecureNoTls: true, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir }, + }); + running.push(server); + return server; + } + + /** Boot a 0.0.0.0 server with a deterministic fixed token. */ + async function bootPublicFixed(token = 'real-token'): Promise { + process.env['KIMI_CODE_PASSWORD'] = 'test-pw'; + const { lockPath, homeDir } = tmpPaths(); + const server = await startServer({ + serviceOverrides: [fixedTokenAuth(token)], + host: '0.0.0.0', + port: 0, + lockPath, + insecureNoTls: true, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir }, + }); + running.push(server); + return server; + } + + it('accepts the user password as a bearer token (verifyPassword path)', async () => { + const server = await bootPublicReal(); + const ok = await fetch(`${server.address}/api/v1/sessions`, { + headers: { Authorization: 'Bearer test-pw' }, + }); + expect(ok.status).toBe(200); + // Security headers ride on every non-loopback response (M6.6). + expect(ok.headers.get('x-content-type-options')).toBe('nosniff'); + expect(ok.headers.get('content-security-policy')).toBe("default-src 'self'"); + }); + + it('rejects wrong and missing credentials with 401', async () => { + const server = await bootPublicReal(); + const wrong = await fetch(`${server.address}/api/v1/sessions`, { + headers: { Authorization: 'Bearer wrong-password' }, + }); + expect(wrong.status).toBe(401); + const missing = await fetch(`${server.address}/api/v1/sessions`); + expect(missing.status).toBe(401); + }); + + it('rate-limits repeated auth failures to 429 on a real bind (M6.4)', async () => { + const server = await bootPublicFixed('real-token'); + const url = `${server.address}/api/v1/sessions`; + let lastStatus = 0; + // Default threshold is 10 failures; the 11th must be 429. + for (let i = 0; i < 11; i += 1) { + const res = await fetch(url, { headers: { Authorization: 'Bearer wrong' } }); + lastStatus = res.status; + if (i < 10) { + expect(res.status).toBe(401); + } + } + expect(lastStatus).toBe(429); + }); + + it('rejects a spoofed Host with 403 and accepts the bound host', async () => { + const server = await bootPublicFixed(); + // Bound host (0.0.0.0) is a literal IP → allowed by the Host allowlist. + const bound = await rawHttpGet(`${server.address}/api/v1/healthz`, { + Host: `0.0.0.0:${new URL(server.address).port}`, + }); + expect(bound.status).toBe(200); + // A spoofed Host is rejected before auth (Host check runs first). + const spoofed = await rawHttpGet(`${server.address}/api/v1/healthz`, { + Host: 'evil.example.com', + }); + expect(spoofed.status).toBe(403); + }); +}); diff --git a/packages/server/test/host-origin.e2e.test.ts b/packages/server/test/host-origin.e2e.test.ts new file mode 100644 index 000000000..b8788518a --- /dev/null +++ b/packages/server/test/host-origin.e2e.test.ts @@ -0,0 +1,262 @@ +/** + * Host / Origin checks wired into HTTP + WS (ROADMAP M4.3). + * + * HTTP: the global `onRequest` Host hook rejects `Host: evil.com` with 403 + * before any route handler (here `/api/v1/healthz`), while the default + * `127.0.0.1:` Host from a real `fetch` is allowed. + * + * WS: `wsGatewayOptions.hostCheck` / `allowedOrigins` gate the upgrade path + * before token validation. `authTokenService` is intentionally left unset so + * these cases isolate Host/Origin from token auth. The Node `ws` client sends + * no `Origin` by default, which is treated as a non-browser client and + * allowed; a spoofed browser `Origin: http://evil.com` is rejected. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { request as httpRequest } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { WebSocket } from 'ws'; + +import { startServer, type RunningServer } from '../src'; +import type { WSGatewayOptions } from '../src/services/gateway/wsGateway'; +import { rawDataToString } from '../src/ws/rawData'; +import { fixedTokenAuth } from './helpers/serverHarness'; + +interface WsFrame { + type: string; + payload?: unknown; + [k: string]: unknown; +} + +interface Conn { + ws: WebSocket; + queue: WsFrame[]; + waiters: Array<(frame: WsFrame) => void>; + closed: Promise<{ code: number; reason: string }>; +} + +interface ConnectOptions { + protocols?: string[]; + headers?: Record; +} + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +const running: RunningServer[] = []; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-host-origin-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-host-origin-home-')); +}); + +afterEach(async () => { + for (const r of running.splice(0)) { + try { + await r.close(); + } catch { + // ignore + } + } + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function spawn(wsGatewayOptions?: WSGatewayOptions): Promise { + const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir: bridgeHome }, + ...(wsGatewayOptions !== undefined ? { wsGatewayOptions } : {}), + }); + running.push(r); + return r; +} + +function wsUrl(http: string): string { + return http.replace(/^http:\/\//, 'ws://') + '/api/v1/ws'; +} + +/** + * Raw HTTP GET that lets us set an arbitrary `Host` header. Node's `fetch` + * (undici) treats `Host` as a forbidden header and silently replaces it with + * the URL host, so we drive `node:http` directly to exercise the Host check. + */ +function rawHttpGet( + url: string, + headers: Record, +): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const u = new URL(url); + const req = httpRequest( + { + hostname: u.hostname, + port: u.port, + path: `${u.pathname}${u.search}`, + method: 'GET', + headers, + }, + (res) => { + let body = ''; + res.on('data', (chunk: Buffer) => { + body += chunk.toString(); + }); + res.on('end', () => resolve({ status: res.statusCode ?? 0, body })); + }, + ); + req.on('error', reject); + req.end(); + }); +} + +function openConn(url: string, opts?: ConnectOptions): Promise { + return new Promise((resolve, reject) => { + // Offer the fixed bearer token so the M5.1 WS auth passes; the Host/Origin + // cases that expect rejection use `expectRejected` (no token) instead. + const protocols = [...(opts?.protocols ?? []), 'kimi-code.bearer.test-token']; + const ws = new WebSocket(url, protocols, { headers: opts?.headers }); + const queue: WsFrame[] = []; + const waiters: Array<(frame: WsFrame) => void> = []; + let closedResolve: (v: { code: number; reason: string }) => void; + const closed = new Promise<{ code: number; reason: string }>((res) => { + closedResolve = res; + }); + ws.on('message', (data) => { + let parsed: WsFrame; + try { + parsed = JSON.parse(rawDataToString(data)) as WsFrame; + } catch { + return; + } + if (waiters.length > 0) { + waiters.shift()?.(parsed); + } else { + queue.push(parsed); + } + }); + ws.on('close', (code, reason) => { + closedResolve({ code, reason: String(reason) }); + }); + ws.once('open', () => resolve({ ws, queue, waiters, closed })); + ws.once('error', (err) => reject(err)); + }); +} + +function receive(conn: Conn, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + if (conn.queue.length > 0) { + resolve(conn.queue.shift()!); + return; + } + const t = setTimeout(() => { + const idx = conn.waiters.indexOf(waiter); + if (idx >= 0) conn.waiters.splice(idx, 1); + reject(new Error(`no message in ${timeoutMs}ms`)); + }, timeoutMs); + const waiter = (frame: WsFrame): void => { + clearTimeout(t); + resolve(frame); + }; + conn.waiters.push(waiter); + }); +} + +async function receiveType(conn: Conn, type: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error(`no message of type ${type} within ${timeoutMs}ms`); + const frame = await receive(conn, remaining); + if (frame.type === type) return frame; + } +} + +function expectRejected(url: string, opts?: ConnectOptions): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, opts?.protocols, { headers: opts?.headers }); + const done = (err?: Error): void => { + clearTimeout(t); + ws.removeAllListeners(); + try { + ws.terminate(); + } catch { + // ignore + } + if (err !== undefined) reject(err); + else resolve(); + }; + const t = setTimeout( + () => done(new Error('connection was not rejected within timeout')), + 1500, + ); + ws.once('open', () => done(new Error('connection unexpectedly opened'))); + ws.once('error', () => done()); + ws.once('close', () => done()); + }); +} + +describe('HTTP Host check (start.ts)', () => { + it('rejects Host: evil.com with 403 before the route handler', async () => { + const r = await spawn(); + const res = await rawHttpGet(`${r.address}/api/v1/healthz`, { Host: 'evil.com' }); + expect(res.status).toBe(403); + const body = JSON.parse(res.body) as Record; + expect(body['code']).toBe(40301); + expect(body['msg']).toBe('Invalid Host header'); + }); + + it('allows the default 127.0.0.1: Host', async () => { + const r = await spawn(); + const res = await rawHttpGet(`${r.address}/api/v1/healthz`, {}); + expect(res.status).toBe(200); + }); +}); + +describe('WS Host/Origin checks (wsGatewayService)', () => { + it('rejects a spoofed Host before token validation', async () => { + const r = await spawn({ hostCheck: { boundHost: '127.0.0.1' }, allowedOrigins: [] }); + await expectRejected(wsUrl(r.address), { headers: { Host: 'evil.com' } }); + }); + + it('accepts a normal Host and delivers server_hello', async () => { + const r = await spawn({ hostCheck: { boundHost: '127.0.0.1' }, allowedOrigins: [] }); + const conn = await openConn(wsUrl(r.address)); + const hello = await receiveType(conn, 'server_hello', 1000); + expect(hello.type).toBe('server_hello'); + conn.ws.close(); + await conn.closed; + }); + + it('rejects a disallowed browser Origin', async () => { + const r = await spawn({ hostCheck: { boundHost: '127.0.0.1' }, allowedOrigins: [] }); + await expectRejected(wsUrl(r.address), { + headers: { origin: 'http://evil.com' }, + }); + }); + + it('allows a Node client with no Origin (present-only check)', async () => { + const r = await spawn({ hostCheck: { boundHost: '127.0.0.1' }, allowedOrigins: [] }); + const conn = await openConn(wsUrl(r.address)); + const hello = await receiveType(conn, 'server_hello', 1000); + expect(hello.type).toBe('server_hello'); + conn.ws.close(); + await conn.closed; + }); + + it('skips the checks when the options are unset', async () => { + const r = await spawn(); + const conn = await openConn(wsUrl(r.address)); + const hello = await receiveType(conn, 'server_hello', 1000); + expect(hello.type).toBe('server_hello'); + conn.ws.close(); + await conn.closed; + }); +}); diff --git a/packages/server/test/hostnames.test.ts b/packages/server/test/hostnames.test.ts new file mode 100644 index 000000000..e5cc1a5c6 --- /dev/null +++ b/packages/server/test/hostnames.test.ts @@ -0,0 +1,150 @@ +/** + * Host-header allowlist (ROADMAP M4.1). + * + * Pure unit cases for `stripPort` / `isAllowedHost` / the env parsers, plus a + * minimal Fastify integration test that exercises the `onRequest` hook through + * `app.inject` (which sends `Host: localhost:80` by default). + */ + +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + createHostCheck, + isAllowedHost, + isHostCheckDisabled, + parseAllowedHosts, + stripPort, +} from '#/middleware/hostnames'; + +describe('stripPort', () => { + it('strips the port from a hostname', () => { + expect(stripPort('localhost:80')).toBe('localhost'); + }); + + it('strips the port from bracketed IPv6', () => { + expect(stripPort('[::1]:80')).toBe('[::1]'); + }); + + it('strips the port from an IPv4 literal', () => { + expect(stripPort('1.2.3.4:5678')).toBe('1.2.3.4'); + }); + + it('lowercases bare hosts', () => { + expect(stripPort('LOCALHOST')).toBe('localhost'); + }); +}); + +describe('isAllowedHost (default allow set)', () => { + const allow = ['localhost', 'localhost:80', 'foo.localhost', '127.0.0.1', '127.0.0.1:58627', '[::1]', '::1', '8.8.8.8']; + + for (const host of allow) { + it(`allows ${host}`, () => { + expect(isAllowedHost(host, {})).toBe(true); + }); + } + + const deny = ['evil.com', 'evil.com:80', '127.0.0.1.evil.com']; + + for (const host of deny) { + it(`denies ${host}`, () => { + expect(isAllowedHost(host, {})).toBe(false); + }); + } + + it('denies a missing Host header', () => { + expect(isAllowedHost(undefined, {})).toBe(false); + }); +}); + +describe('isAllowedHost (boundHost)', () => { + it('allows the bound host', () => { + expect(isAllowedHost('myhost', { boundHost: 'myhost' })).toBe(true); + }); + + it('strips the port on both sides', () => { + expect(isAllowedHost('myhost:1234', { boundHost: 'myhost:8080' })).toBe(true); + }); + + it('still denies unrelated hosts', () => { + expect(isAllowedHost('otherhost', { boundHost: 'myhost' })).toBe(false); + }); +}); + +describe('isAllowedHost (extra)', () => { + it('matches a subdomain wildcard', () => { + expect(isAllowedHost('a.example.com', { extra: ['.example.com'] })).toBe(true); + }); + + it('matches the bare domain of a wildcard', () => { + expect(isAllowedHost('example.com', { extra: ['.example.com'] })).toBe(true); + }); + + it('does not match a partial suffix', () => { + expect(isAllowedHost('baddexample.com', { extra: ['.example.com'] })).toBe(false); + }); + + it('matches an exact entry', () => { + expect(isAllowedHost('foo', { extra: ['foo'] })).toBe(true); + }); +}); + +describe('isAllowedHost (disable)', () => { + it('allows everything when disabled', () => { + expect(isAllowedHost('evil.com', { disable: true })).toBe(true); + }); +}); + +describe('parseAllowedHosts', () => { + it('splits, trims, and drops empties', () => { + expect(parseAllowedHosts({ KIMI_CODE_ALLOWED_HOSTS: ' a, .b.com, ' })).toEqual(['a', '.b.com']); + }); + + it('returns [] when unset', () => { + expect(parseAllowedHosts({})).toEqual([]); + }); +}); + +describe('isHostCheckDisabled', () => { + it('is true when set to "1"', () => { + expect(isHostCheckDisabled({ KIMI_CODE_DISABLE_HOST_CHECK: '1' })).toBe(true); + }); + + it('is false when unset', () => { + expect(isHostCheckDisabled({})).toBe(false); + }); +}); + +describe('createHostCheck (onRequest hook)', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = Fastify(); + app.addHook('onRequest', createHostCheck({}).onRequest); + app.get('/api/v1/probe', async () => ({ ok: true })); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('rejects a disallowed Host with the 40301 envelope', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/probe', + headers: { host: 'evil.com' }, + }); + expect(res.statusCode).toBe(403); + const body = res.json() as Record; + expect(body['code']).toBe(40301); + expect(body['msg']).toBe('Invalid Host header'); + expect(body['data']).toBeNull(); + expect(typeof body['request_id']).toBe('string'); + }); + + it('allows the default app.inject Host (localhost:80)', async () => { + const res = await app.inject({ method: 'GET', url: '/api/v1/probe' }); + expect(res.statusCode).toBe(200); + }); +}); diff --git a/packages/server/test/lock.test.ts b/packages/server/test/lock.test.ts index ed7bea3b0..29ff1bb71 100644 --- a/packages/server/test/lock.test.ts +++ b/packages/server/test/lock.test.ts @@ -8,7 +8,7 @@ * 0)` returns ESRCH for any unallocated pid on Linux/macOS). */ -import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -78,6 +78,14 @@ describe('acquireLock — basic acquire / release', () => { rmSync(lockPath); expect(() => handle.release()).not.toThrow(); }); + + it('creates the lock file with 0600 permissions (ROADMAP M5.2)', () => { + const handle = acquireLock({ lockPath, port: 58627 }); + // The lock file lives next to the per-pid bearer token; it must not be + // group/world readable. + expect(statSync(lockPath).mode & 0o777).toBe(0o600); + handle.release(); + }); }); describe('acquireLock — concurrent-instance protection', () => { diff --git a/packages/server/test/messages.e2e.test.ts b/packages/server/test/messages.e2e.test.ts index 0db71cc75..cc3d3248e 100644 --- a/packages/server/test/messages.e2e.test.ts +++ b/packages/server/test/messages.e2e.test.ts @@ -40,6 +40,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -65,6 +66,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -77,12 +79,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/meta.e2e.test.ts b/packages/server/test/meta.e2e.test.ts index 6a4039be5..c98715bb7 100644 --- a/packages/server/test/meta.e2e.test.ts +++ b/packages/server/test/meta.e2e.test.ts @@ -33,6 +33,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -59,6 +60,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -75,16 +77,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - // We use the same accessor pattern start.ts uses internally. IRestGateway - // is registered with the `FastifyLike` structural type; `.inject()` is the - // Fastify-specific method we need for hermetic tests — it lives on the - // underlying instance, not on FastifyLike. The cast is local to this test. - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } describe('GET /api/v1/meta — envelope + metaResponseSchema', () => { @@ -137,6 +147,7 @@ describe('GET /api/v1/meta — envelope + metaResponseSchema', () => { const homeA = mkdtempSync(join(tmpdir(), 'kimi-server-meta-home-a-')); const homeB = mkdtempSync(join(tmpdir(), 'kimi-server-meta-home-b-')); const r1 = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath: lockA, @@ -144,6 +155,7 @@ describe('GET /api/v1/meta — envelope + metaResponseSchema', () => { coreProcessOptions: { homeDir: homeA }, }); const r2 = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath: lockB, @@ -168,6 +180,7 @@ describe('GET /api/v1/meta — envelope + metaResponseSchema', () => { describe('GET /api/v1/meta — server_version precedence', () => { it('reports the host kimi-code identity version when provided', async () => { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, diff --git a/packages/server/test/model-catalog.e2e.test.ts b/packages/server/test/model-catalog.e2e.test.ts index 3492bd40d..95c89ab3f 100644 --- a/packages/server/test/model-catalog.e2e.test.ts +++ b/packages/server/test/model-catalog.e2e.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IModelCatalogService, type IModelCatalogService as ModelCatalogServiceShape } from '@moonshot-ai/agent-core'; import { IRestGateway, startServer, type RunningServer, type ServerStartOptions } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -40,7 +41,7 @@ async function bootDaemon( lockPath, logger: pino({ level: 'silent' }), coreProcessOptions: { homeDir: bridgeHome }, - serviceOverrides, + serviceOverrides: [fixedTokenAuth(), ...(serviceOverrides ?? [])], }); return server; } @@ -48,12 +49,24 @@ async function bootDaemon( function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/oauth.e2e.test.ts b/packages/server/test/oauth.e2e.test.ts index f0d5b9d70..560c57c92 100644 --- a/packages/server/test/oauth.e2e.test.ts +++ b/packages/server/test/oauth.e2e.test.ts @@ -40,6 +40,7 @@ import type { } from '@moonshot-ai/protocol'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -128,7 +129,7 @@ async function bootDaemon(stub: StubOAuth): Promise { lockPath, logger: pino({ level: 'silent' }), coreProcessOptions: { homeDir: bridgeHome }, - serviceOverrides: [[IOAuthService, stub]], + serviceOverrides: [fixedTokenAuth(), [IOAuthService, stub]], }); return server; } @@ -136,12 +137,24 @@ async function bootDaemon(stub: StubOAuth): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/origin.test.ts b/packages/server/test/origin.test.ts new file mode 100644 index 000000000..af5228fcc --- /dev/null +++ b/packages/server/test/origin.test.ts @@ -0,0 +1,143 @@ +/** + * Origin / CORS middleware (ROADMAP M4.2). + * + * Pure unit cases for `originHost` / `isOriginAllowed` / `parseCorsOrigins`, + * plus a minimal Fastify integration test that drives the `onRequest` hook + * through `app.inject` and asserts the emitted (or withheld) CORS headers. + */ + +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + createOriginHook, + isOriginAllowed, + originHost, + parseCorsOrigins, +} from '#/middleware/origin'; + +describe('originHost', () => { + it('returns the host for a valid origin', () => { + expect(originHost('https://foo.com')).toBe('foo.com'); + }); + + it('drops the default port', () => { + expect(originHost('http://localhost:80')).toBe('localhost'); + }); + + it('keeps a non-default port', () => { + expect(originHost('http://127.0.0.1:58627')).toBe('127.0.0.1:58627'); + }); + + it('returns undefined for a missing origin', () => { + expect(originHost(undefined)).toBeUndefined(); + }); + + it('returns undefined for a malformed origin', () => { + expect(originHost('not a url')).toBeUndefined(); + }); +}); + +describe('isOriginAllowed', () => { + it('allows same-origin', () => { + expect(isOriginAllowed('http://localhost:80', 'localhost:80', [])).toBe(true); + }); + + it('denies cross-origin that is not whitelisted', () => { + expect(isOriginAllowed('http://evil.com', 'localhost:80', [])).toBe(false); + }); + + it('allows cross-origin that is whitelisted', () => { + expect(isOriginAllowed('https://foo.com', 'localhost:80', ['https://foo.com'])).toBe(true); + }); + + it('allows an absent origin', () => { + expect(isOriginAllowed(undefined, 'localhost:80', [])).toBe(true); + }); + + it('treats a malformed origin as absent (allowed)', () => { + expect(isOriginAllowed('not a url', 'h', [])).toBe(true); + }); +}); + +describe('parseCorsOrigins', () => { + it('splits, trims, and drops empties', () => { + expect(parseCorsOrigins({ KIMI_CODE_CORS_ORIGINS: ' https://a.com, https://b.com, ' })).toEqual([ + 'https://a.com', + 'https://b.com', + ]); + }); + + it('returns [] when unset', () => { + expect(parseCorsOrigins({})).toEqual([]); + }); +}); + +describe('createOriginHook (onRequest hook)', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = Fastify(); + app.addHook('onRequest', createOriginHook({ allowedOrigins: ['https://foo.com'] })); + app.get('/api/v1/probe', async () => ({ ok: true })); + app.options('/api/v1/probe', async () => ({ ok: true })); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('echoes CORS headers for a same-origin request', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/probe', + headers: { origin: 'http://localhost:80', host: 'localhost:80' }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['access-control-allow-origin']).toBe('http://localhost:80'); + }); + + it('echoes the whitelisted cross-origin and short-circuits OPTIONS to 204', async () => { + const res = await app.inject({ + method: 'OPTIONS', + url: '/api/v1/probe', + headers: { origin: 'https://foo.com', host: 'localhost:80' }, + }); + expect(res.statusCode).toBe(204); + expect(res.headers['access-control-allow-origin']).toBe('https://foo.com'); + expect(res.headers['access-control-allow-methods']).toBe( + 'GET, POST, PUT, PATCH, DELETE, OPTIONS', + ); + }); + + it('withholds CORS headers for a non-whitelisted cross-origin', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/probe', + headers: { origin: 'http://evil.com', host: 'localhost:80' }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + }); + + it('returns 204 without CORS headers for a non-whitelisted OPTIONS', async () => { + const res = await app.inject({ + method: 'OPTIONS', + url: '/api/v1/probe', + headers: { origin: 'http://evil.com', host: 'localhost:80' }, + }); + expect(res.statusCode).toBe(204); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + }); + + it('emits no CORS headers when Origin is absent', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/probe', + headers: { host: 'localhost:80' }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + }); +}); diff --git a/packages/server/test/prompt.e2e.test.ts b/packages/server/test/prompt.e2e.test.ts index 8cfe3f4a0..c99fb9e2f 100644 --- a/packages/server/test/prompt.e2e.test.ts +++ b/packages/server/test/prompt.e2e.test.ts @@ -33,6 +33,7 @@ import type { Event, PromptSubmission } from '@moonshot-ai/protocol'; import { IEventService, IPromptService, PromptService } from '@moonshot-ai/agent-core'; import { IRestGateway, startServer, type ServerStartOptions, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -66,7 +67,7 @@ async function bootDaemon( logger: pino({ level: 'silent' }), coreProcessOptions: { homeDir: bridgeHome }, wsGatewayOptions: { pingIntervalMs: 5_000, pongTimeoutMs: 5_000 }, - serviceOverrides, + serviceOverrides: [fixedTokenAuth(), ...(serviceOverrides ?? [])], }); return server; } @@ -74,12 +75,24 @@ async function bootDaemon( function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { @@ -204,7 +217,7 @@ async function openSubscriber( const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws'; const received: Record[] = []; const ws = await new Promise((resolve, reject) => { - const sock = new WebSocket(wsUrl); + const sock = new WebSocket(wsUrl, ['kimi-code.bearer.test-token']); sock.on('message', (data) => { try { received.push(JSON.parse(wsDataToString(data)) as Record); diff --git a/packages/server/test/question.e2e.test.ts b/packages/server/test/question.e2e.test.ts index 833daf0f6..669682ce1 100644 --- a/packages/server/test/question.e2e.test.ts +++ b/packages/server/test/question.e2e.test.ts @@ -25,6 +25,7 @@ import { } from '@moonshot-ai/agent-core'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { rawDataToString } from '../src/ws/rawData'; import { QuestionService } from '#/services/question/questionService'; @@ -52,6 +53,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -65,12 +67,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { @@ -112,7 +126,7 @@ async function openSubscriber( const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws'; const received: Record[] = []; const ws = await new Promise((resolve, reject) => { - const sock = new WebSocket(wsUrl); + const sock = new WebSocket(wsUrl, ['kimi-code.bearer.test-token']); sock.on('message', (data) => { try { received.push(JSON.parse(rawDataToString(data)) as Record); diff --git a/packages/server/test/rate-limit.test.ts b/packages/server/test/rate-limit.test.ts new file mode 100644 index 000000000..a70aee17f --- /dev/null +++ b/packages/server/test/rate-limit.test.ts @@ -0,0 +1,165 @@ +/** + * Auth-failure rate limiting (ROADMAP M6.4). + * + * Two layers: + * 1. `createAuthFailureLimiter` unit behavior — failure counting, ban on + * threshold, ban expiry (fake timers), and window reset. + * 2. `createAuthHook` integration — a banned source gets `429` (even with a + * valid token), a different source still gets `401`, and a hook without a + * limiter (the loopback wiring) never returns `429`. + * + * Distinct source IPs are expressed via `X-Forwarded-For` with Fastify + * `trustProxy: true`, which is what `req.ip` reads behind a reverse proxy. + */ + +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createAuthHook } from '#/middleware/auth'; +import { + createAuthFailureLimiter, + type AuthFailureLimiter, +} from '#/middleware/rateLimit'; +import type { IAuthTokenService } from '#/services/auth/authTokenService'; + +const TOKEN = 'test-token'; +const IP_A = '203.0.113.10'; +const IP_B = '203.0.113.11'; + +function fixedImpl(): IAuthTokenService { + return { + _serviceBrand: undefined, + getToken: () => TOKEN, + isValid: async (candidate) => candidate === TOKEN, + }; +} + +function buildApp(limiter?: AuthFailureLimiter): FastifyInstance { + const app = Fastify({ trustProxy: true }); + app.addHook('onRequest', createAuthHook(fixedImpl(), { limiter })); + app.get('/api/v1/sessions', async () => ({ ok: true })); + return app; +} + +function badToken(ip: string): { method: 'GET'; url: string; headers: Record } { + return { + method: 'GET', + url: '/api/v1/sessions', + headers: { 'x-forwarded-for': ip, authorization: 'Bearer wrong-token' }, + }; +} + +describe('createAuthHook rate limiting (M6.4)', () => { + let app: FastifyInstance; + let limiter: AuthFailureLimiter; + + beforeEach(async () => { + limiter = createAuthFailureLimiter({ maxFailures: 3, windowMs: 60_000, banMs: 60_000 }); + app = buildApp(limiter); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + limiter.dispose(); + }); + + it('returns 401 for the first N failures, then 429 on the (N+1)th from the same IP', async () => { + expect((await app.inject(badToken(IP_A))).statusCode).toBe(401); + expect((await app.inject(badToken(IP_A))).statusCode).toBe(401); + expect((await app.inject(badToken(IP_A))).statusCode).toBe(401); + const fourth = await app.inject(badToken(IP_A)); + expect(fourth.statusCode).toBe(429); + const body = fourth.json() as Record; + expect(body['code']).toBe(42901); + expect(body['msg']).toBe('Too many failed auth attempts'); + }); + + it('does not ban a different IP that has not hit the threshold', async () => { + // Push IP_A over the threshold. + await app.inject(badToken(IP_A)); + await app.inject(badToken(IP_A)); + await app.inject(badToken(IP_A)); + expect((await app.inject(badToken(IP_A))).statusCode).toBe(429); + + // IP_B is a fresh source — still 401, not 429. + expect((await app.inject(badToken(IP_B))).statusCode).toBe(401); + }); + + it('returns 429 to a banned IP even when it presents a valid token', async () => { + await app.inject(badToken(IP_A)); + await app.inject(badToken(IP_A)); + await app.inject(badToken(IP_A)); + expect((await app.inject(badToken(IP_A))).statusCode).toBe(429); + + const valid = await app.inject({ + method: 'GET', + url: '/api/v1/sessions', + headers: { 'x-forwarded-for': IP_A, authorization: `Bearer ${TOKEN}` }, + }); + expect(valid.statusCode).toBe(429); + }); + + it('never returns 429 when no limiter is wired (loopback behavior)', async () => { + const noLimiterApp = buildApp(undefined); + await noLimiterApp.ready(); + try { + for (let i = 0; i < 10; i += 1) { + expect((await noLimiterApp.inject(badToken(IP_A))).statusCode).toBe(401); + } + } finally { + await noLimiterApp.close(); + } + }); +}); + +describe('createAuthFailureLimiter (unit)', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('bans a source at the threshold and clears the ban after banMs', () => { + vi.useFakeTimers(); + const limiter = createAuthFailureLimiter({ maxFailures: 2, windowMs: 1_000, banMs: 500 }); + try { + expect(limiter.isBanned('1.2.3.4')).toBe(false); + limiter.recordFailure('1.2.3.4'); + expect(limiter.isBanned('1.2.3.4')).toBe(false); + limiter.recordFailure('1.2.3.4'); + expect(limiter.isBanned('1.2.3.4')).toBe(true); + + vi.advanceTimersByTime(499); + expect(limiter.isBanned('1.2.3.4')).toBe(true); + vi.advanceTimersByTime(1); + expect(limiter.isBanned('1.2.3.4')).toBe(false); + } finally { + limiter.dispose(); + } + }); + + it('resets the failure count once the window elapses', () => { + vi.useFakeTimers(); + const limiter = createAuthFailureLimiter({ maxFailures: 2, windowMs: 1_000, banMs: 500 }); + try { + limiter.recordFailure('5.5.5.5'); + vi.advanceTimersByTime(1_001); // window expired → next failure starts fresh + limiter.recordFailure('5.5.5.5'); + // Only one failure in the new window → not banned. + expect(limiter.isBanned('5.5.5.5')).toBe(false); + } finally { + limiter.dispose(); + } + }); + + it('tracks sources independently', () => { + vi.useFakeTimers(); + const limiter = createAuthFailureLimiter({ maxFailures: 1, windowMs: 1_000, banMs: 500 }); + try { + limiter.recordFailure('9.9.9.9'); + expect(limiter.isBanned('9.9.9.9')).toBe(true); + expect(limiter.isBanned('8.8.8.8')).toBe(false); + } finally { + limiter.dispose(); + } + }); +}); diff --git a/packages/server/test/security-headers.test.ts b/packages/server/test/security-headers.test.ts new file mode 100644 index 000000000..6c1696a92 --- /dev/null +++ b/packages/server/test/security-headers.test.ts @@ -0,0 +1,95 @@ +/** + * Security response headers (ROADMAP M6.6). + * + * Verifies the `onSend` hook is registered only on a non-loopback bind and + * that HSTS is omitted while TLS is terminated elsewhere (`tls: false`). + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { startServer, type RunningServer } from '../src'; +import { authHeaders, fixedTokenAuth } from './helpers/serverHarness'; + +const createdDirs: string[] = []; +const running: RunningServer[] = []; +let prevPassword: string | undefined; + +function tmpPaths(): { lockPath: string; homeDir: string } { + const dir = mkdtempSync(join(tmpdir(), 'kimi-sec-headers-')); + const home = mkdtempSync(join(tmpdir(), 'kimi-sec-headers-home-')); + createdDirs.push(dir, home); + return { lockPath: join(dir, 'lock'), homeDir: home }; +} + +beforeEach(() => { + prevPassword = process.env['KIMI_CODE_PASSWORD']; +}); + +afterEach(async () => { + for (const r of running.splice(0)) { + try { + await r.close(); + } catch { + // ignore — best-effort teardown + } + } + for (const dir of createdDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + if (prevPassword === undefined) { + delete process.env['KIMI_CODE_PASSWORD']; + } else { + process.env['KIMI_CODE_PASSWORD'] = prevPassword; + } +}); + +async function boot(host: string): Promise { + const { lockPath, homeDir } = tmpPaths(); + if (host !== '127.0.0.1') { + // Non-loopback binds require a password + TLS opt-out (M6.3). + process.env['KIMI_CODE_PASSWORD'] = 'test-pw'; + } + const server = await startServer({ + serviceOverrides: [fixedTokenAuth()], + host, + port: 0, + lockPath, + insecureNoTls: host !== '127.0.0.1', + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir }, + }); + running.push(server); + return server; +} + +describe('security response headers (M6.6)', () => { + it('sets nosniff / Referrer-Policy / CSP on a non-loopback bind, without HSTS', async () => { + const server = await boot('0.0.0.0'); + const res = await fetch(`${server.address}/api/v1/sessions`, { + headers: authHeaders(), + }); + expect(res.status).toBe(200); + expect(res.headers.get('x-content-type-options')).toBe('nosniff'); + expect(res.headers.get('referrer-policy')).toBe('no-referrer'); + expect(res.headers.get('content-security-policy')).toBe("default-src 'self'"); + // TLS is terminated by the reverse proxy in this phase → no HSTS here. + expect(res.headers.get('strict-transport-security')).toBeNull(); + }); + + it('does NOT set the security headers on a loopback bind', async () => { + const server = await boot('127.0.0.1'); + const res = await fetch(`${server.address}/api/v1/sessions`, { + headers: authHeaders(), + }); + expect(res.status).toBe(200); + expect(res.headers.get('x-content-type-options')).toBeNull(); + expect(res.headers.get('referrer-policy')).toBeNull(); + expect(res.headers.get('content-security-policy')).toBeNull(); + expect(res.headers.get('strict-transport-security')).toBeNull(); + }); +}); diff --git a/packages/server/test/services-auth.test.ts b/packages/server/test/services-auth.test.ts new file mode 100644 index 000000000..1221a2fa2 --- /dev/null +++ b/packages/server/test/services-auth.test.ts @@ -0,0 +1,182 @@ +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + PrivateFileTooPermissiveError, + readPrivateFile, + writePrivateFile, +} from '#/services/auth/privateFiles'; +import { loadOrCreateServerToken, rotateServerToken } from '#/services/auth/persistentToken'; +import { createTokenStore } from '#/services/auth/tokenStore'; +import { resolvePasswordHash, verifyPassword } from '#/services/auth/password'; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-auth-test-')); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('privateFiles', () => { + it('writes a file with mode 0600', async () => { + const p = join(tmpDir, 'secret'); + await writePrivateFile(p, 'hello'); + expect(statSync(p).mode & 0o777).toBe(0o600); + }); + + it('creates an absent parent dir with mode 0700', async () => { + const p = join(tmpDir, 'nested', 'dir', 'secret'); + await writePrivateFile(p, 'hello'); + expect(statSync(join(tmpDir, 'nested', 'dir')).mode & 0o777).toBe(0o700); + }); + + it('round-trips string content through readPrivateFile', async () => { + const p = join(tmpDir, 'secret'); + await writePrivateFile(p, 's3cr3t-value'); + const buf = await readPrivateFile(p); + expect(buf.toString('utf8')).toBe('s3cr3t-value'); + }); + + it('round-trips Buffer content through readPrivateFile', async () => { + const p = join(tmpDir, 'bin'); + const data = Buffer.from([0, 1, 2, 254, 255]); + await writePrivateFile(p, data); + const buf = await readPrivateFile(p); + expect(buf.equals(data)).toBe(true); + }); + + it('readPrivateFile throws on a 0644 file', async () => { + const p = join(tmpDir, 'leaky'); + writeFileSync(p, 'x', { mode: 0o644 }); + chmodSync(p, 0o644); + await expect(readPrivateFile(p)).rejects.toThrowError( + PrivateFileTooPermissiveError, + ); + }); +}); + +describe('tokenStore', () => { + it('returns the same token from repeated getToken() calls', async () => { + const store = await createTokenStore(join(tmpDir, 'home')); + expect(store.getToken()).toBe(store.getToken()); + await store.dispose(); + }); + + it('produces different tokens for different home dirs', async () => { + const a = await createTokenStore(join(tmpDir, 'home-a')); + const b = await createTokenStore(join(tmpDir, 'home-b')); + expect(a.getToken()).not.toBe(b.getToken()); + await a.dispose(); + await b.dispose(); + }); + + it('reuses the same persistent token across stores in one home dir', async () => { + const home = join(tmpDir, 'home'); + const a = await createTokenStore(home); + const token = a.getToken(); + await a.dispose(); + const b = await createTokenStore(home); + expect(b.getToken()).toBe(token); + await b.dispose(); + }); + + it('writes the token file with mode 0600 at server.token', async () => { + const home = join(tmpDir, 'home'); + const store = await createTokenStore(home); + expect(store.tokenPath).toBe(join(home, 'server.token')); + expect(statSync(store.tokenPath).mode & 0o777).toBe(0o600); + await store.dispose(); + }); + + it('isValid accepts the token and rejects wrong / empty / same-length candidates', async () => { + const store = await createTokenStore(join(tmpDir, 'home')); + const token = store.getToken(); + expect(store.isValid(token)).toBe(true); + expect(store.isValid('wrong')).toBe(false); + expect(store.isValid('')).toBe(false); + + const other = await createTokenStore(join(tmpDir, 'home-other')); + expect(other.getToken().length).toBe(token.length); + expect(store.isValid(other.getToken())).toBe(false); + await store.dispose(); + await other.dispose(); + }); + + it('dispose() keeps the persistent token file on disk', async () => { + const store = await createTokenStore(join(tmpDir, 'home')); + expect(existsSync(store.tokenPath)).toBe(true); + await store.dispose(); + expect(existsSync(store.tokenPath)).toBe(true); + }); + + it('re-reads the token after the file is rewritten (live rotation)', async () => { + const home = join(tmpDir, 'home'); + const store = await createTokenStore(home); + const original = store.getToken(); + + // Rewrite the same way `rotateServerToken` does (atomic rename → new + // inode/mtime). Use a distinct, same-length value so the length check in + // isValid does not short-circuit. + const rotated = 'r'.repeat(original.length); + await writePrivateFile(store.tokenPath, rotated); + + expect(store.getToken()).toBe(rotated); + expect(store.isValid(rotated)).toBe(true); + expect(store.isValid(original)).toBe(false); + await store.dispose(); + }); +}); + +describe('persistentToken', () => { + it('loadOrCreateServerToken generates once and reuses thereafter', async () => { + const home = join(tmpDir, 'home'); + const a = await loadOrCreateServerToken(home); + const b = await loadOrCreateServerToken(home); + expect(a).toBe(b); + expect(statSync(join(home, 'server.token')).mode & 0o777).toBe(0o600); + }); + + it('rotateServerToken writes a new, different token to server.token', async () => { + const home = join(tmpDir, 'home'); + const original = await loadOrCreateServerToken(home); + const rotated = await rotateServerToken(home); + expect(rotated).not.toBe(original); + expect(readFileSync(join(home, 'server.token'), 'utf8').trim()).toBe(rotated); + }); +}); + +describe('password', () => { + it('resolvePasswordHash returns undefined when env is unset or empty', async () => { + expect(await resolvePasswordHash({})).toBeUndefined(); + expect(await resolvePasswordHash({ KIMI_CODE_PASSWORD: '' })).toBeUndefined(); + }); + + it('hashes a set password with bcrypt and verifies correctly', async () => { + const passwordHash = await resolvePasswordHash({ + KIMI_CODE_PASSWORD: 'correct-horse-battery-staple', + }); + expect(passwordHash?.startsWith('$2')).toBe(true); + expect(await verifyPassword('correct-horse-battery-staple', passwordHash)).toBe( + true, + ); + expect(await verifyPassword('wrong-password', passwordHash)).toBe(false); + }); + + it('verifyPassword returns false when the hash is undefined', async () => { + expect(await verifyPassword('anything', undefined)).toBe(false); + }); +}); diff --git a/packages/server/test/sessions-workspace.e2e.test.ts b/packages/server/test/sessions-workspace.e2e.test.ts index 8911b32a4..f1b211dfe 100644 --- a/packages/server/test/sessions-workspace.e2e.test.ts +++ b/packages/server/test/sessions-workspace.e2e.test.ts @@ -21,6 +21,7 @@ import type { Session, Workspace } from '@moonshot-ai/protocol'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -46,6 +47,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -58,12 +60,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/sessions.e2e.test.ts b/packages/server/test/sessions.e2e.test.ts index 090edf346..12ae9b761 100644 --- a/packages/server/test/sessions.e2e.test.ts +++ b/packages/server/test/sessions.e2e.test.ts @@ -36,6 +36,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WebSocket } from 'ws'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -67,6 +68,7 @@ afterEach(async () => { async function bootDaemon(options: { telemetry?: TelemetryClient } = {}): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -92,12 +94,24 @@ function recordingTelemetry(records: TelemetryRecord[]): TelemetryClient { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { code: number; msg: string; data: T | null; request_id: string; details?: unknown } { @@ -118,7 +132,7 @@ async function openSessionListListener(r: RunningServer): Promise<{ const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws'; const received: Record[] = []; const ws = await new Promise((resolve, reject) => { - const sock = new WebSocket(wsUrl); + const sock = new WebSocket(wsUrl, ['kimi-code.bearer.test-token']); sock.on('message', (data) => { try { received.push(JSON.parse(wsDataToString(data)) as Record); diff --git a/packages/server/test/skills.e2e.test.ts b/packages/server/test/skills.e2e.test.ts index 619e772d4..0db590320 100644 --- a/packages/server/test/skills.e2e.test.ts +++ b/packages/server/test/skills.e2e.test.ts @@ -36,6 +36,7 @@ import { import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -66,6 +67,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -78,12 +80,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/snapshot.e2e.test.ts b/packages/server/test/snapshot.e2e.test.ts index e12d657b1..b9f107d2c 100644 --- a/packages/server/test/snapshot.e2e.test.ts +++ b/packages/server/test/snapshot.e2e.test.ts @@ -25,6 +25,7 @@ import type { Event, SessionSnapshotResponse } from '@moonshot-ai/protocol'; import { IEventService, IPromptService, PromptService } from '@moonshot-ai/agent-core'; import { IRestGateway, IWSBroadcastService, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { WSBroadcastService } from '#/services/gateway/wsBroadcastService'; let tmpDir: string; @@ -51,6 +52,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -63,12 +65,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/snapshot.perf.test.ts b/packages/server/test/snapshot.perf.test.ts index d40381237..709cf46f3 100644 --- a/packages/server/test/snapshot.perf.test.ts +++ b/packages/server/test/snapshot.perf.test.ts @@ -20,6 +20,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { startServer, type RunningServer } from '../src'; +import { fixedTokenAuth, withAuth } from './helpers/serverHarness'; let tmpDir: string; let bridgeHome: string; @@ -42,11 +43,14 @@ afterEach(async () => { }); async function postSession(baseUrl: string, cwd: string): Promise { - const res = await fetch(`${baseUrl}/api/v1/sessions`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ metadata: { cwd } }), - }); + const res = await fetch( + `${baseUrl}/api/v1/sessions`, + withAuth({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ metadata: { cwd } }), + }), + ); const env = (await res.json()) as { code: number; data: { id: string } | null }; if (env.code !== 0 || env.data === null) throw new Error(JSON.stringify(env)); return env.data.id; @@ -54,7 +58,7 @@ async function postSession(baseUrl: string, cwd: string): Promise { async function timeSnapshot(baseUrl: string, sid: string): Promise { const t = performance.now(); - const res = await fetch(`${baseUrl}/api/v1/sessions/${sid}/snapshot`); + const res = await fetch(`${baseUrl}/api/v1/sessions/${sid}/snapshot`, withAuth()); await res.text(); return performance.now() - t; } @@ -71,6 +75,7 @@ describe('SnapshotReader perf (real HTTP, 50 sessions)', () => { host: '127.0.0.1', port: 0, lockPath: join(tmpDir, 'lock'), + serviceOverrides: [fixedTokenAuth()], logger: pino({ level: 'silent' }), coreProcessOptions: { homeDir: bridgeHome }, }); diff --git a/packages/server/test/snapshot.smoke.test.ts b/packages/server/test/snapshot.smoke.test.ts index 5d29e9502..5020b4762 100644 --- a/packages/server/test/snapshot.smoke.test.ts +++ b/packages/server/test/snapshot.smoke.test.ts @@ -22,6 +22,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { startServer, type RunningServer } from '../src'; +import { fixedTokenAuth, withAuth } from './helpers/serverHarness'; let tmpDir: string; let bridgeHome: string; @@ -48,6 +49,7 @@ async function boot(): Promise<{ baseUrl: string }> { host: '127.0.0.1', port: 0, lockPath: join(tmpDir, 'lock'), + serviceOverrides: [fixedTokenAuth()], logger: pino({ level: 'silent' }), coreProcessOptions: { homeDir: bridgeHome }, }); @@ -55,11 +57,14 @@ async function boot(): Promise<{ baseUrl: string }> { } async function postSession(baseUrl: string): Promise { - const res = await fetch(`${baseUrl}/api/v1/sessions`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ metadata: { cwd: join(tmpDir, 'workspace') } }), - }); + const res = await fetch( + `${baseUrl}/api/v1/sessions`, + withAuth({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ metadata: { cwd: join(tmpDir, 'workspace') } }), + }), + ); const env = (await res.json()) as { code: number; data: { id: string } | null }; if (env.code !== 0 || env.data === null) throw new Error(`createSession failed: ${JSON.stringify(env)}`); return env.data.id; @@ -71,7 +76,7 @@ async function fetchSnapshot(baseUrl: string, sid: string): Promise<{ ms: number; }> { const t0 = performance.now(); - const res = await fetch(`${baseUrl}/api/v1/sessions/${sid}/snapshot`); + const res = await fetch(`${baseUrl}/api/v1/sessions/${sid}/snapshot`, withAuth()); const envelope = (await res.json()) as { code: number; data: unknown }; return { status: res.status, envelope, ms: performance.now() - t0 }; } diff --git a/packages/server/test/start.test.ts b/packages/server/test/start.test.ts index cd25d5750..3ed2fa39f 100644 --- a/packages/server/test/start.test.ts +++ b/packages/server/test/start.test.ts @@ -47,6 +47,7 @@ import { type LockContents, type RunningServer, } from '../src'; +import { authHeaders, fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -123,6 +124,7 @@ function addrInUse(): NodeJS.ErrnoException { async function spawn(): Promise { const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -171,6 +173,7 @@ describe('startServer — lock + healthz smoke', () => { const thirdPartyLockPath = join(tmpDir, 'lock-third-party'); try { const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port, lockPath: thirdPartyLockPath, @@ -303,6 +306,7 @@ describe('startServer — web assets', () => { writeFileSync(join(assetsDir, 'app.js'), 'console.log("kimi web");', 'utf8'); const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -325,7 +329,7 @@ describe('startServer — web assets', () => { const health = await fetch(`${r.address}/api/v1/healthz`); await expect(health.json()).resolves.toMatchObject({ code: 0 }); - const openApi = await fetch(`${r.address}/openapi.json`); + const openApi = await fetch(`${r.address}/openapi.json`, { headers: authHeaders() }); expect(openApi.status).toBe(200); expect(openApi.headers.get('content-type')).toContain('application/json'); await expect(openApi.json()).resolves.toMatchObject({ @@ -338,7 +342,7 @@ describe('startServer — web assets', () => { }, }); - const asyncApi = await fetch(`${r.address}/asyncapi.json`); + const asyncApi = await fetch(`${r.address}/asyncapi.json`, { headers: authHeaders() }); expect(asyncApi.status).toBe(200); expect(asyncApi.headers.get('content-type')).toContain('application/json'); await expect(asyncApi.json()).resolves.toMatchObject({ @@ -362,6 +366,7 @@ describe('startServer — web assets', () => { it('does not expose the Swagger UI while keeping /openapi.json available', async () => { const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -370,7 +375,7 @@ describe('startServer — web assets', () => { }); running.push(r); - const openApi = await fetch(`${r.address}/openapi.json`); + const openApi = await fetch(`${r.address}/openapi.json`, { headers: authHeaders() }); expect(openApi.status).toBe(200); const res = await fetch(`${r.address}/documentation`); @@ -433,11 +438,14 @@ describe('POST /api/v1/shutdown', () => { coreProcessOptions: { homeDir: bridgeHome }, // Override the real shutdown service so the route does not exit the // test runner via `process.exit(0)`. - serviceOverrides: [[IServerShutdownService, fake] as const], + serviceOverrides: [fixedTokenAuth(), [IServerShutdownService, fake] as const], }); running.push(r); - const res = await fetch(`${r.address}/api/v1/shutdown`, { method: 'POST' }); + const res = await fetch(`${r.address}/api/v1/shutdown`, { + method: 'POST', + headers: authHeaders(), + }); expect(res.status).toBe(200); const body = (await res.json()) as Record; expect(body['code']).toBe(0); diff --git a/packages/server/test/swagger.e2e.test.ts b/packages/server/test/swagger.e2e.test.ts index 2327bb9df..a68a2da7e 100644 --- a/packages/server/test/swagger.e2e.test.ts +++ b/packages/server/test/swagger.e2e.test.ts @@ -13,6 +13,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -38,6 +39,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -50,12 +52,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown; payload: string }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown; payload: string }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown; payload: string }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function asRecord(value: unknown): Record { diff --git a/packages/server/test/tasks.e2e.test.ts b/packages/server/test/tasks.e2e.test.ts index 68c45bf5d..864ad7c7b 100644 --- a/packages/server/test/tasks.e2e.test.ts +++ b/packages/server/test/tasks.e2e.test.ts @@ -40,6 +40,7 @@ import { } from '@moonshot-ai/protocol'; import { IRestGateway, startServer, type ServerStartOptions, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -72,7 +73,7 @@ async function bootDaemon( lockPath, logger: pino({ level: 'silent' }), coreProcessOptions: { homeDir: bridgeHome }, - serviceOverrides, + serviceOverrides: [fixedTokenAuth(), ...(serviceOverrides ?? [])], }); return server; } @@ -80,12 +81,24 @@ async function bootDaemon( function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/terminals.e2e.test.ts b/packages/server/test/terminals.e2e.test.ts index 5b2be145f..6fd012ead 100644 --- a/packages/server/test/terminals.e2e.test.ts +++ b/packages/server/test/terminals.e2e.test.ts @@ -8,6 +8,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { FakeTerminalBackend } from './terminalTestBackend'; let tmpDir: string; @@ -41,6 +42,7 @@ async function bootServer(): Promise { logger: pino({ level: 'silent' }), coreProcessOptions: { homeDir: bridgeHome }, serviceOverrides: [ + fixedTokenAuth(), [ITerminalService, new SyncDescriptor(TerminalService, [{ backend }], false)], ], }); @@ -50,12 +52,24 @@ async function bootServer(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/tools.e2e.test.ts b/packages/server/test/tools.e2e.test.ts index ead6967f0..ef946498c 100644 --- a/packages/server/test/tools.e2e.test.ts +++ b/packages/server/test/tools.e2e.test.ts @@ -26,6 +26,7 @@ import { import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -51,6 +52,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -63,12 +65,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/workspaces.e2e.test.ts b/packages/server/test/workspaces.e2e.test.ts index da2c269f2..08a348856 100644 --- a/packages/server/test/workspaces.e2e.test.ts +++ b/packages/server/test/workspaces.e2e.test.ts @@ -25,6 +25,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import type { Workspace } from '@moonshot-ai/protocol'; let tmpDir: string; @@ -51,6 +52,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -63,12 +65,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/ws-abort.e2e.test.ts b/packages/server/test/ws-abort.e2e.test.ts index 6a59e0000..a7e5cbaef 100644 --- a/packages/server/test/ws-abort.e2e.test.ts +++ b/packages/server/test/ws-abort.e2e.test.ts @@ -32,6 +32,7 @@ import { WebSocket } from 'ws'; import { IPromptService, PromptService } from '@moonshot-ai/agent-core'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { rawDataToString } from '../src/ws/rawData'; let tmpDir: string; @@ -58,6 +59,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -71,12 +73,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { @@ -129,7 +143,7 @@ async function openSubscriber(r: RunningServer, sid: string): Promise[] = []; const ws = await new Promise((resolve, reject) => { - const sock = new WebSocket(wsUrl); + const sock = new WebSocket(wsUrl, ['kimi-code.bearer.test-token']); sock.on('message', (data) => { try { received.push(JSON.parse(rawDataToString(data)) as Record); diff --git a/packages/server/test/ws-auth.e2e.test.ts b/packages/server/test/ws-auth.e2e.test.ts new file mode 100644 index 000000000..01853cf6b --- /dev/null +++ b/packages/server/test/ws-auth.e2e.test.ts @@ -0,0 +1,252 @@ +/** + * WS upgrade auth (ROADMAP M3). + * + * M3.1 adds the `kimi-code.bearer.` subprotocol parser; M3.2 wires it + * into the upgrade path. The parser is exercised as pure unit cases first; the + * upgrade-path cases boot `startServer` with a fixed-token + * `IAuthTokenService` injected through `wsGatewayOptions.authTokenService`. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { WebSocket } from 'ws'; + +import { startServer, type RunningServer } from '../src'; +import { IAuthTokenService } from '../src/services/auth/authTokenService'; +import { extractWsBearerToken } from '../src/services/gateway/wsGateway'; +import { rawDataToString } from '../src/ws/rawData'; + +describe('extractWsBearerToken', () => { + it('returns undefined for a missing header', () => { + expect(extractWsBearerToken(undefined)).toBeUndefined(); + }); + + it('returns undefined for an empty header', () => { + expect(extractWsBearerToken('')).toBeUndefined(); + }); + + it('extracts the token from a single bearer subprotocol', () => { + expect(extractWsBearerToken('kimi-code.bearer.TOKEN')).toBe('TOKEN'); + }); + + it('finds the bearer subprotocol among a comma-separated list', () => { + expect(extractWsBearerToken('other, kimi-code.bearer.TOKEN2')).toBe('TOKEN2'); + }); + + it('returns undefined for an empty token', () => { + expect(extractWsBearerToken('kimi-code.bearer.')).toBeUndefined(); + }); + + it('returns undefined when no subprotocol matches', () => { + expect(extractWsBearerToken('unrelated')).toBeUndefined(); + }); +}); + +interface WsFrame { + type: string; + payload?: unknown; + [k: string]: unknown; +} + +interface Conn { + ws: WebSocket; + queue: WsFrame[]; + waiters: Array<(frame: WsFrame) => void>; + closed: Promise<{ code: number; reason: string }>; +} + +interface ConnectOptions { + protocols?: string[]; + headers?: Record; +} + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +const running: RunningServer[] = []; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-ws-auth-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-ws-auth-home-')); +}); + +afterEach(async () => { + for (const r of running.splice(0)) { + try { + await r.close(); + } catch { + // ignore + } + } + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +/** Accepts exactly `'test-token'`; mirrors the fixed-token seam used in M2. */ +const fixedTokenAuth: IAuthTokenService = { + _serviceBrand: undefined, + getToken: () => 'test-token', + isValid: async (candidate) => candidate === 'test-token', +}; + +async function spawn(): Promise { + const r = await startServer({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir: bridgeHome }, + wsGatewayOptions: { + pingIntervalMs: 60, + pongTimeoutMs: 200, + }, + // Inject the fixed token via the DI seam (M5.1 reads it through the WS + // gateway's `setAuthTokenService`, no longer via `wsGatewayOptions`). + serviceOverrides: [[IAuthTokenService, fixedTokenAuth]], + }); + running.push(r); + return r; +} + +function wsUrl(http: string): string { + return http.replace(/^http:\/\//, 'ws://') + '/api/v1/ws'; +} + +function openConn(url: string, opts?: ConnectOptions): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, opts?.protocols, { headers: opts?.headers }); + const queue: WsFrame[] = []; + const waiters: Array<(frame: WsFrame) => void> = []; + let closedResolve: (v: { code: number; reason: string }) => void; + const closed = new Promise<{ code: number; reason: string }>((res) => { + closedResolve = res; + }); + ws.on('message', (data) => { + let parsed: WsFrame; + try { + parsed = JSON.parse(rawDataToString(data)) as WsFrame; + } catch { + return; + } + if (waiters.length > 0) { + waiters.shift()?.(parsed); + } else { + queue.push(parsed); + } + }); + ws.on('close', (code, reason) => { + closedResolve({ code, reason: String(reason) }); + }); + ws.once('open', () => resolve({ ws, queue, waiters, closed })); + ws.once('error', (err) => reject(err)); + }); +} + +function receive(conn: Conn, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + if (conn.queue.length > 0) { + resolve(conn.queue.shift()!); + return; + } + const t = setTimeout(() => { + const idx = conn.waiters.indexOf(waiter); + if (idx >= 0) conn.waiters.splice(idx, 1); + reject(new Error(`no message in ${timeoutMs}ms`)); + }, timeoutMs); + const waiter = (frame: WsFrame): void => { + clearTimeout(t); + resolve(frame); + }; + conn.waiters.push(waiter); + }); +} + +async function receiveType(conn: Conn, type: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error(`no message of type ${type} within ${timeoutMs}ms`); + const frame = await receive(conn, remaining); + if (frame.type === type) return frame; + } +} + +/** + * Asserts the upgrade is rejected (the socket errors/closes without ever + * opening, so no `server_hello` is received). Resolves on the first `error` or + * `close`; rejects if the socket opens or nothing happens within the timeout. + */ +function expectRejected(url: string, opts?: ConnectOptions): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, opts?.protocols, { headers: opts?.headers }); + const done = (err?: Error): void => { + clearTimeout(t); + ws.removeAllListeners(); + try { + ws.terminate(); + } catch { + // ignore + } + if (err !== undefined) reject(err); + else resolve(); + }; + const t = setTimeout( + () => done(new Error('connection was not rejected within timeout')), + 1500, + ); + ws.once('open', () => done(new Error('connection unexpectedly opened'))); + ws.once('error', () => done()); + ws.once('close', () => done()); + }); +} + +describe('ws upgrade auth', () => { + it('accepts a valid bearer subprotocol and echoes it', async () => { + const r = await spawn(); + const conn = await openConn(wsUrl(r.address), { + protocols: ['kimi-code.bearer.test-token'], + }); + + await receiveType(conn, 'server_hello', 1000); + expect(conn.ws.protocol).toBe('kimi-code.bearer.test-token'); + + conn.ws.close(); + await conn.closed; + }); + + it('accepts a valid Authorization bearer header', async () => { + const r = await spawn(); + const conn = await openConn(wsUrl(r.address), { + headers: { Authorization: 'Bearer test-token' }, + }); + + const hello = await receiveType(conn, 'server_hello', 1000); + expect(hello.type).toBe('server_hello'); + + conn.ws.close(); + await conn.closed; + }); + + it('rejects a wrong bearer token without server_hello', async () => { + const r = await spawn(); + await expectRejected(wsUrl(r.address), { + protocols: ['kimi-code.bearer.wrong'], + }); + }); + + it('rejects a connection with no token', async () => { + const r = await spawn(); + await expectRejected(wsUrl(r.address)); + }); + + it('rejects upgrades to a non-/api/v1/ws path', async () => { + const r = await spawn(); + const badUrl = r.address.replace(/^http:\/\//, 'ws://') + '/api/v1/other'; + await expectRejected(badUrl, { protocols: ['kimi-code.bearer.test-token'] }); + }); +}); diff --git a/packages/server/test/ws-broadcast.e2e.test.ts b/packages/server/test/ws-broadcast.e2e.test.ts index a544906b3..23f28c50c 100644 --- a/packages/server/test/ws-broadcast.e2e.test.ts +++ b/packages/server/test/ws-broadcast.e2e.test.ts @@ -31,6 +31,7 @@ import { startServer, type RunningServer, } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { rawDataToString } from '../src/ws/rawData'; let tmpDir: string; @@ -52,12 +53,15 @@ afterEach(async () => { // ignore } } - rmSync(tmpDir, { recursive: true, force: true }); - rmSync(bridgeHome, { recursive: true, force: true }); + // The server's core process may still flush files into the sandboxed home + // briefly after close(), so retry removals to ride out EBUSY/ENOTEMPTY races. + rmSync(tmpDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + rmSync(bridgeHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); }); async function spawn(): Promise { const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -91,7 +95,7 @@ interface Conn { function openConn(url: string): Promise { return new Promise((resolve, reject) => { - const ws = new WebSocket(url); + const ws = new WebSocket(url, ['kimi-code.bearer.test-token']); const queue: WsFrame[] = []; const waiters: Array<(frame: WsFrame) => void> = []; ws.on('message', (data) => { diff --git a/packages/server/test/ws-handshake.e2e.test.ts b/packages/server/test/ws-handshake.e2e.test.ts index 10b5d7aa1..49e3c60ea 100644 --- a/packages/server/test/ws-handshake.e2e.test.ts +++ b/packages/server/test/ws-handshake.e2e.test.ts @@ -25,6 +25,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WebSocket } from 'ws'; import { IConnectionRegistry, IWSGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { rawDataToString } from '../src/ws/rawData'; interface TelemetryRecord { @@ -84,6 +85,7 @@ afterEach(async () => { async function spawn(): Promise { const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -122,7 +124,7 @@ interface Conn { function openConn(url: string): Promise { return new Promise((resolve, reject) => { - const ws = new WebSocket(url); + const ws = new WebSocket(url, ['kimi-code.bearer.test-token']); const queue: WsFrame[] = []; const waiters: Array<(frame: WsFrame) => void> = []; let closedResolve: (v: { code: number; reason: string }) => void; @@ -284,6 +286,7 @@ describe('WS gateway connection-count observer', () => { it('reports size 1 after a connect and size 0 after the last disconnect', async () => { const counts: number[] = []; const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -313,6 +316,7 @@ describe('WS gateway connection-count observer', () => { it('tracks multiple concurrent connections independently', async () => { const counts: number[] = []; const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -350,6 +354,7 @@ describe('WS gateway telemetry (ws_connected / ws_disconnected)', () => { it('emits ws_connected on connect and ws_disconnected on close', async () => { const records: TelemetryRecord[] = []; const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, diff --git a/packages/server/test/ws-resync.e2e.test.ts b/packages/server/test/ws-resync.e2e.test.ts index 5d21f2fed..d5ddb6361 100644 --- a/packages/server/test/ws-resync.e2e.test.ts +++ b/packages/server/test/ws-resync.e2e.test.ts @@ -41,6 +41,7 @@ import { startServer, type RunningServer, } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { rawDataToString } from '../src/ws/rawData'; import { WSBroadcastService } from '#/services/gateway/wsBroadcastService'; @@ -69,6 +70,7 @@ afterEach(async () => { async function spawn(): Promise { const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -104,7 +106,7 @@ interface Conn { function openConn(url: string): Promise { return new Promise((resolve, reject) => { - const ws = new WebSocket(url); + const ws = new WebSocket(url, ['kimi-code.bearer.test-token']); const queue: WsFrame[] = []; const waiters: Array<(frame: WsFrame) => void> = []; ws.on('message', (data) => { diff --git a/packages/server/test/ws-terminal.e2e.test.ts b/packages/server/test/ws-terminal.e2e.test.ts index 8b41f5871..353793cc0 100644 --- a/packages/server/test/ws-terminal.e2e.test.ts +++ b/packages/server/test/ws-terminal.e2e.test.ts @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WebSocket } from 'ws'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { rawDataToString } from '../src/ws/rawData'; import { FakeTerminalBackend } from './terminalTestBackend'; @@ -44,6 +45,7 @@ async function bootServer(): Promise { coreProcessOptions: { homeDir: bridgeHome }, wsGatewayOptions: { pingIntervalMs: 5_000, pongTimeoutMs: 5_000 }, serviceOverrides: [ + fixedTokenAuth(), [ITerminalService, new SyncDescriptor(TerminalService, [{ backend }], false)], ], }); @@ -53,12 +55,24 @@ async function bootServer(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { @@ -112,7 +126,9 @@ async function openSocket(r: RunningServer): Promise<{ }> { const received: Record[] = []; const ws = await new Promise((resolve, reject) => { - const sock = new WebSocket(r.address.replace('http://', 'ws://') + '/api/v1/ws'); + const sock = new WebSocket(r.address.replace('http://', 'ws://') + '/api/v1/ws', [ + 'kimi-code.bearer.test-token', + ]); sock.on('message', (data) => { received.push(JSON.parse(rawDataToString(data)) as Record); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8af62e766..bd0208433 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -553,6 +553,9 @@ importers: '@moonshot-ai/protocol': specifier: workspace:^ version: link:../protocol + bcryptjs: + specifier: ^2.4.3 + version: 2.4.3 fastify: specifier: ^5.1.0 version: 5.8.5 @@ -575,6 +578,9 @@ importers: specifier: ^3.25.2 version: 3.25.2(zod@4.3.6) devDependencies: + '@types/bcryptjs': + specifier: ^2.4.6 + version: 2.4.6 '@types/ws': specifier: ^8.18.0 version: 8.18.1 @@ -2561,6 +2567,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/bcryptjs@2.4.6': + resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -3070,6 +3079,9 @@ packages: bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + bcryptjs@2.4.3: + resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -8018,6 +8030,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/bcryptjs@2.4.6': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -8595,6 +8609,8 @@ snapshots: dependencies: tweetnacl: 0.14.5 + bcryptjs@2.4.3: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 From f059649ce89460621f0ca352d3e767f71a273e61 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 25 Jun 2026 18:19:12 +0800 Subject: [PATCH 21/93] feat(agent-core): suggest update-config command in max-steps error (#1099) --- packages/agent-core/src/loop/errors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent-core/src/loop/errors.ts b/packages/agent-core/src/loop/errors.ts index 23fc7dc95..b35e86820 100644 --- a/packages/agent-core/src/loop/errors.ts +++ b/packages/agent-core/src/loop/errors.ts @@ -8,7 +8,7 @@ export function createMaxStepsExceededError(maxSteps: number, message?: string): return new KimiError( ErrorCodes.LOOP_MAX_STEPS_EXCEEDED, message ?? - `Turn exceeded maxSteps=${maxSteps}. If max_steps_per_turn is too small, raise it in config.toml (loop_control.max_steps_per_turn).`, + `Turn exceeded maxSteps=${maxSteps}. If max_steps_per_turn is too small, raise it in config.toml (loop_control.max_steps_per_turn), or run "/update-config" to update it, then "/reload".`, { details: { maxSteps }, }, From 77412b89fabe132e4384297e0b9bcce806abc8d2 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Thu, 25 Jun 2026 19:21:17 +0800 Subject: [PATCH 22/93] fix(server): import bcryptjs via default export for ESM dev runner (#1104) - bcryptjs is a CommonJS package whose index.js re-exports via `module.exports = require("./dist/bcrypt.js")` - Node's cjs-module-lexer cannot detect named exports through that require() indirection, so `import { compare, hash } from 'bcryptjs'` threw "Named export 'compare' not found" under the tsx dev runner (make dev), even though vitest and the esbuild bundle handled it fine - switch to the default import and destructure, which works across tsx, vitest and the bundled binary --- packages/server/src/services/auth/password.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/server/src/services/auth/password.ts b/packages/server/src/services/auth/password.ts index 0a60f8bec..14abc3641 100644 --- a/packages/server/src/services/auth/password.ts +++ b/packages/server/src/services/auth/password.ts @@ -1,4 +1,6 @@ -import { compare, hash } from 'bcryptjs'; +import bcrypt from 'bcryptjs'; + +const { compare, hash } = bcrypt; const BCRYPT_COST = 12; From 3ea6ac278d2e57bb859ab423704bbd0fb2033c72 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 25 Jun 2026 19:25:23 +0800 Subject: [PATCH 23/93] feat(web): render plan review card with plan body and approach choices (#1101) * feat(web): render plan review card with plan body and approach choices The ExitPlanMode plan_review approval in the web UI now renders the plan body as Markdown with one button per approach option, plus Revise and Reject-and-Exit, with the selected label threaded back to the server. The approval header keeps APPROVAL REQUIRED and the minimize control on the title row and shows the plan path on a second line, and the plan body uses up to half the viewport height. The ExitPlanMode tool card also gains a link to the plan file, currently hidden behind a flag until the server can read files outside the workspace. * fix(web): hide misleading shortcut numbers on plan review actions When a plan review has approach options, the option buttons already own [1]/[2]/[3]. Revise and Reject-and-Exit advertised the same numbers even though those keys approve an option, so hide their shortcut labels whenever options are present. --- .changeset/web-plan-review-card.md | 5 + apps/kimi-web/src/api/daemon/eventReducer.ts | 21 ++++ .../src/components/chat/ApprovalCard.vue | 110 ++++++++++++++++-- .../kimi-web/src/components/chat/ChatDock.vue | 2 +- .../kimi-web/src/components/chat/ChatPane.vue | 8 +- .../kimi-web/src/components/chat/ToolCall.vue | 45 ++++++- .../composables/client/useWorkspaceState.ts | 3 +- .../src/composables/messagesToTurns.ts | 39 +++++++ .../src/composables/useKimiWebClient.ts | 34 ++++++ apps/kimi-web/src/i18n/locales/en/approval.ts | 4 + apps/kimi-web/src/i18n/locales/zh/approval.ts | 4 + apps/kimi-web/src/types.ts | 9 ++ 12 files changed, 265 insertions(+), 19 deletions(-) create mode 100644 .changeset/web-plan-review-card.md diff --git a/.changeset/web-plan-review-card.md b/.changeset/web-plan-review-card.md new file mode 100644 index 000000000..bb363cd2e --- /dev/null +++ b/.changeset/web-plan-review-card.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Show the plan body and approach choices in the plan review card when exiting plan mode in the web UI. diff --git a/apps/kimi-web/src/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts index 77d8831f2..f1f68cc14 100644 --- a/apps/kimi-web/src/api/daemon/eventReducer.ts +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -43,6 +43,10 @@ export interface KimiClientState { activeSessionId?: string; messagesBySession: Record; approvalsBySession: Record; + /** Preserved `plan_review` displays keyed by toolCallId. Plan content survives + * approval resolution so the ExitPlanMode tool card can keep rendering the + * plan (approved / rejected / revised) instead of losing it. */ + planReviewByToolCallId: Record; questionsBySession: Record; tasksBySession: Record; goalBySession: Record; @@ -58,6 +62,7 @@ export function createInitialState(): KimiClientState { activeSessionId: undefined, messagesBySession: {}, approvalsBySession: {}, + planReviewByToolCallId: {}, questionsBySession: {}, tasksBySession: {}, goalBySession: {}, @@ -77,6 +82,7 @@ function cloneState(s: KimiClientState): KimiClientState { sessions: [...s.sessions], messagesBySession: { ...s.messagesBySession }, approvalsBySession: { ...s.approvalsBySession }, + planReviewByToolCallId: { ...s.planReviewByToolCallId }, questionsBySession: { ...s.questionsBySession }, tasksBySession: { ...s.tasksBySession }, goalBySession: { ...s.goalBySession }, @@ -454,6 +460,21 @@ export function reduceAppEvent( if (!exists) { next.approvalsBySession[sid] = [...list, event.approval]; } + // Preserve a plan_review display so the plan stays visible in the + // ExitPlanMode tool card after the approval resolves. + const display = event.approval.display as + | { kind?: unknown; plan?: unknown; path?: unknown } + | null + | undefined; + if (display?.kind === 'plan_review' && typeof display.plan === 'string' && display.plan.length > 0) { + next.planReviewByToolCallId = { + ...next.planReviewByToolCallId, + [event.approval.toolCallId]: { + plan: display.plan, + path: typeof display.path === 'string' ? display.path : undefined, + }, + }; + } break; } diff --git a/apps/kimi-web/src/components/chat/ApprovalCard.vue b/apps/kimi-web/src/components/chat/ApprovalCard.vue index a17cf5781..30f281eaa 100644 --- a/apps/kimi-web/src/components/chat/ApprovalCard.vue +++ b/apps/kimi-web/src/components/chat/ApprovalCard.vue @@ -1,9 +1,10 @@ + + + + diff --git a/apps/kimi-web/src/components/chat/DiffView.vue b/apps/kimi-web/src/components/chat/DiffView.vue index 49bae0f63..d74eb0a40 100644 --- a/apps/kimi-web/src/components/chat/DiffView.vue +++ b/apps/kimi-web/src/components/chat/DiffView.vue @@ -6,6 +6,7 @@ import { computed, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import type { DiffViewLine } from '../../types'; +import DiffLines from './DiffLines.vue'; const { t } = useI18n(); @@ -97,18 +98,6 @@ const renderDetail = computed( const diffLines = computed(() => props.fileDiff ?? []); const loading = computed(() => props.fileDiffLoading === true); -/** Gutter cell text for a diff row (old / new line numbers). */ -function oldGutter(line: DiffViewLine): string { - return line.oldNo !== undefined ? String(line.oldNo) : ''; -} -function newGutter(line: DiffViewLine): string { - return line.newNo !== undefined ? String(line.newNo) : ''; -} - -function rowClass(line: DiffViewLine): string { - return `dl-${line.type}`; -} - function onOpen(path: string): void { emit('open', path); } @@ -243,23 +232,8 @@ function treePadding(depth: number): string {
{{ t('diff.loading') }}
-
-
- - -
+
+
{{ t('diff.noDiff') }}
@@ -682,81 +656,12 @@ function treePadding(depth: number): string { outline-offset: 1px; } -.diff-lines { +/* Wrapper that lets fill the panel height and scroll internally. + The line-row styles themselves live in DiffLines.vue. */ +.dv-lines-wrap { flex: 1; + min-height: 0; overflow: auto; - padding: 4px 0 12px; - font-size: var(--ui-font-size); - line-height: 1.5; - -webkit-overflow-scrolling: touch; -} - -.dl { - display: flex; - align-items: flex-start; - min-height: 18px; - white-space: pre; -} - -.dl-gutter { - flex: none; - width: 40px; - padding: 0 6px; - text-align: right; - color: var(--faint, #aeb4bc); - background: var(--panel, #fafbfc); - user-select: none; - border-right: 1px solid var(--line2, #eef1f4); - font-variant-numeric: tabular-nums; -} - -.dl-gutter.new { border-right: 1px solid var(--line, #e7eaee); } - -.dl-sign { - flex: none; - width: 16px; - text-align: center; - color: var(--muted); - user-select: none; -} - -.dl-text { - flex: 1; - padding-right: 14px; - white-space: pre; - color: var(--text); - min-width: 0; -} - -/* Added / removed lines: a faint background plus a left accent bar mark the - change, while the code TEXT keeps the normal ink colour. Washing the whole - line in green/red competed with reading the code itself; the sign (+/-) and - the accent carry the colour so the content stays legible. */ -.dl-add { - background: color-mix(in srgb, var(--ok) 7%, var(--bg)); - box-shadow: inset 2px 0 0 color-mix(in srgb, var(--ok) 55%, transparent); -} -.dl-add .dl-sign { - color: var(--ok, #0e7a38); -} - -.dl-del { - background: color-mix(in srgb, var(--err) 7%, var(--bg)); - box-shadow: inset 2px 0 0 color-mix(in srgb, var(--err) 55%, transparent); -} -.dl-del .dl-sign { - color: var(--err, #b91c1c); -} - -/* Hunk header — muted band spanning the whole row. */ -.dl-hunk { - background: var(--panel2, #f3f5f8); -} -.dl-hunk .hunk-text { - flex: 1; - padding: 1px 12px; - color: var(--muted, #8b929b); - font-style: normal; } /* Context rows keep plain colors (inherit). */ @@ -794,11 +699,5 @@ function treePadding(depth: number): string { } .back-btn:active { background: var(--panel2, #f5f6f8); } .diff-path { font-size: calc(var(--ui-font-size) - 1.5px); } - - /* Line panel: horizontal scroll for long lines; keep the mono gutter intact. */ - .diff-lines { - overflow-x: auto; - font-size: var(--ui-font-size); - } } diff --git a/apps/kimi-web/src/components/chat/ToolCall.vue b/apps/kimi-web/src/components/chat/ToolCall.vue index ade09a214..88ed225ad 100644 --- a/apps/kimi-web/src/components/chat/ToolCall.vue +++ b/apps/kimi-web/src/components/chat/ToolCall.vue @@ -1,8 +1,10 @@ + + + + diff --git a/apps/kimi-web/src/composables/useDetailPanel.ts b/apps/kimi-web/src/composables/useDetailPanel.ts index 11c41fb16..e92880be5 100644 --- a/apps/kimi-web/src/composables/useDetailPanel.ts +++ b/apps/kimi-web/src/composables/useDetailPanel.ts @@ -2,9 +2,11 @@ // Unified right-side detail layer. Only one detail is open at a time. import { computed, ref, watch, type Ref } from 'vue'; -import type { AgentMember } from '../types'; +import type { AgentMember, ToolDiffTarget } from '../types'; import type { DetailTarget } from './useFilePreview'; import type { useKimiWebClient } from './useKimiWebClient'; +import { buildEditDiffLines, extractEditPath, findToolCallById } from '../lib/toolDiff'; +import { toolLabel } from '../lib/toolMeta'; import { clampPanelWidth, panelMaxWidth, useViewportWidth } from './useViewportWidth'; type KimiWebClient = ReturnType; @@ -153,6 +155,46 @@ export function useDetailPanel({ if (detailTarget.value === 'agent') detailTarget.value = null; } + // --------------------------------------------------------------------------- + // Edit/Write tool-call diff preview + // --------------------------------------------------------------------------- + // Store only the tool id and re-derive the panel payload from the live tool + // call in the session turns, so a panel opened while the tool is still + // running keeps tracking its status / output / diff as they update. + const toolDiffToolId = ref(null); + + const toolDiffTarget = computed(() => { + const id = toolDiffToolId.value; + if (!id) return null; + const tool = findToolCallById(client.turns.value, id); + if (!tool) return null; + return { + id, + title: toolLabel(tool.name), + path: extractEditPath(tool.arg), + // On error the diff describes what was attempted, not what happened — + // show the tool output (the failure reason) instead. + lines: tool.status === 'error' ? null : buildEditDiffLines(tool), + output: tool.output, + }; + }); + + const toolDiffVisible = computed(() => toolDiffTarget.value !== null); + + function openToolDiff(id: string): void { + if (detailTarget.value === 'toolDiff' && toolDiffToolId.value === id) { + closeToolDiff(); + return; + } + detailTarget.value = 'toolDiff'; + toolDiffToolId.value = id; + } + + function closeToolDiff(): void { + toolDiffToolId.value = null; + if (detailTarget.value === 'toolDiff') detailTarget.value = null; + } + // --------------------------------------------------------------------------- // Diff detail layer (opened from the chat header git area) // --------------------------------------------------------------------------- @@ -160,6 +202,10 @@ export function useDetailPanel({ const detailDiffPath = ref(null); function openDiffDetail(): void { + if (detailTarget.value === 'diff') { + closeDiffDetail(); + return; + } detailTarget.value = 'diff'; detailDiffMode.value = 'list'; detailDiffPath.value = null; @@ -207,6 +253,7 @@ export function useDetailPanel({ (detailTarget.value !== 'thinking' || thinkingVisible.value) && (detailTarget.value !== 'compaction' || compactionPanelVisible.value) && (detailTarget.value !== 'agent' || agentPanelVisible.value) && + (detailTarget.value !== 'toolDiff' || toolDiffVisible.value) && (detailTarget.value !== 'btw' || btwVisible.value), ); @@ -219,6 +266,7 @@ export function useDetailPanel({ if (detailTarget.value === 'thinking' && thinkingVisible.value) { closeThinkingPanel(); return true; } if (detailTarget.value === 'compaction' && compactionPanelVisible.value) { closeCompactionPanel(); return true; } if (detailTarget.value === 'agent' && agentPanelVisible.value) { closeAgentPanel(); return true; } + if (detailTarget.value === 'toolDiff' && toolDiffVisible.value) { closeToolDiff(); return true; } if (detailTarget.value === 'file') { closeFilePreview(); return true; } if (detailTarget.value === 'diff') { closeDiffDetail(); return true; } if (detailTarget.value === 'btw') { closeSideChat(); return true; } @@ -230,6 +278,7 @@ export function useDetailPanel({ closeThinkingPanel(); closeCompactionPanel(); closeAgentPanel(); + closeToolDiff(); closeDiffDetail(); hideSideChatPanel(); }); @@ -253,6 +302,10 @@ export function useDetailPanel({ agentPanelVisible, openAgentPanel, closeAgentPanel, + toolDiffTarget, + toolDiffVisible, + openToolDiff, + closeToolDiff, detailDiffMode, detailDiffPath, openDiffDetail, diff --git a/apps/kimi-web/src/composables/useFilePreview.ts b/apps/kimi-web/src/composables/useFilePreview.ts index 1419996a4..b6fb58409 100644 --- a/apps/kimi-web/src/composables/useFilePreview.ts +++ b/apps/kimi-web/src/composables/useFilePreview.ts @@ -10,7 +10,7 @@ import type { useKimiWebClient } from './useKimiWebClient'; type KimiWebClient = ReturnType; /** Which occupant currently owns the shared right-side detail layer. */ -export type DetailTarget = 'file' | 'diff' | 'thinking' | 'compaction' | 'agent' | 'btw'; +export type DetailTarget = 'file' | 'diff' | 'thinking' | 'compaction' | 'agent' | 'toolDiff' | 'btw'; export interface UseFilePreviewOptions { client: KimiWebClient; @@ -87,6 +87,17 @@ export function useFilePreview({ client, detailTarget }: UseFilePreviewOptions) } async function openFilePreview(target: FilePreviewRequest): Promise { + // Clicking the link for the already-open file toggles the panel closed. + const current = previewTarget.value; + if ( + detailTarget.value === 'file' && + current && + current.path === target.path && + current.line === target.line + ) { + closeFilePreview(); + return; + } const requestSeq = ++previewRequestSeq; detailTarget.value = 'file'; previewFile.value = null; diff --git a/apps/kimi-web/src/lib/diffLines.ts b/apps/kimi-web/src/lib/diffLines.ts new file mode 100644 index 000000000..6def09452 --- /dev/null +++ b/apps/kimi-web/src/lib/diffLines.ts @@ -0,0 +1,111 @@ +// apps/kimi-web/src/lib/diffLines.ts +// Build line-by-line diff rows for from a before/after pair of +// plain texts (Edit's old_string/new_string, or Write's content vs an empty +// before). Uses a classic line-level LCS so unchanged lines line up as context. + +import type { DiffViewLine } from '../types'; + +/** + * Maximum LCS matrix size (`(oldLines + 1) * (newLines + 1)`) we are willing to + * allocate. Beyond this the diff would be too expensive to compute client-side + * (a 5k × 5k edit is 25M cells, ~200MB) and we fall back to showing the raw + * tool output instead. + */ +const MAX_DIFF_CELLS = 1_000_000; + +/** + * Cap on either side's line count. The output has at most n + m rows, so this + * bounds the result array for asymmetric edits (e.g. one line replaced by a + * hundred thousand) that the matrix-size cap alone would let through. + */ +const MAX_DIFF_ROWS = 5000; + +function splitLines(s: string): string[] { + if (s === '') return []; + const lines = s.split('\n'); + // A trailing newline produces a trailing empty element that is not a real + // content line — drop exactly one of them. + if (lines.at(-1) === '') lines.pop(); + return lines; +} + +export interface DiffStats { + added: number; + removed: number; +} + +/** + * Line-level LCS diff between `before` and `after`, producing rows consumable + * by . Line numbers are 1-based and advance per side like a + * unified diff: context lines advance both, deletions advance old, additions + * advance new. + * + * Returns null when the inputs are large enough that the LCS matrix would + * exceed `MAX_DIFF_CELLS`; callers should fall back to the raw tool output. + */ +export function buildDiffLines(before: string, after: string): DiffViewLine[] | null { + const oldLines = splitLines(before); + const newLines = splitLines(after); + const n = oldLines.length; + const m = newLines.length; + if (n === 0 && m === 0) return []; + if (n > MAX_DIFF_ROWS || m > MAX_DIFF_ROWS) return null; + if ((n + 1) * (m + 1) > MAX_DIFF_CELLS) return null; + + const dp: number[][] = Array.from({ length: n + 1 }, () => Array.from({ length: m + 1 }, () => 0)); + for (let i = 1; i <= n; i++) { + for (let j = 1; j <= m; j++) { + dp[i]![j] = + oldLines[i - 1] === newLines[j - 1] + ? dp[i - 1]![j - 1]! + 1 + : Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!); + } + } + + type Op = { type: 'context' | 'add' | 'del'; text: string }; + const ops: Op[] = []; + let i = n; + let j = m; + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) { + ops.push({ type: 'context', text: oldLines[i - 1]! }); + i--; + j--; + } else if (j > 0 && (i === 0 || dp[i]![j - 1]! >= dp[i - 1]![j]!)) { + ops.push({ type: 'add', text: newLines[j - 1]! }); + j--; + } else { + ops.push({ type: 'del', text: oldLines[i - 1]! }); + i--; + } + } + ops.reverse(); + + const result: DiffViewLine[] = []; + let oldNo = 1; + let newNo = 1; + for (const op of ops) { + if (op.type === 'context') { + result.push({ type: 'context', text: op.text, oldNo, newNo }); + oldNo++; + newNo++; + } else if (op.type === 'add') { + result.push({ type: 'add', text: op.text, newNo }); + newNo++; + } else { + result.push({ type: 'del', text: op.text, oldNo }); + oldNo++; + } + } + return result; +} + +export function diffStats(lines: DiffViewLine[]): DiffStats { + let added = 0; + let removed = 0; + for (const l of lines) { + if (l.type === 'add') added++; + else if (l.type === 'del') removed++; + } + return { added, removed }; +} diff --git a/apps/kimi-web/src/lib/toolDiff.ts b/apps/kimi-web/src/lib/toolDiff.ts new file mode 100644 index 000000000..94975feef --- /dev/null +++ b/apps/kimi-web/src/lib/toolDiff.ts @@ -0,0 +1,57 @@ +// apps/kimi-web/src/lib/toolDiff.ts +// Helpers for previewing Edit/Write tool calls: build the line diff and locate +// a live tool call in the session turns so the side panel can stay reactive. + +import type { ChatTurn, DiffViewLine, ToolCall } from '../types'; +import { buildDiffLines } from './diffLines'; +import { normalizeToolName } from './toolMeta'; + +function parseArg(arg: string): Record | null { + const s = arg.trim(); + if (!s.startsWith('{')) return null; + try { + const v = JSON.parse(s); + return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null; + } catch { + return null; + } +} + +/** + * Build a line diff for an Edit/Write tool call from its input. Returns null + * for any other tool, for operations a from-args diff cannot represent + * (replace_all, append), or when the inputs are too large to diff cheaply. + */ +export function buildEditDiffLines(tool: { name: string; arg: string }): DiffViewLine[] | null { + const kind = normalizeToolName(tool.name); + if (kind !== 'edit' && kind !== 'write') return null; + const d = parseArg(tool.arg); + if (!d) return null; + if (kind === 'edit') { + if (d.replace_all === true) return null; + const before = typeof d.old_string === 'string' ? d.old_string : undefined; + const after = typeof d.new_string === 'string' ? d.new_string : undefined; + if (before === undefined || after === undefined) return null; + return buildDiffLines(before, after); + } + // Write only reports the new content (and whether it appended); the client + // cannot tell a new file from an overwrite of an existing one. A from-empty + // diff would show an overwrite as "all additions, no deletions", which is + // misleading — so fall back to the tool output for every Write. + return null; +} + +/** Pull the file path out of an Edit/Write tool call's input, if present. */ +export function extractEditPath(arg: string): string | undefined { + const d = parseArg(arg); + return d && typeof d.path === 'string' ? d.path : undefined; +} + +/** Find a tool call by id across all session turns (for the live panel lookup). */ +export function findToolCallById(turns: ChatTurn[], id: string): ToolCall | undefined { + for (const turn of turns) { + const found = turn.tools?.find((t) => t.id === id); + if (found) return found; + } + return undefined; +} diff --git a/apps/kimi-web/src/types.ts b/apps/kimi-web/src/types.ts index 0203bc3bb..a625074c2 100644 --- a/apps/kimi-web/src/types.ts +++ b/apps/kimi-web/src/types.ts @@ -177,6 +177,22 @@ export interface FilePreviewRequest { line?: number; } +/** + * Payload for opening an Edit/Write tool-call diff in the right-side detail + * panel. `lines` carries the synthesized diff for single edits / new writes; + * it is null for operations a from-args diff can't represent (replace_all, + * append, multi-edit, errors), in which case `output` (the tool result) is + * shown instead. + */ +export interface ToolDiffTarget { + /** Tool-call id; used so clicking the same card again toggles the panel closed. */ + id: string; + title: string; + path?: string; + lines: DiffViewLine[] | null; + output?: string[]; +} + /** One ordered piece of an assistant turn: a thinking segment, a text segment * OR a tool card. Built in call order so every piece renders inline where it * happened (a turn can think → act → think again — nothing is hoisted). */ diff --git a/apps/kimi-web/test/lib-logic.test.ts b/apps/kimi-web/test/lib-logic.test.ts index e182d164a..f5c713a85 100644 --- a/apps/kimi-web/test/lib-logic.test.ts +++ b/apps/kimi-web/test/lib-logic.test.ts @@ -5,6 +5,8 @@ import { parseFilePathLinkCandidate, } from '../src/lib/filePathLinks'; import { parseDiff } from '../src/lib/parseDiff'; +import { buildDiffLines } from '../src/lib/diffLines'; +import { buildEditDiffLines } from '../src/lib/toolDiff'; import { createCoalescedAsyncRunner } from '../src/lib/snapshotSync'; import { normalizeToolName, toolSummary } from '../src/lib/toolMeta'; @@ -39,6 +41,72 @@ describe('parseDiff', () => { }); }); +describe('buildDiffLines', () => { + it('lines up context, deletions and additions with old/new line numbers', () => { + const before = 'a\nb\nc'; + const after = 'a\nB\nc\nd'; + expect(buildDiffLines(before, after)).toEqual([ + { type: 'context', text: 'a', oldNo: 1, newNo: 1 }, + { type: 'del', text: 'b', oldNo: 2 }, + { type: 'add', text: 'B', newNo: 2 }, + { type: 'context', text: 'c', oldNo: 3, newNo: 3 }, + { type: 'add', text: 'd', newNo: 4 }, + ]); + }); + + it('treats an empty before as an all-addition write', () => { + expect(buildDiffLines('', 'x\ny')).toEqual([ + { type: 'add', text: 'x', newNo: 1 }, + { type: 'add', text: 'y', newNo: 2 }, + ]); + }); + + it('returns all context for identical texts and empty for two empties', () => { + expect(buildDiffLines('a\nb', 'a\nb')).toEqual([ + { type: 'context', text: 'a', oldNo: 1, newNo: 1 }, + { type: 'context', text: 'b', oldNo: 2, newNo: 2 }, + ]); + expect(buildDiffLines('', '')).toEqual([]); + }); + + it('returns null when the LCS matrix would be too large', () => { + const big = Array.from({ length: 2000 }, (_, i) => `line${i}`).join('\n'); + expect(buildDiffLines(big, `${big}\nextra`)).toBeNull(); + }); + + it('returns null when one side is huge even though the matrix is small', () => { + const huge = Array.from({ length: 6000 }, (_, i) => `line${i}`).join('\n'); + expect(buildDiffLines('one line', huge)).toBeNull(); + }); +}); + +describe('buildEditDiffLines', () => { + it('builds a diff for a single Edit', () => { + const arg = JSON.stringify({ path: 'a.ts', old_string: 'a\nb', new_string: 'a\nB' }); + expect(buildEditDiffLines({ name: 'Edit', arg })).toEqual([ + { type: 'context', text: 'a', oldNo: 1, newNo: 1 }, + { type: 'del', text: 'b', oldNo: 2 }, + { type: 'add', text: 'B', newNo: 2 }, + ]); + }); + + it('falls back to output for replace_all edits', () => { + const arg = JSON.stringify({ path: 'a.ts', old_string: 'a', new_string: 'b', replace_all: true }); + expect(buildEditDiffLines({ name: 'Edit', arg })).toBeNull(); + }); + + it('falls back to output for every Write (new file or overwrite)', () => { + expect(buildEditDiffLines({ name: 'Write', arg: JSON.stringify({ path: 'a.ts', content: 'x' }) })).toBeNull(); + expect( + buildEditDiffLines({ name: 'Write', arg: JSON.stringify({ path: 'a.ts', content: 'x', mode: 'append' }) }), + ).toBeNull(); + }); + + it('returns null for non-edit/write tools', () => { + expect(buildEditDiffLines({ name: 'Bash', arg: JSON.stringify({ command: 'ls' }) })).toBeNull(); + }); +}); + describe('filePathLinks', () => { it('rejects URLs and bare unknown filenames', () => { expect(parseFilePathLinkCandidate('https://example.com/a.ts')).toBeNull(); From d554f9ac8771be09b5c9a56943167dd45108dc4f Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 26 Jun 2026 00:22:12 +0800 Subject: [PATCH 29/93] feat(web): show full accumulated subagent progress (#1109) * feat(web): show full accumulated subagent progress The subagent detail panel only showed the most recent 40 progress lines because the reducer truncated the accumulated output. Keep the full history so the panel reflects the entire process as it grows. * fix(web): preserve subagent progress across task updates The projector emits a taskCreated (without reducer-owned outputLines) right before every taskProgress, and the taskCreated branch replaced the task object outright, resetting outputLines to empty on each progress event. So the panel still only showed the latest chunk. Preserve the accumulated outputLines when replacing a task, and update the test to mirror the real taskCreated-before-taskProgress path. * feat(web): clean up subagent progress text Drop the noisy 'Started a step' line and summarize tool calls with a concise target (path / command / pattern) instead of the full JSON args, so the subagent progress panel shows what the subagent is actually doing. * fix(web): strip numeric index from subagent tool result names Some subagents name tool calls with a trailing index (e.g. Read_0, Bash_4), which surfaced in tool.result progress lines since the label did not resolve. Strip the index before resolving the label. * fix(web): bound non-subagent task output and subagent progress text Restore a tail cap for background bash/tool task output (which can grow without bound) while keeping subagent progress in full, and cap individual subagent tool.progress chunks so a single huge output cannot dominate the panel. * feat(web): group and fold subagent progress output Drop the noisy 'Finished' lines, group tool output under its call, and fold long output (first 5 + last 2 lines, expandable) so the subagent progress panel shows the call rhythm at a glance. --- .changeset/web-subagent-full-progress.md | 5 + .../src/api/daemon/agentEventProjector.ts | 56 +++++---- apps/kimi-web/src/api/daemon/eventReducer.ts | 15 ++- .../src/components/chat/AgentDetailPanel.vue | 119 +++++++++++++++++- .../test/agent-event-projector.test.ts | 41 ++++++ apps/kimi-web/test/event-reducer.test.ts | 70 ++++++++++- 6 files changed, 278 insertions(+), 28 deletions(-) create mode 100644 .changeset/web-subagent-full-progress.md create mode 100644 apps/kimi-web/test/agent-event-projector.test.ts diff --git a/.changeset/web-subagent-full-progress.md b/.changeset/web-subagent-full-progress.md new file mode 100644 index 000000000..72cb94470 --- /dev/null +++ b/.changeset/web-subagent-full-progress.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show the full accumulated progress of a subagent in its detail panel, with concise tool-call summaries instead of raw JSON. diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts index 31a01c1c6..5a55282b4 100644 --- a/apps/kimi-web/src/api/daemon/agentEventProjector.ts +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -26,6 +26,7 @@ import type { AppTask, } from '../types'; import { i18n } from '../../i18n'; +import { toolLabel, toolSummary } from '../../lib/toolMeta'; import { toAppMessageContent } from './mappers'; import type { WireMessageContent } from './wire'; @@ -212,41 +213,54 @@ function patchSubagent( return next; } -function shortJson(value: unknown): string { - if (value === undefined || value === null) return ''; - try { - const text = typeof value === 'string' ? value : JSON.stringify(value); - return text.length > 120 ? `${text.slice(0, 117)}...` : text; - } catch { - return ''; - } -} - -function subagentProgressText(rawType: string, payload: Record): string | null { - if (rawType === 'turn.step.started') return 'Started a step'; +export function subagentProgressText(rawType: string, payload: Record): string | null { + // "Started a step" fires on every step and adds no information — the phase + // badge already shows the subagent is working, so skip it to cut the noise. + if (rawType === 'turn.step.started') return null; if (rawType === 'tool.use' || rawType === 'tool.call.started') { const name = stringField(payload, 'name') ?? stringField(payload, 'toolName') ?? 'tool'; - const args = shortJson(payload['args'] ?? payload['input']); - return args ? `Calling ${name}: ${args}` : `Calling ${name}`; + const label = toolLabel(cleanToolName(name)); + const summary = toolArgSummary(name, payload['args'] ?? payload['input']); + return summary ? `Calling ${label}: ${summary}` : `Calling ${label}`; } if (rawType === 'tool.progress') { const update = payload['update']; if (update && typeof update === 'object') { const text = stringField(update as Record, 'text'); - if (text) return text; + if (text) return capProgressText(text); const message = stringField(update as Record, 'message'); - if (message) return message; + if (message) return capProgressText(message); } const message = stringField(payload, 'message'); - if (message) return message; - } - if (rawType === 'tool.result') { - const name = stringField(payload, 'name') ?? stringField(payload, 'toolName') ?? stringField(payload, 'toolCallId') ?? 'tool'; - return `Finished ${name}`; + if (message) return capProgressText(message); } + // tool.result lines ("Finished X") add noise without much information — the + // next call or the final summary already implies completion — so skip them. + if (rawType === 'tool.result') return null; return null; } +/** Strip a trailing `_N` index that some subagents append to tool names in + * `tool.result` events (e.g. `Read_0` → `Read`) so the label resolves. */ +function cleanToolName(name: string): string { + return name.replace(/_\d+$/, ''); +} + +/** Cap a progress text chunk so a single huge tool output (e.g. a big command + * result) cannot dominate the panel. */ +const MAX_PROGRESS_TEXT = 2000; +function capProgressText(text: string): string { + return text.length > MAX_PROGRESS_TEXT ? `${text.slice(0, MAX_PROGRESS_TEXT)}…` : text; +} + +/** A concise, human-readable summary of a tool call's arguments for progress + * lines (e.g. a file path or shell command), instead of the full JSON blob. */ +function toolArgSummary(name: string, args: unknown): string { + if (args === undefined || args === null) return ''; + const arg = typeof args === 'string' ? args : JSON.stringify(args); + return toolSummary(name, arg); +} + function projectSubagentProgress( state: SessionState, sessionId: string, diff --git a/apps/kimi-web/src/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts index f1f68cc14..23a94e002 100644 --- a/apps/kimi-web/src/api/daemon/eventReducer.ts +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -26,6 +26,11 @@ import { i18n } from '../../i18n'; const OPTIMISTIC_USER_MESSAGE_METADATA_KEY = 'kimiWeb.optimisticUserMessage'; +/** Tail cap for accumulated output of non-subagent (bash / background tool) + * tasks, whose stdout can be noisy and unbounded. Subagent progress is kept + * in full (small synthesized lines). */ +const MAX_BACKGROUND_OUTPUT_LINES = 40; + // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- @@ -518,7 +523,9 @@ export function reduceAppEvent( next.tasksBySession[sid] = [...list, event.task]; } else { const patched = [...list]; - patched[idx] = event.task; + // The projected task does not carry reducer-owned accumulated progress; + // preserve it across the replacement so subagent output keeps growing. + patched[idx] = { ...event.task, outputLines: list[idx]!.outputLines }; next.tasksBySession[sid] = patched; } break; @@ -532,9 +539,13 @@ export function reduceAppEvent( if (t.id !== event.taskId) return t; const outputLines = t.outputLines ?? []; if (outputLines.at(-1) === event.outputChunk) return t; + const lines = [...outputLines, event.outputChunk]; return { ...t, - outputLines: [...outputLines, event.outputChunk].slice(-40), + // Keep subagent progress in full (small synthesized lines) so the + // panel shows the whole process; cap background bash/tool output, + // which can grow without bound. + outputLines: t.kind === 'subagent' ? lines : lines.slice(-MAX_BACKGROUND_OUTPUT_LINES), }; }); break; diff --git a/apps/kimi-web/src/components/chat/AgentDetailPanel.vue b/apps/kimi-web/src/components/chat/AgentDetailPanel.vue index 59ebc1f35..be501b208 100644 --- a/apps/kimi-web/src/components/chat/AgentDetailPanel.vue +++ b/apps/kimi-web/src/components/chat/AgentDetailPanel.vue @@ -23,6 +23,55 @@ const progressLines = computed(() => .filter((line) => line.length > 0), ); +interface ProgressGroup { + key: string; + /** The "Calling …" tool-call line, or '' for output with no preceding call. */ + call: string; + output: string[]; +} + +/** Group flat progress lines into tool-call groups: a "Calling …" line starts a + * group and subsequent non-call lines are its output. */ +function groupProgress(lines: string[]): ProgressGroup[] { + const groups: ProgressGroup[] = []; + let current: ProgressGroup | null = null; + let idx = 0; + for (const line of lines) { + if (line.startsWith('Calling ')) { + current = { key: `g${idx++}`, call: line, output: [] }; + groups.push(current); + } else if (current) { + current.output.push(line); + } else { + current = { key: `g${idx++}`, call: '', output: [line] }; + groups.push(current); + } + } + return groups; +} + +const progressGroups = computed(() => groupProgress(progressLines.value)); + +/** Group keys whose folded output is expanded. */ +const expandedGroups = ref>(new Set()); + +const OUTPUT_FOLD_THRESHOLD = 8; +const OUTPUT_HEAD = 5; +const OUTPUT_TAIL = 2; + +function isExpanded(key: string): boolean { + return expandedGroups.value.has(key); +} +function toggleGroup(key: string): void { + const next = new Set(expandedGroups.value); + if (next.has(key)) next.delete(key); + else next.add(key); + expandedGroups.value = next; +} +function foldCount(group: ProgressGroup): number { + return group.output.length - OUTPUT_HEAD - OUTPUT_TAIL; +} + function phaseLabel(phase: AgentMember['phase']): string { switch (phase) { case 'queued': return 'Queued'; @@ -66,10 +115,27 @@ watch( Task
{{ member.prompt }}
-
+
Progress
- {{ line }} +
+
+ + {{ group.call }} +
+
+ + +
+
@@ -189,14 +255,59 @@ watch( .ap-progress { display: flex; flex-direction: column; - gap: 3px; + gap: 6px; font-family: var(--mono); color: var(--text); min-width: 0; } -.ap-progress span { +.ap-group { + min-width: 0; +} +.ap-call { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; + font-weight: 600; + color: var(--ink); + overflow-wrap: anywhere; + white-space: pre-wrap; +} +.ap-glyph { + flex: none; + color: var(--blue); + font-size: 0.85em; +} +.ap-output { + margin: 2px 0 0 16px; + padding-left: 8px; + color: var(--muted); + font-size: calc(var(--ui-font-size) - 1px); + line-height: 1.5; + border-left: 2px solid var(--line2, var(--line)); + min-width: 0; +} +.ap-out-line { min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; } +.ap-fold { + display: inline-block; + margin: 2px 0; + padding: 0; + background: none; + border: none; + color: var(--blue); + font-family: inherit; + font-size: inherit; + cursor: pointer; +} +.ap-fold:hover { + text-decoration: underline; +} +.ap-fold:focus-visible { + outline: 2px solid var(--blue); + outline-offset: 1px; +} diff --git a/apps/kimi-web/test/agent-event-projector.test.ts b/apps/kimi-web/test/agent-event-projector.test.ts new file mode 100644 index 000000000..a01d6f83c --- /dev/null +++ b/apps/kimi-web/test/agent-event-projector.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { subagentProgressText } from '../src/api/daemon/agentEventProjector'; + +describe('subagentProgressText', () => { + it('drops turn.step.started as noise', () => { + expect(subagentProgressText('turn.step.started', {})).toBeNull(); + }); + + it('summarizes a read tool call with its path', () => { + const text = subagentProgressText('tool.use', { name: 'read', args: { path: 'src/foo.ts' } }); + expect(text).toContain('src/foo.ts'); + expect(text).not.toContain('"path"'); + }); + + it('summarizes a bash tool call with its command', () => { + const text = subagentProgressText('tool.call.started', { name: 'bash', args: { command: 'pnpm test' } }); + expect(text).toContain('pnpm test'); + expect(text).not.toContain('"command"'); + }); + + it('drops tool.result lines as noise', () => { + expect(subagentProgressText('tool.result', { name: 'read' })).toBeNull(); + expect(subagentProgressText('tool.result', { name: 'Read_0' })).toBeNull(); + }); + + it('returns tool.progress update text', () => { + expect(subagentProgressText('tool.progress', { update: { text: 'working…' } })).toBe('working…'); + }); + + it('caps a long tool.progress text', () => { + const long = 'x'.repeat(3000); + const text = subagentProgressText('tool.progress', { update: { text: long } }); + expect(text).not.toBeNull(); + expect(text!.length).toBeLessThan(long.length); + expect(text!.endsWith('…')).toBe(true); + }); + + it('returns null for unknown event types', () => { + expect(subagentProgressText('turn.delta', {})).toBeNull(); + }); +}); diff --git a/apps/kimi-web/test/event-reducer.test.ts b/apps/kimi-web/test/event-reducer.test.ts index 8d0531aac..4df40777e 100644 --- a/apps/kimi-web/test/event-reducer.test.ts +++ b/apps/kimi-web/test/event-reducer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { createInitialState, reduceAppEvent } from '../src/api/daemon/eventReducer'; -import type { AppMessage, AppSession } from '../src/api/types'; +import type { AppMessage, AppSession, AppTask } from '../src/api/types'; function makeSession(id: string, updatedAt: string): AppSession { return { @@ -37,6 +37,17 @@ function makeMessage(sessionId: string, createdAt: string): AppMessage { }; } +function makeSubagentTask(id: string, sessionId: string): AppTask { + return { + id, + sessionId, + kind: 'subagent', + description: 'subagent task', + status: 'running', + createdAt: '2026-01-01T00:00:00.000Z', + }; +} + describe('reduceAppEvent messageCreated', () => { it('bumps the session updatedAt so it floats to the top of the sidebar', () => { const state = { @@ -81,3 +92,60 @@ describe('reduceAppEvent messageCreated', () => { expect(next.sessions.find((s) => s.id === 's-b')?.updatedAt).toBe('2026-01-01T00:00:00.000Z'); }); }); + +describe('reduceAppEvent taskProgress', () => { + it('accumulates the full progress output without truncating to a fixed window', () => { + const state = { + ...createInitialState(), + tasksBySession: { 's1': [makeSubagentTask('t1', 's1')] }, + }; + let next = state; + for (let i = 0; i < 60; i++) { + // The real projector emits a taskCreated (without reducer-owned + // outputLines) right before every taskProgress; progress must survive + // that replacement. + next = reduceAppEvent( + next, + { type: 'taskCreated', sessionId: 's1', task: makeSubagentTask('t1', 's1') }, + { sessionId: 's1', seq: i * 2 + 1 }, + ); + next = reduceAppEvent( + next, + { type: 'taskProgress', sessionId: 's1', taskId: 't1', outputChunk: `line ${i}`, stream: 'stdout' }, + { sessionId: 's1', seq: i * 2 + 2 }, + ); + } + const lines = next.tasksBySession['s1']?.[0]?.outputLines; + expect(lines).toHaveLength(60); + expect(lines?.[0]).toBe('line 0'); + expect(lines?.at(-1)).toBe('line 59'); + }); + + it('deduplicates a repeated trailing chunk', () => { + const state = { + ...createInitialState(), + tasksBySession: { 's1': [makeSubagentTask('t1', 's1')] }, + }; + const event = { type: 'taskProgress', sessionId: 's1', taskId: 't1', outputChunk: 'same', stream: 'stdout' } as const; + const once = reduceAppEvent(state, event, { sessionId: 's1', seq: 1 }); + const twice = reduceAppEvent(once, event, { sessionId: 's1', seq: 2 }); + expect(twice.tasksBySession['s1']?.[0]?.outputLines).toEqual(['same']); + }); + + it('caps accumulated output for non-subagent (background) tasks', () => { + const bash: AppTask = { ...makeSubagentTask('b1', 's1'), kind: 'bash' }; + const state = { ...createInitialState(), tasksBySession: { 's1': [bash] } }; + let next = state; + for (let i = 0; i < 60; i++) { + next = reduceAppEvent( + next, + { type: 'taskProgress', sessionId: 's1', taskId: 'b1', outputChunk: `line ${i}`, stream: 'stdout' }, + { sessionId: 's1', seq: i + 1 }, + ); + } + const lines = next.tasksBySession['s1']?.[0]?.outputLines; + expect(lines).toHaveLength(40); + expect(lines?.[0]).toBe('line 20'); + expect(lines?.at(-1)).toBe('line 59'); + }); +}); From 6a97d0bf431bc7038ce801da21164a67e07422d8 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 26 Jun 2026 01:01:56 +0800 Subject: [PATCH 30/93] feat(web): add a copy button to user messages (#1112) * feat(web): add a copy button to user messages Mirror the assistant per-message copy button on user turns, copying the message text with the same checkmark feedback. * fix(web): place user message copy button with undo and time Move the copy button out of the line-number role row into the modern bubble meta row, grouped with the undo and time buttons, with matching styling and tooltip. --- .changeset/web-copy-user-message.md | 5 ++ .../kimi-web/src/components/chat/ChatPane.vue | 67 ++++++++++++++++--- 2 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 .changeset/web-copy-user-message.md diff --git a/.changeset/web-copy-user-message.md b/.changeset/web-copy-user-message.md new file mode 100644 index 000000000..22b4803f5 --- /dev/null +++ b/.changeset/web-copy-user-message.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add a copy button to user messages in the web chat. diff --git a/apps/kimi-web/src/components/chat/ChatPane.vue b/apps/kimi-web/src/components/chat/ChatPane.vue index 5217731cc..f0876a539 100644 --- a/apps/kimi-web/src/components/chat/ChatPane.vue +++ b/apps/kimi-web/src/components/chat/ChatPane.vue @@ -385,6 +385,20 @@ function copyAssistantRun(index: number): void { }).catch(() => {/* ignore */}); } +function copyUserMessage(turn: ChatTurn): void { + const text = turn.text; + if (!text.trim()) return; + void copyTextToClipboard(text).then((ok) => { + if (!ok) return; + copiedTurn.value = turn.id; + if (copiedTimer !== null) clearTimeout(copiedTimer); + copiedTimer = setTimeout(() => { + copiedTimer = null; + copiedTurn.value = null; + }, 1400); + }).catch(() => {/* ignore */}); +} + function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): boolean { if (turn.id !== streamingTurnId.value) return false; return block.sourceIndex === turnBlocks(turn).length - 1; @@ -497,6 +511,21 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
+