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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-27 21:51:31 -05:00
1 parent 7caea4f8d0
commit 3277a9b7b4
2 files changed
+127 -12

No files matched your search

@@ -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 });
+54 -12
View File
@@ -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, "select"> = db) {
return client
.select()
.from(pluginEntities)
.where(
@@ -212,6 +215,7 @@ export function pluginManagedAgentService(
agentId: string,
extraData: Record<string, unknown> = {},
effectiveAdapterType?: string,
client: Pick<Db, "select" | "insert" | "update"> = 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<string, unknown>;
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,