fix: harden pi management verification
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import {
|
||||
PiManagementError,
|
||||
@@ -140,12 +140,95 @@ test("smoke uses the configured timeout and reports a sanitized timeout", async
|
||||
expect(calls).toEqual([{ command: "/usr/local/bin/pi", args: ["--version"], timeout: 750 }]);
|
||||
});
|
||||
|
||||
// Catches a smoke endpoint that validates only the Pi binary/model catalogue and never makes a
|
||||
// request through the configured provider and model.
|
||||
test("smoke exercises the configured provider and model", async () => {
|
||||
const providerChecks: unknown[] = [];
|
||||
const service = createPiManagement(configFor(), {
|
||||
execute: successfulExec([]),
|
||||
listModels: async () => supportedModels,
|
||||
readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }),
|
||||
smokeProvider: async (request) => { providerChecks.push(request); },
|
||||
now: () => new Date("2026-08-05T10:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(service.test()).resolves.toEqual({
|
||||
ready: true,
|
||||
checkedAt: "2026-08-05T10:00:00.000Z",
|
||||
});
|
||||
expect(providerChecks).toEqual([{
|
||||
provider: "zai", model: "glm-5.2", reasoning: "medium", timeoutMs: expect.any(Number),
|
||||
}]);
|
||||
});
|
||||
|
||||
// Catches expired provider credentials being treated as ready or raw provider diagnostics being
|
||||
// reflected through the management API.
|
||||
test("smoke fails closed and sanitizes configured-provider authentication errors", async () => {
|
||||
const service = createPiManagement(configFor(), {
|
||||
execute: successfulExec([]),
|
||||
listModels: async () => supportedModels,
|
||||
readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }),
|
||||
smokeProvider: async () => {
|
||||
throw new Error('401 {"token":"raw-expired-token","output":"raw-provider-output"}');
|
||||
},
|
||||
now: () => new Date("2026-08-05T10:00:00.000Z"),
|
||||
});
|
||||
|
||||
const result = await service.test();
|
||||
expect(result).toEqual({
|
||||
ready: false,
|
||||
message: "Pi provider smoke check failed",
|
||||
checkedAt: "2026-08-05T10:00:00.000Z",
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toMatch(/raw-expired-token|raw-provider-output/);
|
||||
});
|
||||
|
||||
// Catches separate per-phase timeouts that allow a later provider turn to exceed the one
|
||||
// end-to-end Pi Management smoke budget.
|
||||
test("smoke applies one deadline across version and a hung provider turn", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-05T10:00:00.000Z"));
|
||||
try {
|
||||
const providerTimeouts: number[] = [];
|
||||
const service = createPiManagement(configFor(), {
|
||||
execute: async () => await new Promise((resolve) => setTimeout(
|
||||
() => resolve({ stdout: "pi 0.80.3\n", stderr: "" }),
|
||||
500,
|
||||
)),
|
||||
listModels: async () => supportedModels,
|
||||
readSettings: () => ({ provider: "zai", model: "glm-5.2", thinking: "medium" }),
|
||||
smokeProvider: async ({ timeoutMs }) => {
|
||||
providerTimeouts.push(timeoutMs);
|
||||
await new Promise(() => {});
|
||||
},
|
||||
now: () => new Date("2026-08-05T10:00:00.000Z"),
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
const pending = service.test().finally(() => { settled = true; });
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(providerTimeouts).toEqual([250]);
|
||||
await vi.advanceTimersByTimeAsync(249);
|
||||
expect(settled).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await expect(pending).resolves.toEqual({
|
||||
ready: false,
|
||||
message: "Pi smoke check timed out",
|
||||
checkedAt: "2026-08-05T10:00:00.000Z",
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
// 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";
|
||||
source[202] = '{"token":"raw-json-secret","password":"raw-json-password"}';
|
||||
source[201] = "THT_MODEL_API_KEY=raw-env-secret";
|
||||
const service = createPiManagement(configFor(), {
|
||||
execute: successfulExec([]),
|
||||
listModels: async () => supportedModels,
|
||||
@@ -159,5 +242,8 @@ test("logs keep only the latest 200 redacted lines", async () => {
|
||||
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")).not.toContain("raw-json-secret");
|
||||
expect(logs.lines.join("\n")).not.toContain("raw-json-password");
|
||||
expect(logs.lines.join("\n")).not.toContain("raw-env-secret");
|
||||
expect(logs.lines.join("\n")).toContain("[REDACTED]");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { createPiProviderSmoke } from "../src/pi/provider-smoke.js";
|
||||
|
||||
function rpcChild(onCommand: (command: any, emit: (message: unknown) => void) => void) {
|
||||
const child: any = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = { resume: vi.fn() };
|
||||
child.kill = vi.fn();
|
||||
const emit = (message: unknown) => queueMicrotask(() => {
|
||||
child.stdout.emit("data", `${JSON.stringify(message)}\n`);
|
||||
});
|
||||
child.stdin = {
|
||||
write: (data: unknown) => {
|
||||
onCommand(JSON.parse(String(data)), emit);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return child;
|
||||
}
|
||||
|
||||
// Catches a provider smoke implementation that merely selects a model, leaks generated output,
|
||||
// or fails to terminate its ephemeral Pi process after a real model turn.
|
||||
test("provider smoke selects the configured model and completes a fixed output-discarding turn", async () => {
|
||||
const commands: any[] = [];
|
||||
const child = rpcChild((command, emit) => {
|
||||
commands.push(command);
|
||||
if (command.type === "set_model" || command.type === "set_thinking_level") {
|
||||
emit({ type: "response", id: command.id, success: true });
|
||||
}
|
||||
if (command.type === "prompt") {
|
||||
emit({
|
||||
type: "message_end",
|
||||
message: { role: "assistant", stopReason: "stop", content: "raw-provider-output" },
|
||||
});
|
||||
emit({
|
||||
type: "agent_end",
|
||||
messages: [{ role: "assistant", stopReason: "stop", content: "raw-provider-output" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
const smoke = createPiProviderSmoke(loadConfig({
|
||||
THT_HARNESS_DIR: "/app/harness",
|
||||
PI_BIN: "/usr/local/bin/pi",
|
||||
}), {
|
||||
spawnFn: () => child,
|
||||
authProviders: () => new Set(["zai"]),
|
||||
});
|
||||
|
||||
await expect(smoke({
|
||||
provider: "zai", model: "glm-5.2", reasoning: "medium", timeoutMs: 750,
|
||||
})).resolves.toBeUndefined();
|
||||
expect(commands.map(({ id: _id, ...command }) => command)).toEqual([
|
||||
{ type: "set_model", provider: "zai", modelId: "glm-5.2" },
|
||||
{ type: "set_thinking_level", level: "medium" },
|
||||
{ type: "prompt", message: expect.stringMatching(/health check/i) },
|
||||
]);
|
||||
expect(child.kill).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
// Catches provider errors that are accepted as a successful health check or returned with raw
|
||||
// credential/output diagnostics.
|
||||
test("provider smoke rejects a failed model turn with a stable non-secret error", async () => {
|
||||
const child = rpcChild((command, emit) => {
|
||||
if (command.type === "set_model" || command.type === "set_thinking_level") {
|
||||
emit({ type: "response", id: command.id, success: true });
|
||||
}
|
||||
if (command.type === "prompt") {
|
||||
emit({
|
||||
type: "message_end",
|
||||
message: {
|
||||
role: "assistant", stopReason: "error",
|
||||
errorMessage: '401 {"token":"raw-provider-secret"}',
|
||||
},
|
||||
});
|
||||
emit({ type: "agent_end", messages: [] });
|
||||
}
|
||||
});
|
||||
const smoke = createPiProviderSmoke(loadConfig({}), {
|
||||
spawnFn: () => child,
|
||||
authProviders: () => new Set(["zai"]),
|
||||
});
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await smoke({ provider: "zai", model: "glm-5.2", reasoning: "medium", timeoutMs: 750 });
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect((caught as Error).message).toBe("Pi provider smoke check failed");
|
||||
expect(String(caught)).not.toContain("raw-provider-secret");
|
||||
});
|
||||
@@ -85,6 +85,51 @@ test("loopback-only AUTH_MODE=none may read the sanitized Pi status", async () =
|
||||
}
|
||||
});
|
||||
|
||||
// Catches an arbitrary website using browser CORS to mutate a loopback-only installation's Pi
|
||||
// defaults or trigger provider work with the local user's authority.
|
||||
test("loopback-only management rejects cross-origin writes", async () => {
|
||||
const service = fakeService();
|
||||
const app = appWith(service);
|
||||
try {
|
||||
const configured = await app.inject({
|
||||
method: "PUT", url: "/pi-management/config",
|
||||
headers: { host: "127.0.0.1:8080", origin: "https://evil.example" },
|
||||
payload: { provider: "zai", model: "glm-5.2", reasoning: "high" },
|
||||
});
|
||||
const smoke = await app.inject({
|
||||
method: "POST", url: "/pi-management/test",
|
||||
headers: { host: "127.0.0.1:8080", origin: "https://evil.example" },
|
||||
});
|
||||
|
||||
expect(configured.statusCode).toBe(403);
|
||||
expect(smoke.statusCode).toBe(403);
|
||||
expect(service.configure).not.toHaveBeenCalled();
|
||||
expect(service.test).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Catches an origin guard that also blocks the same-origin Docker frontend or non-browser local
|
||||
// lifecycle clients that do not send Origin.
|
||||
test("loopback-only management preserves same-origin frontend and origin-less local writes", async () => {
|
||||
const service = fakeService();
|
||||
const app = appWith(service);
|
||||
try {
|
||||
const sameOrigin = await app.inject({
|
||||
method: "PUT", url: "/pi-management/config",
|
||||
headers: { host: "127.0.0.1:8080", origin: "http://127.0.0.1:8080" },
|
||||
payload: { provider: "zai", model: "glm-5.2", reasoning: "high" },
|
||||
});
|
||||
const lifecycleClient = await app.inject({ method: "POST", url: "/pi-management/test" });
|
||||
|
||||
expect(sameOrigin.statusCode).toBe(200);
|
||||
expect(lifecycleClient.statusCode).toBe(200);
|
||||
} 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 () => {
|
||||
|
||||
Reference in New Issue
Block a user