diff --git a/backend/src/app.ts b/backend/src/app.ts index 085ef8f6..cb357f48 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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; } diff --git a/backend/src/config.ts b/backend/src/config.ts index 7bfd1f20..12df1d6e 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -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>; 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): 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): 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): 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, diff --git a/backend/src/pi/management.ts b/backend/src/pi/management.ts new file mode 100644 index 00000000..8b7eb58d --- /dev/null +++ b/backend/src/pi/management.ts @@ -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; + 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; + readSettings?: () => Settings; + saveSettings?: (settings: Settings) => Settings; + readLogs?: () => string | Promise; + 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> => { + 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(); + 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 (): 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: 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 { + 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 { + 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(); + 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 { + 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 { + 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]@"); +} diff --git a/backend/src/routes/pi-management.ts b/backend/src/routes/pi-management.ts new file mode 100644 index 00000000..6fba64fe --- /dev/null +++ b/backend/src/routes/pi-management.ts @@ -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), + )); + 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( + request: FastifyRequest, + reply: FastifyReply, + deps: { config: AppConfig; service: PiManagementService }, + action: () => Promise, +): Promise { + 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); +} diff --git a/backend/test/pi-management.test.ts b/backend/test/pi-management.test.ts new file mode 100644 index 00000000..8963f6e5 --- /dev/null +++ b/backend/test/pi-management.test.ts @@ -0,0 +1,163 @@ +import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { loadConfig } from "../src/config.js"; +import { + PiManagementError, + createPiManagement, + type PiExecFile, +} from "../src/pi/management.js"; + +function configFor(settingsFile = join(mkdtempSync(join(tmpdir(), "tht-pi-management-")), "settings.json")) { + return loadConfig({ + THT_HARNESS_DIR: "../harness", + SETTINGS_FILE: settingsFile, + PI_BIN: "/usr/local/bin/pi", + PI_MANAGEMENT_TIMEOUT_MS: "750", + }); +} + +const supportedModels = [ + { provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }, + { provider: "deepseek", id: "deepseek-v4", name: "DeepSeek V4", reasoning: true }, +]; + +function successfulExec(calls: Array<{ command: string; args: string[]; timeout: number }>): PiExecFile { + return async (command, args, options) => { + calls.push({ command, args, timeout: options.timeout }); + return { stdout: "pi 0.80.3\n", stderr: "" }; + }; +} + +// Catches a Pi executable that emits unexpected text or is invoked through a shell, which could +// turn a version display into a command-injection or information-disclosure surface. +test("status parses only a Pi version from a fixed execFile argument array", async () => { + const calls: Array<{ command: string; args: string[]; timeout: number }> = []; + const service = createPiManagement(configFor(), { + execute: successfulExec(calls), + listModels: async () => supportedModels, + readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }), + now: () => new Date("2026-08-05T10:00:00.000Z"), + }); + + await expect(service.status()).resolves.toEqual({ + version: "0.80.3", + ready: true, + config: { provider: "zai", model: "glm-5.2", reasoning: "medium" }, + checkedAt: "2026-08-05T10:00:00.000Z", + }); + expect(calls).toEqual([{ command: "/usr/local/bin/pi", args: ["--version"], timeout: 750 }]); +}); + +// Catches an options response that leaks provider metadata or lets callers choose model IDs that +// Pi did not explicitly enable for this installation. +test("options expose only closed provider, model, and reasoning choices", async () => { + const service = createPiManagement(configFor(), { + execute: successfulExec([]), + listModels: async () => supportedModels, + now: () => new Date("2026-08-05T10:00:00.000Z"), + }); + + await expect(service.options()).resolves.toEqual({ + providers: ["zai", "deepseek"], + models: [ + { provider: "zai", id: "glm-5.2" }, + { provider: "deepseek", id: "deepseek-v4" }, + ], + reasoning: ["low", "medium", "high"], + checkedAt: "2026-08-05T10:00:00.000Z", + }); +}); + +// Catches configuration writes that accept whitespace, unknown choices, or extra free-form fields +// before reaching the durable installation settings file. +test("config rejects invalid free-form values before writing settings", async () => { + const directory = mkdtempSync(join(tmpdir(), "tht-pi-management-invalid-")); + try { + let writes = 0; + const service = createPiManagement(configFor(join(directory, "settings.json")), { + execute: successfulExec([]), + listModels: async () => supportedModels, + readSettings: () => ({}), + saveSettings: () => { writes += 1; return {}; }, + }); + + await expect(service.configure({ + provider: "zai ", model: "glm-5.2", reasoning: "medium", unexpected: "value", + } as any)).rejects.toMatchObject({ code: "pi_management_invalid_config" }); + expect(writes).toBe(0); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +// Catches a non-atomic implementation that can leave partial settings or temporary files after a +// normal installation-default update. +test("config validates closed choices and atomically persists non-secret defaults", async () => { + const directory = mkdtempSync(join(tmpdir(), "tht-pi-management-write-")); + const settingsFile = join(directory, "settings.json"); + try { + const service = createPiManagement(configFor(settingsFile), { + execute: successfulExec([]), + listModels: async () => supportedModels, + now: () => new Date("2026-08-05T10:00:00.000Z"), + }); + + await expect(service.configure({ + provider: "zai", model: "glm-5.2", reasoning: "high", + })).resolves.toEqual({ + provider: "zai", model: "glm-5.2", reasoning: "high", updatedAt: "2026-08-05T10:00:00.000Z", + }); + expect(JSON.parse(readFileSync(settingsFile, "utf8"))).toEqual({ + provider: "zai", model: "glm-5.2", thinking: "high", + }); + expect(readdirSync(directory)).toEqual(["settings.json"]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +// Catches a hung Pi smoke check that leaves an operator waiting indefinitely or returns raw child +// diagnostics containing provider credentials. +test("smoke uses the configured timeout and reports a sanitized timeout", async () => { + const calls: Array<{ command: string; args: string[]; timeout: number }> = []; + const service = createPiManagement(configFor(), { + execute: async (command, args, options) => { + calls.push({ command, args, timeout: options.timeout }); + throw Object.assign(new Error("provider token=raw-provider-token"), { code: "ETIMEDOUT" }); + }, + listModels: async () => supportedModels, + readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }), + now: () => new Date("2026-08-05T10:00:00.000Z"), + }); + + await expect(service.test()).resolves.toEqual({ + ready: false, + message: "Pi smoke check timed out", + checkedAt: "2026-08-05T10:00:00.000Z", + }); + expect(calls).toEqual([{ command: "/usr/local/bin/pi", args: ["--version"], timeout: 750 }]); +}); + +// Catches an unbounded diagnostics endpoint or one that returns bearer tokens and connection +// passwords captured in Pi output. +test("logs keep only the latest 200 redacted lines", async () => { + const source = Array.from({ length: 205 }, (_, index) => `line-${index + 1}`); + source[203] = "Authorization: Bearer raw-bearer-token"; + source[204] = "database_url=postgres://thoth:raw-db-password@example.invalid/db"; + const service = createPiManagement(configFor(), { + execute: successfulExec([]), + listModels: async () => supportedModels, + readLogs: () => source.join("\n"), + now: () => new Date("2026-08-05T10:00:00.000Z"), + }); + + const logs = await service.logs(); + expect(logs.checkedAt).toBe("2026-08-05T10:00:00.000Z"); + expect(logs.lines).toHaveLength(200); + expect(logs.lines[0]).toBe("line-6"); + expect(logs.lines.join("\n")).not.toContain("raw-bearer-token"); + expect(logs.lines.join("\n")).not.toContain("raw-db-password"); + expect(logs.lines.join("\n")).toContain("[REDACTED]"); +}); diff --git a/backend/test/routes-pi-management.test.ts b/backend/test/routes-pi-management.test.ts new file mode 100644 index 00000000..35d52a26 --- /dev/null +++ b/backend/test/routes-pi-management.test.ts @@ -0,0 +1,112 @@ +import { expect, test, vi } from "vitest"; +import { buildApp } from "../src/app.js"; +import { loadConfig } from "../src/config.js"; +import type { PiManagementService } from "../src/pi/management.js"; +import { piManagementRoutes } from "../src/routes/pi-management.js"; + +void piManagementRoutes; + +function fakeService(): PiManagementService { + return { + status: vi.fn(async () => ({ + version: "0.80.3", ready: true, + config: { provider: "zai", model: "glm-5.2", reasoning: "medium" }, + checkedAt: "2026-08-05T10:00:00.000Z", + })), + options: vi.fn(async () => ({ + providers: ["zai"], models: [{ provider: "zai", id: "glm-5.2" }], + reasoning: ["low", "medium", "high"], checkedAt: "2026-08-05T10:00:00.000Z", + })), + configure: vi.fn(async (value) => ({ ...value, updatedAt: "2026-08-05T10:00:00.000Z" })), + test: vi.fn(async () => ({ ready: true, checkedAt: "2026-08-05T10:00:00.000Z" })), + logs: vi.fn(async () => ({ lines: ["Pi smoke check succeeded"], checkedAt: "2026-08-05T10:00:00.000Z" })), + }; +} + +function appWith(service: PiManagementService, env: Record = {}) { + return buildApp(loadConfig({ THT_HARNESS_DIR: "../harness", ...env }), { + thtRunner: {} as any, + piManagement: service, + }); +} + +const adminHeaders = { + "x-thoth-principal-issuer": "portal", + "x-thoth-principal-subject": "operator", + "x-thoth-is-admin": "1", +}; + +const exposedServerEnv = { + AUTH_MODE: "upstream", + THOTH_PUBLIC_EXPOSURE: "true", + THT_SESSION_STORAGE: "postgres", + THT_SESSION_DB_HOST: "db.example.invalid", + THT_SESSION_DB_NAME: "thoth_sessions", + THT_SESSION_RUNTIME_USER: "thoth_runtime", + THT_SESSION_RUNTIME_PASSWORD_FILE: "/run/secrets/session-password", + THT_SESSION_DB_SSLMODE: "verify-full", + THT_SESSION_DB_SSLROOTCERT: "/run/secrets/session-ca.pem", +}; + +// Catches a server deployment that lets an ordinary authenticated user inspect or mutate +// installation-wide Pi configuration without the trusted upstream admin claim. +test("exposed upstream deployments reject Pi Management without a trusted admin identity", async () => { + const service = fakeService(); + const app = appWith(service, exposedServerEnv); + try { + const response = await app.inject({ + method: "GET", url: "/pi-management/status", + headers: { ...adminHeaders, "x-thoth-is-admin": "0" }, + }); + + expect(response.statusCode).toBe(403); + expect(response.json()).toEqual({ code: "pi_management_forbidden", error: "Pi management is not permitted" }); + expect(service.status).not.toHaveBeenCalled(); + } finally { + await app.close(); + } +}); + +// Catches an accidental privilege regression that blocks safe loopback-only installations or +// returns fields beyond the sanctioned Pi Management status contract. +test("loopback-only AUTH_MODE=none may read the sanitized Pi status", async () => { + const app = appWith(fakeService()); + try { + const response = await app.inject({ method: "GET", url: "/pi-management/status" }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + version: "0.80.3", ready: true, + config: { provider: "zai", model: "glm-5.2", reasoning: "medium" }, + checkedAt: "2026-08-05T10:00:00.000Z", + }); + } finally { + await app.close(); + } +}); + +// Catches route wiring that bypasses closed service validation or gives the browser a Docker/image +// lifecycle endpoint rather than only installation-default configuration and diagnostics. +test("trusted admins receive only configuration, smoke, options, and log endpoints", async () => { + const service = fakeService(); + const app = appWith(service, exposedServerEnv); + try { + const options = await app.inject({ method: "GET", url: "/pi-management/options", headers: adminHeaders }); + const configured = await app.inject({ + method: "PUT", url: "/pi-management/config", headers: adminHeaders, + payload: { provider: "zai", model: "glm-5.2", reasoning: "high" }, + }); + const smoke = await app.inject({ method: "POST", url: "/pi-management/test", headers: adminHeaders }); + const logs = await app.inject({ method: "GET", url: "/pi-management/logs", headers: adminHeaders }); + + expect(options.statusCode).toBe(200); + expect(configured.statusCode).toBe(200); + expect(configured.json()).toMatchObject({ provider: "zai", model: "glm-5.2", reasoning: "high" }); + expect(smoke.statusCode).toBe(200); + expect(logs.statusCode).toBe(200); + expect(app.printRoutes()).not.toContain("update"); + expect(app.printRoutes()).not.toContain("rollback"); + } finally { + await app.close(); + } +}); diff --git a/tools/thothctl/cmd/thothctl/main_test.go b/tools/thothctl/cmd/thothctl/main_test.go index 45f76f2d..62eb4b66 100644 --- a/tools/thothctl/cmd/thothctl/main_test.go +++ b/tools/thothctl/cmd/thothctl/main_test.go @@ -17,6 +17,8 @@ import ( "github.com/aritmolab/thothii/tools/thothctl/internal/testsupport" ) +// Catches interactive configuration prompts that use retired model-only data instead of the +// provider, model, and reasoning choices supplied by the dedicated Pi Management API. func TestResolvePiConfigureUsesNumberedClosedChoicesOnlyForTTY(t *testing.T) { runner := &wizardRunner{} var prompt bytes.Buffer @@ -88,7 +90,7 @@ type wizardRunner struct{ calls []string } func (r *wizardRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) { r.calls = append(r.calls, strings.Join(args, " ")) - return compose.Result{Stdout: `{"models":[{"provider":"deepseek","id":"deepseek-v4"},{"provider":"zai","id":"glm-5.2"}]}`}, nil + return compose.Result{Stdout: `{"providers":["deepseek","zai"],"models":[{"provider":"deepseek","id":"deepseek-v4"},{"provider":"zai","id":"glm-5.2"}],"reasoning":["low","medium","high"]}`}, nil } func TestRunLogsRedactsAnUnlabelledDeclaredSecret(t *testing.T) { @@ -618,7 +620,7 @@ case " $* " in *"io.thothii.pi.version"*) printf '%s\n' '0.80.3' ;; *"PI_VERSION"*) printf '%s\n' '0.80.3' ;; *" pi --version "*) printf '%s\n' '0.80.3' ;; - *"/models "*) printf '%s\n' '{"models":[{"provider":"provider","id":"model"}]}' ;; + *"/pi-management/options "*) printf '%s\n' '{"providers":["provider"],"models":[{"provider":"provider","id":"model"}],"reasoning":["low","medium","high"]}' ;; *"settings-cli.js --snapshot"*) printf '%s\n' '{"exists":false,"rawBase64":""}' ;; *"/settings "*) printf '%s\n' '{"provider":"provider","model":"model","thinking":"medium"}' ;; *"/internal/maintenance/status "*) printf '%s\n' '{"active":true,"admissions":0}' ;; diff --git a/tools/thothctl/internal/pi/commands.go b/tools/thothctl/internal/pi/commands.go index a4bbd926..c775fab0 100644 --- a/tools/thothctl/internal/pi/commands.go +++ b/tools/thothctl/internal/pi/commands.go @@ -28,6 +28,12 @@ type ModelOption struct { ID string `json:"id"` } +type piOptions struct { + Providers []string `json:"providers"` + Models []ModelOption `json:"models"` + Reasoning []string `json:"reasoning"` +} + type settingsFileSnapshot struct { Exists bool `json:"exists"` RawBase64 string `json:"rawBase64"` @@ -46,19 +52,16 @@ func Configure(ctx context.Context, runner Runner, value Defaults) error { if !choicePattern.MatchString(value.Provider) || !choicePattern.MatchString(value.Model) { return errors.New("provider and model must be supported identifiers") } - if value.Thinking != "low" && value.Thinking != "medium" && value.Thinking != "high" { - return errors.New("thinking must be low, medium, or high") - } before, err := renderedCore(ctx, runner) if err != nil { return err } - options, err := ConfigurationOptions(ctx, runner) + options, err := configurationOptions(ctx, runner) if err != nil { return err } found := false - for _, model := range options { + for _, model := range options.Models { if model.Provider == value.Provider && model.ID == value.Model { found = true } @@ -66,6 +69,15 @@ func Configure(ctx context.Context, runner Runner, value Defaults) error { if !found { return errors.New("provider/model is not in Pi options") } + thinkingFound := false + for _, reasoning := range options.Reasoning { + if reasoning == value.Thinking { + thinkingFound = true + } + } + if !thinkingFound { + return errors.New("thinking is not in Pi options") + } old, err := captureSettingsFile(ctx, runner) if err != nil { return err @@ -107,24 +119,47 @@ func Configure(ctx context.Context, runner Runner, value Defaults) error { } func ConfigurationOptions(ctx context.Context, runner Runner) ([]ModelOption, error) { - args := append([]string{"exec", "-T", "core", "curl", "-fsS"}, internalIdentityHeaders...) - args = append(args, "http://127.0.0.1:8787/models") - models, err := runCompose(ctx, runner, args...) + options, err := configurationOptions(ctx, runner) if err != nil { - return nil, commandError("Pi options check", models, err) + return nil, err } - var payload struct { - Models []ModelOption `json:"models"` + return options.Models, nil +} + +func configurationOptions(ctx context.Context, runner Runner) (piOptions, error) { + args := append([]string{"exec", "-T", "core", "curl", "-fsS"}, internalIdentityHeaders...) + args = append(args, "http://127.0.0.1:8787/pi-management/options") + result, err := runCompose(ctx, runner, args...) + if err != nil { + return piOptions{}, commandError("Pi options check", result, err) } - if json.Unmarshal([]byte(models.Stdout), &payload) != nil || len(payload.Models) == 0 { - return nil, errors.New("Pi options response is invalid or empty") + var payload piOptions + if json.Unmarshal([]byte(result.Stdout), &payload) != nil || len(payload.Providers) == 0 || len(payload.Models) == 0 || len(payload.Reasoning) == 0 { + return piOptions{}, errors.New("Pi options response is invalid or empty") } - for _, option := range payload.Models { - if !choicePattern.MatchString(option.Provider) || !choicePattern.MatchString(option.ID) { - return nil, errors.New("Pi options response contains an invalid provider/model") + providers := make(map[string]bool, len(payload.Providers)) + for _, provider := range payload.Providers { + if !choicePattern.MatchString(provider) || providers[provider] { + return piOptions{}, errors.New("Pi options response contains an invalid provider") } + providers[provider] = true } - return payload.Models, nil + models := make(map[string]bool, len(payload.Models)) + for _, option := range payload.Models { + key := option.Provider + "\x00" + option.ID + if !providers[option.Provider] || !choicePattern.MatchString(option.ID) || models[key] { + return piOptions{}, errors.New("Pi options response contains an invalid provider/model") + } + models[key] = true + } + reasoning := make(map[string]bool, len(payload.Reasoning)) + for _, value := range payload.Reasoning { + if (value != "low" && value != "medium" && value != "high") || reasoning[value] { + return piOptions{}, errors.New("Pi options response contains an invalid reasoning choice") + } + reasoning[value] = true + } + return payload, nil } func writeDefaults(ctx context.Context, runner Runner, value Defaults) (compose.Result, error) { @@ -253,41 +288,25 @@ func expectedVersions(ctx context.Context, runner Runner) (string, string, error return expectedValue, labelValue, nil } -// Test performs the pre-Task-8 composite smoke through core's private loopback endpoint. +// Test retains the direct image-version signal, then delegates all Pi configuration/provider smoke +// validation to core's dedicated, admin-only Pi Management endpoint. func Test(ctx context.Context, runner Runner) error { if _, err := Status(ctx, runner); err != nil { return err } - health, err := runCompose(ctx, runner, "exec", "-T", "core", "curl", "-fsS", "http://127.0.0.1:8787/health") + args := append([]string{"exec", "-T", "core", "curl", "-fsS", "-X", "POST"}, internalIdentityHeaders...) + args = append(args, "http://127.0.0.1:8787/pi-management/test") + smoke, err := runCompose(ctx, runner, args...) if err != nil { - return commandError("Pi smoke check", health, err) + return commandError("Pi smoke check", smoke, err) } - var healthPayload struct { - Status string `json:"status"` + var smokePayload struct { + Ready bool `json:"ready"` } - if json.Unmarshal([]byte(health.Stdout), &healthPayload) != nil || healthPayload.Status != "ok" { - return errors.New("Pi smoke health response is not ready") + if json.Unmarshal([]byte(smoke.Stdout), &smokePayload) != nil || !smokePayload.Ready { + return errors.New("Pi smoke response is not ready") } - models, err := ConfigurationOptions(ctx, runner) - if err != nil { - return err - } - settingsArgs := append([]string{"exec", "-T", "core", "curl", "-fsS"}, internalIdentityHeaders...) - settingsArgs = append(settingsArgs, "http://127.0.0.1:8787/settings") - settings, err := runCompose(ctx, runner, settingsArgs...) - if err != nil { - return commandError("Pi smoke settings check", settings, err) - } - var selected Defaults - if json.Unmarshal([]byte(settings.Stdout), &selected) != nil || !choicePattern.MatchString(selected.Provider) || !choicePattern.MatchString(selected.Model) || (selected.Thinking != "low" && selected.Thinking != "medium" && selected.Thinking != "high") { - return errors.New("Pi smoke settings response is incomplete") - } - for _, model := range models { - if model.Provider == selected.Provider && model.ID == selected.Model { - return nil - } - } - return errors.New("configured provider/model does not match an available Pi model entry") + return nil } func renderedCore(ctx context.Context, runner Runner) (Image, error) { diff --git a/tools/thothctl/internal/pi/commands_test.go b/tools/thothctl/internal/pi/commands_test.go index c5bd36bd..c04f5c18 100644 --- a/tools/thothctl/internal/pi/commands_test.go +++ b/tools/thothctl/internal/pi/commands_test.go @@ -123,8 +123,8 @@ func (f *configureRunner) Run(_ context.Context, args []string, stdin io.Reader) endpoint = "https://drift.example.invalid" } return compose.Result{Stdout: `{"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"` + endpoint + `"}}}}`}, nil - case strings.Contains(call, "/models"): - return compose.Result{Stdout: `{"models":[{"provider":"old","id":"old-model"},{"provider":"new","id":"new-model"}]}`}, nil + case strings.Contains(call, "/pi-management/options"): + return compose.Result{Stdout: `{"providers":["old","new"],"models":[{"provider":"old","id":"old-model"},{"provider":"new","id":"new-model"}],"reasoning":["low","medium","high"]}`}, nil case strings.Contains(call, "settings-cli.js --snapshot"): raw := f.settingsRaw payload := map[string]any{"exists": f.settingsExist, "rawBase64": base64.StdEncoding.EncodeToString(raw)} @@ -218,11 +218,14 @@ func TestConfigureCompensationRestoresAbsentAndExactEmptyPriorFiles(t *testing.T } } -func TestConfigureValidatesBackendModelOptionsWritesRealCoreSettingsAndUsesUpstreamIdentity(t *testing.T) { +// Catches thothctl reading the legacy public model route instead of the admin-only closed Pi +// Management choices before it writes shared installation defaults. +func TestConfigureLoadsDedicatedClosedOptionsWritesRealCoreSettingsAndUsesUpstreamIdentity(t *testing.T) { fake := newFakeRunner() if err := Configure(context.Background(), fake, Defaults{Provider: "provider", Model: "model", Thinking: "medium"}); err != nil { t.Fatal(err) } + assertCalled(t, fake.calls, "/pi-management/options") assertCalled(t, fake.calls, "node /app/backend/dist/settings/settings-cli.js --provider provider --model model --thinking medium") assertCalled(t, fake.calls, "x-thoth-principal-subject: thothctl-maintenance") if got := strings.Join(fake.calls, "\n"); strings.Contains(got, "pi-defaults.json") || strings.Contains(got, "secret") { @@ -233,28 +236,47 @@ func TestConfigureValidatesBackendModelOptionsWritesRealCoreSettingsAndUsesUpstr } } -func TestTestUsesOnlySanitizedPiAndCoreProbes(t *testing.T) { +// Catches a smoke check that composes health/models/settings itself and drifts from the dedicated +// backend contract, rather than retaining only the independent in-container version signal. +func TestTestUsesDedicatedSmokeEndpointAndIndependentImageVersionProbe(t *testing.T) { fake := newFakeRunner() if err := Test(context.Background(), fake); err != nil { t.Fatalf("Test() error = %v", err) } - for _, command := range []string{"pi --version", "/health", "/models", "/settings"} { + for _, command := range []string{"pi --version", "/pi-management/test", "x-thoth-principal-subject: thothctl-maintenance"} { assertCalled(t, fake.calls, command) } + for _, legacy := range []string{"/health", "/models", "/settings"} { + if strings.Contains(strings.Join(fake.calls, "\n"), legacy) { + t.Fatalf("Pi smoke invoked legacy endpoint %q: %s", legacy, strings.Join(fake.calls, "\n")) + } + } if got := strings.Join(fake.calls, "\n"); strings.Contains(got, "secret") { t.Fatalf("probe commands expose secret: %s", got) } } -func TestTestRequiresConfiguredProviderAndModelToMatchOneAvailableEntry(t *testing.T) { +// Catches an ignored negative ready result from the backend smoke endpoint, which would report a +// successfully verified candidate image while its configured Pi runtime is unusable. +func TestTestRequiresDedicatedSmokeEndpointToReportReady(t *testing.T) { fake := newFakeRunner() - fake.modelsWire = `{"models":[{"id":"different-model","provider":"provider"}]}` - if err := Test(context.Background(), fake); err == nil || !strings.Contains(err.Error(), "configured provider/model") { - t.Fatalf("Test() error = %v, want exact settings/model mismatch", err) + fake.piManagementTestWire = `{"ready":false,"message":"provider unavailable"}` + if err := Test(context.Background(), fake); err == nil || !strings.Contains(err.Error(), "Pi smoke response is not ready") { + t.Fatalf("Test() error = %v, want negative dedicated smoke result", err) } - fake.modelsWire = `{"models":[{"id":"model","provider":"provider"}]}` + fake.piManagementTestWire = `{"ready":true}` if err := Test(context.Background(), fake); err != nil { t.Fatalf("Test() exact match error = %v", err) } assertCalled(t, fake.calls, "pi --version") } + +// Catches thothctl accepting a reasoning level that the backend did not publish as a closed +// installation option, which would bypass the Pi Management validation surface. +func TestConfigureRejectsReasoningOutsideDedicatedClosedOptions(t *testing.T) { + fake := newFakeRunner() + fake.piManagementOptionsWire = `{"providers":["provider"],"models":[{"provider":"provider","id":"model"}],"reasoning":["low"]}` + if err := Configure(context.Background(), fake, Defaults{Provider: "provider", Model: "model", Thinking: "high"}); err == nil || !strings.Contains(err.Error(), "Pi options") { + t.Fatalf("Configure() error = %v, want closed reasoning rejection", err) + } +} diff --git a/tools/thothctl/internal/pi/update_test.go b/tools/thothctl/internal/pi/update_test.go index 111487c8..4b80651e 100644 --- a/tools/thothctl/internal/pi/update_test.go +++ b/tools/thothctl/internal/pi/update_test.go @@ -517,6 +517,8 @@ func TestMaintenanceClearAndCompensationFailuresRemainGated(t *testing.T) { } } +// Catches maintenance recovery clearing admission after the obsolete composite smoke rather than +// the same dedicated Pi Management smoke contract used for ordinary image verification. func TestRecoverMaintenanceClearsOnlyAfterTerminalStateAndVerifiedSmoke(t *testing.T) { fake := newFakeRunner() fake.maintenance = true @@ -544,8 +546,7 @@ func TestRecoverMaintenanceClearsOnlyAfterTerminalStateAndVerifiedSmoke(t *testi if selected := readSelectorReference(t, currentImageOverridePath(statePath)); selected != previous.Reference { t.Fatalf("maintenance cleanup changed durable selector to %q", selected) } - assertCalled(t, fake.calls, "/models") - assertCalled(t, fake.calls, "/settings") + assertCalled(t, fake.calls, "/pi-management/test") } func TestRecoverMaintenanceRefusesPendingTransaction(t *testing.T) { @@ -811,6 +812,8 @@ type fakeRunner struct { backendRestarts int dropMaintenanceAfterCandidate bool modelsWire string + piManagementOptionsWire string + piManagementTestWire string rollbackPrepared bool coreRunning bool execFailuresWhileStopped int @@ -867,7 +870,7 @@ func (f *fakeRunner) Run(_ context.Context, args []string, _ io.Reader) (compose if f.fail == "version" && f.built && strings.Contains(call, "pi --version") && strings.Contains(call, "exec") { return compose.Result{ExitCode: 1}, errors.New("version token=secret") } - if f.fail == "smoke" && f.built && strings.Contains(call, "127.0.0.1:8787/models") { + if f.fail == "smoke" && f.built && strings.Contains(call, "127.0.0.1:8787/pi-management/test") { return compose.Result{ExitCode: 1}, errors.New("smoke token=secret") } switch { @@ -1016,6 +1019,19 @@ func (f *fakeRunner) Run(_ context.Context, args []string, _ io.Reader) (compose return compose.Result{Stdout: f.version + "\n"}, nil case strings.Contains(call, "PI_VERSION"): return compose.Result{Stdout: f.expectedVersion + "\n"}, nil + case strings.Contains(call, "/pi-management/options"): + if f.piManagementOptionsWire != "" { + return compose.Result{Stdout: f.piManagementOptionsWire}, nil + } + return compose.Result{Stdout: `{"providers":["provider"],"models":[{"id":"model","provider":"provider"}],"reasoning":["low","medium","high"]}`}, nil + case strings.Contains(call, "/pi-management/test"): + if f.currentImage == "sha256:old" { + f.restoredProofComplete = true + } + if f.piManagementTestWire != "" { + return compose.Result{Stdout: f.piManagementTestWire}, nil + } + return compose.Result{Stdout: `{"ready":true}`}, nil case strings.Contains(call, "/models"): if f.modelsWire != "" { return compose.Result{Stdout: f.modelsWire}, nil