FN-8317: add reviewed report screenshots and activity traces

Capture optional report context while preserving scrubbed text-first filing.\n\n- Add browser screenshot capture, review, validation, and compensating upload cleanup.\n- Include bounded activity traces in report drafts and filing flows.\n- Preserve roadmap deduplication and document report privacy behavior.\n\nFiles changed:\n .changeset/fn-8317-report-context.md               |   7 +
 docs/dashboard-guide.md                            |   4 +-
 packages/dashboard/app/api/report.ts               |  15 +-
 packages/dashboard/app/components/ReportModal.css  |   5 +-
 packages/dashboard/app/components/ReportModal.tsx  |  52 +++----
 .../app/components/__tests__/ReportModal.test.tsx  |  14 ++
 packages/dashboard/app/hooks/useViewState.ts       |   7 +
 .../app/utils/__tests__/report-capture.test.ts     |  17 +++
 packages/dashboard/app/utils/report-capture.ts     |  47 +++++++
 packages/dashboard/package.json                    |   1 -
 .../src/__tests__/report-pipeline.test.ts          | 100 +++++++++++++-
 .../dashboard/src/__tests__/report-routes.test.ts  |  17 +++
 .../dashboard/src/__tests__/report-scrub.test.ts   |   8 ++
 packages/dashboard/src/github.ts                   | 107 +++++++++++++++
 packages/dashboard/src/report-pipeline.ts          | 152 ++++++++++++++++-----
 packages/dashboard/src/report-scrub.ts             |   8 ++
 .../dashboard/src/routes/register-report-routes.ts | 103 ++++++--------
 pnpm-lock.yaml                                     | 129 +++++++++--------
 18 files changed, 590 insertions(+), 203 deletions(-)

Fusion-Task-Id: FN-8317

Fusion-Task-Lineage: 9d200cee-cf69-4827-9e1e-f7789adf09e0

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 17:04:13 -07:00
parent 55101e03cf
commit 7e7c3c999b
18 changed files with 590 additions and 203 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add opt-in reviewed screenshots and scrubbed activity traces to in-app reports.
category: feature
dev: Uses native Screen Capture API; unavailable screenshot hosting falls back to text-only filing.

View File

@@ -2094,4 +2094,6 @@ Projects can opt into **Check roadmap before filing reports** in **Settings →
In **Settings → General**, choose **Review draft before filing** (the default) or **File automatically**. Both paths show the resulting issue, Discussion, or endorsement link. Help checks Fusion's local knowledge index on every server report path first and only escalates when it cannot find an answer.
Reports include a default-on activity trace of up to 20 recent views, report actions, toast messages, and uncaught client errors (at most 4,000 characters). It is ordinary report text and receives the same server-side scrub as every other report field. You can optionally attach a screenshot of the current Fusion view. Fusion captures only the dashboard DOM, shows the exact preview, and requires confirmation before it uploads the PNG/JPEG (up to 2MB) as a local image artifact. In automatic mode this confirmation and local artifact upload happen before filing begins. The server validates the resulting artifact reference (format, image type, MIME type, and report-upload provenance) before it can appear as a text-only local-retention note in a report. Screenshot pixels are never sent to GitHub.
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.

View File

@@ -6,18 +6,9 @@ async function post(path: string, body: unknown) {
return response.json();
}
export interface ReportActivityTraceEntry { ts: string; kind: string; label: string; }
export interface ReportContextInput { actionType: ReportActionType; userPrompt: string; contextRefs?: { taskId?: string; agentId?: string }; activityTrace?: ReportActivityTraceEntry[]; screenshotArtifactId?: string; }
export async function uploadReportScreenshot(blob: Blob, contextRefs?: { taskId?: string; agentId?: string }): Promise<{ artifactId: string; uri?: string }> {
const form = new FormData();
form.append("screenshot", blob, "report-screenshot.jpg");
if (contextRefs) form.append("contextRefs", JSON.stringify(contextRefs));
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();
}
export interface ReportScreenshot { dataUrl: string; capturedAt: string; }
export interface ReportContextInput { actionType: ReportActionType; userPrompt: string; contextRefs?: { taskId?: string; agentId?: string }; activityTrace?: string[]; screenshot?: ReportScreenshot; }
export function reportDraft(input: ReportContextInput) { return post("/api/report/draft", input); }
export function reportFile(input: { actionType: ReportActionType; report: unknown; endorseIssueNumber?: number; endorseDiscussionId?: string; activityTrace?: ReportActivityTraceEntry[]; screenshotArtifactId?: string }) { return post("/api/report/file", input); }
export function reportFile(input: { actionType: ReportActionType; report: unknown; endorseIssueNumber?: number; endorseDiscussionId?: string; activityTrace?: string[]; screenshot?: ReportScreenshot }) { return post("/api/report/file", input); }
export function reportHelp(question: string) { return post("/api/report/help", { question }); }

View File

@@ -3,7 +3,10 @@
.report-modal textarea { min-block-size: var(--space-32); resize: vertical; }
.report-modal__close { position: absolute; inset-block-start: var(--space-2); inset-inline-end: var(--space-2); }
.report-modal__error { color: var(--color-error); }
.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; }
@media (max-width: 768px) { .report-modal { inline-size: 100%; padding: var(--space-4); } }
.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); } }

View File

