FN-8371: store report screenshot artifacts

Store opted-in report screenshots as validated local artifacts rather than GitHub attachments.

- Add multipart report attachment upload with PNG/JPEG validation and provenance metadata.
- Carry validated artifact references through report drafts and filing flows without binary egress.
- Update report UI, tests, documentation, and release metadata.

Files changed:
 .changeset/report-screenshot-artifacts.md          |   7 +
 docs/dashboard-guide.md                            |   2 +-
 packages/core/src/index.gate.ts                    |   2 +-
 packages/core/src/index.ts                         |   2 +-
 packages/core/src/types.ts                         |   7 +
 packages/dashboard/app/api/report.ts               |  14 +-
 packages/dashboard/app/components/ReportModal.css  |   3 +-
 packages/dashboard/app/components/ReportModal.tsx  |  41 ++---
 .../app/components/__tests__/ReportModal.test.tsx  |  58 +++++--
 packages/dashboard/app/utils/report-capture.ts     |  47 ++----
 .../src/__tests__/report-pipeline.test.ts          |  94 ------------
 .../dashboard/src/__tests__/report-routes.test.ts  | 123 +++++++++++----
 packages/dashboard/src/report-pipeline.ts          | 128 +++-------------
 .../dashboard/src/routes/register-report-routes.ts | 169 +++++++--------------
 14 files changed, 279 insertions(+), 418 deletions(-)

Fusion-Task-Id: FN-8371

Fusion-Task-Lineage: 48d0e94d-602f-43e2-a63f-cb497221ac29

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-19 16:44:40 -07:00
parent e04608d62c
commit a9c7a6bcc0
14 changed files with 279 additions and 418 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Store in-app report screenshots as validated local artifacts.
category: feature
dev: Replaces inline screenshot egress with the /report/attachment and screenshotArtifactId contract.

View File

@@ -2127,7 +2127,7 @@ In **Settings → General**, choose **Review draft before filing** (the default)
Reports can include a short activity trace of recent built-in view names (up to 20 entries). The trace is ordinary text and receives the same mandatory server-side scrub as every other report field on every egress path, including edited drafts and duplicate endorsements.
Choose **Attach a screenshot** to request the browser's screen-capture permission and capture one PNG frame. The modal shows the image for review and lets you remove it before continuing. Screenshot pixels are binary and cannot be text-scrubbed, so Fusion never captures or files one automatically: it is included only after this explicit per-report choice, including in automatic filing mode. Fusion first validates and files the scrubbed text report, then hosts and posts the reviewed image as a follow-up only when an approved GitHub image host is available. If that follow-up fails after hosting, Fusion compensates by deleting the uploaded image; it never inserts an unhosted data URL into report text.
Choose **Store a screenshot locally** to request browser screen-capture permission. Fusion captures and uploads one PNG frame to its local artifact registry, then requires confirmation that the screenshot may be retained before a report can reference it. The report carries only `screenshotArtifactId` and a text note that the locally stored artifact exists; pixels never leave Fusion or enter report text, including automatic filing.
## Chat-requested task verification

File diff suppressed because one or more lines are too long

View File

