Merge origin/master into connection grant resolution

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-21 01:55:23 +00:00
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);
});
});
+20
View File
@@ -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);
}
+8
View File
@@ -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);
}
+21
View File
@@ -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,
+23
View File
@@ -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;
+10
View File
@@ -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 } : {}),
});
});
+55
View File
@@ -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
View File
@@ -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) {
+27
View File
@@ -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}",
+23
View File
@@ -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 });
},
};
}
+1
View File
@@ -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;
}