Skip to content

Commit 01bc81b

Browse files
committed
fix(provider): guard file notes against raw base64 data
describeSource returned any non-data: string verbatim, so a raw base64 file part with no filename inlined the whole blob into the note text. Now only URL-shaped strings are used as names; everything else falls back to "file". file:// sources are emitted as filesystem paths (via fileURLToPath, href fallback on invalid URLs) since the local agent's workspace tools act on paths.
1 parent 86524fb commit 01bc81b

2 files changed

Lines changed: 70 additions & 6 deletions

File tree

src/provider/message-map.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { fileURLToPath } from "node:url";
12
import type {
23
LanguageModelV3FilePart,
34
LanguageModelV3Prompt,
@@ -119,12 +120,32 @@ function fileNote(part: LanguageModelV3FilePart): string {
119120
return `[attached file: ${name} (${part.mediaType}) — not forwarded to Cursor]`;
120121
}
121122

123+
/** Matches strings with a URL scheme followed by `://` (http, https, file, …). */
124+
const URL_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
125+
122126
function describeSource(data: string | Uint8Array | URL): string | undefined {
123-
if (data instanceof URL) return data.href;
124-
if (typeof data === "string" && !data.startsWith("data:")) return data;
127+
// file:// sources become filesystem paths: the agent runs locally with
128+
// workspace tools that operate on paths, so a path is actionable where a
129+
// file:// href is not.
130+
if (data instanceof URL)
131+
return data.protocol === "file:" ? fileUrlToPath(data) : data.href;
132+
// Only accept strings that look like a URL. AI-SDK file parts may carry
133+
// raw base64 in `data` (no `data:` prefix); returning that verbatim would
134+
// inline the entire blob into the note text.
135+
if (typeof data === "string" && URL_SCHEME.test(data))
136+
return data.startsWith("file://") ? fileUrlToPath(data) : data;
125137
return undefined;
126138
}
127139

140+
/** Convert a file:// URL to a filesystem path, falling back to the href when invalid. */
141+
function fileUrlToPath(url: string | URL): string {
142+
try {
143+
return fileURLToPath(url);
144+
} catch {
145+
return typeof url === "string" ? url : url.href;
146+
}
147+
}
148+
128149
/**
129150
* Extract only the final user turn as a Cursor message. Used when resuming a
130151
* pooled agent that already remembers the prior conversation, so we send just

test/message-map.test.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,8 @@ describe("promptToCursorMessage", () => {
220220
expect(msg.text).toContain("application/x-directory");
221221
});
222222

223-
it("falls back to the file URL when a file part has no filename", () => {
224-
const { url } = tempFile("noname.bin", Buffer.from([0, 1, 2]));
223+
it("falls back to the filesystem path when a file:// part has no filename", () => {
224+
const { path, url } = tempFile("noname.bin", Buffer.from([0, 1, 2]));
225225
const prompt: LanguageModelV3Prompt = [
226226
{
227227
role: "user",
@@ -236,10 +236,53 @@ describe("promptToCursorMessage", () => {
236236
];
237237
const msg = promptToCursorMessage(prompt);
238238
expect(msg.images).toBeUndefined();
239-
// No filename → the note identifies the file by its source URL.
240-
expect(msg.text).toContain(url);
239+
// No filename → the note identifies the file by its filesystem path
240+
// (not the file:// href) so the local agent's workspace tools can act
241+
// on it directly.
242+
expect(msg.text).toContain(path);
243+
expect(msg.text).not.toContain("file://");
241244
expect(msg.text).toContain("application/octet-stream");
242245
});
246+
247+
it("uses an http URL string as the name when a file part has no filename", () => {
248+
const prompt: LanguageModelV3Prompt = [
249+
{
250+
role: "user",
251+
content: [
252+
{
253+
type: "file",
254+
data: "https://example.com/pic.png",
255+
mediaType: "image/png",
256+
},
257+
],
258+
},
259+
];
260+
const msg = promptToCursorMessage(prompt);
261+
expect(msg.images).toBeUndefined();
262+
expect(msg.text).toContain("https://example.com/pic.png");
263+
});
264+
265+
it("never inlines raw base64 string data; falls back to a generic name", () => {
266+
const b64 = PNG_BYTES.toString("base64");
267+
const prompt: LanguageModelV3Prompt = [
268+
{
269+
role: "user",
270+
content: [
271+
{
272+
type: "file",
273+
// Raw base64 with no data: prefix and no filename — must NOT
274+
// end up verbatim in the note text.
275+
data: b64,
276+
mediaType: "image/png",
277+
},
278+
],
279+
},
280+
];
281+
const msg = promptToCursorMessage(prompt);
282+
expect(msg.images).toBeUndefined();
283+
expect(msg.text).not.toContain(b64);
284+
expect(msg.text).toContain("[attached file: file (image/png)");
285+
});
243286
});
244287

245288
describe("latestUserMessage", () => {

0 commit comments

Comments
 (0)