Skip to content
Open
26 changes: 26 additions & 0 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,32 @@ describe("AssetAccess", () => {
expect(yield* resolveAsset(token, "ignored.png")).toEqual({
kind: "file",
path: attachmentPath,
download: false,
});
}).pipe(Effect.provide(testLayer)),
);

it.effect("marks non-image attachment capabilities for download", () =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const attachmentId = "thread-1-00000000-0000-4000-8000-000000000002";
const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.pdf`);
yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true });
yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3]));

const result = yield* issueAssetUrl({
resource: { _tag: "attachment", attachmentId },
});
const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const separatorIndex = suffix.indexOf("/");
const token = suffix.slice(0, separatorIndex);

expect(yield* resolveAsset(token, "ignored.pdf")).toEqual({
kind: "file",
path: attachmentPath,
download: true,
});
}).pipe(Effect.provide(testLayer)),
);
Expand Down
21 changes: 17 additions & 4 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
} from "../auth/utils.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import { resolveAttachmentPathById } from "../attachmentStore.ts";
import { SAFE_IMAGE_FILE_EXTENSIONS } from "../imageMime.ts";
import * as ServerConfig from "../config.ts";
import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
Expand Down Expand Up @@ -91,7 +92,11 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema);
const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson);
const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson);

export type ResolvedAsset = { readonly kind: "file"; readonly path: string };
export type ResolvedAsset = {
readonly kind: "file";
readonly path: string;
readonly download?: boolean;
};

function decodeClaims(encodedPayload: string): AssetClaims | null {
try {
Expand Down Expand Up @@ -383,9 +388,17 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
),
Effect.orElseSucceed(() => Option.none()),
);
return Option.isSome(info) && info.value.type === "File"
? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset)
: null;
if (!Option.isSome(info) || info.value.type !== "File") {
return null;
}
// Non-image attachments are downloaded rather than rendered inline so the
// same-origin asset route never interprets uploaded content (e.g. HTML/XML).
const extension = attachmentPath.slice(attachmentPath.lastIndexOf(".")).toLowerCase();
return {
kind: "file",
path: attachmentPath,
download: !SAFE_IMAGE_FILE_EXTENSIONS.has(extension),
} satisfies ResolvedAsset;
}

if (claims.kind === "project-favicon") {
Expand Down
97 changes: 97 additions & 0 deletions apps/server/src/attachmentPrompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodePath from "node:path";

import { describe, expect, it } from "vite-plus/test";

import { appendFileAttachmentPromptText } from "./attachmentPrompt.ts";

const attachmentsDir = NodePath.join(NodePath.sep, "tmp", "t3-attachment-prompt");

describe("attachmentPrompt", () => {
it("appends a prompt line for file attachments", () => {
const result = appendFileAttachmentPromptText({
text: "hello",
attachmentsDir,
attachments: [
{
type: "file",
id: "thread-1-00000000-0000-4000-8000-000000000001",
name: "notes.txt",
mimeType: "text/plain",
sizeBytes: 2048,
},
],
});
const attachmentPath = NodePath.join(
attachmentsDir,
"thread-1-00000000-0000-4000-8000-000000000001.txt",
);
expect(result).toBe(
`hello\n\n[Attached file: ${attachmentPath} (notes.txt, text/plain, 2.0 KB). Read it from disk when needed.]`,
);
});

it("returns text unchanged when only image attachments are present", () => {
expect(
appendFileAttachmentPromptText({
text: "hello",
attachmentsDir,
attachments: [
{
type: "image",
id: "thread-1-00000000-0000-4000-8000-000000000002",
name: "screen.png",
mimeType: "image/png",
sizeBytes: 4,
},
],
}),
).toBe("hello");
});

it("returns only the prompt lines when the text is empty", () => {
const result = appendFileAttachmentPromptText({
text: "",
attachmentsDir,
attachments: [
{
type: "file",
id: "thread-1-00000000-0000-4000-8000-000000000003",
name: "data.csv",
mimeType: "text/csv",
sizeBytes: 12,
},
],
});
const attachmentPath = NodePath.join(
attachmentsDir,
"thread-1-00000000-0000-4000-8000-000000000003.csv",
);
expect(result).toBe(
`[Attached file: ${attachmentPath} (data.csv, text/csv, 12 B). Read it from disk when needed.]`,
);
});

it("strips control characters, brackets, and parentheses from attachment names", () => {
const result = appendFileAttachmentPromptText({
text: "hello",
attachmentsDir,
attachments: [
{
type: "file",
id: "thread-1-00000000-0000-4000-8000-000000000004",
name: "a(b)\u0001[c].txt",
mimeType: "text/plain",
sizeBytes: 5,
},
],
});
const attachmentPath = NodePath.join(
attachmentsDir,
"thread-1-00000000-0000-4000-8000-000000000004.txt",
);
expect(result).toBe(
`hello\n\n[Attached file: ${attachmentPath} (a b c .txt, text/plain, 5 B). Read it from disk when needed.]`,
);
});
});
55 changes: 55 additions & 0 deletions apps/server/src/attachmentPrompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { ChatAttachment } from "@t3tools/contracts";

import { resolveAttachmentPath } from "./attachmentStore.ts";

const SIZE_LABEL_UNITS = ["B", "KB", "MB"] as const;

function formatSizeLabel(sizeBytes: number): string {
let value = sizeBytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < SIZE_LABEL_UNITS.length - 1) {
value /= 1024;
unitIndex += 1;
}
const rounded = unitIndex === 0 ? String(value) : value.toFixed(1);
return `${rounded} ${SIZE_LABEL_UNITS[unitIndex]}`;
}

function sanitizePromptFileName(name: string): string {
// eslint-disable-next-line no-control-regex
return name.replace(/[\u0000-\u001f\u007f()[\]]+/gu, " ").trim();
}

/**
* Non-image attachments are delivered by reference: the file already lives on
* the server's disk, so the provider agent reads it with its own tools. Images
* keep their native content-block path and are not mentioned here.
*/
export function appendFileAttachmentPromptText(input: {
readonly text: string;
readonly attachmentsDir: string;
readonly attachments: ReadonlyArray<ChatAttachment>;
}): string {
const lines: Array<string> = [];
for (const attachment of input.attachments) {
if (attachment.type !== "file") {
continue;
}
const attachmentPath = resolveAttachmentPath({
attachmentsDir: input.attachmentsDir,
attachment,
});
if (!attachmentPath) {
continue;
}
const name = sanitizePromptFileName(attachment.name);
lines.push(
`[Attached file: ${attachmentPath} (${name}, ${attachment.mimeType}, ${formatSizeLabel(attachment.sizeBytes)}). Read it from disk when needed.]`,
);
}
if (lines.length === 0) {
return input.text;
}
const joined = lines.join("\n");
return input.text.trim().length > 0 ? `${input.text}\n\n${joined}` : joined;
}
32 changes: 32 additions & 0 deletions apps/server/src/attachmentStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as NodePath from "node:path";
import { describe, expect, it } from "vite-plus/test";

import {
attachmentRelativePath,
createAttachmentId,
parseThreadSegmentFromAttachmentId,
resolveAttachmentPathById,
Expand Down Expand Up @@ -44,6 +45,37 @@ describe("attachmentStore", () => {
expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("thread-foo");
});

it("builds file attachment relative paths with the inferred file extension", () => {
expect(
attachmentRelativePath({
type: "file",
id: "thread-1-00000000-0000-4000-8000-000000000001",
name: "report.pdf",
mimeType: "application/pdf",
sizeBytes: 5,
}),
).toBe("thread-1-00000000-0000-4000-8000-000000000001.pdf");
});

it("resolves attachment path by id for a written file attachment extension", () => {
const attachmentsDir = NodeFS.mkdtempSync(
NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"),
);
try {
const attachmentId = "thread-1-attachment";
const pdfPath = NodePath.join(attachmentsDir, `${attachmentId}.pdf`);
NodeFS.writeFileSync(pdfPath, Buffer.from("hello"));

const resolved = resolveAttachmentPathById({
attachmentsDir,
attachmentId,
});
expect(resolved).toBe(pdfPath);
} finally {
NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
}
});

it("resolves attachment path by id using the extension that exists on disk", () => {
const attachmentsDir = NodeFS.mkdtempSync(
NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"),
Expand Down
18 changes: 16 additions & 2 deletions apps/server/src/attachmentStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,16 @@ import {
normalizeAttachmentRelativePath,
resolveAttachmentRelativePath,
} from "./attachmentPaths.ts";
import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts";
import {
inferFileExtension,
inferImageExtension,
SAFE_FILE_EXTENSIONS,
SAFE_IMAGE_FILE_EXTENSIONS,
} from "./imageMime.ts";

const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"];
const ATTACHMENT_FILENAME_EXTENSIONS = [
...new Set([...SAFE_IMAGE_FILE_EXTENSIONS, ...SAFE_FILE_EXTENSIONS, ".bin"]),
];
const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80;
const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*";
const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
Expand Down Expand Up @@ -63,6 +70,13 @@ export function attachmentRelativePath(attachment: ChatAttachment): string {
});
return `${attachment.id}${extension}`;
}
case "file": {
const extension = inferFileExtension({
mimeType: attachment.mimeType,
fileName: attachment.name,
});
return `${attachment.id}${extension}`;
}
}
}

Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,11 @@ export const assetRouteLayer = HttpRouter.add(
headers: {
"Cache-Control": "private, max-age=3600",
"X-Content-Type-Options": "nosniff",
...(asset.download
? {
"Content-Disposition": `attachment; filename="${asset.path.replace(/^.*[/\\]/, "")}"`,
}
: {}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Download uses storage filename

Low Severity

Non-image asset responses set Content-Disposition with the on-disk name (&lt;attachmentId&gt;.&lt;ext&gt;). Timeline chips set download to the original attachment.name, but same-origin Content-Disposition typically wins, so downloads save under the server id instead of the user-facing filename.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0f54a4d. Configure here.

},
}).pipe(
Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),
Expand Down
37 changes: 36 additions & 1 deletion apps/server/src/imageMime.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vite-plus/test";

import { inferImageExtension, parseBase64DataUrl } from "./imageMime.ts";
import { inferFileExtension, inferImageExtension, parseBase64DataUrl } from "./imageMime.ts";

describe("imageMime", () => {
it("parses base64 data URL with mime type", () => {
Expand Down Expand Up @@ -35,4 +35,39 @@ describe("imageMime", () => {
it("does not read inherited keys from mime extension map", () => {
expect(inferImageExtension({ mimeType: "constructor" })).toBe(".bin");
});

it("infers file extension from an allowlisted file name extension", () => {
expect(
inferFileExtension({ mimeType: "application/octet-stream", fileName: "report.PDF" }),
).toBe(".pdf");
});

it("infers file extension from the last segment of a multi-dot file name", () => {
expect(inferFileExtension({ mimeType: "application/octet-stream", fileName: "a.b.csv" })).toBe(
".csv",
);
});

it("falls back to an allowlisted mime extension when the file name extension is unsafe", () => {
expect(inferFileExtension({ mimeType: "application/pdf", fileName: "notes.exe" })).toBe(".pdf");
});

it("falls back to an allowlisted mime extension when the file name has no extension", () => {
expect(inferFileExtension({ mimeType: "text/plain", fileName: "notes" })).toBe(".txt");
});

it("defaults file extension to .bin when neither file name nor mime is allowlisted", () => {
expect(inferFileExtension({ mimeType: "application/x-unknown", fileName: "payload.exe" })).toBe(
".bin",
);
expect(inferFileExtension({ mimeType: "application/x-unknown", fileName: "notes" })).toBe(
".bin",
);
});

it("defaults dotfile names without an allowlisted mime to .bin", () => {
expect(inferFileExtension({ mimeType: "application/x-unknown", fileName: ".env" })).toBe(
".bin",
);
});
});
Loading
Loading