[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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 3014 additions and 153 deletions

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");

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) {

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" });

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,

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,

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 }),

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,

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);

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;

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" });

View file

@ -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;
}

View file

@ -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);
}