fix: reject unusable WorkBuddy ACP login credentials

This commit is contained in:
tianma-if committed 2026-10-11 09:59:51 +08:00
1 parent 70ebc791fa
commit bbacfc9322
11 files changed
+92 -23

No files matched your search

+19 -4
View File
@@ -723,7 +723,7 @@ export function createAcpHostRuntime(options = {}) {
return adapt(resolveAcpCommand(input, commandDeps));
};
const connect = async (command, requestId, emit, signal, authMethodId, mcpServers = [], allowPermissions = false, workBuddyAuth = null) => {
const connect = async (command, requestId, emit, signal, authMethodId, mcpServers = [], allowPermissions = false, workBuddyAuth = null, verifyWorkBuddy = false) => {
const cwd = await createAcpWorkspace(mkdtempImpl, options.tmpRoot);
let child = null;
let authMethods = [];
@@ -739,10 +739,18 @@ export function createAcpHostRuntime(options = {}) {
void connection.closed?.catch(() => {});
const initialized = await connection.initialize(acpInitializeParams(version));
authMethods = publicAuthMethods(initialized);
const readCredential = async () => {
const result = await connection.extMethod("_codebuddy.ai/getUserInfo", {});
const token = result?.userInfo?.accessToken ?? result?.userInfo?.token;
// The bundled CLI can restore account metadata without decrypting tokens.
if (result?.userInfo && !(typeof token === "string" && token)) throw new Error("workbuddy_credentials_unavailable");
return typeof token === "string" && token ? createHash("sha256").update(token).digest("hex") : null;
};
if (verifyWorkBuddy) await readCredential();
if (authMethodId) {
if (!authMethods.some((method) => method.id === authMethodId)) throw new Error("invalid_auth_method");
authenticationPending = true;
if (workBuddyAuth) await authenticateWorkBuddy(workBuddyAuth, authMethodId, signal);
if (workBuddyAuth) await authenticateWorkBuddy(workBuddyAuth, authMethodId, signal, { readCredential });
else await connection.authenticate({ methodId: authMethodId });
authenticationPending = false;
}
@@ -867,7 +875,7 @@ export function createAcpHostRuntime(options = {}) {
if (!resolved.ok) return adapterFromResolution(id, resolved);
let connected;
try {
connected = await withHandshakeTimeout((signal) => connect(resolved.command, `probe-${id}`, () => {}, signal));
connected = await withHandshakeTimeout((signal) => connect(resolved.command, `probe-${id}`, () => {}, signal, undefined, [], false, null, id === "workbuddyCn" || id === "workbuddyIntl"));
} catch (error) {
const failure = { ...adapterShell(id), ...failureFields(classifyAcpFailure(error)), ...(isAuthRequiredError(error) ? { authMethods: error.authMethods ?? [] } : {}), ...(resolved.version ? { version: resolved.version, managed: true } : {}) };
latestStatus.set(id, failure);
@@ -898,6 +906,13 @@ export function createAcpHostRuntime(options = {}) {
const workBuddyAuth = id === "workbuddyCn" || id === "workbuddyIntl" ? await prepareWorkBuddyAuth(resolved.command) : null;
return connect(workBuddyAuth?.command ?? resolved.command, `auth-${id}`, () => {}, signal, input.methodId, [], false, workBuddyAuth);
}, authenticationTimeoutMs);
if (id === "workbuddyCn" || id === "workbuddyIntl") {
connected.stop();
await removeAcpWorkspace(connected.cwd, rmImpl);
connected = null;
// Login is useful only if a fresh chat process can restore credentials.
connected = await withHandshakeTimeout((signal) => connect(resolved.command, `auth-check-${id}`, () => {}, signal, undefined, [], false, null, true));
}
const adapter = { ...adapterShell(id), state: "available", promptCapabilities: connected.promptCapabilities, authMethods: connected.authMethods, ...(resolved.version ? { version: resolved.version, managed: true } : {}) };
latestStatus.set(id, adapter);
return adapter;
@@ -951,7 +966,7 @@ export function createAcpHostRuntime(options = {}) {
try {
connected = await withHandshakeTimeout((signal) => connect(
resolved.command, requestId, notify, signal, undefined,
mcpBridge ? [mcpServerFor(mcpBridge)] : [], true,
mcpBridge ? [mcpServerFor(mcpBridge)] : [], true, null, input.adapterId === "workbuddyCn" || input.adapterId === "workbuddyIntl",
));
rememberPromptStatus({
state: "available",
+36 -2
View File
@@ -84,9 +84,9 @@ const collector = () => {
};
};
const fakeAgentSource = ({ reportPath, secretPath, allowImage, allowEmbedded, hold, requireAuth, advertiseAuth, promptRefusal, hangAuth, hangSession, workBuddyAccountApi, workBuddyLoginFailure, authMethodId = "browser" }) => `#!/usr/bin/env bun
const fakeAgentSource = ({ reportPath, secretPath, allowImage, allowEmbedded, hold, requireAuth, advertiseAuth, promptRefusal, hangAuth, hangSession, workBuddyAccountApi, workBuddyLoginFailure, workBuddyUnreadable, workBuddyUnreadableAfterLogin, authMethodId = "browser" }) => `#!/usr/bin/env bun
import * as acp from ${JSON.stringify(sdkHref)};
import { writeFileSync } from "node:fs";
import { writeFileSync, readFileSync } from "node:fs";
const reportPath = ${JSON.stringify(reportPath)};
const secretPath = ${JSON.stringify(secretPath)};
@@ -98,6 +98,11 @@ const advertiseAuth = ${advertiseAuth ? "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 };
if (${workBuddyAccountApi ? "true" : "false"}) {
try { Object.assign(report, JSON.parse(readFileSync(reportPath, "utf8"))); authenticated = report.accountLogin === true && ${workBuddyLoginFailure ? "false" : "true"}; } catch {}
}
if (${workBuddyUnreadable ? "true" : "false"}) authenticated = true;
const restoredAccount = authenticated;
const save = () => writeFileSync(reportPath, JSON.stringify(report));
if (${workBuddyAccountApi ? "true" : "false"}) {
const { createServer } = await import("node:http");
@@ -164,6 +169,7 @@ acp.agent({ name: "edgeever-fake-agent" })
authMethods: requireAuth || advertiseAuth ? [{ id: ${JSON.stringify(authMethodId)}, name: "Browser" }] : [],
};
})
.onRequest("_codebuddy.ai/getUserInfo", (params) => params, () => ({ userInfo: authenticated ? { ${workBuddyUnreadable ? 'userId: "saved-user"' : 'accessToken: "new-token"'} , ...(${workBuddyUnreadableAfterLogin ? "true" : "false"} && restoredAccount ? { accessToken: undefined, userId: "saved-user" } : {}) } : undefined }))
.onRequest("authenticate", (ctx) => {
report.authMethod = ctx.params.methodId;
if (${hangAuth ? "true" : "false"}) {
@@ -913,6 +919,34 @@ describe("ACP stdio session", () => {
}
}, 15_000);
sessionTest("WorkBuddy encrypted metadata cannot count as a usable connection or successful login", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "edgeever-acp-unreadable-auth-"));
const reportPath = path.join(directory, "report.json");
try {
const scriptPath = await writeFakeAgent(directory, { reportPath, requireAuth: true, workBuddyAccountApi: true, workBuddyUnreadable: true, authMethodId: "external" });
const runtime = createAcpHostRuntime({ adapterManager: { get: () => ({ command: { command: process.execPath, args: [scriptPath] } }) } });
expect(await runtime.probeAdapter({ id: "workbuddyIntl" })).toMatchObject({ state: "failed", detail: "workbuddy_credentials_unavailable" });
expect(await runtime.authenticateAdapter({ id: "workbuddyIntl", methodId: "external" })).toMatchObject({ state: "failed", detail: "workbuddy_credentials_unavailable" });
const events = [];
await runtime.prompt({ adapterId: "workbuddyIntl", prompt: "hello", noteAccess: false }, (event) => events.push(event));
expect(events).toMatchObject([{ type: "error", message: "workbuddy_credentials_unavailable" }]);
const report = await readReport(reportPath);
expect(report.accountLogin).toBeUndefined();
expect(report.newSession).toBeNull();
} finally { await rm(directory, { recursive: true, force: true }); }
}, 10_000);
sessionTest("WorkBuddy login must survive a fresh chat process before reporting success", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "edgeever-acp-auth-restore-"));
const reportPath = path.join(directory, "report.json");
try {
const scriptPath = await writeFakeAgent(directory, { reportPath, requireAuth: true, workBuddyAccountApi: true, workBuddyUnreadableAfterLogin: true, authMethodId: "external" });
const runtime = createAcpHostRuntime({ adapterManager: { get: () => ({ command: { command: process.execPath, args: [scriptPath] } }) } });
expect(await runtime.authenticateAdapter({ id: "workbuddyIntl", methodId: "external" })).toMatchObject({ state: "failed", detail: "workbuddy_credentials_unavailable" });
expect((await readReport(reportPath)).accountLogin).toBe(true);
} finally { await rm(directory, { recursive: true, force: true }); }
}, 10_000);
for (const adapterId of ["workbuddyIntl", "workbuddyCn"]) for (const loginFailure of [false, true]) sessionTest(`${adapterId} account login ${loginFailure ? "returns failure without hanging" : "verifies ACP session without the broken authenticate RPC"}`, async () => {
const directory = await mkdtemp(path.join(tmpdir(), "edgeever-acp-workbuddy-auth-"));
const reportPath = path.join(directory, "report.json");
+9 -4
View File
@@ -24,7 +24,7 @@ export async function prepareWorkBuddyAuth(command) {
// WorkBuddy's ACP authenticate handler ignores login() returning false and then
// waits forever for a session. Its local account API reports that failure and
// starts login without first logging out (which can cancel login via its watcher).
export async function authenticateWorkBuddy({ endpoint, password }, methodId, signal, { fetchImpl = fetch, pollIntervalMs = 500 } = {}) {
export async function authenticateWorkBuddy({ endpoint, password }, methodId, signal, { fetchImpl = fetch, pollIntervalMs = 500, readCredential } = {}) {
const request = async (route, body) => {
const response = await fetchImpl(`${endpoint}/api/v1/auth/account/${route}`, {
method: body ? "POST" : "GET",
@@ -39,14 +39,19 @@ export async function authenticateWorkBuddy({ endpoint, password }, methodId, si
return result;
};
const signedIn = (status) => status.authenticated === true && typeof status.account?.userId === "string" && Boolean(status.account.userId);
const status = await request("status");
if (signedIn(status)) return;
// A saved account can contain an expired token. An explicit login must start
// a new flow, and the old account must not satisfy polling while it is open.
if (typeof readCredential !== "function") throw new Error("workbuddy_auth_unavailable");
const previousCredential = await readCredential();
const started = await request("login", { method: methodId });
if (started.loginError || started.success !== true) throw new Error("authentication_failed");
for (;;) {
await delay(pollIntervalMs, undefined, { signal });
const status = await request("status");
if (signedIn(status)) return;
if (status.loginError) throw new Error("authentication_failed");
if (signedIn(status)) {
const credential = await readCredential();
if (credential && credential !== previousCredential) return;
}
}
}
+21 -13
View File
@@ -1,10 +1,11 @@
import { expect, test } from "bun:test";
import { authenticateWorkBuddy, prepareWorkBuddyAuth } from "./workbuddy-auth.mjs";
const fixture = (responses) => {
const fixture = (responses, credentials = [null, "new-token"]) => {
const calls = [];
return {
calls,
readCredential: async () => credentials.shift(),
fetchImpl: async (url, options) => {
calls.push({ url, ...options });
const response = responses.shift();
@@ -16,40 +17,47 @@ const fixture = (responses) => {
const auth = { endpoint: "http://127.0.0.1:12345", password: "private-password" };
test("starts login without logout and waits for an authenticated account", async () => {
const f = fixture([{ authenticated: false }, { success: true }, { authenticated: false }, { authenticated: true, account: { userId: "user" } }]);
const f = fixture([{ success: true }, { authenticated: false }, { authenticated: true, account: { userId: "user" } }]);
await authenticateWorkBuddy(auth, "external", new AbortController().signal, { ...f, pollIntervalMs: 1 });
expect(f.calls.map((call) => new URL(call.url).pathname)).toEqual([
"/api/v1/auth/account/status", "/api/v1/auth/account/login", "/api/v1/auth/account/status", "/api/v1/auth/account/status",
"/api/v1/auth/account/login", "/api/v1/auth/account/status", "/api/v1/auth/account/status",
]);
expect(JSON.parse(f.calls[1].body)).toEqual({ method: "external" });
expect(JSON.parse(f.calls[0].body)).toEqual({ method: "external" });
expect(f.calls.every((call) => call.headers.Authorization === "Bearer private-password" && call.headers["x-codebuddy-request"] === "1" && call.redirect === "error")).toBe(true);
});
test("preserves an existing authenticated account without signing it out", async () => {
const f = fixture([{ authenticated: true, account: { userId: "saved-user" } }]);
await authenticateWorkBuddy(auth, "external", new AbortController().signal, f);
expect(f.calls).toHaveLength(1);
test("explicit login replaces a stale saved credential without logout", async () => {
const f = fixture([{ success: true }, { authenticated: true, account: { userId: "saved-user" } }, { authenticated: true, account: { userId: "saved-user" } }], ["old-token", "old-token", "new-token"]);
await authenticateWorkBuddy(auth, "external", new AbortController().signal, { ...f, pollIntervalMs: 1 });
expect(f.calls.map((call) => new URL(call.url).pathname)).toEqual([
"/api/v1/auth/account/login", "/api/v1/auth/account/status", "/api/v1/auth/account/status",
]);
});
test("a failed re-login is not masked by a saved authenticated account", async () => {
const f = fixture([{ success: true }, { authenticated: true, account: { userId: "saved-user" }, loginError: "failed" }], ["old-token"]);
await expect(authenticateWorkBuddy(auth, "external", new AbortController().signal, { ...f, pollIntervalMs: 1 })).rejects.toThrow("authentication_failed");
});
test("returns a swallowed or cancelled login failure before the overall timeout", async () => {
const f = fixture([{ authenticated: false }, { success: true }, { authenticated: false, loginError: "account.login.failed" }]);
const f = fixture([{ success: true }, { authenticated: false, loginError: "account.login.failed" }]);
await expect(authenticateWorkBuddy(auth, "external", new AbortController().signal, { ...f, pollIntervalMs: 1 })).rejects.toThrow("authentication_failed");
});
test("does not treat an opened browser or an accountless status as login success", async () => {
const f = fixture([{ authenticated: true }, { success: true, authUrl: "https://example.com" }, { authenticated: true }, { loginError: "failed" }]);
const f = fixture([{ success: true, authUrl: "https://example.com" }, { authenticated: true }, { loginError: "failed" }]);
await expect(authenticateWorkBuddy(auth, "external", new AbortController().signal, { ...f, pollIntervalMs: 1 })).rejects.toThrow("authentication_failed");
});
test("stops pending polling when authentication times out", async () => {
const f = fixture([{ authenticated: false }, { success: true }]);
const f = fixture([{ success: true }]);
const signal = AbortSignal.timeout(20);
await expect(authenticateWorkBuddy(auth, "external", signal, { ...f, pollIntervalMs: 100 })).rejects.toThrow();
expect(f.calls).toHaveLength(2);
expect(f.calls).toHaveLength(1);
});
test("fails closed when the installed CLI lacks the account API", async () => {
await expect(authenticateWorkBuddy(auth, "external", new AbortController().signal, { fetchImpl: async () => new Response("unsupported", { status: 404 }) })).rejects.toThrow("workbuddy_auth_unavailable");
await expect(authenticateWorkBuddy(auth, "external", new AbortController().signal, { readCredential: async () => null, fetchImpl: async () => new Response("unsupported", { status: 404 }) })).rejects.toThrow("workbuddy_auth_unavailable");
});
test("binds the login server to loopback with a fresh password and keeps the edition", async () => {
@@ -996,6 +996,7 @@ function AiSidebarSession({
: turn));
if (event.type === "error" && event.message) {
setError(event.message === "note_access_unavailable" ? t("aiAssistant.sidebar.noteAccessUnavailable")
: event.message === "workbuddy_credentials_unavailable" ? t("aiAssistant.agentSource.workbuddyCredentialsUnavailable")
: 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);
@@ -44,6 +44,7 @@ const readAdapterId = (): DesktopAcpAdapterId => {
const statusKey = (adapter: DesktopAcpAdapter | undefined, probing: boolean) => {
if (probing) return "aiAssistant.agentSource.probing";
if (!adapter || adapter.detail === "not_probed") return "aiAssistant.agentSource.notProbed";
if (adapter.detail === "workbuddy_credentials_unavailable") return "aiAssistant.agentSource.workbuddyCredentialsUnavailable";
if (adapter.detail === "authentication_timeout") return "aiAssistant.agentSource.authenticationTimedOut";
if (adapter.detail === "authentication_failed") return "aiAssistant.agentSource.authenticationFailed";
if (adapter.detail === "invalid_path") return "aiAssistant.agentSource.invalidPath";
@@ -103,6 +103,7 @@ export const parseInfographicLocalReply = (raw: string, candidates: string[], pr
};
export const infographicLocalAgentErrorKey = (message: string, adapterId: string | null) => {
if (message === "workbuddy_credentials_unavailable") return "aiAssistant.agentSource.workbuddyCredentialsUnavailable";
if (message === "needs_login") {
return adapterId === "workbuddyCn" || adapterId === "workbuddyIntl"
? "aiAssistant.sidebar.workbuddyLoginRequired"
+1
View File
@@ -1732,6 +1732,7 @@ export const enUS = {
workbuddyMissingHint: "The WorkBuddy desktop app includes the ACP program; no separate CodeBuddy installation is needed. A standalone CodeBuddy CLI also works.",
workbuddyCnLoginHint: "ACP tries saved sign-in first. If chat still requires login, use WeChat or your organization's ACP sign-in method.",
workbuddyIntlLoginHint: "ACP tries saved sign-in first. If chat still requires login, use Google/GitHub or your organization's ACP sign-in method.",
workbuddyCredentialsUnavailable: "WorkBuddy saved sign-in credentials could not be read, so ACP is unavailable. Use another local Agent for now; retrying sign-in cannot resolve this issue.",
workbuddyConnectionReady: "ACP connected; sign-in is checked during chat",
installing: "Installing and checking…",
installFailed: "Installation or connection check failed. The previous version was kept.",
+1
View File
@@ -1721,6 +1721,7 @@ export const ja = {
workbuddyMissingHint: "WorkBuddy デスクトップアプリには ACP プログラムが含まれるため、CodeBuddy の別途インストールは不要です。単体の CodeBuddy CLI も利用できます。",
workbuddyCnLoginHint: "ACP は保存済みのログインを試します。チャットでログインが必要な場合は WeChat または組織指定の方法で ACP 認証を行ってください。",
workbuddyIntlLoginHint: "ACP は保存済みのログインを試します。チャットでログインが必要な場合は Google/GitHub または組織指定の方法で ACP 認証を行ってください。",
workbuddyCredentialsUnavailable: "WorkBuddy の保存済みログイン情報を読み取れないため、ACP は利用できません。当面は別のローカル Agent を使用してください。再ログインでは解決できません。",
workbuddyConnectionReady: "ACP 接続済み・ログインはチャット時に確認",
installing: "インストールして確認中…",
installFailed: "インストールまたは接続確認に失敗しました。以前のバージョンは保持されています。",
+1
View File
@@ -1721,6 +1721,7 @@ export const pl = {
workbuddyMissingHint: "Aplikacja desktopowa WorkBuddy zawiera program ACP; osobna instalacja CodeBuddy nie jest potrzebna. Działa też samodzielny CodeBuddy CLI.",
workbuddyCnLoginHint: "ACP najpierw próbuje użyć zapisanego logowania. Jeśli czat nadal wymaga zalogowania, użyj WeChat lub metody logowania ACP swojej organizacji.",
workbuddyIntlLoginHint: "ACP najpierw próbuje użyć zapisanego logowania. Jeśli czat nadal wymaga zalogowania, użyj Google/GitHub lub metody logowania ACP swojej organizacji.",
workbuddyCredentialsUnavailable: "Nie można odczytać zapisanych danych logowania WorkBuddy, więc ACP jest niedostępne. Na razie użyj innego lokalnego agenta; ponowne logowanie nie rozwiąże problemu.",
workbuddyConnectionReady: "Połączono z ACP; logowanie jest sprawdzane podczas czatu",
installing: "Instalowanie i sprawdzanie…",
installFailed: "Instalacja lub sprawdzenie połączenia nie powiodło się. Zachowano poprzednią wersję.",
+1
View File
@@ -1732,6 +1732,7 @@ export const zhCN = {
workbuddyMissingHint: "安装 WorkBuddy 桌面版即可使用其内置 ACP,无需另装 CodeBuddy;也可单独安装 CodeBuddy CLI。",
workbuddyCnLoginHint: "ACP 会尝试使用已保存的登录;若聊天仍提示需要登录,请使用微信或组织指定方式完成 ACP 认证。",
workbuddyIntlLoginHint: "ACP 会尝试使用已保存的登录;若聊天仍提示需要登录,请使用 Google/GitHub 或组织指定方式完成 ACP 认证。",
workbuddyCredentialsUnavailable: "无法读取 WorkBuddy 保存的登录凭据,ACP 当前不可用。请先改用其他本机 Agent;重复点击登录无法解决此问题。",
workbuddyConnectionReady: "ACP 已连接,聊天时验证登录",
installing: "正在安装并检查…",
installFailed: "安装或连接检查失败,原版本已保留。",