Files
PaperClipAI/server/src/__tests__/agent-default-configure-grants.test.ts
T
DottaandPaperclip 9077748894 Remove the Pipelines experiment and preserve its data (#16834)
## Thinking Path

> - Paperclip manages AI agents and their work.
> - Pipelines was an unused experiment with its own UI, API, CLI, and
automation.
> - Keeping it leaves unsupported product surfaces and extra task-detail
queries.
> - Removing the experiment must preserve data that users already saved.
> - This pull request removes the feature and keeps its database schema
and migration history.
> - Cases and Routines remain available as separate features.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Remove the unused Pipelines experiment across the application.

**Current behavior**

The experiment includes pipeline boards, stage settings, case
automation, review queues, learnings, permissions, and CLI commands.
Task detail also queries pipeline links.

**Proposed behavior**

Remove all these product surfaces and their implementation. Preserve
existing tables, records, and documents. Ignore the retired boolean in
older managed configurations so upgrades still start.

**Breaking changes**

Pipeline URLs, API endpoints, CLI commands, and `pipelines:write` are
removed. Task detail no longer includes the old `linkedCases` field. The
independent Cases API remains available. No database migration or
cleanup is included.

Related feature work: #7907, #9110, #10905, #11375, #13004, #14671,
#16133. Related issues: #9663, #10310, #10312. This removal supersedes
the feature; it does not implement those pending enhancements or delete
their saved data.

## What Changed

- Remove Pipeline UI pages, navigation, gates, clients, query keys,
helpers, and unused design tokens.
- Remove Pipeline REST routes, services, automation dispatch,
task-detail queries, and OpenAPI discovery.
- Remove the CLI commands, shared contracts, experimental flag, catalog
entry, and permission defaults.
- Remove feature-specific tests, tutorials, smoke scripts, maps, and
Storybook examples.
- Preserve database schema, migration history, and the reserved document
key used by old records.
- Keep Cases routes independent and cover obsolete stored and managed
settings.

## Verification

- Passed: `pnpm build` and `pnpm -r typecheck`.
- Passed: targeted settings, managed configuration, sidebar, settings
UI, routing, and feature-catalog tests. A first sidebar run caught a
shared Workspaces icon import; the import was restored and the sidebar
rerun passed.
- Passed: 40 tests covering Cases routes, OpenAPI discovery, retained
Pipeline table constraints, and schema snapshot drift.
- Passed: token gates, map validation, generated API metadata check,
Storybook catalog validation, and 28 map/catalog tests.
- Passed: isolated local browser verification of navigation and
Experimental settings with Pipelines absent; Cases and Routines still
open normally. Live GET/POST requests to retired Pipeline endpoints
return 404, and the retired setting is absent. CLI help has no Pipeline
commands.
- Passed: 22 access-service and agent-default-grant tests after
restoring unrelated permission assertions flagged in review.
- Local full run: `pnpm test:run` reported one failure in the unchanged
workspace-runtime port-allocation test. That test passed in an isolated
rerun. The long local run was stopped after full CI passed; it is not
reported as a complete local pass.
- Passed: all 56 checks on `def9181f3a4491d95fca1d38e9739f5b821aae3e` (2
conditional checks skipped), including build, typecheck, test shards,
and browser suites. Eleven interrupted CI jobs reported “The operation
was canceled”; their one-time retry passed. [CI
run](https://github.com/paperclipai/paperclip/actions/runs/39255211006).
- Greptile: 5/5 on the final commit, with the only review finding fixed
and resolved.
- Sandbox socket restrictions affected the first build/API test
attempts. The same commands ran with local sockets available.

## Risks

- Existing Pipeline clients must stop using the removed endpoints and
commands.
- Existing data remains in the database and backups, but the removed UI
no longer displays it.
- Historical boolean `enablePipelines` values are ignored. They cannot
restore the feature.
- Cases and Routines must retain their separate behavior. Targeted route
tests cover this boundary.

## Model Used

OpenAI Codex, GPT-6, with repository inspection, editing, shell
execution, and browser verification. The exact deployed model ID 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>
2026-10-19 08:09:46 -05:00

247 lines
12 KiB
TypeScript

import { createAgentLifecycle } from "../services/agent-lifecycle.js";
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { activityLog, agents, companies, companyMemberships, createDb, principalPermissionGrants } from "@paperclipai/db";
import { LOW_TRUST_REVIEW_PRESET, type PermissionKey } from "@paperclipai/shared";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { agentService } from "../services/agents.js";
import { authorizationService } from "../services/authorization.js";
import { accessService } from "../services/access.js";
import { agentJoinGrantsFromDefaults } from "../services/invite-grants.js";
const support = await getEmbeddedPostgresTestSupport();
const describeDatabase = support.supported ? describe : describe.skip;
const expectedNewAgentGrantKeys: PermissionKey[] = [
"agents:configure",
"agents:suggest-changes",
"skills:create",
"skills:suggest-changes",
"tools:manage_connections",
"tools:manage_profiles",
"tools:view_audit",
"audit:view_agent_actions",
"tools:use",
"tools:manage_runtime",
"inbox:manage",
"tasks:assign",
"tasks:assign_scope",
"tasks:manage_active_checkouts",
].sort();
describeDatabase("new agent configuration defaults", () => {
let db!: ReturnType<typeof createDb>;
let cleanup: (() => Promise<void>) | undefined;
beforeAll(async () => {
const started = await startEmbeddedPostgresTestDatabase("agent-configure-defaults");
db = createDb(started.connectionString);
cleanup = started.cleanup;
}, 20_000);
afterAll(async () => {
await cleanup?.();
});
it("grants standard new agents direct peer configuration, but keeps restricted agents narrow", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Agent permission defaults",
issuePrefix: `PD${companyId.slice(0, 6).toUpperCase()}`,
});
const create = (name: string, permissions: Record<string, unknown> = {}, metadata?: Record<string, unknown>) =>
createAgentLifecycle(db).requestHire(companyId, {
name,
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions,
metadata,
}, metadata ? { allowBuiltInAgentMetadata: true } : undefined);
const standard = await create("Standard");
const peer = await create("Peer");
const lowTrust = await create("Low trust", { trustPreset: LOW_TRUST_REVIEW_PRESET });
const bundled = await create("Bundled", {}, {
paperclipBuiltInAgent: { key: "reflection-coach", featureKeys: [] },
});
const grants = await db.select().from(principalPermissionGrants)
.where(eq(principalPermissionGrants.companyId, companyId));
for (const agent of [standard, peer]) {
expect(grants.filter((grant) => grant.principalId === agent.id)
.map((grant) => grant.permissionKey).sort()).toEqual(expectedNewAgentGrantKeys);
expect(grants.find((grant) => grant.principalId === agent.id && grant.permissionKey === "tasks:assign_scope")?.scope)
.toEqual({ subtreeRootAgentId: agent.id });
expect(grants.find((grant) => grant.principalId === agent.id && grant.permissionKey === "inbox:manage")?.scope)
.toEqual({ responsibleUserOnly: true });
}
for (const agent of [lowTrust, bundled]) {
expect(grants.filter((grant) => grant.principalId === agent.id)).toEqual([]);
}
await db.insert(companyMemberships).values({
companyId,
principalType: "agent",
principalId: standard.id,
status: "active",
membershipRole: "member",
});
for (const permissionKey of expectedNewAgentGrantKeys) {
if (permissionKey === "tasks:assign_scope" || permissionKey === "inbox:manage") continue; // These grants require a target scope or the responsible-user inbox path.
expect(await accessService(db).hasPermission(companyId, "agent", standard.id, permissionKey)).toBe(true);
}
expect(await authorizationService(db).decidePrincipalGrant({
companyId,
principalType: "agent",
principalId: standard.id,
action: "tasks:assign",
permissionKey: "tasks:assign_scope",
scope: { assigneeAgentId: standard.id },
})).toMatchObject({ allowed: true });
expect(await authorizationService(db).decidePrincipalGrant({
companyId,
principalType: "agent",
principalId: standard.id,
action: "tasks:assign",
permissionKey: "tasks:assign_scope",
scope: { assigneeAgentId: peer.id },
})).toMatchObject({ allowed: false, reason: "deny_scope" });
for (const permissionKey of [
"agents:create", "environments:manage", "tools:admin", "users:invite",
"users:manage_permissions", "joins:approve",
] as const) {
expect(await accessService(db).hasPermission(companyId, "agent", standard.id, permissionKey)).toBe(false);
}
const decision = await authorizationService(db).decide({
actor: { type: "agent", agentId: standard.id, companyId, source: "agent_key" },
action: "agent_config:update",
resource: { type: "agent", agentId: peer.id, companyId },
scope: { requiresChangeGrant: true },
});
expect(decision).toMatchObject({ allowed: true, reason: "allow_direct_change" });
// A later ordinary self-update must not recreate a grant removed by an operator.
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, standard.id));
await agentService(db).update(standard.id, { title: "Updated own title" });
expect(await db.select().from(principalPermissionGrants)
.where(eq(principalPermissionGrants.principalId, standard.id))).toEqual([]);
await db.update(agents).set({ status: "terminated", lifecycleState: "terminated" }).where(eq(agents.id, peer.id));
await agentService(db).remove(peer.id);
expect(await db.select().from(principalPermissionGrants)
.where(eq(principalPermissionGrants.principalId, peer.id))).toEqual([]);
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId));
await db.delete(companyMemberships).where(eq(companyMemberships.companyId, companyId));
await db.delete(agents).where(eq(agents.companyId, companyId));
await db.delete(activityLog).where(eq(activityLog.companyId, companyId));
await db.delete(companies).where(eq(companies.id, companyId));
});
it("keeps existing agents and their scoped grants unchanged during ordinary updates", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Existing permission defaults",
issuePrefix: `PE${companyId.slice(0, 6).toUpperCase()}`,
});
const rows = await db.insert(agents).values([
{ companyId, name: "Existing", role: "engineer", permissions: {}, adapterType: "process", adapterConfig: {}, runtimeConfig: {} },
{ companyId, name: "Scoped", role: "engineer", permissions: {}, adapterType: "process", adapterConfig: {}, runtimeConfig: {} },
{ companyId, name: "Restricted", role: "engineer", permissions: { trustPreset: LOW_TRUST_REVIEW_PRESET }, adapterType: "process", adapterConfig: {}, runtimeConfig: {} },
{ companyId, name: "Built in", role: "engineer", metadata: { paperclipBuiltInAgent: { key: "reflection-coach", featureKeys: [] } }, permissions: {}, adapterType: "process", adapterConfig: {}, runtimeConfig: {} },
{ companyId, name: "Pending", role: "engineer", status: "pending_approval", permissions: {}, adapterType: "process", adapterConfig: {}, runtimeConfig: {} },
]).returning();
await db.insert(principalPermissionGrants).values({
companyId,
principalType: "agent",
principalId: rows[1]!.id,
permissionKey: "agents:configure",
scope: { agentIds: [rows[0]!.id] },
});
for (const row of rows) {
if (row.status === "pending_approval") {
await expect(agentService(db).update(row.id, { title: "Updated existing agent" }))
.rejects.toThrow("Pending approval agent configuration cannot be changed before board approval");
} else {
await agentService(db).update(row.id, { title: "Updated existing agent" });
}
}
const grants = await db.select().from(principalPermissionGrants)
.where(eq(principalPermissionGrants.companyId, companyId));
expect(grants.map((grant) => grant.principalId).sort()).toEqual([rows[1]!.id]);
expect(grants.map((grant) => grant.permissionKey)).toEqual(["agents:configure"]);
expect(grants.find((grant) => grant.principalId === rows[1]!.id)?.scope).toEqual({ agentIds: [rows[0]!.id] });
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId));
await db.delete(agents).where(eq(agents.companyId, companyId));
await db.delete(activityLog).where(eq(activityLog.companyId, companyId));
await db.delete(companies).where(eq(companies.id, companyId));
});
it("adds the default grant when a standard pending hire is approved", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Pending permission default",
issuePrefix: `PP${companyId.slice(0, 6).toUpperCase()}`,
});
const pending = await createAgentLifecycle(db).requestHire(companyId, {
name: "Pending hire",
status: "pending_approval",
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
});
expect(await db.select().from(principalPermissionGrants)
.where(eq(principalPermissionGrants.companyId, companyId))).toEqual([]);
const result = await createAgentLifecycle(db).approveHire(pending.id);
expect(result?.activated).toBe(true);
expect((await db.select().from(principalPermissionGrants)
.where(eq(principalPermissionGrants.companyId, companyId)))
.map((grant) => grant.permissionKey).sort()).toEqual(expectedNewAgentGrantKeys);
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId));
await db.delete(agents).where(eq(agents.companyId, companyId));
await db.delete(activityLog).where(eq(activityLog.companyId, companyId));
await db.delete(companies).where(eq(companies.id, companyId));
});
it("keeps configuration access when invitation approval replaces a new agent's grants", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Invited permission default",
issuePrefix: `PI${companyId.slice(0, 6).toUpperCase()}`,
});
const invited = await createAgentLifecycle(db).requestHire(companyId, {
name: "Invited agent",
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
});
await accessService(db).ensureMembership(companyId, "agent", invited.id, "member", "active");
await accessService(db).setPrincipalGrants(
companyId, "agent", invited.id, agentJoinGrantsFromDefaults(null, invited.id), null,
);
const grants = await db.select().from(principalPermissionGrants)
.where(eq(principalPermissionGrants.principalId, invited.id));
expect(grants.map((grant) => grant.permissionKey).sort()).toEqual(expectedNewAgentGrantKeys);
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId));
await db.delete(companyMemberships).where(eq(companyMemberships.companyId, companyId));
await db.delete(agents).where(eq(agents.companyId, companyId));
await db.delete(activityLog).where(eq(activityLog.companyId, companyId));
await db.delete(companies).where(eq(companies.id, companyId));
});
});