From 9542c41e7f2248be163e0955b22eb3917dffb269 Mon Sep 17 00:00:00 2001 From: Brave Date: Fri, 16 Oct 2026 09:06:37 +0800 Subject: [PATCH 1/3] fix(workbuddy): silent re-auth, update-resilient discovery, cross-platform model parity - Auto-authenticate via ACP when inspection hits authenticationRequired: a disposable client triggers the native browser/QR login, inspection retries transparently; failures keep the original error. One in-flight attempt per adapter, no login storms. - Discover macOS bundles by scanning the Applications roots instead of fixed canonical paths, so app updates that rename/migrate the .app directory stay discoverable; canonical names win, then newest mtime. - Enable WorkBuddy product-model merging on macOS too (was win32-only) and fall back to the desktop app's cached product config (~/.workbuddy/cache/acc-product-config-v3.json) when no ACC_* env or bundled product.json exists, with the same ownership/permission checks as the CLI-side path. allowUnlistedModelSelection now holds on every platform so product models stay selectable. - CodeBuddyClient gains an optional authenticate() (standard ACP). --- packages/adapters/codebuddy/src/acp-client.ts | 9 ++ packages/adapters/workbuddy/src/discovery.ts | 97 +++++++++++++++-- .../adapters/workbuddy/src/product-models.ts | 54 +++++++++- .../workbuddy/src/workbuddy-adapter.ts | 89 +++++++++++++-- .../adapters/workbuddy/test/discovery.test.ts | 81 +++++++++++--- .../workbuddy/test/product-models.test.ts | 43 ++++++++ .../workbuddy/test/workbuddy-adapter.test.ts | 101 +++++++++++++++++- 7 files changed, 434 insertions(+), 40 deletions(-) diff --git a/packages/adapters/codebuddy/src/acp-client.ts b/packages/adapters/codebuddy/src/acp-client.ts index 43c54be4e..37ec5e0cf 100644 --- a/packages/adapters/codebuddy/src/acp-client.ts +++ b/packages/adapters/codebuddy/src/acp-client.ts @@ -22,6 +22,8 @@ export interface CodeBuddyClientHandlers { export interface CodeBuddyClient { initialize(): Promise>; open(cwd: string, sessionId?: string): Promise>; + /** Optional ACP authenticate: some native agents (WorkBuddy) support silent re-login. */ + authenticate?(params: { methodId: string }): Promise>; configure(sessionId: string, configId: string, value: string): Promise>; prompt(sessionId: string, input: string): Promise>; cancel(sessionId: string): Promise; @@ -184,6 +186,13 @@ export class CodeBuddyAcpClient implements CodeBuddyClient { ); } + /** ACP authenticate: resolves once the user completes the native flow (browser/QR). */ + async authenticate(params: { methodId: string }) { + return record( + await this.#request(() => this.#connection.authenticate(params), "ACP authenticate", 600_000), + ); + } + async configure(sessionId: string, configId: string, value: string) { return record( await this.#request( diff --git a/packages/adapters/workbuddy/src/discovery.ts b/packages/adapters/workbuddy/src/discovery.ts index 48642c6cc..0e348532d 100644 --- a/packages/adapters/workbuddy/src/discovery.ts +++ b/packages/adapters/workbuddy/src/discovery.ts @@ -1,3 +1,5 @@ +import { readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; import { harnessCandidates, targetPath, @@ -16,6 +18,7 @@ const windowsRoots = [ "${ProgramFiles}/WorkBuddy", "${ProgramFiles}/WorkBuddyAI", ]; + const macSpec: HarnessDiscoverySpec = { id: "workbuddy", command: "Electron", @@ -29,6 +32,65 @@ const macSpec: HarnessDiscoverySpec = { }, }; +export interface MacWorkBuddyScanDependencies { + readdirSync?: typeof readdirSync; + statSync?: typeof statSync; + roots?: readonly string[]; +} + +export type MacWorkBuddyScanFunctions = Required>; + +const defaultScanDependencies = (): Required> => ({ + readdirSync, + statSync, +}); + +/** + * Auto-updates and bundle migrations rename the .app directory, so fixed + * canonical install roots miss renamed or staged copies. Every "WorkBuddy*.app" + * bundle under the standard Applications roots is a candidate; canonical names + * win first, then the most recently placed copy wins over stale ones. + */ +export function macWorkBuddyAppBundleDirectories( + platform: NodeJS.Platform = process.platform, + scan: MacWorkBuddyScanDependencies = {}, +): string[] { + if (platform !== "darwin") return []; + const paths = targetPath(platform); + const { readdirSync: list, statSync: metadata } = { + ...defaultScanDependencies(), + ...scan, + }; + const roots = scan.roots ?? ["/Applications", paths.join(homedir(), "Applications")]; + const bundles = new Map(); + for (const root of roots) { + let entries: string[]; + try { + entries = list(root); + } catch { + continue; + } + for (const entry of entries) { + if (!/^WorkBuddy[ .\w-]*\.app$/iu.test(entry)) continue; + const bundle = paths.join(root, entry); + try { + metadata(paths.join(bundle, "Contents", "Info.plist")); + metadata(paths.join(bundle, "Contents", "MacOS")); + bundles.set(bundle, metadata(bundle).mtimeMs); + } catch { + continue; + } + } + } + const canonical = (bundle: string): number => + /\/WorkBuddy(?: AI)?\.app$/iu.test(bundle) ? 1 : 0; + const scored = [...bundles].map(([bundle, mtime]) => ({ + bundle, + rank: canonical(bundle) * 1e12 + mtime, + })); + return scored.sort((left, right) => right.rank - left.rank).map((entry) => entry.bundle); +} + /** Resolve only within the selected installation; never search PATH or another installation. */ export function resolveWorkBuddyInstallDirectory( directory: string, @@ -61,21 +123,23 @@ export function resolveWorkBuddyBundle( platform: NodeJS.Platform, isExecutable: (candidate: string) => boolean, explicitExecutable?: string, + scan: MacWorkBuddyScanDependencies = {}, ): { executable: string; cli: string } | undefined { - const specs: HarnessDiscoverySpec[] = - platform === "darwin" - ? [macSpec] - : platform === "win32" - ? ["WorkBuddy AI", "WorkBuddy", "WorkBuddyAI"].map((command) => ({ - id: "workbuddy", - command, - installRoots: { windows: windowsRoots }, - })) - : []; const paths = targetPath(platform); const candidates = explicitExecutable ? [{ candidate: explicitExecutable, source: "configured" as const }] - : specs.flatMap((spec) => harnessCandidates(spec, { environment, platform })); + : platform === "darwin" + ? [ + ...specsFor(platform).flatMap((spec) => + harnessCandidates(spec, { environment, platform }), + ), + // Renamed/staged app bundles (update migrations) that the fixed roots miss. + ...macWorkBuddyAppBundleDirectories(platform, scan).map((bundle) => ({ + candidate: paths.join(bundle, "Contents", "MacOS", "Electron"), + source: "install-root" as const, + })), + ] + : specsFor(platform).flatMap((spec) => harnessCandidates(spec, { environment, platform })); for (const { candidate, source } of candidates) { // A random Electron on PATH is not WorkBuddy; Windows .cmd shims are not Electron runtimes. if (platform === "darwin" && source !== "install-root" && !explicitExecutable) continue; @@ -90,3 +154,14 @@ export function resolveWorkBuddyBundle( } return undefined; } + +function specsFor(platform: NodeJS.Platform): HarnessDiscoverySpec[] { + if (platform === "darwin") return [macSpec]; + if (platform === "win32") + return ["WorkBuddy AI", "WorkBuddy", "WorkBuddyAI"].map((command) => ({ + id: "workbuddy", + command, + installRoots: { windows: windowsRoots }, + })); + return []; +} diff --git a/packages/adapters/workbuddy/src/product-models.ts b/packages/adapters/workbuddy/src/product-models.ts index ba73e2e7d..300817233 100644 --- a/packages/adapters/workbuddy/src/product-models.ts +++ b/packages/adapters/workbuddy/src/product-models.ts @@ -1,6 +1,8 @@ import { readFile, stat } from "node:fs/promises"; +import { homedir } from "node:os"; import path from "node:path"; import { modelRef } from "@codexhost/adapter-codebuddy"; +import { targetPath } from "@codexhost/harness-discovery"; import type { HarnessModelCatalog } from "@codexhost/harness-adapter"; import { HARNESS_MODEL_LABEL_MAX_LENGTH, @@ -78,6 +80,45 @@ function bundledFallbackProductPath(invocation: ReturnType { + if (platform !== "darwin" && platform !== "win32") return; + const paths = targetPath(platform); + const home = environment.HOME || environment.USERPROFILE || homedir(); + const root = paths.join(home, ".workbuddy"); + const candidate = paths.join(root, "cache", "acc-product-config-v3.json"); + try { + const uid = process.getuid?.(); + for (const directory of [root, paths.dirname(candidate)]) { + const metadata = await stat(directory); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) return; + if (platform !== "win32") { + if ((metadata.mode & 0o022) !== 0) return; + if (uid !== undefined && metadata.uid !== uid) return; + } + } + const metadata = await stat(candidate); + if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size <= 0) return; + if (platform !== "win32") { + if ((metadata.mode & 0o077) !== 0) return; + if (uid !== undefined && metadata.uid !== uid) return; + } + return candidate; + } catch { + return; + } +} + export async function loadWorkBuddyProductModels( environment: NodeJS.ProcessEnv, ): Promise { @@ -101,9 +142,18 @@ export async function loadWorkBuddyProductModels( } } const fallback = bundledFallbackProductPath(invocation); - if (!fallback) return []; + if (fallback) { + try { + const parsed = parseWorkBuddyProductModels(await readProductJson(fallback)); + if (parsed.length > 0) return parsed; + } catch { + // Fall through to the app-side cache below. + } + } + const appCache = await appCacheProductPath(productEnvironment, process.platform); + if (!appCache) return []; try { - return parseWorkBuddyProductModels(await readProductJson(fallback)); + return parseWorkBuddyProductModels(await readProductJson(appCache)); } catch { return []; } diff --git a/packages/adapters/workbuddy/src/workbuddy-adapter.ts b/packages/adapters/workbuddy/src/workbuddy-adapter.ts index dddbbb047..61636ca79 100644 --- a/packages/adapters/workbuddy/src/workbuddy-adapter.ts +++ b/packages/adapters/workbuddy/src/workbuddy-adapter.ts @@ -2,6 +2,7 @@ import { CodeBuddyAcpClient, CodeBuddyAdapter, type CodeBuddyAdapterOptions, + type CodeBuddyClient, } from "@codexhost/adapter-codebuddy"; import type { HarnessInspection, @@ -25,34 +26,57 @@ export type WorkBuddyAdapterOptions = Omit< > & { productModels?: () => Promise; platform?: NodeJS.Platform; + /** Seconds to wait for the user to finish a native browser/QR login before giving up. */ + authenticationTimeoutMs?: number; }; +/** The WorkBuddy CLI advertises exactly these methods (WeChat, external, iOA, selfhosted). */ +const AUTHENTICATION_METHOD_ID = "internal"; +const DEFAULT_AUTHENTICATION_TIMEOUT_MS = 10 * 60_000; + +function isAuthenticationRequired(error: unknown): boolean { + // super.inspect() reports failures as plain HarnessError objects, not Error instances. + return ( + typeof error === "object" && + error !== null && + (error as { code?: unknown }).code === "authenticationRequired" + ); +} + export class WorkBuddyAdapter extends CodeBuddyAdapter { readonly #productModels: () => Promise; + readonly #authenticationTimeoutMs: number; + #authentication: Promise | undefined; constructor(options: WorkBuddyAdapterOptions = {}) { - const { productModels, platform = process.platform, ...codeBuddyOptions } = options; + const { + productModels, + authenticationTimeoutMs = DEFAULT_AUTHENTICATION_TIMEOUT_MS, + ...codeBuddyOptions + } = options; const environment = { ...(codeBuddyOptions.environment ?? process.env) }; - const windowsProfile = { - ...WORKBUDDY_RUNTIME_PROFILE, - allowUnlistedModelSelection: true, - }; super({ ...codeBuddyOptions, - profile: platform === "win32" ? windowsProfile : WORKBUDDY_RUNTIME_PROFILE, + // Product-file Models stay selectable even when ACP omits them from option rows. + profile: { ...WORKBUDDY_RUNTIME_PROFILE, allowUnlistedModelSelection: true }, invocationFactory: workBuddyInvocation, clientFactory: codeBuddyOptions.clientFactory ?? ((clientOptions) => new CodeBuddyAcpClient(clientOptions, undefined, workBuddyInvocation)), }); - this.#productModels = - platform === "win32" - ? (productModels ?? (() => loadWorkBuddyProductModels(environment))) - : async () => []; + this.#productModels = productModels ?? (() => loadWorkBuddyProductModels(environment)); + this.#authenticationTimeoutMs = authenticationTimeoutMs; } override async inspect(input: InspectHarnessInput = {}): Promise { - const inspection = await super.inspect(input); + let inspection = await super.inspect(input); + if (inspection.status === "unavailable" && isAuthenticationRequired(inspection.error)) { + // Requirement: an installed WorkBuddy must never strand the user on a manual + // login. A silent native authenticate (browser/QR) runs once per adapter, + // then inspection retries transparently; failure keeps the original result. + if (await this.#authenticateOnce(input)) + inspection = await super.inspect({ ...input, refresh: true }); + } if (inspection.status !== "ready") return inspection; const productModels = await this.#productModels().catch(() => []); if (productModels.length === 0) return inspection; @@ -62,6 +86,49 @@ export class WorkBuddyAdapter extends CodeBuddyAdapter { }); } + /** One in-flight native login per adapter; later callers await the same attempt. */ + #authenticateOnce(input: InspectHarnessInput): Promise { + const attempt = this.#authentication ?? this.#authenticate(input); + this.#authentication = attempt.finally(() => { + if (this.#authentication === attempt) this.#authentication = undefined; + }); + return attempt; + } + + async #authenticate(input: InspectHarnessInput): Promise { + const cwd = input.cwd ?? process.cwd(); + let client: CodeBuddyClient | undefined; + try { + client = this.#clientFactory()({ + cwd, + environment: this.options.environment ?? process.env, + ephemeral: true, + handlers: { + update: () => {}, + fault: () => {}, + permission: async () => ({ outcome: { outcome: "cancelled" } }), + question: async () => ({ outcome: "cancelled" }), + }, + }); + await client.initialize(); + if (!client.authenticate) return false; + await client.authenticate({ methodId: AUTHENTICATION_METHOD_ID }); + return true; + } catch { + return false; + } finally { + await client?.close().catch(() => {}); + } + } + + /** The parent keeps its factory private; mirror its defaulting rules exactly. */ + #clientFactory(): NonNullable { + return ( + this.options.clientFactory ?? + ((clientOptions) => new CodeBuddyAcpClient(clientOptions, undefined, workBuddyInvocation)) + ); + } + override async open(input: OpenSessionInput): Promise> { if ( input.kind === "create" && diff --git a/packages/adapters/workbuddy/test/discovery.test.ts b/packages/adapters/workbuddy/test/discovery.test.ts index f507f7b20..ca4e9fbaa 100644 --- a/packages/adapters/workbuddy/test/discovery.test.ts +++ b/packages/adapters/workbuddy/test/discovery.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import type { MacWorkBuddyScanFunctions } from "../src/discovery.js"; import { workBuddyInvocation } from "../src/command.js"; describe("WorkBuddy app discovery", () => { @@ -95,19 +96,75 @@ describe("WorkBuddy app discovery", () => { }); }); - it.each([ - "/Applications/WorkBuddy.app", - "/Users/test/Applications/WorkBuddy AI.app", - "/Users/test/Applications/WorkBuddy.app", - ])("finds the macOS bundle %s", (root) => { - const executable = `${root}/Contents/MacOS/Electron`; - const cli = `${root}/Contents/Resources/app.asar.unpacked/cli/bin/codebuddy`; - const invocation = workBuddyInvocation({ HOME: "/Users/test" }, false, { - platform: "darwin", - isExecutable: (candidate) => [executable, cli].includes(candidate), + it("finds a renamed macOS bundle left behind by an app update migration", async () => { + const { macWorkBuddyAppBundleDirectories } = await import("../src/discovery.js"); + const directories = [ + "/Applications", + "/Applications/WorkBuddy.app", + "/Applications/WorkBuddy.app/Contents/MacOS", + "/Applications/WorkBuddy-6.0.0.app", + "/Applications/WorkBuddy-6.0.0.app/Contents/MacOS", + ]; + const files = [ + "/Applications/WorkBuddy.app/Contents/Info.plist", + "/Applications/WorkBuddy-6.0.0.app/Contents/Info.plist", + ]; + const statSync = ((candidate: string) => { + if (files.includes(candidate) || directories.includes(candidate)) + return { mtimeMs: candidate === "/Applications/WorkBuddy-6.0.0.app" ? 200 : 100 }; + throw new Error("ENOENT"); + }) as unknown as MacWorkBuddyScanFunctions["statSync"]; + const readdirSync = ((root: string) => { + if (root !== "/Applications") throw new Error("ENOENT"); + return ["WorkBuddy.app", "WorkBuddy-6.0.0.app", "Other.app"]; + }) as unknown as MacWorkBuddyScanFunctions["readdirSync"]; + + expect(macWorkBuddyAppBundleDirectories("win32", { readdirSync, statSync })).toEqual([]); + // Canonical name wins over the renamed update-migration copy. + expect(macWorkBuddyAppBundleDirectories("darwin", { readdirSync, statSync })).toEqual([ + "/Applications/WorkBuddy.app", + "/Applications/WorkBuddy-6.0.0.app", + ]); + // A missing canonical bundle leaves the renamed copy on top. + const onlyRenamed = macWorkBuddyAppBundleDirectories("darwin", { + readdirSync: ((root: string) => + root === "/Applications" + ? ["WorkBuddy-6.0.0.app"] + : (() => { + throw new Error("ENOENT"); + })()) as unknown as MacWorkBuddyScanFunctions["readdirSync"], + statSync, }); - expect(invocation.command).toBe(executable); - expect(invocation.arguments).toEqual([cli, "--acp"]); + expect(onlyRenamed).toEqual(["/Applications/WorkBuddy-6.0.0.app"]); + }); + + it("resolves a renamed bundle end-to-end when the fixed roots miss it", async () => { + const { resolveWorkBuddyBundle } = await import("../src/discovery.js"); + const renamed = "/Applications/WorkBuddy-6.0.0.app"; + const executable = `${renamed}/Contents/MacOS/Electron`; + const cli = `${renamed}/Contents/Resources/app.asar.unpacked/cli/bin/codebuddy`; + const statSync = ((candidate: string) => { + if ( + candidate === renamed || + candidate === `${renamed}/Contents/Info.plist` || + candidate === `${renamed}/Contents/MacOS` + ) + return { mtimeMs: 200 }; + throw new Error("ENOENT"); + }) as unknown as MacWorkBuddyScanFunctions["statSync"]; + const readdirSync = ((root: string) => { + if (root !== "/Applications") throw new Error("ENOENT"); + return ["WorkBuddy-6.0.0.app"]; + }) as unknown as MacWorkBuddyScanFunctions["readdirSync"]; + + const bundle = resolveWorkBuddyBundle( + { HOME: "/Users/test" }, + "darwin", + (candidate) => candidate === executable || candidate === cli, + undefined, + { readdirSync, statSync }, + ); + expect(bundle).toEqual({ executable, cli }); }); it("never combines an app executable with another installation's CLI", () => { diff --git a/packages/adapters/workbuddy/test/product-models.test.ts b/packages/adapters/workbuddy/test/product-models.test.ts index ec9e99dea..5776e20d9 100644 --- a/packages/adapters/workbuddy/test/product-models.test.ts +++ b/packages/adapters/workbuddy/test/product-models.test.ts @@ -48,4 +48,47 @@ describe("WorkBuddy product Model metadata", () => { ], }); }); + + it("falls back to the desktop app's cached product config when no env or bundle file exists", async () => { + const { loadWorkBuddyProductModels } = await import("../src/product-models.js"); + const { mkdtemp, mkdir, writeFile, chmod } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const path = await import("node:path"); + + const home = await mkdtemp(path.join(tmpdir(), "codexhost-wb-cache-")); + const cache = path.join(home, ".workbuddy", "cache"); + await mkdir(cache, { recursive: true }); + const snapshot = path.join(cache, "acc-product-config-v3.json"); + await writeFile( + snapshot, + JSON.stringify({ + agents: [{ name: "cli", models: ["glm-5.3"] }], + models: [{ id: "glm-5.3", name: "GLM-5.3", credits: "x1 credits" }], + }), + { mode: 0o600 }, + ); + + const originalHome = process.env.HOME; + const originalXdg = process.env.WORKBUDDY_CONFIG_DIR; + try { + process.env.HOME = home; + delete process.env.WORKBUDDY_CONFIG_DIR; + // The invocation factory reads real env; no ACC_* variables are set, so + // the loader falls through to the app-side cache under $HOME/.workbuddy. + // workBuddyInvocation throws notInstalled without an app installed, and + // the loader must surface that as "no product models", not a crash. + const models = await loadWorkBuddyProductModels(process.env); + // On a machine with WorkBuddy installed the loader now reads the fake + // cache; without the app it returns []. Both are acceptable: the point + // is that it never throws and never reads an unrelated file. + expect(Array.isArray(models)).toBe(true); + await chmod(snapshot, 0o644); // world-readable: must be rejected by permission checks + const rejected = await loadWorkBuddyProductModels(process.env); + expect(Array.isArray(rejected)).toBe(true); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalXdg !== undefined) process.env.WORKBUDDY_CONFIG_DIR = originalXdg; + } + }); }); diff --git a/packages/adapters/workbuddy/test/workbuddy-adapter.test.ts b/packages/adapters/workbuddy/test/workbuddy-adapter.test.ts index 0c7f0a43b..bdfd54268 100644 --- a/packages/adapters/workbuddy/test/workbuddy-adapter.test.ts +++ b/packages/adapters/workbuddy/test/workbuddy-adapter.test.ts @@ -58,6 +58,45 @@ function fakeFactory(): CodeBuddyClientFactory { }) satisfies CodeBuddyClient; } +function unauthenticatedFactory(authenticate: CodeBuddyClient["authenticate"] = async () => ({})) { + const state = { attempts: 0, authenticated: false }; + const authCalls: string[] = []; + const factory: CodeBuddyClientFactory = () => { + state.attempts += 1; + return { + initialize: async () => ({ protocolVersion: 1 }), + open: async () => { + // The first client (the inspection probe) is logged out; after a + // successful authenticate the retry sees a logged-in agent. + if (!state.authenticated) + throw new CodeBuddyError("authenticationRequired", "Authentication required"); + return { sessionId: "workbuddy-native", configOptions }; + }, + ...(authenticate + ? { + authenticate: async (params: { methodId: string }) => { + authCalls.push(params.methodId); + await authenticate(params); + state.authenticated = true; + return {}; + }, + } + : {}), + configure: async (_sessionId, id, value) => ({ + configOptions: configOptions.map((option) => ({ + ...option, + currentValue: option.id === id ? value : option.currentValue, + })), + }), + prompt: async () => ({ stopReason: "end_turn" }), + cancel: async () => {}, + answer: async () => {}, + close: async () => {}, + } satisfies CodeBuddyClient; + }; + return { factory, authCalls, clientCount: () => state.attempts, state }; +} + describe("WorkBuddy Adapter identity", () => { it.each(["notification", "explicit refresh"])( "publishes persisted requests before Turn completion on %s", @@ -274,7 +313,7 @@ describe("WorkBuddy Adapter identity", () => { }); }); - it("keeps macOS on the ACP catalog and rejects Models omitted by ACP", async () => { + it("merges WorkBuddy product-file Models into the macOS ACP catalog too", async () => { const adapter = new WorkBuddyAdapter({ platform: "darwin", clientFactory: fakeFactory(), @@ -284,8 +323,9 @@ describe("WorkBuddy Adapter identity", () => { expect(await adapter.inspect({ cwd: process.cwd() })).toMatchObject({ status: "ready", - catalog: { models: [{ label: "Native Model" }] }, + catalog: { models: [{ label: "Native Model" }, { label: "GLM-5.2" }] }, }); + // Product-file Models stay selectable even when ACP omits them. expect( await adapter.open({ kind: "create", @@ -294,9 +334,62 @@ describe("WorkBuddy Adapter identity", () => { model: modelRef("glm-5.2"), }), ).toMatchObject({ - ok: false, - error: { code: "invalidRequest" }, + ok: true, + value: { + initialState: { + effectiveModel: modelRef("glm-5.2"), + resolvedModelLabel: "glm-5.2", + }, + }, + }); + }); + + it("recovers from logged-out inspection with a silent native authenticate", async () => { + const { factory, authCalls } = unauthenticatedFactory(); + const adapter = new WorkBuddyAdapter({ + clientFactory: factory, + // Environment-independent: no real app cache may leak into the assertion. + productModels: async () => [], + }); + adapters.push(adapter); + + expect(await adapter.inspect({ cwd: process.cwd() })).toMatchObject({ + status: "ready", + catalog: { models: [{ label: "Native Model" }] }, + }); + expect(authCalls).toEqual(["internal"]); + }); + + it("keeps the original error when silent authentication fails", async () => { + const { factory } = unauthenticatedFactory(async () => { + throw new CodeBuddyError("authenticationRequired", "Authentication required"); + }); + const adapter = new WorkBuddyAdapter({ clientFactory: factory }); + adapters.push(adapter); + + expect(await adapter.inspect({ cwd: process.cwd() })).toMatchObject({ + status: "unavailable", + error: { code: "authenticationRequired" }, + }); + }); + + it("does not authenticate when inspection fails for non-auth reasons", async () => { + const { factory, authCalls } = unauthenticatedFactory(); + const adapter = new WorkBuddyAdapter({ + clientFactory: () => ({ + ...factory(), + open: async () => { + throw new CodeBuddyError("nativeFailure", "boom"); + }, + }), + }); + adapters.push(adapter); + + expect(await adapter.inspect({ cwd: process.cwd() })).toMatchObject({ + status: "unavailable", + error: { code: "nativeFailure" }, }); + expect(authCalls).toEqual([]); }); it("rejects CodeBuddy refs instead of crossing Harness history", async () => { From c37841701a217e2ba9a924120e2a5bb99147d9cb Mon Sep 17 00:00:00 2001 From: Brave Date: Fri, 16 Oct 2026 09:34:44 +0800 Subject: [PATCH 2/3] fix(workbuddy): lstat the app-cache fallback; make its test machine-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit appCacheProductPath used stat(), which follows symlinks, so the isSymbolicLink() guards never fired and the no-symlink rule inherited from bundledProductConfigPath (which uses lstatSync) was dead code. Switch to lstat(). The fallback test previously drove loadWorkBuddyProductModels(), whose first step is workBuddyInvocation() — it throws notInstalled on machines without the desktop app, so CI failed on the new test before reaching the fallback. Test appCacheProductPath() directly instead: it is the unit that owns the fallback decision, and the full loader is already covered by the merge tests plus the caller-side catch. Also covers: platform gate, world-readable rejection, symlinked snapshot rejection, missing-file tolerance. --- .../adapters/workbuddy/src/product-models.ts | 10 ++-- .../workbuddy/test/product-models.test.ts | 46 +++++++++---------- 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/packages/adapters/workbuddy/src/product-models.ts b/packages/adapters/workbuddy/src/product-models.ts index 300817233..871267d02 100644 --- a/packages/adapters/workbuddy/src/product-models.ts +++ b/packages/adapters/workbuddy/src/product-models.ts @@ -1,4 +1,4 @@ -import { readFile, stat } from "node:fs/promises"; +import { readFile, lstat } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; import { modelRef } from "@codexhost/adapter-codebuddy"; @@ -68,7 +68,7 @@ export function parseWorkBuddyProductModels(value: unknown): WorkBuddyProductMod } async function readProductJson(file: string): Promise { - const metadata = await stat(file); + const metadata = await lstat(file); if (!metadata.isFile() || metadata.size <= 0 || metadata.size > MAX_PRODUCT_SNAPSHOT_BYTES) return; return JSON.parse(await readFile(file, "utf8")); @@ -88,7 +88,7 @@ function bundledFallbackProductPath(invocation: ReturnType { @@ -100,14 +100,14 @@ async function appCacheProductPath( try { const uid = process.getuid?.(); for (const directory of [root, paths.dirname(candidate)]) { - const metadata = await stat(directory); + const metadata = await lstat(directory); if (!metadata.isDirectory() || metadata.isSymbolicLink()) return; if (platform !== "win32") { if ((metadata.mode & 0o022) !== 0) return; if (uid !== undefined && metadata.uid !== uid) return; } } - const metadata = await stat(candidate); + const metadata = await lstat(candidate); if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size <= 0) return; if (platform !== "win32") { if ((metadata.mode & 0o077) !== 0) return; diff --git a/packages/adapters/workbuddy/test/product-models.test.ts b/packages/adapters/workbuddy/test/product-models.test.ts index 5776e20d9..93392881e 100644 --- a/packages/adapters/workbuddy/test/product-models.test.ts +++ b/packages/adapters/workbuddy/test/product-models.test.ts @@ -50,8 +50,8 @@ describe("WorkBuddy product Model metadata", () => { }); it("falls back to the desktop app's cached product config when no env or bundle file exists", async () => { - const { loadWorkBuddyProductModels } = await import("../src/product-models.js"); - const { mkdtemp, mkdir, writeFile, chmod } = await import("node:fs/promises"); + const { appCacheProductPath } = await import("../src/product-models.js"); + const { mkdtemp, mkdir, writeFile, chmod, symlink, rm } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); const path = await import("node:path"); @@ -68,27 +68,25 @@ describe("WorkBuddy product Model metadata", () => { { mode: 0o600 }, ); - const originalHome = process.env.HOME; - const originalXdg = process.env.WORKBUDDY_CONFIG_DIR; - try { - process.env.HOME = home; - delete process.env.WORKBUDDY_CONFIG_DIR; - // The invocation factory reads real env; no ACC_* variables are set, so - // the loader falls through to the app-side cache under $HOME/.workbuddy. - // workBuddyInvocation throws notInstalled without an app installed, and - // the loader must surface that as "no product models", not a crash. - const models = await loadWorkBuddyProductModels(process.env); - // On a machine with WorkBuddy installed the loader now reads the fake - // cache; without the app it returns []. Both are acceptable: the point - // is that it never throws and never reads an unrelated file. - expect(Array.isArray(models)).toBe(true); - await chmod(snapshot, 0o644); // world-readable: must be rejected by permission checks - const rejected = await loadWorkBuddyProductModels(process.env); - expect(Array.isArray(rejected)).toBe(true); - } finally { - if (originalHome === undefined) delete process.env.HOME; - else process.env.HOME = originalHome; - if (originalXdg !== undefined) process.env.WORKBUDDY_CONFIG_DIR = originalXdg; - } + const environment = { HOME: home }; + // A tight snapshot in a tight config root resolves. + expect(await appCacheProductPath(environment, "darwin")).toBe(snapshot); + expect(await appCacheProductPath(environment, "linux")).toBeUndefined(); + + // World-readable snapshot: same rejection rule as the invocation-side loader. + await chmod(snapshot, 0o644); + expect(await appCacheProductPath(environment, "darwin")).toBeUndefined(); + await chmod(snapshot, 0o600); + + // A symlinked snapshot (or symlinked config root) is never followed. + const elsewhere = path.join(home, "elsewhere.json"); + await writeFile(elsewhere, "clone", { mode: 0o600 }); + await rm(snapshot); + await symlink(elsewhere, snapshot); + expect(await appCacheProductPath(environment, "darwin")).toBeUndefined(); + + // Missing files are not an error — just no fallback. + await rm(snapshot); + expect(await appCacheProductPath(environment, "darwin")).toBeUndefined(); }); }); From 5a45707a323dcaa13c94b4238ef074f9cf8f5f9f Mon Sep 17 00:00:00 2001 From: Brave Date: Fri, 16 Oct 2026 09:46:19 +0800 Subject: [PATCH 3/3] fix(workbuddy): cross-platform typecheck and test fixes from CI Two CI-only failures, both absent locally: - workbuddy-adapter.test.ts spread factory() without the CodeBuddyClientFactory argument (TS2554 under the root tests tsconfig; the package-level build did not catch it). Forward the clientFactory options through instead. - The app-cache fallback test asserted a chmod-tight snapshot, but chmod is a no-op on Windows runners, so the darwin permission path can never see a 0600 file there. Gate the POSIX assertions with it.runIf (same pattern as history-derivation.test.ts) and add a platform-gate assertion that runs everywhere. --- .../workbuddy/test/product-models.test.ts | 80 +++++++++++-------- .../workbuddy/test/workbuddy-adapter.test.ts | 4 +- 2 files changed, 48 insertions(+), 36 deletions(-) diff --git a/packages/adapters/workbuddy/test/product-models.test.ts b/packages/adapters/workbuddy/test/product-models.test.ts index 93392881e..601a42a2f 100644 --- a/packages/adapters/workbuddy/test/product-models.test.ts +++ b/packages/adapters/workbuddy/test/product-models.test.ts @@ -49,44 +49,56 @@ describe("WorkBuddy product Model metadata", () => { }); }); - it("falls back to the desktop app's cached product config when no env or bundle file exists", async () => { - const { appCacheProductPath } = await import("../src/product-models.js"); - const { mkdtemp, mkdir, writeFile, chmod, symlink, rm } = await import("node:fs/promises"); - const { tmpdir } = await import("node:os"); - const path = await import("node:path"); + // Windows runners cannot produce a chmod-tight file (chmod is a no-op there), + // so the POSIX permission assertions only hold on real POSIX filesystems. + // Same split as history-derivation.test.ts. + it.runIf(process.platform !== "win32")( + "falls back to the desktop app's cached product config when no env or bundle file exists", + async () => { + const { appCacheProductPath } = await import("../src/product-models.js"); + const { mkdtemp, mkdir, writeFile, chmod, symlink, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const path = await import("node:path"); - const home = await mkdtemp(path.join(tmpdir(), "codexhost-wb-cache-")); - const cache = path.join(home, ".workbuddy", "cache"); - await mkdir(cache, { recursive: true }); - const snapshot = path.join(cache, "acc-product-config-v3.json"); - await writeFile( - snapshot, - JSON.stringify({ - agents: [{ name: "cli", models: ["glm-5.3"] }], - models: [{ id: "glm-5.3", name: "GLM-5.3", credits: "x1 credits" }], - }), - { mode: 0o600 }, - ); + const home = await mkdtemp(path.join(tmpdir(), "codexhost-wb-cache-")); + const cache = path.join(home, ".workbuddy", "cache"); + await mkdir(cache, { recursive: true }); + const snapshot = path.join(cache, "acc-product-config-v3.json"); + await writeFile( + snapshot, + JSON.stringify({ + agents: [{ name: "cli", models: ["glm-5.3"] }], + models: [{ id: "glm-5.3", name: "GLM-5.3", credits: "x1 credits" }], + }), + { mode: 0o600 }, + ); - const environment = { HOME: home }; - // A tight snapshot in a tight config root resolves. - expect(await appCacheProductPath(environment, "darwin")).toBe(snapshot); - expect(await appCacheProductPath(environment, "linux")).toBeUndefined(); + const environment = { HOME: home }; + // A tight snapshot in a tight config root resolves. + expect(await appCacheProductPath(environment, "darwin")).toBe(snapshot); + expect(await appCacheProductPath(environment, "linux")).toBeUndefined(); - // World-readable snapshot: same rejection rule as the invocation-side loader. - await chmod(snapshot, 0o644); - expect(await appCacheProductPath(environment, "darwin")).toBeUndefined(); - await chmod(snapshot, 0o600); + // World-readable snapshot: same rejection rule as the invocation-side loader. + await chmod(snapshot, 0o644); + expect(await appCacheProductPath(environment, "darwin")).toBeUndefined(); + await chmod(snapshot, 0o600); - // A symlinked snapshot (or symlinked config root) is never followed. - const elsewhere = path.join(home, "elsewhere.json"); - await writeFile(elsewhere, "clone", { mode: 0o600 }); - await rm(snapshot); - await symlink(elsewhere, snapshot); - expect(await appCacheProductPath(environment, "darwin")).toBeUndefined(); + // A symlinked snapshot (or symlinked config root) is never followed. + const elsewhere = path.join(home, "elsewhere.json"); + await writeFile(elsewhere, "clone", { mode: 0o600 }); + await rm(snapshot); + await symlink(elsewhere, snapshot); + expect(await appCacheProductPath(environment, "darwin")).toBeUndefined(); - // Missing files are not an error — just no fallback. - await rm(snapshot); - expect(await appCacheProductPath(environment, "darwin")).toBeUndefined(); + // Missing files are not an error — just no fallback. + await rm(snapshot); + expect(await appCacheProductPath(environment, "darwin")).toBeUndefined(); + }, + ); + + it("ignores the app cache outside darwin and win32 regardless of file state", async () => { + const { appCacheProductPath } = await import("../src/product-models.js"); + // Platform gate is pure logic — safe to assert on every runner. + expect(await appCacheProductPath({ HOME: "/nonexistent/nothing" }, "linux")).toBeUndefined(); }); }); diff --git a/packages/adapters/workbuddy/test/workbuddy-adapter.test.ts b/packages/adapters/workbuddy/test/workbuddy-adapter.test.ts index bdfd54268..d1d203ae6 100644 --- a/packages/adapters/workbuddy/test/workbuddy-adapter.test.ts +++ b/packages/adapters/workbuddy/test/workbuddy-adapter.test.ts @@ -376,8 +376,8 @@ describe("WorkBuddy Adapter identity", () => { it("does not authenticate when inspection fails for non-auth reasons", async () => { const { factory, authCalls } = unauthenticatedFactory(); const adapter = new WorkBuddyAdapter({ - clientFactory: () => ({ - ...factory(), + clientFactory: (options) => ({ + ...factory(options), open: async () => { throw new CodeBuddyError("nativeFailure", "boom"); },