fix desktop WorkBuddy ACP prompt auth refusal
This commit is contained in:
1 parent
9d301013bc
commit
46dfbeb98d
7 files changed
+85
-8
No files matched your search
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1362,6 +1362,9 @@ export const ja = {
|
||||
localStatus: "ローカル Agent",
|
||||
localMissing: "使えるローカル Agent がありません。アダプターを選ぶか、内蔵アシスタントを使ってください。",
|
||||
noteAccessUnavailable: "このアカウントのノートに接続できません。インスタンスへの接続とログイン状態を確認して、再試行してください。",
|
||||
localLoginRequired: "ローカル Agent へのログインが必要です。「設定 → モデルとエージェント」でログインしてから再試行してください。",
|
||||
workbuddyLoginRequired: "WorkBuddy の ACP ログインはデスクトップアプリとは別です。「設定 → モデルとエージェント」でログイン方法を選び、ログイン後に再試行してください。",
|
||||
agentRefused: "ローカル Agent がこのリクエストを拒否しました。ログイン状態またはモデル設定を確認して再試行してください。",
|
||||
unavailable: "送信するには個人アカウントでサインインしてください。デモモードや未ログインでは内蔵アシスタントを使えません。",
|
||||
proposalConfirm: "ノートに適用",
|
||||
proposalReject: "適用しない",
|
||||
|
||||
@@ -1362,6 +1362,9 @@ export const zhCN = {
|
||||
localStatus: "本地 Agent",
|
||||
localMissing: "还没有可用的本地 Agent。请先选择适配器,或改用内置助手。",
|
||||
noteAccessUnavailable: "无法连接当前账号的笔记库。请检查实例连接和登录状态后重试。",
|
||||
localLoginRequired: "本地 Agent 需要登录。请在“设置 → 模型与代理”中完成登录后重试。",
|
||||
workbuddyLoginRequired: "WorkBuddy 的 ACP 登录与桌面 App 登录分开。请在“设置 → 模型与代理”中选择登录方式,完成登录后重试。",
|
||||
agentRefused: "本地 Agent 拒绝了这次请求。请检查代理的登录状态或模型配置后重试。",
|
||||
unavailable: "需要登录个人账户后才能发送。演示模式或未登录时不能使用内置助手。",
|
||||
proposalConfirm: "应用到笔记",
|
||||
proposalReject: "不采用",
|
||||
|
||||
Reference in new issue
Block a user