Files
PaperClipAI/server/src/__tests__/plugin-managed-agents.test.ts
T
DottaandPaperclip a1ab55a56d fix(agents): grant configuration access by default (#15283)
## Thinking Path

> - Paperclip manages agent work and permissions for a company.
> - Agent setup can require one agent to configure another agent.
> - New standard agents could create agents, but they had no direct
configuration grant.
> - Existing agents must keep their current permissions after an
upgrade.
> - The requested new-agent defaults include 13 more direct permissions
for suggestions, skills, tools, audit, inbox, and task assignment.
> - This pull request adds the 14-grant set at creation and approval
activation, retains it through invitations, and keeps existing agents
unchanged.
> - The change keeps low trust and built-in agents on their narrower
permissions.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The agent creation and approval flows, and the agent configuration
authorization path.

**Current behavior**

A new standard agent can create an agent. It cannot make protected
changes to a peer agent unless an operator adds an `agents:configure`
grant.

**Proposed behavior**

Add direct grants for `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`, and `tasks:manage_active_checkouts` to new
standard agents. Scope `tasks:assign_scope` to the agent’s reporting
subtree. Keep existing agents and their grants unchanged.

**Reason and benefit**

New standard agents can complete agent setup and the requested tool,
skill, inbox, and task workflows under existing route, scope, and
approval checks.

**Breaking changes**

Existing agents keep their current permissions. There is no permission
migration.

Related PR: #12212 adds scoped grant routes. This PR changes the default
grant.

## What Changed

- Add the 14 requested direct grants in agent creation and approval
activation. Keep them when invitation approval replaces grants,
preserving explicit scopes. Remove them when an agent is deleted.
- Apply defaults only to new agents. Remove the existing-agent
permission migration, its snapshot, and its journal entry. Preserve
existing scoped grants.
- Add permission, scope, invitation, and existing-agent regression
tests. Document all default and excluded permissions.
- Prevent agent keys from creating, changing, or restoring host-executed
process or local adapter command settings, and from restoring workspace
commands through rollback.

## Verification

- Run `node_modules/.bin/vitest run
server/src/__tests__/agent-default-configure-grants.test.ts
server/src/__tests__/invite-join-grants.test.ts
packages/db/src/migration-snapshot-drift.test.ts`. All 15 tests pass.
These cover new-agent defaults, unchanged existing grants, pending
approval, invitation grants, and migration history.
- Run `node_modules/.bin/tsc -p server/tsconfig.json --noEmit`. It
passes.
- Run `packages/db/node_modules/.bin/tsx
packages/db/src/check-migration-numbering.ts` and
`packages/db/node_modules/.bin/tsx
packages/db/src/check-migration-safety.ts`. Both pass.
- Confirm that this PR has no files under `packages/db/src/migrations/`
in its final diff.
- GitHub CI at `e8173b2c41` passes build, typecheck, server suites,
browser shards, and canary verification. One unchanged OpenCode
transport test reached its five-second timeout in the first Runner shard
run. That test passes locally in 2.55 seconds. The shard passed on one
retry. All 54 checks pass, with two expected skips.
- Greptile gives this exact head 5/5. Security review passes. The PR has
no unresolved review threads or merge conflicts.

## Risks

- The 14 default permissions apply only to new standard agents. Existing
permissions stay unchanged. Low trust and managed built-in agents are
excluded.
- New grants include connection, runtime, and active-checkout
management. Existing company, responsible-user, scope, and approval
checks remain in force. The default inbox grant carries a
responsible-user-only scope so it cannot override another user's inbox
settings.
- Protected changes still need the responsible user's authority when
that check applies. Company boundaries and approval gates still apply.
- Agent-authenticated requests cannot configure process adapters or host
command settings on local adapters. Known provider credential references
remain allowed, as do narrow plain authentication overrides on new peers
when the caller uses an AI connection pool. Arbitrary environment
settings remain blocked. Board operators retain the host configuration
paths.

> I checked `ROADMAP.md`. This is a narrow fix to existing agent
configuration behavior.

## Model Used

- OpenAI Codex, GPT-6 series. This runtime did not expose its exact
hosted model ID or context window. This revision uses OpenAI GPT-6
through Codex with tool use, code execution, and tests. The runtime does
not expose the exact hosted model ID or context window.

## 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-06 05:28:48 -05:00

598 lines
27 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,
agentInstructionRevisions,
agentInstructionHeads,
agents,
approvals,
companies,
createDb,
pluginEntities,
pluginCompanySettings,
pluginManagedResources,
plugins,
principalPermissionGrants,
} 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";
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;
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: { heartbeat: { enabled: false } },
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(principalPermissionGrants);
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.realpath(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")}\``);
// A managed plugin reset must preserve the previous entry in canonical
// history instead of deleting a shared bundle or bypassing its write guard.
const edited = "# User-customized wiki instructions\n";
await fs.writeFile(instructionsFilePath as string, edited);
const reset = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
expect(reset.status).toBe("reset");
expect(await fs.readFile(instructionsFilePath as string, "utf8")).toBe(content);
const history = await db.select().from(agentInstructionRevisions)
.where(eq(agentInstructionRevisions.agentId, created.agentId!));
expect(history).toHaveLength(0);
expect(await db.select().from(agentInstructionHeads)
.where(eq(agentInstructionHeads.agentId, created.agentId!))).toHaveLength(0);
const repeated = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
expect(repeated.status).toBe("reset");
expect(await db.select().from(agentInstructionRevisions)
.where(eq(agentInstructionRevisions.agentId, created.agentId!))).toHaveLength(0);
const audit = await db.select().from(activityLog).where(eq(activityLog.action, "agent.files_updated"));
expect(audit).toHaveLength(1);
expect(audit.every((row) => row.actorType === "plugin" && row.actorId === pluginId)).toBe(true);
const bundles = agentInstructionsService(db);
await bundles.writeFile(repeated.agent!, "CUSTOM.md", "# Alternate configured entry\n");
const switched = await bundles.updateBundle(repeated.agent!, { entryFile: "CUSTOM.md" });
await agentService(db).update(created.agentId!, { adapterConfig: switched.adapterConfig });
const resetEntry = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
expect(resetEntry.agent?.adapterConfig.instructionsEntryFile).toBe("AGENTS.md");
expect(resetEntry.defaultDrift).toBeNull();
expect(await fs.readFile(instructionsFilePath as string, "utf8")).toBe(content);
const preserved = await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, created.agentId!));
expect(preserved).toHaveLength(0);
expect(await fs.readFile(path.join(path.dirname(instructionsFilePath as string), "CUSTOM.md"), "utf8")).toBe("# Alternate configured entry\n");
} 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("fences plugin instruction reset by exact ownership, current capability and canonical CAS", async () => {
const previousHome = process.env.PAPERCLIP_HOME;
const tempHome = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "plugin-reset-cas-")));
process.env.PAPERCLIP_HOME = tempHome;
try {
const pluginManifest = manifest();
pluginManifest.agents![0]!.instructions = { content: "# Default\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 actor = { type: "plugin" as const, pluginId, pluginKey: pluginManifest.id, agentKey: "wiki-maintainer" };
const revisions = agentInstructionRevisionService(db);
const baselineRead = await revisions.readForPluginReset(target, actor);
const baseline = baselineRead.snapshot!;
const input = { ...target, entryFile: "AGENTS.md", baseRevisionId: baseline.revision.id,
configuredEntryFile: baselineRead.configuredEntryFile, content: "# Reset\n" };
await expect(revisions.commitPluginReset(input, { ...actor, pluginId: randomUUID() })).rejects.toMatchObject({ status: 403 });
await expect(revisions.commitPluginReset({ ...input, companyId: randomUUID() }, actor)).rejects.toMatchObject({ status: 404 });
await expect(revisions.commitPluginReset(input, { ...actor, agentKey: "foreign-agent" })).rejects.toMatchObject({ status: 403 });
await db.update(plugins).set({ manifestJson: { ...pluginManifest, capabilities: [] } }).where(eq(plugins.id, pluginId));
await expect(revisions.commitPluginReset(input, actor)).rejects.toMatchObject({ status: 403 });
await db.update(plugins).set({ manifestJson: pluginManifest }).where(eq(plugins.id, pluginId));
const originalMetadata = created.agent!.metadata;
await db.update(agents).set({ metadata: {} }).where(eq(agents.id, target.agentId));
await expect(revisions.commitPluginReset(input, actor)).rejects.toMatchObject({ status: 403 });
await db.update(agents).set({ metadata: originalMetadata }).where(eq(agents.id, target.agentId));
const [binding] = await db.select().from(pluginManagedResources).where(eq(pluginManagedResources.resourceId, target.agentId));
await db.update(pluginManagedResources).set({ resourceId: randomUUID() }).where(eq(pluginManagedResources.id, binding.id));
await expect(revisions.commitPluginReset(input, actor)).rejects.toMatchObject({ status: 403 });
await db.update(pluginManagedResources).set({ resourceId: target.agentId }).where(eq(pluginManagedResources.id, binding.id));
await expect(revisions.commitPluginReset({ ...input, entryFile: "OTHER.md" }, actor)).rejects.toMatchObject({ status: 403 });
const bundles = agentInstructionsService(db);
await bundles.writeFile(created.agent!, "OTHER.md", "# Other entry\n");
const switched = await bundles.updateBundle(created.agent!, { entryFile: "OTHER.md" });
await agentService(db).update(target.agentId, { adapterConfig: switched.adapterConfig });
await expect(revisions.commitPluginReset(input, actor)).rejects.toMatchObject({ status: 409, details: { code: "INSTRUCTION_ENTRY_CHANGED" } });
await agentService(db).update(target.agentId, { adapterConfig: created.agent!.adapterConfig });
const results = await Promise.allSettled([
revisions.commitPluginReset(input, actor),
revisions.commitPluginReset({ ...input, content: "# Concurrent reset\n" }, actor),
]);
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
const loser = results.find((result) => result.status === "rejected") as PromiseRejectedResult;
expect(loser.reason).toMatchObject({ status: 409, details: { code: "INSTRUCTION_REVISION_CONFLICT" } });
const winner = results.find((result) => result.status === "fulfilled") as PromiseFulfilledResult<Awaited<ReturnType<typeof revisions.commitPluginReset>>>;
expect(await fs.readFile(created.agent!.adapterConfig.instructionsFilePath as string, "utf8")).toBe(winner.value.content);
const history = await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, target.agentId));
expect(history).toHaveLength(0);
} finally {
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
await fs.rm(tempHome, { 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("preserves current-file authority 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(0);
} 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 });
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",
});
});
});