mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Paperclip keeps agent lifecycle changes behind control-plane authorization > - Plugins can create agents in a paused state until an operator activates them > - An agent with a direct configuration grant could not resume these agents > - A paused plugin-managed agent also had no stable provenance in its pause reason > - This pull request adds one protected resume path and preserves every other lifecycle gate > - The benefit is safe recovery from plugin provisioning without a broad permission change ## Linked Issues or Issue Description Refs #8168. That pull request uses a role capability and also opens clear-error. This change uses the current grant system and keeps clear-error closed. **What happened?** A plugin can create a paused managed agent. An agent actor cannot resume that agent, even when the actor has a direct `agents:configure` grant. The paused agent can also have a null pause reason. **Expected behavior** An agent with a direct `agents:configure` grant can resume an accessible paused agent. An agent without that grant cannot resume it. Plugin-managed paused agents show stable plugin provenance. A completed resume stays in effect after reconcile. **Steps to reproduce** 1. Install a plugin that declares a managed agent with `status: paused`. 2. Give a same-company agent a direct `agents:configure` grant. 3. Call `POST /api/agents/{id}/resume` with the granted agent key. 4. On the base revision, observe a board-only authorization error. **Paperclip version or commit** `master` at `63df7ad2b3`. **Deployment mode** All deployment modes. This is a server authorization and reconcile behavior. ## What Changed - The resume route now uses the protected `agent_config:update` decision with `requiresChangeGrant: true` for agent actors. - The route keeps board access, tenant non-disclosure, and invalid organization-chain protection. - Resume activity now records the real user or agent actor, run, and API key. - Plugin-managed paused agents now receive a stable provenance reason and pause time at creation. - Reconcile backfills only a null reason on an agent that is still declared and stored as paused. - Reconcile preserves manual, budget, system, and other pause reasons. It does not pause a resumed agent again. - The implementation specification now records the narrow resume exception. ## Verification - `pnpm exec vitest run server/src/__tests__/agent-cross-tenant-authz-routes.test.ts server/src/__tests__/plugin-managed-agents.test.ts` passed: 2 files and 26 tests. - `pnpm --filter @paperclipai/server typecheck` passed. - `pnpm -r typecheck` passed. - `pnpm build` passed. - GitHub CI passed all policy, typecheck, build, test, e2e, canary, and security gates on commit `306edf469c`. - Greptile reviewed all 5 changed files. Its check passed with 0 comments and 0 unresolved threads. - The host uses Node 22.22.2. The repository requests Node 24.11 or newer, so pnpm printed engine warnings. - A broad `pnpm test:run` attempt did not complete its general-server group. Runtime port fixtures failed because host port `52000` was already bound. The isolated failing fixture reproduced the same port conflict. The focused feature tests passed before and after the final commit. ## Risks The main risk is an unintended lifecycle permission increase. The change limits agent access to resume only. It requires a protected direct-change decision. It does not open pause, clear-error, terminate, approval, or key-management routes. Tests cover denial, self-denial, tenant isolation, organization-chain checks, and activity attribution. There is no database migration. > This change fixes a narrow gap in the completed plugin, approval, and activity-log roadmap areas. It does not add a new roadmap feature. ## Model Used OpenAI Codex `gpt-5.6-sol`, with xhigh reasoning, tool use, and code execution. The runtime did not expose its context-window size. ## 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>
434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { promises as fs } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { eq } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
activityLog,
|
|
agentConfigRevisions,
|
|
agents,
|
|
approvals,
|
|
companies,
|
|
createDb,
|
|
pluginEntities,
|
|
pluginCompanySettings,
|
|
pluginManagedResources,
|
|
plugins,
|
|
} from "@paperclipai/db";
|
|
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import { buildHostServices } from "../services/plugin-host-services.js";
|
|
import { agentService } from "../services/agents.js";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
function createEventBusStub() {
|
|
return {
|
|
forPlugin() {
|
|
return {
|
|
emit: async () => {},
|
|
subscribe: () => {},
|
|
};
|
|
},
|
|
} as any;
|
|
}
|
|
|
|
function issuePrefix(id: string) {
|
|
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
}
|
|
|
|
function manifest(): PaperclipPluginManifestV1 {
|
|
return {
|
|
id: "paperclip.managed-agents-test",
|
|
apiVersion: 1,
|
|
version: "0.1.0",
|
|
displayName: "Managed Agents Test",
|
|
description: "Test plugin",
|
|
author: "Paperclip",
|
|
categories: ["automation"],
|
|
capabilities: ["agents.managed"],
|
|
entrypoints: { worker: "./dist/worker.js" },
|
|
agents: [
|
|
{
|
|
agentKey: "wiki-maintainer",
|
|
displayName: "Wiki Maintainer",
|
|
role: "engineer",
|
|
title: "Maintains plugin-owned knowledge",
|
|
capabilities: "Maintains a plugin-owned wiki.",
|
|
adapterType: "process",
|
|
adapterConfig: { command: "pnpm wiki:maintain" },
|
|
runtimeConfig: { modelProfiles: { cheap: { enabled: true, adapterConfig: { model: "small" } } } },
|
|
permissions: { canCreateAgents: false },
|
|
budgetMonthlyCents: 1234,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function pausedManifest(): PaperclipPluginManifestV1 {
|
|
const pluginManifest = manifest();
|
|
pluginManifest.agents![0] = {
|
|
...pluginManifest.agents![0]!,
|
|
status: "paused",
|
|
};
|
|
return pluginManifest;
|
|
}
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping embedded Postgres plugin-managed agent tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
describeEmbeddedPostgres("plugin-managed agents", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-agents-");
|
|
db = createDb(tempDb.connectionString);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(agentConfigRevisions);
|
|
await db.delete(activityLog);
|
|
await db.delete(pluginEntities);
|
|
await db.delete(pluginManagedResources);
|
|
await db.delete(pluginCompanySettings);
|
|
await db.delete(approvals);
|
|
await db.delete(agents);
|
|
await db.delete(plugins);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function seedCompanyAndPlugin(options: { requireApproval?: boolean; manifest?: PaperclipPluginManifestV1 } = {}) {
|
|
const companyId = randomUUID();
|
|
const pluginId = randomUUID();
|
|
const pluginManifest = options.manifest ?? manifest();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: issuePrefix(companyId),
|
|
requireBoardApprovalForNewAgents: options.requireApproval ?? false,
|
|
});
|
|
await db.insert(plugins).values({
|
|
id: pluginId,
|
|
pluginKey: pluginManifest.id,
|
|
packageName: "@paperclipai/plugin-managed-agents-test",
|
|
version: pluginManifest.version,
|
|
apiVersion: pluginManifest.apiVersion,
|
|
categories: pluginManifest.categories,
|
|
manifestJson: pluginManifest,
|
|
status: "ready",
|
|
installOrder: 1,
|
|
});
|
|
const services = buildHostServices(db, pluginId, pluginManifest.id, createEventBusStub(), undefined, {
|
|
manifest: pluginManifest,
|
|
});
|
|
return { companyId, pluginId, pluginManifest, services };
|
|
}
|
|
|
|
it("creates and resolves managed agents by stable resource key", async () => {
|
|
const { companyId, services } = await seedCompanyAndPlugin();
|
|
|
|
const created = await services.agents.managedReconcile({
|
|
companyId,
|
|
agentKey: "wiki-maintainer",
|
|
});
|
|
|
|
expect(created.status).toBe("created");
|
|
expect(created.agentId).toBeTruthy();
|
|
expect(created.agent).toMatchObject({
|
|
name: "Wiki Maintainer",
|
|
role: "engineer",
|
|
adapterConfig: { command: "pnpm wiki:maintain" },
|
|
});
|
|
|
|
const resolved = await services.agents.managedGet({
|
|
companyId,
|
|
agentKey: "wiki-maintainer",
|
|
});
|
|
expect(resolved.status).toBe("resolved");
|
|
expect(resolved.agentId).toBe(created.agentId);
|
|
|
|
const [binding] = await db.select().from(pluginEntities);
|
|
expect(binding?.entityType).toBe("managed_agent");
|
|
expect(binding?.scopeKind).toBe("company");
|
|
expect(binding?.scopeId).toBe(companyId);
|
|
expect(binding?.data).toMatchObject({
|
|
resourceKind: "agent",
|
|
resourceKey: "wiki-maintainer",
|
|
agentId: created.agentId,
|
|
});
|
|
});
|
|
|
|
it("preserves user edits during reconcile and resets only on explicit reset", async () => {
|
|
const { companyId, services } = await seedCompanyAndPlugin();
|
|
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
|
expect(created.agentId).toBeTruthy();
|
|
|
|
await db
|
|
.update(agents)
|
|
.set({
|
|
name: "Knowledge Lead",
|
|
adapterConfig: { command: "custom" },
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(agents.id, created.agentId!));
|
|
|
|
const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
|
expect(reconciled.status).toBe("resolved");
|
|
expect(reconciled.agent).toMatchObject({
|
|
name: "Knowledge Lead",
|
|
adapterConfig: { command: "custom" },
|
|
});
|
|
|
|
const reset = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
|
|
expect(reset.status).toBe("reset");
|
|
expect(reset.agent).toMatchObject({
|
|
name: "Wiki Maintainer",
|
|
adapterConfig: { command: "pnpm wiki:maintain" },
|
|
});
|
|
});
|
|
|
|
it("records plugin provenance when a manifest creates a paused managed agent", async () => {
|
|
const { companyId, services } = await seedCompanyAndPlugin({ manifest: pausedManifest() });
|
|
|
|
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
|
|
|
expect(created.agent).toMatchObject({
|
|
status: "paused",
|
|
pauseReason: "Provisioned paused by plugin paperclip.managed-agents-test; requires explicit activation.",
|
|
});
|
|
expect(created.agent?.pausedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("backfills a legacy null pause reason while the managed declaration and agent remain paused", async () => {
|
|
const { companyId, services } = await seedCompanyAndPlugin({ manifest: pausedManifest() });
|
|
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
|
await db
|
|
.update(agents)
|
|
.set({ pauseReason: null, updatedAt: new Date() })
|
|
.where(eq(agents.id, created.agentId!));
|
|
|
|
const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
|
|
|
expect(reconciled.agent).toMatchObject({
|
|
status: "paused",
|
|
pauseReason: "Provisioned paused by plugin paperclip.managed-agents-test; requires explicit activation.",
|
|
});
|
|
});
|
|
|
|
it.each(["manual", "budget", "system", "maintenance"])(
|
|
"preserves the existing %s pause reason during reconcile",
|
|
async (pauseReason) => {
|
|
const { companyId, services } = await seedCompanyAndPlugin({ manifest: pausedManifest() });
|
|
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
|
await db
|
|
.update(agents)
|
|
.set({ pauseReason, updatedAt: new Date() })
|
|
.where(eq(agents.id, created.agentId!));
|
|
|
|
const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
|
|
|
expect(reconciled.agent).toMatchObject({ status: "paused", pauseReason });
|
|
},
|
|
);
|
|
|
|
it("keeps an explicit resume durable across managed-agent reconcile", async () => {
|
|
const { companyId, services } = await seedCompanyAndPlugin({ manifest: pausedManifest() });
|
|
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
|
await agentService(db).resume(created.agentId!);
|
|
|
|
const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
|
|
|
expect(reconciled.agent).toMatchObject({
|
|
status: "idle",
|
|
pauseReason: null,
|
|
pausedAt: null,
|
|
});
|
|
});
|
|
|
|
it("creates managed agents with the most-used compatible company adapter", async () => {
|
|
const pluginManifest = manifest();
|
|
pluginManifest.agents![0] = {
|
|
...pluginManifest.agents![0]!,
|
|
adapterType: "claude_local",
|
|
adapterPreference: ["claude_local", "codex_local"],
|
|
adapterConfig: {},
|
|
};
|
|
const { companyId, services } = await seedCompanyAndPlugin({ manifest: pluginManifest });
|
|
await db.insert(agents).values([
|
|
{
|
|
id: randomUUID(),
|
|
companyId,
|
|
name: "Codex One",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
{
|
|
id: randomUUID(),
|
|
companyId,
|
|
name: "Codex Two",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
{
|
|
id: randomUUID(),
|
|
companyId,
|
|
name: "Claude One",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "claude_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
]);
|
|
|
|
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
|
|
|
expect(created.status).toBe("created");
|
|
expect(created.agent?.adapterType).toBe("codex_local");
|
|
});
|
|
|
|
it("materializes declared managed agent instructions with local folder paths", async () => {
|
|
const previousHome = process.env.PAPERCLIP_HOME;
|
|
const previousInstance = process.env.PAPERCLIP_INSTANCE_ID;
|
|
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-agent-home-"));
|
|
const wikiRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-agent-wiki-")));
|
|
process.env.PAPERCLIP_HOME = tempHome;
|
|
process.env.PAPERCLIP_INSTANCE_ID = "test";
|
|
try {
|
|
const pluginManifest = manifest();
|
|
pluginManifest.localFolders = [
|
|
{
|
|
folderKey: "wiki-root",
|
|
displayName: "Wiki root",
|
|
access: "readWrite",
|
|
requiredDirectories: [],
|
|
requiredFiles: ["AGENTS.md"],
|
|
},
|
|
];
|
|
pluginManifest.agents![0] = {
|
|
...pluginManifest.agents![0]!,
|
|
adapterType: "claude_local",
|
|
adapterConfig: {},
|
|
instructions: {
|
|
entryFile: "AGENTS.md",
|
|
content: [
|
|
"# LLM Wiki Maintainer",
|
|
"",
|
|
"You are the LLM Wiki Maintainer.",
|
|
"Wiki root: `{{localFolders.wiki-root.path}}`",
|
|
"Wiki schema: `{{localFolders.wiki-root.agentsPath}}`",
|
|
"",
|
|
].join("\n"),
|
|
},
|
|
};
|
|
const { companyId, pluginId, services } = await seedCompanyAndPlugin({ manifest: pluginManifest });
|
|
await fs.writeFile(path.join(wikiRoot, "AGENTS.md"), "# Wiki schema\n", "utf8");
|
|
await db.insert(pluginCompanySettings).values({
|
|
companyId,
|
|
pluginId,
|
|
enabled: true,
|
|
settingsJson: {
|
|
localFolders: {
|
|
"wiki-root": {
|
|
path: wikiRoot,
|
|
access: "readWrite",
|
|
requiredDirectories: [],
|
|
requiredFiles: ["AGENTS.md"],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
|
|
|
const instructionsFilePath = created.agent?.adapterConfig.instructionsFilePath;
|
|
expect(typeof instructionsFilePath).toBe("string");
|
|
const content = await fs.readFile(instructionsFilePath as string, "utf8");
|
|
expect(content).toContain("You are the LLM Wiki Maintainer.");
|
|
expect(content).toContain(`Wiki root: \`${wikiRoot}\``);
|
|
expect(content).toContain(`Wiki schema: \`${path.join(wikiRoot, "AGENTS.md")}\``);
|
|
} finally {
|
|
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
|
|
else process.env.PAPERCLIP_HOME = previousHome;
|
|
if (previousInstance === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
|
|
else process.env.PAPERCLIP_INSTANCE_ID = previousInstance;
|
|
await fs.rm(tempHome, { recursive: true, force: true });
|
|
await fs.rm(wikiRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("repairs a missing binding by relinking a same-company managed agent marker", async () => {
|
|
const { companyId, pluginId, pluginManifest, services } = await seedCompanyAndPlugin();
|
|
const agentId = randomUUID();
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Renamed Wiki Agent",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: "process",
|
|
adapterConfig: { command: "custom" },
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
metadata: {
|
|
paperclipManagedResource: {
|
|
pluginId,
|
|
pluginKey: pluginManifest.id,
|
|
resourceKind: "agent",
|
|
resourceKey: "wiki-maintainer",
|
|
},
|
|
},
|
|
});
|
|
|
|
const relinked = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
|
expect(relinked.status).toBe("relinked");
|
|
expect(relinked.agentId).toBe(agentId);
|
|
|
|
const [binding] = await db.select().from(pluginEntities);
|
|
expect(binding?.data).toMatchObject({ agentId });
|
|
});
|
|
|
|
it("respects board approval policy for new managed agents", async () => {
|
|
const { companyId, services } = await seedCompanyAndPlugin({ requireApproval: true });
|
|
|
|
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
|
|
|
expect(created.status).toBe("created");
|
|
expect(created.agent?.status).toBe("pending_approval");
|
|
expect(created.approvalId).toBeTruthy();
|
|
|
|
const [approval] = await db.select().from(approvals).where(eq(approvals.id, created.approvalId!));
|
|
expect(approval).toMatchObject({
|
|
type: "hire_agent",
|
|
status: "pending",
|
|
});
|
|
expect(approval?.payload).toMatchObject({
|
|
agentId: created.agentId,
|
|
sourcePluginKey: "paperclip.managed-agents-test",
|
|
managedResourceKey: "wiki-maintainer",
|
|
});
|
|
});
|
|
});
|