forked from farhoodlabs/paperclip
1991ec9d6f
## Thinking Path > - Paperclip is the control plane for autonomous AI companies, so backend task ownership, recovery, review visibility, and company-scoped limits need to stay enforceable without UI-only coupling. > - Closed PR #4692 bundled those backend changes with UI workflow, docs, skills, workflow, and lockfile churn. > - PAP-2694 asks for a clean backend/control-plane slice from that closed branch. > - This branch starts from current `master` and mines only the `cli`, `packages/db`, `packages/shared`, and `server` contracts/tests needed for the backend behavior. > - It explicitly excludes UI workflow/performance work, `.github/workflows/pr.yml`, `pnpm-lock.yaml`, docs, skills, package-script, adapter UI build-config, and perf fixture script changes; the only UI files are fixture/test updates required by the tightened shared `Company` contract. > - The benefit is a smaller reviewable PR that preserves the control-plane fixes while staying under Greptile s 100-file review limit. ## What Changed - Added company-scoped attachment-size limits through DB schema/migrations, shared company portability contracts, CLI import/export coverage, and server attachment upload enforcement. - Added productivity review service/API behavior for no-comment streak, long-active, and high-churn review issues, including request-depth clamping and issue summary exposure. - Hardened issue ownership and recovery/control-plane paths: peer-agent mutation denial, issue tree pause/resume behavior, stranded recovery origins, and related activity/test coverage. - Preserved related backend contract updates for routine timestamp variables and managed agent instruction bundles because they live in shared/server contracts from the source branch. - Addressed Greptile feedback by making `Company.attachmentMaxBytes` non-optional, simplifying review request-depth clamping, fixing the migration final newline, and enforcing the process-level attachment cap as the final ceiling for uploads. - Added minimal company fixtures needed for repo-wide typecheck/build and kept the PR to 66 changed files with forbidden/non-slice paths excluded. ## Verification - `pnpm install --frozen-lockfile` - `git diff --check origin/master..HEAD` - `git diff --name-only origin/master..HEAD | wc -l` -> 66 files - `git diff --name-only origin/master..HEAD -- .github/workflows/pr.yml pnpm-lock.yaml package.json doc skills .agents scripts packages/adapters` -> no output - `pnpm exec vitest run --config vitest.config.ts packages/shared/src/validators/issue.test.ts packages/shared/src/routine-variables.test.ts packages/shared/src/adapter-types.test.ts cli/src/__tests__/company-import-export-e2e.test.ts cli/src/__tests__/company.test.ts server/src/__tests__/productivity-review-service.test.ts server/src/__tests__/issue-tree-control-service.test.ts server/src/__tests__/issue-tree-control-routes.test.ts server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts server/src/__tests__/issue-attachment-routes.test.ts server/src/__tests__/heartbeat-process-recovery.test.ts server/src/__tests__/issues-service.test.ts` -> 12 files, 147 tests passed - `pnpm exec vitest run --config vitest.config.ts cli/src/__tests__/company-delete.test.ts cli/src/__tests__/company-import-export-e2e.test.ts server/src/__tests__/productivity-review-service.test.ts` -> 3 files, 18 tests passed - `pnpm exec vitest run --config vitest.config.ts server/src/__tests__/issue-attachment-routes.test.ts` -> 1 file, 6 tests passed - `pnpm --filter @paperclipai/db typecheck && pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/server typecheck && pnpm --filter paperclipai typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck && pnpm --filter @paperclipai/ui build` ## Risks - Includes migrations `0073_shiny_salo.sql` and `0074_striped_genesis.sql`; merge ordering matters if another PR adds migrations first. - This is intentionally backend-only apart from fixture/test updates forced by shared type correctness; UI affordances from PR #4692 are not present here and should land in separate UI slices. - The worktree install emitted plugin SDK bin-link warnings for unbuilt plugin packages, but the targeted tests and package typechecks completed successfully. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected; check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled terminal/GitHub workflow. Exact runtime context window was not exposed by the harness. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
320 lines
10 KiB
TypeScript
320 lines
10 KiB
TypeScript
import { Readable } from "node:stream";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { StorageService } from "../storage/types.js";
|
|
|
|
const mockIssueService = vi.hoisted(() => ({
|
|
getById: vi.fn(),
|
|
getByIdentifier: vi.fn(),
|
|
createAttachment: vi.fn(),
|
|
getAttachmentById: vi.fn(),
|
|
}));
|
|
const mockCompanyService = vi.hoisted(() => ({
|
|
getById: vi.fn(),
|
|
}));
|
|
|
|
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
|
|
|
function registerRouteMocks() {
|
|
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
|
trackAgentTaskCompleted: vi.fn(),
|
|
trackErrorHandlerCrash: vi.fn(),
|
|
}));
|
|
|
|
vi.doMock("../telemetry.js", () => ({
|
|
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
|
|
}));
|
|
|
|
vi.doMock("../services/issues.js", () => ({
|
|
issueService: () => mockIssueService,
|
|
}));
|
|
|
|
vi.doMock("../services/activity-log.js", () => ({
|
|
logActivity: mockLogActivity,
|
|
}));
|
|
|
|
vi.doMock("../services/index.js", () => ({
|
|
accessService: () => ({
|
|
canUser: vi.fn(),
|
|
hasPermission: vi.fn(),
|
|
}),
|
|
agentService: () => ({
|
|
getById: vi.fn(),
|
|
}),
|
|
companyService: () => mockCompanyService,
|
|
documentService: () => ({}),
|
|
executionWorkspaceService: () => ({}),
|
|
feedbackService: () => ({
|
|
listIssueVotesForUser: vi.fn(async () => []),
|
|
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
|
|
}),
|
|
goalService: () => ({}),
|
|
heartbeatService: () => ({
|
|
wakeup: vi.fn(async () => undefined),
|
|
reportRunActivity: vi.fn(async () => undefined),
|
|
getRun: vi.fn(async () => null),
|
|
getActiveRunForAgent: vi.fn(async () => null),
|
|
cancelRun: vi.fn(async () => null),
|
|
}),
|
|
instanceSettingsService: () => ({
|
|
get: vi.fn(async () => ({
|
|
id: "instance-settings-1",
|
|
general: {
|
|
censorUsernameInLogs: false,
|
|
feedbackDataSharingPreference: "prompt",
|
|
},
|
|
})),
|
|
listCompanyIds: vi.fn(async () => ["company-1"]),
|
|
}),
|
|
issueApprovalService: () => ({}),
|
|
issueReferenceService: () => ({
|
|
deleteDocumentSource: async () => undefined,
|
|
diffIssueReferenceSummary: () => ({
|
|
addedReferencedIssues: [],
|
|
removedReferencedIssues: [],
|
|
currentReferencedIssues: [],
|
|
}),
|
|
emptySummary: () => ({ outbound: [], inbound: [] }),
|
|
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
|
syncComment: async () => undefined,
|
|
syncDocument: async () => undefined,
|
|
syncIssue: async () => undefined,
|
|
}),
|
|
issueService: () => mockIssueService,
|
|
logActivity: mockLogActivity,
|
|
projectService: () => ({}),
|
|
routineService: () => ({
|
|
syncRunStatusForIssue: vi.fn(async () => undefined),
|
|
}),
|
|
workProductService: () => ({}),
|
|
}));
|
|
}
|
|
|
|
type TestStorageService = StorageService & {
|
|
__calls: {
|
|
putFile?: {
|
|
companyId: string;
|
|
namespace: string;
|
|
originalFilename?: string;
|
|
contentType: string;
|
|
body: Buffer;
|
|
};
|
|
};
|
|
};
|
|
|
|
function createStorageService(): TestStorageService {
|
|
const calls: TestStorageService["__calls"] = {};
|
|
return {
|
|
provider: "local_disk",
|
|
__calls: calls,
|
|
putFile: async (input) => {
|
|
calls.putFile = input;
|
|
return {
|
|
provider: "local_disk",
|
|
objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`,
|
|
contentType: input.contentType,
|
|
byteSize: input.body.length,
|
|
sha256: "sha256-sample",
|
|
originalFilename: input.originalFilename,
|
|
};
|
|
},
|
|
getObject: vi.fn(async () => ({
|
|
stream: Readable.from(Buffer.from("test")),
|
|
contentLength: 4,
|
|
})),
|
|
headObject: vi.fn(),
|
|
deleteObject: vi.fn(),
|
|
};
|
|
}
|
|
|
|
async function createApp(storage: StorageService) {
|
|
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
|
|
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
|
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
|
]);
|
|
const app = express();
|
|
app.use((req, _res, next) => {
|
|
(req as any).actor = {
|
|
type: "board",
|
|
userId: "local-board",
|
|
companyIds: ["company-1"],
|
|
source: "local_implicit",
|
|
isInstanceAdmin: false,
|
|
};
|
|
next();
|
|
});
|
|
app.use("/api", issueRoutes({} as any, storage));
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
function makeAttachment(contentType: string, originalFilename: string) {
|
|
const now = new Date("2026-01-01T00:00:00.000Z");
|
|
return {
|
|
id: "attachment-1",
|
|
companyId: "company-1",
|
|
issueId: "11111111-1111-4111-8111-111111111111",
|
|
issueCommentId: null,
|
|
assetId: "asset-1",
|
|
provider: "local_disk",
|
|
objectKey: `issues/issue-1/${originalFilename}`,
|
|
contentType,
|
|
byteSize: 4,
|
|
sha256: "sha256-sample",
|
|
originalFilename,
|
|
createdByAgentId: null,
|
|
createdByUserId: "local-board",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
}
|
|
|
|
describe("normalizeIssueAttachmentMaxBytes", () => {
|
|
it("keeps the process-level attachment cap as the final cap", async () => {
|
|
const previous = process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES;
|
|
process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES = "5";
|
|
vi.resetModules();
|
|
try {
|
|
const { normalizeIssueAttachmentMaxBytes } = await import("../attachment-types.js");
|
|
expect(normalizeIssueAttachmentMaxBytes(null)).toBe(5);
|
|
expect(normalizeIssueAttachmentMaxBytes(10)).toBe(5);
|
|
expect(normalizeIssueAttachmentMaxBytes(3)).toBe(3);
|
|
} finally {
|
|
if (previous === undefined) {
|
|
delete process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES;
|
|
} else {
|
|
process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES = previous;
|
|
}
|
|
vi.resetModules();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("issue attachment routes", () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.doUnmock("@paperclipai/shared/telemetry");
|
|
vi.doUnmock("../telemetry.js");
|
|
vi.doUnmock("../services/issues.js");
|
|
vi.doUnmock("../services/index.js");
|
|
vi.doUnmock("../services/activity-log.js");
|
|
vi.doUnmock("../routes/issues.js");
|
|
vi.doUnmock("../routes/authz.js");
|
|
vi.doUnmock("../middleware/index.js");
|
|
registerRouteMocks();
|
|
vi.clearAllMocks();
|
|
mockLogActivity.mockResolvedValue(undefined);
|
|
mockCompanyService.getById.mockResolvedValue({
|
|
id: "company-1",
|
|
attachmentMaxBytes: 1024 * 1024 * 1024,
|
|
});
|
|
});
|
|
|
|
it("accepts zip uploads for issue attachments", async () => {
|
|
const storage = createStorageService();
|
|
mockIssueService.getById.mockResolvedValue({
|
|
id: "11111111-1111-4111-8111-111111111111",
|
|
companyId: "company-1",
|
|
identifier: "PAP-1",
|
|
});
|
|
mockIssueService.createAttachment.mockResolvedValue(makeAttachment("application/zip", "bundle.zip"));
|
|
|
|
const app = await createApp(storage);
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
|
|
.attach("file", Buffer.from("zip"), { filename: "bundle.zip", contentType: "application/zip" });
|
|
|
|
expect([200, 201]).toContain(res.status);
|
|
const putFileCall = storage.__calls.putFile;
|
|
expect(putFileCall).toMatchObject({
|
|
companyId: "company-1",
|
|
namespace: "issues/11111111-1111-4111-8111-111111111111",
|
|
originalFilename: "bundle.zip",
|
|
contentType: "application/zip",
|
|
});
|
|
expect(Buffer.isBuffer(putFileCall?.body)).toBe(true);
|
|
expect(mockIssueService.createAttachment).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
issueId: "11111111-1111-4111-8111-111111111111",
|
|
contentType: "application/zip",
|
|
originalFilename: "bundle.zip",
|
|
}),
|
|
);
|
|
expect(res.body.contentType).toBe("application/zip");
|
|
});
|
|
|
|
it("enforces the process-level issue attachment limit even when the company limit allows more", async () => {
|
|
const storage = createStorageService();
|
|
mockIssueService.getById.mockResolvedValue({
|
|
id: "11111111-1111-4111-8111-111111111111",
|
|
companyId: "company-1",
|
|
identifier: "PAP-1",
|
|
});
|
|
mockIssueService.createAttachment.mockResolvedValue(makeAttachment("application/octet-stream", "large.bin"));
|
|
|
|
const app = await createApp(storage);
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
|
|
.attach("file", Buffer.alloc(10 * 1024 * 1024 + 1), {
|
|
filename: "large.bin",
|
|
contentType: "application/octet-stream",
|
|
});
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.error).toBe("Attachment exceeds 10485760 bytes");
|
|
expect(storage.__calls.putFile).toBeUndefined();
|
|
});
|
|
|
|
it("enforces the configured per-company issue attachment limit", async () => {
|
|
const storage = createStorageService();
|
|
mockCompanyService.getById.mockResolvedValue({
|
|
id: "company-1",
|
|
attachmentMaxBytes: 4,
|
|
});
|
|
mockIssueService.getById.mockResolvedValue({
|
|
id: "11111111-1111-4111-8111-111111111111",
|
|
companyId: "company-1",
|
|
identifier: "PAP-1",
|
|
});
|
|
|
|
const app = await createApp(storage);
|
|
const res = await request(app)
|
|
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
|
|
.attach("file", Buffer.from("large"), { filename: "large.txt", contentType: "text/plain" });
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(res.body.error).toBe("Attachment exceeds 4 bytes");
|
|
expect(mockIssueService.createAttachment).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("serves html attachments as downloads with nosniff", async () => {
|
|
const storage = createStorageService();
|
|
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("text/html", "report.html"));
|
|
|
|
const app = await createApp(storage);
|
|
const res = await request(app).get("/api/attachments/attachment-1/content");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect([
|
|
undefined,
|
|
'attachment; filename="report.html"',
|
|
]).toContain(res.headers["content-disposition"]);
|
|
expect(res.headers["x-content-type-options"]).toBe("nosniff");
|
|
});
|
|
|
|
it("keeps image attachments inline for previews", async () => {
|
|
const storage = createStorageService();
|
|
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("image/png", "preview.png"));
|
|
|
|
const app = await createApp(storage);
|
|
const res = await request(app).get("/api/attachments/attachment-1/content");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect([
|
|
undefined,
|
|
'inline; filename="preview.png"',
|
|
]).toContain(res.headers["content-disposition"]);
|
|
});
|
|
});
|