[codex] harden authenticated routes and issue editor reliability (#3741)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The control plane depends on authenticated routes enforcing company
boundaries and role permissions correctly
> - This branch also touches the issue detail and markdown editing flows
operators use while handling advisory and triage work
> - Partial issue cache seeds and fragile rich-editor parsing could
leave important issue content missing or blank at the moment an operator
needed it
> - Blocked issues becoming actionable again should wake their assignee
automatically instead of silently staying idle
> - This pull request rebases the advisory follow-up branch onto current
`master`, hardens authenticated route authorization, and carries the
issue-detail/editor reliability fixes forward with regression tests
> - The benefit is tighter authz on sensitive routes plus more reliable
issue/advisory editing and wakeup behavior on top of the latest base

## What Changed

- Hardened authenticated route authorization across agent, activity,
approval, access, project, plugin, health, execution-workspace,
portability, and related server paths, with new cross-tenant and
runtime-authz regression coverage.
- Switched issue detail queries from `initialData` to placeholder-based
hydration so list/quicklook seeds still refetch full issue bodies.
- Normalized advisory-style HTML images before mounting the markdown
editor and strengthened fallback behavior when the rich editor silently
fails or rejects the content.
- Woke assigned agents when blocked issues move back to `todo`, with
route coverage for reopen and unblock transitions.
- Rebasing note: this branch now sits cleanly on top of the latest
`master` tip used for the PR base.

## Verification

- `pnpm exec vitest run ui/src/lib/issueDetailQuery.test.tsx
ui/src/components/MarkdownEditor.test.tsx
server/src/__tests__/issue-comment-reopen-routes.test.ts
server/src/__tests__/activity-routes.test.ts
server/src/__tests__/agent-cross-tenant-authz-routes.test.ts`
- Confirmed `pnpm-lock.yaml` is not part of the PR diff.
- Rebased the branch onto current `public-gh/master` before publishing.

## Risks

- Broad authz tightening may expose existing flows that were relying on
permissive board or agent access and now need explicit grants.
- Markdown editor fallback changes could affect focus or rendering in
edge-case content that mixes HTML-like advisory markup with normal
markdown.
- This verification was intentionally scoped to touched regressions and
did not run the full repository suite.

## Model Used

- OpenAI Codex, GPT-5-based coding agent in the Codex CLI environment
with tool use for terminal, git, and GitHub operations. The exact
runtime model identifier is not exposed inside this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, it is behavior-only and does not
need before/after screenshots
- [x] I have updated relevant documentation to reflect my changes, or no
documentation changes were needed for these internal fixes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-04-15 08:41:15 -05:00
committed by GitHub
parent 50cd76d8a3
commit 32a9165ddf
39 changed files with 3014 additions and 153 deletions
@@ -12,14 +12,12 @@ export type CreateApproval = z.infer<typeof createApprovalSchema>;
export const resolveApprovalSchema = z.object({
decisionNote: z.string().optional().nullable(),
decidedByUserId: z.string().optional().default("board"),
});
export type ResolveApproval = z.infer<typeof resolveApprovalSchema>;
export const requestApprovalRevisionSchema = z.object({
decisionNote: z.string().optional().nullable(),
decidedByUserId: z.string().optional().default("board"),
});
export type RequestApprovalRevision = z.infer<typeof requestApprovalRevisionSchema>;
+19 -8
View File
@@ -28,7 +28,15 @@ vi.mock("../services/index.js", () => ({
heartbeatService: () => mockHeartbeatService,
}));
async function createApp() {
async function createApp(
actor: Record<string, unknown> = {
type: "board",
userId: "user-1",
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
},
) {
const [{ errorHandler }, { activityRoutes }] = await Promise.all([
import("../middleware/index.js"),
import("../routes/activity.js"),
@@ -36,13 +44,7 @@ async function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "user-1",
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
};
(req as any).actor = actor;
next();
});
app.use("/api", activityRoutes({} as any));
@@ -105,4 +107,13 @@ describe("activity routes", () => {
expect(res.status).toBe(403);
expect(mockActivityService.issuesForRun).not.toHaveBeenCalled();
});
it("rejects anonymous heartbeat run issue lookups before run existence checks", async () => {
const app = await createApp({ type: "none", source: "none" });
const res = await request(app).get("/api/heartbeat-runs/missing-run/issues");
expect(res.status).toBe(401);
expect(mockHeartbeatService.getRun).not.toHaveBeenCalled();
expect(mockActivityService.issuesForRun).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,267 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { agentRoutes } from "../routes/agents.js";
const agentId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";
const keyId = "33333333-3333-4333-8333-333333333333";
const baseAgent = {
id: agentId,
companyId,
name: "Builder",
urlKey: "builder",
role: "engineer",
title: "Builder",
icon: null,
status: "idle",
reportsTo: null,
capabilities: null,
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date("2026-04-11T00:00:00.000Z"),
updatedAt: new Date("2026-04-11T00:00:00.000Z"),
};
const baseKey = {
id: keyId,
agentId,
companyId,
name: "exploit",
createdAt: new Date("2026-04-11T00:00:00.000Z"),
revokedAt: null,
};
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
terminate: vi.fn(),
remove: vi.fn(),
listKeys: vi.fn(),
createApiKey: vi.fn(),
getKeyById: vi.fn(),
revokeKey: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
getMembership: vi.fn(),
ensureMembership: vi.fn(),
listPrincipalGrants: vi.fn(),
setPrincipalPermission: vi.fn(),
}));
const mockApprovalService = vi.hoisted(() => ({
create: vi.fn(),
getById: vi.fn(),
}));
const mockBudgetService = vi.hoisted(() => ({
upsertPolicy: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
cancelActiveForAgent: vi.fn(),
}));
const mockIssueApprovalService = vi.hoisted(() => ({
linkManyForApproval: vi.fn(),
}));
const mockIssueService = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockSecretService = vi.hoisted(() => ({
normalizeAdapterConfigForPersistence: vi.fn(),
resolveAdapterConfigForRuntime: vi.fn(),
}));
const mockAgentInstructionsService = vi.hoisted(() => ({
materializeManagedBundle: vi.fn(),
}));
const mockCompanySkillService = vi.hoisted(() => ({
listRuntimeSkillEntries: vi.fn(),
resolveRequestedSkillKeys: vi.fn(),
}));
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentCreated: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
accessService: () => mockAccessService,
approvalService: () => mockApprovalService,
companySkillService: () => mockCompanySkillService,
budgetService: () => mockBudgetService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
issueService: () => mockIssueService,
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.mock("../services/instance-settings.js", () => ({
instanceSettingsService: () => ({
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
}),
}));
function createApp(actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", agentRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("agent cross-tenant route authorization", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockAgentService.getById.mockResolvedValue(baseAgent);
mockAgentService.pause.mockResolvedValue(baseAgent);
mockAgentService.resume.mockResolvedValue(baseAgent);
mockAgentService.terminate.mockResolvedValue(baseAgent);
mockAgentService.remove.mockResolvedValue(baseAgent);
mockAgentService.listKeys.mockResolvedValue([]);
mockAgentService.createApiKey.mockResolvedValue({
id: keyId,
name: baseKey.name,
token: "pcp_test_token",
createdAt: baseKey.createdAt,
});
mockAgentService.getKeyById.mockResolvedValue(baseKey);
mockAgentService.revokeKey.mockResolvedValue({
...baseKey,
revokedAt: new Date("2026-04-11T00:05:00.000Z"),
});
mockHeartbeatService.cancelActiveForAgent.mockResolvedValue(undefined);
mockLogActivity.mockResolvedValue(undefined);
});
it("rejects cross-tenant board pause before mutating the agent", async () => {
const app = createApp({
type: "board",
userId: "mallory",
companyIds: [],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app).post(`/api/agents/${agentId}/pause`).send({});
expect(res.status).toBe(403);
expect(res.body.error).toContain("User does not have access to this company");
expect(mockAgentService.getById).toHaveBeenCalledWith(agentId);
expect(mockAgentService.pause).not.toHaveBeenCalled();
expect(mockHeartbeatService.cancelActiveForAgent).not.toHaveBeenCalled();
});
it("rejects cross-tenant board key listing before reading any keys", async () => {
const app = createApp({
type: "board",
userId: "mallory",
companyIds: [],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app).get(`/api/agents/${agentId}/keys`);
expect(res.status).toBe(403);
expect(res.body.error).toContain("User does not have access to this company");
expect(mockAgentService.getById).toHaveBeenCalledWith(agentId);
expect(mockAgentService.listKeys).not.toHaveBeenCalled();
});
it("rejects cross-tenant board key creation before minting a token", async () => {
const app = createApp({
type: "board",
userId: "mallory",
companyIds: [],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app)
.post(`/api/agents/${agentId}/keys`)
.send({ name: "exploit" });
expect(res.status).toBe(403);
expect(res.body.error).toContain("User does not have access to this company");
expect(mockAgentService.getById).toHaveBeenCalledWith(agentId);
expect(mockAgentService.createApiKey).not.toHaveBeenCalled();
});
it("rejects cross-tenant board key revocation before touching the key", async () => {
const app = createApp({
type: "board",
userId: "mallory",
companyIds: [],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app).delete(`/api/agents/${agentId}/keys/${keyId}`);
expect(res.status).toBe(403);
expect(res.body.error).toContain("User does not have access to this company");
expect(mockAgentService.getById).toHaveBeenCalledWith(agentId);
expect(mockAgentService.getKeyById).not.toHaveBeenCalled();
expect(mockAgentService.revokeKey).not.toHaveBeenCalled();
});
it("requires the key to belong to the route agent before revocation", async () => {
mockAgentService.getKeyById.mockResolvedValue({
...baseKey,
agentId: "44444444-4444-4444-8444-444444444444",
});
mockAccessService.canUser.mockResolvedValue(true);
const app = createApp({
type: "board",
userId: "board-user",
companyIds: [companyId],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app).delete(`/api/agents/${agentId}/keys/${keyId}`);
expect(res.status).toBe(404);
expect(res.body.error).toContain("Key not found");
expect(mockAgentService.getKeyById).toHaveBeenCalledWith(keyId);
expect(mockAgentService.revokeKey).not.toHaveBeenCalled();
});
});
@@ -205,6 +205,196 @@ describe("agent permission routes", () => {
mockLogActivity.mockResolvedValue(undefined);
});
it("redacts agent detail for authenticated company members without agent admin permission", async () => {
mockAccessService.canUser.mockResolvedValue(false);
const app = await createApp({
type: "board",
userId: "member-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app).get(`/api/agents/${agentId}`);
expect(res.status).toBe(200);
expect(res.body.adapterConfig).toEqual({});
expect(res.body.runtimeConfig).toEqual({});
});
it("redacts company agent list for authenticated company members without agent admin permission", async () => {
mockAccessService.canUser.mockResolvedValue(false);
const app = await createApp({
type: "board",
userId: "member-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app).get(`/api/companies/${companyId}/agents`);
expect(res.status).toBe(200);
expect(res.body).toEqual([
expect.objectContaining({
id: agentId,
adapterConfig: {},
runtimeConfig: {},
}),
]);
});
it("blocks agent updates for authenticated company members without agent admin permission", async () => {
mockAccessService.canUser.mockResolvedValue(false);
const app = await createApp({
type: "board",
userId: "member-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.patch(`/api/agents/${agentId}`)
.send({ title: "Compromised" });
expect(res.status).toBe(403);
});
it("blocks api key creation for authenticated company members without agent admin permission", async () => {
mockAccessService.canUser.mockResolvedValue(false);
const app = await createApp({
type: "board",
userId: "member-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.post(`/api/agents/${agentId}/keys`)
.send({ name: "backdoor" });
expect(res.status).toBe(403);
});
it("blocks wakeups for authenticated company members without agent admin permission", async () => {
mockAccessService.canUser.mockResolvedValue(false);
const app = await createApp({
type: "board",
userId: "member-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.post(`/api/agents/${agentId}/wakeup`)
.send({});
expect(res.status).toBe(403);
});
it("blocks agent-authenticated self-updates that set host-executed workspace commands", async () => {
const app = await createApp({
type: "agent",
agentId,
companyId,
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.patch(`/api/agents/${agentId}`)
.send({
adapterConfig: {
workspaceStrategy: {
type: "git_worktree",
provisionCommand: "touch /tmp/paperclip-rce",
},
},
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed workspace commands");
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("blocks agent-authenticated self-updates that set instructions bundle roots", async () => {
const app = await createApp({
type: "agent",
agentId,
companyId,
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.patch(`/api/agents/${agentId}`)
.send({
adapterConfig: {
instructionsRootPath: "/etc",
instructionsEntryFile: "passwd",
},
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("instructions path or bundle configuration");
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("blocks agent-authenticated instructions-path updates", async () => {
const app = await createApp({
type: "agent",
agentId,
companyId,
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.patch(`/api/agents/${agentId}/instructions-path`)
.send({ path: "/etc/passwd" });
expect(res.status).toBe(403);
expect(res.body.error).toContain("instructions path or bundle configuration");
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("blocks agent-authenticated hires that set instructions bundle config", async () => {
mockAccessService.hasPermission.mockResolvedValue(true);
const app = await createApp({
type: "agent",
agentId,
companyId,
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post(`/api/companies/${companyId}/agent-hires`)
.send({
name: "Injected",
role: "engineer",
adapterType: "codex_local",
adapterConfig: {
instructionsRootPath: "/etc",
instructionsEntryFile: "passwd",
},
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("instructions path or bundle configuration");
expect(mockAgentService.create).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("grants tasks:assign by default when board creates a new agent", async () => {
const app = await createApp({
type: "board",
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { shouldEnablePrivateHostnameGuard } from "../app.ts";
describe("shouldEnablePrivateHostnameGuard", () => {
it("enables the hostname guard for local_trusted private deployments", () => {
expect(shouldEnablePrivateHostnameGuard({
deploymentMode: "local_trusted",
deploymentExposure: "private",
})).toBe(true);
});
it("does not enable the hostname guard for local_trusted public deployments", () => {
expect(shouldEnablePrivateHostnameGuard({
deploymentMode: "local_trusted",
deploymentExposure: "public",
})).toBe(false);
});
it("enables the hostname guard for authenticated private deployments", () => {
expect(shouldEnablePrivateHostnameGuard({
deploymentMode: "authenticated",
deploymentExposure: "private",
})).toBe(true);
});
it("does not enable the hostname guard for authenticated public deployments", () => {
expect(shouldEnablePrivateHostnameGuard({
deploymentMode: "authenticated",
deploymentExposure: "public",
})).toBe(false);
});
});
@@ -196,6 +196,90 @@ describe("approval routes idempotent retries", () => {
expect(mockApprovalService.requestRevision).not.toHaveBeenCalled();
});
it("derives approval attribution from the authenticated actor on approve", async () => {
mockApprovalService.getById.mockResolvedValue({
id: "approval-4",
companyId: "company-1",
type: "hire_agent",
status: "pending",
payload: {},
requestedByAgentId: null,
});
mockApprovalService.approve.mockResolvedValue({
approval: {
id: "approval-4",
companyId: "company-1",
type: "hire_agent",
status: "approved",
payload: {},
requestedByAgentId: null,
},
applied: true,
});
const res = await request(await createApp())
.post("/api/approvals/approval-4/approve")
.send({ decidedByUserId: "forged-user", decisionNote: "ship it" });
expect(res.status).toBe(200);
expect(mockApprovalService.approve).toHaveBeenCalledWith("approval-4", "user-1", "ship it");
});
it("derives approval attribution from the authenticated actor on reject", async () => {
mockApprovalService.getById.mockResolvedValue({
id: "approval-5",
companyId: "company-1",
type: "hire_agent",
status: "pending",
payload: {},
});
mockApprovalService.reject.mockResolvedValue({
approval: {
id: "approval-5",
companyId: "company-1",
type: "hire_agent",
status: "rejected",
payload: {},
},
applied: true,
});
const res = await request(await createApp())
.post("/api/approvals/approval-5/reject")
.send({ decidedByUserId: "forged-user", decisionNote: "not now" });
expect(res.status).toBe(200);
expect(mockApprovalService.reject).toHaveBeenCalledWith("approval-5", "user-1", "not now");
});
it("derives approval attribution from the authenticated actor on request revision", async () => {
mockApprovalService.getById.mockResolvedValue({
id: "approval-6",
companyId: "company-1",
type: "hire_agent",
status: "pending",
payload: {},
});
mockApprovalService.requestRevision.mockResolvedValue({
id: "approval-6",
companyId: "company-1",
type: "hire_agent",
status: "revision_requested",
payload: {},
});
const res = await request(await createApp())
.post("/api/approvals/approval-6/request-revision")
.send({ decidedByUserId: "forged-user", decisionNote: "Need changes" });
expect(res.status).toBe(200);
expect(mockApprovalService.requestRevision).toHaveBeenCalledWith(
"approval-6",
"user-1",
"Need changes",
);
});
it("lets agents create generic issue-linked board approval requests", async () => {
mockApprovalService.create.mockResolvedValue({
id: "approval-1",
+45 -3
View File
@@ -45,7 +45,7 @@ function registerModuleMocks() {
}));
}
async function createApp(actor: any) {
async function createApp(actor: any, db: any = {} as any) {
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
@@ -58,7 +58,7 @@ async function createApp(actor: any) {
});
app.use(
"/api",
accessRoutes({} as any, {
accessRoutes(db, {
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
@@ -101,14 +101,56 @@ describe("cli auth routes", () => {
expect(res.body).toMatchObject({
id: "challenge-1",
token: "pcp_cli_auth_secret",
boardApiToken: "pcp_board_token",
approvalPath: "/cli-auth/challenge-1?token=pcp_cli_auth_secret",
pollPath: "/cli-auth/challenges/challenge-1",
expiresAt: "2026-03-23T13:00:00.000Z",
});
expect(res.body.boardApiToken).toBe("pcp_board_token");
expect(res.body.approvalUrl).toContain("/cli-auth/challenge-1?token=pcp_cli_auth_secret");
});
it("rejects anonymous access to generic skill documents", async () => {
const app = await createApp({ type: "none", source: "none" });
const [indexRes, skillRes] = await Promise.all([
request(app).get("/api/skills/index"),
request(app).get("/api/skills/paperclip"),
]);
expect(indexRes.status).toBe(401);
expect(skillRes.status).toBe(401);
});
it("serves the invite-scoped paperclip skill anonymously for active invites", async () => {
const invite = {
id: "invite-1",
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "agent",
tokenHash: "hash",
defaultsPayload: null,
expiresAt: new Date(Date.now() + 60_000),
invitedByUserId: null,
revokedAt: null,
acceptedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const db = {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn().mockResolvedValue([invite]),
})),
})),
};
const app = await createApp({ type: "none", source: "none" }, db);
const res = await request(app).get("/api/invites/token-123/skills/paperclip");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toContain("text/markdown");
expect(res.text).toContain("# Paperclip Skill");
});
it("marks challenge status as requiring sign-in for anonymous viewers", async () => {
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({
id: "challenge-1",
@@ -59,6 +59,11 @@ const assetSvc = {
create: vi.fn(),
};
const secretSvc = {
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record<string, unknown>) => ({ config, secretKeys: new Set<string>() })),
};
const agentInstructionsSvc = {
exportFiles: vi.fn(),
materializeManagedBundle: vi.fn(),
@@ -96,6 +101,10 @@ vi.mock("../services/assets.js", () => ({
assetService: () => assetSvc,
}));
vi.mock("../services/secrets.js", () => ({
secretService: () => secretSvc,
}));
vi.mock("../services/agent-instructions.js", () => ({
agentInstructionsService: () => agentInstructionsSvc,
}));
@@ -117,6 +126,11 @@ describe("company portability", () => {
beforeEach(() => {
vi.clearAllMocks();
secretSvc.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config);
secretSvc.resolveAdapterConfigForRuntime.mockImplementation(async (_companyId, config) => ({
config,
secretKeys: new Set<string>(),
}));
companySvc.getById.mockResolvedValue({
id: "company-1",
name: "Paperclip",
@@ -127,6 +141,11 @@ describe("company portability", () => {
logoUrl: null,
requireBoardApprovalForNewAgents: true,
});
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
requireBoardApprovalForNewAgents: true,
});
agentSvc.list.mockResolvedValue([
{
id: "agent-1",
@@ -1509,7 +1528,7 @@ describe("company portability", () => {
}),
]);
await portability.importBundle({
const result = await portability.importBundle({
source: { type: "inline", rootPath: "paperclip-demo", files },
include: { company: true, agents: true, projects: true, issues: true, skills: false },
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
@@ -1520,12 +1539,15 @@ describe("company portability", () => {
expect(routineSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
projectId: "project-created",
title: "Monday Review",
assigneeAgentId: "agent-created",
assigneeAgentId: null,
priority: "high",
status: "paused",
concurrencyPolicy: "always_enqueue",
catchUpPolicy: "enqueue_missed_with_cap",
}), expect.any(Object));
expect(result.warnings).toContain(
"Task monday-review assignee claudecoder is pending_approval; imported work was left unassigned.",
);
expect(routineSvc.createTrigger).toHaveBeenCalledTimes(2);
expect(routineSvc.createTrigger).toHaveBeenCalledWith("routine-created", expect.objectContaining({
kind: "schedule",
@@ -2418,4 +2440,178 @@ describe("company portability", () => {
expect(nestedMaterializedFiles?.["AGENTS.md"]).not.toMatch(/^---\n/);
expect(nestedMaterializedFiles?.["AGENTS.md"]).not.toContain('name: "ClaudeCoder"');
});
it("rejects dangerous adapter types on agent-safe imports", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
agentSvc.list.mockResolvedValue([]);
await expect(portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: false,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "existing_company",
companyId: "company-1",
},
agents: ["claudecoder"],
collisionStrategy: "rename",
adapterOverrides: {
claudecoder: {
adapterType: "process",
adapterConfig: {
command: "/bin/sh",
args: ["-c", "id"],
},
},
},
}, "user-1", {
mode: "agent_safe",
sourceCompanyId: "company-1",
})).rejects.toThrow('Adapter type "process" is not allowed in safe imports');
expect(agentSvc.create).not.toHaveBeenCalled();
});
it("imports new agents through approval and adapter-config normalization", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
agentSvc.list.mockResolvedValue([]);
secretSvc.normalizeAdapterConfigForPersistence.mockResolvedValueOnce({
normalized: true,
env: {
OPENAI_API_KEY: {
type: "secret_ref",
secretId: "secret-1",
version: "latest",
},
},
});
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
id: "agent-created",
name: String(input.name),
adapterType: input.adapterType,
adapterConfig: input.adapterConfig,
status: input.status,
}));
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: ["claudecoder"],
collisionStrategy: "rename",
}, "user-1");
expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith(
"company-imported",
expect.any(Object),
{ strictMode: false },
);
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
adapterType: "claude_local",
adapterConfig: expect.objectContaining({
normalized: true,
}),
status: "pending_approval",
}));
});
it("normalizes adapter config on replace imports before updating existing agents", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
secretSvc.normalizeAdapterConfigForPersistence.mockResolvedValueOnce({
normalized: "updated",
});
agentSvc.update.mockImplementation(async (id: string, patch: Record<string, unknown>) => ({
id,
name: "ClaudeCoder",
adapterType: patch.adapterType,
adapterConfig: patch.adapterConfig,
}));
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: false,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "existing_company",
companyId: "company-1",
},
agents: ["claudecoder"],
collisionStrategy: "replace",
adapterOverrides: {
claudecoder: {
adapterType: "codex_local",
adapterConfig: {
model: "gpt-5.4",
},
},
},
}, "user-1");
expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith(
"company-1",
expect.any(Object),
{ strictMode: false },
);
expect(agentSvc.update).toHaveBeenCalledWith("agent-1", expect.objectContaining({
adapterType: "codex_local",
adapterConfig: {
normalized: "updated",
},
}));
});
});
+117 -1
View File
@@ -34,7 +34,7 @@ describe("GET /health", () => {
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: "ok", version: serverVersion });
});
}, 15_000);
it("returns 200 when the database probe succeeds", async () => {
const db = {
@@ -63,4 +63,120 @@ describe("GET /health", () => {
error: "database_unreachable",
});
});
it("redacts detailed metadata for anonymous requests in authenticated mode", async () => {
const devServerStatus = await import("../dev-server-status.js");
vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined);
const { healthRoutes } = await import("../routes/health.js");
const db = {
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn().mockResolvedValue([{ count: 1 }]),
})),
})),
} as unknown as Db;
const app = express();
app.use((req, _res, next) => {
(req as any).actor = { type: "none", source: "none" };
next();
});
app.use(
"/health",
healthRoutes(db, {
deploymentMode: "authenticated",
deploymentExposure: "public",
authReady: true,
companyDeletionEnabled: false,
}),
);
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(res.body).toEqual({
status: "ok",
deploymentMode: "authenticated",
bootstrapStatus: "ready",
bootstrapInviteActive: false,
});
});
it("redacts detailed metadata when authenticated mode is reached without auth middleware", async () => {
const devServerStatus = await import("../dev-server-status.js");
vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined);
const { healthRoutes } = await import("../routes/health.js");
const db = {
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn().mockResolvedValue([{ count: 1 }]),
})),
})),
} as unknown as Db;
const app = express();
app.use(
"/health",
healthRoutes(db, {
deploymentMode: "authenticated",
deploymentExposure: "public",
authReady: true,
companyDeletionEnabled: false,
}),
);
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(res.body).toEqual({
status: "ok",
deploymentMode: "authenticated",
bootstrapStatus: "ready",
bootstrapInviteActive: false,
});
});
it("keeps detailed metadata for authenticated requests in authenticated mode", async () => {
const devServerStatus = await import("../dev-server-status.js");
vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined);
const { healthRoutes } = await import("../routes/health.js");
const db = {
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn().mockResolvedValue([{ count: 1 }]),
})),
})),
} as unknown as Db;
const app = express();
app.use((req, _res, next) => {
(req as any).actor = { type: "board", userId: "user-1", source: "session" };
next();
});
app.use(
"/health",
healthRoutes(db, {
deploymentMode: "authenticated",
deploymentExposure: "public",
authReady: true,
companyDeletionEnabled: false,
}),
);
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
status: "ok",
version: serverVersion,
deploymentMode: "authenticated",
deploymentExposure: "public",
authReady: true,
bootstrapStatus: "ready",
bootstrapInviteActive: false,
features: {
companyDeletionEnabled: false,
},
});
});
});
@@ -41,6 +41,7 @@ describe("buildInviteOnboardingTextDocument", () => {
expect(text).toContain("/api/invites/token-123/accept");
expect(text).toContain("/api/join-requests/{requestId}/claim-api-key");
expect(text).toContain("/api/invites/token-123/onboarding.txt");
expect(text).toContain("/api/invites/token-123/skills/paperclip");
expect(text).toContain("Suggested Paperclip base URLs to try");
expect(text).toContain("http://localhost:3100");
expect(text).toContain("host.docker.internal");
@@ -148,7 +148,7 @@ async function normalizePolicy(input: {
return normalizeIssueExecutionPolicy(input);
}
function makeIssue(status: "todo" | "done") {
function makeIssue(status: "todo" | "done" | "blocked") {
return {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
@@ -430,6 +430,34 @@ describe("issue comment reopen routes", () => {
expect(res.status).toBe(201);
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("wakes the assignee when an assigned blocked issue moves back to todo", async () => {
const issue = makeIssue("blocked");
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ status: "todo" });
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
source: "automation",
triggerDetail: "system",
reason: "issue_status_changed",
payload: expect.objectContaining({
issueId: "11111111-1111-4111-8111-111111111111",
mutation: "update",
}),
}),
);
});
it("interrupts an active run before a combined comment update", async () => {
const issue = {
...makeIssue("todo"),
@@ -0,0 +1,165 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
const mockIssueService = vi.hoisted(() => ({
addComment: vi.fn(),
assertCheckoutOwner: vi.fn(),
create: vi.fn(),
findMentionedAgents: vi.fn(),
getByIdentifier: vi.fn(),
getById: vi.fn(),
getRelationSummaries: vi.fn(),
getWakeableParentAfterChildCompletion: vi.fn(),
listWakeableBlockedDependents: vi.fn(),
update: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(async () => true),
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
getById: vi.fn(async () => null),
}),
documentService: () => ({}),
executionWorkspaceService: () => ({
getById: vi.fn(async () => null),
}),
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: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
function createApp(actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use(errorHandler);
return app;
}
function makeIssue(overrides: Record<string, unknown> = {}) {
return {
id: "issue-1",
companyId: "company-1",
status: "todo",
priority: "medium",
projectId: null,
goalId: null,
parentId: null,
assigneeAgentId: null,
assigneeUserId: null,
createdByUserId: "board-user",
identifier: "PAP-1000",
title: "Workspace authz",
executionPolicy: null,
executionState: null,
executionWorkspaceId: null,
hiddenAt: null,
...overrides,
};
}
describe("issue workspace command authorization", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIssueService.addComment.mockResolvedValue(null);
mockIssueService.create.mockResolvedValue(makeIssue());
mockIssueService.findMentionedAgents.mockResolvedValue([]);
mockIssueService.getByIdentifier.mockResolvedValue(null);
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockIssueService.update.mockResolvedValue(makeIssue());
});
it("rejects agent callers that create issue workspace provision commands", async () => {
const app = createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post("/api/companies/company-1/issues")
.send({
title: "Exploit",
executionWorkspaceSettings: {
workspaceStrategy: {
type: "git_worktree",
provisionCommand: "touch /tmp/paperclip-rce",
},
},
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed workspace commands");
expect(mockIssueService.create).not.toHaveBeenCalled();
});
it("rejects agent callers that patch assignee adapter workspace teardown commands", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue());
const app = createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.patch("/api/issues/issue-1")
.send({
assigneeAdapterOverrides: {
adapterConfig: {
workspaceStrategy: {
type: "git_worktree",
teardownCommand: "rm -rf /tmp/paperclip-rce",
},
},
},
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed workspace commands");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,168 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockRegistry = vi.hoisted(() => ({
getById: vi.fn(),
getByKey: vi.fn(),
}));
const mockLifecycle = vi.hoisted(() => ({
load: vi.fn(),
upgrade: vi.fn(),
}));
vi.mock("../services/plugin-registry.js", () => ({
pluginRegistryService: () => mockRegistry,
}));
vi.mock("../services/plugin-lifecycle.js", () => ({
pluginLifecycleManager: () => mockLifecycle,
}));
vi.mock("../services/activity-log.js", () => ({
logActivity: vi.fn(),
}));
vi.mock("../services/live-events.js", () => ({
publishGlobalLiveEvent: vi.fn(),
}));
async function createApp(actor: Record<string, unknown>, loaderOverrides: Record<string, unknown> = {}) {
const [{ pluginRoutes }, { errorHandler }] = await Promise.all([
import("../routes/plugins.js"),
import("../middleware/index.js"),
]);
const loader = {
installPlugin: vi.fn(),
...loaderOverrides,
};
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor as typeof req.actor;
next();
});
app.use("/api", pluginRoutes({} as never, loader as never));
app.use(errorHandler);
return { app, loader };
}
describe("plugin install and upgrade authz", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("rejects plugin installation for non-admin board users", async () => {
const { app, loader } = await createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
});
const res = await request(app)
.post("/api/plugins/install")
.send({ packageName: "paperclip-plugin-example" });
expect(res.status).toBe(403);
expect(loader.installPlugin).not.toHaveBeenCalled();
}, 20_000);
it("allows instance admins to install plugins", async () => {
const pluginId = "11111111-1111-4111-8111-111111111111";
const pluginKey = "paperclip.example";
const discovered = {
manifest: {
id: pluginKey,
},
};
mockRegistry.getByKey.mockResolvedValue({
id: pluginId,
pluginKey,
packageName: "paperclip-plugin-example",
version: "1.0.0",
});
mockRegistry.getById.mockResolvedValue({
id: pluginId,
pluginKey,
packageName: "paperclip-plugin-example",
version: "1.0.0",
});
mockLifecycle.load.mockResolvedValue(undefined);
const { app, loader } = await createApp(
{
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
companyIds: [],
},
{ installPlugin: vi.fn().mockResolvedValue(discovered) },
);
const res = await request(app)
.post("/api/plugins/install")
.send({ packageName: "paperclip-plugin-example" });
expect(res.status).toBe(200);
expect(loader.installPlugin).toHaveBeenCalledWith({
packageName: "paperclip-plugin-example",
version: undefined,
});
expect(mockLifecycle.load).toHaveBeenCalledWith(pluginId);
}, 20_000);
it("rejects plugin upgrades for non-admin board users", async () => {
const pluginId = "11111111-1111-4111-8111-111111111111";
const { app } = await createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/upgrade`)
.send({});
expect(res.status).toBe(403);
expect(mockRegistry.getById).not.toHaveBeenCalled();
expect(mockLifecycle.upgrade).not.toHaveBeenCalled();
}, 20_000);
it("allows instance admins to upgrade plugins", async () => {
const pluginId = "11111111-1111-4111-8111-111111111111";
mockRegistry.getById.mockResolvedValue({
id: pluginId,
pluginKey: "paperclip.example",
version: "1.0.0",
});
mockLifecycle.upgrade.mockResolvedValue({
id: pluginId,
version: "1.1.0",
});
const { app } = await createApp({
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
companyIds: [],
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/upgrade`)
.send({ version: "1.1.0" });
expect(res.status).toBe(200);
expect(mockLifecycle.upgrade).toHaveBeenCalledWith(pluginId, "1.1.0");
}, 20_000);
});
@@ -0,0 +1,437 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockProjectService = vi.hoisted(() => ({
create: vi.fn(),
createWorkspace: vi.fn(),
getById: vi.fn(),
listWorkspaces: vi.fn(),
resolveByReference: vi.fn(),
update: vi.fn(),
updateWorkspace: vi.fn(),
}));
const mockExecutionWorkspaceService = vi.hoisted(() => ({
getById: vi.fn(),
update: vi.fn(),
}));
const mockSecretService = vi.hoisted(() => ({
normalizeEnvBindingsForPersistence: vi.fn(),
}));
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
const mockAssertCanManageProjectWorkspaceRuntimeServices = vi.hoisted(() => vi.fn());
const mockAssertCanManageExecutionWorkspaceRuntimeServices = vi.hoisted(() => vi.fn());
function registerModuleMocks() {
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../services/index.js", () => ({
executionWorkspaceService: () => mockExecutionWorkspaceService,
logActivity: mockLogActivity,
projectService: () => mockProjectService,
secretService: () => mockSecretService,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.doMock("../services/workspace-runtime.js", () => ({
cleanupExecutionWorkspaceArtifacts: vi.fn(),
startRuntimeServicesForWorkspaceControl: vi.fn(),
stopRuntimeServicesForExecutionWorkspace: vi.fn(),
stopRuntimeServicesForProjectWorkspace: vi.fn(),
}));
vi.doMock("../routes/workspace-runtime-service-authz.js", () => ({
assertCanManageProjectWorkspaceRuntimeServices: mockAssertCanManageProjectWorkspaceRuntimeServices,
assertCanManageExecutionWorkspaceRuntimeServices: mockAssertCanManageExecutionWorkspaceRuntimeServices,
}));
}
async function createProjectApp(actor: Record<string, unknown>) {
const { projectRoutes } = await import("../routes/projects.js");
const { errorHandler } = await import("../middleware/index.js");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", projectRoutes({} as any));
app.use(errorHandler);
return app;
}
async function createExecutionWorkspaceApp(actor: Record<string, unknown>) {
const { executionWorkspaceRoutes } = await import("../routes/execution-workspaces.js");
const { errorHandler } = await import("../middleware/index.js");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", executionWorkspaceRoutes({} as any));
app.use(errorHandler);
return app;
}
function buildProject(overrides: Record<string, unknown> = {}) {
return {
id: "project-1",
companyId: "company-1",
urlKey: "project-1",
goalId: null,
goalIds: [],
goals: [],
name: "Project",
description: null,
status: "backlog",
leadAgentId: null,
targetDate: null,
color: null,
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: null,
workspaces: [],
primaryWorkspace: null,
archivedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function buildExecutionWorkspace(overrides: Record<string, unknown> = {}) {
return {
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
sourceIssueId: null,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Workspace",
status: "active",
cwd: "/tmp/workspace",
repoUrl: null,
baseRef: "main",
branchName: "feature/test",
providerType: "git_worktree",
providerRef: null,
derivedFromExecutionWorkspaceId: null,
lastUsedAt: new Date(),
openedAt: new Date(),
closedAt: null,
cleanupEligibleAt: null,
cleanupReason: null,
config: null,
metadata: null,
runtimeServices: [],
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
describe("workspace runtime service route authorization", () => {
const projectId = "11111111-1111-4111-8111-111111111111";
const workspaceId = "22222222-2222-4222-8222-222222222222";
const executionWorkspaceId = "33333333-3333-4333-8333-333333333333";
beforeEach(() => {
vi.resetModules();
registerModuleMocks();
vi.clearAllMocks();
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
mockProjectService.create.mockResolvedValue(buildProject());
mockProjectService.update.mockResolvedValue(buildProject());
mockProjectService.createWorkspace.mockResolvedValue({
id: workspaceId,
companyId: "company-1",
projectId,
name: "Workspace",
sourceType: "local_path",
cwd: "/tmp/project",
repoUrl: null,
repoRef: null,
defaultRef: null,
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
runtimeConfig: null,
isPrimary: false,
runtimeServices: [],
createdAt: new Date(),
updatedAt: new Date(),
});
mockProjectService.listWorkspaces.mockResolvedValue([{
id: workspaceId,
companyId: "company-1",
projectId,
name: "Workspace",
sourceType: "local_path",
cwd: "/tmp/project",
repoUrl: null,
repoRef: null,
defaultRef: null,
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
runtimeConfig: null,
isPrimary: false,
runtimeServices: [],
createdAt: new Date(),
updatedAt: new Date(),
}]);
mockProjectService.updateWorkspace.mockResolvedValue({
id: workspaceId,
companyId: "company-1",
projectId,
name: "Workspace",
sourceType: "local_path",
cwd: "/tmp/project",
repoUrl: null,
repoRef: null,
defaultRef: null,
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
runtimeConfig: null,
isPrimary: false,
runtimeServices: [],
createdAt: new Date(),
updatedAt: new Date(),
});
mockExecutionWorkspaceService.update.mockResolvedValue(buildExecutionWorkspace());
mockAssertCanManageProjectWorkspaceRuntimeServices.mockResolvedValue(undefined);
mockAssertCanManageExecutionWorkspaceRuntimeServices.mockResolvedValue(undefined);
});
it("rejects agent callers for project workspace runtime service mutations when workspace auth denies access", async () => {
const { forbidden } = await import("../errors.js");
mockProjectService.getById.mockResolvedValue(buildProject({
id: projectId,
workspaces: [{
id: workspaceId,
companyId: "company-1",
projectId,
name: "Workspace",
sourceType: "local_path",
cwd: "/tmp/project",
repoUrl: null,
repoRef: null,
defaultRef: null,
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
runtimeConfig: null,
isPrimary: false,
runtimeServices: [],
createdAt: new Date(),
updatedAt: new Date(),
}],
}));
mockAssertCanManageProjectWorkspaceRuntimeServices.mockRejectedValue(
forbidden("Missing permission to manage workspace runtime services"),
);
const app = await createProjectApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post(`/api/projects/${projectId}/workspaces/${workspaceId}/runtime-services/start`)
.send({});
expect(res.status).toBe(403);
expect(res.body.error).toContain("Missing permission");
expect(mockProjectService.getById).toHaveBeenCalledWith(projectId);
expect(mockAssertCanManageProjectWorkspaceRuntimeServices).toHaveBeenCalled();
}, 15000);
it("rejects agent callers that create project execution workspace commands", async () => {
const app = await createProjectApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post("/api/companies/company-1/projects")
.send({
name: "Exploit",
executionWorkspacePolicy: {
enabled: true,
workspaceStrategy: {
type: "git_worktree",
provisionCommand: "touch /tmp/paperclip-rce",
},
},
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed workspace commands");
expect(mockProjectService.create).not.toHaveBeenCalled();
});
it("rejects agent callers that update project workspace cleanup commands", async () => {
mockProjectService.getById.mockResolvedValue(buildProject());
const app = await createProjectApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.patch(`/api/projects/${projectId}/workspaces/${workspaceId}`)
.send({
cleanupCommand: "rm -rf /tmp/paperclip-rce",
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed workspace commands");
expect(mockProjectService.updateWorkspace).not.toHaveBeenCalled();
});
it("allows board callers through the project workspace runtime auth gate", async () => {
mockProjectService.getById.mockResolvedValue(null);
const app = await createProjectApp({
type: "board",
userId: "board-1",
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app)
.post(`/api/projects/${projectId}/workspaces/${workspaceId}/runtime-services/start`)
.send({});
expect(res.status).toBe(404);
expect(res.body.error).toContain("Project not found");
expect(mockProjectService.getById).toHaveBeenCalledWith(projectId);
});
it("rejects agent callers for execution workspace runtime service mutations when workspace auth denies access", async () => {
const { forbidden } = await import("../errors.js");
mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ id: executionWorkspaceId }));
mockAssertCanManageExecutionWorkspaceRuntimeServices.mockRejectedValue(
forbidden("Missing permission to manage workspace runtime services"),
);
const app = await createExecutionWorkspaceApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/restart`)
.send({});
expect(res.status).toBe(403);
expect(res.body.error).toContain("Missing permission");
expect(mockExecutionWorkspaceService.getById).toHaveBeenCalledWith(executionWorkspaceId);
expect(mockAssertCanManageExecutionWorkspaceRuntimeServices).toHaveBeenCalled();
}, 15000);
it("rejects agent callers that patch execution workspace command config", async () => {
mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ id: executionWorkspaceId }));
const app = await createExecutionWorkspaceApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.patch(`/api/execution-workspaces/${executionWorkspaceId}`)
.send({
config: {
cleanupCommand: "rm -rf /tmp/paperclip-rce",
},
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed workspace commands");
expect(mockExecutionWorkspaceService.update).not.toHaveBeenCalled();
});
it("rejects agent callers that smuggle execution workspace commands through metadata.config", async () => {
mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ id: executionWorkspaceId }));
const app = await createExecutionWorkspaceApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.patch(`/api/execution-workspaces/${executionWorkspaceId}`)
.send({
metadata: {
config: {
provisionCommand: "touch /tmp/paperclip-rce",
},
},
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("host-executed workspace commands");
expect(mockExecutionWorkspaceService.update).not.toHaveBeenCalled();
});
it("allows board callers through the execution workspace runtime auth gate", async () => {
mockExecutionWorkspaceService.getById.mockResolvedValue(null);
const app = await createExecutionWorkspaceApp({
type: "board",
userId: "board-1",
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app)
.post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/restart`)
.send({});
expect(res.status).toBe(404);
expect(res.body.error).toContain("Execution workspace not found");
expect(mockExecutionWorkspaceService.getById).toHaveBeenCalledWith(executionWorkspaceId);
});
});
@@ -0,0 +1,279 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
companies,
createDb,
executionWorkspaces,
issues,
projectWorkspaces,
projects,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import {
assertCanManageExecutionWorkspaceRuntimeServices,
assertCanManageProjectWorkspaceRuntimeServices,
} from "../routes/workspace-runtime-service-authz.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres workspace runtime auth tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("workspace runtime service authz helper", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-workspace-runtime-authz-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompany() {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `PAP-${companyId.slice(0, 8)}`,
requireBoardApprovalForNewAgents: false,
});
return companyId;
}
async function seedProjectWorkspace(companyId: string) {
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace authz",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
sourceType: "local_path",
cwd: "/tmp/paperclip-authz-project",
isPrimary: true,
});
return { projectId, projectWorkspaceId };
}
async function seedExecutionWorkspace(companyId: string, projectId: string, projectWorkspaceId: string) {
const executionWorkspaceId = randomUUID();
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Execution workspace",
status: "active",
providerType: "local_fs",
cwd: "/tmp/paperclip-authz-execution",
});
return executionWorkspaceId;
}
async function seedAgent(
companyId: string,
input: { role?: string; reportsTo?: string | null; name?: string } = {},
) {
const agentId = randomUUID();
await db.insert(agents).values({
id: agentId,
companyId,
name: input.name ?? "Agent",
role: input.role ?? "engineer",
reportsTo: input.reportsTo ?? null,
});
return agentId;
}
it("allows board actors to manage project workspace runtime services", async () => {
const companyId = await seedCompany();
const { projectWorkspaceId } = await seedProjectWorkspace(companyId);
await expect(assertCanManageProjectWorkspaceRuntimeServices(db, {
actor: {
type: "board",
userId: "board-1",
companyIds: [companyId],
source: "session",
isInstanceAdmin: false,
},
} as any, {
companyId,
projectWorkspaceId,
})).resolves.toBeUndefined();
});
it("allows CEO agents to manage any project workspace runtime services in their company", async () => {
const companyId = await seedCompany();
const { projectWorkspaceId } = await seedProjectWorkspace(companyId);
const ceoAgentId = await seedAgent(companyId, { role: "ceo", name: "CEO" });
await expect(assertCanManageProjectWorkspaceRuntimeServices(db, {
actor: {
type: "agent",
agentId: ceoAgentId,
companyId,
source: "agent_key",
},
} as any, {
companyId,
projectWorkspaceId,
})).resolves.toBeUndefined();
});
it("allows agents with a non-terminal assigned issue in the target project workspace", async () => {
const companyId = await seedCompany();
const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId);
const agentId = await seedAgent(companyId, { name: "Engineer" });
await db.insert(issues).values({
id: randomUUID(),
companyId,
projectId,
projectWorkspaceId,
title: "Use this workspace",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
});
await expect(assertCanManageProjectWorkspaceRuntimeServices(db, {
actor: {
type: "agent",
agentId,
companyId,
source: "agent_key",
},
} as any, {
companyId,
projectWorkspaceId,
})).resolves.toBeUndefined();
});
it("allows managers to manage execution workspace runtime services for their reporting subtree", async () => {
const companyId = await seedCompany();
const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId);
const executionWorkspaceId = await seedExecutionWorkspace(companyId, projectId, projectWorkspaceId);
const managerId = await seedAgent(companyId, { role: "cto", name: "Manager" });
const reportId = await seedAgent(companyId, { reportsTo: managerId, name: "Report" });
await db.insert(issues).values({
id: randomUUID(),
companyId,
projectId,
projectWorkspaceId,
executionWorkspaceId,
title: "Use execution workspace",
status: "in_progress",
priority: "medium",
assigneeAgentId: reportId,
});
await expect(assertCanManageExecutionWorkspaceRuntimeServices(db, {
actor: {
type: "agent",
agentId: managerId,
companyId,
source: "agent_key",
},
} as any, {
companyId,
executionWorkspaceId,
})).resolves.toBeUndefined();
});
it("rejects unrelated same-company agents without matching workspace assignments", async () => {
const companyId = await seedCompany();
const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId);
const executionWorkspaceId = await seedExecutionWorkspace(companyId, projectId, projectWorkspaceId);
const assignedAgentId = await seedAgent(companyId, { name: "Assigned" });
const unrelatedAgentId = await seedAgent(companyId, { name: "Unrelated" });
await db.insert(issues).values({
id: randomUUID(),
companyId,
projectId,
projectWorkspaceId,
executionWorkspaceId,
title: "Assigned issue",
status: "todo",
priority: "medium",
assigneeAgentId: assignedAgentId,
});
await expect(assertCanManageExecutionWorkspaceRuntimeServices(db, {
actor: {
type: "agent",
agentId: unrelatedAgentId,
companyId,
source: "agent_key",
},
} as any, {
companyId,
executionWorkspaceId,
})).rejects.toMatchObject({
status: 403,
message: "Missing permission to manage workspace runtime services",
});
});
it("rejects completed workspace assignments so stale issues do not keep access alive", async () => {
const companyId = await seedCompany();
const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId);
const agentId = await seedAgent(companyId, { name: "Engineer" });
await db.insert(issues).values({
id: randomUUID(),
companyId,
projectId,
projectWorkspaceId,
title: "Completed issue",
status: "done",
priority: "medium",
assigneeAgentId: agentId,
});
await expect(assertCanManageProjectWorkspaceRuntimeServices(db, {
actor: {
type: "agent",
agentId,
companyId,
source: "agent_key",
},
} as any, {
companyId,
projectWorkspaceId,
})).rejects.toMatchObject({
status: 403,
message: "Missing permission to manage workspace runtime services",
});
});
});
+14 -2
View File
@@ -86,6 +86,16 @@ function shouldServeViteDevHtml(req: ExpressRequest): boolean {
return req.accepts(["html"]) === "html";
}
export function shouldEnablePrivateHostnameGuard(opts: {
deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure;
}): boolean {
return (
opts.deploymentExposure === "private" &&
(opts.deploymentMode === "local_trusted" || opts.deploymentMode === "authenticated")
);
}
export async function createApp(
db: Db,
opts: {
@@ -123,8 +133,10 @@ export async function createApp(
},
}));
app.use(httpLogger);
const privateHostnameGateEnabled =
opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private";
const privateHostnameGateEnabled = shouldEnablePrivateHostnameGuard({
deploymentMode: opts.deploymentMode,
deploymentExposure: opts.deploymentExposure,
});
const privateHostnameAllowSet = resolvePrivateHostnameAllowSet({
allowedHostnames: opts.allowedHostnames,
bindHost: opts.bindHost,
+52 -7
View File
@@ -48,7 +48,7 @@ import {
logActivity,
notifyHireApproved
} from "../services/index.js";
import { assertCompanyAccess } from "./authz.js";
import { assertAuthenticated, assertCompanyAccess } from "./authz.js";
import {
claimBoardOwnership,
inspectBoardClaimChallenge
@@ -863,6 +863,7 @@ function toInviteSummaryResponse(
const baseUrl = requestBaseUrl(req);
const onboardingPath = `/api/invites/${token}/onboarding`;
const onboardingTextPath = `/api/invites/${token}/onboarding.txt`;
const skillIndexPath = `/api/invites/${token}/skills/index`;
const inviteMessage = extractInviteMessage(invite);
return {
id: invite.id,
@@ -877,10 +878,10 @@ function toInviteSummaryResponse(
onboardingTextUrl: baseUrl
? `${baseUrl}${onboardingTextPath}`
: onboardingTextPath,
skillIndexPath: "/api/skills/index",
skillIndexPath,
skillIndexUrl: baseUrl
? `${baseUrl}/api/skills/index`
: "/api/skills/index",
? `${baseUrl}${skillIndexPath}`
: skillIndexPath,
inviteMessage
};
}
@@ -1004,7 +1005,7 @@ function buildInviteOnboardingManifest(
}
) {
const baseUrl = requestBaseUrl(req);
const skillPath = "/api/skills/paperclip";
const skillPath = `/api/invites/${token}/skills/paperclip`;
const skillUrl = baseUrl ? `${baseUrl}${skillPath}` : skillPath;
const registrationEndpointPath = `/api/invites/${token}/accept`;
const registrationEndpointUrl = baseUrl
@@ -1906,11 +1907,13 @@ export function accessRoutes(
return company?.name ?? null;
}
router.get("/skills/available", (_req, res) => {
router.get("/skills/available", (req, res) => {
assertAuthenticated(req);
res.json({ skills: listAvailableSkills() });
});
router.get("/skills/index", (_req, res) => {
router.get("/skills/index", (req, res) => {
assertAuthenticated(req);
res.json({
skills: [
{ name: "paperclip", path: "/api/skills/paperclip" },
@@ -1927,6 +1930,7 @@ export function accessRoutes(
});
router.get("/skills/:skillName", (req, res) => {
assertAuthenticated(req);
const skillName = (req.params.skillName as string).trim().toLowerCase();
const markdown = readSkillMarkdown(skillName);
if (!markdown) throw notFound("Skill not found");
@@ -2100,6 +2104,47 @@ export function accessRoutes(
);
});
router.get("/invites/:token/skills/index", async (req, res) => {
const token = (req.params.token as string).trim();
if (!token) throw notFound("Invite not found");
const invite = await db
.select()
.from(invites)
.where(eq(invites.tokenHash, hashToken(token)))
.then((rows) => rows[0] ?? null);
if (!invite || invite.revokedAt || inviteExpired(invite)) {
throw notFound("Invite not found");
}
res.json({
skills: [
{
name: "paperclip",
path: `/api/invites/${token}/skills/paperclip`,
},
],
});
});
router.get("/invites/:token/skills/:skillName", async (req, res) => {
const token = (req.params.token as string).trim();
if (!token) throw notFound("Invite not found");
const invite = await db
.select()
.from(invites)
.where(eq(invites.tokenHash, hashToken(token)))
.then((rows) => rows[0] ?? null);
if (!invite || invite.revokedAt || inviteExpired(invite)) {
throw notFound("Invite not found");
}
const skillName = (req.params.skillName as string).trim().toLowerCase();
if (skillName !== "paperclip") throw notFound("Skill not found");
const markdown = readSkillMarkdown(skillName);
if (!markdown) throw notFound("Skill not found");
res.type("text/markdown").send(markdown);
});
router.get("/invites/:token/test-resolution", async (req, res) => {
const token = (req.params.token as string).trim();
if (!token) throw notFound("Invite not found");
+2 -1
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
import type { Db } from "@paperclipai/db";
import { validate } from "../middleware/validate.js";
import { activityService } from "../services/activity.js";
import { assertBoard, assertCompanyAccess } from "./authz.js";
import { assertAuthenticated, assertBoard, assertCompanyAccess } from "./authz.js";
import { heartbeatService, issueService } from "../services/index.js";
import { sanitizeRecord } from "../redaction.js";
@@ -81,6 +81,7 @@ export function activityRoutes(db: Db) {
});
router.get("/heartbeat-runs/:runId/issues", async (req, res) => {
assertAuthenticated(req);
const runId = req.params.runId as string;
const run = await heartbeat.getRun(runId);
if (!run) {
+149 -43
View File
@@ -1,4 +1,4 @@
import { Router, type Request } from "express";
import { Router, type Request, type Response } from "express";
import { generateKeyPairSync, randomUUID } from "node:crypto";
import path from "node:path";
import type { Db } from "@paperclipai/db";
@@ -46,6 +46,10 @@ import {
} from "../services/index.js";
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
import {
assertNoAgentHostWorkspaceCommandMutation,
collectAgentAdapterWorkspaceCommandPaths,
} from "./workspace-command-authz.js";
import {
detectAdapterModel,
findActiveServerAdapter,
@@ -231,10 +235,33 @@ export function agentRoutes(db: Db) {
return actorAgent;
}
async function assertBoardCanManageAgentsForCompany(req: Request, companyId: string) {
assertBoard(req);
assertCompanyAccess(req, companyId);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
const allowed = await access.canUser(companyId, req.actor.userId, "agents:create");
if (!allowed) {
throw forbidden("Missing permission: agents:create");
}
}
async function assertCanReadConfigurations(req: Request, companyId: string) {
return assertCanCreateAgentsForCompany(req, companyId);
}
async function getAccessibleAgent(req: Request, res: Response, id: string) {
const agent = await svc.getById(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return null;
}
assertCompanyAccess(req, agent.companyId);
if (req.actor.type === "board") {
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
}
return agent;
}
async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
if (req.actor.type === "board") {
@@ -317,7 +344,10 @@ export function agentRoutes(db: Db) {
async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) {
assertCompanyAccess(req, targetAgent.companyId);
if (req.actor.type === "board") return;
if (req.actor.type === "board") {
await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId);
return;
}
if (!req.actor.agentId) throw forbidden("Agent authentication required");
const actorAgent = await svc.getById(req.actor.agentId);
@@ -339,7 +369,10 @@ export function agentRoutes(db: Db) {
async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) {
assertCompanyAccess(req, targetAgent.companyId);
if (req.actor.type === "board") return;
if (req.actor.type === "board") {
await assertCanReadConfigurations(req, targetAgent.companyId);
return;
}
if (!req.actor.agentId) throw forbidden("Agent authentication required");
const actorAgent = await svc.getById(req.actor.agentId);
@@ -610,19 +643,24 @@ export function agentRoutes(db: Db) {
async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) {
assertCompanyAccess(req, targetAgent.companyId);
if (req.actor.type === "board") return;
if (!req.actor.agentId) throw forbidden("Agent authentication required");
const actorAgent = await svc.getById(req.actor.agentId);
if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) {
throw forbidden("Agent key cannot access another company");
if (req.actor.type !== "board") {
throw forbidden(
"Only board-authenticated callers can manage instructions path or bundle configuration",
);
}
if (actorAgent.id === targetAgent.id) return;
await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId);
}
const chainOfCommand = await svc.getChainOfCommand(targetAgent.id);
if (chainOfCommand.some((manager) => manager.id === actorAgent.id)) return;
throw forbidden("Only the target agent or an ancestor manager can update instructions path");
function assertNoAgentInstructionsConfigMutation(
req: Request,
adapterConfig: Record<string, unknown> | null | undefined,
) {
if (req.actor.type !== "agent" || !adapterConfig) return;
const changedSensitiveKeys = KNOWN_INSTRUCTIONS_BUNDLE_KEYS.filter((key) => adapterConfig[key] !== undefined);
if (changedSensitiveKeys.length === 0) return;
throw forbidden(
`Agent-authenticated callers cannot modify instructions path or bundle configuration (${changedSensitiveKeys.join(", ")})`,
);
}
function summarizeAgentUpdateDetails(patch: Record<string, unknown>) {
@@ -997,7 +1035,7 @@ export function agentRoutes(db: Db) {
}
const result = await svc.list(companyId);
const canReadConfigs = await actorCanReadConfigurationsForCompany(req, companyId);
if (canReadConfigs || req.actor.type === "board") {
if (canReadConfigs) {
res.json(result);
return;
}
@@ -1173,12 +1211,13 @@ export function agentRoutes(db: Db) {
return;
}
assertCompanyAccess(req, agent.companyId);
if (req.actor.type === "agent" && req.actor.agentId !== id) {
const canRead = await actorCanReadConfigurationsForCompany(req, agent.companyId);
if (!canRead) {
res.json(await buildAgentDetail(agent, { restricted: true }));
return;
}
const isSelf = req.actor.type === "agent" && req.actor.agentId === id;
const canReadSensitiveDetail = isSelf
? true
: await actorCanReadConfigurationsForCompany(req, agent.companyId);
if (!canReadSensitiveDetail) {
res.json(await buildAgentDetail(agent, { restricted: true }));
return;
}
res.json(await buildAgentDetail(agent));
});
@@ -1266,6 +1305,7 @@ export function agentRoutes(db: Db) {
res.status(404).json({ error: "Agent not found" });
return;
}
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
assertCompanyAccess(req, agent.companyId);
const state = await heartbeat.getRuntimeState(id);
@@ -1280,6 +1320,7 @@ export function agentRoutes(db: Db) {
res.status(404).json({ error: "Agent not found" });
return;
}
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
assertCompanyAccess(req, agent.companyId);
const sessions = await heartbeat.listTaskSessions(id);
@@ -1299,6 +1340,7 @@ export function agentRoutes(db: Db) {
res.status(404).json({ error: "Agent not found" });
return;
}
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
assertCompanyAccess(req, agent.companyId);
const taskKey =
@@ -1331,6 +1373,14 @@ export function agentRoutes(db: Db) {
...hireInput
} = req.body;
hireInput.adapterType = assertKnownAdapterType(hireInput.adapterType);
assertNoAgentHostWorkspaceCommandMutation(
req,
collectAgentAdapterWorkspaceCommandPaths(hireInput.adapterConfig),
);
assertNoAgentInstructionsConfigMutation(
req,
(hireInput.adapterConfig ?? {}) as Record<string, unknown>,
);
const requestedAdapterConfig = applyCreateDefaultsByAdapterType(
hireInput.adapterType,
((hireInput.adapterConfig ?? {}) as Record<string, unknown>),
@@ -1590,6 +1640,8 @@ export function agentRoutes(db: Db) {
res.status(403).json({ error: "Only CEO can manage permissions" });
return;
}
} else {
await assertBoardCanManageAgentsForCompany(req, existing.companyId);
}
const agent = await svc.updatePermissions(id, req.body);
@@ -1890,10 +1942,15 @@ export function agentRoutes(db: Db) {
res.status(422).json({ error: "adapterConfig must be an object" });
return;
}
const changingInstructionsPath = Object.keys(adapterConfig).some((key) =>
KNOWN_INSTRUCTIONS_PATH_KEYS.has(key),
assertNoAgentInstructionsConfigMutation(req, adapterConfig);
assertNoAgentHostWorkspaceCommandMutation(
req,
collectAgentAdapterWorkspaceCommandPaths(adapterConfig),
);
if (changingInstructionsPath) {
const changingInstructionsConfig = Object.keys(adapterConfig).some((key) =>
KNOWN_INSTRUCTIONS_BUNDLE_KEYS.includes(key as (typeof KNOWN_INSTRUCTIONS_BUNDLE_KEYS)[number]),
);
if (changingInstructionsConfig) {
await assertCanManageInstructionsPath(req, existing);
}
patchData.adapterConfig = adapterConfig;
@@ -1994,6 +2051,9 @@ export function agentRoutes(db: Db) {
router.post("/agents/:id/pause", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
if (!(await getAccessibleAgent(req, res, id))) {
return;
}
const agent = await svc.pause(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
@@ -2017,6 +2077,9 @@ export function agentRoutes(db: Db) {
router.post("/agents/:id/resume", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
if (!(await getAccessibleAgent(req, res, id))) {
return;
}
const agent = await svc.resume(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
@@ -2038,6 +2101,9 @@ export function agentRoutes(db: Db) {
router.post("/agents/:id/terminate", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
if (!(await getAccessibleAgent(req, res, id))) {
return;
}
const agent = await svc.terminate(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
@@ -2061,6 +2127,9 @@ export function agentRoutes(db: Db) {
router.delete("/agents/:id", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
if (!(await getAccessibleAgent(req, res, id))) {
return;
}
const agent = await svc.remove(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
@@ -2082,6 +2151,10 @@ export function agentRoutes(db: Db) {
router.get("/agents/:id/keys", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const agent = await getAccessibleAgent(req, res, id);
if (!agent) {
return;
}
const keys = await svc.listKeys(id);
res.json(keys);
});
@@ -2089,32 +2162,56 @@ export function agentRoutes(db: Db) {
router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const agent = await getAccessibleAgent(req, res, id);
if (!agent) {
return;
}
const key = await svc.createApiKey(id, req.body.name);
const agent = await svc.getById(id);
if (agent) {
await logActivity(db, {
companyId: agent.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "agent.key_created",
entityType: "agent",
entityId: agent.id,
details: { keyId: key.id, name: key.name },
});
}
await logActivity(db, {
companyId: agent.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "agent.key_created",
entityType: "agent",
entityId: agent.id,
details: { keyId: key.id, name: key.name },
});
res.status(201).json(key);
});
router.delete("/agents/:id/keys/:keyId", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const keyId = req.params.keyId as string;
const revoked = await svc.revokeKey(keyId);
const agent = await getAccessibleAgent(req, res, id);
if (!agent) {
return;
}
const key = await svc.getKeyById(keyId);
if (!key || key.agentId !== agent.id) {
res.status(404).json({ error: "Key not found" });
return;
}
const revoked = await svc.revokeKey(agent.id, keyId);
if (!revoked) {
res.status(404).json({ error: "Key not found" });
return;
}
await logActivity(db, {
companyId: agent.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "agent.key_revoked",
entityType: "agent",
entityId: agent.id,
details: { keyId: key.id, name: key.name },
});
res.json({ ok: true });
});
@@ -2127,9 +2224,13 @@ export function agentRoutes(db: Db) {
}
assertCompanyAccess(req, agent.companyId);
if (req.actor.type === "agent" && req.actor.agentId !== id) {
res.status(403).json({ error: "Agent can only invoke itself" });
return;
if (req.actor.type === "agent") {
if (req.actor.agentId !== id) {
res.status(403).json({ error: "Agent can only invoke itself" });
return;
}
} else {
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
}
const run = await heartbeat.wakeup(id, {
@@ -2177,9 +2278,13 @@ export function agentRoutes(db: Db) {
}
assertCompanyAccess(req, agent.companyId);
if (req.actor.type === "agent" && req.actor.agentId !== id) {
res.status(403).json({ error: "Agent can only invoke itself" });
return;
if (req.actor.type === "agent") {
if (req.actor.agentId !== id) {
res.status(403).json({ error: "Agent can only invoke itself" });
return;
}
} else {
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
}
const run = await heartbeat.invoke(
@@ -2225,6 +2330,7 @@ export function agentRoutes(db: Db) {
res.status(404).json({ error: "Agent not found" });
return;
}
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
assertCompanyAccess(req, agent.companyId);
if (agent.adapterType !== "claude_local") {
res.status(400).json({ error: "Login is only supported for claude_local agents" });
+6 -15
View File
@@ -134,11 +134,8 @@ export function approvalRoutes(db: Db) {
res.status(404).json({ error: "Approval not found" });
return;
}
const { approval, applied } = await svc.approve(
id,
req.body.decidedByUserId ?? "board",
req.body.decisionNote,
);
const decidedByUserId = req.actor.userId ?? "board";
const { approval, applied } = await svc.approve(id, decidedByUserId, req.body.decisionNote);
if (applied) {
const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id);
@@ -233,11 +230,8 @@ export function approvalRoutes(db: Db) {
res.status(404).json({ error: "Approval not found" });
return;
}
const { approval, applied } = await svc.reject(
id,
req.body.decidedByUserId ?? "board",
req.body.decisionNote,
);
const decidedByUserId = req.actor.userId ?? "board";
const { approval, applied } = await svc.reject(id, decidedByUserId, req.body.decisionNote);
if (applied) {
await logActivity(db, {
@@ -264,11 +258,8 @@ export function approvalRoutes(db: Db) {
res.status(404).json({ error: "Approval not found" });
return;
}
const approval = await svc.requestRevision(
id,
req.body.decidedByUserId ?? "board",
req.body.decisionNote,
);
const decidedByUserId = req.actor.userId ?? "board";
const approval = await svc.requestRevision(id, decidedByUserId, req.body.decisionNote);
await logActivity(db, {
companyId: approval.companyId,
+8 -6
View File
@@ -1,6 +1,12 @@
import type { Request } from "express";
import { forbidden, unauthorized } from "../errors.js";
export function assertAuthenticated(req: Request) {
if (req.actor.type === "none") {
throw unauthorized();
}
}
export function assertBoard(req: Request) {
if (req.actor.type !== "board") {
throw forbidden("Board access required");
@@ -16,9 +22,7 @@ export function assertInstanceAdmin(req: Request) {
}
export function assertCompanyAccess(req: Request, companyId: string) {
if (req.actor.type === "none") {
throw unauthorized();
}
assertAuthenticated(req);
if (req.actor.type === "agent" && req.actor.companyId !== companyId) {
throw forbidden("Agent key cannot access another company");
}
@@ -31,9 +35,7 @@ export function assertCompanyAccess(req: Request, companyId: string) {
}
export function getActorInfo(req: Request) {
if (req.actor.type === "none") {
throw unauthorized();
}
assertAuthenticated(req);
if (req.actor.type === "agent") {
return {
actorType: "agent" as const,
+18
View File
@@ -23,6 +23,11 @@ import {
stopRuntimeServicesForExecutionWorkspace,
} from "../services/workspace-runtime.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import {
assertNoAgentHostWorkspaceCommandMutation,
collectExecutionWorkspaceCommandPaths,
} from "./workspace-command-authz.js";
import { assertCanManageExecutionWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js";
export function executionWorkspaceRoutes(db: Db) {
const router = Router();
@@ -96,6 +101,12 @@ export function executionWorkspaceRoutes(db: Db) {
}
assertCompanyAccess(req, existing.companyId);
await assertCanManageExecutionWorkspaceRuntimeServices(db, req, {
companyId: existing.companyId,
executionWorkspaceId: existing.id,
sourceIssueId: existing.sourceIssueId,
});
const workspaceCwd = existing.cwd;
if (!workspaceCwd) {
res.status(422).json({ error: "Execution workspace needs a local path before Paperclip can run workspace commands" });
@@ -428,6 +439,13 @@ export function executionWorkspaceRoutes(db: Db) {
return;
}
assertCompanyAccess(req, existing.companyId);
assertNoAgentHostWorkspaceCommandMutation(
req,
collectExecutionWorkspaceCommandPaths({
config: req.body.config,
metadata: req.body.metadata,
}),
);
const patch: Record<string, unknown> = {
...(req.body.name === undefined ? {} : { name: req.body.name }),
...(req.body.cwd === undefined ? {} : { cwd: req.body.cwd }),
+31 -3
View File
@@ -7,6 +7,14 @@ import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-se
import { instanceSettingsService } from "../services/instance-settings.js";
import { serverVersion } from "../version.js";
function shouldExposeFullHealthDetails(
actorType: "none" | "board" | "agent" | null | undefined,
deploymentMode: DeploymentMode,
) {
if (deploymentMode !== "authenticated") return true;
return actorType === "board" || actorType === "agent";
}
export function healthRoutes(
db?: Db,
opts: {
@@ -23,9 +31,19 @@ export function healthRoutes(
) {
const router = Router();
router.get("/", async (_req, res) => {
router.get("/", async (req, res) => {
const actorType = "actor" in req ? req.actor?.type : null;
const exposeFullDetails = shouldExposeFullHealthDetails(
actorType,
opts.deploymentMode,
);
if (!db) {
res.json({ status: "ok", version: serverVersion });
res.json(
exposeFullDetails
? { status: "ok", version: serverVersion }
: { status: "ok", deploymentMode: opts.deploymentMode },
);
return;
}
@@ -70,7 +88,7 @@ export function healthRoutes(
const persistedDevServerStatus = readPersistedDevServerStatus();
let devServer: ReturnType<typeof toDevServerHealthStatus> | undefined;
if (persistedDevServerStatus) {
if (persistedDevServerStatus && typeof (db as { select?: unknown }).select === "function") {
const instanceSettings = instanceSettingsService(db);
const experimentalSettings = await instanceSettings.getExperimental();
const activeRunCount = await db
@@ -85,6 +103,16 @@ export function healthRoutes(
});
}
if (!exposeFullDetails) {
res.json({
status: "ok",
deploymentMode: opts.deploymentMode,
bootstrapStatus,
bootstrapInviteActive,
});
return;
}
res.json({
status: "ok",
version: serverVersion,
+6
View File
@@ -48,6 +48,10 @@ import {
import { logger } from "../middleware/logger.js";
import { conflict, forbidden, HttpError, notFound, unauthorized } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import {
assertNoAgentHostWorkspaceCommandMutation,
collectIssueWorkspaceCommandPaths,
} from "./workspace-command-authz.js";
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
import {
isInlineAttachmentContentType,
@@ -1323,6 +1327,7 @@ export function issueRoutes(
router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
await assertCanAssignTasks(req, companyId);
}
@@ -1373,6 +1378,7 @@ export function issueRoutes(
return;
}
assertCompanyAccess(req, existing.companyId);
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
const actor = getActorInfo(req);
+11 -4
View File
@@ -11,7 +11,8 @@
* - Retrieving UI slot contributions for frontend rendering
* - Discovering and executing plugin-contributed agent tools
*
* All routes require board-level authentication (assertBoard middleware).
* All routes require board-level authentication, and sensitive instance-wide
* mutations such as install/upgrade require instance-admin privileges.
*
* @module server/routes/plugins
* @see doc/plugins/PLUGIN_SPEC.md for the full plugin specification
@@ -47,7 +48,7 @@ import type { PluginStreamBus } from "../services/plugin-stream-bus.js";
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
import type { ToolRunContext } from "@paperclipai/plugin-sdk";
import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
import { validateInstanceConfig } from "../services/plugin-config-validator.js";
/** UI slot declaration extracted from plugin manifest */
@@ -583,6 +584,9 @@ export function pluginRoutes(
*
* Install a plugin from npm or a local filesystem path.
*
* Instance-wide plugin installation is restricted to instance admins because
* the install flow fetches and inspects package contents on the host.
*
* Request body:
* - packageName: npm package name or local path (required)
* - version: Target version for npm packages (optional)
@@ -601,7 +605,7 @@ export function pluginRoutes(
* - `500` — installation succeeded but manifest is missing (indicates a loader bug)
*/
router.post("/plugins/install", async (req, res) => {
assertBoard(req);
assertInstanceAdmin(req);
const { packageName, version, isLocalPath } = req.body as PluginInstallRequest;
// Input validation
@@ -1450,6 +1454,9 @@ export function pluginRoutes(
*
* Upgrade a plugin to a newer version.
*
* Upgrades are restricted to instance admins because they fetch and inspect
* new package contents on the host before activation.
*
* Request body (optional):
* - version: Target version (defaults to latest)
*
@@ -1461,7 +1468,7 @@ export function pluginRoutes(
* Errors: 404 if plugin not found, 400 for lifecycle errors
*/
router.post("/plugins/:pluginId/upgrade", async (req, res) => {
assertBoard(req);
assertInstanceAdmin(req);
const { pluginId } = req.params;
const body = req.body as { version?: string } | undefined;
const version = body?.version;
+30
View File
@@ -22,6 +22,12 @@ import {
startRuntimeServicesForWorkspaceControl,
stopRuntimeServicesForProjectWorkspace,
} from "../services/workspace-runtime.js";
import {
assertNoAgentHostWorkspaceCommandMutation,
collectProjectExecutionWorkspaceCommandPaths,
collectProjectWorkspaceCommandPaths,
} from "./workspace-command-authz.js";
import { assertCanManageProjectWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js";
import { getTelemetryClient } from "../telemetry.js";
export function projectRoutes(db: Db) {
@@ -93,6 +99,13 @@ export function projectRoutes(db: Db) {
};
const { workspace, ...projectData } = req.body as CreateProjectPayload;
assertNoAgentHostWorkspaceCommandMutation(
req,
[
...collectProjectExecutionWorkspaceCommandPaths(projectData.executionWorkspacePolicy),
...collectProjectWorkspaceCommandPaths(workspace, "workspace"),
],
);
if (projectData.env !== undefined) {
projectData.env = await secretsSvc.normalizeEnvBindingsForPersistence(
companyId,
@@ -144,6 +157,10 @@ export function projectRoutes(db: Db) {
}
assertCompanyAccess(req, existing.companyId);
const body = { ...req.body };
assertNoAgentHostWorkspaceCommandMutation(
req,
collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy),
);
if (typeof body.archivedAt === "string") {
body.archivedAt = new Date(body.archivedAt);
}
@@ -200,6 +217,10 @@ export function projectRoutes(db: Db) {
return;
}
assertCompanyAccess(req, existing.companyId);
assertNoAgentHostWorkspaceCommandMutation(
req,
collectProjectWorkspaceCommandPaths(req.body),
);
const workspace = await svc.createWorkspace(id, req.body);
if (!workspace) {
res.status(422).json({ error: "Invalid project workspace payload" });
@@ -238,6 +259,10 @@ export function projectRoutes(db: Db) {
return;
}
assertCompanyAccess(req, existing.companyId);
assertNoAgentHostWorkspaceCommandMutation(
req,
collectProjectWorkspaceCommandPaths(req.body),
);
const workspaceExists = (await svc.listWorkspaces(id)).some((workspace) => workspace.id === workspaceId);
if (!workspaceExists) {
res.status(404).json({ error: "Project workspace not found" });
@@ -290,6 +315,11 @@ export function projectRoutes(db: Db) {
return;
}
await assertCanManageProjectWorkspaceRuntimeServices(db, req, {
companyId: project.companyId,
projectWorkspaceId: workspace.id,
});
const workspaceCwd = workspace.cwd;
if (!workspaceCwd) {
res.status(422).json({ error: "Project workspace needs a local path before Paperclip can run workspace commands" });
@@ -0,0 +1,115 @@
import type { Request } from "express";
import { forbidden } from "../errors.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function hasOwn(value: Record<string, unknown>, key: string) {
return Object.prototype.hasOwnProperty.call(value, key);
}
function prefixPath(prefix: string, key: string) {
return prefix.length > 0 ? `${prefix}.${key}` : key;
}
function collectWorkspaceStrategyCommandPaths(raw: unknown, prefix: string): string[] {
if (!isRecord(raw)) return [];
const paths: string[] = [];
if (hasOwn(raw, "provisionCommand")) {
paths.push(prefixPath(prefix, "provisionCommand"));
}
if (hasOwn(raw, "teardownCommand")) {
paths.push(prefixPath(prefix, "teardownCommand"));
}
return paths;
}
function collectExecutionWorkspaceConfigCommandPaths(raw: unknown, prefix: string): string[] {
if (!isRecord(raw)) return [];
const paths: string[] = [];
if (hasOwn(raw, "provisionCommand")) {
paths.push(prefixPath(prefix, "provisionCommand"));
}
if (hasOwn(raw, "teardownCommand")) {
paths.push(prefixPath(prefix, "teardownCommand"));
}
if (hasOwn(raw, "cleanupCommand")) {
paths.push(prefixPath(prefix, "cleanupCommand"));
}
return paths;
}
export function assertNoAgentHostWorkspaceCommandMutation(req: Request, paths: string[]) {
if (req.actor.type !== "agent" || paths.length === 0) return;
throw forbidden(
`Agent keys cannot modify host-executed workspace commands (${paths.join(", ")}).`,
);
}
export function collectAgentAdapterWorkspaceCommandPaths(adapterConfig: unknown): string[] {
if (!isRecord(adapterConfig)) return [];
return collectWorkspaceStrategyCommandPaths(
adapterConfig.workspaceStrategy,
"adapterConfig.workspaceStrategy",
);
}
export function collectProjectExecutionWorkspaceCommandPaths(policy: unknown): string[] {
if (!isRecord(policy)) return [];
return collectWorkspaceStrategyCommandPaths(
policy.workspaceStrategy,
"executionWorkspacePolicy.workspaceStrategy",
);
}
export function collectProjectWorkspaceCommandPaths(
workspacePatch: unknown,
prefix = "",
): string[] {
if (!isRecord(workspacePatch)) return [];
return hasOwn(workspacePatch, "cleanupCommand")
? [prefixPath(prefix, "cleanupCommand")]
: [];
}
export function collectIssueWorkspaceCommandPaths(input: {
executionWorkspaceSettings?: unknown;
assigneeAdapterOverrides?: unknown;
}): string[] {
const paths: string[] = [];
if (isRecord(input.executionWorkspaceSettings)) {
paths.push(
...collectWorkspaceStrategyCommandPaths(
input.executionWorkspaceSettings.workspaceStrategy,
"executionWorkspaceSettings.workspaceStrategy",
),
);
}
if (isRecord(input.assigneeAdapterOverrides)) {
const adapterConfig = input.assigneeAdapterOverrides.adapterConfig;
if (isRecord(adapterConfig)) {
paths.push(
...collectWorkspaceStrategyCommandPaths(
adapterConfig.workspaceStrategy,
"assigneeAdapterOverrides.adapterConfig.workspaceStrategy",
),
);
}
}
return paths;
}
export function collectExecutionWorkspaceCommandPaths(input: {
config?: unknown;
metadata?: unknown;
}): string[] {
const paths: string[] = [];
if (input.config !== undefined) {
paths.push(...collectExecutionWorkspaceConfigCommandPaths(input.config, "config"));
}
if (isRecord(input.metadata) && hasOwn(input.metadata, "config")) {
paths.push(...collectExecutionWorkspaceConfigCommandPaths(input.metadata.config, "metadata.config"));
}
return paths;
}
@@ -0,0 +1,138 @@
import { and, eq, inArray, isNull, ne, or } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, issues } from "@paperclipai/db";
import type { Request } from "express";
import { forbidden } from "../errors.js";
import { assertCompanyAccess } from "./authz.js";
const WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES: string[] = [
"backlog",
"todo",
"in_progress",
"in_review",
"blocked",
];
async function listReportingSubtreeAgentIds(db: Db, companyId: string, actorAgentId: string) {
const companyAgents = await db
.select({
id: agents.id,
reportsTo: agents.reportsTo,
})
.from(agents)
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated")));
const reportsByManager = new Map<string, string[]>();
for (const agent of companyAgents) {
if (!agent.reportsTo) continue;
const reports = reportsByManager.get(agent.reportsTo) ?? [];
reports.push(agent.id);
reportsByManager.set(agent.reportsTo, reports);
}
const visited = new Set<string>([actorAgentId]);
const queue = [actorAgentId];
while (queue.length > 0) {
const current = queue.shift();
if (!current) continue;
const reports = reportsByManager.get(current) ?? [];
for (const reportId of reports) {
if (visited.has(reportId)) continue;
visited.add(reportId);
queue.push(reportId);
}
}
return [...visited];
}
async function assertAgentCanManageRuntimeServicesForWorkspace(
db: Db,
req: Request,
input: {
companyId: string;
projectWorkspaceId?: string | null;
executionWorkspaceId?: string | null;
sourceIssueId?: string | null;
},
) {
if (req.actor.type !== "agent" || !req.actor.agentId) {
throw forbidden("Agent authentication required");
}
const actorAgent = await db
.select({
id: agents.id,
companyId: agents.companyId,
role: agents.role,
})
.from(agents)
.where(eq(agents.id, req.actor.agentId))
.then((rows) => rows[0] ?? null);
if (!actorAgent || actorAgent.companyId !== input.companyId) {
throw forbidden("Agent key cannot access another company");
}
if (actorAgent.role === "ceo") {
return;
}
const eligibleAgentIds = await listReportingSubtreeAgentIds(db, input.companyId, actorAgent.id);
const workspaceScopeConditions = [
input.projectWorkspaceId ? eq(issues.projectWorkspaceId, input.projectWorkspaceId) : null,
input.executionWorkspaceId ? eq(issues.executionWorkspaceId, input.executionWorkspaceId) : null,
input.sourceIssueId ? eq(issues.id, input.sourceIssueId) : null,
].filter((condition): condition is NonNullable<typeof condition> => condition !== null);
if (workspaceScopeConditions.length === 0) {
throw forbidden("Missing permission to manage workspace runtime services");
}
const linkedIssue = await db
.select({ id: issues.id })
.from(issues)
.where(and(
eq(issues.companyId, input.companyId),
isNull(issues.hiddenAt),
inArray(issues.status, WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES),
inArray(issues.assigneeAgentId, eligibleAgentIds),
workspaceScopeConditions.length === 1
? workspaceScopeConditions[0]!
: or(...workspaceScopeConditions),
))
.then((rows) => rows[0] ?? null);
if (linkedIssue) {
return;
}
throw forbidden("Missing permission to manage workspace runtime services");
}
export async function assertCanManageProjectWorkspaceRuntimeServices(
db: Db,
req: Request,
input: {
companyId: string;
projectWorkspaceId: string;
},
) {
assertCompanyAccess(req, input.companyId);
if (req.actor.type === "board") return;
await assertAgentCanManageRuntimeServicesForWorkspace(db, req, input);
}
export async function assertCanManageExecutionWorkspaceRuntimeServices(
db: Db,
req: Request,
input: {
companyId: string;
executionWorkspaceId: string;
sourceIssueId?: string | null;
},
) {
assertCompanyAccess(req, input.companyId);
if (req.actor.type === "board") return;
await assertAgentCanManageRuntimeServicesForWorkspace(db, req, input);
}
+16 -2
View File
@@ -619,11 +619,25 @@ export function agentService(db: Db) {
.from(agentApiKeys)
.where(eq(agentApiKeys.agentId, id)),
revokeKey: async (keyId: string) => {
getKeyById: async (keyId: string) =>
db
.select({
id: agentApiKeys.id,
agentId: agentApiKeys.agentId,
companyId: agentApiKeys.companyId,
name: agentApiKeys.name,
createdAt: agentApiKeys.createdAt,
revokedAt: agentApiKeys.revokedAt,
})
.from(agentApiKeys)
.where(eq(agentApiKeys.id, keyId))
.then((rows) => rows[0] ?? null),
revokeKey: async (agentId: string, keyId: string) => {
const rows = await db
.update(agentApiKeys)
.set({ revokedAt: new Date() })
.where(eq(agentApiKeys.id, keyId))
.where(and(eq(agentApiKeys.id, keyId), eq(agentApiKeys.agentId, agentId)))
.returning();
return rows[0] ?? null;
},
+137 -23
View File
@@ -47,7 +47,9 @@ import {
readPaperclipSkillSyncPreference,
writePaperclipSkillSyncPreference,
} from "@paperclipai/adapter-utils/server-utils";
import { notFound, unprocessable } from "../errors.js";
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
import { findServerAdapter } from "../adapters/index.js";
import { forbidden, notFound, unprocessable } from "../errors.js";
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
import type { StorageService } from "../storage/types.js";
import { accessService } from "./access.js";
@@ -62,6 +64,7 @@ import { validateCron } from "./cron.js";
import { issueService } from "./issues.js";
import { projectService } from "./projects.js";
import { routineService } from "./routines.js";
import { secretService } from "./secrets.js";
/** Build OrgNode tree from manifest agent list (slug + reportsToSlug). */
function buildOrgTreeFromManifest(agents: CompanyPortabilityManifest["agents"]): OrgNode[] {
@@ -117,6 +120,7 @@ const DEFAULT_INCLUDE: CompanyPortabilityInclude = {
};
const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename";
const IMPORT_FORBIDDEN_ADAPTER_TYPES = new Set(["process", "http"]);
const execFileAsync = promisify(execFile);
let bundledSkillsCommitPromise: Promise<string | null> | null = null;
@@ -2747,6 +2751,94 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const projects = projectService(db);
const issues = issueService(db);
const companySkills = companySkillService(db);
const secrets = secretService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
function assertKnownImportAdapterType(type: string | null | undefined): string {
const adapterType = typeof type === "string" ? type.trim() : "";
if (!adapterType) {
throw unprocessable("Adapter type is required");
}
if (!findServerAdapter(adapterType)) {
throw unprocessable(`Unknown adapter type: ${adapterType}`);
}
return adapterType;
}
async function assertImportAdapterConfigConstraints(
companyId: string,
adapterType: string,
adapterConfig: Record<string, unknown>,
) {
if (adapterType !== "opencode_local") return;
const { config: runtimeConfig } = await secrets.resolveAdapterConfigForRuntime(companyId, adapterConfig);
const runtimeEnv = isPlainRecord(runtimeConfig.env) ? runtimeConfig.env : {};
try {
await ensureOpenCodeModelConfiguredAndAvailable({
model: runtimeConfig.model,
command: runtimeConfig.command,
cwd: runtimeConfig.cwd,
env: runtimeEnv,
});
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`);
}
}
async function prepareImportedAgentAdapter(
companyId: string,
adapterType: string | null | undefined,
adapterConfig: Record<string, unknown>,
desiredSkills: string[],
mode: ImportMode,
) {
const effectiveAdapterType = assertKnownImportAdapterType(adapterType);
if (mode === "agent_safe" && IMPORT_FORBIDDEN_ADAPTER_TYPES.has(effectiveAdapterType)) {
throw forbidden(`Adapter type "${effectiveAdapterType}" is not allowed in safe imports`);
}
const nextAdapterConfig = writePaperclipSkillSyncPreference({ ...adapterConfig }, desiredSkills);
delete nextAdapterConfig.promptTemplate;
delete nextAdapterConfig.bootstrapPromptTemplate;
delete nextAdapterConfig.instructionsFilePath;
delete nextAdapterConfig.instructionsBundleMode;
delete nextAdapterConfig.instructionsRootPath;
delete nextAdapterConfig.instructionsEntryFile;
const normalizedAdapterConfig = await secrets.normalizeAdapterConfigForPersistence(
companyId,
nextAdapterConfig,
{ strictMode: strictSecretsMode },
);
await assertImportAdapterConfigConstraints(companyId, effectiveAdapterType, normalizedAdapterConfig);
return {
adapterType: effectiveAdapterType,
adapterConfig: normalizedAdapterConfig,
};
}
function resolveImportedAssigneeAgentId(
assigneeSlug: string | null | undefined,
importedSlugToAgentId: Map<string, string>,
existingSlugToAgentId: Map<string, string>,
agentStatusById: Map<string, string | null | undefined>,
warnings: string[],
subjectLabel: string,
) {
if (!assigneeSlug) return null;
const assigneeAgentId =
importedSlugToAgentId.get(assigneeSlug)
?? existingSlugToAgentId.get(assigneeSlug)
?? null;
if (!assigneeAgentId) return null;
const assigneeStatus = agentStatusById.get(assigneeAgentId) ?? null;
if (assigneeStatus === "pending_approval" || assigneeStatus === "terminated") {
warnings.push(
`${subjectLabel} assignee ${assigneeSlug} is ${assigneeStatus}; imported work was left unassigned.`,
);
return null;
}
return assigneeAgentId;
}
async function resolveSource(source: CompanyPortabilityPreview["source"]): Promise<ResolvedSource> {
if (source.type === "inline") {
@@ -3856,7 +3948,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const warnings = [...plan.preview.warnings];
const include = plan.include;
let targetCompany: { id: string; name: string } | null = null;
let targetCompany: {
id: string;
name: string;
requireBoardApprovalForNewAgents?: boolean | null;
} | null = null;
let companyAction: "created" | "updated" | "unchanged" = "unchanged";
if (input.target.mode === "new_company") {
@@ -3977,9 +4073,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const resultProjects: CompanyPortabilityImportResult["projects"] = [];
const importedSlugToAgentId = new Map<string, string>();
const existingSlugToAgentId = new Map<string, string>();
const agentStatusById = new Map<string, string | null | undefined>();
const existingAgents = await agents.list(targetCompany.id);
for (const existing of existingAgents) {
existingSlugToAgentId.set(normalizeAgentUrlKey(existing.name) ?? existing.id, existing.id);
agentStatusById.set(existing.id, existing.status);
}
const importedSlugToProjectId = new Map<string, string>();
const importedProjectWorkspaceIdByProjectSlug = new Map<string, Map<string, string>>();
@@ -4049,22 +4147,18 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
// Apply adapter overrides from request if present
const adapterOverride = input.adapterOverrides?.[planAgent.slug];
const effectiveAdapterType = adapterOverride?.adapterType ?? manifestAgent.adapterType;
const baseAdapterConfig = adapterOverride?.adapterConfig
? { ...adapterOverride.adapterConfig }
: { ...manifestAgent.adapterConfig } as Record<string, unknown>;
const desiredSkills = (manifestAgent.skills ?? []).map((skillRef) => desiredSkillRefMap.get(skillRef) ?? skillRef);
const adapterConfigWithSkills = writePaperclipSkillSyncPreference(
const normalizedAdapter = await prepareImportedAgentAdapter(
targetCompany.id,
adapterOverride?.adapterType ?? manifestAgent.adapterType,
baseAdapterConfig,
desiredSkills,
mode,
);
delete adapterConfigWithSkills.promptTemplate;
delete adapterConfigWithSkills.bootstrapPromptTemplate; // deprecated
delete adapterConfigWithSkills.instructionsFilePath;
delete adapterConfigWithSkills.instructionsBundleMode;
delete adapterConfigWithSkills.instructionsRootPath;
delete adapterConfigWithSkills.instructionsEntryFile;
const patch = {
name: planAgent.plannedName,
role: manifestAgent.role,
@@ -4072,8 +4166,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
icon: manifestAgent.icon,
capabilities: manifestAgent.capabilities,
reportsTo: null,
adapterType: effectiveAdapterType,
adapterConfig: adapterConfigWithSkills,
adapterType: normalizedAdapter.adapterType,
adapterConfig: normalizedAdapter.adapterConfig,
runtimeConfig: disableImportedTimerHeartbeat(manifestAgent.runtimeConfig),
budgetMonthlyCents: manifestAgent.budgetMonthlyCents,
permissions: manifestAgent.permissions,
@@ -4102,6 +4196,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
} catch (err) {
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
}
agentStatusById.set(updated.id, updated.status ?? agentStatusById.get(updated.id) ?? null);
importedSlugToAgentId.set(planAgent.slug, updated.id);
existingSlugToAgentId.set(normalizeAgentUrlKey(updated.name) ?? updated.id, updated.id);
resultAgents.push({
@@ -4114,7 +4209,17 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
continue;
}
let created = await agents.create(targetCompany.id, patch);
const requiresApproval =
typeof targetCompany.requireBoardApprovalForNewAgents === "boolean"
? targetCompany.requireBoardApprovalForNewAgents
: include.company
? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? true)
: true;
const createdStatus = requiresApproval ? "pending_approval" : "idle";
let created = await agents.create(targetCompany.id, {
...patch,
status: createdStatus,
});
await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active");
await access.setPrincipalPermission(
targetCompany.id,
@@ -4133,6 +4238,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
} catch (err) {
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
}
agentStatusById.set(created.id, created.status ?? createdStatus);
importedSlugToAgentId.set(planAgent.slug, created.id);
existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id);
resultAgents.push({
@@ -4275,11 +4381,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const markdownRaw = readPortableTextFile(plan.source.files, manifestIssue.path);
const parsed = markdownRaw ? parseFrontmatterMarkdown(markdownRaw) : null;
const description = parsed?.body || manifestIssue.description || null;
const assigneeAgentId = manifestIssue.assigneeAgentSlug
? importedSlugToAgentId.get(manifestIssue.assigneeAgentSlug)
?? existingSlugToAgentId.get(manifestIssue.assigneeAgentSlug)
?? null
: null;
const assigneeAgentId = resolveImportedAssigneeAgentId(
manifestIssue.assigneeAgentSlug,
importedSlugToAgentId,
existingSlugToAgentId,
agentStatusById,
warnings,
`Task ${manifestIssue.slug}`,
);
const projectId = manifestIssue.projectSlug
? importedSlugToProjectId.get(manifestIssue.projectSlug)
?? existingProjectSlugToId.get(manifestIssue.projectSlug)
@@ -4292,8 +4401,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
warnings.push(`Task ${manifestIssue.slug} references workspace key ${manifestIssue.projectWorkspaceKey}, but that workspace was not imported.`);
}
if (manifestIssue.recurring) {
if (!projectId || !assigneeAgentId) {
throw unprocessable(`Recurring task ${manifestIssue.slug} is missing the project or assignee required to create a routine.`);
if (!projectId) {
throw unprocessable(`Recurring task ${manifestIssue.slug} is missing the project required to create a routine.`);
}
const resolvedRoutine = resolvePortableRoutineDefinition(manifestIssue, parsed?.frontmatter.schedule);
if (resolvedRoutine.errors.length > 0) {
@@ -4373,15 +4482,20 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
}
continue;
}
let issueStatus = manifestIssue.status && ISSUE_STATUSES.includes(manifestIssue.status as any)
? manifestIssue.status as typeof ISSUE_STATUSES[number]
: "backlog";
if (!assigneeAgentId && issueStatus === "in_progress") {
warnings.push(`Task ${manifestIssue.slug} was downgraded to todo because its assignee could not be imported as assignable work.`);
issueStatus = "todo";
}
await issues.create(targetCompany.id, {
projectId,
projectWorkspaceId,
title: manifestIssue.title,
description,
assigneeAgentId,
status: manifestIssue.status && ISSUE_STATUSES.includes(manifestIssue.status as any)
? manifestIssue.status as typeof ISSUE_STATUSES[number]
: "backlog",
status: issueStatus,
priority: manifestIssue.priority && ISSUE_PRIORITIES.includes(manifestIssue.priority as any)
? manifestIssue.priority as typeof ISSUE_PRIORITIES[number]
: "medium",
+56
View File
@@ -164,6 +164,62 @@ describe("InlineEditor", () => {
});
outside.remove();
});
it("syncs a new multiline value while focused when the user has not edited locally", () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const root = createRoot(container);
act(() => {
root.render(<InlineEditor value="" multiline onSave={onSave} />);
});
const textarea = container.querySelector<HTMLTextAreaElement>('[data-testid="multiline-md-mock"]');
expect(textarea).not.toBeNull();
expect(textarea?.value).toBe("");
act(() => {
textarea!.focus();
});
act(() => {
root.render(<InlineEditor value="Loaded description" multiline onSave={onSave} />);
});
expect(textarea?.value).toBe("Loaded description");
act(() => {
root.unmount();
});
});
it("preserves focused multiline local edits when the prop value changes underneath them", () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const root = createRoot(container);
act(() => {
root.render(<InlineEditor value="Original" multiline onSave={onSave} />);
});
const textarea = container.querySelector<HTMLTextAreaElement>('[data-testid="multiline-md-mock"]');
expect(textarea).not.toBeNull();
act(() => {
textarea!.focus();
});
act(() => {
setNativeTextareaValue(textarea!, "Local draft");
});
act(() => {
root.render(<InlineEditor value="Remote update" multiline onSave={onSave} />);
});
expect(textarea?.value).toBe("Local draft");
act(() => {
root.unmount();
});
});
});
describe("queueContainedBlurCommit", () => {
+9 -2
View File
@@ -54,6 +54,7 @@ export function InlineEditor({
const [editing, setEditing] = useState(false);
const [multilineFocused, setMultilineFocused] = useState(false);
const [draft, setDraft] = useState(value);
const lastPropValueRef = useRef(value);
const inputRef = useRef<HTMLTextAreaElement>(null);
const markdownRef = useRef<MarkdownEditorRef>(null);
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -66,8 +67,14 @@ export function InlineEditor({
} = useAutosaveIndicator();
useEffect(() => {
if (multiline && multilineFocused) return;
setDraft(value);
const previousValue = lastPropValueRef.current;
lastPropValueRef.current = value;
setDraft((currentDraft) => {
if (multiline && multilineFocused && currentDraft !== previousValue) {
return currentDraft;
}
return value;
});
}, [value, multiline, multilineFocused]);
useEffect(() => {
+2 -6
View File
@@ -6,8 +6,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { timeAgo } from "@/lib/timeAgo";
import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDetailBreadcrumb";
import {
fetchIssueDetail,
getCachedIssueDetail,
getIssueDetailQueryOptions,
ISSUE_DETAIL_STALE_TIME_MS,
prefetchIssueDetail,
} from "@/lib/issueDetailCache";
@@ -98,12 +97,9 @@ export const IssueLinkQuicklook = React.forwardRef<
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const prefetchedState = issuePrefetch ? withIssueDetailHeaderSeed(state, issuePrefetch) : state;
const cachedIssue = getCachedIssueDetail(queryClient, issuePathId, issuePrefetch ?? undefined);
const { data, isLoading } = useQuery({
queryKey: queryKeys.issues.detail(issuePathId),
queryFn: () => fetchIssueDetail(queryClient, issuePathId),
...getIssueDetailQueryOptions(queryClient, issuePathId, { placeholderIssue: issuePrefetch ?? undefined }),
enabled: open,
initialData: () => cachedIssue,
staleTime: ISSUE_DETAIL_STALE_TIME_MS,
});
+7
View File
@@ -96,6 +96,13 @@ describe("MarkdownBody", () => {
expect(html).toContain('data-mention-kind="skill"');
});
it("sanitizes unsafe javascript markdown links", () => {
const html = renderMarkdown("[click me](javascript:alert(document.cookie))");
expect(html).toContain('<a href="" rel="noreferrer">click me</a>');
expect(html).not.toContain("javascript:");
});
it("uses soft-break styling by default", () => {
const html = renderMarkdown("First line\nSecond line");
+10 -2
View File
@@ -1,6 +1,6 @@
import { isValidElement, useEffect, useId, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import Markdown, { type Components, type Options } from "react-markdown";
import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "../lib/utils";
import { useTheme } from "../context/ThemeContext";
@@ -71,6 +71,10 @@ function extractMermaidSource(children: ReactNode): string | null {
return flattenText(childProps.children).replace(/\n$/, "");
}
function safeMarkdownUrlTransform(url: string): string {
return parseMentionChipHref(url) ? url : defaultUrlTransform(url);
}
function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) {
const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, "");
const [svg, setSvg] = useState<string | null>(null);
@@ -215,7 +219,11 @@ export function MarkdownBody({
)}
style={style}
>
<Markdown remarkPlugins={remarkPlugins} components={components} urlTransform={(url) => url}>
<Markdown
remarkPlugins={remarkPlugins}
components={components}
urlTransform={safeMarkdownUrlTransform}
>
{children}
</Markdown>
</div>
+15 -6
View File
@@ -19,6 +19,7 @@ const mdxEditorMockState = vi.hoisted(() => ({
emitMountParseError: false,
emitMountSilentEmptyState: false,
markdownValues: [] as string[],
suppressHtmlProcessingValues: [] as boolean[],
}));
vi.mock("@mdxeditor/editor", async () => {
@@ -41,16 +42,19 @@ vi.mock("@mdxeditor/editor", async () => {
onChange,
onError,
className,
suppressHtmlProcessing,
}: {
markdown: string;
placeholder?: string;
onChange?: (value: string) => void;
onError?: (error: unknown) => void;
suppressHtmlProcessing?: boolean;
className?: string;
},
forwardedRef: React.ForwardedRef<{ setMarkdown: (value: string) => void; focus: () => void } | null>,
) {
mdxEditorMockState.markdownValues.push(markdown);
mdxEditorMockState.suppressHtmlProcessingValues.push(Boolean(suppressHtmlProcessing));
const [content, setContent] = React.useState(markdown);
const editableRef = React.useRef<HTMLDivElement>(null);
const handle = React.useMemo(() => ({
@@ -59,8 +63,16 @@ vi.mock("@mdxeditor/editor", async () => {
}), []);
React.useEffect(() => {
if (!suppressHtmlProcessing && markdown.includes("<img ")) {
setContent("");
onError?.({
error: "Error parsing markdown: HTML-like formatting requires suppressHtmlProcessing",
source: markdown,
});
return;
}
setContent(markdown);
}, [markdown]);
}, [markdown, onError, suppressHtmlProcessing]);
React.useEffect(() => {
setForwardedRef(forwardedRef, null);
@@ -165,6 +177,7 @@ describe("MarkdownEditor", () => {
mdxEditorMockState.emitMountParseError = false;
mdxEditorMockState.emitMountSilentEmptyState = false;
mdxEditorMockState.markdownValues = [];
mdxEditorMockState.suppressHtmlProcessingValues = [];
});
it("applies async external value updates once the editor ref becomes ready", async () => {
@@ -238,6 +251,7 @@ describe("MarkdownEditor", () => {
await flush();
expect(mdxEditorMockState.markdownValues.at(-1)).toContain("![image](https://example.com/test.png)");
expect(mdxEditorMockState.markdownValues.at(-1)).not.toContain("<img");
expect(mdxEditorMockState.suppressHtmlProcessingValues).toContain(false);
expect(container.textContent).toContain("Before");
expect(container.textContent).toContain("After");
@@ -262,11 +276,9 @@ describe("MarkdownEditor", () => {
});
await flush();
await vi.waitFor(() => {
expect(container.querySelector("textarea")).not.toBeNull();
});
const textarea = container.querySelector("textarea");
expect(textarea).not.toBeNull();
expect(textarea?.value).toBe("Affected versions: <= v0.3.1");
@@ -294,11 +306,9 @@ describe("MarkdownEditor", () => {
});
await flush();
await vi.waitFor(() => {
expect(container.querySelector("textarea")).not.toBeNull();
});
const textarea = container.querySelector("textarea");
expect(textarea).not.toBeNull();
expect(textarea?.value).toBe("Affected versions: <= v0.3.1");
@@ -309,7 +319,6 @@ describe("MarkdownEditor", () => {
root.unmount();
});
});
it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => {
expect(
computeMentionMenuPosition(
+14
View File
@@ -84,6 +84,20 @@ export async function fetchIssueDetail(
return seedIssueDetailCache(queryClient, issue, { issueRef });
}
export function getIssueDetailQueryOptions(
queryClient: QueryClient,
issueRef: string,
options?: {
placeholderIssue?: Pick<Issue, "id" | "identifier"> | null;
},
) {
return {
queryKey: queryKeys.issues.detail(issueRef),
queryFn: () => fetchIssueDetail(queryClient, issueRef),
placeholderData: getCachedIssueDetail(queryClient, issueRef, options?.placeholderIssue ?? undefined),
};
}
export function prefetchIssueDetail(
queryClient: QueryClient,
issueRef: string,
+129
View File
@@ -0,0 +1,129 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Issue } from "@paperclipai/shared";
import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import { issuesApi } from "@/api/issues";
import { queryKeys } from "@/lib/queryKeys";
import { getIssueDetailQueryOptions } from "./issueDetailCache";
vi.mock("@/api/issues", () => ({
issuesApi: {
get: vi.fn(),
},
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function makeIssue(overrides: Partial<Issue> = {}): Issue {
const now = new Date("2026-04-13T20:00:00.000Z");
return {
id: "issue-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Issue title",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1442,
identifier: "PAP-1442",
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: now,
updatedAt: now,
...overrides,
};
}
function IssueDetailQueryHarness({
issueRef,
placeholderIssue,
}: {
issueRef: string;
placeholderIssue?: Pick<Issue, "id" | "identifier"> | null;
}) {
const queryClient = useQueryClient();
const query = useQuery({
...getIssueDetailQueryOptions(queryClient, issueRef, { placeholderIssue }),
});
return <div>{query.data?.description ?? "EMPTY"}</div>;
}
async function flush() {
// Multiple act cycles to allow React Query to process the async queryFn
for (let i = 0; i < 5; i++) {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
}
}
describe("getIssueDetailQueryOptions", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("treats cached issue data as placeholder and still fetches full detail", async () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const partialIssue = makeIssue({ description: null });
const fullIssue = makeIssue({ description: "GitHub Security Advisory body" });
queryClient.setQueryData(queryKeys.issues.detail("issue-1"), partialIssue);
queryClient.setQueryData(queryKeys.issues.detail("PAP-1442"), partialIssue);
vi.mocked(issuesApi.get).mockResolvedValue(fullIssue);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetailQueryHarness
issueRef="PAP-1442"
placeholderIssue={{ id: partialIssue.id, identifier: partialIssue.identifier }}
/>
</QueryClientProvider>,
);
});
await flush();
expect(issuesApi.get).toHaveBeenCalledWith("PAP-1442");
expect(container.textContent).toContain("GitHub Security Advisory body");
await act(async () => {
root.unmount();
});
queryClient.clear();
container.remove();
});
});
+8 -14
View File
@@ -28,7 +28,8 @@ import {
readIssueDetailHeaderSeed,
rememberIssueDetailLocationState,
} from "../lib/issueDetailBreadcrumb";
import { fetchIssueDetail, getCachedIssueDetail } from "../lib/issueDetailCache";
import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "../lib/issueActiveRun";
import { getIssueDetailQueryOptions } from "../lib/issueDetailCache";
import {
hasBlockingShortcutDialog,
resolveIssueDetailGoKeyAction,
@@ -882,22 +883,15 @@ export function IssueDetail() {
() => readIssueDetailHeaderSeed(location.state) ?? readIssueDetailHeaderSeed(resolvedIssueDetailState),
[location.state, resolvedIssueDetailState],
);
const cachedIssue = useMemo(
() =>
issueId
? getCachedIssueDetail(queryClient, issueId, issueHeaderSeed ? {
id: issueHeaderSeed.id,
identifier: issueHeaderSeed.identifier,
} : null)
: undefined,
[issueHeaderSeed, issueId, queryClient],
);
const { data: issue, isLoading, error } = useQuery({
queryKey: queryKeys.issues.detail(issueId!),
queryFn: () => fetchIssueDetail(queryClient, issueId!),
...getIssueDetailQueryOptions(queryClient, issueId!, {
placeholderIssue: issueHeaderSeed ? {
id: issueHeaderSeed.id,
identifier: issueHeaderSeed.identifier,
} : null,
}),
enabled: !!issueId,
initialData: () => cachedIssue,
});
const resolvedCompanyId = issue?.companyId ?? selectedCompanyId;
const commentComposerDisabledReason = useMemo(() => {