mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents deliver reports as task attachments and workspace files. > - The board already renders Markdown, but it shows HTML reports as source or rejects their preview. > - Reports can need inline scripts to build charts and tables. > - Artifact scripts must not read board cookies, storage, or the parent page. > - This pull request adds an opaque-origin HTML preview and view controls beside Download. > - Users can explore a report, inspect its source, and download the original file. ## Linked Issues or Issue Description **What existing behavior does this improve?** The task attachment panel and workspace file viewers. **Subsystem affected** ui/ and the server workspace file preview service. **Current behavior** HTML attachments show source text. Workspace HTML files cannot be previewed. **Proposed behavior** Render self-contained HTML reports in a sandboxed iframe. Place eye and code controls beside Download. Preserve the original file for raw view and download. **Reason and benefit** Users can read and filter agent reports in Paperclip without giving artifact scripts access to the board session. **Breaking changes** HTML previews now open rendered. Scripts and styles must be embedded in the report. Remote resources, API requests, forms, popups, and host navigation are blocked. Workspace HTML content uses the existing bounded UTF-8 JSON response. **Additional context** Related closed proposals: #3293 and #4857. This change uses the current attachment and workspace viewers and adds no report-serving endpoint. The Artifacts and Work Products roadmap item is complete; this improves its existing preview behavior. ## What Changed - Add a shared HTML iframe renderer with `sandbox="allow-scripts"` and no `allow-same-origin`. - Install a restrictive CSP before artifact markup. Keep inline report scripts and styles. - Add shared rendered/raw icon controls beside Download in the attachment panel, workspace panel, and file sheet. - Return workspace HTML as bounded text inside JSON. Keep download and path-access protections. - Add Storybooks for reports, security probes, workspace viewers, a task journey, and mobile layouts. - Document the security boundary and browser test command. ## Verification - `pnpm -r typecheck` and `pnpm build` pass. - Token gates and the static Storybook build pass. - Four browser tests pass. They cover report filters, raw mode, task entry, workspace viewers, cookies, storage, host DOM, resource requests, forms, popups, and host navigation. - The focused UI suite passes 26 tests. The file-resource server suite passes 36 tests. - Local full-stack acceptance passes with an actual 52 KB HTML report. Its chart and filter render. Raw mode preserves the source. Download bytes match the original file. - The complete local UI suite passes: 698 files and 7,754 tests. - `pnpm test:run` was attempted. Its server group recorded two unrelated timeouts and eight connector failures. All failed cases pass in isolated reruns, including the complete 388-test connector suite. The remaining local run was stopped after the full CI test coverage passed, to release test database resources. The full local command did not complete cleanly. - The separate local CLI group passes 511 tests. Three worktree database cases fail to start embedded PostgreSQL because this Mac has exhausted its shared-memory allocation. One isolated rerun fails at the same database startup step. No CLI code was changed. The corresponding CI test jobs pass. - All 56 PR checks are clean on `c771255f1a2e86bd8825f22eafb5d14a5e342cce`. Greptile gives 5/5 with no actionable findings. The security scan passes. The branch has no merge conflict. - Review the **HTML artifacts** section in Storybook. Run `pnpm exec playwright test --config tests/html-preview/playwright.config.ts` for the browser checks. ## Risks - Never add `allow-same-origin` to this iframe. Its opaque origin is the cookie, storage, and host-DOM boundary. - Reports that depend on CDN assets need to embed their dependencies. - The frame can navigate itself. Sandbox restrictions remain after navigation. This is not full network isolation. - Inline scripts can consume browser resources. This change does not isolate CPU or memory use. - No database migrations or API shape changes are required. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, repository editing, code execution, and browser tool use. The runtime does not expose a more specific model ID or context window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass — feature and UI checks pass; the full local run has the infrastructure limits recorded above. All CI test jobs pass. - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
1813 lines
73 KiB
TypeScript
1813 lines
73 KiB
TypeScript
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("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);
|
|
});
|
|
});
|