Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions apps/desktop/electron/main/composer-paste.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import {
} from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { basename, extname, isAbsolute, join } from "node:path";
import type {
ComposerPasteFile,
ComposerPastedFile,
import {
isSvgAttachment,
SVG_MIME_TYPE,
type ComposerPasteFile,
type ComposerPastedFile,
} from "@pi-desktop/shared";

const SAFE_SESSION_ID = /^[A-Za-z0-9_-]+$/;
Expand All @@ -31,6 +33,7 @@ const IMAGE_EXTENSIONS = new Set([
]);

const MIME_EXTENSIONS: Record<string, string> = {
[SVG_MIME_TYPE]: ".svg",
"image/gif": ".gif",
"image/jpeg": ".jpg",
"image/png": ".png",
Expand Down Expand Up @@ -62,6 +65,7 @@ function isImageMimeType(mimeType: string): boolean {
}

function isImageFile(name: string, mimeType: string): boolean {
if (isSvgAttachment(mimeType, name)) return false;
if (isImageMimeType(mimeType)) return true;
return IMAGE_EXTENSIONS.has(extname(name).toLowerCase());
}
Expand Down Expand Up @@ -126,10 +130,13 @@ export async function saveComposerPasteFiles(
if (totalBytes > MAX_TOTAL_BYTES) {
throw new Error(`pasted files are too large (maximum ${MAX_TOTAL_BYTES} bytes)`);
}
const mimeType =
const suppliedMimeType =
typeof file.mimeType === "string" && file.mimeType.trim()
? file.mimeType.trim().toLowerCase()
: "application/octet-stream";
const mimeType = isSvgAttachment(suppliedMimeType, file.name)
? SVG_MIME_TYPE
: suppliedMimeType;
return {
bytes,
mimeType,
Expand Down
14 changes: 10 additions & 4 deletions apps/desktop/electron/main/prompt-attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { isAbsolute, join, relative, resolve } from "node:path";
import {
ErrorCodes,
formatFileInsert,
isSvgAttachment,
SVG_MIME_TYPE,
MAX_INLINE_IMAGE_BYTES,
type AgentPromptAttachment,
type MessageAttachment,
Expand Down Expand Up @@ -86,7 +88,8 @@ function canonicalPath(path: string): string | undefined {
}
}

function promptMimeType(path: string, supplied?: string): string {
function promptMimeType(path: string, supplied?: string, name?: string): string {
if (isSvgAttachment(supplied, path, name)) return SVG_MIME_TYPE;
const value = supplied?.trim().toLowerCase();
if (value) return value;
const extension = path.split(".").at(-1)?.toLowerCase() ?? "";
Expand All @@ -97,7 +100,8 @@ function isImagePromptAttachment(
attachment: AgentPromptAttachment,
path: string,
): boolean {
const mimeType = promptMimeType(path, attachment.mimeType);
const mimeType = promptMimeType(path, attachment.mimeType, attachment.name);
if (mimeType === SVG_MIME_TYPE) return false;
const extension = path.split(".").at(-1)?.toLowerCase() ?? "";
return (
attachment.kind === "image" ||
Expand Down Expand Up @@ -234,7 +238,7 @@ export async function preparePromptAttachments(
});
}
const name = attachment.name.trim() || source.absolute.split(/[\\/]/).at(-1) || "attachment";
const mimeType = promptMimeType(source.absolute, attachment.mimeType);
const mimeType = promptMimeType(source.absolute, attachment.mimeType, name);
const isImage = isImagePromptAttachment(attachment, source.absolute);
if (!isImage) {
prepared.push({
Expand All @@ -245,7 +249,9 @@ export async function preparePromptAttachments(
...(mimeType !== "application/octet-stream" ? { mimeType } : {}),
...(Number.isFinite(attachment.size) ? { size: attachment.size } : {}),
},
fallbackPath: displayPromptPath(source, projectPath),
fallbackPath: mimeType === SVG_MIME_TYPE && source.root === "attachment"
? await fallbackPathForStoredAttachment(dataRoot, sessionId, source, name)
: displayPromptPath(source, projectPath),
});
continue;
}
Expand Down
10 changes: 5 additions & 5 deletions apps/desktop/test/composer-paste-files.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ import { readMainSource } from "./helpers/main-source.mjs";

const read = (path) => readFile(new URL(path, import.meta.url), "utf8");

const [composer, api, main, attachments, saver, protocol, sidecar, picker] = await Promise.all([
const [composer, api, main, attachments, saver, protocol, history, picker] = await Promise.all([
readComposerSource(),
read("../src/lib/api.ts"),
readMainSource(),
read("../electron/main/prompt-attachments.ts"),
read("../electron/main/composer-paste.ts"),
read("../../../packages/shared/src/protocol.ts"),
read("../../../packages/agent-runtime/src/sidecar.ts"),
read("../../../packages/agent-runtime/src/attachment-history.ts"),
read("../electron/main/composer-picker.ts"),
]);

Expand Down Expand Up @@ -268,9 +268,9 @@ test("large image attachments avoid whole-file startup reads", () => {
assert.match(attachments, /const inline = supportsVision && size <= MAX_INLINE_IMAGE_BYTES/);
assert.match(attachments, /await copyFile\(source, target, fsConstants\.COPYFILE_EXCL\)/);
assert.doesNotMatch(attachments, /const bytes = readFileSync\(source\.absolute\)/);
assert.match(sidecar, /const size = \(await stat\(canonical\)\)\.size/);
assert.match(sidecar, /shouldInline && size <= MAX_INLINE_IMAGE_BYTES/);
assert.match(sidecar, /await copyFile\(source, target, fsConstants\.COPYFILE_EXCL\)/);
assert.match(history, /const size = \(await stat\(canonical\)\)\.size/);
assert.match(history, /shouldInline && size <= MAX_INLINE_IMAGE_BYTES/);
assert.match(history, /await copyFile\(source, target, fsConstants\.COPYFILE_EXCL\)/);
});

test("paste results separate display names from unique storage paths", async () => {
Expand Down
240 changes: 240 additions & 0 deletions apps/desktop/test/svg-attachments.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { isAbsolute, join, relative } from "node:path";
import test from "node:test";
import { importComposerFiles, saveComposerPasteFiles } from "../electron/main/composer-paste.ts";
import { appendPromptFallbackPaths, preparePromptAttachments } from "../electron/main/prompt-attachments.ts";

const SVG_MIME = "image/svg+xml";
const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><text y="12">SVG regression</text></svg>');
const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a9z8AAAAASUVORK5CYII=", "base64");
const sessionId = "svg-regression";

async function fixture(t) {
const root = await mkdtemp(join(tmpdir(), "pi-svg attachments-"));
t.after(() => rm(root, { recursive: true, force: true }));
const data = join(root, "data");
const project = join(root, "project");
const scratch = join(data, "scratch", sessionId);
await Promise.all([mkdir(project, { recursive: true }), mkdir(scratch, { recursive: true })]);
return { root, data, project, scratch };
}

function assertInside(root, path) {
const child = relative(root, path);
assert.ok(child && !child.startsWith("..") && !isAbsolute(child), path);
}

for (const [name, mimeType, expectedName] of [
["diagram.svg", SVG_MIME, "diagram.svg"],
["pasted-diagram", SVG_MIME, "pasted-diagram.svg"],
["", " IMAGE/SVG+XML ; charset=UTF-8 ", "pasted-file-1.svg"],
["diagram.SVG", "", "diagram.SVG"],
["diagram.svg", "application/octet-stream", "diagram.svg"],
["diagram.svg", "image/png", "diagram.svg"],
["diagram.png", SVG_MIME, "diagram.png"],
]) {
test(`SVG paste stays a readable file: ${JSON.stringify([name, mimeType])}`, async (t) => {
const { data } = await fixture(t);
const input = { name, mimeType, data: new Uint8Array(svg) };
const before = structuredClone(input);
const [saved] = await saveComposerPasteFiles(data, sessionId, [input]);
assert.equal(saved.kind, "file");
assert.equal(saved.mimeType, SVG_MIME);
assert.equal(saved.name, expectedName);
assert.deepEqual(await readFile(saved.path), svg);
const prepared = await preparePromptAttachments(data, sessionId, undefined, [saved], true);
assert.equal(prepared[0].message.kind, "file");
assert.equal(prepared[0].inlineData, undefined);
assert.equal(prepared[0].message.mimeType, SVG_MIME);
assert.ok(appendPromptFallbackPaths("Explain this diagram", prepared).includes(saved.path));
assert.deepEqual(input, before);
});
}

test("picker import and project-path drop use the same SVG fallback", async (t) => {
const { data, project } = await fixture(t);
const source = join(project, "diagram.SVG");
await writeFile(source, svg);
const [picked] = await importComposerFiles(data, sessionId, [source]);
assert.equal(picked.kind, "file");
assert.equal(picked.mimeType, SVG_MIME);
assert.deepEqual(await readFile(picked.path), svg);
const dropped = { path: source, name: "diagram.SVG", kind: "image", mimeType: "image/png" };
for (const supportsVision of [false, true]) {
const prepared = await preparePromptAttachments(data, sessionId, project, [picked, dropped], supportsVision);
assert.ok(prepared.every((item) => item.message.kind === "file" && item.inlineData === undefined));
assert.ok(prepared.every((item) => item.message.mimeType === SVG_MIME));
assert.ok(appendPromptFallbackPaths("Explain both", prepared).includes("diagram.SVG"));
}
assert.deepEqual(await readFile(source), svg);
});

test("legacy content-store SVG retries produce a session-readable file, not an image", async (t) => {
const { data, scratch } = await fixture(t);
const ref = `attachments/${createHash("sha256").update(svg).digest("hex")}`;
await mkdir(join(data, "attachments"));
await writeFile(join(data, ref), svg);
for (const metadata of [
{ name: "pasted-diagram.bin", mimeType: SVG_MIME },
{ name: "diagram.svg", mimeType: "image/png" },
]) {
const attachment = { kind: "image", path: ref, ...metadata };
const before = structuredClone(attachment);
const [prepared] = await preparePromptAttachments(data, sessionId, undefined, [attachment], true);
assert.equal(prepared.message.kind, "file");
assert.equal(prepared.message.ref, ref);
assert.equal(prepared.message.mimeType, SVG_MIME);
assert.equal(prepared.inlineData, undefined);
assertInside(scratch, prepared.fallbackPath);
assert.deepEqual(await readFile(prepared.fallbackPath), svg);
assert.deepEqual(attachment, before);
}
assert.deepEqual(await readFile(join(data, ref)), svg);
});

test("mixed SVG and PNG paste keeps PNG image bytes and SVG file references", async (t) => {
const { data } = await fixture(t);
const saved = await saveComposerPasteFiles(data, sessionId, [
{ name: "diagram.svg", mimeType: SVG_MIME, data: svg },
{ name: "pixel.png", mimeType: "image/png", data: png },
]);
const prepared = await preparePromptAttachments(data, sessionId, undefined, saved, true);
assert.deepEqual(prepared.map((item) => item.message.kind), ["file", "image"]);
assert.equal(prepared[0].inlineData, undefined);
assert.equal(prepared[1].message.mimeType, "image/png");
assert.deepEqual(Buffer.from(prepared[1].inlineData, "base64"), png);
const text = appendPromptFallbackPaths("Compare", prepared);
assert.ok(text.startsWith("Compare\n"));
assert.ok(text.includes(saved[0].path));
assert.ok(!text.includes(saved[1].path));
const nonVision = await preparePromptAttachments(data, sessionId, undefined, saved, false);
assert.ok(nonVision.every((item) => item.inlineData === undefined));
assert.ok(appendPromptFallbackPaths("Compare", nonVision).includes(saved[1].path));
});

test("SVG classification does not bypass attachment roots or mutate failed input", async (t) => {
const { data, project, root } = await fixture(t);
const outside = join(root, "outside.svg");
await writeFile(outside, svg);
const attachment = { kind: "image", path: outside, name: "outside.svg", mimeType: SVG_MIME };
const before = structuredClone(attachment);
await assert.rejects(
preparePromptAttachments(data, sessionId, project, [attachment], true),
{ errorCode: "PATH_OUTSIDE_WORKSPACE" },
);
assert.deepEqual(attachment, before);
assert.deepEqual(await readFile(outside), svg);
const link = join(project, "escape.svg");
try {
await symlink(outside, link);
} catch (error) {
if (error.code === "EPERM" || error.code === "EACCES") return;
throw error;
}
await assert.rejects(
preparePromptAttachments(data, sessionId, project, [{ ...attachment, path: link }], true),
{ errorCode: "PATH_OUTSIDE_WORKSPACE" },
);
});

// The history service is imported without booting a sidecar or using a provider.
const { hydrateAttachmentHistory } = await import("../../../packages/agent-runtime/src/attachment-history.ts");

test("restoring legacy SVG history strips image data and keeps a readable scratch fallback", async (t) => {
const { data, project, scratch } = await fixture(t);
const attachmentsDir = join(data, "attachments");
await mkdir(attachmentsDir);
const svgRef = `attachments/${createHash("sha256").update(svg).digest("hex")}`;
const pngRef = `attachments/${createHash("sha256").update(png).digest("hex")}`;
await Promise.all([writeFile(join(data, svgRef), svg), writeFile(join(data, pngRef), png)]);
const history = [{
id: "legacy-user", role: "user", content: "Explain the diagram", ts: 1,
attachments: [
{ kind: "image", name: "pasted-diagram.bin", mimeType: " IMAGE/SVG+XML ; charset=utf-8", ref: svgRef, data: svg.toString("base64") },
{ kind: "image", name: "pixel.png", mimeType: "image/png", ref: pngRef },
],
}];
const before = structuredClone(history);
const [restored] = await hydrateAttachmentHistory(history, {
scratchDir: scratch, projectPath: project, attachmentsDir, supportsVision: true,
});
const [vector, raster] = restored.attachments;
assert.equal(vector.kind, "file");
assert.equal(vector.mimeType, SVG_MIME);
assert.equal(vector.data, undefined);
assert.equal(vector.ref, svgRef);
assert.equal(raster.kind, "image");
assert.deepEqual(Buffer.from(raster.data, "base64"), png);
assert.ok(restored.content.startsWith("Explain the diagram\n"));
const fallback = restored.content.match(/@"([^"\n]+)"/)?.[1];
assert.ok(fallback);
assertInside(scratch, fallback);
assert.deepEqual(await readFile(fallback), svg);
assert.deepEqual(history, before);
assert.deepEqual(await readFile(join(data, svgRef)), svg);
});

test("history SVG filename metadata overrides stale kind and MIME", async (t) => {
const { data, scratch } = await fixture(t);
const path = join(scratch, "diagram.SVG");
await writeFile(path, svg);
for (const supportsVision of [false, true]) {
const [restored] = await hydrateAttachmentHistory([{
id: "named-svg", role: "user", content: "", ts: 1,
attachments: [{ kind: "image", name: "legacy.bin", ref: path, mimeType: "image/png", data: svg.toString("base64") }],
}], { scratchDir: scratch, attachmentsDir: join(data, "attachments"), supportsVision });
assert.equal(restored.attachments[0].kind, "file");
assert.equal(restored.attachments[0].data, undefined);
assert.ok(restored.content.includes(path));
}
});

test("missing or outside-root SVG history never retains a stale image payload", async (t) => {
const { scratch, root } = await fixture(t);
const outside = join(root, "outside.svg");
await writeFile(outside, svg);
for (const ref of [outside, join(scratch, "missing.svg"), ""]) {
const attachment = { kind: "image", name: "diagram.svg", ref, data: svg.toString("base64") };
const before = structuredClone(attachment);
const [restored] = await hydrateAttachmentHistory([{
id: "unavailable-svg", role: "user", content: "Keep this prompt", ts: 1, attachments: [attachment],
}], { scratchDir: scratch, supportsVision: true });
assert.equal(restored.attachments[0].kind, "file");
assert.equal(restored.attachments[0].data, undefined);
assert.equal(restored.content, "Keep this prompt");
assert.deepEqual(attachment, before);
}
});

test("history non-vision images and non-user messages preserve existing behavior", async (t) => {
const { scratch } = await fixture(t);
const path = join(scratch, "pixel.png");
await writeFile(path, png);
const assistant = { id: "assistant", role: "assistant", content: "Unchanged", ts: 2 };
const [restored, unchanged] = await hydrateAttachmentHistory([{
id: "user", role: "user", content: "Explain", ts: 1,
attachments: [{ kind: "image", name: "pixel.png", ref: path, mimeType: "image/png" }],
}, assistant], { scratchDir: scratch, supportsVision: false });
assert.equal(restored.attachments[0].kind, "image");
assert.equal(restored.attachments[0].data, undefined);
assert.ok(restored.content.includes(path));
assert.equal(unchanged, assistant);
});

test("existing non-SVG image metadata is not narrowed to a new provider allowlist", async (t) => {
const { data } = await fixture(t);
// These fixtures test classification/transport, not image decoding.
for (const extension of ["png", "jpeg", "gif", "webp", "avif", "bmp", "heic", "tiff"]) {
const mimeType = `image/${extension}`;
const bytes = Buffer.from(`transport fixture: ${extension}`);
const [saved] = await saveComposerPasteFiles(data, sessionId, [{ name: `fixture.${extension}`, mimeType, data: bytes }]);
assert.equal(saved.kind, "image");
const [prepared] = await preparePromptAttachments(data, sessionId, undefined, [saved], true);
assert.equal(prepared.message.kind, "image");
assert.equal(prepared.message.mimeType, mimeType);
assert.deepEqual(Buffer.from(prepared.inlineData, "base64"), bytes);
}
});
Loading
Loading