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..871267d02 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 { readFile, lstat } 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, @@ -66,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")); @@ -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 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 lstat(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..601a42a2f 100644 --- a/packages/adapters/workbuddy/test/product-models.test.ts +++ b/packages/adapters/workbuddy/test/product-models.test.ts @@ -48,4 +48,57 @@ describe("WorkBuddy product Model metadata", () => { ], }); }); + + // 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 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(); + }, + ); + + 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 0c7f0a43b..d1d203ae6 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: (options) => ({ + ...factory(options), + 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 () => {