fix: validate prompt API payloads

Fixes #11
This commit is contained in:
Federico Jaramillo Martinez
2026-06-05 11:09:15 +02:00
parent 3330a5a7fa
commit 82ba2e0490
7 changed files with 110 additions and 9 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Prevent malformed session prompt API calls from crashing the session daemon.
@@ -366,6 +366,20 @@ describe("PiSessionService", () => {
await service.dispose();
});
it("rejects malformed prompt text before opening the runtime", async () => {
const fake = fakeRuntime("prompt-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
heartbeatIntervalMs: 60_000,
});
await expect(service.prompt("prompt-session", undefined)).rejects.toThrow("Prompt text is required");
expect(fake.calls.prompt).toEqual([]);
await service.dispose();
});
it("includes queued message details in session status", async () => {
const fake = fakeRuntime("status-session", {
messages: [{ role: "user", content: "hello" }, { role: "assistant", content: "hi" }],
+19 -6
View File
@@ -41,6 +41,17 @@ interface QueuedPrompt {
text: string;
}
function requirePromptText(value: unknown): string {
if (typeof value !== "string") throw new Error("Prompt text is required");
return value;
}
function parsePromptStreamingBehavior(value: unknown): QueuedPromptKind | undefined {
if (value === undefined) return undefined;
if (value === "steer" || value === "followUp") return value;
throw new Error('Prompt streamingBehavior must be "steer" or "followUp"');
}
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "get" | "archive" | "restore" | "isArchived">;
interface PiSessionListEntry {
id: string;
@@ -360,22 +371,24 @@ export class PiSessionService {
return commands.sort((a, b) => a.name.localeCompare(b.name));
}
async prompt(sessionId: string, text: string, streamingBehavior?: "steer" | "followUp"): Promise<void> {
async prompt(sessionId: string, text: unknown, streamingBehavior?: unknown): Promise<void> {
const promptText = requirePromptText(text);
const requestedBehavior = parsePromptStreamingBehavior(streamingBehavior);
await this.assertWritable(sessionId);
const session = await this.getOrOpen(sessionId);
this.maybeGenerateSessionName(session, text);
this.maybeGenerateSessionName(session, promptText);
const isQueued = session.isStreaming || session.isCompacting;
const behavior = isQueued ? streamingBehavior ?? "followUp" : undefined;
if (isQueued && this.hasQueuedMessageText(session, text)) {
const behavior = isQueued ? requestedBehavior ?? "followUp" : undefined;
if (isQueued && this.hasQueuedMessageText(session, promptText)) {
this.publishActivity(session, "duplicate queued message ignored", "active");
this.publishStatus(session);
return;
}
if (session.isCompacting) {
this.enqueuePromptDuringCompaction(session, text, behavior ?? "followUp");
this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp");
return;
}
void this.submitPrompt(session, text, behavior);
void this.submitPrompt(session, promptText, behavior);
}
private submitPrompt(session: PiAgentSession, text: string, behavior: QueuedPromptKind | undefined): Promise<void> {
@@ -15,4 +15,8 @@ describe("sessionNameGenerator", () => {
expect(fallbackSessionName('<skill name="x" location="/x">\nDo x\n</skill>\n\nCheck the UI now'))
.toBe("Check the UI now");
});
it("skips fallback names when the first request is missing", () => {
expect(fallbackSessionName(undefined)).toBeUndefined();
});
});
+3 -1
View File
@@ -43,7 +43,9 @@ export async function generateShortSessionName<TApi extends Api>(modelRegistry:
return cleanSessionName(finalMessage === undefined ? streamedText : textFromAssistant(finalMessage));
}
export function fallbackSessionName(firstMessage: string): string | undefined {
export function fallbackSessionName(firstMessage: unknown): string | undefined {
if (typeof firstMessage !== "string") return undefined;
return cleanSessionName(firstMessage
.replace(/<skill name="[^"]+" location="[^"]+">[\s\S]*?<\/skill>/g, "")
.replace(/```[\s\S]*?```/g, " ")
+58
View File
@@ -0,0 +1,58 @@
import Fastify, { type FastifyInstance } from "fastify";
import fastifyWebsocket from "@fastify/websocket";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js";
import { registerSessionRoutes } from "./sessionRoutes.js";
let app: FastifyInstance;
let service: PiSessionService;
let sessionManager: RejectingSessionManager;
beforeEach(async () => {
app = Fastify({ logger: false });
await app.register(fastifyWebsocket);
sessionManager = new RejectingSessionManager();
const eventHub = new SessionEventHub();
service = new PiSessionService(eventHub, { sessionManager, heartbeatIntervalMs: 60_000 });
registerSessionRoutes(app, service, eventHub);
});
afterEach(async () => {
await service.dispose();
await app.close();
});
describe("session routes", () => {
it("rejects prompt payloads that omit text without opening a session", async () => {
const response = await app.inject({ method: "POST", url: "/sessions/session-1/prompt", payload: { body: "Build the thing" } });
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "Prompt text is required" });
expect(sessionManager.calls).toEqual({ create: 0, list: 0, listAll: 0, open: 0 });
});
});
class RejectingSessionManager implements PiSessionManagerGateway {
readonly calls = { create: 0, list: 0, listAll: 0, open: 0 };
list() {
this.calls.list += 1;
return Promise.resolve([]);
}
create(): never {
this.calls.create += 1;
throw new Error("Session manager should not create sessions for invalid prompt payloads");
}
listAll() {
this.calls.listAll += 1;
return Promise.resolve([]);
}
open(): never {
this.calls.open += 1;
throw new Error("Session manager should not open sessions for invalid prompt payloads");
}
}
+7 -2
View File
@@ -2,6 +2,11 @@ import type { FastifyInstance } from "fastify";
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
import type { PiSessionService } from "./piSessionService.js";
interface PromptRequestBody {
text?: unknown;
streamingBehavior?: unknown;
}
export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionService, eventHub: SessionEventHub, prefix = ""): void {
app.get<{ Querystring: { cwd?: string } }>(`${prefix}/sessions`, async (request, reply) => {
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
@@ -89,9 +94,9 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
}
});
app.post<{ Params: { sessionId: string }; Body: { text: string; streamingBehavior?: "steer" | "followUp" } }>(`${prefix}/sessions/:sessionId/prompt`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: PromptRequestBody | undefined }>(`${prefix}/sessions/:sessionId/prompt`, async (request, reply) => {
try {
await sessions.prompt(request.params.sessionId, request.body.text, request.body.streamingBehavior);
await sessions.prompt(request.params.sessionId, request.body?.text, request.body?.streamingBehavior);
return { accepted: true };
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });