From 0ea41378a43591d1b838df1f2c6cd5f569fe19f6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 28 May 2026 00:54:43 +0800 Subject: [PATCH] Fix provider binary diagnostics for command overrides (#1191) * fix: align provider binary diagnostics * Fix provider launch tests on Windows * Separate provider launch availability checks --- .../agent/provider-launch-config.test.ts | 175 +++++++++++++++++- .../server/agent/provider-launch-config.ts | 167 ++++++++++++----- .../src/server/agent/providers/acp-agent.ts | 30 +-- .../server/agent/providers/claude/agent.ts | 97 ++++------ .../agent/providers/codex-app-server-agent.ts | 71 ++++--- .../agent/providers/copilot-acp-agent.ts | 26 +-- .../agent/providers/diagnostic-utils.ts | 64 ++++++- .../agent/providers/generic-acp-agent.ts | 51 +++-- .../server/agent/providers/opencode-agent.ts | 55 +++--- .../src/server/agent/providers/pi/agent.ts | 37 ++-- 10 files changed, 532 insertions(+), 241 deletions(-) diff --git a/packages/server/src/server/agent/provider-launch-config.test.ts b/packages/server/src/server/agent/provider-launch-config.test.ts index 42fc13c60..2fc4cf32e 100644 --- a/packages/server/src/server/agent/provider-launch-config.test.ts +++ b/packages/server/src/server/agent/provider-launch-config.test.ts @@ -1,25 +1,71 @@ -import { describe, expect, test, vi } from "vitest"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; import { + checkProviderLaunchAvailable, createProviderEnv, migrateProviderSettings, ProviderOverrideSchema, + resolveProviderLaunch, resolveProviderCommandPrefix, type ProviderRuntimeSettings, } from "./provider-launch-config.js"; +const originalPath = process.env.PATH; +const tempDirs: string[] = []; + +afterEach(() => { + process.env.PATH = originalPath; + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeTempDir(): string { + const dir = mkdtempSync(path.join(tmpdir(), "paseo-provider-launch-")); + tempDirs.push(dir); + return dir; +} + +interface TestExecutable { + command: string; + path: string; +} + +function createExecutable(dir: string, name: string, body = "echo test-version\n"): TestExecutable { + const command = process.platform === "win32" ? `${name}.cmd` : name; + const file = path.join(dir, command); + const contents = process.platform === "win32" ? "@echo test-version\r\n" : `#!/bin/sh\n${body}`; + writeFileSync(file, contents, "utf8"); + chmodSync(file, 0o755); + return { + command, + path: file, + }; +} + describe("resolveProviderCommandPrefix", () => { test("uses resolved default command in default mode", async () => { - const resolveDefault = vi.fn(() => "/usr/local/bin/claude"); + let calls = 0; + const resolveDefault = () => { + calls += 1; + return "/usr/local/bin/claude"; + }; const resolved = await resolveProviderCommandPrefix(undefined, resolveDefault); - expect(resolveDefault).toHaveBeenCalledTimes(1); + expect(calls).toBe(1); expect(resolved).toEqual({ command: "/usr/local/bin/claude", args: [] }); }); test("appends args in append mode", async () => { - const resolveDefault = vi.fn(() => "/usr/local/bin/claude"); + let calls = 0; + const resolveDefault = () => { + calls += 1; + return "/usr/local/bin/claude"; + }; const resolved = await resolveProviderCommandPrefix( { @@ -29,7 +75,7 @@ describe("resolveProviderCommandPrefix", () => { resolveDefault, ); - expect(resolveDefault).toHaveBeenCalledTimes(1); + expect(calls).toBe(1); expect(resolved).toEqual({ command: "/usr/local/bin/claude", args: ["--chrome"], @@ -37,7 +83,11 @@ describe("resolveProviderCommandPrefix", () => { }); test("replaces command in replace mode without resolving default", async () => { - const resolveDefault = vi.fn(() => "/usr/local/bin/claude"); + let calls = 0; + const resolveDefault = () => { + calls += 1; + return "/usr/local/bin/claude"; + }; const resolved = await resolveProviderCommandPrefix( { @@ -47,7 +97,7 @@ describe("resolveProviderCommandPrefix", () => { resolveDefault, ); - expect(resolveDefault).not.toHaveBeenCalled(); + expect(calls).toBe(0); expect(resolved).toEqual({ command: "docker", args: ["run", "--rm", "my-wrapper"], @@ -55,6 +105,117 @@ describe("resolveProviderCommandPrefix", () => { }); }); +describe("resolveProviderLaunch", () => { + test("uses replace override as the spawned command", async () => { + const binDir = makeTempDir(); + const shim = createExecutable(binDir, "custom-provider"); + + const launch = await resolveProviderLaunch({ + commandConfig: { mode: "replace", argv: [shim.command, "--wrapped"] }, + defaultBinary: "provider", + }); + + expect(launch).toEqual({ + command: shim.command, + args: ["--wrapped"], + source: "override", + }); + }); + + test("keeps an absolute replace override when the path exists", async () => { + const binDir = makeTempDir(); + const shim = createExecutable(binDir, "custom-provider", "exit 42\n"); + + const launch = await resolveProviderLaunch({ + commandConfig: { mode: "replace", argv: [shim.path, "--wrapped"] }, + defaultBinary: "provider", + }); + + expect(launch).toEqual({ + command: shim.path, + args: ["--wrapped"], + source: "override", + }); + }); + + test("resolves append mode through the default binary", async () => { + const binDir = makeTempDir(); + const binary = createExecutable(binDir, "default-provider"); + process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`; + + const launch = await resolveProviderLaunch({ + commandConfig: { mode: "append", args: ["--profile", "work"] }, + defaultBinary: binary.command, + }); + + expect(launch).toEqual({ + command: binary.command, + args: ["--profile", "work"], + source: "append", + }); + }); + + test("resolves the default binary when no override is configured", async () => { + const binDir = makeTempDir(); + const binary = createExecutable(binDir, "default-provider"); + process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`; + + const launch = await resolveProviderLaunch({ + defaultBinary: binary.command, + }); + + expect(launch).toEqual({ + command: binary.command, + args: [], + source: "default", + }); + }); + + test("keeps the default command when the default binary is missing", async () => { + process.env.PATH = makeTempDir(); + + const launch = await resolveProviderLaunch({ + defaultBinary: "paseo-provider-missing", + }); + + expect(launch).toEqual({ + command: "paseo-provider-missing", + args: [], + source: "default", + }); + }); +}); + +describe("checkProviderLaunchAvailable", () => { + test("reports available with a resolved path", async () => { + const binDir = makeTempDir(); + const binary = createExecutable(binDir, "default-provider"); + process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`; + + const launch = await resolveProviderLaunch({ + defaultBinary: binary.command, + }); + + await expect(checkProviderLaunchAvailable(launch)).resolves.toEqual({ + available: true, + resolvedPath: binary.path, + }); + }); + + test("reports missing override commands as unavailable", async () => { + process.env.PATH = makeTempDir(); + const launch = await resolveProviderLaunch({ + commandConfig: { mode: "replace", argv: ["paseo-provider-missing"] }, + defaultBinary: "provider", + }); + + await expect(checkProviderLaunchAvailable(launch)).resolves.toEqual({ + available: false, + resolvedPath: null, + }); + }); +}); + describe("createProviderEnv", () => { test("merges provider env overrides", () => { const base = { diff --git a/packages/server/src/server/agent/provider-launch-config.ts b/packages/server/src/server/agent/provider-launch-config.ts index 2ca2c2e1d..7272dd888 100644 --- a/packages/server/src/server/agent/provider-launch-config.ts +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -1,8 +1,7 @@ import { z } from "zod"; -import { execFileSync } from "node:child_process"; -import path from "node:path"; -import { isCommandAvailable } from "../../utils/executable.js"; +import { isAbsolute } from "node:path"; +import { executableExists, findExecutable } from "../../utils/executable.js"; import { createExternalProcessEnv, type ProcessEnvRecord } from "../paseo-env.js"; import type { AgentProvider } from "./agent-sdk-types.js"; import { AgentProviderSchema } from "./provider-manifest.js"; @@ -93,27 +92,121 @@ export interface ProviderCommandPrefix { args: string[]; } +export type ProviderLaunchSource = "default" | "append" | "override"; + +export interface ResolvedProviderLaunch { + command: string; + args: string[]; + source: ProviderLaunchSource; +} + +export interface ProviderLaunchAvailability { + available: boolean; + resolvedPath: string | null; +} + +export interface ProviderLaunchDefault { + command: string; + resolvePath?: () => Promise; +} + +function normalizeLaunchDefault( + defaultBinary: string | ProviderLaunchDefault, +): ProviderLaunchDefault { + if (typeof defaultBinary === "string") { + return { command: defaultBinary }; + } + return defaultBinary; +} + +async function resolveLaunchPath(command: string): Promise { + const found = await findExecutable(command); + if (found) { + return found; + } + if (isAbsolute(command)) { + return executableExists(command); + } + return null; +} + +async function resolveDefaultLaunchPath( + defaultBinary: ProviderLaunchDefault, +): Promise { + return defaultBinary.resolvePath + ? await defaultBinary.resolvePath() + : await resolveLaunchPath(defaultBinary.command); +} + +export interface ResolveProviderLaunchOptions { + commandConfig?: ProviderCommand; + defaultBinary?: string | ProviderLaunchDefault; +} + +export async function resolveProviderLaunch({ + commandConfig, + defaultBinary, +}: ResolveProviderLaunchOptions): Promise { + if (commandConfig?.mode === "replace") { + const command = commandConfig.argv[0]; + return { + command, + args: commandConfig.argv.slice(1), + source: "override", + }; + } + + if (defaultBinary === undefined) { + throw new Error("defaultBinary is required when provider command is not replaced"); + } + const normalizedDefault = normalizeLaunchDefault(defaultBinary); + const args = commandConfig?.mode === "append" ? [...(commandConfig.args ?? [])] : []; + return { + command: normalizedDefault.command, + args, + source: commandConfig?.mode === "append" ? "append" : "default", + }; +} + +export async function checkProviderLaunchAvailable( + launch: ResolvedProviderLaunch, + defaultBinary?: ProviderLaunchDefault, +): Promise { + const resolvedPath = + defaultBinary && launch.source !== "override" + ? await resolveDefaultLaunchPath(defaultBinary) + : await resolveLaunchPath(launch.command); + return { + available: resolvedPath !== null, + resolvedPath, + }; +} + export async function resolveProviderCommandPrefix( commandConfig: ProviderCommand | undefined, resolveDefaultCommand: () => string | Promise, ): Promise { - if (!commandConfig || commandConfig.mode === "default") { + if (commandConfig?.mode === "replace") { + const launch = await resolveProviderLaunch({ + commandConfig, + }); return { - command: await resolveDefaultCommand(), - args: [], - }; - } - - if (commandConfig.mode === "append") { - return { - command: await resolveDefaultCommand(), - args: [...(commandConfig.args ?? [])], + command: launch.command, + args: launch.args, }; } + const defaultCommand = await resolveDefaultCommand(); + const launch = await resolveProviderLaunch({ + commandConfig, + defaultBinary: { + command: defaultCommand, + resolvePath: async () => defaultCommand, + }, + }); return { - command: commandConfig.argv[0], - args: commandConfig.argv.slice(1), + command: launch.command, + args: launch.args, }; } @@ -213,39 +306,27 @@ export function createProviderEnv(options: ProviderEnvOptions = {}): NodeJS.Proc return createExternalProcessEnv(spec.baseEnv ?? process.env, spec.envOverlay); } -export function findExecutable(name: string): string | null { - const trimmed = name.trim(); - if (!trimmed) return null; - if (trimmed.includes("/") || trimmed.includes("\\")) { - try { - const { existsSync } = require("node:fs"); - return existsSync(trimmed) ? trimmed : null; - } catch { - return null; - } - } - try { - const cmd = process.platform === "win32" ? "where.exe" : "which"; - const result = execFileSync(cmd, [trimmed], { - encoding: "utf8", - env: createProviderEnv({ baseEnv: process.env }), - windowsHide: true, - }).trim(); - const lines = result.split(/\r?\n/).filter((l: string) => l.trim()); - const candidate = lines.at(-1)?.trim() ?? null; - return candidate && path.isAbsolute(candidate) ? candidate : null; - } catch { - return null; - } -} - export async function isProviderCommandAvailable( commandConfig: ProviderCommand | undefined, resolveDefaultCommand: () => string | Promise, ): Promise { try { - const prefix = await resolveProviderCommandPrefix(commandConfig, resolveDefaultCommand); - return isCommandAvailable(prefix.command); + if (commandConfig?.mode === "replace") { + const launch = await resolveProviderLaunch({ + commandConfig, + }); + const availability = await checkProviderLaunchAvailable(launch); + return availability.available; + } + + const defaultCommand = await resolveDefaultCommand(); + const defaultBinary = { + command: defaultCommand, + resolvePath: async () => defaultCommand, + }; + const launch = await resolveProviderLaunch({ commandConfig, defaultBinary }); + const availability = await checkProviderLaunchAvailable(launch, defaultBinary); + return availability.available; } catch { return false; } diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index a4d35216d..5b5f3657c 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -87,13 +87,13 @@ import { type ToolCallTimelineItem, } from "../agent-sdk-types.js"; import { + checkProviderLaunchAvailable, createProviderEnvSpec, - resolveProviderCommandPrefix, + resolveProviderLaunch, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js"; -import { findExecutable } from "../../../utils/executable.js"; import { platformShell, spawnProcess } from "../../../utils/spawn.js"; function assertChildWithPipes( @@ -862,13 +862,14 @@ export class ACPAgentClient implements AgentClient { } protected async resolveLaunchCommand(): Promise<{ command: string; args: string[] }> { - const resolved = await findExecutable(this.defaultCommand[0]); - const prefix = await resolveProviderCommandPrefix(this.runtimeSettings?.command, () => { - if (!resolved) { - throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`); - } - return resolved; + const prefix = await resolveProviderLaunch({ + commandConfig: this.runtimeSettings?.command, + defaultBinary: this.defaultCommand[0], }); + const availability = await checkProviderLaunchAvailable(prefix); + if (!availability.available) { + throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`); + } return { command: prefix.command, args: [...prefix.args, ...this.defaultCommand.slice(1)], @@ -1807,13 +1808,14 @@ export class ACPAgentSession implements AgentSession, ACPClient { } private async spawnProcess(): Promise { - const resolved = await findExecutable(this.defaultCommand[0]); - const prefix = await resolveProviderCommandPrefix(this.runtimeSettings?.command, () => { - if (!resolved) { - throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`); - } - return resolved; + const prefix = await resolveProviderLaunch({ + commandConfig: this.runtimeSettings?.command, + defaultBinary: this.defaultCommand[0], }); + const availability = await checkProviderLaunchAvailable(prefix); + if (!availability.available) { + throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`); + } const command = prefix.command; const args = [...prefix.args, ...this.defaultCommand.slice(1)]; diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 16b48868b..4538af2a1 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -33,6 +33,7 @@ import { getClaudeModelsWithSettings, normalizeClaudeRuntimeModelId } from "./mo import { parsePartialJsonObject } from "./partial-json.js"; import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; import { + buildBinaryDiagnosticRows, formatDiagnosticStatus, formatProviderDiagnostic, formatProviderDiagnosticError, @@ -75,11 +76,13 @@ import { type PersistedAgentDescriptor, } from "../../agent-sdk-types.js"; import { + checkProviderLaunchAvailable, createProviderEnv, createProviderEnvSpec, + resolveProviderLaunch, type ProviderRuntimeSettings, + type ResolvedProviderLaunch, } from "../../provider-launch-config.js"; -import { findExecutable, isCommandAvailable } from "../../../../utils/executable.js"; import { withTimeout } from "../../../../utils/promise-timeout.js"; import { execCommand } from "../../../../utils/spawn.js"; import { composeSystemPromptParts } from "../../system-prompt.js"; @@ -1335,19 +1338,25 @@ export class ClaudeAgentClient implements AgentClient { } async isAvailable(): Promise { - const command = this.runtimeSettings?.command; - if (command?.mode === "replace") { - return await isCommandAvailable(command.argv[0]); - } - return await isCommandAvailable("claude"); + const launch = await resolveProviderLaunch({ + commandConfig: this.runtimeSettings?.command, + defaultBinary: "claude", + }); + const availability = await checkProviderLaunchAvailable(launch); + return availability.available; } async getDiagnostic(): Promise<{ diagnostic: string }> { try { - const resolvedBinary = (await findExecutable("claude")) ?? "not found"; - const available = await this.isAvailable(); - const version = await resolveClaudeVersion(this.runtimeSettings); - const auth = available ? await resolveClaudeAuth(this.runtimeSettings) : null; + const launch = await resolveProviderLaunch({ + commandConfig: this.runtimeSettings?.command, + defaultBinary: "claude", + }); + const availability = await checkProviderLaunchAvailable(launch); + const available = availability.available; + const auth = available + ? await resolveClaudeAuth(launch, availability, this.runtimeSettings) + : null; let modelsValue = "Not checked"; let status = formatDiagnosticStatus(available); @@ -1369,8 +1378,7 @@ export class ClaudeAgentClient implements AgentClient { return { diagnostic: formatProviderDiagnostic("Claude Code", [ - { label: "Binary", value: resolvedBinary }, - ...(version ? [{ label: "Version", value: version }] : []), + ...(await buildBinaryDiagnosticRows(launch, availability)), ...(auth ? [{ label: "Auth", value: auth }] : []), { label: "Models", value: modelsValue }, { label: "Status", value: status }, @@ -1392,59 +1400,24 @@ export class ClaudeAgentClient implements AgentClient { } async function resolveClaudeBinary(runtimeSettings?: ProviderRuntimeSettings): Promise { - const command = runtimeSettings?.command; - if (command?.mode === "replace") { - const foundOverride = await findExecutable(command.argv[0]); - if (foundOverride) { - return foundOverride; - } - } - - const found = await findExecutable("claude"); - if (found) { - return found; + const launch = await resolveProviderLaunch({ + commandConfig: runtimeSettings?.command, + defaultBinary: "claude", + }); + const availability = await checkProviderLaunchAvailable(launch); + if (availability.available) { + return availability.resolvedPath ?? launch.command; } throw new Error( "Claude binary not found. Install Claude Code (https://github.com/anthropics/claude-code) and ensure it is available in your shell PATH.", ); } -async function resolveClaudeVersion( - runtimeSettings?: ProviderRuntimeSettings, -): Promise { - const command = runtimeSettings?.command; - const envSpec = createProviderEnvSpec({ runtimeSettings }); - - try { - if (command?.mode === "replace") { - const { stdout } = await execCommand( - command.argv[0], - [...command.argv.slice(1), "--version"], - { ...envSpec, timeout: 5_000 }, - ); - return stdout.trim() || null; - } - - const executable = await findExecutable("claude"); - if (!executable) { - return null; - } - - const { stdout } = await execCommand(executable, ["--version"], { - ...envSpec, - timeout: 5_000, - }); - return stdout.trim() || null; - } catch { - return null; - } -} - async function resolveClaudeAuth( + launch: ResolvedProviderLaunch, + availability: { resolvedPath: string | null }, runtimeSettings?: ProviderRuntimeSettings, ): Promise { - const command = runtimeSettings?.command; - const run = async ( executable: string, args: string[], @@ -1464,16 +1437,8 @@ async function resolveClaudeAuth( }; try { - let result: { stdout: string; stderr: string }; - if (command?.mode === "replace") { - result = await run(command.argv[0], [...command.argv.slice(1), "auth", "status"]); - } else { - const executable = await findExecutable("claude"); - if (!executable) { - return null; - } - result = await run(executable, ["auth", "status"]); - } + const executable = availability.resolvedPath ?? launch.command; + const result = await run(executable, [...launch.args, "auth", "status"]); const combined = [result.stdout, result.stderr] .map((s) => s.trim()) diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 13c3a8b72..78609f13f 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -48,12 +48,14 @@ import { mapCodexToolCallFromThreadItem, } from "./codex/tool-call-mapper.js"; import { + checkProviderLaunchAvailable, createProviderEnv, createProviderEnvSpec, - resolveProviderCommandPrefix, + resolveProviderLaunch, type ProviderRuntimeSettings, + type ResolvedProviderLaunch, } from "../provider-launch-config.js"; -import { findExecutable, isCommandAvailable, probeExecutable } from "../../../utils/executable.js"; +import { findExecutable, probeExecutable } from "../../../utils/executable.js"; import { createPathEquivalenceMatcher } from "../../../utils/path.js"; import { spawnProcess } from "../../../utils/spawn.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; @@ -78,6 +80,7 @@ import { formatDiagnosticStatus, formatProviderDiagnostic, formatProviderDiagnosticError, + buildBinaryDiagnosticRows, resolveBinaryVersion, toDiagnosticErrorMessage, } from "./diagnostic-utils.js"; @@ -441,21 +444,41 @@ export async function findDefaultCodexBinary(): Promise { return (await findExecutable("codex")) ?? (await findCodexMicrosoftStoreBinary()); } -async function resolveCodexBinary(): Promise { - const found = await findDefaultCodexBinary(); - if (found) { - return found; - } - throw new Error( - "Codex binary not found. Install the Codex CLI (https://github.com/openai/codex) and ensure it is available in your shell PATH.", - ); -} - async function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSettings): Promise<{ command: string; args: string[]; }> { - return resolveProviderCommandPrefix(runtimeSettings?.command, resolveCodexBinary); + const launch = await resolveCodexLaunch(runtimeSettings); + const availability = await checkCodexLaunchAvailable(launch); + if (!availability.available) { + throw new Error( + "Codex binary not found. Install the Codex CLI (https://github.com/openai/codex) and ensure it is available in your shell PATH.", + ); + } + return { + command: + launch.source === "override" ? launch.command : (availability.resolvedPath ?? launch.command), + args: launch.args, + }; +} + +async function resolveCodexLaunch( + runtimeSettings?: ProviderRuntimeSettings, +): Promise { + return resolveProviderLaunch({ + commandConfig: runtimeSettings?.command, + defaultBinary: { + command: "codex", + resolvePath: findDefaultCodexBinary, + }, + }); +} + +async function checkCodexLaunchAvailable(launch: ResolvedProviderLaunch) { + return checkProviderLaunchAvailable(launch, { + command: "codex", + resolvePath: findDefaultCodexBinary, + }); } function resolveCodexHomeDir(): string { @@ -5570,26 +5593,18 @@ export class CodexAppServerAgentClient implements AgentClient { } async isAvailable(): Promise { - const command = this.runtimeSettings?.command; - if (command?.mode === "replace") { - return await isCommandAvailable(command.argv[0]); - } - return (await findDefaultCodexBinary()) !== null; + const launch = await resolveCodexLaunch(this.runtimeSettings); + const availability = await checkCodexLaunchAvailable(launch); + return availability.available; } async getDiagnostic(): Promise<{ diagnostic: string }> { try { - const available = await this.isAvailable(); - const resolvedBinary = await findDefaultCodexBinary(); + const launch = await resolveCodexLaunch(this.runtimeSettings); + const availability = await checkCodexLaunchAvailable(launch); + const available = availability.available; const entries: Array<{ label: string; value: string }> = [ - { - label: "Binary", - value: resolvedBinary ?? "not found", - }, - { - label: "Version", - value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown", - }, + ...(await buildBinaryDiagnosticRows(launch, availability)), ]; let status = formatDiagnosticStatus(available); diff --git a/packages/server/src/server/agent/providers/copilot-acp-agent.ts b/packages/server/src/server/agent/providers/copilot-acp-agent.ts index 2c3122dd3..d6a8fadf9 100644 --- a/packages/server/src/server/agent/providers/copilot-acp-agent.ts +++ b/packages/server/src/server/agent/providers/copilot-acp-agent.ts @@ -3,8 +3,11 @@ import { homedir } from "node:os"; import type { SessionConfigOption } from "@agentclientprotocol/sdk"; import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js"; -import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; -import { findExecutable } from "../../../utils/executable.js"; +import { + checkProviderLaunchAvailable, + resolveProviderLaunch, + type ProviderRuntimeSettings, +} from "../provider-launch-config.js"; import { ACPAgentClient, type ACPBeforeModeWriteResult, @@ -16,7 +19,7 @@ import { formatDiagnosticStatus, formatProviderDiagnostic, formatProviderDiagnosticError, - resolveBinaryVersion, + buildBinaryDiagnosticRows, toDiagnosticErrorMessage, } from "./diagnostic-utils.js"; @@ -88,8 +91,12 @@ export class CopilotACPAgentClient extends ACPAgentClient { async getDiagnostic(): Promise<{ diagnostic: string }> { try { - const available = await this.isAvailable(); - const resolvedBinary = await findExecutable("copilot"); + const launch = await resolveProviderLaunch({ + commandConfig: this.runtimeSettings?.command, + defaultBinary: "copilot", + }); + const availability = await checkProviderLaunchAvailable(launch); + const available = availability.available; let modelsValue = "Not checked"; let status = formatDiagnosticStatus(available); @@ -119,14 +126,7 @@ export class CopilotACPAgentClient extends ACPAgentClient { return { diagnostic: formatProviderDiagnostic("Copilot", [ - { - label: "Binary", - value: resolvedBinary ?? "not found", - }, - { - label: "Version", - value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown", - }, + ...(await buildBinaryDiagnosticRows(launch, availability)), { label: "Models", value: modelsValue }, { label: "Status", value: status }, ]), diff --git a/packages/server/src/server/agent/providers/diagnostic-utils.ts b/packages/server/src/server/agent/providers/diagnostic-utils.ts index 81778f86b..6d34d63ed 100644 --- a/packages/server/src/server/agent/providers/diagnostic-utils.ts +++ b/packages/server/src/server/agent/providers/diagnostic-utils.ts @@ -1,7 +1,12 @@ -import { createProviderEnvSpec, type ProviderRuntimeSettings } from "../provider-launch-config.js"; +import { + createProviderEnvSpec, + type ProviderLaunchAvailability, + type ProviderRuntimeSettings, + type ResolvedProviderLaunch, +} from "../provider-launch-config.js"; import { execCommand } from "../../../utils/spawn.js"; -interface DiagnosticEntry { +export interface DiagnosticEntry { label: string; value: string; } @@ -138,6 +143,61 @@ export async function resolveBinaryVersion(binaryPath: string): Promise } } +export interface BinaryDiagnosticVersionCommand { + command: string; + args: string[]; + env?: Record; +} + +export interface BinaryDiagnosticRowsOptions { + binaryLabel?: string; + versionCommand?: BinaryDiagnosticVersionCommand; +} + +async function resolveCommandVersion(invocation: BinaryDiagnosticVersionCommand): Promise { + try { + const { stdout, stderr } = await execCommand(invocation.command, invocation.args, { + ...createProviderEnvSpec({ runtimeSettings: { env: invocation.env } }), + timeout: 5_000, + }); + return stdout.trim() || stderr.trim() || "unknown"; + } catch (error) { + return `error: ${toDiagnosticErrorMessage(error)}`; + } +} + +export async function buildBinaryDiagnosticRows( + launch: ResolvedProviderLaunch, + availability: ProviderLaunchAvailability, + options: BinaryDiagnosticRowsOptions = {}, +): Promise { + const defaultBinaryLabel = launch.source === "override" ? "Binary (override)" : "Binary"; + const binaryLabel = options.binaryLabel ?? defaultBinaryLabel; + let version = "unknown"; + if (options.versionCommand && availability.available) { + version = await resolveCommandVersion(options.versionCommand); + } else if (availability.available) { + version = await resolveCommandVersion({ + command: availability.resolvedPath ?? launch.command, + args: [...launch.args, "--version"], + }); + } + return [ + { + label: binaryLabel, + value: launch.command, + }, + { + label: "Resolved path", + value: availability.resolvedPath ?? "not found", + }, + { + label: "Version", + value: version, + }, + ]; +} + export function formatConfiguredCommand( defaultArgv: readonly string[], runtimeSettings?: ProviderRuntimeSettings, diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.ts b/packages/server/src/server/agent/providers/generic-acp-agent.ts index 303f1e5d1..88d2c9525 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.ts @@ -1,10 +1,8 @@ import { homedir } from "node:os"; import type { Logger } from "pino"; -import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; -import { execCommand } from "../../../utils/spawn.js"; import type { AgentProvider } from "../agent-sdk-types.js"; -import { createProviderEnvSpec } from "../provider-launch-config.js"; +import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js"; import { ACPAgentClient, deriveModelDefinitionsFromACP, @@ -15,6 +13,7 @@ import { formatDiagnosticStatus, formatProviderDiagnostic, formatProviderDiagnosticError, + buildBinaryDiagnosticRows, toDiagnosticErrorMessage, } from "./diagnostic-utils.js"; @@ -61,15 +60,18 @@ export class GenericACPAgentClient extends ACPAgentClient { } override async isAvailable(): Promise { - return isCommandAvailable(this.command[0]); + const launch = await this.resolveConfiguredLaunch(); + const availability = await checkProviderLaunchAvailable(launch); + return availability.available; } async getDiagnostic(): Promise<{ diagnostic: string }> { const providerName = formatProviderName(this.label, this.providerId); try { - const resolvedBinary = await findExecutable(this.command[0]); - const available = resolvedBinary !== null; + const launch = await this.resolveConfiguredLaunch(); + const availability = await checkProviderLaunchAvailable(launch); + const available = availability.available; const versionProbe = buildVersionProbeCommand(this.command); const probeResult = available ? await this.runDiagnosticACPProbe() @@ -85,17 +87,18 @@ export class GenericACPAgentClient extends ACPAgentClient { diagnostic: formatProviderDiagnostic(providerName, [ { label: "Provider ID", value: this.providerId ?? "unknown" }, { label: "Configured command", value: this.command.join(" ") }, - { label: "Launcher binary", value: resolvedBinary ?? "not found" }, + ...(await buildBinaryDiagnosticRows(launch, availability, { + binaryLabel: "Launcher binary", + versionCommand: { + command: versionProbe.command, + args: versionProbe.args, + env: this.runtimeSettings?.env, + }, + })), { label: "Version command", value: formatCommand(versionProbe.command, versionProbe.args), }, - { - label: "Version", - value: resolvedBinary - ? await resolveCommandVersion(versionProbe, this.runtimeSettings?.env) - : "unknown", - }, { label: "ACP initialize", value: probeResult.initialize }, { label: "ACP session/new", value: probeResult.session }, { label: "Models", value: probeResult.models }, @@ -110,6 +113,13 @@ export class GenericACPAgentClient extends ACPAgentClient { } } + private async resolveConfiguredLaunch() { + return resolveProviderLaunch({ + commandConfig: { mode: "replace", argv: this.command }, + defaultBinary: this.command[0], + }); + } + private async runDiagnosticACPProbe(): Promise { let initializeValue = "Not checked"; let sessionValue = "Not checked"; @@ -277,21 +287,6 @@ function formatProbeError(currentValue: string, error: unknown): string { return `Error - ${toDiagnosticErrorMessage(error)}`; } -async function resolveCommandVersion( - invocation: CommandInvocation, - env: Record | undefined, -): Promise { - try { - const { stdout, stderr } = await execCommand(invocation.command, invocation.args, { - ...createProviderEnvSpec({ runtimeSettings: { env } }), - timeout: 5_000, - }); - return stdout.trim() || stderr.trim() || "unknown"; - } catch (error) { - return `error: ${toDiagnosticErrorMessage(error)}`; - } -} - async function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { let timeout: ReturnType | null = null; try { diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 9192d943a..cdc5ca08a 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -10,7 +10,6 @@ import { type Session as OpenCodeSession, type TextPartInput as OpenCodeTextPartInput, } from "@opencode-ai/sdk/v2/client"; -import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; import { createPathEquivalenceMatcher } from "../../../utils/path.js"; import type { Logger } from "pino"; import { z } from "zod"; @@ -46,7 +45,12 @@ import { type ToolCallDetail, type ToolCallTimelineItem, } from "../agent-sdk-types.js"; -import { createProviderEnvSpec, type ProviderRuntimeSettings } from "../provider-launch-config.js"; +import { + checkProviderLaunchAvailable, + createProviderEnvSpec, + resolveProviderLaunch, + type ProviderRuntimeSettings, +} from "../provider-launch-config.js"; import { withTimeout } from "../../../utils/promise-timeout.js"; import { execCommand } from "../../../utils/spawn.js"; import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js"; @@ -56,7 +60,7 @@ import { formatDiagnosticStatus, formatProviderDiagnostic, formatProviderDiagnosticError, - resolveBinaryVersion, + buildBinaryDiagnosticRows, toDiagnosticErrorMessage, } from "./diagnostic-utils.js"; import { runProviderTurn } from "./provider-runner.js"; @@ -1391,17 +1395,22 @@ export class OpenCodeAgentClient implements AgentClient { } async isAvailable(): Promise { - const command = this.runtimeSettings?.command; - if (command?.mode === "replace") { - return await isCommandAvailable(command.argv[0]); - } - return await isCommandAvailable("opencode"); + const launch = await resolveProviderLaunch({ + commandConfig: this.runtimeSettings?.command, + defaultBinary: "opencode", + }); + const availability = await checkProviderLaunchAvailable(launch); + return availability.available; } async getDiagnostic(): Promise<{ diagnostic: string }> { try { - const available = await this.isAvailable(); - const resolvedBinary = await findExecutable("opencode"); + const launch = await resolveProviderLaunch({ + commandConfig: this.runtimeSettings?.command, + defaultBinary: "opencode", + }); + const availability = await checkProviderLaunchAvailable(launch); + const available = availability.available; let serverStatus = "Not running"; let modelsValue = "Not checked"; let status = formatDiagnosticStatus(available); @@ -1414,12 +1423,19 @@ export class OpenCodeAgentClient implements AgentClient { } let authValue = "Not checked"; - if (resolvedBinary) { + const authCommand = availability.available + ? (availability.resolvedPath ?? launch.command) + : null; + if (authCommand) { try { - const { stdout, stderr } = await execCommand(resolvedBinary, ["auth", "list"], { - ...createProviderEnvSpec(), - timeout: 5_000, - }); + const { stdout, stderr } = await execCommand( + authCommand, + [...launch.args, "auth", "list"], + { + ...createProviderEnvSpec(), + timeout: 5_000, + }, + ); const text = (stdout.trim() || stderr.trim()).trim(); authValue = text ? `\n ${text.replace(/\n/g, "\n ")}` : "(empty)"; } catch (error) { @@ -1453,14 +1469,7 @@ export class OpenCodeAgentClient implements AgentClient { return { diagnostic: formatProviderDiagnostic("OpenCode", [ - { - label: "Binary", - value: resolvedBinary ?? "not found", - }, - { - label: "Version", - value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown", - }, + ...(await buildBinaryDiagnosticRows(launch, availability)), { label: "Server", value: serverStatus }, { label: "Auth", value: authValue }, { label: "Models", value: modelsValue }, diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index bf6de600f..ac350f50c 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -30,15 +30,19 @@ import { type PersistedAgentDescriptor, } from "../../agent-sdk-types.js"; import { runProviderTurn } from "../provider-runner.js"; -import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; +import { + checkProviderLaunchAvailable, + resolveProviderLaunch, + type ProviderRuntimeSettings, + type ResolvedProviderLaunch, +} from "../../provider-launch-config.js"; import { renderPromptAttachmentAsText } from "../../prompt-attachments.js"; import { composeSystemPromptParts } from "../../system-prompt.js"; -import { findExecutable } from "../../../../utils/executable.js"; import { + buildBinaryDiagnosticRows, formatDiagnosticStatus, formatProviderDiagnostic, formatProviderDiagnosticError, - resolveBinaryVersion, toDiagnosticErrorMessage, } from "../diagnostic-utils.js"; import { @@ -1484,8 +1488,9 @@ export class PiRpcAgentClient implements AgentClient { } async isAvailable(): Promise { - const binary = await this.resolvePiBinary(); - if (!binary) { + const launch = await this.resolvePiLaunch(); + const availability = await checkProviderLaunchAvailable(launch); + if (!availability.available) { return false; } const runtimeSession = await this.runtime.startSession({ cwd: homedir() }).catch(() => null); @@ -1503,16 +1508,16 @@ export class PiRpcAgentClient implements AgentClient { async getDiagnostic(): Promise<{ diagnostic: string }> { try { - const available = await this.isAvailable(); - const binary = await this.resolvePiBinary(); - const version = binary ? await resolveBinaryVersion(binary) : "unknown"; + const launch = await this.resolvePiLaunch(); + const availability = await checkProviderLaunchAvailable(launch); + const available = availability.available; const authConfigPath = join(homedir(), ".pi", "agent", "auth.json"); let modelsValue = "Not checked"; let configuredProvidersValue = "none"; let mcpToolsValue = "Not checked"; let status = formatDiagnosticStatus(available); - if (binary) { + if (availability.available) { const runtimeSession = await this.runtime .startSession({ cwd: homedir() }) .catch((error) => { @@ -1550,8 +1555,7 @@ export class PiRpcAgentClient implements AgentClient { return { diagnostic: formatProviderDiagnostic("Pi", [ - { label: "Binary", value: binary ?? "not found" }, - { label: "Version", value: version }, + ...(await buildBinaryDiagnosticRows(launch, availability)), { label: "Configured providers", value: configuredProvidersValue }, { label: "Auth config (~/.pi/agent/auth.json)", @@ -1601,11 +1605,10 @@ export class PiRpcAgentClient implements AgentClient { } } - private async resolvePiBinary(): Promise { - const command = this.runtimeSettings?.command; - if (command?.mode === "replace" && command.argv[0]) { - return await findExecutable(command.argv[0]); - } - return await findExecutable(PI_BINARY_COMMAND); + private async resolvePiLaunch(): Promise { + return resolveProviderLaunch({ + commandConfig: this.runtimeSettings?.command, + defaultBinary: PI_BINARY_COMMAND, + }); } }