@@ -28,7 +28,7 @@ export type {
MissionLineageApprovalResult,
MissionLineageSnapshot,
} from "./symbol-lock-lineage-approval.js";
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js";
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, REPORT_ATTACHMENT_SOURCE, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js";
export {
resolveEntryPointBranchAssignment,
sanitizeBranchSegment,

View File

@@ -730,6 +730,13 @@ export interface TaskDocumentWithTask extends TaskDocument {
/** Supported artifact media classes for the persisted artifact registry. */
export type ArtifactType = "document" | "image" | "video" | "audio" | "other";
/**
* FNXC:ReportPipeline 2026-07-19-10:00:
* Report screenshots are local image artifacts with this explicit provenance.
* Only the reference may reach report egress; screenshot pixels never do.
*/
export const REPORT_ATTACHMENT_SOURCE = "report-attachment";
/**
* FNXC:ArtifactRegistry 2026-06-19-22:04:
* Agents need a first-class registry for multi-type artifacts that are visible across agents and tasks. Store binary media on disk and persist only metadata plus relative URIs in SQLite so query paths stay lightweight and never inline binary bytes.

View File

@@ -6,9 +6,15 @@ async function post(path: string, body: unknown) {
return response.json();
}
export interface ReportScreenshot { dataUrl: string; capturedAt: string; }
export interface ReportContextInput { actionType: ReportActionType; userPrompt: string; contextRefs?: { taskId?: string; agentId?: string }; activityTrace?: string[]; screenshot?: ReportScreenshot; }
export interface ReportContextInput { actionType: ReportActionType; userPrompt: string; contextRefs?: { taskId?: string; agentId?: string }; activityTrace?: string[]; screenshotArtifactId?: string; }
export function reportDraft(input: ReportContextInput) { return post("/api/report/draft", input); }
export function reportFile(input: { actionType: ReportActionType; report: unknown; endorseIssueNumber?: number; endorseDiscussionId?: string; endorseRoadmapIssueNumber?: number; activityTrace?: string[]; screenshot?: ReportScreenshot }) { return post("/api/report/file", input); }
export function reportFile(input: { actionType: ReportActionType; report: unknown; endorseIssueNumber?: number; endorseDiscussionId?: string; endorseRoadmapIssueNumber?: number; activityTrace?: string[]; screenshotArtifactId?: string }) { return post("/api/report/file", input); }
export function reportHelp(question: string) { return post("/api/report/help", { question }); }
/** Upload is intentionally multipart: screenshot bytes never join JSON report text. */
export async function reportAttachment(screenshot: Blob): Promise<{ artifactId: string }> {
const form = new FormData(); form.append("screenshot", screenshot, "report-screenshot.png");
const response = await fetch("/api/report/attachment", { method: "POST", body: form });
if (!response.ok) throw new Error((await response.json().catch(() => ({ error: response.statusText }))).error ?? response.statusText);
return response.json() as Promise<{ artifactId: string }>;
}

View File

@@ -6,7 +6,6 @@
.report-modal__warning { color: var(--color-warning); }
.report-modal__screenshot-option { display: flex; gap: var(--space-2); align-items: center; }
.report-modal__screenshot-preview { display: grid; gap: var(--space-2); padding: var(--space-3); border-radius: var(--radius-md); background: color-mix(in srgb, var(--bg-raised) 85%, transparent); }
.report-modal__screenshot-preview img { max-inline-size: 100%; max-block-size: var(--space-96); object-fit: contain; }
.report-modal__activity-trace { padding: var(--space-3); border-radius: var(--radius-md); background: color-mix(in srgb, var(--bg-raised) 85%, transparent); }
.report-modal__activity-trace ul { margin: var(--space-2) 0 0; padding-inline-start: var(--space-5); }
@media (max-width: 768px) { .report-modal { inline-size: 100%; padding: var(--space-4); } .report-modal__screenshot-preview img { max-block-size: var(--space-64); } }
@media (max-width: 768px) { .report-modal { inline-size: 100%; padding: var(--space-4); } }

View File

@@ -1,9 +1,9 @@
import { useState } from "react";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { ReportActionType } from "@fusion/core";
import { reportDraft, reportFile, reportHelp } from "../api";
import { captureScreenshot as captureScreen, getRecentActivity, recordActivity, type ReportScreenshot } from "../utils/report-capture";
import { reportAttachment, reportDraft, reportFile, reportHelp } from "../api";
import { captureScreenshot as captureScreen, getRecentActivity, recordActivity } from "../utils/report-capture";
import "./ReportModal.css";
const prompts: Record<ReportActionType, string> = { bug: "What went wrong?", feedback: "What would you like to share?", idea: "What would you like Fusion to do?", help: "What would you like help with?" };
@@ -23,23 +23,30 @@ export function ReportModal({ actionType, onClose, contextRefs }: { actionType:
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
const [screenshotEnabled, setScreenshotEnabled] = useState(false);
const [capturedScreenshot, setCapturedScreenshot] = useState<ReportScreenshot>();
const captureScreenshot = async () => {
const [screenshotArtifactId, setScreenshotArtifactId] = useState<string>();
const [retentionConfirmed, setRetentionConfirmed] = useState(false);
const captureGeneration = useRef(0);
const captureScreenshot = async (generation: number) => {
setBusy(true);
setError(undefined);
try {
const captured = await captureScreen();
if (!captured) throw new Error("Screen capture was unavailable or denied.");
setCapturedScreenshot(captured);
const { artifactId } = await reportAttachment(captured);
if (captureGeneration.current !== generation) return;
setScreenshotArtifactId(artifactId);
} catch (captureError) {
if (captureGeneration.current !== generation) return;
setScreenshotEnabled(false);
setError(captureError instanceof Error ? captureError.message : "We could not capture the current screen.");
} finally { setBusy(false); }
} finally {
if (captureGeneration.current === generation) setBusy(false);
}
};
const submit = async () => {
if (!prompt.trim()) return;
if (screenshotEnabled && !capturedScreenshot) {
setError("Capture a screenshot before continuing, or turn attachment off.");
if (screenshotEnabled && (!screenshotArtifactId || !retentionConfirmed)) {
setError("Capture and confirm local screenshot retention before continuing, or turn attachment off.");
return;
}
setBusy(true);
@@ -50,7 +57,7 @@ export function ReportModal({ actionType, onClose, contextRefs }: { actionType:
const help = await reportHelp(prompt);
if (help.answered) { setResult({ kind: "help", answer: help.answer }); return; }
}
setResult(await reportDraft({ actionType, userPrompt: prompt, contextRefs, activityTrace: getRecentActivity(), screenshot: screenshotEnabled ? capturedScreenshot : undefined }));
setResult(await reportDraft({ actionType, userPrompt: prompt, contextRefs, activityTrace: getRecentActivity(), screenshotArtifactId: screenshotEnabled && retentionConfirmed ? screenshotArtifactId : undefined }));
} catch {
setError("We could not prepare your report. Check your connection and try again.");
} finally { setBusy(false); }
@@ -61,7 +68,7 @@ export function ReportModal({ actionType, onClose, contextRefs }: { actionType:
setError(undefined);
try {
recordActivity("report");
setResult(await reportFile({ actionType, report: result.report, endorseIssueNumber, endorseDiscussionId, endorseRoadmapIssueNumber, activityTrace: getRecentActivity(), screenshot: screenshotEnabled ? capturedScreenshot : undefined }));
setResult(await reportFile({ actionType, report: result.report, endorseIssueNumber, endorseDiscussionId, endorseRoadmapIssueNumber, activityTrace: getRecentActivity(), screenshotArtifactId: screenshotEnabled && retentionConfirmed ? screenshotArtifactId : undefined }));
} catch {
setError("We could not send your report. Your draft is still here; try again.");
@@ -71,12 +78,12 @@ setResult(await reportFile({ actionType, report: result.report, endorseIssueNumb
<button className="btn-icon report-modal__close" type="button" aria-label="Close report" onClick={onClose}>×</button>
{error && <p className="report-modal__error" role="alert">{error}</p>}
{!result && <><h2>{actionType[0].toUpperCase() + actionType.slice(1)}</h2><label htmlFor="report-prompt">{prompts[actionType]}</label><textarea id="report-prompt" className="input" value={prompt} onChange={(event) => setPrompt(event.target.value)} maxLength={4000} />
{/* FNXC:ReportPipeline 2026-07-18-12:45: Screenshots are opt-in and
user-reviewable. They are sent only after this explicit per-report choice,
never silently by auto-file; traces remain scrubbed text on server egress. */}
<label className="report-modal__screenshot-option"><input type="checkbox" checked={screenshotEnabled} onChange={(event) => { setScreenshotEnabled(event.target.checked); if (event.target.checked) void captureScreenshot(); else setCapturedScreenshot(undefined); }} /> Attach a screenshot</label>
{/* FNXC:ReportPipeline 2026-07-19-10:00: Screenshot storage is opt-in and
requires retention confirmation before its artifact reference is sent. A
capture that finishes after opt-out is discarded rather than restoring it. */}
<label className="report-modal__screenshot-option"><input type="checkbox" checked={screenshotEnabled} onChange={(event) => { const enabled = event.target.checked; const generation = ++captureGeneration.current; setScreenshotEnabled(enabled); setScreenshotArtifactId(undefined); setRetentionConfirmed(false); if (enabled) void captureScreenshot(generation); else setBusy(false); }} /> Store a screenshot locally</label>
{screenshotEnabled && <div className="report-modal__screenshot-preview">
{capturedScreenshot ? <><img src={capturedScreenshot.dataUrl} alt="Review screenshot before it is attached" /><button className="btn btn-secondary" type="button" onClick={() => { setCapturedScreenshot(undefined); setScreenshotEnabled(false); }}>Remove screenshot</button></> : <p>Capturing a preview…</p>}
{screenshotArtifactId ? <label className="report-modal__screenshot-option"><input type="checkbox" checked={retentionConfirmed} onChange={(event) => setRetentionConfirmed(event.target.checked)} /> I confirm Fusion may retain this screenshot locally for this report.</label> : <p>Capturing and storing locally…</p>}
</div>}
<details className="report-modal__activity-trace"><summary>Activity trace to send</summary><ul>{getRecentActivity().map((entry, index) => <li key={`${entry}-${index}`}>{entry}</li>)}</ul></details>
<button className="btn btn-primary" type="button" disabled={!prompt.trim() || busy} onClick={() => void submit()}>{error ? "Retry" : "Continue"}</button></>}
@@ -109,7 +116,7 @@ setResult(await reportFile({ actionType, report: result.report, endorseIssueNumb
<button className="btn btn-primary" type="button" disabled={busy} onClick={() => void file(result.issue!.discussionId ? undefined : result.issue!.roadmap ? undefined : result.issue!.number, result.issue!.discussionId, result.issue!.roadmap ? result.issue!.number : undefined)}>Confirm and add data point</button>
</>}
{(result?.kind === "filed" || result?.kind === "endorsed") && <><h2>{result.screenshotNotAttached ? "Report sent without screenshot" : "Report sent"}</h2>{result.screenshotNotAttached && <p className="report-modal__warning" role="status">Your report was sent, but the screenshot could not be attached. No screenshot pixels were shared.</p>}<a href={result.url} target="_blank" rel="noreferrer">View on GitHub</a>{result.report?.body && <><label htmlFor="filed-report">Final report</label><textarea id="filed-report" className="input" value={result.report.body} readOnly /></>}</>}
{(result?.kind === "filed" || result?.kind === "endorsed") && <><h2>Report sent</h2><a href={result.url} target="_blank" rel="noreferrer">View on GitHub</a>{result.report?.body && <><label htmlFor="filed-report">Final report</label><textarea id="filed-report" className="input" value={result.report.body} readOnly /></>}</>}
{result?.kind === "help" && <><h2>Suggested help</h2><p>{result.answer?.summary ?? result.answer?.content}</p></>}
{result?.kind === "unavailable" && <>

View File

@@ -2,18 +2,60 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ReportModal } from "../ReportModal";
import * as api from "../../api";
import * as capture from "../../utils/report-capture";
vi.mock("../../api", () => ({
reportAttachment: vi.fn(),
reportDraft: vi.fn(),
reportFile: vi.fn(),
reportHelp: vi.fn(),
}));
vi.mock("../../utils/report-capture", () => ({
captureScreenshot: vi.fn(),
getRecentActivity: vi.fn(() => []),
recordActivity: vi.fn(),
}));
const reportAttachment = vi.mocked(api.reportAttachment);
const reportDraft = vi.mocked(api.reportDraft);
const reportFile = vi.mocked(api.reportFile);
const captureScreenshot = vi.mocked(capture.captureScreenshot);
describe("ReportModal", () => {
beforeEach(() => vi.clearAllMocks());
beforeEach(() => {
vi.clearAllMocks();
captureScreenshot.mockResolvedValue(new Blob(["png"], { type: "image/png" }));
});
it("uploads an opted-in screenshot and sends its reference only after retention confirmation", async () => {
reportAttachment.mockResolvedValueOnce({ artifactId: "123e4567-e89b-42d3-a456-426614174000" });
reportDraft.mockResolvedValueOnce({ kind: "draft-ready", report: { userPrompt: "It crashes", body: "## Summary\nIt crashes", context: {} } });
render(<ReportModal actionType="bug" onClose={vi.fn()} />);
fireEvent.change(screen.getByLabelText("What went wrong?"), { target: { value: "It crashes" } });
fireEvent.click(screen.getByRole("checkbox", { name: "Store a screenshot locally" }));
const confirmation = await screen.findByRole("checkbox", { name: /I confirm Fusion may retain/ });
fireEvent.click(confirmation);
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => expect(reportDraft).toHaveBeenCalledWith(expect.objectContaining({
screenshotArtifactId: "123e4567-e89b-42d3-a456-426614174000",
})));
});
it("clears a pending capture when the reporter opts out", async () => {
let resolveUpload!: (value: { artifactId: string }) => void;
reportAttachment.mockReturnValueOnce(new Promise((resolve) => { resolveUpload = resolve; }));
render(<ReportModal actionType="bug" onClose={vi.fn()} />);
const option = screen.getByRole("checkbox", { name: "Store a screenshot locally" });
fireEvent.click(option);
fireEvent.click(option);
resolveUpload({ artifactId: "123e4567-e89b-42d3-a456-426614174000" });
await waitFor(() => expect(screen.queryByRole("checkbox", { name: /I confirm Fusion may retain/ })).not.toBeInTheDocument());
expect(option).not.toBeChecked();
});
it("shows an actionable error and retry affordance when preparing a report fails", async () => {
reportDraft.mockRejectedValueOnce(new Error("offline"));
@@ -85,20 +127,6 @@ describe("ReportModal", () => {
expect(screen.getByLabelText("What would you like to share?")).toBeInTheDocument();
});
it("warns when a reviewed screenshot could not be attached", async () => {
reportDraft.mockResolvedValueOnce({ kind: "draft-ready", report: { userPrompt: "It crashes", body: "## Summary\nIt crashes", context: {} } });
reportFile.mockResolvedValueOnce({ kind: "filed", url: "https://example.test/1", screenshotNotAttached: true });
render(<ReportModal actionType="bug" onClose={vi.fn()} />);
fireEvent.change(screen.getByLabelText("What went wrong?"), { target: { value: "It crashes" } });
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await screen.findByText("Review your report");
fireEvent.click(screen.getByRole("button", { name: "File report" }));
expect(await screen.findByText("Report sent without screenshot")).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("No screenshot pixels were shared");
});
it("keeps the original derivation marker when the review prompt is edited", async () => {
reportDraft.mockResolvedValueOnce({ kind: "draft-ready", report: { userPrompt: "It crashes", sourcePrompt: "It crashes", body: "## Summary\nIt crashes\n\n## Environment\nCollected context", context: {} } });
reportFile.mockResolvedValueOnce({ kind: "filed", url: "https://example.test/1" });

View File

@@ -1,47 +1,22 @@
export interface ReportScreenshot {
dataUrl: string;
capturedAt: string;
}
const MAX_ACTIVITY = 20;
const activity: string[] = [];
/**
* FNXC:ReportPipeline 2026-07-18-12:30:
* Capture requires a browser-owned display permission prompt and produces one
* user-reviewed frame. Unsupported or denied capture is an optional capability,
* so callers receive undefined and may still submit their text report.
* FNXC:ReportPipeline 2026-07-19-10:00:
* Capture returns a local PNG blob solely for immediate artifact upload. The
* caller must not serialize it into a report payload or display it as a draft.
*/
export async function captureScreenshot(): Promise<ReportScreenshot | undefined> {
export async function captureScreenshot(): Promise<Blob | undefined> {
if (!navigator.mediaDevices?.getDisplayMedia) return undefined;
let stream: MediaStream | undefined;
try {
stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
const video = document.createElement("video");
video.srcObject = stream;
await video.play();
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const video = document.createElement("video"); video.srcObject = stream; await video.play();
const canvas = document.createElement("canvas"); canvas.width = video.videoWidth; canvas.height = video.videoHeight;
canvas.getContext("2d")?.drawImage(video, 0, 0);
return { dataUrl: canvas.toDataURL("image/png"), capturedAt: new Date().toISOString() };
} catch {
return undefined;
} finally {
stream?.getTracks().forEach((track) => track.stop());
}
}
/** Record only built-in view labels, never URLs, task ids, or page content. */
export function recordActivity(label: string): void {
activity.push(label.slice(0, 80));
while (activity.length > MAX_ACTIVITY) activity.shift();
}
export function getRecentActivity(): string[] {
return [...activity];
}
export function clearReportActivityForTests(): void {
activity.length = 0;
return await new Promise<Blob | undefined>((resolve) => canvas.toBlob((blob) => resolve(blob ?? undefined), "image/png"));
} catch { return undefined; } finally { stream?.getTracks().forEach((track) => track.stop()); }
}
export function recordActivity(label: string): void { activity.push(label.slice(0, 80)); while (activity.length > MAX_ACTIVITY) activity.shift(); }
export function getRecentActivity(): string[] { return [...activity]; }
export function clearReportActivityForTests(): void { activity.length = 0; }

View File

@@ -145,43 +145,6 @@ describe("report pipeline", () => {
expect(client.createIssue).not.toHaveBeenCalled();
});
it("appends an approved screenshot after scrubbing an issue duplicate endorsement", async () => {
const imageUrl = "https://raw.githubusercontent.com/Runfusion/Fusion/main/.fusion/report-screenshots/issue.png";
const client = {
createIssue: vi.fn(),
addIssueReaction: vi.fn(),
commentOnIssue: vi.fn().mockResolvedValue({ url: "https://github.com/Runfusion/Fusion/issues/9#issuecomment-9" }),
searchIssues: vi.fn().mockResolvedValue([{ number: 9, title: "dashboard screenshot attachment failed", body: "dashboard screenshot attachment failed", html_url: "https://github.com/Runfusion/Fusion/issues/9", state: "open" }]),
uploadReportImage: vi.fn().mockResolvedValue(imageUrl),
deleteReportImage: vi.fn(),
};
const result = await runReportPipeline({ actionType: "bug", userPrompt: "dashboard screenshot attachment failed", screenshot: { dataUrl: "data:image/png;base64,AAAA", capturedAt: "2026-07-18T00:00:00Z" } }, deps({ projectSettings: { ...settings, reportMode: "auto-file" }, client }));
expect(result).toMatchObject({ kind: "endorsed" });
const comment = String(client.commentOnIssue.mock.calls[1][3]);
expect(comment).toContain(imageUrl);
expect(comment).not.toContain("[REDACTED_PATH]");
});
it("appends an approved screenshot after scrubbing a discussion duplicate endorsement", async () => {
const imageUrl = "https://raw.githubusercontent.com/Runfusion/Fusion/main/.fusion/report-screenshots/discussion.png";
const client = {
createIssue: vi.fn(),
addIssueReaction: vi.fn(),
commentOnIssue: vi.fn(),
searchIssues: vi.fn(),
searchDiscussions: vi.fn().mockResolvedValue([{ id: "D_kwDO2", number: 8, title: "report screenshot attachment feedback", body: "report screenshot attachment feedback", url: "https://github.com/Runfusion/Fusion/discussions/8", state: "open" }]),
addDiscussionReaction: vi.fn(),
commentOnDiscussion: vi.fn().mockResolvedValue({ url: "https://github.com/Runfusion/Fusion/discussions/8#discussioncomment-1" }),
uploadReportImage: vi.fn().mockResolvedValue(imageUrl),
deleteReportImage: vi.fn(),
};
const result = await runReportPipeline({ actionType: "feedback", userPrompt: "report screenshot attachment feedback", screenshot: { dataUrl: "data:image/png;base64,AAAA", capturedAt: "2026-07-18T00:00:00Z" } }, deps({ projectSettings: { ...settings, reportMode: "auto-file" }, client }));
expect(result).toMatchObject({ kind: "endorsed" });
const comment = String(client.commentOnDiscussion.mock.calls[1][1]);
expect(comment).toContain(imageUrl);
expect(comment).not.toContain("[REDACTED_PATH]");
});
it("preserves reviewed gathered context and session token when filing", async () => {
const context = deps({ projectSettings: { ...settings, reportMode: "auto-file" } });
const result = await runReportPipeline({ actionType: "bug", userPrompt: "reviewed prompt" }, context, {
@@ -217,36 +180,6 @@ describe("report pipeline", () => {
expect(client.commentOnIssue).not.toHaveBeenCalled();
});
it("does not upload a screenshot when duplicate verification rejects the endorsement", async () => {
const client = {
createIssue: vi.fn(), addIssueReaction: vi.fn(), commentOnIssue: vi.fn(),
searchIssues: vi.fn().mockResolvedValue([]), uploadReportImage: vi.fn(), deleteReportImage: vi.fn(),
};
const result = await runReportPipeline(
{ actionType: "bug", userPrompt: "dashboard failure", screenshot: { dataUrl: "data:image/png;base64,AAAA", capturedAt: "2026-07-18T00:00:00Z" } },
deps({ client }), { file: true, endorseIssueNumber: 7 },
);
expect(result).toMatchObject({ kind: "unavailable", reason: "duplicate_not_verified" });
expect(client.uploadReportImage).not.toHaveBeenCalled();
});
it("compensates for a failed screenshot attachment comment after filing text", async () => {
const imageUrl = "https://raw.githubusercontent.com/Runfusion/Fusion/main/.fusion/report-screenshots/failed.png";
const client = {
...deps().client!,
uploadReportImage: vi.fn().mockResolvedValue(imageUrl),
deleteReportImage: vi.fn(),
commentOnIssue: vi.fn().mockRejectedValue(new Error("comment failed")),
};
const result = await runReportPipeline(
{ actionType: "bug", userPrompt: "capture", screenshot: { dataUrl: "data:image/png;base64,AAAA", capturedAt: "2026-07-18T00:00:00Z" } },
deps({ projectSettings: { ...settings, reportMode: "auto-file" }, client }),
);
expect(result).toMatchObject({ kind: "filed", screenshotNotAttached: true });
expect(client.createIssue).toHaveBeenCalledBefore(client.uploadReportImage);
expect(client.deleteReportImage).toHaveBeenCalledWith("Runfusion", "Fusion", imageUrl);
});
it("posts one scrubbed duplicate endorsement per report session", async () => {
const context = deps();
const report = { userPrompt: "private-project", summary: "dashboard rendering failed", body: "/Users/alice/private-project ghp_abcdefghijk1234567890", context: {}, sessionToken: "session-test" };
@@ -259,32 +192,5 @@ describe("report pipeline", () => {
expect(String((context.client!.commentOnIssue as ReturnType<typeof vi.fn>).mock.calls[0][3])).not.toContain("private-project");
});
it("embeds an explicitly reviewed screenshot from the approved repository image host", async () => {
const imageUrl = "https://raw.githubusercontent.com/Runfusion/Fusion/main/.fusion/report-screenshots/report.png";
const client = { ...deps().client!, uploadReportImage: vi.fn().mockResolvedValue(imageUrl), deleteReportImage: vi.fn() };
const result = await runReportPipeline({ actionType: "bug", userPrompt: "capture", screenshot: { dataUrl: "data:image/png;base64,AAAA", capturedAt: "2026-07-18T00:00:00Z" } }, deps({ projectSettings: { ...settings, reportMode: "auto-file" }, client }));
expect(result).toMatchObject({ kind: "filed" });
expect(client.createIssue).toHaveBeenCalledBefore(client.uploadReportImage);
expect(client.commentOnIssue).toHaveBeenCalledWith("Runfusion", "Fusion", 42, expect.stringContaining(imageUrl));
});
it.each([
"https://images.example.test/report.png",
"data:image/png;base64,AAAA",
"https://raw.githubusercontent.com/other/repository/main/report.png",
"https://raw.githubusercontent.com/Runfusion/Fusion/main/report.png)\nInjected Markdown",
])("files text-only when the host returns an unapproved screenshot URL: %s", async (imageUrl) => {
const client = { ...deps().client!, uploadReportImage: vi.fn().mockResolvedValue(imageUrl), deleteReportImage: vi.fn() };
const result = await runReportPipeline({ actionType: "bug", userPrompt: "capture", screenshot: { dataUrl: "data:image/png;base64,AAAA", capturedAt: "2026-07-18T00:00:00Z" } }, deps({ projectSettings: { ...settings, reportMode: "auto-file" }, client }));
expect(result).toMatchObject({ kind: "filed", screenshotNotAttached: true });
expect(client.createIssue).toHaveBeenCalledWith(expect.objectContaining({ body: expect.not.stringContaining(imageUrl) }));
});
it("files text when screenshot hosting is unavailable and never uploads an absent auto-file screenshot", async () => {
const client = deps().client!;
const result = await runReportPipeline({ actionType: "bug", userPrompt: "capture", screenshot: { dataUrl: "data:image/png;base64,AAAA", capturedAt: "2026-07-18T00:00:00Z" } }, deps({ projectSettings: { ...settings, reportMode: "auto-file" }, client }));
expect(result).toMatchObject({ kind: "filed", screenshotNotAttached: true });
expect(client.createIssue).toHaveBeenCalledWith(expect.objectContaining({ body: expect.not.stringContaining("data:image") }));
});
});

View File

@@ -13,37 +13,63 @@ vi.mock("../report-pipeline.js", () => ({
import { queryKnowledgePagesAsync } from "../knowledge-index.js";
import { runReportPipeline } from "../report-pipeline.js";
import { registerReportRoutes } from "../routes/register-report-routes.js";
import { ARTIFACT_ID_PATTERN, MAX_SCREENSHOT_BYTES, registerReportRoutes } from "../routes/register-report-routes.js";
type TestRequest = { body?: unknown; file?: { buffer: Buffer; mimetype?: string } };
type TestResponse = { json: (body: unknown) => void };
type TestHandler = (req: TestRequest, res: TestResponse, next?: (error?: unknown) => void) => unknown;
function setup(projectSettings: Record<string, unknown> = { reportMode: "auto-file" }) {
const handlers = new Map<string, (req: { body?: unknown }, res: { json: (body: unknown) => void }) => Promise<void>>();
const handlers = new Map<string, TestHandler[]>();
let uploadFile: TestRequest["file"];
const single = vi.fn(() => async (req: TestRequest, _res: TestResponse, next: (error?: unknown) => void) => {
req.file = uploadFile;
next();
});
const router = {
post: vi.fn((path: string, handler: (req: { body?: unknown }, res: { json: (body: unknown) => void }) => Promise<void>) => handlers.set(path, handler)),
post: vi.fn((path: string, ...routeHandlers: TestHandler[]) => handlers.set(path, routeHandlers)),
} as unknown as Router;
const store = {
getSettingsByScopeFast: vi.fn().mockResolvedValue({ project: projectSettings, global: {} }),
getRootDir: () => "/Users/alice/private-project",
getArtifact: vi.fn().mockResolvedValue({ type: "image", metadata: { source: "report-attachment" } }),
registerArtifact: vi.fn().mockResolvedValue({ id: "123e4567-e89b-42d3-a456-426614174000" }),
};
registerReportRoutes({
router,
getScopedStore: vi.fn().mockResolvedValue(store),
rethrowAsApiError: (error: unknown) => { throw error; },
reportUpload: { single },
} as never);
return handlers;
return { handlers, store, single, setUploadFile: (file: TestRequest["file"]) => { uploadFile = file; } };
}
async function invoke(handler: (req: { body?: unknown }, res: { json: (body: unknown) => void }) => Promise<void>, body: unknown) {
async function invoke(handlers: TestHandler[], body: unknown) {
const json = vi.fn();
await handler({ body }, { json });
return json.mock.calls[0][0];
const req: TestRequest = { body };
const res: TestResponse = { json };
let index = 0;
let nextHandler: Promise<unknown> | undefined;
const next = (error?: unknown): void => {
if (error) throw error;
const handler = handlers[index++];
if (handler) nextHandler = Promise.resolve(handler(req, res, next));
};
const first = handlers[index++];
if (first) await first(req, res, next);
// The upload middleware calls next synchronously, so await the separately
// captured route promise to prove the handler sees the injected file.
await nextHandler;
return { body: json.mock.calls[0]?.[0], json, req };
}
describe("report routes", () => {
beforeEach(() => vi.clearAllMocks());
it("passes roadmap settings and a roadmap endorsement target to the pipeline", async () => {
vi.mocked(queryKnowledgePagesAsync).mockResolvedValue([]);
vi.mocked(runReportPipeline).mockResolvedValue({ kind: "duplicate-found" } as never);
const handlers = setup({ reportMode: "auto-file", reportRoadmapDedupeEnabled: true, reportRoadmapLabel: "roadmap" });
const { handlers } = setup({ reportMode: "auto-file", reportRoadmapDedupeEnabled: true, reportRoadmapLabel: "roadmap" });
await invoke(handlers.get("/report/file")!, { actionType: "idea", endorseRoadmapIssueNumber: 30, report: { userPrompt: "Dashboard report controls", body: "/Users/alice/private-project", context: {} } });
const [input, deps, options] = vi.mocked(runReportPipeline).mock.calls.at(-1)!;
expect(deps.projectSettings).toMatchObject({ reportRoadmapDedupeEnabled: true, reportRoadmapLabel: "roadmap" });
@@ -52,31 +78,72 @@ describe("report routes", () => {
expect(input.actionType).toBe("idea");
});
const PNG_SCREENSHOT = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9WlYk8sAAAAASUVORK5CYII=";
describe("report screenshot references", () => {
it("runs the multipart middleware before storing only signature-validated artifacts", async () => {
const { handlers, single, store, setUploadFile } = setup();
const route = handlers.get("/report/attachment")!;
expect(route).toHaveLength(2);
expect(single).toHaveBeenCalledWith("screenshot");
beforeEach(() => vi.clearAllMocks());
setUploadFile({ buffer: Buffer.from("not an image"), mimetype: "image/png" });
await expect(invoke(route, {})).rejects.toThrow("PNG or JPEG");
setUploadFile({ buffer: Buffer.alloc(MAX_SCREENSHOT_BYTES + 1, 0x89), mimetype: "image/png" });
await expect(invoke(route, {})).rejects.toThrow("PNG or JPEG");
describe("report routes capture validation", () => {
it("accepts a signature-validated screenshot but rejects a data-URL prefix with arbitrary bytes", async () => {
vi.mocked(runReportPipeline).mockResolvedValue({ kind: "draft-ready", mode: "draft-review", report: {} } as never);
const handlers = setup();
await invoke(handlers.get("/report/draft")!, { actionType: "bug", userPrompt: "It crashes", screenshot: { dataUrl: PNG_SCREENSHOT, capturedAt: "2026-07-18T00:00:00Z" } });
expect(runReportPipeline).toHaveBeenCalledWith(expect.objectContaining({ screenshot: { dataUrl: PNG_SCREENSHOT, capturedAt: "2026-07-18T00:00:00Z" } }), expect.anything());
setUploadFile({ buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), mimetype: "text/plain" });
const result = await invoke(route, {});
expect(result.body).toEqual({ artifactId: "123e4567-e89b-42d3-a456-426614174000" });
expect(store.registerArtifact).toHaveBeenCalledWith(expect.objectContaining({
type: "image", authorType: "system", authorId: "report-attachment", metadata: { source: "report-attachment" }, mimeType: "image/png",
}));
});
await expect(invoke(handlers.get("/report/draft")!, { actionType: "bug", userPrompt: "It crashes", screenshot: { dataUrl: "data:image/png;base64,QUFBQQ==", capturedAt: "2026-07-18T00:00:00Z" } })).rejects.toThrow("Screenshot is invalid");
it("accepts UUID references and rejects malformed or invalid provenance on both draft and file", async () => {
vi.mocked(runReportPipeline).mockResolvedValue({ kind: "draft-ready", mode: "draft-review", report: {} } as never);
const { handlers, store } = setup();
const id = "123e4567-e89b-42d3-a456-426614174000";
expect(ARTIFACT_ID_PATTERN.test(id)).toBe(true);
expect(ARTIFACT_ID_PATTERN.test("bad-id")).toBe(false);
for (const path of ["/report/draft", "/report/file"]) {
const body = path === "/report/file"
? { actionType: "bug", report: { userPrompt: "It crashes", context: {}, screenshotArtifactId: id } }
: { actionType: "bug", userPrompt: "It crashes", screenshotArtifactId: id };
await invoke(handlers.get(path)!, body);
}
expect(runReportPipeline).toHaveBeenCalledWith(expect.objectContaining({ screenshotArtifactId: id }), expect.anything(), expect.anything());
for (const path of ["/report/draft", "/report/file"]) {
const malformed = path === "/report/file"
? { actionType: "bug", report: { userPrompt: "It crashes", context: {}, screenshotArtifactId: "bad-id" } }
: { actionType: "bug", userPrompt: "It crashes", screenshotArtifactId: "bad-id" };
await expect(invoke(handlers.get(path)!, malformed)).rejects.toThrow("Screenshot artifact reference is invalid");
}
for (const artifact of [
{ type: "image", metadata: { source: "other-source" } },
{ type: "document", metadata: { source: "report-attachment" } },
]) {
store.getArtifact.mockResolvedValue(artifact);
for (const path of ["/report/draft", "/report/file"]) {
const body = path === "/report/file"
? { actionType: "bug", report: { userPrompt: "It crashes", context: {}, screenshotArtifactId: id } }
: { actionType: "bug", userPrompt: "It crashes", screenshotArtifactId: id };
await expect(invoke(handlers.get(path)!, body)).rejects.toThrow("Screenshot artifact is unavailable or invalid");
}
}
});
});
});
describe("Help self-check", () => {
it.each(["/report/draft", "/report/file"]) ("does not let direct Help %s bypass a confident knowledge answer", async (path) => {
vi.mocked(queryKnowledgePagesAsync).mockResolvedValue([{ title: "Use settings", summary: "Open settings first." }]);
const handlers = setup();
const response = await invoke(handlers.get(path)!, path === "/report/file"
? { actionType: "help", report: { userPrompt: "How do I use settings?", context: {} } }
: { actionType: "help", userPrompt: "How do I use settings?" });
expect(response).toMatchObject({ kind: "help", answer: { title: "Use settings" } });
expect(runReportPipeline).not.toHaveBeenCalled();
});
it.each(["/report/draft", "/report/file"])("does not let direct Help %s bypass a confident knowledge answer", async (path) => {
vi.mocked(queryKnowledgePagesAsync).mockResolvedValue([{ title: "Use settings", summary: "Open settings first." }]);
const { handlers } = setup();
const response = await invoke(handlers.get(path)!, path === "/report/file"
? { actionType: "help", report: { userPrompt: "How do I use settings?", context: {} } }
: { actionType: "help", userPrompt: "How do I use settings?" });
expect(response.body).toMatchObject({ kind: "help", answer: { title: "Use settings" } });
expect(runReportPipeline).not.toHaveBeenCalled();
});
});
});

View File

@@ -7,18 +7,13 @@ import { scrubReportPayload, type ReportScrubContext } from "./report-scrub.js";
export type { ReportActionType, ReportMode };
export interface ReportScreenshot {
dataUrl: string;
capturedAt: string;
}
export interface ReportInput {
actionType: ReportActionType;
userPrompt: string;
contextRefs?: { taskId?: string; agentId?: string };
activityTrace?: string[];
/** Binary pixels are preserved for explicit, reviewed upload only. */
screenshot?: ReportScreenshot;
/** Provenance-validated local screenshot artifact reference. */
screenshotArtifactId?: string;
}
export interface StructuredReport {
@@ -28,30 +23,29 @@ export interface StructuredReport {
summary: string;
body: string;
context: Record<string, unknown>;
/** User-reviewed pixels; never interpolated into text context. */
screenshot?: ReportScreenshot;
/** Local screenshot artifact reference; pixels never transit egress. */
screenshotArtifactId?: string;
sessionToken?: string;
}
export type ReportResult =
| { kind: "draft-ready"; report: StructuredReport; mode: ReportMode }
| { kind: "duplicate-found"; report: StructuredReport; mode: ReportMode; issue: { number: number; url: string; title: string; discussionId?: string; roadmap?: true } }
| { kind: "filed"; url: string; report: StructuredReport; screenshotNotAttached?: boolean }
| { kind: "endorsed"; url: string; issueNumber: number; report: StructuredReport; screenshotNotAttached?: boolean }
| { kind: "filed"; url: string; report: StructuredReport }
| { kind: "endorsed"; url: string; issueNumber: number; report: StructuredReport }
| { kind: "unavailable"; reason: string; message: string };
export interface ReportPipelineDeps {
projectSettings: Pick<ProjectSettings, "reportMode" | "reportModeByAction" | "reportRoadmapDedupeEnabled" | "reportRoadmapLabel" | "reportRoadmapRepo" | "githubTrackingDefaultRepo" | "githubAuthMode" | "githubAuthToken">;
globalSettings?: Partial<GlobalSettings>;
client?: Pick<GitHubClient, "createIssue" | "searchIssues" | "commentOnIssue" | "addIssueReaction"> & Partial<Pick<GitHubClient, "searchDiscussions" | "createDiscussion" | "commentOnDiscussion" | "addDiscussionReaction" | "uploadReportImage" | "deleteReportImage">>;
client?: Pick<GitHubClient, "createIssue" | "searchIssues" | "commentOnIssue" | "addIssueReaction"> & Partial<Pick<GitHubClient, "searchDiscussions" | "createDiscussion" | "commentOnDiscussion" | "addDiscussionReaction">>;
scrubContext?: ReportScrubContext;
gatherContext?: (input: ReportInput) => Promise<Record<string, unknown>>;
}
const MAX_PROMPT_LENGTH = 4_000;
export const MAX_ACTIVITY_TRACE_ENTRIES = 20;
export const MAX_SCREENSHOT_DATA_URL_LENGTH = 2_800_000;
/*
FNXC:ReportPipeline 2026-07-16-10:45:
Screenshot capture remains a per-report, off-by-default user choice rather than
@@ -89,7 +83,6 @@ function expectedBehavior(actionType: ReportActionType): string {
function structureReport(input: ReportInput, gathered: Record<string, unknown>): StructuredReport {
const prompt = requirePrompt(input);
if (input.activityTrace && (input.activityTrace.length > MAX_ACTIVITY_TRACE_ENTRIES || input.activityTrace.some((entry) => typeof entry !== "string" || entry.length > 1_000))) throw new Error("Activity trace is invalid.");
if (input.screenshot && input.screenshot.dataUrl.length > MAX_SCREENSHOT_DATA_URL_LENGTH) throw new Error("Screenshot is too large.");
// FNXC:ReportPipeline 2026-07-16-09:00:
// Activity trace is ordinary text context. It must continue through
// scrubReportPayload with every other report field before GitHub egress.
@@ -99,9 +92,9 @@ function structureReport(input: ReportInput, gathered: Record<string, unknown>):
userPrompt: prompt,
sourcePrompt: prompt,
summary: `[${input.actionType}] ${prompt.slice(0, 120)}`,
body: `## Summary\n${prompt}\n\n## Reproduction / context\n${formattedContext}\n\n## Expected behavior\n${expectedBehavior(input.actionType)}\n\n## Actual behavior / request\n${prompt}\n\n## Environment\n${formattedContext}`,
body: `## Summary\n${prompt}\n\n## Reproduction / context\n${formattedContext}\n\n## Expected behavior\n${expectedBehavior(input.actionType)}\n\n## Actual behavior / request\n${prompt}\n\n## Environment\n${formattedContext}${input.screenshotArtifactId ? `\n\n## Screenshot\nA screenshot was captured and stored locally (artifact ${input.screenshotArtifactId}).` : ""}`,
context,
screenshot: input.screenshot,
screenshotArtifactId: input.screenshotArtifactId,
sessionToken: crypto.randomUUID(),
};
}
@@ -177,65 +170,10 @@ async function findRoadmapDuplicate(client: NonNullable<ReportPipelineDeps["clie
return undefined;
}
function approvedReportImageUrl(candidate: string | undefined, owner: string, repo: string): string | undefined {
if (!candidate || candidate.length > 2_048 || /[\s()[\]<>"']/.test(candidate)) return undefined;
try {
const url = new URL(candidate);
const repositoryPrefix = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/`;
// FNXC:ReportPipeline 2026-07-18-16:30: The image host response is outside
// the mandatory text scrub boundary. Embed only a raw.githubusercontent.com
// HTTPS URL for the selected repository, never arbitrary Markdown or pixels.
if (
url.protocol !== "https:"
|| url.hostname !== "raw.githubusercontent.com"
|| url.port !== ""
|| url.username
|| url.password
|| url.search
|| url.hash
|| !url.pathname.startsWith(repositoryPrefix)
) return undefined;
return url.toString();
} catch {
return undefined;
}
}
function appendReviewedScreenshot(report: StructuredReport, screenshotUrl: string | undefined): StructuredReport {
return screenshotUrl
? { ...report, body: `${report.body}\n\n## Screenshot\n![User-reviewed screenshot](${screenshotUrl})` }
: report;
}
async function attachReviewedScreenshot(
screenshot: ReportScreenshot | undefined,
client: NonNullable<ReportPipelineDeps["client"]>,
owner: string,
repo: string,
attach: (screenshotUrl: string) => Promise<void>,
): Promise<{ screenshotUrl?: string; screenshotNotAttached: boolean }> {
if (!screenshot) return { screenshotNotAttached: false };
// FNXC:ReportPipeline 2026-07-18-19:30: A screenshot is sensitive binary
// egress. Upload only after the report's text operation succeeds, and require
// a compensating delete before upload so an attachment-comment failure cannot
// leave an orphaned user image in GitHub.
if (!client.uploadReportImage || !client.deleteReportImage) return { screenshotNotAttached: true };
const candidate = await client.uploadReportImage(owner, repo, screenshot).catch(() => undefined);
const screenshotUrl = approvedReportImageUrl(candidate, owner, repo);
if (!screenshotUrl) return { screenshotNotAttached: true };
try {
await attach(screenshotUrl);
return { screenshotUrl, screenshotNotAttached: false };
} catch {
await Promise.resolve(client.deleteReportImage(owner, repo, screenshotUrl)).catch(() => undefined);
return { screenshotNotAttached: true };
}
}
async function endorseDiscussionDuplicate(args: { issueNumber: number; discussionId: string; report: StructuredReport; screenshotUrl?: string; client: NonNullable<ReportPipelineDeps["client"]> & Pick<GitHubClient, "commentOnDiscussion" | "addDiscussionReaction">; scrubContext?: ReportScrubContext }): Promise<Extract<ReportResult, { kind: "endorsed" }>> {
async function endorseDiscussionDuplicate(args: { issueNumber: number; discussionId: string; report: StructuredReport; client: NonNullable<ReportPipelineDeps["client"]> & Pick<GitHubClient, "commentOnDiscussion" | "addDiscussionReaction">; scrubContext?: ReportScrubContext }): Promise<Extract<ReportResult, { kind: "endorsed" }>> {
const sessionToken = args.report.sessionToken ?? `${args.discussionId}:${args.report.summary}`;
const report = appendReviewedScreenshot(scrubReportPayload(args.report, args.scrubContext), args.screenshotUrl);
const report = scrubReportPayload(args.report, args.scrubContext);
const existing = endorsedSessions.get(sessionToken);
if (existing) return { kind: "endorsed", ...existing, report };
/*
@@ -250,10 +188,10 @@ async function endorseDiscussionDuplicate(args: { issueNumber: number; discussio
return { kind: "endorsed", ...result, report };
}
export async function endorseDuplicate(args: { owner: string; repo: string; issueNumber: number; report: StructuredReport; screenshotUrl?: string; client: NonNullable<ReportPipelineDeps["client"]>; scrubContext?: ReportScrubContext }): Promise<Extract<ReportResult, { kind: "endorsed" }>> {
export async function endorseDuplicate(args: { owner: string; repo: string; issueNumber: number; report: StructuredReport; client: NonNullable<ReportPipelineDeps["client"]>; scrubContext?: ReportScrubContext }): Promise<Extract<ReportResult, { kind: "endorsed" }>> {
const sessionToken = args.report.sessionToken ?? `${args.issueNumber}:${args.report.summary}`;
const existing = endorsedSessions.get(sessionToken);
const report = appendReviewedScreenshot(scrubReportPayload(args.report, args.scrubContext), args.screenshotUrl);
const report = scrubReportPayload(args.report, args.scrubContext);
if (existing) return { kind: "endorsed", ...existing, report };
/*
FNXC:ReportPipeline 2026-07-16-18:00:
@@ -289,7 +227,7 @@ function normalizeSubmittedReport(input: ReportInput, gathered: Record<string, u
summary: !promptChangedSinceDerivation && typeof submitted.summary === "string" && submitted.summary.trim() ? submitted.summary : rebuilt.summary,
body: !promptChangedSinceDerivation && typeof submitted.body === "string" && submitted.body.trim() ? submitted.body : rebuilt.body,
context: submitted.context && typeof submitted.context === "object" ? { ...rebuilt.context, ...submitted.context } : rebuilt.context,
screenshot: input.screenshot,
screenshotArtifactId: input.screenshotArtifactId,
sessionToken: typeof submitted.sessionToken === "string" && submitted.sessionToken ? submitted.sessionToken : rebuilt.sessionToken,
};
}
@@ -297,11 +235,7 @@ function normalizeSubmittedReport(input: ReportInput, gathered: Record<string, u
export async function runReportPipeline(input: ReportInput, deps: ReportPipelineDeps, options: { file?: boolean; endorseIssueNumber?: number; endorseDiscussionId?: string; endorseRoadmapIssueNumber?: number; report?: StructuredReport } = {}): Promise<ReportResult> {
const gathered = await deps.gatherContext?.(input) ?? { taskId: input.contextRefs?.taskId, agentId: input.contextRefs?.agentId };
const normalized = normalizeSubmittedReport(input, gathered, options.report);
// FNXC:ReportPipeline 2026-07-18-14:30: Screenshot data is validated by the
// route and is not textual report content. Strip it before the mandatory text
// scrub, then restore only the explicit per-report input for reviewed upload.
const { screenshot: _screenshot, ...textualReport } = normalized;
let report: StructuredReport = { ...scrubReportPayload(textualReport, deps.scrubContext), ...(input.screenshot ? { screenshot: input.screenshot } : {}) };
const report: StructuredReport = scrubReportPayload(normalized, deps.scrubContext);
const mode = resolveReportMode(input.actionType, deps.projectSettings);
const clientResult = createClient(deps);
@@ -316,20 +250,8 @@ export async function runReportPipeline(input: ReportInput, deps: ReportPipeline
issue +1/scrub path; unavailable roadmap search falls through without egress.
*/
const roadmapDuplicate = await findRoadmapDuplicate(clientResult.client, roadmap, report);
const shouldAttachScreenshot = Boolean(input.screenshot) && (options.file || mode === "auto-file");
const destination = destinationFor(input.actionType);
// FNXC:ReportPipeline 2026-07-18-19:30: Validate and publish the scrubbed
// text report before starting screenshot egress. This avoids uploading pixels
// for stale duplicate endorsements or a failed create operation.
const duplicate = await findDuplicate(clientResult.client, repo.owner, repo.repo, report, destination);
const attachToIssue = (issueNumber: number) => attachReviewedScreenshot(
shouldAttachScreenshot ? input.screenshot : undefined, clientResult.client!, repo.owner, repo.repo,
async (url) => { await clientResult.client!.commentOnIssue(repo.owner, repo.repo, issueNumber, `## Screenshot\n![User-reviewed screenshot](${url})`); },
);
const attachToDiscussion = (discussionId: string) => attachReviewedScreenshot(
shouldAttachScreenshot ? input.screenshot : undefined, clientResult.client!, repo.owner, repo.repo,
async (url) => { await clientResult.client!.commentOnDiscussion!(discussionId, `## Screenshot\n![User-reviewed screenshot](${url})`); },
);
if (options.endorseRoadmapIssueNumber) {
if (!roadmapDuplicate || roadmapDuplicate.number !== options.endorseRoadmapIssueNumber || !roadmap.repo) {
return { kind: "unavailable", reason: "duplicate_not_verified", message: "The selected roadmap item is no longer an open matching report. Please prepare the report again." };
@@ -342,16 +264,14 @@ export async function runReportPipeline(input: ReportInput, deps: ReportPipeline
return { kind: "unavailable", reason: "duplicate_not_verified", message: "The selected discussion is no longer an open matching report. Please prepare the report again." };
}
const endorsed = await endorseDiscussionDuplicate({ issueNumber: duplicate.number, discussionId: duplicate.discussionId, report, client: clientResult.client as NonNullable<ReportPipelineDeps["client"]> & Pick<GitHubClient, "commentOnDiscussion" | "addDiscussionReaction">, scrubContext: deps.scrubContext });
const attachment = await attachToDiscussion(duplicate.discussionId);
return { ...endorsed, report: appendReviewedScreenshot(endorsed.report, attachment.screenshotUrl), ...(attachment.screenshotNotAttached ? { screenshotNotAttached: true } : {}) };
return endorsed;
}
if (options.endorseIssueNumber) {
if (destination !== "issue" || duplicate?.number !== options.endorseIssueNumber) {
return { kind: "unavailable", reason: "duplicate_not_verified", message: "The selected issue is no longer an open matching report. Please prepare the report again." };
}
const endorsed = await endorseDuplicate({ owner: repo.owner, repo: repo.repo, issueNumber: duplicate.number, report, client: clientResult.client, scrubContext: deps.scrubContext });
const attachment = await attachToIssue(duplicate.number);
return { ...endorsed, report: appendReviewedScreenshot(endorsed.report, attachment.screenshotUrl), ...(attachment.screenshotNotAttached ? { screenshotNotAttached: true } : {}) };
return endorsed;
}
if (roadmapDuplicate) {
if (mode === "auto-file") return endorseDuplicate({ owner: roadmap.repo!.owner, repo: roadmap.repo!.repo, issueNumber: roadmapDuplicate.number, report, client: clientResult.client, scrubContext: deps.scrubContext });
@@ -364,12 +284,10 @@ export async function runReportPipeline(input: ReportInput, deps: ReportPipeline
return { kind: "unavailable", reason: "discussion_unsupported", message: "This GitHub connection cannot endorse discussions." };
}
const endorsed = await endorseDiscussionDuplicate({ issueNumber: duplicate.number, discussionId: duplicate.discussionId, report, client: clientResult.client as NonNullable<ReportPipelineDeps["client"]> & Pick<GitHubClient, "commentOnDiscussion" | "addDiscussionReaction">, scrubContext: deps.scrubContext });
const attachment = await attachToDiscussion(duplicate.discussionId);
return { ...endorsed, report: appendReviewedScreenshot(endorsed.report, attachment.screenshotUrl), ...(attachment.screenshotNotAttached ? { screenshotNotAttached: true } : {}) };
return endorsed;
}
const endorsed = await endorseDuplicate({ owner: repo.owner, repo: repo.repo, issueNumber: duplicate.number, report, client: clientResult.client, scrubContext: deps.scrubContext });
const attachment = await attachToIssue(duplicate.number);
return { ...endorsed, report: appendReviewedScreenshot(endorsed.report, attachment.screenshotUrl), ...(attachment.screenshotNotAttached ? { screenshotNotAttached: true } : {}) };
return endorsed;
}
return { kind: "duplicate-found", report, mode, issue: { number: duplicate.number, url: duplicate.html_url, title: duplicate.title, discussionId: duplicate.discussionId } };
}
@@ -377,12 +295,8 @@ export async function runReportPipeline(input: ReportInput, deps: ReportPipeline
if (destination === "discussion") {
if (!clientResult.client.createDiscussion || !clientResult.client.commentOnDiscussion) return { kind: "unavailable", reason: "discussion_unsupported", message: "This GitHub connection cannot create discussions." };
const created = await clientResult.client.createDiscussion(repo.owner, repo.repo, report.summary, report.body);
const attachment = await attachToDiscussion(created.id);
report = appendReviewedScreenshot(report, attachment.screenshotUrl);
return { kind: "filed", url: created.htmlUrl, report, ...(attachment.screenshotNotAttached ? { screenshotNotAttached: true } : {}) };
return { kind: "filed", url: created.htmlUrl, report };
}
const created = await clientResult.client.createIssue({ owner: repo.owner, repo: repo.repo, title: report.summary, body: report.body, labels: ["community"] });
const attachment = await attachToIssue(created.number);
report = appendReviewedScreenshot(report, attachment.screenshotUrl);
return { kind: "filed", url: created.htmlUrl, report, ...(attachment.screenshotNotAttached ? { screenshotNotAttached: true } : {}) };
return { kind: "filed", url: created.htmlUrl, report };
}

View File

@@ -1,3 +1,5 @@
import { REPORT_ATTACHMENT_SOURCE } from "@fusion/core";
import type { Request, Response } from "express";
import { ApiError } from "../api-error.js";
import { queryKnowledgePagesAsync } from "../knowledge-index.js";
import { requireAsyncLayer } from "../require-async-layer.js";
@@ -9,9 +11,11 @@ import type { ApiRouteRegistrar } from "./types.js";
const ACTION_TYPES = new Set(["bug", "feedback", "idea", "help"]);
const MAX_ACTIVITY_TRACE_ENTRIES = 20;
const MAX_ACTIVITY_TRACE_CHARS = 4_000;
const MAX_SCREENSHOT_DATA_URL_LENGTH = 2_800_000;
const MAX_SCREENSHOT_BYTES = 2 * 1024 * 1024;
const SCREENSHOT_DATA_URL = /^data:image\/(png|jpeg);base64,([A-Za-z0-9+/=]+)$/i;
export const MAX_SCREENSHOT_BYTES = 2 * 1024 * 1024;
/** UUID v4 references keep report input independent of binary transport. */
export const ARTIFACT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const JPEG_SIGNATURE = Buffer.from([0xff, 0xd8, 0xff]);
function parseActivityTrace(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
@@ -20,38 +24,27 @@ function parseActivityTrace(value: unknown): string[] | undefined {
return entries;
}
function parseScreenshot(value: unknown): ReportInput["screenshot"] {
function parseScreenshotArtifactId(value: unknown): string | undefined {
if (value === undefined) return undefined;
if (!value || typeof value !== "object") throw new ApiError(400, "Screenshot is invalid.");
const candidate = value as Record<string, unknown>;
const match = typeof candidate.dataUrl === "string" && candidate.dataUrl.length <= MAX_SCREENSHOT_DATA_URL_LENGTH
? candidate.dataUrl.match(SCREENSHOT_DATA_URL)
: undefined;
if (typeof candidate.capturedAt !== "string" || !match || match[2].length % 4 !== 0) throw new ApiError(400, "Screenshot is invalid.");
const bytes = Buffer.from(match[2], "base64");
const canonical = bytes.toString("base64") === match[2];
const isPng = match[1].toLowerCase() === "png" && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
const isJpeg = match[1].toLowerCase() === "jpeg" && bytes.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]));
/*
FNXC:ReportPipeline 2026-07-18-14:30:
A data-URL prefix is not proof of an image. Decode the bounded payload and
verify its declared PNG/JPEG signature before the reviewed screenshot reaches
any configured host.
*/
if (!canonical || bytes.length === 0 || bytes.length > MAX_SCREENSHOT_BYTES || (!isPng && !isJpeg)) throw new ApiError(400, "Screenshot is invalid.");
return { dataUrl: match[0], capturedAt: candidate.capturedAt.slice(0, 64) };
if (typeof value !== "string" || !ARTIFACT_ID_PATTERN.test(value)) throw new ApiError(400, "Screenshot artifact reference is invalid.");
return value;
}
async function validateScreenshotArtifact(store: Awaited<ReturnType<Parameters<ApiRouteRegistrar>[0]["getScopedStore"]>>, id: string | undefined): Promise<void> {
if (!id) return;
const artifact = await store.getArtifact(id);
if (artifact?.type !== "image" || artifact.metadata?.source !== REPORT_ATTACHMENT_SOURCE) throw new ApiError(400, "Screenshot artifact is unavailable or invalid.");
}
function imageMimeType(buffer: Buffer): "image/png" | "image/jpeg" | undefined {
if (buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return "image/png";
if (buffer.subarray(0, JPEG_SIGNATURE.length).equals(JPEG_SIGNATURE)) return "image/jpeg";
return undefined;
}
async function gatherReportContext(store: Awaited<ReturnType<Parameters<ApiRouteRegistrar>[0]["getScopedStore"]>>, input: ReportInput, settings: Record<string, unknown>): Promise<Record<string, unknown>> {
const context: Record<string, unknown> = {
reportMode: settings.reportMode,
githubAuthMode: settings.githubAuthMode,
taskId: input.contextRefs?.taskId,
agentId: input.contextRefs?.agentId,
activityTrace: input.activityTrace,
};
const context: Record<string, unknown> = { reportMode: settings.reportMode, githubAuthMode: settings.githubAuthMode, taskId: input.contextRefs?.taskId, agentId: input.contextRefs?.agentId, activityTrace: input.activityTrace };
if (!input.contextRefs?.taskId) return context;
const task = await store.getTask(input.contextRefs.taskId).catch(() => null);
if (!task) return context;
const logs = await store.getAgentLogs(task.id, { limit: 10 }).catch(() => []);
@@ -71,102 +64,54 @@ function parseInput(body: unknown): ReportInput {
const actionType = typeof value.actionType === "string" ? value.actionType : "";
const userPrompt = typeof value.userPrompt === "string" ? value.userPrompt : "";
if (!ACTION_TYPES.has(actionType) || !userPrompt.trim()) throw new ApiError(400, "A report type and description are required.");
return {
actionType: actionType as ReportInput["actionType"],
userPrompt,
contextRefs: typeof value.contextRefs === "object" && value.contextRefs ? value.contextRefs as ReportInput["contextRefs"] : undefined,
activityTrace: parseActivityTrace(value.activityTrace),
screenshot: parseScreenshot(value.screenshot),
};
return { actionType: actionType as ReportInput["actionType"], userPrompt, contextRefs: typeof value.contextRefs === "object" && value.contextRefs ? value.contextRefs as ReportInput["contextRefs"] : undefined, activityTrace: parseActivityTrace(value.activityTrace), screenshotArtifactId: parseScreenshotArtifactId(value.screenshotArtifactId) };
}
/**
* FNXC:ReportPipeline 2026-07-16-12:00:
* All report routes inherit dashboard auth and resolve a scoped store. The file
* route treats edited drafts as untrusted and re-scrubs server-side immediately
* before the pipeline may call GitHub.
* FNXC:ReportPipeline 2026-07-19-10:00:
* Report routes persist opted-in PNG/JPEG pixels locally as provenance-marked
* artifacts. Draft and file requests carry only a validated reference and text
* note, so no screenshot pixels can cross the GitHub egress boundary.
*/
export const registerReportRoutes: ApiRouteRegistrar = ({ router, getScopedStore, rethrowAsApiError }) => {
router.post("/report/draft", async (req, res) => {
export const registerReportRoutes: ApiRouteRegistrar = ({ router, getScopedStore, rethrowAsApiError, reportUpload }) => {
const attachment = async (req: Request & { file?: { buffer?: Buffer; mimetype?: string } }, res: Response) => {
try {
const store = await getScopedStore(req);
const scopes = await store.getSettingsByScopeFast();
const input = parseInput(req.body);
const help = await selfCheckHelpBeforePipeline(store, input);
if (help?.answered) {
res.json({ kind: "help", answer: help.answer });
return;
}
const result = await runReportPipeline(input, {
projectSettings: scopes.project,
globalSettings: scopes.global,
scrubContext: { rootDir: store.getRootDir(), projectName: store.getRootDir().split(/[\\/]/).pop() },
gatherContext: (reportInput) => gatherReportContext(store, reportInput, scopes.project as Record<string, unknown>),
});
res.json(result);
const file = req.file;
const mimeType = file?.buffer ? imageMimeType(file.buffer) : undefined;
if (!file?.buffer || file.buffer.length === 0 || file.buffer.length > MAX_SCREENSHOT_BYTES || !mimeType) throw new ApiError(400, "A PNG or JPEG screenshot under 2MB is required.");
const artifact = await store.registerArtifact({ type: "image", title: "Report screenshot", authorId: REPORT_ATTACHMENT_SOURCE, authorType: "system", metadata: { source: REPORT_ATTACHMENT_SOURCE }, data: file.buffer, mimeType });
res.json({ artifactId: artifact.id });
} catch (error) {
if (error instanceof ApiError) throw error;
rethrowAsApiError(error, "Failed to prepare report draft");
rethrowAsApiError(error, "Failed to store report screenshot");
}
};
if (reportUpload) router.post("/report/attachment", reportUpload.single("screenshot"), attachment);
else router.post("/report/attachment", attachment);
router.post("/report/draft", async (req, res) => {
try {
const store = await getScopedStore(req); const scopes = await store.getSettingsByScopeFast(); const input = parseInput(req.body);
await validateScreenshotArtifact(store, input.screenshotArtifactId);
const help = await selfCheckHelpBeforePipeline(store, input);
if (help?.answered) return void res.json({ kind: "help", answer: help.answer });
res.json(await runReportPipeline(input, { projectSettings: scopes.project, globalSettings: scopes.global, scrubContext: { rootDir: store.getRootDir(), projectName: store.getRootDir().split(/[\\/]/).pop() }, gatherContext: (reportInput) => gatherReportContext(store, reportInput, scopes.project as Record<string, unknown>) }));
} catch (error) { if (error instanceof ApiError) throw error; rethrowAsApiError(error, "Failed to prepare report draft"); }
});
router.post("/report/file", async (req, res) => {
try {
const store = await getScopedStore(req);
const scopes = await store.getSettingsByScopeFast();
const raw = (req.body ?? {}) as Record<string, unknown>;
const rawReport = (raw.report ?? raw) as StructuredReport;
// Validate the only binary-bearing field before separating it from the
// untrusted editable draft. scrubReportPayload intentionally scrubs every
// string, including arbitrary pasted data URLs in report.body.
const screenshot = parseScreenshot(raw.screenshot ?? rawReport.screenshot);
const { screenshot: _submittedScreenshot, ...textualRawReport } = rawReport;
const store = await getScopedStore(req); const scopes = await store.getSettingsByScopeFast(); const raw = (req.body ?? {}) as Record<string, unknown>; const rawReport = (raw.report ?? raw) as StructuredReport;
const { screenshotArtifactId: reportArtifactId, ...textualRawReport } = rawReport;
const untrusted = scrubReportPayload(textualRawReport, { rootDir: store.getRootDir(), projectName: store.getRootDir().split(/[\\/]/).pop() });
const input = parseInput({
actionType: raw.actionType ?? (untrusted.context as Record<string, unknown> | undefined)?.actionType ?? "bug",
userPrompt: untrusted.userPrompt ?? untrusted.summary,
contextRefs: (untrusted.context as Record<string, unknown> | undefined) && {
taskId: typeof (untrusted.context as Record<string, unknown>).taskId === "string" ? (untrusted.context as Record<string, unknown>).taskId : undefined,
agentId: typeof (untrusted.context as Record<string, unknown>).agentId === "string" ? (untrusted.context as Record<string, unknown>).agentId : undefined,
},
activityTrace: raw.activityTrace ?? (untrusted.context as Record<string, unknown> | undefined)?.activityTrace,
screenshot,
});
const validatedInput = input;
const endorseIssueNumber = typeof raw.endorseIssueNumber === "number" ? raw.endorseIssueNumber : undefined;
const endorseDiscussionId = typeof raw.endorseDiscussionId === "string" ? raw.endorseDiscussionId : undefined;
// FNXC:ReportPipeline 2026-07-18-20:30: A browser-supplied public-roadmap
// issue number is never authority to comment. The pipeline re-searches the
// OPEN label-qualified item and re-scrubs this editable payload before egress.
const endorseRoadmapIssueNumber = typeof raw.endorseRoadmapIssueNumber === "number" ? raw.endorseRoadmapIssueNumber : undefined;
const help = await selfCheckHelpBeforePipeline(store, validatedInput);
if (help?.answered) {
res.json({ kind: "help", answer: help.answer });
return;
}
const result = await runReportPipeline(validatedInput, {
projectSettings: scopes.project,
globalSettings: scopes.global,
scrubContext: { rootDir: store.getRootDir(), projectName: store.getRootDir().split(/[\\/]/).pop() },
gatherContext: (reportInput) => gatherReportContext(store, reportInput, scopes.project as Record<string, unknown>),
}, { file: true, endorseIssueNumber, endorseDiscussionId, endorseRoadmapIssueNumber, report: untrusted });
res.json(result);
} catch (error) {
if (error instanceof ApiError) throw error;
rethrowAsApiError(error, "Failed to file report");
}
const input = parseInput({ actionType: raw.actionType ?? (untrusted.context as Record<string, unknown> | undefined)?.actionType ?? "bug", userPrompt: untrusted.userPrompt ?? untrusted.summary, contextRefs: (untrusted.context as Record<string, unknown> | undefined) && { taskId: typeof (untrusted.context as Record<string, unknown>).taskId === "string" ? (untrusted.context as Record<string, unknown>).taskId : undefined, agentId: typeof (untrusted.context as Record<string, unknown>).agentId === "string" ? (untrusted.context as Record<string, unknown>).agentId : undefined }, activityTrace: raw.activityTrace ?? (untrusted.context as Record<string, unknown> | undefined)?.activityTrace, screenshotArtifactId: raw.screenshotArtifactId ?? reportArtifactId });
await validateScreenshotArtifact(store, input.screenshotArtifactId);
const help = await selfCheckHelpBeforePipeline(store, input);
if (help?.answered) return void res.json({ kind: "help", answer: help.answer });
res.json(await runReportPipeline(input, { projectSettings: scopes.project, globalSettings: scopes.global, scrubContext: { rootDir: store.getRootDir(), projectName: store.getRootDir().split(/[\\/]/).pop() }, gatherContext: (reportInput) => gatherReportContext(store, reportInput, scopes.project as Record<string, unknown>) }, { file: true, endorseIssueNumber: typeof raw.endorseIssueNumber === "number" ? raw.endorseIssueNumber : undefined, endorseDiscussionId: typeof raw.endorseDiscussionId === "string" ? raw.endorseDiscussionId : undefined, endorseRoadmapIssueNumber: typeof raw.endorseRoadmapIssueNumber === "number" ? raw.endorseRoadmapIssueNumber : undefined, report: untrusted }));
} catch (error) { if (error instanceof ApiError) throw error; rethrowAsApiError(error, "Failed to file report"); }
});
router.post("/report/help", async (req, res) => {
try {
const store = await getScopedStore(req);
const question = typeof req.body?.question === "string" ? req.body.question : "";
const layer = requireAsyncLayer(store, "Help self-check");
const result = await selfCheckHelp(question, (query) => queryKnowledgePagesAsync(layer, { query, limit: 1 }));
res.json(result);
} catch (error) {
if (error instanceof ApiError) throw error;
rethrowAsApiError(error, "Failed to self-check help question");
}
});
router.post("/report/help", async (req, res) => { try { const store = await getScopedStore(req); const layer = requireAsyncLayer(store, "Help self-check"); res.json(await selfCheckHelp(typeof req.body?.question === "string" ? req.body.question : "", (query) => queryKnowledgePagesAsync(layer, { query, limit: 1 }))); } catch (error) { if (error instanceof ApiError) throw error; rethrowAsApiError(error, "Failed to self-check help question"); } });
};