fix desktop WorkBuddy ACP prompt auth refusal

This commit is contained in:
tianma-if committed 2026-10-01 16:46:22 +08:00
1 parent 9d301013bc
commit 46dfbeb98d
7 files changed
+85 -8

No files matched your search

+31 -4
View File
@@ -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 };
},
+37 -1
View File
@@ -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");
@@ -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;
@@ -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" ? (
<p className="text-xs leading-relaxed text-slate-600">{t("aiAssistant.agentSource.piAgentLoginHint")}</p>
) : null}
{shown?.state === "needs_login" && (adapterId === "workbuddyCn" || adapterId === "workbuddyIntl") ? (
{(shown?.state === "needs_login" || shown?.state === "available") && isWorkBuddy ? (
<p className="text-xs leading-relaxed text-slate-600">{t(adapterId === "workbuddyCn" ? "aiAssistant.agentSource.workbuddyCnLoginHint" : "aiAssistant.agentSource.workbuddyIntlLoginHint")}</p>
) : null}
{shown?.state === "needs_login" && authMethods.length ? (
{(shown?.state === "needs_login" || (isWorkBuddy && shown?.state === "available")) && authMethods.length ? (
<div className="flex flex-wrap gap-2 pt-0.5">
{authMethods.map((method) => (
<Button key={method.id} type="button" variant="outline" size="sm" className="h-8 bg-card text-xs font-normal" disabled={authenticating} onClick={() => void authenticate(method.id)}>
+3
View File
@@ -1362,6 +1362,9 @@ export const enUS = {
localStatus: "Local agent",
localMissing: "No local agent is available. Choose an adapter, or use the built-in assistant.",
noteAccessUnavailable: "Could not connect to this account's notes. Check the instance connection and sign-in status, then try again.",
localLoginRequired: "This local agent needs sign-in. Sign in under Settings → Models & Agents, then try again.",
workbuddyLoginRequired: "WorkBuddy ACP sign-in is separate from the desktop app. Choose a sign-in method under Settings → Models & Agents, then try again.",
agentRefused: "The local agent refused this request. Check its sign-in status or model settings, then try again.",
unavailable: "Sign in to a personal account to send. Demo mode and signed-out sessions cannot use the built-in assistant.",
proposalConfirm: "Apply to note",
proposalReject: "Don't apply",
+3
View File
@@ -1362,6 +1362,9 @@ export const ja = {
localStatus: "ローカル Agent",
localMissing: "使えるローカル Agent がありません。アダプターを選ぶか、内蔵アシスタントを使ってください。",
noteAccessUnavailable: "このアカウントのノートに接続できません。インスタンスへの接続とログイン状態を確認して、再試行してください。",
localLoginRequired: "ローカル Agent へのログインが必要です。「設定 → モデルとエージェント」でログインしてから再試行してください。",
workbuddyLoginRequired: "WorkBuddy の ACP ログインはデスクトップアプリとは別です。「設定 → モデルとエージェント」でログイン方法を選び、ログイン後に再試行してください。",
agentRefused: "ローカル Agent がこのリクエストを拒否しました。ログイン状態またはモデル設定を確認して再試行してください。",
unavailable: "送信するには個人アカウントでサインインしてください。デモモードや未ログインでは内蔵アシスタントを使えません。",
proposalConfirm: "ノートに適用",
proposalReject: "適用しない",
+3
View File
@@ -1362,6 +1362,9 @@ export const zhCN = {
localStatus: "本地 Agent",
localMissing: "还没有可用的本地 Agent。请先选择适配器,或改用内置助手。",
noteAccessUnavailable: "无法连接当前账号的笔记库。请检查实例连接和登录状态后重试。",
localLoginRequired: "本地 Agent 需要登录。请在“设置 → 模型与代理”中完成登录后重试。",
workbuddyLoginRequired: "WorkBuddy 的 ACP 登录与桌面 App 登录分开。请在“设置 → 模型与代理”中选择登录方式,完成登录后重试。",
agentRefused: "本地 Agent 拒绝了这次请求。请检查代理的登录状态或模型配置后重试。",
unavailable: "需要登录个人账户后才能发送。演示模式或未登录时不能使用内置助手。",
proposalConfirm: "应用到笔记",
proposalReject: "不采用",