mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
Merge origin/master into connection grant resolution
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
f5cbb2ac57
66 files changed
+3676
-1602
No files matched your search
@@ -0,0 +1,95 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Operator-hidden Access surface floor (`instance.access` in
|
||||
* PAPERCLIP_HIDDEN_SETTINGS): instance-admin user management routes reject
|
||||
* with a stable code while the rest of the access router stays untouched.
|
||||
* The floor throws before any data access, so a stub db suffices; the
|
||||
* unfloored happy paths are covered by the embedded-postgres access tests.
|
||||
*/
|
||||
|
||||
const stubDb = {
|
||||
select: () => ({
|
||||
from: () => {
|
||||
const chain = {
|
||||
orderBy: async () => [] as unknown[],
|
||||
where: async () => [] as unknown[],
|
||||
};
|
||||
return chain;
|
||||
},
|
||||
}),
|
||||
} as never;
|
||||
|
||||
async function createApp() {
|
||||
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/access.js"),
|
||||
import("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = {
|
||||
type: "board",
|
||||
userId: "instance-admin-1",
|
||||
source: "local_implicit",
|
||||
companyIds: ["company-1"],
|
||||
memberships: [{ companyId: "company-1", membershipRole: "owner", status: "active" }],
|
||||
isInstanceAdmin: true,
|
||||
} as Express.Request["actor"];
|
||||
next();
|
||||
});
|
||||
app.use("/api", accessRoutes(stubDb, {
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
}));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("operator-hidden access admin floor", () => {
|
||||
let app: express.Express;
|
||||
|
||||
// The access router is a large module; import and build the app once so the
|
||||
// cost is not charged to the first test's timeout on slow CI runners.
|
||||
beforeAll(async () => {
|
||||
app = await createApp();
|
||||
}, 30_000);
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.PAPERCLIP_HIDDEN_SETTINGS;
|
||||
});
|
||||
|
||||
const attempts: Array<[string, () => request.Test]> = [
|
||||
["promote", () => request(app).post("/api/admin/users/user-1/promote-instance-admin")],
|
||||
["demote", () => request(app).post("/api/admin/users/user-1/demote-instance-admin")],
|
||||
[
|
||||
"company access write",
|
||||
() => request(app).put("/api/admin/users/user-1/company-access").send({ companyIds: [] }),
|
||||
],
|
||||
["user listing", () => request(app).get("/api/admin/users")],
|
||||
["company access read", () => request(app).get("/api/admin/users/user-1/company-access")],
|
||||
];
|
||||
|
||||
it.each(attempts)(
|
||||
"floors the %s route when the operator hides the surface",
|
||||
async (_name, buildRequest) => {
|
||||
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.access";
|
||||
|
||||
const res = await buildRequest();
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.details).toMatchObject({ code: "settings_operator_managed" });
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the routes reachable when the surface is not hidden", async () => {
|
||||
const res = await request(app).get("/api/admin/users");
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -367,4 +367,41 @@ describe.sequential("adapter management route authorization", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("operator-hidden adapter management floor", () => {
|
||||
beforeEach(() => {
|
||||
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.adapters";
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.PAPERCLIP_HIDDEN_SETTINGS;
|
||||
});
|
||||
|
||||
it.each(["install", "disable", "override", "delete", "reload", "reinstall"] as const)(
|
||||
"floors adapter %s for instance admins when the operator hides the Adapters surface",
|
||||
async (routeName) => {
|
||||
resetInstalledExternalAdapterState();
|
||||
if (routeName !== "install") {
|
||||
seedInstalledExternalAdapter();
|
||||
}
|
||||
const app = createApp(instanceAdmin);
|
||||
|
||||
const res = await sendMutatingRequest(app, routeName);
|
||||
|
||||
expect(res.status, `${routeName}: ${JSON.stringify(res.body)}`).toBe(403);
|
||||
expect(res.body.details).toMatchObject({ code: "settings_operator_managed" });
|
||||
expect(mocks.execFile).not.toHaveBeenCalled();
|
||||
expect(mocks.loadExternalAdapterPackage).not.toHaveBeenCalled();
|
||||
expect(mocks.reloadExternalAdapter).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps adapter reads open while the surface is hidden", async () => {
|
||||
seedInstalledExternalAdapter();
|
||||
const app = createApp(boardMember("admin"));
|
||||
|
||||
const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/adapters"));
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
|
||||
const ISSUE_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const WORK_PRODUCT_ID = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
getByIdentifier: vi.fn(),
|
||||
getAttachmentById: vi.fn(),
|
||||
}));
|
||||
const mockCompanyService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
const mockWorkProductService = vi.hoisted(() => ({
|
||||
createForIssue: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
decide: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
explanation: "Allowed by test mock",
|
||||
})),
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const mockEnsureForWorkProduct = vi.hoisted(() => vi.fn());
|
||||
const mockSyncDocumentSafely = 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/artifact-review-documents.js", () => ({
|
||||
artifactReviewDocumentService: () => ({
|
||||
ensureForWorkProduct: mockEnsureForWorkProduct,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.doMock("../services/external-objects.js", () => ({
|
||||
externalObjectService: () => ({
|
||||
syncDocumentSafely: mockSyncDocumentSafely,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
companySkillService: () => ({}),
|
||||
companyService: () => mockCompanyService,
|
||||
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||
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,
|
||||
}),
|
||||
issueThreadInteractionService: () => ({
|
||||
listForIssue: vi.fn(async () => []),
|
||||
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
|
||||
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
|
||||
}),
|
||||
issueRecoveryActionService: () => ({
|
||||
getActiveForIssue: vi.fn(async () => null),
|
||||
listActiveForIssues: vi.fn(async () => new Map()),
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => mockWorkProductService,
|
||||
}));
|
||||
}
|
||||
|
||||
function createStorageService(): StorageService {
|
||||
return {
|
||||
provider: "local_disk",
|
||||
putFile: vi.fn(),
|
||||
getObject: vi.fn(),
|
||||
headObject: vi.fn(),
|
||||
deleteObject: vi.fn(),
|
||||
} as unknown as StorageService;
|
||||
}
|
||||
|
||||
async function createApp(options?: { companyIds?: string[] }) {
|
||||
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(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: options?.companyIds ?? ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, createStorageService()));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
function makeIssue() {
|
||||
return {
|
||||
id: ISSUE_ID,
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-1",
|
||||
projectId: null,
|
||||
status: "in_progress",
|
||||
assigneeAgentId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeWorkProduct(overrides: Record<string, unknown> = {}) {
|
||||
const attachmentId = "55555555-5555-4555-8555-555555555555";
|
||||
const contentPath = `/api/attachments/${attachmentId}/content`;
|
||||
return {
|
||||
id: WORK_PRODUCT_ID,
|
||||
companyId: "company-1",
|
||||
issueId: ISSUE_ID,
|
||||
type: "artifact",
|
||||
provider: "paperclip",
|
||||
title: "Verification report",
|
||||
metadata: {
|
||||
attachmentId,
|
||||
contentType: "text/markdown",
|
||||
byteSize: 128,
|
||||
contentPath,
|
||||
openPath: contentPath,
|
||||
downloadPath: `${contentPath}?download=1`,
|
||||
originalFilename: "verification-report.md",
|
||||
},
|
||||
createdByRunId: null,
|
||||
sourceTrust: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDocument(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
companyId: "company-1",
|
||||
issueId: ISSUE_ID,
|
||||
key: `artifact-review-${WORK_PRODUCT_ID}`,
|
||||
title: "Verification report",
|
||||
format: "markdown",
|
||||
body: "# Report",
|
||||
latestRevisionId: "44444444-4444-4444-8444-444444444444",
|
||||
latestRevisionNumber: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("work product review-document route", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
registerRouteMocks();
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue());
|
||||
mockWorkProductService.getById.mockResolvedValue(makeWorkProduct());
|
||||
});
|
||||
|
||||
it("returns 404 when the work product belongs to another issue", async () => {
|
||||
mockWorkProductService.getById.mockResolvedValue(
|
||||
makeWorkProduct({ issueId: "55555555-5555-4555-8555-555555555555" }),
|
||||
);
|
||||
|
||||
const app = await createApp();
|
||||
const res = await request(app).post(
|
||||
`/api/issues/${ISSUE_ID}/work-products/${WORK_PRODUCT_ID}/review-document`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(mockEnsureForWorkProduct).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 404 when the work product does not exist", async () => {
|
||||
mockWorkProductService.getById.mockResolvedValue(null);
|
||||
|
||||
const app = await createApp();
|
||||
const res = await request(app).post(
|
||||
`/api/issues/${ISSUE_ID}/work-products/${WORK_PRODUCT_ID}/review-document`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(mockEnsureForWorkProduct).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("materializes the review document and logs document creation", async () => {
|
||||
mockEnsureForWorkProduct.mockResolvedValue({
|
||||
document: makeDocument(),
|
||||
created: true,
|
||||
revisionChanged: true,
|
||||
remappedAnnotations: [],
|
||||
});
|
||||
|
||||
const app = await createApp();
|
||||
const res = await request(app).post(
|
||||
`/api/issues/${ISSUE_ID}/work-products/${WORK_PRODUCT_ID}/review-document`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.key).toBe(`artifact-review-${WORK_PRODUCT_ID}`);
|
||||
expect(mockEnsureForWorkProduct).toHaveBeenCalledWith({
|
||||
issue: { id: ISSUE_ID, companyId: "company-1" },
|
||||
workProduct: expect.objectContaining({ id: WORK_PRODUCT_ID }),
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.document_created",
|
||||
details: expect.objectContaining({
|
||||
workProductId: WORK_PRODUCT_ID,
|
||||
artifactReviewDocument: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockSyncDocumentSafely).toHaveBeenCalledWith(makeDocument().id);
|
||||
});
|
||||
|
||||
it("returns 200 without side effects when the document is already current", async () => {
|
||||
mockEnsureForWorkProduct.mockResolvedValue({
|
||||
document: makeDocument(),
|
||||
created: false,
|
||||
revisionChanged: false,
|
||||
remappedAnnotations: [],
|
||||
});
|
||||
|
||||
const app = await createApp();
|
||||
const res = await request(app).post(
|
||||
`/api/issues/${ISSUE_ID}/work-products/${WORK_PRODUCT_ID}/review-document`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.id).toBe(makeDocument().id);
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
expect(mockSyncDocumentSafely).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes typed materialization errors through to the response", async () => {
|
||||
const { HttpError } = await vi.importActual<typeof import("../errors.js")>("../errors.js");
|
||||
mockEnsureForWorkProduct.mockRejectedValue(
|
||||
new HttpError(415, "Work product attachment is not Markdown"),
|
||||
);
|
||||
|
||||
const app = await createApp();
|
||||
const res = await request(app).post(
|
||||
`/api/issues/${ISSUE_ID}/work-products/${WORK_PRODUCT_ID}/review-document`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(415);
|
||||
expect(res.body.error).toBe("Work product attachment is not Markdown");
|
||||
});
|
||||
|
||||
it("resynchronizes an eligible review document after a title-only work product update", async () => {
|
||||
const renamed = makeWorkProduct({ title: "Renamed verification report" });
|
||||
mockWorkProductService.update.mockResolvedValue(renamed);
|
||||
mockEnsureForWorkProduct.mockResolvedValue({
|
||||
document: makeDocument({ title: "Renamed verification report", latestRevisionNumber: 2 }),
|
||||
created: false,
|
||||
revisionChanged: true,
|
||||
remappedAnnotations: [],
|
||||
});
|
||||
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.patch(`/api/work-products/${WORK_PRODUCT_ID}`)
|
||||
.send({ title: "Renamed verification report" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockEnsureForWorkProduct).toHaveBeenCalledWith({
|
||||
issue: { id: ISSUE_ID, companyId: "company-1" },
|
||||
workProduct: renamed,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Readable } from "node:stream";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
assets,
|
||||
companies,
|
||||
createDb,
|
||||
documentRevisions,
|
||||
documents,
|
||||
heartbeatRuns,
|
||||
issueAttachments,
|
||||
issueDocuments,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import { artifactReviewDocumentKey } from "@paperclipai/shared";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { artifactReviewDocumentService } from "../services/artifact-review-documents.js";
|
||||
import { documentService } from "../services/documents.js";
|
||||
import { HttpError } from "../errors.js";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres artifact review document tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
function createStorageService(files: Record<string, Buffer> = {}): StorageService {
|
||||
return {
|
||||
provider: "local_disk",
|
||||
putFile: vi.fn(),
|
||||
getObject: vi.fn(async (_companyId, objectKey, options) => {
|
||||
const body = files[objectKey] ?? Buffer.alloc(0);
|
||||
const range = options?.range;
|
||||
const ranged = range ? body.subarray(range.start, range.end + 1) : body;
|
||||
return {
|
||||
stream: Readable.from(ranged),
|
||||
contentType: "text/markdown",
|
||||
contentLength: ranged.length,
|
||||
};
|
||||
}),
|
||||
headObject: vi.fn(),
|
||||
deleteObject: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("artifactReviewDocumentService", () => {
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-artifact-review-docs-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(documentRevisions);
|
||||
await db.delete(issueDocuments);
|
||||
await db.delete(documents);
|
||||
await db.delete(issueAttachments);
|
||||
await db.delete(assets);
|
||||
await db.delete(issues);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedFixture(options: {
|
||||
body?: Buffer;
|
||||
contentType?: string;
|
||||
originalFilename?: string | null;
|
||||
byteSize?: number;
|
||||
} = {}) {
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const assetId = randomUUID();
|
||||
const attachmentId = randomUUID();
|
||||
const workProductId = randomUUID();
|
||||
const body = options.body ?? Buffer.from("# Report\n\nHello **markdown**.\n", "utf8");
|
||||
const contentType = options.contentType ?? "text/markdown";
|
||||
const originalFilename = options.originalFilename === undefined ? "report.md" : options.originalFilename;
|
||||
const objectKey = `issues/${issueId}/${assetId}`;
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
identifier: "PAP-1534",
|
||||
title: "Bridge markdown work products",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "ReportWriter",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(assets).values({
|
||||
id: assetId,
|
||||
companyId,
|
||||
provider: "local_disk",
|
||||
objectKey,
|
||||
contentType,
|
||||
byteSize: options.byteSize ?? body.length,
|
||||
sha256: `sha256-${assetId}`,
|
||||
originalFilename,
|
||||
createdByAgentId: agentId,
|
||||
});
|
||||
await db.insert(issueAttachments).values({
|
||||
id: attachmentId,
|
||||
companyId,
|
||||
issueId,
|
||||
assetId,
|
||||
});
|
||||
|
||||
const contentPath = `/api/attachments/${attachmentId}/content`;
|
||||
const workProduct = {
|
||||
id: workProductId,
|
||||
companyId,
|
||||
issueId,
|
||||
type: "artifact" as const,
|
||||
provider: "paperclip",
|
||||
metadata: {
|
||||
attachmentId,
|
||||
contentType,
|
||||
byteSize: options.byteSize ?? body.length,
|
||||
contentPath,
|
||||
openPath: contentPath,
|
||||
downloadPath: `${contentPath}?download=1`,
|
||||
originalFilename,
|
||||
},
|
||||
title: "Verification report",
|
||||
createdByRunId: null,
|
||||
sourceTrust: null,
|
||||
};
|
||||
|
||||
const files: Record<string, Buffer> = { [objectKey]: body };
|
||||
const storage = createStorageService(files);
|
||||
const svc = artifactReviewDocumentService(db, storage);
|
||||
return { companyId, issueId, agentId, assetId, attachmentId, workProduct, files, objectKey, svc };
|
||||
}
|
||||
|
||||
it("materializes a review document with promoted attribution", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const result = await fixture.svc.ensureForWorkProduct({
|
||||
issue: { id: fixture.issueId, companyId: fixture.companyId },
|
||||
workProduct: fixture.workProduct,
|
||||
});
|
||||
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.revisionChanged).toBe(true);
|
||||
expect(result.document.key).toBe(artifactReviewDocumentKey(fixture.workProduct.id));
|
||||
expect(result.document.title).toBe("Verification report");
|
||||
expect(result.document.latestRevisionNumber).toBe(1);
|
||||
expect(result.document.latestRevisionId).toBeTruthy();
|
||||
expect(result.document.body).toBe("# Report\n\nHello **markdown**.\n");
|
||||
expect(result.document.createdByAgentId).toBe(fixture.agentId);
|
||||
expect(result.document.createdByUserId).toBeNull();
|
||||
|
||||
const listed = await documentService(db).getIssueDocumentByKey(
|
||||
fixture.issueId,
|
||||
artifactReviewDocumentKey(fixture.workProduct.id),
|
||||
);
|
||||
expect(listed?.id).toBe(result.document.id);
|
||||
});
|
||||
|
||||
it("is idempotent for unchanged content", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const first = await fixture.svc.ensureForWorkProduct({
|
||||
issue: { id: fixture.issueId, companyId: fixture.companyId },
|
||||
workProduct: fixture.workProduct,
|
||||
});
|
||||
const second = await fixture.svc.ensureForWorkProduct({
|
||||
issue: { id: fixture.issueId, companyId: fixture.companyId },
|
||||
workProduct: fixture.workProduct,
|
||||
});
|
||||
|
||||
expect(second.created).toBe(false);
|
||||
expect(second.revisionChanged).toBe(false);
|
||||
expect(second.document.id).toBe(first.document.id);
|
||||
expect(second.document.latestRevisionNumber).toBe(1);
|
||||
|
||||
const revisions = await db.select().from(documentRevisions);
|
||||
expect(revisions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("converges concurrent ensures onto one document", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const input = {
|
||||
issue: { id: fixture.issueId, companyId: fixture.companyId },
|
||||
workProduct: fixture.workProduct,
|
||||
};
|
||||
const [first, second] = await Promise.all([
|
||||
fixture.svc.ensureForWorkProduct(input),
|
||||
fixture.svc.ensureForWorkProduct(input),
|
||||
]);
|
||||
|
||||
expect(first.document.id).toBe(second.document.id);
|
||||
const links = await db.select().from(issueDocuments);
|
||||
expect(links).toHaveLength(1);
|
||||
const revisions = await db.select().from(documentRevisions);
|
||||
expect(revisions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("writes a new revision when the attachment content changes", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const first = await fixture.svc.ensureForWorkProduct({
|
||||
issue: { id: fixture.issueId, companyId: fixture.companyId },
|
||||
workProduct: fixture.workProduct,
|
||||
});
|
||||
|
||||
fixture.files[fixture.objectKey] = Buffer.from("# Report v2\n", "utf8");
|
||||
const second = await fixture.svc.ensureForWorkProduct({
|
||||
issue: { id: fixture.issueId, companyId: fixture.companyId },
|
||||
workProduct: fixture.workProduct,
|
||||
});
|
||||
|
||||
expect(second.created).toBe(false);
|
||||
expect(second.revisionChanged).toBe(true);
|
||||
expect(second.document.id).toBe(first.document.id);
|
||||
expect(second.document.latestRevisionNumber).toBe(2);
|
||||
expect(second.document.body).toBe("# Report v2\n");
|
||||
expect(Array.isArray(second.remappedAnnotations)).toBe(true);
|
||||
});
|
||||
|
||||
it("synchronizes title and run provenance when the attachment body is unchanged", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const first = await fixture.svc.ensureForWorkProduct({
|
||||
issue: { id: fixture.issueId, companyId: fixture.companyId },
|
||||
workProduct: fixture.workProduct,
|
||||
});
|
||||
|
||||
const renamed = await fixture.svc.ensureForWorkProduct({
|
||||
issue: { id: fixture.issueId, companyId: fixture.companyId },
|
||||
workProduct: { ...fixture.workProduct, title: "Renamed verification report" },
|
||||
});
|
||||
expect(renamed.created).toBe(false);
|
||||
expect(renamed.revisionChanged).toBe(true);
|
||||
expect(renamed.document.id).toBe(first.document.id);
|
||||
expect(renamed.document.title).toBe("Renamed verification report");
|
||||
expect(renamed.document.latestRevisionNumber).toBe(2);
|
||||
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId: fixture.companyId,
|
||||
agentId: fixture.agentId,
|
||||
status: "succeeded",
|
||||
});
|
||||
const reattributed = await fixture.svc.ensureForWorkProduct({
|
||||
issue: { id: fixture.issueId, companyId: fixture.companyId },
|
||||
workProduct: {
|
||||
...fixture.workProduct,
|
||||
title: "Renamed verification report",
|
||||
createdByRunId: runId,
|
||||
},
|
||||
});
|
||||
expect(reattributed.revisionChanged).toBe(true);
|
||||
expect(reattributed.document.latestRevisionNumber).toBe(3);
|
||||
|
||||
const latestRevision = await db
|
||||
.select()
|
||||
.from(documentRevisions)
|
||||
.then((rows) => rows.find((row) => row.id === reattributed.document.latestRevisionId));
|
||||
expect(latestRevision?.createdByRunId).toBe(runId);
|
||||
|
||||
const unchanged = await fixture.svc.ensureForWorkProduct({
|
||||
issue: { id: fixture.issueId, companyId: fixture.companyId },
|
||||
workProduct: {
|
||||
...fixture.workProduct,
|
||||
title: "Renamed verification report",
|
||||
createdByRunId: runId,
|
||||
},
|
||||
});
|
||||
expect(unchanged.revisionChanged).toBe(false);
|
||||
expect(unchanged.document.latestRevisionNumber).toBe(3);
|
||||
});
|
||||
|
||||
it("rejects work products that are not attachment-backed Paperclip artifacts", async () => {
|
||||
const fixture = await seedFixture();
|
||||
await expect(
|
||||
fixture.svc.ensureForWorkProduct({
|
||||
issue: { id: fixture.issueId, companyId: fixture.companyId },
|
||||
workProduct: { ...fixture.workProduct, provider: "github" },
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 422 });
|
||||
await expect(
|
||||
fixture.svc.ensureForWorkProduct({
|
||||
issue: { id: fixture.issueId, companyId: fixture.companyId },
|
||||
workProduct: { ...fixture.workProduct, metadata: { attachmentId: "not-a-uuid" } },
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
|
||||
it("rejects non-Markdown attachments with 415", async () => {
|
||||
const fixture = await seedFixture({ contentType: "application/pdf", originalFilename: "report.pdf" });
|
||||
await expect(
|
||||
fixture.svc.ensureForWorkProduct({
|
||||
issue: { id: fixture.issueId, companyId: fixture.companyId },
|
||||
workProduct: fixture.workProduct,
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 415 });
|
||||
});
|
||||
|
||||
it("rejects mismatched issues and cross-issue attachments", async () => {
|
||||
const fixture = await seedFixture();
|
||||
await expect(
|
||||
fixture.svc.ensureForWorkProduct({
|
||||
issue: { id: randomUUID(), companyId: fixture.companyId },
|
||||
workProduct: fixture.workProduct,
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
|
||||
const otherIssueId = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id: otherIssueId,
|
||||
companyId: fixture.companyId,
|
||||
identifier: "PAP-1535",
|
||||
title: "Other issue",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
});
|
||||
await expect(
|
||||
fixture.svc.ensureForWorkProduct({
|
||||
issue: { id: otherIssueId, companyId: fixture.companyId },
|
||||
workProduct: { ...fixture.workProduct, issueId: otherIssueId },
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
|
||||
it("rejects oversized attachments with 413", async () => {
|
||||
const fixture = await seedFixture({ byteSize: 512 * 1024 + 1 });
|
||||
await expect(
|
||||
fixture.svc.ensureForWorkProduct({
|
||||
issue: { id: fixture.issueId, companyId: fixture.companyId },
|
||||
workProduct: fixture.workProduct,
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 413 });
|
||||
});
|
||||
|
||||
it("rejects invalid UTF-8 content with 422", async () => {
|
||||
const fixture = await seedFixture({ body: Buffer.from([0x23, 0x20, 0xff, 0xfe, 0x00]) });
|
||||
await expect(
|
||||
fixture.svc.ensureForWorkProduct({
|
||||
issue: { id: fixture.issueId, companyId: fixture.companyId },
|
||||
workProduct: fixture.workProduct,
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
|
||||
it("surfaces a conflict when the review document is locked and content changed", async () => {
|
||||
const fixture = await seedFixture();
|
||||
await fixture.svc.ensureForWorkProduct({
|
||||
issue: { id: fixture.issueId, companyId: fixture.companyId },
|
||||
workProduct: fixture.workProduct,
|
||||
});
|
||||
await documentService(db).lockIssueDocument({
|
||||
issueId: fixture.issueId,
|
||||
key: artifactReviewDocumentKey(fixture.workProduct.id),
|
||||
lockedByUserId: "user-board",
|
||||
});
|
||||
|
||||
// Unchanged content still resolves without a write.
|
||||
const unchanged = await fixture.svc.ensureForWorkProduct({
|
||||
issue: { id: fixture.issueId, companyId: fixture.companyId },
|
||||
workProduct: fixture.workProduct,
|
||||
});
|
||||
expect(unchanged.revisionChanged).toBe(false);
|
||||
|
||||
fixture.files[fixture.objectKey] = Buffer.from("# Locked update\n", "utf8");
|
||||
await expect(
|
||||
fixture.svc.ensureForWorkProduct({
|
||||
issue: { id: fixture.issueId, companyId: fixture.companyId },
|
||||
workProduct: fixture.workProduct,
|
||||
}),
|
||||
).rejects.toSatisfy((error: unknown) => error instanceof HttpError && error.status === 409);
|
||||
});
|
||||
});
|
||||
@@ -116,6 +116,26 @@ describe("GET /health", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("lists operator-hidden settings and drops unknown keys", async () => {
|
||||
const app = createApp(undefined, testServerInfo, undefined, {
|
||||
PAPERCLIP_HIDDEN_SETTINGS: "instance.plugins,instance.adapters,instance.bogus",
|
||||
});
|
||||
|
||||
const res = await request(app).get("/health");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hiddenSettings).toEqual(["instance.plugins", "instance.adapters"]);
|
||||
});
|
||||
|
||||
it("omits hiddenSettings entirely when nothing is hidden", async () => {
|
||||
const app = createApp(undefined, testServerInfo, undefined, {});
|
||||
|
||||
const res = await request(app).get("/health");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Object.prototype.hasOwnProperty.call(res.body, "hiddenSettings")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 200 when the database probe succeeds", async () => {
|
||||
const db = {
|
||||
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
|
||||
|
||||
@@ -133,6 +133,18 @@ describe("mergeHeartbeatRunResultJson", () => {
|
||||
expect(buildHeartbeatRunIssueComment(merged)).toBe("## Summary\n\n1. first thing\n2. second thing");
|
||||
});
|
||||
|
||||
it("posts only the final adapter summary when raw output contains intermediate narration", () => {
|
||||
const merged = mergeHeartbeatRunResultJson(
|
||||
{ stdout: "Intermediate setup that must not be published" },
|
||||
"## Final update\n\n- Remediation verified",
|
||||
);
|
||||
|
||||
expect(buildHeartbeatRunIssueComment(merged)).toBe(
|
||||
"## Final update\n\n- Remediation verified",
|
||||
);
|
||||
expect(buildHeartbeatRunIssueComment(merged)).not.toContain("Intermediate setup");
|
||||
});
|
||||
|
||||
it("creates a result payload when only a summary exists", () => {
|
||||
expect(mergeHeartbeatRunResultJson(null, "done")).toEqual({ summary: "done" });
|
||||
});
|
||||
|
||||
@@ -805,4 +805,123 @@ describe("instance settings routes", () => {
|
||||
expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({ executionMode: "kubernetes" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("operator-hidden settings floor", () => {
|
||||
const adminActor = {
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: ["company-1"],
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.PAPERCLIP_HIDDEN_SETTINGS;
|
||||
});
|
||||
|
||||
it("rejects a write that changes a hidden general field", async () => {
|
||||
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.general.censorUsernameInLogs";
|
||||
const app = await createApp(adminActor);
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/instance/settings/general")
|
||||
.send({ censorUsernameInLogs: true });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.details).toMatchObject({ code: "settings_operator_managed" });
|
||||
expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a same-value echo of a hidden general field", async () => {
|
||||
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.general.censorUsernameInLogs";
|
||||
const app = await createApp(adminActor);
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/instance/settings/general")
|
||||
.send({ censorUsernameInLogs: false, keyboardShortcuts: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({
|
||||
censorUsernameInLogs: false,
|
||||
keyboardShortcuts: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("deep-compares hidden backupRetention echoes instead of rejecting them", async () => {
|
||||
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.general.backupRetention";
|
||||
mockInstanceSettingsService.getGeneral.mockResolvedValue({
|
||||
censorUsernameInLogs: false,
|
||||
keyboardShortcuts: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
backupRetention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
|
||||
});
|
||||
const app = await createApp(adminActor);
|
||||
|
||||
const echo = await request(app)
|
||||
.patch("/api/instance/settings/general")
|
||||
.send({ backupRetention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 } });
|
||||
expect(echo.status).toBe(200);
|
||||
|
||||
const change = await request(app)
|
||||
.patch("/api/instance/settings/general")
|
||||
.send({ backupRetention: { dailyDays: 14, weeklyWeeks: 4, monthlyMonths: 1 } });
|
||||
expect(change.status).toBe(403);
|
||||
expect(change.body.details).toMatchObject({ code: "settings_operator_managed" });
|
||||
});
|
||||
|
||||
it("rejects a write that changes a hidden experimental toggle", async () => {
|
||||
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.experimental.enableEnvironments";
|
||||
const app = await createApp(adminActor);
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
.send({ enableEnvironments: true });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.details).toMatchObject({ code: "settings_operator_managed" });
|
||||
expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows writes to non-hidden experimental toggles while others are hidden", async () => {
|
||||
process.env.PAPERCLIP_HIDDEN_SETTINGS =
|
||||
"instance.experimental.enableEnvironments,instance.experimental.enableServerInfoDebugView";
|
||||
const app = await createApp(adminActor);
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
.send({ enableIsolatedWorkspaces: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
|
||||
enableIsolatedWorkspaces: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("floors every experimental toggle when the whole Experimental page is hidden", async () => {
|
||||
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.experimental";
|
||||
const app = await createApp(adminActor);
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
.send({ enableIsolatedWorkspaces: true });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.details).toMatchObject({ code: "settings_operator_managed" });
|
||||
expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps every field writable when the env var is unset", async () => {
|
||||
const app = await createApp(adminActor);
|
||||
|
||||
const general = await request(app)
|
||||
.patch("/api/instance/settings/general")
|
||||
.send({ censorUsernameInLogs: true });
|
||||
expect(general.status).toBe(200);
|
||||
|
||||
const experimental = await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
.send({ enableEnvironments: true });
|
||||
expect(experimental.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -515,6 +515,36 @@ describe("issue attachment routes", () => {
|
||||
expect(res.headers["x-content-type-options"]).toBe("nosniff");
|
||||
});
|
||||
|
||||
it("declares utf-8 for inline markdown attachments", async () => {
|
||||
const storage = createStorageService(Buffer.from("# Hello\n"));
|
||||
mockIssueService.getAttachmentById.mockResolvedValue({
|
||||
...makeAttachment("text/markdown", "notes.md"),
|
||||
byteSize: 8,
|
||||
});
|
||||
|
||||
const app = await createApp(storage);
|
||||
const res = await request(app).get("/api/attachments/attachment-1/content");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-type"]).toBe("text/markdown; charset=utf-8");
|
||||
expect(res.headers["x-content-type-options"]).toBe("nosniff");
|
||||
});
|
||||
|
||||
it("keeps the charset declaration on forced markdown downloads", async () => {
|
||||
const storage = createStorageService(Buffer.from("# Hello\n"));
|
||||
mockIssueService.getAttachmentById.mockResolvedValue({
|
||||
...makeAttachment("text/markdown", "notes.md"),
|
||||
byteSize: 8,
|
||||
});
|
||||
|
||||
const app = await createApp(storage);
|
||||
const res = await request(app).get("/api/attachments/attachment-1/content?download=1");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-type"]).toBe("text/markdown; charset=utf-8");
|
||||
expect(res.headers["content-disposition"]).toBe('attachment; filename="notes.md"');
|
||||
});
|
||||
|
||||
it("keeps image attachments inline for previews", async () => {
|
||||
const storage = createStorageService();
|
||||
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("image/png", "preview.png"));
|
||||
|
||||
@@ -210,6 +210,11 @@ describe("openapi routes", () => {
|
||||
},
|
||||
});
|
||||
expect(res.body.paths["/api/companies/{companyId}/folders"].post.responses["201"]).toBeDefined();
|
||||
expect(
|
||||
Object.keys(
|
||||
res.body.paths["/api/issues/{id}/work-products/{workProductId}/review-document"].post.responses,
|
||||
).sort(),
|
||||
).toEqual(["200", "201", "401", "403", "404", "409", "413", "415", "422"]);
|
||||
expect(
|
||||
res.body.paths["/api/issues/{id}/interactions/{interactionId}/withdraw"].post.summary,
|
||||
).toBe("Withdraw a pending issue thread interaction");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockRegistry = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
@@ -1103,3 +1103,55 @@ describe.sequential("plugin tool and bridge authz", () => {
|
||||
expect(executeTool).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("operator-hidden plugin management floor", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.plugins";
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.PAPERCLIP_HIDDEN_SETTINGS;
|
||||
});
|
||||
|
||||
const instanceAdmin = () => boardActor({ isInstanceAdmin: true, userId: "instance-admin" });
|
||||
|
||||
it("floors plugin lifecycle and config writes for instance admins", async () => {
|
||||
const { app, loader } = await createApp(instanceAdmin());
|
||||
readyPlugin();
|
||||
|
||||
const attempts: Array<[string, request.Test]> = [
|
||||
["install", request(app).post("/api/plugins/install").send({ packageName: "@paperclipai/plugin-modal" })],
|
||||
["uninstall", request(app).delete(`/api/plugins/${pluginId}`)],
|
||||
["enable", request(app).post(`/api/plugins/${pluginId}/enable`)],
|
||||
["disable", request(app).post(`/api/plugins/${pluginId}/disable`).send({})],
|
||||
["upgrade", request(app).post(`/api/plugins/${pluginId}/upgrade`).send({})],
|
||||
["config", request(app).post(`/api/plugins/${pluginId}/config`).send({ config: {} })],
|
||||
["config test", request(app).post(`/api/plugins/${pluginId}/config/test`).send({ config: {} })],
|
||||
[
|
||||
"local folder",
|
||||
request(app)
|
||||
.put(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/data`)
|
||||
.send({ path: "/tmp/folder" }),
|
||||
],
|
||||
];
|
||||
for (const [name, attempt] of attempts) {
|
||||
const res = await attempt;
|
||||
expect(res.status, `${name}: ${JSON.stringify(res.body)}`).toBe(403);
|
||||
expect(res.body.details, name).toMatchObject({ code: "settings_operator_managed" });
|
||||
}
|
||||
expect(loader.installPlugin).not.toHaveBeenCalled();
|
||||
expect(mockLifecycle.enable).not.toHaveBeenCalled();
|
||||
expect(mockLifecycle.disable).not.toHaveBeenCalled();
|
||||
expect(mockLifecycle.upgrade).not.toHaveBeenCalled();
|
||||
expect(mockLifecycle.unload).not.toHaveBeenCalled();
|
||||
expect(mockRegistry.upsertConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps plugin reads open while the surface is hidden", async () => {
|
||||
const { app } = await createApp(boardActor());
|
||||
|
||||
const res = await request(app).get("/api/plugins/examples");
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -4309,7 +4309,11 @@ describe("ensureRuntimeServicesForRun", () => {
|
||||
},
|
||||
adapterEnv: {},
|
||||
}),
|
||||
).rejects.toThrow(/Readiness check failed for http:\/\/127\.0\.0\.1:\d+\/api\/health: received HTTP 503/);
|
||||
// The 503 health server must never pass readiness. Assert only that the
|
||||
// readiness check fails for the health URL. Do not assert the exact reason
|
||||
// string: under load the last probe near the deadline can get a small
|
||||
// budget and abort with a timeout before it reads the HTTP 503 response.
|
||||
).rejects.toThrow(/Readiness check failed for http:\/\/127\.0\.0\.1:\d+\/api\/health/);
|
||||
} finally {
|
||||
await releaseRuntimeServicesForRun(runId);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,14 @@ export function conflict(message: string, details?: unknown) {
|
||||
return new HttpError(409, message, details);
|
||||
}
|
||||
|
||||
export function payloadTooLarge(message: string, details?: unknown) {
|
||||
return new HttpError(413, message, details);
|
||||
}
|
||||
|
||||
export function unsupportedMediaType(message: string, details?: unknown) {
|
||||
return new HttpError(415, message, details);
|
||||
}
|
||||
|
||||
export function unprocessable(message: string, details?: unknown) {
|
||||
return new HttpError(422, message, details);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,22 @@ import {
|
||||
badRequest,
|
||||
tooManyRequests
|
||||
} from "../errors.js";
|
||||
import { getHiddenSettings } from "../services/settings-visibility.js";
|
||||
|
||||
/**
|
||||
* Floor: when the hosting operator hides the Instance Access surface
|
||||
* (`instance.access` in PAPERCLIP_HIDDEN_SETTINGS), instance-admin user
|
||||
* management is rejected alongside it — user administration then belongs to
|
||||
* the operator's own control plane. Applies to the Access page's reads too;
|
||||
* invite and company-membership routes are company-scoped and stay open.
|
||||
*/
|
||||
function assertAccessAdminVisible() {
|
||||
if (getHiddenSettings().has("instance.access")) {
|
||||
throw forbidden("Instance user administration is managed by the hosting operator on this instance", {
|
||||
code: "settings_operator_managed",
|
||||
});
|
||||
}
|
||||
}
|
||||
import {
|
||||
createInviteRateLimiter,
|
||||
type InviteRateLimiter,
|
||||
@@ -4774,6 +4790,7 @@ export function accessRoutes(
|
||||
"/admin/users/:userId/promote-instance-admin",
|
||||
async (req, res) => {
|
||||
await assertInstanceAdmin(req);
|
||||
assertAccessAdminVisible();
|
||||
const userId = req.params.userId as string;
|
||||
const result = await access.promoteInstanceAdmin(userId);
|
||||
res.status(201).json(result);
|
||||
@@ -4782,6 +4799,7 @@ export function accessRoutes(
|
||||
|
||||
router.get("/admin/users", async (req, res) => {
|
||||
await assertInstanceAdmin(req);
|
||||
assertAccessAdminVisible();
|
||||
const query = searchAdminUsersQuerySchema.parse(req.query);
|
||||
const needle = query.query.trim().toLowerCase();
|
||||
const users = await db
|
||||
@@ -4844,6 +4862,7 @@ export function accessRoutes(
|
||||
"/admin/users/:userId/demote-instance-admin",
|
||||
async (req, res) => {
|
||||
await assertInstanceAdmin(req);
|
||||
assertAccessAdminVisible();
|
||||
const userId = req.params.userId as string;
|
||||
const removed = await access.demoteInstanceAdmin(userId);
|
||||
if (!removed) throw notFound("Instance admin role not found");
|
||||
@@ -4853,6 +4872,7 @@ export function accessRoutes(
|
||||
|
||||
router.get("/admin/users/:userId/company-access", async (req, res) => {
|
||||
await assertInstanceAdmin(req);
|
||||
assertAccessAdminVisible();
|
||||
const userId = req.params.userId as string;
|
||||
res.json(await loadUserCompanyAccessResponse(db, access, userId));
|
||||
});
|
||||
@@ -4862,6 +4882,7 @@ export function accessRoutes(
|
||||
validate(updateUserCompanyAccessSchema),
|
||||
async (req, res) => {
|
||||
await assertInstanceAdmin(req);
|
||||
assertAccessAdminVisible();
|
||||
const userId = req.params.userId as string;
|
||||
await access.setUserCompanyAccess(
|
||||
userId,
|
||||
|
||||
@@ -50,6 +50,7 @@ import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSour
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { isCloudManagedInstance } from "../services/cloud-instance.js";
|
||||
import { getHiddenSettings } from "../services/settings-visibility.js";
|
||||
import { assertBoardOrgAccess, assertInstanceAdmin } from "./authz.js";
|
||||
import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js";
|
||||
|
||||
@@ -71,6 +72,20 @@ function assertAdapterCodeInstallAllowed() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Floor: when the hosting operator hides the Adapters settings surface
|
||||
* (`instance.adapters` in PAPERCLIP_HIDDEN_SETTINGS), adapter management
|
||||
* writes are rejected alongside it. Reads stay open — adapter metadata is
|
||||
* consumed by agent-creation UIs outside the hidden page.
|
||||
*/
|
||||
function assertAdapterManagementVisible() {
|
||||
if (getHiddenSettings().has("instance.adapters")) {
|
||||
throw forbidden("Adapter management is managed by the hosting operator on this instance", {
|
||||
code: "settings_operator_managed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request / Response types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -288,6 +303,7 @@ export function adapterRoutes() {
|
||||
router.post("/adapters/install", async (req, res) => {
|
||||
assertInstanceAdmin(req);
|
||||
assertAdapterCodeInstallAllowed();
|
||||
assertAdapterManagementVisible();
|
||||
|
||||
const { packageName, isLocalPath = false, version } = req.body as AdapterInstallRequest;
|
||||
|
||||
@@ -435,6 +451,8 @@ export function adapterRoutes() {
|
||||
router.patch("/adapters/:type", async (req, res) => {
|
||||
assertInstanceAdmin(req);
|
||||
|
||||
assertAdapterManagementVisible();
|
||||
|
||||
const adapterType = req.params.type;
|
||||
const { disabled } = req.body as { disabled?: boolean };
|
||||
|
||||
@@ -470,6 +488,8 @@ export function adapterRoutes() {
|
||||
router.patch("/adapters/:type/override", async (req, res) => {
|
||||
assertInstanceAdmin(req);
|
||||
|
||||
assertAdapterManagementVisible();
|
||||
|
||||
const adapterType = req.params.type;
|
||||
const { paused } = req.body as { paused?: boolean };
|
||||
|
||||
@@ -497,6 +517,7 @@ export function adapterRoutes() {
|
||||
*/
|
||||
router.delete("/adapters/:type", async (req, res) => {
|
||||
assertInstanceAdmin(req);
|
||||
assertAdapterManagementVisible();
|
||||
|
||||
const adapterType = req.params.type;
|
||||
|
||||
@@ -573,6 +594,7 @@ export function adapterRoutes() {
|
||||
*/
|
||||
router.post("/adapters/:type/reload", async (req, res) => {
|
||||
assertInstanceAdmin(req);
|
||||
assertAdapterManagementVisible();
|
||||
|
||||
const type = req.params.type;
|
||||
|
||||
@@ -626,6 +648,7 @@ export function adapterRoutes() {
|
||||
router.post("/adapters/:type/reinstall", async (req, res) => {
|
||||
assertInstanceAdmin(req);
|
||||
assertAdapterCodeInstallAllowed();
|
||||
assertAdapterManagementVisible();
|
||||
|
||||
const type = req.params.type;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
isCloudManagedInstance,
|
||||
type CloudInstanceEnv,
|
||||
} from "../services/cloud-instance.js";
|
||||
import { getHiddenSettings } from "../services/settings-visibility.js";
|
||||
import {
|
||||
inspectDatabaseBackupHealth,
|
||||
type DatabaseBackupHealthStatus,
|
||||
@@ -157,6 +158,11 @@ export function healthRoutes(
|
||||
);
|
||||
const runtimeEnv = opts.runtimeEnv ?? process.env;
|
||||
const cloud = getCloudHealthStatus(runtimeEnv);
|
||||
// Operator-hidden settings ride every response (like `cloud`): the list
|
||||
// holds UI surface names only, and the settings nav needs it before any
|
||||
// fuller-detail fetch. Omitted entirely when nothing is hidden, so
|
||||
// deployments without the env var keep today's byte-identical responses.
|
||||
const hiddenSettings = [...getHiddenSettings(runtimeEnv)];
|
||||
// serverInfo (git SHA + process start) rides on the full-details responses
|
||||
// only, so it reaches board/agent actors in authenticated mode or any caller
|
||||
// in local_trusted dev — never anonymous authenticated callers. The
|
||||
@@ -192,12 +198,14 @@ export function healthRoutes(
|
||||
commit,
|
||||
serverInfo,
|
||||
...(cloud ? { cloud } : {}),
|
||||
...(hiddenSettings.length ? { hiddenSettings } : {}),
|
||||
}
|
||||
: {
|
||||
status: "ok",
|
||||
deploymentMode: opts.deploymentMode,
|
||||
commit,
|
||||
...(cloud ? { cloud } : {}),
|
||||
...(hiddenSettings.length ? { hiddenSettings } : {}),
|
||||
},
|
||||
);
|
||||
return;
|
||||
@@ -307,6 +315,7 @@ export function healthRoutes(
|
||||
// this instance becomes visible.
|
||||
...(workspaceReadiness ? { workspace: workspaceReadiness } : {}),
|
||||
...(cloud ? { cloud } : {}),
|
||||
...(hiddenSettings.length ? { hiddenSettings } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -330,6 +339,7 @@ export function healthRoutes(
|
||||
...(devServer ? { devServer } : {}),
|
||||
...(workspaceReadiness ? { workspace: workspaceReadiness } : {}),
|
||||
...(cloud ? { cloud } : {}),
|
||||
...(hiddenSettings.length ? { hiddenSettings } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,12 +8,52 @@ import {
|
||||
} from "@paperclipai/shared";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { isCloudManagedInstance } from "../services/cloud-instance.js";
|
||||
import { getHiddenSettings } from "../services/settings-visibility.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { heartbeatService, instanceSettingsService, logActivity } from "../services/index.js";
|
||||
import { environmentService } from "../services/environments.js";
|
||||
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
|
||||
import { assertBoardOrgAccess, getActorInfo } from "./authz.js";
|
||||
|
||||
function sameJsonValue(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
return (
|
||||
Array.isArray(a)
|
||||
&& Array.isArray(b)
|
||||
&& a.length === b.length
|
||||
&& a.every((value, i) => sameJsonValue(value, b[i]))
|
||||
);
|
||||
}
|
||||
const aKeys = Object.keys(a);
|
||||
const bKeys = new Set(Object.keys(b));
|
||||
return aKeys.length === bKeys.size && aKeys.every((key) =>
|
||||
bKeys.has(key) && sameJsonValue((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Floor writes to operator-hidden settings. Same-value writes pass so clients
|
||||
* that echo a full GET response keep working (the executionMode precedent);
|
||||
* only a write that would actually change a hidden setting is rejected.
|
||||
*/
|
||||
async function assertNoHiddenSettingChanges(
|
||||
body: Record<string, unknown>,
|
||||
getCurrent: () => Promise<object>,
|
||||
isHiddenField: (field: string) => boolean,
|
||||
) {
|
||||
const hiddenKeys = Object.keys(body).filter(isHiddenField);
|
||||
if (hiddenKeys.length === 0) return;
|
||||
const current = (await getCurrent()) as Record<string, unknown>;
|
||||
for (const key of hiddenKeys) {
|
||||
if (sameJsonValue(body[key], current[key])) continue;
|
||||
throw forbidden(`${key} is managed by the hosting operator on this instance`, {
|
||||
code: "settings_operator_managed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function assertCanManageInstanceSettings(req: Request) {
|
||||
if (req.actor.type !== "board") {
|
||||
throw forbidden("Board access required");
|
||||
@@ -122,6 +162,12 @@ export function instanceSettingsRoutes(db: Db) {
|
||||
});
|
||||
}
|
||||
}
|
||||
const hidden = getHiddenSettings();
|
||||
await assertNoHiddenSettingChanges(
|
||||
req.body,
|
||||
() => svc.getGeneral(),
|
||||
(field) => hidden.has(`instance.general.${field}`),
|
||||
);
|
||||
const updated = await svc.updateGeneral(req.body);
|
||||
const actor = getActorInfo(req);
|
||||
const companyIds = await svc.listCompanyIds();
|
||||
@@ -161,6 +207,15 @@ export function instanceSettingsRoutes(db: Db) {
|
||||
validate(patchInstanceExperimentalSettingsSchema),
|
||||
async (req, res) => {
|
||||
assertCanManageInstanceSettings(req);
|
||||
// Hiding the whole Experimental page floors every toggle; otherwise
|
||||
// only individually hidden keys are floored.
|
||||
const hidden = getHiddenSettings();
|
||||
await assertNoHiddenSettingChanges(
|
||||
req.body,
|
||||
() => svc.getExperimental(),
|
||||
(field) =>
|
||||
hidden.has("instance.experimental") || hidden.has(`instance.experimental.${field}`),
|
||||
);
|
||||
const updated = await svc.updateExperimental(req.body);
|
||||
const actor = getActorInfo(req);
|
||||
const companyIds = await svc.listCompanyIds();
|
||||
|
||||
+120
-1
@@ -67,6 +67,8 @@ import {
|
||||
upsertIssueDocumentSchema,
|
||||
updateIssueSchema,
|
||||
isClosedIsolatedExecutionWorkspace,
|
||||
isMarkdownArtifactWorkProduct,
|
||||
isMarkdownAttachmentContent,
|
||||
isUuidLike,
|
||||
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
|
||||
type CompactIssue,
|
||||
@@ -138,6 +140,7 @@ import {
|
||||
routineService,
|
||||
workProductService,
|
||||
} from "../services/index.js";
|
||||
import { artifactReviewDocumentService } from "../services/artifact-review-documents.js";
|
||||
import { assertCanResolveProposal } from "../services/secret-proposal-authorization.js";
|
||||
import { buildDocumentReviewContext, buildPlanReviewContext } from "../services/plan-review-context.js";
|
||||
import {
|
||||
@@ -2830,6 +2833,7 @@ export function issueRoutes(
|
||||
const executionWorkspacesSvc = executionWorkspaceServiceDirect(db);
|
||||
const workProductsSvc = workProductService(db);
|
||||
const documentsSvc = documentService(db);
|
||||
const artifactReviewDocumentsSvc = artifactReviewDocumentService(db, storage);
|
||||
const companySkillsSvc = companySkillService(db);
|
||||
const documentAnnotationsSvc = documentAnnotationService(db);
|
||||
const decisionTrainingSvc = decisionTrainingService(db);
|
||||
@@ -7888,9 +7892,110 @@ export function issueRoutes(
|
||||
actor,
|
||||
workProductChanged: true,
|
||||
});
|
||||
await materializeArtifactReviewDocumentBestEffort({ issue, workProduct: product, actor });
|
||||
res.status(201).json(product);
|
||||
});
|
||||
|
||||
async function ensureArtifactReviewDocumentForWorkProduct(input: {
|
||||
issue: NonNullable<Awaited<ReturnType<typeof svc.getById>>>;
|
||||
workProduct: NonNullable<Awaited<ReturnType<typeof workProductsSvc.getById>>>;
|
||||
actor: ReturnType<typeof getActorInfo>;
|
||||
}) {
|
||||
const { issue, workProduct, actor } = input;
|
||||
const result = await artifactReviewDocumentsSvc.ensureForWorkProduct({
|
||||
issue: { id: issue.id, companyId: issue.companyId },
|
||||
workProduct,
|
||||
});
|
||||
if (!result.revisionChanged) return result;
|
||||
const doc = result.document;
|
||||
await issueReferencesSvc.syncDocument(doc.id);
|
||||
await externalObjectsSvc.syncDocumentSafely(doc.id);
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
agentApiKeyId: actor.agentApiKeyId,
|
||||
action: result.created ? "issue.document_created" : "issue.document_updated",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
key: doc.key,
|
||||
documentId: doc.id,
|
||||
title: doc.title,
|
||||
format: doc.format,
|
||||
revisionNumber: doc.latestRevisionNumber,
|
||||
workProductId: workProduct.id,
|
||||
artifactReviewDocument: true,
|
||||
},
|
||||
});
|
||||
for (const remap of result.remappedAnnotations) {
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
agentApiKeyId: actor.agentApiKeyId,
|
||||
action: "issue.document_annotation_remapped",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
key: doc.key,
|
||||
documentId: doc.id,
|
||||
threadId: remap.thread.id,
|
||||
revisionNumber: doc.latestRevisionNumber,
|
||||
anchorState: remap.thread.anchorState,
|
||||
anchorConfidence: remap.thread.anchorConfidence,
|
||||
snapshotId: remap.snapshot.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
await revalidateActiveSourceRecoveryAfterCommittedWrite({
|
||||
issue,
|
||||
trigger: "document",
|
||||
actor,
|
||||
documentChanged: true,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function materializeArtifactReviewDocumentBestEffort(input: {
|
||||
issue: NonNullable<Awaited<ReturnType<typeof svc.getById>>>;
|
||||
workProduct: NonNullable<Awaited<ReturnType<typeof workProductsSvc.getById>>>;
|
||||
actor: ReturnType<typeof getActorInfo>;
|
||||
}) {
|
||||
if (!isMarkdownArtifactWorkProduct(input.workProduct)) return;
|
||||
try {
|
||||
await ensureArtifactReviewDocumentForWorkProduct(input);
|
||||
} catch (error) {
|
||||
// Work-product writes stay fail-open: raw open and download remain
|
||||
// available, and the explicit review-document endpoint is the retry path.
|
||||
logger.warn(
|
||||
{ err: error, issueId: input.issue.id, workProductId: input.workProduct.id },
|
||||
"markdown work product review-document materialization failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
router.post("/issues/:id/work-products/:workProductId/review-document", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const workProductId = req.params.workProductId as string;
|
||||
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
|
||||
if (!issue) return;
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||
if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return;
|
||||
const workProduct = await workProductsSvc.getById(workProductId);
|
||||
if (!workProduct || workProduct.issueId !== issue.id || workProduct.companyId !== issue.companyId) {
|
||||
res.status(404).json({ error: "Work product not found" });
|
||||
return;
|
||||
}
|
||||
const actor = getActorInfo(req);
|
||||
const result = await ensureArtifactReviewDocumentForWorkProduct({ issue, workProduct, actor });
|
||||
res.status(result.created ? 201 : 200).json(result.document);
|
||||
});
|
||||
|
||||
router.post("/issues/:id/low-trust/promotions", validate(promoteLowTrustOutputSchema), async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
|
||||
@@ -8087,6 +8192,11 @@ export function issueRoutes(
|
||||
actor,
|
||||
workProductChanged: true,
|
||||
});
|
||||
const reviewDocumentInputChanged = ["type", "provider", "metadata", "title", "createdByRunId"]
|
||||
.some((key) => Object.prototype.hasOwnProperty.call(patch, key));
|
||||
if (reviewDocumentInputChanged || sourceTrust) {
|
||||
await materializeArtifactReviewDocumentBestEffort({ issue, workProduct: product, actor });
|
||||
}
|
||||
res.json(product);
|
||||
});
|
||||
|
||||
@@ -12997,7 +13107,16 @@ export function issueRoutes(
|
||||
objectContentType: object.contentType,
|
||||
originalFilename: attachment.originalFilename,
|
||||
});
|
||||
res.setHeader("Content-Type", responseContentType);
|
||||
// Markdown bodies are stored as UTF-8; declare the charset so inline
|
||||
// (raw) views do not mojibake. SVG/inline checks below stay on the bare type.
|
||||
const isMarkdownResponse = isMarkdownAttachmentContent({
|
||||
contentType: responseContentType,
|
||||
originalFilename: attachment.originalFilename,
|
||||
});
|
||||
res.setHeader(
|
||||
"Content-Type",
|
||||
isMarkdownResponse ? `${responseContentType}; charset=utf-8` : responseContentType,
|
||||
);
|
||||
res.setHeader("Cache-Control", "private, max-age=60");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
if (responseContentType === SVG_CONTENT_TYPE) {
|
||||
|
||||
@@ -536,6 +536,14 @@ const responses = {
|
||||
description: "Conflict",
|
||||
content: { "application/json": { schema: ErrorSchema } },
|
||||
},
|
||||
payloadTooLarge: {
|
||||
description: "Payload too large",
|
||||
content: { "application/json": { schema: ErrorSchema } },
|
||||
},
|
||||
unsupportedMediaType: {
|
||||
description: "Unsupported media type",
|
||||
content: { "application/json": { schema: ErrorSchema } },
|
||||
},
|
||||
unprocessable: {
|
||||
description: "Unprocessable entity",
|
||||
content: { "application/json": { schema: ErrorSchema } },
|
||||
@@ -2318,6 +2326,25 @@ registry.registerPath({
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/issues/{id}/work-products/{workProductId}/review-document",
|
||||
tags: ["issues"],
|
||||
summary: "Ensure the review document for a Markdown work product",
|
||||
request: { params: z.object({ id: z.string(), workProductId: z.string() }) },
|
||||
responses: {
|
||||
200: r.ok(),
|
||||
201: r.ok(),
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
404: r.notFound,
|
||||
409: r.conflict,
|
||||
413: r.payloadTooLarge,
|
||||
415: r.unsupportedMediaType,
|
||||
422: r.unprocessable,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "patch",
|
||||
path: "/api/work-products/{id}",
|
||||
|
||||
@@ -89,9 +89,24 @@ import {
|
||||
isWithinBundledPluginRoot,
|
||||
} from "../services/plugin-install-guard.js";
|
||||
import { isCloudManagedInstance } from "../services/cloud-instance.js";
|
||||
import { getHiddenSettings } from "../services/settings-visibility.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
|
||||
|
||||
/**
|
||||
* Floor: when the hosting operator hides the Plugins settings surface
|
||||
* (`instance.plugins` in PAPERCLIP_HIDDEN_SETTINGS), plugin lifecycle and
|
||||
* configuration writes are rejected alongside it. Reads stay open — installed
|
||||
* plugins keep running and `/plugins/ui-contributions` still powers their UI.
|
||||
*/
|
||||
function assertPluginManagementVisible() {
|
||||
if (getHiddenSettings().has("instance.plugins")) {
|
||||
throw forbidden("Plugin management is managed by the hosting operator on this instance", {
|
||||
code: "settings_operator_managed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** UI slot declaration extracted from plugin manifest */
|
||||
type PluginUiSlotDeclaration = NonNullable<NonNullable<PaperclipPluginManifestV1["ui"]>["slots"]>[number];
|
||||
/** Launcher declaration extracted from plugin manifest */
|
||||
@@ -1125,6 +1140,7 @@ export function pluginRoutes(
|
||||
*/
|
||||
router.post("/plugins/install", async (req, res) => {
|
||||
assertInstanceAdmin(req);
|
||||
assertPluginManagementVisible();
|
||||
const { packageName, version, isLocalPath } = req.body as PluginInstallRequest;
|
||||
|
||||
// Input validation
|
||||
@@ -1968,6 +1984,7 @@ export function pluginRoutes(
|
||||
*/
|
||||
router.delete("/plugins/:pluginId", async (req, res) => {
|
||||
assertInstanceAdmin(req);
|
||||
assertPluginManagementVisible();
|
||||
const { pluginId } = req.params;
|
||||
const purge = req.query.purge === "true";
|
||||
|
||||
@@ -2004,6 +2021,7 @@ export function pluginRoutes(
|
||||
*/
|
||||
router.post("/plugins/:pluginId/enable", async (req, res) => {
|
||||
assertInstanceAdmin(req);
|
||||
assertPluginManagementVisible();
|
||||
const { pluginId } = req.params;
|
||||
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
@@ -2042,6 +2060,7 @@ export function pluginRoutes(
|
||||
*/
|
||||
router.post("/plugins/:pluginId/disable", async (req, res) => {
|
||||
assertInstanceAdmin(req);
|
||||
assertPluginManagementVisible();
|
||||
const { pluginId } = req.params;
|
||||
const body = req.body as { reason?: string } | undefined;
|
||||
const reason = body?.reason;
|
||||
@@ -2204,6 +2223,7 @@ export function pluginRoutes(
|
||||
*/
|
||||
router.post("/plugins/:pluginId/upgrade", async (req, res) => {
|
||||
assertInstanceAdmin(req);
|
||||
assertPluginManagementVisible();
|
||||
const { pluginId } = req.params;
|
||||
const body = req.body as { version?: string } | undefined;
|
||||
const version = body?.version;
|
||||
@@ -2285,6 +2305,7 @@ export function pluginRoutes(
|
||||
*/
|
||||
router.post("/plugins/:pluginId/config", async (req, res) => {
|
||||
assertInstanceAdmin(req);
|
||||
assertPluginManagementVisible();
|
||||
const { pluginId } = req.params;
|
||||
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
@@ -2417,6 +2438,7 @@ export function pluginRoutes(
|
||||
*/
|
||||
router.post("/plugins/:pluginId/config/test", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
assertPluginManagementVisible();
|
||||
|
||||
if (!bridgeDeps) {
|
||||
res.status(501).json({ error: "Plugin bridge is not enabled" });
|
||||
@@ -2893,6 +2915,7 @@ export function pluginRoutes(
|
||||
|
||||
router.put("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
assertPluginManagementVisible();
|
||||
const { pluginId, companyId, folderKey } = req.params;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { assets, documentRevisions, documents, issueAttachments, issueDocuments } from "@paperclipai/db";
|
||||
import {
|
||||
MARKDOWN_REVIEW_DOCUMENT_MAX_BYTES,
|
||||
artifactReviewDocumentKey,
|
||||
getAttachmentArtifactWorkProductMetadata,
|
||||
isMarkdownAttachmentContent,
|
||||
type AttachmentArtifactWorkProductMetadata,
|
||||
type IssueWorkProduct,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
HttpError,
|
||||
conflict,
|
||||
notFound,
|
||||
payloadTooLarge,
|
||||
unprocessable,
|
||||
unsupportedMediaType,
|
||||
} from "../errors.js";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
import { documentAnnotationService } from "./document-annotations.js";
|
||||
import { documentService, issueDocumentSelect, mapIssueDocumentRow } from "./documents.js";
|
||||
|
||||
export type EnsureArtifactReviewWorkProduct = Pick<
|
||||
IssueWorkProduct,
|
||||
"id" | "companyId" | "issueId" | "type" | "provider" | "metadata" | "title" | "createdByRunId" | "sourceTrust"
|
||||
>;
|
||||
|
||||
export interface EnsureArtifactReviewDocumentInput {
|
||||
issue: { id: string; companyId: string };
|
||||
workProduct: EnsureArtifactReviewWorkProduct;
|
||||
}
|
||||
|
||||
type RemappedAnnotations = Awaited<
|
||||
ReturnType<ReturnType<typeof documentAnnotationService>["remapOpenThreadsForDocument"]>
|
||||
>;
|
||||
|
||||
export interface EnsureArtifactReviewDocumentResult {
|
||||
document: ReturnType<typeof mapIssueDocumentRow> & { body?: string };
|
||||
created: boolean;
|
||||
revisionChanged: boolean;
|
||||
remappedAnnotations: RemappedAnnotations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Materializes the review IssueDocument for an eligible Markdown
|
||||
* attachment-backed work product under the deterministic
|
||||
* `artifact-review-<workProductId>` key. The operation is idempotent: repeated
|
||||
* calls return the existing document and only write a new revision when the
|
||||
* review content or its work-product provenance changed.
|
||||
*/
|
||||
export function artifactReviewDocumentService(db: Db, storage: StorageService) {
|
||||
const documentsSvc = documentService(db);
|
||||
const annotationsSvc = documentAnnotationService(db);
|
||||
|
||||
const getExistingByKey = async (issueId: string, key: string) => {
|
||||
const document = await db
|
||||
.select(issueDocumentSelect)
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
|
||||
.then((rows) => (rows[0] ? mapIssueDocumentRow(rows[0], true) : null));
|
||||
if (!document) return null;
|
||||
const latestRevision = document.latestRevisionId
|
||||
? await db
|
||||
.select({
|
||||
createdByAgentId: documentRevisions.createdByAgentId,
|
||||
createdByUserId: documentRevisions.createdByUserId,
|
||||
createdByRunId: documentRevisions.createdByRunId,
|
||||
})
|
||||
.from(documentRevisions)
|
||||
.where(and(
|
||||
eq(documentRevisions.id, document.latestRevisionId),
|
||||
eq(documentRevisions.documentId, document.id),
|
||||
eq(documentRevisions.companyId, document.companyId),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
return { document, latestRevision };
|
||||
};
|
||||
|
||||
const readMarkdownAttachmentBody = async (
|
||||
issue: { id: string; companyId: string },
|
||||
metadata: AttachmentArtifactWorkProductMetadata,
|
||||
) => {
|
||||
const attachment = await db
|
||||
.select({
|
||||
id: issueAttachments.id,
|
||||
companyId: issueAttachments.companyId,
|
||||
issueId: issueAttachments.issueId,
|
||||
objectKey: assets.objectKey,
|
||||
contentType: assets.contentType,
|
||||
byteSize: assets.byteSize,
|
||||
originalFilename: assets.originalFilename,
|
||||
createdByAgentId: assets.createdByAgentId,
|
||||
createdByUserId: assets.createdByUserId,
|
||||
})
|
||||
.from(issueAttachments)
|
||||
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
|
||||
.where(eq(issueAttachments.id, metadata.attachmentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
// Unknown and cross-scope attachment ids are deliberately
|
||||
// indistinguishable: canonical metadata must reference an attachment on
|
||||
// the same issue and company.
|
||||
if (!attachment || attachment.companyId !== issue.companyId || attachment.issueId !== issue.id) {
|
||||
throw unprocessable("Work product attachment must reference an attachment on the same issue", {
|
||||
code: "invalid_attachment_artifact_metadata",
|
||||
attachmentId: metadata.attachmentId,
|
||||
});
|
||||
}
|
||||
if (!isMarkdownAttachmentContent(attachment)) {
|
||||
throw unsupportedMediaType("Work product attachment is not Markdown", {
|
||||
code: "unsupported_review_document_content_type",
|
||||
contentType: attachment.contentType,
|
||||
});
|
||||
}
|
||||
if (attachment.byteSize > MARKDOWN_REVIEW_DOCUMENT_MAX_BYTES) {
|
||||
throw payloadTooLarge("Markdown attachment exceeds the review document size limit", {
|
||||
code: "review_document_too_large",
|
||||
byteSize: attachment.byteSize,
|
||||
maxBytes: MARKDOWN_REVIEW_DOCUMENT_MAX_BYTES,
|
||||
});
|
||||
}
|
||||
|
||||
const object = await storage.getObject(attachment.companyId, attachment.objectKey);
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
for await (const chunk of object.stream) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
total += buffer.length;
|
||||
// Defense in depth: enforce the cap during streaming too, so a
|
||||
// metadata/object size mismatch can never exceed the limit.
|
||||
if (total > MARKDOWN_REVIEW_DOCUMENT_MAX_BYTES) {
|
||||
object.stream.destroy();
|
||||
throw payloadTooLarge("Markdown attachment exceeds the review document size limit", {
|
||||
code: "review_document_too_large",
|
||||
maxBytes: MARKDOWN_REVIEW_DOCUMENT_MAX_BYTES,
|
||||
});
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
|
||||
let body: string;
|
||||
try {
|
||||
body = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
|
||||
} catch {
|
||||
throw unprocessable("Markdown attachment is not valid UTF-8", {
|
||||
code: "invalid_review_document_encoding",
|
||||
attachmentId: attachment.id,
|
||||
});
|
||||
}
|
||||
return { attachment, body };
|
||||
};
|
||||
|
||||
return {
|
||||
ensureForWorkProduct: async (
|
||||
input: EnsureArtifactReviewDocumentInput,
|
||||
): Promise<EnsureArtifactReviewDocumentResult> => {
|
||||
const { issue, workProduct } = input;
|
||||
if (workProduct.issueId !== issue.id || workProduct.companyId !== issue.companyId) {
|
||||
throw notFound("Work product not found");
|
||||
}
|
||||
const metadata = getAttachmentArtifactWorkProductMetadata(workProduct);
|
||||
if (!metadata) {
|
||||
throw unprocessable("Work product is not an attachment-backed Paperclip artifact", {
|
||||
code: "not_attachment_backed_artifact",
|
||||
workProductId: workProduct.id,
|
||||
});
|
||||
}
|
||||
if (!isMarkdownAttachmentContent(metadata)) {
|
||||
throw unsupportedMediaType("Work product attachment is not Markdown", {
|
||||
code: "unsupported_review_document_content_type",
|
||||
contentType: metadata.contentType,
|
||||
});
|
||||
}
|
||||
|
||||
const { attachment, body } = await readMarkdownAttachmentBody(issue, metadata);
|
||||
const key = artifactReviewDocumentKey(workProduct.id);
|
||||
// The review document represents agent-authored attachment content, so
|
||||
// attribution promotes the attachment/work-product provenance instead of
|
||||
// the actor who triggered materialization.
|
||||
const attribution = {
|
||||
createdByAgentId: attachment.createdByAgentId ?? null,
|
||||
createdByUserId: attachment.createdByUserId ?? null,
|
||||
createdByRunId: workProduct.createdByRunId ?? null,
|
||||
sourceTrust: workProduct.sourceTrust ?? null,
|
||||
};
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const existingState = await getExistingByKey(issue.id, key);
|
||||
const existing = existingState?.document ?? null;
|
||||
const latestRevision = existingState?.latestRevision ?? null;
|
||||
const reviewStateMatches = existing &&
|
||||
existing.body === body &&
|
||||
existing.title === (workProduct.title ?? null) &&
|
||||
existing.format === "markdown" &&
|
||||
latestRevision?.createdByAgentId === attribution.createdByAgentId &&
|
||||
latestRevision.createdByUserId === attribution.createdByUserId &&
|
||||
latestRevision.createdByRunId === attribution.createdByRunId &&
|
||||
isDeepStrictEqual(existing.sourceTrust ?? null, attribution.sourceTrust);
|
||||
if (reviewStateMatches) {
|
||||
return { document: existing, created: false, revisionChanged: false, remappedAnnotations: [] };
|
||||
}
|
||||
try {
|
||||
const result = await documentsSvc.upsertIssueDocument({
|
||||
issueId: issue.id,
|
||||
key,
|
||||
title: workProduct.title ?? null,
|
||||
format: "markdown",
|
||||
body,
|
||||
changeSummary: existing
|
||||
? "Synced from the work product attachment"
|
||||
: "Materialized from the Markdown work product attachment",
|
||||
baseRevisionId: existing?.latestRevisionId ?? null,
|
||||
...attribution,
|
||||
lockedDocumentStrategy: "conflict",
|
||||
});
|
||||
const remappedAnnotations = result.created
|
||||
? []
|
||||
: await annotationsSvc.remapOpenThreadsForDocument({
|
||||
issueId: issue.id,
|
||||
key: result.document.key,
|
||||
documentId: result.document.id,
|
||||
nextRevisionId: result.document.latestRevisionId,
|
||||
nextRevisionNumber: result.document.latestRevisionNumber,
|
||||
nextBody: result.document.body,
|
||||
});
|
||||
return {
|
||||
document: result.document,
|
||||
created: result.created,
|
||||
revisionChanged: true,
|
||||
remappedAnnotations,
|
||||
};
|
||||
} catch (error) {
|
||||
// A concurrent materialization or document write raced this one.
|
||||
// Re-read once so identical content converges on the winner.
|
||||
if (error instanceof HttpError && error.status === 409 && attempt === 0) continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw conflict("Concurrent review-document updates did not converge", { key });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export {
|
||||
export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js";
|
||||
export { assetService } from "./assets.js";
|
||||
export { documentService, extractLegacyPlanBody } from "./documents.js";
|
||||
export { artifactReviewDocumentService } from "./artifact-review-documents.js";
|
||||
export { statusCardService } from "./status-cards.js";
|
||||
export { finalizeStatusCardsForStalledGeneration } from "./status-card-finalization.js";
|
||||
export { documentAnnotationService } from "./document-annotations.js";
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { parseHiddenSettingsList } from "@paperclipai/shared";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
/**
|
||||
* Operator-hidden settings, from the `PAPERCLIP_HIDDEN_SETTINGS` env var
|
||||
* (comma-separated keys from the shared settings-visibility registry). Unknown
|
||||
* keys are warned about once and ignored so one list can be rolled across a
|
||||
* fleet of mixed app versions without refusing boot on older images.
|
||||
*/
|
||||
export const HIDDEN_SETTINGS_ENV_KEY = "PAPERCLIP_HIDDEN_SETTINGS";
|
||||
|
||||
export type HiddenSettingsEnv = Record<string, string | undefined>;
|
||||
|
||||
let cache: { raw: string | undefined; hidden: ReadonlySet<string> } | null = null;
|
||||
|
||||
/**
|
||||
* Parse-once accessor keyed on the raw env value. Callers that pass a custom
|
||||
* env (tests) get a fresh parse whenever the raw value differs; process.env
|
||||
* callers share one parsed set for the process lifetime. Members are always
|
||||
* `HideableSettingKey`s; typed as strings so route code can probe with
|
||||
* computed `instance.*` keys.
|
||||
*/
|
||||
export function getHiddenSettings(
|
||||
env: HiddenSettingsEnv = process.env,
|
||||
): ReadonlySet<string> {
|
||||
const raw = env[HIDDEN_SETTINGS_ENV_KEY];
|
||||
if (cache && cache.raw === raw) return cache.hidden;
|
||||
const { hidden, unknown } = parseHiddenSettingsList(raw);
|
||||
if (unknown.length > 0) {
|
||||
logger.warn(
|
||||
{ unknownKeys: unknown },
|
||||
`${HIDDEN_SETTINGS_ENV_KEY} contains unknown keys; they are ignored`,
|
||||
);
|
||||
}
|
||||
cache = { raw, hidden: new Set(hidden) };
|
||||
return cache.hidden;
|
||||
}
|
||||
Reference in new issue
Block a user