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, saveSettings, type Settings, } from "../settings/settings-store.js"; import type { PiModel } from "./list-models.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 { version?: string; ready: boolean; credentials: PiCredentialStatus; config: PiInstallationConfig; checkedAt: string; message?: string; } export interface PiOptions { providers: string[]; models: Array<{ provider: string; id: string }>; reasoning: PiReasoning[]; checkedAt: 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; options(): Promise; configure(value: PiInstallationConfig): Promise; test(): Promise; logs(): Promise; } export class PiManagementError extends Error { constructor( public readonly code: "pi_management_invalid_config" | "pi_management_unavailable" | "pi_management_write_failed", message: string, ) { super(message); } } interface PiManagementDeps { execute?: PiExecFile; listModels: () => Promise; smokeProvider?: PiProviderSmoke; readSettings?: () => Settings; saveSettings?: (settings: Settings) => Settings; readLogs?: () => string | Promise; credentialStatus?: (provider: string | undefined) => PiCredentialStatus; now?: () => Date; } export function createPiManagement(config: AppConfig, deps: PiManagementDeps): PiManagementService { 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 persistSettings = deps.saveSettings ?? ((settings) => saveSettings(config, settings)); const readLogs = deps.readLogs ?? (() => diagnostics.join("\n")); const smokeProvider = deps.smokeProvider ?? createPiProviderSmoke(config); const credentialStatus = deps.credentialStatus ?? ((provider: string | undefined) => { try { return piProviderCredentialStatus({ provider, authProviders: loadPiAuthProviders(), resolveCredentialValue: () => secretValue(config, "THT_MODEL_API_KEY"), credentialFile: config.modelApiKeyFile, configuredApiKey: configuredPiProviderApiKey( readConfiguredPiAgentFile("models.json", true), provider, ), }); } catch { return "missing"; } }); const closedOptions = async (): Promise> => { let listed: PiModel[]; try { listed = await deps.listModels(); } catch (error) { if (isPiManagedConfigError(error)) { throw new PiManagementError("pi_management_unavailable", PI_MANAGED_CONFIG_ERROR_MESSAGE); } throw new PiManagementError("pi_management_unavailable", "Pi model choices are unavailable"); } const models: Array<{ provider: string; id: string }> = []; const providers: string[] = []; const seenModels = new Set(); const seenProviders = new Set(); for (const model of listed) { if (!isChoice(model?.provider) || !isChoice(model?.id)) continue; const key = `${model.provider}\u0000${model.id}`; if (seenModels.has(key)) continue; seenModels.add(key); models.push({ provider: model.provider, id: model.id }); if (!seenProviders.has(model.provider)) { seenProviders.add(model.provider); providers.push(model.provider); } } return { providers, models, reasoning: [...REASONING_CHOICES] }; }; 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 provider = config.defaults.provider ?? settings.provider; const model = config.defaults.model ?? settings.model; const reasoning = config.defaults.thinking ?? settings.thinking; return { ...(isChoice(provider) ? { provider } : {}), ...(isChoice(model) ? { model } : {}), ...(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 { version: currentVersion, ready: true, credentials, config: current, checkedAt }; } catch (error) { const message = stableMessage(error, "Pi runtime is unavailable"); addDiagnostic(message); return { ready: false, credentials, config: current, checkedAt, message }; } }, async options(): Promise { const choices = await closedOptions(); return { ...choices, checkedAt: now().toISOString() }; }, async configure(value: PiInstallationConfig): Promise { if (!isInstallationConfig(value)) { throw new PiManagementError("pi_management_invalid_config", "Pi installation configuration is invalid"); } const choices = await closedOptions(); if (!choices.models.some((model) => model.provider === value.provider && model.id === value.model)) { throw new PiManagementError("pi_management_invalid_config", "Pi provider and model must be selected from available choices"); } try { persistSettings({ ...readSettings(), provider: value.provider, model: value.model, thinking: value.reasoning }); } catch { throw new PiManagementError("pi_management_write_failed", "Pi installation configuration could not be saved"); } addDiagnostic("Pi installation defaults updated"); return { ...value, updatedAt: now().toISOString() }; }, 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 isChoice(value: unknown): value is string { return typeof value === "string" && value.length > 0 && value.length <= 128 && value.trim() === value && /^[A-Za-z0-9][A-Za-z0-9._/-]*$/u.test(value); } function isReasoning(value: unknown): value is PiReasoning { return typeof value === "string" && (REASONING_CHOICES as readonly string[]).includes(value); } function isInstallationConfig(value: unknown): value is Required { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const candidate = value as Record; if (Object.keys(candidate).length !== 3 || Object.keys(candidate).some((key) => !["provider", "model", "reasoning"].includes(key))) { return false; } return isChoice(candidate.provider) && isChoice(candidate.model) && isReasoning(candidate.reasoning); } 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()); }