Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions docs/harnesses/workbuddy/workbuddy-harness-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 环境保持原样;已有 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):

```sh
CODEBUDDY_CONFIG_DIR="$HOME/.workbuddy-ai" \
Expand All @@ -46,6 +48,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 提示。

若引导未生效,独立 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 任务。

## 工作目录与原生历史定位

Session 的执行工作目录始终来自 Codex Desktop 传入的 `cwd`,包括 Desktop 选择的 worktree;配置根 `~/.workbuddy-ai` 只是原生状态目录,不替代执行目录。历史文件使用 WorkBuddy 内置 CLI 的 `PathUtils.canonicalizeStorePath` / `compressPath` 规则:先解析工作目录的真实路径,再将 `/`、`\`、`:` 转为连字符、去除首尾连字符并合并连续连字符,保留大小写、点号、空格、下划线及 Unicode 字符。
Expand Down
29 changes: 29 additions & 0 deletions packages/adapters/codebuddy/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,35 @@ export function failure<T>(
return { ok: false, error: nativeError(new CodeBuddyError(code, message), profile) };
}

export function nativeTurnError(
stopReason: unknown,
meta: Record<string, unknown>,
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<T>(
operation: Promise<T>,
milliseconds: number,
Expand Down
9 changes: 2 additions & 7 deletions packages/adapters/codebuddy/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
CodeBuddyError,
failure,
nativeError,
nativeTurnError,
record,
text,
type CodeBuddyRuntimeProfile,
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion packages/adapters/workbuddy/src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"] : []),
Expand Down
114 changes: 114 additions & 0 deletions packages/adapters/workbuddy/src/credential-bootstrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/** Runs inside the selected app's Electron, before its bundled CLI is loaded. */
export const WORKBUDDY_CREDENTIAL_PRELOAD = String.raw`
(() => {
const env = process.env;
const bootstrapEnvironment = [
"CODEBUDDY_SIDECAR_CREDENTIAL_BOOTSTRAP_SOCKET",
"CODEBUDDY_SIDECAR_READY_SOCKET",
"CODEBUDDY_SIDECAR_READY_TOKEN",
"CODEBUDDY_SIDECAR_READY_SESSION_ID",
];
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 {
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);
})();
`;
11 changes: 10 additions & 1 deletion packages/adapters/workbuddy/test/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading