fix: harden pi management verification
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
type Settings,
|
||||
} from "../settings/settings-store.js";
|
||||
import type { PiModel } from "./list-models.js";
|
||||
import { createPiProviderSmoke, type PiProviderSmoke } from "./provider-smoke.js";
|
||||
|
||||
const execFile = promisify(nodeExecFile);
|
||||
const REASONING_CHOICES = ["low", "medium", "high"] as const;
|
||||
@@ -80,6 +81,7 @@ export class PiManagementError extends Error {
|
||||
interface PiManagementDeps {
|
||||
execute?: PiExecFile;
|
||||
listModels: () => Promise<PiModel[]>;
|
||||
smokeProvider?: PiProviderSmoke;
|
||||
readSettings?: () => Settings;
|
||||
saveSettings?: (settings: Settings) => Settings;
|
||||
readLogs?: () => string | Promise<string>;
|
||||
@@ -97,6 +99,7 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P
|
||||
const readSettings = deps.readSettings ?? (() => loadSettings(config));
|
||||
const persistSettings = deps.saveSettings ?? ((settings) => saveSettings(config, settings));
|
||||
const readLogs = deps.readLogs ?? (() => diagnostics.join("\n"));
|
||||
const smokeProvider = deps.smokeProvider ?? createPiProviderSmoke(config);
|
||||
|
||||
const closedOptions = async (): Promise<Omit<PiOptions, "checkedAt">> => {
|
||||
let listed: PiModel[];
|
||||
@@ -123,12 +126,12 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P
|
||||
return { providers, models, reasoning: [...REASONING_CHOICES] };
|
||||
};
|
||||
|
||||
const version = async (): Promise<string> => {
|
||||
const version = async (timeoutMs = config.piManagementTimeoutMs): 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,
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: MAX_EXEC_OUTPUT_BYTES,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -193,20 +196,40 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P
|
||||
|
||||
async test(): Promise<PiTestResult> {
|
||||
const checkedAt = now().toISOString();
|
||||
const deadline = Date.now() + config.piManagementTimeoutMs;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
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);
|
||||
}
|
||||
const check = async (): Promise<void> => {
|
||||
await version(remainingBudget(deadline));
|
||||
const current = installationConfig();
|
||||
if (!current.provider || !current.model || !current.reasoning) {
|
||||
throw new PiManagementError(
|
||||
"pi_management_unavailable",
|
||||
"Pi installation configuration is incomplete",
|
||||
);
|
||||
}
|
||||
await smokeProvider({
|
||||
provider: current.provider,
|
||||
model: current.model,
|
||||
reasoning: current.reasoning,
|
||||
timeoutMs: remainingBudget(deadline),
|
||||
});
|
||||
};
|
||||
await Promise.race([
|
||||
check(),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new PiManagementError("pi_management_unavailable", "Pi smoke check timed out")),
|
||||
config.piManagementTimeoutMs,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
addDiagnostic("Pi smoke check succeeded");
|
||||
return { ready: true, checkedAt };
|
||||
} catch (error) {
|
||||
return smokeFailure(stableMessage(error, "Pi smoke check failed"), checkedAt, addDiagnostic);
|
||||
return smokeFailure(stableMessage(error, "Pi provider smoke check failed"), checkedAt, addDiagnostic);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -279,7 +302,11 @@ function smokeFailure(
|
||||
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(/((?:["']?)[A-Za-z0-9_-]*(?:api[_-]?key|token|password|secret|authorization)[A-Za-z0-9_-]*(?:["']?)\s*(?:=|:)\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;}]+)/giu, "$1[REDACTED]")
|
||||
.replace(/(\bBearer\s+)[^\s,;]+/giu, "$1[REDACTED]")
|
||||
.replace(/(\w+:\/\/[^:/\s]+:)[^@/\s]+@/gu, "$1[REDACTED]@");
|
||||
}
|
||||
|
||||
function remainingBudget(deadline: number): number {
|
||||
return Math.max(1, deadline - Date.now());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { AppConfig } from "../config.js";
|
||||
import { secretValue } from "../config/secret-bundle.js";
|
||||
import { clearPrincipalEnvironment } from "../auth/principal.js";
|
||||
import { RpcClient } from "../rpc/rpc-client.js";
|
||||
import { loadPiAuthProviders } from "./auth-providers.js";
|
||||
import { buildPiChildEnv, canonicalPiProvider } from "./provider-credentials.js";
|
||||
import type { PiReasoning } from "./management.js";
|
||||
|
||||
const SMOKE_PROMPT = "Provider health check only. Reply with exactly OK without using tools.";
|
||||
|
||||
export interface PiProviderSmokeRequest {
|
||||
provider: string;
|
||||
model: string;
|
||||
reasoning: PiReasoning;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export type PiProviderSmoke = (request: PiProviderSmokeRequest) => Promise<void>;
|
||||
|
||||
interface ProviderSmokeOptions {
|
||||
spawnFn?: (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { cwd: string; env: NodeJS.ProcessEnv },
|
||||
) => ChildProcessWithoutNullStreams;
|
||||
authProviders?: () => ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export function createPiProviderSmoke(
|
||||
config: AppConfig,
|
||||
options: ProviderSmokeOptions = {},
|
||||
): PiProviderSmoke {
|
||||
const spawnFn = options.spawnFn ?? nodeSpawn;
|
||||
const authProviders = options.authProviders ?? (() => loadPiAuthProviders());
|
||||
|
||||
return async ({ provider, model, reasoning, timeoutMs }): Promise<void> => {
|
||||
let child: ChildProcessWithoutNullStreams | undefined;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
const canonicalProvider = canonicalPiProvider(provider);
|
||||
if (!canonicalProvider || timeoutMs <= 0) throw providerFailure();
|
||||
const env = buildPiChildEnv({
|
||||
provider: canonicalProvider,
|
||||
authProviders: authProviders(),
|
||||
credentialValue: secretValue(config, "THT_MODEL_API_KEY"),
|
||||
credentialFile: config.modelApiKeyFile,
|
||||
});
|
||||
clearPrincipalEnvironment(env);
|
||||
delete env.THT_DATA_ROOT;
|
||||
if (config.dataRoot !== undefined) env.THT_DATA_ROOT = config.dataRoot;
|
||||
|
||||
child = spawnFn(config.piBin, ["--mode", "rpc"], { cwd: config.harnessDir, env });
|
||||
child.stderr.resume();
|
||||
const rpc = new RpcClient(child);
|
||||
const turn = async (): Promise<void> => {
|
||||
requireSuccessfulResponse(await rpc.request({
|
||||
type: "set_model", provider: canonicalProvider, modelId: model,
|
||||
} as object & { type: string }));
|
||||
requireSuccessfulResponse(await rpc.request({
|
||||
type: "set_thinking_level", level: reasoning,
|
||||
} as object & { type: string }));
|
||||
await waitForProviderTurn(rpc, child!);
|
||||
};
|
||||
await Promise.race([
|
||||
turn(),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(providerTimeout()), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
if (isProviderTimeout(error)) throw providerTimeout();
|
||||
throw providerFailure();
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (child) {
|
||||
try { child.kill(); } catch { /* preserve the sanitized smoke outcome */ }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function waitForProviderTurn(rpc: RpcClient, child: ChildProcessWithoutNullStreams): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let failed = false;
|
||||
rpc.on("event", (event) => {
|
||||
if (event?.type === "message_end" && event.message?.role === "assistant"
|
||||
&& event.message.stopReason === "error") {
|
||||
failed = true;
|
||||
reject(providerFailure());
|
||||
return;
|
||||
}
|
||||
if (event?.type === "agent_end") {
|
||||
const messages = Array.isArray(event.messages) ? event.messages : [];
|
||||
const eventFailed = messages.some((message: any) => (
|
||||
message?.role === "assistant" && message?.stopReason === "error"
|
||||
));
|
||||
if (failed || eventFailed) reject(providerFailure());
|
||||
else resolve();
|
||||
}
|
||||
});
|
||||
child.once("exit", () => reject(providerFailure()));
|
||||
rpc.send({ type: "prompt", message: SMOKE_PROMPT });
|
||||
});
|
||||
}
|
||||
|
||||
function requireSuccessfulResponse(response: any): void {
|
||||
if (!response || response.success !== true) throw providerFailure();
|
||||
}
|
||||
|
||||
function providerFailure(): Error {
|
||||
return new Error("Pi provider smoke check failed");
|
||||
}
|
||||
|
||||
function providerTimeout(): Error {
|
||||
return Object.assign(new Error("Pi smoke check timed out"), { code: "ETIMEDOUT" });
|
||||
}
|
||||
|
||||
function isProviderTimeout(error: unknown): boolean {
|
||||
return Boolean(error && typeof error === "object" && (error as { code?: unknown }).code === "ETIMEDOUT");
|
||||
}
|
||||
@@ -29,6 +29,10 @@ async function run<T>(
|
||||
if (!managementAllowed(deps.config, principal.isAdmin)) {
|
||||
return reply.code(403).send({ code: "pi_management_forbidden", error: "Pi management is not permitted" });
|
||||
}
|
||||
if (deps.config.authMode === "none" && isManagementWrite(request.method)
|
||||
&& !sameOriginOrNonBrowser(request)) {
|
||||
return reply.code(403).send({ code: "pi_management_forbidden", error: "Pi management is not permitted" });
|
||||
}
|
||||
try {
|
||||
return await action();
|
||||
} catch (error) {
|
||||
@@ -44,3 +48,20 @@ function managementAllowed(config: AppConfig, isAdmin: boolean): boolean {
|
||||
return (config.authMode === "none" && !config.publicExposure)
|
||||
|| (config.authMode === "upstream" && isAdmin);
|
||||
}
|
||||
|
||||
function isManagementWrite(method: string): boolean {
|
||||
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
|
||||
}
|
||||
|
||||
function sameOriginOrNonBrowser(request: FastifyRequest): boolean {
|
||||
const origin = request.headers.origin;
|
||||
if (origin === undefined) return true;
|
||||
if (typeof origin !== "string" || typeof request.headers.host !== "string") return false;
|
||||
try {
|
||||
const supplied = new URL(origin);
|
||||
const expected = new URL(`${request.protocol}://${request.headers.host}`);
|
||||
return supplied.origin === expected.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user