From 96bcbaaaaec4861d4f7afa01b0655180f4e80d3a Mon Sep 17 00:00:00 2001 From: CHEN KUN Date: Wed, 11 Nov 2026 18:15:03 +0800 Subject: [PATCH] fix(mcp): expose Kimi OAuth authorization while waiting --- .../message/activity/activity-rows.test.tsx | 123 ++++++++++++ .../activity/rows/mcp-oauth-notice.tsx | 36 ++++ .../message/activity/rows/mcp-row.tsx | 14 +- src/contexts/acp-connections-context.test.tsx | 135 +++++++++++++ src/contexts/acp-connections-context.tsx | 23 ++- src/i18n/messages/ar.json | 7 + src/i18n/messages/de.json | 7 + src/i18n/messages/en.json | 7 + src/i18n/messages/es.json | 7 + src/i18n/messages/fr.json | 7 + src/i18n/messages/ja.json | 7 + src/i18n/messages/ko.json | 7 + src/i18n/messages/pt.json | 7 + src/i18n/messages/zh-CN.json | 7 + src/i18n/messages/zh-TW.json | 7 + src/lib/adapters/ai-elements-adapter.ts | 6 +- src/lib/kimi-mcp-oauth.test.ts | 190 ++++++++++++++++++ src/lib/kimi-mcp-oauth.ts | 138 +++++++++++++ src/lib/snapshot-denormalize.test.ts | 40 ++++ src/lib/snapshot-denormalize.ts | 3 +- 20 files changed, 768 insertions(+), 10 deletions(-) create mode 100644 src/components/message/activity/rows/mcp-oauth-notice.tsx create mode 100644 src/lib/kimi-mcp-oauth.test.ts create mode 100644 src/lib/kimi-mcp-oauth.ts diff --git a/src/components/message/activity/activity-rows.test.tsx b/src/components/message/activity/activity-rows.test.tsx index 70c01405fd9..42929203035 100644 --- a/src/components/message/activity/activity-rows.test.tsx +++ b/src/components/message/activity/activity-rows.test.tsx @@ -1418,3 +1418,126 @@ describe("a row's figure", () => { expect(command.querySelector(".activity-figure")).toBeEmptyDOMElement() }) }) + +describe("Kimi MCP OAuth entry", () => { + const url = + "https://auth.example.test/authorize?state=a%2Bb&code_challenge=x_y&redirect_uri=http%3A%2F%2F127.0.0.1%3A43127%2Fcallback" + function oauthParts(phase = "waiting", done = false) { + return parts( + turn([ + toolUse( + "oauth", + "mcp__notion__authenticate", + {}, + { + "codeg.kimiMcpOAuth": { + toolName: "mcp__notion__authenticate", + server: "notion", + phase, + url: phase === "waiting" ? url : null, + }, + } + ), + ...(done ? [toolResult("oauth", "cancelled", true)] : []), + ]), + !done, + done ? undefined : ["oauth"] + ) + } + + it("offers the full URL before completion and opens only on an explicit click", () => { + const open = vi.spyOn(window, "open").mockReturnValue(null) + try { + const view = renderUi() + const link = screen.getByRole("link", { + name: "Open authorization in system browser", + }) + expect(link).toHaveAttribute("href", url) + expect(open).not.toHaveBeenCalled() + fireEvent.click(link) + expect(open).toHaveBeenCalledExactlyOnceWith(url, "_blank", "noreferrer") + view.rerender( + withProviders( + + ) + ) + expect( + screen.queryByRole("link", { + name: "Open authorization in system browser", + }) + ).not.toBeInTheDocument() + expect(screen.getByText(/Reconnecting notion/)).toBeInTheDocument() + expect( + screen.getByText(/verify access with a real MCP tool call/) + ).toBeInTheDocument() + expect(open).toHaveBeenCalledTimes(1) + view.rerender( + withProviders() + ) + expect( + screen.queryByText(/Waiting for notion authorization/) + ).not.toBeInTheDocument() + } finally { + open.mockRestore() + } + }) + + it("reports native opener failure without leaking its error and allows retry", async () => { + const platform = await import("@/lib/platform") + const opener = vi + .spyOn(platform, "openUrl") + .mockRejectedValueOnce(new Error("private authorization parameters")) + .mockResolvedValueOnce() + vi.stubGlobal("__TAURI_INTERNALS__", {}) + try { + renderUi() + const link = screen.getByRole("link", { + name: "Open authorization in system browser", + }) + expect(opener).not.toHaveBeenCalled() + await act(async () => { + fireEvent.click(link) + }) + expect(opener).toHaveBeenLastCalledWith(url) + expect(screen.getByText(/Could not open the browser/)).toBeInTheDocument() + expect( + screen.queryByText(/private authorization parameters/) + ).not.toBeInTheDocument() + expect(link).toHaveAttribute("href", url) + await act(async () => { + fireEvent.click(link) + }) + expect(opener).toHaveBeenCalledTimes(2) + expect( + screen.queryByText(/Could not open the browser/) + ).not.toBeInTheDocument() + expect( + screen.getByText(/Waiting for notion authorization/) + ).toBeInTheDocument() + } finally { + opener.mockRestore() + vi.unstubAllGlobals() + } + }) + + it("never exposes a link from ordinary MCP output", () => { + renderUi( + + ) + expect( + screen.queryByRole("link", { + name: "Open authorization in system browser", + }) + ).not.toBeInTheDocument() + }) +}) diff --git a/src/components/message/activity/rows/mcp-oauth-notice.tsx b/src/components/message/activity/rows/mcp-oauth-notice.tsx new file mode 100644 index 00000000000..8ebc791b1ed --- /dev/null +++ b/src/components/message/activity/rows/mcp-oauth-notice.tsx @@ -0,0 +1,36 @@ +"use client" + +import { useState } from "react" +import { useTranslations } from "next-intl" + +import { BrowserLink } from "@/components/ui/browser-link" +import type { KimiMcpOAuth } from "@/lib/kimi-mcp-oauth" +import { openInSystemBrowser } from "@/lib/link-open" + +/** Opening a browser is only a user action, never proof of authentication. */ +export function McpOAuthNotice({ progress }: { progress: KimiMcpOAuth }) { + const t = useTranslations("Folder.chat.activity.oauth") + const [failed, setFailed] = useState(false) + if (progress.phase === "pending") return null + return ( +
+

{t(progress.phase, { server: progress.server })}

+ {progress.phase === "waiting" && progress.url ? ( + { + event.preventDefault() + setFailed(false) + // Keep the exact string, including state, PKCE and callback port. + void openInSystemBrowser(progress.url!).catch(() => setFailed(true)) + }} + > + {t("open")} + + ) : null} +

{t("verify")}

+ {failed ?

{t("openFailed")}

: null} +
+ ) +} diff --git a/src/components/message/activity/rows/mcp-row.tsx b/src/components/message/activity/rows/mcp-row.tsx index 670e2326870..31d87eaf087 100644 --- a/src/components/message/activity/rows/mcp-row.tsx +++ b/src/components/message/activity/rows/mcp-row.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl" import { tryParseJson } from "@/lib/activity/json" import { mcpResultPayload } from "@/lib/activity/mcp" +import { readKimiMcpOAuth } from "@/lib/kimi-mcp-oauth" import { cn } from "@/lib/utils" import { useActivityRender } from "../activity-context" import { ActivityRow } from "../activity-row" @@ -14,6 +15,7 @@ import { RailIcon } from "../rail-icon" import { ResultBody } from "../result-body" import { ToolFigure, toolIcon, useStatusText } from "../step-visuals" import type { ToolStep } from "./explore-rows" +import { McpOAuthNotice } from "./mcp-oauth-notice" /** `mcp · linear` — where the tool came from, not a status. */ function McpChip({ server }: { server: string }) { @@ -43,7 +45,7 @@ function prettyJson(text: string | null): string | null { export const McpRow = memo(function McpRow({ step }: { step: ToolStep }) { const t = useTranslations("Folder.chat.activity") const statusText = useStatusText() - const { recoveredKeys } = useActivityRender() + const { recoveredKeys, isStreaming } = useActivityRender() const { tool, part } = step const failed = tool.status === "failed" || tool.status === "rejected" const [open, setOpen] = useDisclosure(step.key, { @@ -60,6 +62,11 @@ export const McpRow = memo(function McpRow({ step }: { step: ToolStep }) { return JSON.stringify(payload.json, null, 2) }, [part.output, part.errorText]) const expandable = !!args || !!result + const oauth = readKimiMcpOAuth(part.meta) + const showOAuth = + isStreaming && + (tool.status === "pending" || tool.status === "running") && + oauth?.toolName === part.toolName return ( } + addon={ + showOAuth && oauth ? ( + + ) : null + } detail={() => (
diff --git a/src/contexts/acp-connections-context.test.tsx b/src/contexts/acp-connections-context.test.tsx index ad6bdcedb2c..99bb096ec14 100644 --- a/src/contexts/acp-connections-context.test.tsx +++ b/src/contexts/acp-connections-context.test.tsx @@ -2520,6 +2520,141 @@ describe("AcpConnectionsProvider permission request details", () => { } }) + it("exposes Kimi OAuth status before the tool settles and retires its link", async () => { + const frames: FrameRequestCallback[] = [] + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + frames.push(cb) + return frames.length + }) + vi.stubGlobal("cancelAnimationFrame", () => {}) + try { + await mountProvider() + await act(async () => { + await h.actions!.connect(TAB, "kimi_code", "/tmp/x", "sess-1") + }) + const handlers = latestAttachHandlers() + let seq = 0 + const emit = (event: Omit) => { + emitAcpEvent(handlers, { + ...event, + seq: ++seq, + connection_id: "spawned-conn", + } as EventEnvelope) + act(() => { + while (frames.length) frames.shift()!(0) + }) + } + const call = () => { + const block = h + .store!.getConnection(TAB)! + .liveMessage?.content.find( + (b) => b.type === "tool_call" && b.info.tool_call_id === "auth-1" + ) + return block?.type === "tool_call" ? block.info : null + } + emit({ type: "status_changed", status: "prompting" } as EventEnvelope) + emit({ + type: "tool_call", + tool_call_id: "auth-1", + title: "mcp__notion__authenticate", + kind: "other", + status: "pending", + content: null, + raw_input: null, + raw_output: null, + } as EventEnvelope) + emit({ + type: "tool_call_update", + tool_call_id: "auth-1", + title: "Authenticating notion", + status: "inprogress", + content: null, + raw_input: "{}", + raw_output: null, + } as EventEnvelope) + const url = + "https://auth.example.test/authorize?state=a%2Bb&code_challenge=x_y&redirect_uri=http%3A%2F%2F127.0.0.1%3A43127%2Fcallback" + const progress = `Open this URL in your browser to authorize "notion":\n\n${url}\n\nWaiting for the OAuth callback (timeout 15 min). If you cancel, call this tool again to restart the flow.` + const update = (title: string | null, status: string | null = null) => + emit({ + type: "tool_call_update", + tool_call_id: "auth-1", + title, + status, + content: null, + raw_input: null, + raw_output: null, + } as EventEnvelope) + update(progress) + expect(call()?.status).toBe("inprogress") + expect(call()?.title).toBe("mcp__notion__authenticate") + expect(call()?.meta?.["codeg.kimiMcpOAuth"]).toMatchObject({ + phase: "waiting", + url, + }) + const { buildStreamingTurnsFromLiveMessage } = + await import("@/stores/conversation-runtime-store") + const { adaptMessageTurn } = + await import("@/lib/adapters/ai-elements-adapter") + const { MOCKUP_ADAPTER_TEXT } = + await import("@/lib/activity/__fixtures__/mockup-turn") + const built = buildStreamingTurnsFromLiveMessage( + 42, + h.store!.getConnection(TAB)!.liveMessage! + ) + const adapted = adaptMessageTurn( + built.turns[0]!, + MOCKUP_ADAPTER_TEXT, + true, + new Set(["auth-1"]) + ) + expect(adapted.content.find((p) => p.type === "tool-call")).toMatchObject( + { + toolName: "mcp__notion__authenticate", + meta: { "codeg.kimiMcpOAuth": { phase: "waiting", url } }, + } + ) + update("Authorized — reconnecting notion…") + expect(call()?.meta?.["codeg.kimiMcpOAuth"]).toMatchObject({ + phase: "reconnecting", + url: null, + }) + update(null, "failed") + expect(call()?.meta?.["codeg.kimiMcpOAuth"]).toBeNull() + update(progress) + expect(call()?.meta?.["codeg.kimiMcpOAuth"]).toBeNull() + emit({ + type: "tool_call_update", + tool_call_id: "late-auth", + title: "mcp__notion__authenticate", + status: "inprogress", + content: null, + raw_input: "{}", + raw_output: null, + meta: { + "codeg.kimiMcpOAuth": { + toolName: "mcp__notion__authenticate", + server: "notion", + phase: "waiting", + url, + }, + }, + } as EventEnvelope) + const late = h + .store!.getConnection(TAB)! + .liveMessage?.content.find( + (b) => b.type === "tool_call" && b.info.tool_call_id === "late-auth" + ) + expect( + late?.type === "tool_call" + ? late.info.meta?.["codeg.kimiMcpOAuth"] + : undefined + ).toBeNull() + } finally { + vi.unstubAllGlobals() + } + }) + it("keeps a Monitor a monitor through the frames that announce it", async () => { // claude-agent-acp 0.98.0, recorded: the call is announced by its tool // name with no input, and the input follows in an update that names no diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index ebe1aa6b3a6..d5d3d93d6f5 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -1,5 +1,12 @@ "use client" +import { + readKimiMcpOAuth, + startKimiMcpOAuth, + stripKimiMcpOAuth, + updateKimiMcpOAuth, +} from "@/lib/kimi-mcp-oauth" + import { createContext, useCallback, @@ -1881,15 +1888,16 @@ function mergeToolCallUpdate( newTotalBytes = update.raw_output.length } + const oauth = readKimiMcpOAuth(info.meta) return { ...info, - title: update.title ?? info.title, + title: oauth?.toolName ?? update.title ?? info.title, status: update.status ?? info.status, content: update.content ?? info.content, raw_input: update.raw_input ?? info.raw_input, raw_output_chunks: newChunks, locations: update.locations ?? info.locations, - meta: update.meta ?? info.meta, + meta: updateKimiMcpOAuth(info.meta, update, info.status), raw_output_total_bytes: newTotalBytes, images: update.images !== null ? update.images : info.images, } @@ -2441,6 +2449,7 @@ function connectionsReducer( case "TOOL_CALL": { const conn = state.get(action.contextKey) if (!conn) return state + const meta = startKimiMcpOAuth(conn.agentType, action.title, action.meta) // Out-of-turn wire tool activity stays OUT of `liveMessage` (the // transcript overlay renders that content — grafting it here recreated // the garbled-timeline bug), but its context is still recorded so a @@ -2457,7 +2466,7 @@ function connectionsReducer( action.raw_output !== null ? [action.raw_output] : [], raw_output_total_bytes: action.raw_output?.length ?? 0, locations: action.locations, - meta: action.meta, + meta, images: action.images ?? [], } const next = new Map(state) @@ -2510,7 +2519,7 @@ function connectionsReducer( // The announcement is what names the tool // (`_meta.claudeCode.toolName`), so its `_meta` must land. locations: action.locations ?? block.info.locations, - meta: action.meta ?? block.info.meta, + meta: meta ?? block.info.meta, images: action.images !== null ? action.images : block.info.images, }, @@ -2536,7 +2545,7 @@ function connectionsReducer( action.raw_output !== null ? [action.raw_output] : [], raw_output_total_bytes: action.raw_output?.length ?? 0, locations: action.locations ?? null, - meta: action.meta ?? null, + meta, images: action.images ?? [], }, }, @@ -5256,7 +5265,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { raw_input: e.raw_input, raw_output: e.raw_output, locations: e.locations ?? null, - meta: (e.meta as ToolCallMeta) ?? null, + meta: stripKimiMcpOAuth((e.meta as ToolCallMeta) ?? null), images: e.images ?? null, }) break @@ -5274,7 +5283,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { raw_output: e.raw_output, raw_output_append: e.raw_output_append, locations: e.locations ?? null, - meta: (e.meta as ToolCallMeta) ?? null, + meta: stripKimiMcpOAuth((e.meta as ToolCallMeta) ?? null), images: e.images ?? null, }) scheduleToolCallUpdateFlush() diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 092624ad9cc..294ad0ead4e 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -3946,6 +3946,13 @@ "htmlPreviewHide": "إخفاء المعاينة" }, "activity": { + "oauth": { + "waiting": "بانتظار تفويض {server}", + "reconnecting": "جارٍ إعادة الاتصال بـ {server}…", + "open": "فتح التفويض في متصفح النظام", + "verify": "بعد إعادة الاتصال، تحقق من الوصول باستدعاء فعلي لأداة MCP. فتح المتصفح لا يؤكد نجاح التفويض.", + "openFailed": "تعذر فتح المتصفح. استخدم رابط التفويض للمحاولة مجددًا." + }, "header": { "label": "سير التنفيذ", "working": "قيد التنفيذ", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 717bf1ec451..ab049ded82d 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -3946,6 +3946,13 @@ "htmlPreviewHide": "Vorschau ausblenden" }, "activity": { + "oauth": { + "waiting": "Warten auf Autorisierung für {server}", + "reconnecting": "Verbindung zu {server} wird wiederhergestellt…", + "open": "Autorisierung im Systembrowser öffnen", + "verify": "Prüfe den Zugriff nach dem Neuverbinden mit einem echten MCP-Werkzeugaufruf. Das Öffnen des Browsers bestätigt keine Autorisierung.", + "openFailed": "Der Browser konnte nicht geöffnet werden. Versuche es über den Autorisierungslink erneut." + }, "header": { "label": "Ablauf", "working": "Läuft", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 95c2505440a..a9102ceede4 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3946,6 +3946,13 @@ "htmlPreviewHide": "Hide preview" }, "activity": { + "oauth": { + "waiting": "Waiting for {server} authorization", + "reconnecting": "Reconnecting {server}…", + "open": "Open authorization in system browser", + "verify": "After reconnecting, verify access with a real MCP tool call. Opening the browser does not confirm authorization.", + "openFailed": "Could not open the browser. Use the authorization link to try again." + }, "header": { "label": "Activity", "working": "Working", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 8410c0165b1..2664d616da4 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -3946,6 +3946,13 @@ "htmlPreviewHide": "Ocultar vista previa" }, "activity": { + "oauth": { + "waiting": "Esperando autorización de {server}", + "reconnecting": "Reconectando con {server}…", + "open": "Abrir autorización en el navegador del sistema", + "verify": "Tras reconectar, verifica el acceso con una llamada real a una herramienta MCP. Abrir el navegador no confirma la autorización.", + "openFailed": "No se pudo abrir el navegador. Usa el enlace de autorización para reintentar." + }, "header": { "label": "Actividad", "working": "En curso", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 1e03d8719c5..cb4e8f329ac 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -3946,6 +3946,13 @@ "htmlPreviewHide": "Masquer l’aperçu" }, "activity": { + "oauth": { + "waiting": "En attente d’autorisation pour {server}", + "reconnecting": "Reconnexion à {server}…", + "open": "Ouvrir l’autorisation dans le navigateur système", + "verify": "Après la reconnexion, vérifiez l’accès avec un véritable appel d’outil MCP. Ouvrir le navigateur ne confirme pas l’autorisation.", + "openFailed": "Impossible d’ouvrir le navigateur. Réessayez avec le lien d’autorisation." + }, "header": { "label": "Activité", "working": "En cours", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index c4695f5207a..9d833005828 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -3946,6 +3946,13 @@ "htmlPreviewHide": "プレビューを閉じる" }, "activity": { + "oauth": { + "waiting": "{server} の認可を待機中", + "reconnecting": "{server} に再接続中…", + "open": "システムブラウザーで認可を開く", + "verify": "再接続後、実際の MCP ツール呼び出しでアクセスを確認してください。ブラウザーを開くだけでは認可は確認されません。", + "openFailed": "ブラウザーを開けませんでした。認可リンクから再試行してください。" + }, "header": { "label": "実行過程", "working": "実行中", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 412f820533b..eddae8bcc95 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -3946,6 +3946,13 @@ "htmlPreviewHide": "미리보기 닫기" }, "activity": { + "oauth": { + "waiting": "{server} 승인 대기 중", + "reconnecting": "{server}에 다시 연결 중…", + "open": "시스템 브라우저에서 승인 열기", + "verify": "다시 연결한 후 실제 MCP 도구 호출로 접근을 확인하세요. 브라우저를 여는 것만으로 승인이 확인되지는 않습니다.", + "openFailed": "브라우저를 열지 못했습니다. 승인 링크로 다시 시도하세요." + }, "header": { "label": "실행 과정", "working": "진행 중", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 503cba05049..e59ad4051e6 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -3946,6 +3946,13 @@ "htmlPreviewHide": "Ocultar pré-visualização" }, "activity": { + "oauth": { + "waiting": "Aguardando autorização de {server}", + "reconnecting": "Reconectando a {server}…", + "open": "Abrir autorização no navegador do sistema", + "verify": "Após reconectar, verifique o acesso com uma chamada real de ferramenta MCP. Abrir o navegador não confirma a autorização.", + "openFailed": "Não foi possível abrir o navegador. Use o link de autorização para tentar novamente." + }, "header": { "label": "Atividade", "working": "Em andamento", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index b3bfccb921b..492f63a54f6 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -3946,6 +3946,13 @@ "htmlPreviewHide": "收起预览" }, "activity": { + "oauth": { + "waiting": "等待 {server} 授权", + "reconnecting": "正在重新连接 {server}…", + "open": "在系统浏览器中打开授权", + "verify": "重新连接后,请用真实 MCP 工具调用验证访问。打开浏览器不代表授权成功。", + "openFailed": "无法打开浏览器,请通过授权链接重试。" + }, "header": { "label": "执行过程", "working": "进行中", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 5bc94c985c0..a5c2bf3e7d1 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -3946,6 +3946,13 @@ "htmlPreviewHide": "收起預覽" }, "activity": { + "oauth": { + "waiting": "等待 {server} 授權", + "reconnecting": "正在重新連線 {server}…", + "open": "在系統瀏覽器中開啟授權", + "verify": "重新連線後,請用實際 MCP 工具呼叫驗證存取。開啟瀏覽器不代表授權成功。", + "openFailed": "無法開啟瀏覽器,請透過授權連結重試。" + }, "header": { "label": "執行過程", "working": "進行中", diff --git a/src/lib/adapters/ai-elements-adapter.ts b/src/lib/adapters/ai-elements-adapter.ts index 3b4a16ec1ef..e61f71fcc13 100644 --- a/src/lib/adapters/ai-elements-adapter.ts +++ b/src/lib/adapters/ai-elements-adapter.ts @@ -21,6 +21,7 @@ import { import { isGrepNoMatchCommandResult } from "@/lib/grep-no-match" import { isBackgroundTaskToolCall } from "@/lib/background-task" import { isContextCompactionMeta } from "@/lib/context-compaction" +import { readKimiMcpOAuth } from "@/lib/kimi-mcp-oauth" import { isUnsettledToolCall } from "@/lib/tool-call-lifecycle" import { feedbackCheckHasContent } from "@/lib/feedback-check" import { @@ -1737,7 +1738,10 @@ export function dropEmptyInFlightToolCalls( isBackgroundTaskToolCall(part) || // A compaction runs with no arguments until it completes; dropping it // would hide the "compacting" divider for as long as it works. - isContextCompactionMeta(part.meta) + isContextCompactionMeta(part.meta) || + // Kimi's synthetic authenticate tool takes {} and has no result while + // awaiting OAuth. Its scoped progress is the content the user needs. + readKimiMcpOAuth(part.meta)?.toolName === part.toolName ) { return true } diff --git a/src/lib/kimi-mcp-oauth.test.ts b/src/lib/kimi-mcp-oauth.test.ts new file mode 100644 index 00000000000..a44e960ad16 --- /dev/null +++ b/src/lib/kimi-mcp-oauth.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest" + +import { + KIMI_MCP_OAUTH_META, + readKimiMcpOAuth, + startKimiMcpOAuth, + updateKimiMcpOAuth, +} from "./kimi-mcp-oauth" + +const URL = + "https://auth.example.test/authorize?state=a%2Bb&code_challenge=x_y&code_challenge_method=S256&redirect_uri=http%3A%2F%2F127.0.0.1%3A43127%2Fcallback" +const statusText = (url = URL, server = "notion") => + `Open this URL in your browser to authorize "${server}":\n\n${url}\n\nWaiting for the OAuth callback (timeout 15 min). If you cancel, call this tool again to restart the flow.` +const opening = () => + startKimiMcpOAuth("kimi_code", "mcp__notion__authenticate", { keep: 1 }) +const update = (title: string | null, status: string | null = null) => ({ + title, + status, + content: null, + raw_input: null, + raw_output: null, + meta: null, +}) +const waiting = () => + updateKimiMcpOAuth(opening(), update(statusText()), "in_progress") + +describe("Kimi ACP OAuth status compatibility", () => { + it("keeps the original HTTPS URL bytes, and independent metadata, while waiting", () => { + expect(readKimiMcpOAuth(waiting())).toEqual({ + toolName: "mcp__notion__authenticate", + server: "notion", + phase: "waiting", + url: URL, + }) + expect(waiting()?.keep).toBe(1) + }) + + it.each([ + ["kimi_code", "mcp__notion__fetch"], + ["kimi_code", "authenticate"], + ["codex", "mcp__notion__authenticate"], + ["claude_code", "mcp__notion__authenticate"], + ])("does not establish OAuth context for %s / %s", (agent, tool) => { + const meta = startKimiMcpOAuth(agent, tool, null) + expect( + readKimiMcpOAuth( + updateKimiMcpOAuth(meta, update(statusText()), "in_progress") + ) + ).toBeNull() + }) + + it.each([ + "http://auth.example.test/authorize?state=x", + "javascript:alert(1)", + "file:///tmp/oauth", + "https://user:password@auth.example.test/authorize", + "https://auth.example.test/authorize\nhttps://evil.example.test", + "https://auth.example.test/authorize oauth", + "https://", + "https://auth.example.test/authorize\u0000", + "https://auth.example.test/authorize\u007f", + ])("rejects an unsafe or malformed URL: %s", (url) => { + expect( + readKimiMcpOAuth( + updateKimiMcpOAuth(opening(), update(statusText(url)), "in_progress") + )?.url + ).toBeNull() + }) + + it("never finds authorization URLs in a tool result, input or ordinary title", () => { + for (const patch of [ + { title: "result", raw_output: statusText() }, + { title: "result", content: statusText() }, + { title: "result", raw_input: statusText() }, + { title: `Please open ${URL}` }, + { title: statusText(), status: "in_progress" }, + { title: statusText(), raw_output: "unrelated result" }, + { title: statusText(URL, "other-server") }, + ]) { + const meta = updateKimiMcpOAuth( + opening(), + { ...update(null), ...patch }, + "in_progress" + ) + expect(readKimiMcpOAuth(meta)?.url).toBeNull() + } + }) + + it.each(["completed", "failed", "cancelled", "error"])( + "retires URLs on %s and ignores late progress", + (status) => { + const ended = updateKimiMcpOAuth( + waiting(), + update(null, status), + "in_progress" + ) + expect(ended?.[KIMI_MCP_OAUTH_META]).toBeNull() + expect( + readKimiMcpOAuth( + updateKimiMcpOAuth(ended, update(statusText()), status) + ) + ).toBeNull() + } + ) + + it("removes the link while reconnecting without declaring verified access", () => { + const meta = updateKimiMcpOAuth( + waiting(), + update("Authorized — reconnecting notion…"), + "in_progress" + ) + expect(readKimiMcpOAuth(meta)).toMatchObject({ + phase: "reconnecting", + url: null, + }) + expect( + readKimiMcpOAuth( + updateKimiMcpOAuth( + meta, + update("Discovering OAuth metadata for notion…"), + "in_progress" + ) + ) + ).toMatchObject({ phase: "pending", url: null }) + }) + + it("fails closed after context loss, and never imports a foreign binding from update metadata", () => { + expect( + readKimiMcpOAuth( + updateKimiMcpOAuth(null, update(statusText()), "in_progress") + ) + ).toBeNull() + expect( + readKimiMcpOAuth( + updateKimiMcpOAuth( + null, + { ...update(statusText()), meta: waiting() }, + "in_progress" + ) + ) + ).toBeNull() + expect( + readKimiMcpOAuth( + startKimiMcpOAuth("codex", "mcp__notion__authenticate", waiting()) + ) + ).toBeNull() + }) + + it("does not borrow the URL of another parallel authenticate call", () => { + const other = startKimiMcpOAuth( + "kimi_code", + "mcp__other__authenticate", + null + ) + expect( + readKimiMcpOAuth( + updateKimiMcpOAuth(other, update(statusText()), "in_progress") + )?.url + ).toBeNull() + expect(readKimiMcpOAuth(waiting())?.url).toBe(URL) + }) +}) + +it("accepts the Rust ACP bridge's inprogress status spelling", () => { + const running = updateKimiMcpOAuth( + opening(), + update("Authenticating notion", "inprogress"), + "pending" + ) + const meta = updateKimiMcpOAuth(running, update(statusText()), "inprogress") + expect(readKimiMcpOAuth(meta)?.url).toBe(URL) +}) + +it("matches Kimi-sanitized tool names to raw server labels without borrowing another server", () => { + const meta = startKimiMcpOAuth( + "kimi_code", + "mcp__my_notion__authenticate", + null + ) + const progress = updateKimiMcpOAuth( + meta, + update(statusText(URL, "my.notion")), + "inprogress" + ) + expect(readKimiMcpOAuth(progress)).toMatchObject({ + phase: "waiting", + server: "my.notion", + url: URL, + }) +}) diff --git a/src/lib/kimi-mcp-oauth.ts b/src/lib/kimi-mcp-oauth.ts new file mode 100644 index 00000000000..da46997ae7a --- /dev/null +++ b/src/lib/kimi-mcp-oauth.ts @@ -0,0 +1,138 @@ +/** Kimi 2.5.3 drops custom OAuth events at its ACP boundary, but forwards + * this exact status template as a title-only tool_call_update. Never scan + * tool inputs, results, assistant prose or pages for an authorization URL. */ +export const KIMI_MCP_OAUTH_META = "codeg.kimiMcpOAuth" + +type Meta = Record | null + +// Kimi's tool-naming.ts; status text uses the original server label. +function qualifiedAuthTool(server: string): string { + const name = server.replace(/[^a-zA-Z0-9_-]/g, "_").replace(/_+/g, "_") + return `mcp__${name}__authenticate` +} + +export interface KimiMcpOAuth { + toolName: string + server: string + phase: "pending" | "waiting" | "reconnecting" + url: string | null +} + +/** This binding is derived locally, never accepted from agent metadata. */ +export function stripKimiMcpOAuth(meta: Meta): Meta { + return meta && KIMI_MCP_OAUTH_META in meta + ? { ...meta, [KIMI_MCP_OAUTH_META]: null } + : meta +} + +export function isOAuthHttpsUrl(value: string): boolean { + if (!value.startsWith("https://") || /[\s\u0000-\u001f\u007f]/.test(value)) { + return false + } + try { + const url = new URL(value) + return !!url.hostname && !url.username && !url.password + } catch { + return false + } +} + +export function readKimiMcpOAuth(meta: Meta | undefined): KimiMcpOAuth | null { + const value = meta?.[KIMI_MCP_OAUTH_META] + if (!value || typeof value !== "object") return null + const p = value as KimiMcpOAuth + if ( + typeof p.server !== "string" || + !p.server || + /["\r\n]/.test(p.server) || + p.toolName !== qualifiedAuthTool(p.server) || + !["pending", "waiting", "reconnecting"].includes(p.phase) || + (p.phase === "waiting" + ? typeof p.url !== "string" || !isOAuthHttpsUrl(p.url) + : p.url !== null) + ) { + return null + } + return p +} + +/** Only the opening call can establish provenance. Snapshots/late updates + * that missed that identity fail closed until a new authenticate call. */ +export function startKimiMcpOAuth( + agentType: string, + title: string, + meta: Meta +): Meta { + const server = + agentType === "kimi_code" + ? /^mcp__([\w-]+)__authenticate$/.exec(title)?.[1] + : null + if (!server) return stripKimiMcpOAuth(meta) + return { + ...meta, + [KIMI_MCP_OAUTH_META]: server + ? { toolName: title, server, phase: "pending", url: null } + : null, + } +} + +const WAIT_SUFFIX = + "\n\nWaiting for the OAuth callback (timeout 15 min). If you cancel, call this tool again to restart the flow." + +export function updateKimiMcpOAuth( + previous: Meta, + update: { + title: string | null + status: string | null + content: string | null + raw_input: string | null + raw_output: string | null + meta: Meta + }, + currentStatus: string +): Meta { + const next = update.meta ?? previous + const progress = readKimiMcpOAuth(previous) + if (!progress && !next?.[KIMI_MCP_OAUTH_META]) return next + const status = (update.status ?? currentStatus) + .toLowerCase() + .replace(/_/g, "") + if (!progress || !["pending", "inprogress"].includes(status)) { + return { ...next, [KIMI_MCP_OAUTH_META]: null } + } + let value = progress + if ( + update.title !== null && + update.status === null && + update.content === null && + update.raw_input === null && + update.raw_output === null + ) { + // A new status retires the old URL, including reconnect and retry. + value = { ...progress, phase: "pending", url: null } + const prefix = + /^Open this URL in your browser to authorize "([^"\r\n]+)":\n\n/.exec( + update.title + ) + const server = prefix?.[1] + if ( + prefix && + server && + qualifiedAuthTool(server) === progress.toolName && + update.title.endsWith(WAIT_SUFFIX) + ) { + const url = update.title.slice(prefix[0].length, -WAIT_SUFFIX.length) + if (isOAuthHttpsUrl(url)) + value = { ...value, server, phase: "waiting", url } + } else { + const reconnect = + /^(?:Authorized — reconnecting |Already authorized; reconnecting )([^"\r\n]+)…$/.exec( + update.title + )?.[1] + if (reconnect && qualifiedAuthTool(reconnect) === progress.toolName) { + value = { ...value, server: reconnect, phase: "reconnecting" } + } + } + } + return { ...next, [KIMI_MCP_OAUTH_META]: value } +} diff --git a/src/lib/snapshot-denormalize.test.ts b/src/lib/snapshot-denormalize.test.ts index 7ae5d6983ea..a57489461f4 100644 --- a/src/lib/snapshot-denormalize.test.ts +++ b/src/lib/snapshot-denormalize.test.ts @@ -278,3 +278,43 @@ describe("denormalizeSnapshot — last_error", () => { expect(patch.status).toBe("connected") }) }) + +it("does not treat agent-supplied snapshot metadata as trusted OAuth progress", () => { + const patch = denormalizeSnapshot( + baseSnapshot({ + status: "prompting", + live_message: { + id: "lm", + role: "assistant", + started_at: "2026-11-11T09:00:00Z", + content: [{ kind: "tool_call_ref", tool_call_id: "auth" }], + }, + active_tool_calls: [ + { + id: "auth", + label: "mcp__notion__authenticate", + kind: "other", + status: "in_progress", + input: {}, + output: null, + content: null, + locations: null, + meta: { + keep: true, + "codeg.kimiMcpOAuth": { + toolName: "mcp__notion__authenticate", + server: "notion", + phase: "waiting", + url: "https://untrusted.example.test/authorize", + }, + }, + }, + ], + }) + ) + const block = patch.liveMessage?.content[0] + expect(block?.type).toBe("tool_call") + if (block?.type !== "tool_call") throw new Error("tool call missing") + expect(block.info.meta?.["codeg.kimiMcpOAuth"]).toBeNull() + expect(block.info.meta?.keep).toBe(true) +}) diff --git a/src/lib/snapshot-denormalize.ts b/src/lib/snapshot-denormalize.ts index 3853cb27146..ed9d30d8044 100644 --- a/src/lib/snapshot-denormalize.ts +++ b/src/lib/snapshot-denormalize.ts @@ -1,3 +1,4 @@ +import { stripKimiMcpOAuth } from "@/lib/kimi-mcp-oauth" import type { ActiveDelegationState, AsyncTaskRecord, @@ -323,7 +324,7 @@ function toolStateToInfo(tc: ToolCallState): ToolCallInfo { raw_output_chunks: outputChunks, raw_output_total_bytes: outputBytes, locations: tc.locations ?? null, - meta: tc.meta ?? null, + meta: stripKimiMcpOAuth(tc.meta ?? null), images: tc.images ?? [], } }