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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-19 18:45:27 -07:00
parent 18f1cb743a
commit 64c1193d1e
15 changed files with 401 additions and 50 deletions

View File

@@ -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.

View File

@@ -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. 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 ## Chat-requested task verification

View File

@@ -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<typeof import("node:child_process")>("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();
});
});

View File

@@ -33,6 +33,8 @@ export interface StructuredGhError {
}; };
} }
export const MAX_GH_STDIN_INPUT_BYTES = 8 * 1024 * 1024;
export interface RunGhOptions { export interface RunGhOptions {
cwd?: string; cwd?: string;
/** External abort signal — propagated to the spawned `gh` process. */ /** 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. * pins any AI session's `prompt()` that triggered the tool call.
*/ */
timeoutMs?: number; 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; 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. * hangs when `gh` stalls on the network or a credential helper.
*/ */
export function runGhAsync(args: string[], cwdOrOptions?: string | RunGhOptions): Promise<string> { export function runGhAsync(args: string[], cwdOrOptions?: string | RunGhOptions): Promise<string> {
const { cwd, signal: externalSignal, timeoutMs = DEFAULT_GH_TIMEOUT_MS } = const { cwd, signal: externalSignal, timeoutMs = DEFAULT_GH_TIMEOUT_MS, input } =
normalizeRunGhOptions(cwdOrOptions); 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) => { return new Promise((resolve, reject) => {
if (externalSignal?.aborted) { if (externalSignal?.aborted) {
@@ -196,7 +206,7 @@ export function runGhAsync(args: string[], cwdOrOptions?: string | RunGhOptions)
if (externalSignal) externalSignal.removeEventListener("abort", onExternalAbort); if (externalSignal) externalSignal.removeEventListener("abort", onExternalAbort);
}; };
execFile( const child = execFile(
"gh", "gh",
args, 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);
}); });
} }

View File

@@ -41,6 +41,11 @@ describe("ReportModal", () => {
await waitFor(() => expect(reportDraft).toHaveBeenCalledWith(expect.objectContaining({ await waitFor(() => expect(reportDraft).toHaveBeenCalledWith(expect.objectContaining({
screenshotArtifactId: "123e4567-e89b-42d3-a456-426614174000", 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 () => { it("clears a pending capture when the reporter opts out", async () => {

View File

@@ -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");
}
});
});

View File

@@ -0,0 +1,39 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@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();
});
});

View File

@@ -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");
});
});

View File

@@ -10,9 +10,13 @@ vi.mock("../require-async-layer.js", () => ({
vi.mock("../report-pipeline.js", () => ({ vi.mock("../report-pipeline.js", () => ({
runReportPipeline: vi.fn(), runReportPipeline: vi.fn(),
})); }));
vi.mock("../artifact-media.js", () => ({
readArtifactMediaBytes: vi.fn(),
}));
import { queryKnowledgePagesAsync } from "../knowledge-index.js"; import { queryKnowledgePagesAsync } from "../knowledge-index.js";
import { runReportPipeline } from "../report-pipeline.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"; import { ARTIFACT_ID_PATTERN, MAX_SCREENSHOT_BYTES, registerReportRoutes } from "../routes/register-report-routes.js";
type TestRequest = { body?: unknown; file?: { buffer: Buffer; mimetype?: string } }; type TestRequest = { body?: unknown; file?: { buffer: Buffer; mimetype?: string } };
@@ -33,7 +37,7 @@ function setup(projectSettings: Record<string, unknown> = { reportMode: "auto-fi
const store = { const store = {
getSettingsByScopeFast: vi.fn().mockResolvedValue({ project: projectSettings, global: {} }), getSettingsByScopeFast: vi.fn().mockResolvedValue({ project: projectSettings, global: {} }),
getRootDir: () => "/Users/alice/private-project", 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" }), registerArtifact: vi.fn().mockResolvedValue({ id: "123e4567-e89b-42d3-a456-426614174000" }),
}; };
registerReportRoutes({ registerReportRoutes({
@@ -65,7 +69,10 @@ async function invoke(handlers: TestHandler[], body: unknown) {
} }
describe("report routes", () => { 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 () => { it("passes roadmap settings and a roadmap endorsement target to the pipeline", async () => {
vi.mocked(queryKnowledgePagesAsync).mockResolvedValue([]); 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) => { it.each(["/report/draft", "/report/file"])("passes valid targetType through the filing pipeline on %s", async (path) => {

View File

@@ -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<Buffer> {
const mediaPath = resolveArtifactMediaPath(scopedStore, artifact);
if (!mediaPath) throw badRequest("Artifact has no stored media");
return readFile(mediaPath);
}

View File

@@ -13,6 +13,7 @@ import {
getCurrentRepo, getCurrentRepo,
runGh, runGh,
} from "@fusion/core"; } from "@fusion/core";
import { ALLOWED_IMAGE_MIMES, MAX_IMAGE_BYTES } from "./issue-image-attachments.js";
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -234,6 +235,23 @@ export interface CreatedIssue {
createdAt: string; 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 { export interface DiscussionCandidate {
id: string; id: string;
number: number; number: number;
@@ -759,6 +777,45 @@ export class GitHubClient {
this.forceMode = tokenOrOptions?.forceMode; 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<UploadedImageAsset> {
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<UploadedImageAsset> {
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<UploadedImageAsset> {
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: * FNXC:ReportPipeline 2026-07-18-16:30:
* An explicitly reviewed report screenshot may be hosted only in the selected * An explicitly reviewed report screenshot may be hosted only in the selected

View File

@@ -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. */ /** 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. */ /** 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. */ /** Bound per-issue work: a pathological issue (or a long comment thread) must not stall the import request. */
const MAX_IMAGES_PER_ISSUE = 10; const MAX_IMAGES_PER_ISSUE = 10;
@@ -28,7 +28,7 @@ const DOWNLOAD_TIMEOUT_MS = 15_000;
const MAX_DOWNLOAD_REDIRECTS = 3; const MAX_DOWNLOAD_REDIRECTS = 3;
const IMAGE_DOWNLOAD_CONCURRENCY = 3; const IMAGE_DOWNLOAD_CONCURRENCY = 3;
const EXT_BY_MIME: Record<string, string> = { export const EXT_BY_MIME: Record<string, string> = {
"image/png": "png", "image/png": "png",
"image/jpeg": "jpg", "image/jpeg": "jpg",
"image/gif": "gif", "image/gif": "gif",

View File

@@ -1,12 +1,21 @@
import { createHash } from "node:crypto";
import type { GlobalSettings, ProjectSettings, ReportActionType, ReportMode, ReportTarget } from "@fusion/core"; import type { GlobalSettings, ProjectSettings, ReportActionType, ReportMode, ReportTarget } from "@fusion/core";
import { parseRepoSlug, resolveTaskGithubTracking } from "@fusion/core"; import { parseRepoSlug, resolveTaskGithubTracking } from "@fusion/core";
import { GitHubClient } from "./github.js"; import { GitHubClient } from "./github.js";
import { EXT_BY_MIME } from "./issue-image-attachments.js";
import { resolveGithubTrackingAuth } from "./github-auth.js"; import { resolveGithubTrackingAuth } from "./github-auth.js";
import { buildIssueSearchQueries, DEDUP_MATCH_THRESHOLD, scoreCandidateIssue } from "./github-tracking-dedup.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 type { ReportActionType, ReportMode, ReportTarget };
export interface ReportScreenshot {
artifactId: string;
filename: string;
mimeType: string;
bytes: Buffer | Uint8Array;
}
export interface ReportInput { export interface ReportInput {
actionType: ReportActionType; actionType: ReportActionType;
userPrompt: string; userPrompt: string;
@@ -14,6 +23,8 @@ export interface ReportInput {
activityTrace?: string[]; activityTrace?: string[];
/** Provenance-validated local screenshot artifact reference. */ /** Provenance-validated local screenshot artifact reference. */
screenshotArtifactId?: string; screenshotArtifactId?: string;
/** Server-resolved report screenshot; never client-supplied pixels. */
attachment?: ReportScreenshot;
} }
export interface StructuredReport { export interface StructuredReport {
@@ -25,6 +36,7 @@ export interface StructuredReport {
context: Record<string, unknown>; context: Record<string, unknown>;
/** Local screenshot artifact reference; pixels never transit egress. */ /** Local screenshot artifact reference; pixels never transit egress. */
screenshotArtifactId?: string; screenshotArtifactId?: string;
attachment?: ReportScreenshot;
sessionToken?: string; sessionToken?: string;
} }
@@ -39,7 +51,7 @@ export type ReportResult =
export interface ReportPipelineDeps { export interface ReportPipelineDeps {
projectSettings: Pick<ProjectSettings, "reportMode" | "reportModeByAction" | "reportTarget" | "reportTargetByAction" | "reportDiscussionCategory" | "reportRoadmapDedupeEnabled" | "reportRoadmapLabel" | "reportRoadmapRepo" | "githubTrackingDefaultRepo" | "githubAuthMode" | "githubAuthToken">; projectSettings: Pick<ProjectSettings, "reportMode" | "reportModeByAction" | "reportTarget" | "reportTargetByAction" | "reportDiscussionCategory" | "reportRoadmapDedupeEnabled" | "reportRoadmapLabel" | "reportRoadmapRepo" | "githubTrackingDefaultRepo" | "githubAuthMode" | "githubAuthToken">;
globalSettings?: Partial<GlobalSettings>; globalSettings?: Partial<GlobalSettings>;
client?: Pick<GitHubClient, "createIssue" | "searchIssues" | "commentOnIssue" | "addIssueReaction"> & Partial<Pick<GitHubClient, "searchDiscussions" | "createDiscussion" | "commentOnDiscussion" | "addDiscussionReaction" | "listDiscussionCategories">>; client?: Pick<GitHubClient, "createIssue" | "searchIssues" | "commentOnIssue" | "addIssueReaction"> & Partial<Pick<GitHubClient, "searchDiscussions" | "createDiscussion" | "commentOnDiscussion" | "addDiscussionReaction" | "listDiscussionCategories" | "uploadImageAsset">>;
scrubContext?: ReportScrubContext; scrubContext?: ReportScrubContext;
gatherContext?: (input: ReportInput) => Promise<Record<string, unknown>>; gatherContext?: (input: ReportInput) => Promise<Record<string, unknown>>;
} }
@@ -95,6 +107,7 @@ function structureReport(input: ReportInput, gathered: Record<string, unknown>):
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}).` : ""}`, 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, context,
screenshotArtifactId: input.screenshotArtifactId, screenshotArtifactId: input.screenshotArtifactId,
attachment: input.attachment,
sessionToken: crypto.randomUUID(), sessionToken: crypto.randomUUID(),
}; };
} }
@@ -176,6 +189,34 @@ async function findRoadmapDuplicate(client: NonNullable<ReportPipelineDeps["clie
return undefined; return undefined;
} }
function markdownEscapeImageAlt(value: string): string {
return value.replace(/[[\]()!\r\n]/g, (character) => character === "\r" || character === "\n" ? " " : `\\` + character);
}
async function embedScreenshot(args: { report: StructuredReport; client: NonNullable<ReportPipelineDeps["client"]>; owner: string; repo: string; scrubContext?: ReportScrubContext }): Promise<StructuredReport> {
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<ReportPipelineDeps["client"]> & Pick<GitHubClient, "commentOnDiscussion" | "addDiscussionReaction">; scrubContext?: ReportScrubContext }): Promise<Extract<ReportResult, { kind: "endorsed" }>> { async function endorseDiscussionDuplicate(args: { issueNumber: number; discussionId: string; report: StructuredReport; client: NonNullable<ReportPipelineDeps["client"]> & Pick<GitHubClient, "commentOnDiscussion" | "addDiscussionReaction">; scrubContext?: ReportScrubContext }): Promise<Extract<ReportResult, { kind: "endorsed" }>> {
const sessionToken = args.report.sessionToken ?? `${args.discussionId}:${args.report.summary}`; const sessionToken = args.report.sessionToken ?? `${args.discussionId}:${args.report.summary}`;
@@ -234,6 +275,7 @@ function normalizeSubmittedReport(input: ReportInput, gathered: Record<string, u
body: !promptChangedSinceDerivation && typeof submitted.body === "string" && submitted.body.trim() ? submitted.body : rebuilt.body, 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, context: submitted.context && typeof submitted.context === "object" ? { ...rebuilt.context, ...submitted.context } : rebuilt.context,
screenshotArtifactId: input.screenshotArtifactId, screenshotArtifactId: input.screenshotArtifactId,
attachment: input.attachment,
sessionToken: typeof submitted.sessionToken === "string" && submitted.sessionToken ? submitted.sessionToken : rebuilt.sessionToken, sessionToken: typeof submitted.sessionToken === "string" && submitted.sessionToken ? submitted.sessionToken : rebuilt.sessionToken,
}; };
} }
@@ -241,7 +283,8 @@ function normalizeSubmittedReport(input: ReportInput, gathered: Record<string, u
export async function runReportPipeline(input: ReportInput, deps: ReportPipelineDeps, options: { file?: boolean; targetType?: ReportTarget; endorseIssueNumber?: number; endorseDiscussionId?: string; endorseRoadmapIssueNumber?: number; report?: StructuredReport } = {}): Promise<ReportResult> { export async function runReportPipeline(input: ReportInput, deps: ReportPipelineDeps, options: { file?: boolean; targetType?: ReportTarget; endorseIssueNumber?: number; endorseDiscussionId?: string; endorseRoadmapIssueNumber?: number; report?: StructuredReport } = {}): Promise<ReportResult> {
const gathered = await deps.gatherContext?.(input) ?? { taskId: input.contextRefs?.taskId, agentId: input.contextRefs?.agentId }; const gathered = await deps.gatherContext?.(input) ?? { taskId: input.contextRefs?.taskId, agentId: input.contextRefs?.agentId };
const normalized = normalizeSubmittedReport(input, gathered, options.report); 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 mode = resolveReportMode(input.actionType, deps.projectSettings);
const clientResult = createClient(deps); const clientResult = createClient(deps);
@@ -267,25 +310,25 @@ export async function runReportPipeline(input: ReportInput, deps: ReportPipeline
if (!roadmapDuplicate || roadmapDuplicate.number !== options.endorseRoadmapIssueNumber || !roadmap.repo) { 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." }; 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; return endorsed;
} }
if (options.endorseDiscussionId) { if (options.endorseDiscussionId) {
if (!clientResult.client.commentOnDiscussion || !clientResult.client.addDiscussionReaction || destination !== "discussion" || duplicate?.discussionId !== 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 { kind: "unavailable", reason: "duplicate_not_verified", message: "The selected discussion is no longer an open matching report. Please prepare the report again." };
} }
const endorsed = await endorseDiscussionDuplicate({ issueNumber: duplicate.number, discussionId: duplicate.discussionId, report, client: clientResult.client as NonNullable<ReportPipelineDeps["client"]> & Pick<GitHubClient, "commentOnDiscussion" | "addDiscussionReaction">, scrubContext: deps.scrubContext }); const 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<ReportPipelineDeps["client"]> & Pick<GitHubClient, "commentOnDiscussion" | "addDiscussionReaction">, scrubContext: deps.scrubContext });
return endorsed; return endorsed;
} }
if (options.endorseIssueNumber) { if (options.endorseIssueNumber) {
if (destination !== "issue" || duplicate?.number !== 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 { 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; return endorsed;
} }
if (roadmapDuplicate) { 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 } }; return { kind: "duplicate-found", report, mode, issue: { number: roadmapDuplicate.number, url: roadmapDuplicate.html_url, title: roadmapDuplicate.title, roadmap: true } };
} }
if (duplicate) { 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." }; return { kind: "unavailable", reason: "discussion_unsupported", message: "This GitHub connection cannot endorse discussions." };
} }
try { try {
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 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<ReportPipelineDeps["client"]> & Pick<GitHubClient, "commentOnDiscussion" | "addDiscussionReaction">, scrubContext: deps.scrubContext });
return endorsed; return endorsed;
} catch { return { kind: "unavailable", reason: "discussion_unavailable", message: "GitHub Discussions are unavailable for this repository or token." }; } } 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 endorsed;
} }
return { kind: "duplicate-found", report, mode, issue: { number: duplicate.number, url: duplicate.html_url, title: duplicate.title, discussionId: duplicate.discussionId } }; 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; 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." }; } 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." }; 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"] }); const embeddedReport = await embedScreenshot({ report, client: clientResult.client, owner: repo.owner, repo: repo.repo, scrubContext: deps.scrubContext });
return { kind: "filed", url: created.htmlUrl, report }; 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 };
} }

View File

@@ -1,11 +1,13 @@
import { REPORT_ATTACHMENT_SOURCE, resolveTaskGithubTracking } from "@fusion/core"; 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 type { Request, Response } from "express";
import { ApiError } from "../api-error.js"; import { ApiError } from "../api-error.js";
import { GitHubClient } from "../github.js"; import { GitHubClient } from "../github.js";
import { resolveGithubTrackingAuth } from "../github-auth.js"; import { resolveGithubTrackingAuth } from "../github-auth.js";
import { queryKnowledgePagesAsync } from "../knowledge-index.js"; import { queryKnowledgePagesAsync } from "../knowledge-index.js";
import { requireAsyncLayer } from "../require-async-layer.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 { scrubReportPayload } from "../report-scrub.js";
import { selfCheckHelp } from "../report-help-selfcheck.js"; import { selfCheckHelp } from "../report-help-selfcheck.js";
import type { ApiRouteRegistrar } from "./types.js"; import type { ApiRouteRegistrar } from "./types.js";
@@ -30,13 +32,22 @@ function parseActivityTrace(value: unknown): string[] | undefined {
function parseScreenshotArtifactId(value: unknown): string | undefined { function parseScreenshotArtifactId(value: unknown): string | undefined {
if (value === undefined) return undefined; if (value === undefined) return undefined;
if (typeof value !== "string" || !ARTIFACT_ID_PATTERN.test(value)) throw new ApiError(400, "Screenshot artifact reference is invalid."); 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<ReturnType<Parameters<ApiRouteRegistrar>[0]["getScopedStore"]>>, id: string | undefined): Promise<ReportScreenshot | undefined> {
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<ReturnType<Parameters<ApiRouteRegistrar>[0]["getScopedStore"]>>, id: string | undefined): Promise<void> { async function validateScreenshotArtifact(store: Awaited<ReturnType<Parameters<ApiRouteRegistrar>[0]["getScopedStore"]>>, id: string | undefined): Promise<void> {
if (!id) return; if (!id) return;
const artifact = await store.getArtifact(id); 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 { 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: * FNXC:ReportPipeline 2026-07-19-10:00:
* Report routes persist opted-in PNG/JPEG pixels locally as provenance-marked * Report routes persist opted-in PNG/JPEG pixels locally as provenance-marked
* artifacts. Draft and file requests carry only a validated reference and text * 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 }) => { export const registerReportRoutes: ApiRouteRegistrar = ({ router, getScopedStore, rethrowAsApiError, reportUpload }) => {
const attachment = async (req: Request & { file?: { buffer?: Buffer; mimetype?: string } }, res: Response) => { 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) => { router.post("/report/file", async (req, res) => {
try { try {
const store = await getScopedStore(req); const scopes = await store.getSettingsByScopeFast(); const raw = (req.body ?? {}) as Record<string, unknown>; const rawReport = (raw.report ?? raw) as StructuredReport; const store = await getScopedStore(req); const scopes = await store.getSettingsByScopeFast(); const raw = (req.body ?? {}) as Record<string, unknown>; const rawReport = (raw.report ?? raw) as StructuredReport;
const { screenshotArtifactId: reportArtifactId, ...textualRawReport } = rawReport; const { screenshotArtifactId: reportArtifactId, attachment: _untrustedAttachment, ...textualRawReport } = rawReport;
const untrusted = scrubReportPayload(textualRawReport, { rootDir: store.getRootDir(), projectName: store.getRootDir().split(/[\\/]/).pop() }); const untrusted = scrubReportPayload(textualRawReport, { rootDir: store.getRootDir(), projectName: store.getRootDir().split(/[\\/]/).pop() });
const input = parseInput({ actionType: raw.actionType ?? (untrusted.context as Record<string, unknown> | undefined)?.actionType ?? "bug", userPrompt: untrusted.userPrompt ?? untrusted.summary, contextRefs: (untrusted.context as Record<string, unknown> | undefined) && { taskId: typeof (untrusted.context as Record<string, unknown>).taskId === "string" ? (untrusted.context as Record<string, unknown>).taskId : undefined, agentId: typeof (untrusted.context as Record<string, unknown>).agentId === "string" ? (untrusted.context as Record<string, unknown>).agentId : undefined }, activityTrace: raw.activityTrace ?? (untrusted.context as Record<string, unknown> | undefined)?.activityTrace, screenshotArtifactId: raw.screenshotArtifactId ?? reportArtifactId }); const input = parseInput({ actionType: raw.actionType ?? (untrusted.context as Record<string, unknown> | undefined)?.actionType ?? "bug", userPrompt: untrusted.userPrompt ?? untrusted.summary, contextRefs: (untrusted.context as Record<string, unknown> | undefined) && { taskId: typeof (untrusted.context as Record<string, unknown>).taskId === "string" ? (untrusted.context as Record<string, unknown>).taskId : undefined, agentId: typeof (untrusted.context as Record<string, unknown>).agentId === "string" ? (untrusted.context as Record<string, unknown>).agentId : undefined }, activityTrace: raw.activityTrace ?? (untrusted.context as Record<string, unknown> | undefined)?.activityTrace, screenshotArtifactId: raw.screenshotArtifactId ?? reportArtifactId });
// FNXC:ReportPipeline 2026-07-16-21:00: Validate target before Help can return locally. // FNXC:ReportPipeline 2026-07-16-21:00: Validate target before Help can return locally.
const targetType = parseTargetType(raw.targetType); const targetType = parseTargetType(raw.targetType);
await validateScreenshotArtifact(store, input.screenshotArtifactId); const attachment = await resolveReportScreenshot(store, input.screenshotArtifactId);
const help = await selfCheckHelpBeforePipeline(store, input); const inputWithAttachment = attachment ? { ...input, attachment } : input;
const help = await selfCheckHelpBeforePipeline(store, inputWithAttachment);
if (help?.answered) return void res.json({ kind: "help", answer: help.answer }); if (help?.answered) return void res.json({ kind: "help", answer: help.answer });
res.json(await runReportPipeline(input, { projectSettings: scopes.project, globalSettings: scopes.global, scrubContext: { rootDir: store.getRootDir(), projectName: store.getRootDir().split(/[\\/]/).pop() }, gatherContext: (reportInput) => gatherReportContext(store, reportInput, scopes.project as Record<string, unknown>) }, { file: true, 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<string, unknown>) }, { 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"); } } catch (error) { if (error instanceof ApiError) throw error; rethrowAsApiError(error, "Failed to file report"); }
}); });

View File

@@ -1,6 +1,6 @@
import { createReadStream } from "node:fs"; import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises"; import { stat } from "node:fs/promises";
import { join, resolve, sep } from "node:path"; import { join } from "node:path";
import type { import type {
TaskStore, TaskStore,
Task, Task,
@@ -56,6 +56,7 @@ import {
type ThinkingLevel, type ThinkingLevel,
} from "@fusion/core"; } from "@fusion/core";
import { GitHubClient } from "../github.js"; import { GitHubClient } from "../github.js";
import { resolveArtifactMediaPath } from "../artifact-media.js";
import { githubRateLimiter } from "../github-poll.js"; import { githubRateLimiter } from "../github-poll.js";
import { createTrackingIssueForTask } from "../github-tracking-hook.js"; import { createTrackingIssueForTask } from "../github-tracking-hook.js";
import { parseGitHubBadgeUrl } from "./register-git-github.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); 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/<file>` 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 { interface AutoSyncOutcome {
worktreePath: string | null; worktreePath: string | null;
outcome: string; outcome: string;