Files
PaperClipAI/server/src/__tests__/file-resources.test.ts
T
Devin FoleyandPaperclip 2eba718bef Fix sandbox bridge credentials and stalled review recovery (#8844)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The local adapter and heartbeat recovery systems decide whether an
agent has a real control-plane mutation path.
> - Sandboxed local adapters split execution between the trusted host
process and the sandbox shell/tool surface.
> - A host-side adapter can still reach Paperclip while the sandbox
shell surface cannot, which leaves agents thinking no endpoint or
credentials are configured even though the host can still post comments.
> - Execution-policy review stages can also remain pending after a
reviewer run finishes without recording a decision.
> - This pull request makes the sandbox bridge available to the actual
shell mutation surface and adds bounded recovery for
terminal-but-still-pending review participants.
> - The benefit is that agents get a real reachable Paperclip API path
where they need it, and stalled review stages become visible recovery
work instead of silently drifting.

## Linked Issues or Issue Description

No exact public GitHub issue matched this combined failure. I searched
for exact and related terms including `cannot reach the Paperclip
control plane`, `execution_review_participant_recovery`, `sandbox
callback bridge`, `review participant in_review`, and `control plane
sandbox`.

Related public issues:

- Refs #8482 for `in_review` liveness invariant recovery.
- Refs #863 for prior agent API-key reachability confusion.
- Refs #248 for the broader sandboxed agent execution model.

Bug summary:

- What happened: a sandboxed local-adapter run could have host-side
Paperclip access while the sandbox Bash/tool surface lacked a reachable
API endpoint or usable run credentials. Separately, a reviewer run could
finish while its execution-review stage remained pending, leaving the
source issue in `in_review` with no decision and no live participant
run.
- Expected behavior: the mutation surface that agents actually use
should receive a run-scoped Paperclip bridge, and pending review
participants should get one bounded normal-model recovery wake before
moving to explicit blocked/source-scoped recovery.
- Steps to reproduce: run a sandbox-backed local adapter that needs
Bash/curl/tooling to call Paperclip from inside the sandbox, or finish
an execution-policy reviewer run without submitting the pending review
decision.
- Deployment mode: local/authenticated private development instance with
sandbox-backed local adapters.

## What Changed

- Changed sandbox callback bridge startup so bridge credentials are
passed through the sandbox runner environment instead of embedded in the
visible `nohup env ...` command string.
- Added adapter-utils coverage proving the sandbox shell can call
Paperclip through the bridge, forwards the host run JWT with
`X-Paperclip-Run-Id`, and does not leak host or bridge tokens into
stdout/stderr, runner command text, or runtime files.
- Added one bounded execution-review participant recovery path for
terminal reviewer runs whose `executionState` remains pending.
- Escalated exhausted or non-invokable review participant recovery to
blocked/source-scoped recovery with dedicated evidence, activity, and
next-action text.
- Documented the mutation-surface reachability contract in
`doc/execution-semantics.md` and updated the Paperclip skill
authentication guidance for sandbox bridge env vars.

## Verification

- `pnpm exec vitest run
packages/adapter-utils/src/execution-target-sandbox.test.ts`
- `pnpm exec vitest run
server/src/__tests__/heartbeat-process-recovery.test.ts
--no-file-parallelism --maxWorkers=1`
- `pnpm --filter @paperclipai/adapter-utils typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `git diff --check`
- `curl -fsS $PAPERCLIP_API_URL/api/health` returned `status: ok` on the
local instance.

## Risks

- Medium behavioral risk: more `in_review` issues with
terminal-but-pending reviewer runs will now be retried once and then
blocked explicitly instead of remaining quiet.
- Low sandbox bridge risk: credential delivery moved from command text
to the runner environment, which is less leaky but depends on sandbox
providers honoring the env payload for startup commands.
- No database migration is included.
- Full repo build and CI were not run locally before opening the PR;
targeted server/adapter tests and typechecks passed.

## Model Used

OpenAI GPT-5 via the Codex local agent, with repository tool use and
shell-based code execution. The runtime did not expose a precise
context-window value to the agent.

## 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
- [x] I have run tests locally and they 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
- [ ] All Paperclip CI gates are green
- [ ] 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>
2026-07-01 21:38:01 -07:00

1524 lines
60 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 {
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);
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> {
const suffix = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
const prefixSuffix = suffix.toUpperCase();
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("resolves and reads a project file without exposing absolute paths", async () => {
const { root, projectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot });
await fs.writeFile(path.join(projectRoot, "src", "app.ts"), "export const ok = true;\n", { encoding: "utf8" }).catch(async (error) => {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
await fs.mkdir(path.join(projectRoot, "src"), { recursive: true });
await fs.writeFile(path.join(projectRoot, "src", "app.ts"), "export const ok = true;\n", "utf8");
});
const app = createApp(db, {
type: "board",
userId: "board-user",
companyIds: [graph.companyId],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/content`)
.query({ workspace: "project", path: "src/app.ts" });
expect(res.status).toBe(200);
expect(res.headers["x-content-type-options"]).toBe("nosniff");
expect(res.body.resource.displayPath).toBe("src/app.ts");
expect(JSON.stringify(res.body)).not.toContain(root);
expect(res.body.content.data).toContain("export const ok");
});
it("lists and downloads non-previewable workspace files", async () => {
const { root, projectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot });
const relativePath = "artifacts/archive.bin";
const bytes = Buffer.from([0, 1, 2, 3, 4, 255]);
await fs.mkdir(path.join(projectRoot, "artifacts"), { recursive: true });
await fs.writeFile(path.join(projectRoot, relativePath), bytes);
const app = createApp(db, {
type: "board",
userId: "board-user",
companyIds: [graph.companyId],
source: "session",
isInstanceAdmin: false,
});
const listed = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/list`)
.query({ workspace: "project", mode: "all", path: "artifacts" });
expect(listed.status).toBe(200);
expect(listed.body.items).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "file",
relativePath,
previewKind: "unsupported",
capabilities: { preview: false, download: true, listChildren: false },
}),
]));
expect(JSON.stringify(listed.body)).not.toContain(root);
const downloaded = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/content`)
.query({ workspace: "project", path: relativePath, download: "1" })
.buffer(true)
.parse((res, callback) => {
const chunks: Buffer[] = [];
res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
res.on("end", () => callback(null, Buffer.concat(chunks)));
});
expect(downloaded.status).toBe(200);
expect(downloaded.headers["content-disposition"]).toBe('attachment; filename="archive.bin"');
expect(downloaded.headers["x-content-type-options"]).toBe("nosniff");
expect(Buffer.compare(downloaded.body as Buffer, bytes)).toBe(0);
});
it("falls back from an execution workspace miss to the project workspace", async () => {
const { projectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot });
await fs.writeFile(path.join(projectRoot, "README.md"), "# Project\n", "utf8");
const resolved = await workspaceFileResourceService(db).resolve(graph.issueId, {
path: "README.md",
workspace: "auto",
});
expect(resolved.workspaceKind).toBe("project_workspace");
expect(resolved.displayPath).toBe("README.md");
expect(resolved.capabilities.preview).toBe(true);
});
it("auto-discovers unhinted same-company project files when issue workspaces miss", async () => {
const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot });
const targetPath = "docs/reference/skills.md";
await fs.mkdir(path.join(targetProjectRoot, path.dirname(targetPath)), { recursive: true });
await fs.writeFile(path.join(targetProjectRoot, targetPath), "# Skills reference\n", "utf8");
const resolved = await workspaceFileResourceService(db).resolve(graph.issueId, {
path: targetPath,
workspace: "auto",
});
expect(resolved).toMatchObject({
workspaceKind: "project_workspace",
workspaceId: graph.targetProjectWorkspaceId,
projectId: graph.targetProjectId,
projectName: "Target project",
displayPath: `Target project / ${targetPath}`,
previewKind: "text",
});
const content = await workspaceFileResourceService(db).readContent(graph.issueId, {
path: targetPath,
workspace: "auto",
});
expect(content.resource.workspaceId).toBe(graph.targetProjectWorkspaceId);
expect(content.content.data).toContain("# Skills reference");
});
it("resolves explicit same-company cross-project workspace files and logs target details", async () => {
const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot });
await fs.mkdir(path.join(targetProjectRoot, "docs"), { recursive: true });
await fs.writeFile(path.join(targetProjectRoot, "docs", "README.md"), "# Target project\n", "utf8");
const app = createApp(db, {
type: "board",
userId: "board-user",
companyIds: [graph.companyId],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/content`)
.query({
projectId: graph.targetProjectId,
workspaceId: graph.targetProjectWorkspaceId,
path: "docs/README.md",
});
expect(res.status).toBe(200);
expect(res.body.resource).toMatchObject({
workspaceKind: "project_workspace",
workspaceId: graph.targetProjectWorkspaceId,
projectId: graph.targetProjectId,
projectName: "Target project",
displayPath: "Target project / docs/README.md",
});
expect(res.body.content.data).toContain("# Target project");
expect(JSON.stringify(res.body)).not.toContain(root);
const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId));
const read = rows.find((row) => row.action === "issue.file_resource_content_read");
expect(read?.details).toMatchObject({
outcome: "success",
workspaceId: graph.targetProjectWorkspaceId,
projectId: graph.targetProjectId,
projectName: "Target project",
displayPath: "Target project / docs/README.md",
});
expect(JSON.stringify(read?.details)).not.toContain(targetProjectRoot);
});
it("reads explicit cross-project git_repo workspaces with a local checkout", async () => {
const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, {
projectRoot,
targetProjectRoot,
executionRoot,
targetProjectSourceType: "git_repo",
});
const readmePath = "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/README.md";
await fs.mkdir(path.join(targetProjectRoot, path.dirname(readmePath)), { recursive: true });
await fs.writeFile(path.join(targetProjectRoot, readmePath), "# Bundled skills\n\nRendered from content project.\n", "utf8");
const app = createApp(db, {
type: "board",
userId: "board-user",
companyIds: [graph.companyId],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/content`)
.query({
projectId: graph.targetProjectId,
workspaceId: graph.targetProjectWorkspaceId,
path: readmePath,
});
expect(res.status).toBe(200);
expect(res.body.resource).toMatchObject({
workspaceKind: "project_workspace",
workspaceId: graph.targetProjectWorkspaceId,
projectId: graph.targetProjectId,
projectName: "Target project",
displayPath: `Target project / ${readmePath}`,
provider: "git_repo",
previewKind: "text",
});
expect(res.body.content.encoding).toBe("utf8");
expect(res.body.content.data).toContain("# Bundled skills");
expect(JSON.stringify(res.body)).not.toContain(root);
});
it("resolves and lists explicit same-company cross-project workspace folders", async () => {
const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot });
const folderPath = "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/";
await fs.mkdir(path.join(targetProjectRoot, folderPath), { recursive: true });
await fs.writeFile(path.join(targetProjectRoot, folderPath, "README.md"), "# Bundled skills\n", "utf8");
await fs.writeFile(path.join(targetProjectRoot, folderPath, "notes.txt"), "notes\n", "utf8");
await fs.writeFile(path.join(targetProjectRoot, "outside.txt"), "not in focused folder\n", "utf8");
const app = createApp(db, {
type: "board",
userId: "board-user",
companyIds: [graph.companyId],
source: "session",
isInstanceAdmin: false,
});
const resolved = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/resolve`)
.query({
projectId: graph.targetProjectId,
workspaceId: graph.targetProjectWorkspaceId,
path: folderPath,
});
expect(resolved.status).toBe(200);
expect(resolved.body).toMatchObject({
kind: "directory",
workspaceKind: "project_workspace",
workspaceId: graph.targetProjectWorkspaceId,
projectId: graph.targetProjectId,
projectName: "Target project",
displayPath: `Target project / ${folderPath}`,
capabilities: { preview: false, download: false, listChildren: true },
});
expect(JSON.stringify(resolved.body)).not.toContain(root);
const listed = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/list`)
.query({
projectId: graph.targetProjectId,
workspaceId: graph.targetProjectWorkspaceId,
path: folderPath,
mode: "all",
});
expect(listed.status).toBe(200);
expect(listed.body.state).toBe("available");
expect(listed.body.query).toMatchObject({ path: folderPath.slice(0, -1), mode: "all" });
expect(new Set(listed.body.items.map((item: { relativePath: string }) => item.relativePath))).toEqual(new Set([
`${folderPath}README.md`,
`${folderPath}notes.txt`,
]));
expect(JSON.stringify(listed.body)).not.toContain(root);
expect(JSON.stringify(listed.body)).not.toContain("outside.txt");
});
it("auto-discovers unhinted same-company project folders when issue workspaces miss", async () => {
const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot });
const folderPath = "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/";
await fs.mkdir(path.join(targetProjectRoot, folderPath), { recursive: true });
await fs.writeFile(path.join(targetProjectRoot, folderPath, "README.md"), "# Bundled skills\n", "utf8");
await fs.writeFile(path.join(targetProjectRoot, folderPath, "suggestions.md"), "# Suggestions\n", "utf8");
const app = createApp(db, {
type: "board",
userId: "board-user",
companyIds: [graph.companyId],
source: "session",
isInstanceAdmin: false,
});
const resolved = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/resolve`)
.query({ path: folderPath });
expect(resolved.status).toBe(200);
expect(resolved.body).toMatchObject({
kind: "directory",
workspaceId: graph.targetProjectWorkspaceId,
projectId: graph.targetProjectId,
projectName: "Target project",
displayPath: `Target project / ${folderPath}`,
});
const listed = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/list`)
.query({ path: folderPath });
expect(listed.status).toBe(200);
expect(listed.body.state).toBe("available");
expect(listed.body.workspace).toMatchObject({
workspaceId: graph.targetProjectWorkspaceId,
projectId: graph.targetProjectId,
projectName: "Target project",
});
expect(new Set(listed.body.items.map((item: { relativePath: string }) => item.relativePath))).toEqual(new Set([
`${folderPath}README.md`,
`${folderPath}suggestions.md`,
]));
expect(JSON.stringify(listed.body)).not.toContain(root);
});
it("rejects ambiguous unhinted same-company project file matches", async () => {
const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot });
const duplicateProjectId = crypto.randomUUID();
const duplicateWorkspaceId = crypto.randomUUID();
const duplicateRoot = path.join(path.dirname(targetProjectRoot), "duplicate-target");
const targetPath = "docs/reference/skills.md";
await fs.mkdir(path.join(targetProjectRoot, path.dirname(targetPath)), { recursive: true });
await fs.writeFile(path.join(targetProjectRoot, targetPath), "# Target\n", "utf8");
await fs.mkdir(path.join(duplicateRoot, path.dirname(targetPath)), { recursive: true });
await fs.writeFile(path.join(duplicateRoot, targetPath), "# Duplicate\n", "utf8");
await db.insert(projects).values({
id: duplicateProjectId,
companyId: graph.companyId,
name: "Duplicate target",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: duplicateWorkspaceId,
companyId: graph.companyId,
projectId: duplicateProjectId,
name: "Duplicate workspace",
sourceType: "local_path",
cwd: duplicateRoot,
isPrimary: true,
});
await expect(workspaceFileResourceService(db).resolve(graph.issueId, {
path: targetPath,
workspace: "auto",
})).rejects.toMatchObject({
status: 409,
details: { code: "ambiguous_workspace_path" },
});
});
it("denies explicit cross-company project workspaces", async () => {
const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot });
const app = createApp(db, {
type: "board",
userId: "board-user",
companyIds: [graph.companyId],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/resolve`)
.query({
projectId: graph.otherProjectId,
workspaceId: graph.otherProjectWorkspaceId,
path: "README.md",
});
expect(res.status).toBe(403);
expect(res.body?.details?.code).toBe("cross_company_workspace");
const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId));
const denied = rows.find((row) => row.action === "issue.file_resource_resolve_denied");
expect(denied?.details).toMatchObject({
outcome: "denied",
projectId: graph.otherProjectId,
workspaceId: graph.otherProjectWorkspaceId,
denialReason: "cross_company_workspace",
});
});
it("rejects explicit project/workspace mismatches", async () => {
const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot });
const app = createApp(db, {
type: "board",
userId: "board-user",
companyIds: [graph.companyId],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/resolve`)
.query({
projectId: graph.targetProjectId,
workspaceId: graph.projectWorkspaceId,
path: "README.md",
});
expect(res.status).toBe(422);
expect(res.body?.details?.code).toBe("workspace_project_mismatch");
});
it("blocks symlink escapes from explicit cross-project workspaces", async () => {
const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot });
await fs.writeFile(path.join(root, "outside-target.txt"), "secret\n", "utf8");
await fs.symlink(path.join(root, "outside-target.txt"), path.join(targetProjectRoot, "escape.txt"));
await expect(workspaceFileResourceService(db).readContent(graph.issueId, {
projectId: graph.targetProjectId,
workspaceId: graph.targetProjectWorkspaceId,
path: "escape.txt",
})).rejects.toMatchObject({
status: 403,
details: { code: "outside_workspace" },
});
});
it("blocks symlink directory escapes from explicit cross-project workspaces", async () => {
const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot });
const outsideDir = path.join(root, "outside-dir");
await fs.mkdir(outsideDir, { recursive: true });
await fs.writeFile(path.join(outsideDir, "secret.txt"), "secret\n", "utf8");
await fs.symlink(outsideDir, path.join(targetProjectRoot, "escape-dir"));
await expect(workspaceFileResourceService(db).resolve(graph.issueId, {
projectId: graph.targetProjectId,
workspaceId: graph.targetProjectWorkspaceId,
path: "escape-dir/",
})).rejects.toMatchObject({
status: 403,
details: { code: "outside_workspace" },
});
});
it("resolves and reads video workspace files as base64 previews", async () => {
const { projectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot });
await fs.writeFile(path.join(projectRoot, "demo.mp4"), Buffer.from([0, 0, 0, 24, 102, 116, 121, 112]));
const resolved = await workspaceFileResourceService(db).resolve(graph.issueId, {
path: "demo.mp4",
workspace: "project",
});
expect(resolved.previewKind).toBe("video");
expect(resolved.contentType).toBe("video/mp4");
expect(resolved.capabilities.preview).toBe(true);
const content = await workspaceFileResourceService(db).readContent(graph.issueId, {
path: "demo.mp4",
workspace: "project",
});
expect(content.resource.previewKind).toBe("video");
expect(content.content.encoding).toBe("base64");
expect(content.content.data).toBe(Buffer.from([0, 0, 0, 24, 102, 116, 121, 112]).toString("base64"));
});
it("lists and searches safe file candidates from the preferred execution workspace", async () => {
const { root, projectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot });
await fs.mkdir(path.join(executionRoot, "src"), { recursive: true });
await fs.mkdir(path.join(executionRoot, "node_modules", "pkg"), { recursive: true });
await fs.writeFile(path.join(executionRoot, "src", "app.ts"), "export const ok = true;\n", "utf8");
await fs.writeFile(path.join(executionRoot, ".env"), "TOKEN=secret\n", "utf8");
await fs.writeFile(path.join(executionRoot, "node_modules", "pkg", "index.ts"), "export {}\n", "utf8");
await fs.writeFile(path.join(executionRoot, "blob.bin"), Buffer.from([0, 1, 2, 3]));
await fs.writeFile(path.join(projectRoot, "src-project.ts"), "export const project = true;\n", "utf8");
await fs.writeFile(path.join(root, "outside.ts"), "secret\n", "utf8");
await fs.symlink(path.join(root, "outside.ts"), path.join(executionRoot, "escape.ts"));
const app = createApp(db, {
type: "board",
userId: "board-user",
companyIds: [graph.companyId],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/list`)
.query({ workspace: "auto", q: "src", limit: 10 });
expect(res.status).toBe(200);
expect(res.body.state).toBe("available");
expect(res.body.workspace.workspaceKind).toBe("execution_workspace");
expect(res.body.items.map((item: { displayPath: string }) => item.displayPath)).toEqual(["src/app.ts"]);
expect(JSON.stringify(res.body)).not.toContain(root);
expect(JSON.stringify(res.body)).not.toContain(".env");
expect(JSON.stringify(res.body)).not.toContain("node_modules");
expect(JSON.stringify(res.body)).not.toContain("escape.ts");
expect(JSON.stringify(res.body)).not.toContain("blob.bin");
});
it("enforces default and hard list/search caps", async () => {
const { projectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot });
await Promise.all(
Array.from({ length: 30 }, (_, index) =>
fs.writeFile(
path.join(projectRoot, `file-${String(index).padStart(2, "0")}.ts`),
"export {}\n",
"utf8",
),
),
);
const tooDeepDir = path.join(projectRoot, ...Array.from({ length: 21 }, (_, index) => `deep-${index}`));
await fs.mkdir(tooDeepDir, { recursive: true });
await fs.writeFile(path.join(tooDeepDir, "too-deep.ts"), "export const hidden = true;\n", "utf8");
const app = createApp(db, {
type: "board",
userId: "board-user",
companyIds: [graph.companyId],
source: "session",
isInstanceAdmin: false,
});
const defaultLimit = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/list`)
.query({ workspace: "project", mode: "all" });
expect(defaultLimit.status).toBe(200);
expect(defaultLimit.body.query.limit).toBe(25);
expect(defaultLimit.body.items).toHaveLength(25);
expect(defaultLimit.body.truncated).toBe(true);
const tooLargeLimit = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/list`)
.query({ workspace: "project", mode: "all", limit: 101 });
expect(tooLargeLimit.status).toBe(422);
expect(tooLargeLimit.body?.details?.code).toBe("invalid_query");
const tooDeep = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/list`)
.query({ workspace: "project", mode: "all", q: "too-deep", limit: 100 });
expect(tooDeep.status).toBe(200);
expect(tooDeep.body.items).toEqual([]);
expect(tooDeep.body.truncated).toBe(true);
});
it("lists one folder level at a time so deep descendants do not hide selected siblings", async () => {
const { projectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot });
await fs.mkdir(path.join(projectRoot, "docs", "deep"), { recursive: true });
await fs.writeFile(path.join(projectRoot, "docs", "selected.md"), "# Selected\n", "utf8");
await Promise.all(
Array.from({ length: 130 }, (_, index) =>
fs.writeFile(
path.join(projectRoot, "docs", "deep", `generated-${String(index).padStart(3, "0")}.md`),
"# Generated\n",
"utf8",
),
),
);
const app = createApp(db, {
type: "board",
userId: "board-user",
companyIds: [graph.companyId],
source: "session",
isInstanceAdmin: false,
});
const parent = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/list`)
.query({ workspace: "project", path: "docs", mode: "all", limit: 100 });
expect(parent.status).toBe(200);
expect(parent.body.query).toMatchObject({ path: "docs", mode: "all", offset: 0 });
expect(parent.body.truncated).toBe(false);
expect(parent.body.items.map((item: { kind: string; relativePath: string }) => `${item.kind}:${item.relativePath}`)).toEqual([
"directory:docs/deep",
"file:docs/selected.md",
]);
const firstDeepPage = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/list`)
.query({ workspace: "project", path: "docs/deep", mode: "all", limit: 100 });
expect(firstDeepPage.status).toBe(200);
expect(firstDeepPage.body.items).toHaveLength(100);
expect(firstDeepPage.body.truncated).toBe(true);
const secondDeepPage = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/list`)
.query({ workspace: "project", path: "docs/deep", mode: "all", limit: 100, offset: 100 });
expect(secondDeepPage.status).toBe(200);
expect(secondDeepPage.body.query.offset).toBe(100);
expect(secondDeepPage.body.items).toHaveLength(30);
expect(secondDeepPage.body.truncated).toBe(false);
});
it("supports recent mode, limit caps, and list activity logging", async () => {
const { projectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot });
await fs.writeFile(path.join(projectRoot, "old.ts"), "export const old = true;\n", "utf8");
await fs.writeFile(path.join(projectRoot, "new.ts"), "export const newer = true;\n", "utf8");
const app = createApp(db, {
type: "board",
userId: "board-user",
companyIds: [graph.companyId],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/list`)
.query({ workspace: "project", mode: "recent", limit: 1 });
expect(res.status).toBe(200);
expect(res.body.items).toHaveLength(1);
expect(res.body.truncated).toBe(true);
const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId));
const listRead = rows.find((row) => row.action === "issue.file_resource_list");
expect(listRead).toBeTruthy();
expect(listRead?.details).toMatchObject({
outcome: "success",
workspaceSelector: "project",
mode: "recent",
resultCount: 1,
truncated: true,
});
expect(JSON.stringify(listRead?.details)).not.toContain(projectRoot);
expect(JSON.stringify(listRead?.details)).not.toContain("old.ts");
expect(JSON.stringify(listRead?.details)).not.toContain("new.ts");
});
it("applies list offsets to search, recent, and changed modes", async () => {
const { projectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot });
await execFileAsync("git", ["-C", projectRoot, "init"]);
await Promise.all([
fs.writeFile(path.join(projectRoot, "alpha.ts"), "export const alpha = true;\n", "utf8"),
fs.writeFile(path.join(projectRoot, "beta.ts"), "export const beta = true;\n", "utf8"),
fs.writeFile(path.join(projectRoot, "gamma.ts"), "export const gamma = true;\n", "utf8"),
]);
const older = new Date("2026-01-01T00:00:00.000Z");
const middle = new Date("2026-01-02T00:00:00.000Z");
const newer = new Date("2026-01-03T00:00:00.000Z");
await fs.utimes(path.join(projectRoot, "alpha.ts"), older, older);
await fs.utimes(path.join(projectRoot, "beta.ts"), middle, middle);
await fs.utimes(path.join(projectRoot, "gamma.ts"), newer, newer);
const app = createApp(db, {
type: "board",
userId: "board-user",
companyIds: [graph.companyId],
source: "session",
isInstanceAdmin: false,
});
const searchPage = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/list`)
.query({ workspace: "project", q: ".ts", limit: 1, offset: 1 });
expect(searchPage.status).toBe(200);
expect(searchPage.body.items.map((item: { relativePath: string }) => item.relativePath)).toEqual(["beta.ts"]);
expect(searchPage.body.truncated).toBe(true);
const recentPage = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/list`)
.query({ workspace: "project", mode: "recent", limit: 1, offset: 1 });
expect(recentPage.status).toBe(200);
expect(recentPage.body.items.map((item: { relativePath: string }) => item.relativePath)).toEqual(["beta.ts"]);
expect(recentPage.body.truncated).toBe(true);
const changedPage = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/list`)
.query({ workspace: "project", mode: "changed", limit: 1, offset: 1 });
expect(changedPage.status).toBe(200);
expect(changedPage.body.items.map((item: { relativePath: string }) => item.relativePath)).toEqual(["beta.ts"]);
expect(changedPage.body.truncated).toBe(true);
});
it("rejects overlong list searches and redacts the raw query from denial audit details", async () => {
const { projectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot });
const app = createApp(db, {
type: "board",
userId: "board-user",
companyIds: [graph.companyId],
source: "session",
isInstanceAdmin: false,
});
const rawQuery = "é".repeat(65);
const res = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/list`)
.query({ workspace: "project", q: rawQuery });
expect(res.status).toBe(422);
expect(res.body?.details?.code).toBe("invalid_query");
const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId));
const denied = rows.find((row) => row.action === "issue.file_resource_list_denied");
expect(denied?.details).toMatchObject({
outcome: "denied",
workspaceSelector: "project",
mode: "all",
denialReason: "invalid_query",
});
expect(JSON.stringify(denied?.details)).not.toContain(rawQuery);
});
it("rejects control characters in the path without crashing the audit log", async () => {
const { projectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot });
const app = createApp(db, {
type: "board",
userId: "board-user",
companyIds: [graph.companyId],
source: "session",
isInstanceAdmin: false,
});
const nullByte = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/content?workspace=project&path=foo%00bar.ts`);
expect(nullByte.status).toBe(422);
expect(nullByte.body?.details?.code).toBe("invalid_path");
const resolveNullByte = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/resolve?workspace=project&path=foo%00bar.ts`);
expect(resolveNullByte.status).toBe(422);
expect(resolveNullByte.body?.details?.code).toBe("invalid_path");
const otherControl = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/content?workspace=project&path=a%0Bb.ts`);
expect(otherControl.status).toBe(422);
expect(otherControl.body?.details?.code).toBe("invalid_path");
});
it("rejects traversal, encoded traversal, home-relative paths, backslash traversal, and double-encoding without double-decoding", async () => {
const { projectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot });
await fs.writeFile(path.join(projectRoot, "safe.txt"), "safe\n", "utf8");
const app = createApp(db, {
type: "board",
userId: "board-user",
companyIds: [graph.companyId],
source: "session",
isInstanceAdmin: false,
});
expect((await request(app).get(`/api/issues/${graph.issueId}/file-resources/content?workspace=project&path=..%2Fsecret.txt`)).status).toBe(403);
expect((await request(app).get(`/api/issues/${graph.issueId}/file-resources/content?workspace=project&path=%2e%2e%2Fsecret.txt`)).status).toBe(403);
expect((await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "project", path: "~/secret.txt" })).status).toBe(422);
expect((await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "project", path: "..\\secret.txt" })).status).toBe(422);
const doubleEncoded = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/content?workspace=project&path=%252e%252e%252Fsecret.txt`);
expect(doubleEncoded.status).toBe(404);
});
it("blocks symlink escapes and symlinks to denied sensitive files", async () => {
const { root, projectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot });
await fs.writeFile(path.join(root, "outside-secret.txt"), "secret\n", "utf8");
await fs.mkdir(path.join(projectRoot, "safe"), { recursive: true });
await fs.writeFile(path.join(projectRoot, "safe", ".env"), "TOKEN=secret\n", "utf8");
await fs.symlink(path.join(root, "outside-secret.txt"), path.join(projectRoot, "escape.txt"));
await fs.symlink(path.join(projectRoot, "safe", ".env"), path.join(projectRoot, "linked-env"));
const app = createApp(db, {
type: "board",
userId: "board-user",
companyIds: [graph.companyId],
source: "session",
isInstanceAdmin: false,
});
const escape = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/content`)
.query({ workspace: "project", path: "escape.txt" });
const linkedSecret = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/content`)
.query({ workspace: "project", path: "linked-env" });
expect(escape.status).toBe(403);
expect(linkedSecret.status).toBe(403);
});
it("rejects denied paths, non-regular files, oversized text, binary, and HTML while previewing SVG as source", async () => {
const { projectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot });
await fs.mkdir(path.join(projectRoot, ".git"), { recursive: true });
await fs.writeFile(path.join(projectRoot, ".git", "config"), "[core]\n", "utf8");
await fs.mkdir(path.join(projectRoot, "folder"), { recursive: true });
await fs.writeFile(path.join(projectRoot, "big.txt"), Buffer.alloc(WORKSPACE_FILE_TEXT_MAX_BYTES + 1, "a"));
await fs.writeFile(path.join(projectRoot, "blob.bin"), Buffer.from([0, 1, 2, 3]));
await fs.writeFile(path.join(projectRoot, "index.html"), "<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", "index.html"]) {
const res = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/content`)
.query({ workspace: "project", path: filePath });
expect([403, 422]).toContain(res.status);
}
const svg = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/content`)
.query({ workspace: "project", path: "icon.svg" });
expect(svg.status).toBe(200);
expect(svg.body.resource.previewKind).toBe("text");
expect(svg.body.content.data).toContain("<svg");
});
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(403);
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(403);
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(403);
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 })),
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 })),
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 })),
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 })),
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);
});
});
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 })),
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);
});
});