mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
Introduce company-scoped computer attachments, bounded runner and viewer ownership, persistent files and worktrees, Boat ingress, and native and legacy runtime integration behind the experimental Boat flag. Keep desktop UI and extended acceptance cases in the stacked feature branch. Co-Authored-By: Paperclip <noreply@paperclip.ing>
1853 lines
76 KiB
TypeScript
1853 lines
76 KiB
TypeScript
import * as computerModule from "../modules/computers/index.js";
|
|
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<TestGraph> {
|
|
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<typeof fileResourceRoutes>[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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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("browses a recorded computer placement and confines previews/downloads to safe relative files", async () => {
|
|
const workspace = await makeWorkspace();
|
|
const graph = await seedGraph(db, { projectRoot: workspace.projectRoot, executionRoot: workspace.executionRoot });
|
|
const environmentId = crypto.randomUUID(), placementId = crypto.randomUUID();
|
|
await db.update(executionWorkspaces).set({ providerType: "computer", cwd: "/home/user/paperclip/remote/project",
|
|
metadata: { fileAuthority: { kind: "remote-persistent", environmentId, placementId } } })
|
|
.where(eq(executionWorkspaces.sourceIssueId, graph.issueId));
|
|
const bytes = Buffer.from("only on Boat");
|
|
const readBytes = vi.fn(async () => ({ bytes, sha256: "unused-by-preview" }));
|
|
const listPage = vi.fn(async () => ({ entries: [{ name: "README.md", kind: "file", size: bytes.length, mtimeMs: 1 },
|
|
{ name: ".env", kind: "file", size: 1, mtimeMs: 1 }, { name: ".paperclip-runtime", kind: "directory", size: 0, mtimeMs: 1 }], truncated: true }));
|
|
const stat = vi.fn(async (name: string) => ({ name, kind: "file", size: bytes.length, mtimeMs: 1 }));
|
|
const workspaceFiles = vi.fn(async () => ({ root: "/home/user/paperclip/remote/project", listPage, stat, readBytes }));
|
|
const factory = vi.spyOn(computerModule, "computerService").mockReturnValue({ workspaceFiles } as never);
|
|
try {
|
|
const service = workspaceFileResourceService(db);
|
|
const listed = await service.list(graph.issueId, { workspace: "execution" });
|
|
expect(listed.items.map(item => item.title)).toEqual(["README.md"]);
|
|
expect(listed.truncated).toBe(true);
|
|
expect(listPage).toHaveBeenCalledWith(undefined, { limit: 1000 });
|
|
expect(workspaceFiles).toHaveBeenCalledWith({ companyId: graph.companyId, environmentId, placementId });
|
|
const content = await service.readContent(graph.issueId, { path: "README.md", workspace: "execution" });
|
|
expect(content.content).toEqual({ encoding: "utf8", data: "only on Boat" });
|
|
expect(readBytes).toHaveBeenCalledWith("README.md", WORKSPACE_FILE_TEXT_MAX_BYTES);
|
|
await service.readContent(graph.issueId, { path: "beyond-listing-cap.md", workspace: "execution" });
|
|
expect(stat).toHaveBeenCalledWith("beyond-listing-cap.md");
|
|
expect(listPage).toHaveBeenCalledOnce();
|
|
expect(await service.prepareDownload(graph.issueId, { path: "README.md", workspace: "execution" })).toMatchObject({ bytes });
|
|
readBytes.mockClear();
|
|
await expect(service.readContent(graph.issueId, { path: ".env", workspace: "execution" })).rejects.toMatchObject({ status: 403 });
|
|
await expect(service.readContent(graph.issueId, { path: "../other-company/secret", workspace: "execution" })).rejects.toMatchObject({ status: 403 });
|
|
expect(readBytes).not.toHaveBeenCalled();
|
|
workspaceFiles.mockClear();
|
|
const readOnly = workspaceFileResourceService(db, undefined, { allowComputerAccess: false });
|
|
await expect(readOnly.prepareDownload(graph.issueId, { path: "README.md", workspace: "execution" })).rejects.toMatchObject({ status: 422, details: { code: "remote_workspace" } });
|
|
expect(workspaceFiles).not.toHaveBeenCalled();
|
|
} finally { factory.mockRestore(); await fs.rm(workspace.root, { recursive: true, force: true }); }
|
|
});
|
|
|
|
it("withholds private task files and private cross-project targets", async () => {
|
|
const { projectRoot, executionRoot, targetProjectRoot } = await makeWorkspace();
|
|
const graph = await seedGraph(db, { projectRoot, executionRoot, targetProjectRoot });
|
|
await fs.writeFile(path.join(projectRoot, "secret.txt"), "PRIVATE_FILE", "utf8");
|
|
await fs.writeFile(path.join(targetProjectRoot, "secret.txt"), "PRIVATE_TARGET", "utf8");
|
|
const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false });
|
|
await db.update(issues).set({ visibility: "private", responsibleUserId: "owner" }).where(eq(issues.id, graph.issueId));
|
|
await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "project", path: "secret.txt" }).expect(404);
|
|
await db.update(issues).set({ visibility: "open" }).where(eq(issues.id, graph.issueId));
|
|
await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "project", path: "secret.txt" }).expect(200);
|
|
await db.update(projects).set({ visibility: "private" }).where(eq(projects.id, graph.targetProjectId));
|
|
await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ projectId: graph.targetProjectId, workspaceId: graph.targetProjectWorkspaceId, path: "secret.txt" }).expect(404);
|
|
});
|
|
|
|
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 and binary while returning HTML and SVG as text", 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"), "<script>alert(1)</script>", "utf8");
|
|
await fs.writeFile(path.join(projectRoot, "icon.svg"), "<svg></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"]) {
|
|
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("<svg");
|
|
const html = await request(app)
|
|
.get(`/api/issues/${graph.issueId}/file-resources/content`)
|
|
.query({ workspace: "project", path: "index.html" });
|
|
expect(html.status).toBe(200);
|
|
expect(html.headers["content-type"]).toContain("application/json");
|
|
expect(html.headers["x-content-type-options"]).toBe("nosniff");
|
|
expect(html.body.resource).toMatchObject({ contentType: "text/html", previewKind: "text", capabilities: { preview: true } });
|
|
expect(html.body.content).toEqual({ encoding: "utf8", data: "<script>alert(1)</script>" });
|
|
const downloaded = await request(app)
|
|
.get(`/api/issues/${graph.issueId}/file-resources/content`)
|
|
.query({ workspace: "project", path: "index.html", download: "1" });
|
|
expect(downloaded.status).toBe(200);
|
|
expect(downloaded.headers["content-disposition"]).toBe('attachment; filename="index.html"');
|
|
});
|
|
|
|
it("rejects remote workspaces without fetching provider resources", async () => {
|
|
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<void>((resolve) => {
|
|
releaseSlowResolve = resolve;
|
|
});
|
|
const resolveStarted = new Promise<void>((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<void>((resolve) => {
|
|
releaseSlowContent = resolve;
|
|
});
|
|
const contentStarted = new Promise<void>((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<void>((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<void>((resolve) => {
|
|
releaseSlowList = resolve;
|
|
});
|
|
const listStarted = new Promise<void>((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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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<void>((resolve) => {
|
|
releaseSlowRead = resolve;
|
|
});
|
|
const readStarted = new Promise<void>((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<void>((resolve) => {
|
|
releaseSlowAvailability = resolve;
|
|
});
|
|
const availabilityStarted = new Promise<void>((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);
|
|
});
|
|
});
|