fix(security): sanitize session bootstrap failures

This commit is contained in:
User
2026-07-14 23:13:21 +02:00
parent f58b1d84ae
commit d2d8029ff2
4 changed files with 62 additions and 10 deletions
+5 -3
View File
@@ -6,6 +6,9 @@ import type { Settings } from "../settings/settings-store.js";
import { getUser } from "../auth/auth.js";
import type { ReadinessManager } from "../runtime/readiness-manager.js";
const BOOTSTRAP_FAILURE_MESSAGE =
"Session startup failed. Check configuration and connectivity, then Resume the session.";
export function sessionRoutes(
app: FastifyInstance,
d: { mgr: PiProcessManager; tht: ThtRunner; hub: SseHub; getSettings: () => Settings; readiness: ReadinessManager },
@@ -34,11 +37,10 @@ export function sessionRoutes(
await Promise.all([configure, retrieval]);
info(id, "Starting model");
start();
} catch (error) {
} catch {
d.mgr.teardown(id);
const text = error instanceof Error ? error.message : String(error);
void d.tht.failSession(id, d.getSettings().workspace).catch(() => undefined);
rt.bridge.emitClientEvent({ type: "info", level: "error", text: `Session bootstrap failed: ${text}` });
rt.bridge.emitClientEvent({ type: "info", level: "error", text: BOOTSTRAP_FAILURE_MESSAGE });
rt.bridge.emitClientEvent({ type: "system_event", event: "session_failed" });
rt.bridge.emitClientEvent({ type: "system_event", event: "agent_end" });
}
+48
View File
@@ -471,3 +471,51 @@ test("POST /sessions returns after bridge attachment but starts only after retri
await new Promise((resolve) => setImmediate(resolve));
expect(started).toBe(true);
});
test("POST /sessions bootstrap failure emits only a fixed recovery message", async () => {
const published: Array<{ event: string; data: any }> = [];
let listener: ((event: any) => void) | undefined;
const bridge = {
onClientEvent: (callback: (event: any) => void) => { listener = callback; },
emitClientEvent: (event: any) => listener?.(event),
};
const runtime = { bridge } as any;
const rawFailure =
"connect https://secret.invalid/bootstrap?token=DO_NOT_LEAK using /srv/private/model-key";
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
mgr: {
createFor: () => runtime,
configure: async () => { throw new Error(rawFailure); },
start: () => {},
teardown: () => {},
} as any,
hub: {
publish: (_id: string, event: string, data: any) => published.push({ event, data }),
} as any,
thtRunner: {
ollamaEnsure: async () => ({ ok: true }),
sessionNew: async () => ({ id: "s-bootstrap" }),
searchPack: async () => {},
failSession: async () => {},
} as any,
getSettings: () => ({ workspace: "psd" }) as any,
});
const response = await app.inject({
method: "POST",
url: "/sessions",
payload: { question: "q" },
});
await new Promise((resolve) => setImmediate(resolve));
expect(response.json()).toEqual({ id: "s-bootstrap" });
expect(published.map(({ data }) => data)).toContainEqual({
type: "info",
level: "error",
text: "Session startup failed. Check configuration and connectivity, then Resume the session.",
});
const clientOutput = JSON.stringify(published);
expect(clientOutput).not.toContain("secret.invalid");
expect(clientOutput).not.toContain("DO_NOT_LEAK");
expect(clientOutput).not.toContain("/srv/private/model-key");
});
@@ -68,6 +68,9 @@ test("Archive accordion expands to reveal archived sessions", async () => {
test("Resume from the panel activates the session and closes the panel", async () => {
let resumed: string | null = null;
useSessionStore.setState({
activityLog: [{ kind: "status", phase: "F7", text: "Stale prior activity", level: "info" }],
});
server.use(
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => {
resumed = params.id as string;
@@ -78,11 +81,9 @@ test("Resume from the panel activates the session and closes the panel", async (
await userEvent.click(await screen.findByText("Attiva uno"));
await screen.findByText("Domanda originale"); // panel open
await userEvent.click(screen.getByRole("button", { name: /resume/i }));
expect(useSessionStore.getState().activityLog).toContainEqual({
kind: "lifecycle",
phase: null,
text: "Resuming session",
});
expect(useSessionStore.getState().activityLog).toEqual([
{ kind: "lifecycle", phase: null, text: "Resuming session" },
]);
await waitFor(() => expect(resumed).toBe("s1"));
await waitFor(() => expect(screen.queryByText("Domanda originale")).not.toBeInTheDocument()); // panel closed
});
@@ -230,7 +231,7 @@ test("closing and reopening Model activity preserves the complete activity log",
});
store.applyEvent({ type: "text_delta", text: "Cohort ready" });
});
const beforeClose = useSessionStore.getState().activityLog;
const beforeClose = useSessionStore.getState().activityLog.map((entry) => ({ ...entry }));
expect(screen.queryByRole("heading", { name: /model activity/i })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /model activity/i }));
@@ -109,11 +109,12 @@ test("close calls onClose without mutating the activity log", async () => {
];
useSessionStore.setState({ activityLog });
render(<ModelActivityPanel onClose={onClose} />);
const expectedLog = useSessionStore.getState().activityLog.map((entry) => ({ ...entry }));
await userEvent.click(screen.getByRole("button", { name: /close model activity/i }));
expect(onClose).toHaveBeenCalledOnce();
expect(useSessionStore.getState().activityLog).toEqual(activityLog);
expect(useSessionStore.getState().activityLog).toEqual(expectedLog);
});
test("isNearBottom includes the 48px boundary", () => {