@@ -1,13 +1,15 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { ReportActionType } from "@fusion/core";
import { reportDraft, reportFile, reportHelp, uploadReportScreenshot } from "../api";
import { captureAppScreenshot, type CapturedScreenshot } from "../utils/capture-screenshot";
import { recordActivity, snapshotActivityTrace } from "../utils/activity-trace";
import { reportDraft, reportFile, reportHelp } from "../api";
import { captureScreenshot as captureScreen, getRecentActivity, recordActivity, type ReportScreenshot } 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?" };
type ModalResult = { kind: string; report?: { userPrompt: string; sourcePrompt?: string; summary?: string; body?: string; context?: Record<string, unknown>; sessionToken?: string }; issue?: { number: number; url: string; title: string; discussionId?: string }; roadmap?: { featureId: string; title: string; description: string }; url?: string; answer?: { summary?: string; content?: string }; message?: string };
type ModalResult = { kind: string; report?: { userPrompt: string; sourcePrompt?: string; summary?: string; body?: string; context?: Record<string, unknown>; sessionToken?: string }; issue?: { number: number; url: string; title: string; discussionId?: string }; roadmap?: { featureId: string; title: string; description: string }; url?: string; answer?: { summary?: string; content?: string }; message?: string; screenshotNotAttached?: boolean };
/**
* FNXC:ReportPipeline 2026-07-16-12:00:
@@ -21,17 +23,14 @@ 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<CapturedScreenshot>();
const [screenshotConfirmed, setScreenshotConfirmed] = useState(false);
const [screenshotArtifactId, setScreenshotArtifactId] = useState<string>();
useEffect(() => () => { if (capturedScreenshot) URL.revokeObjectURL(capturedScreenshot.previewUrl); }, [capturedScreenshot]);
const [capturedScreenshot, setCapturedScreenshot] = useState<ReportScreenshot>();
const captureScreenshot = async () => {
setBusy(true);
setError(undefined);
try {
const captured = await captureAppScreenshot();
const captured = await captureScreen();
if (!captured) throw new Error("Screen capture was unavailable or denied.");
setCapturedScreenshot(captured);
setScreenshotConfirmed(false);
} catch (captureError) {
setScreenshotEnabled(false);
setError(captureError instanceof Error ? captureError.message : "We could not capture the current screen.");
@@ -39,24 +38,19 @@ export function ReportModal({ actionType, onClose, contextRefs }: { actionType:
};
const submit = async () => {
if (!prompt.trim()) return;
if (screenshotEnabled && (!capturedScreenshot || !screenshotConfirmed)) {
setError("Preview and confirm the screenshot before continuing.");
if (screenshotEnabled && !capturedScreenshot) {
setError("Capture a screenshot before continuing, or turn attachment off.");
return;
}
setBusy(true);
setError(undefined);
try {
recordActivity({ kind: "report", label: `${actionType} report submitted` });
recordActivity("report");
if (actionType === "help") {
const help = await reportHelp(prompt);
if (help.answered) { setResult({ kind: "help", answer: help.answer }); return; }
}
const attachment = screenshotEnabled && capturedScreenshot && !screenshotArtifactId
? await uploadReportScreenshot(capturedScreenshot.blob, contextRefs)
: undefined;
const artifactId = attachment?.artifactId ?? screenshotArtifactId;
if (artifactId) setScreenshotArtifactId(artifactId);
setResult(await reportDraft({ actionType, userPrompt: prompt, contextRefs, activityTrace: snapshotActivityTrace(), screenshotArtifactId: artifactId }));
setResult(await reportDraft({ actionType, userPrompt: prompt, contextRefs, activityTrace: getRecentActivity(), screenshot: screenshotEnabled ? capturedScreenshot : undefined }));
} catch {
setError("We could not prepare your report. Check your connection and try again.");
} finally { setBusy(false); }
@@ -66,8 +60,9 @@ export function ReportModal({ actionType, onClose, contextRefs }: { actionType:
setBusy(true);
setError(undefined);
try {
recordActivity({ kind: "report", label: `${actionType} report filed` });
setResult(await reportFile({ actionType, report: result.report, endorseIssueNumber, endorseDiscussionId, activityTrace: snapshotActivityTrace(), screenshotArtifactId }));
recordActivity("report");
setResult(await reportFile({ actionType, report: result.report, endorseIssueNumber, endorseDiscussionId, activityTrace: getRecentActivity(), screenshot: screenshotEnabled ? capturedScreenshot : undefined }));
} catch {
setError("We could not send your report. Your draft is still here; try again.");
} finally { setBusy(false); }
@@ -76,11 +71,14 @@ export function ReportModal({ actionType, onClose, contextRefs }: { actionType:
<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-16-11:00: Screenshot capture is opt-in and always previewed and explicitly confirmed before its local upload or any report filing, including auto-file mode. */}
<label className="report-modal__screenshot-option"><input type="checkbox" checked={screenshotEnabled} onChange={(event) => { setScreenshotEnabled(event.target.checked); if (event.target.checked) void captureScreenshot(); else { setScreenshotConfirmed(false); setScreenshotArtifactId(undefined); } }} /> Attach a screenshot of the current screen</label>
{/* 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>
{screenshotEnabled && <div className="report-modal__screenshot-preview">
{capturedScreenshot ? <><img src={capturedScreenshot.previewUrl} alt="Preview of the screenshot that will be retained locally" /><label><input type="checkbox" checked={screenshotConfirmed} onChange={(event) => setScreenshotConfirmed(event.target.checked)} /> I confirm this preview may be retained locally with my report.</label></> : <p>Capturing a preview…</p>}
{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>}
</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></>}
{result?.kind === "draft-ready" && result.report && <><h2>Review your report</h2><label htmlFor="report-review-prompt">Report summary</label><textarea id="report-review-prompt" className="input" value={result.report.userPrompt} onChange={(event) => {
const userPrompt = event.target.value;
@@ -109,6 +107,7 @@ export function ReportModal({ actionType, onClose, contextRefs }: { actionType:
<textarea id="report-duplicate-body" className="input" value={result.report.body ?? ""} onChange={(event) => setResult({ ...result, report: { ...result.report!, body: event.target.value } })} />
<button className="btn btn-primary" type="button" disabled={busy} onClick={() => void file(result.issue!.discussionId ? undefined : result.issue!.number, result.issue!.discussionId)}>Confirm and add data point</button>
</>}
{result?.kind === "roadmap-match" && result.roadmap && <>
{/* FNXC:ReportPipeline 2026-07-18-12:45: A roadmap match stays inline because the roadmap view is intentionally not a dashboard destination; it informs the reporter and offers no dead deep-link or filing control. */}
<h2>{t("report.roadmapMatch.title", "Already on the roadmap")}</h2>
@@ -116,7 +115,8 @@ export function ReportModal({ actionType, onClose, contextRefs }: { actionType:
<h3>{result.roadmap.title}</h3>
{result.roadmap.description && <p>{result.roadmap.description}</p>}
</>}
{(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 === "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 === "help" && <><h2>Suggested help</h2><p>{result.answer?.summary ?? result.answer?.content}</p></>}
{result?.kind === "unavailable" && <>
<p role="alert">{result.message}</p>

View File

@@ -87,6 +87,20 @@ 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

@@ -3,6 +3,7 @@ import type { ThemeMode } from "@fusion/core";
import type { ProjectInfo } from "../api";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
import { getPluginViewId, isPluginViewId, isPluginViewRegistered } from "../plugins/pluginViewRegistry";
import { recordActivity } from "../utils/report-capture";
export type ViewMode = "overview" | "project";
/*
@@ -191,6 +192,12 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
setScopedItem("kb-dashboard-task-view", taskView, currentProject?.id);
}, [currentProject?.id, taskView]);
useEffect(() => {
// FNXC:ReportPipeline 2026-07-18-12:30: Report traces describe only the
// selected view name; never capture URLs, embedded identifiers, or content.
if (isBuiltInTaskView(taskView)) recordActivity(taskView);
}, [taskView]);
useEffect(() => {
if (typeof window === "undefined") {
return;

View File

@@ -0,0 +1,17 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { captureScreenshot, clearReportActivityForTests, getRecentActivity, recordActivity } from "../report-capture.js";
describe("report capture", () => {
afterEach(() => clearReportActivityForTests());
it("returns undefined when screen capture is unsupported", async () => {
vi.stubGlobal("navigator", { mediaDevices: undefined });
await expect(captureScreenshot()).resolves.toBeUndefined();
vi.unstubAllGlobals();
});
it("keeps only the most recent bounded view labels", () => {
for (let index = 0; index < 22; index++) recordActivity(`view-${index}`);
expect(getRecentActivity()).toEqual(Array.from({ length: 20 }, (_, index) => `view-${index + 2}`));
});
});

View File

@@ -0,0 +1,47 @@
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.
*/
export async function captureScreenshot(): Promise<ReportScreenshot | 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;
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;
}

View File

@@ -133,7 +133,6 @@
"@xyflow/react": "^12.11.0",
"archiver": "^7.0.1",
"express": "^5.1.0",
"html2canvas": "1.4.1",
"i18next": "^26.3.1",
"i18next-browser-languagedetector": "^8.2.1",
"i18next-resources-to-backend": "^1.2.1",

View File

@@ -6,7 +6,7 @@ const settings = { reportMode: "draft-review" as const, githubTrackingDefaultRep
function deps(overrides: Partial<ReportPipelineDeps> = {}): ReportPipelineDeps {
return {
projectSettings: settings,
client: { createIssue: vi.fn().mockResolvedValue({ htmlUrl: "https://github.com/Runfusion/Fusion/issues/42" }), searchIssues: vi.fn().mockResolvedValue([]), addIssueReaction: vi.fn(), commentOnIssue: vi.fn().mockResolvedValue({ url: "https://github.com/Runfusion/Fusion/issues/1#issuecomment-1" }), searchDiscussions: vi.fn().mockResolvedValue([]), createDiscussion: vi.fn().mockResolvedValue({ htmlUrl: "https://github.com/Runfusion/Fusion/discussions/42" }), commentOnDiscussion: vi.fn().mockResolvedValue({ url: "https://github.com/Runfusion/Fusion/discussions/1#discussioncomment-1" }) },
client: { createIssue: vi.fn().mockResolvedValue({ number: 42, htmlUrl: "https://github.com/Runfusion/Fusion/issues/42" }), searchIssues: vi.fn().mockResolvedValue([]), addIssueReaction: vi.fn(), commentOnIssue: vi.fn().mockResolvedValue({ url: "https://github.com/Runfusion/Fusion/issues/1#issuecomment-1" }), searchDiscussions: vi.fn().mockResolvedValue([]), createDiscussion: vi.fn().mockResolvedValue({ htmlUrl: "https://github.com/Runfusion/Fusion/discussions/42" }), commentOnDiscussion: vi.fn().mockResolvedValue({ url: "https://github.com/Runfusion/Fusion/discussions/1#discussioncomment-1" }) },
scrubContext: { projectName: "private-project", rootDir: "/Users/alice/private-project" },
...overrides,
};
@@ -30,7 +30,7 @@ describe("report pipeline", () => {
const result = await runReportPipeline({
actionType: "bug",
userPrompt: "report failure",
activityTrace: [{ ts: "2026-07-16T00:00:00Z", kind: "error", label: "Jane Doe at /Users/alice/private-project/a.ts emailed alice@example.com with ghp_abcdefghijk1234567890 and sk-abcdefghijklmnopqrstuvwxyz" }],
activityTrace: ["Jane Doe at /Users/alice/private-project/a.ts emailed alice@example.com with ghp_abcdefghijk1234567890 and sk-abcdefghijklmnopqrstuvwxyz"],
}, deps());
expect(result.kind).toBe("draft-ready");
if (result.kind === "draft-ready") {
@@ -122,6 +122,43 @@ 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, {
@@ -157,6 +194,36 @@ 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" };
@@ -168,4 +235,33 @@ describe("report pipeline", () => {
expect(context.client!.commentOnIssue).toHaveBeenCalledOnce();
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

@@ -56,6 +56,23 @@ describe("report routes", () => {
expect(vi.mocked(runReportPipeline).mock.calls.at(-1)?.[1].roadmapSource).toBeUndefined();
});
const PNG_SCREENSHOT = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9WlYk8sAAAAASUVORK5CYII=";
beforeEach(() => vi.clearAllMocks());
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());
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");
});
});
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." }]);

View File

@@ -44,4 +44,12 @@ describe("report scrub", () => {
expect(scrubReportText(undefined, context)).toBe("");
expect(scrubReportPayload({ summary: "", context: undefined }, context)).toEqual({ summary: "", context: undefined });
});
it("scrubs activity trace text and does not exempt arbitrary data URLs", () => {
const screenshot = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE=";
const result = scrubReportPayload({ context: { activityTrace: ["private-project /Users/alice/work/acme-private ghp_abcdefghijk1234567890"] }, body: `Pasted ${screenshot}` }, context);
expect(JSON.stringify(result.context?.activityTrace)).not.toMatch(/\/Users\/alice|ghp_/);
expect(result.body).toBe("Pasted [REDACTED_BINARY]");
});
});

View File

@@ -748,6 +748,113 @@ export class GitHubClient {
this.forceMode = tokenOrOptions?.forceMode;
}
/**
* FNXC:ReportPipeline 2026-07-18-16:30:
* An explicitly reviewed report screenshot may be hosted only in the selected
* GitHub repository through this client's existing authenticated transport.
* A failed or unsupported upload returns undefined so filing remains scrubbed,
* text-only; raw data URLs must never leave the report pipeline.
*/
async uploadReportImage(owner: string, repo: string, screenshot: { dataUrl: string; capturedAt: string }): Promise<string | undefined> {
const match = /^data:image\/(png|jpeg);base64,([A-Za-z0-9+/]+={0,2})$/.exec(screenshot.dataUrl);
if (!match) return undefined;
const extension = match[1] === "jpeg" ? "jpg" : "png";
const path = `.fusion/report-screenshots/${crypto.randomUUID()}.${extension}`;
const endpoint = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${path}`;
const body = {
message: "chore: add user-reviewed Fusion report screenshot",
content: match[2],
};
try {
if (this.forceMode === "gh-cli") {
this.requireGh();
return this.uploadReportImageWithGh(endpoint, body);
}
if (this.forceMode === "token") {
this.requireToken();
return this.uploadReportImageWithApi(endpoint, body);
}
if (this.hasGhAuth()) {
try {
return await this.uploadReportImageWithGh(endpoint, body);
} catch {
if (!this.token) return undefined;
}
}
return this.token ? await this.uploadReportImageWithApi(endpoint, body) : undefined;
} catch {
return undefined;
}
}
/**
* FNXC:ReportPipeline 2026-07-18-19:30: Screenshot attachment is a two-step
* GitHub operation. Compensate if the post-upload report comment fails so a
* sensitive, user-reviewed image is not orphaned outside the report thread.
*/
async deleteReportImage(owner: string, repo: string, url: string): Promise<void> {
const prefix = `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/main/`;
if (!url.startsWith(prefix)) return;
const path = url.slice(prefix.length);
if (!path.startsWith(".fusion/report-screenshots/") || path.includes("..")) return;
const endpoint = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${path}`;
try {
if (this.forceMode === "gh-cli") {
this.requireGh();
await this.deleteReportImageWithGh(endpoint);
} else if (this.forceMode === "token") {
this.requireToken();
await this.deleteReportImageWithApi(endpoint);
} else if (this.hasGhAuth()) {
try {
await this.deleteReportImageWithGh(endpoint);
} catch {
if (this.token) await this.deleteReportImageWithApi(endpoint);
}
} else if (this.token) {
await this.deleteReportImageWithApi(endpoint);
}
} catch {
// Best-effort compensation: never let cleanup mask successful text filing.
}
}
private async uploadReportImageWithGh(endpoint: string, body: { message: string; content: string }): Promise<string | undefined> {
const result = await runGhJsonAsync<{ content?: { download_url?: string | null } }>([
"api", "--method", "PUT", endpoint,
"-f", `message=${body.message}`,
"-f", `content=${body.content}`,
]);
return result.content?.download_url ?? undefined;
}
private async uploadReportImageWithApi(endpoint: string, body: { message: string; content: string }): Promise<string | undefined> {
const result = await this.fetchThrottled<{ content?: { download_url?: string | null } }>(`${this.baseUrl}/${endpoint}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return result.success ? result.data?.content?.download_url ?? undefined : undefined;
}
private async deleteReportImageWithGh(endpoint: string): Promise<void> {
const existing = await runGhJsonAsync<{ sha?: string }>(["api", endpoint]);
if (!existing.sha) return;
await runGhJsonAsync(["api", "--method", "DELETE", endpoint, "-f", "message=chore: remove unattached Fusion report screenshot", "-f", `sha=${existing.sha}`]);
}
private async deleteReportImageWithApi(endpoint: string): Promise<void> {
const existing = await this.fetchThrottled<{ sha?: string }>(`${this.baseUrl}/${endpoint}`);
if (!existing.success || !existing.data?.sha) return;
await this.fetchThrottled(`${this.baseUrl}/${endpoint}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: "chore: remove unattached Fusion report screenshot", sha: existing.data.sha }),
});
}
private hasGhAuth(): boolean {
return isGhAvailable() && isGhAuthenticated();
}

View File

@@ -8,18 +8,18 @@ import type { RoadmapDedupSource } from "./report-roadmap-source.js";
export type { ReportActionType, ReportMode };
export interface ReportActivityTraceEntry {
ts: string;
kind: string;
label: string;
export interface ReportScreenshot {
dataUrl: string;
capturedAt: string;
}
export interface ReportInput {
actionType: ReportActionType;
userPrompt: string;
contextRefs?: { taskId?: string; agentId?: string };
activityTrace?: ReportActivityTraceEntry[];
screenshotArtifactId?: string;
activityTrace?: string[];
/** Binary pixels are preserved for explicit, reviewed upload only. */
screenshot?: ReportScreenshot;
}
export interface StructuredReport {
@@ -29,6 +29,8 @@ export interface StructuredReport {
summary: string;
body: string;
context: Record<string, unknown>;
/** User-reviewed pixels; never interpolated into text context. */
screenshot?: ReportScreenshot;
sessionToken?: string;
}
@@ -36,20 +38,23 @@ 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 } }
| { kind: "roadmap-match"; report: StructuredReport; mode: ReportMode; roadmap: { featureId: string; title: string; description: string } }
| { kind: "filed"; url: string; report: StructuredReport }
| { kind: "endorsed"; url: string; issueNumber: number; report: StructuredReport }
| { kind: "filed"; url: string; report: StructuredReport; screenshotNotAttached?: boolean }
| { kind: "endorsed"; url: string; issueNumber: number; report: StructuredReport; screenshotNotAttached?: boolean }
| { kind: "unavailable"; reason: string; message: string };
export interface ReportPipelineDeps {
projectSettings: Pick<ProjectSettings, "reportMode" | "reportModeByAction" | "reportRoadmapDedup" | "githubTrackingDefaultRepo" | "githubAuthMode" | "githubAuthToken">;
globalSettings?: Partial<GlobalSettings>;
client?: Pick<GitHubClient, "createIssue" | "searchIssues" | "commentOnIssue" | "addIssueReaction"> & Partial<Pick<GitHubClient, "searchDiscussions" | "createDiscussion" | "commentOnDiscussion" | "addDiscussionReaction">>;
client?: Pick<GitHubClient, "createIssue" | "searchIssues" | "commentOnIssue" | "addIssueReaction"> & Partial<Pick<GitHubClient, "searchDiscussions" | "createDiscussion" | "commentOnDiscussion" | "addDiscussionReaction" | "uploadReportImage" | "deleteReportImage">>;
scrubContext?: ReportScrubContext;
gatherContext?: (input: ReportInput) => Promise<Record<string, unknown>>;
roadmapSource?: RoadmapDedupSource;
}
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
@@ -86,6 +91,8 @@ 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.
@@ -97,6 +104,7 @@ function structureReport(input: ReportInput, gathered: Record<string, unknown>):
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}`,
context,
screenshot: input.screenshot,
sessionToken: crypto.randomUUID(),
};
}
@@ -147,9 +155,65 @@ export async function findRoadmapMatch(roadmapSource: RoadmapDedupSource, report
return match?.candidate;
}
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" }>> {
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" }>> {
const sessionToken = args.report.sessionToken ?? `${args.discussionId}:${args.report.summary}`;
const report = scrubReportPayload(args.report, args.scrubContext);
const report = appendReviewedScreenshot(scrubReportPayload(args.report, args.scrubContext), args.screenshotUrl);
const existing = endorsedSessions.get(sessionToken);
if (existing) return { kind: "endorsed", ...existing, report };
/*
@@ -164,10 +228,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; client: NonNullable<ReportPipelineDeps["client"]>; scrubContext?: ReportScrubContext }): Promise<Extract<ReportResult, { kind: "endorsed" }>> {
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" }>> {
const sessionToken = args.report.sessionToken ?? `${args.issueNumber}:${args.report.summary}`;
const existing = endorsedSessions.get(sessionToken);
const report = scrubReportPayload(args.report, args.scrubContext);
const report = appendReviewedScreenshot(scrubReportPayload(args.report, args.scrubContext), args.screenshotUrl);
if (existing) return { kind: "endorsed", ...existing, report };
/*
FNXC:ReportPipeline 2026-07-16-18:00:
@@ -203,21 +267,20 @@ 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,
sessionToken: typeof submitted.sessionToken === "string" && submitted.sessionToken ? submitted.sessionToken : rebuilt.sessionToken,
};
}
export async function runReportPipeline(input: ReportInput, deps: ReportPipelineDeps, options: { file?: boolean; endorseIssueNumber?: number; endorseDiscussionId?: string; report?: StructuredReport } = {}): Promise<ReportResult> {
const gathered = await deps.gatherContext?.(input) ?? { taskId: input.contextRefs?.taskId, agentId: input.contextRefs?.agentId };
let report = scrubReportPayload(normalizeSubmittedReport(input, gathered, options.report), deps.scrubContext);
if (input.screenshotArtifactId) {
// FNXC:ReportPipeline 2026-07-16-10:30:
// The route has validated this reference before pipeline entry. Rebuild the
// text-only local-retention note here so edited drafts cannot omit it and
// image bytes never cross the GitHub boundary.
const note = `Screenshot captured and retained locally (artifact ${input.screenshotArtifactId}).`;
if (!report.body.includes(note)) report = { ...report, body: `${report.body}\n\n${note}` };
}
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 mode = resolveReportMode(input.actionType, deps.projectSettings);
/*
FNXC:ReportPipeline 2026-07-18-12:30:
@@ -238,45 +301,62 @@ export async function runReportPipeline(input: ReportInput, deps: ReportPipeline
if (clientResult.unavailable) return clientResult.unavailable;
const repo = resolveRepo(deps);
if (!repo || !clientResult.client) return { kind: "unavailable", reason: "repo_missing", message: "Configure a GitHub tracking repository before filing reports." };
// FNXC:ReportPipeline 2026-07-16-17:15:
// A browser-provided issue number is not authorization to comment. Re-run
// open-only matching immediately before endorsement so callers cannot post
// arbitrary data to closed or unrelated issues.
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.endorseDiscussionId) {
if (!clientResult.client.commentOnDiscussion || !clientResult.client.addDiscussionReaction || destination !== "discussion" || duplicate?.discussionId !== options.endorseDiscussionId) {
return { kind: "unavailable", reason: "duplicate_not_verified", message: "The selected discussion is no longer an open matching report. Please prepare the report again." };
}
return endorseDiscussionDuplicate({ issueNumber: duplicate.number, discussionId: duplicate.discussionId, report, client: clientResult.client as NonNullable<ReportPipelineDeps["client"]> & Pick<GitHubClient, "commentOnDiscussion" | "addDiscussionReaction">, scrubContext: deps.scrubContext });
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 } : {}) };
}
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." };
}
return endorseDuplicate({ owner: repo.owner, repo: repo.repo, issueNumber: duplicate.number, report, client: clientResult.client, scrubContext: deps.scrubContext });
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 } : {}) };
}
if (duplicate) {
// FNXC:ReportPipeline 2026-07-16-16:30:
// Auto-file promises a zero-friction result for every dedupe outcome, so a
// strong open match receives the scrubbed data point automatically.
if (mode === "auto-file") {
if (destination === "discussion") {
if (!duplicate.discussionId || !clientResult.client.commentOnDiscussion || !clientResult.client.addDiscussionReaction) {
return { kind: "unavailable", reason: "discussion_unsupported", message: "This GitHub connection cannot endorse discussions." };
}
return endorseDiscussionDuplicate({ issueNumber: duplicate.number, discussionId: duplicate.discussionId, report, client: clientResult.client as NonNullable<ReportPipelineDeps["client"]> & Pick<GitHubClient, "commentOnDiscussion" | "addDiscussionReaction">, scrubContext: deps.scrubContext });
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 endorseDuplicate({ owner: repo.owner, repo: repo.repo, issueNumber: duplicate.number, report, client: clientResult.client, scrubContext: deps.scrubContext });
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 { kind: "duplicate-found", report, mode, issue: { number: duplicate.number, url: duplicate.html_url, title: duplicate.title, discussionId: duplicate.discussionId } };
}
if (!options.file && mode === "draft-review") return { kind: "draft-ready", report, mode };
if (destination === "discussion") {
if (!clientResult.client.createDiscussion) return { kind: "unavailable", reason: "discussion_unsupported", message: "This GitHub connection cannot create discussions." };
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);
return { kind: "filed", url: created.htmlUrl, report };
const attachment = await attachToDiscussion(created.id);
report = appendReviewedScreenshot(report, attachment.screenshotUrl);
return { kind: "filed", url: created.htmlUrl, report, ...(attachment.screenshotNotAttached ? { screenshotNotAttached: true } : {}) };
}
const created = await clientResult.client.createIssue({ owner: repo.owner, repo: repo.repo, title: report.summary, body: report.body, labels: ["community"] });
return { kind: "filed", url: created.htmlUrl, report };
const attachment = await attachToIssue(created.number);
report = appendReviewedScreenshot(report, attachment.screenshotUrl);
return { kind: "filed", url: created.htmlUrl, report, ...(attachment.screenshotNotAttached ? { screenshotNotAttached: true } : {}) };
}

View File

@@ -44,6 +44,7 @@ export function scrubReportText(text: string | undefined, context: ReportScrubCo
// Home-directory usernames and absolute paths are identifying even when the
// caller cannot provide a fully resolved local root directory.
return scrubbed
.replace(/data:image\/(?:png|jpeg);base64,[A-Za-z0-9+/=]+/gi, "[REDACTED_BINARY]")
.replace(/(?:~|\/Users|\/home)\/[A-Za-z0-9._-]+(?:\/[\w .@+=,~:/-]*)?/g, "[REDACTED_PATH]")
.replace(/(?:[A-Za-z]:\\|\\\\[^\\/]+\\[^\\/]+|\/(?:[\w .@+=,~-]+\/)+[\w .@+=,~-]*)/g, "[REDACTED_PATH]")
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[REDACTED_EMAIL]")
@@ -52,6 +53,13 @@ export function scrubReportText(text: string | undefined, context: ReportScrubCo
}
function scrubValue(value: unknown, context: ReportScrubContext): unknown {
/*
FNXC:ReportPipeline 2026-07-18-14:30:
Draft bodies are user-editable and therefore untrusted on the file route.
Never make a generic data-URL exception here: only the separately validated
typed screenshot upload path may preserve binary pixels. Every report string,
including a pasted image URL, remains subject to the mandatory text scrub.
*/
if (typeof value === "string") return scrubReportText(value, context);
if (Array.isArray(value)) return value.map((item) => scrubValue(item, context));
if (value && typeof value === "object") {

View File

@@ -5,54 +5,44 @@ import { runReportPipeline, type ReportInput, type StructuredReport } from "../r
import { createRoadmapDedupSourceForTaskStore } from "../report-roadmap-source.js";
import { scrubReportPayload } from "../report-scrub.js";
import { selfCheckHelp } from "../report-help-selfcheck.js";
import type { Request, Response } from "express";
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 ARTIFACT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const REPORT_ATTACHMENT_SOURCE = "report-attachment";
const SCREENSHOT_DATA_URL = /^data:image\/(png|jpeg);base64,([A-Za-z0-9+/=]+)$/i;
type ScopedStore = Awaited<ReturnType<Parameters<ApiRouteRegistrar>[0]["getScopedStore"]>>;
function isImagePayload(mimeType: string | undefined, bytes: Buffer): boolean {
const png = bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
const jpeg = bytes.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]));
return (mimeType === "image/png" && png) || (mimeType === "image/jpeg" && jpeg);
}
async function validateScreenshotArtifact(store: ScopedStore, input: ReportInput): Promise<ReportInput> {
if (!input.screenshotArtifactId) return input;
// FNXC:ReportPipeline 2026-07-16-10:30:
// The client reference is untrusted. Validate UUID, scoped artifact type,
// MIME, and report-upload provenance before it can become egressed text;
// this blocks data-URI and arbitrary-text smuggling into GitHub reports.
if (!ARTIFACT_ID_PATTERN.test(input.screenshotArtifactId)) throw new ApiError(400, "Invalid report screenshot reference.");
const artifact = await store.getArtifact(input.screenshotArtifactId);
if (artifact?.type !== "image" || !["image/png", "image/jpeg"].includes(artifact.mimeType ?? "") || artifact.metadata?.source !== REPORT_ATTACHMENT_SOURCE) {
throw new ApiError(400, "Invalid report screenshot reference.");
}
return input;
}
function runUpload(upload: NonNullable<Parameters<ApiRouteRegistrar>[0]["reportUpload"]>, req: Request, res: Response): Promise<void> {
return new Promise((resolve, reject) => upload.single("screenshot")(req, res, (error?: unknown) => error ? reject(error) : resolve()));
}
function parseActivityTrace(value: unknown): ReportInput["activityTrace"] {
function parseActivityTrace(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const entries = value.flatMap((entry) => {
if (!entry || typeof entry !== "object") return [];
const candidate = entry as Record<string, unknown>;
if (typeof candidate.ts !== "string" || typeof candidate.kind !== "string" || typeof candidate.label !== "string") return [];
return [{ ts: candidate.ts.slice(0, 64), kind: candidate.kind.slice(0, 80), label: candidate.label.slice(0, 1_000) }];
});
while (entries.length > MAX_ACTIVITY_TRACE_ENTRIES || entries.reduce((total, entry) => total + entry.ts.length + entry.kind.length + entry.label.length, 0) > MAX_ACTIVITY_TRACE_CHARS) entries.shift();
const entries = value.filter((entry): entry is string => typeof entry === "string").map((entry) => entry.slice(0, 1_000));
if (entries.length !== value.length || entries.length > MAX_ACTIVITY_TRACE_ENTRIES || entries.join("").length > MAX_ACTIVITY_TRACE_CHARS) throw new ApiError(400, "Activity trace is invalid.");
return entries;
}
function parseScreenshot(value: unknown): ReportInput["screenshot"] {
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) };
}
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,
@@ -60,7 +50,6 @@ async function gatherReportContext(store: Awaited<ReturnType<Parameters<ApiRoute
taskId: input.contextRefs?.taskId,
agentId: input.contextRefs?.agentId,
activityTrace: input.activityTrace,
...(input.screenshotArtifactId ? { screenshot: `Screenshot captured and retained locally (artifact ${input.screenshotArtifactId}).` } : {}),
};
if (!input.contextRefs?.taskId) return context;
@@ -88,7 +77,7 @@ function parseInput(body: unknown): ReportInput {
userPrompt,
contextRefs: typeof value.contextRefs === "object" && value.contextRefs ? value.contextRefs as ReportInput["contextRefs"] : undefined,
activityTrace: parseActivityTrace(value.activityTrace),
screenshotArtifactId: typeof value.screenshotArtifactId === "string" ? value.screenshotArtifactId : undefined,
screenshot: parseScreenshot(value.screenshot),
};
}
@@ -98,32 +87,12 @@ function parseInput(body: unknown): ReportInput {
* route treats edited drafts as untrusted and re-scrubs server-side immediately
* before the pipeline may call GitHub.
*/
export const registerReportRoutes: ApiRouteRegistrar = ({ router, getScopedStore, rethrowAsApiError, reportUpload }) => {
router.post("/report/attachment", async (req, res) => {
try {
if (!reportUpload) throw new ApiError(500, "Report attachment upload is unavailable.");
await runUpload(reportUpload, req, res);
const file = req.file;
if (!file || file.size > MAX_SCREENSHOT_BYTES || !isImagePayload(file.mimetype, file.buffer)) throw new ApiError(400, "Report screenshots must be PNG or JPEG files up to 2MB.");
const store = await getScopedStore(req);
const contextRefs = typeof req.body?.contextRefs === "string" ? JSON.parse(req.body.contextRefs) : req.body?.contextRefs;
const taskId = contextRefs && typeof contextRefs.taskId === "string" ? contextRefs.taskId : undefined;
const artifact = await store.registerArtifact({ type: "image", title: "Report screenshot", mimeType: file.mimetype, data: Buffer.from(file.buffer), taskId, authorType: "user", authorId: "dashboard-user", metadata: { source: REPORT_ATTACHMENT_SOURCE } });
// FNXC:ReportPipeline 2026-07-16-10:30:
// Pixels are unscrubbable. Persist this optional, confirmed screenshot
// locally only; no GitHub transport receives its bytes or a data URI.
res.status(201).json({ artifactId: artifact.id, uri: artifact.uri });
} catch (error) {
if (error instanceof ApiError) throw error;
rethrowAsApiError(error, "Failed to retain report screenshot");
}
});
export const registerReportRoutes: ApiRouteRegistrar = ({ router, getScopedStore, rethrowAsApiError }) => {
router.post("/report/draft", async (req, res) => {
try {
const store = await getScopedStore(req);
const scopes = await store.getSettingsByScopeFast();
const input = await validateScreenshotArtifact(store, parseInput(req.body));
const input = parseInput(req.body);
const help = await selfCheckHelpBeforePipeline(store, input);
if (help?.answered) {
res.json({ kind: "help", answer: help.answer });
@@ -148,7 +117,13 @@ export const registerReportRoutes: ApiRouteRegistrar = ({ router, getScopedStore
const store = await getScopedStore(req);
const scopes = await store.getSettingsByScopeFast();
const raw = (req.body ?? {}) as Record<string, unknown>;
const untrusted = scrubReportPayload((raw.report ?? raw) as StructuredReport, { rootDir: store.getRootDir(), projectName: store.getRootDir().split(/[\\/]/).pop() });
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 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,
@@ -157,9 +132,9 @@ export const registerReportRoutes: ApiRouteRegistrar = ({ router, getScopedStore
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 ?? (untrusted.context as Record<string, unknown> | undefined)?.screenshotArtifactId,
screenshot,
});
const validatedInput = await validateScreenshotArtifact(store, input);
const validatedInput = input;
const endorseIssueNumber = typeof raw.endorseIssueNumber === "number" ? raw.endorseIssueNumber : undefined;
const endorseDiscussionId = typeof raw.endorseDiscussionId === "string" ? raw.endorseDiscussionId : undefined;
const help = await selfCheckHelpBeforePipeline(store, validatedInput);

129
pnpm-lock.yaml generated
View File

@@ -52,10 +52,10 @@ importers:
dependencies:
'@earendil-works/pi-ai':
specifier: 0.80.10
version: 0.80.10(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
version: 0.80.10(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
'@earendil-works/pi-coding-agent':
specifier: 0.80.10
version: 0.80.10(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
version: 0.80.10(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
claude-code-cli-acp:
specifier: 0.1.1
version: 0.1.1
@@ -332,9 +332,6 @@ importers:
express:
specifier: ^5.1.0
version: 5.2.1
html2canvas:
specifier: 1.4.1
version: 1.4.1
i18next:
specifier: ^26.3.1
version: 26.3.1(typescript@5.9.3)
@@ -3957,10 +3954,6 @@ packages:
bare-url@2.4.0:
resolution: {integrity: sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==}
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -4443,9 +4436,6 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
css-line-break@2.1.0:
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
css-tree@3.2.1:
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
@@ -5484,10 +5474,6 @@ packages:
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
http-cache-semantics@4.2.0:
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
@@ -7505,9 +7491,6 @@ packages:
text-decoder@1.2.7:
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
text-segmentation@1.0.3:
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'}
@@ -7783,9 +7766,6 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
@@ -8149,6 +8129,10 @@ snapshots:
package-manager-detector: 1.6.0
tinyexec: 1.2.4
'@anthropic-ai/sdk@0.91.1':
dependencies:
json-schema-to-ts: 3.1.1
'@anthropic-ai/sdk@0.91.1(zod@3.25.76)':
dependencies:
json-schema-to-ts: 3.1.1
@@ -8891,9 +8875,9 @@ snapshots:
- ws
- zod
'@earendil-works/pi-agent-core@0.80.10(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
'@earendil-works/pi-agent-core@0.80.10(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)':
dependencies:
'@earendil-works/pi-ai': 0.80.10(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
'@earendil-works/pi-ai': 0.80.10(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
ignore: 7.0.5
typebox: 1.1.38
yaml: 2.9.0
@@ -8935,15 +8919,15 @@ snapshots:
'@earendil-works/pi-ai@0.80.10':
dependencies:
'@anthropic-ai/sdk': 0.91.1(zod@4.3.6)
'@anthropic-ai/sdk': 0.91.1
'@aws-sdk/client-bedrock-runtime': 3.1048.0
'@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))
'@google/genai': 1.52.0
'@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0)
'@opentelemetry/api': 1.9.0
'@smithy/node-http-handler': 4.7.3
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
openai: 6.26.0(ws@8.20.0)(zod@4.3.6)
openai: 6.26.0
partial-json: 0.1.7
typebox: 1.1.38
transitivePeerDependencies:
@@ -8954,17 +8938,17 @@ snapshots:
- ws
- zod
'@earendil-works/pi-ai@0.80.10(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
'@earendil-works/pi-ai@0.80.10(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)':
dependencies:
'@anthropic-ai/sdk': 0.91.1(zod@4.3.6)
'@anthropic-ai/sdk': 0.91.1(zod@3.25.76)
'@aws-sdk/client-bedrock-runtime': 3.1048.0
'@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))
'@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))
'@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0)
'@opentelemetry/api': 1.9.0
'@smithy/node-http-handler': 4.7.3
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
openai: 6.26.0(ws@8.20.0)(zod@4.3.6)
openai: 6.26.0(ws@8.20.0)(zod@3.25.76)
partial-json: 0.1.7
typebox: 1.1.38
transitivePeerDependencies:
@@ -9000,7 +8984,7 @@ snapshots:
dependencies:
'@anthropic-ai/sdk': 0.91.1(zod@3.25.76)
'@aws-sdk/client-bedrock-runtime': 3.1048.0
'@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))
'@google/genai': 1.52.0
'@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0)
'@opentelemetry/api': 1.9.0
'@smithy/node-http-handler': 4.7.3
@@ -9047,10 +9031,10 @@ snapshots:
- ws
- zod
'@earendil-works/pi-coding-agent@0.80.10(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
'@earendil-works/pi-coding-agent@0.80.10(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)':
dependencies:
'@earendil-works/pi-agent-core': 0.80.10(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
'@earendil-works/pi-ai': 0.80.10(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
'@earendil-works/pi-agent-core': 0.80.10(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
'@earendil-works/pi-ai': 0.80.10(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
'@earendil-works/pi-tui': 0.80.10
'@silvia-odwyer/photon-node': 0.3.4
chalk: 5.6.2
@@ -9477,6 +9461,30 @@ snapshots:
'@exodus/bytes@1.15.0': {}
'@google/genai@1.52.0':
dependencies:
google-auth-library: 10.6.2
p-retry: 4.6.2
protobufjs: 7.5.8
ws: 8.21.1
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
'@google/genai@1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))':
dependencies:
google-auth-library: 10.6.2
p-retry: 4.6.2
protobufjs: 7.5.8
ws: 8.21.1
optionalDependencies:
'@modelcontextprotocol/sdk': 1.28.0(zod@3.25.76)
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
'@google/genai@1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))':
dependencies:
google-auth-library: 10.6.2
@@ -9996,6 +10004,29 @@ snapshots:
- bufferutil
- utf-8-validate
'@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)':
dependencies:
'@hono/node-server': 1.19.12(hono@4.12.9)
ajv: 8.18.0
ajv-formats: 3.0.1(ajv@8.18.0)
content-type: 1.0.5
cors: 2.8.6
cross-spawn: 7.0.6
eventsource: 3.0.7
eventsource-parser: 3.0.6
express: 5.2.1
express-rate-limit: 8.3.1(express@5.2.1)
hono: 4.12.9
jose: 6.2.2
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
raw-body: 3.0.2
zod: 3.25.76
zod-to-json-schema: 3.25.1(zod@3.25.76)
transitivePeerDependencies:
- supports-color
optional: true
'@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)':
dependencies:
'@hono/node-server': 1.19.12(hono@4.12.9)
@@ -10869,7 +10900,7 @@ snapshots:
obug: 2.1.2
std-env: 4.1.0
tinyrainbow: 3.1.0
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/expect@4.1.8':
dependencies:
@@ -11272,8 +11303,6 @@ snapshots:
dependencies:
bare-path: 3.0.0
base64-arraybuffer@1.0.2: {}
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.10: {}
@@ -11745,10 +11774,6 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
css-line-break@2.1.0:
dependencies:
utrie: 1.0.2
css-tree@3.2.1:
dependencies:
mdn-data: 2.27.1
@@ -12999,11 +13024,6 @@ snapshots:
html-void-elements@3.0.0: {}
html2canvas@1.4.1:
dependencies:
css-line-break: 2.1.0
text-segmentation: 1.0.3
http-cache-semantics@4.2.0: {}
http-errors@2.0.1:
@@ -14223,16 +14243,13 @@ snapshots:
is-docker: 2.2.1
is-wsl: 2.2.0
openai@6.26.0: {}
openai@6.26.0(ws@8.20.0)(zod@3.25.76):
optionalDependencies:
ws: 8.20.0
zod: 3.25.76
openai@6.26.0(ws@8.20.0)(zod@4.3.6):
optionalDependencies:
ws: 8.20.0
zod: 4.3.6
openai@6.26.0(ws@8.21.1)(zod@4.3.6):
optionalDependencies:
ws: 8.21.1
@@ -15380,10 +15397,6 @@ snapshots:
transitivePeerDependencies:
- react-native-b4a
text-segmentation@1.0.3:
dependencies:
utrie: 1.0.2
thenify-all@1.6.0:
dependencies:
thenify: 3.3.1
@@ -15651,10 +15664,6 @@ snapshots:
util-deprecate@1.0.2: {}
utrie@1.0.2:
dependencies:
base64-arraybuffer: 1.0.2
uuid@10.0.0: {}
uuid@14.0.1: {}