fix(security): sanitize session bootstrap failures
This commit is contained in:
@@ -6,6 +6,9 @@ import type { Settings } from "../settings/settings-store.js";
|
|||||||
import { getUser } from "../auth/auth.js";
|
import { getUser } from "../auth/auth.js";
|
||||||
import type { ReadinessManager } from "../runtime/readiness-manager.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(
|
export function sessionRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
d: { mgr: PiProcessManager; tht: ThtRunner; hub: SseHub; getSettings: () => Settings; readiness: ReadinessManager },
|
d: { mgr: PiProcessManager; tht: ThtRunner; hub: SseHub; getSettings: () => Settings; readiness: ReadinessManager },
|
||||||
@@ -34,11 +37,10 @@ export function sessionRoutes(
|
|||||||
await Promise.all([configure, retrieval]);
|
await Promise.all([configure, retrieval]);
|
||||||
info(id, "Starting model");
|
info(id, "Starting model");
|
||||||
start();
|
start();
|
||||||
} catch (error) {
|
} catch {
|
||||||
d.mgr.teardown(id);
|
d.mgr.teardown(id);
|
||||||
const text = error instanceof Error ? error.message : String(error);
|
|
||||||
void d.tht.failSession(id, d.getSettings().workspace).catch(() => undefined);
|
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: "session_failed" });
|
||||||
rt.bridge.emitClientEvent({ type: "system_event", event: "agent_end" });
|
rt.bridge.emitClientEvent({ type: "system_event", event: "agent_end" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -471,3 +471,51 @@ test("POST /sessions returns after bridge attachment but starts only after retri
|
|||||||
await new Promise((resolve) => setImmediate(resolve));
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
expect(started).toBe(true);
|
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 () => {
|
test("Resume from the panel activates the session and closes the panel", async () => {
|
||||||
let resumed: string | null = null;
|
let resumed: string | null = null;
|
||||||
|
useSessionStore.setState({
|
||||||
|
activityLog: [{ kind: "status", phase: "F7", text: "Stale prior activity", level: "info" }],
|
||||||
|
});
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => {
|
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => {
|
||||||
resumed = params.id as string;
|
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 userEvent.click(await screen.findByText("Attiva uno"));
|
||||||
await screen.findByText("Domanda originale"); // panel open
|
await screen.findByText("Domanda originale"); // panel open
|
||||||
await userEvent.click(screen.getByRole("button", { name: /resume/i }));
|
await userEvent.click(screen.getByRole("button", { name: /resume/i }));
|
||||||
expect(useSessionStore.getState().activityLog).toContainEqual({
|
expect(useSessionStore.getState().activityLog).toEqual([
|
||||||
kind: "lifecycle",
|
{ kind: "lifecycle", phase: null, text: "Resuming session" },
|
||||||
phase: null,
|
]);
|
||||||
text: "Resuming session",
|
|
||||||
});
|
|
||||||
await waitFor(() => expect(resumed).toBe("s1"));
|
await waitFor(() => expect(resumed).toBe("s1"));
|
||||||
await waitFor(() => expect(screen.queryByText("Domanda originale")).not.toBeInTheDocument()); // panel closed
|
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" });
|
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();
|
expect(screen.queryByRole("heading", { name: /model activity/i })).not.toBeInTheDocument();
|
||||||
await userEvent.click(screen.getByRole("button", { name: /model activity/i }));
|
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 });
|
useSessionStore.setState({ activityLog });
|
||||||
render(<ModelActivityPanel onClose={onClose} />);
|
render(<ModelActivityPanel onClose={onClose} />);
|
||||||
|
const expectedLog = useSessionStore.getState().activityLog.map((entry) => ({ ...entry }));
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: /close model activity/i }));
|
await userEvent.click(screen.getByRole("button", { name: /close model activity/i }));
|
||||||
|
|
||||||
expect(onClose).toHaveBeenCalledOnce();
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
expect(useSessionStore.getState().activityLog).toEqual(activityLog);
|
expect(useSessionStore.getState().activityLog).toEqual(expectedLog);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("isNearBottom includes the 48px boundary", () => {
|
test("isNearBottom includes the 48px boundary", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user