fix: reject unusable WorkBuddy ACP login credentials
This commit is contained in:
1 parent
70ebc791fa
commit
bbacfc9322
11 files changed
+92
-23
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: "インストールまたは接続確認に失敗しました。以前のバージョンは保持されています。",
|
||||
|
||||
@@ -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ę.",
|
||||
|
||||
@@ -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: "安装或连接检查失败,原版本已保留。",
|
||||
|
||||
Reference in new issue
Block a user