From 46dfbeb98d3945898753932498de63c705f133ef Mon Sep 17 00:00:00 2001 From: tianma-if Date: Thu, 1 Oct 2026 16:46:22 +0800 Subject: [PATCH] fix desktop WorkBuddy ACP prompt auth refusal --- apps/desktop/src/main/acp-host.mjs | 35 +++++++++++++++-- apps/desktop/src/main/acp-host.test.mjs | 38 ++++++++++++++++++- .../src/components/ai-sidebar/AiSidebar.tsx | 5 ++- .../settings/DesktopAcpAgentCard.tsx | 6 ++- packages/shared/src/i18n/en-US.ts | 3 ++ packages/shared/src/i18n/ja.ts | 3 ++ packages/shared/src/i18n/zh-CN.ts | 3 ++ 7 files changed, 85 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/acp-host.mjs b/apps/desktop/src/main/acp-host.mjs index 5a96915b..46b15622 100644 --- a/apps/desktop/src/main/acp-host.mjs +++ b/apps/desktop/src/main/acp-host.mjs @@ -664,6 +664,18 @@ const promptFailureMessage = (failure) => { return failure.detail || "connection_failed"; }; +export function promptResultFailure(result) { + if (result?.stopReason !== "refusal") return null; + const rawError = result?._meta?.["codebuddy.ai/errorMessage"]; + if (typeof rawError === "string" && rawError.length <= 4_096) { + try { + const error = JSON.parse(rawError); + if (error?.data?.category === "auth" || isAuthRequiredError(error)) return "needs_login"; + } catch { /* The agent may return an unstructured refusal. */ } + } + return "agent_refused"; +} + export function createAcpHostRuntime(options = {}) { const spawnImpl = options.spawnImpl ?? nodeSpawn; const mkdtempImpl = options.mkdtemp ?? mkdtemp; @@ -725,6 +737,7 @@ export function createAcpHostRuntime(options = {}) { child, connection, sessionId: session.sessionId, + authMethods, promptCapabilities: normalizePromptCapabilities(initialized), stop: abort, }; @@ -847,6 +860,7 @@ export function createAcpHostRuntime(options = {}) { ...adapterShell(id), state: "available", promptCapabilities: connected.promptCapabilities, + ...((id === "workbuddyCn" || id === "workbuddyIntl") ? { authMethods: connected.authMethods } : {}), ...(resolved.version ? { version: resolved.version, managed: true } : {}), }; latestStatus.set(id, adapter); @@ -864,7 +878,9 @@ export function createAcpHostRuntime(options = {}) { let connected; try { connected = await withHandshakeTimeout((signal) => connect(resolved.command, `auth-${id}`, () => {}, signal, input.methodId), 5 * 60_000); - return { ...adapterShell(id), state: "available", promptCapabilities: connected.promptCapabilities, ...(resolved.version ? { version: resolved.version, managed: true } : {}) }; + const adapter = { ...adapterShell(id), state: "available", promptCapabilities: connected.promptCapabilities, ...((id === "workbuddyCn" || id === "workbuddyIntl") ? { authMethods: connected.authMethods } : {}), ...(resolved.version ? { version: resolved.version, managed: true } : {}) }; + latestStatus.set(id, adapter); + return adapter; } catch (error) { return { ...adapterShell(id), ...failureFields(classifyAcpFailure(error)), ...(isAuthRequiredError(error) ? { authMethods: error.authMethods ?? [] } : {}), ...(resolved.version ? { version: resolved.version, managed: true } : {}) }; } finally { @@ -941,11 +957,22 @@ export function createAcpHostRuntime(options = {}) { void removeAcpWorkspace(session.cwd, rmImpl); void session.mcpBridge?.close(); }; - void connected.connection.prompt({ sessionId: connected.sessionId, prompt: content.blocks }).then(() => { - finish({ requestId, type: "done" }); + void connected.connection.prompt({ sessionId: connected.sessionId, prompt: content.blocks }).then((result) => { + if (session.cancelled) return finish({ requestId, type: "done" }); + const failure = promptResultFailure(result); + if (failure === "needs_login") { + latestStatus.set(input.adapterId, { ...adapterShell(input.adapterId), state: "needs_login", authMethods: connected.authMethods }); + } + finish(failure ? { requestId, type: "error", message: failure } : { requestId, type: "done" }); }).catch((error) => { if (session.cancelled) finish({ requestId, type: "done" }); - else finish({ requestId, type: "error", message: promptFailureMessage(classifyAcpFailure(error)) }); + else { + const failure = classifyAcpFailure(error); + if (failure.state === "needs_login") { + latestStatus.set(input.adapterId, { ...adapterShell(input.adapterId), state: "needs_login", authMethods: connected.authMethods }); + } + finish({ requestId, type: "error", message: promptFailureMessage(failure) }); + } }); return { requestId, rejectedAttachments: content.rejectedAttachments }; }, diff --git a/apps/desktop/src/main/acp-host.test.mjs b/apps/desktop/src/main/acp-host.test.mjs index e8808781..a328ac26 100644 --- a/apps/desktop/src/main/acp-host.test.mjs +++ b/apps/desktop/src/main/acp-host.test.mjs @@ -16,6 +16,7 @@ import { detectInstalledAgentApps, eventsFromSessionUpdate, isAuthRequiredError, + promptResultFailure, registerAcpIpc, resolveAcpCommand, sanitizeFailureDetail, @@ -28,6 +29,13 @@ const home = path.resolve(homedir()); const encode = (value) => Buffer.from(value).toString("base64"); +test("classifies ACP prompt refusals without exposing vendor error details", () => { + expect(promptResultFailure({ stopReason: "end_turn" })).toBeNull(); + expect(promptResultFailure({ stopReason: "refusal" })).toBe("agent_refused"); + expect(promptResultFailure({ stopReason: "refusal", _meta: { "codebuddy.ai/errorMessage": "not-json" } })).toBe("agent_refused"); + expect(promptResultFailure({ stopReason: "refusal", _meta: { "codebuddy.ai/errorMessage": JSON.stringify({ data: { category: "auth" }, message: "private detail" }) } })).toBe("needs_login"); +}); + const collector = () => { const events = []; let waiters = []; @@ -60,7 +68,7 @@ const collector = () => { }; }; -const fakeAgentSource = ({ reportPath, secretPath, allowImage, allowEmbedded, hold, requireAuth }) => `#!/usr/bin/env bun +const fakeAgentSource = ({ reportPath, secretPath, allowImage, allowEmbedded, hold, requireAuth, promptRefusal }) => `#!/usr/bin/env bun import * as acp from ${JSON.stringify(sdkHref)}; import { writeFileSync } from "node:fs"; @@ -70,6 +78,7 @@ const allowImage = ${allowImage ? "true" : "false"}; const allowEmbedded = ${allowEmbedded ? "true" : "false"}; const hold = ${hold ? "true" : "false"}; const requireAuth = ${requireAuth ? "true" : "false"}; +const promptRefusal = ${promptRefusal ? JSON.stringify(promptRefusal) : "null"}; let authenticated = false; const report = { initialize: null, newSession: null, permission: null, readError: null, readResult: null, prompt: null, authMethod: null }; const save = () => writeFileSync(reportPath, JSON.stringify(report)); @@ -128,6 +137,7 @@ acp.agent({ name: "edgeever-fake-agent" }) .onRequest("session/prompt", async (ctx) => { report.prompt = ctx.params.prompt; save(); + if (promptRefusal) return promptRefusal; await ctx.client.notify("session/update", { sessionId: ctx.params.sessionId, update: { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "thinking" } }, @@ -698,6 +708,32 @@ describe("ACP stdio session", () => { } }, 15_000); + sessionTest("reports a prompt-time authentication refusal instead of an empty reply", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "edgeever-acp-refusal-")); + try { + const scriptPath = await writeFakeAgent(directory, { + reportPath: path.join(directory, "report.json"), + secretPath: path.join(directory, "secret.txt"), + allowImage: false, + allowEmbedded: false, + hold: false, + promptRefusal: { + stopReason: "refusal", + _meta: { "codebuddy.ai/errorMessage": JSON.stringify({ code: -32000, message: "Authentication required", data: { category: "auth" } }) }, + }, + }); + const runtime = createAcpHostRuntime(); + const probed = await runtime.probeAdapter({ id: "antigravity", path: scriptPath }); + expect(probed.state).toBe("available"); + const events = collector(); + const result = await runtime.prompt({ adapterId: "antigravity", path: scriptPath, prompt: "Hello" }, events.emit); + expect(await events.waitFor((event) => event.type === "error")).toEqual({ requestId: result.requestId, type: "error", message: "needs_login" }); + expect(events.events.some((event) => event.type === "done")).toBe(false); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, 15_000); + sessionTest("streams updates, cancels permission, and never reads a local file", async () => { const directory = await mkdtemp(path.join(tmpdir(), "edgeever-acp-prompt-")); const reportPath = path.join(directory, "report.json"); diff --git a/apps/web/src/components/ai-sidebar/AiSidebar.tsx b/apps/web/src/components/ai-sidebar/AiSidebar.tsx index 1e554327..3db34c2f 100644 --- a/apps/web/src/components/ai-sidebar/AiSidebar.tsx +++ b/apps/web/src/components/ai-sidebar/AiSidebar.tsx @@ -981,7 +981,10 @@ function AiSidebarSession({ ? { ...turn, status: event.type === "error" ? "failed" : "completed" } : turn)); if (event.type === "error" && event.message) { - setError(event.message === "note_access_unavailable" ? t("aiAssistant.sidebar.noteAccessUnavailable") : event.message); + setError(event.message === "note_access_unavailable" ? t("aiAssistant.sidebar.noteAccessUnavailable") + : event.message === "needs_login" ? t(localAdapterId === "workbuddyCn" || localAdapterId === "workbuddyIntl" + ? "aiAssistant.sidebar.workbuddyLoginRequired" : "aiAssistant.sidebar.localLoginRequired") + : event.message === "agent_refused" ? t("aiAssistant.sidebar.agentRefused") : event.message); } if (active.current?.requestId === event.requestId) { active.current = null; diff --git a/apps/web/src/components/settings/DesktopAcpAgentCard.tsx b/apps/web/src/components/settings/DesktopAcpAgentCard.tsx index 9e8a114f..07c1490b 100644 --- a/apps/web/src/components/settings/DesktopAcpAgentCard.tsx +++ b/apps/web/src/components/settings/DesktopAcpAgentCard.tsx @@ -118,6 +118,7 @@ const DesktopAcpAgentCardBody = ({ bridge }: { bridge: boolean }) => { }, []); const shown = displayedDesktopAcpAdapter({ id: adapterId, path: adapterPath, listed, probed }); + const isWorkBuddy = adapterId === "workbuddyCn" || adapterId === "workbuddyIntl"; const status = t(statusKey(shown, probing)); const tone = getStatusTone({ probing, @@ -128,6 +129,7 @@ const DesktopAcpAgentCardBody = ({ bridge }: { bridge: boolean }) => { const hasDetails = Boolean( shown?.state === "not_installed" || shown?.state === "needs_login" || + (isWorkBuddy && shown?.state === "available" && Boolean(shown.authMethods?.length)) || shown?.updateError ); const authMethods = shown?.authMethods?.filter((method) => ( @@ -364,10 +366,10 @@ const DesktopAcpAgentCardBody = ({ bridge }: { bridge: boolean }) => { {shown?.state === "needs_login" && adapterId === "piAgent" ? (

{t("aiAssistant.agentSource.piAgentLoginHint")}

) : null} - {shown?.state === "needs_login" && (adapterId === "workbuddyCn" || adapterId === "workbuddyIntl") ? ( + {(shown?.state === "needs_login" || shown?.state === "available") && isWorkBuddy ? (

{t(adapterId === "workbuddyCn" ? "aiAssistant.agentSource.workbuddyCnLoginHint" : "aiAssistant.agentSource.workbuddyIntlLoginHint")}

) : null} - {shown?.state === "needs_login" && authMethods.length ? ( + {(shown?.state === "needs_login" || (isWorkBuddy && shown?.state === "available")) && authMethods.length ? (
{authMethods.map((method) => (