mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
## Thinking Path > - Paperclip is the open source control plane for agent companies. > - People choose a harness when creating an agent. > - The server should resolve that harness to a qualified runner. > - Codex is the first harness in the staged default rollout. > - Saved agents must retain their execution choices. > - New Codex agents therefore use Paperclip Runner on qualified macOS/Linux targets, with an explicit Legacy override. ## Linked Issues or Issue Description Refs #16028, #15555, #15673, #15422, #15631, #15926, #15932, #16149. The runtime/install prerequisite and release repair are merged. This slice owns Codex creation, setup, configuration and preservation. Other harness defaults remain unchanged. It adds no database migration; incoming master migrations, required maps and regression coverage are retained. ## What Changed - Resolve `runner: auto | paperclip | legacy` at the common creation boundary across onboarding, API/CLI, hires, built-ins, plugins and imports. Freeze pending hires and export resolved execution choices. - Preserve existing runners on upgrades, unrelated edits and import updates, including compatible accounts, workspace settings, Codex effort and intentional clears. - Use the underlying harness picker and branding; put the runner override in Advanced. Use the shared dropdown and update production-component stories and creation contracts. - Support qualified Apple Silicon, Intel Mac, Linux x64 and Linux ARM64. Linux requires GNU libc 2.39+. Disabled, external, unsupported and unqualified combinations retain their applicable Legacy execution. - Test the selected runtime/account/model/environment. Ordinary Connect probes eligible immutable managed targets and refreshes scoped discovery. Creation/discovery do not provision. Auth, dependency, incompatible-setting and cleanup failures remain actionable without changing runners. - Keep setup probes read-only, preserve local/SSH isolation and resolve packaged assets before staging credentials. Bound cleanup reads by the remaining absolute deadline; physically close every owned ingress socket before endpoint release. Unconfirmed cleanup retains recovery state. - Preserve validated completed-onboarding callbacks through startup recovery. First file-producing work belongs to the child task and uses the existing dependency/block handoff and task-scoped registration. - Reuse existing E2E fixtures, artifact checks, reporting and controls. Keep Docker/Cargo/native compilation remote. Paperclip tarballs contain no Codex payloads; official optional npm platform dependencies remain. ## Verification Current tested head: `1c03a8815672d82c264a9b08d4ea1984e8fae107`. **Merge-ready: required CI, fresh review, package qualification, Mac core acceptance and Test task/follow-up acceptance pass.** The existing attachment and native-continuity oracles independently verify both Test downloads, run attribution and unchanged native/provider/workspace identities. The maintainer authorized a code-owner-review exception for this PR only. The first Test setup failure and normal Board completion-review friction are retained below. Actual post-merge packages and private Cloud distribution remain separate qualification gates. - [Current CI](https://github.com/paperclipai/paperclip/actions/runs/38564241625): all 48 ordinary jobs and required verify/E2E pass. Attempt 1 retained six simultaneous host-shutdown cancellations. One failed-only retry passes those six lanes and two aggregates, preserving 40 successes. Actual integration checkout `53104c1` combines this head with master `c80`; it is not relabeled as the branch tree. Fresh exact-head Greptile 5/5, 30 resolved threads, no actionable findings. - [Exact-source package qualification](https://github.com/paperclipai/paperclip/actions/runs/38565287239): nine applicable jobs pass, nine conditional jobs skip. Four remote native producers; Linux x64 clean install; Intel Mac, Apple Silicon and Linux ARM64 native startup/physical cleanup controls. All 18 tarballs/four daemon source/hash/manifest bindings and 343 migrations through 0344 verify. No Codex payload is packed. These hosted tarballs are not a published npm canary. - Normal Apple Silicon installation and credential-free native startup/cleanup pass using the packaged daemon, with no source build or provider turn. Fresh installed DB initialization passes. Upgrade 651a→1c03 preserves all three complete execution configs, accounts, ten recorded run profiles, paused budgets, active incidents, cost summaries and two historical unpriced events. Historical recovery state is retained. - Cleanup regressions: four delayed-read baseline failures become 15 passing deadline/authority controls. No-acknowledgement and prior-error socket ownership regressions become 12 passing controls. Direct runtime/server types, maps: 35 areas / 176 IDs, diff checks and independent reviews pass. Permission/auth/reconnect policy and the 10-second setup budget remain intact. - Existing source coverage includes creation/selection/imports, pending approvals, recovery, account/lifecycle handling, mounted forms, dropdowns and E2E support. Full typechecks/tests/build/token and applicable Storybook checks pass in the recorded current CI; no duplicate suite or weakened grader. - Literal 9546 live Mac proof: ordinary Auto Codex onboarding, useful document, real completed-child callback/reply, seven successful native runs, manual follow-up and independently downloaded1,101-byte child file. Existing Legacy title edit/reload preserves execution; two real Legacy runs produce independently checked15/34-byte files. These remain historical source records. - Current literal 1c03 Mac core acceptance passes independent checks: ordinary Auto native Codex onboarding without runner override; only timeout 180 seconds/concurrency 1 saved; six successful native runs and six serialized turns (maximum overlap 1); both same-task continuity checks and five mechanical callback checks; actual rendered parent reply and exact child downloads. The useful handoff advances from revision 1 to 2. Both files are 48 bytes, SHA-256 `8b742bd4e8123a7c078f0a8d9f3449fbfa8a320d7d90be641a2818db3c53d92c` and `f42db9774fa06e804ab7d596fc8b922a2b638921c418b87d0111151d290a6afe`; exactly one byte changes and old history remains. The agent is paused with zero active runs/errors. Both artifact work products have `isPrimary=false`, and descriptive titles fail a stricter title-helper assumption; these retained results are scoped editorial follow-up, not new merge gates. Six source-bound screenshots are uploaded and verified in the private primary QA report. - [Literal 9546 managed proof](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-38485488621-1/index.html): original 19 assertions and completed judge pass, four native turns, child-bound240-byte file and session/workspace continuity. Checkout reports a verified generated target-lock mutation; full final porcelain was not retained. Signed execution-image identity remains separate from Core source. - Actual 651a Test deployment succeeds on one owned/pinned target. One ordinary Auto API-key Connect then fails native cleanup before hire. Controller turn/drain/suspend completion and service-recorded environment cleanup are observed; they do not certify physical deletion or native authority retirement. Failure/recovery/unknown holds are retained. No Legacy switch or unchanged paid retry. The two source repairs above require actual treatment. - [Current immutable Core preview](https://github.com/paperclipai/paperclip/actions/runs/38570365725) and matching DB/shared artifacts pass. One owned, pinned Test target serves exact `1c03`; identical migration history correctly skips the migrator. Ordinary Auto Codex Connect succeeds and hires native; saved native selection and QA bounds survive reload. The initial child preparation attempt fails before provider work, with actual stdout identifying `renewal_deadline`; its trigger remains unknown and the failure is retained. Normal Board **Try again** and **Resume** recover the child to Done, producing a browser-verified 44-byte file, SHA-256 `0a8458b1efcc2d7a0d815689458c4caab039a46c7c7a3e5805de6bc8b1a96ecc`. The actual parent callback reports the completed child document/file. The existing validator requires a parent-owned attachment, so recovery enters normal Review; after byte checking, normal **Approve completion** reaches parent Done. No forced status or duplicate parent file is used. The validator is unchanged from integrated and current master. Autonomous initial callback-to-Done is not proven. The same-child follow-up completes document revision 2 and publishes a second independently downloaded 44-byte file, SHA-256 `22022257300fae762784632d08d3bbe0bab61e2ce81f91864a14dcd7aa464591`; only the revision digit changes. The unchanged existing native-continuity and attachment oracles pass. All ten recorded runs are terminal: nine native successes and the retained pre-provider setup failure. The QA agent is paused with zero active runs. The pre-existing parent evidence scope mismatch is a separate follow-up, with ordinary completion review exercised rather than a forced status or relaxed validation. This unsigned Core preview remains distinct from post-merge private Cloud composition. ## Risks - Saved agents are not migrated. Explicit runner changes use normal configuration/session rules; historical runs retain their recorded execution. - Native readiness depends on verified assets and server-owned target qualification. A Legacy probe, HTTP200 or successful account save cannot prove native readiness. - Recovery exceptions remain restricted to validated completed-onboarding reports; process-loss, ownership and terminal-state safeguards remain active. Unexecuted live journeys stay open. - Linux ARM paid tasks, Windows, Alpine and 32-bit ARM remain deferred. Cold Cargo cache misses are recorded; no warm-cache speedup is claimed. - Historical invoices/physical cleanup remain unconfirmed and held. After merge, exact published npm canary, signed standard image/migrator, private composition and serving/live acceptance still require qualification. Production deployment/default promotion is not authorized. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, tools, code execution and parallel agents. Exact serving snapshot and context-window size are not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used with available 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 linked public issues or PRs above - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] I have run the stated focused tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation - [x] I have considered and documented risks - [x] All Paperclip CI gates are green on the current candidate - [x] Greptile is 5/5 with no open findings on the current candidate - [x] I will address all reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
625 lines
29 KiB
TypeScript
625 lines
29 KiB
TypeScript
import { createAgentLifecycle, configureAgentLifecycle } from "../services/agent-lifecycle.js";
|
|
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, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
activityLog,
|
|
agentConfigRevisions,
|
|
agentInstructionRevisions,
|
|
agentInstructionHeads,
|
|
agents,
|
|
approvals,
|
|
budgetPolicies,
|
|
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;
|
|
let runnerSelectionSpy: { mockRestore(): void } | undefined;
|
|
const runnerTarget = { driver: "local", platform: "linux", architecture: "x64" };
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-agents-");
|
|
db = createDb(tempDb.connectionString);
|
|
}, 20_000);
|
|
|
|
beforeEach(async () => {
|
|
runnerTarget.platform = "linux";
|
|
runnerTarget.architecture = "x64";
|
|
const selection = await import("../services/agent-runner-selection.js");
|
|
runnerSelectionSpy = vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
|
|
selection.resolveNewAgentRunner({ ...input, target: runnerTarget }));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
runnerSelectionSpy?.mockRestore();
|
|
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(budgetPolicies);
|
|
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 createAgentLifecycle(db).resumeAgent(created.agentId!);
|
|
|
|
const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
|
|
|
expect(reconciled.agent).toMatchObject({ lifecycleState: "resuming", pauseReason: null });
|
|
const worker = configureAgentLifecycle(db, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost: async () => "complete" });
|
|
await worker.process(created.agentId!);
|
|
await worker.stop();
|
|
expect((await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" })).agent).toMatchObject({
|
|
status: "idle",
|
|
pauseReason: null,
|
|
pausedAt: null,
|
|
});
|
|
});
|
|
|
|
it.each([{ platform: "linux", architecture: "x64" }, { platform: "linux", architecture: "arm64" }, { platform: "darwin", architecture: "arm64" }, { platform: "darwin", architecture: "x64" }])("creates managed agents with the most-used compatible company adapter ($platform/$architecture)", async ({ platform, architecture }) => {
|
|
runnerTarget.platform = platform;
|
|
runnerTarget.architecture = architecture;
|
|
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: "paperclip_runner",
|
|
adapterConfig: { provider: "codex", model: "gpt-5.4" },
|
|
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).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } });
|
|
const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
|
expect(reconciled.agent?.adapterConfig).toEqual(created.agent?.adapterConfig);
|
|
runnerTarget.platform = "win32";
|
|
runnerTarget.architecture = "x64";
|
|
const reset = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
|
|
expect(reset.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } });
|
|
});
|
|
|
|
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);
|
|
expect(relinked.agent?.urlKey).toBe("renamed-wiki-agent");
|
|
|
|
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",
|
|
});
|
|
});
|
|
});
|