From 64c1193d1e227c54847e67c587b2f35d07ff4388 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 19 Jul 2026 18:45:27 -0700 Subject: [PATCH] FN-8333: embed report screenshots in GitHub filings Enable opt-in report screenshots to be safely hosted and embedded in filed GitHub reports. - add GitHub Contents API image upload support and CLI-safe payload handling - validate and resolve report screenshot artifacts before best-effort Markdown embedding - cover upload, media access, filing, and report modal behavior with tests Files changed: .changeset/github-report-screenshot-embed.md | 7 +++ docs/dashboard-guide.md | 4 +- packages/core/src/__tests__/gh-cli-input.test.ts | 32 ++++++++++ packages/core/src/gh-cli.ts | 21 ++++++- .../app/components/__tests__/ReportModal.test.tsx | 5 ++ .../dashboard/src/__tests__/artifact-media.test.ts | 20 ++++++ .../__tests__/github-upload-image-asset.test.ts | 39 ++++++++++++ .../__tests__/report-pipeline-screenshots.test.ts | 67 ++++++++++++++++++++ .../dashboard/src/__tests__/report-routes.test.ts | 36 ++++++++++- packages/dashboard/src/artifact-media.ts | 32 ++++++++++ packages/dashboard/src/github.ts | 57 +++++++++++++++++ packages/dashboard/src/issue-image-attachments.ts | 6 +- packages/dashboard/src/report-pipeline.ts | 72 ++++++++++++++++++---- .../dashboard/src/routes/register-report-routes.ts | 29 ++++++--- .../src/routes/register-task-workflow-routes.ts | 24 +------- 15 files changed, 401 insertions(+), 50 deletions(-) Fusion-Task-Id: FN-8333 Fusion-Task-Lineage: b706810d-2fec-4053-b57a-1128e5c16e94 Co-authored-by: Fusion (runfusion.ai) --- .changeset/github-report-screenshot-embed.md | 7 ++ docs/dashboard-guide.md | 4 +- .../core/src/__tests__/gh-cli-input.test.ts | 32 +++++++++ packages/core/src/gh-cli.ts | 21 +++++- .../components/__tests__/ReportModal.test.tsx | 5 ++ .../src/__tests__/artifact-media.test.ts | 20 ++++++ .../github-upload-image-asset.test.ts | 39 ++++++++++ .../report-pipeline-screenshots.test.ts | 67 +++++++++++++++++ .../src/__tests__/report-routes.test.ts | 36 +++++++++- packages/dashboard/src/artifact-media.ts | 32 +++++++++ packages/dashboard/src/github.ts | 57 +++++++++++++++ .../dashboard/src/issue-image-attachments.ts | 6 +- packages/dashboard/src/report-pipeline.ts | 72 +++++++++++++++---- .../src/routes/register-report-routes.ts | 29 +++++--- .../routes/register-task-workflow-routes.ts | 24 +------ 15 files changed, 401 insertions(+), 50 deletions(-) create mode 100644 .changeset/github-report-screenshot-embed.md create mode 100644 packages/core/src/__tests__/gh-cli-input.test.ts create mode 100644 packages/dashboard/src/__tests__/artifact-media.test.ts create mode 100644 packages/dashboard/src/__tests__/github-upload-image-asset.test.ts create mode 100644 packages/dashboard/src/__tests__/report-pipeline-screenshots.test.ts create mode 100644 packages/dashboard/src/artifact-media.ts diff --git a/.changeset/github-report-screenshot-embed.md b/.changeset/github-report-screenshot-embed.md new file mode 100644 index 0000000000..eda2b24df6 --- /dev/null +++ b/.changeset/github-report-screenshot-embed.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Embed opted-in report screenshots in filed GitHub reports. +category: feature +dev: Uploads one validated local report artifact through the GitHub Contents API; private raw URLs require viewer access. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index acc545897a..0496054712 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -2134,7 +2134,9 @@ 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 **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. +Choose **Store a screenshot locally** to request browser screen-capture permission. Fusion captures one PNG frame in its local artifact registry, then requires confirmation that the screenshot may be retained before a report can reference it. When filing or endorsing the report, Fusion resolves that single provenance-validated local artifact server-side and makes a best-effort GitHub Contents API upload; a successful upload is embedded inline in the Issue, Discussion, or duplicate data-point comment. Upload failure never blocks the scrubbed text report, and raw pixels are never accepted from the report-file request. + +The uploaded image uses the tracking repository's raw URL. It renders inline for public repositories; viewers of private repositories need GitHub authorization for that raw URL, so anonymous viewers will not see the image. The original screenshot remains a local artifact as well. ## Chat-requested task verification diff --git a/packages/core/src/__tests__/gh-cli-input.test.ts b/packages/core/src/__tests__/gh-cli-input.test.ts new file mode 100644 index 0000000000..d57003b30d --- /dev/null +++ b/packages/core/src/__tests__/gh-cli-input.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() })); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { ...actual, execFile }; +}); + +import { MAX_GH_STDIN_INPUT_BYTES, runGhAsync } from "../gh-cli.js"; + +describe("runGhAsync stdin input", () => { + beforeEach(() => vi.clearAllMocks()); + it("streams bounded input to stdin instead of command arguments", async () => { + const end = vi.fn(); + execFile.mockImplementation((_bin, _args, _options, callback) => { + callback(null, "ok", ""); + return { stdin: { end } }; + }); + const input = JSON.stringify({ content: "A".repeat(1024 * 1024) }); + + await expect(runGhAsync(["api", "--input", "-"], { input })).resolves.toBe("ok"); + expect(execFile).toHaveBeenCalledWith("gh", ["api", "--input", "-"], expect.any(Object), expect.any(Function)); + expect(end).toHaveBeenCalledWith(input); + expect(JSON.stringify(execFile.mock.calls[0]?.[1])).not.toContain(input); + }); + + it("rejects input exceeding the stdin ceiling before spawning gh", async () => { + await expect(runGhAsync(["api"], { input: "x".repeat(MAX_GH_STDIN_INPUT_BYTES + 1) })).rejects.toMatchObject({ code: "INPUT_TOO_LARGE" }); + expect(execFile).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/gh-cli.ts b/packages/core/src/gh-cli.ts index 8f99946897..a07d28ae70 100644 --- a/packages/core/src/gh-cli.ts +++ b/packages/core/src/gh-cli.ts @@ -33,6 +33,8 @@ export interface StructuredGhError { }; } +export const MAX_GH_STDIN_INPUT_BYTES = 8 * 1024 * 1024; + export interface RunGhOptions { cwd?: string; /** External abort signal — propagated to the spawned `gh` process. */ @@ -47,6 +49,11 @@ export interface RunGhOptions { * pins any AI session's `prompt()` that triggered the tool call. */ timeoutMs?: number; + /** + * Optional UTF-8 request body streamed to child stdin. Limited to 8 MiB so + * callers can send bounded Contents API payloads without argv overflow. + */ + input?: string; } const DEFAULT_GH_TIMEOUT_MS = 30_000; @@ -160,8 +167,11 @@ export function runGh(args: string[], cwd?: string): string { * hangs when `gh` stalls on the network or a credential helper. */ export function runGhAsync(args: string[], cwdOrOptions?: string | RunGhOptions): Promise { - const { cwd, signal: externalSignal, timeoutMs = DEFAULT_GH_TIMEOUT_MS } = + const { cwd, signal: externalSignal, timeoutMs = DEFAULT_GH_TIMEOUT_MS, input } = normalizeRunGhOptions(cwdOrOptions); + if (input !== undefined && Buffer.byteLength(input, "utf8") > MAX_GH_STDIN_INPUT_BYTES) { + return Promise.reject(makeGhError(`gh stdin input exceeds ${MAX_GH_STDIN_INPUT_BYTES} byte limit`, "INPUT_TOO_LARGE")); + } return new Promise((resolve, reject) => { if (externalSignal?.aborted) { @@ -196,7 +206,7 @@ export function runGhAsync(args: string[], cwdOrOptions?: string | RunGhOptions) if (externalSignal) externalSignal.removeEventListener("abort", onExternalAbort); }; - execFile( + const child = execFile( "gh", args, { @@ -229,6 +239,13 @@ export function runGhAsync(args: string[], cwdOrOptions?: string | RunGhOptions) } } ); + /* + FNXC:GhCliStdin 2026-07-19-12:00: + GitHub Contents API image bodies can contain several megabytes of base64. + Keep that payload on stdin because operating-system argv limits are much + smaller and can otherwise reject a valid bounded report screenshot. + */ + if (input !== undefined) child.stdin?.end(input); }); } diff --git a/packages/dashboard/app/components/__tests__/ReportModal.test.tsx b/packages/dashboard/app/components/__tests__/ReportModal.test.tsx index b6bb90ba02..78f38ecac5 100644 --- a/packages/dashboard/app/components/__tests__/ReportModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/ReportModal.test.tsx @@ -41,6 +41,11 @@ describe("ReportModal", () => { await waitFor(() => expect(reportDraft).toHaveBeenCalledWith(expect.objectContaining({ screenshotArtifactId: "123e4567-e89b-42d3-a456-426614174000", }))); + reportFile.mockResolvedValueOnce({ kind: "filed", url: "https://example.test/1" }); + fireEvent.click(await screen.findByRole("button", { name: "File report" })); + await waitFor(() => expect(reportFile).toHaveBeenCalledWith(expect.objectContaining({ + screenshotArtifactId: "123e4567-e89b-42d3-a456-426614174000", + }))); }); it("clears a pending capture when the reporter opts out", async () => { diff --git a/packages/dashboard/src/__tests__/artifact-media.test.ts b/packages/dashboard/src/__tests__/artifact-media.test.ts new file mode 100644 index 0000000000..d0b0f179ad --- /dev/null +++ b/packages/dashboard/src/__tests__/artifact-media.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { resolveArtifactMediaPath } from "../artifact-media.js"; + +const store = { + getTaskDir: (taskId: string) => `/workspace/.fusion/tasks/${taskId}`, + getFusionDir: () => "/workspace/.fusion", +}; + +describe("resolveArtifactMediaPath", () => { + it("preserves task artifact and attachment media paths", () => { + expect(resolveArtifactMediaPath(store as never, { taskId: "FN-1", uri: "artifacts/screenshot.png" })).toBe("/workspace/.fusion/tasks/FN-1/artifacts/screenshot.png"); + expect(resolveArtifactMediaPath(store as never, { taskId: "FN-1", uri: "attachments/capture.png" })).toBe("/workspace/.fusion/tasks/FN-1/attachments/capture.png"); + }); + + it("rejects traversal and absolute paths outside the artifact storage roots", () => { + for (const uri of ["../../etc/passwd", "/etc/passwd", "other/capture.png"]) { + expect(() => resolveArtifactMediaPath(store as never, { taskId: "FN-1", uri })).toThrow("Invalid artifact media path"); + } + }); +}); diff --git a/packages/dashboard/src/__tests__/github-upload-image-asset.test.ts b/packages/dashboard/src/__tests__/github-upload-image-asset.test.ts new file mode 100644 index 0000000000..ea200ea8f7 --- /dev/null +++ b/packages/dashboard/src/__tests__/github-upload-image-asset.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@fusion/core", async () => { + const actual = await vi.importActual("@fusion/core"); + return { ...actual, isGhAvailable: vi.fn(() => true), isGhAuthenticated: vi.fn(() => true), runGhJsonAsync: vi.fn(), runGhAsync: vi.fn(), runGh: vi.fn(), getGhErrorMessage: vi.fn((error) => String(error)) }; +}); + +import { runGhJsonAsync } from "@fusion/core"; +import { GitHubClient } from "../github.js"; + +const image = Buffer.alloc(1024 * 1024, 0xab).toString("base64"); +const params = { owner: "owner", repo: "repo", path: ".fusion-reports/safe/image.png", contentBase64: image, message: "Store report screenshot", mimeType: "image/png" }; +const response = { content: { html_url: "https://github.com/owner/repo/blob/main/.fusion-reports/safe/image.png", download_url: "https://raw.githubusercontent.com/owner/repo/main/.fusion-reports/safe/image.png", path: params.path, sha: "abc" } }; + +describe("GitHubClient.uploadImageAsset", () => { + beforeEach(() => vi.clearAllMocks()); + + it("sends large contents payload through gh stdin, never argv", async () => { + vi.mocked(runGhJsonAsync).mockResolvedValue(response as never); + const uploaded = await new GitHubClient({ forceMode: "gh-cli" }).uploadImageAsset(params); + const [argv, options] = vi.mocked(runGhJsonAsync).mock.calls[0]!; + expect(argv).toEqual(["api", "--method", "PUT", "repos/owner/repo/contents/.fusion-reports/safe/image.png", "--input", "-"]); + expect(JSON.stringify(argv)).not.toContain(image); + expect(JSON.parse((options as { input: string }).input).content).toBe(image); + expect(uploaded.rawUrl).toBe(response.content.download_url); + }); + + it("uses encoded Contents API endpoint in token mode", async () => { + const fetch = vi.spyOn(global, "fetch").mockResolvedValue({ ok: true, status: 201, json: async () => response, headers: new Headers() } as Response); + await new GitHubClient({ token: "token", forceMode: "token" }).uploadImageAsset({ ...params, path: ".fusion-reports/a space/image.png" }); + expect(fetch).toHaveBeenCalledWith("https://api.github.com/repos/owner/repo/contents/.fusion-reports/a%20space/image.png", expect.objectContaining({ method: "PUT" })); + }); + + it.each([{ mimeType: "image/svg+xml" }, { contentBase64: Buffer.alloc(5 * 1024 * 1024 + 1).toString("base64") }])("rejects invalid upload before transport", async (patch) => { + const client = new GitHubClient({ forceMode: "gh-cli" }); + await expect(client.uploadImageAsset({ ...params, ...patch })).rejects.toThrow(); + expect(runGhJsonAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/src/__tests__/report-pipeline-screenshots.test.ts b/packages/dashboard/src/__tests__/report-pipeline-screenshots.test.ts new file mode 100644 index 0000000000..61535bc31e --- /dev/null +++ b/packages/dashboard/src/__tests__/report-pipeline-screenshots.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; +import { runReportPipeline, type ReportPipelineDeps, type ReportScreenshot } from "../report-pipeline.js"; + +const settings = { reportMode: "auto-file" as const, reportRoadmapDedupeEnabled: false, githubTrackingDefaultRepo: "Runfusion/Fusion", githubAuthMode: "token", githubAuthToken: "test" }; +const screenshot: ReportScreenshot = { artifactId: "f1e2d3c4-b5a6-4789-8abc-def012345678", filename: "/Users/alice/private-project/evil[alt](break)!../capture.png", mimeType: "image/png", bytes: Buffer.from([0x89, 0x50, 0x4e, 0x47]) }; + +function client() { + return { + createIssue: vi.fn().mockResolvedValue({ htmlUrl: "https://github.com/Runfusion/Fusion/issues/42" }), createDiscussion: vi.fn().mockResolvedValue({ htmlUrl: "https://github.com/Runfusion/Fusion/discussions/42" }), + searchIssues: vi.fn().mockResolvedValue([]), searchDiscussions: vi.fn().mockResolvedValue([]), + commentOnIssue: vi.fn().mockResolvedValue({ url: "issue-comment" }), commentOnDiscussion: vi.fn().mockResolvedValue({ url: "discussion-comment" }), + addIssueReaction: vi.fn(), addDiscussionReaction: vi.fn(), + uploadImageAsset: vi.fn().mockResolvedValue({ rawUrl: "https://raw.githubusercontent.com/Runfusion/Fusion/main/.fusion-reports/safe/screenshot.png" }), + }; +} + +function deps(fakeClient = client()): ReportPipelineDeps { + return { projectSettings: settings, client: fakeClient, scrubContext: { rootDir: "/Users/alice/private-project", projectName: "private-project" } }; +} + +describe("report screenshot embedding", () => { + it("uploads and embeds one resolved PNG in a filed issue without leaking untrusted path input", async () => { + const fakeClient = client(); + const result = await runReportPipeline({ actionType: "bug", userPrompt: "Report failure", attachment: screenshot }, deps(fakeClient), { file: true }); + + expect(result.kind).toBe("filed"); + expect(fakeClient.uploadImageAsset).toHaveBeenCalledTimes(1); + expect(fakeClient.uploadImageAsset).toHaveBeenCalledWith(expect.objectContaining({ path: expect.stringMatching(/^\.fusion-reports\/[a-f0-9]{32}\/screenshot\.png$/), contentBase64: screenshot.bytes.toString("base64") })); + const body = fakeClient.createIssue.mock.calls[0][0].body as string; + expect(body).toContain("## Screenshots"); + expect(body).toMatch(/!\[[^\r\n]*\\\[alt\\\]\\\(break\\\)\\![^\r\n]*\]\(https:\/\/raw\.githubusercontent\.com\/Runfusion\/Fusion\/main\/\.fusion-reports\/safe\/screenshot\.png\)/); + expect(body).not.toContain("private-project"); + expect(body).not.toContain("/Users/alice"); + expect(body).not.toContain(".fusion-reports/../"); + }); + + it("does not upload or alter a filed body without the optional screenshot", async () => { + const fakeClient = client(); + await runReportPipeline({ actionType: "idea", userPrompt: "Add a filter" }, deps(fakeClient), { file: true }); + expect(fakeClient.uploadImageAsset).not.toHaveBeenCalled(); + expect(fakeClient.createIssue.mock.calls[0][0].body).not.toContain("## Screenshots"); + }); + + it.each(["feedback", "help"] as const)("embeds screenshots in filed %s discussions", async (actionType) => { + const fakeClient = client(); + await runReportPipeline({ actionType, userPrompt: "Clarify report status", attachment: screenshot }, deps(fakeClient), { file: true }); + expect(fakeClient.uploadImageAsset).toHaveBeenCalledTimes(1); + expect(fakeClient.createDiscussion.mock.calls[0][3]).toContain("## Screenshots"); + }); + + it("keeps filing the scrubbed text body if hosting fails", async () => { + const fakeClient = client(); + fakeClient.uploadImageAsset.mockRejectedValueOnce(new Error("permission denied")); + const result = await runReportPipeline({ actionType: "bug", userPrompt: "Report failure", attachment: screenshot }, deps(fakeClient), { file: true }); + expect(result.kind).toBe("filed"); + expect(fakeClient.createIssue).toHaveBeenCalledTimes(1); + expect(fakeClient.createIssue.mock.calls[0][0].body).not.toContain("## Screenshots"); + }); + + it("embeds the one screenshot in an endorsed duplicate data-point comment", async () => { + const fakeClient = client(); + fakeClient.searchIssues.mockResolvedValue([{ number: 7, title: "dashboard rendering failed", body: "dashboard rendering failed", html_url: "issue", state: "open" }]); + await runReportPipeline({ actionType: "bug", userPrompt: "dashboard rendering failed", attachment: screenshot }, deps(fakeClient), { file: true, endorseIssueNumber: 7 }); + expect(fakeClient.uploadImageAsset).toHaveBeenCalledTimes(1); + expect(fakeClient.commentOnIssue.mock.calls[0][3]).toContain("## Screenshots"); + }); +}); diff --git a/packages/dashboard/src/__tests__/report-routes.test.ts b/packages/dashboard/src/__tests__/report-routes.test.ts index b5f3ce091c..d80ee3d810 100644 --- a/packages/dashboard/src/__tests__/report-routes.test.ts +++ b/packages/dashboard/src/__tests__/report-routes.test.ts @@ -10,9 +10,13 @@ vi.mock("../require-async-layer.js", () => ({ vi.mock("../report-pipeline.js", () => ({ runReportPipeline: vi.fn(), })); +vi.mock("../artifact-media.js", () => ({ + readArtifactMediaBytes: vi.fn(), +})); import { queryKnowledgePagesAsync } from "../knowledge-index.js"; import { runReportPipeline } from "../report-pipeline.js"; +import { readArtifactMediaBytes } from "../artifact-media.js"; import { ARTIFACT_ID_PATTERN, MAX_SCREENSHOT_BYTES, registerReportRoutes } from "../routes/register-report-routes.js"; type TestRequest = { body?: unknown; file?: { buffer: Buffer; mimetype?: string } }; @@ -33,7 +37,7 @@ function setup(projectSettings: Record = { reportMode: "auto-fi const store = { getSettingsByScopeFast: vi.fn().mockResolvedValue({ project: projectSettings, global: {} }), getRootDir: () => "/Users/alice/private-project", - getArtifact: vi.fn().mockResolvedValue({ type: "image", metadata: { source: "report-attachment" } }), + getArtifact: vi.fn().mockResolvedValue({ id: "123e4567-e89b-42d3-a456-426614174000", type: "image", title: "Report screenshot", mimeType: "image/png", uri: "artifacts/report.png", taskId: "FN-1", metadata: { source: "report-attachment" } }), registerArtifact: vi.fn().mockResolvedValue({ id: "123e4567-e89b-42d3-a456-426614174000" }), }; registerReportRoutes({ @@ -65,7 +69,10 @@ async function invoke(handlers: TestHandler[], body: unknown) { } describe("report routes", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(readArtifactMediaBytes).mockResolvedValue(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); + }); it("passes roadmap settings and a roadmap endorsement target to the pipeline", async () => { vi.mocked(queryKnowledgePagesAsync).mockResolvedValue([]); @@ -134,6 +141,31 @@ describe("report routes", () => { } } }); + + it("resolves the single persisted screenshot server-side before filing", async () => { + vi.mocked(runReportPipeline).mockResolvedValue({ kind: "filed" } as never); + const { handlers, store } = setup(); + const id = "123e4567-e89b-42d3-a456-426614174000"; + await invoke(handlers.get("/report/file")!, { actionType: "bug", report: { userPrompt: "It crashes", context: {}, screenshotArtifactId: id } }); + + expect(readArtifactMediaBytes).toHaveBeenCalledWith(store, expect.objectContaining({ id, metadata: { source: "report-attachment" } })); + expect(runReportPipeline).toHaveBeenCalledWith(expect.objectContaining({ attachment: expect.objectContaining({ artifactId: id, mimeType: "image/png", bytes: expect.any(Buffer) }) }), expect.anything(), expect.anything()); + }); + + it("rejects invalid artifact bytes before the filing pipeline", async () => { + const id = "123e4567-e89b-42d3-a456-426614174000"; + const { handlers, store } = setup(); + store.getArtifact.mockResolvedValue({ id, type: "image", mimeType: "image/png", uri: "../../etc/passwd", taskId: "FN-1", metadata: { source: "report-attachment" } }); + vi.mocked(readArtifactMediaBytes).mockRejectedValueOnce(new Error("Invalid artifact media path")); + + await expect(invoke(handlers.get("/report/file")!, { actionType: "bug", report: { userPrompt: "It crashes", context: {}, screenshotArtifactId: id } })).rejects.toThrow("Invalid artifact media path"); + expect(runReportPipeline).not.toHaveBeenCalled(); + + store.getArtifact.mockResolvedValue({ id, type: "image", mimeType: "image/png", uri: "artifacts/report.png", taskId: "FN-1", metadata: { source: "report-attachment" } }); + vi.mocked(readArtifactMediaBytes).mockResolvedValueOnce(Buffer.alloc(MAX_SCREENSHOT_BYTES + 1)); + await expect(invoke(handlers.get("/report/file")!, { actionType: "bug", report: { userPrompt: "It crashes", context: {}, screenshotArtifactId: id } })).rejects.toThrow("Screenshot artifact is unavailable or invalid"); + expect(runReportPipeline).not.toHaveBeenCalled(); + }); }); it.each(["/report/draft", "/report/file"])("passes valid targetType through the filing pipeline on %s", async (path) => { diff --git a/packages/dashboard/src/artifact-media.ts b/packages/dashboard/src/artifact-media.ts new file mode 100644 index 0000000000..47d2d612a4 --- /dev/null +++ b/packages/dashboard/src/artifact-media.ts @@ -0,0 +1,32 @@ +import { readFile } from "node:fs/promises"; +import { resolve, sep } from "node:path"; +import type { TaskStore } from "@fusion/core"; +import { badRequest } from "./api-error.js"; + +export type MediaArtifact = { taskId?: string; uri?: string }; + +/** + * FNXC:ArtifactMedia 2026-07-19-17:25: + * Artifact URIs are storage metadata, not trusted filesystem paths. Media reads + * must stay confined to the owning task's artifacts/attachments directories (or + * task-less .fusion/artifacts) before a report can upload user-reviewed pixels. + */ +export function resolveArtifactMediaPath(scopedStore: TaskStore, artifact: MediaArtifact): string | null { + if (!artifact.uri) return null; + + const anchorDir = artifact.taskId ? scopedStore.getTaskDir(artifact.taskId) : scopedStore.getFusionDir(); + const expectedArtifactsDir = resolve(anchorDir, "artifacts"); + const expectedAttachmentsDir = artifact.taskId ? resolve(anchorDir, "attachments") : null; + const mediaPath = resolve(anchorDir, artifact.uri); + const underArtifacts = mediaPath === expectedArtifactsDir || mediaPath.startsWith(`${expectedArtifactsDir}${sep}`); + const underAttachments = expectedAttachmentsDir !== null && (mediaPath === expectedAttachmentsDir || mediaPath.startsWith(`${expectedAttachmentsDir}${sep}`)); + if (!underArtifacts && !underAttachments) throw badRequest("Invalid artifact media path"); + return mediaPath; +} + +/** Reads bytes only after the shared task-directory confinement check succeeds. */ +export async function readArtifactMediaBytes(scopedStore: TaskStore, artifact: MediaArtifact): Promise { + const mediaPath = resolveArtifactMediaPath(scopedStore, artifact); + if (!mediaPath) throw badRequest("Artifact has no stored media"); + return readFile(mediaPath); +} diff --git a/packages/dashboard/src/github.ts b/packages/dashboard/src/github.ts index a26c2752b2..a4070dad28 100644 --- a/packages/dashboard/src/github.ts +++ b/packages/dashboard/src/github.ts @@ -13,6 +13,7 @@ import { getCurrentRepo, runGh, } from "@fusion/core"; +import { ALLOWED_IMAGE_MIMES, MAX_IMAGE_BYTES } from "./issue-image-attachments.js"; const execAsync = promisify(exec); @@ -234,6 +235,23 @@ export interface CreatedIssue { createdAt: string; } +export interface UploadImageAssetParams { + owner: string; + repo: string; + path: string; + contentBase64: string; + message: string; + branch?: string; + mimeType: string; +} + +export interface UploadedImageAsset { + htmlUrl: string; + rawUrl: string; + path: string; + sha: string; +} + export interface DiscussionCandidate { id: string; number: number; @@ -759,6 +777,45 @@ export class GitHubClient { this.forceMode = tokenOrOptions?.forceMode; } + /** + * FNXC:ReportScreenshotUpload 2026-07-19-12:00: + * Report pixels cross the permanent GitHub boundary only through the documented + * Contents API, never the undocumented web upload endpoint. MIME and decoded-size + * validation happens before either auth transport. A private repository's raw URL + * requires viewer authentication and therefore cannot be promised as anonymous inline media. + */ + async uploadImageAsset(params: UploadImageAssetParams): Promise { + if (!ALLOWED_IMAGE_MIMES.has(params.mimeType)) throw new Error("Unsupported image MIME type for GitHub upload."); + const normalized = params.contentBase64.replace(/\s/g, ""); + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized) || Buffer.from(normalized, "base64").byteLength > MAX_IMAGE_BYTES) { + throw new Error("Image upload exceeds the 5MB limit or is not valid base64."); + } + const endpoint = `repos/${encodeURIComponent(params.owner)}/${encodeURIComponent(params.repo)}/contents/${params.path.split("/").map(encodeURIComponent).join("/")}`; + if (this.forceMode === "gh-cli") { this.requireGh(); return this.uploadImageAssetWithGh(endpoint, params); } + if (this.forceMode === "token") { this.requireToken(); return this.uploadImageAssetWithApi(endpoint, params); } + if (this.hasGhAuth()) { + try { return await this.uploadImageAssetWithGh(endpoint, params); } + catch (error) { if (!this.token) throw new Error("Failed to upload GitHub image asset.", { cause: error }); } + } + if (this.token) return this.uploadImageAssetWithApi(endpoint, params); + throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided. Run 'gh auth login' or set GITHUB_TOKEN."); + } + + private async uploadImageAssetWithGh(endpoint: string, params: UploadImageAssetParams): Promise { + const body = JSON.stringify({ message: params.message, content: params.contentBase64, ...(params.branch ? { branch: params.branch } : {}) }); + const result = await runGhJsonAsync<{ content?: { html_url?: string; download_url?: string; path?: string; sha?: string } }>(["api", "--method", "PUT", endpoint, "--input", "-"], { input: body }); + const content = result.content; + if (!content?.html_url || !content.download_url || !content.path || !content.sha) throw new Error("GitHub Contents API returned an incomplete image asset."); + return { htmlUrl: content.html_url, rawUrl: content.download_url, path: content.path, sha: content.sha }; + } + + private async uploadImageAssetWithApi(endpoint: string, params: UploadImageAssetParams): Promise { + const result = await this.fetchThrottled<{ content?: { html_url?: string; download_url?: string; path?: string; sha?: string } }>(`${this.baseUrl}/${endpoint}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: params.message, content: params.contentBase64, ...(params.branch ? { branch: params.branch } : {}) }) }); + const content = result.data?.content; + if (!result.success || !content?.html_url || !content.download_url || !content.path || !content.sha) throw new Error(result.error ?? "GitHub Contents API returned an incomplete image asset."); + return { htmlUrl: content.html_url, rawUrl: content.download_url, path: content.path, sha: content.sha }; + } + /** * FNXC:ReportPipeline 2026-07-18-16:30: * An explicitly reviewed report screenshot may be hosted only in the selected diff --git a/packages/dashboard/src/issue-image-attachments.ts b/packages/dashboard/src/issue-image-attachments.ts index 8ddcf5d10b..3102019946 100644 --- a/packages/dashboard/src/issue-image-attachments.ts +++ b/packages/dashboard/src/issue-image-attachments.ts @@ -16,10 +16,10 @@ Import must never fail because an image failed to download: the task (the operat */ /** Mirrors TaskStore.ALLOWED_MIME_TYPES image subset — addAttachment rejects anything else. */ -const ALLOWED_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); +export const ALLOWED_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); /** Mirrors TaskStore.MAX_ATTACHMENT_SIZE (5MB). Checked before buffering so a huge asset can't balloon dashboard memory. */ -const MAX_IMAGE_BYTES = 5 * 1024 * 1024; +export const MAX_IMAGE_BYTES = 5 * 1024 * 1024; /** Bound per-issue work: a pathological issue (or a long comment thread) must not stall the import request. */ const MAX_IMAGES_PER_ISSUE = 10; @@ -28,7 +28,7 @@ const DOWNLOAD_TIMEOUT_MS = 15_000; const MAX_DOWNLOAD_REDIRECTS = 3; const IMAGE_DOWNLOAD_CONCURRENCY = 3; -const EXT_BY_MIME: Record = { +export const EXT_BY_MIME: Record = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", diff --git a/packages/dashboard/src/report-pipeline.ts b/packages/dashboard/src/report-pipeline.ts index 0c24b233fc..ec1be4ad85 100644 --- a/packages/dashboard/src/report-pipeline.ts +++ b/packages/dashboard/src/report-pipeline.ts @@ -1,12 +1,21 @@ +import { createHash } from "node:crypto"; import type { GlobalSettings, ProjectSettings, ReportActionType, ReportMode, ReportTarget } from "@fusion/core"; import { parseRepoSlug, resolveTaskGithubTracking } from "@fusion/core"; import { GitHubClient } from "./github.js"; +import { EXT_BY_MIME } from "./issue-image-attachments.js"; import { resolveGithubTrackingAuth } from "./github-auth.js"; import { buildIssueSearchQueries, DEDUP_MATCH_THRESHOLD, scoreCandidateIssue } from "./github-tracking-dedup.js"; -import { scrubReportPayload, type ReportScrubContext } from "./report-scrub.js"; +import { scrubReportPayload, scrubReportText, type ReportScrubContext } from "./report-scrub.js"; export type { ReportActionType, ReportMode, ReportTarget }; +export interface ReportScreenshot { + artifactId: string; + filename: string; + mimeType: string; + bytes: Buffer | Uint8Array; +} + export interface ReportInput { actionType: ReportActionType; userPrompt: string; @@ -14,6 +23,8 @@ export interface ReportInput { activityTrace?: string[]; /** Provenance-validated local screenshot artifact reference. */ screenshotArtifactId?: string; + /** Server-resolved report screenshot; never client-supplied pixels. */ + attachment?: ReportScreenshot; } export interface StructuredReport { @@ -25,6 +36,7 @@ export interface StructuredReport { context: Record; /** Local screenshot artifact reference; pixels never transit egress. */ screenshotArtifactId?: string; + attachment?: ReportScreenshot; sessionToken?: string; } @@ -39,7 +51,7 @@ export type ReportResult = export interface ReportPipelineDeps { projectSettings: Pick; globalSettings?: Partial; - client?: Pick & Partial>; + client?: Pick & Partial>; scrubContext?: ReportScrubContext; gatherContext?: (input: ReportInput) => Promise>; } @@ -95,6 +107,7 @@ function structureReport(input: ReportInput, gathered: Record): 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, screenshotArtifactId: input.screenshotArtifactId, + attachment: input.attachment, sessionToken: crypto.randomUUID(), }; } @@ -176,6 +189,34 @@ async function findRoadmapDuplicate(client: NonNullable character === "\r" || character === "\n" ? " " : `\\` + character); +} + +async function embedScreenshot(args: { report: StructuredReport; client: NonNullable; owner: string; repo: string; scrubContext?: ReportScrubContext }): Promise { + const { attachment } = args.report; + if (!attachment || !args.client.uploadImageAsset) return args.report; + try { + const extension = EXT_BY_MIME[attachment.mimeType]; + if (!extension) return args.report; + /* + FNXC:ReportScreenshotEmbedding 2026-07-19-12:30: + Repository paths must not reveal client tokens, artifact ids, filenames, or + local project details. A one-way digest yields a fixed, traversal-free + identifier while the separately scrubbed and Markdown-escaped alt text + preserves a safe user-facing label. + */ + const safeId = createHash("sha256").update(`${args.report.sessionToken ?? ""}:${attachment.artifactId}`).digest("hex").slice(0, 32); + const path = `.fusion-reports/${safeId}/screenshot.${extension}`; + const uploaded = await args.client.uploadImageAsset({ owner: args.owner, repo: args.repo, path, contentBase64: Buffer.from(attachment.bytes).toString("base64"), message: "chore: add Fusion report screenshot", mimeType: attachment.mimeType }); + const alt = markdownEscapeImageAlt(scrubReportText(attachment.filename, args.scrubContext) || "Report screenshot"); + return { ...args.report, body: `${args.report.body}\n\n## Screenshots\n![${alt}](${uploaded.rawUrl})` }; + } catch { + // Image hosting is explicitly best-effort: scrubbed text filing must proceed. + return args.report; + } +} + async function endorseDiscussionDuplicate(args: { issueNumber: number; discussionId: string; report: StructuredReport; client: NonNullable & Pick; scrubContext?: ReportScrubContext }): Promise> { const sessionToken = args.report.sessionToken ?? `${args.discussionId}:${args.report.summary}`; @@ -234,6 +275,7 @@ function normalizeSubmittedReport(input: ReportInput, gathered: Record { const gathered = await deps.gatherContext?.(input) ?? { taskId: input.contextRefs?.taskId, agentId: input.contextRefs?.agentId }; const normalized = normalizeSubmittedReport(input, gathered, options.report); - const report: StructuredReport = scrubReportPayload(normalized, deps.scrubContext); + // Buffers are not text scrub input; retain the already route-validated attachment separately. + const report: StructuredReport = { ...scrubReportPayload({ ...normalized, attachment: undefined }, deps.scrubContext), attachment: normalized.attachment }; const mode = resolveReportMode(input.actionType, deps.projectSettings); const clientResult = createClient(deps); @@ -267,25 +310,25 @@ export async function runReportPipeline(input: ReportInput, deps: ReportPipeline 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." }; } - const endorsed = await endorseDuplicate({ owner: roadmap.repo.owner, repo: roadmap.repo.repo, issueNumber: roadmapDuplicate.number, report, client: clientResult.client, scrubContext: deps.scrubContext }); + const endorsed = await endorseDuplicate({ owner: roadmap.repo.owner, repo: roadmap.repo.repo, issueNumber: roadmapDuplicate.number, report: await embedScreenshot({ report, client: clientResult.client, owner: roadmap.repo.owner, repo: roadmap.repo.repo, scrubContext: deps.scrubContext }), client: clientResult.client, scrubContext: deps.scrubContext }); return endorsed; } 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." }; } - const endorsed = await endorseDiscussionDuplicate({ issueNumber: duplicate.number, discussionId: duplicate.discussionId, report, client: clientResult.client as NonNullable & Pick, scrubContext: deps.scrubContext }); + const endorsed = await endorseDiscussionDuplicate({ issueNumber: duplicate.number, discussionId: duplicate.discussionId, report: await embedScreenshot({ report, client: clientResult.client, owner: repo.owner, repo: repo.repo, scrubContext: deps.scrubContext }), client: clientResult.client as NonNullable & Pick, scrubContext: deps.scrubContext }); 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 endorsed = await endorseDuplicate({ owner: repo.owner, repo: repo.repo, issueNumber: duplicate.number, report: await embedScreenshot({ report, client: clientResult.client, owner: repo.owner, repo: repo.repo, scrubContext: deps.scrubContext }), client: clientResult.client, scrubContext: deps.scrubContext }); 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 }); + if (mode === "auto-file") return endorseDuplicate({ owner: roadmap.repo!.owner, repo: roadmap.repo!.repo, issueNumber: roadmapDuplicate.number, report: await embedScreenshot({ report, client: clientResult.client, owner: roadmap.repo!.owner, repo: roadmap.repo!.repo, scrubContext: deps.scrubContext }), client: clientResult.client, scrubContext: deps.scrubContext }); return { kind: "duplicate-found", report, mode, issue: { number: roadmapDuplicate.number, url: roadmapDuplicate.html_url, title: roadmapDuplicate.title, roadmap: true } }; } if (duplicate) { @@ -295,11 +338,11 @@ export async function runReportPipeline(input: ReportInput, deps: ReportPipeline return { kind: "unavailable", reason: "discussion_unsupported", message: "This GitHub connection cannot endorse discussions." }; } try { - const endorsed = await endorseDiscussionDuplicate({ issueNumber: duplicate.number, discussionId: duplicate.discussionId, report, client: clientResult.client as NonNullable & Pick, scrubContext: deps.scrubContext }); + const endorsed = await endorseDiscussionDuplicate({ issueNumber: duplicate.number, discussionId: duplicate.discussionId, report: await embedScreenshot({ report, client: clientResult.client, owner: repo.owner, repo: repo.repo, scrubContext: deps.scrubContext }), client: clientResult.client as NonNullable & Pick, scrubContext: deps.scrubContext }); return endorsed; } catch { return { kind: "unavailable", reason: "discussion_unavailable", message: "GitHub Discussions are unavailable for this repository or token." }; } } - const endorsed = await 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: await embedScreenshot({ report, client: clientResult.client, owner: repo.owner, repo: repo.repo, scrubContext: deps.scrubContext }), client: clientResult.client, scrubContext: deps.scrubContext }); return endorsed; } return { kind: "duplicate-found", report, mode, issue: { number: duplicate.number, url: duplicate.html_url, title: duplicate.title, discussionId: duplicate.discussionId } }; @@ -312,8 +355,13 @@ export async function runReportPipeline(input: ReportInput, deps: ReportPipeline let categoryId: string | undefined; try { categoryId = (await clientResult.client.listDiscussionCategories(repo.owner, repo.repo)).find((category) => category.id === configuredCategory || category.slug === configuredCategory)?.id; } catch { return { kind: "unavailable", reason: "discussion_categories_unavailable", message: "GitHub Discussions are unavailable for this repository or token." }; } if (!categoryId) return { kind: "unavailable", reason: "discussion_category_invalid", message: "The selected Discussion category is missing or unavailable for this repository." }; - try { const created = await clientResult.client.createDiscussion(repo.owner, repo.repo, report.summary, report.body, categoryId); return { kind: "filed", url: created.htmlUrl, report }; } catch { return { kind: "unavailable", reason: "discussion_unavailable", message: "GitHub Discussions are unavailable for this repository or token." }; } + try { + const embeddedReport = await embedScreenshot({ report, client: clientResult.client, owner: repo.owner, repo: repo.repo, scrubContext: deps.scrubContext }); + const created = await clientResult.client.createDiscussion(repo.owner, repo.repo, embeddedReport.summary, embeddedReport.body, categoryId); + return { kind: "filed", url: created.htmlUrl, report: embeddedReport }; + } catch { return { kind: "unavailable", reason: "discussion_unavailable", message: "GitHub Discussions are unavailable for this repository or token." }; } } - 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 embeddedReport = await embedScreenshot({ report, client: clientResult.client, owner: repo.owner, repo: repo.repo, scrubContext: deps.scrubContext }); + const created = await clientResult.client.createIssue({ owner: repo.owner, repo: repo.repo, title: embeddedReport.summary, body: embeddedReport.body, labels: ["community"] }); + return { kind: "filed", url: created.htmlUrl, report: embeddedReport }; } diff --git a/packages/dashboard/src/routes/register-report-routes.ts b/packages/dashboard/src/routes/register-report-routes.ts index 519fd63e5a..1d3b0b514b 100644 --- a/packages/dashboard/src/routes/register-report-routes.ts +++ b/packages/dashboard/src/routes/register-report-routes.ts @@ -1,11 +1,13 @@ import { REPORT_ATTACHMENT_SOURCE, resolveTaskGithubTracking } from "@fusion/core"; +import { ALLOWED_IMAGE_MIMES, MAX_IMAGE_BYTES } from "../issue-image-attachments.js"; +import { readArtifactMediaBytes } from "../artifact-media.js"; import type { Request, Response } from "express"; import { ApiError } from "../api-error.js"; import { GitHubClient } from "../github.js"; import { resolveGithubTrackingAuth } from "../github-auth.js"; import { queryKnowledgePagesAsync } from "../knowledge-index.js"; import { requireAsyncLayer } from "../require-async-layer.js"; -import { runReportPipeline, type ReportInput, type StructuredReport } from "../report-pipeline.js"; +import { runReportPipeline, type ReportInput, type ReportScreenshot, type StructuredReport } from "../report-pipeline.js"; import { scrubReportPayload } from "../report-scrub.js"; import { selfCheckHelp } from "../report-help-selfcheck.js"; import type { ApiRouteRegistrar } from "./types.js"; @@ -30,13 +32,22 @@ function parseActivityTrace(value: unknown): string[] | undefined { function parseScreenshotArtifactId(value: unknown): string | undefined { if (value === undefined) return undefined; if (typeof value !== "string" || !ARTIFACT_ID_PATTERN.test(value)) throw new ApiError(400, "Screenshot artifact reference is invalid."); - return value as "issue" | "discussion"; + return value; +} + +async function resolveReportScreenshot(store: Awaited[0]["getScopedStore"]>>, id: string | undefined): Promise { + if (!id) return undefined; + const artifact = await store.getArtifact(id); + if (artifact?.type !== "image" || artifact.metadata?.source !== REPORT_ATTACHMENT_SOURCE || !artifact.mimeType || !ALLOWED_IMAGE_MIMES.has(artifact.mimeType)) throw new ApiError(400, "Screenshot artifact is unavailable or invalid."); + const bytes = await readArtifactMediaBytes(store, artifact); + if (bytes.length === 0 || bytes.length > MAX_IMAGE_BYTES || imageMimeType(bytes) !== artifact.mimeType) throw new ApiError(400, "Screenshot artifact is unavailable or invalid."); + return { artifactId: artifact.id, filename: artifact.title || "Report screenshot", mimeType: artifact.mimeType, bytes }; } async function validateScreenshotArtifact(store: Awaited[0]["getScopedStore"]>>, id: string | undefined): Promise { 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."); + if (artifact?.type !== "image" || artifact.metadata?.source !== REPORT_ATTACHMENT_SOURCE || !artifact.mimeType || !ALLOWED_IMAGE_MIMES.has(artifact.mimeType)) throw new ApiError(400, "Screenshot artifact is unavailable or invalid."); } function imageMimeType(buffer: Buffer): "image/png" | "image/jpeg" | undefined { @@ -80,7 +91,8 @@ function parseInput(body: unknown): ReportInput { * 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. + * note. Filing resolves that explicit reference through the guarded artifact-media + * seam before the separately consented Contents-API upload; raw request pixels are never accepted. */ export const registerReportRoutes: ApiRouteRegistrar = ({ router, getScopedStore, rethrowAsApiError, reportUpload }) => { const attachment = async (req: Request & { file?: { buffer?: Buffer; mimetype?: string } }, res: Response) => { @@ -114,15 +126,16 @@ export const registerReportRoutes: ApiRouteRegistrar = ({ router, getScopedStore router.post("/report/file", async (req, res) => { try { const store = await getScopedStore(req); const scopes = await store.getSettingsByScopeFast(); const raw = (req.body ?? {}) as Record; const rawReport = (raw.report ?? raw) as StructuredReport; - const { screenshotArtifactId: reportArtifactId, ...textualRawReport } = rawReport; + const { screenshotArtifactId: reportArtifactId, attachment: _untrustedAttachment, ...textualRawReport } = rawReport; const untrusted = scrubReportPayload(textualRawReport, { rootDir: store.getRootDir(), projectName: store.getRootDir().split(/[\\/]/).pop() }); const input = parseInput({ actionType: raw.actionType ?? (untrusted.context as Record | undefined)?.actionType ?? "bug", userPrompt: untrusted.userPrompt ?? untrusted.summary, contextRefs: (untrusted.context as Record | undefined) && { taskId: typeof (untrusted.context as Record).taskId === "string" ? (untrusted.context as Record).taskId : undefined, agentId: typeof (untrusted.context as Record).agentId === "string" ? (untrusted.context as Record).agentId : undefined }, activityTrace: raw.activityTrace ?? (untrusted.context as Record | undefined)?.activityTrace, screenshotArtifactId: raw.screenshotArtifactId ?? reportArtifactId }); // FNXC:ReportPipeline 2026-07-16-21:00: Validate target before Help can return locally. const targetType = parseTargetType(raw.targetType); - await validateScreenshotArtifact(store, input.screenshotArtifactId); - const help = await selfCheckHelpBeforePipeline(store, input); + const attachment = await resolveReportScreenshot(store, input.screenshotArtifactId); + const inputWithAttachment = attachment ? { ...input, attachment } : input; + const help = await selfCheckHelpBeforePipeline(store, inputWithAttachment); 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) }, { file: true, targetType, 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 })); + res.json(await runReportPipeline(inputWithAttachment, { projectSettings: scopes.project, globalSettings: scopes.global, scrubContext: { rootDir: store.getRootDir(), projectName: store.getRootDir().split(/[\\/]/).pop() }, gatherContext: (reportInput) => gatherReportContext(store, reportInput, scopes.project as Record) }, { file: true, targetType, 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"); } }); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index c6d70ad25b..6e9ec29a98 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -1,6 +1,6 @@ import { createReadStream } from "node:fs"; import { stat } from "node:fs/promises"; -import { join, resolve, sep } from "node:path"; +import { join } from "node:path"; import type { TaskStore, Task, @@ -56,6 +56,7 @@ import { type ThinkingLevel, } from "@fusion/core"; import { GitHubClient } from "../github.js"; +import { resolveArtifactMediaPath } from "../artifact-media.js"; import { githubRateLimiter } from "../github-poll.js"; import { createTrackingIssueForTask } from "../github-tracking-hook.js"; import { parseGitHubBadgeUrl } from "./register-git-github.js"; @@ -163,27 +164,6 @@ function isArtifactType(value: string): value is ArtifactType { return ARTIFACT_TYPES.has(value as ArtifactType); } -function resolveArtifactMediaPath(scopedStore: TaskStore, artifact: { taskId?: string; uri?: string }): string | null { - if (!artifact.uri) { - return null; - } - - const anchorDir = artifact.taskId ? scopedStore.getTaskDir(artifact.taskId) : scopedStore.getFusionDir(); - const expectedArtifactsDir = resolve(anchorDir, "artifacts"); - const expectedAttachmentsDir = artifact.taskId ? resolve(anchorDir, "attachments") : null; - const mediaPath = resolve(anchorDir, artifact.uri); - const underArtifacts = mediaPath === expectedArtifactsDir || mediaPath.startsWith(`${expectedArtifactsDir}${sep}`); - const underAttachments = expectedAttachmentsDir !== null && (mediaPath === expectedAttachmentsDir || mediaPath.startsWith(`${expectedAttachmentsDir}${sep}`)); - /* - * FNXC:ArtifactRegistry 2026-07-10-00:00: - * Attachment-sourced image artifacts intentionally store `attachments/` URIs so /media streams the original task attachment bytes without a second artifact copy. Keep the resolver anchored to task-owned artifact/attachment directories only; task-less artifacts still resolve exclusively under `.fusion/artifacts/`. - */ - if (!underArtifacts && !underAttachments) { - throw badRequest("Invalid artifact media path"); - } - return mediaPath; -} - interface AutoSyncOutcome { worktreePath: string | null; outcome: string;