Merge public-gh/master into paperclip-company-import-export

This commit is contained in:
dotta
2026-03-20 06:25:24 -05:00
41 changed files with 11912 additions and 392 deletions

View File

@@ -2450,6 +2450,14 @@ export function accessRoutes(
"member",
"active"
);
await access.setPrincipalPermission(
companyId,
"agent",
created.id,
"tasks:assign",
true,
req.actor.userId ?? null
);
const grants = grantsFromDefaults(
invite.defaultsPayload as Record<string, unknown> | null,
"agent"

View File

@@ -87,6 +87,80 @@ export function agentRoutes(db: Db) {
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
}
async function buildAgentAccessState(agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>) {
const membership = await access.getMembership(agent.companyId, "agent", agent.id);
const grants = membership
? await access.listPrincipalGrants(agent.companyId, "agent", agent.id)
: [];
const hasExplicitTaskAssignGrant = grants.some((grant) => grant.permissionKey === "tasks:assign");
if (agent.role === "ceo") {
return {
canAssignTasks: true,
taskAssignSource: "ceo_role" as const,
membership,
grants,
};
}
if (canCreateAgents(agent)) {
return {
canAssignTasks: true,
taskAssignSource: "agent_creator" as const,
membership,
grants,
};
}
if (hasExplicitTaskAssignGrant) {
return {
canAssignTasks: true,
taskAssignSource: "explicit_grant" as const,
membership,
grants,
};
}
return {
canAssignTasks: false,
taskAssignSource: "none" as const,
membership,
grants,
};
}
async function buildAgentDetail(
agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>,
options?: { restricted?: boolean },
) {
const [chainOfCommand, accessState] = await Promise.all([
svc.getChainOfCommand(agent.id),
buildAgentAccessState(agent),
]);
return {
...(options?.restricted ? redactForRestrictedAgentView(agent) : agent),
chainOfCommand,
access: accessState,
};
}
async function applyDefaultAgentTaskAssignGrant(
companyId: string,
agentId: string,
grantedByUserId: string | null,
) {
await access.ensureMembership(companyId, "agent", agentId, "member", "active");
await access.setPrincipalPermission(
companyId,
"agent",
agentId,
"tasks:assign",
true,
grantedByUserId,
);
}
async function assertCanCreateAgentsForCompany(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
if (req.actor.type === "board") {
@@ -861,8 +935,7 @@ export function agentRoutes(db: Db) {
res.status(404).json({ error: "Agent not found" });
return;
}
const chainOfCommand = await svc.getChainOfCommand(agent.id);
res.json({ ...agent, chainOfCommand });
res.json(await buildAgentDetail(agent));
});
router.get("/agents/me/inbox-lite", async (req, res) => {
@@ -904,13 +977,11 @@ export function agentRoutes(db: Db) {
if (req.actor.type === "agent" && req.actor.agentId !== id) {
const canRead = await actorCanReadConfigurationsForCompany(req, agent.companyId);
if (!canRead) {
const chainOfCommand = await svc.getChainOfCommand(agent.id);
res.json({ ...redactForRestrictedAgentView(agent), chainOfCommand });
res.json(await buildAgentDetail(agent, { restricted: true }));
return;
}
}
const chainOfCommand = await svc.getChainOfCommand(agent.id);
res.json({ ...agent, chainOfCommand });
res.json(await buildAgentDetail(agent));
});
router.get("/agents/:id/configuration", async (req, res) => {
@@ -1185,6 +1256,12 @@ export function agentRoutes(db: Db) {
},
});
await applyDefaultAgentTaskAssignGrant(
companyId,
agent.id,
actor.actorType === "user" ? actor.actorId : null,
);
if (approval) {
await logActivity(db, {
companyId,
@@ -1261,6 +1338,12 @@ export function agentRoutes(db: Db) {
},
});
await applyDefaultAgentTaskAssignGrant(
companyId,
agent.id,
req.actor.type === "board" ? (req.actor.userId ?? null) : null,
);
if (agent.budgetMonthlyCents > 0) {
await budgets.upsertPolicy(
companyId,
@@ -1304,6 +1387,18 @@ export function agentRoutes(db: Db) {
return;
}
const effectiveCanAssignTasks =
agent.role === "ceo" || Boolean(agent.permissions?.canCreateAgents) || req.body.canAssignTasks;
await access.ensureMembership(agent.companyId, "agent", agent.id, "member", "active");
await access.setPrincipalPermission(
agent.companyId,
"agent",
agent.id,
"tasks:assign",
effectiveCanAssignTasks,
req.actor.type === "board" ? (req.actor.userId ?? null) : null,
);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: agent.companyId,
@@ -1314,10 +1409,13 @@ export function agentRoutes(db: Db) {
action: "agent.permissions_updated",
entityType: "agent",
entityId: agent.id,
details: req.body,
details: {
canCreateAgents: agent.permissions?.canCreateAgents ?? false,
canAssignTasks: effectiveCanAssignTasks,
},
});
res.json(agent);
res.json(await buildAgentDetail(agent));
});
router.patch("/agents/:id/instructions-path", validate(updateAgentInstructionsPathSchema), async (req, res) => {

View File

@@ -7,6 +7,7 @@ import {
createCompanySchema,
updateCompanyBrandingSchema,
updateCompanySchema,
updateCompanyBrandingSchema,
} from "@paperclipai/shared";
import { forbidden } from "../errors.js";
import { validate } from "../middleware/validate.js";
@@ -90,9 +91,12 @@ export function companyRoutes(db: Db, storage?: StorageService) {
});
router.get("/:companyId", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
// Allow agents (CEO) to read their own company; board always allowed
if (req.actor.type !== "agent") {
assertBoard(req);
}
const company = await svc.getById(companyId);
if (!company) {
res.status(404).json({ error: "Company not found" });
@@ -238,23 +242,44 @@ export function companyRoutes(db: Db, storage?: StorageService) {
res.status(201).json(company);
});
router.patch("/:companyId", validate(updateCompanySchema), async (req, res) => {
assertBoard(req);
router.patch("/:companyId", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const company = await svc.update(companyId, req.body);
const actor = getActorInfo(req);
let body: Record<string, unknown>;
if (req.actor.type === "agent") {
// Only CEO agents may update company branding fields
const agentSvc = agentService(db);
const actorAgent = req.actor.agentId ? await agentSvc.getById(req.actor.agentId) : null;
if (!actorAgent || actorAgent.role !== "ceo") {
throw forbidden("Only CEO agents or board users may update company settings");
}
if (actorAgent.companyId !== companyId) {
throw forbidden("Agent key cannot access another company");
}
body = updateCompanyBrandingSchema.parse(req.body);
} else {
assertBoard(req);
body = updateCompanySchema.parse(req.body);
}
const company = await svc.update(companyId, body);
if (!company) {
res.status(404).json({ error: "Company not found" });
return;
}
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.updated",
entityType: "company",
entityId: companyId,
details: req.body,
details: body,
});
res.json(company);
});

View File

@@ -820,6 +820,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
}
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
const actor = getActorInfo(req);
const { comment: commentBody, hiddenAt: hiddenAtRaw, ...updateFields } = req.body;
if (hiddenAtRaw !== undefined) {
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
@@ -856,6 +857,11 @@ export function issueRoutes(db: Db, storage: StorageService) {
return;
}
if (actor.runId) {
await heartbeat.reportRunActivity(actor.runId).catch((err) =>
logger.warn({ err, runId: actor.runId }, "failed to clear detached run warning after issue activity"));
}
// Build activity details with previous values for changed fields
const previous: Record<string, unknown> = {};
for (const key of Object.keys(updateFields)) {
@@ -864,7 +870,6 @@ export function issueRoutes(db: Db, storage: StorageService) {
}
}
const actor = getActorInfo(req);
const hasFieldChanges = Object.keys(previous).length > 0;
await logActivity(db, {
companyId: issue.companyId,
@@ -1278,6 +1283,11 @@ export function issueRoutes(db: Db, storage: StorageService) {
userId: actor.actorType === "user" ? actor.actorId : undefined,
});
if (actor.runId) {
await heartbeat.reportRunActivity(actor.runId).catch((err) =>
logger.warn({ err, runId: actor.runId }, "failed to clear detached run warning after issue comment"));
}
await logActivity(db, {
companyId: currentIssue.companyId,
actorType: actor.actorType,