feat: expose safe pi management api

This commit is contained in:
2026-08-05 00:31:44 +02:00
parent 09eeea17e5
commit d6b4a08a02
10 changed files with 744 additions and 61 deletions
+5
View File
@@ -13,12 +13,14 @@ import { sqlRoutes } from "./routes/sql.js";
import { metaRoutes, type ListModelsFn } from "./routes/meta.js";
import { settingsRoutes, effectiveSettings } from "./routes/settings.js";
import { createPiModelLister } from "./pi/list-models.js";
import { createPiManagement, type PiManagementService } from "./pi/management.js";
import { loadSettings, type Settings } from "./settings/settings-store.js";
import { ReadinessManager } from "./runtime/readiness-manager.js";
import { MaintenanceBarrier } from "./runtime/maintenance-gate.js";
import { WorkspaceRegistry } from "./workspaces/registry.js";
import { createProductionWorkspaceDiagnoser } from "./workspaces/diagnostics.js";
import { workspaceRoutes, type WorkspaceDiagnoser } from "./routes/workspaces.js";
import { piManagementRoutes } from "./routes/pi-management.js";
import { resolveRuntimeBindings, supportsSessionRuntime } from "./workspaces/bindings.js";
import type { WorkspaceDescriptor } from "./workspaces/schema.js";
@@ -34,6 +36,7 @@ export interface BuildAppDeps {
workspaceDiagnoser?: WorkspaceDiagnoser;
workspaceRuntimeSupport?: (workspace: WorkspaceDescriptor) => boolean;
maintenanceBarrier?: MaintenanceBarrier;
piManagement?: PiManagementService;
}
export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance {
@@ -86,6 +89,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
if (deps?.getSettings) return await deps.getSettings(principal);
return effectiveSettings(config, loadSettings(config));
};
const piManagement = deps?.piManagement ?? createPiManagement(config, { listModels });
const maintenanceBarrier = deps?.maintenanceBarrier ?? new MaintenanceBarrier(config.maintenanceFile);
const authenticate = authPreHandler(config.authMode);
@@ -141,6 +145,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
metaRoutes(app, { harnessDir: config.harnessDir, listModels });
workspaceRoutes(app, { registry: workspaceRegistry, config: config.workspaceRegistry, diagnose: workspaceDiagnoser });
settingsRoutes(app, { cfg: config, listModels, getSettings });
piManagementRoutes(app, { config, service: piManagement });
return app;
}
+15 -2
View File
@@ -4,6 +4,7 @@ import type { WorkspaceRegistryConfig } from "./workspaces/types.js";
export interface AppConfig {
host: string; port: number; harnessDir: string; thtBin: string; piBin: string;
authMode: "none" | "mock" | "upstream";
publicExposure: boolean;
sessionStorage: {
mode: "local" | "postgres";
host?: string; port?: number; database?: string; runtimeUser?: string;
@@ -15,6 +16,7 @@ export interface AppConfig {
maintenanceFile: string;
dataRoot?: string;
ollamaEnsureTimeoutMs: number;
piManagementTimeoutMs: number;
secretsFile?: string;
secretFiles: Readonly<Record<string, string | undefined>>;
modelApiKeyFile?: string;
@@ -91,19 +93,28 @@ function diagnosticTimeout(value: string | undefined): number {
return timeout;
}
function piManagementTimeout(value: string | undefined): number {
const timeout = Number(value ?? 8_000);
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 30_000) {
throw new Error("Pi management timeout configuration is invalid");
}
return timeout;
}
export function loadConfig(env: Record<string, string | undefined>): AppConfig {
const authMode = env.AUTH_MODE ?? "none";
if (!(["none", "mock", "upstream"] as const).includes(authMode as AppConfig["authMode"])) {
throw new Error(`unsupported AUTH_MODE=${authMode}; use none, mock, or upstream`);
}
if (env.THOTH_PUBLIC_EXPOSURE === "true" && authMode !== "upstream") {
const publicExposure = env.THOTH_PUBLIC_EXPOSURE === "true";
if (publicExposure && authMode !== "upstream") {
throw new Error("public exposure requires AUTH_MODE=upstream behind a trusted proxy");
}
const sessionStorageMode = env.THT_SESSION_STORAGE ?? "local";
if (sessionStorageMode !== "local" && sessionStorageMode !== "postgres") {
throw new Error("session storage configuration is invalid");
}
if (sessionStorageMode === "local" && env.THOTH_PUBLIC_EXPOSURE === "true") {
if (sessionStorageMode === "local" && publicExposure) {
throw new Error("local session storage requires loopback-only deployment");
}
const legacyWorkspaceMode = env.THT_LEGACY_WORKSPACE_MODE;
@@ -204,6 +215,7 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
thtBin: env.THT_BIN ?? "tht",
piBin: env.PI_BIN ?? "pi",
authMode: authMode as AppConfig["authMode"],
publicExposure,
sessionStorage,
defaults: { provider: env.PI_PROVIDER, model: env.PI_MODEL, thinking: env.PI_THINKING },
maxPiProcesses: Number(env.MAX_PI_PROCESSES ?? 4),
@@ -211,6 +223,7 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
maintenanceFile: env.THT_MAINTENANCE_FILE ?? path.join(path.dirname(settingsFile), "maintenance.json"),
dataRoot: env.THT_DATA_ROOT,
ollamaEnsureTimeoutMs: Number(env.OLLAMA_ENSURE_TIMEOUT_MS ?? 60000),
piManagementTimeoutMs: piManagementTimeout(env.PI_MANAGEMENT_TIMEOUT_MS),
secretsFile,
secretFiles,
modelApiKeyFile,
+285
View File
@@ -0,0 +1,285 @@
import { execFile as nodeExecFile } from "node:child_process";
import { promisify } from "node:util";
import type { AppConfig } from "../config.js";
import {
loadSettings,
saveSettings,
type Settings,
} from "../settings/settings-store.js";
import type { PiModel } from "./list-models.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;
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<PiStatus>;
options(): Promise<PiOptions>;
configure(value: PiInstallationConfig): Promise<PiInstallationConfig & { updatedAt: string }>;
test(): Promise<PiTestResult>;
logs(): Promise<PiLogs>;
}
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<PiModel[]>;
readSettings?: () => Settings;
saveSettings?: (settings: Settings) => Settings;
readLogs?: () => string | Promise<string>;
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 closedOptions = async (): Promise<Omit<PiOptions, "checkedAt">> => {
let listed: PiModel[];
try {
listed = await deps.listModels();
} catch {
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<string>();
const seenProviders = new Set<string>();
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 (): Promise<string> => {
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: config.piManagementTimeoutMs,
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<PiStatus> {
const checkedAt = now().toISOString();
const current = installationConfig();
try {
const currentVersion = await version();
addDiagnostic("Pi version probe succeeded");
return { version: currentVersion, ready: true, config: current, checkedAt };
} catch (error) {
const message = stableMessage(error, "Pi runtime is unavailable");
addDiagnostic(message);
return { ready: false, config: current, checkedAt, message };
}
},
async options(): Promise<PiOptions> {
const choices = await closedOptions();
return { ...choices, checkedAt: now().toISOString() };
},
async configure(value: PiInstallationConfig): Promise<PiInstallationConfig & { updatedAt: string }> {
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<PiTestResult> {
const checkedAt = now().toISOString();
try {
await version();
const current = installationConfig();
if (!current.provider || !current.model || !current.reasoning) {
return smokeFailure("Pi installation configuration is incomplete", checkedAt, addDiagnostic);
}
const choices = await closedOptions();
if (!choices.models.some((model) => model.provider === current.provider && model.id === current.model)) {
return smokeFailure("Configured Pi provider and model are unavailable", checkedAt, addDiagnostic);
}
addDiagnostic("Pi smoke check succeeded");
return { ready: true, checkedAt };
} catch (error) {
return smokeFailure(stableMessage(error, "Pi smoke check failed"), checkedAt, addDiagnostic);
}
},
async logs(): Promise<PiLogs> {
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<PiInstallationConfig> {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const candidate = value as Record<string, unknown>;
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 {
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(/(\b(?:api[_-]?key|token|password|secret|authorization)\b\s*(?:=|:)\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;]+)/giu, "$1[REDACTED]")
.replace(/(\bBearer\s+)[^\s,;]+/giu, "$1[REDACTED]")
.replace(/(\w+:\/\/[^:/\s]+:)[^@/\s]+@/gu, "$1[REDACTED]@");
}
+46
View File
@@ -0,0 +1,46 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { getPrincipal } from "../auth/auth.js";
import type { AppConfig } from "../config.js";
import { PiManagementError, type PiManagementService } from "../pi/management.js";
export function piManagementRoutes(
app: FastifyInstance,
deps: { config: AppConfig; service: PiManagementService },
): void {
app.get("/pi-management/status", async (request, reply) => run(request, reply, deps, () => deps.service.status()));
app.get("/pi-management/options", async (request, reply) => run(request, reply, deps, () => deps.service.options()));
app.put("/pi-management/config", async (request, reply) => run(
request,
reply,
deps,
() => deps.service.configure((request.body ?? {}) as Record<string, unknown>),
));
app.post("/pi-management/test", async (request, reply) => run(request, reply, deps, () => deps.service.test()));
app.get("/pi-management/logs", async (request, reply) => run(request, reply, deps, () => deps.service.logs()));
}
async function run<T>(
request: FastifyRequest,
reply: FastifyReply,
deps: { config: AppConfig; service: PiManagementService },
action: () => Promise<T>,
): Promise<T | FastifyReply> {
const principal = getPrincipal(request);
if (!managementAllowed(deps.config, principal.isAdmin)) {
return reply.code(403).send({ code: "pi_management_forbidden", error: "Pi management is not permitted" });
}
try {
return await action();
} catch (error) {
if (error instanceof PiManagementError) {
const statusCode = error.code === "pi_management_invalid_config" ? 400 : 503;
return reply.code(statusCode).send({ code: error.code, error: error.message });
}
return reply.code(503).send({ code: "pi_management_unavailable", error: "Pi management is unavailable" });
}
}
function managementAllowed(config: AppConfig, isAdmin: boolean): boolean {
return (config.authMode === "none" && !config.publicExposure)
|| (config.authMode === "upstream" && isAdmin);
}