Fix provider binary diagnostics for command overrides (#1191)

* fix: align provider binary diagnostics

* Fix provider launch tests on Windows

* Separate provider launch availability checks
This commit is contained in:
Mohamed Boudra
2026-05-28 00:54:43 +08:00
committed by GitHub
parent 00759e7994
commit 0ea41378a4
10 changed files with 532 additions and 241 deletions

View File

@@ -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 { import {
checkProviderLaunchAvailable,
createProviderEnv, createProviderEnv,
migrateProviderSettings, migrateProviderSettings,
ProviderOverrideSchema, ProviderOverrideSchema,
resolveProviderLaunch,
resolveProviderCommandPrefix, resolveProviderCommandPrefix,
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
} from "./provider-launch-config.js"; } 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", () => { describe("resolveProviderCommandPrefix", () => {
test("uses resolved default command in default mode", async () => { 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); const resolved = await resolveProviderCommandPrefix(undefined, resolveDefault);
expect(resolveDefault).toHaveBeenCalledTimes(1); expect(calls).toBe(1);
expect(resolved).toEqual({ command: "/usr/local/bin/claude", args: [] }); expect(resolved).toEqual({ command: "/usr/local/bin/claude", args: [] });
}); });
test("appends args in append mode", async () => { 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( const resolved = await resolveProviderCommandPrefix(
{ {
@@ -29,7 +75,7 @@ describe("resolveProviderCommandPrefix", () => {
resolveDefault, resolveDefault,
); );
expect(resolveDefault).toHaveBeenCalledTimes(1); expect(calls).toBe(1);
expect(resolved).toEqual({ expect(resolved).toEqual({
command: "/usr/local/bin/claude", command: "/usr/local/bin/claude",
args: ["--chrome"], args: ["--chrome"],
@@ -37,7 +83,11 @@ describe("resolveProviderCommandPrefix", () => {
}); });
test("replaces command in replace mode without resolving default", async () => { 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( const resolved = await resolveProviderCommandPrefix(
{ {
@@ -47,7 +97,7 @@ describe("resolveProviderCommandPrefix", () => {
resolveDefault, resolveDefault,
); );
expect(resolveDefault).not.toHaveBeenCalled(); expect(calls).toBe(0);
expect(resolved).toEqual({ expect(resolved).toEqual({
command: "docker", command: "docker",
args: ["run", "--rm", "my-wrapper"], 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", () => { describe("createProviderEnv", () => {
test("merges provider env overrides", () => { test("merges provider env overrides", () => {
const base = { const base = {

View File

@@ -1,8 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { execFileSync } from "node:child_process"; import { isAbsolute } from "node:path";
import path from "node:path"; import { executableExists, findExecutable } from "../../utils/executable.js";
import { isCommandAvailable } from "../../utils/executable.js";
import { createExternalProcessEnv, type ProcessEnvRecord } from "../paseo-env.js"; import { createExternalProcessEnv, type ProcessEnvRecord } from "../paseo-env.js";
import type { AgentProvider } from "./agent-sdk-types.js"; import type { AgentProvider } from "./agent-sdk-types.js";
import { AgentProviderSchema } from "./provider-manifest.js"; import { AgentProviderSchema } from "./provider-manifest.js";
@@ -93,27 +92,121 @@ export interface ProviderCommandPrefix {
args: string[]; 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<string | null>;
}
function normalizeLaunchDefault(
defaultBinary: string | ProviderLaunchDefault,
): ProviderLaunchDefault {
if (typeof defaultBinary === "string") {
return { command: defaultBinary };
}
return defaultBinary;
}
async function resolveLaunchPath(command: string): Promise<string | null> {
const found = await findExecutable(command);
if (found) {
return found;
}
if (isAbsolute(command)) {
return executableExists(command);
}
return null;
}
async function resolveDefaultLaunchPath(
defaultBinary: ProviderLaunchDefault,
): Promise<string | null> {
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<ResolvedProviderLaunch> {
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<ProviderLaunchAvailability> {
const resolvedPath =
defaultBinary && launch.source !== "override"
? await resolveDefaultLaunchPath(defaultBinary)
: await resolveLaunchPath(launch.command);
return {
available: resolvedPath !== null,
resolvedPath,
};
}
export async function resolveProviderCommandPrefix( export async function resolveProviderCommandPrefix(
commandConfig: ProviderCommand | undefined, commandConfig: ProviderCommand | undefined,
resolveDefaultCommand: () => string | Promise<string>, resolveDefaultCommand: () => string | Promise<string>,
): Promise<ProviderCommandPrefix> { ): Promise<ProviderCommandPrefix> {
if (!commandConfig || commandConfig.mode === "default") { if (commandConfig?.mode === "replace") {
const launch = await resolveProviderLaunch({
commandConfig,
});
return { return {
command: await resolveDefaultCommand(), command: launch.command,
args: [], args: launch.args,
};
}
if (commandConfig.mode === "append") {
return {
command: await resolveDefaultCommand(),
args: [...(commandConfig.args ?? [])],
}; };
} }
const defaultCommand = await resolveDefaultCommand();
const launch = await resolveProviderLaunch({
commandConfig,
defaultBinary: {
command: defaultCommand,
resolvePath: async () => defaultCommand,
},
});
return { return {
command: commandConfig.argv[0], command: launch.command,
args: commandConfig.argv.slice(1), args: launch.args,
}; };
} }
@@ -213,39 +306,27 @@ export function createProviderEnv(options: ProviderEnvOptions = {}): NodeJS.Proc
return createExternalProcessEnv(spec.baseEnv ?? process.env, spec.envOverlay); 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( export async function isProviderCommandAvailable(
commandConfig: ProviderCommand | undefined, commandConfig: ProviderCommand | undefined,
resolveDefaultCommand: () => string | Promise<string>, resolveDefaultCommand: () => string | Promise<string>,
): Promise<boolean> { ): Promise<boolean> {
try { try {
const prefix = await resolveProviderCommandPrefix(commandConfig, resolveDefaultCommand); if (commandConfig?.mode === "replace") {
return isCommandAvailable(prefix.command); 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 { } catch {
return false; return false;
} }

View File

@@ -87,13 +87,13 @@ import {
type ToolCallTimelineItem, type ToolCallTimelineItem,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import { import {
checkProviderLaunchAvailable,
createProviderEnvSpec, createProviderEnvSpec,
resolveProviderCommandPrefix, resolveProviderLaunch,
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
} from "../provider-launch-config.js"; } from "../provider-launch-config.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js"; import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import { findExecutable } from "../../../utils/executable.js";
import { platformShell, spawnProcess } from "../../../utils/spawn.js"; import { platformShell, spawnProcess } from "../../../utils/spawn.js";
function assertChildWithPipes( function assertChildWithPipes(
@@ -862,13 +862,14 @@ export class ACPAgentClient implements AgentClient {
} }
protected async resolveLaunchCommand(): Promise<{ command: string; args: string[] }> { protected async resolveLaunchCommand(): Promise<{ command: string; args: string[] }> {
const resolved = await findExecutable(this.defaultCommand[0]); const prefix = await resolveProviderLaunch({
const prefix = await resolveProviderCommandPrefix(this.runtimeSettings?.command, () => { commandConfig: this.runtimeSettings?.command,
if (!resolved) { defaultBinary: this.defaultCommand[0],
throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`);
}
return resolved;
}); });
const availability = await checkProviderLaunchAvailable(prefix);
if (!availability.available) {
throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`);
}
return { return {
command: prefix.command, command: prefix.command,
args: [...prefix.args, ...this.defaultCommand.slice(1)], args: [...prefix.args, ...this.defaultCommand.slice(1)],
@@ -1807,13 +1808,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
} }
private async spawnProcess(): Promise<SpawnedACPProcess> { private async spawnProcess(): Promise<SpawnedACPProcess> {
const resolved = await findExecutable(this.defaultCommand[0]); const prefix = await resolveProviderLaunch({
const prefix = await resolveProviderCommandPrefix(this.runtimeSettings?.command, () => { commandConfig: this.runtimeSettings?.command,
if (!resolved) { defaultBinary: this.defaultCommand[0],
throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`);
}
return resolved;
}); });
const availability = await checkProviderLaunchAvailable(prefix);
if (!availability.available) {
throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`);
}
const command = prefix.command; const command = prefix.command;
const args = [...prefix.args, ...this.defaultCommand.slice(1)]; const args = [...prefix.args, ...this.defaultCommand.slice(1)];

View File

@@ -33,6 +33,7 @@ import { getClaudeModelsWithSettings, normalizeClaudeRuntimeModelId } from "./mo
import { parsePartialJsonObject } from "./partial-json.js"; import { parsePartialJsonObject } from "./partial-json.js";
import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
import { import {
buildBinaryDiagnosticRows,
formatDiagnosticStatus, formatDiagnosticStatus,
formatProviderDiagnostic, formatProviderDiagnostic,
formatProviderDiagnosticError, formatProviderDiagnosticError,
@@ -75,11 +76,13 @@ import {
type PersistedAgentDescriptor, type PersistedAgentDescriptor,
} from "../../agent-sdk-types.js"; } from "../../agent-sdk-types.js";
import { import {
checkProviderLaunchAvailable,
createProviderEnv, createProviderEnv,
createProviderEnvSpec, createProviderEnvSpec,
resolveProviderLaunch,
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
type ResolvedProviderLaunch,
} from "../../provider-launch-config.js"; } from "../../provider-launch-config.js";
import { findExecutable, isCommandAvailable } from "../../../../utils/executable.js";
import { withTimeout } from "../../../../utils/promise-timeout.js"; import { withTimeout } from "../../../../utils/promise-timeout.js";
import { execCommand } from "../../../../utils/spawn.js"; import { execCommand } from "../../../../utils/spawn.js";
import { composeSystemPromptParts } from "../../system-prompt.js"; import { composeSystemPromptParts } from "../../system-prompt.js";
@@ -1335,19 +1338,25 @@ export class ClaudeAgentClient implements AgentClient {
} }
async isAvailable(): Promise<boolean> { async isAvailable(): Promise<boolean> {
const command = this.runtimeSettings?.command; const launch = await resolveProviderLaunch({
if (command?.mode === "replace") { commandConfig: this.runtimeSettings?.command,
return await isCommandAvailable(command.argv[0]); defaultBinary: "claude",
} });
return await isCommandAvailable("claude"); const availability = await checkProviderLaunchAvailable(launch);
return availability.available;
} }
async getDiagnostic(): Promise<{ diagnostic: string }> { async getDiagnostic(): Promise<{ diagnostic: string }> {
try { try {
const resolvedBinary = (await findExecutable("claude")) ?? "not found"; const launch = await resolveProviderLaunch({
const available = await this.isAvailable(); commandConfig: this.runtimeSettings?.command,
const version = await resolveClaudeVersion(this.runtimeSettings); defaultBinary: "claude",
const auth = available ? await resolveClaudeAuth(this.runtimeSettings) : null; });
const availability = await checkProviderLaunchAvailable(launch);
const available = availability.available;
const auth = available
? await resolveClaudeAuth(launch, availability, this.runtimeSettings)
: null;
let modelsValue = "Not checked"; let modelsValue = "Not checked";
let status = formatDiagnosticStatus(available); let status = formatDiagnosticStatus(available);
@@ -1369,8 +1378,7 @@ export class ClaudeAgentClient implements AgentClient {
return { return {
diagnostic: formatProviderDiagnostic("Claude Code", [ diagnostic: formatProviderDiagnostic("Claude Code", [
{ label: "Binary", value: resolvedBinary }, ...(await buildBinaryDiagnosticRows(launch, availability)),
...(version ? [{ label: "Version", value: version }] : []),
...(auth ? [{ label: "Auth", value: auth }] : []), ...(auth ? [{ label: "Auth", value: auth }] : []),
{ label: "Models", value: modelsValue }, { label: "Models", value: modelsValue },
{ label: "Status", value: status }, { label: "Status", value: status },
@@ -1392,59 +1400,24 @@ export class ClaudeAgentClient implements AgentClient {
} }
async function resolveClaudeBinary(runtimeSettings?: ProviderRuntimeSettings): Promise<string> { async function resolveClaudeBinary(runtimeSettings?: ProviderRuntimeSettings): Promise<string> {
const command = runtimeSettings?.command; const launch = await resolveProviderLaunch({
if (command?.mode === "replace") { commandConfig: runtimeSettings?.command,
const foundOverride = await findExecutable(command.argv[0]); defaultBinary: "claude",
if (foundOverride) { });
return foundOverride; const availability = await checkProviderLaunchAvailable(launch);
} if (availability.available) {
} return availability.resolvedPath ?? launch.command;
const found = await findExecutable("claude");
if (found) {
return found;
} }
throw new Error( 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.", "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<string | null> {
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( async function resolveClaudeAuth(
launch: ResolvedProviderLaunch,
availability: { resolvedPath: string | null },
runtimeSettings?: ProviderRuntimeSettings, runtimeSettings?: ProviderRuntimeSettings,
): Promise<string | null> { ): Promise<string | null> {
const command = runtimeSettings?.command;
const run = async ( const run = async (
executable: string, executable: string,
args: string[], args: string[],
@@ -1464,16 +1437,8 @@ async function resolveClaudeAuth(
}; };
try { try {
let result: { stdout: string; stderr: string }; const executable = availability.resolvedPath ?? launch.command;
if (command?.mode === "replace") { const result = await run(executable, [...launch.args, "auth", "status"]);
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 combined = [result.stdout, result.stderr] const combined = [result.stdout, result.stderr]
.map((s) => s.trim()) .map((s) => s.trim())

View File

@@ -48,12 +48,14 @@ import {
mapCodexToolCallFromThreadItem, mapCodexToolCallFromThreadItem,
} from "./codex/tool-call-mapper.js"; } from "./codex/tool-call-mapper.js";
import { import {
checkProviderLaunchAvailable,
createProviderEnv, createProviderEnv,
createProviderEnvSpec, createProviderEnvSpec,
resolveProviderCommandPrefix, resolveProviderLaunch,
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
type ResolvedProviderLaunch,
} from "../provider-launch-config.js"; } 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 { createPathEquivalenceMatcher } from "../../../utils/path.js";
import { spawnProcess } from "../../../utils/spawn.js"; import { spawnProcess } from "../../../utils/spawn.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
@@ -78,6 +80,7 @@ import {
formatDiagnosticStatus, formatDiagnosticStatus,
formatProviderDiagnostic, formatProviderDiagnostic,
formatProviderDiagnosticError, formatProviderDiagnosticError,
buildBinaryDiagnosticRows,
resolveBinaryVersion, resolveBinaryVersion,
toDiagnosticErrorMessage, toDiagnosticErrorMessage,
} from "./diagnostic-utils.js"; } from "./diagnostic-utils.js";
@@ -441,21 +444,41 @@ export async function findDefaultCodexBinary(): Promise<string | null> {
return (await findExecutable("codex")) ?? (await findCodexMicrosoftStoreBinary()); return (await findExecutable("codex")) ?? (await findCodexMicrosoftStoreBinary());
} }
async function resolveCodexBinary(): Promise<string> {
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<{ async function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSettings): Promise<{
command: string; command: string;
args: 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<ResolvedProviderLaunch> {
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 { function resolveCodexHomeDir(): string {
@@ -5570,26 +5593,18 @@ export class CodexAppServerAgentClient implements AgentClient {
} }
async isAvailable(): Promise<boolean> { async isAvailable(): Promise<boolean> {
const command = this.runtimeSettings?.command; const launch = await resolveCodexLaunch(this.runtimeSettings);
if (command?.mode === "replace") { const availability = await checkCodexLaunchAvailable(launch);
return await isCommandAvailable(command.argv[0]); return availability.available;
}
return (await findDefaultCodexBinary()) !== null;
} }
async getDiagnostic(): Promise<{ diagnostic: string }> { async getDiagnostic(): Promise<{ diagnostic: string }> {
try { try {
const available = await this.isAvailable(); const launch = await resolveCodexLaunch(this.runtimeSettings);
const resolvedBinary = await findDefaultCodexBinary(); const availability = await checkCodexLaunchAvailable(launch);
const available = availability.available;
const entries: Array<{ label: string; value: string }> = [ const entries: Array<{ label: string; value: string }> = [
{ ...(await buildBinaryDiagnosticRows(launch, availability)),
label: "Binary",
value: resolvedBinary ?? "not found",
},
{
label: "Version",
value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown",
},
]; ];
let status = formatDiagnosticStatus(available); let status = formatDiagnosticStatus(available);

View File

@@ -3,8 +3,11 @@ import { homedir } from "node:os";
import type { SessionConfigOption } from "@agentclientprotocol/sdk"; import type { SessionConfigOption } from "@agentclientprotocol/sdk";
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js"; import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; import {
import { findExecutable } from "../../../utils/executable.js"; checkProviderLaunchAvailable,
resolveProviderLaunch,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { import {
ACPAgentClient, ACPAgentClient,
type ACPBeforeModeWriteResult, type ACPBeforeModeWriteResult,
@@ -16,7 +19,7 @@ import {
formatDiagnosticStatus, formatDiagnosticStatus,
formatProviderDiagnostic, formatProviderDiagnostic,
formatProviderDiagnosticError, formatProviderDiagnosticError,
resolveBinaryVersion, buildBinaryDiagnosticRows,
toDiagnosticErrorMessage, toDiagnosticErrorMessage,
} from "./diagnostic-utils.js"; } from "./diagnostic-utils.js";
@@ -88,8 +91,12 @@ export class CopilotACPAgentClient extends ACPAgentClient {
async getDiagnostic(): Promise<{ diagnostic: string }> { async getDiagnostic(): Promise<{ diagnostic: string }> {
try { try {
const available = await this.isAvailable(); const launch = await resolveProviderLaunch({
const resolvedBinary = await findExecutable("copilot"); commandConfig: this.runtimeSettings?.command,
defaultBinary: "copilot",
});
const availability = await checkProviderLaunchAvailable(launch);
const available = availability.available;
let modelsValue = "Not checked"; let modelsValue = "Not checked";
let status = formatDiagnosticStatus(available); let status = formatDiagnosticStatus(available);
@@ -119,14 +126,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
return { return {
diagnostic: formatProviderDiagnostic("Copilot", [ diagnostic: formatProviderDiagnostic("Copilot", [
{ ...(await buildBinaryDiagnosticRows(launch, availability)),
label: "Binary",
value: resolvedBinary ?? "not found",
},
{
label: "Version",
value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown",
},
{ label: "Models", value: modelsValue }, { label: "Models", value: modelsValue },
{ label: "Status", value: status }, { label: "Status", value: status },
]), ]),

View File

@@ -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"; import { execCommand } from "../../../utils/spawn.js";
interface DiagnosticEntry { export interface DiagnosticEntry {
label: string; label: string;
value: string; value: string;
} }
@@ -138,6 +143,61 @@ export async function resolveBinaryVersion(binaryPath: string): Promise<string>
} }
} }
export interface BinaryDiagnosticVersionCommand {
command: string;
args: string[];
env?: Record<string, string>;
}
export interface BinaryDiagnosticRowsOptions {
binaryLabel?: string;
versionCommand?: BinaryDiagnosticVersionCommand;
}
async function resolveCommandVersion(invocation: BinaryDiagnosticVersionCommand): Promise<string> {
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<DiagnosticEntry[]> {
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( export function formatConfiguredCommand(
defaultArgv: readonly string[], defaultArgv: readonly string[],
runtimeSettings?: ProviderRuntimeSettings, runtimeSettings?: ProviderRuntimeSettings,

View File

@@ -1,10 +1,8 @@
import { homedir } from "node:os"; import { homedir } from "node:os";
import type { Logger } from "pino"; 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 type { AgentProvider } from "../agent-sdk-types.js";
import { createProviderEnvSpec } from "../provider-launch-config.js"; import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js";
import { import {
ACPAgentClient, ACPAgentClient,
deriveModelDefinitionsFromACP, deriveModelDefinitionsFromACP,
@@ -15,6 +13,7 @@ import {
formatDiagnosticStatus, formatDiagnosticStatus,
formatProviderDiagnostic, formatProviderDiagnostic,
formatProviderDiagnosticError, formatProviderDiagnosticError,
buildBinaryDiagnosticRows,
toDiagnosticErrorMessage, toDiagnosticErrorMessage,
} from "./diagnostic-utils.js"; } from "./diagnostic-utils.js";
@@ -61,15 +60,18 @@ export class GenericACPAgentClient extends ACPAgentClient {
} }
override async isAvailable(): Promise<boolean> { override async isAvailable(): Promise<boolean> {
return isCommandAvailable(this.command[0]); const launch = await this.resolveConfiguredLaunch();
const availability = await checkProviderLaunchAvailable(launch);
return availability.available;
} }
async getDiagnostic(): Promise<{ diagnostic: string }> { async getDiagnostic(): Promise<{ diagnostic: string }> {
const providerName = formatProviderName(this.label, this.providerId); const providerName = formatProviderName(this.label, this.providerId);
try { try {
const resolvedBinary = await findExecutable(this.command[0]); const launch = await this.resolveConfiguredLaunch();
const available = resolvedBinary !== null; const availability = await checkProviderLaunchAvailable(launch);
const available = availability.available;
const versionProbe = buildVersionProbeCommand(this.command); const versionProbe = buildVersionProbeCommand(this.command);
const probeResult = available const probeResult = available
? await this.runDiagnosticACPProbe() ? await this.runDiagnosticACPProbe()
@@ -85,17 +87,18 @@ export class GenericACPAgentClient extends ACPAgentClient {
diagnostic: formatProviderDiagnostic(providerName, [ diagnostic: formatProviderDiagnostic(providerName, [
{ label: "Provider ID", value: this.providerId ?? "unknown" }, { label: "Provider ID", value: this.providerId ?? "unknown" },
{ label: "Configured command", value: this.command.join(" ") }, { 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", label: "Version command",
value: formatCommand(versionProbe.command, versionProbe.args), 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 initialize", value: probeResult.initialize },
{ label: "ACP session/new", value: probeResult.session }, { label: "ACP session/new", value: probeResult.session },
{ label: "Models", value: probeResult.models }, { 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<ACPDiagnosticProbeResult> { private async runDiagnosticACPProbe(): Promise<ACPDiagnosticProbeResult> {
let initializeValue = "Not checked"; let initializeValue = "Not checked";
let sessionValue = "Not checked"; let sessionValue = "Not checked";
@@ -277,21 +287,6 @@ function formatProbeError(currentValue: string, error: unknown): string {
return `Error - ${toDiagnosticErrorMessage(error)}`; return `Error - ${toDiagnosticErrorMessage(error)}`;
} }
async function resolveCommandVersion(
invocation: CommandInvocation,
env: Record<string, string> | undefined,
): Promise<string> {
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<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> { async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | null = null; let timeout: ReturnType<typeof setTimeout> | null = null;
try { try {

View File

@@ -10,7 +10,6 @@ import {
type Session as OpenCodeSession, type Session as OpenCodeSession,
type TextPartInput as OpenCodeTextPartInput, type TextPartInput as OpenCodeTextPartInput,
} from "@opencode-ai/sdk/v2/client"; } from "@opencode-ai/sdk/v2/client";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import { createPathEquivalenceMatcher } from "../../../utils/path.js"; import { createPathEquivalenceMatcher } from "../../../utils/path.js";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { z } from "zod"; import { z } from "zod";
@@ -46,7 +45,12 @@ import {
type ToolCallDetail, type ToolCallDetail,
type ToolCallTimelineItem, type ToolCallTimelineItem,
} from "../agent-sdk-types.js"; } 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 { withTimeout } from "../../../utils/promise-timeout.js";
import { execCommand } from "../../../utils/spawn.js"; import { execCommand } from "../../../utils/spawn.js";
import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js"; import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
@@ -56,7 +60,7 @@ import {
formatDiagnosticStatus, formatDiagnosticStatus,
formatProviderDiagnostic, formatProviderDiagnostic,
formatProviderDiagnosticError, formatProviderDiagnosticError,
resolveBinaryVersion, buildBinaryDiagnosticRows,
toDiagnosticErrorMessage, toDiagnosticErrorMessage,
} from "./diagnostic-utils.js"; } from "./diagnostic-utils.js";
import { runProviderTurn } from "./provider-runner.js"; import { runProviderTurn } from "./provider-runner.js";
@@ -1391,17 +1395,22 @@ export class OpenCodeAgentClient implements AgentClient {
} }
async isAvailable(): Promise<boolean> { async isAvailable(): Promise<boolean> {
const command = this.runtimeSettings?.command; const launch = await resolveProviderLaunch({
if (command?.mode === "replace") { commandConfig: this.runtimeSettings?.command,
return await isCommandAvailable(command.argv[0]); defaultBinary: "opencode",
} });
return await isCommandAvailable("opencode"); const availability = await checkProviderLaunchAvailable(launch);
return availability.available;
} }
async getDiagnostic(): Promise<{ diagnostic: string }> { async getDiagnostic(): Promise<{ diagnostic: string }> {
try { try {
const available = await this.isAvailable(); const launch = await resolveProviderLaunch({
const resolvedBinary = await findExecutable("opencode"); commandConfig: this.runtimeSettings?.command,
defaultBinary: "opencode",
});
const availability = await checkProviderLaunchAvailable(launch);
const available = availability.available;
let serverStatus = "Not running"; let serverStatus = "Not running";
let modelsValue = "Not checked"; let modelsValue = "Not checked";
let status = formatDiagnosticStatus(available); let status = formatDiagnosticStatus(available);
@@ -1414,12 +1423,19 @@ export class OpenCodeAgentClient implements AgentClient {
} }
let authValue = "Not checked"; let authValue = "Not checked";
if (resolvedBinary) { const authCommand = availability.available
? (availability.resolvedPath ?? launch.command)
: null;
if (authCommand) {
try { try {
const { stdout, stderr } = await execCommand(resolvedBinary, ["auth", "list"], { const { stdout, stderr } = await execCommand(
...createProviderEnvSpec(), authCommand,
timeout: 5_000, [...launch.args, "auth", "list"],
}); {
...createProviderEnvSpec(),
timeout: 5_000,
},
);
const text = (stdout.trim() || stderr.trim()).trim(); const text = (stdout.trim() || stderr.trim()).trim();
authValue = text ? `\n ${text.replace(/\n/g, "\n ")}` : "(empty)"; authValue = text ? `\n ${text.replace(/\n/g, "\n ")}` : "(empty)";
} catch (error) { } catch (error) {
@@ -1453,14 +1469,7 @@ export class OpenCodeAgentClient implements AgentClient {
return { return {
diagnostic: formatProviderDiagnostic("OpenCode", [ diagnostic: formatProviderDiagnostic("OpenCode", [
{ ...(await buildBinaryDiagnosticRows(launch, availability)),
label: "Binary",
value: resolvedBinary ?? "not found",
},
{
label: "Version",
value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown",
},
{ label: "Server", value: serverStatus }, { label: "Server", value: serverStatus },
{ label: "Auth", value: authValue }, { label: "Auth", value: authValue },
{ label: "Models", value: modelsValue }, { label: "Models", value: modelsValue },

View File

@@ -30,15 +30,19 @@ import {
type PersistedAgentDescriptor, type PersistedAgentDescriptor,
} from "../../agent-sdk-types.js"; } from "../../agent-sdk-types.js";
import { runProviderTurn } from "../provider-runner.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 { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
import { composeSystemPromptParts } from "../../system-prompt.js"; import { composeSystemPromptParts } from "../../system-prompt.js";
import { findExecutable } from "../../../../utils/executable.js";
import { import {
buildBinaryDiagnosticRows,
formatDiagnosticStatus, formatDiagnosticStatus,
formatProviderDiagnostic, formatProviderDiagnostic,
formatProviderDiagnosticError, formatProviderDiagnosticError,
resolveBinaryVersion,
toDiagnosticErrorMessage, toDiagnosticErrorMessage,
} from "../diagnostic-utils.js"; } from "../diagnostic-utils.js";
import { import {
@@ -1484,8 +1488,9 @@ export class PiRpcAgentClient implements AgentClient {
} }
async isAvailable(): Promise<boolean> { async isAvailable(): Promise<boolean> {
const binary = await this.resolvePiBinary(); const launch = await this.resolvePiLaunch();
if (!binary) { const availability = await checkProviderLaunchAvailable(launch);
if (!availability.available) {
return false; return false;
} }
const runtimeSession = await this.runtime.startSession({ cwd: homedir() }).catch(() => null); const runtimeSession = await this.runtime.startSession({ cwd: homedir() }).catch(() => null);
@@ -1503,16 +1508,16 @@ export class PiRpcAgentClient implements AgentClient {
async getDiagnostic(): Promise<{ diagnostic: string }> { async getDiagnostic(): Promise<{ diagnostic: string }> {
try { try {
const available = await this.isAvailable(); const launch = await this.resolvePiLaunch();
const binary = await this.resolvePiBinary(); const availability = await checkProviderLaunchAvailable(launch);
const version = binary ? await resolveBinaryVersion(binary) : "unknown"; const available = availability.available;
const authConfigPath = join(homedir(), ".pi", "agent", "auth.json"); const authConfigPath = join(homedir(), ".pi", "agent", "auth.json");
let modelsValue = "Not checked"; let modelsValue = "Not checked";
let configuredProvidersValue = "none"; let configuredProvidersValue = "none";
let mcpToolsValue = "Not checked"; let mcpToolsValue = "Not checked";
let status = formatDiagnosticStatus(available); let status = formatDiagnosticStatus(available);
if (binary) { if (availability.available) {
const runtimeSession = await this.runtime const runtimeSession = await this.runtime
.startSession({ cwd: homedir() }) .startSession({ cwd: homedir() })
.catch((error) => { .catch((error) => {
@@ -1550,8 +1555,7 @@ export class PiRpcAgentClient implements AgentClient {
return { return {
diagnostic: formatProviderDiagnostic("Pi", [ diagnostic: formatProviderDiagnostic("Pi", [
{ label: "Binary", value: binary ?? "not found" }, ...(await buildBinaryDiagnosticRows(launch, availability)),
{ label: "Version", value: version },
{ label: "Configured providers", value: configuredProvidersValue }, { label: "Configured providers", value: configuredProvidersValue },
{ {
label: "Auth config (~/.pi/agent/auth.json)", label: "Auth config (~/.pi/agent/auth.json)",
@@ -1601,11 +1605,10 @@ export class PiRpcAgentClient implements AgentClient {
} }
} }
private async resolvePiBinary(): Promise<string | null> { private async resolvePiLaunch(): Promise<ResolvedProviderLaunch> {
const command = this.runtimeSettings?.command; return resolveProviderLaunch({
if (command?.mode === "replace" && command.argv[0]) { commandConfig: this.runtimeSettings?.command,
return await findExecutable(command.argv[0]); defaultBinary: PI_BINARY_COMMAND,
} });
return await findExecutable(PI_BINARY_COMMAND);
} }
} }