From 4f4207997451ba1577f285dc8517a7698867a7a5 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 8 Jun 2026 16:42:53 +0000 Subject: [PATCH] Implement cross-project file resources Co-Authored-By: Paperclip --- .../src/types/workspace-file-resource.ts | 10 +- .../src/validators/workspace-file-resource.ts | 18 ++- server/src/__tests__/file-resources.test.ts | 143 +++++++++++++++++- server/src/routes/file-resources.ts | 121 +++++++++++++-- .../src/services/workspace-file-resources.ts | 108 +++++++++++-- 5 files changed, 368 insertions(+), 32 deletions(-) diff --git a/packages/shared/src/types/workspace-file-resource.ts b/packages/shared/src/types/workspace-file-resource.ts index 5ae97b8c7b6..7ae4e49b034 100644 --- a/packages/shared/src/types/workspace-file-resource.ts +++ b/packages/shared/src/types/workspace-file-resource.ts @@ -7,7 +7,9 @@ export type WorkspaceFileContentEncoding = "utf8" | "base64"; export interface WorkspaceFileRef { kind: "workspace_file"; - issueId: string; + issueId?: string; + projectId?: string; + projectName?: string; workspaceKind: WorkspaceFileWorkspaceKind; workspaceId: string; relativePath: string; @@ -24,6 +26,8 @@ export interface ResolvedWorkspaceResource { workspaceLabel: string; workspaceKind: WorkspaceFileWorkspaceKind; workspaceId: string; + projectId?: string | null; + projectName?: string | null; contentType?: string | null; byteSize?: number | null; previewKind: WorkspaceFilePreviewKind; @@ -52,6 +56,8 @@ export interface WorkspaceFileListItem { workspaceLabel: string; workspaceKind: WorkspaceFileWorkspaceKind; workspaceId: string; + projectId?: string | null; + projectName?: string | null; contentType?: string | null; byteSize?: number | null; modifiedAt?: string | null; @@ -72,6 +78,8 @@ export interface WorkspaceFileListResponse { workspaceLabel: string; workspaceKind: WorkspaceFileWorkspaceKind; workspaceId: string; + projectId?: string | null; + projectName?: string | null; } | null; query: { workspace: WorkspaceFileSelector; diff --git a/packages/shared/src/validators/workspace-file-resource.ts b/packages/shared/src/validators/workspace-file-resource.ts index a6407eca5ce..9bc1080953d 100644 --- a/packages/shared/src/validators/workspace-file-resource.ts +++ b/packages/shared/src/validators/workspace-file-resource.ts @@ -14,7 +14,9 @@ export const workspaceFileResourceKindSchema = z.enum(["file", "remote_resource" export const workspaceFileRefSchema = z.object({ kind: z.literal("workspace_file"), - issueId: z.string().uuid(), + issueId: z.string().uuid().optional(), + projectId: z.string().uuid().optional(), + projectName: z.string().min(1).optional(), workspaceKind: workspaceFileWorkspaceKindSchema, workspaceId: z.string().uuid(), relativePath: z.string().min(1), @@ -24,6 +26,8 @@ export const workspaceFileRefSchema = z.object({ }); export const workspaceFileResourceQuerySchema = z.object({ + projectId: z.string().uuid().optional(), + workspaceId: z.string().uuid().optional(), path: z .string() .min(1) @@ -32,9 +36,15 @@ export const workspaceFileResourceQuerySchema = z.object({ params: { code: "invalid_path" }, }), workspace: workspaceFileSelectorSchema.optional(), +}).refine((value) => Boolean(value.projectId) === Boolean(value.workspaceId), { + message: "Workspace file target requires both projectId and workspaceId", + path: ["workspaceId"], + params: { code: "invalid_target" }, }); export const workspaceFileListQuerySchema = z.object({ + projectId: z.string().uuid().optional(), + workspaceId: z.string().uuid().optional(), workspace: workspaceFileSelectorSchema.optional(), mode: workspaceFileListModeSchema.optional(), q: z @@ -49,6 +59,10 @@ export const workspaceFileListQuerySchema = z.object({ }) .optional(), limit: z.coerce.number().int().min(1).max(100).default(25), +}).refine((value) => Boolean(value.projectId) === Boolean(value.workspaceId), { + message: "Workspace file target requires both projectId and workspaceId", + path: ["workspaceId"], + params: { code: "invalid_target" }, }); export const resolvedWorkspaceResourceSchema = z.object({ @@ -59,6 +73,8 @@ export const resolvedWorkspaceResourceSchema = z.object({ workspaceLabel: z.string().min(1), workspaceKind: workspaceFileWorkspaceKindSchema, workspaceId: z.string().uuid(), + projectId: z.string().uuid().nullable().optional(), + projectName: z.string().min(1).nullable().optional(), contentType: z.string().nullable().optional(), byteSize: z.number().int().nonnegative().nullable().optional(), previewKind: workspaceFilePreviewKindSchema, diff --git a/server/src/__tests__/file-resources.test.ts b/server/src/__tests__/file-resources.test.ts index 21230721edc..c3276609070 100644 --- a/server/src/__tests__/file-resources.test.ts +++ b/server/src/__tests__/file-resources.test.ts @@ -32,21 +32,30 @@ type TestGraph = { 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, executionRoot }; + return { root, projectRoot, targetProjectRoot, executionRoot }; } async function seedGraph(db: Db, input: { projectRoot: string; + targetProjectRoot?: string; executionRoot?: string | null; projectSourceType?: string; }): Promise { @@ -56,8 +65,10 @@ async function seedGraph(db: Db, input: { 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(); @@ -73,6 +84,7 @@ async function seedGraph(db: Db, input: { ]); 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([ @@ -94,6 +106,15 @@ async function seedGraph(db: Db, input: { cwd: input.projectRoot, isPrimary: true, }, + { + id: targetProjectWorkspaceId, + companyId, + projectId: targetProjectId, + name: "Target workspace", + sourceType: "local_path", + cwd: input.targetProjectRoot ?? input.projectRoot, + isPrimary: true, + }, ]); await db.insert(issues).values([ { @@ -139,7 +160,13 @@ async function seedGraph(db: Db, input: { issueId, otherIssueId, projectId, + projectWorkspaceId, + targetProjectId, + targetProjectWorkspaceId, + otherProjectId, + otherProjectWorkspaceId, workspaceRoot: input.projectRoot, + targetWorkspaceRoot: input.targetProjectRoot ?? input.projectRoot, executionRoot: input.executionRoot ?? input.projectRoot, }; } @@ -215,6 +242,120 @@ describeEmbeddedPostgres("workspace file resources", () => { expect(resolved.capabilities.preview).toBe(true); }); + 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("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("resolves and reads video workspace files as base64 previews", async () => { const { projectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, executionRoot }); diff --git a/server/src/routes/file-resources.ts b/server/src/routes/file-resources.ts index 4abd0e6be8d..59bddb1a9c5 100644 --- a/server/src/routes/file-resources.ts +++ b/server/src/routes/file-resources.ts @@ -17,18 +17,20 @@ export type WorkspaceFileResourceService = { getIssue(issueId: string): Promise<{ companyId: string }>; list(issueId: string, input: { workspace?: "auto" | "execution" | "project" | null; + projectId?: string | null; + workspaceId?: string | null; mode?: "all" | "recent" | "changed" | null; q?: string | null; limit?: number | null; }, opts?: { issue?: Awaited> }): Promise; resolve( issueId: string, - input: { path: string; workspace?: "auto" | "execution" | "project" | null }, + input: { path: string; workspace?: "auto" | "execution" | "project" | null; projectId?: string | null; workspaceId?: string | null }, opts?: { issue?: Awaited> }, ): Promise; readContent( issueId: string, - input: { path: string; workspace?: "auto" | "execution" | "project" | null }, + input: { path: string; workspace?: "auto" | "execution" | "project" | null; projectId?: string | null; workspaceId?: string | null }, opts?: { issue?: Awaited> }, ): Promise; }; @@ -106,16 +108,20 @@ function readQuery(query: unknown) { parsed = workspaceFileResourceQuerySchema.parse(query); } catch (error) { if (error instanceof ZodError) { - const refinement = error.errors.find( - (issue) => (issue as { params?: { code?: string } }).params?.code === "invalid_path", - ); - if (refinement) throw unprocessable(refinement.message, { code: "invalid_path" }); + const refinement = error.errors.find((issue) => { + const code = (issue as { params?: { code?: string } }).params?.code; + return code === "invalid_path" || code === "invalid_target"; + }); + const code = (refinement as { params?: { code?: string } } | undefined)?.params?.code; + if (refinement) throw unprocessable(refinement.message, { code: code ?? "invalid_path" }); } throw error; } return { path: parsed.path, workspace: parsed.workspace ?? "auto", + projectId: parsed.projectId ?? null, + workspaceId: parsed.workspaceId ?? null, }; } @@ -125,16 +131,20 @@ function readListQuery(query: unknown) { parsed = workspaceFileListQuerySchema.parse(query); } catch (error) { if (error instanceof ZodError) { - const refinement = error.errors.find( - (issue) => (issue as { params?: { code?: string } }).params?.code === "invalid_query", - ); - if (refinement) throw unprocessable(refinement.message, { code: "invalid_query" }); + const refinement = error.errors.find((issue) => { + const code = (issue as { params?: { code?: string } }).params?.code; + return code === "invalid_query" || code === "invalid_target"; + }); + const code = (refinement as { params?: { code?: string } } | undefined)?.params?.code; + if (refinement) throw unprocessable(refinement.message, { code: code ?? "invalid_query" }); throw unprocessable("Workspace file list query is invalid", { code: "invalid_query" }); } throw error; } return { workspace: parsed.workspace ?? "auto", + projectId: parsed.projectId ?? null, + workspaceId: parsed.workspaceId ?? null, mode: parsed.mode ?? "all", q: parsed.q?.trim() || null, limit: parsed.limit, @@ -145,6 +155,8 @@ function activityDetails(input: { outcome: "success" | "denied" | "unavailable"; workspaceKind?: string | null; workspaceId?: string | null; + projectId?: string | null; + projectName?: string | null; displayPath?: string | null; denialReason?: string | null; byteSize?: number | null; @@ -154,6 +166,8 @@ function activityDetails(input: { outcome: input.outcome, ...(input.workspaceKind ? { workspaceKind: input.workspaceKind } : {}), ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), + ...(input.projectId ? { projectId: input.projectId } : {}), + ...(input.projectName ? { projectName: input.projectName } : {}), ...(input.displayPath ? { displayPath: input.displayPath } : {}), ...(input.denialReason ? { denialReason: input.denialReason } : {}), ...(typeof input.byteSize === "number" ? { byteSize: input.byteSize } : {}), @@ -167,6 +181,8 @@ function listActivityDetails(input: { mode: "all" | "recent" | "changed"; workspaceKind?: string | null; workspaceId?: string | null; + projectId?: string | null; + projectName?: string | null; resultCount?: number | null; scannedCount?: number | null; truncated?: boolean | null; @@ -178,6 +194,8 @@ function listActivityDetails(input: { mode: input.mode, ...(input.workspaceKind ? { workspaceKind: input.workspaceKind } : {}), ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), + ...(input.projectId ? { projectId: input.projectId } : {}), + ...(input.projectName ? { projectName: input.projectName } : {}), ...(typeof input.resultCount === "number" ? { resultCount: input.resultCount } : {}), ...(typeof input.scannedCount === "number" ? { scannedCount: input.scannedCount } : {}), ...(typeof input.truncated === "boolean" ? { truncated: input.truncated } : {}), @@ -200,10 +218,21 @@ function safeListAuditQuery(query: unknown): { return { workspace, mode }; } +function safeAuditTarget(query: unknown): { projectId: string | null; workspaceId: string | null } { + if (!query || typeof query !== "object") return { projectId: null, workspaceId: null }; + const record = query as Record; + return { + projectId: typeof record.projectId === "string" ? record.projectId : null, + workspaceId: typeof record.workspaceId === "string" ? record.workspaceId : null, + }; +} + function safeAuditDisplayPath(query: unknown): string { if (!query || typeof query !== "object") return ""; const path = (query as Record).path; - return typeof path === "string" ? path : ""; + if (typeof path !== "string") return ""; + if (/[\x00-\x1f\x7f]/.test(path)) return ""; + return path; } function denialReasonFromError(error: unknown) { @@ -231,6 +260,8 @@ export function fileResourceRoutes(db: Db, opts: { actor: ReturnType; issueId: string; displayPath: string; + projectId?: string | null; + workspaceId?: string | null; error: unknown; action?: "issue.file_resource_content_denied" | "issue.file_resource_resolve_denied"; }) { @@ -246,6 +277,8 @@ export function fileResourceRoutes(db: Db, opts: { details: activityDetails({ outcome: "denied", displayPath: input.displayPath, + projectId: input.projectId, + workspaceId: input.workspaceId, denialReason: denialReasonFromError(input.error), }), }); @@ -256,6 +289,7 @@ export function fileResourceRoutes(db: Db, opts: { actor: ReturnType; issueId: string; query: { workspace: "auto" | "execution" | "project"; mode: "all" | "recent" | "changed" }; + target?: { projectId: string | null; workspaceId: string | null }; error: unknown; }) { await logActivity(db, { @@ -271,6 +305,8 @@ export function fileResourceRoutes(db: Db, opts: { outcome: "denied", workspaceSelector: input.query.workspace, mode: input.query.mode, + projectId: input.target?.projectId ?? null, + workspaceId: input.target?.workspaceId ?? null, denialReason: denialReasonFromError(input.error), }), }); @@ -278,6 +314,7 @@ export function fileResourceRoutes(db: Db, opts: { router.get("/issues/:issueId/file-resources/list", async (req, res) => { const auditQuery = safeListAuditQuery(req.query); + const auditTarget = safeAuditTarget(req.query); try { assertBoard(req); } catch (error) { @@ -287,6 +324,7 @@ export function fileResourceRoutes(db: Db, opts: { actor: getActorInfo(req), issueId: req.params.issueId, query: auditQuery, + target: auditTarget, error, }); } @@ -302,6 +340,7 @@ export function fileResourceRoutes(db: Db, opts: { actor, issueId: req.params.issueId, query: auditQuery, + target: auditTarget, error, }); throw error; @@ -316,6 +355,7 @@ export function fileResourceRoutes(db: Db, opts: { actor, issueId: req.params.issueId, query: auditQuery, + target: auditTarget, error, }); throw error; @@ -330,6 +370,7 @@ export function fileResourceRoutes(db: Db, opts: { actor, issueId: req.params.issueId, query, + target: { projectId: query.projectId, workspaceId: query.workspaceId }, error, }); throw error; @@ -352,6 +393,8 @@ export function fileResourceRoutes(db: Db, opts: { mode: result.query.mode, workspaceKind: result.workspace?.workspaceKind ?? null, workspaceId: result.workspace?.workspaceId ?? null, + projectId: result.workspace?.projectId ?? null, + projectName: result.workspace?.projectName ?? null, resultCount: result.items.length, scannedCount: result.scannedCount, truncated: result.truncated, @@ -365,6 +408,7 @@ export function fileResourceRoutes(db: Db, opts: { actor, issueId: req.params.issueId, query, + target: { projectId: query.projectId, workspaceId: query.workspaceId }, error, }); throw error; @@ -374,6 +418,7 @@ export function fileResourceRoutes(db: Db, opts: { }); router.get("/issues/:issueId/file-resources/resolve", async (req, res) => { + const auditTarget = safeAuditTarget(req.query); try { assertBoard(req); } catch (error) { @@ -383,6 +428,8 @@ export function fileResourceRoutes(db: Db, opts: { actor: getActorInfo(req), issueId: req.params.issueId, displayPath: safeAuditDisplayPath(req.query), + projectId: auditTarget.projectId, + workspaceId: auditTarget.workspaceId, error, action: "issue.file_resource_resolve_denied", }); @@ -399,12 +446,29 @@ export function fileResourceRoutes(db: Db, opts: { actor, issueId: req.params.issueId, displayPath: safeAuditDisplayPath(req.query), + projectId: auditTarget.projectId, + workspaceId: auditTarget.workspaceId, + error, + action: "issue.file_resource_resolve_denied", + }); + throw error; + } + let query: ReturnType; + try { + query = readQuery(req.query); + } catch (error) { + await logDeniedAttempt({ + companyId: issue.companyId, + actor, + issueId: req.params.issueId, + displayPath: safeAuditDisplayPath(req.query), + projectId: auditTarget.projectId, + workspaceId: auditTarget.workspaceId, error, action: "issue.file_resource_resolve_denied", }); throw error; } - const query = readQuery(req.query); let release: (() => void) | null = null; try { release = limiter.acquire(limiterKey(issue.companyId, actor.actorId, req.params.issueId)); @@ -414,6 +478,8 @@ export function fileResourceRoutes(db: Db, opts: { actor, issueId: req.params.issueId, displayPath: query.path, + projectId: query.projectId, + workspaceId: query.workspaceId, error, action: "issue.file_resource_resolve_denied", }); @@ -434,6 +500,8 @@ export function fileResourceRoutes(db: Db, opts: { outcome: "success", workspaceKind: result.workspaceKind, workspaceId: result.workspaceId, + projectId: result.projectId ?? null, + projectName: result.projectName ?? null, displayPath: result.displayPath, byteSize: result.byteSize ?? null, contentType: result.contentType ?? null, @@ -447,6 +515,8 @@ export function fileResourceRoutes(db: Db, opts: { actor, issueId: req.params.issueId, displayPath: query.path, + projectId: query.projectId, + workspaceId: query.workspaceId, error, action: "issue.file_resource_resolve_denied", }); @@ -457,6 +527,7 @@ export function fileResourceRoutes(db: Db, opts: { }); router.get("/issues/:issueId/file-resources/content", async (req, res) => { + const auditTarget = safeAuditTarget(req.query); try { assertBoard(req); } catch (error) { @@ -466,6 +537,8 @@ export function fileResourceRoutes(db: Db, opts: { actor: getActorInfo(req), issueId: req.params.issueId, displayPath: safeAuditDisplayPath(req.query), + projectId: auditTarget.projectId, + workspaceId: auditTarget.workspaceId, error, }); } @@ -481,11 +554,27 @@ export function fileResourceRoutes(db: Db, opts: { actor, issueId: req.params.issueId, displayPath: safeAuditDisplayPath(req.query), + projectId: auditTarget.projectId, + workspaceId: auditTarget.workspaceId, + error, + }); + throw error; + } + let query: ReturnType; + try { + query = readQuery(req.query); + } catch (error) { + await logDeniedAttempt({ + companyId: issue.companyId, + actor, + issueId: req.params.issueId, + displayPath: safeAuditDisplayPath(req.query), + projectId: auditTarget.projectId, + workspaceId: auditTarget.workspaceId, error, }); throw error; } - const query = readQuery(req.query); let release: (() => void) | null = null; try { release = limiter.acquire(limiterKey(issue.companyId, actor.actorId, req.params.issueId)); @@ -495,6 +584,8 @@ export function fileResourceRoutes(db: Db, opts: { actor, issueId: req.params.issueId, displayPath: query.path, + projectId: query.projectId, + workspaceId: query.workspaceId, error, }); throw error; @@ -509,6 +600,8 @@ export function fileResourceRoutes(db: Db, opts: { actor, issueId: req.params.issueId, displayPath: query.path, + projectId: query.projectId, + workspaceId: query.workspaceId, error, }); throw error; @@ -528,6 +621,8 @@ export function fileResourceRoutes(db: Db, opts: { outcome: "success", workspaceKind: result.resource.workspaceKind, workspaceId: result.resource.workspaceId, + projectId: result.resource.projectId ?? null, + projectName: result.resource.projectName ?? null, displayPath: result.resource.displayPath, byteSize: result.resource.byteSize ?? null, contentType: result.resource.contentType ?? null, diff --git a/server/src/services/workspace-file-resources.ts b/server/src/services/workspace-file-resources.ts index e01c2d743e5..8888914bc57 100644 --- a/server/src/services/workspace-file-resources.ts +++ b/server/src/services/workspace-file-resources.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { promisify } from "node:util"; import { and, desc, eq, inArray, isNull } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { executionWorkspaces, issues, projectWorkspaces } from "@paperclipai/db"; +import { executionWorkspaces, issues, projects, projectWorkspaces } from "@paperclipai/db"; import type { ResolvedWorkspaceResource, WorkspaceFileContent, @@ -101,6 +101,8 @@ type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect; type WorkspaceCandidate = { workspaceKind: WorkspaceFileWorkspaceKind; workspaceId: string; + projectId?: string | null; + projectName?: string | null; provider: string; label: string; rootPath: string | null; @@ -119,11 +121,18 @@ type LocalResolvedFile = { type WorkspaceFileListQueryInput = { workspace?: WorkspaceFileSelector | null; + projectId?: string | null; + workspaceId?: string | null; mode?: WorkspaceFileListMode | null; q?: string | null; limit?: number | null; }; +type WorkspaceTargetInput = { + projectId?: string | null; + workspaceId?: string | null; +}; + function previewCapForKind(kind: WorkspaceFilePreviewKind) { return kind === "image" || kind === "video" || kind === "pdf" ? WORKSPACE_FILE_MEDIA_MAX_BYTES @@ -226,10 +235,12 @@ function listItemFromStat(input: { provider: input.candidate.provider, title: path.posix.basename(input.relativePath), relativePath: input.relativePath, - displayPath: input.relativePath, + displayPath: input.candidate.projectName ? `${input.candidate.projectName} / ${input.relativePath}` : input.relativePath, workspaceLabel: input.candidate.label, workspaceKind: input.candidate.workspaceKind, workspaceId: input.candidate.workspaceId, + projectId: input.candidate.projectId ?? null, + projectName: input.candidate.projectName ?? null, contentType: contentType ?? (previewKind === "text" ? "text/plain; charset=utf-8" : "application/octet-stream"), byteSize: input.stat.size, modifiedAt: input.stat.mtime.toISOString(), @@ -268,10 +279,12 @@ function remoteResource(candidate: WorkspaceCandidate, relativePath: string): Re kind: "remote_resource", provider: candidate.provider || "remote_managed", title: path.posix.basename(relativePath), - displayPath: relativePath, + displayPath: candidate.projectName ? `${candidate.projectName} / ${relativePath}` : relativePath, workspaceLabel: candidate.label, workspaceKind: candidate.workspaceKind, workspaceId: candidate.workspaceId, + projectId: candidate.projectId ?? null, + projectName: candidate.projectName ?? null, contentType: null, byteSize: null, previewKind: "unsupported", @@ -298,13 +311,18 @@ function candidateFromExecutionWorkspace(row: ExecutionWorkspaceRow): WorkspaceC }; } -function candidateFromProjectWorkspace(row: ProjectWorkspaceRow): WorkspaceCandidate { +function candidateFromProjectWorkspace( + row: ProjectWorkspaceRow, + project?: { id: string; name: string } | null, +): WorkspaceCandidate { const provider = row.sourceType === "git_worktree" ? "git_worktree" : row.sourceType === "local_path" ? "local_fs" : row.sourceType; const rootPath = row.cwd ?? null; const remote = !["local_fs", "git_worktree"].includes(provider) || !rootPath; return { workspaceKind: "project_workspace", workspaceId: row.id, + projectId: project?.id ?? row.projectId, + projectName: project?.name ?? null, provider, label: row.name, rootPath, @@ -361,10 +379,12 @@ async function statLocalCandidate(candidate: WorkspaceCandidate, normalized: Nor kind: "file", provider: candidate.provider, title: path.posix.basename(normalized.relativePath), - displayPath: normalized.relativePath, + displayPath: candidate.projectName ? `${candidate.projectName} / ${normalized.relativePath}` : normalized.relativePath, workspaceLabel: candidate.label, workspaceKind: candidate.workspaceKind, workspaceId: candidate.workspaceId, + projectId: candidate.projectId ?? null, + projectName: candidate.projectName ?? null, contentType: contentType ?? (previewKind === "text" ? "text/plain; charset=utf-8" : "application/octet-stream"), byteSize: stat.size, previewKind, @@ -413,6 +433,8 @@ function unavailableFileList(input: { workspaceLabel: input.candidate.label, workspaceKind: input.candidate.workspaceKind, workspaceId: input.candidate.workspaceId, + projectId: input.candidate.projectId ?? null, + projectName: input.candidate.projectName ?? null, } : null, query: { @@ -445,6 +467,8 @@ function availableFileList(input: { workspaceLabel: input.candidate.label, workspaceKind: input.candidate.workspaceKind, workspaceId: input.candidate.workspaceId, + projectId: input.candidate.projectId ?? null, + projectName: input.candidate.projectName ?? null, }, query: { workspace: input.selector, @@ -637,7 +661,38 @@ export function workspaceFileResourceService(db: Db) { return issue; } - async function listCandidates(issue: IssueRow, selector: WorkspaceFileSelector): Promise { + async function targetProjectWorkspaceCandidate( + issue: IssueRow, + target: WorkspaceTargetInput, + ): Promise { + const projectId = target.projectId ?? null; + const workspaceId = target.workspaceId ?? null; + if (!projectId && !workspaceId) return null; + if (!projectId || !workspaceId) { + throw unprocessable("Workspace file target requires both projectId and workspaceId", { code: "invalid_target" }); + } + + const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1); + const [workspace] = await db.select().from(projectWorkspaces).where(eq(projectWorkspaces.id, workspaceId)).limit(1); + if (!project || !workspace) throw notFound("Project workspace not found"); + if (project.companyId !== issue.companyId || workspace.companyId !== issue.companyId) { + throw new HttpError(403, "Project workspace belongs to another company", { code: "cross_company_workspace" }); + } + if (workspace.projectId !== project.id) { + throw unprocessable("Workspace does not belong to the selected project", { code: "workspace_project_mismatch" }); + } + + return candidateFromProjectWorkspace(workspace, { id: project.id, name: project.name }); + } + + async function listCandidates( + issue: IssueRow, + selector: WorkspaceFileSelector, + target: WorkspaceTargetInput = {}, + ): Promise { + const explicitTarget = await targetProjectWorkspaceCandidate(issue, target); + if (explicitTarget) return [explicitTarget]; + const candidates: WorkspaceCandidate[] = []; if ((selector === "auto" || selector === "execution") && issue.projectId) { const executionIds = [issue.executionWorkspaceId].filter((id): id is string => Boolean(id)); @@ -700,11 +755,14 @@ export function workspaceFileResourceService(db: Db) { async function resolve(issueId: string, input: { path: string; workspace?: WorkspaceFileSelector | null; + projectId?: string | null; + workspaceId?: string | null; }, opts: { issue?: IssueRow } = {}): Promise { const issue = opts.issue ?? await getIssue(issueId); const selector = input.workspace ?? "auto"; + const explicitTarget = Boolean(input.projectId || input.workspaceId); const normalized = normalizeWorkspaceRelativePath(input.path); - const candidates = await listCandidates(issue, selector); + const candidates = await listCandidates(issue, selector, input); if (candidates.length === 0) { throw unprocessable("No workspace is available for this issue", { code: "no_workspace" }); } @@ -712,13 +770,19 @@ export function workspaceFileResourceService(db: Db) { let lastNotFound: unknown = null; for (const candidate of candidates) { if (candidate.remote) { - if (selector !== "auto") return remoteResource(candidate, normalized.relativePath); + if (explicitTarget || selector !== "auto") return remoteResource(candidate, normalized.relativePath); continue; } try { return (await statLocalCandidate(candidate, normalized)).resource; } catch (error) { - if (error instanceof Error && "status" in error && (error as { status?: number }).status === 404 && selector === "auto") { + if ( + !explicitTarget && + error instanceof Error && + "status" in error && + (error as { status?: number }).status === 404 && + selector === "auto" + ) { lastNotFound = error; continue; } @@ -737,6 +801,7 @@ export function workspaceFileResourceService(db: Db) { ): Promise { const issue = opts.issue ?? await getIssue(issueId); const selector = input.workspace ?? "auto"; + const explicitTarget = Boolean(input.projectId || input.workspaceId); const mode = input.mode ?? "all"; const limit = Math.min( WORKSPACE_FILE_LIST_MAX_LIMIT, @@ -744,7 +809,7 @@ export function workspaceFileResourceService(db: Db) { ); const q = input.q?.trim() || null; const normalizedQuery = q?.toLowerCase() ?? null; - const candidates = await listCandidates(issue, selector); + const candidates = await listCandidates(issue, selector, input); if (candidates.length === 0) { return unavailableFileList({ selector, mode, q, limit, reason: "no_workspace" }); } @@ -753,7 +818,7 @@ export function workspaceFileResourceService(db: Db) { for (const candidate of candidates) { if (candidate.remote) { firstUnavailable ??= { candidate, reason: "remote_workspace" }; - if (selector !== "auto") { + if (explicitTarget || selector !== "auto") { return unavailableFileList({ selector, mode, q, limit, candidate, reason: "remote_workspace" }); } continue; @@ -764,7 +829,7 @@ export function workspaceFileResourceService(db: Db) { rootReal = await fs.realpath(candidate.rootPath!); } catch { firstUnavailable ??= { candidate, reason: "workspace_unavailable" }; - if (selector !== "auto") { + if (explicitTarget || selector !== "auto") { return unavailableFileList({ selector, mode, q, limit, candidate, reason: "workspace_unavailable" }); } continue; @@ -775,7 +840,7 @@ export function workspaceFileResourceService(db: Db) { if ("unavailableReason" in changed) { const reason = changed.unavailableReason ?? "changed_unavailable"; firstUnavailable ??= { candidate, reason }; - if (selector !== "auto") { + if (explicitTarget || selector !== "auto") { return unavailableFileList({ selector, mode, q, limit, candidate, reason }); } continue; @@ -824,11 +889,14 @@ export function workspaceFileResourceService(db: Db) { async function readContent(issueId: string, input: { path: string; workspace?: WorkspaceFileSelector | null; + projectId?: string | null; + workspaceId?: string | null; }, opts: { issue?: IssueRow } = {}): Promise { const issue = opts.issue ?? await getIssue(issueId); const selector = input.workspace ?? "auto"; + const explicitTarget = Boolean(input.projectId || input.workspaceId); const normalized = normalizeWorkspaceRelativePath(input.path); - const candidates = await listCandidates(issue, selector); + const candidates = await listCandidates(issue, selector, input); if (candidates.length === 0) { throw unprocessable("No workspace is available for this issue", { code: "no_workspace" }); } @@ -836,14 +904,22 @@ export function workspaceFileResourceService(db: Db) { let lastNotFound: unknown = null; for (const candidate of candidates) { if (candidate.remote) { - if (selector !== "auto") throw unprocessable("Remote workspaces cannot be previewed by the server", { code: "remote_workspace" }); + if (explicitTarget || selector !== "auto") { + throw unprocessable("Remote workspaces cannot be previewed by the server", { code: "remote_workspace" }); + } continue; } let resolved: LocalResolvedFile; try { resolved = await statLocalCandidate(candidate, normalized); } catch (error) { - if (error instanceof Error && "status" in error && (error as { status?: number }).status === 404 && selector === "auto") { + if ( + !explicitTarget && + error instanceof Error && + "status" in error && + (error as { status?: number }).status === 404 && + selector === "auto" + ) { lastNotFound = error; continue; }