mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-15 20:53:23 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents use AI connections to run tasks for a responsible user. > - A connection can deny access when its owner has not shared it with that user. > - The task conversation hides this cause behind a collapsed failure marker. > - People need to see which connection blocked the agent and how to fix it. > - This pull request shows an expanded access error in the task conversation. > - The error names the agent and connection, and links to the agent's AI settings. ## Linked Issues or Issue Description **What happened?** A task could fail because its responsible user could not use the agent's AI connection. The user saw a temporary error toast and a collapsed task failure. The task did not clearly explain the access problem. **Expected behavior** The task should show a visible error that names the agent, the connection, and the person who lacks access. For example: "Codie is configured to use Dotta's API Key, but you don't have access to that credential." It should offer a link to change the AI connection. **Steps to reproduce** 1. Configure an agent with a personal AI connection. 2. Start a task for a user who cannot use that connection. 3. Open the task after the run fails. 4. Check that the access error stays visible and links to AI settings. Refs #15714. That change classifies expected connection-sharing denials. This change makes the same failure clear in task chat. ## What Changed - Keep the connection display name in private error metadata when a sharing check denies access. - Store a private grant reference for the name visibility check. Use one rule for task summaries and full run responses. Show the name to the affected user and the connection sharing audience. Other task readers see a generic label. Strip grant IDs from public run responses. - Show an expanded error card for native and legacy task runs. Use "you" only when the viewer is the responsible user. - Link to AI settings and the failed run. Preserve names on resumed native runs. Hide the old error after a newer successful attempt. - Add full task-page Storybook examples, focused tests, and design guidance. ## Verification - Passed 249 focused tests for task chat, error text, private error metadata, and the real database run summary. These include resumed native runs and viewer access rules. - Passed all UI token gates on the rebased branch. - Checked the production task page in Storybook. Verified the viewer's error, another user's error, mobile layout, and a newer successful attempt. - Passed `pnpm -r typecheck` and `pnpm build` in a clean checkout with Node 24 and the pinned pnpm version. Passed the final server typecheck after the response filter changes. - Passed the real database route regression for the connection owner, affected user, and another task reader. Passed the registered secret redaction tests. - The local route checks needed `--testTimeout 60000` for cold imports. A broader local route run hit cold-import timeouts and an old fixture assertion that is now fixed. The duplicate local `pnpm test:run` aggregate was stopped to free resources. The full suite passed in CI with the normal timeouts. - Passed the full [final-commit CI workflow](https://github.com/paperclipai/paperclip/actions/runs/37996148342), including all server, UI, runner, browser, typecheck, build, and canary packaging gates. All 54 executed checks passed; two Storybook jobs were skipped. Greptile reviewed the final commit at 5/5 with no remaining findings, and every review thread is resolved. - Review the Storybook group `Task / Credential access` for the notice and full task-page examples. ## Risks - Only the allowed connection display name enters the compact run summary. Credential secrets, grant IDs, and arbitrary error details do not enter it. - Existing runs can lack the connection name. They show a clear fallback message. - The notice uses the existing task-run relevance rules. A newer attempt or a completed task hides the old failure. - No schema migration or API permission change is required. ## Model Used OpenAI GPT-6 through Codex, with tool execution and browser verification. The exact serving variant and context-window size are not exposed in 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 checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
import { Router } from "express";
|
|
import { z } from "zod";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { normalizeIssueIdentifier } from "@paperclipai/shared";
|
|
import { validate } from "../middleware/validate.js";
|
|
import { activityService, normalizeActivityLimit } from "../services/activity.js";
|
|
import { assertAuthenticated, assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js";
|
|
import { accessService, heartbeatService, issueService } from "../services/index.js";
|
|
import { sanitizeRecord } from "../redaction.js";
|
|
import { badRequest, forbidden } from "../errors.js";
|
|
import { agentActionAuditService } from "../services/agent-action-audit.js";
|
|
import { logActivity } from "../services/activity-log.js";
|
|
import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js";
|
|
import { issueReadSqlCondition } from "../services/authorization.js";
|
|
|
|
/** Max rows a single CSV export will stream (guards against runaway exports). */
|
|
const AUDIT_CSV_EXPORT_MAX_ROWS = 10_000;
|
|
const AUDIT_CSV_PAGE_SIZE = 200;
|
|
const CSV_FORMULA_CHARS = /^[=+\-@\t\r]/;
|
|
|
|
const AUDIT_CSV_COLUMNS = [
|
|
"createdAt",
|
|
"action",
|
|
"actorType",
|
|
"actorId",
|
|
"agentId",
|
|
"runId",
|
|
"responsibleUserId",
|
|
"entityType",
|
|
"entityId",
|
|
"issueIdentifier",
|
|
"issueTitle",
|
|
"commentExcerpt",
|
|
"documentKey",
|
|
] as const;
|
|
|
|
function csvCell(value: unknown): string {
|
|
if (value === null || value === undefined) return "";
|
|
const str = value instanceof Date ? value.toISOString() : String(value);
|
|
// Prevent spreadsheet applications from interpreting user-controlled cells
|
|
// as formulas when an operator opens the export.
|
|
const safe = CSV_FORMULA_CHARS.test(str) ? `'${str}` : str;
|
|
// Quote if the value contains a delimiter, quote, or newline; escape quotes by doubling.
|
|
return /[",\r\n]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe;
|
|
}
|
|
|
|
function readNested(value: unknown, ...keys: string[]): string | null {
|
|
let cursor: unknown = value;
|
|
for (const key of keys) {
|
|
if (!cursor || typeof cursor !== "object") return null;
|
|
cursor = (cursor as Record<string, unknown>)[key];
|
|
}
|
|
return typeof cursor === "string" ? cursor : null;
|
|
}
|
|
|
|
type AuditCsvRow = {
|
|
createdAt: Date | string;
|
|
action: string;
|
|
actorType: string | null;
|
|
actorId: string | null;
|
|
agentId: string | null;
|
|
runId: string | null;
|
|
responsibleUserId: string | null;
|
|
entityType: string;
|
|
entityId: string;
|
|
// Enrichment snippet is redacted server-side into a plain record, so read it
|
|
// defensively rather than assuming a fixed shape.
|
|
entity: unknown;
|
|
};
|
|
|
|
function auditRowsToCsv(rows: AuditCsvRow[]): string {
|
|
const lines = [AUDIT_CSV_COLUMNS.join(",")];
|
|
for (const row of rows) {
|
|
lines.push([
|
|
csvCell(row.createdAt),
|
|
csvCell(row.action),
|
|
csvCell(row.actorType),
|
|
csvCell(row.actorId),
|
|
csvCell(row.agentId),
|
|
csvCell(row.runId),
|
|
csvCell(row.responsibleUserId),
|
|
csvCell(row.entityType),
|
|
csvCell(row.entityId),
|
|
csvCell(readNested(row.entity, "issue", "identifier")),
|
|
csvCell(readNested(row.entity, "issue", "title")),
|
|
csvCell(readNested(row.entity, "comment", "excerpt")),
|
|
csvCell(readNested(row.entity, "document", "key")),
|
|
].join(","));
|
|
}
|
|
// Trailing newline keeps POSIX tools + spreadsheet importers happy.
|
|
return `${lines.join("\r\n")}\r\n`;
|
|
}
|
|
|
|
const createActivitySchema = z.object({
|
|
actorType: z.enum(["agent", "user", "system", "plugin"]).optional().default("system"),
|
|
actorId: z.string().min(1),
|
|
action: z.string().min(1),
|
|
entityType: z.string().min(1),
|
|
entityId: z.string().min(1),
|
|
agentId: z.string().guid().optional().nullable(),
|
|
details: z.record(z.string(), z.unknown()).optional().nullable(),
|
|
});
|
|
|
|
const agentActionAuditActorScopeSchema = z.enum(["agents", "all"]);
|
|
|
|
const agentActionAuditQuerySchema = z.object({
|
|
actorScope: agentActionAuditActorScopeSchema.default("agents"),
|
|
agentId: z.string().guid().optional(),
|
|
responsibleUserId: z.string().min(1).optional(),
|
|
runId: z.string().guid().optional(),
|
|
entityType: z.string().min(1).optional(),
|
|
entityId: z.string().min(1).optional(),
|
|
action: z.string().min(1).optional(),
|
|
actorType: z.enum(["agent", "user", "system", "plugin"]).optional(),
|
|
from: z.coerce.date().optional(),
|
|
to: z.coerce.date().optional(),
|
|
cursor: z.string().min(1).optional(),
|
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
|
});
|
|
|
|
export function activityRoutes(db: Db) {
|
|
const router = Router();
|
|
const svc = activityService(db);
|
|
const access = accessService(db);
|
|
const heartbeat = heartbeatService(db);
|
|
const issueSvc = issueService(db);
|
|
const agentAudit = agentActionAuditService(db);
|
|
|
|
async function hasAgentAuditPermission(req: import("express").Request, companyId: string) {
|
|
if (req.actor.type !== "board") return false;
|
|
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true;
|
|
return Boolean(
|
|
req.actor.userId
|
|
&& await access.canUser(companyId, req.actor.userId, "audit:view_agent_actions"),
|
|
);
|
|
}
|
|
|
|
async function assertAgentAuditPermission(req: import("express").Request, companyId: string) {
|
|
assertBoard(req);
|
|
assertCompanyAccess(req, companyId);
|
|
if (await hasAgentAuditPermission(req, companyId)) return;
|
|
throw forbidden("Missing permission: audit:view_agent_actions");
|
|
}
|
|
|
|
function hasAttributionFilters(query: z.infer<typeof agentActionAuditQuerySchema>) {
|
|
return query.agentId !== undefined
|
|
|| query.responsibleUserId !== undefined
|
|
|| query.runId !== undefined
|
|
|| query.entityType !== undefined
|
|
|| query.entityId !== undefined
|
|
|| query.action !== undefined
|
|
|| query.actorType !== undefined
|
|
|| query.from !== undefined
|
|
|| query.to !== undefined;
|
|
}
|
|
|
|
function stripAuditAttribution<T extends {
|
|
items: Array<{
|
|
agentId: string | null;
|
|
runId: string | null;
|
|
responsibleUserId: string | null;
|
|
details: unknown;
|
|
}>;
|
|
}>(result: T): T {
|
|
return {
|
|
...result,
|
|
items: result.items.map((item) => ({
|
|
...item,
|
|
agentId: null,
|
|
runId: null,
|
|
responsibleUserId: null,
|
|
details: null,
|
|
})),
|
|
};
|
|
}
|
|
|
|
async function assertCompanyScopeReadAllowed(req: Parameters<typeof assertCompanyAccess>[0], res: any, companyId: string) {
|
|
const decision = await access.decide({
|
|
actor: req.actor,
|
|
action: "company_scope:read",
|
|
resource: { type: "company", companyId },
|
|
});
|
|
if (decision.allowed) return true;
|
|
res.status(403).json({ error: "Activity is outside this actor's authorization boundary" });
|
|
return false;
|
|
}
|
|
|
|
async function assertIssueReadAllowed(req: Parameters<typeof assertCompanyAccess>[0], res: any, issue: {
|
|
id: string;
|
|
companyId: string;
|
|
projectId: string | null;
|
|
parentId: string | null;
|
|
assigneeAgentId: string | null;
|
|
assigneeUserId: string | null;
|
|
status: string;
|
|
}) {
|
|
const decision = await access.decide({
|
|
actor: req.actor,
|
|
action: "issue:read",
|
|
resource: {
|
|
type: "issue",
|
|
companyId: issue.companyId,
|
|
issueId: issue.id,
|
|
projectId: issue.projectId,
|
|
parentIssueId: issue.parentId,
|
|
assigneeAgentId: issue.assigneeAgentId,
|
|
assigneeUserId: issue.assigneeUserId,
|
|
status: issue.status,
|
|
},
|
|
});
|
|
if (decision.allowed) return true;
|
|
res.status(403).json({ error: "Issue activity is outside this actor's authorization boundary" });
|
|
return false;
|
|
}
|
|
|
|
async function resolveIssueByRef(rawId: string) {
|
|
const identifier = normalizeIssueIdentifier(rawId);
|
|
if (identifier) {
|
|
return issueSvc.getByIdentifier(identifier);
|
|
}
|
|
return issueSvc.getById(rawId);
|
|
}
|
|
|
|
router.get("/companies/:companyId/activity", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
if (!(await assertCompanyScopeReadAllowed(req, res, companyId))) return;
|
|
|
|
const filters = {
|
|
companyId,
|
|
agentId: req.query.agentId as string | undefined,
|
|
entityType: req.query.entityType as string | undefined,
|
|
entityId: req.query.entityId as string | undefined,
|
|
limit: normalizeActivityLimit(Number(req.query.limit)),
|
|
readCondition: await issueReadSqlCondition(db, req.actor),
|
|
};
|
|
const result = await svc.list(filters);
|
|
res.json(result);
|
|
});
|
|
|
|
router.get("/companies/:companyId/audit/agent-actions", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
const parsedActorScope = agentActionAuditActorScopeSchema.safeParse(req.query.actorScope ?? "agents");
|
|
if (!parsedActorScope.success) {
|
|
throw badRequest("Invalid agent action audit query", parsedActorScope.error.issues);
|
|
}
|
|
if (parsedActorScope.data === "agents") {
|
|
// Keep the legacy authorization-before-validation behavior for callers
|
|
// that omit the new flag.
|
|
await assertAgentAuditPermission(req, companyId);
|
|
} else {
|
|
assertCompanyAccess(req, companyId);
|
|
if (!(await assertCompanyScopeReadAllowed(req, res, companyId))) return;
|
|
}
|
|
|
|
const parsedQuery = agentActionAuditQuerySchema.safeParse(req.query);
|
|
if (!parsedQuery.success) {
|
|
throw badRequest("Invalid agent action audit query", parsedQuery.error.issues);
|
|
}
|
|
if (parsedQuery.data.actorScope === "agents") {
|
|
const result = await agentAudit.list({ companyId, ...parsedQuery.data });
|
|
res.json({ ...result, accessTier: "full" });
|
|
return;
|
|
}
|
|
|
|
const canViewAttribution = await hasAgentAuditPermission(req, companyId);
|
|
if (!canViewAttribution && hasAttributionFilters(parsedQuery.data)) {
|
|
throw forbidden("Audit filters require permission: audit:view_agent_actions");
|
|
}
|
|
const result = await agentAudit.list({ companyId, ...parsedQuery.data });
|
|
res.json({
|
|
...(canViewAttribution ? result : stripAuditAttribution(result)),
|
|
accessTier: canViewAttribution ? "full" : "basic",
|
|
});
|
|
});
|
|
|
|
router.get("/companies/:companyId/audit/agent-actions.csv", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
await assertAgentAuditPermission(req, companyId);
|
|
const parsedQuery = agentActionAuditQuerySchema.safeParse(req.query);
|
|
if (!parsedQuery.success) {
|
|
throw badRequest("Invalid agent action audit query", parsedQuery.error.issues);
|
|
}
|
|
// Drive our own pagination for the export; a client-supplied cursor/limit
|
|
// would silently truncate the export, so ignore them.
|
|
const { cursor: _cursor, limit: _limit, ...filters } = parsedQuery.data;
|
|
const rows: Awaited<ReturnType<typeof agentAudit.list>>["items"] = [];
|
|
let cursor: string | undefined;
|
|
do {
|
|
const page = await agentAudit.list({ companyId, ...filters, cursor, limit: AUDIT_CSV_PAGE_SIZE });
|
|
for (const item of page.items) {
|
|
if (rows.length >= AUDIT_CSV_EXPORT_MAX_ROWS) break;
|
|
rows.push(item);
|
|
}
|
|
cursor = page.nextCursor ?? undefined;
|
|
} while (cursor && rows.length < AUDIT_CSV_EXPORT_MAX_ROWS);
|
|
|
|
// The export is itself an auditable act (training-data export precedent):
|
|
// record who exported what filter set and how many rows left the system.
|
|
const actorUserId = req.actor.type === "board" ? req.actor.userId ?? null : null;
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actorUserId ? "user" : "system",
|
|
actorId: actorUserId ?? "local-board",
|
|
action: "audit.exported",
|
|
entityType: "company",
|
|
entityId: companyId,
|
|
details: {
|
|
format: "csv",
|
|
rowCount: rows.length,
|
|
truncated: rows.length >= AUDIT_CSV_EXPORT_MAX_ROWS && Boolean(cursor),
|
|
filters: {
|
|
actorScope: filters.actorScope,
|
|
agentId: filters.agentId ?? null,
|
|
responsibleUserId: filters.responsibleUserId ?? null,
|
|
runId: filters.runId ?? null,
|
|
entityType: filters.entityType ?? null,
|
|
entityId: filters.entityId ?? null,
|
|
action: filters.action ?? null,
|
|
actorType: filters.actorType ?? null,
|
|
from: filters.from ? filters.from.toISOString() : null,
|
|
to: filters.to ? filters.to.toISOString() : null,
|
|
},
|
|
},
|
|
});
|
|
|
|
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
|
res.setHeader("Content-Disposition", `attachment; filename="agent-audit-${companyId}.csv"`);
|
|
res.send(auditRowsToCsv(rows));
|
|
});
|
|
|
|
router.post("/companies/:companyId/activity", validate(createActivitySchema), async (req, res) => {
|
|
assertBoard(req);
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const event = await svc.create({
|
|
companyId,
|
|
...req.body,
|
|
details: req.body.details ? sanitizeRecord(req.body.details) : null,
|
|
});
|
|
res.status(201).json(event);
|
|
});
|
|
|
|
router.get("/issues/:id/activity", async (req, res) => {
|
|
const rawId = req.params.id as string;
|
|
const issue = await getAccessibleResource(req, res, resolveIssueByRef(rawId), "Issue not found");
|
|
if (!issue) return;
|
|
if (!(await assertIssueReadAllowed(req, res, issue))) return;
|
|
const result = await svc.forIssue(issue.id);
|
|
res.json(result);
|
|
});
|
|
|
|
router.get("/issues/:id/runs", async (req, res) => {
|
|
const rawId = req.params.id as string;
|
|
const issue = await getAccessibleResource(req, res, resolveIssueByRef(rawId), "Issue not found");
|
|
if (!issue) return;
|
|
if (!(await assertIssueReadAllowed(req, res, issue))) return;
|
|
const result = await svc.runsForIssue(issue.companyId, issue.id,
|
|
req.actor.type === "board" ? getActorInfo(req).actorId : null);
|
|
res.json(result);
|
|
});
|
|
|
|
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 || !hasCompanyAccess(req, run.companyId)) {
|
|
// Return `200 []` for both "doesn't exist" and "cross-tenant" — preserves the
|
|
// legacy API contract while keeping the cross-tenant existence oracle closed
|
|
// (both branches yield indistinguishable responses).
|
|
res.json([]);
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, run.companyId);
|
|
if (!(await assertCompanyScopeReadAllowed(req, res, run.companyId))) return;
|
|
if (!(await canActorReadHeartbeatRun(db, access, req.actor, run))) {
|
|
res.json([]);
|
|
return;
|
|
}
|
|
const result = await svc.issuesForRun(runId);
|
|
res.json(result);
|
|
});
|
|
|
|
return router;
|
|
}
|