From df11ef3af9e009ea0eca9d27c58874aa9e62e9c6 Mon Sep 17 00:00:00 2001 From: fs-lx <158259039+fs-lx@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:07:10 +0800 Subject: [PATCH 1/3] fix(codebuddy): preserve native ACP authentication errors --- .../workbuddy-harness-integration.md | 4 + packages/adapters/codebuddy/src/common.ts | 29 ++++ packages/adapters/codebuddy/src/session.ts | 9 +- .../workbuddy/test/native-auth-errors.test.ts | 145 ++++++++++++++++++ 4 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 packages/adapters/workbuddy/test/native-auth-errors.test.ts diff --git a/docs/harnesses/workbuddy/workbuddy-harness-integration.md b/docs/harnesses/workbuddy/workbuddy-harness-integration.md index ca131b210..3fb938726 100644 --- a/docs/harnesses/workbuddy/workbuddy-harness-integration.md +++ b/docs/harnesses/workbuddy/workbuddy-harness-integration.md @@ -46,6 +46,10 @@ ELECTRON_RUN_AS_NODE=1 \ 进入 TUI 后输入 `/login`,完成浏览器认证再重试 Harness。这里使用的仍是 App 内置 CLI,不表示需要另装一个 CLI。不要使用 Desktop owner runtime 的凭据,也不要发明 `codebuddy auth login` 命令。 +若 `session/new` 成功而首个 Turn 返回 `refusal`,应检查 ACP 终态 `_meta["codebuddy.ai/errorMessage"]`。共享 CodeBuddy Session 会提取其中的 `data.details` 或 `message`,经诊断脱敏后显示;`data.category="auth"` 映射为 `authenticationRequired`,而不是仅显示 `FAILED_MODEL_REQUEST`。JSON-RPC `-32000` 本身不区分认证与其他原生拒绝。缺少可读诊断时保留原有的 stop reason / outcome 提示。 + +部分新版 WorkBuddy 将桌面登录凭据加密保存,独立启动的内置 CLI 可能记录 `AtRestEncryption ... category=missing-key`,并在恢复登录时显示 `hasSession=true hasAccessToken=false`。此时桌面已登录、模型目录可读取或 Thread 标题已生成,都不能证明当前 ACP 进程可以完成认证。原生 `/login` 提示也不代表 CodexHost 已支持该版本所需的凭据引导。此兼容问题见 [Issue #464](https://github.com/BytePioneer-AI/codex-host/issues/464);当前集成仍遵守下述私有运行时边界,不会自动复制或解密 Desktop 凭据。 + ## 工作目录与原生历史定位 Session 的执行工作目录始终来自 Codex Desktop 传入的 `cwd`,包括 Desktop 选择的 worktree;配置根 `~/.workbuddy-ai` 只是原生状态目录,不替代执行目录。历史文件使用 WorkBuddy 内置 CLI 的 `PathUtils.canonicalizeStorePath` / `compressPath` 规则:先解析工作目录的真实路径,再将 `/`、`\`、`:` 转为连字符、去除首尾连字符并合并连续连字符,保留大小写、点号、空格、下划线及 Unicode 字符。 diff --git a/packages/adapters/codebuddy/src/common.ts b/packages/adapters/codebuddy/src/common.ts index a1a0057fc..71e983fb7 100644 --- a/packages/adapters/codebuddy/src/common.ts +++ b/packages/adapters/codebuddy/src/common.ts @@ -88,6 +88,35 @@ export function failure( return { ok: false, error: nativeError(new CodeBuddyError(code, message), profile) }; } +export function nativeTurnError( + stopReason: unknown, + meta: Record, + profile: CodeBuddyRuntimeProfile, +): HarnessError { + const fallback = `Native Turn stopped: ${text(stopReason) || "unknown"} (${text(meta["codebuddy.ai/outcome"]) || "no native outcome"})`; + const raw = text(meta["codebuddy.ai/errorMessage"]).trim(); + let diagnostic = ""; + let category: unknown; + if (raw) { + try { + const error = record(JSON.parse(raw)); + const data = record(error.data); + diagnostic = text(data.details).trim() || text(error.message).trim(); + category = data.category; + } catch { + diagnostic = raw; + } + } + return nativeError( + new CodeBuddyError( + // -32000 is shared by native refusals; the category identifies authentication failures. + category === "auth" ? "authenticationRequired" : "nativeFailure", + diagnostic ? `${fallback}: ${diagnostic}` : fallback, + ), + profile, + ); +} + export async function bounded( operation: Promise, milliseconds: number, diff --git a/packages/adapters/codebuddy/src/session.ts b/packages/adapters/codebuddy/src/session.ts index 87d53a57c..56611122f 100644 --- a/packages/adapters/codebuddy/src/session.ts +++ b/packages/adapters/codebuddy/src/session.ts @@ -39,6 +39,7 @@ import { CodeBuddyError, failure, nativeError, + nativeTurnError, record, text, type CodeBuddyRuntimeProfile, @@ -578,13 +579,7 @@ export class CodeBuddySession implements HarnessSession { ? { status: "succeeded" } : { status: "failed", - error: nativeError( - new CodeBuddyError( - "nativeFailure", - `Native Turn stopped: ${text(stop) || "unknown"} (${text(meta["codebuddy.ai/outcome"]) || "no native outcome"})`, - ), - this.profile, - ), + error: nativeTurnError(stop, meta, this.profile), }; const snapshot = await this.#snapshot(); const added = snapshot.turns.filter( diff --git a/packages/adapters/workbuddy/test/native-auth-errors.test.ts b/packages/adapters/workbuddy/test/native-auth-errors.test.ts new file mode 100644 index 000000000..5951c7612 --- /dev/null +++ b/packages/adapters/workbuddy/test/native-auth-errors.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import type { HarnessOutput } from "@codexhost/harness-adapter"; +import { hostTurnIdSchema } from "@codexhost/shared-contracts"; +import { WorkBuddyAdapter } from "../src/workbuddy-adapter.js"; + +async function failedTurn(errorMessage?: unknown) { + const adapter = new WorkBuddyAdapter({ + environment: {}, + readHistory: async () => "", + clientFactory: () => ({ + initialize: async () => ({ protocolVersion: 1 }), + open: async () => ({ + sessionId: "native-auth-failure", + configOptions: [ + { + id: "model", + currentValue: "native/model", + options: [{ value: "native/model", name: "Native Model" }], + }, + { + id: "mode", + currentValue: "default", + options: [{ value: "default", name: "Default" }], + }, + { + id: "thought_level", + currentValue: "low", + options: [{ value: "low", name: "Low" }], + }, + ], + }), + configure: async () => ({}), + prompt: async () => ({ + stopReason: "refusal", + _meta: { + "codebuddy.ai/outcome": "FAILED_MODEL_REQUEST", + "codebuddy.ai/errorMessage": errorMessage, + }, + }), + cancel: async () => {}, + answer: async () => {}, + close: async () => {}, + }), + }); + const outputs: HarnessOutput[] = []; + try { + const opened = await adapter.open({ kind: "create", cwd: process.cwd(), environment: {} }); + if (!opened.ok) throw Error(opened.error.message); + const session = opened.value; + const collected = (async () => { + for await (const output of session.outputs) { + outputs.push(output); + if (output.kind === "event" && output.event.type === "turn.completed") + return output.event.outcome; + } + throw Error("Session closed without a terminal Turn"); + })(); + expect( + await session.execute({ + type: "turn.start", + turnId: hostTurnIdSchema.parse("auth-turn"), + input: [{ type: "text", text: "1+1" }], + }), + ).toMatchObject({ ok: true }); + return { outcome: await collected, outputs }; + } finally { + await adapter.close(); + } +} + +describe("WorkBuddy native Turn errors", () => { + it("preserves the native authentication category and login instruction from ACP metadata", async () => { + // WorkBuddy's bundled CLI returns a successful RPC containing this failed outcome. + const details = "Authentication required. Please use /login command to sign in to your account"; + const { outcome, outputs } = await failedTurn( + JSON.stringify({ + code: -32000, + message: "Authentication required", + data: { category: "auth", details }, + }), + ); + expect(outcome).toMatchObject({ + status: "failed", + error: { + code: "authenticationRequired", + message: expect.stringContaining(details), + retryable: false, + }, + }); + expect( + outputs.filter((o) => o.kind === "event" && o.event.type === "turn.completed"), + ).toHaveLength(1); + }); + + it("recognizes an auth category without depending on English error text", async () => { + const { outcome } = await failedTurn( + JSON.stringify({ code: -32000, message: "请重新登录", data: { category: "auth" } }), + ); + expect(outcome).toMatchObject({ + error: { code: "authenticationRequired", message: expect.stringContaining("请重新登录") }, + }); + }); + + it("keeps non-auth refusals as native failures even with RPC code -32000", async () => { + const { outcome } = await failedTurn( + JSON.stringify({ code: -32000, message: "Request blocked", data: { category: "policy" } }), + ); + expect(outcome).toMatchObject({ + error: { code: "nativeFailure", message: expect.stringContaining("Request blocked") }, + }); + }); + + it("sanitizes the extracted diagnostic before publishing it", async () => { + const { outcome } = await failedTurn( + JSON.stringify({ message: "Request failed Authorization: Bearer test-secret-value" }), + ); + expect(outcome).toMatchObject({ + error: { message: expect.stringContaining("Request failed") }, + }); + expect(JSON.stringify(outcome)).not.toContain("test-secret-value"); + }); + + it("preserves plain-text errors from older native versions", async () => { + const { outcome } = await failedTurn("Upstream model unavailable"); + expect(outcome).toMatchObject({ + error: { + code: "nativeFailure", + message: expect.stringContaining("Upstream model unavailable"), + }, + }); + }); + + it.each([undefined, "", "{}", "[]", "null", 42])( + "retains the terminal fallback when metadata has no diagnostic: %j", + async (metadata) => { + const { outcome } = await failedTurn(metadata); + expect(outcome).toMatchObject({ + error: { + code: "nativeFailure", + message: "WorkBuddy: Native Turn stopped: refusal (FAILED_MODEL_REQUEST)", + }, + }); + }, + ); +}); From 152b802db08e0ad8dce86f05747789c26f087900 Mon Sep 17 00:00:00 2001 From: fs-lx <158259039+fs-lx@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:40:18 +0800 Subject: [PATCH 2/3] fix(workbuddy): initialize native encrypted credential reads --- .../workbuddy-harness-integration.md | 8 +- packages/adapters/workbuddy/src/command.ts | 5 +- .../workbuddy/src/credential-bootstrap.ts | 106 +++++++++++++ .../adapters/workbuddy/test/command.test.ts | 11 +- .../test/credential-bootstrap.test.ts | 149 ++++++++++++++++++ .../workbuddy/test/delegation.test.ts | 12 +- .../adapters/workbuddy/test/discovery.test.ts | 14 +- 7 files changed, 294 insertions(+), 11 deletions(-) create mode 100644 packages/adapters/workbuddy/src/credential-bootstrap.ts create mode 100644 packages/adapters/workbuddy/test/credential-bootstrap.test.ts diff --git a/docs/harnesses/workbuddy/workbuddy-harness-integration.md b/docs/harnesses/workbuddy/workbuddy-harness-integration.md index 3fb938726..460a37548 100644 --- a/docs/harnesses/workbuddy/workbuddy-harness-integration.md +++ b/docs/harnesses/workbuddy/workbuddy-harness-integration.md @@ -28,13 +28,15 @@ ELECTRON_RUN_AS_NODE=1 "/Applications/WorkBuddy AI.app/Contents/MacOS/Electron" --acp ``` -这是 macOS 应用内置入口的实际启动形态;显式原生 CLI 覆盖直接追加公开的 `--acp` 参数;显式 Desktop 入口沿用上述运行时与脚本配对。每个可写 Host Session 拥有一个 stdio ACP 子进程。普通 Prompt 不进入 Shell 参数,Host 只通过 ACP 请求发送内容。 +这是 macOS 应用内置入口的运行时与脚本配对;插件在加载内置脚本前通过 Electron 的 `--eval` 执行下述凭据引导,保持原生 CLI 的 `process.argv` 与参数。显式原生 CLI 覆盖直接追加公开的 `--acp` 参数,不注入应用内置引导;显式 Desktop 入口沿用内置启动路径。每个可写 Host Session 拥有一个 stdio ACP 子进程。普通 Prompt 不进入 Shell 参数,Host 只通过 ACP 请求发送内容。 ## 认证与数据隔离 直接运行内置 CLI 时,其内部默认值可能回退到 `~/.codebuddy`。插件不会沿用这一跨产品默认值:它只把 `WORKBUDDY_CONFIG_DIR` 视为用户选择;没有该值时使用 `~/.workbuddy-ai`,随后强制传给子进程的 `WORKBUDDY_CONFIG_DIR` 与 `CODEBUDDY_CONFIG_DIR` 指向同一 WorkBuddy 根。这样即使普通 CodeBuddy 配置了自己的 `CODEBUDDY_CONFIG_DIR`,WorkBuddy 的历史、认证和设置也不会泄漏到该目录。WorkBuddy CLI 负责认证、原生设置、工具和 MCP 配置;codexhost 不保存、复制或推断其凭据。 -只完成 `initialize` 后进行的无 Prompt `session/new` 探测返回了 `Authentication required`。CodexHost 不读取或复制 WorkBuddy Desktop 的私有登录态,因此不能依赖 GUI 登录一定可供独立 ACP 进程使用。若 ACP 要求认证,应在 Host 外启动相同产品配置和数据根的内置 TUI,然后执行官方 [`/login`](https://cloud.tencent.com/document/product/1831/137046): +新版内置 CLI 的独立 ACP 启动不会自行初始化加密凭据读取能力。对于应用内置入口,插件先在同一个 WorkBuddy Electron 进程中调用其 `electron_browser_workbuddy_storage` 原生绑定,再通过内置 CLI 的 credential bootstrap 接收器初始化字段加密。密钥按原生 `normalizeAtRestKeyPayload` 规则规范化,只在该进程内存与本次随机管道中传递;不写入参数、环境变量或文件,也不由 Host 解密、复制登录 Token。CLI 随后按自己的认证逻辑读取已有登录文件。已有的受管 bootstrap 环境保持原样;旧应用没有该原生绑定时继续原有启动流程。 + +此接入使用 WorkBuddy 自带的实现接口,不属于 ACP 标准:当前已在 Windows WorkBuddy AI 5.7.6 / CLI 2.156.0 的字段加密策略下验证,升级后须复核原生绑定、密钥格式和接收器。失败时停止启动,不关闭加密或写回明文凭据。POSIX 临时 socket 使用独立的 `0700` 目录,Windows 使用随机命名管道;收到原生确认后清理引导资源。若独立 CLI 仍要求认证,应在 Host 外启动相同产品配置和数据根的内置 TUI,然后执行官方 [`/login`](https://cloud.tencent.com/document/product/1831/137046): ```sh CODEBUDDY_CONFIG_DIR="$HOME/.workbuddy-ai" \ @@ -48,7 +50,7 @@ ELECTRON_RUN_AS_NODE=1 \ 若 `session/new` 成功而首个 Turn 返回 `refusal`,应检查 ACP 终态 `_meta["codebuddy.ai/errorMessage"]`。共享 CodeBuddy Session 会提取其中的 `data.details` 或 `message`,经诊断脱敏后显示;`data.category="auth"` 映射为 `authenticationRequired`,而不是仅显示 `FAILED_MODEL_REQUEST`。JSON-RPC `-32000` 本身不区分认证与其他原生拒绝。缺少可读诊断时保留原有的 stop reason / outcome 提示。 -部分新版 WorkBuddy 将桌面登录凭据加密保存,独立启动的内置 CLI 可能记录 `AtRestEncryption ... category=missing-key`,并在恢复登录时显示 `hasSession=true hasAccessToken=false`。此时桌面已登录、模型目录可读取或 Thread 标题已生成,都不能证明当前 ACP 进程可以完成认证。原生 `/login` 提示也不代表 CodexHost 已支持该版本所需的凭据引导。此兼容问题见 [Issue #464](https://github.com/BytePioneer-AI/codex-host/issues/464);当前集成仍遵守下述私有运行时边界,不会自动复制或解密 Desktop 凭据。 +若引导未生效,独立 CLI 可能记录 `AtRestEncryption ... category=missing-key`,并在恢复登录时显示 `hasSession=true hasAccessToken=false`。此时桌面已登录、模型目录可读取或 Thread 标题已生成,都不能证明当前 ACP 进程可以完成认证。此兼容问题见 [Issue #464](https://github.com/BytePioneer-AI/codex-host/issues/464)。引导只初始化 CLI 自己的凭据读取能力,仍遵守下述私有运行时边界,不激活 Desktop owner runtime 或接管已有 Desktop 任务。 ## 工作目录与原生历史定位 diff --git a/packages/adapters/workbuddy/src/command.ts b/packages/adapters/workbuddy/src/command.ts index e5b96d599..ee9d0b294 100644 --- a/packages/adapters/workbuddy/src/command.ts +++ b/packages/adapters/workbuddy/src/command.ts @@ -12,6 +12,7 @@ import { type HarnessDiscoverySpec, } from "@codexhost/harness-discovery"; import { workBuddyDelegationArguments } from "./delegation.js"; +import { WORKBUDDY_CREDENTIAL_PRELOAD } from "./credential-bootstrap.js"; import { resolveWorkBuddyBundle, resolveWorkBuddyInstallDirectory, @@ -183,7 +184,9 @@ export function workBuddyInvocation( ...commandInvocation( executable, [ - ...(bundle ? [bundle.cli] : []), + ...(bundle + ? ["--eval", `${WORKBUDDY_CREDENTIAL_PRELOAD}\nrequire(process.argv[1]);`, bundle.cli] + : []), ...(nativeArguments ?? [ "--acp", ...(ephemeral ? ["--no-session-persistence"] : []), diff --git a/packages/adapters/workbuddy/src/credential-bootstrap.ts b/packages/adapters/workbuddy/src/credential-bootstrap.ts new file mode 100644 index 000000000..ee150912f --- /dev/null +++ b/packages/adapters/workbuddy/src/credential-bootstrap.ts @@ -0,0 +1,106 @@ +/** Runs inside the selected app's Electron, before its bundled CLI is loaded. */ +export const WORKBUDDY_CREDENTIAL_PRELOAD = String.raw` +(() => { + const env = process.env; + if ([ + "WORKBUDDY_AT_REST_ENCRYPTION", + "CODEBUDDY_SIDECAR_CREDENTIAL_BOOTSTRAP_SOCKET", + "CODEBUDDY_SIDECAR_READY_SOCKET", + "CODEBUDDY_SIDECAR_READY_TOKEN", + "CODEBUDDY_SIDECAR_READY_SESSION_ID", + ].some(name => env[name] !== undefined)) return; + + let provider; + try { + provider = process._linkedBinding("electron_browser_workbuddy_storage"); + } catch { + // Older app runtimes predate encrypted credential fields. + return; + } + const crypto = require("node:crypto"); + const net = require("node:net"); + const fs = require("node:fs"); + const path = require("node:path"); + let payload; + try { payload = JSON.parse(provider.loggerGet()); } + catch { throw Error("WorkBuddy credential key provider is unavailable"); } + let secret = payload && payload.atRestSecretKey; + const decoded = typeof secret === "string" ? Buffer.from(secret, "base64") : Buffer.alloc(0); + const valid = payload && payload.version === 1 && decoded.length === 32 && + decoded.toString("base64") === secret && !decoded.every(byte => byte === 0); + decoded.fill(0); + if (!valid) throw Error("WorkBuddy credential key payload is unsupported"); + // Match the native normalizeAtRestKeyPayload; the base64 text is hashed, not its decoded bytes. + const key = crypto.createHash("sha256").update(secret, "utf8").digest(); + secret = undefined; + payload = undefined; + const token = crypto.randomBytes(24).toString("hex"); + const sessionId = crypto.randomUUID(); + let directory; + let socketPath; + if (process.platform === "win32") { + socketPath = "\\\\.\\pipe\\codexhost-wb-" + process.pid + "-" + token; + } else { + let root = require("node:os").tmpdir(); + if (Buffer.byteLength(root) > 65) root = "/tmp"; + directory = fs.mkdtempSync(path.join(root, "ch-wb-")); + fs.chmodSync(directory, 0o700); + socketPath = path.join(directory, "c"); + } + const cleanup = () => { + if (directory) try { fs.rmSync(directory, {recursive:true,force:true}); } catch {} + }; + process.once("exit", cleanup); + Object.assign(env, { + WORKBUDDY_AT_REST_ENCRYPTION: "fields", + CODEBUDDY_SIDECAR_CREDENTIAL_BOOTSTRAP_SOCKET: socketPath, + CODEBUDDY_SIDECAR_READY_SOCKET: socketPath + "-ready", + CODEBUDDY_SIDECAR_READY_TOKEN: token, + CODEBUDDY_SIDECAR_READY_SESSION_ID: sessionId, + }); + let message = JSON.stringify({version:1,sessionId,token,pid:process.pid,bootstrap:{ + version:1,policy:"fields", + symmetricKey:{keyId:crypto.createHash("sha256").update(key).digest("hex").slice(0,16),keyBase64:key.toString("base64")}, + asymmetricUnavailable:"missing-key", + }}) + "\n"; + key.fill(0); + let done = false; + let socket; + const finish = success => { + if (done) return; + done = true; + clearTimeout(timer); + socket && socket.destroy(); + message = ""; + cleanup(); + process.removeListener("exit", cleanup); + if (!success) { + process.stderr.write("WorkBuddy credential bootstrap failed\n"); + process.exit(1); + } + }; + const timer = setTimeout(() => finish(false), 4500); + const connect = () => { + if (done) return; + socket = net.createConnection(socketPath); + let reply = ""; + socket.once("connect", () => socket.end(message, () => { message = ""; })); + socket.on("data", chunk => { + reply += chunk; + if (reply.length > 1024) return finish(false); + if (!reply.includes("\n")) return; + try { + const result = JSON.parse(reply.slice(0, reply.indexOf("\n"))); + finish(result.ok === true && result.policy === "fields"); + } catch { finish(false); } + }); + socket.once("end", () => { if (!done) finish(false); }); + socket.once("error", error => { + socket.destroy(); + if (error.code === "ENOENT" || error.code === "ECONNREFUSED") setTimeout(connect, 20); + else finish(false); + }); + }; + setImmediate(connect); +})(); +`; diff --git a/packages/adapters/workbuddy/test/command.test.ts b/packages/adapters/workbuddy/test/command.test.ts index 48a2b48d4..eed1b8be5 100644 --- a/packages/adapters/workbuddy/test/command.test.ts +++ b/packages/adapters/workbuddy/test/command.test.ts @@ -55,6 +55,8 @@ describe("WorkBuddy command invocation", () => { expect(invocation.command).toBe(WORKBUDDY_MACOS_ELECTRON); expect(invocation.arguments).toEqual([ + "--eval", + expect.any(String), WORKBUDDY_MACOS_CLI, "--acp", "--no-session-persistence", @@ -264,6 +266,8 @@ describe("WorkBuddy command invocation", () => { expect(invocation.command).toBe(WORKBUDDY_MACOS_ELECTRON); expect(invocation.arguments).toEqual([ + "--eval", + expect.any(String), WORKBUDDY_MACOS_CLI, "--resume", "source", @@ -375,7 +379,12 @@ describe("WorkBuddy model-free history copy startup", () => { const environment = { HOME: "/Users/test" }; const copy = workBuddyInvocation(environment, false, dependencies, copyArguments); expect(copy.environment.DISABLE_TELEMETRY).toBe("1"); - expect(copy.arguments).toEqual([WORKBUDDY_MACOS_CLI, ...copyArguments]); + expect(copy.arguments).toEqual([ + "--eval", + expect.any(String), + WORKBUDDY_MACOS_CLI, + ...copyArguments, + ]); expect(environment).not.toHaveProperty("DISABLE_TELEMETRY"); for (const args of [undefined, ["--acp", "--serve"], ["--print"], ["--fork-session"]]) { expect( diff --git a/packages/adapters/workbuddy/test/credential-bootstrap.test.ts b/packages/adapters/workbuddy/test/credential-bootstrap.test.ts new file mode 100644 index 000000000..ed9480b72 --- /dev/null +++ b/packages/adapters/workbuddy/test/credential-bootstrap.test.ts @@ -0,0 +1,149 @@ +import { execFile } from "node:child_process"; +import { createCipheriv, createHash } from "node:crypto"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; +import { workBuddyInvocation } from "../src/command.js"; + +const run = promisify(execFile); +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +async function fixture(provider: string, cli: string, extraEnvironment: NodeJS.ProcessEnv = {}) { + const directory = await mkdtemp(path.join(os.tmpdir(), "wb-bootstrap-")); + directories.push(directory); + const platform = process.platform === "win32" ? "win32" : "darwin"; + const bundle = + platform === "win32" + ? path.join(directory, "WorkBuddyAI.exe") + : path.join(directory, "WorkBuddy AI.app", "Contents", "MacOS", "Electron"); + const resources = + platform === "win32" + ? path.join(directory, "resources") + : path.join(directory, "WorkBuddy AI.app", "Contents", "Resources"); + const script = path.join(resources, "app.asar.unpacked", "cli", "bin", "codebuddy"); + const preload = path.join(directory, "binding.cjs"); + await mkdir(path.dirname(script), { recursive: true }); + await writeFile(script, cli); + await writeFile(preload, `process._linkedBinding = (name) => { ${provider} };`); + const environment: NodeJS.ProcessEnv = Object.fromEntries( + Object.entries(process.env).filter( + ([name]) => !name.startsWith("CODEBUDDY_SIDECAR_") && name !== "WORKBUDDY_AT_REST_ENCRYPTION", + ), + ); + Object.assign(environment, extraEnvironment); + const invocation = workBuddyInvocation( + { ...environment, CODEXHOST_WORKBUDDY_COMMAND: bundle }, + false, + { platform, isExecutable: () => true, isDirectory: () => false }, + ); + // Node simulates the selected Electron runtime; its native binding is the only mocked boundary. + return run(process.execPath, ["--require", preload, ...invocation.arguments], { + env: invocation.environment, + timeout: 10_000, + windowsHide: true, + }); +} + +describe("WorkBuddy bundled credential startup", () => { + it("lets the native CLI decrypt existing credentials before its first request", async () => { + const secret = Buffer.alloc(32, 7).toString("base64"); + const key = createHash("sha256").update(secret, "utf8").digest(); + const nonce = Buffer.alloc(12, 3); + const cipher = createCipheriv("aes-256-gcm", key, nonce); + const encrypted = Buffer.concat([cipher.update("fixture-login-token"), cipher.final()]); + const authTag = cipher.getAuthTag(); + const { stdout, stderr } = await fixture( + `return { loggerGet: () => ${JSON.stringify(JSON.stringify({ version: 1, atRestSecretKey: secret }))} };`, + ` + const assert = require('node:assert/strict'); + const crypto = require('node:crypto'); + const net = require('node:net'); + const env = process.env; + if (!env.CODEBUDDY_SIDECAR_CREDENTIAL_BOOTSTRAP_SOCKET) { + console.error('Authentication required: credential key unavailable'); + process.exit(1); + } + assert.deepEqual(process.argv.slice(2), ['--acp']); + const server = net.createServer(socket => { + let data = ''; + socket.on('data', chunk => { + data += chunk; + if (!data.includes('\\n')) return; + const request = JSON.parse(data); + assert.equal(request.pid, process.pid); + assert.equal(request.sessionId, env.CODEBUDDY_SIDECAR_READY_SESSION_ID); + assert.equal(request.token, env.CODEBUDDY_SIDECAR_READY_TOKEN); + assert.equal(request.bootstrap.policy, 'fields'); + const key = Buffer.from(request.bootstrap.symmetricKey.keyBase64, 'base64'); + assert.equal(request.bootstrap.symmetricKey.keyId, crypto.createHash('sha256').update(key).digest('hex').slice(0,16)); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from('${nonce.toString("base64")}', 'base64')); + decipher.setAuthTag(Buffer.from('${authTag.toString("base64")}', 'base64')); + assert.equal(Buffer.concat([decipher.update(Buffer.from('${encrypted.toString("base64")}', 'base64')), decipher.final()]).toString(), 'fixture-login-token'); + socket.end(JSON.stringify({ok:true,policy:'fields'})+'\\n'); + server.close(); + console.log('2'); + }); + }); + server.listen(env.CODEBUDDY_SIDECAR_CREDENTIAL_BOOTSTRAP_SOCKET); + `, + ); + expect(stdout.trim()).toBe("2"); + expect(stderr).toBe(""); + expect(stdout + stderr).not.toContain(secret); + expect(stdout + stderr).not.toContain("fixture-login-token"); + }); + + it("keeps older Electron runtimes without the key provider usable", async () => { + const { stdout } = await fixture( + `throw Error('No such binding was linked: ' + name);`, + `if (process.env.CODEBUDDY_SIDECAR_CREDENTIAL_BOOTSTRAP_SOCKET) throw Error('Unexpected bootstrap'); console.log('legacy');`, + ); + expect(stdout.trim()).toBe("legacy"); + }); + + it("preserves a caller-owned managed credential bootstrap", async () => { + const { stdout } = await fixture( + `throw Error('Do not access the provider for a managed child');`, + `if(process.env.CODEBUDDY_SIDECAR_CREDENTIAL_BOOTSTRAP_SOCKET !== 'existing-bootstrap') throw Error('Overwritten bootstrap'); console.log('managed');`, + { + WORKBUDDY_AT_REST_ENCRYPTION: "fields", + CODEBUDDY_SIDECAR_CREDENTIAL_BOOTSTRAP_SOCKET: "existing-bootstrap", + }, + ); + expect(stdout.trim()).toBe("managed"); + }); + + it("fails before loading the CLI when the native key payload is invalid", async () => { + await expect( + fixture( + `return {loggerGet: () => JSON.stringify({version:1,atRestSecretKey:'private-invalid-key'})};`, + `console.log('CLI should not start');`, + ), + ).rejects.toMatchObject({ + code: 1, + stdout: "", + stderr: expect.stringContaining("WorkBuddy credential key payload is unsupported"), + }); + }); + + it("stops when the native receiver does not acknowledge credential protection", async () => { + const secret = Buffer.alloc(32, 7).toString("base64"); + await expect( + fixture( + `return {loggerGet: () => ${JSON.stringify(JSON.stringify({ version: 1, atRestSecretKey: secret }))}};`, + `require('node:net').createServer(socket => socket.on('data', () => socket.end('{"ok":false}\\n'))).listen(process.env.CODEBUDDY_SIDECAR_CREDENTIAL_BOOTSTRAP_SOCKET);`, + ), + ).rejects.toMatchObject({ + code: 1, + stdout: "", + stderr: "WorkBuddy credential bootstrap failed\n", + }); + }); +}); diff --git a/packages/adapters/workbuddy/test/delegation.test.ts b/packages/adapters/workbuddy/test/delegation.test.ts index 42858e411..7d84c1e72 100644 --- a/packages/adapters/workbuddy/test/delegation.test.ts +++ b/packages/adapters/workbuddy/test/delegation.test.ts @@ -45,6 +45,8 @@ describe("WorkBuddy cross-Harness delegation", () => { }); const instructions = WORKBUDDY_DELEGATION_INSTRUCTIONS; expect(invocation.arguments).toEqual([ + "--eval", + expect.any(String), WORKBUDDY_MACOS_CLI, "--acp", "--append-system-prompt", @@ -81,7 +83,13 @@ describe("WorkBuddy cross-Harness delegation", () => { platform: "darwin", isExecutable: () => true, }).arguments, - ).toEqual([WORKBUDDY_MACOS_CLI, "--acp", "--no-session-persistence"]); + ).toEqual([ + "--eval", + expect.any(String), + WORKBUDDY_MACOS_CLI, + "--acp", + "--no-session-persistence", + ]); expect( workBuddyInvocation( { @@ -92,7 +100,7 @@ describe("WorkBuddy cross-Harness delegation", () => { false, { platform: "darwin", isExecutable: () => true }, ).arguments, - ).toEqual([WORKBUDDY_MACOS_CLI, "--acp"]); + ).toEqual(["--eval", expect.any(String), WORKBUDDY_MACOS_CLI, "--acp"]); }); it("preserves per-Thread delegation environment across create and resume", async () => { diff --git a/packages/adapters/workbuddy/test/discovery.test.ts b/packages/adapters/workbuddy/test/discovery.test.ts index f507f7b20..e02f51375 100644 --- a/packages/adapters/workbuddy/test/discovery.test.ts +++ b/packages/adapters/workbuddy/test/discovery.test.ts @@ -19,7 +19,7 @@ describe("WorkBuddy app discovery", () => { dependencies, ); expect(invocation.command).toBe(executable); - expect(invocation.arguments).toEqual([cli, "--acp"]); + expect(invocation.arguments).toEqual(["--eval", expect.any(String), cli, "--acp"]); expect(invocation.environment.ELECTRON_RUN_AS_NODE).toBe("1"); expect(() => workBuddyInvocation({ CODEXHOST_WORKBUDDY_COMMAND: directory }, false, { @@ -53,7 +53,7 @@ describe("WorkBuddy app discovery", () => { isExecutable: (file) => file === executable || file === cli, }); expect(invocation.command).toBe(executable); - expect(invocation.arguments).toEqual([cli, "--acp"]); + expect(invocation.arguments).toEqual(["--eval", expect.any(String), cli, "--acp"]); expect(invocation.environment.ELECTRON_RUN_AS_NODE).toBe("1"); expect(() => workBuddyInvocation({ CODEXHOST_WORKBUDDY_COMMAND: executable }, false, { @@ -87,7 +87,13 @@ describe("WorkBuddy app discovery", () => { }, ); expect(invocation.command.toLowerCase()).toBe(executable.toLowerCase()); - expect(invocation.arguments).toEqual([cli, "--acp", "--no-session-persistence"]); + expect(invocation.arguments).toEqual([ + "--eval", + expect.any(String), + cli, + "--acp", + "--no-session-persistence", + ]); expect(invocation.environment).toMatchObject({ ELECTRON_RUN_AS_NODE: "1", WORKBUDDY_CONFIG_DIR: "C:\\Users\\Test\\.workbuddy-ai", @@ -107,7 +113,7 @@ describe("WorkBuddy app discovery", () => { isExecutable: (candidate) => [executable, cli].includes(candidate), }); expect(invocation.command).toBe(executable); - expect(invocation.arguments).toEqual([cli, "--acp"]); + expect(invocation.arguments).toEqual(["--eval", expect.any(String), cli, "--acp"]); }); it("never combines an app executable with another installation's CLI", () => { From 20e699917eff63180a1d2b332336c219b8273453 Mon Sep 17 00:00:00 2001 From: BytePioneer-AI <28417416@qq.com> Date: Sat, 17 Oct 2026 22:29:52 +0800 Subject: [PATCH 3/3] fix(workbuddy): validate managed credential bootstrap --- .../workbuddy-harness-integration.md | 2 +- .../workbuddy/src/credential-bootstrap.ts | 14 +++- .../test/credential-bootstrap.test.ts | 83 +++++++++++++++++-- 3 files changed, 88 insertions(+), 11 deletions(-) diff --git a/docs/harnesses/workbuddy/workbuddy-harness-integration.md b/docs/harnesses/workbuddy/workbuddy-harness-integration.md index 460a37548..4f87fe5f6 100644 --- a/docs/harnesses/workbuddy/workbuddy-harness-integration.md +++ b/docs/harnesses/workbuddy/workbuddy-harness-integration.md @@ -34,7 +34,7 @@ ELECTRON_RUN_AS_NODE=1 "/Applications/WorkBuddy AI.app/Contents/MacOS/Electron" 直接运行内置 CLI 时,其内部默认值可能回退到 `~/.codebuddy`。插件不会沿用这一跨产品默认值:它只把 `WORKBUDDY_CONFIG_DIR` 视为用户选择;没有该值时使用 `~/.workbuddy-ai`,随后强制传给子进程的 `WORKBUDDY_CONFIG_DIR` 与 `CODEBUDDY_CONFIG_DIR` 指向同一 WorkBuddy 根。这样即使普通 CodeBuddy 配置了自己的 `CODEBUDDY_CONFIG_DIR`,WorkBuddy 的历史、认证和设置也不会泄漏到该目录。WorkBuddy CLI 负责认证、原生设置、工具和 MCP 配置;codexhost 不保存、复制或推断其凭据。 -新版内置 CLI 的独立 ACP 启动不会自行初始化加密凭据读取能力。对于应用内置入口,插件先在同一个 WorkBuddy Electron 进程中调用其 `electron_browser_workbuddy_storage` 原生绑定,再通过内置 CLI 的 credential bootstrap 接收器初始化字段加密。密钥按原生 `normalizeAtRestKeyPayload` 规则规范化,只在该进程内存与本次随机管道中传递;不写入参数、环境变量或文件,也不由 Host 解密、复制登录 Token。CLI 随后按自己的认证逻辑读取已有登录文件。已有的受管 bootstrap 环境保持原样;旧应用没有该原生绑定时继续原有启动流程。 +新版内置 CLI 的独立 ACP 启动不会自行初始化加密凭据读取能力。对于应用内置入口,插件先在同一个 WorkBuddy Electron 进程中调用其 `electron_browser_workbuddy_storage` 原生绑定,再通过内置 CLI 的 credential bootstrap 接收器初始化字段加密。密钥按原生 `normalizeAtRestKeyPayload` 规则规范化,只在该进程内存与本次随机管道中传递;不写入参数、环境变量或文件,也不由 Host 解密、复制登录 Token。CLI 随后按自己的认证逻辑读取已有登录文件。完整的受管 bootstrap 环境保持原样;已有 socket、Token 或 Session ID 配置时,要求加密策略与四项通信配置均非空,否则在加载 CLI 前明确报错。仅设置 `WORKBUDDY_AT_REST_ENCRYPTION=fields` 或空策略时仍执行字段加密引导,其他显式策略交给原生 CLI 处理;旧应用没有该原生绑定时继续原有启动流程。 此接入使用 WorkBuddy 自带的实现接口,不属于 ACP 标准:当前已在 Windows WorkBuddy AI 5.7.6 / CLI 2.156.0 的字段加密策略下验证,升级后须复核原生绑定、密钥格式和接收器。失败时停止启动,不关闭加密或写回明文凭据。POSIX 临时 socket 使用独立的 `0700` 目录,Windows 使用随机命名管道;收到原生确认后清理引导资源。若独立 CLI 仍要求认证,应在 Host 外启动相同产品配置和数据根的内置 TUI,然后执行官方 [`/login`](https://cloud.tencent.com/document/product/1831/137046): diff --git a/packages/adapters/workbuddy/src/credential-bootstrap.ts b/packages/adapters/workbuddy/src/credential-bootstrap.ts index ee150912f..6896f9d96 100644 --- a/packages/adapters/workbuddy/src/credential-bootstrap.ts +++ b/packages/adapters/workbuddy/src/credential-bootstrap.ts @@ -2,13 +2,21 @@ export const WORKBUDDY_CREDENTIAL_PRELOAD = String.raw` (() => { const env = process.env; - if ([ - "WORKBUDDY_AT_REST_ENCRYPTION", + const bootstrapEnvironment = [ "CODEBUDDY_SIDECAR_CREDENTIAL_BOOTSTRAP_SOCKET", "CODEBUDDY_SIDECAR_READY_SOCKET", "CODEBUDDY_SIDECAR_READY_TOKEN", "CODEBUDDY_SIDECAR_READY_SESSION_ID", - ].some(name => env[name] !== undefined)) return; + ]; + if (bootstrapEnvironment.some(name => env[name] !== undefined)) { + if (!["WORKBUDDY_AT_REST_ENCRYPTION", ...bootstrapEnvironment].every(name => env[name]?.trim())) { + throw Error("WorkBuddy credential bootstrap environment is incomplete"); + } + return; + } + const policy = env.WORKBUDDY_AT_REST_ENCRYPTION?.trim(); + // Other explicit policies remain owned by the native CLI. + if (policy && policy !== "fields") return; let provider; try { diff --git a/packages/adapters/workbuddy/test/credential-bootstrap.test.ts b/packages/adapters/workbuddy/test/credential-bootstrap.test.ts index ed9480b72..97bea8c4c 100644 --- a/packages/adapters/workbuddy/test/credential-bootstrap.test.ts +++ b/packages/adapters/workbuddy/test/credential-bootstrap.test.ts @@ -52,7 +52,12 @@ async function fixture(provider: string, cli: string, extraEnvironment: NodeJS.P } describe("WorkBuddy bundled credential startup", () => { - it("lets the native CLI decrypt existing credentials before its first request", async () => { + it.each([ + ["unset", undefined], + ["fields", "fields"], + ["empty", ""], + ["blank", " "], + ])("decrypts existing credentials with a %s encryption policy", async (_name, policy) => { const secret = Buffer.alloc(32, 7).toString("base64"); const key = createHash("sha256").update(secret, "utf8").digest(); const nonce = Buffer.alloc(12, 3); @@ -93,6 +98,7 @@ describe("WorkBuddy bundled credential startup", () => { }); server.listen(env.CODEBUDDY_SIDECAR_CREDENTIAL_BOOTSTRAP_SOCKET); `, + policy === undefined ? {} : { WORKBUDDY_AT_REST_ENCRYPTION: policy }, ); expect(stdout.trim()).toBe("2"); expect(stderr).toBe(""); @@ -109,15 +115,78 @@ describe("WorkBuddy bundled credential startup", () => { }); it("preserves a caller-owned managed credential bootstrap", async () => { - const { stdout } = await fixture( - `throw Error('Do not access the provider for a managed child');`, - `if(process.env.CODEBUDDY_SIDECAR_CREDENTIAL_BOOTSTRAP_SOCKET !== 'existing-bootstrap') throw Error('Overwritten bootstrap'); console.log('managed');`, + const environment = { + WORKBUDDY_AT_REST_ENCRYPTION: "fields", + CODEBUDDY_SIDECAR_CREDENTIAL_BOOTSTRAP_SOCKET: "existing-bootstrap", + CODEBUDDY_SIDECAR_READY_SOCKET: "existing-ready", + CODEBUDDY_SIDECAR_READY_TOKEN: "existing-token", + CODEBUDDY_SIDECAR_READY_SESSION_ID: "existing-session", + }; + const { stdout, stderr } = await fixture( + `process.stderr.write('Unexpected key provider access'); throw Error('Managed child');`, + `const assert = require('node:assert/strict'); const expected = ${JSON.stringify(environment)}; + for (const [name, value] of Object.entries(expected)) assert.equal(process.env[name], value); + console.log('managed');`, + environment, + ); + expect(stdout.trim()).toBe("managed"); + expect(stderr).toBe(""); + }); + + it.each(["disabled", "required"])("preserves the native %s encryption policy", async (policy) => { + const { stdout, stderr } = await fixture( + `process.stderr.write('Unexpected key provider access'); throw Error('Native policy');`, + `const assert = require('node:assert/strict'); + assert.equal(process.env.WORKBUDDY_AT_REST_ENCRYPTION, ${JSON.stringify(policy)}); + assert.equal(process.env.CODEBUDDY_SIDECAR_CREDENTIAL_BOOTSTRAP_SOCKET, undefined); + console.log('native');`, + { WORKBUDDY_AT_REST_ENCRYPTION: policy }, + ); + expect(stdout.trim()).toBe("native"); + expect(stderr).toBe(""); + }); + + it.each([ + ["bootstrap socket only", { CODEBUDDY_SIDECAR_CREDENTIAL_BOOTSTRAP_SOCKET: "existing" }], + [ + "policy and bootstrap socket only", { WORKBUDDY_AT_REST_ENCRYPTION: "fields", - CODEBUDDY_SIDECAR_CREDENTIAL_BOOTSTRAP_SOCKET: "existing-bootstrap", + CODEBUDDY_SIDECAR_CREDENTIAL_BOOTSTRAP_SOCKET: "existing", }, - ); - expect(stdout.trim()).toBe("managed"); + ], + [ + "missing policy", + { + CODEBUDDY_SIDECAR_CREDENTIAL_BOOTSTRAP_SOCKET: "existing", + CODEBUDDY_SIDECAR_READY_SOCKET: "existing-ready", + CODEBUDDY_SIDECAR_READY_TOKEN: "existing-token", + CODEBUDDY_SIDECAR_READY_SESSION_ID: "existing-session", + }, + ], + [ + "blank token", + { + WORKBUDDY_AT_REST_ENCRYPTION: "fields", + CODEBUDDY_SIDECAR_CREDENTIAL_BOOTSTRAP_SOCKET: "existing", + CODEBUDDY_SIDECAR_READY_SOCKET: "existing-ready", + CODEBUDDY_SIDECAR_READY_TOKEN: " ", + CODEBUDDY_SIDECAR_READY_SESSION_ID: "existing-session", + }, + ], + ["empty ready socket", { CODEBUDDY_SIDECAR_READY_SOCKET: "" }], + ] as const)("rejects an incomplete managed bootstrap: %s", async (_name, environment) => { + await expect( + fixture( + `process.stderr.write('Unexpected key provider access'); throw Error('Incomplete bootstrap');`, + `console.log('CLI should not start');`, + environment, + ), + ).rejects.toMatchObject({ + code: 1, + stdout: "", + stderr: expect.stringContaining("WorkBuddy credential bootstrap environment is incomplete"), + }); }); it("fails before loading the CLI when the native key payload is invalid", async () => {