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
6 changes: 5 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions packages/host-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
"./settings": {
"types": "./src/trpc/router/settings/index.ts",
"default": "./src/trpc/router/settings/index.ts"
},
"./attachments": {
"types": "./src/trpc/router/attachments/index.ts",
"default": "./src/trpc/router/attachments/index.ts"
}
},
"scripts": {
Expand Down Expand Up @@ -62,6 +66,7 @@
"drizzle-orm": "0.45.2",
"hono": "^4.8.5",
"mastracode": "0.15.0-alpha.3",
"mime-types": "^3.0.2",
"node-pty": "1.1.0",
"simple-git": "^3.30.0",
"superjson": "^2.2.5",
Expand All @@ -71,6 +76,7 @@
"devDependencies": {
"@superset/typescript": "workspace:*",
"@types/better-sqlite3": "^7.6.13",
"@types/mime-types": "^3.0.1",
"@types/node": "^24.9.1",
"bun-types": "^1.3.1",
"drizzle-kit": "0.31.8",
Expand Down
220 changes: 220 additions & 0 deletions packages/host-service/src/trpc/router/attachments/attachments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import type { HostServiceContext } from "../../../types";
import { attachmentsRouter } from "./attachments";
import { MAX_ATTACHMENT_BYTES } from "./constants";
import {
getAttachmentDir,
getAttachmentFilePath,
getAttachmentsRoot,
} from "./storage";

let tempBase: string;

beforeEach(() => {
tempBase = mkdtempSync(join(tmpdir(), "superset-attachments-test-"));
process.env.HOST_MANIFEST_DIR = tempBase;
});

afterEach(() => {
rmSync(tempBase, { recursive: true, force: true });
delete process.env.HOST_MANIFEST_DIR;
});

function createCaller() {
const ctx = { isAuthenticated: true } as unknown as HostServiceContext;
return attachmentsRouter.createCaller(ctx);
}

const PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";

describe("attachmentsRouter.upload", () => {
it("writes bytes and metadata to disk under HOST_MANIFEST_DIR", async () => {
const caller = createCaller();
const result = await caller.upload({
data: { kind: "base64", data: PNG_BASE64 },
mediaType: "image/png",
originalFilename: "pixel.png",
});

expect(result.attachmentId).toMatch(/^[0-9a-f-]{36}$/);
expect(result.mediaType).toBe("image/png");
expect(result.originalFilename).toBe("pixel.png");
expect(result.sizeBytes).toBeGreaterThan(0);

const filePath = getAttachmentFilePath(result.attachmentId, "image/png");
const metaPath = join(
getAttachmentDir(result.attachmentId),
"metadata.json",
);
expect(existsSync(filePath)).toBe(true);
expect(existsSync(metaPath)).toBe(true);
expect(readFileSync(filePath)).toEqual(Buffer.from(PNG_BASE64, "base64"));

const metadata = JSON.parse(readFileSync(metaPath, "utf8"));
expect(metadata.attachmentId).toBe(result.attachmentId);
expect(metadata.mediaType).toBe("image/png");
expect(metadata.originalFilename).toBe("pixel.png");
expect(metadata.sizeBytes).toBe(result.sizeBytes);
expect(typeof metadata.createdAt).toBe("number");
});

it("uses the right extension for known MIME types", async () => {
const caller = createCaller();
const cases: Array<[string, string]> = [
["text/plain", ".txt"],
["application/pdf", ".pdf"],
["image/jpeg", ".jpg"],
["application/json", ".json"],
];
for (const [mediaType, expectedExt] of cases) {
const result = await caller.upload({
data: {
kind: "base64",
data: Buffer.from("payload").toString("base64"),
},
mediaType,
});
const filePath = getAttachmentFilePath(result.attachmentId, mediaType);
expect(filePath).toMatch(new RegExp(`${expectedExt}$`));
expect(existsSync(filePath)).toBe(true);
}
});

it("rejects unrecognized media type", async () => {
const caller = createCaller();
await expect(
caller.upload({
data: { kind: "base64", data: PNG_BASE64 },
mediaType: "application/x-totally-fake",
}),
).rejects.toThrow(/unrecognized media type/i);
});

it("accepts a single decoded byte", async () => {
const caller = createCaller();
// "AA==" is base64 of [0x00] — non-empty after decode.
await expect(
caller.upload({
data: { kind: "base64", data: "AA==" },
mediaType: "image/png",
}),
).resolves.toBeDefined();
});

it("rejects an empty input string at the schema layer", async () => {
const caller = createCaller();
await expect(
caller.upload({
// biome-ignore lint/suspicious/noExplicitAny: testing invalid input
data: { kind: "base64", data: "" } as any,
mediaType: "image/png",
}),
).rejects.toThrow();
});

it("rejects base64 that decodes to zero bytes", async () => {
const caller = createCaller();
// "=" passes z.string().min(1) but Buffer.from("=", "base64") is 0 bytes.
await expect(
caller.upload({
data: { kind: "base64", data: "=" },
mediaType: "image/png",
}),
).rejects.toThrow(/empty/i);
});

it("rejects oversized payload before decoding", async () => {
const caller = createCaller();
// A base64 string ~4/3 longer than MAX is enough — we shouldn't even
// allocate the decoded buffer. Use a fake oversized base64 string
// composed only of valid characters; we only care that it's rejected.
const oversizedBase64 = "A".repeat(
Math.ceil((MAX_ATTACHMENT_BYTES + 1) * (4 / 3)) + 4,
);
await expect(
caller.upload({
data: { kind: "base64", data: oversizedBase64 },
mediaType: "application/octet-stream",
}),
).rejects.toThrow(/exceeds/i);
});

it("assigns a unique id per upload", async () => {
const caller = createCaller();
const a = await caller.upload({
data: { kind: "base64", data: PNG_BASE64 },
mediaType: "image/png",
});
const b = await caller.upload({
data: { kind: "base64", data: PNG_BASE64 },
mediaType: "image/png",
});
expect(a.attachmentId).not.toBe(b.attachmentId);
});
});

describe("attachmentsRouter.delete", () => {
it("removes the attachment directory", async () => {
const caller = createCaller();
const uploaded = await caller.upload({
data: { kind: "base64", data: PNG_BASE64 },
mediaType: "image/png",
});
const dir = getAttachmentDir(uploaded.attachmentId);
expect(existsSync(dir)).toBe(true);

const result = await caller.delete({ attachmentId: uploaded.attachmentId });

expect(result.success).toBe(true);
expect(existsSync(dir)).toBe(false);
});

it("is idempotent for unknown id", async () => {
const caller = createCaller();
const result = await caller.delete({
attachmentId: "00000000-0000-0000-0000-000000000000",
});
expect(result.success).toBe(true);
});

it("rejects non-UUID id (path traversal guard)", async () => {
const caller = createCaller();
await expect(
caller.delete({ attachmentId: "../../etc/passwd" }),
).rejects.toThrow();
});
});

describe("getAttachmentsRoot", () => {
it("falls back to ~/.superset/host/standalone when HOST_MANIFEST_DIR is blank", () => {
const original = process.env.HOST_MANIFEST_DIR;
process.env.HOST_MANIFEST_DIR = "";
try {
const root = getAttachmentsRoot();
expect(root).toBe(
join(homedir(), ".superset", "host", "standalone", "attachments"),
);
} finally {
if (original === undefined) delete process.env.HOST_MANIFEST_DIR;
else process.env.HOST_MANIFEST_DIR = original;
}
});

it("falls back when HOST_MANIFEST_DIR is whitespace-only", () => {
const original = process.env.HOST_MANIFEST_DIR;
process.env.HOST_MANIFEST_DIR = " ";
try {
const root = getAttachmentsRoot();
expect(root).toBe(
join(homedir(), ".superset", "host", "standalone", "attachments"),
);
} finally {
if (original === undefined) delete process.env.HOST_MANIFEST_DIR;
else process.env.HOST_MANIFEST_DIR = original;
}
});
});
103 changes: 103 additions & 0 deletions packages/host-service/src/trpc/router/attachments/attachments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { randomUUID } from "node:crypto";
import { TRPCError } from "@trpc/server";
import mimeTypes from "mime-types";
import { z } from "zod";
import { protectedProcedure, router } from "../../index";
import { MAX_ATTACHMENT_BYTES } from "./constants";
import {
type AttachmentMetadata,
deleteAttachment,
writeAttachment,
} from "./storage";

const uploadInputSchema = z.object({
data: z.object({
kind: z.literal("base64"),
data: z.string().min(1),
}),
mediaType: z.string().min(1),
originalFilename: z.string().optional(),
});

/**
* Cheap size estimate from a base64 string without allocating the
* decoded buffer. Used to reject oversized uploads before Buffer.from
* spikes memory.
*/
function estimateDecodedBase64Bytes(value: string): number {
const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
return Math.floor((value.length * 3) / 4) - padding;
}

export const attachmentsRouter = router({
/**
* Upload a single attachment to per-org host storage. Returns an
* opaque `attachmentId` callers reference in agent prompts. The
* renderer never sees the on-disk path.
*/
upload: protectedProcedure.input(uploadInputSchema).mutation(({ input }) => {
if (!mimeTypes.extension(input.mediaType)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unrecognized media type: ${input.mediaType}`,
});
}

// Reject before allocating the decoded buffer so a 1GB base64
// payload doesn't spike host memory only to be rejected at the end.
if (estimateDecodedBase64Bytes(input.data.data) > MAX_ATTACHMENT_BYTES) {
throw new TRPCError({
code: "PAYLOAD_TOO_LARGE",
message: `Attachment exceeds ${MAX_ATTACHMENT_BYTES} bytes`,
});
}

// Buffer.from(..., "base64") never throws on invalid input — it
// silently drops unrecognized characters. We rely on bytes.length
// (post-decode) to catch payloads that decode to nothing.
const bytes = Buffer.from(input.data.data, "base64");
if (bytes.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Attachment is empty",
});
}

const metadata: AttachmentMetadata = {
attachmentId: randomUUID(),
mediaType: input.mediaType,
originalFilename: input.originalFilename,
sizeBytes: bytes.length,
createdAt: Date.now(),
};

// Buffer already extends Uint8Array; no need to wrap.
writeAttachment(bytes, metadata);

return {
attachmentId: metadata.attachmentId,
originalFilename: metadata.originalFilename,
mediaType: metadata.mediaType,
sizeBytes: metadata.sizeBytes,
};
}),

/**
* Delete an attachment by id. Idempotent — succeeds whether or not
* the directory still exists. Treat as cleanup; don't rely on it to
* confirm the row was present.
*/
delete: protectedProcedure
.input(z.object({ attachmentId: z.string().uuid() }))
.mutation(({ input }) => {
deleteAttachment(input.attachmentId);
return { success: true as const };
}),
});

export type AttachmentUploadResult = {
attachmentId: string;
originalFilename?: string;
mediaType: string;
sizeBytes: number;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Per-file upload cap. Sized for image/PDF/source-file attachments fed
* into coding agents. Larger blobs (e.g. video) belong in a different
* flow.
*/
export const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
3 changes: 3 additions & 0 deletions packages/host-service/src/trpc/router/attachments/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type { AttachmentUploadResult } from "./attachments";
export { attachmentsRouter } from "./attachments";
export { MAX_ATTACHMENT_BYTES } from "./constants";
Loading
Loading