diff --git a/.changeset/upstream-fix-batch-a.md b/.changeset/upstream-fix-batch-a.md new file mode 100644 index 00000000000..e842b75d05e --- /dev/null +++ b/.changeset/upstream-fix-batch-a.md @@ -0,0 +1,5 @@ +--- +"echadron": patch +--- + +More upstream reliability fixes: cancelling an MCP authorization no longer leaves the flow hanging until its timeout, `/feedback` now works for any signed-in user rather than only those running a managed model, a missing Git Bash on Windows reports what it looked for instead of a raw probe failure, web session export is no longer capped at 64 MiB, and a multi-select question in the VS Code panel waits for every answer. diff --git a/apps/kimi-code/src/feedback/feedback-attachments.ts b/apps/kimi-code/src/feedback/feedback-attachments.ts index c372e4d1189..df3b918abb8 100644 --- a/apps/kimi-code/src/feedback/feedback-attachments.ts +++ b/apps/kimi-code/src/feedback/feedback-attachments.ts @@ -92,7 +92,8 @@ async function prepareAndUploadCodebaseArchive( * `produce` write the archive to it, upload it, then always remove the temp * directory — even when `produce` or the upload throws. Both the session log * archive and the codebase archive flow through here so their cleanup and - * error handling cannot drift apart. + * error handling cannot drift apart. Every failure — including archive-path + * creation — is contained here as a non-fatal partial failure. */ async function uploadProducedArchive( api: FeedbackUploadUrlApi, @@ -100,16 +101,22 @@ async function uploadProducedArchive( filename: string, produce: (archivePath: string) => Promise, ): Promise { - const { archivePath, cleanupDir } = await createFeedbackArchivePath(filename); + let cleanupDir: string | undefined; try { - const archive = await produce(archivePath); - await uploadArchive(api, { ...archive, cleanupDir }, feedbackId, { filename }); + const target = await createFeedbackArchivePath(filename); + cleanupDir = target.cleanupDir; + const archive = await produce(target.archivePath); + await uploadArchive(api, { ...archive, cleanupDir: target.cleanupDir }, feedbackId, { + filename, + }); return true; } catch (error) { await logFeedbackUploadError(error); return false; } finally { - await rm(cleanupDir, { recursive: true, force: true }).catch(() => {}); + if (cleanupDir !== undefined) { + await rm(cleanupDir, { recursive: true, force: true }).catch(() => {}); + } } } diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index fd5d397f4bf..71c6b12b74c 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -19,7 +19,7 @@ import { feedbackSessionLine, withFeedbackVersionPrefix, } from '../constant/feedback'; -import { isManagedUsageProvider } from '../constant/kimi-tui'; +import { DEFAULT_OAUTH_PROVIDER_NAME, isManagedUsageProvider } from '../constant/kimi-tui'; import { submitFeedbackWithAttachments } from '../../feedback/feedback-attachments'; import { formatErrorMessage } from '../utils/event-payload'; import { openUrl } from '#/utils/open-url'; @@ -37,9 +37,24 @@ export async function handleFeedbackCommand(host: SlashCommandHost): Promise provider.providerName === DEFAULT_OAUTH_PROVIDER_NAME && provider.hasToken, + ); + } catch { + // The sign-in state is unreadable — keep the feedback entry usable by + // falling back to GitHub Issues instead of failing the command. + fallback(FEEDBACK_STATUS_FALLBACK); + return; + } + if (!signedIn) { + host.showStatus(FEEDBACK_STATUS_NOT_SIGNED_IN); + host.showStatus(FEEDBACK_ISSUE_URL); return; } @@ -60,8 +75,8 @@ export async function handleFeedbackCommand(host: SlashCommandHost): Promise { if (stopped) return; @@ -84,7 +99,15 @@ export async function handleFeedbackCommand(host: SlashCommandHost): Promise ({ uploadArchive: vi.fn(), })); -// /feedback falls back to opening GitHub Issues in a browser when not signed in -// or when submission fails — stub it out so the test suite never spawns a -// browser window. +vi.mock('../../src/feedback/archive', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // Wrap the real implementation so archive packaging keeps working in the + // other tests; individual tests can reject it to simulate an unwritable + // cache dir. + createFeedbackArchivePath: vi.fn(actual.createFeedbackArchivePath), + }; +}); + +// /feedback opens GitHub Issues in a browser when submission fails — stub it +// out so the test suite never spawns a browser window. vi.mock('#/utils/open-url', () => ({ openUrl: vi.fn() })); const ESC = String.fromCodePoint(0x1b); @@ -284,7 +296,11 @@ function makeHarness(session = makeSession(), overrides: Record }), getExperimentalFeatures: vi.fn(async () => []), auth: { - status: vi.fn(), + // /feedback gates on the OAuth token rather than the active model, so + // the default mock is a signed-in user; signed-out cases override this. + status: vi.fn(async () => ({ + providers: [{ providerName: 'managed:kimi-code', hasToken: true }], + })), login: vi.fn(), logout: vi.fn(), getManagedUsage: vi.fn(), @@ -545,21 +561,67 @@ command = "vim" ).toContain('Session reloaded.'); }); - it('tracks successful feedback submissions only after the request succeeds', async () => { - const { driver, harness } = await makeDriver( - makeSession(), - { - getConfig: vi.fn(async () => ({ - models: { - k2: { - model: 'moonshot-v1', - maxContextSize: 100, - provider: 'managed:kimi-code', - }, - }, - })), + it('prints the GitHub Issues link when not signed in', async () => { + const { driver, harness } = await makeDriver(makeSession()); + harness.auth.status.mockResolvedValueOnce({ + providers: [{ providerName: 'managed:kimi-code', hasToken: false }], + }); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(openUrl).mockClear(); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(openUrl).not.toHaveBeenCalled(); + expect(promptFeedbackInput).not.toHaveBeenCalled(); + expect(harness.auth.submitFeedback).not.toHaveBeenCalled(); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain("You're not signed in"); + expect(transcript).toContain('https://github.com/YaseenHQ/echadron/issues'); + }); + + it('falls back to GitHub Issues when the sign-in status cannot be read', async () => { + const { driver, harness } = await makeDriver(makeSession()); + harness.auth.status.mockRejectedValueOnce(new Error('token storage unavailable')); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockClear(); + vi.mocked(openUrl).mockClear(); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(openUrl).toHaveBeenCalledTimes(1); + expect(openUrl).toHaveBeenCalledWith('https://github.com/YaseenHQ/echadron/issues'); + expect(promptFeedbackInput).not.toHaveBeenCalled(); + expect(harness.auth.submitFeedback).not.toHaveBeenCalled(); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Opening GitHub Issues as fallback'); + }); + + it('submits feedback via OAuth for a signed-in user on an API-key model', async () => { + const { driver, harness } = await makeDriver(makeSession()); + driver.state.appState.availableModels = { + k2: { + provider: 'openai', + model: 'gpt-x', + maxContextSize: 100, + displayName: 'GPT X', + capabilities: [], }, - ); + }; + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'none'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 7 }); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(harness.auth.submitFeedback).toHaveBeenCalledOnce(); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 7'); + }); + + it('tracks successful feedback submissions only after the request succeeds', async () => { + const { driver, harness } = await makeDriver(makeSession()); const feedbackDriver = driver as unknown as FeedbackDriver; vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'none'); @@ -582,20 +644,7 @@ command = "vim" }); it('submits text feedback before preparing requested attachments', async () => { - const { driver, harness } = await makeDriver( - makeSession(), - { - getConfig: vi.fn(async () => ({ - models: { - k2: { - model: 'moonshot-v1', - maxContextSize: 100, - provider: 'managed:kimi-code', - }, - }, - })), - }, - ); + const { driver, harness } = await makeDriver(makeSession()); const feedbackDriver = driver as unknown as FeedbackDriver; vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs'); @@ -648,20 +697,7 @@ command = "vim" }); it('waits for the codebase upload to finish before returning', async () => { - const { driver, harness } = await makeDriver( - makeSession(), - { - getConfig: vi.fn(async () => ({ - models: { - k2: { - model: 'moonshot-v1', - maxContextSize: 100, - provider: 'managed:kimi-code', - }, - }, - })), - }, - ); + const { driver, harness } = await makeDriver(makeSession()); const feedbackDriver = driver as unknown as FeedbackDriver; vi.mocked(scanCodebase).mockReset(); harness.exportSession.mockReset(); @@ -735,20 +771,7 @@ command = "vim" }); it('uploads session logs when codebase scanning fails but the session directory is available', async () => { - const { driver, harness } = await makeDriver( - makeSession(), - { - getConfig: vi.fn(async () => ({ - models: { - k2: { - model: 'moonshot-v1', - maxContextSize: 100, - provider: 'managed:kimi-code', - }, - }, - })), - }, - ); + const { driver, harness } = await makeDriver(makeSession()); const feedbackDriver = driver as unknown as FeedbackDriver; vi.mocked(scanCodebase).mockReset(); harness.exportSession.mockReset(); @@ -784,21 +807,27 @@ command = "vim" expect(transcript).toContain('attachment upload failed'); }); + it('keeps archive-path creation failures as partial failures without the GitHub fallback', async () => { + const { driver, harness } = await makeDriver(makeSession()); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); + vi.mocked(createFeedbackArchivePath).mockRejectedValueOnce(new Error('cache dir not writable')); + vi.mocked(openUrl).mockClear(); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(openUrl).not.toHaveBeenCalled(); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback submitted, thank you!'); + expect(transcript).toContain('Feedback ID: 3'); + expect(transcript).toContain('attachment upload failed'); + }); + it('tells the user when feedback is sent but codebase packaging fails', async () => { - const { driver, harness } = await makeDriver( - makeSession(), - { - getConfig: vi.fn(async () => ({ - models: { - k2: { - model: 'moonshot-v1', - maxContextSize: 100, - provider: 'managed:kimi-code', - }, - }, - })), - }, - ); + const { driver, harness } = await makeDriver(makeSession()); const feedbackDriver = driver as unknown as FeedbackDriver; vi.mocked(scanCodebase).mockReset(); vi.mocked(packageCodebase).mockReset(); @@ -840,20 +869,7 @@ command = "vim" }); it('tells the user when the codebase upload fails', async () => { - const { driver, harness } = await makeDriver( - makeSession(), - { - getConfig: vi.fn(async () => ({ - models: { - k2: { - model: 'moonshot-v1', - maxContextSize: 100, - provider: 'managed:kimi-code', - }, - }, - })), - }, - ); + const { driver, harness } = await makeDriver(makeSession()); const feedbackDriver = driver as unknown as FeedbackDriver; vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); @@ -883,20 +899,7 @@ command = "vim" }); it('shows feedback API error messages without replacing them with HTTP status text', async () => { - const { driver, harness } = await makeDriver( - makeSession(), - { - getConfig: vi.fn(async () => ({ - models: { - k2: { - model: 'moonshot-v1', - maxContextSize: 100, - provider: 'managed:kimi-code', - }, - }, - })), - }, - ); + const { driver, harness } = await makeDriver(makeSession()); const feedbackDriver = driver as unknown as FeedbackDriver; vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'none'); @@ -914,21 +917,23 @@ command = "vim" expect(transcript).not.toContain('Failed to submit feedback (HTTP 500).'); }); + it('falls back to GitHub Issues when the submission request rejects', async () => { + const { driver, harness } = await makeDriver(makeSession()); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'none'); + harness.auth.submitFeedback.mockRejectedValueOnce(new Error('socket hangup')); + vi.mocked(openUrl).mockClear(); + + await expect(handleFeedbackCommand(feedbackDriver as any)).rejects.toThrow('socket hangup'); + + expect(openUrl).toHaveBeenCalledWith('https://github.com/YaseenHQ/echadron/issues'); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Opening GitHub Issues as fallback'); + }); + it('does not track feedback when the dialog is cancelled', async () => { - const { driver, harness } = await makeDriver( - makeSession(), - { - getConfig: vi.fn(async () => ({ - models: { - k2: { - model: 'moonshot-v1', - maxContextSize: 100, - provider: 'managed:kimi-code', - }, - }, - })), - }, - ); + const { driver, harness } = await makeDriver(makeSession()); const feedbackDriver = driver as unknown as FeedbackDriver; vi.mocked(promptFeedbackInput).mockImplementation(async () => undefined); harness.track.mockClear(); diff --git a/apps/vscode/webview-ui/src/components/QuestionDialog.tsx b/apps/vscode/webview-ui/src/components/QuestionDialog.tsx index c4919269a9b..c5f2e46fd8e 100644 --- a/apps/vscode/webview-ui/src/components/QuestionDialog.tsx +++ b/apps/vscode/webview-ui/src/components/QuestionDialog.tsx @@ -9,9 +9,12 @@ export function QuestionDialog() { const [selectedIndex, setSelectedIndex] = useState(1); const [questionIndex, setQuestionIndex] = useState(0); const [answers, setAnswers] = useState>({}); + const [multiSelected, setMultiSelected] = useState([]); const questions = pendingQuestion?.questions ?? []; const question = questions[questionIndex]; + const isMultiSelect = question?.multi_select === true; + const isLastQuestion = questionIndex + 1 >= questions.length; useEffect(() => { if (pendingQuestion) { @@ -20,6 +23,7 @@ export function QuestionDialog() { setSelectedIndex(1); setQuestionIndex(0); setAnswers({}); + setMultiSelected([]); } }, [pendingQuestion?.id]); @@ -28,28 +32,43 @@ export function QuestionDialog() { // Step through the questions one by one; submit all answers after the last. const handleAnswer = async (answer: string) => { const nextAnswers = { ...answers, [question.question]: answer }; - if (questionIndex + 1 < questions.length) { + if (!isLastQuestion) { setAnswers(nextAnswers); setQuestionIndex(questionIndex + 1); setShowCustom(false); setCustomInput(""); setSelectedIndex(1); + setMultiSelected([]); } else { await respondQuestion(nextAnswers); } }; const handleSelect = async (optionLabel: string) => { + if (isMultiSelect) { + setMultiSelected((prev) => + prev.includes(optionLabel) ? prev.filter((value) => value !== optionLabel) : [...prev, optionLabel], + ); + return; + } await handleAnswer(optionLabel); }; const handleCustomSubmit = async () => { - if (!customInput.trim()) return; - await handleAnswer(customInput.trim()); + const value = customInput.trim(); + if (!value) return; + if (isMultiSelect) { + setMultiSelected((prev) => (prev.includes(value) ? prev : [...prev, value])); + setCustomInput(""); + setShowCustom(false); + return; + } + await handleAnswer(value); }; const options = question.options || []; const customIndex = options.length + 1; + const customValues = multiSelected.filter((value) => !options.some((option) => option.label === value)); return (
@@ -61,25 +80,51 @@ export function QuestionDialog() { )} {question.header &&
{question.header}
}
{question.question}
+ {isMultiSelect &&
Select all that apply
}
- {options.map((option, idx) => ( + {options.map((option, idx) => { + const isChecked = isMultiSelect && multiSelected.includes(option.label); + const isHighlighted = selectedIndex === idx + 1; + return ( + + ); + })} + {customValues.map((value) => ( ))} {showCustom ? ( @@ -119,6 +164,17 @@ export function QuestionDialog() { Custom response... )} + {isMultiSelect && ( + + )}
diff --git a/docs/en/guides/sessions.md b/docs/en/guides/sessions.md index a4eb160b044..5aaab802501 100644 --- a/docs/en/guides/sessions.md +++ b/docs/en/guides/sessions.md @@ -123,8 +123,6 @@ You can also export from inside the TUI without leaving the interactive session: In the web UI, `/export` downloads the current session as a diagnostic ZIP. It includes the persisted session data, diagnostic logs, and a bounded metadata-only `logs/echadron-web.jsonl` record of key browser events. Prompt text, WebSocket payloads, and console arguments are not copied into this browser log. This web command differs from the TUI `/export` alias above. -The browser buffers the ZIP before saving it, so web exports are limited to 64 MiB. For a larger session, use `echadron export ` or the TUI `/export-debug-zip` command. - ::: tip Exported files may contain code, command output, and file paths that are sensitive. Review the content before sharing. ::: diff --git a/docs/zh/guides/sessions.md b/docs/zh/guides/sessions.md index 5c40efdabee..67f3349e8c4 100644 --- a/docs/zh/guides/sessions.md +++ b/docs/zh/guides/sessions.md @@ -121,8 +121,6 @@ echadron export -o ~/Desktop/my-session.zip 在 web UI 中,`/export` 会把当前会话下载为诊断 ZIP。压缩包包含持久化的会话数据、诊断日志,以及记录浏览器关键事件且大小有上限、只含元数据的 `logs/echadron-web.jsonl`;提示词正文、WebSocket 内容和 console 参数不会写入这份浏览器日志。这里的 web 命令与上面的 TUI `/export` 别名行为不同。 -浏览器需要先把 ZIP 缓存在内存中再保存,因此 web 导出上限为 64 MiB。更大的会话请使用 `echadron export ` 或 TUI 的 `/export-debug-zip`。 - ::: tip 提示 导出文件可能包含代码、命令输出和路径等敏感信息,分享前请先确认内容。 ::: diff --git a/packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts b/packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts index 8e68cb9cee3..7408934ff47 100644 --- a/packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts +++ b/packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts @@ -7,10 +7,12 @@ * same suite runs identically on any host OS. `probeHostEnvironmentFromNode()` * bundles the Node defaults for production callers and memoises the promise. * - * On Windows the probe expects bash from Git for Windows or MSYS2. If it - * cannot be located the function throws a plain `Error` with the checked paths - * in the message; the App-scope host-environment service catches that at first - * resolution. Set `KIMI_SHELL_PATH` to override. + * On Windows the probe expects bash from Git for Windows or MSYS2. If no + * shell can be located the function throws `ProbeShellNotFoundError`, a + * distinct type carrying the checked paths (`checked`) with an install hint + * in its message, so the DI boundary can tell a missing shell apart from + * other probe errors and translate it into a coded error. Set + * `KIMI_SHELL_PATH` to override. * * Vendored from `@moonshot-ai/kaos` `environment.ts` — kept as a pure helper * with no DI dependencies. @@ -26,6 +28,16 @@ export type OsKind = string; export type ShellName = 'bash' | 'sh'; export type PathClass = 'posix' | 'win32'; +export class ProbeShellNotFoundError extends Error { + readonly checked: readonly string[]; + + constructor(message: string, checked: readonly string[]) { + super(message); + this.name = 'ProbeShellNotFoundError'; + this.checked = checked; + } +} + export interface HostEnvironmentInfo { readonly osKind: OsKind; readonly osArch: string; @@ -183,8 +195,9 @@ async function locateWindowsGitBash(deps: HostEnvironmentProbeDeps): Promise'; +/** Raised on a pending `waitForCode` when the listener is closed. */ +export class OAuthCallbackClosedError extends Error { + constructor() { + super('OAuth callback listener closed'); + this.name = 'OAuthCallbackClosedError'; + } +} + export async function startCallbackServer(): Promise { let resolveCode: ((value: CallbackResult) => void) | undefined; let rejectCode: ((reason: Error) => void) | undefined; @@ -111,6 +119,10 @@ export async function startCallbackServer(): Promise { const close = async () => { if (closed) return; closed = true; + // Closing the listener strands any waiter: the browser can no longer + // deliver a code, so waitForCode would sit until its timeout. Settle it + // now, which is what cancelling an in-flight authorization does. + settle(() => rejectCode?.(new OAuthCallbackClosedError())); await new Promise((resolve) => { server.close(() => { resolve(); diff --git a/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts b/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts index df06acd2e7a..976ce29f0b3 100644 --- a/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts +++ b/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts @@ -58,7 +58,6 @@ export interface ExportSessionResult { export interface ExportSessionOptions { readonly webLog?: string; readonly signal?: AbortSignal; - readonly maxArchiveBytes?: number; } export interface ISessionExportService { diff --git a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts index 0dd0f02d841..921373d5d06 100644 --- a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts +++ b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts @@ -92,7 +92,6 @@ export class SessionExportService implements ISessionExportService { : undefined, webLog: options.webLog, signal: options.signal, - maxArchiveBytes: options.maxArchiveBytes, }); } @@ -166,7 +165,6 @@ export async function exportSessionDirectory(input: { readonly desktopLogPath?: string | undefined; readonly webLog?: string; readonly signal?: AbortSignal; - readonly maxArchiveBytes?: number; }): Promise { input.signal?.throwIfAborted(); const sessionDir = input.summary.sessionDir; @@ -246,7 +244,6 @@ export async function exportSessionDirectory(input: { sessionFiles: selectedSessionFiles, extraEntries: extras, signal: input.signal, - maxArchiveBytes: input.maxArchiveBytes, }); sessionLogSourceTransferred = sessionLogSource !== undefined; globalSourceTransferred = globalSource !== undefined; diff --git a/packages/agent-core-v2/src/app/sessionExport/zip.ts b/packages/agent-core-v2/src/app/sessionExport/zip.ts index 0a1168ccdfd..d31a2e4912d 100644 --- a/packages/agent-core-v2/src/app/sessionExport/zip.ts +++ b/packages/agent-core-v2/src/app/sessionExport/zip.ts @@ -8,7 +8,7 @@ import { createWriteStream } from 'node:fs'; import { mkdir, mkdtemp, readdir, rename, rm, stat } from 'node:fs/promises'; -import { Readable, Transform } from 'node:stream'; +import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { dirname, join, relative, resolve } from 'pathe'; @@ -49,7 +49,6 @@ export async function writeExportZip(args: { readonly sessionFiles: readonly SessionZipEntry[]; readonly extraEntries?: readonly ExtraZipEntry[]; readonly signal?: AbortSignal; - readonly maxArchiveBytes?: number; }): Promise { const unusedSources = new Set([ ...args.sessionFiles.flatMap((entry) => (typeof entry === 'string' ? [] : [entry.source])), @@ -110,12 +109,7 @@ export async function writeExportZip(args: { args.signal?.addEventListener('abort', onAbort, { once: true }); const destination = createWriteStream(tempOutputPath, { flags: 'wx' }); - writing = - args.maxArchiveBytes === undefined - ? pipeline(output, destination, { signal: args.signal }) - : pipeline(output, createArchiveLimit(args.maxArchiveBytes), destination, { - signal: args.signal, - }); + writing = pipeline(output, destination, { signal: args.signal }); const activate = (source: ZipSource): Readable => { unusedSources.delete(source); @@ -263,26 +257,6 @@ function abortReason(signal: AbortSignal): Error { : new DOMException('The operation was aborted.', 'AbortError'); } -function createArchiveLimit(maxArchiveBytes: number): Transform { - let archiveBytes = 0; - return new Transform({ - transform(chunk: Buffer, _encoding, callback) { - archiveBytes += chunk.length; - if (archiveBytes > maxArchiveBytes) { - callback( - new Error2( - ErrorCodes.SESSION_EXPORT_TOO_LARGE, - `Session export exceeds the ${maxArchiveBytes} byte archive limit.`, - { details: { archiveBytes, maxArchiveBytes } }, - ), - ); - return; - } - callback(null, chunk); - }, - }); -} - async function findConflictingSource(args: { readonly outputPath: string; readonly sessionFiles: readonly SessionZipEntry[]; diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostEnvironmentService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostEnvironmentService.ts index b6fe56b323f..d35b6185223 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostEnvironmentService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostEnvironmentService.ts @@ -5,12 +5,20 @@ * login-shell PATH enrichment (`applyLoginShellPathFromNode`) at construction * time; the sync fields become populated once `ready` resolves. Reads before * `ready` throws with a clear message so misuse fails loudly instead of - * returning stale zeros. Bound at App scope. + * returning stale zeros. A failed probe is translated at this boundary — a + * missing Git Bash on Windows becomes `HostProcessError` + * (`shell.git_bash_not_found`) — and surfaces identically from `ready` and + * from sync field reads, while an internal no-op handler keeps the rejection + * from ever becoming an unhandledRejection during App-scope construction. + * Bound at App scope. */ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { BugIndicatingError } from '#/_base/errors/errors'; -import { probeHostEnvironmentFromNode } from '#/_base/execEnv/environmentProbe'; +import { + probeHostEnvironmentFromNode, + ProbeShellNotFoundError, +} from '#/_base/execEnv/environmentProbe'; import { applyLoginShellPathFromNode } from '#/_base/execEnv/loginShellPath'; import { @@ -20,11 +28,13 @@ import { type PathClass, type ShellName, } from '#/os/interface/hostEnvironment'; +import { HostProcessError, OsProcessErrors } from '#/os/interface/hostProcess'; export class HostEnvironmentService implements IHostEnvironment { declare readonly _serviceBrand: undefined; private _info?: HostEnvironmentInfo; + private _probeError?: Error; readonly ready: Promise; constructor() { @@ -33,10 +43,20 @@ export class HostEnvironmentService implements IHostEnvironment { this._info = info; }), applyLoginShellPathFromNode(), - ]).then(() => {}); + ]) + .then(() => {}) + .catch((error: unknown) => { + const translated = this.toHostProcessError(error); + this._probeError = translated; + throw translated; + }); + this.ready.catch(() => {}); } private require(field: keyof HostEnvironmentInfo): never | HostEnvironmentInfo[typeof field] { + if (this._probeError !== undefined) { + throw this._probeError; + } if (this._info === undefined) { throw new BugIndicatingError( `IHostEnvironment.${field} accessed before ready — await IHostEnvironment.ready first (composition root should do so before creating a Session scope).`, @@ -45,6 +65,17 @@ export class HostEnvironmentService implements IHostEnvironment { return this._info[field]; } + private toHostProcessError(error: unknown): Error { + if (error instanceof ProbeShellNotFoundError) { + return new HostProcessError( + OsProcessErrors.codes.SHELL_GIT_BASH_NOT_FOUND, + error.message, + { details: { checkedPaths: error.checked }, cause: error }, + ); + } + return error instanceof Error ? error : new Error(String(error)); + } + get osKind(): OsKind { return this.require('osKind') as OsKind; } diff --git a/packages/agent-core-v2/src/os/interface/hostProcess.ts b/packages/agent-core-v2/src/os/interface/hostProcess.ts index 01b7d732bf5..9573d34b59c 100644 --- a/packages/agent-core-v2/src/os/interface/hostProcess.ts +++ b/packages/agent-core-v2/src/os/interface/hostProcess.ts @@ -55,6 +55,7 @@ export const OsProcessErrors = { codes: { OS_PROCESS_SPAWN_FAILED: 'os.process.spawn_failed', OS_PROCESS_KILL_FAILED: 'os.process.kill_failed', + SHELL_GIT_BASH_NOT_FOUND: 'shell.git_bash_not_found', }, info: { 'os.process.spawn_failed': { @@ -68,6 +69,12 @@ export const OsProcessErrors = { retryable: false, public: true, }, + 'shell.git_bash_not_found': { + title: 'Git Bash not found', + retryable: false, + public: true, + action: 'Install Git for Windows so shell commands can run under Git Bash.', + }, }, } as const satisfies ErrorDomain; @@ -76,6 +83,7 @@ registerErrorDomain(OsProcessErrors); export const HostProcessErrorCode = { SpawnFailed: OsProcessErrors.codes.OS_PROCESS_SPAWN_FAILED, KillFailed: OsProcessErrors.codes.OS_PROCESS_KILL_FAILED, + ShellGitBashNotFound: OsProcessErrors.codes.SHELL_GIT_BASH_NOT_FOUND, } as const; export type HostProcessErrorCode = (typeof HostProcessErrorCode)[keyof typeof HostProcessErrorCode]; diff --git a/packages/agent-core-v2/test/_base/execEnv/environmentProbe.test.ts b/packages/agent-core-v2/test/_base/execEnv/environmentProbe.test.ts index 9e084a0ebc5..7cc4b7f2252 100644 --- a/packages/agent-core-v2/test/_base/execEnv/environmentProbe.test.ts +++ b/packages/agent-core-v2/test/_base/execEnv/environmentProbe.test.ts @@ -20,6 +20,7 @@ import { describe, expect, it } from 'vitest'; import { probeHostEnvironment, + ProbeShellNotFoundError, type HostEnvironmentProbeDeps, } from '#/_base/execEnv/environmentProbe'; @@ -96,4 +97,20 @@ describe('probeHostEnvironment', () => { expect(env.shellName).toBe('bash'); expect(env.shellPath).toBe('C:\\msys64\\usr\\bin\\bash.exe'); }); + + it('throws ProbeShellNotFoundError when Git Bash is missing on Windows', async () => { + const rejected: unknown = await probeHostEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'C:\\Windows\\System32' }, + existingPaths: [], + }), + ).catch((error: unknown) => error); + + expect(rejected).toBeInstanceOf(ProbeShellNotFoundError); + const probeError = rejected as ProbeShellNotFoundError; + expect(probeError.message).toContain('https://gitforwindows.org/'); + expect(probeError.message).not.toContain('Checked:'); + expect(probeError.checked.length).toBeGreaterThan(0); + }); }); diff --git a/packages/agent-core-v2/test/agent/mcp/oauth/callback-server.test.ts b/packages/agent-core-v2/test/agent/mcp/oauth/callback-server.test.ts new file mode 100644 index 00000000000..c15ee46016a --- /dev/null +++ b/packages/agent-core-v2/test/agent/mcp/oauth/callback-server.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { + OAuthCallbackClosedError, + startCallbackServer, +} from '#/agent/mcp/oauth/callback-server'; + +describe('MCP OAuth callback server', () => { + it('settles a pending waitForCode when the listener is closed', async () => { + // Cancelling an in-flight authorization closes the listener. The browser + // can no longer deliver a code, so a waiter that is not settled here sits + // until its timeout instead of failing immediately. + const server = await startCallbackServer(); + const pending = server.waitForCode({ timeoutMs: 60_000 }); + await server.close(); + await expect(pending).rejects.toBeInstanceOf(OAuthCallbackClosedError); + }); + + it('closing twice is harmless', async () => { + const server = await startCallbackServer(); + const pending = server.waitForCode({ timeoutMs: 60_000 }); + await server.close(); + await server.close(); + await expect(pending).rejects.toBeInstanceOf(OAuthCallbackClosedError); + }); +}); diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index 07a5c832e87..a22a0688070 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -713,23 +713,6 @@ describe('sessionExport', () => { expect((await readdir(tmp)).toSorted()).toEqual(['export.zip', 'safe-output', 'state.json']); }); - it('rejects with a coded error when compressed output exceeds the configured limit', async () => { - const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-')); - - await expect( - writeExportZip({ - outputPath: join(tmp, 'too-large.zip'), - manifest: testManifest('ses_too_large'), - sessionDir: tmp, - sessionFiles: [], - maxArchiveBytes: 1, - }), - ).rejects.toMatchObject({ - code: 'session.export_too_large', - details: { maxArchiveBytes: 1 }, - }); - }); - it('throws a coded error when the session is unknown', async () => { const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-')); ix = createTestServices(tmp, { diff --git a/packages/agent-core-v2/test/os/backends/node-local/hostEnvironmentService.test.ts b/packages/agent-core-v2/test/os/backends/node-local/hostEnvironmentService.test.ts new file mode 100644 index 00000000000..4215819450c --- /dev/null +++ b/packages/agent-core-v2/test/os/backends/node-local/hostEnvironmentService.test.ts @@ -0,0 +1,72 @@ +/** + * HostEnvironmentService — shell-probe error handling. + * + * Stubs the host-environment probe to fail the way a Windows host without Git + * Bash does, so the suite runs identically on any platform. Pins the failure + * contract: `ready` rejects with the translated `HostProcessError` + * (`shell.git_bash_not_found`), sync field reads after a failed probe throw + * the same coded error, and the rejection never surfaces as an + * unhandledRejection while the App scope is being constructed (vitest fails + * the file on any unhandled rejection). + */ + +import { describe, expect, it, vi } from 'vitest'; + +import { ProbeShellNotFoundError } from '#/_base/execEnv/environmentProbe'; +import { HostEnvironmentService } from '#/os/backends/node-local/hostEnvironmentService'; +import { HostProcessError, OsProcessErrors } from '#/os/interface/hostProcess'; + +vi.mock('#/_base/execEnv/environmentProbe', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + probeHostEnvironmentFromNode: () => + Promise.reject( + new actual.ProbeShellNotFoundError('Git Bash missing (stubbed)', [ + 'C:\\Program Files\\Git\\bin\\bash.exe', + ]), + ), + }; +}); + +vi.mock('#/_base/execEnv/loginShellPath', () => ({ + applyLoginShellPathFromNode: () => Promise.resolve(), +})); + +describe('HostEnvironmentService', () => { + it('rejects ready with the translated HostProcessError when the probe fails', async () => { + const service = new HostEnvironmentService(); + + await expect(service.ready).rejects.toBeInstanceOf(HostProcessError); + await expect(service.ready).rejects.toMatchObject({ + code: OsProcessErrors.codes.SHELL_GIT_BASH_NOT_FOUND, + }); + }); + + it('preserves the probe error as cause and checked paths as details', async () => { + const service = new HostEnvironmentService(); + + const rejected: unknown = await service.ready.catch((error: unknown) => error); + + expect(rejected).toBeInstanceOf(HostProcessError); + const hostError = rejected as HostProcessError; + expect(hostError.details).toEqual({ checkedPaths: ['C:\\Program Files\\Git\\bin\\bash.exe'] }); + expect(hostError.cause).toBeInstanceOf(ProbeShellNotFoundError); + }); + + it('does not surface the ready rejection as an unhandledRejection', async () => { + const service = new HostEnvironmentService(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + await expect(service.ready).rejects.toBeInstanceOf(HostProcessError); + }); + + it('throws HostProcessError when reading fields after a failed probe', async () => { + const service = new HostEnvironmentService(); + await service.ready.catch(() => {}); + + expect(() => service.shellPath).toThrow(HostProcessError); + expect(() => service.osKind).toThrow(HostProcessError); + }); +}); diff --git a/packages/agent-core/src/mcp/oauth/callback-server.ts b/packages/agent-core/src/mcp/oauth/callback-server.ts index 332bff77ebf..24d61556263 100644 --- a/packages/agent-core/src/mcp/oauth/callback-server.ts +++ b/packages/agent-core/src/mcp/oauth/callback-server.ts @@ -45,6 +45,14 @@ const ERROR_HTML = '

The authorization server reported an error. Return to kimi-code for details.

' + ''; +/** Raised on a pending `waitForCode` when the listener is closed. */ +export class OAuthCallbackClosedError extends Error { + constructor() { + super('OAuth callback listener closed'); + this.name = 'OAuthCallbackClosedError'; + } +} + export async function startCallbackServer(): Promise { let resolveCode: ((value: CallbackResult) => void) | undefined; let rejectCode: ((reason: Error) => void) | undefined; @@ -117,6 +125,10 @@ export async function startCallbackServer(): Promise { const close = async () => { if (closed) return; closed = true; + // Closing the listener strands any waiter: the browser can no longer + // deliver a code, so waitForCode would sit until its timeout. Settle it + // now, which is what cancelling an in-flight authorization does. + settle(() => rejectCode?.(new OAuthCallbackClosedError())); await new Promise((resolve) => { server.close(() => { resolve(); diff --git a/packages/kap-server/src/routes/sessionExport.ts b/packages/kap-server/src/routes/sessionExport.ts index bc47540490a..8fa1788a514 100644 --- a/packages/kap-server/src/routes/sessionExport.ts +++ b/packages/kap-server/src/routes/sessionExport.ts @@ -29,8 +29,6 @@ import { exportSessionRequestSchema, } from '../protocol/rest-session'; -const MAX_WEB_SESSION_EXPORT_BYTES = 64 * 1024 * 1024; - interface SessionExportRouteHost { post( path: string, @@ -64,7 +62,6 @@ export function registerSessionExportRoute( errors: { [ErrorCode.VALIDATION_FAILED]: {}, [ErrorCode.SESSION_NOT_FOUND]: {}, - [ErrorCode.FILE_TOO_LARGE]: {}, [ErrorCode.INTERNAL_ERROR]: {}, }, description: 'Export a session and diagnostic logs as a zip archive', @@ -132,7 +129,6 @@ export function registerSessionExportRoute( { webLog: req.body.web_log, signal: exportAbort.signal, - maxArchiveBytes: MAX_WEB_SESSION_EXPORT_BYTES, }, ); if (aborted) { @@ -200,16 +196,6 @@ function sendMappedError(reply: SessionExportReply, req: { id: string }, error: reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, error.message, requestId)); return; } - if (error.code === ErrorCodes.SESSION_EXPORT_TOO_LARGE) { - reply.send( - errEnvelope( - ErrorCode.FILE_TOO_LARGE, - 'session export exceeds the 64 MiB web limit', - requestId, - ), - ); - return; - } } requestLog(req)?.error({ err: error }, 'session export failed'); reply.send( diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 59cf5361b22..fed16a10555 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -462,6 +462,13 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { async ensureConfigFile(): Promise { await ensureConfigFile(this.configPath); + // Surface a missing Git Bash early, before the TUI starts. The wait is + // Windows-only: the failure cannot happen on POSIX, and `ready` also + // covers the login-shell PATH enrichment, which spawns the user's login + // shell (5s timeout) — config-only commands must not block on that. + if (process.platform === 'win32') { + await this.app.accessor.get(IHostEnvironment).ready; + } } async close(): Promise { diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index 3f3794cc3d5..fb5f2f98e01 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -11,7 +11,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createKimiHarnessV2, ErrorCodes, KimiError, KimiHarness, SDKRpcClientV2 } from '#/index'; import { foldAgentWireReplay } from '#/v2/resume-replay'; @@ -19,6 +19,25 @@ import { foldAgentWireReplay } from '#/v2/resume-replay'; import { TEST_IDENTITY } from './test-identity'; import { recordingTelemetry, type TelemetryRecord } from './telemetry'; +const hostEnvProbe = vi.hoisted(() => ({ failWithMissingShell: false })); + +vi.mock('@moonshot-ai/agent-core-v2/_base/execEnv/environmentProbe', async (importOriginal) => { + const actual = await importOriginal< + typeof import('@moonshot-ai/agent-core-v2/_base/execEnv/environmentProbe') + >(); + return { + ...actual, + probeHostEnvironmentFromNode: () => + hostEnvProbe.failWithMissingShell + ? Promise.reject( + new actual.ProbeShellNotFoundError('Git Bash missing (stubbed)', [ + 'C:\\Program Files\\Git\\bin\\bash.exe', + ]), + ) + : actual.probeHostEnvironmentFromNode(), + }; +}); + const tempDirs: string[] = []; afterEach(async () => { @@ -27,6 +46,16 @@ afterEach(async () => { } }); +function stubProcessPlatform(platform: NodeJS.Platform): () => void { + const descriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + return () => { + if (descriptor !== undefined) { + Object.defineProperty(process, 'platform', descriptor); + } + }; +} + async function makeHarness(): Promise<{ harness: KimiHarness; homeDir: string }> { const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); tempDirs.push(homeDir);