mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Implement cross-project file resources
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
f67c02f894
commit
4f42079974
5 files changed
+368
-32
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TestGraph> {
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<ReturnType<WorkspaceFileResourceService["getIssue"]>> }): Promise<WorkspaceFileListResponse>;
|
||||
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<ReturnType<WorkspaceFileResourceService["getIssue"]>> },
|
||||
): Promise<ResolvedWorkspaceResource>;
|
||||
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<ReturnType<WorkspaceFileResourceService["getIssue"]>> },
|
||||
): Promise<WorkspaceFileContent>;
|
||||
};
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>).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<typeof getActorInfo>;
|
||||
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<typeof getActorInfo>;
|
||||
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<typeof readQuery>;
|
||||
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<typeof readQuery>;
|
||||
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,
|
||||
|
||||
@@ -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<WorkspaceCandidate[]> {
|
||||
async function targetProjectWorkspaceCandidate(
|
||||
issue: IssueRow,
|
||||
target: WorkspaceTargetInput,
|
||||
): Promise<WorkspaceCandidate | null> {
|
||||
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<WorkspaceCandidate[]> {
|
||||
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<ResolvedWorkspaceResource> {
|
||||
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<WorkspaceFileListResponse> {
|
||||
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<WorkspaceFileContent> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user