feat: expose safe pi management api
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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]@");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<PiManagementError>({ 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]");
|
||||
});
|
||||
@@ -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<string, string> = {}) {
|
||||
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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user