mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
## Thinking Path > - Paperclip is the control plane people use to manage AI-agent companies and their work > - The activity log is the generic audit spine for mutations across the control plane > - Activity rows identify agents and runs, but they do not persist the responsible human upstream > - Relying only on run joins loses attribution after run pruning and misses agent API-key actions outside a run > - This pull request resolves responsible-user attribution when each activity row is written and stores it directly > - The benefit is durable, queryable agent audit feeds without rewriting historical provenance ## Linked Issues or Issue Description ### Problem or motivation Agent activity records do not persist the responsible user, so attribution can disappear when runs are pruned and no-run API-key mutations cannot be attributed correctly. ### Proposed solution Resolve attribution for each new activity row from the run, related issue, active agent API key, or company default, in that order, and persist the result directly. ### Alternatives considered Read-time joins alone were rejected because pruned runs lose durable attribution and out-of-run agent-key actions have no run to join. Historical backfill was rejected because it would invent provenance. ### Roadmap alignment This strengthens the durable audit-trail direction described in `ROADMAP.md` without adding a new product surface. ## What Changed - Added nullable `activity_log.responsible_user_id` plus company/agent/time and company/responsible-user/time indexes. - Added an idempotent forward-only migration with no historical backfill. - Added centralized write-time resolution: heartbeat run → issue attribution → active agent API key → company default. - Propagated authenticated API-key IDs through existing request-backed `logActivity()` calls. - Added unit coverage for every fallback and an embedded-Postgres assertion for the no-run API-key stamping path. ## Verification - `pnpm --filter @paperclipai/db typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/activity-log-responsible-user.test.ts src/__tests__/authz-company-access.test.ts` - Initial focused verification: 25 tests passed; migration safety passed. - Follow-up regression verification: 54 focused attribution/company-skill/environment/issue-tree/tool-gateway tests passed; server typecheck passed. - GitHub: full build, typecheck, server shards, serialized suites, e2e, security, and policy checks passed. ## Risks - Adding two indexes to an existing large table can hold a write lock while the transactional migration runs. The migration safety suppressions document why `CONCURRENTLY` is unavailable under the current Drizzle migration runner. - Historical rows remain nullable by design; this avoids inventing provenance and keeps the migration forward-only. - API-key attribution requires request-backed activity call sites to pass the authenticated key ID; this PR mechanically updates the existing actor-based activity calls and covers the no-run path with integration testing. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, `gpt-5.4`; context-window size was not exposed by the runtime. Medium reasoning with repository editing, terminal execution, and test execution capabilities was used. ## 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>
291 lines
10 KiB
TypeScript
291 lines
10 KiB
TypeScript
import { Router, urlencoded, type Request } from "express";
|
|
import type { Db } from "@paperclipai/db";
|
|
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
|
|
import {
|
|
createSmokeRunSchema,
|
|
recordSmokeRunStepSchema,
|
|
updateSmokeRunSchema,
|
|
} from "@paperclipai/shared";
|
|
import { validate } from "../middleware/validate.js";
|
|
import { assertBoard, assertBoardOrAgent, assertCompanyAccess, getActorInfo } from "./authz.js";
|
|
import { logActivity, smokeLabService } from "../services/index.js";
|
|
|
|
function configuredPublicBaseUrl() {
|
|
const raw = (
|
|
process.env.PAPERCLIP_PUBLIC_URL?.trim()
|
|
|| process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim()
|
|
|| process.env.BETTER_AUTH_URL?.trim()
|
|
|| process.env.BETTER_AUTH_BASE_URL?.trim()
|
|
);
|
|
if (!raw) return null;
|
|
try {
|
|
return new URL(raw).origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function requestBaseUrl(req: Request) {
|
|
const configured = configuredPublicBaseUrl();
|
|
if (configured) return configured;
|
|
const host = req.get("host")?.trim() || req.hostname;
|
|
return `${req.protocol}://${host}`;
|
|
}
|
|
|
|
function smokeLabBaseUrl(req: Request, companyId: string) {
|
|
return `${requestBaseUrl(req)}/api/companies/${encodeURIComponent(companyId)}/smoke-lab`;
|
|
}
|
|
|
|
function stringBodyValue(body: unknown, key: string) {
|
|
if (!body || typeof body !== "object") return undefined;
|
|
const value = (body as Record<string, unknown>)[key];
|
|
return typeof value === "string" ? value : undefined;
|
|
}
|
|
|
|
export function smokeLabRoutes(db: Db, options: {
|
|
deploymentMode?: DeploymentMode;
|
|
deploymentExposure?: DeploymentExposure;
|
|
nodeEnv?: string;
|
|
} = {}) {
|
|
const router = Router();
|
|
const svc = smokeLabService(db, options);
|
|
const formParser = urlencoded({ extended: false });
|
|
|
|
async function assertSmokeLabEnabled() {
|
|
await svc.assertEnabled();
|
|
}
|
|
|
|
router.get("/companies/:companyId/smoke-lab/oauth/authorize", async (req, res) => {
|
|
await assertSmokeLabEnabled();
|
|
const companyId = req.params.companyId as string;
|
|
res.type("html").send(svc.oauthAuthorizePage({
|
|
companyId,
|
|
clientId: String(req.query.client_id ?? "smoke-client"),
|
|
redirectUri: String(req.query.redirect_uri ?? "http://127.0.0.1/callback"),
|
|
state: typeof req.query.state === "string" ? req.query.state : undefined,
|
|
scope: typeof req.query.scope === "string" ? req.query.scope : undefined,
|
|
responseType: typeof req.query.response_type === "string" ? req.query.response_type : undefined,
|
|
requestOrigin: configuredPublicBaseUrl() ?? undefined,
|
|
}));
|
|
});
|
|
|
|
router.post("/companies/:companyId/smoke-lab/oauth/authorize", formParser, async (req, res) => {
|
|
await assertSmokeLabEnabled();
|
|
const location = svc.completeAuthorize({
|
|
companyId: req.params.companyId as string,
|
|
clientId: stringBodyValue(req.body, "client_id") ?? "smoke-client",
|
|
redirectUri: stringBodyValue(req.body, "redirect_uri") ?? "http://127.0.0.1/callback",
|
|
state: stringBodyValue(req.body, "state"),
|
|
scope: stringBodyValue(req.body, "scope"),
|
|
email: stringBodyValue(req.body, "email"),
|
|
password: stringBodyValue(req.body, "password"),
|
|
requestOrigin: configuredPublicBaseUrl() ?? undefined,
|
|
});
|
|
res.redirect(302, location);
|
|
});
|
|
|
|
router.post("/companies/:companyId/smoke-lab/oauth/token", formParser, async (req, res) => {
|
|
await assertSmokeLabEnabled();
|
|
res.json(svc.issueToken({
|
|
companyId: req.params.companyId as string,
|
|
grantType: stringBodyValue(req.body, "grant_type"),
|
|
code: stringBodyValue(req.body, "code"),
|
|
refreshToken: stringBodyValue(req.body, "refresh_token"),
|
|
clientId: stringBodyValue(req.body, "client_id"),
|
|
redirectUri: stringBodyValue(req.body, "redirect_uri"),
|
|
}));
|
|
});
|
|
|
|
router.get("/companies/:companyId/smoke-lab/oauth/userinfo", async (req, res) => {
|
|
await assertSmokeLabEnabled();
|
|
res.json(svc.userinfo({
|
|
companyId: req.params.companyId as string,
|
|
authorization: req.get("authorization"),
|
|
}));
|
|
});
|
|
|
|
router.post("/companies/:companyId/smoke-lab/oauth/revoke", formParser, async (req, res) => {
|
|
await assertSmokeLabEnabled();
|
|
res.json(svc.revoke({
|
|
companyId: req.params.companyId as string,
|
|
token: stringBodyValue(req.body, "token"),
|
|
}));
|
|
});
|
|
|
|
router.get("/companies/:companyId/smoke-lab/services", async (req, res) => {
|
|
assertBoard(req);
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
res.json(await svc.listServices(smokeLabBaseUrl(req, companyId)));
|
|
});
|
|
|
|
router.post("/companies/:companyId/smoke-lab/services/start", async (req, res) => {
|
|
assertBoard(req);
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const result = await svc.startServices(companyId, smokeLabBaseUrl(req, companyId));
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
agentApiKeyId: actor.agentApiKeyId,
|
|
action: "smoke_lab.services_started",
|
|
entityType: "smoke_lab",
|
|
entityId: companyId,
|
|
details: { services: result.services.map((service) => service.id) },
|
|
});
|
|
res.json(result);
|
|
});
|
|
|
|
router.post("/companies/:companyId/smoke-lab/services/stop", async (req, res) => {
|
|
assertBoard(req);
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const result = await svc.stopServices(smokeLabBaseUrl(req, companyId));
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
agentApiKeyId: actor.agentApiKeyId,
|
|
action: "smoke_lab.services_stopped",
|
|
entityType: "smoke_lab",
|
|
entityId: companyId,
|
|
details: { services: result.services.map((service) => service.id) },
|
|
});
|
|
res.json(result);
|
|
});
|
|
|
|
router.post("/companies/:companyId/smoke-lab/install-fixtures", async (req, res) => {
|
|
assertBoard(req);
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const result = await svc.installFixtures(companyId, getActorInfo(req));
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
agentApiKeyId: actor.agentApiKeyId,
|
|
action: "smoke_lab.fixtures_installed",
|
|
entityType: "smoke_lab",
|
|
entityId: companyId,
|
|
details: {
|
|
created: result.created,
|
|
applicationIds: result.applications.map((application) => application.id),
|
|
connectionIds: result.connections.map((connection) => connection.id),
|
|
catalogEntryCount: result.catalog.length,
|
|
profileId: result.profile.id,
|
|
},
|
|
});
|
|
res.status(result.created ? 201 : 200).json(result);
|
|
});
|
|
|
|
router.get("/companies/:companyId/smoke-lab/runs", async (req, res) => {
|
|
assertBoardOrAgent(req);
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
res.json(await svc.listRuns(companyId));
|
|
});
|
|
|
|
router.post("/companies/:companyId/smoke-lab/runs", validate(createSmokeRunSchema), async (req, res) => {
|
|
assertBoardOrAgent(req);
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const run = await svc.createRun(companyId, req.body);
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
agentApiKeyId: actor.agentApiKeyId,
|
|
action: "smoke_lab.run_created",
|
|
entityType: "smoke_run",
|
|
entityId: run.id,
|
|
details: { trigger: run.trigger },
|
|
});
|
|
res.status(201).json({ run });
|
|
});
|
|
|
|
router.get("/companies/:companyId/smoke-lab/runs/:runId", async (req, res) => {
|
|
assertBoardOrAgent(req);
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
res.json(await svc.getRun(companyId, req.params.runId as string));
|
|
});
|
|
|
|
router.patch("/companies/:companyId/smoke-lab/runs/:runId", validate(updateSmokeRunSchema), async (req, res) => {
|
|
assertBoardOrAgent(req);
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const run = await svc.updateRun(companyId, req.params.runId as string, req.body);
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
agentApiKeyId: actor.agentApiKeyId,
|
|
action: "smoke_lab.run_updated",
|
|
entityType: "smoke_run",
|
|
entityId: run.id,
|
|
details: { status: run.status },
|
|
});
|
|
res.json({ run });
|
|
});
|
|
|
|
router.post("/companies/:companyId/smoke-lab/runs/:runId/steps", validate(recordSmokeRunStepSchema), async (req, res) => {
|
|
assertBoardOrAgent(req);
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const result = await svc.recordStep(companyId, req.params.runId as string, req.body);
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
agentApiKeyId: actor.agentApiKeyId,
|
|
action: "smoke_lab.step_recorded",
|
|
entityType: "smoke_run_step",
|
|
entityId: result.step.id,
|
|
details: { smokeRunId: req.params.runId, path: result.step.path, status: result.step.status },
|
|
});
|
|
res.status(201).json(result);
|
|
});
|
|
|
|
router.post("/companies/:companyId/smoke-lab/reset", async (req, res) => {
|
|
assertBoard(req);
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const result = await svc.reset(companyId);
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
agentApiKeyId: actor.agentApiKeyId,
|
|
action: "smoke_lab.reset",
|
|
entityType: "smoke_lab",
|
|
entityId: companyId,
|
|
details: result,
|
|
});
|
|
res.json(result);
|
|
});
|
|
|
|
return router;
|
|
}
|