diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx index 664f736c7..3172ebf55 100644 --- a/packages/app/src/components/provider-diagnostic-sheet.tsx +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -1,4 +1,5 @@ -import { AlertTriangle, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native"; +import * as Clipboard from "expo-clipboard"; +import { AlertTriangle, Copy, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native"; import type { TFunction } from "i18next"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -20,6 +21,7 @@ import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; +import { useToast } from "@/contexts/toast-context"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; @@ -248,6 +250,7 @@ function DiagnosticSubSheet({ }) { const { t } = useTranslation(); const { theme } = useUnistyles(); + const toast = useToast(); const client = useHostRuntimeClient(serverId); const [diagnostic, setDiagnostic] = useState(null); const [loading, setLoading] = useState(false); @@ -288,31 +291,62 @@ function DiagnosticSubSheet({ void fetchDiagnostic(); }, [fetchDiagnostic]); + const copyButtonStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + sheetStyles.iconButton, + (Boolean(hovered) || pressed) && Boolean(diagnostic) && sheetStyles.iconButtonHovered, + diagnostic ? null : sheetStyles.disabled, + ], + [diagnostic], + ); + + const handleCopyPress = useCallback(() => { + if (!diagnostic) return; + void Clipboard.setStringAsync(diagnostic) + .then(() => toast.copied(t("settings.providers.diagnostic.copyLabel"))) + .catch(() => toast.error(t("settings.providers.diagnostic.copyFailed"))); + }, [diagnostic, t, toast]); + const header = useMemo( () => ({ title: t("settings.providers.diagnostic.title"), actions: ( - - {loading ? ( - - ) : ( - - )} - + + + + + + {loading ? ( + + ) : ( + + )} + + ), }), [ + copyButtonStyle, + diagnostic, + handleCopyPress, handleRefreshPress, loading, refreshButtonStyle, @@ -733,6 +767,11 @@ const sheetStyles = StyleSheet.create((theme) => ({ iconButtonHovered: { backgroundColor: theme.colors.surface2, }, + headerActions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, disabled: { opacity: 0.5, }, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 806fc7b67..8b99a6f77 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1778,6 +1778,9 @@ export const ar: TranslationResources = { button: "التشخيص", refresh: "ينعش", refreshing: "منعش...", + copyLabel: "التشخيص", + copyAccessibility: "نسخ التشخيص", + copyFailed: "فشل نسخ التشخيص", refreshAccessibility: "تحديث التشخيص", refreshingAccessibility: "تحديث التشخيص", running: "تشغيل التشخيص...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 26ecc630c..a17d54de0 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1786,6 +1786,9 @@ export const en = { button: "Diagnostic", refresh: "Refresh", refreshing: "Refreshing...", + copyLabel: "diagnostic", + copyAccessibility: "Copy diagnostic", + copyFailed: "Failed to copy diagnostic", refreshAccessibility: "Refresh diagnostic", refreshingAccessibility: "Refreshing diagnostic", running: "Running diagnostic...", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index bb3579440..8240c7899 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1819,6 +1819,9 @@ export const es: TranslationResources = { button: "Diagnóstico", refresh: "Refrescar", refreshing: "Refrescante...", + copyLabel: "diagnóstico", + copyAccessibility: "Copiar diagnóstico", + copyFailed: "No se pudo copiar el diagnóstico", refreshAccessibility: "Actualizar diagnóstico", refreshingAccessibility: "Diagnóstico refrescante", running: "Ejecutando diagnóstico...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 8b8dd8327..271066ae9 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1824,6 +1824,9 @@ export const fr: TranslationResources = { button: "Diagnostique", refresh: "Rafraîchir", refreshing: "Rafraîchissant...", + copyLabel: "diagnostic", + copyAccessibility: "Copier le diagnostic", + copyFailed: "Échec de la copie du diagnostic", refreshAccessibility: "Actualiser le diagnostic", refreshingAccessibility: "Diagnostic rafraîchissant", running: "Exécution du diagnostic...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index f01330d94..2a9ceb22c 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1810,6 +1810,9 @@ export const ru: TranslationResources = { button: "Диагностика", refresh: "Обновить", refreshing: "Освежающий...", + copyLabel: "диагностика", + copyAccessibility: "Скопировать диагностику", + copyFailed: "Не удалось скопировать диагностику", refreshAccessibility: "Обновить диагностику", refreshingAccessibility: "Обновление диагностики", running: "Запускаю диагностику...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index e637c7567..d05a0f5ac 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1755,6 +1755,9 @@ export const zhCN: TranslationResources = { button: "诊断", refresh: "刷新", refreshing: "正在刷新...", + copyLabel: "诊断", + copyAccessibility: "复制诊断", + copyFailed: "复制诊断失败", refreshAccessibility: "刷新诊断", refreshingAccessibility: "正在刷新诊断", running: "正在运行诊断...", diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 8a0d3d319..62493f28e 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -35,6 +35,7 @@ import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js"; import { buildBinaryDiagnosticRows, + buildCommandResolutionDiagnosticRows, formatDiagnosticStatus, formatProviderDiagnostic, formatProviderDiagnosticError, @@ -1494,6 +1495,9 @@ export class ClaudeAgentClient implements AgentClient { return { diagnostic: formatProviderDiagnostic("Claude Code", [ + ...(await buildCommandResolutionDiagnosticRows(launch, { + knownBinaryNames: ["claude"], + })), ...(await buildBinaryDiagnosticRows(launch, availability)), ...(auth ? [{ label: "Auth", value: auth }] : []), { label: "Models", value: modelsValue }, 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 82880c15a..99f9686a9 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 @@ -87,6 +87,7 @@ import { formatProviderDiagnostic, formatProviderDiagnosticError, buildBinaryDiagnosticRows, + buildCommandResolutionDiagnosticRows, resolveBinaryVersion, toDiagnosticErrorMessage, } from "./diagnostic-utils.js"; @@ -5606,6 +5607,9 @@ export class CodexAppServerAgentClient implements AgentClient { const availability = await checkCodexLaunchAvailable(launch); const available = availability.available; const entries: Array<{ label: string; value: string }> = [ + ...(await buildCommandResolutionDiagnosticRows(launch, { + knownBinaryNames: ["codex"], + })), ...(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 1d493b623..402a74381 100644 --- a/packages/server/src/server/agent/providers/copilot-acp-agent.ts +++ b/packages/server/src/server/agent/providers/copilot-acp-agent.ts @@ -20,6 +20,7 @@ import { formatProviderDiagnostic, formatProviderDiagnosticError, buildBinaryDiagnosticRows, + buildCommandResolutionDiagnosticRows, toDiagnosticErrorMessage, } from "./diagnostic-utils.js"; @@ -127,6 +128,9 @@ export class CopilotACPAgentClient extends ACPAgentClient { return { diagnostic: formatProviderDiagnostic("Copilot", [ + ...(await buildCommandResolutionDiagnosticRows(launch, { + knownBinaryNames: ["copilot"], + })), ...(await buildBinaryDiagnosticRows(launch, availability)), { label: "Models", value: modelsValue }, { label: "Status", value: status }, diff --git a/packages/server/src/server/agent/providers/diagnostic-utils.test.ts b/packages/server/src/server/agent/providers/diagnostic-utils.test.ts index 93770d772..2242b2bbd 100644 --- a/packages/server/src/server/agent/providers/diagnostic-utils.test.ts +++ b/packages/server/src/server/agent/providers/diagnostic-utils.test.ts @@ -1,6 +1,131 @@ -import { describe, expect, test } 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 { toDiagnosticErrorMessage } from "./diagnostic-utils.js"; +import { + buildCommandResolutionDiagnosticRows, + toDiagnosticErrorMessage, +} from "./diagnostic-utils.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +function makeTempDir(): string { + const dir = mkdtempSync(path.join(tmpdir(), "paseo-diagnostic-path-")); + tempDirs.push(dir); + return dir; +} + +function writeExecutable(dir: string, name: string): string { + const filePath = path.join(dir, name); + writeFileSync(filePath, "#!/bin/sh\nexit 0\n"); + if (process.platform !== "win32") { + chmodSync(filePath, 0o755); + } + return filePath; +} + +describe("buildCommandResolutionDiagnosticRows", () => { + test("reports daemon PATH matches for known binary names", async () => { + const binDir = makeTempDir(); + const binaryPath = writeExecutable(binDir, "claude"); + + const rows = await buildCommandResolutionDiagnosticRows( + { command: "claude", args: [], source: "default" }, + { + knownBinaryNames: ["claude"], + pathValue: binDir, + }, + ); + + expect(rows).toContainEqual({ label: "Command source", value: "default" }); + expect(rows).toContainEqual({ label: "Configured command", value: "claude" }); + expect(rows).toContainEqual({ + label: "Daemon PATH", + value: expect.stringContaining(binDir), + }); + expect(rows).toContainEqual({ + label: "PATH matches", + value: expect.stringContaining(binaryPath), + }); + if (process.platform === "win32") { + expect(rows.some((row) => row.label === "where.exe claude")).toBe(true); + expect(rows.some((row) => row.label === "powershell Get-Command -All claude")).toBe(true); + } else { + expect(rows.some((row) => row.label === "which -a claude")).toBe(true); + expect(rows.some((row) => row.label.endsWith(" -lc type -a claude"))).toBe(true); + } + }); + + test("reports none when the daemon PATH has no matching executable", async () => { + const binDir = makeTempDir(); + + const rows = await buildCommandResolutionDiagnosticRows( + { command: "claude", args: [], source: "default" }, + { knownBinaryNames: ["claude"], includeCommandProbes: false, pathValue: binDir }, + ); + + expect(rows).toContainEqual({ label: "PATH matches", value: "none" }); + }); + + test("truncates very long daemon PATH values", async () => { + const rows = await buildCommandResolutionDiagnosticRows( + { command: "claude", args: [], source: "default" }, + { + knownBinaryNames: ["claude"], + includeCommandProbes: false, + pathValue: "x".repeat(5000), + }, + ); + + expect(rows).toContainEqual({ + label: "Daemon PATH", + value: expect.stringContaining("(truncated)"), + }); + }); + + test("matches Windows PATHEXT executable names", async () => { + const binDir = makeTempDir(); + const binaryPath = writeExecutable(binDir, "claude.EXE"); + + const rows = await buildCommandResolutionDiagnosticRows( + { command: "claude", args: [], source: "default" }, + { + knownBinaryNames: ["claude"], + includeCommandProbes: false, + pathext: ".EXE;.CMD", + pathValue: binDir, + platform: "win32", + }, + ); + + expect(rows).toContainEqual({ + label: "PATH matches", + value: expect.stringContaining(binaryPath), + }); + }); + + test("does not treat absolute configured commands as PATH binary names", async () => { + const rows = await buildCommandResolutionDiagnosticRows( + { command: "/Users/mn/.local/bin/claude", args: [], source: "override" }, + { + knownBinaryNames: ["/Users/mn/.local/bin/claude"], + includeCommandProbes: false, + }, + ); + + expect(rows).toContainEqual({ label: "PATH matches", value: "not checked" }); + }); +}); describe("toDiagnosticErrorMessage", () => { test("returns message for plain Error", () => { diff --git a/packages/server/src/server/agent/providers/diagnostic-utils.ts b/packages/server/src/server/agent/providers/diagnostic-utils.ts index 6d34d63ed..7eda942c8 100644 --- a/packages/server/src/server/agent/providers/diagnostic-utils.ts +++ b/packages/server/src/server/agent/providers/diagnostic-utils.ts @@ -1,3 +1,7 @@ +import { constants } from "node:fs"; +import { access, stat } from "node:fs/promises"; +import path from "node:path"; + import { createProviderEnvSpec, type ProviderLaunchAvailability, @@ -154,6 +158,232 @@ export interface BinaryDiagnosticRowsOptions { versionCommand?: BinaryDiagnosticVersionCommand; } +export interface CommandResolutionDiagnosticRowsOptions { + knownBinaryNames: readonly string[]; + includeCommandProbes?: boolean; + pathValue?: string; + pathext?: string; + platform?: NodeJS.Platform; + shell?: string; +} + +const COMMAND_PROBE_TIMEOUT_MS = 3_000; +const COMMAND_PROBE_MAX_BUFFER = 32 * 1024; + +function resolvePlatform(options?: CommandResolutionDiagnosticRowsOptions): NodeJS.Platform { + return options?.platform ?? process.platform; +} + +function resolvePathValue(options?: CommandResolutionDiagnosticRowsOptions): string { + return options?.pathValue ?? process.env["PATH"] ?? process.env["Path"] ?? ""; +} + +function resolveShellValue(options?: CommandResolutionDiagnosticRowsOptions): string { + if (options?.shell) { + return options.shell; + } + if (resolvePlatform(options) === "win32") { + return process.env["ComSpec"] ?? "cmd.exe"; + } + return process.env["SHELL"] ?? "/bin/sh"; +} + +async function isExecutableFile(filePath: string, platform: NodeJS.Platform): Promise { + try { + const candidate = await stat(filePath); + if (!candidate.isFile()) { + return false; + } + if (platform === "win32") { + return true; + } + await access(filePath, constants.X_OK); + return true; + } catch { + return false; + } +} + +function resolveSearchableNames(binaryNames: readonly string[]): string[] { + return binaryNames.filter( + (binaryName) => + binaryName.trim().length > 0 && !binaryName.includes("/") && !binaryName.includes("\\"), + ); +} + +function resolveWindowsPathExt(options: CommandResolutionDiagnosticRowsOptions): string[] { + const value = options.pathext ?? process.env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD"; + return value + .split(";") + .map((extension) => extension.trim()) + .filter(Boolean) + .map((extension) => (extension.startsWith(".") ? extension : `.${extension}`)); +} + +function resolveBinaryCandidateNames( + binaryName: string, + options: CommandResolutionDiagnosticRowsOptions, +): string[] { + if (resolvePlatform(options) !== "win32" || path.win32.extname(binaryName)) { + return [binaryName]; + } + return [binaryName, ...resolveWindowsPathExt(options).map((extension) => binaryName + extension)]; +} + +async function formatPathMatches(options: CommandResolutionDiagnosticRowsOptions): Promise { + const binaryNames = options.knownBinaryNames; + const searchableNames = resolveSearchableNames(binaryNames); + + if (searchableNames.length === 0) { + return "not checked"; + } + + const pathDelimiter = resolvePlatform(options) === "win32" ? ";" : path.delimiter; + const pathEntries = resolvePathValue(options).split(pathDelimiter).filter(Boolean); + const matches: string[] = []; + const seen = new Set(); + const platform = resolvePlatform(options); + + for (const directory of pathEntries) { + for (const binaryName of searchableNames) { + for (const candidateName of resolveBinaryCandidateNames(binaryName, options)) { + const candidate = path.join(directory, candidateName); + if (seen.has(candidate)) { + continue; + } + seen.add(candidate); + if (await isExecutableFile(candidate, platform)) { + matches.push(candidate); + } + } + } + } + + return matches.length > 0 ? matches.join("\n ") : "none"; +} + +function shellToken(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function formatCommandProbeOutput(stdout: string, stderr: string): string { + const sections: string[] = []; + const trimmedStdout = truncateForDiagnostic(stdout); + const trimmedStderr = truncateForDiagnostic(stderr); + if (trimmedStdout.length > 0) { + sections.push(trimmedStdout); + } + if (trimmedStderr.length > 0) { + sections.push(`stderr: ${trimmedStderr}`); + } + return sections.length > 0 ? sections.join("\n") : "(no output)"; +} + +function formatCommandProbeError(error: unknown): string { + return toDiagnosticErrorMessage(error); +} + +async function runCommandProbe(command: string, args: string[]): Promise { + try { + const { stdout, stderr } = await execCommand(command, args, { + timeout: COMMAND_PROBE_TIMEOUT_MS, + killSignal: "SIGKILL", + maxBuffer: COMMAND_PROBE_MAX_BUFFER, + }); + return formatCommandProbeOutput(stdout, stderr); + } catch (error) { + return formatCommandProbeError(error); + } +} + +async function buildPosixCommandProbeRows(binaryName: string): Promise { + const shell = resolveShellValue(); + const typeCommand = `type -a ${shellToken(binaryName)}`; + return [ + { + label: `which -a ${binaryName}`, + value: await runCommandProbe("/usr/bin/which", ["-a", binaryName]), + }, + { + label: `${path.basename(shell)} -lc type -a ${binaryName}`, + value: await runCommandProbe(shell, ["-lc", typeCommand]), + }, + ]; +} + +async function buildWindowsCommandProbeRows(binaryName: string): Promise { + const powershellCommand = [ + "$ErrorActionPreference = 'Continue';", + `Get-Command -All ${JSON.stringify(binaryName)} |`, + "Select-Object CommandType,Source,Name,Definition |", + "Format-List", + ].join(" "); + + return [ + { + label: `where.exe ${binaryName}`, + value: await runCommandProbe("where.exe", [binaryName]), + }, + { + label: `powershell Get-Command -All ${binaryName}`, + value: await runCommandProbe("powershell.exe", [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + powershellCommand, + ]), + }, + ]; +} + +async function buildCommandProbeRows(binaryNames: readonly string[]): Promise { + const searchableNames = resolveSearchableNames(binaryNames); + if (searchableNames.length === 0) { + return []; + } + + const rows: DiagnosticEntry[] = []; + for (const binaryName of searchableNames) { + rows.push( + ...(process.platform === "win32" + ? await buildWindowsCommandProbeRows(binaryName) + : await buildPosixCommandProbeRows(binaryName)), + ); + } + return rows; +} + +export async function buildCommandResolutionDiagnosticRows( + launch: ResolvedProviderLaunch, + options: CommandResolutionDiagnosticRowsOptions, +): Promise { + const includeCommandProbes = options.includeCommandProbes ?? true; + return [ + { + label: "Command source", + value: launch.source, + }, + { + label: "Configured command", + value: [launch.command, ...launch.args].join(" "), + }, + { + label: "Daemon PATH", + value: truncateForDiagnostic(resolvePathValue(options)) || "(empty)", + }, + { + label: "Daemon shell", + value: resolveShellValue(options), + }, + { + label: "PATH matches", + value: await formatPathMatches(options), + }, + ...(includeCommandProbes ? await buildCommandProbeRows(options.knownBinaryNames) : []), + ]; +} + async function resolveCommandVersion(invocation: BinaryDiagnosticVersionCommand): Promise { try { const { stdout, stderr } = await execCommand(invocation.command, invocation.args, { diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index fb935e7c6..3b577bcb2 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -71,6 +71,7 @@ import { formatProviderDiagnostic, formatProviderDiagnosticError, buildBinaryDiagnosticRows, + buildCommandResolutionDiagnosticRows, toDiagnosticErrorMessage, } from "./diagnostic-utils.js"; import { runProviderTurn } from "./provider-runner.js"; @@ -1609,6 +1610,9 @@ export class OpenCodeAgentClient implements AgentClient { return { diagnostic: formatProviderDiagnostic("OpenCode", [ + ...(await buildCommandResolutionDiagnosticRows(launch, { + knownBinaryNames: ["opencode"], + })), ...(await buildBinaryDiagnosticRows(launch, availability)), { label: "Server", value: serverStatus }, { label: "Auth", value: authValue }, diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 27ce7b445..019084dfb 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -45,6 +45,7 @@ import { renderPromptAttachmentAsText } from "../../prompt-attachments.js"; import { composeSystemPromptParts } from "../../system-prompt.js"; import { buildBinaryDiagnosticRows, + buildCommandResolutionDiagnosticRows, formatDiagnosticStatus, formatProviderDiagnostic, formatProviderDiagnosticError, @@ -2062,6 +2063,9 @@ export class PiRpcAgentClient implements AgentClient { return { diagnostic: formatProviderDiagnostic("Pi", [ + ...(await buildCommandResolutionDiagnosticRows(launch, { + knownBinaryNames: [launch.command], + })), ...(await buildBinaryDiagnosticRows(launch, availability)), { label: "Configured providers", value: configuredProvidersValue }, {