import express from "express"; import type { Request } from "express"; import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import request from "supertest"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { activityLog, agents, companies, createDb, executionWorkspaces, goals, issues, projects, projectWorkspaces, type Db } from "@paperclipai/db"; import { eq } from "drizzle-orm"; import { errorHandler } from "../middleware/index.js"; import { createFileResourceAvailabilityLimiter, createFileResourceLimiter, createFileResourceListLimiter, fileResourceRoutes, type WorkspaceFileResourceService, } from "../routes/file-resources.js"; import { WORKSPACE_FILE_TEXT_MAX_BYTES, workspaceFileResourceService, } from "../services/workspace-file-resources.js"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; const execFileAsync = promisify(execFile); let seedGraphSequence = 0; type TestGraph = { companyId: string; otherCompanyId: string; issueId: string; otherIssueId: string; projectId: string; projectWorkspaceId: string; targetProjectId: string; targetProjectWorkspaceId: string; otherProjectId: string; otherProjectWorkspaceId: string; workspaceRoot: string; targetWorkspaceRoot: string; executionRoot: string; }; async function makeWorkspace() { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-file-resources-")); const projectRoot = path.join(root, "project"); const targetProjectRoot = path.join(root, "target-project"); const executionRoot = path.join(root, "execution"); await fs.mkdir(projectRoot, { recursive: true }); await fs.mkdir(targetProjectRoot, { recursive: true }); await fs.mkdir(executionRoot, { recursive: true }); return { root, projectRoot, targetProjectRoot, executionRoot }; } async function seedGraph(db: Db, input: { projectRoot: string; targetProjectRoot?: string; executionRoot?: string | null; projectSourceType?: string; targetProjectSourceType?: string; }): Promise { seedGraphSequence += 1; const prefixSuffix = seedGraphSequence.toString(36).toUpperCase().padStart(4, "0"); const suffix = crypto.randomUUID().slice(0, 8); const companyId = crypto.randomUUID(); const otherCompanyId = crypto.randomUUID(); const goalId = crypto.randomUUID(); const otherGoalId = crypto.randomUUID(); const projectId = crypto.randomUUID(); const targetProjectId = crypto.randomUUID(); const otherProjectId = crypto.randomUUID(); const projectWorkspaceId = crypto.randomUUID(); const targetProjectWorkspaceId = crypto.randomUUID(); const otherProjectWorkspaceId = crypto.randomUUID(); const executionWorkspaceId = crypto.randomUUID(); const issueId = crypto.randomUUID(); const otherIssueId = crypto.randomUUID(); await db.insert(companies).values([ { id: companyId, name: `Company ${suffix}`, issuePrefix: `F${prefixSuffix}` }, { id: otherCompanyId, name: `Other ${suffix}`, issuePrefix: `G${prefixSuffix}` }, ]); await db.insert(goals).values([ { id: goalId, companyId, title: "Goal", level: "company", status: "active" }, { id: otherGoalId, companyId: otherCompanyId, title: "Other goal", level: "company", status: "active" }, ]); await db.insert(projects).values([ { id: projectId, companyId, goalId, name: "Project", status: "in_progress" }, { id: targetProjectId, companyId, goalId, name: "Target project", status: "in_progress" }, { id: otherProjectId, companyId: otherCompanyId, goalId: otherGoalId, name: "Other project", status: "in_progress" }, ]); await db.insert(projectWorkspaces).values([ { id: projectWorkspaceId, companyId, projectId, name: "Primary workspace", sourceType: input.projectSourceType ?? "local_path", cwd: input.projectRoot, isPrimary: true, }, { id: otherProjectWorkspaceId, companyId: otherCompanyId, projectId: otherProjectId, name: "Other workspace", sourceType: "local_path", cwd: input.projectRoot, isPrimary: true, }, { id: targetProjectWorkspaceId, companyId, projectId: targetProjectId, name: "Target workspace", sourceType: input.targetProjectSourceType ?? "local_path", cwd: input.targetProjectRoot ?? input.projectRoot, isPrimary: true, }, ]); await db.insert(issues).values([ { id: issueId, companyId, projectId, goalId, projectWorkspaceId, title: "Read a file", status: "todo", priority: "medium", }, { id: otherIssueId, companyId: otherCompanyId, projectId: otherProjectId, goalId: otherGoalId, projectWorkspaceId: otherProjectWorkspaceId, title: "Other issue", status: "todo", priority: "medium", }, ]); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, projectId, projectWorkspaceId, sourceIssueId: issueId, mode: "isolated_workspace", strategyType: "git_worktree", name: "Issue worktree", status: "active", cwd: input.executionRoot ?? null, providerType: input.executionRoot ? "git_worktree" : "remote_managed", providerRef: input.executionRoot ?? "remote-workspace", }); await db.update(issues).set({ executionWorkspaceId }).where(eq(issues.id, issueId)); return { companyId, otherCompanyId, issueId, otherIssueId, projectId, projectWorkspaceId, targetProjectId, targetProjectWorkspaceId, otherProjectId, otherProjectWorkspaceId, workspaceRoot: input.projectRoot, targetWorkspaceRoot: input.targetProjectRoot ?? input.projectRoot, executionRoot: input.executionRoot ?? input.projectRoot, }; } function createApp( db: Db, actor: Request["actor"], routeOpts: Parameters[1] = {}, ) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.actor = actor; next(); }); app.use("/api", fileResourceRoutes(db, routeOpts)); app.use(errorHandler); return app; } describeEmbeddedPostgres("workspace file resources", () => { let tempDb: Awaited> | null = null; let db: Db; beforeAll(async () => { tempDb = await startEmbeddedPostgresTestDatabase("paperclip-file-resources-"); db = createDb(tempDb.connectionString); }, 60_000); afterAll(async () => { await tempDb?.cleanup(); }); it("resolves and reads a project file without exposing absolute paths", async () => { const { root, projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); await fs.writeFile(path.join(projectRoot, "src", "app.ts"), "export const ok = true;\n", { encoding: "utf8" }).catch(async (error) => { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; await fs.mkdir(path.join(projectRoot, "src"), { recursive: true }); await fs.writeFile(path.join(projectRoot, "src", "app.ts"), "export const ok = true;\n", "utf8"); }); const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); const res = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/content`) .query({ workspace: "project", path: "src/app.ts" }); expect(res.status).toBe(200); expect(res.headers["x-content-type-options"]).toBe("nosniff"); expect(res.body.resource.displayPath).toBe("src/app.ts"); expect(JSON.stringify(res.body)).not.toContain(root); expect(res.body.content.data).toContain("export const ok"); }); it("lists and downloads non-previewable workspace files", async () => { const { root, projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); const relativePath = "artifacts/archive.bin"; const bytes = Buffer.from([0, 1, 2, 3, 4, 255]); await fs.mkdir(path.join(projectRoot, "artifacts"), { recursive: true }); await fs.writeFile(path.join(projectRoot, relativePath), bytes); const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); const listed = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/list`) .query({ workspace: "project", mode: "all", path: "artifacts" }); expect(listed.status).toBe(200); expect(listed.body.items).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: "file", relativePath, previewKind: "unsupported", capabilities: { preview: false, download: true, listChildren: false }, }), ])); expect(JSON.stringify(listed.body)).not.toContain(root); const downloaded = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/content`) .query({ workspace: "project", path: relativePath, download: "1" }) .buffer(true) .parse((res, callback) => { const chunks: Buffer[] = []; res.on("data", (chunk) => chunks.push(Buffer.from(chunk))); res.on("end", () => callback(null, Buffer.concat(chunks))); }); expect(downloaded.status).toBe(200); expect(downloaded.headers["content-disposition"]).toBe('attachment; filename="archive.bin"'); expect(downloaded.headers["x-content-type-options"]).toBe("nosniff"); expect(Buffer.compare(downloaded.body as Buffer, bytes)).toBe(0); }); it("falls back from an execution workspace miss to the project workspace", async () => { const { projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); await fs.writeFile(path.join(projectRoot, "README.md"), "# Project\n", "utf8"); const resolved = await workspaceFileResourceService(db).resolve(graph.issueId, { path: "README.md", workspace: "auto", }); expect(resolved.workspaceKind).toBe("project_workspace"); expect(resolved.displayPath).toBe("README.md"); expect(resolved.capabilities.preview).toBe(true); }); it("auto-discovers unhinted same-company project files when issue workspaces miss", async () => { const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); const targetPath = "docs/reference/skills.md"; await fs.mkdir(path.join(targetProjectRoot, path.dirname(targetPath)), { recursive: true }); await fs.writeFile(path.join(targetProjectRoot, targetPath), "# Skills reference\n", "utf8"); const resolved = await workspaceFileResourceService(db).resolve(graph.issueId, { path: targetPath, workspace: "auto", }); expect(resolved).toMatchObject({ workspaceKind: "project_workspace", workspaceId: graph.targetProjectWorkspaceId, projectId: graph.targetProjectId, projectName: "Target project", displayPath: `Target project / ${targetPath}`, previewKind: "text", }); const content = await workspaceFileResourceService(db).readContent(graph.issueId, { path: targetPath, workspace: "auto", }); expect(content.resource.workspaceId).toBe(graph.targetProjectWorkspaceId); expect(content.content.data).toContain("# Skills reference"); }); it("resolves explicit same-company cross-project workspace files and logs target details", async () => { const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); await fs.mkdir(path.join(targetProjectRoot, "docs"), { recursive: true }); await fs.writeFile(path.join(targetProjectRoot, "docs", "README.md"), "# Target project\n", "utf8"); const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); const res = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/content`) .query({ projectId: graph.targetProjectId, workspaceId: graph.targetProjectWorkspaceId, path: "docs/README.md", }); expect(res.status).toBe(200); expect(res.body.resource).toMatchObject({ workspaceKind: "project_workspace", workspaceId: graph.targetProjectWorkspaceId, projectId: graph.targetProjectId, projectName: "Target project", displayPath: "Target project / docs/README.md", }); expect(res.body.content.data).toContain("# Target project"); expect(JSON.stringify(res.body)).not.toContain(root); const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId)); const read = rows.find((row) => row.action === "issue.file_resource_content_read"); expect(read?.details).toMatchObject({ outcome: "success", workspaceId: graph.targetProjectWorkspaceId, projectId: graph.targetProjectId, projectName: "Target project", displayPath: "Target project / docs/README.md", }); expect(JSON.stringify(read?.details)).not.toContain(targetProjectRoot); }); it("reads explicit cross-project git_repo workspaces with a local checkout", async () => { const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot, targetProjectSourceType: "git_repo", }); const readmePath = "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/README.md"; await fs.mkdir(path.join(targetProjectRoot, path.dirname(readmePath)), { recursive: true }); await fs.writeFile(path.join(targetProjectRoot, readmePath), "# Bundled skills\n\nRendered from content project.\n", "utf8"); const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); const res = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/content`) .query({ projectId: graph.targetProjectId, workspaceId: graph.targetProjectWorkspaceId, path: readmePath, }); expect(res.status).toBe(200); expect(res.body.resource).toMatchObject({ workspaceKind: "project_workspace", workspaceId: graph.targetProjectWorkspaceId, projectId: graph.targetProjectId, projectName: "Target project", displayPath: `Target project / ${readmePath}`, provider: "git_repo", previewKind: "text", }); expect(res.body.content.encoding).toBe("utf8"); expect(res.body.content.data).toContain("# Bundled skills"); expect(JSON.stringify(res.body)).not.toContain(root); }); it("resolves and lists explicit same-company cross-project workspace folders", async () => { const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); const folderPath = "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/"; await fs.mkdir(path.join(targetProjectRoot, folderPath), { recursive: true }); await fs.writeFile(path.join(targetProjectRoot, folderPath, "README.md"), "# Bundled skills\n", "utf8"); await fs.writeFile(path.join(targetProjectRoot, folderPath, "notes.txt"), "notes\n", "utf8"); await fs.writeFile(path.join(targetProjectRoot, "outside.txt"), "not in focused folder\n", "utf8"); const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); const resolved = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/resolve`) .query({ projectId: graph.targetProjectId, workspaceId: graph.targetProjectWorkspaceId, path: folderPath, }); expect(resolved.status).toBe(200); expect(resolved.body).toMatchObject({ kind: "directory", workspaceKind: "project_workspace", workspaceId: graph.targetProjectWorkspaceId, projectId: graph.targetProjectId, projectName: "Target project", displayPath: `Target project / ${folderPath}`, capabilities: { preview: false, download: false, listChildren: true }, }); expect(JSON.stringify(resolved.body)).not.toContain(root); const listed = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/list`) .query({ projectId: graph.targetProjectId, workspaceId: graph.targetProjectWorkspaceId, path: folderPath, mode: "all", }); expect(listed.status).toBe(200); expect(listed.body.state).toBe("available"); expect(listed.body.query).toMatchObject({ path: folderPath.slice(0, -1), mode: "all" }); expect(new Set(listed.body.items.map((item: { relativePath: string }) => item.relativePath))).toEqual(new Set([ `${folderPath}README.md`, `${folderPath}notes.txt`, ])); expect(JSON.stringify(listed.body)).not.toContain(root); expect(JSON.stringify(listed.body)).not.toContain("outside.txt"); }); it("auto-discovers unhinted same-company project folders when issue workspaces miss", async () => { const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); const folderPath = "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/"; await fs.mkdir(path.join(targetProjectRoot, folderPath), { recursive: true }); await fs.writeFile(path.join(targetProjectRoot, folderPath, "README.md"), "# Bundled skills\n", "utf8"); await fs.writeFile(path.join(targetProjectRoot, folderPath, "suggestions.md"), "# Suggestions\n", "utf8"); const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); const resolved = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/resolve`) .query({ path: folderPath }); expect(resolved.status).toBe(200); expect(resolved.body).toMatchObject({ kind: "directory", workspaceId: graph.targetProjectWorkspaceId, projectId: graph.targetProjectId, projectName: "Target project", displayPath: `Target project / ${folderPath}`, }); const listed = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/list`) .query({ path: folderPath }); expect(listed.status).toBe(200); expect(listed.body.state).toBe("available"); expect(listed.body.workspace).toMatchObject({ workspaceId: graph.targetProjectWorkspaceId, projectId: graph.targetProjectId, projectName: "Target project", }); expect(new Set(listed.body.items.map((item: { relativePath: string }) => item.relativePath))).toEqual(new Set([ `${folderPath}README.md`, `${folderPath}suggestions.md`, ])); expect(JSON.stringify(listed.body)).not.toContain(root); }); it("rejects ambiguous unhinted same-company project file matches", async () => { const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); const duplicateProjectId = crypto.randomUUID(); const duplicateWorkspaceId = crypto.randomUUID(); const duplicateRoot = path.join(path.dirname(targetProjectRoot), "duplicate-target"); const targetPath = "docs/reference/skills.md"; await fs.mkdir(path.join(targetProjectRoot, path.dirname(targetPath)), { recursive: true }); await fs.writeFile(path.join(targetProjectRoot, targetPath), "# Target\n", "utf8"); await fs.mkdir(path.join(duplicateRoot, path.dirname(targetPath)), { recursive: true }); await fs.writeFile(path.join(duplicateRoot, targetPath), "# Duplicate\n", "utf8"); await db.insert(projects).values({ id: duplicateProjectId, companyId: graph.companyId, name: "Duplicate target", status: "in_progress", }); await db.insert(projectWorkspaces).values({ id: duplicateWorkspaceId, companyId: graph.companyId, projectId: duplicateProjectId, name: "Duplicate workspace", sourceType: "local_path", cwd: duplicateRoot, isPrimary: true, }); await expect(workspaceFileResourceService(db).resolve(graph.issueId, { path: targetPath, workspace: "auto", })).rejects.toMatchObject({ status: 409, details: { code: "ambiguous_workspace_path" }, }); }); it("denies explicit cross-company project workspaces", async () => { const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); const res = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/resolve`) .query({ projectId: graph.otherProjectId, workspaceId: graph.otherProjectWorkspaceId, path: "README.md", }); expect(res.status).toBe(403); expect(res.body?.details?.code).toBe("cross_company_workspace"); const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId)); const denied = rows.find((row) => row.action === "issue.file_resource_resolve_denied"); expect(denied?.details).toMatchObject({ outcome: "denied", projectId: graph.otherProjectId, workspaceId: graph.otherProjectWorkspaceId, denialReason: "cross_company_workspace", }); }); it("rejects explicit project/workspace mismatches", async () => { const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); const res = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/resolve`) .query({ projectId: graph.targetProjectId, workspaceId: graph.projectWorkspaceId, path: "README.md", }); expect(res.status).toBe(422); expect(res.body?.details?.code).toBe("workspace_project_mismatch"); }); it("blocks symlink escapes from explicit cross-project workspaces", async () => { const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); await fs.writeFile(path.join(root, "outside-target.txt"), "secret\n", "utf8"); await fs.symlink(path.join(root, "outside-target.txt"), path.join(targetProjectRoot, "escape.txt")); await expect(workspaceFileResourceService(db).readContent(graph.issueId, { projectId: graph.targetProjectId, workspaceId: graph.targetProjectWorkspaceId, path: "escape.txt", })).rejects.toMatchObject({ status: 403, details: { code: "outside_workspace" }, }); }); it("blocks symlink directory escapes from explicit cross-project workspaces", async () => { const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); const outsideDir = path.join(root, "outside-dir"); await fs.mkdir(outsideDir, { recursive: true }); await fs.writeFile(path.join(outsideDir, "secret.txt"), "secret\n", "utf8"); await fs.symlink(outsideDir, path.join(targetProjectRoot, "escape-dir")); await expect(workspaceFileResourceService(db).resolve(graph.issueId, { projectId: graph.targetProjectId, workspaceId: graph.targetProjectWorkspaceId, path: "escape-dir/", })).rejects.toMatchObject({ status: 403, details: { code: "outside_workspace" }, }); }); it("resolves and reads video workspace files as base64 previews", async () => { const { projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); await fs.writeFile(path.join(projectRoot, "demo.mp4"), Buffer.from([0, 0, 0, 24, 102, 116, 121, 112])); const resolved = await workspaceFileResourceService(db).resolve(graph.issueId, { path: "demo.mp4", workspace: "project", }); expect(resolved.previewKind).toBe("video"); expect(resolved.contentType).toBe("video/mp4"); expect(resolved.capabilities.preview).toBe(true); const content = await workspaceFileResourceService(db).readContent(graph.issueId, { path: "demo.mp4", workspace: "project", }); expect(content.resource.previewKind).toBe("video"); expect(content.content.encoding).toBe("base64"); expect(content.content.data).toBe(Buffer.from([0, 0, 0, 24, 102, 116, 121, 112]).toString("base64")); }); it("lists and searches safe file candidates from the preferred execution workspace", async () => { const { root, projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); await fs.mkdir(path.join(executionRoot, "src"), { recursive: true }); await fs.mkdir(path.join(executionRoot, "node_modules", "pkg"), { recursive: true }); await fs.writeFile(path.join(executionRoot, "src", "app.ts"), "export const ok = true;\n", "utf8"); await fs.writeFile(path.join(executionRoot, ".env"), "TOKEN=secret\n", "utf8"); await fs.writeFile(path.join(executionRoot, "node_modules", "pkg", "index.ts"), "export {}\n", "utf8"); await fs.writeFile(path.join(executionRoot, "blob.bin"), Buffer.from([0, 1, 2, 3])); await fs.writeFile(path.join(projectRoot, "src-project.ts"), "export const project = true;\n", "utf8"); await fs.writeFile(path.join(root, "outside.ts"), "secret\n", "utf8"); await fs.symlink(path.join(root, "outside.ts"), path.join(executionRoot, "escape.ts")); const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); const res = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/list`) .query({ workspace: "auto", q: "src", limit: 10 }); expect(res.status).toBe(200); expect(res.body.state).toBe("available"); expect(res.body.workspace.workspaceKind).toBe("execution_workspace"); expect(res.body.items.map((item: { displayPath: string }) => item.displayPath)).toEqual(["src/app.ts"]); expect(JSON.stringify(res.body)).not.toContain(root); expect(JSON.stringify(res.body)).not.toContain(".env"); expect(JSON.stringify(res.body)).not.toContain("node_modules"); expect(JSON.stringify(res.body)).not.toContain("escape.ts"); expect(JSON.stringify(res.body)).not.toContain("blob.bin"); }); it("enforces default and hard list/search caps", async () => { const { projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); await Promise.all( Array.from({ length: 30 }, (_, index) => fs.writeFile( path.join(projectRoot, `file-${String(index).padStart(2, "0")}.ts`), "export {}\n", "utf8", ), ), ); const tooDeepDir = path.join(projectRoot, ...Array.from({ length: 21 }, (_, index) => `deep-${index}`)); await fs.mkdir(tooDeepDir, { recursive: true }); await fs.writeFile(path.join(tooDeepDir, "too-deep.ts"), "export const hidden = true;\n", "utf8"); const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); const defaultLimit = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/list`) .query({ workspace: "project", mode: "all" }); expect(defaultLimit.status).toBe(200); expect(defaultLimit.body.query.limit).toBe(25); expect(defaultLimit.body.items).toHaveLength(25); expect(defaultLimit.body.truncated).toBe(true); const tooLargeLimit = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/list`) .query({ workspace: "project", mode: "all", limit: 101 }); expect(tooLargeLimit.status).toBe(422); expect(tooLargeLimit.body?.details?.code).toBe("invalid_query"); const tooDeep = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/list`) .query({ workspace: "project", mode: "all", q: "too-deep", limit: 100 }); expect(tooDeep.status).toBe(200); expect(tooDeep.body.items).toEqual([]); expect(tooDeep.body.truncated).toBe(true); }); it("lists one folder level at a time so deep descendants do not hide selected siblings", async () => { const { projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); await fs.mkdir(path.join(projectRoot, "docs", "deep"), { recursive: true }); await fs.writeFile(path.join(projectRoot, "docs", "selected.md"), "# Selected\n", "utf8"); await Promise.all( Array.from({ length: 130 }, (_, index) => fs.writeFile( path.join(projectRoot, "docs", "deep", `generated-${String(index).padStart(3, "0")}.md`), "# Generated\n", "utf8", ), ), ); const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); const parent = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/list`) .query({ workspace: "project", path: "docs", mode: "all", limit: 100 }); expect(parent.status).toBe(200); expect(parent.body.query).toMatchObject({ path: "docs", mode: "all", offset: 0 }); expect(parent.body.truncated).toBe(false); expect(parent.body.items.map((item: { kind: string; relativePath: string }) => `${item.kind}:${item.relativePath}`)).toEqual([ "directory:docs/deep", "file:docs/selected.md", ]); const firstDeepPage = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/list`) .query({ workspace: "project", path: "docs/deep", mode: "all", limit: 100 }); expect(firstDeepPage.status).toBe(200); expect(firstDeepPage.body.items).toHaveLength(100); expect(firstDeepPage.body.truncated).toBe(true); const secondDeepPage = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/list`) .query({ workspace: "project", path: "docs/deep", mode: "all", limit: 100, offset: 100 }); expect(secondDeepPage.status).toBe(200); expect(secondDeepPage.body.query.offset).toBe(100); expect(secondDeepPage.body.items).toHaveLength(30); expect(secondDeepPage.body.truncated).toBe(false); }); it("supports recent mode, limit caps, and list activity logging", async () => { const { projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); await fs.writeFile(path.join(projectRoot, "old.ts"), "export const old = true;\n", "utf8"); await fs.writeFile(path.join(projectRoot, "new.ts"), "export const newer = true;\n", "utf8"); const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); const res = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/list`) .query({ workspace: "project", mode: "recent", limit: 1 }); expect(res.status).toBe(200); expect(res.body.items).toHaveLength(1); expect(res.body.truncated).toBe(true); const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId)); const listRead = rows.find((row) => row.action === "issue.file_resource_list"); expect(listRead).toBeTruthy(); expect(listRead?.details).toMatchObject({ outcome: "success", workspaceSelector: "project", mode: "recent", resultCount: 1, truncated: true, }); expect(JSON.stringify(listRead?.details)).not.toContain(projectRoot); expect(JSON.stringify(listRead?.details)).not.toContain("old.ts"); expect(JSON.stringify(listRead?.details)).not.toContain("new.ts"); }); it("applies list offsets to search, recent, and changed modes", async () => { const { projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); await execFileAsync("git", ["-C", projectRoot, "init"]); await Promise.all([ fs.writeFile(path.join(projectRoot, "alpha.ts"), "export const alpha = true;\n", "utf8"), fs.writeFile(path.join(projectRoot, "beta.ts"), "export const beta = true;\n", "utf8"), fs.writeFile(path.join(projectRoot, "gamma.ts"), "export const gamma = true;\n", "utf8"), ]); const older = new Date("2026-01-01T00:00:00.000Z"); const middle = new Date("2026-01-02T00:00:00.000Z"); const newer = new Date("2026-01-03T00:00:00.000Z"); await fs.utimes(path.join(projectRoot, "alpha.ts"), older, older); await fs.utimes(path.join(projectRoot, "beta.ts"), middle, middle); await fs.utimes(path.join(projectRoot, "gamma.ts"), newer, newer); const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); const searchPage = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/list`) .query({ workspace: "project", q: ".ts", limit: 1, offset: 1 }); expect(searchPage.status).toBe(200); expect(searchPage.body.items.map((item: { relativePath: string }) => item.relativePath)).toEqual(["beta.ts"]); expect(searchPage.body.truncated).toBe(true); const recentPage = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/list`) .query({ workspace: "project", mode: "recent", limit: 1, offset: 1 }); expect(recentPage.status).toBe(200); expect(recentPage.body.items.map((item: { relativePath: string }) => item.relativePath)).toEqual(["beta.ts"]); expect(recentPage.body.truncated).toBe(true); const changedPage = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/list`) .query({ workspace: "project", mode: "changed", limit: 1, offset: 1 }); expect(changedPage.status).toBe(200); expect(changedPage.body.items.map((item: { relativePath: string }) => item.relativePath)).toEqual(["beta.ts"]); expect(changedPage.body.truncated).toBe(true); }); it("rejects overlong list searches and redacts the raw query from denial audit details", async () => { const { projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); const rawQuery = "é".repeat(65); const res = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/list`) .query({ workspace: "project", q: rawQuery }); expect(res.status).toBe(422); expect(res.body?.details?.code).toBe("invalid_query"); const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId)); const denied = rows.find((row) => row.action === "issue.file_resource_list_denied"); expect(denied?.details).toMatchObject({ outcome: "denied", workspaceSelector: "project", mode: "all", denialReason: "invalid_query", }); expect(JSON.stringify(denied?.details)).not.toContain(rawQuery); }); it("rejects control characters in the path without crashing the audit log", async () => { const { projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); const nullByte = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/content?workspace=project&path=foo%00bar.ts`); expect(nullByte.status).toBe(422); expect(nullByte.body?.details?.code).toBe("invalid_path"); const resolveNullByte = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/resolve?workspace=project&path=foo%00bar.ts`); expect(resolveNullByte.status).toBe(422); expect(resolveNullByte.body?.details?.code).toBe("invalid_path"); const otherControl = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/content?workspace=project&path=a%0Bb.ts`); expect(otherControl.status).toBe(422); expect(otherControl.body?.details?.code).toBe("invalid_path"); }); it("rejects traversal, encoded traversal, home-relative paths, backslash traversal, and double-encoding without double-decoding", async () => { const { projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); await fs.writeFile(path.join(projectRoot, "safe.txt"), "safe\n", "utf8"); const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); expect((await request(app).get(`/api/issues/${graph.issueId}/file-resources/content?workspace=project&path=..%2Fsecret.txt`)).status).toBe(403); expect((await request(app).get(`/api/issues/${graph.issueId}/file-resources/content?workspace=project&path=%2e%2e%2Fsecret.txt`)).status).toBe(403); expect((await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "project", path: "~/secret.txt" })).status).toBe(422); expect((await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "project", path: "..\\secret.txt" })).status).toBe(422); const doubleEncoded = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/content?workspace=project&path=%252e%252e%252Fsecret.txt`); expect(doubleEncoded.status).toBe(404); }); it("blocks symlink escapes and symlinks to denied sensitive files", async () => { const { root, projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); await fs.writeFile(path.join(root, "outside-secret.txt"), "secret\n", "utf8"); await fs.mkdir(path.join(projectRoot, "safe"), { recursive: true }); await fs.writeFile(path.join(projectRoot, "safe", ".env"), "TOKEN=secret\n", "utf8"); await fs.symlink(path.join(root, "outside-secret.txt"), path.join(projectRoot, "escape.txt")); await fs.symlink(path.join(projectRoot, "safe", ".env"), path.join(projectRoot, "linked-env")); const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); const escape = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/content`) .query({ workspace: "project", path: "escape.txt" }); const linkedSecret = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/content`) .query({ workspace: "project", path: "linked-env" }); expect(escape.status).toBe(403); expect(linkedSecret.status).toBe(403); }); it("rejects denied paths, non-regular files, oversized text, binary, and HTML while previewing SVG as source", async () => { const { projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); await fs.mkdir(path.join(projectRoot, ".git"), { recursive: true }); await fs.writeFile(path.join(projectRoot, ".git", "config"), "[core]\n", "utf8"); await fs.mkdir(path.join(projectRoot, "folder"), { recursive: true }); await fs.writeFile(path.join(projectRoot, "big.txt"), Buffer.alloc(WORKSPACE_FILE_TEXT_MAX_BYTES + 1, "a")); await fs.writeFile(path.join(projectRoot, "blob.bin"), Buffer.from([0, 1, 2, 3])); await fs.writeFile(path.join(projectRoot, "index.html"), "", "utf8"); await fs.writeFile(path.join(projectRoot, "icon.svg"), "\n", "utf8"); const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); for (const filePath of [".git/config", "folder", "big.txt", "blob.bin", "index.html"]) { const res = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/content`) .query({ workspace: "project", path: filePath }); expect([403, 422]).toContain(res.status); } const svg = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/content`) .query({ workspace: "project", path: "icon.svg" }); expect(svg.status).toBe(200); expect(svg.body.resource.previewKind).toBe("text"); expect(svg.body.content.data).toContain(" { const { projectRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot: null, projectSourceType: "remote_managed", }); const resolved = await workspaceFileResourceService(db).resolve(graph.issueId, { path: "README.md", workspace: "project", }); expect(resolved.kind).toBe("remote_resource"); expect(resolved.capabilities.preview).toBe(false); await expect(workspaceFileResourceService(db).readContent(graph.issueId, { path: "http://169.254.169.254/latest/meta-data/", workspace: "project", })).rejects.toMatchObject({ status: 422 }); const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); const listed = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/list`) .query({ workspace: "project" }); expect(listed.status).toBe(200); expect(listed.body.state).toBe("unavailable"); expect(listed.body.unavailableReason).toBe("remote_workspace"); expect(listed.body.items).toEqual([]); }); it("blocks agents and cross-company board users before content reads", async () => { const { projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); await fs.writeFile(path.join(projectRoot, "README.md"), "# Secret\n", "utf8"); const agentId = crypto.randomUUID(); await db.insert(agents).values({ id: agentId, companyId: graph.companyId, name: "File audit agent", role: "engineer", adapterType: "process", adapterConfig: {}, }); const agentApp = createApp(db, { type: "agent", agentId, companyId: graph.companyId, source: "agent_key", }); const boardApp = createApp(db, { type: "board", userId: "mallory", companyIds: [graph.otherCompanyId], source: "session", isInstanceAdmin: false, }); expect((await request(agentApp).get(`/api/issues/${graph.issueId}/file-resources/resolve`).query({ path: "README.md" })).status).toBe(403); expect((await request(boardApp).get(`/api/issues/${graph.issueId}/file-resources/resolve`).query({ path: "README.md" })).status).toBe(404); expect((await request(agentApp).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ path: "README.md" })).status).toBe(403); expect((await request(boardApp).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ path: "README.md" })).status).toBe(404); expect((await request(agentApp).get(`/api/issues/${graph.issueId}/file-resources/list`)).status).toBe(403); expect((await request(boardApp).get(`/api/issues/${graph.issueId}/file-resources/list`)).status).toBe(404); const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId)); const listDenials = rows.filter((row) => row.action === "issue.file_resource_list_denied"); const resolveDenials = rows.filter((row) => row.action === "issue.file_resource_resolve_denied"); const contentDenials = rows.filter((row) => row.action === "issue.file_resource_content_denied"); expect(listDenials).toHaveLength(2); expect(resolveDenials).toHaveLength(2); expect(contentDenials).toHaveLength(2); expect(JSON.stringify(listDenials.map((row) => row.details))).not.toContain("README.md"); expect(JSON.stringify(rows.map((row) => row.details))).not.toContain(projectRoot); }); it("logs successful content reads and denied security-relevant attempts", async () => { const { projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); await fs.writeFile(path.join(projectRoot, "README.md"), "# Project\n", "utf8"); await fs.writeFile(path.join(projectRoot, ".env"), "TOKEN=secret\n", "utf8"); const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "project", path: "README.md" }); await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "project", path: ".env" }); const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId)); expect(rows.some((row) => row.action === "issue.file_resource_content_read")).toBe(true); expect(rows.some((row) => row.action === "issue.file_resource_content_denied")).toBe(true); expect(JSON.stringify(rows.map((row) => row.details))).not.toContain(projectRoot); }); it("logs successful resolves and resolve/content limiter denials", async () => { const { projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); await fs.writeFile(path.join(projectRoot, "README.md"), "# Visible\n", "utf8"); const app = createApp( db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }, { limiter: createFileResourceLimiter({ maxConcurrent: 1, maxRequests: 100, windowMs: 60_000 }), }, ); const resolved = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/resolve`) .query({ workspace: "project", path: "README.md" }); expect(resolved.status).toBe(200); const rowsAfterResolve = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId)); const resolveRead = rowsAfterResolve.find((row) => row.action === "issue.file_resource_resolve"); expect(resolveRead?.details).toMatchObject({ outcome: "success", workspaceKind: "project_workspace", displayPath: "README.md", }); expect(JSON.stringify(resolveRead?.details)).not.toContain(projectRoot); let releaseSlowResolve: (() => void) | null = null; let slowResolveStarted: (() => void) | null = null; const slowResolve = new Promise((resolve) => { releaseSlowResolve = resolve; }); const resolveStarted = new Promise((resolve) => { slowResolveStarted = resolve; }); const resolveLimitedService: WorkspaceFileResourceService = { getIssue: vi.fn(async () => ({ companyId: graph.companyId })), availability: vi.fn(async () => { throw new Error("not used"); }), list: vi.fn(async () => { throw new Error("not used"); }), resolve: vi.fn(async () => { slowResolveStarted?.(); await slowResolve; return { kind: "file", provider: "local_fs", title: "README.md", displayPath: "README.md", workspaceLabel: "Workspace", workspaceKind: "project_workspace", workspaceId: "11111111-1111-4111-8111-111111111111", previewKind: "text", capabilities: { preview: true, download: true, listChildren: false }, }; }), readContent: vi.fn(async () => { throw new Error("not used"); }), prepareDownload: vi.fn(async () => { throw new Error("not used"); }), }; const resolveLimitedApp = createApp( db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }, { service: resolveLimitedService, limiter: createFileResourceLimiter({ maxConcurrent: 1, maxRequests: 100, windowMs: 60_000 }), }, ); const firstResolve = request(resolveLimitedApp) .get(`/api/issues/${graph.issueId}/file-resources/resolve`) .query({ path: "README.md" }); const firstResolveResponse = firstResolve.then((res) => res); await resolveStarted; const secondResolve = await request(resolveLimitedApp) .get(`/api/issues/${graph.issueId}/file-resources/resolve`) .query({ path: "README.md" }); expect(secondResolve.status).toBe(429); releaseSlowResolve?.(); expect((await firstResolveResponse).status).toBe(200); let releaseSlowContent: (() => void) | null = null; let slowContentStarted: (() => void) | null = null; const slowContent = new Promise((resolve) => { releaseSlowContent = resolve; }); const contentStarted = new Promise((resolve) => { slowContentStarted = resolve; }); const contentLimitedService: WorkspaceFileResourceService = { getIssue: vi.fn(async () => ({ companyId: graph.companyId })), availability: vi.fn(async () => { throw new Error("not used"); }), list: vi.fn(async () => { throw new Error("not used"); }), resolve: vi.fn(async () => { throw new Error("not used"); }), readContent: vi.fn(async () => { slowContentStarted?.(); await slowContent; return { resource: { kind: "file", provider: "local_fs", title: "README.md", displayPath: "README.md", workspaceLabel: "Workspace", workspaceKind: "project_workspace", workspaceId: "11111111-1111-4111-8111-111111111111", previewKind: "text", capabilities: { preview: true, download: true, listChildren: false }, }, content: { encoding: "utf8", data: "# Visible\n" }, }; }), prepareDownload: vi.fn(async () => { throw new Error("not used"); }), }; const contentLimitedApp = createApp( db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }, { service: contentLimitedService, limiter: createFileResourceLimiter({ maxConcurrent: 1, maxRequests: 100, windowMs: 60_000 }), }, ); const firstContent = request(contentLimitedApp) .get(`/api/issues/${graph.issueId}/file-resources/content`) .query({ path: "README.md" }); const firstContentResponse = firstContent.then((res) => res); await contentStarted; const secondContent = await request(contentLimitedApp) .get(`/api/issues/${graph.issueId}/file-resources/content`) .query({ path: "README.md" }); expect(secondContent.status).toBe(429); releaseSlowContent?.(); expect((await firstContentResponse).status).toBe(200); const rowsAfterLimits = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId)); expect(rowsAfterLimits.some((row) => row.action === "issue.file_resource_resolve_denied")).toBe(true); expect(rowsAfterLimits.some((row) => row.action === "issue.file_resource_content_denied")).toBe(true); expect(JSON.stringify(rowsAfterLimits.map((row) => row.details))).not.toContain(projectRoot); }); it("holds download concurrency slots until the file stream completes", async () => { if (process.platform === "win32") return; const { projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); const fifoPath = path.join(projectRoot, "slow-download.bin"); await execFileAsync("mkfifo", [fifoPath]); let slowDownloadStarted: (() => void) | null = null; const downloadStarted = new Promise((resolve) => { slowDownloadStarted = resolve; }); const service: WorkspaceFileResourceService = { getIssue: vi.fn(async () => ({ companyId: graph.companyId })), availability: vi.fn(async () => { throw new Error("not used"); }), list: vi.fn(async () => { throw new Error("not used"); }), resolve: vi.fn(async () => { throw new Error("not used"); }), readContent: vi.fn(async () => { throw new Error("not used"); }), prepareDownload: vi.fn(async () => { slowDownloadStarted?.(); return { resource: { kind: "file", provider: "local_fs", title: "slow-download.bin", displayPath: "slow-download.bin", workspaceLabel: "Workspace", workspaceKind: "project_workspace", workspaceId: "11111111-1111-4111-8111-111111111111", previewKind: "unsupported", contentType: "application/octet-stream", byteSize: null, capabilities: { preview: false, download: true, listChildren: false }, }, realPath: fifoPath, }; }), }; const app = createApp( db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }, { service, limiter: createFileResourceLimiter({ maxConcurrent: 1, maxRequests: 100, windowMs: 60_000 }), }, ); const firstDownload = request(app) .get(`/api/issues/${graph.issueId}/file-resources/content`) .query({ path: "slow-download.bin", download: "1" }) .buffer(true) .parse((res, callback) => { const chunks: Buffer[] = []; res.on("data", (chunk) => chunks.push(Buffer.from(chunk))); res.on("end", () => callback(null, Buffer.concat(chunks))); }); const firstDownloadResponse = firstDownload.then((res) => res); await downloadStarted; await new Promise((resolve) => setImmediate(resolve)); const secondDownload = await request(app) .get(`/api/issues/${graph.issueId}/file-resources/content`) .query({ path: "slow-download.bin", download: "1" }); expect(secondDownload.status).toBe(429); const writer = await fs.open(fifoPath, "w"); await writer.write(Buffer.from("slow")); await writer.close(); const first = await firstDownloadResponse; expect(first.status).toBe(200); expect(first.headers["content-length"]).toBeUndefined(); expect(first.headers["content-disposition"]).toBe('attachment; filename="slow-download.bin"'); expect(Buffer.compare(first.body as Buffer, Buffer.from("slow"))).toBe(0); }); it("uses tighter list-specific rate and concurrency limits", async () => { const { projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); let releaseSlowList: (() => void) | null = null; let slowListStarted: (() => void) | null = null; const slowList = new Promise((resolve) => { releaseSlowList = resolve; }); const listStarted = new Promise((resolve) => { slowListStarted = resolve; }); const service: WorkspaceFileResourceService = { getIssue: vi.fn(async () => ({ companyId: graph.companyId })), availability: vi.fn(async () => { throw new Error("not used"); }), list: vi.fn(async () => { slowListStarted?.(); await slowList; return { kind: "workspace_file_list", state: "available", workspace: { provider: "local_fs", workspaceLabel: "Workspace", workspaceKind: "project_workspace", workspaceId: "11111111-1111-4111-8111-111111111111", }, query: { workspace: "auto", mode: "all", q: null, limit: 25, }, items: [], scannedCount: 0, truncated: false, }; }), resolve: vi.fn(async () => { throw new Error("not used"); }), readContent: vi.fn(async () => { throw new Error("not used"); }), prepareDownload: vi.fn(async () => { throw new Error("not used"); }), }; const app = createApp( db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }, { service, listLimiter: createFileResourceListLimiter({ maxConcurrent: 1, maxRequests: 2, windowMs: 60_000 }), }, ); const first = request(app).get(`/api/issues/${graph.issueId}/file-resources/list`); const firstResponse = first.then((res) => res); await listStarted; const second = await request(app).get(`/api/issues/${graph.issueId}/file-resources/list`); expect(second.status).toBe(429); releaseSlowList?.(); expect((await firstResponse).status).toBe(200); const third = await request(app).get(`/api/issues/${graph.issueId}/file-resources/list`); expect(third.status).toBe(429); }); it("returns mixed deduplicated availability results with one aggregate audit event", async () => { const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot, targetProjectSourceType: "remote_managed", }); await fs.mkdir(path.join(projectRoot, "docs"), { recursive: true }); await fs.writeFile(path.join(projectRoot, "README.md"), "# Visible\n", "utf8"); await fs.writeFile(path.join(projectRoot, "docs", "guide.md"), "# Guide\n", "utf8"); await fs.writeFile(path.join(projectRoot, "archive.bin"), Buffer.from([0, 1, 2, 3])); await fs.writeFile(path.join(projectRoot, "large.txt"), Buffer.alloc(WORKSPACE_FILE_TEXT_MAX_BYTES + 1, "a")); await fs.writeFile(path.join(projectRoot, ".env"), "TOKEN=secret\n", "utf8"); await fs.writeFile(path.join(root, "outside-secret.txt"), "outside\n", "utf8"); await fs.symlink(path.join(root, "outside-secret.txt"), path.join(projectRoot, "escape.txt")); const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); const response = await request(app) .post(`/api/issues/${graph.issueId}/file-resources/availability`) .send({ queries: [ { workspace: "project", path: "README.md" }, { workspace: "project", path: "./README.md" }, { workspace: "project", path: "docs/" }, { workspace: "project", path: "archive.bin" }, { workspace: "project", path: "large.txt" }, { workspace: "project", path: ".env" }, { workspace: "project", path: "../outside-secret.txt" }, { workspace: "project", path: path.join(root, "host-secret.txt") }, { workspace: "project", path: "escape.txt" }, { workspace: "project", path: "missing.ts" }, { workspace: "project", projectId: graph.targetProjectId, workspaceId: graph.targetProjectWorkspaceId, path: "remote.txt", }, ], }); expect(response.status).toBe(200); expect(response.body.kind).toBe("workspace_file_availability"); expect(response.body.results).toHaveLength(10); const byPath = new Map(response.body.results.map((result: { query: { path: string } }) => [result.query.path, result])); expect(byPath.get("README.md")).toMatchObject({ openable: true, resource: { kind: "file" } }); expect(byPath.get("docs/")).toMatchObject({ openable: true, resource: { kind: "directory" } }); expect(byPath.get("archive.bin")).toMatchObject({ openable: false, unavailableReason: "unsupported_content" }); expect(byPath.get("large.txt")).toMatchObject({ openable: false, unavailableReason: "too_large" }); expect(byPath.get(".env")).toMatchObject({ openable: false, unavailableReason: "denied_secret", resource: null }); expect(byPath.get("../outside-secret.txt")).toMatchObject({ openable: false, unavailableReason: "outside_workspace", resource: null, }); expect(byPath.get("host-secret.txt")).toMatchObject({ openable: false, unavailableReason: "invalid_path", resource: null, }); expect(byPath.get("escape.txt")).toMatchObject({ openable: false, unavailableReason: "outside_workspace", resource: null, }); expect(byPath.get("missing.ts")).toMatchObject({ openable: false, unavailableReason: "not_found", resource: null }); expect(byPath.get("remote.txt")).toMatchObject({ openable: false, unavailableReason: "remote_workspace", resource: { kind: "remote_resource" }, }); expect(JSON.stringify(response.body)).not.toContain(root); const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId)); const availabilityRows = rows.filter((row) => row.action === "issue.file_resource_availability"); expect(availabilityRows).toHaveLength(1); expect(availabilityRows[0]?.details).toMatchObject({ outcome: "success", requestedCount: 11, uniqueCount: 10, openableCount: 2, unavailableCount: 8, }); expect(JSON.stringify(availabilityRows[0]?.details)).not.toContain(root); }); it("reports ambiguous auto-discovery as one unavailable result", async () => { const { projectRoot, targetProjectRoot, executionRoot, root } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); const extraRoot = path.join(root, "extra-project"); await fs.mkdir(extraRoot, { recursive: true }); await fs.writeFile(path.join(targetProjectRoot, "shared.md"), "target\n", "utf8"); await fs.writeFile(path.join(extraRoot, "shared.md"), "extra\n", "utf8"); const extraProjectId = crypto.randomUUID(); await db.insert(projects).values({ id: extraProjectId, companyId: graph.companyId, name: "Extra project", status: "in_progress", }); await db.insert(projectWorkspaces).values({ id: crypto.randomUUID(), companyId: graph.companyId, projectId: extraProjectId, name: "Extra workspace", sourceType: "local_path", cwd: extraRoot, isPrimary: true, }); const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); const response = await request(app) .post(`/api/issues/${graph.issueId}/file-resources/availability`) .send({ queries: [{ path: "shared.md" }] }); expect(response.status).toBe(200); expect(response.body.results).toEqual([ { query: { path: "shared.md", workspace: "auto", projectId: null, workspaceId: null }, openable: false, unavailableReason: "ambiguous_workspace_path", resource: null, }, ]); expect(JSON.stringify(response.body)).not.toContain(root); }); it("enforces board access, company boundaries, and the 100-query cap", async () => { const { projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); const agentId = crypto.randomUUID(); await db.insert(agents).values({ id: agentId, companyId: graph.companyId, name: "Availability audit agent", role: "engineer", adapterType: "process", adapterConfig: {}, }); const agentApp = createApp(db, { type: "agent", agentId, companyId: graph.companyId, source: "agent_key", }); const otherCompanyApp = createApp(db, { type: "board", userId: "other-board", companyIds: [graph.otherCompanyId], source: "session", isInstanceAdmin: false, }); const boardApp = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false, }); expect((await request(agentApp) .post(`/api/issues/${graph.issueId}/file-resources/availability`) .send({ queries: [{ path: "README.md" }] })).status).toBe(403); expect((await request(otherCompanyApp) .post(`/api/issues/${graph.issueId}/file-resources/availability`) .send({ queries: [{ path: "README.md" }] })).status).toBe(404); expect((await request(boardApp) .post(`/api/issues/${graph.issueId}/file-resources/availability`) .send({ queries: Array.from({ length: 101 }, (_, index) => ({ path: `file-${index}.ts` })) })).status).toBe(400); }); }); describeEmbeddedPostgres("file resource route guards", () => { let tempDb: Awaited> | null = null; let db: Db; beforeAll(async () => { tempDb = await startEmbeddedPostgresTestDatabase("paperclip-file-resource-guards-"); db = createDb(tempDb.connectionString); }, 60_000); afterAll(async () => { await tempDb?.cleanup(); }); it("enforces bounded rate and concurrency limits", async () => { const companyId = crypto.randomUUID(); await db.insert(companies).values({ id: companyId, name: "Rate limit company", issuePrefix: "RAT", }); let releaseSlowRead: (() => void) | null = null; let slowReadStarted: (() => void) | null = null; const slowRead = new Promise((resolve) => { releaseSlowRead = resolve; }); const readStarted = new Promise((resolve) => { slowReadStarted = resolve; }); const service: WorkspaceFileResourceService = { getIssue: vi.fn(async () => ({ companyId })), availability: vi.fn(async () => { throw new Error("not used"); }), list: vi.fn(async () => { throw new Error("not used"); }), resolve: vi.fn(async () => { slowReadStarted?.(); await slowRead; return { kind: "file", provider: "local_fs", title: "README.md", displayPath: "README.md", workspaceLabel: "Workspace", workspaceKind: "project_workspace", workspaceId: "11111111-1111-4111-8111-111111111111", previewKind: "text", capabilities: { preview: true, download: true, listChildren: false }, }; }), readContent: vi.fn(async () => { throw new Error("not used"); }), prepareDownload: vi.fn(async () => { throw new Error("not used"); }), }; const app = express(); app.use((req, _res, next) => { req.actor = { type: "board", userId: "board-user", companyIds: [companyId], source: "session", isInstanceAdmin: false, }; next(); }); app.use("/api", fileResourceRoutes(db, { service, limiter: createFileResourceLimiter({ maxConcurrent: 1, maxRequests: 2, windowMs: 60_000 }), })); app.use(errorHandler); const first = request(app).get("/api/issues/issue-1/file-resources/resolve").query({ path: "README.md" }); const firstResponse = first.then((res) => res); await readStarted; const second = await request(app).get("/api/issues/issue-1/file-resources/resolve").query({ path: "README.md" }); expect(second.status).toBe(429); releaseSlowRead?.(); expect((await firstResponse).status).toBe(200); const third = await request(app).get("/api/issues/issue-1/file-resources/resolve").query({ path: "README.md" }); expect(third.status).toBe(429); }); it("uses a batch-specific availability limiter", async () => { const companyId = crypto.randomUUID(); await db.insert(companies).values({ id: companyId, name: "Availability rate limit company", issuePrefix: "AVL", }); let releaseSlowAvailability: (() => void) | null = null; let slowAvailabilityStarted: (() => void) | null = null; const slowAvailability = new Promise((resolve) => { releaseSlowAvailability = resolve; }); const availabilityStarted = new Promise((resolve) => { slowAvailabilityStarted = resolve; }); const service: WorkspaceFileResourceService = { getIssue: vi.fn(async () => ({ companyId })), availability: vi.fn(async () => { slowAvailabilityStarted?.(); await slowAvailability; return { kind: "workspace_file_availability", results: [] }; }), list: vi.fn(async () => { throw new Error("not used"); }), resolve: vi.fn(async () => { throw new Error("not used"); }), readContent: vi.fn(async () => { throw new Error("not used"); }), prepareDownload: vi.fn(async () => { throw new Error("not used"); }), }; const app = createApp( db, { type: "board", userId: "board-user", companyIds: [companyId], source: "session", isInstanceAdmin: false, }, { service, availabilityLimiter: createFileResourceAvailabilityLimiter({ maxConcurrent: 1, maxRequests: 2, windowMs: 60_000, }), }, ); const firstRequest = request(app) .post("/api/issues/issue-availability/file-resources/availability") .send({ queries: [{ path: "README.md" }] }); const firstResponse = firstRequest.then((response) => response); await availabilityStarted; expect((await request(app) .post("/api/issues/issue-availability/file-resources/availability") .send({ queries: [{ path: "README.md" }] })).status).toBe(429); releaseSlowAvailability?.(); expect((await firstResponse).status).toBe(200); expect((await request(app) .post("/api/issues/issue-availability/file-resources/availability") .send({ queries: [{ path: "README.md" }] })).status).toBe(429); }); });