diff --git a/bun.lock b/bun.lock index f0ccfaf6843..d4cb26e7370 100644 --- a/bun.lock +++ b/bun.lock @@ -782,6 +782,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", @@ -791,6 +792,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", @@ -2955,7 +2957,7 @@ "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], - "@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="], + "@types/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], @@ -6357,6 +6359,8 @@ "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + "@mariozechner/pi-tui/@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="], + "@mariozechner/pi-tui/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], "@mastra/agent-browser/@mastra/core": ["@mastra/core@1.25.0", "", { "dependencies": { "@a2a-js/sdk": "~0.2.5", "@ai-sdk/provider-utils-v5": "npm:@ai-sdk/provider-utils@3.0.23", "@ai-sdk/provider-utils-v6": "npm:@ai-sdk/provider-utils@4.0.23", "@ai-sdk/provider-v5": "npm:@ai-sdk/provider@2.0.1", "@ai-sdk/provider-v6": "npm:@ai-sdk/provider@3.0.8", "@ai-sdk/ui-utils-v5": "npm:@ai-sdk/ui-utils@1.2.11", "@isaacs/ttlcache": "^2.1.4", "@lukeed/uuid": "^2.0.1", "@mastra/schema-compat": "1.2.8", "@modelcontextprotocol/sdk": "^1.27.1", "@sindresorhus/slugify": "^2.2.1", "@standard-schema/spec": "^1.1.0", "ajv": "^8.18.0", "chat": "^4.24.0", "dotenv": "^17.3.1", "execa": "^9.6.1", "gray-matter": "^4.0.3", "hono": "^4.12.8", "hono-openapi": "^1.3.0", "ignore": "^7.0.5", "js-tiktoken": "^1.0.21", "json-schema": "^0.4.0", "lru-cache": "^11.2.7", "p-map": "^7.0.4", "p-retry": "^7.1.1", "picomatch": "^4.0.3", "radash": "^12.1.1", "tokenx": "^1.3.0", "ws": "^8.19.0", "xxhash-wasm": "^1.1.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-4dkDXtufKWRO5Y7ic2JIgHpSSty5uYhqjiS2JfbKb3uV7rNpty8Fp5vSKC1ept08UudKAd5CcZWLNeKSP5816A=="], diff --git a/packages/host-service/package.json b/packages/host-service/package.json index b2c0a3ce223..8a5c674be3e 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -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": { @@ -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", @@ -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", diff --git a/packages/host-service/src/trpc/router/attachments/attachments.test.ts b/packages/host-service/src/trpc/router/attachments/attachments.test.ts new file mode 100644 index 00000000000..5dc6ac0cec1 --- /dev/null +++ b/packages/host-service/src/trpc/router/attachments/attachments.test.ts @@ -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; + } + }); +}); diff --git a/packages/host-service/src/trpc/router/attachments/attachments.ts b/packages/host-service/src/trpc/router/attachments/attachments.ts new file mode 100644 index 00000000000..59414349050 --- /dev/null +++ b/packages/host-service/src/trpc/router/attachments/attachments.ts @@ -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; +}; diff --git a/packages/host-service/src/trpc/router/attachments/constants.ts b/packages/host-service/src/trpc/router/attachments/constants.ts new file mode 100644 index 00000000000..b8ca70c33da --- /dev/null +++ b/packages/host-service/src/trpc/router/attachments/constants.ts @@ -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; diff --git a/packages/host-service/src/trpc/router/attachments/index.ts b/packages/host-service/src/trpc/router/attachments/index.ts new file mode 100644 index 00000000000..3d21421be7b --- /dev/null +++ b/packages/host-service/src/trpc/router/attachments/index.ts @@ -0,0 +1,3 @@ +export type { AttachmentUploadResult } from "./attachments"; +export { attachmentsRouter } from "./attachments"; +export { MAX_ATTACHMENT_BYTES } from "./constants"; diff --git a/packages/host-service/src/trpc/router/attachments/storage.ts b/packages/host-service/src/trpc/router/attachments/storage.ts new file mode 100644 index 00000000000..e351da58350 --- /dev/null +++ b/packages/host-service/src/trpc/router/attachments/storage.ts @@ -0,0 +1,91 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import mimeTypes from "mime-types"; + +export interface AttachmentMetadata { + attachmentId: string; + mediaType: string; + originalFilename?: string; + sizeBytes: number; + createdAt: number; +} + +/** + * Resolves the per-org attachment storage root. Honors + * `HOST_MANIFEST_DIR` (set by host-service-coordinator with the active + * org id baked in) so attachments live alongside that org's `host.db`. + * Falls back to `~/.superset/host/standalone` when the host service is + * run outside the desktop coordinator. + * + * Override with `baseDirOverride` in tests. + */ +export function getAttachmentsRoot(baseDirOverride?: string): string { + if (baseDirOverride) return join(baseDirOverride, "attachments"); + const envBase = process.env.HOST_MANIFEST_DIR?.trim(); + const base = + envBase && envBase.length > 0 + ? envBase + : join(homedir(), ".superset", "host", "standalone"); + return join(base, "attachments"); +} + +export function getAttachmentDir( + attachmentId: string, + baseDirOverride?: string, +): string { + return join(getAttachmentsRoot(baseDirOverride), attachmentId); +} + +export function getAttachmentFilePath( + attachmentId: string, + mediaType: string, + baseDirOverride?: string, +): string { + const ext = mimeTypes.extension(mediaType); + if (!ext) { + throw new Error(`Unsupported media type: ${mediaType}`); + } + return join( + getAttachmentDir(attachmentId, baseDirOverride), + `${attachmentId}.${ext}`, + ); +} + +export function getAttachmentMetadataPath( + attachmentId: string, + baseDirOverride?: string, +): string { + return join(getAttachmentDir(attachmentId, baseDirOverride), "metadata.json"); +} + +export function writeAttachment( + bytes: Uint8Array, + metadata: AttachmentMetadata, + baseDirOverride?: string, +): void { + const dir = getAttachmentDir(metadata.attachmentId, baseDirOverride); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + writeFileSync( + getAttachmentFilePath( + metadata.attachmentId, + metadata.mediaType, + baseDirOverride, + ), + bytes, + { mode: 0o600 }, + ); + writeFileSync( + getAttachmentMetadataPath(metadata.attachmentId, baseDirOverride), + JSON.stringify(metadata, null, 2), + { mode: 0o600 }, + ); +} + +export function deleteAttachment( + attachmentId: string, + baseDirOverride?: string, +): void { + const dir = getAttachmentDir(attachmentId, baseDirOverride); + rmSync(dir, { recursive: true, force: true }); +} diff --git a/packages/host-service/src/trpc/router/router.ts b/packages/host-service/src/trpc/router/router.ts index 6a6984d8f2c..94566b79a46 100644 --- a/packages/host-service/src/trpc/router/router.ts +++ b/packages/host-service/src/trpc/router/router.ts @@ -1,4 +1,5 @@ import { router } from "../index"; +import { attachmentsRouter } from "./attachments"; import { authRouter } from "./auth"; import { chatRouter } from "./chat"; import { cloudRouter } from "./cloud"; @@ -18,6 +19,7 @@ import { workspaceCleanupRouter } from "./workspace-cleanup"; import { workspaceCreationRouter } from "./workspace-creation"; export const appRouter = router({ + attachments: attachmentsRouter, auth: authRouter, health: healthRouter, host: hostRouter, diff --git a/plans/20260425-host-attachments-pr2.md b/plans/20260425-host-attachments-pr2.md new file mode 100644 index 00000000000..7c137c9df23 --- /dev/null +++ b/plans/20260425-host-attachments-pr2.md @@ -0,0 +1,104 @@ +# PR 2 Plan: Host Attachment Store + +## Summary + +This PR introduces host-scoped attachment storage. The renderer can upload a file once, get back an opaque `attachmentId`, and reference that id in later agent launches without re-uploading or shuttling bytes through workspace creation. + +**Scope of this PR:** the host-service `attachments.upload` / `attachments.delete` tRPC procedures and the on-disk storage layout. Nothing else. The renderer state slice that tracks uploaded ids and clears on host switch is intentionally deferred to **PR 5** ("Migrate Interactive Create UI" in `20260425-canonical-workspace-create-flow.md`) — the only consumer of that slice is the new workspace modal that lands in PR 5, so building it earlier means it sits unused for two PRs and risks shape drift. + +This PR is independent of PR 1 (host agent configs) and PR 3 (pane store registry). PR 4 (`workspace.create()`) is what eventually consumes both attachments and agent configs together. + +## Public API + +```ts +attachments.upload({ + data: { kind: "base64"; data: string }, + mediaType: string, + originalFilename?: string, +}) => { + attachmentId: string, + originalFilename?: string, + mediaType: string, + sizeBytes: number, +} + +attachments.delete({ attachmentId: string }) => { success: true } +``` + +Notes: + +- `attachmentId` is a UUID. The renderer treats it as opaque. +- `data` mirrors the existing `writeFileContentSchema` pattern in `filesystem.ts` — a tagged base64 string transported via tRPC over HTTP. Streaming/direct upload is a follow-up; the doc lists "Move attachment upload to the direct host upload flow" as future work. +- The renderer never sees the on-disk path. Path → host paths is resolved inside `workspace.create()` in PR 4. +- `delete` is idempotent (silent success on missing). Different verb semantics than `agentConfigs.remove` — attachments are typically deleted as cleanup after a failed flow, where "already gone" is the right answer. + +## On-Disk Layout + +Storage is **per-org under `HOST_MANIFEST_DIR`**, matching where `host.db` lives: + +``` +/attachments//. +/attachments//metadata.json +``` + +`HOST_MANIFEST_DIR` is set per-org by the desktop coordinator (`host-service-coordinator.ts`) and contains the active org id. Standalone host-service runs fall back to `~/.superset/host/standalone/`. + +Why per-org rather than `~/.superset/attachments/`: + +- Same isolation boundary as `host.db`. One rule for "where does this org's data live?" +- Clean GC when an org is removed: `rm -rf` of the org dir takes attachments with it. A shared root would leave orphans forever. +- Defense-in-depth if a renderer bug ever leaks an `attachmentId` across hosts. The PR2 spec already mandates client-side clear-on-host-switch (lands in PR 5), but the storage boundary is belt-and-suspenders. + +`metadata.json` shape: + +```ts +{ + attachmentId: string, + mediaType: string, + originalFilename?: string, + sizeBytes: number, + createdAt: number, // epoch ms +} +``` + +File extensions are derived from MIME type via the `mime-types` library. Any MIME the lib recognizes is accepted — there is **no hand-curated allowlist**. The original draft had one (7 types: png/jpeg/gif/webp/pdf/txt/markdown), but for a coding-agent attachment store there's no good reason to rule out JSON, CSV, SVG, source files, etc. The library handles the long tail; we just need a known extension to write. + +## Validation + +- `mediaType` must resolve to a known extension via `mimeTypes.extension(...)` — otherwise `BAD_REQUEST`. +- Decoded bytes must be non-empty — otherwise `BAD_REQUEST`. +- Decoded bytes must be ≤ `MAX_ATTACHMENT_BYTES` (25 MB) — otherwise `PAYLOAD_TOO_LARGE`. +- `attachmentId` on `delete` is `z.string().uuid()`. This blocks path-traversal attacks (`"../../etc/passwd"`) at the schema layer; in practice the auth boundary already protects us, but it's free defense-in-depth and locks the format. + +File and directory permissions: dir `0o700`, file `0o600`. User-private storage. + +## Out of Scope + +- **Renderer state slice for tracking uploaded ids + display metadata.** Moves to PR 5 with the new workspace modal. +- **Streaming/direct upload endpoint.** Listed as a follow-up in the umbrella plan; base64-over-tRPC is the v1 transport. +- **`workspace.create()` resolving `attachmentId` → host paths.** That's PR 4. +- **Listing or enumerating attachments.** Renderer tracks its own ids; server doesn't need to enumerate. +- **GC of orphaned attachments.** Not currently needed — the renderer drives lifecycle. If long-lived orphans become a problem, add a sweep based on `metadata.json.createdAt` later. +- **A migration of any existing attachment storage.** There isn't one; the v1 desktop path used IndexedDB blobs scoped to a pending workspace row. The IndexedDB path is left intact until the create flow migrates in PR 5. + +## Tests + +Backend tests run against a temp directory injected via `process.env.HOST_MANIFEST_DIR`: + +- upload writes bytes + metadata to the expected path +- correct extension chosen per MIME (txt, pdf, jpg, json — exercising the `mime-types` lookup) +- unrecognized MIME rejected with `BAD_REQUEST` +- empty payload rejected +- oversized payload rejected with `PAYLOAD_TOO_LARGE` +- unique id assigned per upload +- delete removes the directory +- delete is idempotent for unknown id +- non-UUID id on delete is rejected (path-traversal guard) + +## Follow-Ups + +- Add the renderer attachment state slice in PR 5 alongside the new workspace modal. +- Switch to a streaming/direct upload endpoint when base64-over-tRPC starts mattering for size or memory. +- Resolve `attachmentId` → host-readable path inside `workspace.create()` prompt assembly (PR 4). +- Add a periodic GC sweep if orphaned attachment dirs become a real problem. +- **Per-org storage quota.** v1 has no aggregate cap — only the 25 MB per-file limit. An authenticated user can in principle fill disk through repeated uploads. Same blast radius as v1 desktop's IndexedDB blob storage (also unbounded). Add a guard once telemetry shows real footprint creep: count `metadata.json` files or sum `sizeBytes` across the attachment dir before accepting a new upload.