Repository navigation
fix(workbuddy): silent re-auth, update-resilient macOS discovery, model parity on all platforms #539
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
fix(workbuddy): silent re-auth, update-resilient macOS discovery, model parity on all platforms #539
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Omit<MacWorkBuddyScanDependencies, "roots">>; | ||
|
|
||
| const defaultScanDependencies = (): Required<Omit<MacWorkBuddyScanDependencies, "roots">> => ({ | ||
| 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<string, number>(); | ||
| 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 接受带括号的应用包名称。 如果应用包名为 🤖 Prompt for AI Agents |
||
| 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 []; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<readonly WorkBuddyProductModel[]>; | ||
| 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 WorkBuddyProductModel[]>; | ||
| readonly #authenticationTimeoutMs: number; | ||
| #authentication: Promise<boolean> | 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift 让 构造函数保存了该选项,但认证流程从未读取它。即使调用者设置较短的超时,原生 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| override async inspect(input: InspectHarnessInput = {}): Promise<HarnessInspection> { | ||
| 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<boolean> { | ||
| const attempt = this.#authentication ?? this.#authenticate(input); | ||
| this.#authentication = attempt.finally(() => { | ||
| if (this.#authentication === attempt) this.#authentication = undefined; | ||
| }); | ||
|
Comment on lines
+92
to
+94
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 修正认证 Promise 的清理条件。
🤖 Prompt for AI Agents |
||
| return attempt; | ||
| } | ||
|
|
||
| async #authenticate(input: InspectHarnessInput): Promise<boolean> { | ||
| 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<CodeBuddyAdapterOptions["clientFactory"]> { | ||
| return ( | ||
| this.options.clientFactory ?? | ||
| ((clientOptions) => new CodeBuddyAcpClient(clientOptions, undefined, workBuddyInvocation)) | ||
| ); | ||
| } | ||
|
|
||
| override async open(input: OpenSessionInput): Promise<HarnessResult<HarnessSession>> { | ||
| if ( | ||
| input.kind === "create" && | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
使用调用方的主目录扫描用户应用包。
如果
resolveWorkBuddyBundle收到的environment.HOME与进程主目录不同,homedir()会扫描错误的Applications目录。此时,调用方主目录中的重命名应用包无法被发现。请从resolveWorkBuddyBundle传入基于environment的扫描根目录,并保持直接调用扫描函数时的默认行为。🤖 Prompt for AI Agents