import { execFile as nodeExecFile } from "node:child_process"; import { promisify } from "node:util"; import type { AppConfig } from "../config.js"; import { secretValue } from "../config/secret-bundle.js"; import { loadSettings, type Settings } from "../settings/settings-store.js"; import { splitCanonicalModelId, type RuntimeModelCatalog, } from "../models/runtime-model-catalog.js"; import { configuredPiProviderApiKey, PI_MANAGED_CONFIG_ERROR_MESSAGE, isPiManagedConfigError, readConfiguredPiAgentFile, } from "./managed-config.js"; import { createPiProviderSmoke, type PiProviderSmoke } from "./provider-smoke.js"; import { loadPiAuthProviders } from "./auth-providers.js"; import { piProviderCredentialStatus, type PiCredentialStatus, } from "./provider-credentials.js"; const execFile = promisify(nodeExecFile); const REASONING_CHOICES = ["low", "medium", "high"] as const; const VERSION_PATTERN = /^(?:pi(?:\s+version)?\s+)?v?(\d+(?:\.\d+){1,3}(?:[-+][0-9A-Za-z.-]+)?)$/; const MAX_LOG_LINES = 200; const MAX_LOG_LINE_LENGTH = 4_096; const MAX_EXEC_OUTPUT_BYTES = 64 * 1024; export type PiReasoning = typeof REASONING_CHOICES[number]; export interface PiInstallationConfig { provider?: string; model?: string; reasoning?: PiReasoning; } export interface PiStatus { hostPlatform: "linux" | "macos" | "windows"; version?: string; ready: boolean; credentials: PiCredentialStatus; config: PiInstallationConfig; checkedAt: string; message?: string; } export interface PiTestResult { ready: boolean; checkedAt: string; message?: string; } export interface PiLogs { lines: string[]; checkedAt: string; } export interface PiExecFileOptions { timeout: number; maxBuffer: number; } export type PiExecFile = ( command: string, args: string[], options: PiExecFileOptions, ) => Promise<{ stdout: string; stderr: string }>; export interface PiManagementService { status(): Promise; test(): Promise; logs(): Promise; } export class PiManagementError extends Error { constructor( public readonly code: "pi_management_unavailable", message: string, ) { super(message); } } interface PiManagementDeps { execute?: PiExecFile; modelCatalog: RuntimeModelCatalog; smokeProvider?: PiProviderSmoke; readSettings?: () => Settings; readLogs?: () => string | Promise; credentialStatus?: (provider: string | undefined) => PiCredentialStatus; now?: () => Date; } export function createPiManagement(config: AppConfig, deps: PiManagementDeps): PiManagementService { const platform = config.hostPlatform ?? process.platform; const hostPlatform = platform === "darwin" ? "macos" : platform === "windows" || platform === "win32" ? "windows" : "linux"; const now = deps.now ?? (() => new Date()); const diagnostics: string[] = []; const addDiagnostic = (message: string): void => { diagnostics.push(`${now().toISOString()} ${redact(message)}`); if (diagnostics.length > MAX_LOG_LINES) diagnostics.splice(0, diagnostics.length - MAX_LOG_LINES); }; const execute = deps.execute ?? defaultExecFile; const readSettings = deps.readSettings ?? (() => loadSettings(config)); const readLogs = deps.readLogs ?? (() => diagnostics.join("\n")); const smokeProvider = deps.smokeProvider ?? createPiProviderSmoke(config, { modelCatalog: deps.modelCatalog, }); const credentialStatus = deps.credentialStatus ?? ((provider: string | undefined) => { try { const model = deps.modelCatalog.defaultInteraction ? deps.modelCatalog.sessionModels().find((entry) => entry.id === deps.modelCatalog.defaultInteraction) : undefined; const credentialName = model?.authentication.mode === "secret_env" ? model.authentication.apiKeyEnv : undefined; const configuredApiKey = credentialName ? `$${credentialName}` : configuredPiProviderApiKey( readConfiguredPiAgentFile("models.json", true), provider, ); return piProviderCredentialStatus({ provider, authProviders: credentialName ? new Set() : loadPiAuthProviders(), resolveCredentialValue: () => credentialName ? secretValue(config, credentialName) : config.modelCatalogFile ? undefined : secretValue(config, "THT_MODEL_API_KEY"), credentialFile: credentialName ? undefined : config.modelApiKeyFile, configuredApiKey, }); } catch { return "missing"; } }); const version = async (timeoutMs = config.piManagementTimeoutMs): Promise => { let output: { stdout: string; stderr: string }; try { // The Pi executable and every argument are installation-owned constants. Do not add a shell. output = await execute(config.piBin, ["--version"], { timeout: timeoutMs, maxBuffer: MAX_EXEC_OUTPUT_BYTES, }); } catch (error) { if (isTimeout(error)) { throw new PiManagementError("pi_management_unavailable", "Pi smoke check timed out"); } throw new PiManagementError("pi_management_unavailable", "Pi runtime is unavailable"); } const matched = VERSION_PATTERN.exec(output.stdout.trim()); if (!matched) throw new PiManagementError("pi_management_unavailable", "Pi runtime returned an invalid version"); return matched[1]; }; const installationConfig = (): PiInstallationConfig => { const settings = readSettings(); const reasoning = config.defaults.thinking ?? settings.thinking; const selected = deps.modelCatalog.defaultInteraction ? splitCanonicalModelId(deps.modelCatalog.defaultInteraction) : undefined; return { ...(selected ? selected : {}), ...(isReasoning(reasoning) ? { reasoning } : {}), }; }; return { async status(): Promise { const checkedAt = now().toISOString(); const current = installationConfig(); const credentials = credentialStatus(current.provider); try { const currentVersion = await version(); addDiagnostic("Pi version probe succeeded"); return { hostPlatform, version: currentVersion, ready: true, credentials, config: current, checkedAt }; } catch (error) { const message = stableMessage(error, "Pi runtime is unavailable"); addDiagnostic(message); return { hostPlatform, ready: false, credentials, config: current, checkedAt, message }; } }, async test(): Promise { const checkedAt = now().toISOString(); const deadline = Date.now() + config.piManagementTimeoutMs; let timer: NodeJS.Timeout | undefined; try { const check = async (): Promise => { await version(remainingBudget(deadline)); const current = installationConfig(); if (!current.provider || !current.model || !current.reasoning) { throw new PiManagementError( "pi_management_unavailable", "Pi installation configuration is incomplete", ); } await smokeProvider({ provider: current.provider, model: current.model, reasoning: current.reasoning, timeoutMs: remainingBudget(deadline), }); }; await Promise.race([ check(), new Promise((_resolve, reject) => { timer = setTimeout( () => reject(new PiManagementError("pi_management_unavailable", "Pi smoke check timed out")), config.piManagementTimeoutMs, ); }), ]); addDiagnostic("Pi smoke check succeeded"); return { ready: true, checkedAt }; } catch (error) { return smokeFailure(stableMessage(error, "Pi provider smoke check failed"), checkedAt, addDiagnostic); } finally { if (timer) clearTimeout(timer); } }, async logs(): Promise { let source = ""; try { source = await readLogs(); } catch { source = "Pi diagnostics are unavailable"; } const lines = source .split(/\r?\n/u) .filter((line) => line.length > 0) .slice(-MAX_LOG_LINES) .map((line) => redact(line.slice(0, MAX_LOG_LINE_LENGTH))); return { lines, checkedAt: now().toISOString() }; }, }; } async function defaultExecFile(command: string, args: string[], options: PiExecFileOptions) { const result = await execFile(command, args, { timeout: options.timeout, maxBuffer: options.maxBuffer, windowsHide: true, }); return { stdout: String(result.stdout), stderr: String(result.stderr) }; } function isReasoning(value: unknown): value is PiReasoning { return typeof value === "string" && (REASONING_CHOICES as readonly string[]).includes(value); } function isTimeout(error: unknown): boolean { return Boolean( error && typeof error === "object" && ( (error as { code?: unknown }).code === "ETIMEDOUT" || (error as { killed?: unknown }).killed === true ), ); } function stableMessage(error: unknown, fallback: string): string { if (isPiManagedConfigError(error)) return PI_MANAGED_CONFIG_ERROR_MESSAGE; return error instanceof PiManagementError ? error.message : fallback; } function smokeFailure( message: string, checkedAt: string, addDiagnostic: (message: string) => void, ): PiTestResult { addDiagnostic(message); return { ready: false, message, checkedAt }; } export function redact(value: string): string { return value .replace(/(\bauthorization\b\s*:\s*Bearer\s+)[^\s,;]+/giu, "$1[REDACTED]") .replace(/((?:["']?)[A-Za-z0-9_-]*(?:api[_-]?key|token|password|secret|authorization)[A-Za-z0-9_-]*(?:["']?)\s*(?:=|:)\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;}]+)/giu, "$1[REDACTED]") .replace(/(\bBearer\s+)[^\s,;]+/giu, "$1[REDACTED]") .replace(/(\w+:\/\/[^:/\s]+:)[^@/\s]+@/gu, "$1[REDACTED]@"); } function remainingBudget(deadline: number): number { return Math.max(1, deadline - Date.now()); }