import { access, chmod, mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { defaultRosterConfig, FREE_ROLES } from "../config/defaults.js"; import { discoverFreeModels, FREE_MODELS } from "../models/free-profile.js"; import { rolesForRoster } from "../models/roster.js"; import type { ModelRosterConfig, Policy, PrivacyProfile, UltraConfig } from "../types.js"; import { ensureStorage, loadConfig, pathsFor, updateConfig, writeJsonAtomic } from "../config/loader.js"; import { FREE_UNAVAILABLE_MARKER, PROFILE_MARKER, inspectProfileMarker } from "./profile-marker.js"; export { assertAutoModeProfile, inspectProfileMarker } from "./profile-marker.js"; export interface ProfilePaths { root: string; privateDir: string; freeDir: string } export interface PrivateProfileDefaults { weeklyCreditBudget: number; dailyCreditBudget: number; perTaskPolicy: Policy } const SENSITIVE_MCP_VALUE = /(?:auth|oauth|token|secret|password|credential|api.?key|bearer|header|env|--(?:key|token|secret|password)|(?:^|[\s_-])(?:key|token|secret|password)(?:$|[=:\s]))/i; const MCP_TRANSPORTS = ["stdio", "sse", "streamable-http", "http", "websocket"] as const; const MCP_FIELDS = new Set(["command", "args", "env", "url", "headers", "transport", "type", "cwd", "enabled", "disabled", "timeout", "alwaysAllow"]); export function profilePaths(_agentDir: string, home = homedir()): ProfilePaths { return { root: home, privateDir: join(home, ".pi-private"), freeDir: join(home, ".pi-free") }; } async function readJson(file: string): Promise { try { return JSON.parse(await readFile(file, "utf8")); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } } function object(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; } function safeMcpUrl(value: unknown): string | undefined { if (typeof value !== "string" || SENSITIVE_MCP_VALUE.test(value)) return undefined; try { const url = new URL(value); if (!/^https?:$/.test(url.protocol) || url.username || url.password || url.search || url.hash) return undefined; return url.toString(); } catch { return undefined; } } function safeMcpServer(value: unknown): Record | undefined { const source = object(value); if (!source) return undefined; const safe: Record = {}; if (typeof source.command === "string" && !SENSITIVE_MCP_VALUE.test(source.command)) safe.command = source.command; if (typeof source.transport === "string" && ["stdio", "sse", "streamable-http", "http", "websocket"].includes(source.transport)) safe.transport = source.transport; if (typeof source.type === "string" && ["stdio", "sse", "streamable-http", "http", "websocket"].includes(source.type)) safe.type = source.type; const url = safeMcpUrl(source.url); if (url) safe.url = url; return Object.keys(safe).length ? safe : undefined; } function safeMcpDocument(value: unknown): { mcpServers: Record> } | undefined { const source = object(value); const servers = object(source?.mcpServers) ?? object(source?.servers); if (!servers) return undefined; const mcpServers = Object.fromEntries(Object.entries(servers).flatMap(([name, server]) => { const safe = safeMcpServer(server); return safe ? [[name, safe]] : []; })); return Object.keys(mcpServers).length ? { mcpServers } : undefined; } function validMcpServer(value: unknown): boolean { const source = object(value); if (!source || Object.keys(source).some((key) => !MCP_FIELDS.has(key))) return false; const hasCommand = Object.hasOwn(source, "command"); const hasUrl = Object.hasOwn(source, "url"); if (hasCommand === hasUrl || (hasCommand && (typeof source.command !== "string" || !source.command.trim() || SENSITIVE_MCP_VALUE.test(source.command)))) return false; if (hasUrl && !safeMcpUrl(source.url)) return false; if (Object.hasOwn(source, "args") && (!Array.isArray(source.args) || source.args.some((value) => typeof value !== "string"))) return false; for (const key of ["env", "headers"] as const) { const value = source[key]; if (value !== undefined && (!value || typeof value !== "object" || Array.isArray(value) || Object.values(value as Record).some((entry) => typeof entry !== "string"))) return false; } for (const key of ["transport", "type"] as const) if (source[key] !== undefined && (typeof source[key] !== "string" || !MCP_TRANSPORTS.includes(source[key] as typeof MCP_TRANSPORTS[number]))) return false; if (source.transport !== undefined && source.type !== undefined && source.transport !== source.type) return false; if (source.cwd !== undefined && typeof source.cwd !== "string") return false; if (source.enabled !== undefined && typeof source.enabled !== "boolean") return false; if (source.disabled !== undefined && typeof source.disabled !== "boolean") return false; if (source.alwaysAllow !== undefined && (!Array.isArray(source.alwaysAllow) || source.alwaysAllow.some((entry) => typeof entry !== "string"))) return false; if (source.timeout !== undefined && (typeof source.timeout !== "number" || !Number.isFinite(source.timeout))) return false; return true; } function safeMcpFromSettings(value: unknown): { mcpServers: Record> } | undefined { const settings = object(value); return safeMcpDocument(settings?.mcpServers ? { mcpServers: settings.mcpServers } : settings?.mcp ? { mcpServers: settings.mcp } : undefined); } function mergeMcp(...values: Array<{ mcpServers: Record> } | undefined>): { mcpServers: Record> } | undefined { const mcpServers: Record> = {}; for (const value of values) for (const [name, server] of Object.entries(value?.mcpServers ?? {})) mcpServers[name] = server; return Object.keys(mcpServers).length ? { mcpServers } : undefined; } async function safeMcpFile(directory: string): Promise<{ value?: { mcpServers: Record> }; exists: boolean }> { const file = join(directory, "mcp.json"); try { await access(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { exists: false }; throw error; } return { value: safeMcpDocument(await readJson(file)), exists: true }; } export interface McpSettingsStatus { present: boolean; valid: boolean; serverCount: number; reason: "ok" | "missing" | "invalid-json" | "invalid-structure" | "no-safe-servers" } function mcpStatus(value: unknown): McpSettingsStatus { const root = object(value); if (!root || (!root.mcpServers && !root.servers)) return { present: true, valid: false, serverCount: 0, reason: "invalid-structure" }; const servers = object(root.mcpServers) ?? object(root.servers); if (!servers || Object.keys(servers).some((name) => !name.trim() || !validMcpServer(servers[name]))) return { present: true, valid: false, serverCount: 0, reason: "invalid-structure" }; const safe = safeMcpDocument(value); if (!safe) return { present: true, valid: false, serverCount: 0, reason: "no-safe-servers" }; return { present: true, valid: true, serverCount: Object.keys(safe.mcpServers).length, reason: "ok" }; } export async function inspectMcpSettings(agentDir: string): Promise { const mcpFile = join(agentDir, "mcp.json"); try { await access(mcpFile); try { return mcpStatus(await readJson(mcpFile)); } catch (error) { if (error instanceof SyntaxError) return { present: true, valid: false, serverCount: 0, reason: "invalid-json" }; throw error; } } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } const settings = object(await readJson(join(agentDir, "settings.json"))); const embedded = settings?.mcpServers ? { mcpServers: settings.mcpServers } : settings?.mcp ? { mcpServers: settings.mcp } : undefined; return embedded ? mcpStatus(embedded) : { present: false, valid: false, serverCount: 0, reason: "missing" }; } export async function hasMcpSettings(agentDir: string): Promise { try { await access(join(agentDir, "mcp.json")); return true; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; const settings = object(await readJson(join(agentDir, "settings.json"))); return Boolean(settings?.mcpServers || settings?.mcp); } } /** * Rewrites a profile's config so every role is drawn from `roster`, and records the roster * itself. Returns without creating a config version when nothing would change, so re-running * the installer stays idempotent. */ async function applyRoster(directory: string, roster: ModelRosterConfig, reason: string, clamp: (config: UltraConfig) => UltraConfig = (config) => config): Promise { const storage = pathsFor(directory); const current = await loadConfig(storage); const roles = rolesForRoster(roster.tiers, roster.nonCritical ?? []); const byLens = current.scout.modelByLens; const next = clamp({ ...current, models: roster, root: { ...current.root, model: roles.stable }, scout: { ...current.scout, model: roles.cheap, ...(byLens ? { modelByLens: Object.fromEntries(Object.entries(byLens).map(([lens, model]) => [lens, roster.tiers.includes(model) ? model : roles.stable])) } : {}) }, boundedWriter: { ...current.boundedWriter, model: roles.deciding }, repair: { ...current.repair, model: roles.deciding }, deep: { ...current.deep, model: roles.strong }, arbitration: { ...current.arbitration, model: roles.strong }, }); if (JSON.stringify(next) === JSON.stringify(current)) return; await updateConfig(storage, current, () => next, reason); } export async function discoverConfiguredFreeModels(agentDir: string, candidates: readonly string[] = FREE_MODELS): Promise { const store = await readJson(join(agentDir, "models-store.json")).catch(() => undefined); if (!store || typeof store !== "object" || Array.isArray(store)) return []; const available = new Set(); for (const [provider, entry] of Object.entries(store)) { if (!entry || typeof entry !== "object" || !Array.isArray((entry as { models?: unknown }).models)) continue; for (const model of (entry as { models: unknown[] }).models) { if (model && typeof model === "object" && typeof (model as { id?: unknown }).id === "string") available.add(`${provider}/${(model as { id: string }).id}`); } } const settings = await readJson(join(agentDir, "settings.json")).catch(() => undefined); const configured = settings && typeof settings === "object" && Array.isArray((settings as { enabledModels?: unknown }).enabledModels) ? new Set((settings as { enabledModels: unknown[] }).enabledModels.filter((value): value is string => typeof value === "string" && !/[?*[\]]/.test(value))) : undefined; return [...new Set(candidates)].filter((model) => available.has(model) && (!configured || configured.size === 0 || configured.has(model) || configured.has(model.slice(model.indexOf("/") + 1)))); } export async function createProfiles(agentDir: string, extensionDir: string, home = homedir(), privateDefaults?: PrivateProfileDefaults, privateRoster: ModelRosterConfig = defaultRosterConfig("private")): Promise { const paths = profilePaths(agentDir, home); await Promise.all([paths.privateDir, paths.freeDir].map((path) => mkdir(path, { recursive: true, mode: 0o700 }))); const writeProfile = async (profile: PrivacyProfile, directory: string, enabledModels: string[], defaultModel?: string) => { const extensionEntry = join(extensionDir, "src", "index.ts"); const defaults = defaultModel ? { defaultProvider: defaultModel.slice(0, defaultModel.indexOf("/")), defaultModel: defaultModel.slice(defaultModel.indexOf("/") + 1) } : {}; const sourceSettings = await readJson(join(agentDir, "settings.json")); const existingSettings = await readJson(join(directory, "settings.json")); const sourceMcpFile = await safeMcpFile(agentDir); const existingMcpFile = await safeMcpFile(directory); const mcp = mergeMcp(safeMcpFromSettings(sourceSettings), sourceMcpFile.value, safeMcpFromSettings(existingSettings), existingMcpFile.value); const settings = { ...defaults, enabledModels, extensions: [extensionEntry], ...(mcp && (safeMcpFromSettings(sourceSettings) || safeMcpFromSettings(existingSettings)) ? mcp : {}) }; await writeFile(join(directory, "settings.json"), `${JSON.stringify(settings, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); await chmod(join(directory, "settings.json"), 0o600); if (mcp && (sourceMcpFile.exists || existingMcpFile.exists)) { await writeJsonAtomic(join(directory, "mcp.json"), mcp); } await writeFile(join(directory, PROFILE_MARKER), `${JSON.stringify({ profile }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); await chmod(join(directory, PROFILE_MARKER), 0o600); const storage = pathsFor(directory); await ensureStorage(storage, profile); if (profile === "private" && privateDefaults) { const current = await loadConfig(storage); if (current.policy !== privateDefaults.perTaskPolicy || current.budgets.weeklyCreditBudget !== privateDefaults.weeklyCreditBudget || current.budgets.dailyCreditBudget !== privateDefaults.dailyCreditBudget || !current.budgets.acknowledged) { await updateConfig(storage, current, (config) => ({ ...config, policy: privateDefaults.perTaskPolicy, budgets: { weeklyCreditBudget: privateDefaults.weeklyCreditBudget, dailyCreditBudget: privateDefaults.dailyCreditBudget, acknowledged: true } }), "installer:private-defaults"); } } }; const privateRoles = rolesForRoster(privateRoster.tiers, privateRoster.nonCritical ?? []); await writeProfile("private", paths.privateDir, [...privateRoster.tiers], privateRoles.stable); await applyRoster(paths.privateDir, privateRoster, "installer:private-roster"); const freeModels = await discoverConfiguredFreeModels(agentDir); const mapping = discoverFreeModels(freeModels); const freeReady = mapping.normal.enabled && mapping.deep.enabled; await writeProfile("free", paths.freeDir, freeModels.length > 0 ? freeModels : ["opencode/__ultrapi-no-free-models__"], freeReady ? mapping.normal.model : undefined); const marker = join(paths.freeDir, FREE_UNAVAILABLE_MARKER); if (freeReady) { await unlink(marker).catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") throw error; }); // The free roster is whatever the host actually has configured, weakest first, with the // scout-grade model barred from deciding unless it is the only model there is. const nonCritical = freeModels.length > 1 ? freeModels.filter((model) => model === FREE_ROLES.scout) : []; await applyRoster(paths.freeDir, { tiers: freeModels, ...(nonCritical.length ? { nonCritical } : {}) }, "installer:free-roster", (config) => ({ ...config, scout: { ...config.scout, maxParallel: Math.min(config.scout.maxParallel, 2) }, piAgentsBudgets: { ...config.piAgentsBudgets, maxParallelism: Math.min(config.piAgentsBudgets.maxParallelism, 2) }, })); } else await writeFile(marker, "No configured critical free model was discovered.\n", { encoding: "utf8", mode: 0o600 }); return paths; } export async function profileForAgentDir(agentDir: string): Promise { const marker = await inspectProfileMarker(agentDir); if (marker.reason === "free-unavailable") throw new Error("UltraPi free profile unavailable: no configured critical free model was discovered"); return marker.valid ? marker.profile : undefined; } export async function writeProfileLaunchers(binDir: string, profiles: ProfilePaths): Promise { await mkdir(binDir, { recursive: true, mode: 0o700 }); for (const [name, profile] of [["pi-private", profiles.privateDir], ["pi-free", profiles.freeDir]] as const) { const file = join(binDir, name); const unavailable = name === "pi-free" && await readFile(join(profile, FREE_UNAVAILABLE_MARKER), "utf8").then(() => true, (error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return false; throw error; }); const script = unavailable ? "#!/bin/sh\necho 'UltraPi free profile unavailable: no configured critical free model was discovered.' >&2\nexit 1\n" // POSIX single-quote escaping is `'\''`: close, emit a literal quote, reopen. The // previous expression produced `'\"'\"`, which still parses -- so `sh -n` is happy and // nothing errors -- but expands to the wrong path. A user whose home is /Users/O'Brien // got a launcher pointing at a directory that does not exist, with no diagnostic. : `#!/bin/sh\nexec env PI_CODING_AGENT_DIR='${profile.replace(/'/g, "'\\''")}' pi \"$@\"\n`; await writeFile(file, script, { encoding: "utf8", mode: 0o700 }); await chmod(file, 0o700); } }