From 3277a9b7b4937c9bd210ee239ef5af8a88406f33 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 27 Sep 2026 21:51:31 -0500 Subject: [PATCH] fix: refresh managed agent ownership after plugin reinstall Hard uninstall removes plugin bindings but preserves the managed agent and its instruction history. Revalidate the registered stable-key declaration and refresh provenance atomically when relinking, so explicit reset can use its canonical revision authority. Co-Authored-By: Paperclip --- .../__tests__/plugin-managed-agents.test.ts | 73 +++++++++++++++++++ server/src/services/plugin-managed-agents.ts | 66 ++++++++++++++--- 2 files changed, 127 insertions(+), 12 deletions(-) diff --git a/server/src/__tests__/plugin-managed-agents.test.ts b/server/src/__tests__/plugin-managed-agents.test.ts index 547080c406e..0e38c2ae1f4 100644 --- a/server/src/__tests__/plugin-managed-agents.test.ts +++ b/server/src/__tests__/plugin-managed-agents.test.ts @@ -27,6 +27,7 @@ import { buildHostServices } from "../services/plugin-host-services.js"; import { agentService } from "../services/agents.js"; import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js"; import { agentInstructionsService } from "../services/agent-instructions.js"; +import { pluginRegistryService } from "../services/plugin-registry.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -509,6 +510,78 @@ describeEmbeddedPostgres("plugin-managed agents", () => { expect(binding?.data).toMatchObject({ agentId }); }); + it("preserves canonical history when relinking after a hard uninstall and reinstall", async () => { + const previousHome = process.env.PAPERCLIP_HOME; + const tempHome = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "plugin-reinstall-reset-"))); + process.env.PAPERCLIP_HOME = tempHome; + try { + const pluginManifest = manifest(); + pluginManifest.agents![0]!.instructions = { content: "# Original stock\n" }; + const { companyId, pluginId, services } = await seedCompanyAndPlugin({ manifest: pluginManifest }); + const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" }); + const target = { companyId, agentId: created.agentId! }; + const revisions = agentInstructionRevisionService(db); + const original = await revisions.readForPluginReset(target, { + type: "plugin", pluginId, pluginKey: pluginManifest.id, agentKey: "wiki-maintainer", + }); + const registry = pluginRegistryService(db); + await registry.uninstall(pluginId, true); + expect(await db.select().from(pluginManagedResources)).toHaveLength(0); + expect(await db.select().from(pluginEntities)).toHaveLength(0); + const nextManifest = structuredClone(pluginManifest); + nextManifest.agents![0]!.instructions = { content: "# Reinstalled stock\n" }; + const installed = await registry.install({ packageName: "@paperclipai/plugin-managed-agents-test" }, nextManifest); + expect(installed!.id).not.toBe(pluginId); + await registry.updateStatus(installed!.id, { status: "ready" }); + const reinstalledServices = buildHostServices(db, installed!.id, nextManifest.id, createEventBusStub(), undefined, { + manifest: nextManifest, + }); + const relinked = await reinstalledServices.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" }); + expect(relinked.status).toBe("relinked"); + expect(relinked.agentId).toBe(created.agentId); + const reset = await reinstalledServices.agents.managedReset({ companyId, agentKey: "wiki-maintainer" }); + expect(reset.agentId).toBe(created.agentId); + expect(reset.agent!.metadata).toMatchObject({ paperclipManagedResource: { pluginId: installed!.id }, pluginManagedAgent: { pluginId: installed!.id } }); + expect(await fs.readFile(reset.agent!.adapterConfig.instructionsFilePath as string, "utf8")).toBe("# Reinstalled stock\n"); + const history = await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, created.agentId!)); + expect(history).toHaveLength(2); + expect(history.find((row) => row.id === original.snapshot!.revision.id)?.contentBase64).toBe(Buffer.from("# Original stock\n").toString("base64")); + expect(history.find((row) => row.id !== original.snapshot!.revision.id)?.parentRevisionId).toBe(original.snapshot!.revision.id); + } finally { + if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome; + await fs.rm(tempHome, { recursive: true, force: true }); + } + }); + + it.each(["not-ready", "capability-removed", "declaration-removed", "other-plugin-owner"])( + "does not relink a managed agent when its authority is %s", async (reason) => { + const { companyId, pluginId, pluginManifest, services } = await seedCompanyAndPlugin(); + const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" }); + await db.delete(pluginEntities); + await db.delete(pluginManagedResources); + if (reason === "not-ready") { + await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, pluginId)); + } else if (reason === "capability-removed") { + await db.update(plugins).set({ manifestJson: { ...pluginManifest, capabilities: [] } }).where(eq(plugins.id, pluginId)); + } else if (reason === "declaration-removed") { + await db.update(plugins).set({ manifestJson: { ...pluginManifest, agents: [] } }).where(eq(plugins.id, pluginId)); + } else { + const otherId = randomUUID(); + await db.insert(plugins).values({ id: otherId, pluginKey: "paperclip.other-owner", packageName: "other", + version: "0.1.0", apiVersion: 1, categories: [], manifestJson: { ...pluginManifest, id: "paperclip.other-owner" }, status: "ready", installOrder: 2 }); + await db.update(agents).set({ metadata: { ...created.agent!.metadata, + paperclipManagedResource: { pluginId: otherId, pluginKey: pluginManifest.id, resourceKind: "agent", resourceKey: "wiki-maintainer" }, + } }).where(eq(agents.id, created.agentId!)); + } + const [before] = await db.select().from(agents).where(eq(agents.id, created.agentId!)); + await expect(services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" })).rejects.toMatchObject({ status: 403 }); + expect(await db.select().from(pluginEntities)).toHaveLength(0); + expect(await db.select().from(pluginManagedResources)).toHaveLength(0); + const [after] = await db.select().from(agents).where(eq(agents.id, created.agentId!)); + expect(after.metadata).toEqual(before.metadata); + }, + ); + it("respects board approval policy for new managed agents", async () => { const { companyId, services } = await seedCompanyAndPlugin({ requireApproval: true }); diff --git a/server/src/services/plugin-managed-agents.ts b/server/src/services/plugin-managed-agents.ts index df04321d740..2013252edd6 100644 --- a/server/src/services/plugin-managed-agents.ts +++ b/server/src/services/plugin-managed-agents.ts @@ -6,6 +6,8 @@ import { companies, pluginEntities, pluginManagedResources, + plugins, + activityLog, } from "@paperclipai/db"; import type { Agent, @@ -13,7 +15,8 @@ import type { PluginManagedAgentDeclaration, PluginManagedAgentResolution, } from "@paperclipai/shared"; -import { conflict, notFound } from "../errors.js"; +import { isUuidLike } from "@paperclipai/shared"; +import { conflict, forbidden, notFound } from "../errors.js"; import { agentService } from "./agents.js"; import { approvalService } from "./approvals.js"; import { logActivity } from "./activity-log.js"; @@ -192,8 +195,8 @@ export function pluginManagedAgentService( return declaration; } - async function getBinding(companyId: string, agentKey: string) { - return db + async function getBinding(companyId: string, agentKey: string, client: Pick = db) { + return client .select() .from(pluginEntities) .where( @@ -212,6 +215,7 @@ export function pluginManagedAgentService( agentId: string, extraData: Record = {}, effectiveAdapterType?: string, + client: Pick = db, ) { const adapterType = effectiveAdapterType ?? (await resolveManagedAdapterType(companyId, declaration)); const defaultsJson = { @@ -229,7 +233,7 @@ export function pluginManagedAgentService( budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0, instructions: declaration.instructions ?? null, }; - const managedResource = await db + const managedResource = await client .select({ id: pluginManagedResources.id }) .from(pluginManagedResources) .where(and( @@ -240,12 +244,12 @@ export function pluginManagedAgentService( )) .then((rows) => rows[0] ?? null); if (managedResource) { - await db + await client .update(pluginManagedResources) .set({ resourceId: agentId, defaultsJson, updatedAt: new Date() }) .where(eq(pluginManagedResources.id, managedResource.id)); } else { - await db.insert(pluginManagedResources).values({ + await client.insert(pluginManagedResources).values({ companyId, pluginId: options.pluginId, pluginKey: options.pluginKey, @@ -267,9 +271,9 @@ export function pluginManagedAgentService( lastReconciledAt: new Date().toISOString(), ...extraData, }; - const existing = await getBinding(companyId, declaration.agentKey); + const existing = await getBinding(companyId, declaration.agentKey, client); if (existing) { - return db + return client .update(pluginEntities) .set({ scopeKind: "company", @@ -283,7 +287,7 @@ export function pluginManagedAgentService( .returning() .then((rows) => rows[0]); } - return db + return client .insert(pluginEntities) .values({ pluginId: options.pluginId, @@ -307,6 +311,46 @@ export function pluginManagedAgentService( return rows.find((row) => rowIsManagedAgent(row, options.pluginKey, declaration.agentKey)) ?? null; } + async function relinkManagedAgent(companyId: string, declaration: PluginManagedAgentDeclaration, agentId: string) { + const adapterType = await resolveManagedAdapterType(companyId, declaration); + return db.transaction(async (tx) => { + const [agent] = await tx.select().from(agents).where(and( + eq(agents.id, agentId), eq(agents.companyId, companyId), ne(agents.status, "terminated"), + )).for("update"); + if (!agent || !rowIsManagedAgent(agent, options.pluginKey, declaration.agentKey)) { + throw conflict("Managed agent ownership changed before relink"); + } + const [plugin] = await tx.select().from(plugins).where(and( + eq(plugins.id, options.pluginId), eq(plugins.pluginKey, options.pluginKey), + )).for("share"); + if (!plugin || plugin.status !== "ready" || !plugin.manifestJson.capabilities.includes("agents.managed") + || !plugin.manifestJson.agents?.some((entry) => entry.agentKey === declaration.agentKey)) { + throw forbidden("Plugin relink is limited to its registered managed agent declaration"); + } + const marker = agent.metadata!.paperclipManagedResource as Record; + const previousPluginId = marker.pluginId; + if (typeof previousPluginId !== "string" || !isUuidLike(previousPluginId)) throw forbidden("Managed agent has no plugin owner"); + if (previousPluginId !== options.pluginId) { + const [previousPlugin] = await tx.select({ id: plugins.id }).from(plugins).where(eq(plugins.id, previousPluginId)); + if (previousPlugin) throw forbidden("Managed agent still belongs to another installed plugin"); + } + // Hard uninstall cascades the bindings, but deliberately leaves the agent + // and its canonical history. Transfer only its proven stable-key marker to + // the replacement installation, atomically with the new scoped bindings. + await upsertBinding(companyId, declaration, agentId, {}, adapterType, tx); + const [relinked] = await tx.update(agents).set({ + metadata: managedMetadata(options.pluginId, options.pluginKey, declaration, agent.metadata), + updatedAt: new Date(), + }).where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))).returning(); + await tx.insert(activityLog).values({ + companyId, actorType: "plugin", actorId: options.pluginId, + action: "plugin.managed_agent.relinked", entityType: "agent", entityId: agentId, + details: { sourcePluginKey: options.pluginKey, managedResourceKey: declaration.agentKey, previousPluginId }, + }); + return relinked as Agent; + }); + } + async function companyAdapterUsage(companyId: string) { const rows = await db .select({ adapterType: agents.adapterType }) @@ -599,9 +643,7 @@ export function pluginManagedAgentService( const relinkCandidate = await findRelinkCandidate(companyId, declaration); if (relinkCandidate) { - await upsertBinding(companyId, declaration, relinkCandidate.id); - const relinkedAgent = await agentSvc.getById(relinkCandidate.id) as Agent | null; - if (!relinkedAgent) throw notFound("Managed agent not found"); + const relinkedAgent = await relinkManagedAgent(companyId, declaration, relinkCandidate.id); const agent = await backfillManagedPauseReason( companyId, declaration,